Skip to content

opentelemetry-exporter-otlp-proto-grpc: set grpc user agent properly #4658

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -99,6 +100,7 @@ def __init__(
"headers": headers,
"timeout": timeout or environ_timeout,
"compression": compression,
"channel_options": channel_options,
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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__(
Expand All @@ -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__()

Expand Down Expand Up @@ -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)
Expand All @@ -258,6 +272,7 @@ def __init__(
self._channel = insecure_channel(
self._endpoint,
compression=compression,
options=self._channel_options,
)
else:
credentials = _get_credentials(
Expand All @@ -270,6 +285,7 @@ def __init__(
self._endpoint,
credentials,
compression=compression,
options=self._channel_options,
)
self._client = self._stub(self._channel)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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__),
),
)

Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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__),
),
)

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down