Skip to content

Commit 71df82b

Browse files
authored
opentelemetry-exporter-otlp-proto-grpc: set grpc user agent properly (#4658)
* opentelemetry-exporter-otlp-proto-grpc: set user agent properly It looks like metadata is ignored and instead we should set the grpc.primary_user_agent channel option instead. User-agent will change from: grpc-python/1.71.0 grpc-c/46.0.0 (linux; chttp2) to: OTel-OTLP-Exporter-Python/1.34.1 grpc-python/1.71.0 grpc-c/46.0.0 (linux; chttp2) * Add Changelog * Remove default metadata as user-agent there is overriden by grpc itself * Merge passed channel options with the defaults
1 parent 43341d7 commit 71df82b

File tree

10 files changed

+142
-12
lines changed

10 files changed

+142
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
([#4649](https://github.com/open-telemetry/opentelemetry-python/pull/4649))
3535
- proto: relax protobuf version requirement to support v6
3636
([#4620](https://github.com/open-telemetry/opentelemetry-python/pull/4620))
37+
- Set expected User-Agent in HTTP headers for grpc OTLP exporter
38+
([#4658](https://github.com/open-telemetry/opentelemetry-python/pull/4658))
3739

3840
## Version 1.34.0/0.55b0 (2025-06-04)
3941

exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,7 @@
7373
from .version import __version__
7474

7575
_USER_AGENT_HEADER_VALUE = "OTel-OTLP-Exporter-Python/" + __version__
76-
_OTLP_GRPC_HEADERS = [("user-agent", _USER_AGENT_HEADER_VALUE)]
76+
_OTLP_GRPC_CHANNEL_OPTIONS = [
77+
# this will appear in the http User-Agent header
78+
("grpc.primary_user_agent", _USER_AGENT_HEADER_VALUE)
79+
]

exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/_log_exporter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
] = None,
6161
timeout: Optional[float] = None,
6262
compression: Optional[Compression] = None,
63+
channel_options: Optional[TypingSequence[Tuple[str, str]]] = None,
6364
):
6465
if insecure is None:
6566
insecure = environ.get(OTEL_EXPORTER_OTLP_LOGS_INSECURE)
@@ -99,6 +100,7 @@ def __init__(
99100
"headers": headers,
100101
"timeout": timeout or environ_timeout,
101102
"compression": compression,
103+
"channel_options": channel_options,
102104
}
103105
)
104106

exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
_get_resource_data,
5252
)
5353
from opentelemetry.exporter.otlp.proto.grpc import (
54-
_OTLP_GRPC_HEADERS,
54+
_OTLP_GRPC_CHANNEL_OPTIONS,
5555
)
5656
from opentelemetry.proto.common.v1.common_pb2 import ( # noqa: F401
5757
AnyValue,
@@ -196,6 +196,7 @@ class OTLPExporterMixin(
196196
headers: Headers to send when exporting
197197
timeout: Backend request timeout in seconds
198198
compression: gRPC compression method to use
199+
channel_options: gRPC channel options
199200
"""
200201

201202
def __init__(
@@ -208,6 +209,7 @@ def __init__(
208209
] = None,
209210
timeout: Optional[float] = None,
210211
compression: Optional[Compression] = None,
212+
channel_options: Optional[TypingSequence[Tuple[str, str]]] = None,
211213
):
212214
super().__init__()
213215

@@ -239,9 +241,21 @@ def __init__(
239241
elif isinstance(self._headers, dict):
240242
self._headers = tuple(self._headers.items())
241243
if self._headers is None:
242-
self._headers = tuple(_OTLP_GRPC_HEADERS)
244+
self._headers = tuple()
245+
246+
if channel_options:
247+
# merge the default channel options with the one passed as parameter
248+
overridden_options = {
249+
opt_name for (opt_name, _) in channel_options
250+
}
251+
default_options = [
252+
(opt_name, opt_value)
253+
for opt_name, opt_value in _OTLP_GRPC_CHANNEL_OPTIONS
254+
if opt_name not in overridden_options
255+
]
256+
self._channel_options = tuple(default_options) + channel_options
243257
else:
244-
self._headers = tuple(self._headers) + tuple(_OTLP_GRPC_HEADERS)
258+
self._channel_options = tuple(_OTLP_GRPC_CHANNEL_OPTIONS)
245259

246260
self._timeout = timeout or float(
247261
environ.get(OTEL_EXPORTER_OTLP_TIMEOUT, 10)
@@ -258,6 +272,7 @@ def __init__(
258272
self._channel = insecure_channel(
259273
self._endpoint,
260274
compression=compression,
275+
options=self._channel_options,
261276
)
262277
else:
263278
credentials = _get_credentials(
@@ -270,6 +285,7 @@ def __init__(
270285
self._endpoint,
271286
credentials,
272287
compression=compression,
288+
options=self._channel_options,
273289
)
274290
self._client = self._stub(self._channel)
275291

exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/metric_exporter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def __init__(
105105
| None = None,
106106
preferred_aggregation: dict[type, Aggregation] | None = None,
107107
max_export_batch_size: int | None = None,
108+
channel_options: TypingSequence[Tuple[str, str]] | None = None,
108109
):
109110
if insecure is None:
110111
insecure = environ.get(OTEL_EXPORTER_OTLP_METRICS_INSECURE)
@@ -146,6 +147,7 @@ def __init__(
146147
headers=headers or environ.get(OTEL_EXPORTER_OTLP_METRICS_HEADERS),
147148
timeout=timeout or environ_timeout,
148149
compression=compression,
150+
channel_options=channel_options,
149151
)
150152

151153
self._max_export_batch_size: int | None = max_export_batch_size

exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/trace_exporter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def __init__(
9393
] = None,
9494
timeout: Optional[float] = None,
9595
compression: Optional[Compression] = None,
96+
channel_options: Optional[TypingSequence[Tuple[str, str]]] = None,
9697
):
9798
if insecure is None:
9899
insecure = environ.get(OTEL_EXPORTER_OTLP_TRACES_INSECURE)
@@ -131,6 +132,7 @@ def __init__(
131132
or environ.get(OTEL_EXPORTER_OTLP_TRACES_HEADERS),
132133
"timeout": timeout or environ_timeout,
133134
"compression": compression,
135+
"channel_options": channel_options,
134136
}
135137
)
136138

exporter/opentelemetry-exporter-otlp-proto-grpc/tests/logs/test_otlp_logs_exporter.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import time
1818
from os.path import dirname
1919
from unittest import TestCase
20-
from unittest.mock import patch
20+
from unittest.mock import Mock, patch
2121

2222
from google.protobuf.json_format import MessageToDict
2323
from grpc import ChannelCredentials, Compression
@@ -266,6 +266,45 @@ def test_env_variables_with_only_certificate(
266266

267267
mock_logger_error.assert_not_called()
268268

269+
@patch.dict(
270+
"os.environ",
271+
{
272+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "logs:4317",
273+
OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE: THIS_DIR
274+
+ "/../fixtures/test.cert",
275+
OTEL_EXPORTER_OTLP_LOGS_HEADERS: " key1=value1,KEY2 = VALUE=2",
276+
OTEL_EXPORTER_OTLP_LOGS_TIMEOUT: "10",
277+
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION: "gzip",
278+
},
279+
)
280+
@patch(
281+
"opentelemetry.exporter.otlp.proto.grpc.exporter.OTLPExporterMixin.__init__"
282+
)
283+
@patch("logging.Logger.error")
284+
def test_kwargs_have_precedence_over_env_variables(
285+
self, mock_logger_error, mock_exporter_mixin
286+
):
287+
credentials_mock = Mock()
288+
OTLPLogExporter(
289+
endpoint="logs:4318",
290+
headers=(("an", "header"),),
291+
timeout=20,
292+
credentials=credentials_mock,
293+
compression=Compression.NoCompression,
294+
channel_options=(("some", "options"),),
295+
)
296+
297+
self.assertTrue(len(mock_exporter_mixin.call_args_list) == 1)
298+
_, kwargs = mock_exporter_mixin.call_args_list[0]
299+
self.assertEqual(kwargs["endpoint"], "logs:4318")
300+
self.assertEqual(kwargs["headers"], (("an", "header"),))
301+
self.assertEqual(kwargs["timeout"], 20)
302+
self.assertEqual(kwargs["compression"], Compression.NoCompression)
303+
self.assertEqual(kwargs["credentials"], credentials_mock)
304+
self.assertEqual(kwargs["channel_options"], (("some", "options"),))
305+
306+
mock_logger_error.assert_not_called()
307+
269308
def export_log_and_deserialize(self, log_data):
270309
# pylint: disable=protected-access
271310
translated_data = self.exporter._translate_data([log_data])

exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_exporter_mixin.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ def test_otlp_exporter_otlp_compression_unspecified(
268268
mock_insecure_channel.assert_called_once_with(
269269
"localhost:4317",
270270
compression=Compression.NoCompression,
271+
options=(
272+
(
273+
"grpc.primary_user_agent",
274+
"OTel-OTLP-Exporter-Python/" + __version__,
275+
),
276+
),
271277
)
272278

273279
# pylint: disable=no-self-use, disable=unused-argument
@@ -291,7 +297,14 @@ def test_otlp_exporter_otlp_compression_envvar(
291297
"""Just OTEL_EXPORTER_OTLP_COMPRESSION should work"""
292298
OTLPSpanExporterForTesting(insecure=True)
293299
mock_insecure_channel.assert_called_once_with(
294-
"localhost:4317", compression=Compression.Gzip
300+
"localhost:4317",
301+
compression=Compression.Gzip,
302+
options=(
303+
(
304+
"grpc.primary_user_agent",
305+
"OTel-OTLP-Exporter-Python/" + __version__,
306+
),
307+
),
295308
)
296309

297310
def test_shutdown(self):
@@ -457,7 +470,7 @@ def test_otlp_headers_from_env(self):
457470
# This ensures that there is no other header than standard user-agent.
458471
self.assertEqual(
459472
self.exporter._headers,
460-
(("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),),
473+
(),
461474
)
462475

463476
def test_permanent_failure(self):

exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_metrics_exporter.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure):
256256
(
257257
("key1", "value1"),
258258
("key2", "VALUE=2"),
259-
("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),
260259
),
261260
)
262261
exporter = OTLPMetricExporter(
@@ -268,7 +267,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure):
268267
(
269268
("key3", "value3"),
270269
("key4", "value4"),
271-
("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),
272270
),
273271
)
274272

@@ -299,6 +297,32 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel):
299297
mock_insecure_channel.assert_called_once_with(
300298
"localhost:4317",
301299
compression=Compression.NoCompression,
300+
options=(
301+
(
302+
"grpc.primary_user_agent",
303+
"OTel-OTLP-Exporter-Python/" + __version__,
304+
),
305+
),
306+
)
307+
308+
# pylint: disable=no-self-use
309+
@patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
310+
def test_otlp_exporter_otlp_channel_options_kwarg(
311+
self, mock_insecure_channel
312+
):
313+
OTLPMetricExporter(
314+
insecure=True, channel_options=(("some", "options"),)
315+
)
316+
mock_insecure_channel.assert_called_once_with(
317+
"localhost:4317",
318+
compression=Compression.NoCompression,
319+
options=(
320+
(
321+
"grpc.primary_user_agent",
322+
"OTel-OTLP-Exporter-Python/" + __version__,
323+
),
324+
("some", "options"),
325+
),
302326
)
303327

304328
def test_split_metrics_data_many_data_points(self):

exporter/opentelemetry-exporter-otlp-proto-grpc/tests/test_otlp_trace_exporter.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure):
282282
(
283283
("key1", "value1"),
284284
("key2", "VALUE=2"),
285-
("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),
286285
),
287286
)
288287
exporter = OTLPSpanExporter(
@@ -294,7 +293,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure):
294293
(
295294
("key3", "value3"),
296295
("key4", "value4"),
297-
("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),
298296
),
299297
)
300298
exporter = OTLPSpanExporter(
@@ -306,7 +304,6 @@ def test_otlp_headers_from_env(self, mock_ssl_channel, mock_secure):
306304
(
307305
("key5", "value5"),
308306
("key6", "value6"),
309-
("user-agent", "OTel-OTLP-Exporter-Python/" + __version__),
310307
),
311308
)
312309

@@ -335,6 +332,12 @@ def test_otlp_exporter_otlp_compression_kwarg(self, mock_insecure_channel):
335332
mock_insecure_channel.assert_called_once_with(
336333
"localhost:4317",
337334
compression=Compression.NoCompression,
335+
options=(
336+
(
337+
"grpc.primary_user_agent",
338+
"OTel-OTLP-Exporter-Python/" + __version__,
339+
),
340+
),
338341
)
339342

340343
# pylint: disable=no-self-use
@@ -353,6 +356,30 @@ def test_otlp_exporter_otlp_compression_precendence(
353356
mock_insecure_channel.assert_called_once_with(
354357
"localhost:4317",
355358
compression=Compression.Gzip,
359+
options=(
360+
(
361+
"grpc.primary_user_agent",
362+
"OTel-OTLP-Exporter-Python/" + __version__,
363+
),
364+
),
365+
)
366+
367+
# pylint: disable=no-self-use
368+
@patch("opentelemetry.exporter.otlp.proto.grpc.exporter.insecure_channel")
369+
def test_otlp_exporter_otlp_channel_options_kwarg(
370+
self, mock_insecure_channel
371+
):
372+
OTLPSpanExporter(insecure=True, channel_options=(("some", "options"),))
373+
mock_insecure_channel.assert_called_once_with(
374+
"localhost:4317",
375+
compression=Compression.NoCompression,
376+
options=(
377+
(
378+
"grpc.primary_user_agent",
379+
"OTel-OTLP-Exporter-Python/" + __version__,
380+
),
381+
("some", "options"),
382+
),
356383
)
357384

358385
def test_translate_spans(self):

0 commit comments

Comments
 (0)