From 015b3cd2cbbe821df8c0e4f4c31517a0a9d84522 Mon Sep 17 00:00:00 2001 From: Oleksii Kutsenko Date: Thu, 4 Jun 2026 16:38:30 +0300 Subject: [PATCH 1/5] fix(exporter/otlp-proto-http): auto-append signal path for base-URL endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `endpoint` is passed to OTLPSpanExporter, OTLPMetricExporter, or OTLPLogExporter without a signal-specific path (empty path or just `/`), treat it as a base URL and append /v1/traces, /v1/metrics, or /v1/logs respectively — consistent with how OTEL_EXPORTER_OTLP_ENDPOINT behaves. This makes switching from gRPC to HTTP exporters easier: users can pass the same base URL style (e.g. http://host:4318) without having to manually construct signal-specific URLs. --- .changelog/0.added | 1 + .../otlp/proto/http/_common/__init__.py | 7 ++++++ .../otlp/proto/http/_log_exporter/__init__.py | 20 +++++++++++----- .../proto/http/metric_exporter/__init__.py | 20 +++++++++++----- .../proto/http/trace_exporter/__init__.py | 20 +++++++++++----- .../metrics/test_otlp_metrics_exporter.py | 24 +++++++++++++++++++ .../tests/test_proto_log_exporter.py | 16 +++++++++++++ .../tests/test_proto_span_exporter.py | 16 +++++++++++++ 8 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 .changelog/0.added diff --git a/.changelog/0.added b/.changelog/0.added new file mode 100644 index 00000000000..41e9dc44473 --- /dev/null +++ b/.changelog/0.added @@ -0,0 +1 @@ +`opentelemetry-exporter-otlp-proto-http`: auto-append signal path when a base URL (no path or path `/`) is passed as `endpoint` to HTTP OTLP exporters diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py index 57bd7ca065a..0d0b29f6441 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py @@ -3,6 +3,7 @@ from os import environ from typing import Literal +from urllib.parse import urlparse import requests @@ -12,6 +13,12 @@ from opentelemetry.util._importlib_metadata import entry_points +def _is_base_endpoint(endpoint: str) -> bool: + """Return True if endpoint has no signal-specific path (empty path or just '/').""" + path = urlparse(endpoint).path + return not path or path == "/" + + def _is_retryable(resp: requests.Response) -> bool: if resp.status_code == 408: return True diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py index 56906b96501..9eac126d777 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py @@ -25,6 +25,7 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( + _is_base_endpoint, _is_retryable, _load_session_from_envvar, ) @@ -88,12 +89,19 @@ def __init__( meter_provider: MeterProvider | None = None, ): self._shutdown_is_occuring = threading.Event() - self._endpoint = endpoint or environ.get( - OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, - _append_logs_path( - environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) - ), - ) + if endpoint is not None: + self._endpoint = ( + _append_logs_path(endpoint) + if _is_base_endpoint(endpoint) + else endpoint + ) + else: + self._endpoint = environ.get( + OTEL_EXPORTER_OTLP_LOGS_ENDPOINT, + _append_logs_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) # Keeping these as instance variables because they are used in tests self._certificate_file = certificate_file or environ.get( OTEL_EXPORTER_OTLP_LOGS_CERTIFICATE, diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py index eb1e69cfe4f..50e86600bf4 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py @@ -39,6 +39,7 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( + _is_base_endpoint, _is_retryable, _load_session_from_envvar, ) @@ -147,12 +148,19 @@ def __init__( If it is set and the number of data points exceeds the max, the request will be split. """ self._shutdown_in_progress = threading.Event() - self._endpoint = endpoint or environ.get( - OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, - _append_metrics_path( - environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) - ), - ) + if endpoint is not None: + self._endpoint = ( + _append_metrics_path(endpoint) + if _is_base_endpoint(endpoint) + else endpoint + ) + else: + self._endpoint = environ.get( + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT, + _append_metrics_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) self._certificate_file = certificate_file or environ.get( OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE, environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py index 2be240103c0..c132036c161 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py @@ -27,6 +27,7 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( + _is_base_endpoint, _is_retryable, _load_session_from_envvar, ) @@ -84,12 +85,19 @@ def __init__( meter_provider: MeterProvider | None = None, ): self._shutdown_in_progress = threading.Event() - self._endpoint = endpoint or environ.get( - OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, - _append_trace_path( - environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) - ), - ) + if endpoint is not None: + self._endpoint = ( + _append_trace_path(endpoint) + if _is_base_endpoint(endpoint) + else endpoint + ) + else: + self._endpoint = environ.get( + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, + _append_trace_path( + environ.get(OTEL_EXPORTER_OTLP_ENDPOINT, DEFAULT_ENDPOINT) + ), + ) self._certificate_file = certificate_file or environ.get( OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE, environ.get(OTEL_EXPORTER_OTLP_CERTIFICATE, True), diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py index 84a11e8ae90..8551f8b1ccf 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py @@ -303,6 +303,30 @@ def test_exporter_env_endpoint_with_slash(self): OS_ENV_ENDPOINT + f"/{DEFAULT_METRICS_EXPORT_PATH}", ) + def test_endpoint_base_url_no_path(self): + exporter = OTLPMetricExporter(endpoint="http://collector:4318") + self.assertEqual( + exporter._endpoint, "http://collector:4318/v1/metrics" + ) + + def test_endpoint_base_url_trailing_slash(self): + exporter = OTLPMetricExporter(endpoint="http://collector:4318/") + self.assertEqual( + exporter._endpoint, "http://collector:4318/v1/metrics" + ) + + def test_endpoint_full_url_unchanged(self): + exporter = OTLPMetricExporter( + endpoint="http://collector:4318/v1/metrics" + ) + self.assertEqual( + exporter._endpoint, "http://collector:4318/v1/metrics" + ) + + def test_endpoint_custom_path_unchanged(self): + exporter = OTLPMetricExporter(endpoint="http://collector:4318/custom") + self.assertEqual(exporter._endpoint, "http://collector:4318/custom") + @patch.dict( "os.environ", { diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py index d7f3592e288..98a9d3da536 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py @@ -259,6 +259,22 @@ def test_exporter_env(self): ) self.assertIsInstance(exporter._session, requests.Session) + def test_endpoint_base_url_no_path(self): + exporter = OTLPLogExporter(endpoint="http://collector:4318") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs") + + def test_endpoint_base_url_trailing_slash(self): + exporter = OTLPLogExporter(endpoint="http://collector:4318/") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs") + + def test_endpoint_full_url_unchanged(self): + exporter = OTLPLogExporter(endpoint="http://collector:4318/v1/logs") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/logs") + + def test_endpoint_custom_path_unchanged(self): + exporter = OTLPLogExporter(endpoint="http://collector:4318/custom") + self.assertEqual(exporter._endpoint, "http://collector:4318/custom") + @staticmethod def export_log_and_deserialize(log): with patch("requests.Session.post") as mock_post: diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py index 1580e5a1802..b9e63944d7e 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py @@ -247,6 +247,22 @@ def test_exporter_env_endpoint_with_slash(self): OS_ENV_ENDPOINT + f"/{DEFAULT_TRACES_EXPORT_PATH}", ) + def test_endpoint_base_url_no_path(self): + exporter = OTLPSpanExporter(endpoint="http://collector:4318") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces") + + def test_endpoint_base_url_trailing_slash(self): + exporter = OTLPSpanExporter(endpoint="http://collector:4318/") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces") + + def test_endpoint_full_url_unchanged(self): + exporter = OTLPSpanExporter(endpoint="http://collector:4318/v1/traces") + self.assertEqual(exporter._endpoint, "http://collector:4318/v1/traces") + + def test_endpoint_custom_path_unchanged(self): + exporter = OTLPSpanExporter(endpoint="http://collector:4318/custom") + self.assertEqual(exporter._endpoint, "http://collector:4318/custom") + @patch.dict( "os.environ", { From 9bb7c8b1947b1f2bd2f5719862a763bfba3fc7ca Mon Sep 17 00:00:00 2001 From: Oleksii Kutsenko Date: Thu, 4 Jun 2026 16:48:42 +0300 Subject: [PATCH 2/5] chore: rename changelog fragment to PR number 5273 --- .changelog/{0.added => 5273.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{0.added => 5273.added} (100%) diff --git a/.changelog/0.added b/.changelog/5273.added similarity index 100% rename from .changelog/0.added rename to .changelog/5273.added From b120ca0c0a884e1bf31390afbaa2181d663a6bce Mon Sep 17 00:00:00 2001 From: Oleksii Kutsenko Date: Thu, 4 Jun 2026 16:57:47 +0300 Subject: [PATCH 3/5] fix: preserve query string when appending signal path to base endpoint Replace _is_base_endpoint + string-concatenation with _resolve_endpoint_to_signal that uses urlparse/urlunparse, so that an endpoint like http://host:4318?tenant=acme becomes http://host:4318/v1/traces?tenant=acme rather than the malformed http://host:4318?tenant=acme/v1/traces. --- .../otlp/proto/http/_common/__init__.py | 17 ++++++++++++----- .../otlp/proto/http/_log_exporter/__init__.py | 8 +++----- .../otlp/proto/http/metric_exporter/__init__.py | 8 +++----- .../otlp/proto/http/trace_exporter/__init__.py | 8 +++----- .../tests/test_proto_span_exporter.py | 9 +++++++++ 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py index 0d0b29f6441..3e6962010fa 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py @@ -3,7 +3,7 @@ from os import environ from typing import Literal -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse import requests @@ -13,10 +13,17 @@ from opentelemetry.util._importlib_metadata import entry_points -def _is_base_endpoint(endpoint: str) -> bool: - """Return True if endpoint has no signal-specific path (empty path or just '/').""" - path = urlparse(endpoint).path - return not path or path == "/" +def _resolve_endpoint_to_signal(endpoint: str, signal_path: str) -> str: + """Append signal_path to endpoint if it has no signal-specific path. + + Uses proper URL manipulation so query strings and fragments are preserved. + If the endpoint already has a path other than '/', it is returned unchanged. + """ + parsed = urlparse(endpoint) + if not parsed.path or parsed.path == "/": + base = parsed.path.rstrip("/") + return urlunparse(parsed._replace(path=f"{base}/{signal_path}")) + return endpoint def _is_retryable(resp: requests.Response) -> bool: diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py index 9eac126d777..7dcc57120a1 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py @@ -25,9 +25,9 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( - _is_base_endpoint, _is_retryable, _load_session_from_envvar, + _resolve_endpoint_to_signal, ) from opentelemetry.metrics import MeterProvider from opentelemetry.sdk._logs import ReadableLogRecord @@ -90,10 +90,8 @@ def __init__( ): self._shutdown_is_occuring = threading.Event() if endpoint is not None: - self._endpoint = ( - _append_logs_path(endpoint) - if _is_base_endpoint(endpoint) - else endpoint + self._endpoint = _resolve_endpoint_to_signal( + endpoint, DEFAULT_LOGS_EXPORT_PATH ) else: self._endpoint = environ.get( diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py index 50e86600bf4..4abcfc7cce2 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py @@ -39,9 +39,9 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( - _is_base_endpoint, _is_retryable, _load_session_from_envvar, + _resolve_endpoint_to_signal, ) from opentelemetry.metrics import MeterProvider from opentelemetry.proto.collector.metrics.v1.metrics_service_pb2 import ( # noqa: F401 @@ -149,10 +149,8 @@ def __init__( """ self._shutdown_in_progress = threading.Event() if endpoint is not None: - self._endpoint = ( - _append_metrics_path(endpoint) - if _is_base_endpoint(endpoint) - else endpoint + self._endpoint = _resolve_endpoint_to_signal( + endpoint, DEFAULT_METRICS_EXPORT_PATH ) else: self._endpoint = environ.get( diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py index c132036c161..7949df1f0e0 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py @@ -27,9 +27,9 @@ Compression, ) from opentelemetry.exporter.otlp.proto.http._common import ( - _is_base_endpoint, _is_retryable, _load_session_from_envvar, + _resolve_endpoint_to_signal, ) from opentelemetry.metrics import MeterProvider from opentelemetry.sdk.environment_variables import ( @@ -86,10 +86,8 @@ def __init__( ): self._shutdown_in_progress = threading.Event() if endpoint is not None: - self._endpoint = ( - _append_trace_path(endpoint) - if _is_base_endpoint(endpoint) - else endpoint + self._endpoint = _resolve_endpoint_to_signal( + endpoint, DEFAULT_TRACES_EXPORT_PATH ) else: self._endpoint = environ.get( diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py index b9e63944d7e..b8ec62f848c 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py @@ -263,6 +263,15 @@ def test_endpoint_custom_path_unchanged(self): exporter = OTLPSpanExporter(endpoint="http://collector:4318/custom") self.assertEqual(exporter._endpoint, "http://collector:4318/custom") + def test_endpoint_base_url_with_query_string(self): + exporter = OTLPSpanExporter( + endpoint="http://collector:4318?tenant=acme" + ) + self.assertEqual( + exporter._endpoint, + "http://collector:4318/v1/traces?tenant=acme", + ) + @patch.dict( "os.environ", { From a4af36be1cc438afd3272d3c803df0cd076544f1 Mon Sep 17 00:00:00 2001 From: Oleksii Kutsenko Date: Wed, 10 Jun 2026 12:46:50 +0300 Subject: [PATCH 4/5] fix: return endpoint unchanged when it cannot be parsed as a URL Address review feedback: guard urlparse with try/except ValueError so unparsable endpoints (e.g. unclosed IPv6 brackets) pass through unchanged, and add tests covering malformed and schemeless endpoints. --- .../otlp/proto/http/_common/__init__.py | 8 ++++++-- .../tests/test_proto_log_exporter.py | 19 +++++++++++++++++++ .../tests/test_proto_span_exporter.py | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py index 3e6962010fa..4de877ff09f 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py @@ -17,9 +17,13 @@ def _resolve_endpoint_to_signal(endpoint: str, signal_path: str) -> str: """Append signal_path to endpoint if it has no signal-specific path. Uses proper URL manipulation so query strings and fragments are preserved. - If the endpoint already has a path other than '/', it is returned unchanged. + If the endpoint already has a path other than '/', or cannot be parsed as + a URL, it is returned unchanged. """ - parsed = urlparse(endpoint) + try: + parsed = urlparse(endpoint) + except ValueError: + return endpoint if not parsed.path or parsed.path == "/": base = parsed.path.rstrip("/") return urlunparse(parsed._replace(path=f"{base}/{signal_path}")) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py index 98a9d3da536..b9f3b3729de 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_log_exporter.py @@ -17,6 +17,9 @@ from opentelemetry._logs import LogRecord, SeverityNumber from opentelemetry.exporter.otlp.proto.http import Compression +from opentelemetry.exporter.otlp.proto.http._common import ( + _resolve_endpoint_to_signal, +) from opentelemetry.exporter.otlp.proto.http._log_exporter import ( DEFAULT_COMPRESSION, DEFAULT_ENDPOINT, @@ -275,6 +278,22 @@ def test_endpoint_custom_path_unchanged(self): exporter = OTLPLogExporter(endpoint="http://collector:4318/custom") self.assertEqual(exporter._endpoint, "http://collector:4318/custom") + def test_endpoint_unparsable_url_unchanged(self): + # Unclosed IPv6 bracket makes urlparse raise ValueError; the + # helper must pass the endpoint through unchanged. + self.assertEqual( + _resolve_endpoint_to_signal( + "http://[invalid:4318", DEFAULT_LOGS_EXPORT_PATH + ), + "http://[invalid:4318", + ) + + def test_endpoint_schemeless_host_port_unchanged(self): + # Without a scheme, urlparse reads "collector" as the scheme and + # "4318" as the path, so the endpoint is left untouched. + exporter = OTLPLogExporter(endpoint="collector:4318") + self.assertEqual(exporter._endpoint, "collector:4318") + @staticmethod def export_log_and_deserialize(log): with patch("requests.Session.post") as mock_post: diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py index b8ec62f848c..a3b33f5ef74 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py @@ -13,6 +13,9 @@ from requests.models import Response from opentelemetry.exporter.otlp.proto.http import Compression +from opentelemetry.exporter.otlp.proto.http._common import ( + _resolve_endpoint_to_signal, +) from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( DEFAULT_COMPRESSION, DEFAULT_ENDPOINT, @@ -272,6 +275,22 @@ def test_endpoint_base_url_with_query_string(self): "http://collector:4318/v1/traces?tenant=acme", ) + def test_endpoint_unparsable_url_unchanged(self): + # Unclosed IPv6 bracket makes urlparse raise ValueError; the + # helper must pass the endpoint through unchanged. + self.assertEqual( + _resolve_endpoint_to_signal( + "http://[invalid:4318", DEFAULT_TRACES_EXPORT_PATH + ), + "http://[invalid:4318", + ) + + def test_endpoint_schemeless_host_port_unchanged(self): + # Without a scheme, urlparse reads "collector" as the scheme and + # "4318" as the path, so the endpoint is left untouched. + exporter = OTLPSpanExporter(endpoint="collector:4318") + self.assertEqual(exporter._endpoint, "collector:4318") + @patch.dict( "os.environ", { From 28ce1098dea66d9d52eac3d9925edd9a213e6647 Mon Sep 17 00:00:00 2001 From: Oleksii Kutsenko Date: Wed, 10 Jun 2026 14:29:01 +0300 Subject: [PATCH 5/5] refactor: use defensive attribute access for parsed endpoint path --- .../exporter/otlp/proto/http/_common/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py index 4de877ff09f..af19e4ee3b5 100644 --- a/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py +++ b/exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_common/__init__.py @@ -24,8 +24,9 @@ def _resolve_endpoint_to_signal(endpoint: str, signal_path: str) -> str: parsed = urlparse(endpoint) except ValueError: return endpoint - if not parsed.path or parsed.path == "/": - base = parsed.path.rstrip("/") + path = getattr(parsed, "path", "") + if not path or path == "/": + base = path.rstrip("/") return urlunparse(parsed._replace(path=f"{base}/{signal_path}")) return endpoint