diff --git a/CHANGELOG.md b/CHANGELOG.md index ba135db4f8..07197c274f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4649](https://github.com/open-telemetry/opentelemetry-python/pull/4649)) - proto: relax protobuf version requirement to support v6 ([#4620](https://github.com/open-telemetry/opentelemetry-python/pull/4620)) +- Set expected User-Agent in HTTP headers for grpc OTLP exporter + ([#4658](https://github.com/open-telemetry/opentelemetry-python/pull/4658)) ## Version 1.34.0/0.55b0 (2025-06-04) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py index 31976a43da..12275ef481 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py @@ -73,4 +73,7 @@ from .version import __version__ _USER_AGENT_HEADER_VALUE = "OTel-OTLP-Exporter-Python/" + __version__ -_OTLP_GRPC_HEADERS = [("user-agent", _USER_AGENT_HEADER_VALUE)] +_OTLP_GRPC_CHANNEL_OPTIONS = [ + # this will appear in the http User-Agent header + ("grpc.primary_user_agent", _USER_AGENT_HEADER_VALUE) +] diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py index e66f9dbcca..70f3df444a 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py @@ -60,6 +60,7 @@ def __init__( ] = None, timeout: Optional[float] = None, compression: Optional[Compression] = None, + channel_options: Optional[TypingSequence[Tuple[str, str]]] = None, ): if insecure is None: insecure = environ.get(OTEL_EXPORTER_OTLP_LOGS_INSECURE) @@ -99,6 +100,7 @@ def __init__( "headers": headers, "timeout": timeout or environ_timeout, "compression": compression, + "channel_options": channel_options, } ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py index cb4a234e7e..c41f7219ae 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py @@ -51,7 +51,7 @@ _get_resource_data, ) from opentelemetry.exporter.otlp.proto.grpc import ( - _OTLP_GRPC_HEADERS, + _OTLP_GRPC_CHANNEL_OPTIONS, ) from opentelemetry.proto.common.v1.common_pb2 import ( # noqa: F401 AnyValue, @@ -196,6 +196,7 @@ class OTLPExporterMixin( headers: Headers to send when exporting timeout: Backend request timeout in seconds compression: gRPC compression method to use + channel_options: gRPC channel options """ def __init__( @@ -208,6 +209,7 @@ def __init__( ] = None, timeout: Optional[float] = None, compression: Optional[Compression] = None, + channel_options: Optional[TypingSequence[Tuple[str, str]]] = None, ): super().__init__() @@ -239,9 +241,21 @@ def __init__( elif isinstance(self._headers, dict): self._headers = tuple(self._headers.items()) if self._headers is None: - self._headers = tuple(_OTLP_GRPC_HEADERS) + self._headers = tuple() + + if channel_options: + # merge the default channel options with the one passed as parameter + overridden_options = { + opt_name for (opt_name, _) in channel_options + } + default_options = [ + (opt_name, opt_value) + for opt_name, opt_value in _OTLP_GRPC_CHANNEL_OPTIONS + if opt_name not in overridden_options + ] + self._channel_options = tuple(default_options) + channel_options else: - self._headers = tuple(self._headers) + tuple(_OTLP_GRPC_HEADERS) + self._channel_options = tuple(_OTLP_GRPC_CHANNEL_OPTIONS) self._timeout = timeout or float( environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, 10) @@ -258,6 +272,7 @@ def __init__( self._channel = insecure_channel( self._endpoint, compression=compression, + options=self._channel_options, ) else: credentials = _get_credentials( @@ -270,6 +285,7 @@ def __init__( self._endpoint, credentials, compression=compression, + options=self._channel_options, ) self._client = self._stub(self._channel) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py index dbb2a8e1de..d1bfa4de94 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py @@ -105,6 +105,7 @@ def __init__( | None = None, preferred_aggregation: dict[type, Aggregation] | None = None, max_export_batch_size: int | None = None, + channel_options: TypingSequence[Tuple[str, str]] | None = None, ): if insecure is None: insecure = environ.get(OTEL_EXPORTER_OTLP_METRICS_INSECURE) @@ -146,6 +147,7 @@ def __init__( headers=headers or environ.get(OTEL_EXPORTER_OTLP_METRICS_HEADERS), timeout=timeout or environ_timeout, compression=compression, + channel_options=channel_options, ) self._max_export_batch_size: int | None = max_export_batch_size diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py index 7aef65a2ca..0dbdb22bc5 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py @@ -93,6 +93,7 @@ def __init__( ] = None, timeout: Optional[float] = None, compression: Optional[Compression] = None, + channel_options: Optional[TypingSequence[Tuple[str, str]]] = None, ): if insecure is None: insecure = environ.get(OTEL_EXPORTER_OTLP_TRACES_INSECURE) @@ -131,6 +132,7 @@ def __init__( or environ.get(OTEL_EXPORTER_OTLP_TRACES_HEADERS), "timeout": timeout or environ_timeout, "compression": compression, + "channel_options": channel_options, } ) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py index d4407649b2..a8e015e821 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py @@ -17,7 +17,7 @@ import time from os.path import dirname from unittest import TestCase -from unittest.mock import patch +from unittest.mock import Mock, patch from google.protobuf.json_format import MessageToDict from grpc import ChannelCredentials, Compression @@ -266,6 +266,45 @@ def test_env_variables_with_only_certificate( mock_logger_error.assert_not_called() + @patch.dict( + "os.environ", + { + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "logs:4317", + OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE: THIS_DIR + + "/../fixtures/test.cert", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: " key1=value1,KEY2 = VALUE=2", + OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: "10", + OTEL_EXPORTER_OTLP_LOGS_COMPRESSION: "gzip", + }, + ) + @patch( + "opentelemetry.exporter.otlp.proto.grpc.exporter.OTLPExporterMixin.__init__" + ) + @patch("logging.Logger.error") + def test_kwargs_have_precedence_over_env_variables( + self, mock_logger_error, mock_exporter_mixin + ): + credentials_mock = Mock() + OTLPLogExporter( + endpoint="logs:4318", + headers=(("an", "header"),), + timeout=20, + credentials=credentials_mock, + compression=Compression.NoCompression, + channel_options=(("some", "options"),), + ) + + self.assertTrue(len(mock_exporter_mixin.call_args_list) == 1) + _, kwargs = mock_exporter_mixin.call_args_list[0] + self.assertEqual(kwargs["endpoint"], "logs:4318") + self.assertEqual(kwargs["headers"], (("an", "header"),)) + self.assertEqual(kwargs["timeout"], 20) + self.assertEqual(kwargs["compression"], Compression.NoCompression) + self.assertEqual(kwargs["credentials"], credentials_mock) + self.assertEqual(kwargs["channel_options"], (("some", "options"),)) + + mock_logger_error.assert_not_called() + def export_log_and_deserialize(self, log_data): # pylint: disable=protected-access translated_data = self.exporter._translate_data([log_data]) diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py index ae4944456e..595f1bac5b 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py @@ -268,6 +268,12 @@ def test_otlp_exporter_otlp_compression_unspecified( mock_insecure_channel.assert_called_once_with( "localhost:4317", compression=Compression.NoCompression, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ), ) # pylint: disable=no-self-use, disable=unused-argument @@ -291,7 +297,14 @@ def test_otlp_exporter_otlp_compression_envvar( """Just OTEL_EXPORTER_OTLP_COMPRESSION should work""" OTLPSpanExporterForTesting(insecure=True) mock_insecure_channel.assert_called_once_with( - "localhost:4317", compression=Compression.Gzip + "localhost:4317", + compression=Compression.Gzip, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ), ) def test_shutdown(self): @@ -457,7 +470,7 @@ def test_otlp_headers_from_env(self): # This ensures that there is no other header than standard user-agent. self.assertEqual( self.exporter._headers, - (("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),), + (), ) def test_permanent_failure(self): diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py index c7dc83ef32..4dd8a6b804 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py @@ -256,7 +256,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure): ( ("key1", "value1"), ("key2", "VALUE=2"), - ("user-agent", "OTel-OTLP-Exporter-Python/" + __version__), ), ) exporter = OTLPMetricExporter( @@ -268,7 +267,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure): ( ("key3", "value3"), ("key4", "value4"), - ("user-agent", "OTel-OTLP-Exporter-Python/" + __version__), ), ) @@ -299,6 +297,32 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel): mock_insecure_channel.assert_called_once_with( "localhost:4317", compression=Compression.NoCompression, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ), + ) + + # pylint: disable=no-self-use + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel") + def test_otlp_exporter_otlp_channel_options_kwarg( + self, mock_insecure_channel + ): + OTLPMetricExporter( + insecure=True, channel_options=(("some", "options"),) + ) + mock_insecure_channel.assert_called_once_with( + "localhost:4317", + compression=Compression.NoCompression, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ("some", "options"), + ), ) def test_split_metrics_data_many_data_points(self): diff --git a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py index 7a609c3486..59333849be 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py @@ -282,7 +282,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure): ( ("key1", "value1"), ("key2", "VALUE=2"), - ("user-agent", "OTel-OTLP-Exporter-Python/" + __version__), ), ) exporter = OTLPSpanExporter( @@ -294,7 +293,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure): ( ("key3", "value3"), ("key4", "value4"), - ("user-agent", "OTel-OTLP-Exporter-Python/" + __version__), ), ) exporter = OTLPSpanExporter( @@ -306,7 +304,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure): ( ("key5", "value5"), ("key6", "value6"), - ("user-agent", "OTel-OTLP-Exporter-Python/" + __version__), ), ) @@ -335,6 +332,12 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel): mock_insecure_channel.assert_called_once_with( "localhost:4317", compression=Compression.NoCompression, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ), ) # pylint: disable=no-self-use @@ -353,6 +356,30 @@ def test_otlp_exporter_otlp_compression_precendence( mock_insecure_channel.assert_called_once_with( "localhost:4317", compression=Compression.Gzip, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ), + ) + + # pylint: disable=no-self-use + @patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel") + def test_otlp_exporter_otlp_channel_options_kwarg( + self, mock_insecure_channel + ): + OTLPSpanExporter(insecure=True, channel_options=(("some", "options"),)) + mock_insecure_channel.assert_called_once_with( + "localhost:4317", + compression=Compression.NoCompression, + options=( + ( + "grpc.primary_user_agent", + "OTel-OTLP-Exporter-Python/" + __version__, + ), + ("some", "options"), + ), ) def test_translate_spans(self):