diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 25a0a06e4b..51b3ab07bc 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -16,6 +16,12 @@ components: - lukeina2z - yiyuan-he + instrumentation/opentelemetry-instrumentation-redis-valkey-base: + - sightseeker + + instrumentation/opentelemetry-instrumentation-valkey: + - sightseeker + instrumentation/opentelemetry-instrumentation-pymssql: - guillaumep diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index d9f90d57d8..79f9dcfefc 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -2697,6 +2697,29 @@ jobs: - name: Run tests run: tox -e py310-test-instrumentation-redis -- -ra + py310-test-instrumentation-valkey: + name: instrumentation-valkey + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-valkey -- -ra + py310-test-instrumentation-remoulade: name: instrumentation-remoulade runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ed8b69922c..32c4fc6945 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -954,6 +954,24 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-redis + lint-instrumentation-valkey: + name: instrumentation-valkey + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-instrumentation-valkey lint-instrumentation-remoulade: name: instrumentation-remoulade diff --git a/CHANGELOG.md b/CHANGELOG.md index 090e996805..5e8aabd1cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added + +- `opentelemetry-instrumentation-valkey` Instrumentation for Valkey + ([#3478](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3478)) +- `opentelemetry-instrumentation-redis-valkey-base` Shared base package for Redis and Valkey instrumentation, + extracting common tracing logic into a reusable module + ([#3478](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3478)) - Add `BaggageLogProcessor` to `opentelemetry-processor-baggage` ([#4371](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4371)) diff --git a/instrumentation/README.md b/instrumentation/README.md index 8d9a247945..ff5399ecb7 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -50,4 +50,5 @@ | [opentelemetry-instrumentation-tortoiseorm](./opentelemetry-instrumentation-tortoiseorm) | tortoise-orm >= 0.17.0 | No | development | [opentelemetry-instrumentation-urllib](./opentelemetry-instrumentation-urllib) | urllib | Yes | migration | [opentelemetry-instrumentation-urllib3](./opentelemetry-instrumentation-urllib3) | urllib3 >= 1.0.0, < 3.0.0 | Yes | migration +| [opentelemetry-instrumentation-valkey](./opentelemetry-instrumentation-valkey) | valkey >= 6.0.0 | No | development | [opentelemetry-instrumentation-wsgi](./opentelemetry-instrumentation-wsgi) | wsgi | Yes | migration \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/LICENSE b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/README.rst b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/README.rst new file mode 100644 index 0000000000..e2cf6e725b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/README.rst @@ -0,0 +1,25 @@ +OpenTelemetry Redis/Valkey Instrumentation Base +================================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-redis-valkey-base.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-redis-valkey-base/ + +This package provides shared instrumentation logic used by both +``opentelemetry-instrumentation-redis`` and ``opentelemetry-instrumentation-valkey``. + +It is not intended to be used directly. Install one of the backend-specific +packages instead. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-redis-valkey-base + +References +---------- + +* `OpenTelemetry Project `_ diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/pyproject.toml b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/pyproject.toml new file mode 100644 index 0000000000..6f69339aa7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-redis-valkey-base" +dynamic = ["version"] +description = "OpenTelemetry shared base for Redis and Valkey instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation == 0.63b0.dev", + "opentelemetry-semantic-conventions == 0.63b0.dev", + "wrapt >= 1.0.0, < 2.0.0", +] + +[project.optional-dependencies] +instruments = [] + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-redis-valkey-base" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/_redis_valkey/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/__init__.py b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/__init__.py new file mode 100644 index 0000000000..71b8341544 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/__init__.py @@ -0,0 +1,253 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Shared instrumentation base for Redis and Valkey. + +This module provides parameterized factory functions and utilities used by +both ``opentelemetry-instrumentation-redis`` and +``opentelemetry-instrumentation-valkey``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from opentelemetry import trace +from opentelemetry.instrumentation._redis_valkey.util import ( + _add_create_attributes, + _add_search_attributes, + _build_span_meta_data_for_pipeline, + _build_span_name, + _format_command_args, + _set_connection_attributes, +) +from opentelemetry.instrumentation.utils import is_instrumentation_enabled +from opentelemetry.semconv._incubating.attributes.db_attributes import ( + DB_STATEMENT, +) +from opentelemetry.trace import StatusCode, Tracer + + +@dataclass(frozen=True) +class KVStoreConfig: + """Configuration for a key-value store backend (Redis or Valkey). + + All backend-specific differences are captured here so that the shared + factory functions can be used by both Redis and Valkey instrumentations. + """ + + # Identity + backend_name: str # "redis" or "valkey" + db_system: str # "redis" or "valkey" + + # Span attribute keys + db_system_attr: str # DB_SYSTEM semconv key + db_index_attr: str # DB_REDIS_DATABASE_INDEX or "db.valkey.database_index" + args_length_attr: str # "db.redis.args_length" or "db.valkey.args_length" + pipeline_length_attr: ( + str # "db.redis.pipeline_length" or "db.valkey.pipeline_length" + ) + + # WatchError class from the backend library + watch_error_class: type + + +def _traced_execute_factory( + config: KVStoreConfig, + tracer: Tracer, + request_hook: Callable | None = None, + response_hook: Callable | None = None, +): + """Create a wrapper for execute_command that creates spans.""" + + def _traced_execute_command(func, instance, args, kwargs): + if not is_instrumentation_enabled(): + return func(*args, **kwargs) + + query = _format_command_args(args) + name = _build_span_name(instance, args, config.backend_name) + with tracer.start_as_current_span( + name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + span.set_attribute(DB_STATEMENT, query) + _set_connection_attributes( + span, + instance, + config.db_system, + config.db_system_attr, + config.db_index_attr, + ) + span.set_attribute(config.args_length_attr, len(args)) + if span.name == f"{config.backend_name}.create_index": + _add_create_attributes(span, args, config.backend_name) + if callable(request_hook): + request_hook(span, instance, args, kwargs) + response = func(*args, **kwargs) + if span.is_recording(): + if span.name == f"{config.backend_name}.search": + _add_search_attributes( + span, response, args, config.backend_name + ) + if callable(response_hook): + response_hook(span, instance, response) + return response + + return _traced_execute_command + + +def _traced_execute_pipeline_factory( + config: KVStoreConfig, + tracer: Tracer, + request_hook: Callable | None = None, + response_hook: Callable | None = None, +): + """Create a wrapper for pipeline execute that creates spans.""" + + def _traced_execute_pipeline(func, instance, args, kwargs): + if not is_instrumentation_enabled(): + return func(*args, **kwargs) + + ( + command_stack, + resource, + span_name, + ) = _build_span_meta_data_for_pipeline(instance, config.backend_name) + exception = None + with tracer.start_as_current_span( + span_name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + span.set_attribute(DB_STATEMENT, resource) + _set_connection_attributes( + span, + instance, + config.db_system, + config.db_system_attr, + config.db_index_attr, + ) + span.set_attribute( + config.pipeline_length_attr, len(command_stack) + ) + + response = None + try: + response = func(*args, **kwargs) + except config.watch_error_class as watch_exception: + span.set_status(StatusCode.UNSET) + exception = watch_exception + + if callable(response_hook): + response_hook(span, instance, response) + + if exception: + raise exception + + return response + + return _traced_execute_pipeline + + +def _async_traced_execute_factory( + config: KVStoreConfig, + tracer: Tracer, + request_hook: Callable | None = None, + response_hook: Callable | None = None, +): + """Create an async wrapper for execute_command that creates spans.""" + + async def _async_traced_execute_command(func, instance, args, kwargs): + if not is_instrumentation_enabled(): + return await func(*args, **kwargs) + + query = _format_command_args(args) + name = _build_span_name(instance, args, config.backend_name) + + with tracer.start_as_current_span( + name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + span.set_attribute(DB_STATEMENT, query) + _set_connection_attributes( + span, + instance, + config.db_system, + config.db_system_attr, + config.db_index_attr, + ) + span.set_attribute(config.args_length_attr, len(args)) + if callable(request_hook): + request_hook(span, instance, args, kwargs) + response = await func(*args, **kwargs) + if callable(response_hook): + response_hook(span, instance, response) + return response + + return _async_traced_execute_command + + +def _async_traced_execute_pipeline_factory( + config: KVStoreConfig, + tracer: Tracer, + request_hook: Callable | None = None, + response_hook: Callable | None = None, +): + """Create an async wrapper for pipeline execute that creates spans.""" + + async def _async_traced_execute_pipeline(func, instance, args, kwargs): + if not is_instrumentation_enabled(): + return await func(*args, **kwargs) + + ( + command_stack, + resource, + span_name, + ) = _build_span_meta_data_for_pipeline(instance, config.backend_name) + + exception = None + + with tracer.start_as_current_span( + span_name, kind=trace.SpanKind.CLIENT + ) as span: + if span.is_recording(): + span.set_attribute(DB_STATEMENT, resource) + _set_connection_attributes( + span, + instance, + config.db_system, + config.db_system_attr, + config.db_index_attr, + ) + span.set_attribute( + config.pipeline_length_attr, len(command_stack) + ) + + response = None + try: + response = await func(*args, **kwargs) + except config.watch_error_class as watch_exception: + span.set_status(StatusCode.UNSET) + exception = watch_exception + + if callable(response_hook): + response_hook(span, instance, response) + + if exception: + raise exception + + return response + + return _async_traced_execute_pipeline diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/package.py b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/package.py new file mode 100644 index 0000000000..292ae46b67 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/package.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +_instruments = () diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/py.typed b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/util.py b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/util.py new file mode 100644 index 0000000000..414d7d2687 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/util.py @@ -0,0 +1,227 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Shared utility functions for Redis/Valkey instrumentation. +""" + +from __future__ import annotations + +from typing import Any + +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.trace import Span + +_FIELD_TYPES = ["NUMERIC", "TEXT", "GEO", "TAG", "VECTOR"] + + +def _extract_conn_attributes( + conn_kwargs: dict[str, Any], + db_system: str, + db_system_attr: str, + db_index_attr: str, +) -> dict[str, Any]: + """Transform connection info into a dict of span attributes. + + Args: + conn_kwargs: Connection keyword arguments from the client's connection pool. + db_system: The database system name (e.g., "redis" or "valkey"). + db_system_attr: The attribute key for DB_SYSTEM. + db_index_attr: The attribute key for the database index. + """ + attributes: dict[str, Any] = { + db_system_attr: db_system, + } + db = conn_kwargs.get("db", 0) + attributes[db_index_attr] = db + if "path" in conn_kwargs: + attributes[SERVER_ADDRESS] = conn_kwargs.get("path", "") + else: + attributes[SERVER_ADDRESS] = conn_kwargs.get("host", "localhost") + attributes[SERVER_PORT] = conn_kwargs.get("port", 6379) + + return attributes + + +def _format_command_args(args: list[str]) -> str: + """Format and sanitize command arguments, and trim them as needed.""" + cmd_max_len = 1000 + value_too_long_mark = "..." + + # Sanitized query format: "COMMAND ? ?" + args_length = len(args) + if args_length > 0: + out = [str(args[0])] + ["?"] * (args_length - 1) + out_str = " ".join(out) + + if len(out_str) > cmd_max_len: + out_str = ( + out_str[: cmd_max_len - len(value_too_long_mark)] + + value_too_long_mark + ) + else: + out_str = "" + + return out_str + + +def _set_span_attribute_if_value(span: Span, name: str, value: Any) -> None: + if value is not None and value != "": + span.set_attribute(name, value) + + +def _value_or_none(values: Any, n: int) -> Any: + try: + return values[n] + except IndexError: + return None + + +def _set_connection_attributes( + span: Span, + conn: Any, + db_system: str, + db_system_attr: str, + db_index_attr: str, +) -> None: + """Set connection attributes on a span from a client instance.""" + if not span.is_recording() or not hasattr(conn, "connection_pool"): + return + for key, value in _extract_conn_attributes( + conn.connection_pool.connection_kwargs, + db_system, + db_system_attr, + db_index_attr, + ).items(): + span.set_attribute(key, value) + + +def _build_span_name( + instance: Any, + cmd_args: tuple[Any, ...], + backend_name: str, +) -> str: + """Build a span name from the command arguments. + + Args: + instance: The client instance. + cmd_args: The command arguments. + backend_name: The backend name (e.g., "redis" or "valkey"). + """ + if len(cmd_args) > 0 and cmd_args[0]: + if cmd_args[0] == "FT.SEARCH": + name = f"{backend_name}.search" + elif cmd_args[0] == "FT.CREATE": + name = f"{backend_name}.create_index" + else: + name = cmd_args[0] + else: + name = instance.connection_pool.connection_kwargs.get("db", 0) + return name + + +def _add_create_attributes( + span: Span, + args: tuple[Any, ...], + backend_name: str, +) -> None: + """Add FT.CREATE index attributes to a span.""" + _set_span_attribute_if_value( + span, f"{backend_name}.create_index.index", _value_or_none(args, 1) + ) + try: + schema_index = args.index("SCHEMA") + except ValueError: + return + schema = args[schema_index:] + field_attribute = "".join( + f"Field(name: {schema[index - 1]}, type: {schema[index]});" + for index in range(1, len(schema)) + if schema[index] in _FIELD_TYPES + ) + _set_span_attribute_if_value( + span, + f"{backend_name}.create_index.fields", + field_attribute, + ) + + +def _add_search_attributes( + span: Span, + response: Any, + args: tuple[Any, ...], + backend_name: str, +) -> None: + """Add FT.SEARCH response attributes to a span.""" + _set_span_attribute_if_value( + span, f"{backend_name}.search.index", _value_or_none(args, 1) + ) + _set_span_attribute_if_value( + span, f"{backend_name}.search.query", _value_or_none(args, 2) + ) + number_of_returned_documents = _value_or_none(response, 0) + _set_span_attribute_if_value( + span, f"{backend_name}.search.total", number_of_returned_documents + ) + if "NOCONTENT" in args or not number_of_returned_documents: + return + for document_number in range(number_of_returned_documents): + document_index = _value_or_none(response, 1 + 2 * document_number) + if document_index: + document = response[2 + 2 * document_number] + for attribute_name_index in range(0, len(document), 2): + _set_span_attribute_if_value( + span, + f"{backend_name}.search.xdoc_{document_index}.{document[attribute_name_index]}", + document[attribute_name_index + 1], + ) + + +def _build_span_meta_data_for_pipeline( + instance: Any, + backend_name: str, +) -> tuple[list[Any], str, str]: + """Build span metadata for a pipeline execution. + + Returns: + A tuple of (command_stack, resource, span_name). + """ + try: + command_stack = ( + instance.command_stack + if hasattr(instance, "command_stack") + else instance._command_stack + ) + + cmds = [ + _format_command_args(c.args if hasattr(c, "args") else c[0]) + for c in command_stack + ] + resource = "\n".join(cmds) + + span_name = " ".join( + [ + (c.args[0] if hasattr(c, "args") else c[0][0]) + for c in command_stack + ] + ) + except (AttributeError, IndexError): + command_stack = [] + resource = "" + span_name = "" + + return command_stack, resource, span_name or backend_name diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/version.py b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/version.py new file mode 100644 index 0000000000..a07bc2663e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/src/opentelemetry/instrumentation/_redis_valkey/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.63b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/test-requirements.txt new file mode 100644 index 0000000000..39ba46f0d3 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/test-requirements.txt @@ -0,0 +1,9 @@ +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.5.0 +pytest==7.4.4 +tomli==2.0.1 +typing_extensions==4.12.2 +wrapt==1.16.0 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-redis-valkey-base diff --git a/instrumentation/opentelemetry-instrumentation-redis-valkey-base/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-redis-valkey-base/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml b/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml index 19c5e194f9..34e1bf3fc0 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-redis/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ dependencies = [ "opentelemetry-api ~= 1.12", "opentelemetry-instrumentation == 0.63b0.dev", + "opentelemetry-instrumentation-redis-valkey-base == 0.63b0.dev", "opentelemetry-semantic-conventions == 0.63b0.dev", "wrapt >= 1.12.1", ] diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py index 194351c7da..e181fe1d7b 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py @@ -135,52 +135,31 @@ def response_hook(span, instance, response): from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Callable, Collection +from typing import TYPE_CHECKING, Any, Collection import redis from wrapt import wrap_function_wrapper -from opentelemetry import trace +from opentelemetry.instrumentation._redis_valkey import ( + KVStoreConfig, + _async_traced_execute_factory, + _async_traced_execute_pipeline_factory, + _traced_execute_factory, + _traced_execute_pipeline_factory, +) from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.redis.package import _instruments -from opentelemetry.instrumentation.redis.util import ( - _add_create_attributes, - _add_search_attributes, - _build_span_meta_data_for_pipeline, - _build_span_name, - _format_command_args, - _set_connection_attributes, -) from opentelemetry.instrumentation.redis.version import __version__ -from opentelemetry.instrumentation.utils import ( - is_instrumentation_enabled, - unwrap, -) +from opentelemetry.instrumentation.utils import unwrap from opentelemetry.semconv._incubating.attributes.db_attributes import ( - DB_STATEMENT, -) -from opentelemetry.trace import ( - StatusCode, - Tracer, - TracerProvider, - get_tracer, + DB_REDIS_DATABASE_INDEX, + DB_SYSTEM, ) +from opentelemetry.semconv.trace import DbSystemValues +from opentelemetry.trace import TracerProvider, get_tracer if TYPE_CHECKING: - from typing import Awaitable - - import redis.asyncio.client - import redis.asyncio.cluster - import redis.client - import redis.cluster - import redis.connection - from opentelemetry.instrumentation.redis.custom_types import ( - AsyncPipelineInstance, - AsyncRedisInstance, - PipelineInstance, - R, - RedisInstance, RequestHook, ResponseHook, ) @@ -192,7 +171,6 @@ def response_hook(span, instance, response): _REDIS_CLUSTER_VERSION = (4, 1, 0) _REDIS_ASYNCIO_CLUSTER_VERSION = (4, 3, 2) - _CLIENT_ASYNCIO_SUPPORT = redis.VERSION >= _REDIS_ASYNCIO_VERSION _CLIENT_ASYNCIO_CLUSTER_SUPPORT = ( redis.VERSION >= _REDIS_ASYNCIO_CLUSTER_VERSION @@ -205,188 +183,28 @@ def response_hook(span, instance, response): _INSTRUMENTATION_ATTR = "_is_instrumented_by_opentelemetry" - -def _traced_execute_factory( - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, -): - def _traced_execute_command( - func: Callable[..., R], - instance: RedisInstance, - args: tuple[Any, ...], - kwargs: dict[str, Any], - ) -> R: - if not is_instrumentation_enabled(): - return func(*args, **kwargs) - - query = _format_command_args(args) - name = _build_span_name(instance, args) - with tracer.start_as_current_span( - name, kind=trace.SpanKind.CLIENT - ) as span: - if span.is_recording(): - span.set_attribute(DB_STATEMENT, query) - _set_connection_attributes(span, instance) - span.set_attribute("db.redis.args_length", len(args)) - if span.name == "redis.create_index": - _add_create_attributes(span, args) - if callable(request_hook): - request_hook(span, instance, args, kwargs) - response = func(*args, **kwargs) - if span.is_recording(): - if span.name == "redis.search": - _add_search_attributes(span, response, args) - if callable(response_hook): - response_hook(span, instance, response) - return response - - return _traced_execute_command - - -def _traced_execute_pipeline_factory( - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, -): - def _traced_execute_pipeline( - func: Callable[..., R], - instance: PipelineInstance, - args: tuple[Any, ...], - kwargs: dict[str, Any], - ) -> R: - if not is_instrumentation_enabled(): - return func(*args, **kwargs) - - ( - command_stack, - resource, - span_name, - ) = _build_span_meta_data_for_pipeline(instance) - exception = None - with tracer.start_as_current_span( - span_name, kind=trace.SpanKind.CLIENT - ) as span: - if span.is_recording(): - span.set_attribute(DB_STATEMENT, resource) - _set_connection_attributes(span, instance) - span.set_attribute( - "db.redis.pipeline_length", len(command_stack) - ) - - response = None - try: - response = func(*args, **kwargs) - except redis.WatchError as watch_exception: - span.set_status(StatusCode.UNSET) - exception = watch_exception - - if callable(response_hook): - response_hook(span, instance, response) - - if exception: - raise exception - - return response - - return _traced_execute_pipeline - - -def _async_traced_execute_factory( - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, -): - async def _async_traced_execute_command( - func: Callable[..., Awaitable[R]], - instance: AsyncRedisInstance, - args: tuple[Any, ...], - kwargs: dict[str, Any], - ) -> Awaitable[R]: - if not is_instrumentation_enabled(): - return await func(*args, **kwargs) - - query = _format_command_args(args) - name = _build_span_name(instance, args) - - with tracer.start_as_current_span( - name, kind=trace.SpanKind.CLIENT - ) as span: - if span.is_recording(): - span.set_attribute(DB_STATEMENT, query) - _set_connection_attributes(span, instance) - span.set_attribute("db.redis.args_length", len(args)) - if callable(request_hook): - request_hook(span, instance, args, kwargs) - response = await func(*args, **kwargs) - if callable(response_hook): - response_hook(span, instance, response) - return response - - return _async_traced_execute_command - - -def _async_traced_execute_pipeline_factory( - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, -): - async def _async_traced_execute_pipeline( - func: Callable[..., Awaitable[R]], - instance: AsyncPipelineInstance, - args: tuple[Any, ...], - kwargs: dict[str, Any], - ) -> Awaitable[R]: - if not is_instrumentation_enabled(): - return await func(*args, **kwargs) - - ( - command_stack, - resource, - span_name, - ) = _build_span_meta_data_for_pipeline(instance) - - exception = None - - with tracer.start_as_current_span( - span_name, kind=trace.SpanKind.CLIENT - ) as span: - if span.is_recording(): - span.set_attribute(DB_STATEMENT, resource) - _set_connection_attributes(span, instance) - span.set_attribute( - "db.redis.pipeline_length", len(command_stack) - ) - - response = None - try: - response = await func(*args, **kwargs) - except redis.WatchError as watch_exception: - span.set_status(StatusCode.UNSET) - exception = watch_exception - - if callable(response_hook): - response_hook(span, instance, response) - - if exception: - raise exception - - return response - - return _async_traced_execute_pipeline +_REDIS_CONFIG = KVStoreConfig( + backend_name="redis", + db_system=DbSystemValues.REDIS.value, + db_system_attr=DB_SYSTEM, + db_index_attr=DB_REDIS_DATABASE_INDEX, + args_length_attr="db.redis.args_length", + pipeline_length_attr="db.redis.pipeline_length", + watch_error_class=redis.WatchError, +) # pylint: disable=R0915 def _instrument( - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, + tracer, + request_hook=None, + response_hook=None, ): _traced_execute_command = _traced_execute_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) _traced_execute_pipeline = _traced_execute_pipeline_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) pipeline_class = "BasePipeline" if _CLIENT_BEFORE_V3 else "Pipeline" redis_class = "StrictRedis" if _CLIENT_BEFORE_V3 else "Redis" @@ -417,10 +235,10 @@ def _instrument( ) _async_traced_execute_command = _async_traced_execute_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) _async_traced_execute_pipeline = _async_traced_execute_pipeline_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) if _CLIENT_ASYNCIO_SUPPORT: wrap_function_wrapper( @@ -453,16 +271,16 @@ def _instrument( def _instrument_client( client, - tracer: Tracer, - request_hook: RequestHook | None = None, - response_hook: ResponseHook | None = None, + tracer, + request_hook=None, + response_hook=None, ): # first, handle async clients and cluster clients _async_traced_execute = _async_traced_execute_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) _async_traced_execute_pipeline = _async_traced_execute_pipeline_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) if _CLIENT_ASYNCIO_SUPPORT and isinstance(client, redis.asyncio.Redis): @@ -500,10 +318,10 @@ def _async_cluster_pipeline_wrapper(func, instance, args, kwargs): # for redis.client.Redis, redis.Cluster and v3.0.0 redis.client.StrictRedis # the wrappers are the same _traced_execute = _traced_execute_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) _traced_execute_pipeline = _traced_execute_pipeline_factory( - tracer, request_hook, response_hook + _REDIS_CONFIG, tracer, request_hook, response_hook ) def _pipeline_wrapper(func, instance, args, kwargs): diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py index b7d9113334..5ee3e4e40a 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py @@ -2,198 +2,94 @@ # SPDX-License-Identifier: Apache-2.0 # """ -Some utils used by the redis integration +Utility functions for Redis instrumentation. + +These functions delegate to the shared base in +``opentelemetry.instrumentation._redis_valkey.util``, using Redis-specific +attribute names and conventions. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any +from opentelemetry.instrumentation._redis_valkey.util import ( + _add_create_attributes as _base_add_create_attributes, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _add_search_attributes as _base_add_search_attributes, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _build_span_meta_data_for_pipeline as _base_build_span_meta_data_for_pipeline, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _build_span_name as _base_build_span_name, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _extract_conn_attributes as _base_extract_conn_attributes, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _format_command_args, + _set_span_attribute_if_value, + _value_or_none, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _set_connection_attributes as _base_set_connection_attributes, +) from opentelemetry.semconv._incubating.attributes.db_attributes import ( DB_REDIS_DATABASE_INDEX, DB_SYSTEM, ) -from opentelemetry.semconv._incubating.attributes.net_attributes import ( - NET_PEER_NAME, - NET_PEER_PORT, - NET_TRANSPORT, -) -from opentelemetry.semconv.trace import ( - DbSystemValues, - NetTransportValues, -) +from opentelemetry.semconv.trace import DbSystemValues from opentelemetry.trace import Span -if TYPE_CHECKING: - from opentelemetry.instrumentation.redis.custom_types import ( - AsyncPipelineInstance, - AsyncRedisInstance, - PipelineInstance, - RedisInstance, +_BACKEND_NAME = "redis" +_DB_SYSTEM = DbSystemValues.REDIS.value +_DB_SYSTEM_ATTR = DB_SYSTEM +_DB_INDEX_ATTR = DB_REDIS_DATABASE_INDEX + +# Re-export unchanged functions +__all__ = [ + "_extract_conn_attributes", + "_format_command_args", + "_set_span_attribute_if_value", + "_value_or_none", + "_set_connection_attributes", + "_build_span_name", + "_add_create_attributes", + "_add_search_attributes", + "_build_span_meta_data_for_pipeline", +] + + +def _extract_conn_attributes(conn_kwargs: dict[str, Any]) -> dict[str, Any]: + """Transform redis conn info into dict.""" + return _base_extract_conn_attributes( + conn_kwargs, _DB_SYSTEM, _DB_SYSTEM_ATTR, _DB_INDEX_ATTR ) -_FIELD_TYPES = ["NUMERIC", "TEXT", "GEO", "TAG", "VECTOR"] - - -def _extract_conn_attributes(conn_kwargs): - """Transform redis conn info into dict""" - attributes = { - DB_SYSTEM: DbSystemValues.REDIS.value, - } - db = conn_kwargs.get("db", 0) - attributes[DB_REDIS_DATABASE_INDEX] = db - if "path" in conn_kwargs: - attributes[NET_PEER_NAME] = conn_kwargs.get("path", "") - attributes[NET_TRANSPORT] = NetTransportValues.OTHER.value - else: - attributes[NET_PEER_NAME] = conn_kwargs.get("host", "localhost") - attributes[NET_PEER_PORT] = conn_kwargs.get("port", 6379) - attributes[NET_TRANSPORT] = NetTransportValues.IP_TCP.value - - return attributes - -def _format_command_args(args: list[str]): - """Format and sanitize command arguments, and trim them as needed""" - cmd_max_len = 1000 - value_too_long_mark = "..." - - # Sanitized query format: "COMMAND ? ?" - args_length = len(args) - if args_length > 0: - out = [str(args[0])] + ["?"] * (args_length - 1) - out_str = " ".join(out) - - if len(out_str) > cmd_max_len: - out_str = ( - out_str[: cmd_max_len - len(value_too_long_mark)] - + value_too_long_mark - ) - else: - out_str = "" - - return out_str +def _set_connection_attributes(span: Span, conn: Any) -> None: + _base_set_connection_attributes( + span, conn, _DB_SYSTEM, _DB_SYSTEM_ATTR, _DB_INDEX_ATTR + ) -def _set_span_attribute_if_value(span, name, value): - if value is not None and value != "": - span.set_attribute(name, value) +def _build_span_name(instance: Any, cmd_args: tuple[Any, ...]) -> str: + return _base_build_span_name(instance, cmd_args, _BACKEND_NAME) -def _value_or_none(values, n): - try: - return values[n] - except IndexError: - return None +def _add_create_attributes(span: Span, args: tuple[Any, ...]) -> None: + _base_add_create_attributes(span, args, _BACKEND_NAME) -def _set_connection_attributes( - span: Span, conn: RedisInstance | AsyncRedisInstance +def _add_search_attributes( + span: Span, response: Any, args: tuple[Any, ...] ) -> None: - if not span.is_recording() or not hasattr(conn, "connection_pool"): - return - for key, value in _extract_conn_attributes( - conn.connection_pool.connection_kwargs - ).items(): - span.set_attribute(key, value) - - -def _build_span_name( - instance: RedisInstance | AsyncRedisInstance, cmd_args: tuple[Any, ...] -) -> str: - if len(cmd_args) > 0 and cmd_args[0]: - if cmd_args[0] == "FT.SEARCH": - name = "redis.search" - elif cmd_args[0] == "FT.CREATE": - name = "redis.create_index" - else: - name = cmd_args[0] - else: - name = instance.connection_pool.connection_kwargs.get("db", 0) - return name - - -def _add_create_attributes(span: Span, args: tuple[Any, ...]): - _set_span_attribute_if_value( - span, "redis.create_index.index", _value_or_none(args, 1) - ) - # According to: https://github.com/redis/redis-py/blob/master/redis/commands/search/commands.py#L155 schema is last argument for execute command - try: - schema_index = args.index("SCHEMA") - except ValueError: - return - schema = args[schema_index:] - field_attribute = "" - # Schema in format: - # [first_field_name, first_field_type, first_field_some_attribute1, first_field_some_attribute2, second_field_name, ...] - field_attribute = "".join( - f"Field(name: {schema[index - 1]}, type: {schema[index]});" - for index in range(1, len(schema)) - if schema[index] in _FIELD_TYPES - ) - _set_span_attribute_if_value( - span, - "redis.create_index.fields", - field_attribute, - ) - - -def _add_search_attributes(span: Span, response, args): - _set_span_attribute_if_value( - span, "redis.search.index", _value_or_none(args, 1) - ) - _set_span_attribute_if_value( - span, "redis.search.query", _value_or_none(args, 2) - ) - # Parse response from search - # https://redis.io/docs/latest/commands/ft.search/ - # Response in format: - # [number_of_returned_documents, index_of_first_returned_doc, first_doc(as a list), index_of_second_returned_doc, second_doc(as a list) ...] - # Returned documents in array format: - # [first_field_name, first_field_value, second_field_name, second_field_value ...] - number_of_returned_documents = _value_or_none(response, 0) - _set_span_attribute_if_value( - span, "redis.search.total", number_of_returned_documents - ) - if "NOCONTENT" in args or not number_of_returned_documents: - return - for document_number in range(number_of_returned_documents): - document_index = _value_or_none(response, 1 + 2 * document_number) - if document_index: - document = response[2 + 2 * document_number] - for attribute_name_index in range(0, len(document), 2): - _set_span_attribute_if_value( - span, - f"redis.search.xdoc_{document_index}.{document[attribute_name_index]}", - document[attribute_name_index + 1], - ) + _base_add_search_attributes(span, response, args, _BACKEND_NAME) def _build_span_meta_data_for_pipeline( - instance: PipelineInstance | AsyncPipelineInstance, + instance: Any, ) -> tuple[list[Any], str, str]: - try: - command_stack = ( - instance.command_stack - if hasattr(instance, "command_stack") - else instance._command_stack - ) - - cmds = [ - _format_command_args(c.args if hasattr(c, "args") else c[0]) - for c in command_stack - ] - resource = "\n".join(cmds) - - span_name = " ".join( - [ - (c.args[0] if hasattr(c, "args") else c[0][0]) - for c in command_stack - ] - ) - except (AttributeError, IndexError): - command_stack = [] - resource = "" - span_name = "" - - return command_stack, resource, span_name or "redis" + return _base_build_span_meta_data_for_pipeline(instance, _BACKEND_NAME) diff --git a/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt index 627b13573c..502cec12ac 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt @@ -13,4 +13,5 @@ typing_extensions==4.12.2 wrapt==1.16.0 zipp==3.19.2 -e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-redis-valkey-base -e instrumentation/opentelemetry-instrumentation-redis diff --git a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py index a09e2de117..0c7ffb3285 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py +++ b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py @@ -20,11 +20,9 @@ DB_SYSTEM, DbSystemValues, ) -from opentelemetry.semconv._incubating.attributes.net_attributes import ( - NET_PEER_NAME, - NET_PEER_PORT, - NET_TRANSPORT, - NetTransportValues, +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, ) from opentelemetry.test.test_base import TestBase from opentelemetry.trace import SpanKind @@ -258,12 +256,8 @@ def test_attributes_default(self): DbSystemValues.REDIS.value, ) self.assertEqual(span.attributes[DB_REDIS_DATABASE_INDEX], 0) - self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") - self.assertEqual(span.attributes[NET_PEER_PORT], 6379) - self.assertEqual( - span.attributes[NET_TRANSPORT], - NetTransportValues.IP_TCP.value, - ) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 6379) def test_attributes_tcp(self): redis_client = redis.Redis.from_url("redis://foo:bar@1.1.1.1:6380/1") @@ -280,12 +274,8 @@ def test_attributes_tcp(self): DbSystemValues.REDIS.value, ) self.assertEqual(span.attributes[DB_REDIS_DATABASE_INDEX], 1) - self.assertEqual(span.attributes[NET_PEER_NAME], "1.1.1.1") - self.assertEqual(span.attributes[NET_PEER_PORT], 6380) - self.assertEqual( - span.attributes[NET_TRANSPORT], - NetTransportValues.IP_TCP.value, - ) + self.assertEqual(span.attributes[SERVER_ADDRESS], "1.1.1.1") + self.assertEqual(span.attributes[SERVER_PORT], 6380) def test_attributes_unix_socket(self): redis_client = redis.Redis.from_url( @@ -305,13 +295,9 @@ def test_attributes_unix_socket(self): ) self.assertEqual(span.attributes[DB_REDIS_DATABASE_INDEX], 3) self.assertEqual( - span.attributes[NET_PEER_NAME], + span.attributes[SERVER_ADDRESS], "/path/to/socket.sock", ) - self.assertEqual( - span.attributes[NET_TRANSPORT], - NetTransportValues.OTHER.value, - ) def test_connection_error(self): server = fakeredis.FakeServer() diff --git a/instrumentation/opentelemetry-instrumentation-valkey/LICENSE b/instrumentation/opentelemetry-instrumentation-valkey/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-valkey/README.rst b/instrumentation/opentelemetry-instrumentation-valkey/README.rst new file mode 100644 index 0000000000..20a02babc2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/README.rst @@ -0,0 +1,23 @@ +OpenTelemetry Valkey Instrumentation +==================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-valkey.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-valkey/ + +This library allows tracing requests made by the Valkey library. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-valkey + +References +---------- + +* `OpenTelemetry Valkey Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation/opentelemetry-instrumentation-valkey/pyproject.toml b/instrumentation/opentelemetry-instrumentation-valkey/pyproject.toml new file mode 100644 index 0000000000..b08c8d507c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-valkey" +dynamic = ["version"] +description = "OpenTelemetry Valkey instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.12", + "opentelemetry-instrumentation == 0.63b0.dev", + "opentelemetry-instrumentation-redis-valkey-base == 0.63b0.dev", + "opentelemetry-semantic-conventions == 0.63b0.dev", + "wrapt >= 1.12.1", +] + +[project.optional-dependencies] +instruments = [ + "valkey[libvalkey] >= 6.1.0", +] + +[project.entry-points.opentelemetry_instrumentor] +valkey = "opentelemetry.instrumentation.valkey:ValkeyInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-valkey" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/valkey/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/__init__.py b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/__init__.py new file mode 100644 index 0000000000..70fb85e4a8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/__init__.py @@ -0,0 +1,474 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Instrument `valkey`_ to report Valkey queries. + +.. _valkey: https://pypi.org/project/valkey/ + + +Instrument All Clients +---------------------- + +The easiest way to instrument all valkey client instances is by +``ValkeyInstrumentor().instrument()``: + +.. code:: python + + from opentelemetry.instrumentation.valkey import ValkeyInstrumentor + import valkey + + + # Instrument valkey + ValkeyInstrumentor().instrument() + + # This will report a span with the default settings + client = valkey.StrictValkey(host="localhost", port=6379) + client.get("my-key") + +Async Valkey clients (i.e. ``valkey.asyncio.Valkey``) are also instrumented in the same way: + +.. code:: python + + from opentelemetry.instrumentation.valkey import ValkeyInstrumentor + import valkey.asyncio + + + # Instrument valkey + ValkeyInstrumentor().instrument() + + # This will report a span with the default settings + async def valkey_get(): + client = valkey.asyncio.Valkey(host="localhost", port=6379) + await client.get("my-key") + +.. note:: + Calling the ``instrument`` method will instrument the client classes, so any client + created after the ``instrument`` call will be instrumented. To instrument only a + single client, use :func:`ValkeyInstrumentor.instrument_client` method. + +Instrument Single Client +------------------------ + +The :func:`ValkeyInstrumentor.instrument_client` can instrument a connection instance. This is useful when there are multiple clients with a different valkey database index. +Or, you might have a different connection pool used for an application function you +don't want instrumented. + +.. code:: python + + from opentelemetry.instrumentation.valkey import ValkeyInstrumentor + import valkey + + instrumented_client = valkey.Valkey() + not_instrumented_client = valkey.Valkey() + + # Instrument valkey + ValkeyInstrumentor.instrument_client(client=instrumented_client) + + # This will report a span with the default settings + instrumented_client.get("my-key") + + # This will not have a span + not_instrumented_client.get("my-key") + +.. warning:: + All client instances created after calling ``ValkeyInstrumentor().instrument`` will + be instrumented. To avoid instrumenting all clients, use + :func:`ValkeyInstrumentor.instrument_client` . + +Request/Response Hooks +---------------------- + +.. code:: python + + from opentelemetry.instrumentation.valkey import ValkeyInstrumentor + import valkey + + def request_hook(span, instance, args, kwargs): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_request_hook", "some-value") + + def response_hook(span, instance, response): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_response_hook", "some-value") + + # Instrument valkey with hooks + ValkeyInstrumentor().instrument(request_hook=request_hook, response_hook=response_hook) + + # This will report a span with the default settings and the custom attributes added from the hooks + client = valkey.StrictValkey(host="localhost", port=6379) + client.get("my-key") + +Suppress Instrumentation +------------------------ + +You can use the ``suppress_instrumentation`` context manager to prevent instrumentation +from being applied to specific Valkey operations. This is useful when you want to avoid +creating spans for internal operations, health checks, or during specific code paths. + +.. code:: python + + from opentelemetry.instrumentation.valkey import ValkeyInstrumentor + from opentelemetry.instrumentation.utils import suppress_instrumentation + import valkey + + # Instrument valkey + ValkeyInstrumentor().instrument() + + client = valkey.StrictValkey(host="localhost", port=6379) + + # This will report a span + client.get("my-key") + + # This will NOT report a span + with suppress_instrumentation(): + client.get("internal-key") + client.set("cache-key", "value") + + # This will report a span again + client.get("another-key") + +API +--- +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Callable, Collection + +import valkey +import valkey.asyncio +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation._redis_valkey import ( + KVStoreConfig, + _async_traced_execute_factory, + _async_traced_execute_pipeline_factory, + _traced_execute_factory, + _traced_execute_pipeline_factory, +) +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.instrumentation.valkey.package import _instruments +from opentelemetry.instrumentation.valkey.version import __version__ +from opentelemetry.semconv._incubating.attributes.db_attributes import ( + DB_REDIS_DATABASE_INDEX, + DB_SYSTEM, +) +from opentelemetry.trace import TracerProvider, get_tracer + +if TYPE_CHECKING: + import valkey.asyncio.client + import valkey.asyncio.cluster + import valkey.client + import valkey.cluster + import valkey.connection + + +_logger = logging.getLogger(__name__) + +_INSTRUMENTATION_ATTR = "_is_instrumented_by_opentelemetry" + +_VALKEY_CONFIG = KVStoreConfig( + backend_name="valkey", + db_system="valkey", + db_system_attr=DB_SYSTEM, + db_index_attr=DB_REDIS_DATABASE_INDEX, + args_length_attr="db.redis.args_length", + pipeline_length_attr="db.redis.pipeline_length", + watch_error_class=valkey.WatchError, +) + + +# pylint: disable=R0915 +def _instrument( + tracer, + request_hook=None, + response_hook=None, +): + _traced_execute_command = _traced_execute_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + _traced_execute_pipeline = _traced_execute_pipeline_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + + wrap_function_wrapper( + "valkey", "Valkey.execute_command", _traced_execute_command + ) + wrap_function_wrapper( + "valkey.client", + "Pipeline.execute", + _traced_execute_pipeline, + ) + wrap_function_wrapper( + "valkey.client", + "Pipeline.immediate_execute_command", + _traced_execute_command, + ) + wrap_function_wrapper( + "valkey.cluster", + "ValkeyCluster.execute_command", + _traced_execute_command, + ) + wrap_function_wrapper( + "valkey.cluster", + "ClusterPipeline.execute", + _traced_execute_pipeline, + ) + + _async_traced_execute_command = _async_traced_execute_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + _async_traced_execute_pipeline = _async_traced_execute_pipeline_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + + wrap_function_wrapper( + "valkey.asyncio", + "Valkey.execute_command", + _async_traced_execute_command, + ) + wrap_function_wrapper( + "valkey.asyncio.client", + "Pipeline.execute", + _async_traced_execute_pipeline, + ) + wrap_function_wrapper( + "valkey.asyncio.client", + "Pipeline.immediate_execute_command", + _async_traced_execute_command, + ) + wrap_function_wrapper( + "valkey.asyncio.cluster", + "ValkeyCluster.execute_command", + _async_traced_execute_command, + ) + wrap_function_wrapper( + "valkey.asyncio.cluster", + "ClusterPipeline.execute", + _async_traced_execute_pipeline, + ) + + +def _instrument_client( + client, + tracer, + request_hook=None, + response_hook=None, +): + _async_traced_execute = _async_traced_execute_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + _async_traced_execute_pipeline = _async_traced_execute_pipeline_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + + if isinstance(client, valkey.asyncio.Valkey): + + def _async_pipeline_wrapper(func, instance, args, kwargs): + result = func(*args, **kwargs) + wrap_function_wrapper( + result, "execute", _async_traced_execute_pipeline + ) + wrap_function_wrapper( + result, "immediate_execute_command", _async_traced_execute + ) + return result + + wrap_function_wrapper(client, "execute_command", _async_traced_execute) + wrap_function_wrapper(client, "pipeline", _async_pipeline_wrapper) + return + + if isinstance(client, valkey.asyncio.ValkeyCluster): + + def _async_cluster_pipeline_wrapper(func, instance, args, kwargs): + result = func(*args, **kwargs) + wrap_function_wrapper( + result, "execute", _async_traced_execute_pipeline + ) + return result + + wrap_function_wrapper(client, "execute_command", _async_traced_execute) + wrap_function_wrapper( + client, "pipeline", _async_cluster_pipeline_wrapper + ) + return + + _traced_execute = _traced_execute_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + _traced_execute_pipeline = _traced_execute_pipeline_factory( + _VALKEY_CONFIG, tracer, request_hook, response_hook + ) + + def _pipeline_wrapper(func, instance, args, kwargs): + result = func(*args, **kwargs) + wrap_function_wrapper(result, "execute", _traced_execute_pipeline) + wrap_function_wrapper( + result, "immediate_execute_command", _traced_execute + ) + return result + + wrap_function_wrapper( + client, + "execute_command", + _traced_execute, + ) + wrap_function_wrapper( + client, + "pipeline", + _pipeline_wrapper, + ) + + +class ValkeyInstrumentor(BaseInstrumentor): + """An instrumentor for Valkey. + + See `BaseInstrumentor` + """ + + @staticmethod + def _get_tracer(**kwargs): + tracer_provider = kwargs.get("tracer_provider") + return get_tracer( + __name__, + __version__, + tracer_provider=tracer_provider, + schema_url="https://opentelemetry.io/schemas/1.11.0", + ) + + def instrument( + self, + tracer_provider: TracerProvider | None = None, + request_hook: Callable | None = None, + response_hook: Callable | None = None, + **kwargs, + ): + """Instruments all Valkey/StrictValkey/ValkeyCluster and async client instances. + + Args: + tracer_provider: A TracerProvider, defaults to global. + request_hook: + a function with extra user-defined logic to run before performing the request. + + The ``args`` is a tuple, where items are + command arguments. For example ``client.set("mykey", "value", ex=5)`` would + have ``args`` as ``('SET', 'mykey', 'value', 'EX', 5)``. + + The ``kwargs`` represents occasional ``options`` passed by valkey. For example, + if you use ``client.set("mykey", "value", get=True)``, the ``kwargs`` would be + ``{'get': True}``. + response_hook: + a function with extra user-defined logic to run after the request is complete. + + The ``args`` represents the response. + """ + super().instrument( + tracer_provider=tracer_provider, + request_hook=request_hook, + response_hook=response_hook, + **kwargs, + ) + + def _instrument(self, **kwargs: Any): + """Instruments the valkey module + + Args: + **kwargs: Optional arguments + ``tracer_provider``: a TracerProvider, defaults to global. + ``request_hook``: An optional callback that is invoked right after a span is created. + ``response_hook``: An optional callback which is invoked right before the span is finished processing a response. + """ + _instrument( + self._get_tracer(**kwargs), + request_hook=kwargs.get("request_hook"), + response_hook=kwargs.get("response_hook"), + ) + + def _uninstrument(self, **kwargs: Any): + unwrap(valkey.Valkey, "execute_command") + unwrap(valkey.Valkey, "pipeline") + unwrap(valkey.client.Pipeline, "execute") + unwrap(valkey.client.Pipeline, "immediate_execute_command") + unwrap(valkey.cluster.ValkeyCluster, "execute_command") + unwrap(valkey.cluster.ClusterPipeline, "execute") + unwrap(valkey.asyncio.Valkey, "execute_command") + unwrap(valkey.asyncio.Valkey, "pipeline") + unwrap(valkey.asyncio.client.Pipeline, "execute") + unwrap(valkey.asyncio.client.Pipeline, "immediate_execute_command") + unwrap(valkey.asyncio.cluster.ValkeyCluster, "execute_command") + unwrap(valkey.asyncio.cluster.ClusterPipeline, "execute") + + @staticmethod + def instrument_client( + client: valkey.Valkey + | valkey.asyncio.Valkey + | valkey.cluster.ValkeyCluster + | valkey.asyncio.ValkeyCluster, + tracer_provider: TracerProvider | None = None, + request_hook: Callable | None = None, + response_hook: Callable | None = None, + ): + """Instrument the provided Valkey Client. The client can be sync or async. + Cluster client is also supported. + + Args: + client: The valkey client. + tracer_provider: A TracerProvider, defaults to global. + request_hook: a function with extra user-defined logic to run before + performing the request. + response_hook: a function with extra user-defined logic to run after + the request is complete. + """ + if not hasattr(client, _INSTRUMENTATION_ATTR): + setattr(client, _INSTRUMENTATION_ATTR, False) + if not getattr(client, _INSTRUMENTATION_ATTR): + _instrument_client( + client, + ValkeyInstrumentor._get_tracer( + tracer_provider=tracer_provider + ), + request_hook=request_hook, + response_hook=response_hook, + ) + setattr(client, _INSTRUMENTATION_ATTR, True) + else: + _logger.warning( + "Attempting to instrument Valkey connection while already instrumented" + ) + + @staticmethod + def uninstrument_client( + client: valkey.Valkey + | valkey.asyncio.Valkey + | valkey.cluster.ValkeyCluster + | valkey.asyncio.ValkeyCluster, + ): + """Disables instrumentation for the given client instance + + Args: + client: The valkey client + """ + if getattr(client, _INSTRUMENTATION_ATTR): + unwrap(client, "execute_command") + unwrap(client, "pipeline") + else: + _logger.warning( + "Attempting to un-instrument Valkey connection that wasn't instrumented" + ) + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments diff --git a/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/package.py b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/package.py new file mode 100644 index 0000000000..27a3eb9e7d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/package.py @@ -0,0 +1,16 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +_instruments = ("valkey >= 6.0.0",) diff --git a/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/py.typed b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/util.py b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/util.py new file mode 100644 index 0000000000..cd901a8756 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/util.py @@ -0,0 +1,57 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +""" +Utility functions for Valkey instrumentation. + +These functions delegate to the shared base in +``opentelemetry.instrumentation._redis_valkey.util``, using Valkey-specific +attribute names and conventions. +""" + +from __future__ import annotations + +from typing import Any + +from opentelemetry.instrumentation._redis_valkey.util import ( + _extract_conn_attributes as _base_extract_conn_attributes, +) +from opentelemetry.instrumentation._redis_valkey.util import ( + _format_command_args, + _set_span_attribute_if_value, + _value_or_none, +) +from opentelemetry.semconv._incubating.attributes.db_attributes import ( + DB_REDIS_DATABASE_INDEX, + DB_SYSTEM, +) + +_BACKEND_NAME = "valkey" +_DB_SYSTEM = "valkey" +_DB_SYSTEM_ATTR = DB_SYSTEM +_DB_INDEX_ATTR = DB_REDIS_DATABASE_INDEX + +__all__ = [ + "_extract_conn_attributes", + "_format_command_args", + "_set_span_attribute_if_value", + "_value_or_none", +] + + +def _extract_conn_attributes(conn_kwargs: dict[str, Any]) -> dict[str, Any]: + """Transform valkey conn info into dict.""" + return _base_extract_conn_attributes( + conn_kwargs, _DB_SYSTEM, _DB_SYSTEM_ATTR, _DB_INDEX_ATTR + ) diff --git a/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/version.py b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/version.py new file mode 100644 index 0000000000..a07bc2663e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/src/opentelemetry/instrumentation/valkey/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.63b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-valkey/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-valkey/test-requirements.txt new file mode 100644 index 0000000000..8fa2c63a39 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/test-requirements.txt @@ -0,0 +1,17 @@ +asgiref==3.8.1 +async-timeout==4.0.3 +Deprecated==1.2.14 +fakeredis==2.29.0 +iniconfig==2.0.0 +packaging==24.0 +pluggy==1.5.0 +py-cpuinfo==9.0.0 +pytest==7.4.4 +valkey==6.1.0 +tomli==2.0.1 +typing_extensions==4.12.2 +wrapt==1.16.0 +zipp==3.19.2 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-redis-valkey-base +-e instrumentation/opentelemetry-instrumentation-valkey diff --git a/instrumentation/opentelemetry-instrumentation-valkey/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-valkey/tests/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-valkey/tests/test_valkey.py b/instrumentation/opentelemetry-instrumentation-valkey/tests/test_valkey.py new file mode 100644 index 0000000000..0ec6db86f8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-valkey/tests/test_valkey.py @@ -0,0 +1,293 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio +from unittest import mock +from unittest.mock import AsyncMock + +import valkey +import valkey.asyncio + +from opentelemetry import trace +from opentelemetry.instrumentation.valkey import ValkeyInstrumentor +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import SpanKind + + +class TestValkey(TestBase): + def setUp(self): + super().setUp() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + super().tearDown() + ValkeyInstrumentor().uninstrument() + + def test_span_properties(self): + valkey_client = valkey.Valkey() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "GET") + self.assertEqual(span.kind, SpanKind.CLIENT) + + def test_not_recording(self): + valkey_client = valkey.Valkey() + + mock_tracer = mock.Mock() + mock_span = mock.Mock() + mock_span.is_recording.return_value = False + mock_tracer.start_span.return_value = mock_span + with mock.patch("opentelemetry.trace.get_tracer") as tracer: + with mock.patch.object(valkey_client, "connection"): + tracer.return_value = mock_tracer + valkey_client.get("key") + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + + def test_instrument_uninstrument(self): + valkey_client = valkey.Valkey() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.memory_exporter.clear() + + # Test uninstrument + ValkeyInstrumentor().uninstrument() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + self.memory_exporter.clear() + + # Test instrument again + ValkeyInstrumentor().instrument() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + def test_instrument_uninstrument_async_client_command(self): + valkey_client = valkey.asyncio.Valkey() + + with mock.patch.object(valkey_client, "connection", AsyncMock()): + asyncio.run(valkey_client.get("key")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.memory_exporter.clear() + + # Test uninstrument + ValkeyInstrumentor().uninstrument() + + with mock.patch.object(valkey_client, "connection", AsyncMock()): + asyncio.run(valkey_client.get("key")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + self.memory_exporter.clear() + + # Test instrument again + ValkeyInstrumentor().instrument() + + with mock.patch.object(valkey_client, "connection", AsyncMock()): + asyncio.run(valkey_client.get("key")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + def test_response_hook(self): + valkey_client = valkey.Valkey() + connection = valkey.connection.Connection() + valkey_client.connection = connection + + response_attribute_name = "db.valkey.response" + + def response_hook(span, conn, response): + span.set_attribute(response_attribute_name, response) + + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument( + tracer_provider=self.tracer_provider, response_hook=response_hook + ) + + test_value = "test_value" + + with mock.patch.object(connection, "send_command"): + with mock.patch.object( + valkey_client, "parse_response", return_value=test_value + ): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes.get(response_attribute_name), test_value + ) + + def test_request_hook(self): + valkey_client = valkey.Valkey() + connection = valkey.connection.Connection() + valkey_client.connection = connection + + custom_attribute_name = "my.request.attribute" + + def request_hook(span, conn, args, kwargs): + if span and span.is_recording(): + span.set_attribute(custom_attribute_name, args[0]) + + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument( + tracer_provider=self.tracer_provider, request_hook=request_hook + ) + + test_value = "test_value" + + with mock.patch.object(connection, "send_command"): + with mock.patch.object( + valkey_client, "parse_response", return_value=test_value + ): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes.get(custom_attribute_name), "GET") + + def test_query_sanitizer_enabled(self): + valkey_client = valkey.Valkey() + connection = valkey.connection.Connection() + valkey_client.connection = connection + + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument( + tracer_provider=self.tracer_provider, + sanitize_query=True, + ) + + with mock.patch.object(valkey_client, "connection"): + valkey_client.set("key", "value") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes.get("db.statement"), "SET ? ?") + + def test_query_sanitizer(self): + valkey_client = valkey.Valkey() + connection = valkey.connection.Connection() + valkey_client.connection = connection + + with mock.patch.object(valkey_client, "connection"): + valkey_client.set("key", "value") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual(span.attributes.get("db.statement"), "SET ? ?") + + def test_no_op_tracer_provider(self): + ValkeyInstrumentor().uninstrument() + tracer_provider = trace.NoOpTracerProvider() + ValkeyInstrumentor().instrument(tracer_provider=tracer_provider) + + valkey_client = valkey.Valkey() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) + + def test_attributes_default(self): + valkey_client = valkey.Valkey() + + with mock.patch.object(valkey_client, "connection"): + valkey_client.set("key", "value") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[SpanAttributes.DB_SYSTEM], + "valkey", + ) + self.assertEqual(span.attributes["db.redis.database_index"], 0) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 6379) + + def test_attributes_tcp(self): + valkey_client = valkey.Valkey.from_url( + "valkey://foo:bar@1.1.1.1:6380/1" + ) + + with mock.patch.object(valkey_client, "connection"): + valkey_client.set("key", "value") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[SpanAttributes.DB_SYSTEM], + "valkey", + ) + self.assertEqual(span.attributes["db.redis.database_index"], 1) + self.assertEqual(span.attributes[SERVER_ADDRESS], "1.1.1.1") + self.assertEqual(span.attributes[SERVER_PORT], 6380) + + def test_attributes_unix_socket(self): + valkey_client = valkey.Valkey.from_url( + "unix://foo@/path/to/socket.sock?db=3&password=bar" + ) + + with mock.patch.object(valkey_client, "connection"): + valkey_client.set("key", "value") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertEqual( + span.attributes[SpanAttributes.DB_SYSTEM], + "valkey", + ) + self.assertEqual(span.attributes["db.redis.database_index"], 3) + self.assertEqual( + span.attributes[SERVER_ADDRESS], + "/path/to/socket.sock", + ) diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 630509a4b2..a254e9666d 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -67,6 +67,7 @@ dependencies = [ "opentelemetry-instrumentation-pymysql==0.63b0.dev", "opentelemetry-instrumentation-pyramid==0.63b0.dev", "opentelemetry-instrumentation-redis==0.63b0.dev", + "opentelemetry-instrumentation-redis-valkey-base==0.63b0.dev", "opentelemetry-instrumentation-remoulade==0.63b0.dev", "opentelemetry-instrumentation-requests==0.63b0.dev", "opentelemetry-instrumentation-sqlalchemy==0.63b0.dev", @@ -78,6 +79,7 @@ dependencies = [ "opentelemetry-instrumentation-tortoiseorm==0.63b0.dev", "opentelemetry-instrumentation-urllib==0.63b0.dev", "opentelemetry-instrumentation-urllib3==0.63b0.dev", + "opentelemetry-instrumentation-valkey==0.63b0.dev", "opentelemetry-instrumentation-wsgi==0.63b0.dev", ] diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index 7f38e43c96..9fc284151a 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -201,11 +201,16 @@ "library": "urllib3 >= 1.0.0, < 3.0.0", "instrumentation": "opentelemetry-instrumentation-urllib3==0.63b0.dev", }, + { + "library": "valkey[libvalkey] >= 6.1.0", + "instrumentation": "opentelemetry-instrumentation-valkey==0.63b0.dev", + }, ] default_instrumentations = [ "opentelemetry-instrumentation-asyncio==0.63b0.dev", "opentelemetry-instrumentation-dbapi==0.63b0.dev", "opentelemetry-instrumentation-logging==0.63b0.dev", + "opentelemetry-instrumentation-redis-valkey-base==0.63b0.dev", "opentelemetry-instrumentation-sqlite3==0.63b0.dev", "opentelemetry-instrumentation-threading==0.63b0.dev", "opentelemetry-instrumentation-urllib==0.63b0.dev", diff --git a/pyproject.toml b/pyproject.toml index 8f8f26855c..2b4960ae08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dependencies = [ "opentelemetry-instrumentation-tortoiseorm[instruments]", "opentelemetry-instrumentation-urllib", "opentelemetry-instrumentation-urllib3[instruments]", + "opentelemetry-instrumentation-valkey[instruments]", "opentelemetry-instrumentation-wsgi", "opentelemetry-propagator-ot-trace", "opentelemetry-propagator-aws-xray", @@ -122,6 +123,7 @@ opentelemetry-instrumentation-pymongo = { workspace = true } opentelemetry-instrumentation-pymysql = { workspace = true } opentelemetry-instrumentation-pyramid = { workspace = true } opentelemetry-instrumentation-redis = { workspace = true } +opentelemetry-instrumentation-redis-valkey-base = { workspace = true } opentelemetry-instrumentation-remoulade = { workspace = true } opentelemetry-instrumentation-requests = { workspace = true } opentelemetry-instrumentation-sqlalchemy = { workspace = true } @@ -132,6 +134,7 @@ opentelemetry-instrumentation-tornado = { workspace = true } opentelemetry-instrumentation-tortoiseorm = { workspace = true } opentelemetry-instrumentation-urllib = { workspace = true } opentelemetry-instrumentation-urllib3 = { workspace = true } +opentelemetry-instrumentation-valkey = { workspace = true } opentelemetry-instrumentation-wsgi = { workspace = true } opentelemetry-propagator-ot-trace = { workspace = true } opentelemetry-propagator-aws-xray = { workspace = true } diff --git a/tests/opentelemetry-docker-tests/tests/check_availability.py b/tests/opentelemetry-docker-tests/tests/check_availability.py index 2d1d488d55..2055c46063 100644 --- a/tests/opentelemetry-docker-tests/tests/check_availability.py +++ b/tests/opentelemetry-docker-tests/tests/check_availability.py @@ -9,6 +9,7 @@ import pymongo import pyodbc import redis +import valkey MONGODB_COLLECTION_NAME = "test" MONGODB_DB_NAME = os.getenv("MONGODB_DB_NAME", "opentelemetry-tests") @@ -26,6 +27,8 @@ POSTGRES_USER = os.getenv("POSTGRESQL_USER", "testuser") REDIS_HOST = os.getenv("REDIS_HOST", "localhost") REDIS_PORT = int(os.getenv("REDIS_PORT ", "6379")) +VALKEY_HOST = os.getenv("VALKEY_HOST", "localhost") +VALKEY_PORT = int(os.getenv("VALKEY_PORT ", "16379")) MSSQL_DB_NAME = os.getenv("MSSQL_DB_NAME", "opentelemetry-tests") MSSQL_HOST = os.getenv("MSSQL_HOST", "localhost") MSSQL_PORT = int(os.getenv("MSSQL_PORT", "1433")) @@ -101,6 +104,12 @@ def check_redis_connection(): connection.hgetall("*") +@retryable +def check_valkey_connection(): + connection = valkey.Valkey(host=VALKEY_HOST, port=VALKEY_PORT) + connection.hgetall("*") + + def new_mssql_connection() -> pyodbc.Connection: connection = pyodbc.connect( f"DRIVER={{ODBC Driver 18 for SQL Server}};SERVER={MSSQL_HOST}," @@ -130,6 +139,7 @@ def check_docker_services_availability(): check_mysql_connection() check_postgres_connection() check_redis_connection() + check_valkey_connection() check_mssql_connection() setup_mssql_db() diff --git a/tests/opentelemetry-docker-tests/tests/docker-compose.yml b/tests/opentelemetry-docker-tests/tests/docker-compose.yml index 02a3721d9b..92c4a5fdbe 100644 --- a/tests/opentelemetry-docker-tests/tests/docker-compose.yml +++ b/tests/opentelemetry-docker-tests/tests/docker-compose.yml @@ -38,6 +38,11 @@ services: - "127.0.0.1:7003:7003" - "127.0.0.1:7004:7004" - "127.0.0.1:7005:7005" + otvalkey: + image: valkey/valkey:8.1.1 + ports: + - "127.0.0.1:16379:6379" + otjaeger: image: jaegertracing/all-in-one:1.8 environment: diff --git a/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py b/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py index 1f6e370bc6..4600a218e6 100644 --- a/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py +++ b/tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py @@ -20,9 +20,9 @@ DB_REDIS_DATABASE_INDEX, DB_STATEMENT, ) -from opentelemetry.semconv._incubating.attributes.net_attributes import ( - NET_PEER_NAME, - NET_PEER_PORT, +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, ) from opentelemetry.test.test_base import TestBase @@ -42,8 +42,8 @@ def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) self.assertEqual(span.attributes.get(DB_REDIS_DATABASE_INDEX), 0) - self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") - self.assertEqual(span.attributes[NET_PEER_PORT], 6379) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 6379) def test_long_command_sanitized(self): RedisInstrumentor().uninstrument() @@ -266,8 +266,8 @@ def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) self.assertEqual(span.attributes.get(DB_REDIS_DATABASE_INDEX), 0) - self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") - self.assertEqual(span.attributes[NET_PEER_PORT], 6379) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 6379) def test_long_command(self): async_call(self.redis_client.mget(*range(1000))) @@ -564,8 +564,8 @@ def tearDown(self): def _check_span(self, span, name): self.assertEqual(span.name, name) self.assertIs(span.status.status_code, trace.StatusCode.UNSET) - self.assertEqual(span.attributes[NET_PEER_NAME], "localhost") - self.assertEqual(span.attributes[NET_PEER_PORT], 6379) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 6379) self.assertEqual(span.attributes[DB_REDIS_DATABASE_INDEX], 10) def test_get(self): diff --git a/tests/opentelemetry-docker-tests/tests/test-requirements.txt b/tests/opentelemetry-docker-tests/tests/test-requirements.txt index 4b6c61fba5..1e2110ff9f 100644 --- a/tests/opentelemetry-docker-tests/tests/test-requirements.txt +++ b/tests/opentelemetry-docker-tests/tests/test-requirements.txt @@ -66,6 +66,7 @@ tomli==2.0.1 typing_extensions==4.12.2 tzdata==2024.1 urllib3==1.26.19 +valkey[libvalkey]==6.1.0 vine==5.1.0 wcwidth==0.2.13 websocket-client==0.59.0 diff --git a/tests/opentelemetry-docker-tests/tests/valkey/test_valkey_functional.py b/tests/opentelemetry-docker-tests/tests/valkey/test_valkey_functional.py new file mode 100644 index 0000000000..48fe167f46 --- /dev/null +++ b/tests/opentelemetry-docker-tests/tests/valkey/test_valkey_functional.py @@ -0,0 +1,608 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from time import time_ns + +import valkey +import valkey.asyncio + +from opentelemetry import trace +from opentelemetry.instrumentation.valkey import ValkeyInstrumentor +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.test.test_base import TestBase + + +class TestValkeyInstrument(TestBase): + def setUp(self): + super().setUp() + self.valkey_client = valkey.Valkey(port=16379) + self.valkey_client.flushall() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + ValkeyInstrumentor().uninstrument() + super().tearDown() + + def _check_span(self, span, name): + self.assertEqual(span.name, name) + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + self.assertEqual(span.attributes.get("db.redis.database_index"), 0) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 16379) + + def test_long_command_sanitized(self): + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + self.valkey_client.mget(*range(2000)) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "MGET") + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).startswith( + "MGET ? ? ? ?" + ) + ) + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).endswith("...") + ) + + def test_long_command(self): + self.valkey_client.mget(*range(1000)) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "MGET") + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).startswith( + "MGET ? ? ? ?" + ) + ) + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).endswith("...") + ) + + def test_basics_sanitized(self): + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + self.assertIsNone(self.valkey_client.get("cheese")) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) + self.assertEqual(span.attributes.get("db.redis.args_length"), 2) + + def test_basics(self): + self.assertIsNone(self.valkey_client.get("cheese")) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) + self.assertEqual(span.attributes.get("db.redis.args_length"), 2) + + def test_pipeline_traced_sanitized(self): + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + with self.valkey_client.pipeline(transaction=False) as pipeline: + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + pipeline.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "SET RPUSH HGETALL") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), + "SET ? ?\nRPUSH ? ?\nHGETALL ?", + ) + self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3) + + def test_pipeline_traced(self): + with self.valkey_client.pipeline(transaction=False) as pipeline: + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + pipeline.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "SET RPUSH HGETALL") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), + "SET ? ?\nRPUSH ? ?\nHGETALL ?", + ) + self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3) + + def test_pipeline_immediate_sanitized(self): + ValkeyInstrumentor().uninstrument() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + with self.valkey_client.pipeline() as pipeline: + pipeline.set("a", 1) + pipeline.immediate_execute_command("SET", "b", 2) + pipeline.execute() + + spans = self.memory_exporter.get_finished_spans() + # expecting two separate spans here, rather than a + # single span for the whole pipeline + self.assertEqual(len(spans), 2) + span = spans[0] + self._check_span(span, "SET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "SET ? ?" + ) + + def test_pipeline_immediate(self): + with self.valkey_client.pipeline() as pipeline: + pipeline.set("a", 1) + pipeline.immediate_execute_command("SET", "b", 2) + pipeline.execute() + + spans = self.memory_exporter.get_finished_spans() + # expecting two separate spans here, rather than a + # single span for the whole pipeline + self.assertEqual(len(spans), 2) + span = spans[0] + self._check_span(span, "SET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "SET ? ?" + ) + + def test_parent(self): + """Ensure OpenTelemetry works with valkey.""" + ot_tracer = trace.get_tracer("valkey_svc") + + with ot_tracer.start_as_current_span("valkey_get"): + self.assertIsNone(self.valkey_client.get("cheese")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + child_span, parent_span = spans[0], spans[1] + + # confirm the parenting + self.assertIsNone(parent_span.parent) + self.assertIs(child_span.parent, parent_span.get_span_context()) + + self.assertEqual(parent_span.name, "valkey_get") + self.assertEqual(parent_span.instrumentation_info.name, "valkey_svc") + + self.assertEqual(child_span.name, "GET") + + +class TestValkeyClusterInstrument(TestBase): + def setUp(self): + super().setUp() + self.valkey_client = valkey.cluster.ValkeyCluster( + host="localhost", port=7000 + ) + self.valkey_client.flushall() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + super().tearDown() + ValkeyInstrumentor().uninstrument() + + def _check_span(self, span, name): + self.assertEqual(span.name, name) + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + def test_basics(self): + self.assertIsNone(self.valkey_client.get("cheese")) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) + self.assertEqual(span.attributes.get("db.redis.args_length"), 2) + + def test_pipeline_traced(self): + with self.valkey_client.pipeline(transaction=False) as pipeline: + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + pipeline.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "SET RPUSH HGETALL") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), + "SET ? ?\nRPUSH ? ?\nHGETALL ?", + ) + self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3) + + def test_parent(self): + """Ensure OpenTelemetry works with valkey.""" + ot_tracer = trace.get_tracer("valkey_svc") + + with ot_tracer.start_as_current_span("valkey_get"): + self.assertIsNone(self.valkey_client.get("cheese")) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + child_span, parent_span = spans[0], spans[1] + + # confirm the parenting + self.assertIsNone(parent_span.parent) + self.assertIs(child_span.parent, parent_span.get_span_context()) + + self.assertEqual(parent_span.name, "valkey_get") + self.assertEqual(parent_span.instrumentation_info.name, "valkey_svc") + + self.assertEqual(child_span.name, "GET") + + +def async_call(coro): + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +class TestAsyncValkeyInstrument(TestBase): + def setUp(self): + super().setUp() + self.valkey_client = valkey.asyncio.Valkey(port=16379) + async_call(self.valkey_client.flushall()) + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + ValkeyInstrumentor().uninstrument() + super().tearDown() + + def _check_span(self, span, name): + self.assertEqual(span.name, name) + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + self.assertEqual(span.attributes.get("db.redis.database_index"), 0) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 16379) + + def test_long_command(self): + async_call(self.valkey_client.mget(*range(1000))) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "MGET") + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).startswith( + "MGET ? ? ? ?" + ) + ) + self.assertTrue( + span.attributes.get(SpanAttributes.DB_STATEMENT).endswith("...") + ) + + def test_basics(self): + self.assertIsNone(async_call(self.valkey_client.get("cheese"))) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) + self.assertEqual(span.attributes.get("db.redis.args_length"), 2) + + def test_execute_command_traced_full_time(self): + """Command should be traced for coroutine execution time, not creation time.""" + coro_created_time = None + finish_time = None + + async def pipeline_simple(): + nonlocal coro_created_time + nonlocal finish_time + + # delay coroutine creation from coroutine execution + coro = self.valkey_client.get("foo") + coro_created_time = time_ns() + await coro + finish_time = time_ns() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertTrue(span.start_time > coro_created_time) + self.assertTrue(span.end_time < finish_time) + + def test_pipeline_traced(self): + async def pipeline_simple(): + async with self.valkey_client.pipeline( + transaction=False + ) as pipeline: + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + await pipeline.execute() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "SET RPUSH HGETALL") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), + "SET ? ?\nRPUSH ? ?\nHGETALL ?", + ) + self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3) + + def test_pipeline_traced_full_time(self): + """Command should be traced for coroutine execution time, not creation time.""" + coro_created_time = None + finish_time = None + + async def pipeline_simple(): + async with self.valkey_client.pipeline( + transaction=False + ) as pipeline: + nonlocal coro_created_time + nonlocal finish_time + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + + # delay coroutine creation from coroutine execution + coro = pipeline.execute() + coro_created_time = time_ns() + await coro + finish_time = time_ns() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertTrue(span.start_time > coro_created_time) + self.assertTrue(span.end_time < finish_time) + + def test_pipeline_immediate(self): + async def pipeline_immediate(): + async with self.valkey_client.pipeline() as pipeline: + pipeline.set("a", 1) + await pipeline.immediate_execute_command("SET", "b", 2) + await pipeline.execute() + + async_call(pipeline_immediate()) + + spans = self.memory_exporter.get_finished_spans() + # expecting two separate spans here, rather than a + # single span for the whole pipeline + self.assertEqual(len(spans), 2) + span = spans[0] + self._check_span(span, "SET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "SET ? ?" + ) + + def test_pipeline_immediate_traced_full_time(self): + """Command should be traced for coroutine execution time, not creation time.""" + coro_created_time = None + finish_time = None + + async def pipeline_simple(): + async with self.valkey_client.pipeline( + transaction=False + ) as pipeline: + nonlocal coro_created_time + nonlocal finish_time + pipeline.set("a", 1) + + # delay coroutine creation from coroutine execution + coro = pipeline.immediate_execute_command("SET", "b", 2) + coro_created_time = time_ns() + await coro + finish_time = time_ns() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertTrue(span.start_time > coro_created_time) + self.assertTrue(span.end_time < finish_time) + + def test_parent(self): + """Ensure OpenTelemetry works with valkey.""" + ot_tracer = trace.get_tracer("valkey_svc") + + with ot_tracer.start_as_current_span("valkey_get"): + self.assertIsNone(async_call(self.valkey_client.get("cheese"))) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + child_span, parent_span = spans[0], spans[1] + + # confirm the parenting + self.assertIsNone(parent_span.parent) + self.assertIs(child_span.parent, parent_span.get_span_context()) + + self.assertEqual(parent_span.name, "valkey_get") + self.assertEqual(parent_span.instrumentation_info.name, "valkey_svc") + + self.assertEqual(child_span.name, "GET") + + +class TestAsyncValkeyClusterInstrument(TestBase): + def setUp(self): + super().setUp() + self.valkey_client = valkey.asyncio.cluster.ValkeyCluster( + host="localhost", port=7000 + ) + async_call(self.valkey_client.flushall()) + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + super().tearDown() + ValkeyInstrumentor().uninstrument() + + def _check_span(self, span, name): + self.assertEqual(span.name, name) + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + def test_basics(self): + self.assertIsNone(async_call(self.valkey_client.get("cheese"))) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) + self.assertEqual(span.attributes.get("db.redis.args_length"), 2) + + def test_execute_command_traced_full_time(self): + """Command should be traced for coroutine execution time, not creation time.""" + coro_created_time = None + finish_time = None + + async def pipeline_simple(): + nonlocal coro_created_time + nonlocal finish_time + + # delay coroutine creation from coroutine execution + coro = self.valkey_client.get("foo") + coro_created_time = time_ns() + await coro + finish_time = time_ns() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertTrue(span.start_time > coro_created_time) + self.assertTrue(span.end_time < finish_time) + + def test_pipeline_traced(self): + async def pipeline_simple(): + async with self.valkey_client.pipeline( + transaction=False + ) as pipeline: + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + await pipeline.execute() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "SET RPUSH HGETALL") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), + "SET ? ?\nRPUSH ? ?\nHGETALL ?", + ) + self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3) + + def test_pipeline_traced_full_time(self): + """Command should be traced for coroutine execution time, not creation time.""" + coro_created_time = None + finish_time = None + + async def pipeline_simple(): + async with self.valkey_client.pipeline( + transaction=False + ) as pipeline: + nonlocal coro_created_time + nonlocal finish_time + pipeline.set("blah", 32) + pipeline.rpush("foo", "éé") + pipeline.hgetall("xxx") + + # delay coroutine creation from coroutine execution + coro = pipeline.execute() + coro_created_time = time_ns() + await coro + finish_time = time_ns() + + async_call(pipeline_simple()) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertTrue(span.start_time > coro_created_time) + self.assertTrue(span.end_time < finish_time) + + def test_parent(self): + """Ensure OpenTelemetry works with valkey.""" + ot_tracer = trace.get_tracer("valkey_svc") + + with ot_tracer.start_as_current_span("valkey_get"): + self.assertIsNone(async_call(self.valkey_client.get("cheese"))) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + child_span, parent_span = spans[0], spans[1] + + # confirm the parenting + self.assertIsNone(parent_span.parent) + self.assertIs(child_span.parent, parent_span.get_span_context()) + + self.assertEqual(parent_span.name, "valkey_get") + self.assertEqual(parent_span.instrumentation_info.name, "valkey_svc") + + self.assertEqual(child_span.name, "GET") + + +class TestValkeyDBIndexInstrument(TestBase): + def setUp(self): + super().setUp() + self.valkey_client = valkey.Valkey(port=16379, db=10) + self.valkey_client.flushall() + ValkeyInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self): + ValkeyInstrumentor().uninstrument() + super().tearDown() + + def _check_span(self, span, name): + self.assertEqual(span.name, name) + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + self.assertEqual(span.attributes[SERVER_ADDRESS], "localhost") + self.assertEqual(span.attributes[SERVER_PORT], 16379) + self.assertEqual(span.attributes["db.redis.database_index"], 10) + + def test_get(self): + self.assertIsNone(self.valkey_client.get("foo")) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self._check_span(span, "GET") + self.assertEqual( + span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?" + ) diff --git a/tox.ini b/tox.ini index 897a1c75d2..1f4df7c525 100644 --- a/tox.ini +++ b/tox.ini @@ -439,6 +439,11 @@ envlist = ; requires snappy headers to be available on the system lint-processor-baggage + ; opentelemetry-instrumentation-valkey + py3{10,11,12,13,14}-test-instrumentation-valkey + pypy3-test-instrumentation-valkey + lint-instrumentation-valkey + ; opentelemetry-opamp-client py3{10,11,12,13,14}-test-opamp-client-{latest,lowest} ; https://github.com/kevin1024/vcrpy/pull/775#issuecomment-1847849962 @@ -667,6 +672,9 @@ deps = redis: {[testenv]test_deps} redis: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt + valkey: {[testenv]test_deps} + valkey: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-valkey/test-requirements.txt + remoulade: {[testenv]test_deps} remoulade: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-remoulade/test-requirements.txt @@ -954,6 +962,9 @@ commands = test-instrumentation-redis: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-redis/tests {posargs} lint-instrumentation-redis: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-redis" + test-instrumentation-valkey: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-valkey/tests {posargs} + lint-instrumentation-valkey: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-valkey" + test-instrumentation-remoulade: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-remoulade/tests {posargs} lint-instrumentation-remoulade: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-remoulade" @@ -1075,7 +1086,9 @@ deps = -e {toxinidir}/instrumentation/opentelemetry-instrumentation-pymssql -e {toxinidir}/instrumentation/opentelemetry-instrumentation-sqlalchemy -e {toxinidir}/instrumentation/opentelemetry-instrumentation-aiopg + -e {toxinidir}/instrumentation/opentelemetry-instrumentation-redis-valkey-base -e {toxinidir}/instrumentation/opentelemetry-instrumentation-redis + -e {toxinidir}/instrumentation/opentelemetry-instrumentation-valkey -e {toxinidir}/instrumentation/opentelemetry-instrumentation-wsgi -e {toxinidir}/util/opentelemetry-util-http diff --git a/uv.lock b/uv.lock index 14d5ea35b6..b32bd2679f 100644 --- a/uv.lock +++ b/uv.lock @@ -60,6 +60,7 @@ members = [ "opentelemetry-instrumentation-pymysql", "opentelemetry-instrumentation-pyramid", "opentelemetry-instrumentation-redis", + "opentelemetry-instrumentation-redis-valkey-base", "opentelemetry-instrumentation-remoulade", "opentelemetry-instrumentation-requests", "opentelemetry-instrumentation-sqlalchemy", @@ -71,6 +72,7 @@ members = [ "opentelemetry-instrumentation-tortoiseorm", "opentelemetry-instrumentation-urllib", "opentelemetry-instrumentation-urllib3", + "opentelemetry-instrumentation-valkey", "opentelemetry-instrumentation-vertexai", "opentelemetry-instrumentation-weaviate", "opentelemetry-instrumentation-wsgi", @@ -2284,6 +2286,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, ] +[[package]] +name = "libvalkey" +version = "4.0.1" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/38/49/57857ba9d02ba4df3bc1e71044f599c82ea590e928328e6b512dbf720228/libvalkey-4.0.1.tar.gz", hash = "sha256:fe60ef535bc826fc35f4019228a0a46bdce8b41fd6013a7591e822a8a17c3170", size = 109005, upload-time = "2024-11-26T14:35:38.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/ac/c7b21f810c17527f77c8bd4145e21568a95a4eccc32bce8df4c5ba5d8863/libvalkey-4.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e180a893ac62e340a63e18c6dcc91fc756c9f2291b47b35ee1febec68c6d13c", size = 43044, upload-time = "2024-11-26T14:32:50.692Z" }, + { url = "https://files.pythonhosted.org/packages/e9/67/84c88cf0e8df1d68da9a2e7f6a79e818031a7ced1b70d66c60041e8dd7b5/libvalkey-4.0.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:f51c08cae774071ea354658f9a9bb7ffb7b1743661011a28668650b130e0d063", size = 82094, upload-time = "2024-11-26T14:32:52.855Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b3/4a7bf5a0275674cb17e0204c05e16356c94406256d811317e6ba87e1f234/libvalkey-4.0.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:98a37d1cb5f4c3dde6646968b0a624d3051fd99176583a5245641050e931a682", size = 44753, upload-time = "2024-11-26T14:32:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/aee00783de3ad3fbbadca23692f854f4b9f4b545c55db19657a4330e1bb4/libvalkey-4.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01c4051c3b772bd3032ca66c96f229412622ef0bef344f9ad645221f56082573", size = 170126, upload-time = "2024-11-26T14:32:55.608Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/c8f80d3fa881cf79d20b537dcbc3ab5c8776db51cfa50b2a04a3191d027d/libvalkey-4.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf424fe1f45462ae4fea5f88b250ae86d7217a9662cfe5cd8a25208268129833", size = 165552, upload-time = "2024-11-26T14:32:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0c/e697bc18740d95dd0a3104404a0fbaaf4f1d463ac6b7ed2f6fc0c8be4b6a/libvalkey-4.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea9bdd8fc54de6ceea9e28dc28b327a443423dd1d110bb9fa1d67f02626a8679", size = 181753, upload-time = "2024-11-26T14:32:58.239Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/49e0b1eb0cc9aee67d711386cbda604ea67752d17d9f94b62ea9866716af/libvalkey-4.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:641eed2e36408b8ba553c4606b2cfbee05286183249c76841188897d595c6639", size = 170657, upload-time = "2024-11-26T14:32:59.516Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/205b2e63936f880d0ff8cc7cc27059aa698a0d4f02dc9194854eadce985c/libvalkey-4.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbf76b1b51bd5fe23dd09d0b7599cf6ee7a074e73a1933910e5faa1741408708", size = 170331, upload-time = "2024-11-26T14:33:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/35/93/31734676641cb36a3ac23ffedc89b328a63a2f00eba80fc0554e2dd93872/libvalkey-4.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:75918faf78b2728ef8a73438361c328d70757cdb0e8bf57fa636e0776f302d4e", size = 164756, upload-time = "2024-11-26T14:33:03.974Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fd/1f8476e45792c1bd6bb364e7f04c0274caa37767d4ed12658b1d863874cf/libvalkey-4.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aa678090591e1c28a5f0647ce69531752e8f75e83a03e8963500941475898ccf", size = 162825, upload-time = "2024-11-26T14:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/03/c293870a74b88d8ce2c5fed2a049eb2ec2373e29ccd3eb3df32217746e00/libvalkey-4.0.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a1eff0939e0577ddc6b8b359a846c0a83cb7ed3b0688fe98f8f8cf3ba8aa04b7", size = 176181, upload-time = "2024-11-26T14:33:06.637Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b0/8624fa9195c4af93044860c9685b7667c1b7dd9bff6b62f815e65a512f24/libvalkey-4.0.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b3ac608744fc2727eb87cdd7613f8d64b18a210b1661706d2b2de09fffd3d2d0", size = 167755, upload-time = "2024-11-26T14:33:08.033Z" }, + { url = "https://files.pythonhosted.org/packages/f6/66/74f722d90e37addf2b1235ce8294d50751c1a406a7dd05e7b4893f474daa/libvalkey-4.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e8801cf0274b2a6b0d19ea47de351e5ce67579b8503c4f9905ab53b52fbf270e", size = 165462, upload-time = "2024-11-26T14:33:10.035Z" }, + { url = "https://files.pythonhosted.org/packages/18/a1/16512251a897ad7022787ae395c2ecaf48449f7fce170d6656c5a27df795/libvalkey-4.0.1-cp310-cp310-win32.whl", hash = "sha256:a9438415f500c1b65fe258f102b004ef690db142a74d681d10fd82e344dc947f", size = 19468, upload-time = "2024-11-26T14:33:11.446Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a8/7819672c42b470c67a994db05dd876b88ad97d5e4ae3152a7e49172b0b1b/libvalkey-4.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:cd3495a5c4c7f04c26bd5c34feb15c13da2dab5a349756a3f42f2a15521a5197", size = 21354, upload-time = "2024-11-26T14:33:12.635Z" }, + { url = "https://files.pythonhosted.org/packages/c4/7a/d7e4726c9a08c703fd4e824c7c644ae6c5f0ee3f1b99474a524f0149b77f/libvalkey-4.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f342da7200765da30e8a6a540722a8e9e689b0b0604e067290d308981d93826f", size = 43043, upload-time = "2024-11-26T14:33:13.78Z" }, + { url = "https://files.pythonhosted.org/packages/27/ae/30ba1da48e143da9c1fa0e4cf4899bbd4402b0a0c8f6c355aa76e89b6490/libvalkey-4.0.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:0db70261f8843007ea995f7adf0d619780380ac3abc4c1ac44ad4f3e885d5594", size = 82093, upload-time = "2024-11-26T14:33:15.517Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/1c2b524ad44a7c24d7e62bb63d995bb8e59863c0f88844778c109604f83f/libvalkey-4.0.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:785c73ba7177777d9af01f48aa3344099815cdae3fef12c5d0f35b9b392f35bf", size = 44754, upload-time = "2024-11-26T14:33:16.656Z" }, + { url = "https://files.pythonhosted.org/packages/f7/1f/954f1ac80371dc50efaada4ca133219e2edb265c136c7fa2af1821caf5b2/libvalkey-4.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d56ed4c6c17bfb65bf4fe0745fdb3ae6bd1111af6171294497173e3a45226d7", size = 170152, upload-time = "2024-11-26T14:33:17.841Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3f/1fbe055702041bcf2a85e056955219102e8b0aa3967482a5d68f7a3bb8c8/libvalkey-4.0.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d99b4adae993b9d8f057e150e5a2f938823d17286abcbc5cca0cb4741c530ddf", size = 165334, upload-time = "2024-11-26T14:33:19.504Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/ef273ef578fbac6e3b336c2138e457893654da0a40c75fcc76bf28be4761/libvalkey-4.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3d803812b4933a1926479aff4057f06b4332977b796038b4309d98546c56edf5", size = 181816, upload-time = "2024-11-26T14:33:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/2e/87/3b681951477e98e135dee6f8b570779875a96a0367ad886327610f9134ee/libvalkey-4.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b473dc3d005a9b57e4445cb2a9ab48f8a26ea90889458ef3cb4d3dd7b23b5a26", size = 170687, upload-time = "2024-11-26T14:33:22.596Z" }, + { url = "https://files.pythonhosted.org/packages/13/4c/d384395d2378e6481cdcb1fb7c6e6d0a3ae0feb3dffaefbd6c1eb39cb3d1/libvalkey-4.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:195f7e78cb6d2c391dac2d0fc1bf7e65555ebad856e0c36bcc4986e0b3b6c616", size = 170453, upload-time = "2024-11-26T14:33:24.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d8/1c64a5704ef4f843e2ffab8d815f1c918445f92e38660a6d44c7c64d1fe1/libvalkey-4.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:79b446abdb18aefc984214de68ac5f50164550a00b703b81c2b9d9c1618f4a13", size = 164803, upload-time = "2024-11-26T14:33:26.272Z" }, + { url = "https://files.pythonhosted.org/packages/e1/65/6d3004b011a799781f379bdba531a3c19bc5f8cd929bafa96fc066a59b12/libvalkey-4.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3453cd138a43cdcce32cbbbdc99d99472fb7905e56df8ff2f73dac5be70f0657", size = 162855, upload-time = "2024-11-26T14:33:27.85Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b8/35c8f8cedfeaf2ddb261f09e9a618a3e5ce44c8d4ea29e19ce4c1ae175d7/libvalkey-4.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a9acf658749ee324750643df040a62401d479de9a4507ca8f69bcf02df1b189", size = 176207, upload-time = "2024-11-26T14:33:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ea/428c9404f41bce80d946ed904b5749a5b3ac2f2a6a1a48f4da1c9b58f8b2/libvalkey-4.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ebfe6976a10ab6fb84d885622a39ff580803f3244a048b75fb63a97048cb894c", size = 167761, upload-time = "2024-11-26T14:33:31.473Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/b0e5fc755c78ac23a6e7a5c81288e7b44131becc11af07ef087f54054adf/libvalkey-4.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:860862322eceb3ed2ff2031663ee42d9ff4146226af3734f818b54a70339c440", size = 165463, upload-time = "2024-11-26T14:33:32.831Z" }, + { url = "https://files.pythonhosted.org/packages/c7/fd/9700a1edec4ebacbcb7ccdf55ac6548f2a5693b016768d721c0d520753ad/libvalkey-4.0.1-cp311-cp311-win32.whl", hash = "sha256:dd96985818cc1ddc8882dda67fa1cf711db37d0a24a4cd70897fd83a7377a11c", size = 19475, upload-time = "2024-11-26T14:33:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/85/25/d59dbdf8cb16d5c1f9215fc7cf66d2cbca6d05008eda1104b321df785647/libvalkey-4.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:4a1174d53d949f38128efc038b2d77cb79c4db127f5707ad350de0cda3aa9451", size = 21358, upload-time = "2024-11-26T14:33:35.487Z" }, + { url = "https://files.pythonhosted.org/packages/2b/62/fb85f94411890d233c74aad7b581bb65a0f809b5757446a280a8fa42a50d/libvalkey-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3d5da92598f8e6a82a5e3dedd49dae39fce6b3089030e17681e2b73df4a6d89", size = 43057, upload-time = "2024-11-26T14:33:36.532Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cb/6f7614cab744f0e4e0eae583b2997bf22ffee4aa333e26786b91342986b6/libvalkey-4.0.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ce623bb8c37beb16d0f2c7c5b7080a3172dae4030e3bcd71326c7731883f766f", size = 82198, upload-time = "2024-11-26T14:33:38.044Z" }, + { url = "https://files.pythonhosted.org/packages/cf/de/9515ba0f436c3e54331b66cf7652c03fc73688e0b6f22853a6fc2cc8aa23/libvalkey-4.0.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:00adc978a791e944e2f6b442212cd3494a8d683ed215ff785dc9384968b212b6", size = 44839, upload-time = "2024-11-26T14:33:41.112Z" }, + { url = "https://files.pythonhosted.org/packages/21/f1/dd28a89f6c89e4fbcd95be18a04758f6f57fc69d38a7bc22a59377b277fc/libvalkey-4.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:827ea20b5ee1d99cf3d2c10da24c356c55836dd6ff1689b3fbf190b5bffe1605", size = 173314, upload-time = "2024-11-26T14:33:42.737Z" }, + { url = "https://files.pythonhosted.org/packages/c6/17/debc72596eb3e4c27a4ae1a5b5636e99b7b5e606c072c8816358ab69fb7f/libvalkey-4.0.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f81f7d806e5dd826532c0a4b6d8bc91a485fba65a3767cfdeb796b493ac59c8c", size = 167968, upload-time = "2024-11-26T14:33:44.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/451b234f5125e0b9396d7ca4b9d2ab9785e21475f4da60ff019e48912f73/libvalkey-4.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65fe45323bbabee8d770127c0a763182a0d540a8c1afe6537d97dcc021fc26c4", size = 184371, upload-time = "2024-11-26T14:33:45.463Z" }, + { url = "https://files.pythonhosted.org/packages/09/c1/e10266e11034af9194feacec78221bb01db6961b662303a980c77a61f159/libvalkey-4.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c20ec7a26163dad23b0dfdbb81dd13ae3aa4083942b438c07dadaed88fa0ca6c", size = 173422, upload-time = "2024-11-26T14:33:47.165Z" }, + { url = "https://files.pythonhosted.org/packages/88/ba/fe3b25281e41546ea96c41b7899d2a83a262463432280e07daed44beb7f5/libvalkey-4.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c0d5b4b01c2e4e5bad8339d8b585f42c0b10fb05a6e4469c15c43d925be6285", size = 173617, upload-time = "2024-11-26T14:33:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/a75b6edcaabdc6245ee719357d307e2fccb375ca09d012327fbc1ef68771/libvalkey-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c151a43b250b6e6412c5b0a75093c49d248bbe541db91d2d7f04fd523ea616b3", size = 167110, upload-time = "2024-11-26T14:33:49.728Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/602254b8865a0fb21df3f3cd57815ca7e6049cd79318bfb426449b661cee/libvalkey-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c8f59bb57f8e02e287da54a2e1250b9d591131922c006b873e5e2cad50fc36c", size = 164932, upload-time = "2024-11-26T14:33:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c6/116f5432c8234630079f3dadcf48828db41c2bfcdfaeb36ef197a2efa380/libvalkey-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:89e79fade6e6953c786b0e2d255a2930733f5d9e7ef07cf47a4eb0db4eabba5e", size = 178905, upload-time = "2024-11-26T14:33:52.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/00/ec095e022b7e5c2035787ad7389e0a11157ceb98f4ceae7fc44805907be0/libvalkey-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56495ab791f539dc3ee30378f9783f017e000ad8b03751ad2426003f74eee0bc", size = 170350, upload-time = "2024-11-26T14:33:53.769Z" }, + { url = "https://files.pythonhosted.org/packages/a5/84/d8bd771b07854c58cbf8926d98fed1701a4dcbe97ef31e8fea63416fc461/libvalkey-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:df9d7ba691c49c632bdc953b5c0af5c50231d0ae3bb0397688f63257a12786c0", size = 168125, upload-time = "2024-11-26T14:33:55.212Z" }, + { url = "https://files.pythonhosted.org/packages/45/88/00d5d2d0960d0023c52d25d5ae87d47b5aec995241788be5c424826727aa/libvalkey-4.0.1-cp312-cp312-win32.whl", hash = "sha256:a39ad585b3d2d48d6f5b60941f9d6a5f3f30a396ae129db15bf626316f71594f", size = 19651, upload-time = "2024-11-26T14:33:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/8f/57/a3f837524d63ed927f6ab3da3be2050f6c838279c796d5b44b617cad0047/libvalkey-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:7b39754c9cdf7fe704c636a2ea179c17229566e7c79af453df3a604b98879dc3", size = 21451, upload-time = "2024-11-26T14:33:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/51/3c/8571abc9b8a78281312b99348bccb35dfa161a3a3e9b963afdd473beea40/libvalkey-4.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f72346eca7408cfd6d6f407e7e548040b310ed6fdaec7d9ea67b49f20dc90a9e", size = 43065, upload-time = "2024-11-26T14:33:58.883Z" }, + { url = "https://files.pythonhosted.org/packages/8e/08/197217ff273fde5670b84682a2d67fe372890d14595ae0a15284626975ba/libvalkey-4.0.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:66b4558ca5b8fa48fde40dfc79547779d78c5397906f5ea5671b9a75f0952ff4", size = 82206, upload-time = "2024-11-26T14:34:00.181Z" }, + { url = "https://files.pythonhosted.org/packages/51/e1/a806533c315cf758812e4f609548ccbb51c10221e2a3c6f9aa6e17633ee9/libvalkey-4.0.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:33d29f8d826b59e972a8502e8547d5fef7b1a1376fa0884cf1360c15977bca50", size = 44840, upload-time = "2024-11-26T14:34:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c8/9908fdb4a5661bef8972cf94d149f5c5199c3835bba1a6f523225e2d6c37/libvalkey-4.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8adea3c5824937c1e94bb1d5bc30c57661e8bbcb1a79e8ead77b45bbe206f488", size = 173200, upload-time = "2024-11-26T14:34:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/80/d2/3b6c235393137b16dacddb04ac6b632f3998cd10cb52b1e34b2eb7bdcabf/libvalkey-4.0.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50e956e1bc0ab21e2479fb5729456ae74de808a1be799b9163bf7a25029eeb41", size = 168270, upload-time = "2024-11-26T14:34:06.909Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/844123ae52d63d9ec97f4ad026054a0b616d83fd886adcfd2f8484939044/libvalkey-4.0.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:56d6db545639a5fbb1c634621634a7f87845b7867056b1460a8299cc489e3364", size = 184290, upload-time = "2024-11-26T14:34:08.335Z" }, + { url = "https://files.pythonhosted.org/packages/7c/13/390d1f1fac2167e5e4531cbc090ec99e974271fc05e4c0c8597db593596d/libvalkey-4.0.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b83e133826ca50506c9f49b5c4fd64ef0dc3bbc6e85bb18b781a08320bf3f0db", size = 173279, upload-time = "2024-11-26T14:34:09.721Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9f/74deb4ea77efb2cbf24c2a7364628b93f3bd02bff7203162c88e9bfe8f13/libvalkey-4.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e58b6dcea57df7ee8d80f914ed8895141fbb53d6f344b310ebe6cae3e407d0f", size = 173436, upload-time = "2024-11-26T14:34:11.091Z" }, + { url = "https://files.pythonhosted.org/packages/db/e6/9bb87d6d2fe4ba4da960104356c2b165766cb1d420ef1d7f0f671729f3ab/libvalkey-4.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35576248ac379e755cf40c4ebe6bf735f278d46b5e449d0c8ea9f66869e3a8d4", size = 167050, upload-time = "2024-11-26T14:34:12.451Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/35eda3faa3a7e9a5702c1833cf2ff0fdb024661d8342b24393198db968a2/libvalkey-4.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2be9cd8533638be94956567602554bbffa65d6fc8e758cd628f317a58cbcafda", size = 164934, upload-time = "2024-11-26T14:34:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/6439403407317d1228f8b2d5e38917ff162cd86b371bf28953e727674b20/libvalkey-4.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a03058988844b5a56f296d0d5608bbbe38df1b875246d6c6d7b442af793b5197", size = 178943, upload-time = "2024-11-26T14:34:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/8628c445f9683d2b0f2df3cf2084ec48917d33ccf3f857b2b4b25c6ba3a8/libvalkey-4.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:885a2ca644a2fdaf555e9fdab2bbe7de0f91de4e2a07726751efa35417736d55", size = 170398, upload-time = "2024-11-26T14:34:17.671Z" }, + { url = "https://files.pythonhosted.org/packages/e1/86/9780ad4d818fbb63efb3763041109fbdbe20a901413effa70a8fcf0ec56b/libvalkey-4.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:90fb5252391a8a9a3bb2a4eadd3f24a8af1032821d818e620da16854fb52503e", size = 168104, upload-time = "2024-11-26T14:34:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/61/b2/8ec653c1e1cb85a8135f6413be90e40c1d3c6732f5933f4d3990f1777d09/libvalkey-4.0.1-cp313-cp313-win32.whl", hash = "sha256:ac6d515ece9c769ce8a0692fcb0d2ceeb5a71503a7db0cbfef0c255b5fb56b19", size = 19657, upload-time = "2024-11-26T14:34:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/f0b490a7bd7749aeed9e0dfca1f73e860a82ccb3ddb729591e4324c54367/libvalkey-4.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:125ff9fdba066a97e4bbdea69649184a5f5fcb832106bbaca81b80ff8dbee55c", size = 21461, upload-time = "2024-11-26T14:34:22.01Z" }, + { url = "https://files.pythonhosted.org/packages/39/7d/8dec58f9f2f0f4eb8b1b861a4ee5ef2c8e7b63a46f0ffbba274f5170125b/libvalkey-4.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:75528a996263e57880d12a5b1421d90f3b63d8f65e1229af02075079ba17be0a", size = 37210, upload-time = "2024-11-26T14:35:06.987Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/857376c3e0af4988d1b8ad13e8ee964dcfae9860e1917febd4f6b0819feb/libvalkey-4.0.1-pp310-pypy310_pp73-macosx_11_0_x86_64.whl", hash = "sha256:4ee4f42a117cc172286f3180fdd2db9d74e7d3f28f97466e29d69e7c126eb084", size = 39638, upload-time = "2024-11-26T14:35:08.209Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/9c0fcf578a56d860a9e056e67fbdff54b33952a88b63c384bc05b0cfe215/libvalkey-4.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb120643254a1b44fee2d07a9cb2eef80e65635ac6cd82af7c76b4c09941575e", size = 48760, upload-time = "2024-11-26T14:35:09.51Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/76fa39c775c33e356be5b7b687b85d7fea512e1fd314a17b75f143e85b67/libvalkey-4.0.1-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e62c472774dd38d9e5809d4bea07ec6309a088e2102ddce8beef5a3e0d68b76", size = 56262, upload-time = "2024-11-26T14:35:12.351Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/60131ef56e5152829c834c9c55cc5facb0a1988ba909c23d43f13bb65e0d/libvalkey-4.0.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a73ce687cda35c1a07c24bfb09b3b877c3d74837b59674cded032d03d12c1e55", size = 48920, upload-time = "2024-11-26T14:35:16.5Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/69d01a8e2ad5c94bd261784b8e8c2be3992b48492009baf56fcd16d1ab15/libvalkey-4.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1cd593e96281b80f5831292b9befe631a88415c68ad0748fe3d69101a093c501", size = 21366, upload-time = "2024-11-26T14:35:17.808Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -3857,6 +3933,7 @@ source = { editable = "instrumentation/opentelemetry-instrumentation-redis" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-redis-valkey-base" }, { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] @@ -3870,12 +3947,32 @@ instruments = [ requires-dist = [ { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-redis-valkey-base", editable = "instrumentation/opentelemetry-instrumentation-redis-valkey-base" }, { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, { name = "redis", marker = "extra == 'instruments'", specifier = ">=2.6" }, { name = "wrapt", specifier = ">=1.12.1" }, ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-redis-valkey-base" +source = { editable = "instrumentation/opentelemetry-instrumentation-redis-valkey-base" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, + { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, + { name = "wrapt", specifier = ">=1.0.0,<2.0.0" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-remoulade" source = { editable = "instrumentation/opentelemetry-instrumentation-remoulade" } @@ -4132,6 +4229,33 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-valkey" +source = { editable = "instrumentation/opentelemetry-instrumentation-valkey" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-redis-valkey-base" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "valkey", extra = ["libvalkey"] }, +] + +[package.metadata] +requires-dist = [ + { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, + { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-redis-valkey-base", editable = "instrumentation/opentelemetry-instrumentation-redis-valkey-base" }, + { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, + { name = "valkey", extras = ["libvalkey"], marker = "extra == 'instruments'", specifier = ">=6.1.0" }, + { name = "wrapt", specifier = ">=1.12.1" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-vertexai" source = { editable = "instrumentation-genai/opentelemetry-instrumentation-vertexai" } @@ -4288,6 +4412,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-tortoiseorm", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-urllib" }, { name = "opentelemetry-instrumentation-urllib3", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-valkey", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-vertexai", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-wsgi" }, { name = "opentelemetry-propagator-aws-xray" }, @@ -4362,6 +4487,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-tortoiseorm", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-tortoiseorm" }, { name = "opentelemetry-instrumentation-urllib", editable = "instrumentation/opentelemetry-instrumentation-urllib" }, { name = "opentelemetry-instrumentation-urllib3", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-urllib3" }, + { name = "opentelemetry-instrumentation-valkey", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-valkey" }, { name = "opentelemetry-instrumentation-vertexai", extras = ["instruments"], editable = "instrumentation-genai/opentelemetry-instrumentation-vertexai" }, { name = "opentelemetry-instrumentation-wsgi", editable = "instrumentation/opentelemetry-instrumentation-wsgi" }, { name = "opentelemetry-propagator-aws-xray", editable = "propagator/opentelemetry-propagator-aws-xray" }, @@ -6198,6 +6324,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, ] +[[package]] +name = "valkey" +version = "6.1.1" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/ee/7fd930fc712275084722ddd464a0ea296abdb997d2da396320507968daeb/valkey-6.1.1.tar.gz", hash = "sha256:5880792990c6c2b5eb604a5ed5f98f300880b6dd92d123819b66ed54bb259731", size = 4601372, upload-time = "2025-08-11T06:41:10.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/a2/252afa4da08c714460f49e943070f86a02931f99f886182765194002fe33/valkey-6.1.1-py3-none-any.whl", hash = "sha256:e2691541c6e1503b53c714ad9a35551ac9b7c0bbac93865f063dbc859a46de92", size = 259474, upload-time = "2025-08-11T06:41:08.769Z" }, +] + +[package.optional-dependencies] +libvalkey = [ + { name = "libvalkey" }, +] + [[package]] name = "venusian" version = "3.1.1"