From a5f355e04a4cc13da8ce8ddc13430d343decfa8d Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:29:50 +0530 Subject: [PATCH] Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append (#5913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Python: Fix OTLP HTTP base-endpoint losing /v1/{signal} auto-append Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK auto-appends /v1/traces, /v1/metrics, /v1/logs when it reads the env var directly. Signal-specific endpoint env vars are *full* URLs used verbatim. _get_exporters_from_env read the base endpoint and forwarded it as the constructor ``endpoint=`` argument, which the SDK always treats as a full signal URL. As a result, with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 and HTTP protocol, the exporter sent to http://localhost:4318 instead of http://localhost:4318/v1/traces (and likewise for metrics/logs). Replicate the spec's auto-append here when falling back to the base endpoint under HTTP. gRPC behavior is unchanged. * Python: Fix mypy type errors in OTLP endpoint assignment Pre-declare traces_endpoint, metrics_endpoint, logs_endpoint as str | None before the if/else block. Mypy inferred str from the if-branch f-string assignments and then rejected the str | None expressions in the else-branch as incompatible. --- .../core/agent_framework/observability.py | 28 ++++- .../core/tests/core/test_observability.py | 109 ++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index d7734f2457..1654799d87 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -498,14 +498,34 @@ def _get_exporters_from_env( # Get base endpoint base_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT") - # Get signal-specific endpoints (these override base endpoint) - traces_endpoint = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or base_endpoint - metrics_endpoint = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or base_endpoint - logs_endpoint = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") or base_endpoint + # Get signal-specific endpoints (these override base endpoint and are used verbatim) + traces_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") + metrics_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") + logs_endpoint_specific = os.getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT") # Get protocol (default is grpc) protocol = os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() + # Per the OTel spec, OTEL_EXPORTER_OTLP_ENDPOINT is a *base* URL for HTTP — the SDK + # auto-appends /v1/{traces,metrics,logs} when it reads the env var directly. The + # signal-specific endpoint env vars are *full* URLs used verbatim. Because we read + # the env vars here and forward them as the ``endpoint=`` constructor argument + # (which the SDK always treats as a full URL), we must replicate the auto-append + # ourselves for HTTP when falling back to the base endpoint. For gRPC, the base + # endpoint is used as-is. + traces_endpoint: str | None + metrics_endpoint: str | None + logs_endpoint: str | None + if protocol in ("http/protobuf", "http") and base_endpoint: + base_for_http = base_endpoint.rstrip("/") + traces_endpoint = traces_endpoint_specific or f"{base_for_http}/v1/traces" + metrics_endpoint = metrics_endpoint_specific or f"{base_for_http}/v1/metrics" + logs_endpoint = logs_endpoint_specific or f"{base_for_http}/v1/logs" + else: + traces_endpoint = traces_endpoint_specific or base_endpoint + metrics_endpoint = metrics_endpoint_specific or base_endpoint + logs_endpoint = logs_endpoint_specific or base_endpoint + # Get base headers base_headers_str = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "") base_headers = _parse_headers(base_headers_str) diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 372cb8a7dd..94d4ee3bad 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -761,6 +761,115 @@ def test_get_exporters_from_env_missing_grpc_dependency(monkeypatch): _get_exporters_from_env() +# region Test OTLP endpoint computation (base-URL auto-append for HTTP) + + +def test_get_exporters_from_env_http_base_endpoint_appends_signal_paths(monkeypatch): + """OTEL_EXPORTER_OTLP_ENDPOINT is a base URL for HTTP; SDK auto-appends + /v1/{traces,metrics,logs}. Because we read the env var and forward it as the + constructor ``endpoint=`` arg (which the SDK treats as a full URL), we must + replicate the auto-append ourselves. + """ + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["protocol"] == "http/protobuf" + assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces" + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_http_base_endpoint_trailing_slash(monkeypatch): + """A trailing slash on the base endpoint should not produce a doubled slash.""" + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["traces_endpoint"] == "http://localhost:4318/v1/traces" + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_http_signal_specific_used_verbatim(monkeypatch): + """Signal-specific endpoint env vars are full URLs and must be used verbatim, + even when a base endpoint is also set. + """ + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://traces.example.com/custom/path") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + for key in ( + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + # Signal-specific is verbatim — no path appended + assert kwargs["traces_endpoint"] == "http://traces.example.com/custom/path" + # Others fall back to base, with path appended + assert kwargs["metrics_endpoint"] == "http://localhost:4318/v1/metrics" + assert kwargs["logs_endpoint"] == "http://localhost:4318/v1/logs" + + +def test_get_exporters_from_env_grpc_base_endpoint_unchanged(monkeypatch): + """For gRPC, the base endpoint applies to all signals as-is (no path append).""" + from unittest.mock import patch + + from agent_framework import observability + + monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317") + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + for key in ( + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ): + monkeypatch.delenv(key, raising=False) + + with patch.object(observability, "_create_otlp_exporters", return_value=[]) as create: + observability._get_exporters_from_env() + + kwargs = create.call_args.kwargs + assert kwargs["protocol"] == "grpc" + assert kwargs["traces_endpoint"] == "http://localhost:4317" + assert kwargs["metrics_endpoint"] == "http://localhost:4317" + assert kwargs["logs_endpoint"] == "http://localhost:4317" + + # region Test create_resource