diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc9179da3..e5c32e8d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add new environment variables to the SDK `OTEL_PYTHON_EXPORTER_OTLP_{METRICS/TRACES/LOGS}_CREDENTIAL_PROVIDER` that can be used to +inject a `requests.Session` or `grpc.ChannelCredentials` object into OTLP exporters created during auto instrumentation [#4689](https://github.com/open-telemetry/opentelemetry-python/pull/4689). + ## Version 1.36.0/0.57b0 (2025-07-29) - Add missing Prometheus exporter documentation diff --git a/dev-requirements.txt b/dev-requirements.txt index cd203a1210..292ffbda48 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -17,4 +17,4 @@ psutil==5.9.6 GitPython==3.1.41 pre-commit==3.7.0; python_version >= '3.9' pre-commit==3.5.0; python_version < '3.9' -ruff==0.6.9 +ruff==0.6.9 \ No newline at end of file diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 60640739e3..a6a7c031e8 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -19,12 +19,23 @@ from __future__ import annotations +import inspect import logging import logging.config import os from abc import ABC, abstractmethod from os import environ -from typing import Any, Callable, Mapping, Sequence, Type, Union +from typing import ( + Any, + Callable, + Mapping, + MutableMapping, + Optional, + Sequence, + Type, + TypeVar, + Union, +) from typing_extensions import Literal @@ -46,6 +57,10 @@ OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, + OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER, + OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER, + OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER, + OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) @@ -64,6 +79,22 @@ from opentelemetry.trace import set_tracer_provider from opentelemetry.util._importlib_metadata import entry_points +try: + from grpc import ChannelCredentials + + _GRPC_IMPORTED = True +except ImportError: + _GRPC_IMPORTED = False + +try: + from requests import Session + + _REQUESTS_IMPORTED = True +except ImportError: + _REQUESTS_IMPORTED = False + +T = TypeVar("T") + _EXPORTER_OTLP = "otlp" _EXPORTER_OTLP_PROTO_GRPC = "otlp_proto_grpc" _EXPORTER_OTLP_PROTO_HTTP = "otlp_proto_http" @@ -79,6 +110,12 @@ "logs": OTEL_LOGS_EXPORTER, } +_EXPORTER_CREDENTIAL_BY_SIGNAL_TYPE = { + "traces": OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER, + "metrics": OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER, + "logs": OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER, +} + _PROTOCOL_ENV_BY_SIGNAL_TYPE = { "traces": OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, "metrics": OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, @@ -99,10 +136,42 @@ Type[MetricReader], Type[LogExporter], ], - Mapping[str, Any], + MutableMapping[str, Any], ] +def _load_credential_from_envvar( + environment_variable: str, +) -> Optional[ + tuple[ + Literal["credentials", "session"], + Union["ChannelCredentials", "Session"], + ] +]: + credential_env = os.getenv(environment_variable) + if credential_env: + credentials = _import_config_component( + credential_env, "opentelemetry_otlp_credential_provider" + )() + if _GRPC_IMPORTED and isinstance(credentials, ChannelCredentials): # type: ignore[reportPossiblyUnboundVariable] + return ("credentials", credentials) + + if _REQUESTS_IMPORTED and isinstance(credentials, Session): # type: ignore[reportPossiblyUnboundVariable] + return ("session", credentials) + raise RuntimeError( + f"{credential_env} is neither a grpc.ChannelCredentials or requests.Session type." + ) + return None + + +def _import_config_component( + selected_component: str, entry_point_name: str +) -> Type: + return _import_config_components([selected_component], entry_point_name)[ + 0 + ][1] + + def _import_config_components( selected_components: Sequence[str], entry_point_name: str ) -> list[tuple[str, Type]]: @@ -202,12 +271,50 @@ def _get_exporter_names( ] +def _init_exporter( + signal_type: Literal["traces", "metrics", "logs"], + exporter_args: MutableMapping[str, Any], + exporter_class: Type[T], + otlp_credential_param_for_all_signal_types: Optional[ + tuple[ + Literal["credentials", "session"], + Union["ChannelCredentials", "Session"], + ] + ] = None, +) -> T: + # Per signal type envvar should take precedence over all signal type env var. + otlp_credential_param = ( + _load_credential_from_envvar( + _EXPORTER_CREDENTIAL_BY_SIGNAL_TYPE[signal_type] + ) + or otlp_credential_param_for_all_signal_types + ) + if otlp_credential_param: + credential_key, credential = otlp_credential_param + # We only want to inject credentials into the appropriate OTLP HTTP // GRPC exporters. + if credential_key in inspect.signature( + exporter_class.__init__ + ).parameters and ( + "opentelemetry.exporter.otlp.proto.http" in str(exporter_class) + or "opentelemetry.exporter.otlp.proto.grpc" in str(exporter_class) + or "tests.test_configurator" in str(exporter_class) + ): + exporter_args[credential_key] = credential + return exporter_class(**exporter_args) + + def _init_tracing( exporters: dict[str, Type[SpanExporter]], id_generator: IdGenerator | None = None, sampler: Sampler | None = None, resource: Resource | None = None, exporter_args_map: ExporterArgsMap | None = None, + otlp_credential_param: Optional[ + tuple[ + Literal["credentials", "session"], + Union["ChannelCredentials", "Session"], + ] + ] = None, ): provider = TracerProvider( id_generator=id_generator, @@ -220,7 +327,14 @@ def _init_tracing( for _, exporter_class in exporters.items(): exporter_args = exporter_args_map.get(exporter_class, {}) provider.add_span_processor( - BatchSpanProcessor(exporter_class(**exporter_args)) + BatchSpanProcessor( + _init_exporter( + "traces", + exporter_args, + exporter_class, + otlp_credential_param, + ) + ) ) @@ -230,6 +344,12 @@ def _init_metrics( ], resource: Resource | None = None, exporter_args_map: ExporterArgsMap | None = None, + otlp_credential_param: Optional[ + tuple[ + Literal["credentials", "session"], + Union["ChannelCredentials", "Session"], + ] + ] = None, ): metric_readers = [] @@ -241,7 +361,12 @@ def _init_metrics( else: metric_readers.append( PeriodicExportingMetricReader( - exporter_or_reader_class(**exporter_args) + _init_exporter( + "metrics", + exporter_args, + exporter_or_reader_class, + otlp_credential_param, + ) ) ) @@ -254,6 +379,12 @@ def _init_logging( resource: Resource | None = None, setup_logging_handler: bool = True, exporter_args_map: ExporterArgsMap | None = None, + otlp_credential_param: Optional[ + tuple[ + Literal["credentials", "session"], + Union["ChannelCredentials", "Session"], + ] + ] = None, ): provider = LoggerProvider(resource=resource) set_logger_provider(provider) @@ -262,7 +393,14 @@ def _init_logging( for _, exporter_class in exporters.items(): exporter_args = exporter_args_map.get(exporter_class, {}) provider.add_log_record_processor( - BatchLogRecordProcessor(exporter_class(**exporter_args)) + BatchLogRecordProcessor( + _init_exporter( + "logs", + exporter_args, + exporter_class, + otlp_credential_param, + ) + ) ) event_logger_provider = EventLoggerProvider(logger_provider=provider) @@ -406,7 +544,7 @@ def _import_id_generator(id_generator_name: str) -> IdGenerator: raise RuntimeError(f"{id_generator_name} is not an IdGenerator") -def _initialize_components( +def _initialize_components( # pylint: disable=too-many-locals auto_instrumentation_version: str | None = None, trace_exporter_names: list[str] | None = None, metric_exporter_names: list[str] | None = None, @@ -445,15 +583,22 @@ def _initialize_components( # from the env variable else defaults to "unknown_service" resource = Resource.create(resource_attributes) + otlp_credential_param = _load_credential_from_envvar( + OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER + ) _init_tracing( exporters=span_exporters, id_generator=id_generator, sampler=sampler, resource=resource, + otlp_credential_param=otlp_credential_param, exporter_args_map=exporter_args_map, ) _init_metrics( - metric_exporters, resource, exporter_args_map=exporter_args_map + metric_exporters, + resource, + otlp_credential_param=otlp_credential_param, + exporter_args_map=exporter_args_map, ) if setup_logging_handler is None: setup_logging_handler = ( @@ -468,6 +613,7 @@ def _initialize_components( log_exporters, resource, setup_logging_handler, + otlp_credential_param=otlp_credential_param, exporter_args_map=exporter_args_map, ) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 23b634fcd8..5874bc1a7e 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -394,6 +394,43 @@ A scheme of https indicates a secure connection and takes precedence over this configuration setting. """ +OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER = ( + "OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER" +) +""" +.. envvar:: OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER + +The :envvar:`OTEL_PYTHON_EXPORTER_OTLP_LOGS_CREDENTIAL_PROVIDER` provides either ChannelCredentials for grpc OTLP Log exporters, +or request.Session for HTTP Log exporters. +""" +OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER = ( + "OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER" +) +""" +.. envvar:: OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER + +The :envvar:`OTEL_PYTHON_EXPORTER_OTLP_CREDENTIAL_PROVIDER` provides either ChannelCredentials for all grpc OTLP exporters, +or request.Session for HTTP exporters. +""" +OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER = ( + "OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER" +) +""" +.. envvar:: OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER + +The :envvar:`OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER` provides either ChannelCredentials for grpc OTLP Span exporters, +or request.Session for HTTP Span exporters. +""" +OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER = ( + "OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER" +) +""" +.. envvar:: OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER + +The :envvar:`OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER` provides either ChannelCredentials for grpc OTLP Metric exporters, +or request.Session for HTTP Metric exporters. +""" + OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE = "OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE" """ .. envvar:: OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE diff --git a/opentelemetry-sdk/test-requirements.txt b/opentelemetry-sdk/test-requirements.txt index 859a2196e1..a52e97a7f5 100644 --- a/opentelemetry-sdk/test-requirements.txt +++ b/opentelemetry-sdk/test-requirements.txt @@ -9,6 +9,8 @@ py-cpuinfo==9.0.0 pytest==7.4.4 tomli==2.0.1 typing_extensions==4.10.0 +grpcio==1.66.2 +requests==2.32.3 wrapt==1.16.0 zipp==3.19.2 -e tests/opentelemetry-test-utils diff --git a/opentelemetry-sdk/tests/test_configurator.py b/opentelemetry-sdk/tests/test_configurator.py index 6e9221b124..35154dcdc3 100644 --- a/opentelemetry-sdk/tests/test_configurator.py +++ b/opentelemetry-sdk/tests/test_configurator.py @@ -23,7 +23,9 @@ from unittest import TestCase, mock from unittest.mock import Mock, patch +from grpc import ChannelCredentials from pytest import raises +from requests import Session from opentelemetry import trace from opentelemetry.context import Context @@ -39,6 +41,7 @@ _import_exporters, _import_id_generator, _import_sampler, + _init_exporter, _init_logging, _init_metrics, _init_tracing, @@ -49,6 +52,8 @@ from opentelemetry.sdk._logs._internal.export import LogExporter from opentelemetry.sdk._logs.export import ConsoleLogExporter from opentelemetry.sdk.environment_variables import ( + OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER, + OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) @@ -179,7 +184,14 @@ def shutdown(self, timeout_millis: float = 30_000, **kwargs) -> None: class DummyOTLPMetricExporter: - def __init__(self, compression: str | None = None, *args, **kwargs): + def __init__( + self, + compression: str | None = None, + session: Session | None = None, + *args, + **kwargs, + ): + self.session = session self.export_called = False self.compression = compression @@ -204,8 +216,15 @@ def shutdown(self): class OTLPSpanExporter: - def __init__(self, compression: str | None = None, *args, **kwargs): + def __init__( + self, + compression: str | None = None, + credentials: ChannelCredentials | None = None, + *args, + **kwargs, + ): self.compression = compression + self.credentials = credentials class DummyOTLPLogExporter(LogExporter): @@ -408,6 +427,87 @@ def test_trace_init_custom_id_generator(self, mock_entry_points): provider = self.set_provider_mock.call_args[0][0] self.assertIsInstance(provider.id_generator, CustomIdGenerator) + @patch.dict( + environ, + { + OTEL_PYTHON_EXPORTER_OTLP_METRICS_CREDENTIAL_PROVIDER: "custom_session" + }, + ) + @patch("opentelemetry.sdk._configuration.entry_points") + def test_that_session_gets_passed_to_exporter(self, mock_entry_points): + # Should not be used, metric specific version should override. + session_for_metrics_only = Session() + session_for_all_signals = Session() + + def f(): + return session_for_metrics_only + + mock_entry_points.configure_mock( + return_value=[IterEntryPoint("custom_session", f)] + ) + exporter = _init_exporter( + "metrics", + {}, + DummyOTLPMetricExporter, + otlp_credential_param_for_all_signal_types=( + "session", + session_for_all_signals, + ), + ) + assert exporter.session is session_for_metrics_only + assert exporter.session is not session_for_all_signals + + @patch.dict( + environ, + { + OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER: "custom_credential" + }, + ) + @patch("opentelemetry.sdk._configuration.entry_points") + def test_that_credential_gets_passed_to_exporter(self, mock_entry_points): + # Should not be used, trace specific version should override. + credential_for_traces_only = ChannelCredentials(None) + credential_for_all_signals = ChannelCredentials(None) + + def f(): + return credential_for_traces_only + + mock_entry_points.configure_mock( + return_value=[IterEntryPoint("custom_credential", f)] + ) + exporter = _init_exporter( + "traces", + {}, + OTLPSpanExporter, + otlp_credential_param_for_all_signal_types=credential_for_all_signals, + ) + assert exporter.credentials is credential_for_traces_only + assert exporter.credentials is not credential_for_all_signals + + @patch.dict( + environ, + { + OTEL_PYTHON_EXPORTER_OTLP_TRACES_CREDENTIAL_PROVIDER: "custom_credential" + }, + ) + @patch("opentelemetry.sdk._configuration.entry_points") + def test_that_invalid_credential_type_raises_exception( + self, mock_entry_points + ): + def f(): + # Entry point must return a grpc.ChannelCredential or requests.Session. + return 32 + + mock_entry_points.configure_mock( + return_value=[IterEntryPoint("custom_credential", f)] + ) + with raises(RuntimeError): + _init_exporter( + "traces", + {}, + OTLPSpanExporter, + ) + @patch.dict( "os.environ", {OTEL_TRACES_SAMPLER: "non_existent_entry_point"} ) @@ -738,7 +838,11 @@ def test_logging_init_disable_default(self, logging_mock, tracing_mock): _initialize_components(auto_instrumentation_version="auto-version") self.assertEqual(tracing_mock.call_count, 1) logging_mock.assert_called_once_with( - mock.ANY, mock.ANY, False, exporter_args_map=None + mock.ANY, + mock.ANY, + False, + otlp_credential_param=None, + exporter_args_map=None, ) @patch.dict( @@ -754,7 +858,11 @@ def test_logging_init_enable_env(self, logging_mock, tracing_mock): with self.assertLogs(level=WARNING): _initialize_components(auto_instrumentation_version="auto-version") logging_mock.assert_called_once_with( - mock.ANY, mock.ANY, True, exporter_args_map=None + mock.ANY, + mock.ANY, + True, + otlp_credential_param=None, + exporter_args_map=None, ) self.assertEqual(tracing_mock.call_count, 1) @@ -872,17 +980,20 @@ def test_initialize_components_kwargs( id_generator="TEST_GENERATOR", sampler="TEST_SAMPLER", resource="TEST_RESOURCE", + otlp_credential_param=None, exporter_args_map={1: {"compression": "gzip"}}, ) metrics_mock.assert_called_once_with( "TEST_METRICS_EXPORTERS_DICT", "TEST_RESOURCE", + otlp_credential_param=None, exporter_args_map={1: {"compression": "gzip"}}, ) logging_mock.assert_called_once_with( "TEST_LOG_EXPORTERS_DICT", "TEST_RESOURCE", True, + otlp_credential_param=None, exporter_args_map={1: {"compression": "gzip"}}, ) diff --git a/tox.ini b/tox.ini index b2b1dae85e..27171eb0b7 100644 --- a/tox.ini +++ b/tox.ini @@ -342,11 +342,14 @@ deps = -c {toxinidir}/dev-requirements.txt pyright psutil + requests + grpcio -e {toxinidir}/opentelemetry-api -e {toxinidir}/opentelemetry-semantic-conventions -e {toxinidir}/opentelemetry-sdk -e {toxinidir}/tests/opentelemetry-test-utils commands = + pip freeze pyright --version pyright