mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-03 07:23:33 +00:00
* fix(otel)!: keep a credential-less langfuse_otel on OTel v2 so key destinations get the Langfuse mapper With LITELLM_OTEL_V2 on, langfuse_otel in callbacks and no operator LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY, the preset raised at startup and the callback fell back to the legacy logger. Key and team destinations were then served by a generic v2 logger without the Langfuse mapper, so their traces lost tags, user and cost, and the legacy logger added an orphaned raw_gen_ai_request span. The preset now always degrades to the credential-gated exporter when the operator has no keys, the same way arize_preset does, so the startup logger stays on v2, exports nowhere for traffic without a destination, and carries the Langfuse mapper to every key and team destination. * test(otel): type the langfuse tenant-only test helpers * fix(otel): give key/team Langfuse destinations the Langfuse fields when langfuse_otel is not a callback With langfuse_otel only in a key's or team's logging, the startup logger is the generic one and its spans reached the key's Langfuse project without user, session or tags. The emitter now adds the Langfuse mapper for a request with a Langfuse destination, and every non-Langfuse exporter strips the Langfuse request metadata, since any logger can now write it. * fix(otel): stop a key's Langfuse logger from re-sending spans to the operator's collector With langfuse_otel only in key or team logging, the proxy publishes the generic V2 logger at startup and it already exports to the operator's OTLP collector. The Langfuse logger a key's request builds kept the same base exporter, so the collector got every model span twice. A logger built after the publish now keeps only its preset's own exporters, as it already did beside a configured V2 logger, and a later call that would rebuild one exporting nowhere reuses it. * test(otel): assert the collector gets one copy instead of reading the key logger's exporters * test(otel): count the collector's spans with one comprehension clause * fix(otel): keep the legacy langfuse_otel logger and stand it down for requests a Langfuse destination carries Reverts the preset degrade and the published-provider exporter trim, so every request without a key or team Langfuse destination keeps main's legacy logger. For a request with one, the V2 fan-out already delivers the whole trace with the Langfuse fields, so the legacy logger emits nothing for it. * test(otel): pin the legacy langfuse_otel paths a tenant's project still gets without a destination * fix(otel): shed a destination's Langfuse request metadata before its other Langfuse fields At its attribute limit a span sheds the Langfuse fields only a destination asked for first, in the mapper's order, so the generation marker went before the request metadata blob. Put the request metadata first, as a Langfuse-configured logger already does. * fix(otel): keep the legacy langfuse_otel span for a destination's call rejected before any provider The OTel v2 fan-out records no call for a request the proxy rejects before a provider (rate limit, pre-call hook), so standing the legacy logger down there lost the user, session and tags main sent the tenant. Stand it down only for a call the fan-out records, using the same check the v2 logger uses. The tenant and collector integration tests now hold their exact counts for a few seconds, so a late duplicate copy fails them. * fix(otel): give a key's Langfuse destination the langfuse_environment the legacy logger wrote The destination's resource carries langfuse.environment from the key's or team's langfuse_environment, else LANGFUSE_TRACING_ENVIRONMENT, so a key's project files its traces under the environment it chose instead of default. Only that destination's export carries it. * fix(otel): keep the Langfuse fields written for a key's Langfuse destination off every other exporter * fix(otel): give a key's Langfuse destination the prompt and response the legacy logger sent, only where it ran * fix(otel): keep a key's Langfuse destination on the global capture setting when it sets none, with or without global Langfuse keys
1589 lines
64 KiB
Python
1589 lines
64 KiB
Python
import asyncio
|
|
import base64
|
|
import contextvars
|
|
import json
|
|
import os
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
from opentelemetry import trace
|
|
from opentelemetry.context import Context
|
|
from opentelemetry.sdk.trace import SpanLimits, TracerProvider
|
|
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
from opentelemetry.trace import Span
|
|
|
|
import litellm
|
|
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL, REDACTED_BY_LITELLM
|
|
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
|
|
from litellm.integrations.opentelemetry import LITELLM_PROXY_REQUEST_SPAN_NAME, OpenTelemetry, OpenTelemetryConfig
|
|
from litellm.integrations.otel.model.destination import OtelDestination
|
|
from litellm.integrations.otel.plumbing.context import set_request_destinations
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.types.llms.openai import ResponsesAPIResponse
|
|
from litellm.types.utils import Choices, ModelResponse, StandardCallbackDynamicParams
|
|
|
|
|
|
class TestLangfuseOtelIntegration:
|
|
def test_get_langfuse_otel_config_with_required_env_vars(self):
|
|
"""Test that config is created correctly with required environment variables."""
|
|
# Clean environment of any Langfuse-related variables
|
|
env_vars_to_clean = [
|
|
"LANGFUSE_HOST",
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
]
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
|
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
|
},
|
|
clear=False,
|
|
):
|
|
# Remove any existing Langfuse variables
|
|
for var in env_vars_to_clean:
|
|
if var in os.environ:
|
|
del os.environ[var]
|
|
|
|
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
|
|
|
assert isinstance(config, OpenTelemetryConfig)
|
|
assert config.exporter == "otlp_http"
|
|
assert "Authorization=Basic" in config.headers
|
|
# Note: We no longer set os.environ explicitly to avoid leakage
|
|
# assert os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") == "https://us.cloud.langfuse.com/api/public/otel"
|
|
# assert "Authorization=Basic" in os.environ.get("OTEL_EXPORTER_OTLP_HEADERS", "")
|
|
|
|
def test_get_langfuse_otel_config_missing_keys(self):
|
|
"""Test that ValueError is raised when required keys are missing."""
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set",
|
|
):
|
|
LangfuseOtelLogger.get_langfuse_otel_config()
|
|
|
|
def test_get_langfuse_otel_config_with_eu_host(self):
|
|
"""Test config with EU host."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
|
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
|
"LANGFUSE_HOST": "https://cloud.langfuse.com",
|
|
},
|
|
clear=False,
|
|
):
|
|
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
|
# Endpoint assertion removed as side effect is gone
|
|
assert isinstance(config, OpenTelemetryConfig)
|
|
|
|
def test_get_langfuse_otel_config_with_custom_host(self):
|
|
"""Test config with custom host."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
|
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
|
"LANGFUSE_HOST": "https://my-langfuse.com",
|
|
},
|
|
clear=False,
|
|
):
|
|
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
|
# Endpoint assertion removed as side effect is gone
|
|
assert isinstance(config, OpenTelemetryConfig)
|
|
|
|
def test_get_langfuse_otel_config_with_host_no_protocol(self):
|
|
"""Test config with custom host without protocol."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
|
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
|
"LANGFUSE_HOST": "my-langfuse.com",
|
|
},
|
|
clear=False,
|
|
):
|
|
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
|
# Endpoint assertion removed as side effect is gone
|
|
assert isinstance(config, OpenTelemetryConfig)
|
|
|
|
def test_set_langfuse_otel_attributes(self):
|
|
"""Test that set_langfuse_otel_attributes calls the Arize utils function."""
|
|
from litellm.integrations.langfuse.langfuse_otel_attributes import (
|
|
LangfuseLLMObsOTELAttributes,
|
|
)
|
|
|
|
mock_span = MagicMock()
|
|
mock_kwargs = {"test": "kwargs"}
|
|
mock_response = {"test": "response"}
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.set_attributes"
|
|
) as mock_set_attributes:
|
|
LangfuseOtelLogger.set_langfuse_otel_attributes(
|
|
mock_span, mock_kwargs, mock_response
|
|
)
|
|
|
|
mock_set_attributes.assert_called_once_with(
|
|
mock_span, mock_kwargs, mock_response, LangfuseLLMObsOTELAttributes
|
|
)
|
|
mock_span.set_attribute.assert_any_call(
|
|
"langfuse.observation.type", "generation"
|
|
)
|
|
|
|
def test_set_langfuse_environment_attribute(self):
|
|
"""Test that Langfuse environment is set correctly when environment variable is present."""
|
|
mock_span = MagicMock()
|
|
mock_kwargs = {"test": "kwargs"}
|
|
test_env = "staging"
|
|
|
|
with patch.dict(os.environ, {"LANGFUSE_TRACING_ENVIRONMENT": test_env}):
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
mock_span, mock_kwargs, {}
|
|
)
|
|
|
|
# safe_set_attribute(span, key, value) → positional args
|
|
mock_safe_set_attribute.assert_any_call(
|
|
mock_span, "langfuse.environment", test_env
|
|
)
|
|
|
|
def test_set_langfuse_environment_attribute_prefers_dynamic_param(self):
|
|
"""Per-key/team langfuse_environment beats the deployment env var."""
|
|
|
|
class _RecordingSpan:
|
|
def __init__(self):
|
|
self.attributes = {}
|
|
|
|
def set_attribute(self, key, value):
|
|
self.attributes[key] = value
|
|
|
|
span = _RecordingSpan()
|
|
mock_kwargs = {
|
|
"standard_callback_dynamic_params": {
|
|
"langfuse_environment": "team-a-env"
|
|
}
|
|
}
|
|
|
|
with patch.dict(
|
|
os.environ, {"LANGFUSE_TRACING_ENVIRONMENT": "deployment-wide"}
|
|
):
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
span, mock_kwargs, {}
|
|
)
|
|
|
|
assert span.attributes["langfuse.environment"] == "team-a-env"
|
|
|
|
@pytest.mark.parametrize(
|
|
"completion_start_time",
|
|
[
|
|
datetime(2026, 10, 9, 12, 0, 0, 250000, tzinfo=timezone.utc),
|
|
datetime(2026, 10, 9, 12, 0, 0, 250000),
|
|
],
|
|
ids=["aware", "naive_local_time"],
|
|
)
|
|
def test_set_langfuse_specific_attributes_completion_start_time(self, completion_start_time: datetime):
|
|
"""The first-token time goes out the way the Langfuse SDK sends it: a JSON string, with an explicit offset."""
|
|
span: Final = TracerProvider().get_tracer(__name__).start_span("litellm_request")
|
|
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(span, {"completion_start_time": completion_start_time}, {})
|
|
|
|
sent: Final = datetime.fromisoformat(json.loads(span.attributes["langfuse.observation.completion_start_time"]))
|
|
assert sent.tzinfo is not None
|
|
assert sent.timestamp() == completion_start_time.timestamp()
|
|
|
|
def test_set_langfuse_specific_attributes_without_completion_start_time(self):
|
|
span: Final = TracerProvider().get_tracer(__name__).start_span("litellm_request")
|
|
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(span, {}, {})
|
|
|
|
assert "langfuse.observation.completion_start_time" not in span.attributes
|
|
|
|
def test_extract_langfuse_metadata_basic(self):
|
|
"""Ensure metadata is correctly pulled from litellm_params."""
|
|
metadata_in = {"generation_name": "my-gen", "custom": "data"}
|
|
kwargs = {"litellm_params": {"metadata": metadata_in}}
|
|
extracted = LangfuseOtelLogger._extract_langfuse_metadata(kwargs)
|
|
assert extracted == metadata_in
|
|
|
|
def test_extract_langfuse_metadata_with_header_enrichment(self, monkeypatch):
|
|
"""_extract_langfuse_metadata should call LangFuseLogger.add_metadata_from_header when available."""
|
|
import sys
|
|
import types
|
|
|
|
# Build a stub module + class on-the-fly
|
|
stub_module = types.ModuleType("litellm.integrations.langfuse.langfuse")
|
|
|
|
class StubLFLogger:
|
|
@staticmethod
|
|
def add_metadata_from_header(litellm_params, metadata):
|
|
# Echo back existing metadata plus a marker
|
|
return {**metadata, "enriched": True}
|
|
|
|
stub_module.LangFuseLogger = StubLFLogger # type: ignore
|
|
|
|
# Register stub in sys.modules so import inside method succeeds.
|
|
# Use monkeypatch so the real module is restored after the test runs,
|
|
# preventing sys.modules corruption that would break patch() targets in
|
|
# later tests (the patch would hit the stub while the real module's
|
|
# globals remain unpatched).
|
|
monkeypatch.setitem(sys.modules, "litellm.integrations.langfuse.langfuse", stub_module) # type: ignore
|
|
|
|
kwargs = {"litellm_params": {"metadata": {"foo": "bar"}}}
|
|
extracted = LangfuseOtelLogger._extract_langfuse_metadata(kwargs)
|
|
assert extracted.get("foo") == "bar"
|
|
assert extracted.get("enriched") is True
|
|
|
|
def test_set_langfuse_specific_attributes_metadata(self):
|
|
"""Verify every supported metadata key maps to the correct OTEL attribute and complex types are JSON-serialised."""
|
|
# Build a sample metadata payload covering all mappings
|
|
metadata = {
|
|
"generation_name": "gen-name",
|
|
"generation_id": "gen-id",
|
|
"parent_observation_id": "parent-id",
|
|
"version": "v1",
|
|
"mask_input": True,
|
|
"mask_output": False,
|
|
"trace_user_id": "user-123",
|
|
"session_id": "sess-456",
|
|
"tags": ["tagA", "tagB"],
|
|
"trace_name": "trace-name",
|
|
"trace_id": "trace-id",
|
|
"trace_metadata": {"k": "v"},
|
|
"trace_version": "t-ver",
|
|
"trace_release": "rel-1",
|
|
"existing_trace_id": "existing-id",
|
|
"update_trace_keys": ["key1", "key2"],
|
|
"debug_langfuse": True,
|
|
}
|
|
kwargs = {"litellm_params": {"metadata": metadata}}
|
|
|
|
# Capture calls to safe_set_attribute
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
MagicMock(), kwargs, None
|
|
)
|
|
|
|
# Build expected calls manually for clarity
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
|
|
expected = {
|
|
LangfuseSpanAttributes.GENERATION_NAME.value: "gen-name",
|
|
LangfuseSpanAttributes.GENERATION_ID.value: "gen-id",
|
|
LangfuseSpanAttributes.PARENT_OBSERVATION_ID.value: "parent-id",
|
|
LangfuseSpanAttributes.VERSION.value: "t-ver",
|
|
LangfuseSpanAttributes.MASK_INPUT.value: True,
|
|
LangfuseSpanAttributes.MASK_OUTPUT.value: False,
|
|
LangfuseSpanAttributes.TRACE_USER_ID.value: "user-123",
|
|
LangfuseSpanAttributes.SESSION_ID.value: "sess-456",
|
|
# Lists / dicts should be JSON strings
|
|
LangfuseSpanAttributes.TAGS.value: json.dumps(["tagA", "tagB"]),
|
|
LangfuseSpanAttributes.TRACE_NAME.value: "trace-name",
|
|
LangfuseSpanAttributes.TRACE_ID.value: "traceid", # stripped dashes
|
|
LangfuseSpanAttributes.TRACE_METADATA.value: json.dumps({"k": "v"}),
|
|
LangfuseSpanAttributes.RELEASE.value: "rel-1",
|
|
LangfuseSpanAttributes.EXISTING_TRACE_ID.value: "existing-id",
|
|
LangfuseSpanAttributes.UPDATE_TRACE_KEYS.value: json.dumps(
|
|
["key1", "key2"]
|
|
),
|
|
LangfuseSpanAttributes.DEBUG_LANGFUSE.value: True,
|
|
}
|
|
|
|
# Flatten the actual calls into {key: value}
|
|
actual = {
|
|
call.args[1]: call.args[2] # (span, key, value)
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
}
|
|
|
|
assert (
|
|
actual == expected
|
|
), "Mismatch between expected and actual OTEL attribute mapping."
|
|
|
|
@pytest.mark.parametrize(
|
|
"metadata, expected_version",
|
|
[
|
|
(
|
|
{"version": "v-observation", "trace_version": "v-trace"},
|
|
"v-trace",
|
|
),
|
|
({"trace_version": "v-trace"}, "v-trace"),
|
|
({"version": "v-observation"}, "v-observation"),
|
|
({"version": "v-observation", "trace_version": ""}, ""),
|
|
({}, None),
|
|
],
|
|
ids=[
|
|
"trace-version-wins-as-documented",
|
|
"trace-only",
|
|
"observation-version-is-the-fallback",
|
|
"empty-trace-version-is-not-absent",
|
|
"neither-key-emits-nothing",
|
|
],
|
|
)
|
|
def test_version_emitted_on_langfuse_v4_key(self, metadata, expected_version):
|
|
kwargs = {"litellm_params": {"metadata": {"trace_release": "rel-9", **metadata}}}
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
MagicMock(), kwargs, None
|
|
)
|
|
|
|
emitted = {
|
|
call.args[1]: call.args[2] for call in mock_safe_set_attribute.call_args_list
|
|
}
|
|
|
|
if expected_version is None:
|
|
assert "langfuse.version" not in emitted
|
|
else:
|
|
assert emitted["langfuse.version"] == expected_version
|
|
assert emitted["langfuse.release"] == "rel-9"
|
|
for retired_key in (
|
|
"langfuse.generation.version",
|
|
"langfuse.trace.version",
|
|
"langfuse.trace.release",
|
|
):
|
|
assert retired_key not in emitted
|
|
|
|
def test_set_langfuse_specific_attributes_with_content(self):
|
|
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with regular content response."""
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
from litellm.types.utils import Choices, ModelResponse
|
|
|
|
# Create response with content
|
|
response_obj = ModelResponse(
|
|
id="chatcmpl-test",
|
|
model="gpt-4o",
|
|
choices=[
|
|
Choices(
|
|
finish_reason="stop",
|
|
message={
|
|
"role": "assistant",
|
|
"content": "The weather in Tokyo is sunny.",
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
kwargs = {
|
|
"messages": [{"role": "user", "content": "What's the weather in Tokyo?"}],
|
|
}
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
MagicMock(), kwargs, response_obj
|
|
)
|
|
|
|
expect_output = {
|
|
LangfuseSpanAttributes.OBSERVATION_INPUT.value: [
|
|
{"role": "user", "content": "What's the weather in Tokyo?"}
|
|
],
|
|
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value: {
|
|
"role": "assistant",
|
|
"content": "The weather in Tokyo is sunny.",
|
|
},
|
|
}
|
|
|
|
# Flatten the actual calls into {key: value}
|
|
actual = {
|
|
call.args[1]: json.loads(call.args[2])
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] in expect_output
|
|
}
|
|
|
|
assert (
|
|
actual == expect_output
|
|
), "Mismatch in observation input/output OTEL attributes."
|
|
|
|
def test_set_langfuse_specific_attributes_with_tool_calls(self):
|
|
"""Test that _set_langfuse_specific_attributes correctly sets observation.output with tool calls in Langfuse format."""
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
from litellm.types.utils import (
|
|
ChatCompletionMessageToolCall,
|
|
Choices,
|
|
Function,
|
|
ModelResponse,
|
|
)
|
|
|
|
# Create response with tool calls
|
|
response_obj = ModelResponse(
|
|
id="chatcmpl-test",
|
|
model="gpt-4o",
|
|
choices=[
|
|
Choices(
|
|
finish_reason="tool_calls",
|
|
message={
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
ChatCompletionMessageToolCall(
|
|
function=Function(
|
|
arguments='{"location":"Tokyo"}', name="get_weather"
|
|
),
|
|
id="call_123",
|
|
type="function",
|
|
)
|
|
],
|
|
},
|
|
)
|
|
],
|
|
)
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
MagicMock(), {}, response_obj
|
|
)
|
|
|
|
expected = {
|
|
LangfuseSpanAttributes.OBSERVATION_OUTPUT.value: [
|
|
{
|
|
"id": "chatcmpl-test",
|
|
"name": "get_weather",
|
|
"arguments": {"location": "Tokyo"},
|
|
"call_id": "call_123",
|
|
"type": "function_call",
|
|
}
|
|
]
|
|
}
|
|
|
|
# Flatten the actual calls into {key: value}
|
|
actual = {
|
|
call.args[1]: json.loads(call.args[2])
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] in expected
|
|
}
|
|
assert (
|
|
actual == expected
|
|
), "Mismatch in observation output OTEL attribute for tool calls."
|
|
|
|
def test_construct_dynamic_otel_headers_with_langfuse_keys(self):
|
|
"""Test that construct_dynamic_otel_headers creates proper auth headers when langfuse keys are provided."""
|
|
from litellm.types.utils import StandardCallbackDynamicParams
|
|
|
|
# Create dynamic params with langfuse keys
|
|
dynamic_params = StandardCallbackDynamicParams(
|
|
langfuse_public_key="test_public_key", langfuse_secret_key="test_secret_key"
|
|
)
|
|
|
|
logger = LangfuseOtelLogger()
|
|
result = logger.construct_dynamic_otel_headers(dynamic_params)
|
|
|
|
# Should return a dict with otlp_auth_headers
|
|
assert result is not None
|
|
assert "Authorization" in result
|
|
|
|
# The auth header should contain the basic auth format
|
|
auth_header = result["Authorization"]
|
|
assert auth_header.startswith("Basic ")
|
|
|
|
# Verify the header format by decoding
|
|
import base64
|
|
|
|
# Extract the base64 part from "Authorization=Basic <base64>"
|
|
base64_part = auth_header.replace("Basic ", "")
|
|
decoded = base64.b64decode(base64_part).decode()
|
|
|
|
assert decoded == "test_public_key:test_secret_key"
|
|
|
|
def test_construct_dynamic_otel_headers_empty_params(self):
|
|
"""Test that construct_dynamic_otel_headers returns empty dict when no langfuse keys are provided."""
|
|
from litellm.types.utils import StandardCallbackDynamicParams
|
|
|
|
# Create dynamic params without langfuse keys
|
|
dynamic_params = StandardCallbackDynamicParams()
|
|
|
|
logger = LangfuseOtelLogger()
|
|
result = logger.construct_dynamic_otel_headers(dynamic_params)
|
|
|
|
# Should return an empty dict
|
|
assert result == {}
|
|
|
|
def test_get_langfuse_otel_config_with_otel_host_priority(self):
|
|
"""LANGFUSE_OTEL_HOST should take priority over LANGFUSE_HOST."""
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"LANGFUSE_PUBLIC_KEY": "test_public_key",
|
|
"LANGFUSE_SECRET_KEY": "test_secret_key",
|
|
"LANGFUSE_HOST": "https://should-not-be-used.com",
|
|
"LANGFUSE_OTEL_HOST": "https://otel-host.com",
|
|
},
|
|
clear=False,
|
|
):
|
|
config = LangfuseOtelLogger.get_langfuse_otel_config()
|
|
assert isinstance(config, OpenTelemetryConfig)
|
|
# Endpoint assertion removed as side effect is gone
|
|
|
|
|
|
def _exported_auth_headers(exporter):
|
|
import requests_mock
|
|
from opentelemetry.sdk.trace.export import SpanExportResult
|
|
|
|
with requests_mock.Mocker() as collector:
|
|
collector.post(requests_mock.ANY, status_code=200)
|
|
assert exporter.export(()) is SpanExportResult.SUCCESS
|
|
return [
|
|
(
|
|
request.url,
|
|
{name: request.headers[name] for name in ("Authorization", "x-langfuse-ingestion-version")},
|
|
)
|
|
for request in collector.request_history
|
|
]
|
|
|
|
|
|
class TestLangfuseOtelKeyDynamicConfig:
|
|
"""Key/team-scoped Langfuse credentials must define the full export target
|
|
(OTLP endpoint + auth), not just auth headers on the init-time exporter."""
|
|
|
|
CLEAN_ENV_VARS = [
|
|
"LANGFUSE_PUBLIC_KEY",
|
|
"LANGFUSE_SECRET_KEY",
|
|
"LANGFUSE_HOST",
|
|
"LANGFUSE_OTEL_HOST",
|
|
"OTEL_EXPORTER",
|
|
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
|
"OTEL_ENDPOINT",
|
|
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
"OTEL_HEADERS",
|
|
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
]
|
|
|
|
def _clean_env(self):
|
|
cleaned = {k: v for k, v in os.environ.items() if k not in self.CLEAN_ENV_VARS}
|
|
return patch.dict(os.environ, cleaned, clear=True)
|
|
|
|
def _dynamic_params(self, **overrides):
|
|
from litellm.types.utils import StandardCallbackDynamicParams
|
|
|
|
params = {
|
|
"langfuse_public_key": "key_public",
|
|
"langfuse_secret_key": "key_secret",
|
|
"langfuse_host": "https://langfuse.example.com",
|
|
}
|
|
params.update(overrides)
|
|
return StandardCallbackDynamicParams(**{k: v for k, v in params.items() if v is not None})
|
|
|
|
def test_construct_dynamic_otel_config_with_key_credentials(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
config = logger.construct_dynamic_otel_config(self._dynamic_params())
|
|
|
|
assert config is not None
|
|
assert config.exporter == "otlp_http"
|
|
assert config.endpoint == "https://langfuse.example.com/api/public/otel"
|
|
|
|
import base64
|
|
|
|
expected_auth = base64.b64encode(b"key_public:key_secret").decode()
|
|
assert config.headers == f"Authorization=Basic {expected_auth},x-langfuse-ingestion-version=4"
|
|
|
|
def test_construct_dynamic_otel_config_host_without_protocol(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
config = logger.construct_dynamic_otel_config(self._dynamic_params(langfuse_host="langfuse.example.com"))
|
|
|
|
assert config is not None
|
|
assert config.endpoint == "https://langfuse.example.com/api/public/otel"
|
|
|
|
def test_construct_dynamic_otel_config_defaults_to_us_cloud(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
config = logger.construct_dynamic_otel_config(self._dynamic_params(langfuse_host=None))
|
|
|
|
assert config is not None
|
|
assert config.endpoint == "https://us.cloud.langfuse.com/api/public/otel"
|
|
|
|
def test_construct_dynamic_otel_config_falls_back_to_env_host(self):
|
|
with self._clean_env():
|
|
with patch.dict(os.environ, {"LANGFUSE_HOST": "https://env-host.example.com"}):
|
|
logger = LangfuseOtelLogger()
|
|
config = logger.construct_dynamic_otel_config(self._dynamic_params(langfuse_host=None))
|
|
|
|
assert config is not None
|
|
assert config.endpoint == "https://env-host.example.com/api/public/otel"
|
|
|
|
def test_construct_dynamic_otel_config_requires_both_keys(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
|
|
assert logger.construct_dynamic_otel_config(self._dynamic_params(langfuse_secret_key=None)) is None
|
|
assert logger.construct_dynamic_otel_config(self._dynamic_params(langfuse_public_key=None)) is None
|
|
|
|
def test_key_dynamic_params_create_otlp_exporter_without_global_env(self):
|
|
"""Without global LANGFUSE_* env vars, a request carrying key-scoped Langfuse
|
|
credentials must get a tracer exporting via OTLP HTTP to that key's host."""
|
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
OTLPSpanExporter,
|
|
)
|
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
assert logger.OTEL_EXPORTER == "console"
|
|
|
|
tracer = logger.get_tracer_to_use_for_request(
|
|
{"standard_callback_dynamic_params": self._dynamic_params()}
|
|
)
|
|
|
|
assert tracer is not logger.tracer
|
|
assert len(logger._tracer_provider_cache) == 1
|
|
|
|
provider = next(iter(logger._tracer_provider_cache.values())).provider
|
|
span_processors = provider._active_span_processor._span_processors
|
|
assert len(span_processors) == 1
|
|
assert isinstance(span_processors[0], BatchSpanProcessor)
|
|
|
|
exporter = span_processors[0].span_exporter
|
|
assert isinstance(exporter, OTLPSpanExporter)
|
|
import base64
|
|
|
|
expected_auth = base64.b64encode(b"key_public:key_secret").decode()
|
|
assert _exported_auth_headers(exporter) == [
|
|
(
|
|
"https://langfuse.example.com/api/public/otel/v1/traces",
|
|
{"Authorization": f"Basic {expected_auth}", "x-langfuse-ingestion-version": "4"},
|
|
)
|
|
]
|
|
|
|
def test_key_dynamic_params_reuse_cached_provider(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
kwargs = {"standard_callback_dynamic_params": self._dynamic_params()}
|
|
logger.get_tracer_to_use_for_request(kwargs)
|
|
logger.get_tracer_to_use_for_request(kwargs)
|
|
|
|
assert len(logger._tracer_provider_cache) == 1
|
|
|
|
def test_no_dynamic_params_keeps_default_tracer(self):
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
tracer = logger.get_tracer_to_use_for_request({})
|
|
|
|
assert tracer is logger.tracer
|
|
assert logger._tracer_provider_cache == {}
|
|
|
|
def test_key_credentials_never_passed_to_debug_logger(self):
|
|
"""The span-processor debug logs must receive a redacted header value, so the
|
|
key-scoped Langfuse secret never enters a log record regardless of downstream
|
|
handler configuration, while the exporter still gets the real header."""
|
|
import base64
|
|
|
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
OTLPSpanExporter,
|
|
)
|
|
|
|
from litellm.integrations import opentelemetry as otel_module
|
|
|
|
secret = base64.b64encode(b"key_public:key_secret").decode()
|
|
|
|
recorded_arguments = []
|
|
|
|
def _spy(message, *args, **kwargs):
|
|
recorded_arguments.append(" ".join(str(part) for part in (message, *args)))
|
|
|
|
with self._clean_env():
|
|
logger = LangfuseOtelLogger()
|
|
with patch.object(otel_module.verbose_logger, "debug", side_effect=_spy):
|
|
logger.get_tracer_to_use_for_request(
|
|
{"standard_callback_dynamic_params": self._dynamic_params()}
|
|
)
|
|
|
|
logged = "\n".join(recorded_arguments)
|
|
assert "initializing span processor" in logged
|
|
assert secret not in logged
|
|
assert f"Basic {secret}" not in logged
|
|
|
|
provider = next(iter(logger._tracer_provider_cache.values())).provider
|
|
exporter = provider._active_span_processor._span_processors[0].span_exporter
|
|
assert isinstance(exporter, OTLPSpanExporter)
|
|
assert _exported_auth_headers(exporter) == [
|
|
(
|
|
"https://langfuse.example.com/api/public/otel/v1/traces",
|
|
{"Authorization": f"Basic {secret}", "x-langfuse-ingestion-version": "4"},
|
|
)
|
|
]
|
|
|
|
|
|
class TestLangfuseOtelResponsesAPI:
|
|
"""Test suite for Langfuse OTEL integration with ResponsesAPI"""
|
|
|
|
def test_langfuse_otel_with_responses_api(self):
|
|
"""Test that Langfuse OTEL logger works with ResponsesAPI responses and logs metadata."""
|
|
# Create a mock ResponsesAPIResponse
|
|
mock_response = ResponsesAPIResponse(
|
|
id="response-123",
|
|
created_at=1234567890,
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": "Hello from responses API"}],
|
|
}
|
|
],
|
|
parallel_tool_calls=False,
|
|
tool_choice="auto",
|
|
tools=[],
|
|
top_p=1.0,
|
|
)
|
|
|
|
# Create kwargs with metadata that should be logged
|
|
test_metadata = {
|
|
"user_id": "test123",
|
|
"session_id": "abc456",
|
|
"custom_field": "test_value",
|
|
"generation_name": "responses_test_generation",
|
|
"trace_name": "responses_api_trace",
|
|
}
|
|
|
|
kwargs = {
|
|
"call_type": "responses",
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
"model": "gpt-4o",
|
|
"optional_params": {},
|
|
"litellm_params": {"metadata": test_metadata},
|
|
}
|
|
|
|
mock_span = MagicMock()
|
|
|
|
from litellm.integrations.langfuse.langfuse_otel_attributes import (
|
|
LangfuseLLMObsOTELAttributes,
|
|
)
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.set_attributes"
|
|
) as mock_set_attributes:
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
logger = LangfuseOtelLogger()
|
|
logger.set_langfuse_otel_attributes(mock_span, kwargs, mock_response)
|
|
|
|
# Verify that set_attributes was called for general attributes
|
|
mock_set_attributes.assert_called_once_with(
|
|
mock_span, kwargs, mock_response, LangfuseLLMObsOTELAttributes
|
|
)
|
|
|
|
# Verify that Langfuse-specific attributes were set
|
|
mock_safe_set_attribute.assert_any_call(
|
|
mock_span, "langfuse.generation.name", "responses_test_generation"
|
|
)
|
|
mock_safe_set_attribute.assert_any_call(
|
|
mock_span, "langfuse.trace.name", "responses_api_trace"
|
|
)
|
|
|
|
def test_responses_api_metadata_extraction(self):
|
|
"""Test that metadata is correctly extracted from ResponsesAPI kwargs."""
|
|
# Clean up any existing module mocks
|
|
import sys
|
|
|
|
if "litellm.integrations.langfuse.langfuse" in sys.modules:
|
|
sys.modules["litellm.integrations.langfuse.langfuse"]
|
|
|
|
test_metadata = {
|
|
"user_id": "responses_user_123",
|
|
"session_id": "responses_session_456",
|
|
"custom_metadata": {"key": "value"},
|
|
"generation_name": "responses_generation",
|
|
"trace_id": "custom_trace_id",
|
|
}
|
|
|
|
kwargs = {
|
|
"call_type": "responses",
|
|
"model": "gpt-4o",
|
|
"litellm_params": {"metadata": test_metadata},
|
|
}
|
|
|
|
extracted_metadata = LangfuseOtelLogger._extract_langfuse_metadata(kwargs)
|
|
|
|
# Verify all expected metadata was extracted (may have additional fields from header enrichment)
|
|
for key, value in test_metadata.items():
|
|
assert extracted_metadata[key] == value
|
|
|
|
assert extracted_metadata["user_id"] == "responses_user_123"
|
|
assert extracted_metadata["generation_name"] == "responses_generation"
|
|
assert extracted_metadata["trace_id"] == "custom_trace_id"
|
|
|
|
def test_responses_api_langfuse_specific_attributes(self):
|
|
"""Test that ResponsesAPI metadata maps correctly to Langfuse OTEL attributes."""
|
|
metadata = {
|
|
"generation_name": "responses_gen",
|
|
"generation_id": "resp_gen_123",
|
|
"trace_name": "responses_trace",
|
|
"trace_user_id": "resp_user_456",
|
|
"session_id": "resp_session_789",
|
|
"tags": ["responses", "api", "test"],
|
|
"trace_metadata": {"source": "responses_api", "version": "1.0"},
|
|
}
|
|
|
|
kwargs = {"call_type": "responses", "litellm_params": {"metadata": metadata}}
|
|
|
|
mock_span = MagicMock()
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, {})
|
|
|
|
# Verify specific attributes were set
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
|
|
expected_calls = [
|
|
(
|
|
mock_span,
|
|
LangfuseSpanAttributes.GENERATION_NAME.value,
|
|
"responses_gen",
|
|
),
|
|
(mock_span, LangfuseSpanAttributes.GENERATION_ID.value, "resp_gen_123"),
|
|
(mock_span, LangfuseSpanAttributes.TRACE_NAME.value, "responses_trace"),
|
|
(
|
|
mock_span,
|
|
LangfuseSpanAttributes.TRACE_USER_ID.value,
|
|
"resp_user_456",
|
|
),
|
|
(
|
|
mock_span,
|
|
LangfuseSpanAttributes.SESSION_ID.value,
|
|
"resp_session_789",
|
|
),
|
|
(
|
|
mock_span,
|
|
LangfuseSpanAttributes.TAGS.value,
|
|
json.dumps(["responses", "api", "test"]),
|
|
),
|
|
(
|
|
mock_span,
|
|
LangfuseSpanAttributes.TRACE_METADATA.value,
|
|
json.dumps({"source": "responses_api", "version": "1.0"}),
|
|
),
|
|
]
|
|
|
|
for expected_call in expected_calls:
|
|
mock_safe_set_attribute.assert_any_call(*expected_call)
|
|
|
|
def test_responses_api_with_output(self):
|
|
"""Test Langfuse OTEL logger with Responses API output (reasoning + message)."""
|
|
from openai.types.responses import (
|
|
ResponseReasoningItem,
|
|
ResponseOutputMessage,
|
|
ResponseOutputText,
|
|
)
|
|
from openai.types.responses.response_reasoning_item import Summary
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
|
|
# Create Responses API response with reasoning and message
|
|
response_obj = ResponsesAPIResponse(
|
|
id="response-456",
|
|
created_at=1625247600,
|
|
output=[
|
|
ResponseReasoningItem(
|
|
id="reasoning-001",
|
|
type="reasoning",
|
|
summary=[
|
|
Summary(
|
|
text="Let me analyze this problem step by step...",
|
|
type="summary_text",
|
|
),
|
|
Summary(
|
|
text="Now checking the forecast data.",
|
|
type="summary_text",
|
|
),
|
|
],
|
|
),
|
|
ResponseOutputMessage(
|
|
id="msg-001",
|
|
type="message",
|
|
role="assistant",
|
|
status="completed",
|
|
content=[
|
|
ResponseOutputText(
|
|
annotations=[],
|
|
text="The weather in San Francisco is sunny, 20°C.",
|
|
type="output_text",
|
|
)
|
|
],
|
|
),
|
|
],
|
|
)
|
|
|
|
kwargs = {
|
|
"call_type": "responses",
|
|
"messages": [
|
|
{"role": "user", "content": "What's the weather in San Francisco?"}
|
|
],
|
|
"model": "gpt-4o",
|
|
"optional_params": {},
|
|
}
|
|
|
|
mock_span = MagicMock()
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
mock_span, kwargs, response_obj
|
|
)
|
|
|
|
# Verify observation output was set
|
|
output_calls = [
|
|
call
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
|
|
]
|
|
|
|
assert len(output_calls) > 0, "observation.output should be set"
|
|
output_json = output_calls[0].args[2]
|
|
output_data = json.loads(output_json)
|
|
|
|
# Verify output contains reasoning and message
|
|
assert isinstance(output_data, list)
|
|
assert len(output_data) == 3
|
|
|
|
assert output_data[0]["role"] == "reasoning_summary"
|
|
assert (
|
|
output_data[0]["content"]
|
|
== "Let me analyze this problem step by step..."
|
|
)
|
|
assert output_data[1]["role"] == "reasoning_summary"
|
|
assert output_data[1]["content"] == "Now checking the forecast data."
|
|
|
|
# Verify message
|
|
assert output_data[2]["role"] == "assistant"
|
|
assert (
|
|
output_data[2]["content"]
|
|
== "The weather in San Francisco is sunny, 20°C."
|
|
)
|
|
|
|
trace_output_calls: Final = [
|
|
call
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] == LangfuseSpanAttributes.TRACE_OUTPUT.value
|
|
]
|
|
assert len(trace_output_calls) > 0, "trace.output should be set"
|
|
assert trace_output_calls[0].args[2] == output_json
|
|
|
|
def test_responses_api_with_function_calls(self):
|
|
"""Test Langfuse OTEL logger with Responses API function_call output."""
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
|
|
# Create Responses API response with function call
|
|
response_obj = ResponsesAPIResponse(
|
|
id="response-789",
|
|
created_at=1625247700,
|
|
output=[
|
|
ResponseFunctionToolCall(
|
|
id="fc-123",
|
|
type="function_call",
|
|
name="get_weather",
|
|
call_id="call-abc",
|
|
arguments='{"location": "San Francisco", "unit": "celsius"}',
|
|
status="completed",
|
|
)
|
|
],
|
|
)
|
|
|
|
kwargs = {
|
|
"call_type": "responses",
|
|
"messages": [
|
|
{"role": "user", "content": "What's the weather in San Francisco?"}
|
|
],
|
|
"model": "gpt-4o",
|
|
"optional_params": {},
|
|
}
|
|
|
|
mock_span = MagicMock()
|
|
|
|
with patch(
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
mock_span, kwargs, response_obj
|
|
)
|
|
|
|
# Verify observation output was set
|
|
output_calls = [
|
|
call
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
|
|
]
|
|
|
|
assert len(output_calls) > 0, "observation.output should be set"
|
|
output_json = output_calls[0].args[2]
|
|
output_data = json.loads(output_json)
|
|
|
|
# Verify output contains function call
|
|
assert isinstance(output_data, list)
|
|
assert len(output_data) == 1
|
|
|
|
# Verify function call details
|
|
assert output_data[0]["type"] == "function_call"
|
|
assert output_data[0]["id"] == "fc-123"
|
|
assert output_data[0]["name"] == "get_weather"
|
|
assert output_data[0]["call_id"] == "call-abc"
|
|
assert output_data[0]["arguments"]["location"] == "San Francisco"
|
|
assert output_data[0]["arguments"]["unit"] == "celsius"
|
|
|
|
def test_responses_api_function_call_with_redacted_arguments(self):
|
|
"""Sentinel arguments (invalid JSON) must not kill the whole observation output."""
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
|
|
from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
|
|
|
|
response_obj = ResponsesAPIResponse(
|
|
id="response-redacted",
|
|
created_at=1625247700,
|
|
output=[
|
|
ResponseFunctionToolCall(
|
|
id="fc-redacted",
|
|
type="function_call",
|
|
name="get_weather",
|
|
call_id="call-redacted",
|
|
arguments="redacted-by-litellm",
|
|
status="completed",
|
|
)
|
|
],
|
|
)
|
|
|
|
kwargs = {
|
|
"call_type": "responses",
|
|
"messages": [{"role": "user", "content": "What's the weather?"}],
|
|
"model": "gpt-4o",
|
|
"optional_params": {},
|
|
}
|
|
|
|
mock_span = MagicMock()
|
|
|
|
with patch( # test-quality-ok: the span attribute sink is the observable boundary; sibling tests in this class stub the same seam
|
|
"litellm.integrations.arize._utils.safe_set_attribute"
|
|
) as mock_safe_set_attribute:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, response_obj)
|
|
|
|
output_calls = [
|
|
call
|
|
for call in mock_safe_set_attribute.call_args_list
|
|
if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
|
|
]
|
|
|
|
assert len(output_calls) > 0, "observation.output should still be set"
|
|
output_data = json.loads(output_calls[0].args[2])
|
|
assert output_data[0]["name"] == "get_weather"
|
|
assert output_data[0]["arguments"] == {}
|
|
|
|
|
|
_TRACER: Final = TracerProvider().get_tracer("langfuse-otel-tests")
|
|
TRACE_FIELD_KEYS: Final = (
|
|
"langfuse.trace.name",
|
|
"langfuse.trace.input",
|
|
"langfuse.trace.output",
|
|
"langfuse.trace.tags",
|
|
)
|
|
CALLER: Final = trace.SpanContext(
|
|
trace_id=0x4BF92F3577B34DA6A3CE929D0E0E4736,
|
|
span_id=0x00F067AA0BA902B7,
|
|
is_remote=True,
|
|
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
|
|
)
|
|
CHAT_KWARGS: Final = {
|
|
"call_type": "acompletion",
|
|
"messages": [{"role": "user", "content": "weather?"}],
|
|
}
|
|
|
|
|
|
def _chat_response(content: str) -> ModelResponse:
|
|
return ModelResponse(
|
|
id="chatcmpl-derived",
|
|
model="gpt-4o",
|
|
choices=[Choices(finish_reason="stop", message={"role": "assistant", "content": content})],
|
|
)
|
|
|
|
|
|
def _stamped(span: Span, kwargs: Mapping[str, object], response_obj: object = None) -> Mapping[str, object]:
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(span, kwargs, response_obj)
|
|
span.end()
|
|
return dict(getattr(span, "attributes", None) or {})
|
|
|
|
|
|
def _emitted(kwargs: Mapping[str, object], response_obj: object = None) -> Mapping[str, object]:
|
|
return _stamped(_TRACER.start_span("litellm_request"), kwargs, response_obj)
|
|
|
|
|
|
def _trace_fields(attributes: Mapping[str, object]) -> Mapping[str, object]:
|
|
return {key: attributes[key] for key in TRACE_FIELD_KEYS if key in attributes}
|
|
|
|
|
|
class _PresetCache:
|
|
@staticmethod
|
|
def get_preset_cache_key_from_kwargs(**kwargs: object) -> str: # kwargs-ok: mirrors Cache's signature
|
|
return "preset-abc"
|
|
|
|
|
|
class TestDerivedTraceFields:
|
|
def test_chat_call_derives_trace_name_input_and_output(self) -> None:
|
|
attributes: Final = _emitted({**CHAT_KWARGS, "litellm_params": {"metadata": {}}}, _chat_response("Sunny."))
|
|
|
|
assert attributes["langfuse.trace.name"] == "litellm-acompletion"
|
|
assert attributes["langfuse.trace.input"] == attributes["langfuse.observation.input"]
|
|
assert attributes["langfuse.trace.output"] == attributes["langfuse.observation.output"]
|
|
assert json.loads(str(attributes["langfuse.trace.input"])) == [{"role": "user", "content": "weather?"}]
|
|
assert json.loads(str(attributes["langfuse.trace.output"])) == {"role": "assistant", "content": "Sunny."}
|
|
|
|
def test_missing_call_type_defaults_to_completion(self) -> None:
|
|
attributes: Final = _emitted(
|
|
{"messages": [{"role": "user", "content": "hi"}], "litellm_params": {"metadata": {}}}
|
|
)
|
|
|
|
assert attributes["langfuse.trace.name"] == "litellm-completion"
|
|
|
|
def test_caller_trace_name_wins_and_is_the_only_name(self) -> None:
|
|
attributes: Final = _emitted(
|
|
{"call_type": "acompletion", "litellm_params": {"metadata": {"trace_name": "caller-trace"}}}
|
|
)
|
|
|
|
assert attributes["langfuse.trace.name"] == "caller-trace"
|
|
|
|
def test_existing_trace_id_keeps_caller_tags_and_leaves_derived_trace_fields_off(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "langfuse_default_tags", ["cache_hit"])
|
|
attributes: Final = _emitted(
|
|
{
|
|
**CHAT_KWARGS,
|
|
"cache_hit": True,
|
|
"litellm_params": {"metadata": {"existing_trace_id": "abc123", "tags": ["experiment"]}},
|
|
"standard_logging_object": {"request_tags": ["injected"]},
|
|
},
|
|
_chat_response("hi"),
|
|
)
|
|
|
|
assert _trace_fields(attributes) == {"langfuse.trace.tags": json.dumps(["experiment"])}
|
|
assert attributes["langfuse.trace.existing_id"] == "abc123"
|
|
assert "langfuse.observation.input" in attributes
|
|
assert "langfuse.observation.output" in attributes
|
|
|
|
@pytest.mark.parametrize(
|
|
("mask_flags", "masked_trace_keys"),
|
|
[
|
|
({"mask_input": True}, frozenset({"langfuse.trace.input"})),
|
|
({"mask_output": True}, frozenset({"langfuse.trace.output"})),
|
|
({"mask_input": True, "mask_output": True}, frozenset({"langfuse.trace.input", "langfuse.trace.output"})),
|
|
(
|
|
{"mask_input": "true", "mask_output": "TRUE"},
|
|
frozenset({"langfuse.trace.input", "langfuse.trace.output"}),
|
|
),
|
|
({"mask_input": "false", "mask_output": "False"}, frozenset()),
|
|
({"mask_input": False, "mask_output": None}, frozenset()),
|
|
],
|
|
)
|
|
def test_mask_flags_redact_the_derived_trace_io(
|
|
self, mask_flags: Mapping[str, object], masked_trace_keys: frozenset[str]
|
|
) -> None:
|
|
attributes: Final = _emitted(
|
|
{
|
|
"call_type": "acompletion",
|
|
"messages": [{"role": "user", "content": "secret question"}],
|
|
"litellm_params": {"metadata": mask_flags},
|
|
},
|
|
_chat_response("secret answer"),
|
|
)
|
|
observation_of: Final = {
|
|
"langfuse.trace.input": attributes["langfuse.observation.input"],
|
|
"langfuse.trace.output": attributes["langfuse.observation.output"],
|
|
}
|
|
|
|
assert {key: attributes.get(key) for key in observation_of} == {
|
|
key: REDACTED_BY_LITELLM if key in masked_trace_keys else observation
|
|
for key, observation in observation_of.items()
|
|
}
|
|
|
|
def test_responses_api_output_mirrors_observation_output_items(self) -> None:
|
|
response_obj: Final = ResponsesAPIResponse(
|
|
id="response-derived",
|
|
created_at=1625247700,
|
|
output=[
|
|
ResponseFunctionToolCall(
|
|
id="fc-1",
|
|
type="function_call",
|
|
name="get_weather",
|
|
call_id="call-1",
|
|
arguments='{"city":"sf"}',
|
|
status="completed",
|
|
)
|
|
],
|
|
)
|
|
attributes: Final = _emitted({"call_type": "aresponses", "litellm_params": {"metadata": {}}}, response_obj)
|
|
|
|
assert attributes["langfuse.trace.name"] == "litellm-aresponses"
|
|
assert attributes["langfuse.trace.output"] == attributes["langfuse.observation.output"]
|
|
assert json.loads(str(attributes["langfuse.trace.output"])) == [
|
|
{
|
|
"id": "fc-1",
|
|
"name": "get_weather",
|
|
"call_id": "call-1",
|
|
"type": "function_call",
|
|
"arguments": {"city": "sf"},
|
|
}
|
|
]
|
|
|
|
def test_tags_merge_caller_request_and_default_tags_in_order(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(litellm, "langfuse_default_tags", ["cache_hit", "user_api_key_alias"])
|
|
attributes: Final = _emitted(
|
|
{
|
|
"call_type": "acompletion",
|
|
"cache_hit": True,
|
|
"litellm_params": {"metadata": {"tags": ["a", "b"], "user_api_key_alias": "k1"}},
|
|
"standard_logging_object": {"request_tags": ["b", "c"]},
|
|
}
|
|
)
|
|
|
|
assert json.loads(str(attributes["langfuse.trace.tags"])) == [
|
|
"a",
|
|
"b",
|
|
"c",
|
|
"cache_hit:True",
|
|
"user_api_key_alias:k1",
|
|
]
|
|
|
|
@pytest.mark.parametrize(
|
|
("metadata", "expected"),
|
|
[
|
|
({}, ["cache_key:preset-abc"]),
|
|
({"hidden_params": {"cache_key": "explicit-key"}}, ["cache_key:explicit-key"]),
|
|
],
|
|
ids=["falls_back_to_the_preset_cache_key", "prefers_hidden_params_over_the_preset"],
|
|
)
|
|
def test_cache_key_default_tag(
|
|
self, monkeypatch: pytest.MonkeyPatch, metadata: Mapping[str, object], expected: Sequence[str]
|
|
) -> None:
|
|
monkeypatch.setattr(litellm, "langfuse_default_tags", ["cache_key"])
|
|
monkeypatch.setattr(litellm, "cache", _PresetCache())
|
|
attributes: Final = _emitted({"call_type": "acompletion", "litellm_params": {"metadata": metadata}})
|
|
|
|
assert json.loads(str(attributes["langfuse.trace.tags"])) == expected
|
|
|
|
@pytest.mark.parametrize(
|
|
("caller_tags", "expected"),
|
|
[
|
|
("single", ["single", "req"]),
|
|
("h1, h2", ["h1", "h2", "req"]),
|
|
('["j1", "j2"]', ["j1", "j2", "req"]),
|
|
(["x", 1], ["x", "1", "req"]),
|
|
("", ["req"]),
|
|
],
|
|
)
|
|
def test_caller_tags_are_read_the_way_langfuse_reads_a_raw_tags_value(
|
|
self, caller_tags: object, expected: Sequence[str]
|
|
) -> None:
|
|
attributes: Final = _emitted(
|
|
{
|
|
"call_type": "acompletion",
|
|
"litellm_params": {"metadata": {"tags": caller_tags}},
|
|
"standard_logging_object": {"request_tags": ["req"]},
|
|
}
|
|
)
|
|
|
|
assert json.loads(str(attributes["langfuse.trace.tags"])) == expected
|
|
|
|
def test_no_tags_anywhere_leaves_trace_tags_unset(self) -> None:
|
|
attributes: Final = _emitted({"call_type": "acompletion", "litellm_params": {"metadata": {}}})
|
|
|
|
assert "langfuse.trace.tags" not in attributes
|
|
|
|
def test_empty_string_role_is_omitted_from_output(self) -> None:
|
|
response_obj: Final = {"id": "chatcmpl-norole", "choices": [{"message": {"role": "", "content": "hi"}}]}
|
|
attributes: Final = _emitted({"call_type": "acompletion", "litellm_params": {"metadata": {}}}, response_obj)
|
|
|
|
assert json.loads(str(attributes["langfuse.observation.output"])) == {"content": "hi"}
|
|
|
|
def test_no_messages_leaves_trace_input_unset_but_emits_output(self) -> None:
|
|
attributes: Final = _emitted(
|
|
{"call_type": "acompletion", "litellm_params": {"metadata": {}}}, _chat_response("hi")
|
|
)
|
|
|
|
assert "langfuse.trace.input" not in attributes
|
|
assert "langfuse.observation.input" not in attributes
|
|
assert attributes["langfuse.trace.output"] == attributes["langfuse.observation.output"]
|
|
|
|
|
|
def _proxy_span(parent: Context | None = None) -> Span:
|
|
return _TRACER.start_span(LITELLM_PROXY_REQUEST_SPAN_NAME, context=parent)
|
|
|
|
|
|
def _child_of(parent: Span) -> Span:
|
|
return _TRACER.start_span("litellm_request", context=trace.set_span_in_context(parent))
|
|
|
|
|
|
def _carrying(parent: Span, key: str = "metadata") -> Mapping[str, object]:
|
|
return {**CHAT_KWARGS, "litellm_params": {key: {"litellm_parent_otel_span": parent}}}
|
|
|
|
|
|
class TestDerivedTraceFieldsOnlyOnTracesLitellmStarts:
|
|
def test_a_span_under_an_inbound_traceparent_joins_the_caller_trace(self) -> None:
|
|
span: Final = _TRACER.start_span(
|
|
"litellm_request", context=trace.set_span_in_context(trace.NonRecordingSpan(CALLER))
|
|
)
|
|
|
|
attributes: Final = _stamped(span, {**CHAT_KWARGS, "litellm_params": {"metadata": {}}}, _chat_response("ok"))
|
|
|
|
assert _trace_fields(attributes) == {}
|
|
assert "langfuse.observation.input" in attributes and "langfuse.observation.output" in attributes
|
|
|
|
def test_the_active_app_span_litellm_writes_onto_keeps_its_trace_fields(self) -> None:
|
|
with _TRACER.start_as_current_span("checkout") as app_span:
|
|
app_span.set_attribute("langfuse.trace.name", "checkout")
|
|
|
|
attributes: Final = _stamped(
|
|
app_span, {**CHAT_KWARGS, "litellm_params": {"metadata": {}}}, _chat_response("ok")
|
|
)
|
|
|
|
assert _trace_fields(attributes) == {"langfuse.trace.name": "checkout"}
|
|
|
|
def test_a_litellm_root_span_derives_while_an_app_span_is_current(self) -> None:
|
|
with _TRACER.start_as_current_span("checkout"):
|
|
span: Final = _TRACER.start_span("litellm_request", context=Context())
|
|
|
|
attributes: Final = _stamped(
|
|
span, {**CHAT_KWARGS, "litellm_params": {"metadata": {}}}, _chat_response("ok")
|
|
)
|
|
|
|
assert attributes["langfuse.trace.name"] == "litellm-acompletion"
|
|
|
|
def test_a_span_under_a_caller_provided_parent_span_joins_the_caller_trace(self) -> None:
|
|
caller_span: Final = _TRACER.start_span("caller-root")
|
|
|
|
attributes: Final = _stamped(_child_of(caller_span), _carrying(caller_span), _chat_response("ok"))
|
|
|
|
assert _trace_fields(attributes) == {}
|
|
|
|
@pytest.mark.parametrize("key", ["metadata", "litellm_metadata"])
|
|
def test_a_span_under_litellms_own_unparented_proxy_span_derives(self, key: str) -> None:
|
|
proxy_span: Final = _proxy_span()
|
|
|
|
attributes: Final = _stamped(_child_of(proxy_span), _carrying(proxy_span, key), _chat_response("ok"))
|
|
|
|
assert attributes["langfuse.trace.name"] == "litellm-acompletion"
|
|
assert attributes["langfuse.trace.input"] == attributes["langfuse.observation.input"]
|
|
assert attributes["langfuse.trace.output"] == attributes["langfuse.observation.output"]
|
|
|
|
def test_a_span_under_a_proxy_span_with_an_inbound_traceparent_joins_the_caller_trace(self) -> None:
|
|
proxy_span: Final = _proxy_span(trace.set_span_in_context(trace.NonRecordingSpan(CALLER)))
|
|
|
|
attributes: Final = _stamped(_child_of(proxy_span), _carrying(proxy_span), _chat_response("ok"))
|
|
|
|
assert _trace_fields(attributes) == {}
|
|
|
|
def test_a_span_whose_parent_is_not_the_carried_proxy_span_joins_that_parent_trace(self) -> None:
|
|
other_parent: Final = _TRACER.start_span("caller-root")
|
|
|
|
attributes: Final = _stamped(_child_of(other_parent), _carrying(_proxy_span()), _chat_response("ok"))
|
|
|
|
assert _trace_fields(attributes) == {}
|
|
|
|
def test_an_inbound_traceparent_still_carries_the_caller_trace_name_and_tags(self) -> None:
|
|
span: Final = _TRACER.start_span(
|
|
"litellm_request", context=trace.set_span_in_context(trace.NonRecordingSpan(CALLER))
|
|
)
|
|
|
|
attributes: Final = _stamped(
|
|
span,
|
|
{**CHAT_KWARGS, "litellm_params": {"metadata": {"trace_name": "caller-trace", "tags": ["caller"]}}},
|
|
_chat_response("ok"),
|
|
)
|
|
|
|
assert _trace_fields(attributes) == {
|
|
"langfuse.trace.name": "caller-trace",
|
|
"langfuse.trace.tags": json.dumps(["caller"]),
|
|
}
|
|
|
|
|
|
ERROR_KEYS: Final = (
|
|
"error.code",
|
|
"http.response.status_code",
|
|
"error.type",
|
|
"error.message",
|
|
"error.llm_provider",
|
|
"error.stack_trace",
|
|
)
|
|
SPAN_ATTRIBUTE_LIMIT: Final = 128
|
|
_LIMITED_TRACER: Final = TracerProvider(span_limits=SpanLimits(max_span_attributes=SPAN_ATTRIBUTE_LIMIT)).get_tracer(
|
|
"langfuse-otel-limit-tests"
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ExportedSpan:
|
|
keys: frozenset[str]
|
|
dropped: int
|
|
|
|
|
|
def _exported_full_span(filler: int, *, joins_caller: bool, failed: bool) -> ExportedSpan:
|
|
span: Final = _LIMITED_TRACER.start_span(
|
|
"litellm_request",
|
|
context=trace.set_span_in_context(trace.NonRecordingSpan(CALLER)) if joins_caller else Context(),
|
|
)
|
|
span.set_attributes({f"main.{index}": index for index in range(filler)})
|
|
LangfuseOtelLogger._set_langfuse_specific_attributes(
|
|
span, {**CHAT_KWARGS, "litellm_params": {"metadata": {}}}, _chat_response("ok")
|
|
)
|
|
span.set_attributes(dict.fromkeys(ERROR_KEYS, "scripted failure") if failed else {})
|
|
span.end()
|
|
return ExportedSpan(frozenset(getattr(span, "attributes", None) or {}), getattr(span, "dropped_attributes", 0))
|
|
|
|
|
|
class TestDerivedTraceFieldsAtTheSpanAttributeLimit:
|
|
@pytest.mark.parametrize("failed", [False, True])
|
|
@pytest.mark.parametrize("filler", range(SPAN_ATTRIBUTE_LIMIT - 30, SPAN_ATTRIBUTE_LIMIT + 1))
|
|
def test_trace_fields_never_evict_or_drop_another_attribute(self, filler: int, failed: bool) -> None:
|
|
derived: Final = _exported_full_span(filler, joins_caller=False, failed=failed)
|
|
without_trace_fields: Final = _exported_full_span(filler, joins_caller=True, failed=failed)
|
|
|
|
assert without_trace_fields.keys <= derived.keys
|
|
assert derived.dropped == without_trace_fields.dropped
|
|
|
|
def test_a_span_with_room_to_spare_carries_every_trace_field(self) -> None:
|
|
derived: Final = _exported_full_span(SPAN_ATTRIBUTE_LIMIT - 30, joins_caller=False, failed=False)
|
|
|
|
assert {"langfuse.trace.name", "langfuse.trace.input", "langfuse.trace.output"} <= derived.keys
|
|
|
|
|
|
LANGFUSE_ENV_ONLY: Final = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if not key.startswith(("LANGFUSE_", "OTEL_"))
|
|
}
|
|
|
|
|
|
def _decoded_basic_auth(header: str) -> str:
|
|
assert header.startswith("Basic ")
|
|
return base64.b64decode(header.removeprefix("Basic ")).decode()
|
|
|
|
|
|
def test_langfuse_otel_env_config_headers_carry_v4_ingestion_and_basic_auth() -> None:
|
|
with patch.dict(
|
|
os.environ, {**LANGFUSE_ENV_ONLY, "LANGFUSE_PUBLIC_KEY": "pk-lf-123", "LANGFUSE_SECRET_KEY": "sk-lf-123"}, clear=True
|
|
):
|
|
logger: Final = LangfuseOtelLogger()
|
|
headers: Final = OpenTelemetry._get_headers_dictionary(logger.config.headers)
|
|
assert headers["x-langfuse-ingestion-version"] == "4"
|
|
assert _decoded_basic_auth(headers["Authorization"]) == "pk-lf-123:sk-lf-123"
|
|
|
|
|
|
def test_langfuse_otel_dynamic_headers_carry_v4_ingestion_and_basic_auth() -> None:
|
|
with patch.dict(os.environ, LANGFUSE_ENV_ONLY, clear=True):
|
|
logger: Final = LangfuseOtelLogger()
|
|
headers: Final = logger.construct_dynamic_otel_headers(
|
|
StandardCallbackDynamicParams(langfuse_public_key="pk-lf-dynamic", langfuse_secret_key="sk-lf-dynamic")
|
|
)
|
|
assert headers is not None
|
|
assert headers["x-langfuse-ingestion-version"] == "4"
|
|
assert _decoded_basic_auth(headers["Authorization"]) == "pk-lf-dynamic:sk-lf-dynamic"
|
|
|
|
|
|
def test_langfuse_otel_does_not_start_proxy_request_span() -> None:
|
|
langfuse_provider: Final = TracerProvider()
|
|
generic_provider: Final = TracerProvider()
|
|
with patch.dict(os.environ, LANGFUSE_ENV_ONLY, clear=True):
|
|
langfuse_logger: Final = LangfuseOtelLogger(tracer_provider=langfuse_provider)
|
|
generic_logger: Final = OpenTelemetry(
|
|
config=OpenTelemetryConfig(exporter="console", skip_set_global=True), tracer_provider=generic_provider
|
|
)
|
|
started_at: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
request_headers: Final = {"Authorization": "Bearer test"}
|
|
try:
|
|
assert (
|
|
langfuse_logger.create_litellm_proxy_request_started_span(start_time=started_at, headers=request_headers)
|
|
is None
|
|
)
|
|
assert (
|
|
generic_logger.create_litellm_proxy_request_started_span(start_time=started_at, headers=request_headers)
|
|
is not None
|
|
)
|
|
finally:
|
|
langfuse_provider.shutdown()
|
|
generic_provider.shutdown()
|
|
|
|
|
|
_HOOK_CALL: Final[Mapping[str, object]] = MappingProxyType({})
|
|
|
|
|
|
def _spans_the_legacy_logger_exports(
|
|
destinations: tuple[OtelDestination, ...], call: Mapping[str, object] = _HOOK_CALL
|
|
) -> tuple[str, ...]:
|
|
exporter: Final = InMemorySpanExporter()
|
|
provider: Final = TracerProvider()
|
|
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
with patch.dict(os.environ, LANGFUSE_ENV_ONLY, clear=True):
|
|
logger: Final = LangfuseOtelLogger(tracer_provider=provider)
|
|
at: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
|
kwargs: Final[dict[str, object]] = {**CHAT_KWARGS, **call, "litellm_params": {"metadata": {}}}
|
|
server_span: Final = provider.get_tracer("proxy").start_span("POST /v1/chat/completions")
|
|
|
|
async def log() -> None:
|
|
set_request_destinations(destinations)
|
|
logger.log_success_event(dict(kwargs), _chat_response("ok"), at, at) # pyright: ignore[reportUnknownMemberType] # the v1 logger hooks are untyped
|
|
await logger.async_log_failure_event({**kwargs, "exception": ValueError("boom")}, None, at, at) # pyright: ignore[reportUnknownMemberType] # the v1 logger hooks are untyped
|
|
await logger.async_post_call_failure_hook({}, ValueError("boom"), UserAPIKeyAuth(parent_otel_span=server_span))
|
|
|
|
try:
|
|
contextvars.copy_context().run(asyncio.run, log())
|
|
return tuple(span.name for span in exporter.get_finished_spans())
|
|
finally:
|
|
provider.shutdown()
|
|
|
|
|
|
_LANGFUSE_DESTINATION: Final = OtelDestination(
|
|
endpoint="http://tenant.local/api/public/otel",
|
|
headers={"Authorization": "Basic dGVuYW50"},
|
|
callback_name="langfuse_otel",
|
|
)
|
|
|
|
|
|
_PROVIDER_CALL: Final[Mapping[str, object]] = MappingProxyType(
|
|
{"standard_logging_object": {"id": "c1"}, "api_call_start_time": datetime(2026, 1, 1, tzinfo=timezone.utc)}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("call", [_HOOK_CALL, _PROVIDER_CALL], ids=["hook", "provider-call"])
|
|
def test_a_request_with_a_langfuse_destination_exports_nothing_through_the_legacy_logger(
|
|
call: Mapping[str, object],
|
|
) -> None:
|
|
assert _spans_the_legacy_logger_exports((_LANGFUSE_DESTINATION,), call) == ()
|
|
|
|
|
|
def test_a_call_rejected_before_any_provider_still_exports_through_the_legacy_logger() -> None:
|
|
rejected: Final = {"standard_logging_object": {"id": "c1"}, LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: True}
|
|
|
|
assert _spans_the_legacy_logger_exports((_LANGFUSE_DESTINATION,), rejected) == (
|
|
"litellm_request",
|
|
"raw_gen_ai_request",
|
|
"litellm_request",
|
|
)
|
|
|
|
|
|
def test_a_request_without_a_langfuse_destination_still_exports_through_the_legacy_logger() -> None:
|
|
logged: Final = (
|
|
"litellm_request",
|
|
"raw_gen_ai_request",
|
|
"litellm_request",
|
|
"Failed Proxy Server Request",
|
|
"POST /v1/chat/completions",
|
|
)
|
|
|
|
assert _spans_the_legacy_logger_exports(()) == logged
|
|
assert _spans_the_legacy_logger_exports((_LANGFUSE_DESTINATION.model_copy(update={"callback_name": "arize"}),)) == (
|
|
logged
|
|
)
|