diff --git a/litellm/integrations/SlackAlerting/utils.py b/litellm/integrations/SlackAlerting/utils.py index 090213e1c90..2e203b5ab98 100644 --- a/litellm/integrations/SlackAlerting/utils.py +++ b/litellm/integrations/SlackAlerting/utils.py @@ -66,26 +66,20 @@ async def _add_langfuse_trace_id_to_alert( -> trace_id -> litellm_call_id """ - from litellm.integrations.langfuse.langfuse import LangFuseLogger + from litellm.integrations.langfuse.langfuse import LangFuseLogger, resolve_langfuse_host callbacks: Final = litellm.logging_callback_manager._get_all_callbacks() if not any(callback == "langfuse" or isinstance(callback, LangFuseLogger) for callback in callbacks): return None - if request_data is not None and request_data.get("litellm_logging_obj", None) is not None: - trace_id: str | None = None - litellm_logging_obj: Final[Logging] = request_data["litellm_logging_obj"] + if request_data is None or request_data.get("litellm_logging_obj", None) is None: + return None - for _ in range(3): - trace_id = litellm_logging_obj._get_trace_id(service_name="langfuse") - if trace_id is not None: - break - await asyncio.sleep(3) # wait 3s before retrying for trace id - if trace_id is None: - return None - - langfuse_object: Final = litellm_logging_obj._get_callback_object(service_name="langfuse") - if isinstance(langfuse_object, LangFuseLogger): - return f"{langfuse_object.langfuse_host}/trace/{trace_id}" + litellm_logging_obj: Final[Logging] = request_data["litellm_logging_obj"] + host: Final = resolve_langfuse_host(litellm_logging_obj.standard_callback_dynamic_params.get("langfuse_host")) + for _ in range(3): + if (trace_id := litellm_logging_obj._get_trace_id(service_name="langfuse")) is not None: + return f"{host}/trace/{trace_id}" + await asyncio.sleep(3) # wait 3s before retrying for trace id return None diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index 46283d8a499..e1d70a5502a 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -3,7 +3,7 @@ import os import re import traceback -from collections.abc import Callable, Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping from datetime import datetime from functools import lru_cache from importlib.metadata import version @@ -45,13 +45,15 @@ from litellm.types.utils import ( ) if TYPE_CHECKING: - from langfuse import Langfuse, LangfuseGeneration + from langfuse import Langfuse + from litellm.integrations.langfuse.langfuse_sdk import LangfuseObservation, LangfuseTracing from litellm.litellm_core_utils.litellm_logging import DynamicLoggingCache else: DynamicLoggingCache = Any Langfuse = Any - LangfuseGeneration = Any + LangfuseObservation = Any + LangfuseTracing = Any _DENIED_STEERING_KEYS: Final = frozenset({"headers", "endpoint", "caching_groups", "previous_models"}) @@ -190,8 +192,8 @@ def raise_if_unsupported_langfuse_version(installed_version: str) -> None: """Fail at logger construction rather than dropping every event at request time. v4 moved the callback onto OpenTelemetry, so on an older SDK the import of - `propagate_attributes` raises inside the per-request handler and the broad - except there turns it into silent total data loss. + `LangfuseOtelSpanAttributes` raises inside the per-request handler and the + broad except there turns it into silent total data loss. """ installed: Final = Version(installed_version) # compare majors, not versions: "5.0.0rc1" sorts below "5" but is just as unsupported @@ -205,52 +207,6 @@ def raise_if_unsupported_langfuse_version(installed_version: str) -> None: ) -_PROPAGATED_TRACE_KEYS: Final = MappingProxyType( - {"name": "trace_name", "user_id": "user_id", "session_id": "session_id", "version": "version", "tags": "tags"} -) -_GENERATION_ONLY_KEYS: Final = frozenset( - {"id", "start_time", "end_time", "parent_observation_id", "usage", "name", "version"} -) - - -_PROPAGATED_VALUE_MAX_CHARS: Final = 200 - - -def _coerce_propagated_value(value: object) -> str | Sequence[str]: - """v4 silently drops non-string or >200-char propagated values; v2's pydantic coerced them.""" - if isinstance(value, (list, tuple)): - return [str(item)[:_PROPAGATED_VALUE_MAX_CHARS] for item in value] - return str(value)[:_PROPAGATED_VALUE_MAX_CHARS] - - -def _propagated_trace_metadata(value: object) -> Mapping[str, str] | None: - """v2's ``trace(metadata=...)`` took any JSON; v4 propagates one flat string per key.""" - entries: Final = _object_mapping(value) - if not entries: - return None - return MappingProxyType({str(key): str(item)[:_PROPAGATED_VALUE_MAX_CHARS] for key, item in entries.items()}) - - -def _trace_attributes_for_propagation(trace_params: Mapping[str, object]) -> Mapping[str, object]: - """Trace-level fields in v4 are propagated onto the observations, not set on a trace object. - - Values are coerced and capped up front: the SDK drops offenders with only a - warning, and a dropped ``version`` would vanish from the generation too, - because ``_generation_attributes`` already stripped it as propagated. - """ - trace_metadata: Final = _propagated_trace_metadata(trace_params.get("metadata")) - return MappingProxyType( - { - **{ - propagated: _coerce_propagated_value(trace_params[key]) - for key, propagated in _PROPAGATED_TRACE_KEYS.items() - if trace_params.get(key) is not None - }, - **({"metadata": trace_metadata} if trace_metadata is not None else {}), - } - ) - - def _optional_str(value: object) -> str | None: """v4 sets attribute values raw; a non-string version would be dropped by the server.""" return str(value) if value is not None else None @@ -263,28 +219,6 @@ def _trace_public_flag(value: object) -> bool | None: return _as_steering_flag(value) -def _generation_attributes( - generation_params: Mapping[str, object], *, propagated: Mapping[str, object] -) -> Mapping[str, object]: - """Drop what the v4 wrapper cannot take: ids it generates, and timings set on the span itself. - - ``usage`` is the v2 shape that v4 replaced with ``usage_details``, which the - caller already builds alongside it. - - v4 has one ``version`` for a trace and its observations, so the trace's - propagated value covers the generation; a continued trace propagates none - and the generation keeps its own, as it did in v2. - """ - keep_version: Final = "version" not in propagated and generation_params.get("version") is not None - return MappingProxyType( - { - key: value - for key, value in generation_params.items() - if key not in _GENERATION_ONLY_KEYS or (key == "version" and keep_version) - } - ) - - def resolve_langfuse_credentials( langfuse_public_key=None, langfuse_secret=None, @@ -299,11 +233,15 @@ def resolve_langfuse_credentials( secret_key = langfuse_secret or langfuse_secret_key or os.getenv("LANGFUSE_SECRET_KEY") public_key = langfuse_public_key or os.getenv("LANGFUSE_PUBLIC_KEY") - resolved_host: Final = ( + return public_key, secret_key, resolve_langfuse_host(langfuse_host) + + +def resolve_langfuse_host(langfuse_host: object = None) -> str: + """The Langfuse base URL for ``langfuse_host`` with the env fallbacks, always carrying a scheme.""" + resolved: Final = str( langfuse_host or os.getenv("LANGFUSE_HOST") or os.getenv("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" ) - - return public_key, secret_key, resolved_host + return resolved if resolved.startswith(("http://", "https://")) else f"http://{resolved}" def warn_if_upstream_langfuse_configured() -> None: @@ -356,9 +294,6 @@ class LangFuseLogger: langfuse_host=langfuse_host, allow_env_credentials=allow_env_credentials, ) - if not (self.langfuse_host.startswith("http://") or self.langfuse_host.startswith("https://")): - # add http:// if unset, assume communicating over private network - e.g. render - self.langfuse_host = "http://" + self.langfuse_host _env_override: Final = str(langfuse_environment).strip() if langfuse_environment is not None else None if _env_override: validate_langfuse_environment_value(_env_override) @@ -388,6 +323,17 @@ class LangFuseLogger: "environment": self.langfuse_environment, } self.Langfuse: Langfuse = self.safe_init_langfuse_client(self.langfuse_client_parameters) + from litellm.integrations.langfuse.langfuse_sdk import acquire_langfuse_tracing + + self.tracing: LangfuseTracing = acquire_langfuse_tracing( + public_key=str(self.public_key), + secret_key=str(self.secret_key), + base_url=self.langfuse_host, + environment=self.langfuse_environment, + release=self.langfuse_release, + flush_interval=self.langfuse_flush_interval, + mock_mode=self.is_mock_mode, + ) # set the current langfuse project id in the environ # this is used by Alerting to link to the correct project @@ -421,11 +367,11 @@ class LangFuseLogger: raise Exception( f"Max langfuse clients reached: {litellm.initialized_langfuse_clients} is greater than {MAX_LANGFUSE_INITIALIZED_CLIENTS}" ) - from litellm.integrations.langfuse.langfuse_sdk import acquire_langfuse_client + from litellm.integrations.langfuse.langfuse_sdk import build_langfuse_client environment_param: Final = cast(str | None, parameters.get("environment")) # cast-ok: untyped dict release_param: Final = cast(str | None, parameters.get("release")) # cast-ok: untyped dict - langfuse_client: Final = acquire_langfuse_client( + langfuse_client: Final = build_langfuse_client( parameters=parameters, environment=environment_param, release=release_param, @@ -435,20 +381,10 @@ class LangFuseLogger: verbose_logger.debug("Created langfuse client number %s", litellm.initialized_langfuse_clients) return langfuse_client - def _renew_langfuse_client(self) -> Langfuse: - """Replace a client the cache evicted after handing this logger to the callback. - - Bypasses the initialized-client ceiling: eviction already released this logger's slot, and the - replacement is never evicted itself, so its provider is retired with the logger instead. - """ - from litellm.integrations.langfuse.langfuse_sdk import acquire_langfuse_client - - return acquire_langfuse_client( - parameters=self.langfuse_client_parameters, - environment=self.langfuse_environment, - release=self.langfuse_release, - mock_mode=self.is_mock_mode, - ) + def flush(self) -> None: + """Push every queued observation to Langfuse before the process goes away.""" + self.tracing.flush() + self.Langfuse.flush() @staticmethod def add_metadata_from_header(litellm_params: dict, metadata: dict) -> dict[str, object]: @@ -544,24 +480,20 @@ class LangFuseLogger: status_message=status_message, ) verbose_logger.debug("OUTPUT IN LANGFUSE: %s; original: %s", output, response_obj) - from litellm.integrations.langfuse.langfuse_sdk import lease_langfuse_client - - with lease_langfuse_client(self.Langfuse, self._renew_langfuse_client) as leased: - self.Langfuse = leased - trace_id, generation_id = self._log_langfuse_v2( - user_id=user_id, - metadata=metadata, - litellm_params=litellm_params, - output=output, - start_time=start_time, - end_time=end_time, - kwargs=kwargs, - optional_params=optional_params, - input=input, - response_obj=response_obj, - level=level, - litellm_call_id=litellm_call_id, - ) + trace_id, generation_id = self._log_langfuse_v2( + user_id=user_id, + metadata=metadata, + litellm_params=litellm_params, + output=output, + start_time=start_time, + end_time=end_time, + kwargs=kwargs, + optional_params=optional_params, + input=input, + response_obj=response_obj, + level=level, + litellm_call_id=litellm_call_id, + ) verbose_logger.debug("Langfuse Layer Logging - final response object: %s", response_obj) verbose_logger.info("Langfuse Layer Logging - logging success") @@ -939,7 +871,7 @@ class LangFuseLogger: if usage is not None and isinstance(cost, (int, float)) else None, "metadata": { # mutable-ok: langfuse serializes this payload, a proxy is not json-encodable - **(trace_params.get("metadata") or {}), + **(_object_mapping(trace_params.get("metadata")) or _NO_METADATA), **log_requester_metadata(redact_user_api_key_info(metadata=allowlisted_metadata)), # pyright: ignore[reportArgumentType] # TypedDict in, plain metadata dict out **enrichments, **_lookup_ids(litellm_call_id, response_obj), @@ -961,57 +893,66 @@ class LangFuseLogger: if masked_output is not None and isinstance(masked_output, str) and level == "ERROR": generation_params["status_message"] = masked_output - generation_params["completion_start_time"] = kwargs.get("completion_start_time", None) - # langfuse ships in the proxy-runtime extra, so this module must import cleanly without it from litellm.integrations.langfuse.langfuse_sdk import ( - open_trace_context, - propagate_attributes, + observation_attributes, resolve_observation_id, resolve_trace_id, start_generation, - to_unix_nanos, + trace_attributes, ) resolved_trace_id: Final = resolve_trace_id(call_trace_id) # pyright: ignore[reportArgumentType] # metadata value, str or None at runtime - - propagated_trace_attributes: Final = _trace_attributes_for_propagation(trace_params) - with propagate_attributes(**propagated_trace_attributes): # pyright: ignore[reportArgumentType] # kwargs-ok: keys fixed by _PROPAGATED_TRACE_KEYS, values are the SDK's own trace fields - trace_context, claim_trace_root = open_trace_context( - client=self.Langfuse, - trace_id=resolved_trace_id, - parent_observation_id=resolve_observation_id(parent_observation_id), # pyright: ignore[reportArgumentType] # metadata value, str or None at runtime - existing_trace=existing_trace_id is not None, - ) - generation: Final = start_generation( - client=self.Langfuse, - context=trace_context, - name=generation_params["name"], # pyright: ignore[reportArgumentType] # always the str set a few lines up - start_time=start_time, - claim_trace_root=claim_trace_root, - release=trace_params.get("release"), - public=_trace_public_flag(trace_params.get("public")), - observation_id=resolve_observation_id(generation_params["id"]), - attributes=_generation_attributes(generation_params, propagated=propagated_trace_attributes), - ) - if existing_trace_id is not None and ("input" in update_trace_keys or "output" in update_trace_keys): - # with a real parent the generation is not the trace root, so trace-level - # I/O has to be stamped explicitly; v2 updated the trace object directly - generation.set_trace_io( # pyright: ignore[reportDeprecated] # the SDK keeps it exactly for this legacy trace-level contract - input=trace_params.get("input") if "input" in update_trace_keys else None, - output=trace_params.get("output") if "output" in update_trace_keys else None, - ) - log_provider_specific_information_as_span( - client=self.Langfuse, parent=generation, enrichments=enrichments - ) - self._log_guardrail_information_as_span( - client=self.Langfuse, parent=generation, standard_logging_object=standard_logging_object - ) - generation.end(end_time=to_unix_nanos(end_time)) + continued_trace: Final = existing_trace_id is not None + trace_public: Final = _trace_public_flag(trace_params.get("public")) + trace_input: Final = trace_params.get("input") + trace_output: Final = trace_params.get("output") + trace_level_attributes: Final = trace_attributes( + name=trace_params.get("name"), + user_id=trace_params.get("user_id"), + session_id=trace_params.get("session_id"), + version=trace_params.get("version"), + release=trace_params.get("release"), + tags=trace_params.get("tags"), + metadata=trace_params.get("metadata"), + public=trace_public, + input=trace_input if continued_trace or trace_input != generation_params["input"] else None, + output=trace_output if continued_trace or trace_output != generation_params["output"] else None, + ) + generation_attributes: Final = observation_attributes( + observation_type="generation", + input=generation_params["input"], + output=generation_params["output"], + metadata=generation_params["metadata"], + level=level, + status_message=generation_params.get("status_message"), + version=generation_params["version"], + model=model_name, + model_parameters=optional_params, + usage_details=usage_details, + cost_details=generation_params["cost_details"], + completion_start_time=kwargs.get("completion_start_time", None), + prompt=generation_params.get("prompt"), + ) + generation: Final = start_generation( + tracing=self.tracing, + trace_id=resolved_trace_id, + parent_observation_id=resolve_observation_id(parent_observation_id), # pyright: ignore[reportArgumentType] # metadata value, str or None at runtime + existing_trace=continued_trace, + observation_id=resolve_observation_id(generation_params["id"]), + name=generation_params["name"], # pyright: ignore[reportArgumentType] # always the str set a few lines up + start_time=start_time, + public=trace_public, + attributes=MappingProxyType({**generation_attributes, **trace_level_attributes}), + ) + log_provider_specific_information_as_span(tracing=self.tracing, parent=generation, enrichments=enrichments) + self._log_guardrail_information_as_span( + tracing=self.tracing, parent=generation, standard_logging_object=standard_logging_object + ) + generation.end(end_time) # log_event_on_langfuse tuple-unpacks this and re-wraps it in the dict callers cache. - # The wrapper's id is the exported observation id: the requested generation_id after - # resolve_observation_id, unless the provider was adopted from user code. + # The observation id is the requested generation_id after resolve_observation_id. return resolved_trace_id, generation.id except Exception: verbose_logger.error("Langfuse Layer Error - %s", traceback.format_exc()) @@ -1150,8 +1091,8 @@ class LangFuseLogger: def _log_guardrail_information_as_span( self, - client: "Langfuse", - parent: "LangfuseGeneration", + tracing: "LangfuseTracing", + parent: "LangfuseObservation", standard_logging_object: StandardLoggingPayload | None, ): """ @@ -1173,7 +1114,7 @@ class LangFuseLogger: ) return - from litellm.integrations.langfuse.langfuse_sdk import start_child_span, to_unix_nanos + from litellm.integrations.langfuse.langfuse_sdk import observation_attributes, start_child_span for guardrail_entry in guardrail_information: if not isinstance(guardrail_entry, dict): @@ -1184,23 +1125,26 @@ class LangFuseLogger: continue span = start_child_span( - client=client, + tracing=tracing, parent=parent, name="guardrail", start_time=guardrail_entry.get("start_time", None), - attributes={ # mutable-ok: langfuse serializes this payload, a proxy is not json-encodable - "input": guardrail_entry.get("guardrail_request", None), - "output": guardrail_entry.get("guardrail_response", None), - "metadata": { - "guardrail_name": guardrail_entry.get("guardrail_name", None), - "guardrail_mode": guardrail_entry.get("guardrail_mode", None), - "guardrail_masked_entity_count": guardrail_entry.get("masked_entity_count", None), - }, - }, + attributes=observation_attributes( + observation_type="span", + input=guardrail_entry.get("guardrail_request", None), + output=guardrail_entry.get("guardrail_response", None), + metadata=MappingProxyType( + { + "guardrail_name": guardrail_entry.get("guardrail_name", None), + "guardrail_mode": guardrail_entry.get("guardrail_mode", None), + "guardrail_masked_entity_count": guardrail_entry.get("masked_entity_count", None), + } + ), + ), ) verbose_logger.debug("Logged guardrail information as span: %s", span) - span.end(end_time=to_unix_nanos(guardrail_entry.get("end_time", None))) + span.end(guardrail_entry.get("end_time", None)) def _add_prompt_to_generation_params( @@ -1278,8 +1222,8 @@ def _add_prompt_to_generation_params( def log_provider_specific_information_as_span( *, - client: "Langfuse", - parent: "LangfuseGeneration", + tracing: "LangfuseTracing", + parent: "LangfuseObservation", enrichments: Mapping[str, Any], ): """Logs provider-specific information as spans under the generation.""" @@ -1295,24 +1239,24 @@ def log_provider_specific_information_as_span( for elem in vertex_ai_grounding_metadata: if isinstance(elem, dict): for key, value in elem.items(): - _end_grounding_span(client=client, parent=parent, name=key, value=value) + _end_grounding_span(tracing=tracing, parent=parent, name=key, value=value) else: - _end_grounding_span(client=client, parent=parent, name="vertex_ai_grounding_metadata", value=elem) + _end_grounding_span(tracing=tracing, parent=parent, name="vertex_ai_grounding_metadata", value=elem) else: _end_grounding_span( - client=client, parent=parent, name="vertex_ai_grounding_metadata", value=vertex_ai_grounding_metadata + tracing=tracing, parent=parent, name="vertex_ai_grounding_metadata", value=vertex_ai_grounding_metadata ) -def _end_grounding_span(*, client: "Langfuse", parent: "LangfuseGeneration", name: str, value: object) -> None: - from litellm.integrations.langfuse.langfuse_sdk import start_child_span +def _end_grounding_span(*, tracing: "LangfuseTracing", parent: "LangfuseObservation", name: str, value: object) -> None: + from litellm.integrations.langfuse.langfuse_sdk import observation_attributes, start_child_span start_child_span( - client=client, + tracing=tracing, parent=parent, name=name, start_time=None, - attributes={"input": value}, # mutable-ok: langfuse serializes this payload + attributes=observation_attributes(observation_type="span", input=value), ).end() diff --git a/litellm/integrations/langfuse/langfuse_prompt_management.py b/litellm/integrations/langfuse/langfuse_prompt_management.py index 9982f48616a..e49521ce606 100644 --- a/litellm/integrations/langfuse/langfuse_prompt_management.py +++ b/litellm/integrations/langfuse/langfuse_prompt_management.py @@ -72,7 +72,7 @@ def langfuse_client_init( """ try: from langfuse import ( - Langfuse, # noqa: F401 # the import is the install probe; construction moved to acquire_langfuse_client + Langfuse, # noqa: F401 # the import is the install probe; construction happens in build_langfuse_client ) except Exception as e: raise Exception( @@ -124,9 +124,9 @@ def langfuse_client_init( parameters["environment"] = LangFuseLogger.resolve_deployment_environment() - from .langfuse_sdk import acquire_langfuse_client + from .langfuse_sdk import build_langfuse_client - client: Final = acquire_langfuse_client( + client: Final = build_langfuse_client( parameters=parameters, environment=parameters["environment"], release=langfuse_release, @@ -152,6 +152,8 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge flush_interval=1, ): + from .langfuse_sdk import acquire_langfuse_tracing + self.langfuse_sdk_version = installed_langfuse_version() self.Langfuse = langfuse_client_init( langfuse_public_key=langfuse_public_key, @@ -159,6 +161,20 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge langfuse_host=langfuse_host, flush_interval=flush_interval, ) + public_key, secret_key, host = resolve_langfuse_credentials( + langfuse_public_key=langfuse_public_key, + langfuse_secret=langfuse_secret, + langfuse_host=langfuse_host, + ) + self.tracing = acquire_langfuse_tracing( + public_key=str(public_key), + secret_key=str(secret_key), + base_url=host, + environment=LangFuseLogger.resolve_deployment_environment(), + release=os.getenv("LANGFUSE_RELEASE"), + flush_interval=LangFuseLogger._get_langfuse_flush_interval(flush_interval), # pyright: ignore[reportPrivateUsage] # shared env-fallback helper, not part of the logger's API + mock_mode=should_use_langfuse_mock(), + ) @property def integration_name(self): diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py index 4576282f63a..9ba66c17aaa 100644 --- a/litellm/integrations/langfuse/langfuse_sdk.py +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -4,8 +4,7 @@ import os import re import threading from base64 import b64encode -from collections.abc import Callable, Generator, Mapping, Sequence -from contextlib import contextmanager +from collections.abc import Iterable, Mapping, Sequence from contextvars import ContextVar from dataclasses import dataclass from datetime import datetime @@ -14,52 +13,49 @@ from importlib.metadata import version from itertools import chain from time import sleep from types import MappingProxyType -from typing import Final -from weakref import WeakKeyDictionary, WeakSet +from typing import Final, Literal +import httpx import opentelemetry.trace as otel_trace -from langfuse import Langfuse, LangfuseGeneration, LangfuseSpan, propagate_attributes -from langfuse._client.resource_manager import LangfuseResourceManager +from langfuse import Langfuse, LangfuseOtelSpanAttributes +from langfuse.api import LangfuseAPI +from langfuse.model import BasePromptClient from opentelemetry.context import Context from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import ReadableSpan, TracerProvider -from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter, SpanExportResult from opentelemetry.sdk.trace.id_generator import RandomIdGenerator from opentelemetry.sdk.trace.sampling import Decision, Sampler, SamplingResult -from opentelemetry.trace import Link, SpanKind, TraceState -from opentelemetry.util.types import Attributes -from requests import RequestException +from opentelemetry.trace import Link, NonRecordingSpan, Span, SpanContext, SpanKind, TraceFlags, Tracer, TraceState +from opentelemetry.util.types import Attributes, AttributeValue +from requests import PreparedRequest, RequestException, Response, Session +from requests.adapters import HTTPAdapter from litellm._logging import verbose_logger +from litellm.litellm_core_utils.safe_json_dumps import safe_dumps __all__ = ( - "AS_ROOT_ATTRIBUTE", - "PUBLIC_ATTRIBUTE", - "RELEASE_ATTRIBUTE", "DiscardingSpanExporter", + "LangfuseObservation", + "LangfuseTracing", "RetryingSpanExporter", "TraceIdHashSampler", - "acquire_langfuse_client", - "build_isolated_tracer_provider", + "acquire_langfuse_tracing", + "build_langfuse_client", + "build_langfuse_tracing", "configured_sample_rate", - "evict_stale_langfuse_resources", - "lease_langfuse_client", - "open_trace_context", - "propagate_attributes", - "register_langfuse_client", + "observation_attributes", "resolve_observation_id", "resolve_trace_id", - "shutdown_langfuse_client", "start_child_span", "start_generation", "to_unix_nanos", + "trace_attributes", ) -AS_ROOT_ATTRIBUTE: Final = "langfuse.internal.as_root" -PUBLIC_ATTRIBUTE: Final = "langfuse.trace.public" -RELEASE_ATTRIBUTE: Final = "langfuse.release" _TRACE_ID_PATTERN: Final = re.compile(r"^(?=.*[1-9a-f])[0-9a-f]{32}$") _OBSERVATION_ID_PATTERN: Final = re.compile(r"^(?=.*[1-9a-f])[0-9a-f]{16}$") +_TRACER_NAME: Final = "litellm.langfuse" def to_unix_nanos(value: datetime | float | None) -> int | None: @@ -94,125 +90,268 @@ def resolve_observation_id(observation_id: object | None) -> str | None: return sha256(serialized.encode("utf-8")).digest()[:8].hex() -def open_trace_context( - *, - client: Langfuse, - trace_id: str, - parent_observation_id: str | None, - existing_trace: bool = False, -) -> tuple[Context, bool]: - """Build the OTel context that places new observations inside ``trace_id``. +def _serialize(value: object) -> str | None: + return value if value is None or isinstance(value, str) else safe_dumps(value) - Returns the context plus whether the caller must claim trace root. Langfuse - fabricates a random parent span id when no real parent is supplied, so the - observation is a child of something that will never be exported; the public - SDK path compensates by marking the span as root and this path must do the - same. - ``existing_trace`` is the v2 ``existing_trace_id`` contract: the trace is - appended to, never rewritten. The server takes a root observation's name and - I/O as the trace's, so a continuation must not claim root; trace fields it - does want changed travel as explicit ``langfuse.trace.*`` attributes. - """ - remote_parent: Final = client._create_remote_parent_span( # pyright: ignore[reportPrivateUsage] # no public equivalent in v4 - trace_id=trace_id, parent_span_id=parent_observation_id +def _string_or_none(value: object) -> str | None: + return None if value is None else str(value) + + +def _serialize_datetime(value: object) -> str | None: + """A datetime the way the SDK's ``EventSerializer`` sends one: a JSON string, naive values read as local time.""" + if isinstance(value, datetime): + return safe_dumps(value.astimezone().isoformat()) + return _serialize(value) + + +def _strings(items: Iterable[object]) -> tuple[str, ...]: + return tuple(str(item) for item in items) + + +def _string_sequence(value: object) -> Sequence[str] | None: + if value is None: + return None + if isinstance(value, (list, tuple, set, frozenset)): + return _strings(value) or None + return (str(value),) + + +def _present(entries: Iterable[tuple[str, AttributeValue | None]]) -> Mapping[str, AttributeValue]: + return MappingProxyType({key: value for key, value in entries if value is not None}) + + +def _flattened_metadata(prefix: str, metadata: object) -> Mapping[str, AttributeValue]: + """Mirror the SDK's wire shape: one ``.`` attribute per key, or ```` for a non-dict.""" + if metadata is None: + return _present(()) + if not isinstance(metadata, Mapping): + return _present(((prefix, _serialize(metadata)),)) + return _present( + (f"{prefix}.{key}", value if isinstance(value, (str, int)) else _serialize(value)) + for key, value in metadata.items() ) - return otel_trace.set_span_in_context(remote_parent), parent_observation_id is None and not existing_trace -def start_generation( +def trace_attributes( *, - client: Langfuse, - context: Context, - name: str, - start_time: datetime | float | None, - claim_trace_root: bool, - release: str | None = None, + name: object = None, + user_id: object = None, + session_id: object = None, + version: object = None, + release: object = None, + tags: object = None, + metadata: object = None, public: bool | None = None, - observation_id: str | None = None, - attributes: Mapping[str, object], -) -> LangfuseGeneration: - """Create a generation whose start time is when the model call began. + input: object = None, + output: object = None, +) -> Mapping[str, AttributeValue]: + """Trace-level fields ride on an observation's span as ``langfuse.trace.*`` style attributes in v4. - No public v4 API accepts a historical start time, so this drives the SDK's - own OTel tracer, which does. Langfuse documents this route for backdated - ingestion. - - ``public`` is the v2 ``trace(public=...)`` flag; v4 reads it off the root - observation's ``langfuse.trace.public`` attribute instead. - - ``observation_id`` is the v2 ``generation(id=...)`` argument. v4 derives the - observation id from the OTel span id, so it is honoured through the - isolated provider's id generator; a provider adopted from user code keeps - its own generator and the returned generation's ``id`` is the truth. + On the root observation they define the trace; on a continuation they update it, which is + how v2's ``trace(...)`` and ``update_trace_keys`` contracts map onto the OTLP ingestion. """ - requested: Final = _requested_span_id.set(int(observation_id, 16) if observation_id is not None else None) - try: - otel_span: Final = client._otel_tracer.start_span( # pyright: ignore[reportPrivateUsage] # only route to a historical start time - name=name, context=context, start_time=to_unix_nanos(start_time) - ) - finally: - _requested_span_id.reset(requested) - if claim_trace_root: - otel_span.set_attribute(AS_ROOT_ATTRIBUTE, True) - if public is not None: - otel_span.set_attribute(PUBLIC_ATTRIBUTE, public) - generation: Final = LangfuseGeneration(otel_span=otel_span, langfuse_client=client, **attributes) # pyright: ignore[reportArgumentType] # kwargs-ok: callback-built params, v2 accepted the same shapes - if release is not None: - # after the wrapper, which stamps the client-wide release and would otherwise - # overwrite the release this request asked for - otel_span.set_attribute(RELEASE_ATTRIBUTE, release) - return generation + scalar: Final[tuple[tuple[str, str | bool | None], ...]] = ( + (LangfuseOtelSpanAttributes.TRACE_NAME, _string_or_none(name)), + (LangfuseOtelSpanAttributes.TRACE_USER_ID, _string_or_none(user_id)), + (LangfuseOtelSpanAttributes.TRACE_SESSION_ID, _string_or_none(session_id)), + (LangfuseOtelSpanAttributes.VERSION, _string_or_none(version)), + (LangfuseOtelSpanAttributes.RELEASE, _string_or_none(release)), + (LangfuseOtelSpanAttributes.TRACE_PUBLIC, public), + (LangfuseOtelSpanAttributes.TRACE_INPUT, _serialize(input)), + (LangfuseOtelSpanAttributes.TRACE_OUTPUT, _serialize(output)), + ) + tags_entry: Final[tuple[str, Sequence[str] | None]] = ( + LangfuseOtelSpanAttributes.TRACE_TAGS, + _string_sequence(tags), + ) + return _present( + chain(scalar, (tags_entry,), _flattened_metadata(LangfuseOtelSpanAttributes.TRACE_METADATA, metadata).items()) + ) -def start_child_span( +def observation_attributes( *, - client: Langfuse, - parent: LangfuseGeneration, - name: str, - start_time: datetime | float | None, - attributes: Mapping[str, object], -) -> LangfuseSpan: - """Create an observation under the generation, keeping its own time window. + observation_type: Literal["generation", "span"], + input: object = None, + output: object = None, + metadata: object = None, + level: object = None, + status_message: object = None, + version: object = None, + model: object = None, + model_parameters: object = None, + usage_details: object = None, + cost_details: object = None, + completion_start_time: object = None, + prompt: object = None, +) -> Mapping[str, AttributeValue]: + """The observation's own fields, serialized the way the SDK's ``create_generation_attributes`` does. - The server derives the trace's name and I/O from every observation marked - root, last start time wins, so only the generation may claim root. Nesting - the rest under it keeps a post-call guardrail from rewriting the trace. - - The trace's ``public`` flag is folded the same way, with a missing attribute - read as ``False``, so the child repeats the generation's value. + ``prompt`` links the generation to a managed prompt only when it is a real prompt client; + v2 dropped anything else, and a fallback prompt has no server-side version to link. """ - parent_span: Final = parent._otel_span # pyright: ignore[reportPrivateUsage] # the wrapper exposes no public span handle - otel_span: Final = client._otel_tracer.start_span( # pyright: ignore[reportPrivateUsage] # only route to a historical start time - name=name, - context=otel_trace.set_span_in_context(parent_span), - start_time=to_unix_nanos(start_time), + linked_prompt: Final = prompt if isinstance(prompt, BasePromptClient) and not prompt.is_fallback else None + scalar: Final[tuple[tuple[str, str | int | None], ...]] = ( + (LangfuseOtelSpanAttributes.OBSERVATION_TYPE, observation_type), + (LangfuseOtelSpanAttributes.OBSERVATION_LEVEL, _string_or_none(level)), + (LangfuseOtelSpanAttributes.OBSERVATION_STATUS_MESSAGE, _string_or_none(status_message)), + (LangfuseOtelSpanAttributes.VERSION, _string_or_none(version)), + (LangfuseOtelSpanAttributes.OBSERVATION_INPUT, _serialize(input)), + (LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT, _serialize(output)), + (LangfuseOtelSpanAttributes.OBSERVATION_MODEL, _string_or_none(model)), + (LangfuseOtelSpanAttributes.OBSERVATION_MODEL_PARAMETERS, _serialize(model_parameters)), + (LangfuseOtelSpanAttributes.OBSERVATION_USAGE_DETAILS, _serialize(usage_details)), + (LangfuseOtelSpanAttributes.OBSERVATION_COST_DETAILS, _serialize(cost_details)), + (LangfuseOtelSpanAttributes.OBSERVATION_COMPLETION_START_TIME, _serialize_datetime(completion_start_time)), + (LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_NAME, linked_prompt.name if linked_prompt else None), + (LangfuseOtelSpanAttributes.OBSERVATION_PROMPT_VERSION, linked_prompt.version if linked_prompt else None), ) - public: Final = ( - parent_span.attributes.get(PUBLIC_ATTRIBUTE) - if isinstance(parent_span, ReadableSpan) and parent_span.attributes is not None - else None + return _present( + chain(scalar, _flattened_metadata(LangfuseOtelSpanAttributes.OBSERVATION_METADATA, metadata).items()) ) - if public is not None: - otel_span.set_attribute(PUBLIC_ATTRIBUTE, public) - return LangfuseSpan(otel_span=otel_span, langfuse_client=client, **attributes) # pyright: ignore[reportArgumentType] # kwargs-ok: callback-built params, v2 accepted the same shapes -_ENVIRONMENT_ATTRIBUTE: Final = "langfuse.environment" +@dataclass(frozen=True, slots=True) +class LangfuseObservation: + """A Langfuse observation as the OTel span litellm exports for it.""" + + span: Span + public: bool | None + + @property + def id(self) -> str: + return format(self.span.get_span_context().span_id, "016x") + + @property + def trace_id(self) -> str: + return format(self.span.get_span_context().trace_id, "032x") + + def end(self, end_time: datetime | float | None = None) -> None: + self.span.end(end_time=to_unix_nanos(end_time)) + + +_requested_trace_id: Final[ContextVar[int | None]] = ContextVar("litellm_langfuse_requested_trace_id", default=None) _requested_span_id: Final[ContextVar[int | None]] = ContextVar("litellm_langfuse_requested_span_id", default=None) -class _RequestedSpanIdGenerator(RandomIdGenerator): - """Hand out the span id the calling context asked for, random otherwise.""" +class _RequestedIdGenerator(RandomIdGenerator): + """Hand out the ids the calling context asked for, random otherwise. + + v2 took caller trace and generation ids as plain fields; OTel derives both from + the tracer's id generator, so the request rides on a context variable instead. + """ + + def generate_trace_id(self) -> int: + requested: Final = _requested_trace_id.get() + return super().generate_trace_id() if requested is None else requested def generate_span_id(self) -> int: requested: Final = _requested_span_id.get() return super().generate_span_id() if requested is None else requested -# providers litellm itself constructed; a bundle adopted from user code may hold the -# process-global provider, which litellm must never shut down. -_litellm_built_providers: Final[WeakSet] = WeakSet() +def _parent_context(*, trace_id: str, parent_observation_id: str | None, existing_trace: bool) -> Context: + """Where a new observation hangs: nowhere for a fresh trace, under a remote parent when continuing one. + + ``existing_trace`` is the v2 ``existing_trace_id`` contract: the trace is appended to, never + rewritten. The server takes a root observation's name and I/O as the trace's, so a continuation + without a known parent hangs under a parent id that is never exported instead of claiming root. + An explicitly empty context also keeps the caller's own active span out of the picture. + """ + if parent_observation_id is None and not existing_trace: + return Context() + parent_span_id: Final = ( + int(parent_observation_id, 16) if parent_observation_id is not None else RandomIdGenerator().generate_span_id() + ) + remote_parent: Final = NonRecordingSpan( + SpanContext( + trace_id=int(trace_id, 16), + span_id=parent_span_id, + is_remote=True, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + ) + return otel_trace.set_span_in_context(remote_parent) + + +def _start_span( + tracer: Tracer, + *, + name: str, + context: Context, + start_time: datetime | float | None, + trace_id: str | None, + observation_id: str | None, + attributes: Mapping[str, AttributeValue], +) -> Span: + trace_token: Final = _requested_trace_id.set(int(trace_id, 16) if trace_id is not None else None) + span_token: Final = _requested_span_id.set(int(observation_id, 16) if observation_id is not None else None) + try: + return tracer.start_span( + name=name, context=context, start_time=to_unix_nanos(start_time), attributes=attributes + ) + finally: + _requested_span_id.reset(span_token) + _requested_trace_id.reset(trace_token) + + +def start_generation( + *, + tracing: LangfuseTracing, + trace_id: str, + parent_observation_id: str | None, + existing_trace: bool, + observation_id: str | None, + name: str, + start_time: datetime | float | None, + public: bool | None, + attributes: Mapping[str, AttributeValue], +) -> LangfuseObservation: + """Create the generation for one model call, timed from when that call began. + + ``trace_id``, ``parent_observation_id`` and ``observation_id`` are the v2 ``trace(id=...)``, + ``generation(parent_observation_id=...)`` and ``generation(id=...)`` arguments, already + normalized by ``resolve_trace_id`` and ``resolve_observation_id``. + """ + span: Final = _start_span( + tracing.tracer, + name=name, + context=_parent_context( + trace_id=trace_id, parent_observation_id=parent_observation_id, existing_trace=existing_trace + ), + start_time=start_time, + trace_id=trace_id, + observation_id=observation_id, + attributes=attributes, + ) + return LangfuseObservation(span=span, public=public) + + +def start_child_span( + *, + tracing: LangfuseTracing, + parent: LangfuseObservation, + name: str, + start_time: datetime | float | None, + attributes: Mapping[str, AttributeValue], +) -> LangfuseObservation: + """Create an observation under the generation, keeping its own time window. + + The server folds the trace's ``public`` flag across every observation, with a missing + attribute read as ``False``, so the child repeats the generation's value. + """ + public_entry: Final[tuple[str, bool | None]] = (LangfuseOtelSpanAttributes.TRACE_PUBLIC, parent.public) + span: Final = _start_span( + tracing.tracer, + name=name, + context=otel_trace.set_span_in_context(parent.span), + start_time=start_time, + trace_id=None, + observation_id=None, + attributes=_present(chain((public_entry,), attributes.items())), + ) + return LangfuseObservation(span=span, public=parent.public) @dataclass(frozen=True, slots=True) @@ -270,44 +409,12 @@ def configured_sample_rate() -> float: return parsed -def build_isolated_tracer_provider( - *, environment: str | None, release: str | None, sample_rate: float = 1.0 -) -> TracerProvider: - """Give the langfuse client a provider of its own instead of the process-wide one. - - v4 is built on OpenTelemetry and otherwise either claims the global tracer - provider, which silently disables litellm's own exporters, or attaches its - processor to litellm's, which sends litellm spans to every langfuse project - and langfuse spans to every other litellm destination. - - The resource is rebuilt here because langfuse only applies ``environment`` - and ``release`` when it constructs the provider itself, and the sampler is - installed for the same reason: ``sample_rate`` is otherwise silently - ignored and every trace exports. - """ - attributes: Final = MappingProxyType( - { - key: value - for key, value in ((_ENVIRONMENT_ATTRIBUTE, environment), (RELEASE_ATTRIBUTE, release)) - if value is not None - } - ) - provider: Final = TracerProvider( - resource=Resource.create(attributes), - sampler=TraceIdHashSampler(sample_rate) if sample_rate < 1 else None, - id_generator=_RequestedSpanIdGenerator(), - ) - with _LIVE_CLIENTS_LOCK: - _litellm_built_providers.add(provider) - return provider - - class DiscardingSpanExporter(SpanExporter): """Accept and drop every span, for mock mode. - The mock intercepts the httpx client langfuse used to take, but v4 ships - observations through its own OTLP exporter, so without this the "no network - calls" contract silently sends real traces to the configured host. + The mock intercepts the httpx client the SDK uses for its API, but observations + travel over OTLP, so without this the "no network calls" contract silently sends + real traces to the configured host. """ def export(self, spans: object) -> SpanExportResult: @@ -352,227 +459,27 @@ class RetryingSpanExporter(SpanExporter): return self.exporter.force_flush(timeout_millis) -_LIVE_CLIENTS_LOCK: Final = threading.Lock() -# litellm clients still using each SDK resource bundle; the bundle is torn down with the last one. -# Both sides are weak so a throwaway client (a health probe, an alerting lookup) that is simply -# garbage-collected stops holding the bundle open rather than inflating a counter forever. -_live_clients: Final[WeakKeyDictionary[LangfuseResourceManager, WeakSet]] = WeakKeyDictionary() +class _UnverifiedTlsAdapter(HTTPAdapter): + """Honour ``ssl_verify=False``: the exporter passes ``verify`` per request, which outranks ``Session.verify``.""" + + def send( # pyright: ignore[reportIncompatibleMethodOverride] # the stub types verify as bool | str, the base accepts both + self, + request: PreparedRequest, + stream: bool = False, + timeout: float | tuple[float, float] | tuple[float, None] | None = None, + verify: bool | str = True, + cert: str | tuple[str, str] | None = None, + proxies: Mapping[str, str] | None = None, + ) -> Response: + return super().send(request, stream=stream, timeout=timeout, verify=False, cert=cert, proxies=proxies) -class _LangfuseLifecycleState: - """How many callbacks are leasing one SDK resource bundle, and what eviction has queued behind them. - - ``lock`` is never held across a teardown, which takes the SDK's own registry lock. - """ - - def __init__(self) -> None: - self.lock = threading.Lock() - self.active_leases = 0 - self.teardown_in_progress = False - self.teardown_owner: int | None = None - self.pending_clients: set[Langfuse] = set() # mutable-ok: eviction and callback threads queue into it - self.retired: WeakSet[Langfuse] = WeakSet() # mutable-ok: eviction marks its clients here from its own thread - - def open_lease(self, client: Langfuse) -> bool: - """Take a lease on ``client``; False when eviction already reached it, so a lease would guard a dead client.""" - with self.lock: - if client in self.retired: - return False - self.active_leases += 1 - return True - - def claim_for_teardown(self, client: Langfuse) -> bool: - """Whether this thread owns ``client``'s teardown; a lease or another teardown in flight queues it instead.""" - with self.lock: - self.retired.add(client) - if self.active_leases > 0 or self.teardown_in_progress: - self.pending_clients.add(client) - return False - self.teardown_in_progress = True - self.teardown_owner = threading.get_ident() - return True - - def release_lease(self) -> tuple[Langfuse, ...]: - """Drop this lease and take ownership of the teardowns it was holding up, if it was the last one.""" - with self.lock: - self.active_leases -= 1 - if self.active_leases > 0 or self.teardown_in_progress or not self.pending_clients: - return () - claimed: Final = tuple(self.pending_clients) - self.pending_clients.clear() - self.teardown_in_progress = True - self.teardown_owner = threading.get_ident() - return claimed - - def next_teardown_batch(self) -> tuple[Langfuse, ...]: - """Whatever eviction queued while the last batch was draining, handing the ownership flag back when empty.""" - with self.lock: - if self.active_leases == 0 and self.pending_clients: - claimed: Final = tuple(self.pending_clients) - self.pending_clients.clear() - return claimed - self.teardown_in_progress = False - self.teardown_owner = None - return () - - def requeue(self, clients: tuple[Langfuse, ...]) -> None: - with self.lock: - self.pending_clients.update(clients) - - def end_teardown(self) -> None: - with self.lock: - if self.teardown_owner == threading.get_ident(): - self.teardown_in_progress = False - self.teardown_owner = None - - -_LIFECYCLE_STATES_LOCK: Final = threading.Lock() -_LIFECYCLE_STATES: Final[WeakKeyDictionary[object, _LangfuseLifecycleState]] = WeakKeyDictionary() - - -def _lifecycle_state(client: Langfuse) -> _LangfuseLifecycleState: - """One state per resource bundle, since teardown closes the provider every client on that bundle exports through.""" - resources: Final = getattr(client, "_resources", None) - key: Final = client if resources is None else resources - with _LIFECYCLE_STATES_LOCK: - existing: Final = _LIFECYCLE_STATES.get(key) - if existing is not None: - return existing - created: Final = _LangfuseLifecycleState() - _LIFECYCLE_STATES[key] = created - return created - - -@contextmanager -def lease_langfuse_client(client: Langfuse, renew: Callable[[], Langfuse]) -> Generator[Langfuse]: - """Hold off cache eviction's teardown of ``client`` while the export inside is in flight. - - Eviction reaches a client the cache handed a callback moments earlier, so closing the SDK client - and its tracer provider there drops the spans that callback is still writing. The lease protects - exactly the window it wraps: an eviction arriving inside it is deferred to the last lease exit. - Taking a lease never blocks. When eviction already claimed ``client`` between the cache lookup - and this call, the lease is taken on ``renew()``'s fresh client instead and that client is what - the caller must export through: the registry hands it the live bundle when one remains, where - it registers as a holder and the reference count degrades the queued teardown to a flush, or a - fresh bundle once the old one is gone. - """ - leased, state = _open_lease(client, renew) - try: - yield leased - finally: - _run_teardowns(state, state.release_lease()) - - -def _open_lease(client: Langfuse, renew: Callable[[], Langfuse]) -> tuple[Langfuse, _LangfuseLifecycleState]: - """The first of ``client`` then ``renew()``'s clients that is not already retired, with its lease taken.""" - return next( - (candidate, state) - for candidate in chain((client,), iter(renew, None)) - if (state := _lifecycle_state(candidate)).open_lease(candidate) - ) - - -def _run_teardowns(state: _LangfuseLifecycleState, clients: tuple[Langfuse, ...]) -> None: - """Tear down ``clients``, then whatever eviction queued meanwhile, and hand the flag back. - - A failing ordinary teardown is logged and skipped rather than raised: the thread here is usually a - request callback that merely held the last lease, and its request must not fail on eviction's behalf. - An interrupt requeues the unfinished batch for the next eviction or lease exit and propagates. - """ - batch = clients # rebind-ok: drains each batch queued while the previous one was being torn down - try: - from litellm._logging import verbose_logger - - while batch: - for index, client in enumerate(batch): - try: - _teardown_langfuse_client(client) - except Exception: # noqa: BLE001 # SDK shutdown can raise anything; the request holding the lease must survive it - verbose_logger.exception("Langfuse client teardown failed during cache eviction") - except BaseException: - state.requeue(batch[index:]) - raise - batch = state.next_teardown_batch() - finally: - state.end_teardown() - - -def _evict_if_stale_locked( - *, - public_key: object, - secret_key: object, - base_url: object, - mock_mode: bool | None = None, - sample_rate: float | None = None, -) -> LangfuseResourceManager | None: - """Assumes ``LangfuseResourceManager._lock`` is held; returns the still-valid bundle, evicting a stale one.""" - if not public_key: - return None - cached: Final = LangfuseResourceManager._instances.get(public_key) # pyright: ignore[reportPrivateUsage] # registry has no public accessor - if cached is None: - return None - same_exporter_kind: Final = mock_mode is None or ( - isinstance(getattr(cached, "span_exporter", None), DiscardingSpanExporter) == mock_mode - ) - same_sample_rate: Final = sample_rate is None or getattr(cached, "sample_rate", None) == sample_rate - if ( - getattr(cached, "secret_key", None) == secret_key - and getattr(cached, "base_url", None) == base_url - and same_exporter_kind - and same_sample_rate - ): - return cached - LangfuseResourceManager._instances.pop(public_key, None) # pyright: ignore[reportPrivateUsage] # registry has no public accessor - return None - - -def _retire_orphaned_providers() -> None: - """Shut down every provider litellm built whose bundle nothing uses any more. - - A rotated-out bundle whose last client is simply garbage collected, which is how the - prompt-management LRU drops clients, never reaches ``shutdown_langfuse_client``, and the - provider's own atexit hook would keep its export thread alive for the rest of the process. - - Holders are snapshotted last: a client is registered in the same registry-locked block - that builds its provider, so once the registry snapshot's lock has been acquired, the - client of any provider from the first snapshot is visible to the final one even when a - concurrent rotation already evicted its bundle again. Runs outside both locks because - provider shutdown flushes and joins the export thread. - """ - with _LIVE_CLIENTS_LOCK: - candidates: Final = tuple(_litellm_built_providers) - with LangfuseResourceManager._lock: # pyright: ignore[reportPrivateUsage] # registry has no public accessor - registered: Final = tuple( - getattr(resources, "tracer_provider", None) - for resources in LangfuseResourceManager._instances.values() # pyright: ignore[reportPrivateUsage] # registry has no public accessor - ) - with _LIVE_CLIENTS_LOCK: - held: Final = tuple( - getattr(resources, "tracer_provider", None) - for resources, holders in _live_clients.items() - if len(holders) > 0 - ) - orphaned: Final = tuple(provider for provider in candidates if provider not in registered and provider not in held) - for provider in orphaned: - _litellm_built_providers.discard(provider) - provider.shutdown() - - -def evict_stale_langfuse_resources(*, public_key: str | None, secret_key: str | None, base_url: str | None) -> None: - """Drop a cached client whose credentials no longer match the ones being requested.""" - with LangfuseResourceManager._lock: # pyright: ignore[reportPrivateUsage] # registry has no public accessor - _evict_if_stale_locked(public_key=public_key, secret_key=secret_key, base_url=base_url) - _retire_orphaned_providers() - - -def _build_span_exporter(*, public_key: object, secret_key: object, base_url: object) -> RetryingSpanExporter: +def _build_span_exporter(*, public_key: str, secret_key: str, base_url: str) -> RetryingSpanExporter: """Build the OTLP export channel with litellm's TLS material and v2's retry behaviour. v2 ingested through the injected httpx client, which carried litellm's CA - bundle and client certificate; v4 ships every observation through its own - OTLP exporter, so a private-CA deployment would fail TLS on every export in - a background thread while ``auth_check`` (still on the httpx client) stays - green. Endpoint, headers and timeout mirror ``langfuse._client.span_processor``. + bundle and client certificate. Endpoint, headers and timeout mirror the SDK's + own span processor so the server treats the spans as v4 SDK traffic. """ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter @@ -584,150 +491,173 @@ def _build_span_exporter(*, public_key: object, secret_key: object, base_url: ob 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") or "/api/public/otel/v1/traces" - endpoint: Final = f"{str(base_url).rstrip('/')}/{export_path.lstrip('/')}" + endpoint: Final = f"{base_url.rstrip('/')}/{export_path.lstrip('/')}" encoded_auth: Final = b64encode(f"{public_key}:{secret_key}".encode()).decode("ascii") + session: Final = Session() + if ssl_verify is False: + session.mount("https://", _UnverifiedTlsAdapter()) exporter: Final = OTLPSpanExporter( + session=session, endpoint=endpoint, headers={ # mutable-ok: the exporter copies these into its session headers "Authorization": "Basic " + encoded_auth, "x-langfuse-sdk-name": "python", "x-langfuse-sdk-version": version("langfuse"), - "x-langfuse-public-key": str(public_key), + "x-langfuse-public-key": public_key, }, timeout=int(os.getenv("LANGFUSE_TIMEOUT", "5")), certificate_file=ca_bundle, client_certificate_file=client_certificate, ) - if ssl_verify is False: - exporter._certificate_file = False # pyright: ignore[reportPrivateUsage] # the ctor coerces a False certificate_file back to True return RetryingSpanExporter(exporter) -def acquire_langfuse_client( +def _resource(*, environment: str | None, release: str | None) -> Resource: + return Resource.create( + _present( + ( + (LangfuseOtelSpanAttributes.ENVIRONMENT, environment), + (LangfuseOtelSpanAttributes.RELEASE, release), + ) + ) + ) + + +@dataclass(frozen=True, slots=True) +class LangfuseTracing: + """litellm's own export channel to one Langfuse project: a provider, its tracer and the exporter behind them. + + The channel is litellm's rather than the SDK's so that the process-global OTel provider stays + untouched, historical timestamps and caller ids are honoured, and no SDK internals are needed. + """ + + provider: TracerProvider + tracer: Tracer + + def flush(self, timeout_millis: int = 30_000) -> bool: + return self.provider.force_flush(timeout_millis) + + +@dataclass(frozen=True, slots=True) +class _TracingKey: + public_key: str + secret_key: str + base_url: str + environment: str | None + release: str | None + sample_rate: float + flush_interval_millis: int + mock_mode: bool + + +_TRACING_LOCK: Final = threading.Lock() +_TRACING: Final[ + dict[_TracingKey, LangfuseTracing] +] = {} # mutable-ok: process-wide channel cache, guarded by _TRACING_LOCK + + +def acquire_langfuse_tracing( + *, + public_key: str, + secret_key: str, + base_url: str, + environment: str | None, + release: str | None, + flush_interval: float, + mock_mode: bool, +) -> LangfuseTracing: + """One export channel per credential set, shared by every logger built for it. + + Channels live for the process: a provider owns a batch export thread, and tearing one down + while another logger for the same credentials still exports through it would drop its spans. + """ + key: Final = _TracingKey( + public_key=public_key, + secret_key=secret_key, + base_url=base_url, + environment=environment, + release=release, + sample_rate=configured_sample_rate(), + flush_interval_millis=int(flush_interval * 1000), + mock_mode=mock_mode, + ) + with _TRACING_LOCK: + cached: Final = _TRACING.get(key) + if cached is not None: + return cached + created: Final = build_langfuse_tracing( + exporter=DiscardingSpanExporter() + if mock_mode + else _build_span_exporter(public_key=public_key, secret_key=secret_key, base_url=base_url), + environment=environment, + release=release, + sample_rate=key.sample_rate, + flush_interval_millis=key.flush_interval_millis, + ) + _TRACING[key] = created + return created + + +def build_langfuse_tracing( + *, + exporter: SpanExporter, + environment: str | None, + release: str | None, + sample_rate: float, + flush_interval_millis: int, +) -> LangfuseTracing: + provider: Final = TracerProvider( + resource=_resource(environment=environment, release=release), + sampler=TraceIdHashSampler(sample_rate) if sample_rate < 1 else None, + id_generator=_RequestedIdGenerator(), + ) + provider.add_span_processor(BatchSpanProcessor(exporter, schedule_delay_millis=flush_interval_millis)) + return LangfuseTracing(provider=provider, tracer=provider.get_tracer(_TRACER_NAME)) + + +def build_langfuse_client( *, parameters: Mapping[str, object], environment: str | None, release: str | None, mock_mode: bool, ) -> Langfuse: - """Evict-check, construct, and register a client as one atomic step. + """The SDK client litellm keeps for prompt management and ``auth_check``. - The SDK registry lock is held across the whole sequence: released between - eviction and construction, two concurrent inits for the same public key - with different secrets can bind one tenant's logger to the other tenant's - exporter. The isolated provider is only built when the registry does not - already hold the key — a discarded ``TracerProvider`` stays pinned forever - by its atexit hook, so building one per health probe or alerting lookup - would leak a provider each time. + Observations never go through it, but the SDK still builds a tracer for it and, given no + provider, claims the process-global one, which disables litellm's other OTel exporters. + It gets a provider of its own instead. The SDK caches one resource bundle per public key, + so a user application constructing ``Langfuse`` for the same key afterwards shares this + bundle; the exporter it carries is litellm's so that application's spans still reach Langfuse. + + That same cache keeps the first secret and host it saw for a public key, so the REST client + behind ``get_prompt`` and ``auth_check`` is rebuilt from the credentials actually supplied. + Without both keys the SDK disables the client, which has no REST client to rebuild. """ public_key: Final = parameters.get("public_key") - span_exporter: Final = ( - DiscardingSpanExporter() - if mock_mode - else _build_span_exporter( - public_key=public_key, - secret_key=parameters.get("secret_key"), - base_url=parameters.get("base_url"), - ) + secret_key: Final = parameters.get("secret_key") + base_url: Final = str(parameters.get("base_url")) + httpx_client: Final = parameters.get("httpx_client") + credentialed: Final = isinstance(public_key, str) and isinstance(secret_key, str) + client: Final = Langfuse( + **parameters, # pyright: ignore[reportArgumentType] # kwargs-ok: dict mirrors the typed ctor, values resolved by the callers + tracer_provider=TracerProvider( + resource=_resource(environment=environment, release=release), shutdown_on_exit=False + ), + span_exporter=_build_span_exporter(public_key=str(public_key), secret_key=str(secret_key), base_url=base_url) + if credentialed and not mock_mode + else DiscardingSpanExporter(), + ) + if not isinstance(public_key, str) or not isinstance(secret_key, str): + return client + client.api = LangfuseAPI( + base_url=base_url, + username=public_key, + password=secret_key, + x_langfuse_sdk_name="python", + x_langfuse_sdk_version=version("langfuse"), + x_langfuse_public_key=public_key, + httpx_client=httpx_client if isinstance(httpx_client, httpx.Client) else None, + timeout=int(os.getenv("LANGFUSE_TIMEOUT", "5")), ) - sample_rate: Final = configured_sample_rate() - with LangfuseResourceManager._lock: # pyright: ignore[reportPrivateUsage] # registry has no public accessor - cached: Final = _evict_if_stale_locked( - public_key=public_key, - secret_key=parameters.get("secret_key"), - base_url=parameters.get("base_url"), - mock_mode=mock_mode, - sample_rate=sample_rate, - ) - client: Final = Langfuse( - **parameters, # pyright: ignore[reportArgumentType] # kwargs-ok: dict mirrors the typed ctor, values resolved by the callers - sample_rate=sample_rate, - tracer_provider=None - if cached is not None - else build_isolated_tracer_provider(environment=environment, release=release, sample_rate=sample_rate), - span_exporter=span_exporter, - ) - register_langfuse_client(client) - _retire_orphaned_providers() return client - - -def register_langfuse_client(client: Langfuse) -> None: - """Track the client against the SDK resources it ended up with. - - langfuse keys its resources on the public key alone, so a second client for - the same key (a per-key ``langfuse_environment`` override, a team whose - callback_vars repeat the global credentials) is handed the first client's - tracer provider and export thread rather than its own. Only the last live - client may shut those down; see ``shutdown_langfuse_client``. - """ - resources: Final = getattr(client, "_resources", None) - if resources is None: - return - with _LIVE_CLIENTS_LOCK: - holders = _live_clients.get(resources) - if holders is None: - holders = WeakSet() - _live_clients[resources] = holders - holders.add(client) - - -def _release_langfuse_resources(resources: LangfuseResourceManager, client: Langfuse) -> bool: - """Drop the client's claim; True when no other live client still uses ``resources``.""" - with _LIVE_CLIENTS_LOCK: - holders: Final = _live_clients.get(resources) - if holders is None: - return True - holders.discard(client) - if len(holders) > 0: - return False - _live_clients.pop(resources, None) - return True - - -def shutdown_langfuse_client(client: Langfuse) -> None: - """Release everything the client owns, which the SDK's own shutdown does not. - - ``Langfuse.shutdown`` joins the score and media consumers but leaves the - tracer provider's export thread running and leaves the client in the - registry, so a later request for the same key gets a dead client back. - - A callback holding a lease on the client's bundle postpones all of this to - the moment that lease ends, so eviction cannot close the provider out from - under an export the lease is wrapping. See ``lease_langfuse_client``. - """ - state: Final = _lifecycle_state(client) - if not state.claim_for_teardown(client): - return - _run_teardowns(state, (client,)) - - -def _teardown_langfuse_client(client: Langfuse) -> None: - """The blocking teardown behind ``shutdown_langfuse_client``. - - A client that shares its resources with another live client only flushes: - shutting the shared provider down here would silence the other client for - the rest of its life, as it did before the reference count existed. - - The registry entry is removed before the blocking shutdown so a concurrent - construct builds a fresh bundle instead of adopting a dying one, and the - provider is only shut down when litellm built it: a bundle adopted from - user code may share the process-global provider. - """ - resources: Final = getattr(client, "_resources", None) - client.flush() - if resources is None: - client.shutdown() - return - public_key: Final = getattr(resources, "public_key", None) - with LangfuseResourceManager._lock: # pyright: ignore[reportPrivateUsage] # registry has no public accessor - if not _release_langfuse_resources(resources, client): - return - if public_key is not None and LangfuseResourceManager._instances.get(public_key) is resources: # pyright: ignore[reportPrivateUsage] # registry has no public accessor - LangfuseResourceManager._instances.pop(public_key, None) # pyright: ignore[reportPrivateUsage] # registry has no public accessor - client.shutdown() - provider: Final = getattr(resources, "tracer_provider", None) - if provider is not None and provider in _litellm_built_providers: - _litellm_built_providers.discard(provider) - provider.shutdown() - _retire_orphaned_providers() diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index bbbce6513c4..aa4d102095c 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3683,40 +3683,6 @@ class Logging(LiteLLMLoggingBaseClass): return trace_id - def _get_callback_object(self, service_name: Literal["langfuse"]) -> Any | None: - """ - Return dynamic callback object. - - Meant to solve issue when doing key-based/team-based logging - """ - global langFuseLogger - - if service_name == "langfuse": - if langFuseLogger is None or ( - ( - self.standard_callback_dynamic_params.get("langfuse_public_key") is not None - and self.standard_callback_dynamic_params.get("langfuse_public_key") != langFuseLogger.public_key - ) - or ( - self.standard_callback_dynamic_params.get("langfuse_public_key") is not None - and self.standard_callback_dynamic_params.get("langfuse_public_key") != langFuseLogger.public_key - ) - or ( - self.standard_callback_dynamic_params.get("langfuse_host") is not None - and self.standard_callback_dynamic_params.get("langfuse_host") != langFuseLogger.langfuse_host - ) - ): - return LangFuseLogger( - langfuse_public_key=self.standard_callback_dynamic_params.get("langfuse_public_key"), - langfuse_secret=self.standard_callback_dynamic_params.get("langfuse_secret") - or self.standard_callback_dynamic_params.get("langfuse_secret_key"), - langfuse_host=self.standard_callback_dynamic_params.get("langfuse_host"), - allow_env_credentials=self.standard_callback_dynamic_params.get("langfuse_host") is None, - ) - return langFuseLogger - - return None - def handle_sync_success_callbacks_for_async_calls( self, result: Any, diff --git a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py index 88d959ee0db..1edda18d054 100644 --- a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py +++ b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py @@ -1,10 +1,8 @@ """ This is a cache for LangfuseLoggers. -Langfuse Python SDK initializes a thread for each client. - This ensures we do -1. Proper cleanup of Langfuse initialized clients. +1. Release the initialized-client slot a LangfuseLogger holds when it expires. 2. Re-use created langfuse clients. """ @@ -21,34 +19,17 @@ from ...caching import InMemoryCache class LangfuseInMemoryCache(InMemoryCache): """ - Ensures we do proper cleanup of Langfuse initialized clients. + Releases the initialized-client slot of a LangfuseLogger when it expires. - Langfuse Python SDK initializes a thread for each client, we need to call Langfuse.shutdown() to properly cleanup. - - This ensures we do proper cleanup of Langfuse initialized clients. + Export channels are shared per credential set and outlive the logger, so + nothing else needs tearing down (https://github.com/BerriAI/litellm/issues/11169). """ def _remove_key(self, key: str) -> None: - """ - Override _remove_key in InMemoryCache to ensure we do proper cleanup of Langfuse initialized clients. - - LangfuseLoggers consume threads when initalized, this shuts them down when they are expired - - Relevant Issue: https://github.com/BerriAI/litellm/issues/11169 - """ from litellm.integrations.langfuse.langfuse import LangFuseLogger if isinstance(self.cache_dict[key], LangFuseLogger): - _created_langfuse_logger: Final[LangFuseLogger] = self.cache_dict[key] - ######################################################### - # Clean up Langfuse initialized clients - ######################################################### - from litellm.integrations.langfuse.langfuse_sdk import ( - shutdown_langfuse_client, - ) - litellm.initialized_langfuse_clients -= 1 - shutdown_langfuse_client(_created_langfuse_logger.Langfuse) # Loggers with a periodic flush task (e.g. NewRelicMetricsLogger) expose # stop() so eviction actually ends the task instead of leaking it. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7bc36e175c0..bfb86577b6b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1033,7 +1033,7 @@ async def proxy_shutdown_event(worker_heartbeat: ProxyWorkerHeartbeat | None = N from litellm.utils import langFuseLogger if langFuseLogger is not None: - langFuseLogger.Langfuse.flush() + langFuseLogger.flush() except Exception: # [DO NOT BLOCK shutdown events for this] pass diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json index 58d50ead695..6f359380245 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_bedrock_call.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 6e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_complex_metadata.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json index c1fd8b824d5..b5a0737cf39 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_no_choices.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 1.9999999999999998e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json index c5b4a9a51fc..749796e0d04 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_router.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 1.9999999999999998e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json index beb1f32fe06..39e26f5957d 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json index e8d4769b0ec..5223538f919 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_tags_stream.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json index f5eff2774e0..fe50e6923e3 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/completion_with_vertex_call.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 7.5e-06 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/complex_metadata_2.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/empty_metadata.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_function.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/metadata_with_lock.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/nested_metadata.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata2.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json index 27f77398978..906b1a42a8f 100644 --- a/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json +++ b/tests/logging_callback_tests/langfuse_expected_request_body/simple_metadata3.json @@ -2,7 +2,6 @@ "name": "litellm-acompletion", "parent_span_id": null, "attributes": { - "langfuse.internal.as_root": true, "langfuse.observation.cost_details": { "total": 3.5e-05 }, diff --git a/tests/logging_callback_tests/test_langfuse_e2e_test.py b/tests/logging_callback_tests/test_langfuse_e2e_test.py index 5d9a832337c..9d4f406b0b3 100644 --- a/tests/logging_callback_tests/test_langfuse_e2e_test.py +++ b/tests/logging_callback_tests/test_langfuse_e2e_test.py @@ -27,15 +27,16 @@ import pytest_asyncio LANGFUSE_EXPORT_POST: Final = "requests.Session.post" LANGFUSE_EXPORT_PATH: Final = "/api/public/otel/v1/traces" -_LITELLM_OWNED_ATTRIBUTES: Final = frozenset( +_PER_RUN_ATTRIBUTES: Final = frozenset( { - "langfuse.internal.is_app_root", "langfuse.observation.completion_start_time", "langfuse.observation.metadata.applied_guardrails", "langfuse.observation.metadata.cache_hit", "langfuse.observation.metadata.hidden_params", + "langfuse.observation.metadata.litellm_call_id", "langfuse.observation.metadata.litellm_response_cost", "langfuse.observation.metadata.requester_metadata", + "langfuse.observation.metadata.response_id", "langfuse.observation.metadata.usage_object", } ) @@ -87,8 +88,8 @@ def _comparable(span: Mapping[str, object]) -> dict[str, object]: assert isinstance(attributes, dict) return { "name": span["name"], - "parent_span_id": None if attributes.get("langfuse.internal.as_root") else span["parent_span_id"], - "attributes": {key: value for key, value in sorted(attributes.items()) if key not in _LITELLM_OWNED_ATTRIBUTES}, + "parent_span_id": span["parent_span_id"], + "attributes": {key: value for key, value in sorted(attributes.items()) if key not in _PER_RUN_ATTRIBUTES}, } diff --git a/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_utils.py b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_utils.py index 2f02ddaf19c..e27f0ac96d6 100644 --- a/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_utils.py +++ b/tests/test_litellm/integrations/SlackAlerting/test_slack_alerting_utils.py @@ -31,32 +31,35 @@ async def test_langfuse_not_initialized_returns_none_early(): @pytest.mark.asyncio -async def test_langfuse_trace_url_uses_logger_host(monkeypatch): - from litellm.integrations.langfuse.langfuse import LangFuseLogger +async def test_langfuse_trace_url_uses_the_request_host_without_building_a_logger(monkeypatch): + """Key-scoped callbacks point at their own Langfuse host; the alert link follows it. + The lookup must not construct a LangFuseLogger per alert, or an alert storm + exhausts the initialized-client ceiling and takes the callback down with it. + """ monkeypatch.setattr(litellm, "success_callback", ["langfuse"]) - logger = LangFuseLogger( - langfuse_public_key="pk-slack-test", - langfuse_secret="sk-slack-test", - langfuse_host="http://127.0.0.1:1", - ) + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) logging_obj = MagicMock() logging_obj._get_trace_id.return_value = "abc123" - logging_obj._get_callback_object.return_value = logger + logging_obj.standard_callback_dynamic_params = {"langfuse_host": "http://127.0.0.1:1"} result = await _add_langfuse_trace_id_to_alert({"litellm_logging_obj": logging_obj}) assert result == "http://127.0.0.1:1/trace/abc123" + assert litellm.initialized_langfuse_clients == 0 @pytest.mark.asyncio -async def test_langfuse_trace_url_skips_non_langfuse_callback(monkeypatch): +async def test_langfuse_trace_url_falls_back_to_the_env_host(monkeypatch): monkeypatch.setattr(litellm, "success_callback", ["langfuse"]) + monkeypatch.setenv("LANGFUSE_HOST", "langfuse.internal:3000") logging_obj = MagicMock() logging_obj._get_trace_id.return_value = "abc123" - logging_obj._get_callback_object.return_value = object() + logging_obj.standard_callback_dynamic_params = {} - assert await _add_langfuse_trace_id_to_alert({"litellm_logging_obj": logging_obj}) is None + assert await _add_langfuse_trace_id_to_alert({"litellm_logging_obj": logging_obj}) == ( + "http://langfuse.internal:3000/trace/abc123" + ) @pytest.mark.asyncio @@ -75,7 +78,7 @@ async def test_langfuse_trace_url_when_callback_registered_as_logger_instance(mo monkeypatch.setattr(litellm, "callbacks", []) logging_obj = MagicMock() logging_obj._get_trace_id.return_value = "trace-from-instance" - logging_obj._get_callback_object.return_value = logger + logging_obj.standard_callback_dynamic_params = {"langfuse_host": "http://127.0.0.1:1"} result = await _add_langfuse_trace_id_to_alert({"litellm_logging_obj": logging_obj}) @@ -84,16 +87,10 @@ async def test_langfuse_trace_url_when_callback_registered_as_logger_instance(mo @pytest.mark.asyncio async def test_langfuse_trace_url_absent_when_trace_id_never_arrives(monkeypatch): - from litellm.integrations.langfuse.langfuse import LangFuseLogger - monkeypatch.setattr(litellm, "success_callback", ["langfuse"]) monkeypatch.setattr("litellm.integrations.SlackAlerting.utils.asyncio.sleep", AsyncMock()) logging_obj = MagicMock() logging_obj._get_trace_id.return_value = None - logging_obj._get_callback_object.return_value = LangFuseLogger( - langfuse_public_key="pk-slack-none", - langfuse_secret="sk-slack-none", - langfuse_host="http://127.0.0.1:1", - ) + logging_obj.standard_callback_dynamic_params = {"langfuse_host": "http://127.0.0.1:1"} assert await _add_langfuse_trace_id_to_alert({"litellm_logging_obj": logging_obj}) is None diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py index f7c09e4ad1e..492bc546292 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py @@ -83,7 +83,7 @@ class TestLangfusePromptManagement: ), patch( "litellm.integrations.langfuse.langfuse_sdk.Langfuse", mock_langfuse_class - ), # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + ), # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads patch( "litellm.llms.custom_httpx.http_handler.get_ssl_configuration", return_value=False, @@ -127,7 +127,7 @@ def test_langfuse_client_init_resolves_deployment_environment(monkeypatch, env_v monkeypatch.setattr(_RecordingLangfuseForEnv, "last_environment", None) with patch( "litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuseForEnv - ): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + ): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads langfuse_client_init.cache_clear() langfuse_client_init() langfuse_client_init.cache_clear() @@ -145,7 +145,7 @@ def test_langfuse_client_init_warns_that_upstream_langfuse_is_ignored(monkeypatc with ( patch( "litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuseForEnv - ), # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + ), # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads caplog.at_level("WARNING", logger="LiteLLM"), ): langfuse_client_init.cache_clear() @@ -158,20 +158,13 @@ def test_langfuse_client_init_mock_mode_makes_no_network_calls(monkeypatch): """LANGFUSE_MOCK promises full execution without egress. The registry maps the "langfuse" callback to LangfusePromptManagement, so - this client is the one the standard proxy path emits observations through; - v4 ships them over its own OTLP exporter, which the httpx mock cannot see. + this logger is the one the standard proxy path emits observations through; + they travel over litellm's own OTLP exporter, which the httpx mock cannot see. """ import threading - import time from http.server import BaseHTTPRequestHandler, HTTPServer - from langfuse._client.resource_manager import LangfuseResourceManager - - from litellm.integrations.langfuse.langfuse_sdk import ( - open_trace_context, - start_generation, - to_unix_nanos, - ) + import litellm received = [] @@ -192,35 +185,34 @@ def test_langfuse_client_init_mock_mode_makes_no_network_calls(monkeypatch): monkeypatch.setenv("LANGFUSE_HOST", f"http://127.0.0.1:{server.server_port}") monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-pm-mock-egress") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-pm-mock-egress") - LangfuseResourceManager._instances.pop("pk-pm-mock-egress", None) langfuse_client_init.cache_clear() + now: Final = datetime.now(timezone.utc) try: - client = langfuse_client_init() - context, claim_root = open_trace_context(client=client, trace_id="a" * 32, parent_observation_id=None) - now = datetime.now(timezone.utc) - start_generation( - client=client, - context=context, - name="pm-mock-gen", + logger = LangfusePromptManagement() + logged = logger.log_event_on_langfuse( + kwargs={ + "litellm_call_id": "call-pm-mock-egress", + "call_type": "completion", + "litellm_params": {"metadata": {"trace_id": "a" * 32}}, + "messages": [{"role": "user", "content": "hi"}], + "optional_params": {}, + }, + response_obj=litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "ok"}}]), start_time=now, - claim_trace_root=claim_root, - attributes={}, - ).end(end_time=to_unix_nanos(now)) - client.flush() - time.sleep(1) + end_time=now, + ) + logger.flush() finally: server.shutdown() langfuse_client_init.cache_clear() - LangfuseResourceManager._instances.pop("pk-pm-mock-egress", None) + assert logged["trace_id"] == "a" * 32 assert received == [], f"LANGFUSE_MOCK still sent spans to the configured host: {received}" @pytest.mark.asyncio async def test_async_log_failure_event_records_trace_id_for_alerting(monkeypatch): - from langfuse._client.resource_manager import LangfuseResourceManager - from litellm.integrations.langfuse.langfuse_sdk import resolve_trace_id from litellm.litellm_core_utils.specialty_caches.service_trace_id_cache import in_memory_trace_id_cache @@ -228,7 +220,6 @@ async def test_async_log_failure_event_records_trace_id_for_alerting(monkeypatch monkeypatch.setenv("LANGFUSE_HOST", "http://127.0.0.1:1") monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-pm-trace-cache") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-pm-trace-cache") - LangfuseResourceManager._instances.pop("pk-pm-trace-cache", None) langfuse_client_init.cache_clear() call_id: Final = "call-trace-cache-1" now: Final = datetime.now(timezone.utc) @@ -248,7 +239,6 @@ async def test_async_log_failure_event_records_trace_id_for_alerting(monkeypatch ) finally: langfuse_client_init.cache_clear() - LangfuseResourceManager._instances.pop("pk-pm-trace-cache", None) assert in_memory_trace_id_cache.get_cache(litellm_call_id=call_id, service_name="langfuse") == resolve_trace_id( "alert-trace-1" diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py index d5e173160d2..94f957285d0 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -1,23 +1,24 @@ -"""Covers the v4 observation plumbing: historical timestamps and id normalisation. +"""Covers litellm's own Langfuse export channel: plain OTel spans carrying the v2 contracts. -The timestamp assertions are the regression guard for the migration: v4 has no -public API for an observation start time, so a callback running after the model -call would otherwise record its own duration instead of the call's. +The timestamp assertions are the regression guard for the migration: the v4 SDK's public +API has no observation start time, so a callback running after the model call would +otherwise record its own duration instead of the call's. """ import json import logging -import threading import uuid +from base64 import b64encode from datetime import datetime, timedelta, timezone +from types import MappingProxyType from typing import Final +import httpx import opentelemetry.trace as otel_trace import pytest -from langfuse import Langfuse -from langfuse._client.resource_manager import LangfuseResourceManager +from langfuse import LangfuseOtelSpanAttributes as A +from langfuse.api import UnauthorizedError from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from litellm.integrations.langfuse.langfuse import ( @@ -26,73 +27,80 @@ from litellm.integrations.langfuse.langfuse import ( raise_if_unsupported_langfuse_version, ) from litellm.integrations.langfuse.langfuse_sdk import ( - AS_ROOT_ATTRIBUTE, - PUBLIC_ATTRIBUTE, - RELEASE_ATTRIBUTE, - _lifecycle_state, - _litellm_built_providers, - _teardown_langfuse_client, - acquire_langfuse_client, - build_isolated_tracer_provider, + DiscardingSpanExporter, + LangfuseTracing, + RetryingSpanExporter, + _build_span_exporter, + acquire_langfuse_tracing, + build_langfuse_client, + build_langfuse_tracing, configured_sample_rate, - evict_stale_langfuse_resources, - lease_langfuse_client, - open_trace_context, - register_langfuse_client, + observation_attributes, resolve_observation_id, resolve_trace_id, - shutdown_langfuse_client, start_child_span, start_generation, to_unix_nanos, + trace_attributes, ) CALL_START = datetime(2024, 3, 1, 12, 0, 0, tzinfo=timezone.utc) FIRST_TOKEN = CALL_START + timedelta(seconds=5) CALL_END = CALL_START + timedelta(seconds=20) +TRACE_A = "a" * 32 +PARENT_C = "c" * 16 -@pytest.fixture(name="client") -def _client(): - LangfuseResourceManager._instances.pop("pk-obs-test", None) +@pytest.fixture(name="channel") +def _channel() -> tuple[LangfuseTracing, InMemorySpanExporter]: exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key="pk-obs-test", - secret_key="sk-obs-test", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, + return ( + build_langfuse_tracing( + exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10 + ), + exporter, ) - yield client, exporter - LangfuseResourceManager._instances.pop("pk-obs-test", None) def _only_span(exporter, name): return next(s for s in exporter.get_finished_spans() if s.name == name) -def test_generation_records_the_model_call_window_not_the_callback(client): - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="a" * 32, parent_observation_id=None) - start_generation( - client=lf, - context=context, - name="gen", +def _generation( + tracing, + *, + name="gen", + trace_id=TRACE_A, + parent=None, + existing=False, + observation_id=None, + public=None, + attributes=None, +): + return start_generation( + tracing=tracing, + trace_id=trace_id, + parent_observation_id=parent, + existing_trace=existing, + observation_id=observation_id, + name=name, start_time=CALL_START, - claim_trace_root=claim_root, - attributes={"completion_start_time": FIRST_TOKEN}, - ).end(end_time=to_unix_nanos(CALL_END)) - lf.flush() + public=public, + attributes=attributes if attributes is not None else {}, + ) + + +def test_generation_records_the_model_call_window_not_the_callback(channel): + tracing, exporter = channel + attributes = observation_attributes(observation_type="generation", completion_start_time=FIRST_TOKEN) + _generation(tracing, attributes=attributes).end(CALL_END) + tracing.flush() span = _only_span(exporter, "gen") assert span.start_time == to_unix_nanos(CALL_START) assert span.end_time == to_unix_nanos(CALL_END) assert (span.end_time - span.start_time) == 20 * 1_000_000_000 - assert json.loads(span.attributes["langfuse.observation.completion_start_time"]) == FIRST_TOKEN.isoformat().replace( - "+00:00", "Z" - ) + assert datetime.fromisoformat(json.loads(span.attributes[A.OBSERVATION_COMPLETION_START_TIME])) == FIRST_TOKEN @pytest.mark.parametrize( @@ -105,181 +113,193 @@ def test_timestamps_accept_both_shapes_guardrails_and_callbacks_use(supplied): assert to_unix_nanos(supplied) == 1709294400500000000 -def test_guardrail_span_with_float_timestamps_does_not_break_the_generation(client): - """A guardrail entry must not take the whole event down with it.""" - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="9" * 32, parent_observation_id=None) +def test_guardrail_span_with_float_timestamps_keeps_its_own_window_under_the_generation(channel): + tracing, exporter = channel guardrail_start = 1709294400.0 - generation = start_generation( - client=lf, context=context, name="gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} - ) + generation = _generation(tracing) start_child_span( - client=lf, - parent=generation, - name="guardrail", - start_time=guardrail_start, - attributes={}, - ).end(end_time=to_unix_nanos(guardrail_start + 2)) - generation.end(end_time=to_unix_nanos(CALL_END)) - lf.flush() - - guardrail = _only_span(exporter, "guardrail") - assert (guardrail.end_time - guardrail.start_time) == 2 * 1_000_000_000 - assert _only_span(exporter, "gen") is not None - - -def test_generation_claims_trace_root_only_without_a_real_parent(client): - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="b" * 32, parent_observation_id=None) - assert claim_root is True - start_generation( - client=lf, context=context, name="root-gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} - ).end() - - parented_context, parented_claim = open_trace_context(client=lf, trace_id="b" * 32, parent_observation_id="c" * 16) - assert parented_claim is False - start_generation( - client=lf, - context=parented_context, - name="child-gen", - start_time=CALL_START, - claim_trace_root=parented_claim, - attributes={}, - ).end() - lf.flush() - - assert _only_span(exporter, "root-gen").attributes.get(AS_ROOT_ATTRIBUTE) is True - assert _only_span(exporter, "child-gen").attributes.get(AS_ROOT_ATTRIBUTE) is None - - -def test_child_span_keeps_its_own_window_and_only_the_generation_claims_root(client): - """The server takes trace name and I/O from every root observation, latest start wins. - - A post-call guardrail starts after the model call, so if it also claimed root the - trace would show the guardrail's I/O instead of the model's. - """ - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="d" * 32, parent_observation_id=None) - generation = start_generation( - client=lf, context=context, name="gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} - ) - guardrail_start = CALL_END + timedelta(seconds=1) - start_child_span( - client=lf, - parent=generation, - name="guardrail", - start_time=guardrail_start, - attributes={}, - ).end(end_time=to_unix_nanos(guardrail_start + timedelta(seconds=2))) - generation.end(end_time=to_unix_nanos(CALL_END)) - lf.flush() + tracing=tracing, parent=generation, name="guardrail", start_time=guardrail_start, attributes={} + ).end(guardrail_start + 2) + generation.end(CALL_END) + tracing.flush() guardrail = _only_span(exporter, "guardrail") exported_generation = _only_span(exporter, "gen") assert (guardrail.end_time - guardrail.start_time) == 2 * 1_000_000_000 assert guardrail.context.trace_id == exported_generation.context.trace_id assert guardrail.parent.span_id == exported_generation.context.span_id - assert AS_ROOT_ATTRIBUTE not in guardrail.attributes - assert exported_generation.attributes.get(AS_ROOT_ATTRIBUTE) is True -def test_release_is_carried_on_the_root_observation(client): - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="e" * 32, parent_observation_id=None) - start_generation( - client=lf, - context=context, - name="gen", - start_time=CALL_START, - claim_trace_root=claim_root, - release="v1.2.3", - attributes={}, - ).end() - lf.flush() - assert _only_span(exporter, "gen").attributes[RELEASE_ATTRIBUTE] == "v1.2.3" +def test_requested_trace_id_is_the_exported_trace_id_and_the_generation_is_its_root(channel): + """v2 ``trace(id=...)``: the caller's id is the trace and the generation has no parent.""" + tracing, exporter = channel + generation = _generation(tracing) + generation.end(CALL_END) + tracing.flush() + + span = _only_span(exporter, "gen") + assert generation.trace_id == TRACE_A + assert format(span.context.trace_id, "032x") == TRACE_A + assert span.parent is None + + +def test_parent_observation_id_nests_the_generation_under_the_callers_observation(channel): + tracing, exporter = channel + _generation(tracing, name="child-gen", parent=PARENT_C).end(CALL_END) + tracing.flush() + + span = _only_span(exporter, "child-gen") + assert format(span.context.trace_id, "032x") == TRACE_A + assert format(span.parent.span_id, "016x") == PARENT_C + assert span.parent.is_remote + + +def test_existing_trace_is_appended_to_rather_than_rewritten(channel): + """v2 ``existing_trace_id``: the generation joins the trace without becoming its root.""" + tracing, exporter = channel + _generation(tracing, name="continued", existing=True).end(CALL_END) + tracing.flush() + + span = _only_span(exporter, "continued") + assert format(span.context.trace_id, "032x") == TRACE_A + assert span.parent is not None + assert span.parent.span_id != 0 + + +def test_generation_does_not_hang_under_the_callers_active_span(channel): + """The caller's own OTel span must stay untouched and must not become the generation's parent.""" + tracing, exporter = channel + app_tracer = TracerProvider().get_tracer("app") + with app_tracer.start_as_current_span("app-span") as app_span: + _generation(tracing, trace_id="b" * 32).end(CALL_END) + attributes_after = dict(app_span.attributes or {}) + tracing.flush() + + span = _only_span(exporter, "gen") + assert span.parent is None + assert format(span.context.trace_id, "032x") == "b" * 32 + assert attributes_after == {} + + +def test_requested_observation_id_becomes_the_exported_span_id(channel): + """v2 ``generation(id=...)``: the caller's id is what the export carries and what ``.id`` returns.""" + tracing, exporter = channel + requested = resolve_observation_id("chatcmpl-123") + + generation = _generation(tracing, observation_id=requested) + generation.end(CALL_END) + tracing.flush() + + assert generation.id == requested + assert format(_only_span(exporter, "gen").context.span_id, "016x") == requested + + +def test_requested_ids_do_not_leak_into_the_next_span(channel): + tracing, exporter = channel + requested = resolve_observation_id("chatcmpl-123") + + _generation(tracing, name="first", observation_id=requested).end(CALL_END) + second = _generation(tracing, name="second", trace_id=resolve_trace_id(None)) + second.end(CALL_END) + child = start_child_span(tracing=tracing, parent=second, name="child", start_time=CALL_END, attributes={}) + child.end(CALL_END) + tracing.flush() + + assert second.id != requested + assert child.id not in (requested, second.id) + assert len({span.context.span_id for span in exporter.get_finished_spans()}) == 3 @pytest.mark.parametrize("public", [True, False], ids=["public", "private"]) -def test_trace_public_flag_lands_on_the_root_observation(client, public): - """v2 took ``public`` on ``trace()``; v4 reads ``langfuse.trace.public`` off the root observation.""" - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="a" * 32, parent_observation_id=None) - start_generation( - client=lf, - context=context, - name="gen", - start_time=CALL_START, - claim_trace_root=claim_root, - public=public, - attributes={}, - ).end() - lf.flush() - assert _only_span(exporter, "gen").attributes[PUBLIC_ATTRIBUTE] is public +def test_trace_public_flag_lands_on_the_root_observation(channel, public): + tracing, exporter = channel + _generation(tracing, public=public, attributes=trace_attributes(public=public)).end(CALL_END) + tracing.flush() + assert _only_span(exporter, "gen").attributes[A.TRACE_PUBLIC] is public -def test_trace_public_flag_is_absent_when_not_requested(client): - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="b" * 32, parent_observation_id=None) - start_generation( - client=lf, context=context, name="gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} - ).end() - lf.flush() - assert PUBLIC_ATTRIBUTE not in _only_span(exporter, "gen").attributes +def test_trace_public_flag_is_absent_when_not_requested(channel): + tracing, exporter = channel + _generation(tracing, attributes=trace_attributes(public=None)).end(CALL_END) + tracing.flush() + assert A.TRACE_PUBLIC not in _only_span(exporter, "gen").attributes @pytest.mark.parametrize("public", [True, False, None], ids=["public", "private", "unset"]) -def test_child_span_repeats_the_generation_public_flag(client, public): +def test_child_span_repeats_the_generation_public_flag(channel, public): """The server folds ``public`` across observations and reads a missing value as False. A guardrail span without the flag turned a ``trace_public: true`` request private on Langfuse Cloud. """ - lf, exporter = client - context, claim_root = open_trace_context(client=lf, trace_id="c" * 32, parent_observation_id=None) - generation = start_generation( - client=lf, - context=context, - name="gen", - start_time=CALL_START, - claim_trace_root=claim_root, - public=public, - attributes={}, + tracing, exporter = channel + generation = _generation(tracing, public=public) + start_child_span(tracing=tracing, parent=generation, name="guardrail", start_time=CALL_END, attributes={}).end() + generation.end(CALL_END) + tracing.flush() + + assert _only_span(exporter, "guardrail").attributes.get(A.TRACE_PUBLIC) is public + + +def test_trace_attributes_carry_the_v2_trace_fields(): + attributes = trace_attributes( + name="trace-name", + user_id="user-1", + session_id="session-1", + version="v2", + release="rel-1", + tags=("a", "b"), + metadata={"tenant": "t1", "nested": {"k": 1}}, + input={"messages": []}, + output="answer", ) - start_child_span(client=lf, parent=generation, name="guardrail", start_time=CALL_END, attributes={}).end() - generation.end(end_time=to_unix_nanos(CALL_END)) - lf.flush() - - assert _only_span(exporter, "guardrail").attributes.get(PUBLIC_ATTRIBUTE) is public + assert attributes[A.TRACE_NAME] == "trace-name" + assert attributes[A.TRACE_USER_ID] == "user-1" + assert attributes[A.TRACE_SESSION_ID] == "session-1" + assert attributes[A.VERSION] == "v2" + assert attributes[A.RELEASE] == "rel-1" + assert attributes[A.TRACE_TAGS] == ("a", "b") + assert attributes[f"{A.TRACE_METADATA}.tenant"] == "t1" + assert json.loads(attributes[f"{A.TRACE_METADATA}.nested"]) == {"k": 1} + assert json.loads(attributes[A.TRACE_INPUT]) == {"messages": []} + assert attributes[A.TRACE_OUTPUT] == "answer" -def test_request_release_beats_the_client_wide_release(monkeypatch): - """A client configured with its own release must not overwrite trace_release.""" - monkeypatch.setenv("LANGFUSE_RELEASE", "client-wide-release") - LangfuseResourceManager._instances.pop("pk-release-test", None) - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - lf = Langfuse( - public_key="pk-release-test", - secret_key="sk-release-test", - host="http://127.0.0.1:1", - release="client-wide-release", - tracer_provider=provider, - span_exporter=exporter, +def test_trace_attributes_skip_what_the_request_did_not_supply(): + assert dict(trace_attributes()) == {} + + +def test_non_mapping_metadata_is_carried_whole_instead_of_raising(): + """A truthy non-dict ``trace_metadata`` used to blow up the callback on ``**`` unpacking.""" + attributes = trace_attributes(metadata=("not", "a", "dict")) + assert json.loads(attributes[A.TRACE_METADATA]) == ["not", "a", "dict"] + + +def test_observation_attributes_serialize_the_generation_fields(): + attributes = observation_attributes( + observation_type="generation", + input=[{"role": "user", "content": "hi"}], + output={"role": "assistant", "content": "hello"}, + metadata=MappingProxyType({"litellm_call_id": "call-1", "cache_hit": False}), + level="ERROR", + status_message="boom", + model="gpt-4o", + model_parameters={"temperature": 0.1}, + usage_details={"input": 1, "output": 2}, + cost_details={"total": 0.01}, + prompt="not-a-prompt-client", ) - context, claim_root = open_trace_context(client=lf, trace_id="f" * 32, parent_observation_id=None) - start_generation( - client=lf, - context=context, - name="gen", - start_time=CALL_START, - claim_trace_root=claim_root, - release="per-request-release", - attributes={}, - ).end() - lf.flush() - LangfuseResourceManager._instances.pop("pk-release-test", None) - - assert _only_span(exporter, "gen").attributes[RELEASE_ATTRIBUTE] == "per-request-release" + assert attributes[A.OBSERVATION_TYPE] == "generation" + assert attributes[A.OBSERVATION_LEVEL] == "ERROR" + assert attributes[A.OBSERVATION_STATUS_MESSAGE] == "boom" + assert attributes[A.OBSERVATION_MODEL] == "gpt-4o" + assert json.loads(attributes[A.OBSERVATION_INPUT]) == [{"role": "user", "content": "hi"}] + assert json.loads(attributes[A.OBSERVATION_OUTPUT]) == {"role": "assistant", "content": "hello"} + assert json.loads(attributes[A.OBSERVATION_MODEL_PARAMETERS]) == {"temperature": 0.1} + assert json.loads(attributes[A.OBSERVATION_USAGE_DETAILS]) == {"input": 1, "output": 2} + assert json.loads(attributes[A.OBSERVATION_COST_DETAILS]) == {"total": 0.01} + assert attributes[f"{A.OBSERVATION_METADATA}.litellm_call_id"] == "call-1" + assert attributes[f"{A.OBSERVATION_METADATA}.cache_hit"] is False + assert A.OBSERVATION_PROMPT_NAME not in attributes @pytest.mark.parametrize( @@ -367,25 +387,6 @@ def test_arbitrary_observation_id_is_hashed_to_a_span_id(): assert resolved == resolve_observation_id("my-parent-observation") -PUBLIC_KEY = "pk-lifecycle-test" - - -@pytest.fixture(autouse=True) -def _clean_registry(): - LangfuseResourceManager._instances.pop(PUBLIC_KEY, None) - yield - LangfuseResourceManager._instances.pop(PUBLIC_KEY, None) - - -def _lifecycle_client(secret_key="sk-original", host="http://127.0.0.1:1"): - return Langfuse( - public_key=PUBLIC_KEY, - secret_key=secret_key, - host=host, - tracer_provider=build_isolated_tracer_provider(environment=None, release=None), - ) - - @pytest.mark.parametrize("unsupported", ["2.59.7", "3.15.0", "5.0.0"], ids=["v2", "v3", "v5"]) def test_unsupported_sdk_fails_loudly_rather_than_dropping_every_event(unsupported): with pytest.raises(ImportError) as raised: @@ -398,39 +399,27 @@ def test_supported_sdk_is_accepted(): assert raise_if_unsupported_langfuse_version(installed_langfuse_version()) is None -def test_isolated_provider_carries_environment_and_release(): - provider = build_isolated_tracer_provider(environment="staging", release="v9") - attributes = provider.resource.attributes - assert attributes["langfuse.environment"] == "staging" - assert attributes["langfuse.release"] == "v9" +def test_channel_carries_environment_and_release_on_the_resource(): + tracing = build_langfuse_tracing( + exporter=DiscardingSpanExporter(), + environment="staging", + release="v9", + sample_rate=1.0, + flush_interval_millis=10, + ) + attributes = tracing.provider.resource.attributes + assert attributes[A.ENVIRONMENT] == "staging" + assert attributes[A.RELEASE] == "v9" def _generations_exported_at(sample_rate: float, trace_ids: tuple[str, ...]) -> frozenset[str]: exporter: Final = InMemorySpanExporter() - provider: Final = build_isolated_tracer_provider(environment=None, release=None, sample_rate=sample_rate) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - pk: Final = f"pk-sample-{sample_rate}" - LangfuseResourceManager._instances.pop(pk, None) - client: Final = Langfuse( - public_key=pk, - secret_key="sk-sample", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, + tracing: Final = build_langfuse_tracing( + exporter=exporter, environment=None, release=None, sample_rate=sample_rate, flush_interval_millis=10 ) - try: - for trace_id in trace_ids: - context, claim_root = open_trace_context(client=client, trace_id=trace_id, parent_observation_id=None) - start_generation( - client=client, - context=context, - name="sampled", - start_time=CALL_START, - claim_trace_root=claim_root, - attributes={}, - ).end() - finally: - LangfuseResourceManager._instances.pop(pk, None) + for trace_id in trace_ids: + _generation(tracing, name="sampled", trace_id=trace_id).end(CALL_END) + tracing.flush() return frozenset(format(span.context.trace_id, "032x") for span in exporter.get_finished_spans()) @@ -457,19 +446,6 @@ def test_unusable_sample_rate_warns_and_exports_everything( assert configured_sample_rate() == 1.0 assert "LANGFUSE_SAMPLE_RATE" in caplog.text - pk: Final = f"pk-unusable-rate-{raw}" - LangfuseResourceManager._instances.pop(pk, None) - try: - client: Final = acquire_langfuse_client( - parameters={"public_key": pk, "secret_key": "sk", "base_url": "http://127.0.0.1:1"}, - environment=None, - release=None, - mock_mode=True, - ) - assert client._resources.sample_rate == 1.0 - finally: - LangfuseResourceManager._instances.pop(pk, None) - def test_configured_sample_rate_reads_the_env_var(monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("LANGFUSE_SAMPLE_RATE", raising=False) @@ -478,614 +454,121 @@ def test_configured_sample_rate_reads_the_env_var(monkeypatch: pytest.MonkeyPatc assert configured_sample_rate() == 0.25 -def _isolated_client_with_exporter(): - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-observation-id", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - return client, exporter - - -def _start_generation(client, name, observation_id): - context, claim_root = open_trace_context(client=client, trace_id="b" * 32, parent_observation_id=None) - return start_generation( - client=client, - context=context, - name=name, - start_time=CALL_START, - claim_trace_root=claim_root, - observation_id=observation_id, - attributes={}, - ) - - -def test_requested_observation_id_becomes_the_exported_span_id(): - """v2 ``generation(id=...)``: the caller's id is what the export carries and what ``.id`` returns.""" - lf, exporter = _isolated_client_with_exporter() - requested = resolve_observation_id("chatcmpl-123") - - generation = _start_generation(lf, "requested", requested) - generation.end(end_time=to_unix_nanos(CALL_END)) - lf.flush() - - assert generation.id == requested - assert format(_only_span(exporter, "requested").context.span_id, "016x") == requested - - -def test_requested_observation_id_does_not_leak_into_the_next_span(): - lf, exporter = _isolated_client_with_exporter() - requested = resolve_observation_id("chatcmpl-123") - - _start_generation(lf, "first", requested).end(end_time=to_unix_nanos(CALL_END)) - second = _start_generation(lf, "second", None) - second.end(end_time=to_unix_nanos(CALL_END)) - third = _start_generation(lf, "third", None) - third.end(end_time=to_unix_nanos(CALL_END)) - lf.flush() - - assert second.id != requested - assert third.id != second.id - assert len({span.context.span_id for span in exporter.get_finished_spans()}) == 3 - - -def test_requested_observation_id_is_ignored_on_a_provider_litellm_did_not_build(client): - lf, _ = client - requested = resolve_observation_id("chatcmpl-123") - - generation = _start_generation(lf, "adopted", requested) - generation.end(end_time=to_unix_nanos(CALL_END)) - - assert generation.id != requested - - -def test_environment_override_lands_per_span_despite_shared_resources(): - """The SDK registry is keyed on public key alone, so a second client for the - same key adopts the first client's provider; the observation wrapper stamps - each span with its own client's environment, which the server prefers over - the resource-level value.""" - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - first = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - environment="prod", - tracer_provider=provider, - span_exporter=exporter, - ) - second = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - environment="staging", - ) - assert second._resources is first._resources - - for client, environment in ((first, "prod"), (second, "staging")): - context, claim_trace_root = open_trace_context(client=client, trace_id="a" * 32, parent_observation_id=None) - start_generation( - client=client, - context=context, - name=f"generation-{environment}", - start_time=CALL_START, - claim_trace_root=claim_trace_root, - attributes={}, - ).end() - first.flush() - - spans = {span.name: span for span in exporter.get_finished_spans()} - assert spans["generation-prod"].attributes["langfuse.environment"] == "prod" - assert spans["generation-staging"].attributes["langfuse.environment"] == "staging" - - -def test_client_does_not_take_over_the_process_tracer_provider(): - # the global provider can only be set once per process, so assert it is left - # alone rather than assuming this test is the one that installed it +def test_channel_does_not_take_over_the_process_tracer_provider(): provider_before = otel_trace.get_tracer_provider() - client = _lifecycle_client() + tracing = acquire_langfuse_tracing( + public_key="pk-global-test", + secret_key="sk", + base_url="http://127.0.0.1:1", + environment=None, + release=None, + flush_interval=1.0, + mock_mode=True, + ) assert otel_trace.get_tracer_provider() is provider_before - assert client._resources.tracer_provider is not provider_before - active = getattr(provider_before, "_active_span_processor", None) - if active is not None: - assert not any("Langfuse" in type(processor).__name__ for processor in active._span_processors) + assert tracing.provider is not provider_before -def test_rotated_credentials_replace_the_cached_client(): - original = _lifecycle_client(secret_key="sk-original", host="http://127.0.0.1:1") - original_resources = original._resources - - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-rotated", base_url="http://127.0.0.1:2") - rotated = _lifecycle_client(secret_key="sk-rotated", host="http://127.0.0.1:2") - - assert rotated._resources is not original_resources - assert rotated._resources.secret_key == "sk-rotated" - assert rotated._resources.base_url == "http://127.0.0.1:2" +def _acquire(**overrides): + parameters = { + "public_key": "pk-cache-test", + "secret_key": "sk-cache", + "base_url": "http://127.0.0.1:1", + "environment": None, + "release": None, + "flush_interval": 1.0, + "mock_mode": True, + } + return acquire_langfuse_tracing(**{**parameters, **overrides}) -def test_unchanged_credentials_keep_the_cached_client(): - original = _lifecycle_client() - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-original", base_url="http://127.0.0.1:1") - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is original._resources +def test_same_credentials_share_one_channel(): + assert _acquire() is _acquire() -def test_eviction_flushes_queued_observations_before_tearing_down(): - """An observation already ended when the cache evicts must still reach langfuse.""" - from opentelemetry.sdk.trace.export import BatchSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, +@pytest.mark.parametrize( + "override", + [ + {"secret_key": "sk-rotated"}, + {"base_url": "http://127.0.0.1:2"}, + {"environment": "staging"}, + {"mock_mode": False}, + ], + ids=["secret", "host", "environment", "mock-to-live"], +) +def test_changed_credentials_or_settings_get_their_own_channel(override): + assert _acquire() is not _acquire(**override) + + +def test_a_changed_sample_rate_rebuilds_the_channel(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "0.25") + quarter = _acquire(public_key="pk-resample-test") + monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "1") + full = _acquire(public_key="pk-resample-test") + + assert full is not quarter + assert quarter.provider.sampler.get_description() == "TraceIdHashSampler{0.25}" + assert "TraceIdHashSampler" not in full.provider.sampler.get_description() + + +def test_sdk_client_rest_api_follows_the_supplied_credentials_not_the_registry(): + """The SDK keeps one resource bundle per public key, so a rotated secret or another host + would otherwise keep authenticating prompt fetches with whatever it saw first.""" + requests = [] + + def record(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(401, json={"message": "unauthorized"}) + + parameters = { + "public_key": "pk-rest-test", + "secret_key": "sk-first", + "base_url": "http://127.0.0.1:1", + "httpx_client": httpx.Client(transport=httpx.MockTransport(record)), + } + build_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) + rotated = build_langfuse_client( + parameters={**parameters, "secret_key": "sk-second", "base_url": "http://127.0.0.1:2"}, + environment=None, + release=None, + mock_mode=True, ) - from litellm.integrations.langfuse.langfuse_sdk import ( - open_trace_context, - start_generation, + with pytest.raises(UnauthorizedError): + rotated.auth_check() + assert requests[-1].url.host == "127.0.0.1" and requests[-1].url.port == 2 + assert requests[-1].headers["authorization"] == "Basic " + b64encode(b"pk-rest-test:sk-second").decode() + + +def test_sdk_client_without_keys_is_built_disabled_and_fails_auth_check(monkeypatch): + """``/health/services?service=langfuse`` with no credentials must report a failed check, not crash.""" + for name in ("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"): + monkeypatch.delenv(name, raising=False) + client = build_langfuse_client( + parameters={"public_key": None, "secret_key": None, "base_url": "http://127.0.0.1:1"}, + environment=None, + release=None, + mock_mode=True, ) + assert client.auth_check() is False - exporter = InMemorySpanExporter() - provider = TracerProvider() - # a long delay keeps the span queued, so only the shutdown can flush it - provider.add_span_processor(BatchSpanProcessor(exporter, schedule_delay_millis=600000)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, + +def test_sdk_client_does_not_take_over_the_process_tracer_provider(): + provider_before = otel_trace.get_tracer_provider() + build_langfuse_client( + parameters={"public_key": "pk-sdk-global-test", "secret_key": "sk", "base_url": "http://127.0.0.1:1"}, + environment=None, + release=None, + mock_mode=True, ) - context, claim_root = open_trace_context(client=client, trace_id="a" * 32, parent_observation_id=None) - start_generation( - client=client, context=context, name="in-flight", start_time=None, claim_trace_root=claim_root, attributes={} - ).end() - assert exporter.get_finished_spans() == () - - shutdown_langfuse_client(client) - - assert any(span.name == "in-flight" for span in exporter.get_finished_spans()) - - -def test_shutdown_deregisters_so_a_later_client_is_not_a_corpse(): - client = _lifecycle_client() - resources = client._resources - - shutdown_langfuse_client(client) - - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is not resources - - -def _shared_resources_pair(): - """The SDK hands a second client on the same public key the first client's resources.""" - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - _litellm_built_providers.add(provider) - first = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - second = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=build_isolated_tracer_provider(environment="per-key-override", release=None), - ) - assert second._resources is first._resources - register_langfuse_client(first) - register_langfuse_client(second) - return first, second, exporter - - -def _exports(client, exporter, name): - client.start_observation(name=name).end() - client.flush() - return any(span.name == name for span in exporter.get_finished_spans()) - - -def _never_renew(): - raise AssertionError("the lease renewed a client eviction never reached") - - -def test_garbage_collected_throwaway_clients_do_not_hold_shared_resources_open(): - """A health probe or alerting lookup builds a client it never shuts down. - - Once such a client is garbage collected it must stop counting, or the last - managed client's shutdown would skip the teardown forever. - """ - import gc - - first, second, exporter = _shared_resources_pair() - throwaway = Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1") - register_langfuse_client(throwaway) - shutdown_langfuse_client(second) - del throwaway - gc.collect() - - shutdown_langfuse_client(first) - - assert not _exports(first, exporter, "after-managed-teardown") - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is not first._resources - - -def test_evicting_a_client_that_shares_resources_keeps_the_other_exporting(): - """A per-key ``langfuse_environment`` override is a second client on the global key. - - When the cache evicts it, the global logger must keep exporting. - """ - first, second, exporter = _shared_resources_pair() - - shutdown_langfuse_client(second) - - assert _exports(first, exporter, "after-sibling-eviction") - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is first._resources - - -def test_eviction_defers_teardown_until_active_callback_finishes(): - """A cached client must keep exporting while its callback lease is active.""" - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - register_langfuse_client(client) - - def evict() -> None: - shutdown_langfuse_client(client) - - with lease_langfuse_client(client, _never_renew): - evictor = threading.Thread(target=evict) - evictor.start() - evictor.join(timeout=5) - assert not evictor.is_alive() - assert not exporter._stopped - - context, claim_root = open_trace_context(client=client, trace_id="a" * 32, parent_observation_id=None) - start_generation( - client=client, - context=context, - name="active-callback", - start_time=None, - claim_trace_root=claim_root, - attributes={}, - ).end() - client.flush() - assert any(span.name == "active-callback" for span in exporter.get_finished_spans()) - - evictor.join(timeout=5) - assert not evictor.is_alive() - assert exporter._stopped - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is not client._resources - - -def test_teardown_failure_does_not_strand_queued_clients(monkeypatch): - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - first = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - clients = ( - first, - Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1"), - Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1"), - ) - assert len({client._resources for client in clients}) == 1 - for client in clients: - register_langfuse_client(client) - state = _lifecycle_state(clients[0]) - original_teardown = _teardown_langfuse_client - calls = [] - - def teardown(client): - calls.append(client) - original_teardown(client) - if len(calls) == 1: - raise RuntimeError("teardown failed") - - monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._teardown_langfuse_client", teardown) - with lease_langfuse_client(clients[0], _never_renew): - for client in clients: - shutdown_langfuse_client(client) - - assert len(calls) == 3 - assert not state.pending_clients - assert not state.teardown_in_progress - - -def test_interrupt_during_deferred_teardown_propagates_and_requeues_the_client(monkeypatch): - """A Ctrl-C landing in the lease exit's teardown must reach the caller, not be swallowed.""" - client = Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1") - register_langfuse_client(client) - state = _lifecycle_state(client) - original_teardown = _teardown_langfuse_client - calls = [] - - def teardown(target): - calls.append(target) - if len(calls) == 1: - raise KeyboardInterrupt - original_teardown(target) - - monkeypatch.setattr("litellm.integrations.langfuse.langfuse_sdk._teardown_langfuse_client", teardown) - with pytest.raises(KeyboardInterrupt): - with lease_langfuse_client(client, _never_renew): - shutdown_langfuse_client(client) - - assert state.pending_clients == {client} - assert not state.teardown_in_progress - - replacement = Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1") - with lease_langfuse_client(client, lambda: replacement) as leased: - assert leased is replacement - - assert calls == [client, client] - assert not state.pending_clients - - -def test_lease_on_a_client_evicted_after_the_cache_lookup_exports_through_a_renewed_one(): - """Eviction can land between the cache handing out the logger and the callback taking its lease. - - That callback must not export into a shut-down provider; the lease has to hand it a live client. - """ - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - evicted = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - register_langfuse_client(evicted) - shutdown_langfuse_client(evicted) - assert exporter._stopped - - renewed_exporter = InMemorySpanExporter() - renewed_provider = build_isolated_tracer_provider(environment=None, release=None) - renewed_provider.add_span_processor(SimpleSpanProcessor(renewed_exporter)) - - def renew(): - renewed = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=renewed_provider, - span_exporter=renewed_exporter, - ) - register_langfuse_client(renewed) - return renewed - - with lease_langfuse_client(evicted, renew) as leased: - assert leased is not evicted - assert _exports(leased, renewed_exporter, "after-lookup-eviction") - shutdown_langfuse_client(leased) - assert not renewed_exporter._stopped - - assert renewed_exporter._stopped - - -def test_queued_eviction_waits_for_the_last_of_two_overlapping_leases(): - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - register_langfuse_client(client) - - with lease_langfuse_client(client, _never_renew): - with lease_langfuse_client(client, _never_renew): - shutdown_langfuse_client(client) - assert not exporter._stopped - - assert exporter._stopped - - -def test_a_client_adopted_during_deferred_teardown_keeps_exporting(): - """The registry hands the same bundle back out while its teardown is queued behind a lease; - the holder count must degrade that teardown to a flush.""" - exporter = InMemorySpanExporter() - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(exporter)) - _litellm_built_providers.add(provider) - evicted = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - register_langfuse_client(evicted) - - with lease_langfuse_client(evicted, _never_renew): - shutdown_langfuse_client(evicted) - adopter = Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1") - assert adopter._resources is evicted._resources - register_langfuse_client(adopter) - - assert _exports(adopter, exporter, "after-deferred-teardown") - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is adopter._resources - - -def test_leases_on_one_client_do_not_serialise_callbacks(): - """Every langfuse callback in the process shares one client, so leases must overlap.""" - client = _lifecycle_client() - both_inside = threading.Barrier(2, timeout=5) - - def hold_lease() -> None: - with lease_langfuse_client(client, _never_renew): - both_inside.wait() - - holders = tuple(threading.Thread(target=hold_lease) for _ in range(2)) - for holder in holders: - holder.start() - for holder in holders: - holder.join(timeout=5) - - assert not any(holder.is_alive() for holder in holders) - assert not both_inside.broken - - -def test_last_client_on_shared_resources_tears_them_down(): - first, second, exporter = _shared_resources_pair() - shutdown_langfuse_client(second) - - shutdown_langfuse_client(first) - - assert not _exports(first, exporter, "after-last-eviction") - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is not first._resources - - -def test_shutdown_of_a_stale_client_does_not_deregister_the_live_one(): - stale = _lifecycle_client(secret_key="sk-original", host="http://127.0.0.1:1") - stale_resources = stale._resources - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-rotated", base_url="http://127.0.0.1:2") - live = _lifecycle_client(secret_key="sk-rotated", host="http://127.0.0.1:2") - - shutdown_langfuse_client(stale) - - assert stale_resources is not live._resources - assert LangfuseResourceManager._instances.get(PUBLIC_KEY) is live._resources - - -def _rotation_provider(): - """A litellm-built provider on the lifecycle public key, exporting in memory.""" - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - register_langfuse_client(client) - assert _exports(client, exporter, "before-rotation") - return client, exporter - - -def test_rotation_retires_the_provider_no_client_is_left_on(): - """Prompt management builds throwaway clients from request credentials. - - Alternating the secret for one public key evicts a bundle nobody holds any - more, and its export thread has to go with it or every rotation leaks one. - """ - import gc - - client, exporter = _rotation_provider() - del client - gc.collect() - - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-rotated", base_url="http://127.0.0.1:2") - - assert exporter._stopped - - -def test_rotation_keeps_a_still_live_client_exporting(): - """The evicted bundle is only retired when nothing is on it; a live logger must survive.""" - client, exporter = _rotation_provider() - - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-rotated", base_url="http://127.0.0.1:2") - - assert _exports(client, exporter, "after-rotation") - - -def test_a_client_dropped_without_shutdown_gets_its_provider_retired(): - """The prompt-management LRU drops rotated-out clients without shutting them down. - - Nothing ever calls ``shutdown_langfuse_client`` on such a client, so the next - lifecycle call has to reap the bundle instead of leaking its export thread. - """ - import gc - - client, exporter = _rotation_provider() - evict_stale_langfuse_resources(public_key=PUBLIC_KEY, secret_key="sk-rotated", base_url="http://127.0.0.1:2") - assert _exports(client, exporter, "still-held") - - del client - gc.collect() - evict_stale_langfuse_resources(public_key="pk-unrelated", secret_key="sk", base_url="http://127.0.0.1:3") - - assert exporter._stopped - - -def test_the_registrys_current_bundle_is_not_reaped_when_its_clients_die(): - """The registry hands its bundle to the next client on the same key, so a bundle - that is still current keeps its provider even after every client is collected.""" - import gc - - client, exporter = _rotation_provider() - del client - gc.collect() - - evict_stale_langfuse_resources(public_key="pk-unrelated", secret_key="sk", base_url="http://127.0.0.1:3") - - successor = Langfuse(public_key=PUBLIC_KEY, secret_key="sk-original", host="http://127.0.0.1:1") - assert _exports(successor, exporter, "after-collection") - - -def test_a_sweep_overlapping_registration_and_rotation_keeps_the_live_provider(): - """A sweep can snapshot providers before a client registers, then wait on the registry - lock while that client registers and a rotation evicts its fresh bundle. Holders are - re-read after the registry snapshot, so the stale first look must not win.""" - import threading - - from litellm.integrations.langfuse.langfuse_sdk import _retire_orphaned_providers - - exporter = InMemorySpanExporter() - provider = build_isolated_tracer_provider(environment=None, release=None) - provider.add_span_processor(SimpleSpanProcessor(exporter)) - client = Langfuse( - public_key=PUBLIC_KEY, - secret_key="sk-original", - host="http://127.0.0.1:1", - tracer_provider=provider, - span_exporter=exporter, - ) - registry_lock = LangfuseResourceManager._lock - registry_lock.acquire() - try: - sweeper = threading.Thread(target=_retire_orphaned_providers) - sweeper.start() - sweeper.join(timeout=0.5) # parks on the registry lock once its provider snapshot is taken - register_langfuse_client(client) - LangfuseResourceManager._instances.pop(PUBLIC_KEY, None) # the rotation that evicts the fresh bundle - finally: - registry_lock.release() - sweeper.join(timeout=5) - assert not sweeper.is_alive() - - assert _exports(client, exporter, "after-racing-sweep") + assert otel_trace.get_tracer_provider() is provider_before def test_ssl_exporter_carries_litellm_tls_material(monkeypatch, tmp_path): - """v4 exports over its own OTLP channel, so litellm's CA bundle must be rebuilt onto it.""" + """The channel is litellm's own OTLP client, so litellm's CA bundle must be rebuilt onto it.""" import litellm - from litellm.integrations.langfuse.langfuse_sdk import _build_span_exporter for name in ("SSL_CERTIFICATE", "SSL_VERIFY", "SSL_CERT_FILE", "LANGFUSE_TIMEOUT"): monkeypatch.delenv(name, raising=False) @@ -1117,9 +600,6 @@ def test_ssl_exporter_carries_litellm_tls_material(monkeypatch, tmp_path): ], ) 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: @@ -1135,8 +615,6 @@ def test_retrying_exporter_retries_a_raised_export_and_then_succeeds(monkeypatch from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from requests import ReadTimeout - from litellm.integrations.langfuse.langfuse_sdk import RetryingSpanExporter - attempts = [] slept = [] @@ -1159,8 +637,6 @@ def test_retrying_exporter_gives_up_after_the_last_delay(monkeypatch): from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from requests import ConnectionError as RequestsConnectionError - from litellm.integrations.langfuse.langfuse_sdk import RetryingSpanExporter - attempts = [] slept = [] @@ -1179,9 +655,10 @@ def test_retrying_exporter_gives_up_after_the_last_delay(monkeypatch): @pytest.mark.parametrize("switch", ["attribute", "env"]) def test_ssl_exporter_disables_verification_when_litellm_does(monkeypatch, switch): - """v2 exported through the httpx client, so ``ssl_verify=False`` reached ingestion; v4's exporter must match.""" + """v2 exported through the httpx client, so ``ssl_verify=False`` reached ingestion; the OTLP channel must match.""" + from requests.adapters import HTTPAdapter + import litellm - from litellm.integrations.langfuse.langfuse_sdk import _build_span_exporter for name in ("SSL_CERTIFICATE", "SSL_VERIFY", "SSL_CERT_FILE"): monkeypatch.delenv(name, raising=False) @@ -1193,19 +670,40 @@ def test_ssl_exporter_disables_verification_when_litellm_does(monkeypatch, switc monkeypatch.setenv("SSL_VERIFY", "False") exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example").exporter - assert exporter._certificate_file is False - assert exporter._client_cert is None - posted = [] + sent = [] - def post(self, url, **kwargs): - posted.append((url, kwargs["verify"])) + def send(self, request, **kwargs): + sent.append((request.url, kwargs["verify"])) raise ConnectionError("stop before the network") - monkeypatch.setattr("requests.Session.post", post) + monkeypatch.setattr(HTTPAdapter, "send", send) with pytest.raises(ConnectionError): exporter._export(b"payload") - assert posted[0] == ("https://lf.internal.example/api/public/otel/v1/traces", False) + assert sent[0] == ("https://lf.internal.example/api/public/otel/v1/traces", False) + + +def test_ssl_exporter_verifies_by_default(monkeypatch): + from requests.adapters import HTTPAdapter + + import litellm + + for name in ("SSL_CERTIFICATE", "SSL_VERIFY", "SSL_CERT_FILE"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(litellm, "ssl_certificate", None) + monkeypatch.setattr(litellm, "ssl_verify", True) + exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example").exporter + + sent = [] + + def send(self, request, **kwargs): + sent.append(kwargs["verify"]) + raise ConnectionError("stop before the network") + + monkeypatch.setattr(HTTPAdapter, "send", send) + with pytest.raises(ConnectionError): + exporter._export(b"payload") + assert sent == [True] @pytest.mark.parametrize("with_client_certificate", [False, True]) @@ -1214,7 +712,6 @@ def test_ssl_exporter_falls_back_to_default_ca_when_the_bundle_path_is_missing( ): """The httpx client ignores a CA path that does not exist; handing it to requests would fail every export.""" import litellm - from litellm.integrations.langfuse.langfuse_sdk import _build_span_exporter for name in ("SSL_CERTIFICATE", "SSL_VERIFY", "SSL_CERT_FILE"): monkeypatch.delenv(name, raising=False) @@ -1227,65 +724,3 @@ def test_ssl_exporter_falls_back_to_default_ca_when_the_bundle_path_is_missing( exporter = _build_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example").exporter assert exporter._certificate_file is True assert exporter._client_cert == (str(client_cert) if with_client_certificate else None) - - -def test_second_client_on_the_same_key_does_not_build_another_provider(): - """A discarded TracerProvider is pinned forever by its atexit hook.""" - import gc - - from litellm.integrations.langfuse.langfuse_sdk import ( - _retire_orphaned_providers, - acquire_langfuse_client, - ) - - # reap earlier tests' orphans first, so the count below only moves if a provider is built - gc.collect() - _retire_orphaned_providers() - - pk = "pk-provider-reuse-test" - LangfuseResourceManager._instances.pop(pk, None) - parameters = {"public_key": pk, "secret_key": "sk-reuse", "base_url": "http://127.0.0.1:1"} - try: - first = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) - providers_after_first = len(_litellm_built_providers) - second = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) - - assert second._resources is first._resources - assert len(_litellm_built_providers) == providers_after_first - finally: - LangfuseResourceManager._instances.pop(pk, None) - - -def test_leaving_mock_mode_on_the_same_key_stops_using_the_discarding_exporter(): - from litellm.integrations.langfuse.langfuse_sdk import DiscardingSpanExporter - - pk = "pk-mock-to-live-test" - LangfuseResourceManager._instances.pop(pk, None) - parameters = {"public_key": pk, "secret_key": "sk-live", "base_url": "http://127.0.0.1:1"} - try: - mocked = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) - assert isinstance(mocked._resources.span_exporter, DiscardingSpanExporter) - - live = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=False) - assert live._resources is not mocked._resources - assert not isinstance(live._resources.span_exporter, DiscardingSpanExporter) - assert LangfuseResourceManager._instances.get(pk) is live._resources - finally: - LangfuseResourceManager._instances.pop(pk, None) - - -def test_a_changed_sample_rate_on_the_same_key_rebuilds_the_bundle(monkeypatch: pytest.MonkeyPatch): - pk = "pk-resample-test" - LangfuseResourceManager._instances.pop(pk, None) - parameters = {"public_key": pk, "secret_key": "sk-resample", "base_url": "http://127.0.0.1:1"} - try: - monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "0.25") - quarter = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) - monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "1") - full = acquire_langfuse_client(parameters=parameters, environment=None, release=None, mock_mode=True) - - assert quarter._resources.sample_rate == 0.25 - assert full._resources is not quarter._resources - assert full._resources.sample_rate == 1.0 - finally: - LangfuseResourceManager._instances.pop(pk, None) diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index a825e3732c4..eeb30593828 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1,5 +1,7 @@ import datetime import json +import threading +import time import types import unittest from typing import Final, Optional @@ -10,7 +12,7 @@ import pytest import litellm from litellm.integrations.langfuse import langfuse as langfuse_module from litellm.integrations.langfuse.langfuse import LangFuseLogger -from litellm.integrations.langfuse.langfuse_sdk import _lifecycle_state, resolve_trace_id +from litellm.integrations.langfuse.langfuse_sdk import resolve_trace_id # Import LangfuseUsageDetails directly from the module where it's defined @@ -122,27 +124,26 @@ class TestLangfuseUsageDetails(unittest.TestCase): self.env_patcher.stop() def use_real_langfuse_client(self): - """Point the logger at a real v4 client whose spans land in memory.""" - from langfuse._client.resource_manager import LangfuseResourceManager - from opentelemetry.sdk.trace import TracerProvider + """Point the logger at an export channel whose spans land in memory.""" from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) + from litellm.integrations.langfuse.langfuse_sdk import build_langfuse_tracing + self.span_exporter = InMemorySpanExporter() - self.real_provider = TracerProvider() - LangfuseResourceManager._instances.pop("pk-unit-test", None) - self.logger.Langfuse = self.real_langfuse_class( - public_key="pk-unit-test", - secret_key="sk-unit-test", - host="http://127.0.0.1:1", - tracer_provider=self.real_provider, - span_exporter=self.span_exporter, + self.logger.tracing = build_langfuse_tracing( + exporter=self.span_exporter, + environment=None, + release=None, + sample_rate=1.0, + flush_interval_millis=10, ) - return self.logger.Langfuse + self.real_provider = self.logger.tracing.provider + return self.logger.tracing def exported_generation(self): - self.logger.Langfuse.flush() + self.logger.tracing.flush() spans = [s for s in self.span_exporter.get_finished_spans()] assert spans, "no spans were exported" return spans[-1] @@ -524,7 +525,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): """Every attribute this logger exported to langfuse, as one searchable string.""" import json - self.logger.Langfuse.flush() + self.logger.tracing.flush() return json.dumps( [dict(span.attributes or {}) for span in self.span_exporter.get_finished_spans()], default=repr, @@ -554,7 +555,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): } def exported_spans_named(self, name): - self.logger.Langfuse.flush() + self.logger.tracing.flush() return [span for span in self.span_exporter.get_finished_spans() if span.name == name] def _drive_with_canary(self, extra_metadata=None, hidden_params=None, guardrail_information=None): @@ -645,9 +646,9 @@ class TestLangfuseUsageDetails(unittest.TestCase): def test_only_the_generation_claims_the_trace_root(self): """ - Langfuse v4 derives trace name and I/O from every observation marked root, and - the one with the latest start wins. A post-call guardrail starts after the model - call, so it must nest under the generation instead of claiming root itself, or + Langfuse derives trace name and I/O from the root observation, and with several + roots the one with the latest start wins. A post-call guardrail starts after the + model call, so it must nest under the generation instead of being a root itself, or the trace shows the guardrail's request instead of the model's. """ self._drive_with_canary( @@ -667,11 +668,12 @@ class TestLangfuseUsageDetails(unittest.TestCase): [generation] = [span for span in self.span_exporter.get_finished_spans() if span.name.startswith("litellm-")] [guardrail] = self.exported_spans_named("guardrail") [grounding] = self.exported_spans_named("vertex_ai_grounding_metadata") - assert generation.attributes.get("langfuse.internal.as_root") is True + assert generation.parent is None + assert generation.attributes["langfuse.trace.name"] == "canary-trace" for child in (guardrail, grounding): assert child.parent.span_id == generation.context.span_id assert child.context.trace_id == generation.context.trace_id - assert "langfuse.internal.as_root" not in child.attributes + assert "langfuse.trace.name" not in child.attributes def test_caller_cannot_spoof_an_allowlisted_identity_field(self): """ @@ -1203,6 +1205,53 @@ async def test_async_log_failure_event_works_without_standard_logging_object(): assert "InternalServerError" in call_kwargs["status_message"] +class _OtlpReceiver: + """A local HTTP server that records the paths of every POST it gets, standing in for Langfuse.""" + + def __init__(self) -> None: + from http.server import BaseHTTPRequestHandler, HTTPServer + + self.received: list[str] = [] + received = self.received + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self): + received.append(self.path) + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *args): + pass + + self.server = HTTPServer(("127.0.0.1", 0), _Handler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self.server.server_port}" + + def close(self) -> None: + self.server.shutdown() + + +def _log_one_completion(logger: LangFuseLogger) -> None: + now = datetime.datetime.now() + logger.log_event_on_langfuse( + kwargs={ + "call_type": "completion", + "litellm_params": {"metadata": {}, "proxy_server_request": {"headers": {}}}, + "messages": [{"role": "user", "content": "hi"}], + "optional_params": {}, + }, + response_obj=litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "yo"}}]), + start_time=now, + end_time=now, + ) + logger.flush() + + def test_mock_mode_makes_no_network_calls(monkeypatch): """LANGFUSE_MOCK promises full execution without egress. @@ -1210,55 +1259,21 @@ def test_mock_mode_makes_no_network_calls(monkeypatch): exporter, so nothing stops a real request to the configured host without an exporter that drops them. """ - import threading - import time - from http.server import BaseHTTPRequestHandler, HTTPServer - - from langfuse._client.resource_manager import LangfuseResourceManager - - received = [] - - class _Receiver(BaseHTTPRequestHandler): - def do_POST(self): - received.append(self.path) - self.rfile.read(int(self.headers.get("Content-Length") or 0)) - self.send_response(200) - self.send_header("Content-Length", "0") - self.end_headers() - - def log_message(self, *args): - pass - - server = HTTPServer(("127.0.0.1", 0), _Receiver) - threading.Thread(target=server.serve_forever, daemon=True).start() + receiver = _OtlpReceiver() monkeypatch.setenv("LANGFUSE_MOCK", "true") - monkeypatch.setenv("LANGFUSE_HOST", f"http://127.0.0.1:{server.server_port}") + monkeypatch.setenv("LANGFUSE_HOST", receiver.url) monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-mock-egress") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-mock-egress") - LangfuseResourceManager._instances.pop("pk-mock-egress", None) try: logger = LangFuseLogger() assert logger.is_mock_mode is True - now = datetime.datetime.now() - logger.log_event_on_langfuse( - kwargs={ - "call_type": "completion", - "litellm_params": {"metadata": {}, "proxy_server_request": {"headers": {}}}, - "messages": [{"role": "user", "content": "hi"}], - "optional_params": {}, - }, - response_obj=litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "yo"}}]), - start_time=now, - end_time=now, - ) - logger.Langfuse.flush() + _log_one_completion(logger) time.sleep(1) finally: - server.shutdown() - LangfuseResourceManager._instances.pop("pk-mock-egress", None) + receiver.close() - assert received == [], f"mock mode sent real requests: {received}" + assert receiver.received == [], f"mock mode sent real requests: {receiver.received}" def test_max_langfuse_clients_limit(): @@ -1323,7 +1338,7 @@ class _RecordingLangfuse: def _build_langfuse_logger(monkeypatch) -> LangFuseLogger: monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads return LangFuseLogger( langfuse_public_key="pk-lit5228", langfuse_secret="sk-lit5228", @@ -1335,7 +1350,7 @@ def test_langfuse_environment_is_passed_to_sdk_client(monkeypatch): monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False) monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1350,7 +1365,7 @@ def test_langfuse_environment_falls_back_to_deployment_env_var(monkeypatch): monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "deployment-wide") monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1424,76 +1439,47 @@ _LANGFUSE_REDACTED = "redacted-by-litellm" def _steering_logger(): """``__new__`` skips the network setup in ``__init__``; spans land in memory.""" - from langfuse import Langfuse - from langfuse._client.resource_manager import LangfuseResourceManager - from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, ) from litellm.integrations.langfuse.langfuse import installed_langfuse_version + from litellm.integrations.langfuse.langfuse_sdk import build_langfuse_client, build_langfuse_tracing exporter = InMemorySpanExporter() - LangfuseResourceManager._instances.pop("pk-steering-test", None) logger = LangFuseLogger.__new__(LangFuseLogger) - logger.Langfuse = Langfuse( - public_key="pk-steering-test", - secret_key="sk-steering-test", - host="http://127.0.0.1:1", - tracer_provider=TracerProvider(), - span_exporter=exporter, + logger.tracing = build_langfuse_tracing( + exporter=exporter, environment=None, release=None, sample_rate=1.0, flush_interval_millis=10 + ) + logger.Langfuse = build_langfuse_client( + parameters={ + "public_key": "pk-steering-test", + "secret_key": "sk-steering-test", + "base_url": "http://127.0.0.1:1", + "tracing_enabled": False, + }, + environment=None, + release=None, + mock_mode=True, ) logger.langfuse_sdk_version = installed_langfuse_version() return logger, exporter -def test_log_event_holds_a_client_lease_during_export(): - logger, _ = _steering_logger() - state = _lifecycle_state(logger.Langfuse) +def test_log_event_keeps_exporting_after_the_dynamic_cache_evicts_the_logger(): + """Per-key loggers are evicted from ``DynamicLoggingCache`` while a callback may still hold them. - def assert_lease_is_active(**_: object) -> tuple[str, str]: - assert state.active_leases == 1 - return "trace-id", "generation-id" - - now = datetime.datetime.now() - with patch.object(logger, "_log_langfuse_v2", side_effect=assert_lease_is_active): - returned = logger.log_event_on_langfuse( - kwargs={ - "call_type": "completion", - "litellm_params": {"metadata": {}}, - "messages": [{"role": "user", "content": "the-input"}], - "optional_params": {}, - }, - response_obj=litellm.ModelResponse( - choices=[{"message": {"role": "assistant", "content": "the-output"}}] - ), - start_time=now, - end_time=now, - ) - - assert returned == {"trace_id": "trace-id", "generation_id": "generation-id"} - assert state.active_leases == 0 - - -def test_log_event_renews_a_client_the_cache_evicted_before_the_lease(): - """The cache can evict this logger after handing it to the callback and before the lease opens. - - v2 lost that callback's events to a shut-down client; the callback must export through a fresh one. + v2 lost that callback's events to a shut-down client; the export channel is shared per + credential set and outlives any one logger, so the events still land. """ - from litellm.integrations.langfuse.langfuse_sdk import register_langfuse_client, shutdown_langfuse_client + from litellm.litellm_core_utils.specialty_caches.dynamic_logging_cache import LangfuseInMemoryCache - logger, _ = _steering_logger() - evicted = logger.Langfuse - logger.langfuse_client_parameters = { - "public_key": "pk-steering-test", - "secret_key": "sk-steering-test", - "base_url": "http://127.0.0.1:1", - } - logger.langfuse_environment = None - logger.langfuse_release = None - logger.is_mock_mode = True - register_langfuse_client(evicted) - shutdown_langfuse_client(evicted) + logger, exporter = _steering_logger() + cache = LangfuseInMemoryCache() + cache.set_cache("langfuse-evicted", logger) + litellm.initialized_langfuse_clients += 1 + before = litellm.initialized_langfuse_clients + cache._remove_key("langfuse-evicted") now = datetime.datetime.now() returned = logger.log_event_on_langfuse( @@ -1508,18 +1494,37 @@ def test_log_event_renews_a_client_the_cache_evicted_before_the_lease(): end_time=now, ) - assert returned["trace_id"] is not None - assert logger.Langfuse is not evicted - assert logger.Langfuse._resources is not evicted._resources - assert logger.Langfuse not in _lifecycle_state(logger.Langfuse).retired - shutdown_langfuse_client(logger.Langfuse) + assert litellm.initialized_langfuse_clients == before - 1 + assert _span_trace_id(_exported_span(logger, exporter)) == returned["trace_id"] def _exported_span(logger, exporter): - logger.Langfuse.flush() + logger.flush() return exporter.get_finished_spans()[-1] +_TRACE_FIELD_KEYS = { + "user.id": "user_id", + "session.id": "session_id", + "langfuse.version": "version", + "langfuse.release": "release", +} + + +def _trace_params(span): + """The trace-level fields of the exported span, keyed as v2's ``trace_params`` were.""" + prefix = "langfuse.trace." + attributes = span.attributes or {} + return { + **{ + key[len(prefix) :]: value + for key, value in attributes.items() + if key.startswith(prefix) and not key.startswith(prefix + "metadata.") + }, + **{name: attributes[key] for key, name in _TRACE_FIELD_KEYS.items() if key in attributes}, + } + + def _span_trace_id(span): return format(span.context.trace_id, "032x") @@ -1527,49 +1532,35 @@ def _span_trace_id(span): def _emit(rig, *, metadata=None, headers=None): """``log_event_on_langfuse`` is the entry point that folds ``langfuse_*`` headers into metadata. - v4 has no trace object, so the trace-level fields are captured where the - callback hands them to propagation, and the observation fields are read back - off the span langfuse actually exported. + Both the trace-level and the observation fields are read back off the span litellm exported. """ - from litellm.integrations.langfuse import langfuse as langfuse_module - logger, exporter = rig exporter.clear() - captured_trace_params = {} - propagate_for_real = langfuse_module._trace_attributes_for_propagation - - def capture(trace_params): - captured_trace_params.update(trace_params) - return propagate_for_real(trace_params) now = datetime.datetime.now() response_obj = litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "the-output"}}]) - with patch.object( # test-quality-ok: v4 has no trace object to read back; the propagation call is the only observable trace-level boundary - langfuse_module, "_trace_attributes_for_propagation", capture - ): - logger.log_event_on_langfuse( - kwargs={ - "call_type": "completion", - "litellm_params": { - "metadata": dict(metadata or {}), - "proxy_server_request": {"headers": dict(headers or {})}, - }, - "messages": [{"role": "user", "content": "the-input"}], - "optional_params": {}, + logger.log_event_on_langfuse( + kwargs={ + "call_type": "completion", + "litellm_params": { + "metadata": dict(metadata or {}), + "proxy_server_request": {"headers": dict(headers or {})}, }, - response_obj=response_obj, - start_time=now, - end_time=now, - ) - logger.Langfuse.flush() + "messages": [{"role": "user", "content": "the-input"}], + "optional_params": {}, + }, + response_obj=response_obj, + start_time=now, + end_time=now, + ) prefix = "langfuse.observation." - span = exporter.get_finished_spans()[-1] + span = _exported_span(logger, exporter) generation_params = { key[len(prefix) :]: value for key, value in (span.attributes or {}).items() if key.startswith(prefix) and not key.startswith(prefix + "metadata.") } - return captured_trace_params, generation_params, span + return _trace_params(span), generation_params, span @pytest.mark.parametrize("level", ["DEFAULT", "ERROR"]) @@ -1831,7 +1822,7 @@ def test_mask_input_header_false_keeps_the_prompt(): trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_input": "false"}) - assert trace_params["input"] == {"messages": [{"role": "user", "content": "the-input"}]} + assert "input" not in trace_params assert json.loads(generation_params["input"]) == {"messages": [{"role": "user", "content": "the-input"}]} @@ -1840,7 +1831,7 @@ def test_mask_input_header_true_redacts_the_prompt(): trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_input": "true"}) - assert trace_params["input"] == _LANGFUSE_REDACTED + assert "input" not in trace_params assert generation_params["input"] == _LANGFUSE_REDACTED @@ -1849,8 +1840,8 @@ def test_mask_output_header_false_keeps_the_completion(): trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_output": "false"}) - assert trace_params["output"] != _LANGFUSE_REDACTED - assert generation_params["output"] != _LANGFUSE_REDACTED + assert "output" not in trace_params + assert "the-output" in generation_params["output"] def test_mask_output_header_true_redacts_the_completion(): @@ -1858,7 +1849,7 @@ def test_mask_output_header_true_redacts_the_completion(): trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_output": "true"}) - assert trace_params["output"] == _LANGFUSE_REDACTED + assert "output" not in trace_params assert generation_params["output"] == _LANGFUSE_REDACTED @@ -1874,9 +1865,9 @@ def test_mask_output_header_true_redacts_the_completion(): def test_mask_input_from_the_request_body_is_unchanged(mask_input, expect_redacted): rig = _steering_logger() - trace_params, _, _ = _emit(rig, metadata={"mask_input": mask_input}) + _, generation_params, _ = _emit(rig, metadata={"mask_input": mask_input}) - assert (trace_params["input"] == _LANGFUSE_REDACTED) is expect_redacted + assert (generation_params["input"] == _LANGFUSE_REDACTED) is expect_redacted @pytest.mark.parametrize("flag", [True, "true"]) @@ -1895,8 +1886,6 @@ def test_update_trace_keys_header_applies_every_key_when_enabled(flag, monkeypat ) assert trace_params["release"] == "v1.2.3" - assert trace_params["tail"] == "last" - # v4 models release, so it reaches langfuse; a key it does not model cannot assert span.attributes["langfuse.release"] == "v1.2.3" assert not [key for key in span.attributes if key.endswith("tail")] @@ -1921,7 +1910,6 @@ def test_update_trace_keys_is_off_by_default(): assert "user_api_key_auth" not in trace_params assert "release" not in trace_params - assert "sk-canary" not in json.dumps(trace_params, default=repr) assert "sk-canary" not in json.dumps(dict(span.attributes or {}), default=repr) @@ -1964,7 +1952,6 @@ def test_existing_trace_id_appends_without_claiming_trace_root(): _, _, span = _emit(rig, metadata={"existing_trace_id": "trace-1", "trace_name": "second-call"}) assert span.parent is not None - assert span.attributes.get("langfuse.internal.as_root") is None assert "langfuse.trace.name" not in (span.attributes or {}) @@ -1973,7 +1960,7 @@ def test_a_fresh_trace_still_claims_root_so_its_generation_names_it(): _, _, span = _emit(rig, metadata={"trace_id": "a" * 32, "trace_name": "first-call"}) - assert span.attributes.get("langfuse.internal.as_root") is True + assert span.parent is None assert span.attributes["langfuse.trace.name"] == "first-call" @@ -2026,11 +2013,22 @@ def test_update_trace_keys_trace_metadata_reaches_the_trace_not_just_the_generat }, ) - assert span.attributes["langfuse.trace.metadata.step"] == "2" - assert span.attributes["langfuse.trace.metadata.note"] == "x" * 200 + assert span.attributes["langfuse.trace.metadata.step"] == 2 + assert span.attributes["langfuse.trace.metadata.note"] == "x" * 300 assert span.attributes["langfuse.observation.metadata.step"] == 2 +def test_non_mapping_trace_metadata_does_not_lose_the_event(): + """A caller who passes ``trace_metadata`` as a string still gets a generation, and the string is not spread.""" + rig = _steering_logger() + + trace_params, generation_params, span = _emit(rig, metadata={"trace_metadata": "just-a-note"}) + + assert json.loads(generation_params["output"])["content"] == "the-output" + assert trace_params["name"] == "litellm-completion" + assert not any(key.startswith("langfuse.trace.metadata.") for key in span.attributes or {}) + + def test_trace_metadata_is_not_propagated_when_absent(): rig = _steering_logger() @@ -2054,7 +2052,7 @@ def test_langfuse_environment_is_coerced_and_validated(monkeypatch): monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False) monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -2081,7 +2079,7 @@ def test_langfuse_empty_environment_falls_back_and_is_not_dynamic(monkeypatch): # '' falls back to the deployment env var at init monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where acquire_langfuse_client resolves it; a real client spawns export threads + with patch("litellm.integrations.langfuse.langfuse_sdk.Langfuse", _RecordingLangfuse): # test-quality-ok: the ctor must be intercepted where build_langfuse_client resolves it; a real client spawns export threads logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -2136,9 +2134,8 @@ def test_continued_trace_keeps_the_generation_version(): """v2 set ``version`` on the generation even when the trace was not being updated.""" rig = _steering_logger() - captured_trace_params, _, span = _emit(rig, metadata={"existing_trace_id": "b" * 32, "version": "gen-7"}) + _, _, span = _emit(rig, metadata={"existing_trace_id": "b" * 32, "version": "gen-7"}) - assert "version" not in captured_trace_params assert span.attributes["langfuse.version"] == "gen-7" @@ -2195,41 +2192,38 @@ def test_langfuse_debug_env_string_false_stays_off(monkeypatch): The v4 client does ``if debug:`` and then mutates root logging via ``logging.basicConfig``, so the unparsed string "false" turns debug ON. """ - from langfuse._client.resource_manager import LangfuseResourceManager - monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-debug-parse-test") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-debug-parse-test") monkeypatch.setenv("LANGFUSE_MOCK", "true") monkeypatch.setenv("LANGFUSE_DEBUG", "false") monkeypatch.setattr(litellm, "initialized_langfuse_clients", litellm.initialized_langfuse_clients) - logger = LangFuseLogger() - try: - assert logger.langfuse_debug is False - finally: - LangfuseResourceManager._instances.pop("pk-debug-parse-test", None) + assert LangFuseLogger().langfuse_debug is False def test_explicit_langfuse_host_beats_the_v4_base_url_env(monkeypatch): """Per-key/per-team ``langfuse_host`` must win over LANGFUSE_BASE_URL. - v4 resolves ``base_url or $LANGFUSE_BASE_URL or host``, so passing the - resolved host as ``host=`` lets a stray env var silently redirect every - tenant's traces to one server. + v4 resolves ``base_url or $LANGFUSE_BASE_URL or host``, so a stray env var + could silently redirect every tenant's traces to one server. The proof is a + real round trip: the observation lands on the configured host. """ - from langfuse._client.resource_manager import LangfuseResourceManager - + receiver = _OtlpReceiver() monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-base-url-test") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-base-url-test") - monkeypatch.setenv("LANGFUSE_MOCK", "true") - monkeypatch.setenv("LANGFUSE_BASE_URL", "https://elsewhere.example") + monkeypatch.delenv("LANGFUSE_MOCK", raising=False) + monkeypatch.setenv("LANGFUSE_BASE_URL", "http://127.0.0.1:1") + monkeypatch.setenv("LANGFUSE_FLUSH_INTERVAL", "1") monkeypatch.setattr(litellm, "initialized_langfuse_clients", litellm.initialized_langfuse_clients) - logger = LangFuseLogger(langfuse_host="https://good.example") try: - assert logger.Langfuse._base_url == "https://good.example" + logger = LangFuseLogger(langfuse_host=receiver.url) + _log_one_completion(logger) finally: - LangfuseResourceManager._instances.pop("pk-base-url-test", None) + receiver.close() + + assert logger.langfuse_host == receiver.url + assert receiver.received == ["/api/public/otel/v1/traces"] def test_resolve_credentials_falls_back_to_langfuse_base_url(monkeypatch): @@ -2257,30 +2251,25 @@ def test_version_gate_rejects_v5_prereleases(): langfuse_module.raise_if_unsupported_langfuse_version("5.0.0") -def test_int_steering_values_survive_v4_propagation(): - """v4 drops non-string propagated values outright; v2's pydantic coerced them.""" +def test_int_steering_values_reach_langfuse_as_strings(): + """Langfuse models user, session and version as strings; v2's pydantic coerced ints for the caller.""" rig = _steering_logger() - _, _, span = _emit( - rig, metadata={"trace_user_id": 12345, "session_id": 67, "trace_version": 3, "tags": ["ok", 99]} - ) + _, _, span = _emit(rig, metadata={"trace_user_id": 12345, "session_id": 67, "trace_version": 3}) - # the SDK validates AFTER litellm's coercion: a surviving attribute proves the value was a str assert span.attributes["user.id"] == "12345" assert span.attributes["session.id"] == "67" assert span.attributes["langfuse.version"] == "3" - # tags reach propagation as a list; non-str entries must be coerced item-wise - assert langfuse_module._coerce_propagated_value(["ok", 99]) == ["ok", "99"] -def test_long_steering_values_are_capped_not_dropped(): - """The SDK drops any propagated value over 200 characters with only a warning.""" +def test_long_steering_values_are_neither_capped_nor_dropped(): + """v2 sent ids of any length; the SDK's 200 character rule belongs to baggage propagation, which litellm no longer uses.""" rig = _steering_logger() long_user: Final = "u" * 250 _, _, span = _emit(rig, metadata={"trace_user_id": long_user}) - assert span.attributes["user.id"] == "u" * 200 + assert span.attributes["user.id"] == long_user def test_returned_generation_id_names_the_exported_observation(): @@ -2298,7 +2287,6 @@ def test_returned_generation_id_names_the_exported_observation(): start_time=datetime.datetime.now(), end_time=datetime.datetime.now(), ) - logger.Langfuse.flush() - span = exporter.get_finished_spans()[-1] + span = _exported_span(logger, exporter) assert returned["generation_id"] == format(span.context.span_id, "016x") diff --git a/tests/test_litellm/litellm_core_utils/specialty_caches/test_dynamic_logging_cache.py b/tests/test_litellm/litellm_core_utils/specialty_caches/test_dynamic_logging_cache.py index 5ed9dca68fd..674dc03fcf9 100644 --- a/tests/test_litellm/litellm_core_utils/specialty_caches/test_dynamic_logging_cache.py +++ b/tests/test_litellm/litellm_core_utils/specialty_caches/test_dynamic_logging_cache.py @@ -50,29 +50,22 @@ class TestLangfuseInMemoryCache: assert litellm.initialized_langfuse_clients == initial_count - 1 @patch("litellm.initialized_langfuse_clients", 3) - def test_langfuse_client_shutdown_called_on_eviction(self): - """Test that langfuse client shutdown is called to close the thread.""" + def test_evicted_logger_keeps_its_client_alive(self): + """The SDK keeps one resource bundle per public key, shared by every logger on that key. - # Create a mock LangFuseLogger class - class MockLangFuseLogger: - def __init__(self): - self.Langfuse = MagicMock() - self.Langfuse.flush = MagicMock() - self.Langfuse.shutdown = MagicMock() + Shutting it down on eviction would stop prompt fetches and the exit flush for the + loggers still using it, so eviction only releases the initialized-client slot. + """ + from litellm.integrations.langfuse.langfuse import LangFuseLogger - mock_logger = MockLangFuseLogger() + logger = LangFuseLogger.__new__(LangFuseLogger) + logger.Langfuse = MagicMock() + logger.Langfuse.get_prompt.return_value = "prompt-after-eviction" + self.cache.cache_dict["test_key"] = logger + self.cache.ttl_dict["test_key"] = time.time() + 100 - # Patch the LangFuseLogger import to return our mock class - with patch( - "litellm.integrations.langfuse.langfuse.LangFuseLogger", MockLangFuseLogger - ): - # Add the mock logger to cache - self.cache.cache_dict["test_key"] = mock_logger - self.cache.ttl_dict["test_key"] = time.time() + 100 + self.cache._remove_key("test_key") - # Remove the key (this should trigger cleanup) - self.cache._remove_key("test_key") - - # Verify flush and shutdown were called - mock_logger.Langfuse.flush.assert_called_once() - mock_logger.Langfuse.shutdown.assert_called_once() + assert litellm.initialized_langfuse_clients == 2 + assert logger.Langfuse.get_prompt("greeting") == "prompt-after-eviction" + logger.Langfuse.shutdown.assert_not_called()