diff --git a/CHANGELOG.md b/CHANGELOG.md index d946d825d..05d3b3900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- `opentelemetry-instrumentation`: Fix dependency conflict detection when instrumented packages are not installed by moving check back to before instrumentors are loaded. Add "instruments-any" feature for instrumentations that target multiple packages. + ([#3610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610)) + +### Added + +- `opentelemetry-instrumentation-psycopg2` Utilize instruments-any functionality. + ([#3610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610)) +- `opentelemetry-instrumentation-kafka-python` Utilize instruments-any functionality. + ([#3610](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610)) + ## Version 1.35.0/0.56b0 (2025-07-11) ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e2283a66..1057ad4e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -325,10 +325,25 @@ Below is a checklist of things to be mindful of when implementing a new instrume ### Update supported instrumentation package versions - Navigate to the **instrumentation package directory:** - - Update **`pyproject.toml`** file by modifying _instruments_ entry in the `[project.optional-dependencies]` section with the new version constraint - - Update `_instruments` variable in instrumentation **`package.py`** file with the new version constraint + - Update **`pyproject.toml`** file by modifying `instruments` or `instruments-any` entry in the `[project.optional-dependencies]` section with the new version constraint + - Update `_instruments` or `_instruments_any` variable in instrumentation **`package.py`** file with the new version constraint - At the **root of the project directory**, run `tox -e generate` to regenerate necessary files +Please note that `instruments-any` is an optional field that can be used instead of or in addition to `instruments`. While `instruments` is a list of dependencies, _all_ of which are expected by the instrumentation, `instruments-any` is a list _any_ of which but not all are expected. For example, the following entry requires both `util` and `common` plus either `foo` or `bar` to be present for the instrumentation to occur: +``` +[project.optional-dependencies] +instruments = [ + "util ~= 1.0" + "common ~= 2.0" +] +instruments-any = [ + "foo ~= 3.0" + "bar ~= 4.0" +] +``` + + + If you're adding support for a new version of the instrumentation package, follow these additional steps: - At the **instrumentation package directory:** Add new test-requirements.txt file with the respective package version required for testing diff --git a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py index 408232842..523c165f8 100644 --- a/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py @@ -40,7 +40,6 @@ from opentelemetry.instrumentation.auto_instrumentation._load import ( ) from opentelemetry.instrumentation.dependencies import ( DependencyConflict, - DependencyConflictError, ) from opentelemetry.sdk.metrics.export import ( HistogramDataPoint, @@ -1102,40 +1101,34 @@ class TestAutoInstrumentation(TestBaseAutoFastAPI): [self._instrumentation_loaded_successfully_call()] ) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") - def test_instruments_with_old_fastapi_installed(self, mock_logger): # pylint: disable=no-self-use + def test_instruments_with_old_fastapi_installed( + self, mock_logger, mock_dep + ): # pylint: disable=no-self-use dependency_conflict = DependencyConflict("0.58", "0.57") mock_distro = Mock() - mock_distro.load_instrumentor.side_effect = DependencyConflictError( - dependency_conflict - ) + mock_dep.return_value = dependency_conflict _load_instrumentors(mock_distro) - self.assertEqual(len(mock_distro.load_instrumentor.call_args_list), 1) - (ep,) = mock_distro.load_instrumentor.call_args.args - self.assertEqual(ep.name, "fastapi") - assert ( - self._instrumentation_loaded_successfully_call() - not in mock_logger.debug.call_args_list - ) + mock_distro.load_instrumentor.assert_not_called() mock_logger.debug.assert_has_calls( [self._instrumentation_failed_to_load_call(dependency_conflict)] ) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") - def test_instruments_without_fastapi_installed(self, mock_logger): # pylint: disable=no-self-use + def test_instruments_without_fastapi_installed( + self, mock_logger, mock_dep + ): # pylint: disable=no-self-use dependency_conflict = DependencyConflict("0.58", None) mock_distro = Mock() - mock_distro.load_instrumentor.side_effect = DependencyConflictError( - dependency_conflict - ) + mock_dep.return_value = dependency_conflict _load_instrumentors(mock_distro) - self.assertEqual(len(mock_distro.load_instrumentor.call_args_list), 1) - (ep,) = mock_distro.load_instrumentor.call_args.args - self.assertEqual(ep.name, "fastapi") - assert ( - self._instrumentation_loaded_successfully_call() - not in mock_logger.debug.call_args_list - ) + mock_distro.load_instrumentor.assert_not_called() mock_logger.debug.assert_has_calls( [self._instrumentation_failed_to_load_call(dependency_conflict)] ) diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml b/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml index a552db42d..f9918e4d0 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ ] [project.optional-dependencies] -instruments = [ +instruments = [] +instruments-any = [ "kafka-python >= 2.0, < 3.0", "kafka-python-ng >= 2.0, < 3.0" ] diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/__init__.py b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/__init__.py index c32e786b3..f1ab77e4a 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/__init__.py @@ -91,7 +91,7 @@ from wrapt import wrap_function_wrapper from opentelemetry import trace from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.kafka.package import ( - _instruments, + _instruments_any, _instruments_kafka_python, _instruments_kafka_python_ng, ) @@ -123,7 +123,7 @@ class KafkaInstrumentor(BaseInstrumentor): except PackageNotFoundError: pass - return _instruments + return _instruments_any def _instrument(self, **kwargs): """Instruments the kafka module diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/package.py b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/package.py index 3a4a5e5de..682eeb474 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/package.py +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/src/opentelemetry/instrumentation/kafka/package.py @@ -16,4 +16,5 @@ _instruments_kafka_python = "kafka-python >= 2.0, < 3.0" _instruments_kafka_python_ng = "kafka-python-ng >= 2.0, < 3.0" -_instruments = (_instruments_kafka_python, _instruments_kafka_python_ng) +_instruments = () +_instruments_any = (_instruments_kafka_python, _instruments_kafka_python_ng) diff --git a/instrumentation/opentelemetry-instrumentation-kafka-python/tests/test_instrumentation.py b/instrumentation/opentelemetry-instrumentation-kafka-python/tests/test_instrumentation.py index 673f347da..13587b0c3 100644 --- a/instrumentation/opentelemetry-instrumentation-kafka-python/tests/test_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-kafka-python/tests/test_instrumentation.py @@ -20,7 +20,6 @@ from wrapt import BoundFunctionWrapper from opentelemetry.instrumentation.kafka import KafkaInstrumentor from opentelemetry.instrumentation.kafka.package import ( - _instruments, _instruments_kafka_python, _instruments_kafka_python_ng, ) @@ -134,4 +133,7 @@ class TestKafka(TestCase): call("kafka-python"), ], ) - self.assertEqual(package_to_instrument, _instruments) + self.assertEqual( + package_to_instrument, + (_instruments_kafka_python, _instruments_kafka_python_ng), + ) diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml b/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml index 825786526..ebf55f04d 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ ] [project.optional-dependencies] -instruments = [ +instruments = [] +instruments-any = [ "psycopg2 >= 2.7.3.1", "psycopg2-binary >= 2.7.3.1", ] diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py index 022c59f03..d90f894ef 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/__init__.py @@ -151,7 +151,7 @@ from psycopg2.sql import Composed # pylint: disable=no-name-in-module from opentelemetry.instrumentation import dbapi from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.psycopg2.package import ( - _instruments, + _instruments_any, _instruments_psycopg2, _instruments_psycopg2_binary, ) @@ -187,7 +187,7 @@ class Psycopg2Instrumentor(BaseInstrumentor): except PackageNotFoundError: pass - return _instruments + return _instruments_any def _instrument(self, **kwargs): """Integrate with PostgreSQL Psycopg library. diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/package.py b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/package.py index b1bf92901..1aef25432 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/package.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/src/opentelemetry/instrumentation/psycopg2/package.py @@ -16,7 +16,8 @@ _instruments_psycopg2 = "psycopg2 >= 2.7.3.1" _instruments_psycopg2_binary = "psycopg2-binary >= 2.7.3.1" -_instruments = ( +_instruments = () +_instruments_any = ( _instruments_psycopg2, _instruments_psycopg2_binary, ) diff --git a/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_instrumentation.py b/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_instrumentation.py index fb707730f..93a17d9ed 100644 --- a/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_instrumentation.py +++ b/instrumentation/opentelemetry-instrumentation-psycopg2/tests/test_psycopg2_instrumentation.py @@ -20,7 +20,6 @@ from opentelemetry.instrumentation.auto_instrumentation._load import ( ) from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor from opentelemetry.instrumentation.psycopg2.package import ( - _instruments, _instruments_psycopg2, _instruments_psycopg2_binary, ) @@ -130,19 +129,29 @@ class TestPsycopg2InstrumentationDependencies(TestCase): call("psycopg2-binary"), ], ) - self.assertEqual(package_to_instrument, _instruments) + self.assertEqual( + package_to_instrument, + ( + _instruments_psycopg2, + _instruments_psycopg2_binary, + ), + ) # This test is to verify that the auto instrumentation path # will auto instrument psycopg2 or psycopg2-binary is installed. # Note there is only one test here but it is run twice in tox # once with the psycopg2 package installed and once with # psycopg2-binary installed. + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") - def test_instruments_with_psycopg2_installed(self, mock_logger): + def test_instruments_with_psycopg2_installed(self, mock_logger, mock_dep): def _instrumentation_loaded_successfully_call(): return call("Instrumented %s", "psycopg2") mock_distro = Mock() + mock_dep.return_value = None mock_distro.load_instrumentor.return_value = None _load_instrumentors(mock_distro) self.assertEqual(len(mock_distro.load_instrumentor.call_args_list), 1) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/_load.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/_load.py index 4bbd95b41..fb4698a72 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/_load.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/auto_instrumentation/_load.py @@ -12,10 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +from functools import cached_property from logging import getLogger from os import environ -from opentelemetry.instrumentation.dependencies import DependencyConflictError +from opentelemetry.instrumentation.dependencies import ( + DependencyConflictError, + get_dist_dependency_conflicts, +) from opentelemetry.instrumentation.distro import BaseDistro, DefaultDistro from opentelemetry.instrumentation.environment_variables import ( OTEL_PYTHON_CONFIGURATOR, @@ -23,11 +27,36 @@ from opentelemetry.instrumentation.environment_variables import ( OTEL_PYTHON_DISTRO, ) from opentelemetry.instrumentation.version import __version__ -from opentelemetry.util._importlib_metadata import entry_points +from opentelemetry.util._importlib_metadata import ( + EntryPoint, + distributions, + entry_points, +) _logger = getLogger(__name__) +class _EntryPointDistFinder: + @cached_property + def _mapping(self): + return { + self._key_for(ep): dist + for dist in distributions() + for ep in dist.entry_points + } + + def dist_for(self, entry_point: EntryPoint): + dist = getattr(entry_point, "dist", None) + if dist: + return dist + + return self._mapping.get(self._key_for(entry_point)) + + @staticmethod + def _key_for(entry_point: EntryPoint): + return f"{entry_point.group}:{entry_point.name}:{entry_point.value}" + + def _load_distro() -> BaseDistro: distro_name = environ.get(OTEL_PYTHON_DISTRO, None) for entry_point in entry_points(group="opentelemetry_distro"): @@ -55,6 +84,7 @@ def _load_distro() -> BaseDistro: def _load_instrumentors(distro): package_to_exclude = environ.get(OTEL_PYTHON_DISABLED_INSTRUMENTATIONS, []) + entry_point_finder = _EntryPointDistFinder() if isinstance(package_to_exclude, str): package_to_exclude = package_to_exclude.split(",") # to handle users entering "requests , flask" or "requests, flask" with spaces @@ -71,11 +101,24 @@ def _load_instrumentors(distro): continue try: - distro.load_instrumentor( - entry_point, raise_exception_on_conflict=True - ) + entry_point_dist = entry_point_finder.dist_for(entry_point) + conflict = get_dist_dependency_conflicts(entry_point_dist) + if conflict: + _logger.debug( + "Skipping instrumentation %s: %s", + entry_point.name, + conflict, + ) + continue + + # tell instrumentation to not run dep checks again as we already did it above + distro.load_instrumentor(entry_point, skip_dep_check=True) _logger.debug("Instrumented %s", entry_point.name) except DependencyConflictError as exc: + # Dependency conflicts are generally caught from get_dist_dependency_conflicts + # returning a DependencyConflict. Keeping this error handling in case custom + # distro and instrumentor behavior raises a DependencyConflictError later. + # See https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3610 _logger.debug( "Skipping instrumentation %s: %s", entry_point.name, diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py index 080f3553b..2460f9534 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/dependencies.py @@ -20,6 +20,7 @@ from typing import Collection from packaging.requirements import InvalidRequirement, Requirement from opentelemetry.util._importlib_metadata import ( + Distribution, PackageNotFoundError, version, ) @@ -28,14 +29,44 @@ logger = getLogger(__name__) class DependencyConflict: + """Represents a dependency conflict in OpenTelemetry instrumentation. + + This class is used to track conflicts between required dependencies and the + actual installed packages. It supports two scenarios: + + 1. Standard conflicts where all dependencies are required + 2. Either/or conflicts where only one of a set of dependencies is required + + Attributes: + required: The required dependency specification that conflicts with what's installed. + found: The actual dependency that was found installed (if any). + required_any: Collection of dependency specifications where any one would satisfy + the requirement (for either/or scenarios). + found_any: Collection of actual dependencies found for either/or scenarios. + """ + required: str | None = None found: str | None = None + # The following fields are used when an instrumentation requires any of a set of dependencies rather than all. + required_any: Collection[str] = None + found_any: Collection[str] = None - def __init__(self, required: str | None, found: str | None = None): + def __init__( + self, + required: str | None = None, + found: str | None = None, + required_any: Collection[str] = None, + found_any: Collection[str] = None, + ): self.required = required self.found = found + # The following fields are used when an instrumentation requires any of a set of dependencies rather than all. + self.required_any = required_any + self.found_any = found_any def __str__(self): + if not self.required and (self.required_any or self.found_any): + return f'DependencyConflict: requested any of the following: "{self.required_any}" but found: "{self.found_any}"' return f'DependencyConflict: requested: "{self.required}" but found: "{self.found}"' @@ -49,8 +80,39 @@ class DependencyConflictError(Exception): return str(self.conflict) +def get_dist_dependency_conflicts( + dist: Distribution, +) -> DependencyConflict | None: + instrumentation_deps = [] + instrumentation_any_deps = [] + extra = "extra" + instruments = "instruments" + instruments_marker = {extra: instruments} + instruments_any = "instruments-any" + instruments_any_marker = {extra: instruments_any} + if dist.requires: + for dep in dist.requires: + if extra not in dep: + continue + if instruments not in dep and instruments_any not in dep: + continue + + req = Requirement(dep) + if req.marker.evaluate(instruments_marker): # type: ignore + instrumentation_deps.append(req) # type: ignore + if req.marker.evaluate(instruments_any_marker): # type: ignore + instrumentation_any_deps.append(req) # type: ignore + return get_dependency_conflicts( + instrumentation_deps, instrumentation_any_deps + ) # type: ignore + + def get_dependency_conflicts( - deps: Collection[str | Requirement], + deps: Collection[ + str | Requirement + ], # Dependencies all of which are required + deps_any: Collection[str | Requirement] + | None = None, # Dependencies any of which are required ) -> DependencyConflict | None: for dep in deps: if isinstance(dep, Requirement): @@ -73,4 +135,53 @@ def get_dependency_conflicts( if not req.specifier.contains(dist_version): return DependencyConflict(dep, f"{req.name} {dist_version}") + + # If all the dependencies in "instruments" are present, check "instruments-any" for conflicts. + if deps_any: + return _get_dependency_conflicts_any(deps_any) + return None + + +# This is a helper functions designed to ease reading and meet linting requirements. +def _get_dependency_conflicts_any( + deps_any: Collection[str | Requirement], +) -> DependencyConflict | None: + if not deps_any: + return None + is_dependency_conflict = True + required_any: Collection[str] = [] + found_any: Collection[str] = [] + for dep in deps_any: + if isinstance(dep, Requirement): + req = dep + else: + try: + req = Requirement(dep) + except InvalidRequirement as exc: + logger.warning( + 'error parsing dependency, reporting as a conflict: "%s" - %s', + dep, + exc, + ) + return DependencyConflict(dep) + + try: + dist_version = version(req.name) + except PackageNotFoundError: + required_any.append(str(dep)) + continue + + if req.specifier.contains(dist_version): + # Since only one of the instrumentation_any dependencies is required, there is no dependency conflict. + is_dependency_conflict = False + break + # If the version does not match, add it to the list of unfulfilled requirement options. + required_any.append(str(dep)) + found_any.append(f"{req.name} {dist_version}") + + if is_dependency_conflict: + return DependencyConflict( + required_any=required_any, + found_any=found_any, + ) return None diff --git a/opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py b/opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py index 37af27030..e1d98669f 100644 --- a/opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py +++ b/opentelemetry-instrumentation/tests/auto_instrumentation/test_load.py @@ -19,7 +19,6 @@ from unittest.mock import Mock, call, patch from opentelemetry.instrumentation.auto_instrumentation import _load from opentelemetry.instrumentation.dependencies import ( DependencyConflict, - DependencyConflictError, ) from opentelemetry.instrumentation.environment_variables import ( OTEL_PYTHON_CONFIGURATOR, @@ -27,6 +26,7 @@ from opentelemetry.instrumentation.environment_variables import ( OTEL_PYTHON_DISTRO, ) from opentelemetry.instrumentation.version import __version__ +from opentelemetry.util._importlib_metadata import EntryPoint, entry_points class TestLoad(TestCase): @@ -213,10 +213,13 @@ class TestLoad(TestCase): "os.environ", {OTEL_PYTHON_DISABLED_INSTRUMENTATIONS: " instr1 , instr3 "}, ) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch( "opentelemetry.instrumentation.auto_instrumentation._load.entry_points" ) - def test_load_instrumentors(self, iter_mock): + def test_load_instrumentors(self, iter_mock, mock_dep): # Mock opentelemetry_pre_instrument entry points # pylint: disable=too-many-locals pre_ep_mock1 = Mock() @@ -261,6 +264,8 @@ class TestLoad(TestCase): (ep_mock1, ep_mock2, ep_mock3, ep_mock4), (post_ep_mock1, post_ep_mock2), ] + # No dependency conflict + mock_dep.return_value = None _load._load_instrumentors(distro_mock) # All opentelemetry_pre_instrument entry points should be loaded pre_mock1.assert_called_once() @@ -269,8 +274,8 @@ class TestLoad(TestCase): # Only non-disabled instrumentations should be loaded distro_mock.load_instrumentor.assert_has_calls( [ - call(ep_mock2, raise_exception_on_conflict=True), - call(ep_mock4, raise_exception_on_conflict=True), + call(ep_mock2, skip_dep_check=True), + call(ep_mock4, skip_dep_check=True), ] ) self.assertEqual(distro_mock.load_instrumentor.call_count, 2) @@ -282,56 +287,71 @@ class TestLoad(TestCase): "os.environ", {OTEL_PYTHON_DISABLED_INSTRUMENTATIONS: " instr1 , instr3 "}, ) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") @patch( "opentelemetry.instrumentation.auto_instrumentation._load.entry_points" ) - def test_load_instrumentors_dep_conflict(self, iter_mock, mock_logger): # pylint: disable=no-self-use + def test_load_instrumentors_dep_conflict( + self, iter_mock, mock_logger, mock_dep + ): # pylint: disable=no-self-use ep_mock1 = Mock() - ep_mock1.name = "instr1" + ep_mock1.name = "instr1" # disabled ep_mock2 = Mock() ep_mock2.name = "instr2" ep_mock3 = Mock() - ep_mock3.name = "instr3" + ep_mock3.name = "instr3" # disabled ep_mock4 = Mock() - ep_mock4.name = "instr4" + ep_mock4.name = "instr4" # dependency conflict dependency_conflict = DependencyConflict("1.2.3", None) distro_mock = Mock() - distro_mock.load_instrumentor.side_effect = [ - None, - DependencyConflictError(dependency_conflict), - ] - # If a dependency conflict is raised, that instrumentation should not be loaded, but others still should. - # In this case, ep_mock4 will fail to load and ep_mock2 will succeed. (ep_mock1 and ep_mock3 are disabled) iter_mock.return_value = (ep_mock1, ep_mock2, ep_mock3, ep_mock4) + print((ep_mock1, ep_mock2, ep_mock3, ep_mock4)) + # If a dependency conflict is raised, that instrumentation should not be loaded, but others still should. + # In this case, ep_mock4 will not be loaded and ep_mock2 will succeed. (ep_mock1 and ep_mock3 are disabled) + mock_dep.side_effect = [None, dependency_conflict] _load._load_instrumentors(distro_mock) distro_mock.load_instrumentor.assert_has_calls( [ - call(ep_mock2, raise_exception_on_conflict=True), - call(ep_mock4, raise_exception_on_conflict=True), + call(ep_mock2, skip_dep_check=True), ] ) - assert distro_mock.load_instrumentor.call_count == 2 + distro_mock.load_instrumentor.assert_called_once() mock_logger.debug.assert_has_calls( [ + call( + "Instrumentation skipped for library %s", + ep_mock1.name, + ), + call("Instrumented %s", ep_mock2.name), + call( + "Instrumentation skipped for library %s", + ep_mock3.name, + ), self._instrumentation_failed_to_load_call( - ep_mock4.name, dependency_conflict - ) + ep_mock4.name, + dependency_conflict, + ), ] ) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") @patch( "opentelemetry.instrumentation.auto_instrumentation._load.entry_points" ) def test_load_instrumentors_import_error_does_not_stop_everything( - self, iter_mock, mock_logger + self, iter_mock, mock_logger, mock_dep ): ep_mock1 = Mock(name="instr1") ep_mock2 = Mock(name="instr2") @@ -345,13 +365,14 @@ class TestLoad(TestCase): (ep_mock1, ep_mock2), (), ] + mock_dep.return_value = None _load._load_instrumentors(distro_mock) distro_mock.load_instrumentor.assert_has_calls( [ - call(ep_mock1, raise_exception_on_conflict=True), - call(ep_mock2, raise_exception_on_conflict=True), + call(ep_mock1, skip_dep_check=True), + call(ep_mock2, skip_dep_check=True), ] ) self.assertEqual(distro_mock.load_instrumentor.call_count, 2) @@ -362,10 +383,13 @@ class TestLoad(TestCase): mock_logger.debug.assert_any_call("Instrumented %s", ep_mock2.name) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch( "opentelemetry.instrumentation.auto_instrumentation._load.entry_points" ) - def test_load_instrumentors_raises_exception(self, iter_mock): + def test_load_instrumentors_raises_exception(self, iter_mock, mock_dep): ep_mock1 = Mock(name="instr1") ep_mock2 = Mock(name="instr2") @@ -378,23 +402,27 @@ class TestLoad(TestCase): (ep_mock1, ep_mock2), (), ] + mock_dep.return_value = None with self.assertRaises(ValueError): _load._load_instrumentors(distro_mock) distro_mock.load_instrumentor.assert_has_calls( [ - call(ep_mock1, raise_exception_on_conflict=True), + call(ep_mock1, skip_dep_check=True), ] ) self.assertEqual(distro_mock.load_instrumentor.call_count, 1) + @patch( + "opentelemetry.instrumentation.auto_instrumentation._load.get_dist_dependency_conflicts" + ) @patch("opentelemetry.instrumentation.auto_instrumentation._load._logger") @patch( "opentelemetry.instrumentation.auto_instrumentation._load.entry_points" ) def test_load_instrumentors_module_not_found_error( - self, iter_mock, mock_logger + self, iter_mock, mock_logger, mock_dep ): ep_mock1 = Mock() ep_mock1.name = "instr1" @@ -404,6 +432,8 @@ class TestLoad(TestCase): distro_mock = Mock() + mock_dep.return_value = None + distro_mock.load_instrumentor.side_effect = [ ModuleNotFoundError("No module named 'fake_module'"), None, @@ -415,8 +445,8 @@ class TestLoad(TestCase): distro_mock.load_instrumentor.assert_has_calls( [ - call(ep_mock1, raise_exception_on_conflict=True), - call(ep_mock2, raise_exception_on_conflict=True), + call(ep_mock1, skip_dep_check=True), + call(ep_mock2, skip_dep_check=True), ] ) self.assertEqual(distro_mock.load_instrumentor.call_count, 2) @@ -434,3 +464,31 @@ class TestLoad(TestCase): _load._load_instrumentors(distro_mock) # this has no specific assert because it is run for every instrumentation self.assertTrue(distro_mock) + + def test_entry_point_dist_finder(self): + entry_point_finder = _load._EntryPointDistFinder() + self.assertTrue(entry_point_finder._mapping) + entry_point = list( + entry_points(group="opentelemetry_environment_variables") + )[0] + self.assertTrue(entry_point) + self.assertTrue(entry_point.dist) + + # this will not hit cache + entry_point_dist = entry_point_finder.dist_for(entry_point) + self.assertTrue(entry_point_dist) + # dist are not comparable so we are sure we are not hitting the cache + self.assertEqual(entry_point.dist, entry_point_dist) + + # this will hit cache + entry_point_without_dist = EntryPoint( + name=entry_point.name, + group=entry_point.group, + value=entry_point.value, + ) + self.assertIsNone(entry_point_without_dist.dist) + new_entry_point_dist = entry_point_finder.dist_for( + entry_point_without_dist + ) + # dist are not comparable, being truthy is enough + self.assertTrue(new_entry_point_dist) diff --git a/opentelemetry-instrumentation/tests/test_dependencies.py b/opentelemetry-instrumentation/tests/test_dependencies.py index a06d3923e..91bcbaeed 100644 --- a/opentelemetry-instrumentation/tests/test_dependencies.py +++ b/opentelemetry-instrumentation/tests/test_dependencies.py @@ -14,14 +14,21 @@ # pylint: disable=protected-access +from unittest.mock import patch + import pytest from packaging.requirements import Requirement from opentelemetry.instrumentation.dependencies import ( DependencyConflict, get_dependency_conflicts, + get_dist_dependency_conflicts, ) from opentelemetry.test.test_base import TestBase +from opentelemetry.util._importlib_metadata import ( + Distribution, + PackageNotFoundError, +) class TestDependencyConflicts(TestBase): @@ -62,3 +69,211 @@ class TestDependencyConflicts(TestBase): str(conflict), f'DependencyConflict: requested: "pytest == 5000" but found: "pytest {pytest.__version__}"', ) + + def test_get_dist_dependency_conflicts(self): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + return ['test-pkg ~= 1.0; extra == "instruments"'] + + dist = MockDistribution() + + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + 'DependencyConflict: requested: "test-pkg~=1.0; extra == "instruments"" but found: "None"', + ) + + def test_get_dist_dependency_conflicts_requires_none(self): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + # TODO: make another test for returning something with a blank list for both and and or + return None + + dist = MockDistribution() + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is None) + + @patch("opentelemetry.instrumentation.dependencies.version") + def test_get_dist_dependency_conflicts_any(self, version_mock): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + return [ + 'foo ~= 1.0; extra == "instruments-any"', + 'bar ~= 1.0; extra == "instruments-any"', + ] + + dist = MockDistribution() + + def version_side_effect(package_name): + if package_name == "foo": + raise PackageNotFoundError("foo not found") + if package_name == "bar": + return "1.0.0" + raise PackageNotFoundError(f"{package_name} not found") + + version_mock.side_effect = version_side_effect + conflict = get_dist_dependency_conflicts(dist) + self.assertIsNone(conflict) + + @patch("opentelemetry.instrumentation.dependencies.version") + def test_get_dist_dependency_conflicts_neither(self, version_mock): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + return [ + 'foo ~= 1.0; extra == "instruments-any"', + 'bar ~= 1.0; extra == "instruments-any"', + ] + + dist = MockDistribution() + # version_mock.side_effect = lambda x: "1.0.0" if x == "foo" else "2.0.0" + # version_mock("foo").return_value = "2.0.0" + version_mock.side_effect = PackageNotFoundError("not found") + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + '''DependencyConflict: requested any of the following: "['foo~=1.0; extra == "instruments-any"', 'bar~=1.0; extra == "instruments-any"']" but found: "[]"''', + ) + + # Tests when both "and" and "either" dependencies are specified and both pass. + @patch("opentelemetry.instrumentation.dependencies.version") + def test_get_dist_dependency_conflicts_any_and(self, version_mock): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + # This indicates the instrumentation requires (foo and (bar or baz))) + return [ + 'foo ~= 1.0; extra == "instruments"', + 'bar ~= 2.0; extra == "instruments-any"', + 'baz ~= 3.0; extra == "instruments-any"', + ] + + dist = MockDistribution() + + def version_side_effect(package_name): + if package_name == "foo": + return "1.2.0" + if package_name == "bar": + raise PackageNotFoundError("bar not found") + if package_name == "baz": + return "3.7.0" + raise PackageNotFoundError(f"{package_name} not found") + + version_mock.side_effect = version_side_effect + conflict = get_dist_dependency_conflicts(dist) + self.assertIsNone(conflict) + + # Tests when both "and" and "either" dependencies are specified but the "and" dependencies fail to resolve. + @patch("opentelemetry.instrumentation.dependencies.version") + def test_get_dist_dependency_conflicts_any_and_failed(self, version_mock): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + # This indicates the instrumentation requires (foo and (bar or baz))) + return [ + 'foo ~= 1.0; extra == "instruments"', + 'bar ~= 2.0; extra == "instruments-any"', + 'baz ~= 3.0; extra == "instruments-any"', + ] + + dist = MockDistribution() + + def version_side_effect(package_name): + if package_name == "foo": + raise PackageNotFoundError("foo not found") + if package_name == "bar": + raise PackageNotFoundError("bar not found") + if package_name == "baz": + return "3.7.0" + raise PackageNotFoundError(f"{package_name} not found") + + version_mock.side_effect = version_side_effect + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + 'DependencyConflict: requested: "foo~=1.0; extra == "instruments"" but found: "None"', + ) + + # Tests when both "and" and "either" dependencies are specified but the "either" dependencies fail to resolve. + @patch("opentelemetry.instrumentation.dependencies.version") + def test_get_dist_dependency_conflicts_and_any_failed(self, version_mock): + class MockDistribution(Distribution): + def locate_file(self, path): + pass + + def read_text(self, filename): + pass + + @property + def requires(self): + # This indicates the instrumentation requires (foo and (bar or baz))) + return [ + 'foo ~= 1.0; extra == "instruments"', + 'bar ~= 2.0; extra == "instruments-any"', + 'baz ~= 3.0; extra == "instruments-any"', + ] + + dist = MockDistribution() + + def version_side_effect(package_name): + if package_name == "foo": + return "1.7.0" + if package_name == "bar": + raise PackageNotFoundError("bar not found") + if package_name == "baz": + raise PackageNotFoundError("baz not found") + raise PackageNotFoundError(f"{package_name} not found") + + version_mock.side_effect = version_side_effect + conflict = get_dist_dependency_conflicts(dist) + self.assertTrue(conflict is not None) + self.assertTrue(isinstance(conflict, DependencyConflict)) + self.assertEqual( + str(conflict), + '''DependencyConflict: requested any of the following: "['bar~=2.0; extra == "instruments-any"', 'baz~=3.0; extra == "instruments-any"']" but found: "[]"''', + ) diff --git a/pyproject.toml b/pyproject.toml index 988fa6831..23b171953 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,12 +37,14 @@ dependencies = [ "opentelemetry-instrumentation-httpx[instruments]", "opentelemetry-instrumentation-jinja2[instruments]", "opentelemetry-instrumentation-kafka-python[instruments]", + "opentelemetry-instrumentation-kafka-python[instruments-any]", "opentelemetry-instrumentation-logging", "opentelemetry-instrumentation-mysql[instruments]", "opentelemetry-instrumentation-mysqlclient[instruments]", "opentelemetry-instrumentation-pika[instruments]", "opentelemetry-instrumentation-psycopg[instruments]", "opentelemetry-instrumentation-psycopg2[instruments]", + "opentelemetry-instrumentation-psycopg2[instruments-any]", "opentelemetry-instrumentation-pymemcache[instruments]", "opentelemetry-instrumentation-pymongo[instruments]", "opentelemetry-instrumentation-pymysql[instruments]", diff --git a/scripts/generate_instrumentation_bootstrap.py b/scripts/generate_instrumentation_bootstrap.py index c8bc0f936..3299a0529 100755 --- a/scripts/generate_instrumentation_bootstrap.py +++ b/scripts/generate_instrumentation_bootstrap.py @@ -84,7 +84,7 @@ def main(): pkg_name = pkg.get("name") if pkg_name in packages_to_exclude: continue - if not pkg["instruments"]: + if not pkg["instruments"] and not pkg["instruments-any"]: default_instrumentations.elts.append(ast.Str(pkg["requirement"])) for target_pkg in pkg["instruments"]: libraries.elts.append( @@ -93,6 +93,14 @@ def main(): values=[ast.Str(target_pkg), ast.Str(pkg["requirement"])], ) ) + # instruments-any is an optional field that can be used instead of or in addition to _instruments. While _instruments is a list of dependencies, all of which are expected by the instrumentation, instruments-any is a list any of which but not all are expected. + for target_pkg in pkg["instruments-any"]: + libraries.elts.append( + ast.Dict( + keys=[ast.Str("library"), ast.Str("instrumentation")], + values=[ast.Str(target_pkg), ast.Str(pkg["requirement"])], + ) + ) tree = ast.parse(_source_tmpl) tree.body[0].value = libraries diff --git a/scripts/generate_instrumentation_readme.py b/scripts/generate_instrumentation_readme.py index b4a712cc8..0148480ff 100755 --- a/scripts/generate_instrumentation_readme.py +++ b/scripts/generate_instrumentation_readme.py @@ -58,11 +58,16 @@ def main(base_instrumentation_path): with open(version_filename, encoding="utf-8") as fh: exec(fh.read(), pkg_info) - instruments = pkg_info["_instruments"] + instruments_and = pkg_info.get("_instruments", ()) + # _instruments_any is an optional field that can be used instead of or in addition to _instruments. While _instruments is a list of dependencies, all of which are expected by the instrumentation, _instruments_any is a list any of which but not all are expected. + instruments_any = pkg_info.get("_instruments_any", ()) supports_metrics = pkg_info.get("_supports_metrics") semconv_status = pkg_info.get("_semconv_status") - if not instruments: - instruments = (name,) + instruments_all = () + if not instruments_and and not instruments_any: + instruments_all = (name,) + else: + instruments_all = tuple(instruments_and + instruments_any) if not semconv_status: semconv_status = "development" @@ -70,7 +75,7 @@ def main(base_instrumentation_path): metric_column = "Yes" if supports_metrics else "No" table.append( - f"| [{instrumentation}](./{instrumentation}) | {','.join(instruments)} | {metric_column} | {semconv_status}" + f"| [{instrumentation}](./{instrumentation}) | {','.join(instruments_all)} | {metric_column} | {semconv_status}" ) with open( diff --git a/scripts/otel_packaging.py b/scripts/otel_packaging.py index 12e424cf2..a542b9c06 100644 --- a/scripts/otel_packaging.py +++ b/scripts/otel_packaging.py @@ -60,12 +60,17 @@ def get_instrumentation_packages( with open(pyproject_toml_path, "rb") as file: pyproject_toml = tomli.load(file) + optional_dependencies = pyproject_toml["project"][ + "optional-dependencies" + ] + instruments = optional_dependencies.get("instruments", []) + # instruments-any is an optional field that can be used instead of or in addition to instruments. While instruments is a list of dependencies, all of which are expected by the instrumentation, instruments-any is a list any of which but not all are expected. + instruments_any = optional_dependencies.get("instruments-any", []) instrumentation = { "name": pyproject_toml["project"]["name"], "version": version.strip(), - "instruments": pyproject_toml["project"]["optional-dependencies"][ - "instruments" - ], + "instruments": instruments, + "instruments-any": instruments_any, } if instrumentation["name"] in independent_packages: specifier = independent_packages[instrumentation["name"]] diff --git a/uv.lock b/uv.lock index c4210acc9..4df9f60ec 100644 --- a/uv.lock +++ b/uv.lock @@ -2872,20 +2872,20 @@ dependencies = [ ] [package.optional-dependencies] -instruments = [ +instruments-any = [ { name = "kafka-python" }, { name = "kafka-python-ng" }, ] [package.metadata] requires-dist = [ - { name = "kafka-python", marker = "extra == 'instruments'", specifier = ">=2.0,<3.0" }, - { name = "kafka-python-ng", marker = "extra == 'instruments'", specifier = ">=2.0,<3.0" }, + { name = "kafka-python", marker = "extra == 'instruments-any'", specifier = ">=2.0,<3.0" }, + { name = "kafka-python-ng", marker = "extra == 'instruments-any'", specifier = ">=2.0,<3.0" }, { 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" }, ] -provides-extras = ["instruments"] +provides-extras = ["instruments", "instruments-any"] [[package]] name = "opentelemetry-instrumentation-logging" @@ -3029,7 +3029,7 @@ dependencies = [ ] [package.optional-dependencies] -instruments = [ +instruments-any = [ { name = "psycopg2" }, { name = "psycopg2-binary" }, ] @@ -3039,10 +3039,10 @@ 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-dbapi", editable = "instrumentation/opentelemetry-instrumentation-dbapi" }, - { name = "psycopg2", marker = "extra == 'instruments'", specifier = ">=2.7.3.1" }, - { name = "psycopg2-binary", marker = "extra == 'instruments'", specifier = ">=2.7.3.1" }, + { name = "psycopg2", marker = "extra == 'instruments-any'", specifier = ">=2.7.3.1" }, + { name = "psycopg2-binary", marker = "extra == 'instruments-any'", specifier = ">=2.7.3.1" }, ] -provides-extras = ["instruments"] +provides-extras = ["instruments", "instruments-any"] [[package]] name = "opentelemetry-instrumentation-pymemcache" @@ -3548,14 +3548,14 @@ dependencies = [ { name = "opentelemetry-instrumentation-grpc", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-httpx", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-jinja2", extra = ["instruments"] }, - { name = "opentelemetry-instrumentation-kafka-python", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-kafka-python", extra = ["instruments-any"] }, { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-mysql", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-mysqlclient", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-openai-v2", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-pika", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-psycopg", extra = ["instruments"] }, - { name = "opentelemetry-instrumentation-psycopg2", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-psycopg2", extra = ["instruments-any"] }, { name = "opentelemetry-instrumentation-pymemcache", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-pymongo", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-pymysql", extra = ["instruments"] }, @@ -3613,6 +3613,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-httpx", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-httpx" }, { name = "opentelemetry-instrumentation-jinja2", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-jinja2" }, { name = "opentelemetry-instrumentation-kafka-python", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-kafka-python" }, + { name = "opentelemetry-instrumentation-kafka-python", extras = ["instruments-any"], editable = "instrumentation/opentelemetry-instrumentation-kafka-python" }, { name = "opentelemetry-instrumentation-logging", editable = "instrumentation/opentelemetry-instrumentation-logging" }, { name = "opentelemetry-instrumentation-mysql", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysql" }, { name = "opentelemetry-instrumentation-mysqlclient", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-mysqlclient" }, @@ -3620,6 +3621,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-pika", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pika" }, { name = "opentelemetry-instrumentation-psycopg", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-psycopg" }, { name = "opentelemetry-instrumentation-psycopg2", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-psycopg2" }, + { name = "opentelemetry-instrumentation-psycopg2", extras = ["instruments-any"], editable = "instrumentation/opentelemetry-instrumentation-psycopg2" }, { name = "opentelemetry-instrumentation-pymemcache", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pymemcache" }, { name = "opentelemetry-instrumentation-pymongo", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pymongo" }, { name = "opentelemetry-instrumentation-pymysql", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-pymysql" },