From 3b374d94e0ca661cd7f2eee9ebe57847b6e4814b Mon Sep 17 00:00:00 2001 From: Samuel da Silva Date: Fri, 23 Oct 2026 13:50:31 +0200 Subject: [PATCH 1/2] distro: don't require grpc to be importable The operator image ships cp312 grpcio, so on Python 3.13 apps importing grpc fails and the distro failed at module load, even though the operator exports via http/protobuf. Make the grpc exporter imports optional and only set the grpc user agent channel option when grpc is available. Refs #653 --- src/elasticotel/distro/__init__.py | 34 +++++++++++++++++--------- tests/distro/test_distro.py | 39 +++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/src/elasticotel/distro/__init__.py b/src/elasticotel/distro/__init__.py index f6b1be82..dbc72e9c 100644 --- a/src/elasticotel/distro/__init__.py +++ b/src/elasticotel/distro/__init__.py @@ -27,10 +27,15 @@ OTEL_METRICS_EXPORTER, OTEL_TRACES_EXPORTER, ) -from opentelemetry.exporter.otlp.proto.grpc import _USER_AGENT_HEADER_VALUE -from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCOTLPLogExporter -from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as GRPCOTLPMetricExporter -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCOTLPSpanExporter + +try: + from opentelemetry.exporter.otlp.proto.grpc import _USER_AGENT_HEADER_VALUE + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCOTLPLogExporter + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as GRPCOTLPMetricExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCOTLPSpanExporter +except ImportError: + _USER_AGENT_HEADER_VALUE = None + from opentelemetry.exporter.otlp.proto.http import _OTLP_HTTP_HEADERS from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as HTTPOTLPLogExporter from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter as HTTPOTLPMetricExporter @@ -96,19 +101,24 @@ def _http_exporter_options(signal_headers_env_var: str) -> dict[str, dict[str, s class ElasticOpenTelemetryConfigurator(_OTelSDKConfigurator): def _configure(self, **kwargs): # override GRPC and HTTP user agent headers - otlp_grpc_exporter_options = { - "channel_options": ( - ("grpc.primary_user_agent", f"{EDOT_GRPC_USER_AGENT_HEADER_VALUE} {_USER_AGENT_HEADER_VALUE}"), - ) - } kwargs["exporter_args_map"] = { - GRPCOTLPLogExporter: otlp_grpc_exporter_options, - GRPCOTLPMetricExporter: otlp_grpc_exporter_options, - GRPCOTLPSpanExporter: otlp_grpc_exporter_options, HTTPOTLPLogExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_LOGS_HEADERS), HTTPOTLPMetricExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_METRICS_HEADERS), HTTPOTLPSpanExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_TRACES_HEADERS), } + if _USER_AGENT_HEADER_VALUE is not None: + otlp_grpc_exporter_options = { + "channel_options": ( + ("grpc.primary_user_agent", f"{EDOT_GRPC_USER_AGENT_HEADER_VALUE} {_USER_AGENT_HEADER_VALUE}"), + ) + } + kwargs["exporter_args_map"].update( + { + GRPCOTLPLogExporter: otlp_grpc_exporter_options, + GRPCOTLPMetricExporter: otlp_grpc_exporter_options, + GRPCOTLPSpanExporter: otlp_grpc_exporter_options, + } + ) super()._configure(**kwargs) diff --git a/tests/distro/test_distro.py b/tests/distro/test_distro.py index 4b228341..99e03f68 100644 --- a/tests/distro/test_distro.py +++ b/tests/distro/test_distro.py @@ -14,9 +14,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib import json import logging import os +import sys from unittest import TestCase, mock, skipIf from opentelemetry import trace @@ -26,6 +28,9 @@ OTEL_METRICS_EXPORTER, OTEL_TRACES_EXPORTER, ) +from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as HTTPOTLPLogExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter as HTTPOTLPMetricExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPOTLPSpanExporter from elasticotel.distro import ElasticOpenTelemetryConfigurator, ElasticOpenTelemetryDistro from elasticotel.distro import logger as distro_logger @@ -48,6 +53,7 @@ from opentelemetry.instrumentation.logging.handler import LoggingHandler except ImportError: LoggingHandler = None +from opentelemetry.sdk._configuration import _initialize_components from opentelemetry.sdk._logs import LoggingHandler as SDKLoggingHandler from opentelemetry.sdk.environment_variables import ( OTEL_EXPERIMENTAL_RESOURCE_DETECTORS, @@ -58,7 +64,7 @@ OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, ) -from opentelemetry.sdk.trace import _TracerConfig, sampling +from opentelemetry.sdk.trace import TracerProvider, _TracerConfig, sampling from opentelemetry.sdk.util.instrumentation import _scope_name_matches_glob from opentelemetry.util._once import Once @@ -430,6 +436,37 @@ def test_configurator_does_not_log_otel_env_vars_by_default(self, logger_mock): logger_mock.assert_not_called() + @mock.patch.dict("os.environ", {OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"}, clear=True) + def test_configurator_works_without_grpc(self): + # The k8s operator injects the distro from its own image into the application container, so the bundled + # grpcio may not be built for the application's Python version (e.g. cp312 wheels on Python 3.13) and + # grpc fails to import. The operator exports via http/protobuf so we must still configure the SDK. + import elasticotel.distro as distro_module + + grpc_modules = { + name: None + for name in [*sys.modules, "grpc"] + if name.split(".")[0] == "grpc" or name.startswith("opentelemetry.exporter.otlp.proto.grpc") + } + self.addCleanup(importlib.reload, distro_module) + with ( + mock.patch.dict(sys.modules, grpc_modules), + mock.patch( + "opentelemetry.sdk._configuration._initialize_components", wraps=_initialize_components + ) as initialize_components_mock, + ): + importlib.reload(distro_module) + distro_module.ElasticOpenTelemetryDistro()._configure() + distro_module.ElasticOpenTelemetryConfigurator()._configure() + + self.assertIsInstance(trace.get_tracer_provider(), TracerProvider) + exporter_args_map = initialize_components_mock.call_args.kwargs["exporter_args_map"] + self.assertEqual(set(exporter_args_map), {HTTPOTLPLogExporter, HTTPOTLPMetricExporter, HTTPOTLPSpanExporter}) + for exporter_args in exporter_args_map.values(): + self.assertTrue( + exporter_args["headers"]["User-Agent"].startswith(distro_module.EDOT_HTTP_USER_AGENT_HEADER_VALUE) + ) + class TestConfig(TestCase): def tearDown(self): From 3a85c2c5bb83fd9d1211392e810af5505f0ce371 Mon Sep 17 00:00:00 2001 From: Samuel da Silva Date: Fri, 23 Oct 2026 14:29:11 +0200 Subject: [PATCH 2/2] distro: import grpc exporters inside _configure Doing the optional grpc import in _configure with try/except/else lets pyright see the exporters are bound where they are used, so we don't need a None sentinel for _USER_AGENT_HEADER_VALUE or type: ignore comments. Annotate exporter_args_map so the grpc entries can be added. --- src/elasticotel/distro/__init__.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/elasticotel/distro/__init__.py b/src/elasticotel/distro/__init__.py index dbc72e9c..8f341f3d 100644 --- a/src/elasticotel/distro/__init__.py +++ b/src/elasticotel/distro/__init__.py @@ -18,6 +18,7 @@ import logging import os +from typing import Any from urllib.parse import urlparse, urlunparse from opentelemetry._opamp.agent import OpAMPAgent @@ -27,15 +28,6 @@ OTEL_METRICS_EXPORTER, OTEL_TRACES_EXPORTER, ) - -try: - from opentelemetry.exporter.otlp.proto.grpc import _USER_AGENT_HEADER_VALUE - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCOTLPLogExporter - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as GRPCOTLPMetricExporter - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCOTLPSpanExporter -except ImportError: - _USER_AGENT_HEADER_VALUE = None - from opentelemetry.exporter.otlp.proto.http import _OTLP_HTTP_HEADERS from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter as HTTPOTLPLogExporter from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter as HTTPOTLPMetricExporter @@ -101,24 +93,37 @@ def _http_exporter_options(signal_headers_env_var: str) -> dict[str, dict[str, s class ElasticOpenTelemetryConfigurator(_OTelSDKConfigurator): def _configure(self, **kwargs): # override GRPC and HTTP user agent headers - kwargs["exporter_args_map"] = { + exporter_args_map: dict[type, dict[str, Any]] = { HTTPOTLPLogExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_LOGS_HEADERS), HTTPOTLPMetricExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_METRICS_HEADERS), HTTPOTLPSpanExporter: _http_exporter_options(OTEL_EXPORTER_OTLP_TRACES_HEADERS), } - if _USER_AGENT_HEADER_VALUE is not None: + # When running in the k8s operator the bundled grpcio may not be built for the application's Python + # version (e.g. cp312 wheels on Python 3.13) and grpc fails to import. The operator exports via + # http/protobuf so we don't want to fail if we can't import the grpc exporters. + try: + from opentelemetry.exporter.otlp.proto.grpc import _USER_AGENT_HEADER_VALUE + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCOTLPLogExporter + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter as GRPCOTLPMetricExporter, + ) + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCOTLPSpanExporter + except ImportError: + pass + else: otlp_grpc_exporter_options = { "channel_options": ( ("grpc.primary_user_agent", f"{EDOT_GRPC_USER_AGENT_HEADER_VALUE} {_USER_AGENT_HEADER_VALUE}"), ) } - kwargs["exporter_args_map"].update( + exporter_args_map.update( { GRPCOTLPLogExporter: otlp_grpc_exporter_options, GRPCOTLPMetricExporter: otlp_grpc_exporter_options, GRPCOTLPSpanExporter: otlp_grpc_exporter_options, } ) + kwargs["exporter_args_map"] = exporter_args_map super()._configure(**kwargs)