diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 7370bcdf934..1ba6c27b108 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -1,7 +1,9 @@ import base64 import json # <--- NEW import os +import re from datetime import datetime +from hashlib import sha256 from typing import TYPE_CHECKING, Any, Optional, Union from litellm._logging import verbose_logger @@ -26,6 +28,23 @@ else: LANGFUSE_CLOUD_EU_ENDPOINT = "https://cloud.langfuse.com/api/public/otel" LANGFUSE_CLOUD_US_ENDPOINT = "https://us.cloud.langfuse.com/api/public/otel" +_OTEL_TRACE_ID_HEX = re.compile(r"[0-9a-f]{32}") + + +def normalize_trace_id_for_otel(trace_id: str) -> str: + """Map an arbitrary trace id onto a valid 32-hex-char OTEL/Langfuse trace id. + + Langfuse derives a trace's identity from the OTEL span-context trace id, which + must be 32 lowercase hex chars. An id that is already valid (case-insensitively, + or a dashed UUID) is reused directly so an explicit valid id is preserved; any + other string is hashed exactly as ``langfuse.create_trace_id(seed=...)`` does, + so a caller can reproduce the trace id from their external id. + """ + canonical = trace_id.replace("-", "").lower() + if _OTEL_TRACE_ID_HEX.fullmatch(canonical): + return canonical + return sha256(trace_id.encode("utf-8")).digest()[:16].hex() + class LangfuseOtelLogger(OpenTelemetry): def __init__(self, config=None, *args, **kwargs): diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index 022df5b54d4..83cb8955cd8 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -550,6 +550,63 @@ class TestLangfuseOtelIntegration: # Endpoint assertion removed as side effect is gone +class TestNormalizeTraceIdForOtel: + """Unit tests for the trace-id normalization helper.""" + + def test_valid_lowercase_hex_passthrough(self): + from litellm.integrations.langfuse.langfuse_otel import ( + normalize_trace_id_for_otel, + ) + + tid = "abcdef0123456789abcdef0123456789" + assert normalize_trace_id_for_otel(tid) == tid + + def test_uppercase_hex_is_lowercased(self): + from litellm.integrations.langfuse.langfuse_otel import ( + normalize_trace_id_for_otel, + ) + + assert ( + normalize_trace_id_for_otel("ABCDEF0123456789ABCDEF0123456789") + == "abcdef0123456789abcdef0123456789" + ) + + def test_dashed_uuid_is_stripped_not_hashed(self): + from litellm.integrations.langfuse.langfuse_otel import ( + normalize_trace_id_for_otel, + ) + + assert ( + normalize_trace_id_for_otel("123e4567-e89b-12d3-a456-426614174000") + == "123e4567e89b12d3a456426614174000" + ) + + def test_external_id_hashed_like_langfuse_create_trace_id(self): + """Non-hex ids hash the ORIGINAL string (dashes and case preserved) so + the result is byte-for-byte ``langfuse.create_trace_id(seed=...)``.""" + import hashlib + + from litellm.integrations.langfuse.langfuse_otel import ( + normalize_trace_id_for_otel, + ) + + for seed in ("global_20260225143025_ord2468", "Order-42", "ab-cd"): + assert ( + normalize_trace_id_for_otel(seed) + == hashlib.sha256(seed.encode("utf-8")).digest()[:16].hex() + ) + + def test_output_is_always_32_lowercase_hex(self): + import re + + from litellm.integrations.langfuse.langfuse_otel import ( + normalize_trace_id_for_otel, + ) + + for seed in ("x", "global_seed", "ABCDEF0123456789ABCDEF0123456789", "1-2-3"): + assert re.fullmatch(r"[0-9a-f]{32}", normalize_trace_id_for_otel(seed)) + + class TestLangfuseOtelResponsesAPI: """Test suite for Langfuse OTEL integration with ResponsesAPI"""