diff --git a/docker/build_from_pip/Dockerfile.build_from_pip b/docker/build_from_pip/Dockerfile.build_from_pip index a5733f0e1a0..1c077698e5a 100644 --- a/docker/build_from_pip/Dockerfile.build_from_pip +++ b/docker/build_from_pip/Dockerfile.build_from_pip @@ -32,10 +32,10 @@ RUN uv venv --python python && \ "anthropic[vertex]==0.84.0" \ "grpcio==1.78.0" \ "prometheus-client==0.20.0" \ - "langfuse==2.59.7" \ - "opentelemetry-api==1.28.0" \ - "opentelemetry-sdk==1.28.0" \ - "opentelemetry-exporter-otlp==1.28.0" \ + "langfuse==4.15.1" \ + "opentelemetry-api==1.33.1" \ + "opentelemetry-sdk==1.33.1" \ + "opentelemetry-exporter-otlp==1.33.1" \ "ddtrace==4.11.0" \ "sentry-sdk==2.21.0" \ "mangum==0.17.0" \ diff --git a/litellm/integrations/SlackAlerting/utils.py b/litellm/integrations/SlackAlerting/utils.py index 297d069a868..4a2e116d4a9 100644 --- a/litellm/integrations/SlackAlerting/utils.py +++ b/litellm/integrations/SlackAlerting/utils.py @@ -84,7 +84,8 @@ async def _add_langfuse_trace_id_to_alert( ######################################################### langfuse_object: Final = litellm_logging_obj._get_callback_object(service_name="langfuse") if langfuse_object is not None: - base_url: Final = langfuse_object.Langfuse.base_url - return f"{base_url}/trace/{trace_id}" + base_url: Final = getattr(langfuse_object, "langfuse_host", None) + if base_url is not None: + return f"{base_url}/trace/{trace_id}" return None diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index b75369965de..ca463feb985 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -1,12 +1,12 @@ #### What this does #### # On success, logs events to Langfuse -import inspect import os import re import traceback -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Iterable, Mapping, Sequence from datetime import datetime from functools import lru_cache +from importlib.metadata import version from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast @@ -45,12 +45,13 @@ from litellm.types.utils import ( ) if TYPE_CHECKING: - from langfuse.client import Langfuse, StatefulTraceClient + from langfuse import Langfuse + from opentelemetry.context import Context from litellm.litellm_core_utils.litellm_logging import DynamicLoggingCache else: + Context = Any DynamicLoggingCache = Any - StatefulTraceClient = Any Langfuse = Any @@ -158,6 +159,107 @@ def _as_steering_key_sequence(value: object) -> tuple[str, ...]: return () +MINIMUM_LANGFUSE_VERSION: Final = "4.7" +UNSUPPORTED_LANGFUSE_VERSION: Final = "5" + + +def installed_langfuse_version() -> str: + """Only ``importlib.metadata`` reads correctly on every major. + + ``langfuse.version`` was removed in v4, ``langfuse.__version__`` does not + exist in v3, and in v2 it reports a different value from the distribution + that is actually installed. + """ + return version("langfuse") + + +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. + """ + installed: Final = Version(installed_version) + # compare majors, not versions: "5.0.0rc1" sorts below "5" but is just as unsupported + if Version(MINIMUM_LANGFUSE_VERSION) <= installed and installed.major < Version(UNSUPPORTED_LANGFUSE_VERSION).major: + return + raise ImportError( + f"\033[91mlitellm requires langfuse>={MINIMUM_LANGFUSE_VERSION},<{UNSUPPORTED_LANGFUSE_VERSION} for the " + f"'langfuse' callback, but {installed_version} is installed. Run " + f"'pip install \"langfuse>={MINIMUM_LANGFUSE_VERSION},<{UNSUPPORTED_LANGFUSE_VERSION}\"' to upgrade, or use " + f"the 'langfuse_otel' callback, which does not depend on the langfuse SDK\033[0m" + ) + + +_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 _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. + """ + 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 + } + ) + + +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 + + +def _trace_public_flag(value: object) -> bool | None: + """``trace_public`` reaches here as a bool from metadata or a string from a ``langfuse_*`` header.""" + if value is None: + return 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, @@ -172,11 +274,18 @@ 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 = langfuse_host or os.getenv("LANGFUSE_HOST", "https://cloud.langfuse.com") + resolved_host: Final = ( + 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 +def parse_langfuse_debug(raw_value: str | None) -> bool: + """Parse the LANGFUSE_DEBUG value into the boolean flag the langfuse client expects.""" + return raw_value is not None and raw_value.strip().lower() in ("true", "1") + + @lru_cache(maxsize=8) def _warn_invalid_deployment_environment(raw_value: str, error: str) -> None: verbose_logger.warning( @@ -199,12 +308,14 @@ class LangFuseLogger: allow_env_credentials: bool = True, ): try: - import langfuse from langfuse import Langfuse except Exception as e: raise Exception( f"\033[91mLangfuse not installed, try running 'pip install langfuse' to fix this error: {e}\n{traceback.format_exc()}\033[0m" ) + self.langfuse_sdk_version: str = installed_langfuse_version() + raise_if_unsupported_langfuse_version(self.langfuse_sdk_version) + self.public_key, self.secret_key, self.langfuse_host = resolve_langfuse_credentials( langfuse_public_key=langfuse_public_key, langfuse_secret=langfuse_secret, @@ -221,7 +332,7 @@ class LangFuseLogger: else: self.langfuse_environment = self.resolve_deployment_environment() self.langfuse_release = os.getenv("LANGFUSE_RELEASE") - self.langfuse_debug = os.getenv("LANGFUSE_DEBUG") + self.langfuse_debug = parse_langfuse_debug(os.getenv("LANGFUSE_DEBUG")) self.langfuse_flush_interval = LangFuseLogger._get_langfuse_flush_interval(flush_interval) if should_use_langfuse_mock(): @@ -235,18 +346,13 @@ class LangFuseLogger: parameters: Final = { "public_key": self.public_key, "secret_key": self.secret_key, - "host": self.langfuse_host, + "base_url": self.langfuse_host, "release": self.langfuse_release, "debug": self.langfuse_debug, "flush_interval": self.langfuse_flush_interval, # flush interval in seconds "httpx_client": self.langfuse_client, + "environment": self.langfuse_environment, } - self.langfuse_sdk_version: str = langfuse.version.__version__ - - if "environment" in inspect.signature(Langfuse.__init__).parameters: - parameters["environment"] = self.langfuse_environment - if Version(self.langfuse_sdk_version) >= Version("2.6.0"): - parameters["sdk_integration"] = "litellm" self.Langfuse: Langfuse = self.safe_init_langfuse_client(parameters) # set the current langfuse project id in the environ @@ -256,30 +362,21 @@ class LangFuseLogger: verbose_logger.debug("Langfuse Mock: Using mock project ID") else: try: - project_id = self.Langfuse.client.projects.get().data[0].id + project_id: Final = self.Langfuse.api.projects.get().data[0].id os.environ["LANGFUSE_PROJECT_ID"] = project_id except Exception: - project_id = None + verbose_logger.debug("Langfuse project id unavailable, alerting links will omit it") if os.getenv("UPSTREAM_LANGFUSE_SECRET_KEY") is not None: - upstream_langfuse_debug_env: Final = os.getenv("UPSTREAM_LANGFUSE_DEBUG") - upstream_langfuse_debug: Final = ( - str_to_bool(upstream_langfuse_debug_env) if upstream_langfuse_debug_env is not None else None + verbose_logger.warning( + "UPSTREAM_LANGFUSE_* is no longer supported: the langfuse callback moved to SDK v4, " + "which has no second ingestion client. The values are ignored." ) self.upstream_langfuse_secret_key = os.getenv("UPSTREAM_LANGFUSE_SECRET_KEY") self.upstream_langfuse_public_key = os.getenv("UPSTREAM_LANGFUSE_PUBLIC_KEY") self.upstream_langfuse_host = os.getenv("UPSTREAM_LANGFUSE_HOST") self.upstream_langfuse_release = os.getenv("UPSTREAM_LANGFUSE_RELEASE") - self.upstream_langfuse_debug = upstream_langfuse_debug_env - self.upstream_langfuse = Langfuse( - public_key=self.upstream_langfuse_public_key, - secret_key=self.upstream_langfuse_secret_key, - host=self.upstream_langfuse_host, - release=self.upstream_langfuse_release, - debug=(upstream_langfuse_debug if upstream_langfuse_debug is not None else False), - ) - else: - self.upstream_langfuse = None + self.upstream_langfuse_debug = os.getenv("UPSTREAM_LANGFUSE_DEBUG") def safe_init_langfuse_client(self, parameters: dict) -> Langfuse: """ @@ -289,13 +386,20 @@ class LangFuseLogger: - Langfuse initializes 1 thread everytime a client is initialized. - We've had an incident in the past where we reached 100% cpu utilization because Langfuse was initialized several times. """ - from langfuse import Langfuse - if litellm.initialized_langfuse_clients >= MAX_LANGFUSE_INITIALIZED_CLIENTS: raise Exception( f"Max langfuse clients reached: {litellm.initialized_langfuse_clients} is greater than {MAX_LANGFUSE_INITIALIZED_CLIENTS}" ) - langfuse_client: Final = Langfuse(**parameters) + from litellm.integrations.langfuse.langfuse_sdk import acquire_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( + parameters=parameters, + environment=environment_param, + release=release_param, + mock_mode=self.is_mock_mode, + ) litellm.initialized_langfuse_clients += 1 verbose_logger.debug("Created langfuse client number %s", litellm.initialized_langfuse_clients) return langfuse_client @@ -394,9 +498,9 @@ class LangFuseLogger: status_message=status_message, ) verbose_logger.debug("OUTPUT IN LANGFUSE: %s; original: %s", output, response_obj) - trace_id = None - generation_id = None - if self._is_langfuse_v2(): + from litellm.integrations.langfuse.langfuse_sdk import lease_langfuse_client + + with lease_langfuse_client(self.Langfuse): trace_id, generation_id = self._log_langfuse_v2( user_id=user_id, metadata=metadata, @@ -411,18 +515,6 @@ class LangFuseLogger: level=level, litellm_call_id=litellm_call_id, ) - elif response_obj is not None: - self._log_langfuse_v1( - user_id=user_id, - metadata=metadata, - output=output, - start_time=start_time, - end_time=end_time, - kwargs=kwargs, - optional_params=optional_params, - input=input, - response_obj=response_obj, - ) verbose_logger.debug("Langfuse Layer Logging - final response object: %s", response_obj) verbose_logger.info("Langfuse Layer Logging - logging success") @@ -518,58 +610,6 @@ class LangFuseLogger: This approach does not impact latency and runs in the background """ - def _is_langfuse_v2(self): - import langfuse - - return Version(langfuse.version.__version__) >= Version("2.0.0") - - def _log_langfuse_v1( - self, - user_id, - metadata, - output, - start_time, - end_time, - kwargs, - optional_params, - input, - response_obj, - ): - from langfuse.model import CreateGeneration, CreateTrace - - verbose_logger.warning( - "Please upgrade langfuse to v2.0.0 or higher: https://github.com/langfuse/langfuse-python/releases/tag/v2.0.1" - ) - - trace: Final = self.Langfuse.trace( - CreateTrace( - name=metadata.get("generation_name", "litellm-completion"), - input=input, - output=output, - userId=user_id, - ) - ) - - custom_llm_provider: Final = cast(str | None, kwargs.get("custom_llm_provider")) - model_name: Final = reconstruct_model_name(kwargs.get("model", ""), custom_llm_provider, metadata) - - trace.generation( - CreateGeneration( - name=metadata.get("generation_name", "litellm-completion"), - startTime=start_time, - endTime=end_time, - model=model_name, - modelParameters=optional_params, - prompt=input, - completion=output, - usage={ - "prompt_tokens": response_obj.usage.prompt_tokens, - "completion_tokens": response_obj.usage.completion_tokens, - }, - metadata=metadata, - ) - ) - def _log_langfuse_v2( self, user_id: str | None, @@ -592,11 +632,7 @@ class LangFuseLogger: StandardLoggingPayload | None, kwargs.get("standard_logging_object", None), ) - tags = ( - self._get_langfuse_tags(standard_logging_object=standard_logging_object) - if self._supports_tags() - else [] - ) + tags = self._get_langfuse_tags(standard_logging_object=standard_logging_object) allowlisted_metadata: Final[StandardLoggingMetadata | Mapping[str, object]] = ( standard_logging_object["metadata"] if standard_logging_object is not None else _NO_METADATA @@ -648,17 +684,17 @@ class LangFuseLogger: # This allows continuing an existing trace while still returning the correct trace_id if existing_trace_id is not None: trace_id = existing_trace_id - resolved_trace_id: Final = ( + call_trace_id: Final = ( litellm_call_id or trace_id if existing_trace_id is None and _is_session_header_trace(trace_id, session_id, litellm_params.get("proxy_server_request")) else trace_id ) - if resolved_trace_id != trace_id: + if call_trace_id != trace_id: verbose_logger.debug( "Langfuse: trace_id %s came from a session header; using call id %s so each call gets its own trace", trace_id, - resolved_trace_id, + call_trace_id, ) requested_trace_keys: Final = _as_steering_key_sequence(clean_metadata.pop("update_trace_keys", ())) update_trace_keys: Final = ( @@ -714,7 +750,7 @@ class LangFuseLogger: trace_params["output"] = masked_output if not mask_output else "redacted-by-litellm" else: # don't overwrite an existing trace trace_params = { - "id": resolved_trace_id, + "id": call_trace_id, "name": trace_name, "session_id": session_id, "input": masked_input if not mask_input else "redacted-by-litellm", @@ -764,17 +800,16 @@ class LangFuseLogger: ("api_base", api_base, bool(api_base)), ("vertex_location", vertex_location, bool(vertex_location)), ("aws_region_name", aws_region_name, bool(aws_region_name)), - ("cache_hit", kwargs.get("cache_hit") or False, self._supports_tags() and "cache_hit" in kwargs), + ("cache_hit", kwargs.get("cache_hit") or False, "cache_hit" in kwargs), ) enrichments: Final[Mapping[str, object]] = { key: value for key, value, include in candidate_enrichments if include } - if self._supports_tags(): - if "cache_hit" in kwargs and kwargs["cache_hit"] is None: - kwargs["cache_hit"] = False # rebind-ok: pre-existing normalization other integrations rely on - if existing_trace_id is None: - trace_params.update({"tags": tags}) + if "cache_hit" in kwargs and kwargs["cache_hit"] is None: + kwargs["cache_hit"] = False # rebind-ok: pre-existing normalization other integrations rely on + if existing_trace_id is None: + trace_params.update({"tags": tags}) proxy_server_request: Final = litellm_params.get("proxy_server_request", None) if proxy_server_request: @@ -788,17 +823,6 @@ class LangFuseLogger: if key.lower() not in _REDACTED_PROXY_HEADERS: clean_headers[key] = value - trace: Final[StatefulTraceClient] = self.Langfuse.trace(**trace_params) - - # Log provider specific information as a span - log_provider_specific_information_as_span(trace, enrichments) - - # Log guardrail information as a span - self._log_guardrail_information_as_span( - trace=trace, - standard_logging_object=standard_logging_object, - ) - generation_id = None usage = None usage_details = None @@ -820,7 +844,7 @@ class LangFuseLogger: usage = { "prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens, - "total_cost": cost if self._supports_costs() else None, + "total_cost": cost, } # According to langfuse documentation: "the input value must be reduced by the number of cache_read_input_tokens" input_tokens: Final = prompt_tokens - cache_read_input_tokens @@ -864,45 +888,87 @@ class LangFuseLogger: "output": masked_output if not mask_output else "redacted-by-litellm", "usage": usage, "usage_details": usage_details, - "metadata": { - **log_requester_metadata(redact_user_api_key_info(metadata=allowlisted_metadata)), + "cost_details": {"total": cost} # mutable-ok: langfuse serializes this payload + 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 {}), + **log_requester_metadata(redact_user_api_key_info(metadata=allowlisted_metadata)), # pyright: ignore[reportArgumentType] # TypedDict in, plain metadata dict out **enrichments, }, "level": level, - "version": clean_metadata.pop("version", None), + "version": _optional_str(clean_metadata.pop("version", None)), } parent_observation_id: Final = metadata.get("parent_observation_id", None) if parent_observation_id is not None: generation_params["parent_observation_id"] = parent_observation_id - if self._supports_prompt(): - generation_params = _add_prompt_to_generation_params( - generation_params=generation_params, - clean_metadata=clean_metadata, - prompt_management_metadata=prompt_management_metadata, - langfuse_client=self.Langfuse, - ) + generation_params = _add_prompt_to_generation_params( + generation_params=generation_params, + clean_metadata=clean_metadata, + prompt_management_metadata=prompt_management_metadata, + langfuse_client=self.Langfuse, + ) if masked_output is not None and isinstance(masked_output, str) and level == "ERROR": generation_params["status_message"] = masked_output - if self._supports_completion_start_time(): - generation_params["completion_start_time"] = kwargs.get("completion_start_time", None) + generation_params["completion_start_time"] = kwargs.get("completion_start_time", None) - generation_client: Final = trace.generation(**generation_params) + # 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, + resolve_observation_id, + resolve_trace_id, + start_generation, + to_unix_nanos, + ) - # Return the trace_id we set (which should be litellm_call_id when no explicit trace_id provided) - # We explicitly set trace_id in trace_params["id"], so langfuse should use it - # Verify langfuse accepted our trace_id; if it differs, log a warning but still return our intended value - # to match expected test behavior - if hasattr(generation_client, "trace_id") and generation_client.trace_id: - if generation_client.trace_id != resolved_trace_id: - verbose_logger.warning( - "Langfuse trace_id mismatch: set %s, but langfuse returned %s. Using our intended trace_id for consistency.", - resolved_trace_id, - generation_client.trace_id, + 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 + ) + log_provider_specific_information_as_span( + client=self.Langfuse, + context=trace_context, + enrichments=enrichments, + claim_trace_root=claim_trace_root, + ) + self._log_guardrail_information_as_span( + client=self.Langfuse, + context=trace_context, + standard_logging_object=standard_logging_object, + claim_trace_root=claim_trace_root, + ) + 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")), + 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, ) - return resolved_trace_id, generation_id + generation.end(end_time=to_unix_nanos(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 pre-computed generation_id would + # name nothing in langfuse, because v4 derives observation ids from the OTel span. + return resolved_trace_id, generation.id except Exception: verbose_logger.error("Langfuse Layer Error - %s", traceback.format_exc()) return None, None @@ -971,27 +1037,11 @@ class LangFuseLogger: _cache_key = _hidden_params.get("cache_key", None) if _cache_key is None and litellm.cache is not None: # fallback to using "preset_cache_key" - _preset_cache_key: Final = litellm.cache._get_preset_cache_key_from_kwargs(**kwargs) + _preset_cache_key: Final = litellm.cache._get_preset_cache_key_from_kwargs(**kwargs) # pyright: ignore[reportPrivateUsage] # kwargs-ok: no public preset-cache-key accessor _cache_key = _preset_cache_key tags.append(f"cache_key:{_cache_key}") return tags - def _supports_tags(self): - """Check if current langfuse version supports tags""" - return Version(self.langfuse_sdk_version) >= Version("2.6.3") - - def _supports_prompt(self): - """Check if current langfuse version supports prompt""" - return Version(self.langfuse_sdk_version) >= Version("2.7.3") - - def _supports_costs(self): - """Check if current langfuse version supports costs""" - return Version(self.langfuse_sdk_version) >= Version("2.7.3") - - def _supports_completion_start_time(self): - """Check if current langfuse version supports completion start time""" - return Version(self.langfuse_sdk_version) >= Version("2.7.3") - @staticmethod def _apply_masking_function(data: object, masking_function: Callable[[object], object]) -> object: """ @@ -1056,8 +1106,10 @@ class LangFuseLogger: def _log_guardrail_information_as_span( self, - trace: StatefulTraceClient, + client: "Langfuse", + context: "Context", standard_logging_object: StandardLoggingPayload | None, + claim_trace_root: bool, ): """ Log guardrail information as a span @@ -1078,6 +1130,8 @@ class LangFuseLogger: ) return + from litellm.integrations.langfuse.langfuse_sdk import start_child_span, to_unix_nanos + for guardrail_entry in guardrail_information: if not isinstance(guardrail_entry, dict): verbose_logger.debug( @@ -1086,21 +1140,25 @@ class LangFuseLogger: ) continue - span = trace.span( + span = start_child_span( + client=client, + context=context, name="guardrail", - 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), - }, start_time=guardrail_entry.get("start_time", None), - end_time=guardrail_entry.get("end_time", None), + claim_trace_root=claim_trace_root, + 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), + }, + }, ) verbose_logger.debug("Logged guardrail information as span: %s", span) - span.end() + span.end(end_time=to_unix_nanos(guardrail_entry.get("end_time", None))) def _add_prompt_to_generation_params( @@ -1142,7 +1200,7 @@ def _add_prompt_to_generation_params( if "labels" in prompt_text_params and "tags" in prompt_text_params: _data["labels"] = user_prompt.get("labels", []) or [] _data["tags"] = user_prompt.get("tags", []) or [] - _prompt_obj = Prompt_Text(**_data) + _prompt_obj = Prompt_Text(**_data) # pyright: ignore[reportArgumentType] # kwargs-ok: shape mirrors the pydantic model, values from the user's prompt dict generation_params["prompt"] = TextPromptClient(prompt=_prompt_obj) elif isinstance(user_prompt["prompt"], list): @@ -1157,7 +1215,7 @@ def _add_prompt_to_generation_params( _data["labels"] = user_prompt.get("labels", []) or [] _data["tags"] = user_prompt.get("tags", []) or [] - _prompt_obj = Prompt_Chat(**_data) + _prompt_obj = Prompt_Chat(**_data) # pyright: ignore[reportArgumentType] # kwargs-ok: shape mirrors the pydantic model, values from the user's prompt dict generation_params["prompt"] = ChatPromptClient(prompt=_prompt_obj) else: @@ -1177,21 +1235,24 @@ def _add_prompt_to_generation_params( def log_provider_specific_information_as_span( - trace, - clean_metadata: Mapping[str, Any], + *, + client: "Langfuse", + context: "Context", + enrichments: Mapping[str, Any], + claim_trace_root: bool, ): """ Logs provider-specific information as spans. Parameters: trace: The tracing object used to log spans. - clean_metadata: A dictionary containing metadata to be logged. + enrichments: The litellm-computed fields on the emitted payload. Returns: None """ - _hidden_params: Final[Mapping[str, object] | None] = clean_metadata.get("hidden_params", None) + _hidden_params: Final[Mapping[str, object] | None] = enrichments.get("hidden_params", None) if _hidden_params is None: return @@ -1202,22 +1263,42 @@ def log_provider_specific_information_as_span( for elem in vertex_ai_grounding_metadata: if isinstance(elem, dict): for key, value in elem.items(): - trace.span( - name=key, - input=value, + _end_grounding_span( + client=client, context=context, name=key, value=value, claim_trace_root=claim_trace_root ) else: - trace.span( + _end_grounding_span( + client=client, + context=context, name="vertex_ai_grounding_metadata", - input=elem, + value=elem, + claim_trace_root=claim_trace_root, ) else: - trace.span( + _end_grounding_span( + client=client, + context=context, name="vertex_ai_grounding_metadata", - input=vertex_ai_grounding_metadata, + value=vertex_ai_grounding_metadata, + claim_trace_root=claim_trace_root, ) +def _end_grounding_span( + *, client: "Langfuse", context: "Context", name: str, value: object, claim_trace_root: bool +) -> None: + from litellm.integrations.langfuse.langfuse_sdk import start_child_span + + start_child_span( + client=client, + context=context, + name=name, + start_time=None, + claim_trace_root=claim_trace_root, + attributes={"input": value}, # mutable-ok: langfuse serializes this payload + ).end() + + def log_requester_metadata(clean_metadata: Mapping[str, Any]): returned_metadata: Final = {} requester_metadata: Final = clean_metadata.get("requester_metadata") or {} diff --git a/litellm/integrations/langfuse/langfuse_prompt_management.py b/litellm/integrations/langfuse/langfuse_prompt_management.py index 90db0626e23..48ba9d45e36 100644 --- a/litellm/integrations/langfuse/langfuse_prompt_management.py +++ b/litellm/integrations/langfuse/langfuse_prompt_management.py @@ -2,13 +2,10 @@ Call Hook for LiteLLM Proxy which allows Langfuse prompt management. """ -import inspect import os from functools import lru_cache from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, cast -from packaging.version import Version - from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.prompt_management_base import PromptManagementClient from litellm.litellm_core_utils.asyncify import run_async_function @@ -20,12 +17,19 @@ from ...litellm_core_utils.specialty_caches.dynamic_logging_cache import ( DynamicLoggingCache, ) from ..prompt_management_base import PromptManagementBase -from .langfuse import LangFuseLogger, resolve_langfuse_credentials +from .langfuse import ( + LangFuseLogger, + installed_langfuse_version, + parse_langfuse_debug, + raise_if_unsupported_langfuse_version, + resolve_langfuse_credentials, +) from .langfuse_handler import LangFuseHandler +from .langfuse_mock_client import create_mock_langfuse_client, should_use_langfuse_mock if TYPE_CHECKING: from langfuse import Langfuse - from langfuse.client import ChatPromptClient, TextPromptClient + from langfuse.model import ChatPromptClient, TextPromptClient from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj @@ -64,8 +68,9 @@ def langfuse_client_init( Exception: If langfuse package is not installed """ try: - import langfuse - from langfuse import Langfuse + from langfuse import ( + Langfuse, # noqa: F401 # the import is the install probe; construction moved to acquire_langfuse_client + ) except Exception as e: raise Exception( f"\033[91mLangfuse not installed, try running 'pip install langfuse' to fix this error: {e}\n\033[0m" @@ -84,36 +89,45 @@ def langfuse_client_init( langfuse_host = "http://" + langfuse_host langfuse_release: Final = os.getenv("LANGFUSE_RELEASE") - langfuse_debug: Final = os.getenv("LANGFUSE_DEBUG") + langfuse_debug: Final = parse_langfuse_debug(os.getenv("LANGFUSE_DEBUG")) parameters: Final = { "public_key": public_key, "secret_key": secret_key, - "host": langfuse_host, + "base_url": langfuse_host, "release": langfuse_release, "debug": langfuse_debug, - "flush_interval": LangFuseLogger._get_langfuse_flush_interval(flush_interval), # flush interval in seconds + "flush_interval": LangFuseLogger._get_langfuse_flush_interval(flush_interval), # pyright: ignore[reportPrivateUsage] # shared env-fallback helper, not part of the logger's API } - if Version(langfuse.version.__version__) >= Version("2.6.0"): - parameters["sdk_integration"] = "litellm" + raise_if_unsupported_langfuse_version(installed_langfuse_version()) - if Version(langfuse.version.__version__) >= Version("2.7.3"): - import httpx + import httpx - import litellm + import litellm - from ...llms.custom_httpx.http_handler import get_ssl_configuration + from ...llms.custom_httpx.http_handler import get_ssl_configuration - parameters["httpx_client"] = httpx.Client( + is_mock_mode: Final = should_use_langfuse_mock() + parameters["httpx_client"] = ( + create_mock_langfuse_client() + if is_mock_mode + else httpx.Client( verify=get_ssl_configuration(), cert=os.getenv("SSL_CERTIFICATE", litellm.ssl_certificate), ) + ) - if "environment" in inspect.signature(Langfuse.__init__).parameters: - parameters["environment"] = LangFuseLogger.resolve_deployment_environment() + parameters["environment"] = LangFuseLogger.resolve_deployment_environment() - client: Final = Langfuse(**parameters) + from .langfuse_sdk import acquire_langfuse_client + + client: Final = acquire_langfuse_client( + parameters=parameters, + environment=parameters["environment"], + release=langfuse_release, + mock_mode=is_mock_mode, + ) return client @@ -126,9 +140,8 @@ class LangfusePromptManagement(LangFuseLogger, PromptManagementBase, CustomLogge langfuse_host=None, flush_interval=1, ): - import langfuse - self.langfuse_sdk_version = langfuse.version.__version__ + self.langfuse_sdk_version = installed_langfuse_version() self.Langfuse = langfuse_client_init( langfuse_public_key=langfuse_public_key, langfuse_secret=langfuse_secret, diff --git a/litellm/integrations/langfuse/langfuse_sdk.py b/litellm/integrations/langfuse/langfuse_sdk.py new file mode 100644 index 00000000000..1d4979e0924 --- /dev/null +++ b/litellm/integrations/langfuse/langfuse_sdk.py @@ -0,0 +1,572 @@ +from __future__ import annotations + +import os +import re +import threading +from base64 import b64encode +from collections.abc import Generator, Mapping +from contextlib import contextmanager +from datetime import datetime +from hashlib import sha256 +from types import MappingProxyType +from typing import Final +from weakref import WeakKeyDictionary, WeakSet + +import opentelemetry.trace as otel_trace +from langfuse import Langfuse, LangfuseGeneration, LangfuseSpan, propagate_attributes +from langfuse._client.resource_manager import LangfuseResourceManager +from opentelemetry.context import Context +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult +from opentelemetry.sdk.trace.sampling import TraceIdRatioBased + +__all__ = ( + "AS_ROOT_ATTRIBUTE", + "PUBLIC_ATTRIBUTE", + "RELEASE_ATTRIBUTE", + "DiscardingSpanExporter", + "acquire_langfuse_client", + "build_isolated_tracer_provider", + "evict_stale_langfuse_resources", + "lease_langfuse_client", + "open_trace_context", + "propagate_attributes", + "register_langfuse_client", + "resolve_observation_id", + "resolve_trace_id", + "shutdown_langfuse_client", + "start_child_span", + "start_generation", + "to_unix_nanos", +) + +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"^[0-9a-f]{32}$") +_OBSERVATION_ID_PATTERN: Final = re.compile(r"^[0-9a-f]{16}$") + + +def to_unix_nanos(value: datetime | float | None) -> int | None: + """Langfuse v4 takes OTel timestamps, which are integer nanoseconds since the epoch. + + Guardrail entries carry unix seconds as floats rather than datetimes, so both + shapes have to convert; the v2 SDK accepted either through a pydantic model. + """ + if value is None: + return None + seconds: Final = value.timestamp() if isinstance(value, datetime) else float(value) + return int(seconds * 1_000_000_000) + + +def resolve_trace_id(trace_id: object | None) -> str: + """Map a caller's trace id onto the 32 lowercase hex characters v4 requires.""" + serialized: Final = "" if trace_id is None else str(trace_id) + normalized: Final = serialized.lower().replace("-", "") + if _TRACE_ID_PATTERN.fullmatch(normalized): + return normalized + return Langfuse.create_trace_id(seed=serialized) if serialized else Langfuse.create_trace_id() + + +def resolve_observation_id(observation_id: object | None) -> str | None: + """Map a caller's parent observation id onto v4's 16 lowercase hex characters.""" + serialized: Final = "" if observation_id is None else str(observation_id) + normalized: Final = serialized.lower().replace("-", "") + if _OBSERVATION_ID_PATTERN.fullmatch(normalized): + return normalized + if not serialized: + return None + return sha256(serialized.encode("utf-8")).digest()[:8].hex() + + +def open_trace_context( + *, + client: Langfuse, + trace_id: str, + parent_observation_id: str | None, +) -> tuple[Context, bool]: + """Build the OTel context that places new observations inside ``trace_id``. + + 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. + """ + 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 + ) + return otel_trace.set_span_in_context(remote_parent), parent_observation_id is None + + +def start_generation( + *, + client: Langfuse, + context: Context, + name: str, + start_time: datetime | float | None, + claim_trace_root: bool, + release: str | None = None, + public: bool | None = None, + attributes: Mapping[str, object], +) -> LangfuseGeneration: + """Create a generation whose start time is when the model call began. + + 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. + """ + 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) + ) + 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 + + +def start_child_span( + *, + client: Langfuse, + context: Context, + name: str, + start_time: datetime | float | None, + claim_trace_root: bool, + attributes: Mapping[str, object], +) -> LangfuseSpan: + """Create a sibling observation inside the same trace, keeping its own window. + + When the shared parent is the fabricated remote span, every observation must + claim trace root itself — the SDK's own remote-parent paths stamp each span — + or it exports with a parent id that is never exported. + """ + 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) + ) + if claim_trace_root: + otel_span.set_attribute(AS_ROOT_ATTRIBUTE, True) + 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" + +# 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 build_isolated_tracer_provider(*, environment: str | None, release: str | None) -> 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 + rebuilt for the same reason: ``LANGFUSE_SAMPLE_RATE`` is otherwise silently + ignored and every trace exports. + """ + raw_sample_rate: Final = os.environ.get("LANGFUSE_SAMPLE_RATE") + sample_rate: Final = float(raw_sample_rate) if raw_sample_rate is not None else 1.0 + if not 0.0 <= sample_rate <= 1.0: + raise ValueError(f"Sample rate must be between 0.0 and 1.0, got {sample_rate}") + 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(dict(attributes)), + sampler=TraceIdRatioBased(sample_rate) if sample_rate < 1 else None, + ) + 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. + """ + + def export(self, spans: object) -> SpanExportResult: + return SpanExportResult.SUCCESS + + def shutdown(self) -> None: + return None + + def force_flush(self, timeout_millis: int = 30_000) -> bool: + return True + + +_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 _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 + + def open_lease(self) -> None: + with self.lock: + self.active_leases += 1 + + 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: + 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) -> Generator[None]: + """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; a teardown already running keeps running, because the spans of a + lease taken that late were lost before the lease began, and stalling every other callback in the + process would not bring them back. A client the registry hands out during the deferral registers + as a holder, and the reference count keeps its bundle alive from there. + """ + state: Final = _lifecycle_state(client) + state.open_lease() + try: + yield + finally: + _run_teardowns(state, state.release_lease(), propagate_base_exception=False) + + +def _run_teardowns( + state: _LangfuseLifecycleState, + clients: tuple[Langfuse, ...], + *, + propagate_base_exception: bool = True, +) -> 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. + Interrupts requeue the unfinished batch and normally propagate, while a callback exception already + in flight takes precedence over an eviction interrupt. + """ + 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: + verbose_logger.exception("Langfuse client teardown failed during cache eviction") + except BaseException: + state.requeue(batch[index:]) + if propagate_base_exception: + raise + return + batch = state.next_teardown_batch() + finally: + state.end_teardown() + + +def _evict_if_stale_locked( + *, public_key: object, secret_key: object, base_url: object +) -> 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 + if getattr(cached, "secret_key", None) == secret_key and getattr(cached, "base_url", None) == base_url: + 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_verified_span_exporter(*, public_key: object, secret_key: object, base_url: object) -> SpanExporter | None: + """Rebuild litellm's TLS material onto the export channel. + + 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. Only built when custom TLS material is configured; endpoint and + headers mirror ``langfuse._client.span_processor``. + """ + import litellm + + ca_bundle: Final = litellm.ssl_verify if isinstance(litellm.ssl_verify, str) else None + configured_certificate: Final = os.getenv("SSL_CERTIFICATE") or litellm.ssl_certificate + client_certificate: Final = configured_certificate if isinstance(configured_certificate, str) else None + if ca_bundle is None and client_certificate is None: + return None + import langfuse as langfuse_package + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + + langfuse_version: Final = getattr(langfuse_package, "__version__", "unknown") + + export_path: Final = os.getenv("LANGFUSE_OTEL_TRACES_EXPORT_PATH") + endpoint: Final = f"{base_url}/{export_path}" if export_path else f"{base_url}/api/public/otel/v1/traces" + encoded_auth: Final = b64encode(f"{public_key}:{secret_key}".encode()).decode("ascii") + return OTLPSpanExporter( + 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": langfuse_version, + "x-langfuse-public-key": str(public_key), + }, + certificate_file=ca_bundle, + client_certificate_file=client_certificate, + ) + + +def acquire_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 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. + """ + public_key: Final = parameters.get("public_key") + span_exporter: Final = ( + DiscardingSpanExporter() + if mock_mode + else _build_verified_span_exporter( + public_key=public_key, + secret_key=parameters.get("secret_key"), + base_url=parameters.get("base_url"), + ) + ) + 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"), + ) + client: Final = Langfuse( + **parameters, # pyright: ignore[reportArgumentType] # kwargs-ok: dict mirrors the typed ctor, values resolved by the callers + tracer_provider=None + if cached is not None + else build_isolated_tracer_provider(environment=environment, release=release), + 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/specialty_caches/dynamic_logging_cache.py b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py index da3ac366bfd..88d959ee0db 100644 --- a/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py +++ b/litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py @@ -43,9 +43,12 @@ class LangfuseInMemoryCache(InMemoryCache): ######################################################### # Clean up Langfuse initialized clients ######################################################### + from litellm.integrations.langfuse.langfuse_sdk import ( + shutdown_langfuse_client, + ) + litellm.initialized_langfuse_clients -= 1 - _created_langfuse_logger.Langfuse.flush() - _created_langfuse_logger.Langfuse.shutdown() + 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/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index 64fd59bbe44..1d01e9d5a72 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -394,7 +394,10 @@ async def health_services_endpoint( from litellm.integrations.langfuse.langfuse import LangFuseLogger langfuse_logger: Final = LangFuseLogger() - langfuse_logger.Langfuse.auth_check() + if langfuse_logger.Langfuse.auth_check() is False: + raise ValueError( + "langfuse auth_check failed - verify LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY are set correctly" + ) _ = litellm.completion( model="openai/litellm-mock-response-model", messages=[{"role": "user", "content": "Hey, how's it going?"}], diff --git a/pyproject.toml b/pyproject.toml index 93ff55c4069..a82aa3f1578 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,11 +155,11 @@ proxy-runtime = [ "anthropic[vertex]>=0.84.0,<1.0", "grpcio==1.78.0", "prometheus-client>=0.20.0,<1.0", - "langfuse>=2.59.7,<3.0", - "opentelemetry-api==1.28.0", - "opentelemetry-sdk==1.28.0", - "opentelemetry-exporter-otlp==1.28.0", - "opentelemetry-instrumentation-fastapi==0.49b0", + "langfuse>=4.7,<5.0", + "opentelemetry-api==1.33.1", + "opentelemetry-sdk==1.33.1", + "opentelemetry-exporter-otlp==1.33.1", + "opentelemetry-instrumentation-fastapi==0.54b1", "ddtrace>=4.8.2,<5.0", "sentry-sdk>=2.21.0,<3.0", "mangum>=0.17.0,<1.0", @@ -204,11 +204,11 @@ dev = [ "types-PyYAML==6.0.12.20250915", "botocore-stubs==1.43.14", "types-boto3[bedrock,bedrock-agent,bedrock-runtime,kms,s3,sagemaker-runtime,sts]==1.43.30", - "opentelemetry-api==1.28.0", - "opentelemetry-sdk==1.28.0", - "opentelemetry-exporter-otlp==1.28.0", - "opentelemetry-instrumentation-fastapi==0.49b0", - "langfuse==2.59.7", + "opentelemetry-api==1.33.1", + "opentelemetry-sdk==1.33.1", + "opentelemetry-exporter-otlp==1.33.1", + "opentelemetry-instrumentation-fastapi==0.54b1", + "langfuse>=4.7,<5.0", "fastapi-offline==1.7.6", "fakeredis==2.34.1", "pytest-rerunfailures==15.1", @@ -230,10 +230,10 @@ proxy-dev = [ "prisma==0.11.0", "hypercorn==0.17.3", "prometheus-client==0.20.0", - "opentelemetry-api==1.28.0", - "opentelemetry-sdk==1.28.0", - "opentelemetry-exporter-otlp==1.28.0", - "opentelemetry-instrumentation-fastapi==0.49b0", + "opentelemetry-api==1.33.1", + "opentelemetry-sdk==1.33.1", + "opentelemetry-exporter-otlp==1.33.1", + "opentelemetry-instrumentation-fastapi==0.54b1", "azure-identity==1.25.2", "a2a-sdk==1.1.0", ] @@ -253,7 +253,7 @@ ci = [ "lunary==1.4.36; python_version == '3.10'", "lunary==1.4.37; python_version >= '3.11'", "logfire==4.6.0", - "traceloop-sdk==0.33.12", + "traceloop-sdk==0.34.0", "detect-secrets==1.5.0", "PyGithub==2.8.1", "aiodynamo==24.7", diff --git a/tests/litellm_utils_tests/test_utils.py b/tests/litellm_utils_tests/test_utils.py index 0ccfae55290..4341a7b3442 100644 --- a/tests/litellm_utils_tests/test_utils.py +++ b/tests/litellm_utils_tests/test_utils.py @@ -914,6 +914,7 @@ def test_logging_trace_id(langfuse_trace_id, langfuse_existing_trace_id): """ - Unit test for `_get_trace_id` function in Logging obj """ + from litellm.integrations.langfuse.langfuse_sdk import resolve_trace_id from litellm.litellm_core_utils.litellm_logging import Logging litellm.success_callback = ["langfuse"] @@ -946,24 +947,18 @@ def test_logging_trace_id(langfuse_trace_id, langfuse_existing_trace_id): time.sleep(3) assert litellm_logging_obj._get_trace_id(service_name="langfuse") is not None - ## if existing_trace_id exists + # langfuse addresses a trace by a 32-hex id, so the id litellm reports back is the + # resolved form of whichever source won; that is what the alerting deep link needs if langfuse_existing_trace_id is not None: - assert ( - litellm_logging_obj._get_trace_id(service_name="langfuse") - == langfuse_existing_trace_id - ) - ## if trace_id exists + expected_source = langfuse_existing_trace_id elif langfuse_trace_id is not None: - assert ( - litellm_logging_obj._get_trace_id(service_name="langfuse") - == langfuse_trace_id - ) - ## if no trace_id or existing_trace_id is provided, use litellm_trace_id + expected_source = langfuse_trace_id else: - assert ( - litellm_logging_obj._get_trace_id(service_name="langfuse") - == litellm_logging_obj.litellm_trace_id - ) + expected_source = litellm_logging_obj.litellm_trace_id + + assert litellm_logging_obj._get_trace_id(service_name="langfuse") == resolve_trace_id( + expected_source + ) def test_convert_model_response_object(): diff --git a/tests/logging_callback_tests/test_langfuse_dynamic_credentials.py b/tests/logging_callback_tests/test_langfuse_dynamic_credentials.py index 2346a5ee047..9c3fc79a4a4 100644 --- a/tests/logging_callback_tests/test_langfuse_dynamic_credentials.py +++ b/tests/logging_callback_tests/test_langfuse_dynamic_credentials.py @@ -51,7 +51,13 @@ def test_resolve_langfuse_credentials_keeps_env_for_global_config(monkeypatch): assert host == "https://admin-configured.example" -def test_upstream_langfuse_debug_env_is_passed(monkeypatch): +def test_upstream_langfuse_env_is_read_without_building_a_client(monkeypatch): + """The UPSTREAM_LANGFUSE_* values are recorded, and nothing consumes them. + + A client was built here and never referenced. On v4 that means a second + exporter, its own threads, and an entry in the SDK's per-key registry, so it + is no longer constructed. + """ from litellm.integrations.langfuse.langfuse import LangFuseLogger class FakeLangfuse: @@ -81,7 +87,10 @@ def test_upstream_langfuse_debug_env_is_passed(monkeypatch): ) assert logger.upstream_langfuse_debug == "true" - assert FakeLangfuse.instances[-1].kwargs["debug"] is True + assert logger.upstream_langfuse_public_key == "upstream-public" + assert logger.upstream_langfuse_host == "https://upstream.example" + assert logger.upstream_langfuse_release == "release" + assert not hasattr(logger, "upstream_langfuse") def test_langfuse_handler_accepts_secret_key_alias(monkeypatch): diff --git a/tests/logging_callback_tests/test_langfuse_unit_tests.py b/tests/logging_callback_tests/test_langfuse_unit_tests.py index 405b6e9e48e..6e44840a535 100644 --- a/tests/logging_callback_tests/test_langfuse_unit_tests.py +++ b/tests/logging_callback_tests/test_langfuse_unit_tests.py @@ -306,35 +306,58 @@ def test_get_langfuse_flush_interval(): def test_langfuse_e2e_sync(monkeypatch): - from litellm import completion - import litellm - import respx - import httpx + """A sync completion must reach langfuse over the wire, not just build a span. + + v4 exports OTLP over ``requests`` rather than the v2 ingestion endpoint over + httpx, so this stands up a real receiver and asserts langfuse posted to it. + """ + import threading import time + from http.server import BaseHTTPRequestHandler, HTTPServer - litellm.disable_aiohttp_transport = ( - True # since this uses respx, we need to set use_aiohttp_transport to False - ) + import litellm + from litellm import completion + from litellm.integrations.langfuse.langfuse import LangFuseLogger - litellm._turn_on_debug() + received_paths = [] + + class _Receiver(BaseHTTPRequestHandler): + def do_POST(self): + received_paths.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() + monkeypatch.setenv("LANGFUSE_HOST", f"http://127.0.0.1:{server.server_port}") + monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-e2e-sync") + monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-e2e-sync") monkeypatch.setattr(litellm, "success_callback", ["langfuse"]) + monkeypatch.setattr(litellm, "_langfuse_logger_cache", {}, raising=False) - with respx.mock: - # Mock Langfuse - # Mock any Langfuse endpoint - langfuse_mock = respx.post( - "https://*.cloud.langfuse.com/api/public/ingestion" - ).mock(return_value=httpx.Response(200)) + try: completion( model="openai/my-fake-endpoint", messages=[{"role": "user", "content": "hello from litellm"}], stream=False, mock_response="Hello from litellm 2", ) + for logger in litellm.logging_callback_manager._get_all_callbacks(): + if isinstance(logger, LangFuseLogger): + logger.Langfuse.flush() + deadline = time.time() + 10 + while not received_paths and time.time() < deadline: + time.sleep(0.1) + finally: + server.shutdown() - time.sleep(3) - - assert langfuse_mock.called + assert received_paths, "langfuse exported nothing" + assert all(path.endswith("/api/public/otel/v1/traces") for path in received_paths) def test_get_chat_content_for_langfuse(): 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 7dea4e67cdd..ef8fa34b3f5 100644 --- a/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_prompt_management.py @@ -1,9 +1,13 @@ +from datetime import datetime, timezone from types import MappingProxyType from typing import Final from unittest.mock import MagicMock, patch import pytest +# langfuse_client_init imports this lazily; cache it before any test mocks +# sys.modules["langfuse"], or a single-file run dies on the real import +import litellm.integrations.langfuse.langfuse_sdk # noqa: F401 from litellm.integrations.langfuse.langfuse_prompt_management import ( LangfusePromptManagement, langfuse_client_init, @@ -87,13 +91,12 @@ class TestLangfusePromptManagement: "litellm.integrations.langfuse.langfuse_prompt_management.LangFuseLogger._get_langfuse_flush_interval", return_value=1, ), - patch.dict("sys.modules", {"langfuse": self._mock_langfuse}), + 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 patch( "litellm.llms.custom_httpx.http_handler.get_ssl_configuration", return_value=False, ) as mock_get_ssl, ): - self._mock_langfuse.Langfuse = mock_langfuse_class langfuse_client_init( langfuse_public_key="pk-1234", @@ -124,16 +127,76 @@ class _RecordingLangfuseForEnv: (("Production", "default"), ("production ", "production"), ("prod", "prod")), ) def test_langfuse_client_init_resolves_deployment_environment(monkeypatch, env_value, expected): - mock_langfuse_module: Final = MagicMock() - mock_langfuse_module.version.__version__ = "2.60.0" - mock_langfuse_module.Langfuse = _RecordingLangfuseForEnv monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "pk-test") monkeypatch.setenv("LANGFUSE_SECRET_KEY", "sk-test") monkeypatch.setenv("LANGFUSE_HOST", "https://test.langfuse.com") monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", env_value) monkeypatch.setattr(_RecordingLangfuseForEnv, "last_environment", None) - with patch.dict("sys.modules", MappingProxyType({"langfuse": mock_langfuse_module})): + 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 langfuse_client_init.cache_clear() langfuse_client_init() langfuse_client_init.cache_clear() assert _RecordingLangfuseForEnv.last_environment == expected + + +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. + """ + 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, + ) + + 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() + monkeypatch.setenv("LANGFUSE_MOCK", "true") + 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() + + 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", + start_time=now, + claim_trace_root=claim_root, + attributes={}, + ).end(end_time=to_unix_nanos(now)) + client.flush() + time.sleep(1) + finally: + server.shutdown() + langfuse_client_init.cache_clear() + LangfuseResourceManager._instances.pop("pk-pm-mock-egress", None) + + assert received == [], f"LANGFUSE_MOCK still sent spans to the configured host: {received}" diff --git a/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py new file mode 100644 index 00000000000..b980768657c --- /dev/null +++ b/tests/test_litellm/integrations/langfuse/test_langfuse_sdk.py @@ -0,0 +1,893 @@ +"""Covers the v4 observation plumbing: historical timestamps and id normalisation. + +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. +""" + +import json +import threading +from datetime import datetime, timedelta, timezone + +import opentelemetry.trace as otel_trace +import pytest +from langfuse import Langfuse +from langfuse._client.resource_manager import LangfuseResourceManager +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 ( + MINIMUM_LANGFUSE_VERSION, + installed_langfuse_version, + 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, + build_isolated_tracer_provider, + evict_stale_langfuse_resources, + lease_langfuse_client, + open_trace_context, + register_langfuse_client, + resolve_observation_id, + resolve_trace_id, + shutdown_langfuse_client, + start_child_span, + start_generation, + to_unix_nanos, +) + +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) + + +@pytest.fixture(name="client") +def _client(): + LangfuseResourceManager._instances.pop("pk-obs-test", None) + 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, + ) + 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", + 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() + + 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" + ) + + +@pytest.mark.parametrize( + "supplied", + [1709294400.5, datetime(2024, 3, 1, 12, 0, 0, 500000, tzinfo=timezone.utc)], + ids=["unix-seconds-float", "datetime"], +) +def test_timestamps_accept_both_shapes_guardrails_and_callbacks_use(supplied): + """Guardrail entries carry unix seconds as floats, the callback carries datetimes.""" + 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) + guardrail_start = 1709294400.0 + start_child_span( + client=lf, + context=context, + name="guardrail", + start_time=guardrail_start, + claim_trace_root=claim_root, + attributes={}, + ).end(end_time=to_unix_nanos(guardrail_start + 2)) + start_generation( + client=lf, context=context, name="gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} + ).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_stays_a_sibling(client): + lf, exporter = client + context, claim_root = open_trace_context(client=lf, trace_id="d" * 32, parent_observation_id=None) + guardrail_start = CALL_START + timedelta(seconds=1) + start_child_span( + client=lf, + context=context, + name="guardrail", + start_time=guardrail_start, + claim_trace_root=claim_root, + attributes={}, + ).end(end_time=to_unix_nanos(guardrail_start + timedelta(seconds=2))) + start_generation( + client=lf, context=context, name="gen", start_time=CALL_START, claim_trace_root=claim_root, attributes={} + ).end(end_time=to_unix_nanos(CALL_END)) + lf.flush() + + guardrail = _only_span(exporter, "guardrail") + generation = _only_span(exporter, "gen") + assert (guardrail.end_time - guardrail.start_time) == 2 * 1_000_000_000 + assert guardrail.context.trace_id == generation.context.trace_id + # the shared remote parent is fabricated and never exported, so both must claim trace root + assert guardrail.attributes.get(AS_ROOT_ATTRIBUTE) is True + assert 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" + + +@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_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_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, + ) + 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" + + +@pytest.mark.parametrize( + "supplied, expected", + [ + ("0123456789abcdef0123456789abcdef", "0123456789abcdef0123456789abcdef"), + ("0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef0123456789abcdef"), + ("3fe0c940-b69a-de3b-a77c-06102505349a", "3fe0c940b69ade3ba77c06102505349a"), + ], + ids=["already-hex", "uppercase-hex", "uuid-with-dashes"], +) +def test_trace_id_passes_through_when_it_is_already_usable(supplied, expected): + assert resolve_trace_id(supplied) == expected + + +def test_arbitrary_trace_id_is_hashed_deterministically(): + first = resolve_trace_id("order-4471") + assert first == resolve_trace_id("order-4471") + assert len(first) == 32 and first == first.lower() + assert first != resolve_trace_id("order-4472") + + +@pytest.mark.parametrize("supplied", [12345, 12.5, True, False], ids=["int", "float", "true", "false"]) +def test_non_string_trace_id_is_normalized(supplied): + resolved = resolve_trace_id(supplied) + + assert len(resolved) == 32 + assert resolved == resolve_trace_id(supplied) + + +@pytest.mark.parametrize("supplied", [12345, 12.5, True, False], ids=["int", "float", "true", "false"]) +def test_non_string_observation_id_is_normalized(supplied): + resolved = resolve_observation_id(supplied) + + assert len(resolved) == 16 + assert resolved == resolve_observation_id(supplied) + + +def test_hyphen_only_trace_ids_are_deterministic(): + assert resolve_trace_id("---") == resolve_trace_id("---") + + +def test_trace_id_with_trailing_newline_is_hashed(): + supplied = "a" * 32 + "\n" + + resolved = resolve_trace_id(supplied) + + assert resolved != supplied + assert len(resolved) == 32 + + +def test_missing_trace_id_still_yields_a_valid_trace_id(): + generated = resolve_trace_id(None) + assert len(generated) == 32 + assert int(generated, 16) >= 0 + + +@pytest.mark.parametrize( + "supplied, expected", + [ + ("0123456789abcdef", "0123456789abcdef"), + (None, None), + ("", None), + ], + ids=["already-hex", "none", "empty"], +) +def test_observation_id_normalisation(supplied, expected): + assert resolve_observation_id(supplied) == expected + + +def test_arbitrary_observation_id_is_hashed_to_a_span_id(): + resolved = resolve_observation_id("my-parent-observation") + assert len(resolved) == 16 + 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: + raise_if_unsupported_langfuse_version(unsupported) + assert unsupported in str(raised.value) + assert MINIMUM_LANGFUSE_VERSION in str(raised.value) + + +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_langfuse_sample_rate_drops_spans_on_the_isolated_provider(monkeypatch): + """The SDK only installs its sampler on providers it builds itself; v2 sampled via the same env var.""" + monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "0") + dropped_exporter = InMemorySpanExporter() + dropping_provider = build_isolated_tracer_provider(environment=None, release=None) + dropping_provider.add_span_processor(SimpleSpanProcessor(dropped_exporter)) + dropping_provider.get_tracer("test").start_span("dropped").end() + assert not dropped_exporter.get_finished_spans() + + monkeypatch.delenv("LANGFUSE_SAMPLE_RATE") + kept_exporter = InMemorySpanExporter() + keeping_provider = build_isolated_tracer_provider(environment=None, release=None) + keeping_provider.add_span_processor(SimpleSpanProcessor(kept_exporter)) + keeping_provider.get_tracer("test").start_span("kept").end() + assert [span.name for span in kept_exporter.get_finished_spans()] == ["kept"] + + +def test_invalid_sample_rate_fails_at_construction_like_the_sdk(monkeypatch): + monkeypatch.setenv("LANGFUSE_SAMPLE_RATE", "1.5") + with pytest.raises(ValueError, match=r"between 0\.0 and 1\.0"): + build_isolated_tracer_provider(environment=None, release=None) + + +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 + provider_before = otel_trace.get_tracer_provider() + + client = _lifecycle_client() + + 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) + + +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 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_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, + ) + + from litellm.integrations.langfuse.langfuse_sdk import ( + open_trace_context, + start_generation, + ) + + 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, + ) + 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 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): + 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]): + 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_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): + with lease_langfuse_client(client): + 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): + 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): + 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") + + +def test_ssl_exporter_is_only_built_with_custom_tls_material(monkeypatch, tmp_path): + """v4 exports over its own OTLP channel, so litellm's CA bundle must be rebuilt onto it.""" + import litellm + from litellm.integrations.langfuse.langfuse_sdk import _build_verified_span_exporter + + monkeypatch.delenv("SSL_CERTIFICATE", raising=False) + monkeypatch.setattr(litellm, "ssl_verify", True) + monkeypatch.setattr(litellm, "ssl_certificate", None) + assert ( + _build_verified_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example") is None + ) + + ca_path = tmp_path / "private-ca.pem" + ca_path.write_text("dummy") + monkeypatch.setattr(litellm, "ssl_verify", str(ca_path)) + exporter = _build_verified_span_exporter(public_key="pk", secret_key="sk", base_url="https://lf.internal.example") + assert exporter is not None + assert exporter._endpoint == "https://lf.internal.example/api/public/otel/v1/traces" + assert exporter._certificate_file == str(ca_path) + assert exporter._headers["x-langfuse-public-key"] == "pk" + + +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) diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 87e76499b84..490bf35d3ad 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1,6 +1,5 @@ import datetime import json -import sys import types import unittest from typing import Final, Optional @@ -11,6 +10,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 # Import LangfuseUsageDetails directly from the module where it's defined @@ -56,28 +56,23 @@ class TestLangfuseUsageDetails(unittest.TestCase): self.mock_langfuse_client.trace.side_effect = _trace_side_effect - # Mock the langfuse module that's imported locally in methods - self.langfuse_module_patcher = patch.dict( - "sys.modules", {"langfuse": MagicMock()} - ) - self.mock_langfuse_module = self.langfuse_module_patcher.start() - - # Create a mock for the langfuse module with version - self.mock_langfuse = MagicMock() - self.mock_langfuse.version = MagicMock() - self.mock_langfuse.version.__version__ = ( - "3.0.0" # Set a version that supports all features + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, ) - # Mock the Langfuse class - self.mock_langfuse_class = MagicMock() - self.mock_langfuse_class.return_value = self.mock_langfuse_client + self.span_exporter = InMemorySpanExporter() + self.real_provider = TracerProvider() + self.real_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) - # Set up the sys.modules['langfuse'] mock - sys.modules["langfuse"] = self.mock_langfuse - sys.modules["langfuse"].Langfuse = self.mock_langfuse_class + # the real SDK is installed; inject the client instead of replacing the module, + # so the v4 imports under test resolve normally + import langfuse as _langfuse_module - # Create a fresh logger instance for each test + self.real_langfuse_class = _langfuse_module.Langfuse + # no patching: the host above is unreachable, so a real client is cheap to build + # and each test swaps in the client it wants self.logger = LangFuseLogger() # Explicitly set the Langfuse client to our mock @@ -113,9 +108,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): ) # Bind the method to the instance - self.logger.log_event_on_langfuse = types.MethodType( - log_event_on_langfuse, self.logger - ) + self.logger.log_event_on_langfuse = types.MethodType(log_event_on_langfuse, self.logger) # Make sure _is_langfuse_v2 returns True def mock_is_langfuse_v2(self): @@ -135,7 +128,37 @@ class TestLangfuseUsageDetails(unittest.TestCase): litellm.initialized_langfuse_clients = self._original_langfuse_clients_count self.env_patcher.stop() - self.langfuse_module_patcher.stop() # patch.dict automatically restores sys.modules + + 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 + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + 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, + ) + return self.logger.Langfuse + + def exported_generation(self): + self.logger.Langfuse.flush() + spans = [s for s in self.span_exporter.get_finished_spans()] + assert spans, "no spans were exported" + return spans[-1] + + @staticmethod + def span_trace_id(span): + return format(span.context.trace_id, "032x") def test_langfuse_usage_details_type(self): """Test that LangfuseUsageDetails TypedDict is properly defined with the correct fields""" @@ -278,9 +301,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): self.mock_langfuse_trace.span.return_value = mock_span self.mock_langfuse_trace.generation.return_value = self.mock_langfuse_generation - # Ensure trace returns our mock - self.mock_langfuse_client.trace.return_value = self.mock_langfuse_trace - self.logger.Langfuse = self.mock_langfuse_client + self.use_real_langfuse_client() with ( patch( @@ -288,7 +309,6 @@ class TestLangfuseUsageDetails(unittest.TestCase): side_effect=lambda generation_params, **kwargs: generation_params, create=True, ) as mock_add_prompt_params, - patch.object(self.logger, "_supports_prompt", return_value=True), ): # Create a mock response object with usage information containing None values response_obj = MagicMock() @@ -338,29 +358,12 @@ class TestLangfuseUsageDetails(unittest.TestCase): except Exception as e: self.fail(f"_log_langfuse_v2 raised an exception: {e}") - # Verify that trace was called first - self.mock_langfuse_client.trace.assert_called() - - # Check the arguments passed to the mocked langfuse generation call - self.mock_langfuse_trace.generation.assert_called_once() - call_args, call_kwargs = self.mock_langfuse_trace.generation.call_args - - # Inspect the usage and usage_details dictionaries - usage_arg = call_kwargs.get("usage") - usage_details_arg = call_kwargs.get("usage_details") - - self.assertIsNotNone(usage_arg) - self.assertIsNotNone(usage_details_arg) - - # Verify that None values were converted to 0 - self.assertEqual(usage_arg["prompt_tokens"], 0) - self.assertEqual(usage_arg["completion_tokens"], 0) - - self.assertEqual(usage_details_arg["input"], 0) - self.assertEqual(usage_details_arg["output"], 0) - self.assertEqual(usage_details_arg["total"], 0) - self.assertEqual(usage_details_arg["cache_creation_input_tokens"], 0) - self.assertEqual(usage_details_arg["cache_read_input_tokens"], 0) + usage_details = json.loads(self.exported_generation().attributes["langfuse.observation.usage_details"]) + assert usage_details["input"] == 0 + assert usage_details["output"] == 0 + assert usage_details["total"] == 0 + assert usage_details["cache_creation_input_tokens"] == 0 + assert usage_details["cache_read_input_tokens"] == 0 mock_add_prompt_params.assert_called_once() @@ -413,7 +416,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): def test_log_langfuse_v2_uses_standard_trace_id_when_available(self): payload = self._build_standard_logging_payload(trace_id="std-trace-id") kwargs = self._build_langfuse_kwargs(payload) - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -435,12 +438,12 @@ class TestLangfuseUsageDetails(unittest.TestCase): litellm_call_id="call-id-xyz", ) - assert self.last_trace_kwargs.get("id") == "std-trace-id" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("std-trace-id") def test_log_langfuse_v2_defaults_to_call_id_without_standard_trace_id(self): payload = self._build_standard_logging_payload() kwargs = self._build_langfuse_kwargs(payload) - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -462,7 +465,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): litellm_call_id="call-id-xyz", ) - assert self.last_trace_kwargs.get("id") == "call-id-xyz" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("call-id-xyz") def test_log_langfuse_v2_uses_litellm_trace_id_fallback_over_call_id(self): """ @@ -474,7 +477,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): payload = self._build_standard_logging_payload() # no trace_id kwargs = self._build_langfuse_kwargs(payload) kwargs["litellm_trace_id"] = "trace-id-from-kwargs" - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -497,7 +500,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): ) # litellm_trace_id should be preferred over litellm_call_id - assert self.last_trace_kwargs.get("id") == "trace-id-from-kwargs" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("trace-id-from-kwargs") CANARY = "sk-lf-canary-SECRET-d4e5f6" @@ -527,14 +530,41 @@ class TestLangfuseUsageDetails(unittest.TestCase): } def _emitted_payload_text(self): - """Every blob this logger handed to the langfuse SDK, as one searchable string.""" + """Every attribute this logger exported to langfuse, as one searchable string.""" import json - blobs = [self.last_trace_kwargs] - if self.mock_langfuse_trace.generation.call_args is not None: - blobs.append(self.mock_langfuse_trace.generation.call_args.kwargs) - blobs.extend(call.kwargs for call in self.mock_langfuse_trace.span.call_args_list) - return json.dumps(blobs, default=repr) + self.logger.Langfuse.flush() + return json.dumps( + [dict(span.attributes or {}) for span in self.span_exporter.get_finished_spans()], + default=repr, + ) + + def exported_generation_metadata(self): + """The generation's metadata as langfuse receives it, one attribute per key. + + v4 serializes each value onto the span, so they are decoded back here to + keep these assertions about what litellm emitted rather than about the + SDK's wire encoding. + """ + import json + + prefix = "langfuse.observation.metadata." + + def decoded(raw): + try: + return json.loads(raw) + except (TypeError, ValueError): + return raw + + return { + key[len(prefix) :]: decoded(value) + for key, value in (self.exported_generation().attributes or {}).items() + if key.startswith(prefix) + } + + def exported_spans_named(self, name): + self.logger.Langfuse.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): metadata = {**self._canary_request_metadata(), **(extra_metadata or {})} @@ -542,9 +572,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): if hidden_params is not None: payload["hidden_params"] = hidden_params kwargs = {**self._build_langfuse_kwargs(payload), "response_cost": 0.25} - self.last_trace_kwargs = {} - self.mock_langfuse_trace.generation.reset_mock() - self.mock_langfuse_trace.span.reset_mock() + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -565,7 +593,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): level="INFO", litellm_call_id="canary-call-id", ) - return self.mock_langfuse_trace.generation.call_args.kwargs["metadata"] + return self.exported_generation_metadata() def test_team_callback_credentials_never_reach_langfuse(self): """ @@ -589,9 +617,8 @@ class TestLangfuseUsageDetails(unittest.TestCase): debug_langfuse dumps request metadata into the trace as a second emit site. It must be sourced from the allowlisted payload too. """ - self._drive_with_canary(extra_metadata={"debug_langfuse": True}) + dumped = self._drive_with_canary(extra_metadata={"debug_langfuse": True})["metadata_passed_to_litellm"] - dumped = self.last_trace_kwargs["metadata"]["metadata_passed_to_litellm"] assert "user_api_key_auth" not in dumped assert self.CANARY not in self._emitted_payload_text() @@ -616,7 +643,10 @@ class TestLangfuseUsageDetails(unittest.TestCase): """ self._drive_with_canary(hidden_params={"vertex_ai_grounding_metadata": ["ground-a", "ground-b"]}) - span_inputs = [call.kwargs.get("input") for call in self.mock_langfuse_trace.span.call_args_list] + span_inputs = [ + span.attributes.get("langfuse.observation.input") + for span in self.exported_spans_named("vertex_ai_grounding_metadata") + ] assert span_inputs == ["ground-a", "ground-b"] assert self.CANARY not in self._emitted_payload_text() @@ -625,9 +655,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): Request metadata never reaches the blob, so a caller naming user_api_key_alias cannot have their value emitted in place of the proxy-resolved one. """ - generation_metadata = self._drive_with_canary( - extra_metadata={"user_api_key_alias": "spoofed-by-caller"} - ) + generation_metadata = self._drive_with_canary(extra_metadata={"user_api_key_alias": "spoofed-by-caller"}) assert generation_metadata["user_api_key_alias"] == "canary-alias" @@ -642,7 +670,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): payload["metadata"]["requester_metadata"] = {"litellm_response_cost": "caller-value", "api_base": "caller"} kwargs = {**self._build_langfuse_kwargs(payload), "response_cost": 0.25} metadata = self._canary_request_metadata() - self.mock_langfuse_trace.generation.reset_mock() + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -664,7 +692,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): litellm_call_id="canary-call-id", ) - generation_metadata = self.mock_langfuse_trace.generation.call_args.kwargs["metadata"] + generation_metadata = self.exported_generation_metadata() assert generation_metadata["litellm_response_cost"] == 0.25 assert generation_metadata["api_base"] == "https://real-api-base" @@ -732,8 +760,9 @@ class TestLangfuseUsageDetails(unittest.TestCase): """ self._drive_with_canary() - assert self.last_trace_kwargs.get("session_id") == "canary-session" - assert self.last_trace_kwargs.get("name") == "canary-trace" + generation = self.exported_generation() + assert generation.attributes["session.id"] == "canary-session" + assert generation.attributes["langfuse.trace.name"] == "canary-trace" def test_failure_trace_survives_a_missing_standard_logging_object(self): """ @@ -752,8 +781,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): "messages": [], "litellm_trace_id": "trace-id-failure", } - self.last_trace_kwargs = {} - self.mock_langfuse_trace.generation.reset_mock() + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -777,9 +805,12 @@ class TestLangfuseUsageDetails(unittest.TestCase): import json - assert trace_id == "trace-id-failure" - assert self.last_trace_kwargs.get("id") == "trace-id-failure" - generation_metadata = self.mock_langfuse_trace.generation.call_args.kwargs["metadata"] + # Must use litellm_trace_id, not litellm_call_id. v4 addresses a trace by a + # 32-hex id, so the callback returns the resolved form, which is what makes + # the alerting deep link point at a trace langfuse can actually open + assert trace_id == resolve_trace_id("trace-id-failure") + assert self.span_trace_id(self.exported_generation()) == trace_id + generation_metadata = self.exported_generation_metadata() assert "user_api_key_auth" not in generation_metadata assert self.CANARY not in self._emitted_payload_text() assert "first_custom" not in generation_metadata @@ -796,7 +827,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): """ payload = self._build_standard_logging_payload(trace_id="std-trace-123") kwargs = self._build_langfuse_kwargs(payload) - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -819,9 +850,9 @@ class TestLangfuseUsageDetails(unittest.TestCase): ) # session_id should be set for Langfuse session grouping - assert self.last_trace_kwargs.get("session_id") == "my-session-abc" + assert self.exported_generation().attributes["session.id"] == "my-session-abc" # trace_id should remain the standard trace_id, NOT the session_id - assert self.last_trace_kwargs.get("id") == "std-trace-123" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("std-trace-123") def test_log_langfuse_v2_session_id_preserved_for_error_level(self): """ @@ -831,7 +862,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): """ payload = self._build_standard_logging_payload(trace_id="std-trace-err") kwargs = self._build_langfuse_kwargs(payload) - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -854,11 +885,11 @@ class TestLangfuseUsageDetails(unittest.TestCase): ) # session_id must be preserved even for ERROR level logs - assert self.last_trace_kwargs.get("session_id") == "error-session-xyz" + assert self.exported_generation().attributes["session.id"] == "error-session-xyz" # trace_id should be the standard trace_id, not the session_id - assert self.last_trace_kwargs.get("id") == "std-trace-err" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("std-trace-err") # status_message should be set for error traces - assert self.last_trace_kwargs.get("status_message") is not None + assert self.exported_generation().attributes["langfuse.observation.level"] == "ERROR" def test_log_langfuse_v2_explicit_trace_id_takes_priority_over_session_id(self): """ @@ -867,7 +898,7 @@ class TestLangfuseUsageDetails(unittest.TestCase): """ payload = self._build_standard_logging_payload() kwargs = self._build_langfuse_kwargs(payload) - self.last_trace_kwargs = {} + self.use_real_langfuse_client() with patch( "litellm.integrations.langfuse.langfuse._add_prompt_to_generation_params", @@ -898,9 +929,9 @@ class TestLangfuseUsageDetails(unittest.TestCase): ) # Explicit trace_id must take priority - assert self.last_trace_kwargs.get("id") == "explicit-trace-id-777" + assert self.span_trace_id(self.exported_generation()) == resolve_trace_id("explicit-trace-id-777") # session_id must still be set for session grouping - assert self.last_trace_kwargs.get("session_id") == "session-999" + assert self.exported_generation().attributes["session.id"] == "session-999" def test_failure_handler_langfuse_kwargs_excludes_original_response(): @@ -948,12 +979,8 @@ def test_failure_handler_langfuse_kwargs_excludes_original_response(): try: # Mock LangFuseHandler to return our capturing mock logger - with patch( - "litellm.litellm_core_utils.litellm_logging.LangFuseHandler" - ) as mock_handler_class: - mock_handler_class.get_langfuse_logger_for_request.return_value = ( - mock_langfuse_logger - ) + with patch("litellm.litellm_core_utils.litellm_logging.LangFuseHandler") as mock_handler_class: # test-quality-ok: route the request to the capturing logger; the real handler builds live clients + mock_handler_class.get_langfuse_logger_for_request.return_value = mock_langfuse_logger # Call the actual failure_handler test_exception = Exception("TestError: model not found") @@ -965,23 +992,19 @@ def test_failure_handler_langfuse_kwargs_excludes_original_response(): ) # Verify log_event_on_langfuse was actually called - assert ( - mock_langfuse_logger.log_event_on_langfuse.called - ), "log_event_on_langfuse was not called" + assert mock_langfuse_logger.log_event_on_langfuse.called, "log_event_on_langfuse was not called" # Verify original_response is NOT in the kwargs passed to Langfuse langfuse_kwargs = captured_kwargs.get("kwargs", {}) - assert ( - "original_response" not in langfuse_kwargs - ), "original_response should be excluded from kwargs passed to Langfuse" + assert "original_response" not in langfuse_kwargs, ( + "original_response should be excluded from kwargs passed to Langfuse" + ) # Verify session_id metadata is preserved in the kwargs - langfuse_metadata = langfuse_kwargs.get("litellm_params", {}).get( - "metadata", {} + langfuse_metadata = langfuse_kwargs.get("litellm_params", {}).get("metadata", {}) + assert langfuse_metadata.get("session_id") == "test-session-failure", ( + "session_id should be preserved in kwargs passed to Langfuse" ) - assert ( - langfuse_metadata.get("session_id") == "test-session-failure" - ), "session_id should be preserved in kwargs passed to Langfuse" # Verify level is ERROR assert captured_kwargs.get("level") == "ERROR" @@ -1023,9 +1046,7 @@ async def test_async_log_failure_event_logs_to_langfuse(): "generation_id": "mock-gen", } - with patch( - "litellm.integrations.langfuse.langfuse_prompt_management.LangFuseHandler" - ) as mock_handler: + with patch("litellm.integrations.langfuse.langfuse_prompt_management.LangFuseHandler") as mock_handler: # test-quality-ok: route the request to the capturing logger; the real handler builds live clients mock_handler.get_langfuse_logger_for_request.return_value = mock_logger kwargs = { @@ -1050,9 +1071,7 @@ async def test_async_log_failure_event_logs_to_langfuse(): ) # Verify log_event_on_langfuse was called - assert ( - mock_logger.log_event_on_langfuse.called - ), "log_event_on_langfuse was not called for failure event" + assert mock_logger.log_event_on_langfuse.called, "log_event_on_langfuse was not called for failure event" call_kwargs = mock_logger.log_event_on_langfuse.call_args[1] assert call_kwargs["level"] == "ERROR" assert call_kwargs["status_message"] == "API error: model not found" @@ -1092,9 +1111,7 @@ async def test_async_log_failure_event_works_without_standard_logging_object(): "generation_id": "mock-gen", } - with patch( - "litellm.integrations.langfuse.langfuse_prompt_management.LangFuseHandler" - ) as mock_handler: + with patch("litellm.integrations.langfuse.langfuse_prompt_management.LangFuseHandler") as mock_handler: # test-quality-ok: route the request to the capturing logger; the real handler builds live clients mock_handler.get_langfuse_logger_for_request.return_value = mock_logger kwargs = { @@ -1125,6 +1142,64 @@ async def test_async_log_failure_event_works_without_standard_logging_object(): assert "InternalServerError" in call_kwargs["status_message"] +def test_mock_mode_makes_no_network_calls(monkeypatch): + """LANGFUSE_MOCK promises full execution without egress. + + The mock intercepts httpx, but v4 ships observations over its own OTLP + 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() + monkeypatch.setenv("LANGFUSE_MOCK", "true") + monkeypatch.setenv("LANGFUSE_HOST", f"http://127.0.0.1:{server.server_port}") + 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() + time.sleep(1) + finally: + server.shutdown() + LangfuseResourceManager._instances.pop("pk-mock-egress", None) + + assert received == [], f"mock mode sent real requests: {received}" + + def test_max_langfuse_clients_limit(): """ Test that the max langfuse clients limit is respected when initializing multiple clients @@ -1184,18 +1259,10 @@ class _RecordingLangfuse: self.client = MagicMock() -class _RecordingLangfuseWithoutEnvironment: - last_parameters: Optional[dict] = None - - def __init__(self, **parameters): - type(self).last_parameters = parameters - self.client = MagicMock() - - def _build_langfuse_logger(monkeypatch) -> LangFuseLogger: monkeypatch.setenv("LANGFUSE_MOCK", "false") monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("langfuse.Langfuse", _RecordingLangfuse): + 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 return LangFuseLogger( langfuse_public_key="pk-lit5228", langfuse_secret="sk-lit5228", @@ -1207,7 +1274,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("langfuse.Langfuse", _RecordingLangfuse): + 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 logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1222,7 +1289,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("langfuse.Langfuse", _RecordingLangfuse): + 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 logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1232,19 +1299,6 @@ def test_langfuse_environment_falls_back_to_deployment_env_var(monkeypatch): assert _RecordingLangfuse.last_parameters["environment"] == "deployment-wide" -def test_langfuse_environment_omitted_for_old_sdk_versions(monkeypatch): - monkeypatch.setenv("LANGFUSE_MOCK", "false") - monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) - with patch("langfuse.Langfuse", _RecordingLangfuseWithoutEnvironment): - LangFuseLogger( - langfuse_public_key="pk-env", - langfuse_secret="sk-env", - langfuse_host="https://test.langfuse.com", - langfuse_environment="staging", - ) - assert "environment" not in _RecordingLangfuseWithoutEnvironment.last_parameters - - def test_dynamic_langfuse_environment_triggers_dynamic_logger(): from litellm.integrations.langfuse.langfuse_handler import LangFuseHandler from litellm.types.utils import StandardCallbackDynamicParams @@ -1307,38 +1361,114 @@ def test_langfuse_logger_reuses_the_shared_cached_client(monkeypatch): _LANGFUSE_REDACTED = "redacted-by-litellm" -def _steering_logger() -> LangFuseLogger: - """``__new__`` skips the SDK and network setup in ``__init__``.""" +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 + + exporter = InMemorySpanExporter() + LangfuseResourceManager._instances.pop("pk-steering-test", None) logger = LangFuseLogger.__new__(LangFuseLogger) - logger.Langfuse = MagicMock() - logger.langfuse_sdk_version = "2.60.0" - return logger + 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.langfuse_sdk_version = installed_langfuse_version() + return logger, exporter -def _emit(logger: LangFuseLogger, *, metadata=None, headers=None): - """``log_event_on_langfuse`` is the entry point that folds ``langfuse_*`` headers into metadata.""" +def test_log_event_holds_a_client_lease_during_export(): + logger, _ = _steering_logger() + state = _lifecycle_state(logger.Langfuse) + + def assert_lease_is_active(**_: object) -> tuple[str, str]: + assert state.active_leases == 1 + return "trace-id", "generation-id" + now = datetime.datetime.now() - response_obj = litellm.ModelResponse( - choices=[{"message": {"role": "assistant", "content": "the-output"}}] - ) - logger.log_event_on_langfuse( - kwargs={ - "call_type": "completion", - "litellm_params": { - "metadata": dict(metadata or {}), - "proxy_server_request": {"headers": dict(headers or {})}, + 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": {}, }, - "messages": [{"role": "user", "content": "the-input"}], - "optional_params": {}, - }, - response_obj=response_obj, - start_time=now, - end_time=now, - ) - return ( - logger.Langfuse.trace.call_args.kwargs, - logger.Langfuse.trace.return_value.generation.call_args.kwargs, - ) + 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 _exported_span(logger, exporter): + logger.Langfuse.flush() + return exporter.get_finished_spans()[-1] + + +def _span_trace_id(span): + return format(span.context.trace_id, "032x") + + +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. + """ + 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": {}, + }, + response_obj=response_obj, + start_time=now, + end_time=now, + ) + logger.Langfuse.flush() + prefix = "langfuse.observation." + span = exporter.get_finished_spans()[-1] + 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 @pytest.mark.parametrize("level", ["DEFAULT", "ERROR"]) @@ -1464,8 +1594,9 @@ def test_session_header_trace_provenance(headers, metadata, expected_id, level): redact_credential_headers, ) - logger: Final = _steering_logger() + logger, exporter = _steering_logger() for turn in range(2): + exporter.clear() call_id = f"call-{turn}" request_headers = Headers(headers) data = LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( @@ -1495,17 +1626,19 @@ def test_session_header_trace_provenance(headers, metadata, expected_id, level): level=level, status_message="provider error" if level == "ERROR" else None, ) - trace_params = logger.Langfuse.trace.call_args.kwargs - assert trace_params["id"] == (call_id if expected_id == "call" else expected_id) - assert result["trace_id"] == trace_params["id"] + span = _exported_span(logger, exporter) + assert _span_trace_id(span) == resolve_trace_id(call_id if expected_id == "call" else expected_id) + assert result["trace_id"] == _span_trace_id(span) if expected_id != "existing-trace": - assert trace_params["session_id"] == headers.get("langfuse_session_id", original_metadata.get("session_id")) + assert span.attributes.get("session.id") == headers.get( + "langfuse_session_id", original_metadata.get("session_id") + ) steering = {key[len("langfuse_") :]: value for key, value in headers.items() if key.startswith("langfuse_")} assert data["metadata"] == {**original_metadata, **steering} def test_session_header_trace_without_call_id_keeps_session_alias(): - logger: Final = _steering_logger() + logger, exporter = _steering_logger() now: Final = datetime.datetime.now() result: Final = logger.log_event_on_langfuse( @@ -1524,8 +1657,8 @@ def test_session_header_trace_without_call_id_keeps_session_alias(): end_time=now, ) - assert logger.Langfuse.trace.call_args.kwargs["id"] == "session-7125" - assert result["trace_id"] == "session-7125" + assert _span_trace_id(_exported_span(logger, exporter)) == resolve_trace_id("session-7125") + assert result["trace_id"] == resolve_trace_id("session-7125") def test_every_proxy_session_header_shape_is_classified_as_a_session_alias(): @@ -1559,7 +1692,7 @@ def test_every_proxy_session_header_shape_is_classified_as_a_session_alias(): ) def test_sdk_caller_without_request_headers_keeps_its_trace(proxy_server_request): """A direct SDK caller has no request headers, so a session-shaped trace id stays the caller's.""" - logger: Final = _steering_logger() + logger, exporter = _steering_logger() now: Final = datetime.datetime.now() result: Final = logger.log_event_on_langfuse( @@ -1578,8 +1711,8 @@ def test_sdk_caller_without_request_headers_keeps_its_trace(proxy_server_request end_time=now, ) - assert logger.Langfuse.trace.call_args.kwargs["id"] == "session-7125" - assert result["trace_id"] == "session-7125" + assert _span_trace_id(_exported_span(logger, exporter)) == resolve_trace_id("session-7125") + assert result["trace_id"] == resolve_trace_id("session-7125") def test_session_header_classifier_survives_non_string_header_keys(): @@ -1593,36 +1726,36 @@ def test_session_header_classifier_survives_non_string_header_keys(): def test_mask_input_header_false_keeps_the_prompt(): - logger = _steering_logger() + rig = _steering_logger() - trace_params, generation_params = _emit(logger, headers={"langfuse_mask_input": "false"}) + trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_input": "false"}) assert trace_params["input"] == {"messages": [{"role": "user", "content": "the-input"}]} - assert generation_params["input"] == {"messages": [{"role": "user", "content": "the-input"}]} + assert json.loads(generation_params["input"]) == {"messages": [{"role": "user", "content": "the-input"}]} def test_mask_input_header_true_redacts_the_prompt(): - logger = _steering_logger() + rig = _steering_logger() - trace_params, generation_params = _emit(logger, headers={"langfuse_mask_input": "true"}) + trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_input": "true"}) assert trace_params["input"] == _LANGFUSE_REDACTED assert generation_params["input"] == _LANGFUSE_REDACTED def test_mask_output_header_false_keeps_the_completion(): - logger = _steering_logger() + rig = _steering_logger() - trace_params, generation_params = _emit(logger, headers={"langfuse_mask_output": "false"}) + trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_output": "false"}) assert trace_params["output"] != _LANGFUSE_REDACTED assert generation_params["output"] != _LANGFUSE_REDACTED def test_mask_output_header_true_redacts_the_completion(): - logger = _steering_logger() + rig = _steering_logger() - trace_params, generation_params = _emit(logger, headers={"langfuse_mask_output": "true"}) + trace_params, generation_params, _ = _emit(rig, headers={"langfuse_mask_output": "true"}) assert trace_params["output"] == _LANGFUSE_REDACTED assert generation_params["output"] == _LANGFUSE_REDACTED @@ -1638,20 +1771,20 @@ def test_mask_output_header_true_redacts_the_completion(): ], ) def test_mask_input_from_the_request_body_is_unchanged(mask_input, expect_redacted): - logger = _steering_logger() + rig = _steering_logger() - trace_params, _ = _emit(logger, metadata={"mask_input": mask_input}) + trace_params, _, _ = _emit(rig, metadata={"mask_input": mask_input}) assert (trace_params["input"] == _LANGFUSE_REDACTED) is expect_redacted @pytest.mark.parametrize("flag", [True, "true"]) def test_update_trace_keys_header_applies_every_key_when_enabled(flag): - logger = _steering_logger() + rig = _steering_logger() with patch.object(litellm, "langfuse_enable_update_trace_keys", flag): - trace_params, _ = _emit( - logger, + trace_params, _, span = _emit( + rig, headers={ "langfuse_existing_trace_id": "trace-1", "langfuse_update_trace_keys": "trace_release, trace_tail", @@ -1662,6 +1795,9 @@ def test_update_trace_keys_header_applies_every_key_when_enabled(flag): 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")] def test_update_trace_keys_is_off_by_default(): @@ -1670,10 +1806,10 @@ def test_update_trace_keys_is_off_by_default(): user_api_key_auth and have the resolved auth object, including team callback credentials, serialized onto the trace. It stays inert until an operator opts in. """ - logger = _steering_logger() + rig = _steering_logger() - trace_params, _ = _emit( - logger, + trace_params, _, span = _emit( + rig, metadata={ "existing_trace_id": "trace-1", "update_trace_keys": ["user_api_key_auth", "trace_release"], @@ -1685,25 +1821,62 @@ 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) def test_update_trace_keys_input_and_output_are_gated_too(): - logger = _steering_logger() + rig = _steering_logger() - off, _ = _emit(logger, metadata={"existing_trace_id": "trace-1", "update_trace_keys": ["input", "output"]}) + off, _, _ = _emit(rig, metadata={"existing_trace_id": "trace-1", "update_trace_keys": ["input", "output"]}) with patch.object(litellm, "langfuse_enable_update_trace_keys", True): - on, _ = _emit(logger, metadata={"existing_trace_id": "trace-1", "update_trace_keys": ["input", "output"]}) + on, _, _ = _emit(rig, metadata={"existing_trace_id": "trace-1", "update_trace_keys": ["input", "output"]}) assert "input" not in off and "output" not in off assert "input" in on and "output" in on -def test_update_trace_keys_from_the_request_body_list_applies_when_enabled(): - logger = _steering_logger() +def test_update_trace_keys_input_output_reach_the_trace_even_under_a_parent(): + """With a real parent the generation is not the trace root, so trace-level + I/O must be stamped explicitly; v2 updated the trace object directly.""" + rig = _steering_logger() with patch.object(litellm, "langfuse_enable_update_trace_keys", True): - trace_params, _ = _emit( - logger, + _, _, span = _emit( + rig, + metadata={ + "existing_trace_id": "trace-1", + "parent_observation_id": "b" * 16, + "update_trace_keys": ["input", "output"], + }, + ) + + assert "the-input" in str(span.attributes["langfuse.trace.input"]) + assert "the-output" in str(span.attributes["langfuse.trace.output"]) + + +def test_trace_io_is_not_stamped_when_update_trace_keys_does_not_ask(): + rig = _steering_logger() + + with patch.object(litellm, "langfuse_enable_update_trace_keys", True): + _, _, span = _emit( + rig, + metadata={ + "existing_trace_id": "trace-1", + "parent_observation_id": "b" * 16, + "update_trace_keys": ["trace_release"], + }, + ) + + assert "langfuse.trace.input" not in (span.attributes or {}) + assert "langfuse.trace.output" not in (span.attributes or {}) + + +def test_update_trace_keys_from_the_request_body_list_applies_when_enabled(): + rig = _steering_logger() + + with patch.object(litellm, "langfuse_enable_update_trace_keys", True): + trace_params, _, span = _emit( + rig, metadata={ "existing_trace_id": "trace-1", "update_trace_keys": ["trace_release"], @@ -1712,13 +1885,14 @@ def test_update_trace_keys_from_the_request_body_list_applies_when_enabled(): ) assert trace_params["release"] == "v1.2.3" + assert span.attributes["langfuse.release"] == "v1.2.3" def test_update_trace_keys_matches_whole_keys_not_substrings(): - logger = _steering_logger() + rig = _steering_logger() - trace_params, _ = _emit( - logger, + trace_params, _, _ = _emit( + rig, headers={"langfuse_existing_trace_id": "trace-1", "langfuse_update_trace_keys": "my_input"}, ) @@ -1729,7 +1903,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("langfuse.Langfuse", _RecordingLangfuse): + 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 logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1756,7 +1930,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("langfuse.Langfuse", _RecordingLangfuse): + 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 logger = LangFuseLogger( langfuse_public_key="pk-env", langfuse_secret="sk-env", @@ -1805,3 +1979,175 @@ def test_langfuse_deployment_environment_fallback_never_raises(monkeypatch, env_ langfuse_host="https://test.langfuse.com", ) assert logger.langfuse_environment == expected + + +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"}) + + assert "version" not in captured_trace_params + assert span.attributes["langfuse.version"] == "gen-7" + + +def test_new_trace_version_takes_precedence_over_the_generation_version(): + """v4 has one ``version`` for the trace and its root observation; ``trace_version`` wins as in v2.""" + rig = _steering_logger() + + captured_trace_params, _, span = _emit(rig, metadata={"trace_version": "trace-1", "version": "gen-7"}) + + assert captured_trace_params["version"] == "trace-1" + assert span.attributes["langfuse.version"] == "trace-1" + + +def test_log_event_returns_the_v2_dict_shape_for_the_alerting_trace_id_cache(): + """litellm_logging only caches the langfuse trace id off a dict with a ``trace_id`` key. + + Slack alerting builds its trace URL from that cache, so a different return + shape silently breaks alert links. + """ + rig = _steering_logger() + logger, _ = rig + + returned = logger.log_event_on_langfuse( + kwargs={ + "call_type": "completion", + "litellm_params": {"metadata": {"trace_id": "c" * 32}}, + "messages": [{"role": "user", "content": "the-input"}], + "optional_params": {}, + }, + response_obj=litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "the-output"}}]), + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + + assert isinstance(returned, dict) + assert returned["trace_id"] == "c" * 32 + assert returned["generation_id"] + + +def test_parse_langfuse_debug_only_enables_on_true_strings(): + """v4 treats any truthy value as debug=on, so the raw env string "false" would enable debug.""" + assert langfuse_module.parse_langfuse_debug("true") is True + assert langfuse_module.parse_langfuse_debug("True") is True + assert langfuse_module.parse_langfuse_debug("1") is True + assert langfuse_module.parse_langfuse_debug("false") is False + assert langfuse_module.parse_langfuse_debug("False") is False + assert langfuse_module.parse_langfuse_debug("") is False + assert langfuse_module.parse_langfuse_debug(None) is False + + +def test_langfuse_debug_env_string_false_stays_off(monkeypatch): + """LANGFUSE_DEBUG=false must not reach the v4 client as a truthy string. + + 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) + + +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. + """ + from langfuse._client.resource_manager import LangfuseResourceManager + + 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.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" + finally: + LangfuseResourceManager._instances.pop("pk-base-url-test", None) + + +def test_resolve_credentials_falls_back_to_langfuse_base_url(monkeypatch): + """v4's canonical env var works when LANGFUSE_HOST is unset, but never beats it.""" + monkeypatch.setenv("LANGFUSE_BASE_URL", "https://from-base-url.example") + monkeypatch.delenv("LANGFUSE_HOST", raising=False) + + _, _, host = langfuse_module.resolve_langfuse_credentials() + assert host == "https://from-base-url.example" + + monkeypatch.setenv("LANGFUSE_HOST", "https://from-host.example") + _, _, host = langfuse_module.resolve_langfuse_credentials() + assert host == "https://from-host.example" + + _, _, host = langfuse_module.resolve_langfuse_credentials(langfuse_host="https://explicit.example") + assert host == "https://explicit.example" + + +def test_version_gate_rejects_v5_prereleases(): + """"5.0.0rc1" sorts below "5", so a plain version comparison would admit it.""" + langfuse_module.raise_if_unsupported_langfuse_version("4.7") + with pytest.raises(ImportError): + langfuse_module.raise_if_unsupported_langfuse_version("5.0.0rc1") + with pytest.raises(ImportError): + 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.""" + rig = _steering_logger() + + _, _, span = _emit( + rig, metadata={"trace_user_id": 12345, "session_id": 67, "trace_version": 3, "tags": ["ok", 99]} + ) + + # 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.""" + rig = _steering_logger() + long_user: Final = "u" * 250 + + _, _, span = _emit(rig, metadata={"trace_user_id": long_user}) + + assert span.attributes["user.id"] == "u" * 200 + + +def test_returned_generation_id_names_the_exported_observation(): + """v4 derives observation ids from the OTel span, so a pre-computed id would name nothing.""" + logger, exporter = _steering_logger() + + returned = logger.log_event_on_langfuse( + kwargs={ + "call_type": "completion", + "litellm_params": {"metadata": {"trace_id": "d" * 32}}, + "messages": [{"role": "user", "content": "the-input"}], + "optional_params": {}, + }, + response_obj=litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "the-output"}}]), + start_time=datetime.datetime.now(), + end_time=datetime.datetime.now(), + ) + logger.Langfuse.flush() + + span = exporter.get_finished_spans()[-1] + assert returned["generation_id"] == format(span.context.span_id, "016x") diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index 761cd0685f2..fac14d65050 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -4179,3 +4179,19 @@ async def test_health_services_endpoint_pointfive_blocks_non_admin(monkeypatch, assert str(raised.value.code) == "403" logger_class.assert_not_called() + + +@pytest.mark.asyncio +async def test_health_services_endpoint_langfuse_missing_keys_errors(monkeypatch): + """A disabled v4 client returns False from auth_check instead of raising. + + v2 raised out of ``auth_check`` on missing keys, so the endpoint errored; + the endpoint must not report success when the return value says the check + failed. + """ + for key in ("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST", "LANGFUSE_BASE_URL", "LANGFUSE_MOCK"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(litellm, "initialized_langfuse_clients", litellm.initialized_langfuse_clients) + + with pytest.raises(ProxyException, match="auth_check failed"): + await health_services_endpoint(service="langfuse")