From edb9d6ef7f11df4f4cb091268b7a3b95b51c2007 Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 17 Sep 2026 03:30:55 +0000 Subject: [PATCH] fix(langfuse): normalise the OTLP export path so a trailing host slash never yields a double slash Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/langfuse/langfuse_sdk.py | 4 ++-- .../langfuse/test_langfuse_sdk.py | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index d7334f384bf..fc6e8a5e880 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -559,8 +559,8 @@ def _build_span_exporter(*, public_key: object, secret_key: object, base_url: ob ca_bundle: Final = ssl_verify if isinstance(ssl_verify, str) and os.path.exists(ssl_verify) else None configured_certificate: Final = os.getenv("SSL_CERTIFICATE") or litellm.ssl_certificate client_certificate: Final = configured_certificate if isinstance(configured_certificate, str) else None - export_path: Final = os.getenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH") - endpoint: Final = f"{base_url}/{export_path}" if export_path else f"{base_url}/api/public/otel/v1/traces" + export_path: Final = os.getenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH") or "/api/public/otel/v1/traces" + endpoint: Final = f"{str(base_url).rstrip('/')}/{export_path.lstrip('/')}" encoded_auth: Final = b64encode(f"{public_key}:{secret_key}".encode()).decode("ascii") exporter: Final = OTLPSpanExporter( endpoint=endpoint, diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index 30748eec40c..bf4a380d573 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -1078,6 +1078,29 @@ def test_ssl_exporter_carries_litellm_tls_material(monkeypatch, tmp_path): assert exporter._headers["x-langfuse-sdk-version"] == installed_langfuse_version() +@pytest.mark.parametrize( + ("base_url", "export_path", "expected"), + [ + ("https://lf.internal.example/", None, "https://lf.internal.example/api/public/otel/v1/traces"), + ("https://lf.internal.example", "/otel/traces", "https://lf.internal.example/otel/traces"), + ("https://lf.internal.example/", "/otel/traces", "https://lf.internal.example/otel/traces"), + ("https://lf.internal.example", "otel/traces", "https://lf.internal.example/otel/traces"), + ], +) +def test_export_endpoint_never_doubles_the_slash(monkeypatch, base_url, export_path, expected): + """A trailing host slash or a leading export path slash must not produce `//` in the OTLP route.""" + from litellm.integrations.langfuse.langfuse_sdk import _build_span_exporter + + if export_path is None: + monkeypatch.delenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH", raising=False) + else: + monkeypatch.setenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH", export_path) + + exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url=base_url).exporter + + assert exporter._endpoint == expected + + def test_retrying_exporter_retries_a_raised_export_and_then_succeeds(monkeypatch): """A read timeout used to drop the batch outright; v2 backed off and re-sent it.""" from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult