From 29fe1e895f8392712d11a296bd51ebbcce826e8d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:53:25 -0700 Subject: [PATCH] feat(otel): make the OTel v2 trace export URL configurable (#40286) Add traces_endpoint (env OTEL_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, per-exporter key traces_endpoint, Admin UI field) as a complete OTLP/HTTP trace URL used verbatim, so collectors that do not serve /v1/traces can receive traces. endpoint keeps its existing base-URL + signal-path normalization. Resolves LIT-7218 Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/callback_configs.json | 6 ++ litellm/integrations/otel/model/config.py | 19 ++++- .../integrations/otel/plumbing/providers.py | 11 ++- litellm/proxy/_types.py | 1 + .../otel/test_otel_v2_components.py | 74 +++++++++++++++++++ 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index 7a2295a35ae..b98eef8329b 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -356,6 +356,12 @@ "description": "OpenTelemetry collector endpoint URL", "required": true }, + "otel_traces_endpoint": { + "type": "text", + "ui_name": "Traces Endpoint URL", + "description": "Complete trace export URL used verbatim when the collector does not serve /v1/traces (OTel v2 only)", + "required": false + }, "otel_headers": { "type": "text", "ui_name": "Headers", diff --git a/litellm/integrations/otel/model/config.py b/litellm/integrations/otel/model/config.py index 9e3064c2bff..e3501d0ee94 100644 --- a/litellm/integrations/otel/model/config.py +++ b/litellm/integrations/otel/model/config.py @@ -72,6 +72,14 @@ class ExporterSpec(BaseModel): description="console | in_memory | otlp_http | otlp_grpc | ", ) endpoint: str | None = None + traces_endpoint: str | None = Field( + default=None, + description=( + "Complete OTLP/HTTP trace URL, used verbatim. Set this when the " + "collector serves traces on a path other than ``/v1/traces``; " + "``endpoint`` is a base URL the signal path is appended to." + ), + ) headers: str | None = None owner: ExporterOwner | None = Field( default=None, @@ -127,6 +135,14 @@ class OpenTelemetryV2Config(BaseSettings): default=None, validation_alias=AliasChoices("OTEL_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"), ) + traces_endpoint: str | None = Field( + default=None, + validation_alias=AliasChoices("OTEL_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), + description=( + "Complete OTLP/HTTP trace URL for the single-destination shorthand, " + "used verbatim instead of ``endpoint`` + ``/v1/traces``." + ), + ) headers: str | None = Field( default=None, validation_alias=AliasChoices("OTEL_HEADERS", "OTEL_EXPORTER_OTLP_HEADERS"), @@ -250,7 +266,7 @@ class OpenTelemetryV2Config(BaseSettings): @model_validator(mode="after") def _normalize(self) -> "OpenTelemetryV2Config": # An endpoint with the default exporter kind implies OTLP/HTTP. - if self.endpoint and self.exporter == "console": + if (self.endpoint or self.traces_endpoint) and self.exporter == "console": self.exporter = "otlp_http" # When no explicit destinations are given, fold the single-destination # shorthand into one spec so the provider always has a destination. @@ -259,6 +275,7 @@ class OpenTelemetryV2Config(BaseSettings): ExporterSpec( kind=self.exporter, endpoint=self.endpoint, + traces_endpoint=self.traces_endpoint, headers=self.headers, ) ] diff --git a/litellm/integrations/otel/plumbing/providers.py b/litellm/integrations/otel/plumbing/providers.py index fb74ff85e5b..d8da53017ab 100644 --- a/litellm/integrations/otel/plumbing/providers.py +++ b/litellm/integrations/otel/plumbing/providers.py @@ -170,7 +170,7 @@ def _exporter_from_spec(spec: ExporterSpec) -> SpanExporter: ) return HTTPExporter( - endpoint=_otlp_traces_endpoint(spec.endpoint), + endpoint=spec.traces_endpoint or _otlp_traces_endpoint(spec.endpoint), headers=parse_headers(spec.headers), ) if kind in _OTLP_GRPC_KINDS: @@ -201,7 +201,14 @@ def build_span_exporter(config: OpenTelemetryV2Config) -> SpanExporter: ``exporter`` / ``endpoint`` / ``headers`` fields. To configure multiple exporters, populate ``config.exporters`` directly. """ - return _exporter_from_spec(ExporterSpec(kind=config.exporter, endpoint=config.endpoint, headers=config.headers)) + return _exporter_from_spec( + ExporterSpec( + kind=config.exporter, + endpoint=config.endpoint, + traces_endpoint=config.traces_endpoint, + headers=config.headers, + ) + ) def _otlp_metrics_endpoint(endpoint: str | None) -> str | None: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4dbae6394f6..d1b391d57a3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3585,6 +3585,7 @@ class AllCallbacks(LiteLLMPydanticObjectBase): litellm_callback_params=[ "OTEL_EXPORTER", "OTEL_ENDPOINT", + "OTEL_TRACES_ENDPOINT", "OTEL_HEADERS", ], ) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index 4aa28b5abfd..6676714d7f5 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -3,7 +3,10 @@ baggage helpers, metrics, the typed coercion helpers, mapper branches, span-name builders, and the registry validator's failure paths. Needs the OTel SDK.""" import json +import threading +from collections.abc import Iterator from dataclasses import replace +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest @@ -538,6 +541,77 @@ def test_build_span_exporter_variants(): assert "OTLPSpanExporter" in type(http_exporter).__name__ +@pytest.fixture +def otlp_collector() -> Iterator[tuple[str, list[str]]]: + received_paths: list[str] = [] + + class RecordingHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + received_paths.append(self.path) + self.send_response(200) + self.end_headers() + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), RecordingHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{server.server_port}", received_paths + finally: + server.shutdown() + server.server_close() + + +def _export_one_span(cfg: OpenTelemetryV2Config) -> None: + provider = providers.build_tracer_provider(cfg) + provider.get_tracer("probe").start_span("probe").end() + assert provider.force_flush() + provider.shutdown() + + +def test_traces_endpoint_env_posts_to_the_configured_url_verbatim(monkeypatch, otlp_collector): + base_url, received_paths = otlp_collector + for var in ("OTEL_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_ENDPOINT"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("OTEL_ENDPOINT", f"{base_url}/services/collector") + monkeypatch.setenv("OTEL_TRACES_ENDPOINT", f"{base_url}/services/collector/traces") + + cfg = OpenTelemetryV2Config.from_env() + assert cfg.exporter == "otlp_http" + _export_one_span(cfg) + assert received_paths == ["/services/collector/traces"] + + +def test_traces_endpoint_alias_alone_implies_otlp_http(monkeypatch, otlp_collector): + base_url, received_paths = otlp_collector + for var in ("OTEL_EXPORTER", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", f"{base_url}/custom/traces") + + cfg = OpenTelemetryV2Config.from_env() + assert cfg.exporter == "otlp_http" + _export_one_span(cfg) + assert received_paths == ["/custom/traces"] + + +def test_traces_endpoint_per_exporter_coexists_with_default_normalization(otlp_collector): + base_url, received_paths = otlp_collector + cfg = OpenTelemetryV2Config( + exporters=[ + {"kind": "otlp_http", "endpoint": base_url}, + { + "kind": "otlp_http", + "endpoint": f"{base_url}/services/collector", + "traces_endpoint": f"{base_url}/services/collector/traces", + }, + ] + ) + _export_one_span(cfg) + assert sorted(received_paths) == ["/services/collector/traces", "/v1/traces"] + + def test_otlp_metric_exporter_uses_cumulative_histogram_temporality(): """Histograms must export as cumulative, not delta.