diff --git a/src/elasticotel/distro/__init__.py b/src/elasticotel/distro/__init__.py index f6b1be82..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,10 +28,6 @@ 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 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 +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 - 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, + 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), } + # 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}"), + ) + } + 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) 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):