From 2f33397626cce63ac2eec10763d2ecba35675f9a Mon Sep 17 00:00:00 2001 From: yucheng Date: Thu, 17 Sep 2026 21:18:36 +0000 Subject: [PATCH] refactor(langfuse): emit observations as plain OTel spans, keep the SDK for prompts and auth The callback now owns an isolated TracerProvider and OTLP exporter and builds generation and child spans with public OpenTelemetry APIs plus the LangfuseOtelSpanAttributes constants. Caller trace ids, generation ids, parent observation ids and historical start and end times are honoured through the OTel id generator, remote SpanContext and explicit span timestamps, so no private Langfuse SDK tracing handle is used any more. The Langfuse client stays only for get_prompt and auth_check This also resolves the gauntlet findings on the previous draft: fresh traces start from an empty context so caller application spans are never stamped, the Slack trace link is read from the request logging state instead of constructing a logger per alert, a truthy non-mapping trace_metadata is serialized instead of raising, trace_input and trace_output land on the root generation, discarding a cached client is done under the lock, and the prompt cache no longer leaks a task manager because the client cache no longer tears down shared providers Fixtures under tests/logging_callback_tests lose the SDK-private langfuse.internal.as_root marker; every other exported attribute is unchanged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/SlackAlerting/utils.py | 24 +- litellm/integrations/langfuse/langfuse.py | 300 ++-- .../langfuse/langfuse_prompt_management.py | 22 +- litellm/integrations/langfuse/langfuse_sdk.py | 906 ++++++------ litellm/litellm_core_utils/litellm_logging.py | 34 - .../specialty_caches/dynamic_logging_cache.py | 27 +- litellm/proxy/proxy_server.py | 2 +- .../completion.json | 1 - .../completion_with_bedrock_call.json | 1 - .../completion_with_complex_metadata.json | 1 - .../completion_with_no_choices.json | 1 - .../completion_with_router.json | 1 - .../completion_with_tags.json | 1 - .../completion_with_tags_stream.json | 1 - .../completion_with_vertex_call.json | 1 - .../complex_metadata.json | 1 - .../complex_metadata_2.json | 1 - .../empty_metadata.json | 1 - .../metadata_with_function.json | 1 - .../metadata_with_lock.json | 1 - .../nested_metadata.json | 1 - .../simple_metadata.json | 1 - .../simple_metadata2.json | 1 - .../simple_metadata3.json | 1 - .../test_langfuse_e2e_test.py | 9 +- .../test_slack_alerting_utils.py | 35 +- .../test_langfuse_prompt_management.py | 52 +- .../langfuse/test_langfuse_sdk.py | 1285 +++++------------ .../integrations/test_langfuse.py | 390 +++-- .../test_dynamic_logging_cache.py | 37 +- 30 files changed, 1179 insertions(+), 1961 deletions(-) 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()