diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index 49963a61a..15b6ae4b0 100644 --- a/backend/app/core/langfuse/langfuse.py +++ b/backend/app/core/langfuse/langfuse.py @@ -1,22 +1,101 @@ -import uuid +import json import logging +import uuid +from collections.abc import Callable from functools import wraps -from typing import Any, Callable, Optional +from typing import Any from asgi_correlation_id import correlation_id -from langfuse import Langfuse -from langfuse.client import StatefulGenerationClient, StatefulTraceClient +from langfuse import Langfuse, LangfuseOtelSpanAttributes +from langfuse._client.span import LangfuseGeneration, LangfuseSpan +from langfuse.api.core.api_error import ApiError + from app.models.llm import ( + AudioOutput, LLMCallResponse, NativeCompletionConfig, QueryParams, TextOutput, - AudioOutput, ) logger = logging.getLogger(__name__) +def format_langfuse_error(exc: Exception) -> str: + """Return a concise message for a Langfuse SDK exception. + + Langfuse 4.x's ``ApiError.__str__`` dumps the full HTTP response including + every response header, e.g.:: + + headers: {'date': ..., 'etag': ...}, status_code: 404, body: {...} + + For user-facing/log error messages we only want the actionable parts, so we + drop the headers and keep ``status_code`` and ``body`` (matching the 2.x + string format). Non-API exceptions fall back to ``str(exc)``. + """ + if isinstance(exc, ApiError): + return f"status_code: {exc.status_code}, body: {exc.body}" + return str(exc) + + +def _serialize(value: Any) -> str: + """Serialize a trace attribute value to a JSON string for OTel span attributes.""" + try: + return json.dumps(value, default=str) + except (TypeError, ValueError): + return str(value) + + +def _to_usage_details(usage: dict[str, Any] | None) -> dict[str, int] | None: + """Convert a v2-style usage dict to the v4 ``usage_details`` (int-only) format. + + v2 callers pass ``{"input", "output", "total", "unit": "TOKENS"}``; v4 expects + ``usage_details: dict[str, int]`` and has no ``unit`` field, so non-int values + (e.g. ``"TOKENS"``) are dropped. + """ + if not usage: + return None + return {k: v for k, v in usage.items() if isinstance(v, int)} + + +def set_trace_attributes( + span: LangfuseSpan, + *, + name: str | None = None, + input: Any = None, + output: Any = None, + session_id: str | None = None, + tags: list[str] | None = None, + metadata: dict[str, Any] | None = None, +) -> None: + """Set trace-level attributes on a root span's underlying OTel span. + + v4 has no public imperative setter for trace session_id/tags/metadata (it steers + callers to the ``propagate_attributes`` context manager, which doesn't fit an + imperative, method-by-method tracing lifecycle), so we write the public OTel + attribute keys (``LangfuseOtelSpanAttributes.TRACE_*``) directly on the root span. + Trace metadata round-trips per-key as ``langfuse.trace.metadata.``. + """ + attrs: dict[str, Any] = {} + if name is not None: + attrs[LangfuseOtelSpanAttributes.TRACE_NAME] = name + if input is not None: + attrs[LangfuseOtelSpanAttributes.TRACE_INPUT] = _serialize(input) + if output is not None: + attrs[LangfuseOtelSpanAttributes.TRACE_OUTPUT] = _serialize(output) + if session_id is not None: + attrs[LangfuseOtelSpanAttributes.TRACE_SESSION_ID] = session_id + if tags: + attrs[LangfuseOtelSpanAttributes.TRACE_TAGS] = tags + if metadata: + for key, value in metadata.items(): + attrs[f"{LangfuseOtelSpanAttributes.TRACE_METADATA}.{key}"] = ( + value if isinstance(value, str) else _serialize(value) + ) + if attrs: + span._otel_span.set_attributes(attrs) + + def extract_response_output( llm_output: TextOutput | AudioOutput | None, ) -> str | dict[str, Any]: @@ -47,14 +126,14 @@ def extract_response_output( class LangfuseTracer: def __init__( self, - credentials: Optional[dict] = None, - session_id: Optional[str] = None, - response_id: Optional[str] = None, + credentials: dict | None = None, + session_id: str | None = None, + response_id: str | None = None, ) -> None: self.session_id = session_id or str(uuid.uuid4()) - self.langfuse: Optional[Langfuse] = None - self.trace: Optional[StatefulTraceClient] = None - self.generation: Optional[StatefulGenerationClient] = None + self.langfuse: Langfuse | None = None + self.trace: LangfuseSpan | None = None + self.generation: LangfuseGeneration | None = None self._failed = False has_credentials = ( @@ -66,24 +145,31 @@ def __init__( if has_credentials: try: + # v4 caches one client per public_key (thread-safe singleton); passing + # explicit creds keeps this multi-tenant. Never use bare get_client() — + # with multiple projects in-process it returns a disabled no-op client. self.langfuse = Langfuse( public_key=credentials["public_key"], secret_key=credentials["secret_key"], host=credentials["host"], - enabled=True, # This ensures the client is active + tracing_enabled=True, # This ensures the client is active ) except Exception as e: - logger.warning(f"[LangfuseTracer] Failed to initialize: {e}") + logger.warning( + f"[LangfuseTracer] Failed to initialize: {format_langfuse_error(e)}" + ) self._failed = True return if response_id: try: - traces = self.langfuse.fetch_traces(tags=response_id).data + traces = self.langfuse.api.trace.list(tags=response_id).data if traces: self.session_id = traces[0].session_id except Exception as e: - logger.debug(f"[LangfuseTracer] Session resume failed: {e}") + logger.debug( + f"[LangfuseTracer] Session resume failed: {format_langfuse_error(e)}" + ) logger.info( f"[LangfuseTracer] Tracing enabled | session_id={self.session_id}" @@ -98,7 +184,7 @@ def _langfuse_call(self, fn: Callable, *args: Any, **kwargs: Any) -> Any: return fn(*args, **kwargs) except Exception as e: logger.warning( - f"[LangfuseTracer] {getattr(fn, '__name__', 'operation')} failed: {e}" + f"[LangfuseTracer] {getattr(fn, '__name__', 'operation')} failed: {format_langfuse_error(e)}" ) self._failed = True return None @@ -107,7 +193,7 @@ def start_trace( self, name: str, input: Any, - metadata: Optional[dict[str, Any]] = None, + metadata: dict[str, Any] | None = None, tags: list[str] | None = None, ) -> None: if self._failed or not self.langfuse: @@ -115,26 +201,34 @@ def start_trace( metadata = metadata or {} metadata["request_id"] = correlation_id.get() or "N/A" self.trace = self._langfuse_call( - self.langfuse.trace, + self.langfuse.start_observation, + as_type="span", name=name, input=input, metadata=metadata, - session_id=self.session_id, - tags=tags, ) + if self.trace: + self._langfuse_call( + set_trace_attributes, + self.trace, + name=name, + input=input, + session_id=self.session_id, + tags=tags, + ) def start_generation( self, name: str, input: Any, - metadata: Optional[dict[str, Any]] = None, + metadata: dict[str, Any] | None = None, ) -> None: if self._failed or not self.trace: return self.generation = self._langfuse_call( - self.langfuse.generation, + self.trace.start_observation, + as_type="generation", name=name, - trace_id=self.trace.id, input=input, metadata=metadata or {}, ) @@ -142,31 +236,39 @@ def start_generation( def end_generation( self, output: dict[str, Any], - usage: Optional[dict[str, Any]] = None, - model: Optional[str] = None, + usage: dict[str, Any] | None = None, + model: str | None = None, ) -> None: if self._failed or not self.generation: return self._langfuse_call( - self.generation.end, output=output, usage=usage, model=model + self.generation.update, + output=output, + usage_details=_to_usage_details(usage), + model=model, ) + self._langfuse_call(self.generation.end) def update_trace(self, tags: list[str], output: dict[str, Any]) -> None: if self._failed or not self.trace: return - self._langfuse_call(self.trace.update, tags=tags, output=output) + self._langfuse_call(set_trace_attributes, self.trace, tags=tags, output=output) + self._langfuse_call(self.trace.end) - def log_error(self, error_message: str, response_id: Optional[str] = None) -> None: + def log_error(self, error_message: str, response_id: str | None = None) -> None: if self._failed: return if self.generation: - self._langfuse_call(self.generation.end, output={"error": error_message}) + self._langfuse_call(self.generation.update, output={"error": error_message}) + self._langfuse_call(self.generation.end) if self.trace: self._langfuse_call( - self.trace.update, - tags=[response_id] if response_id else [], + set_trace_attributes, + self.trace, + tags=[response_id] if response_id else None, output={"status": "failure", "error": error_message}, ) + self._langfuse_call(self.trace.end) def flush(self) -> None: if self._failed or not self.langfuse: @@ -203,6 +305,8 @@ def wrapper( return func(completion_config, query, **kwargs) try: + # See LangfuseTracer.__init__: explicit per-key client keeps this + # multi-tenant under v4's public_key-keyed client cache. langfuse = Langfuse( public_key=credentials.get("public_key"), secret_key=credentials.get("secret_key"), @@ -213,7 +317,7 @@ def wrapper( ) except Exception as e: logger.warning( - f"[observe_llm_execution] Failed to initialize client: {e}" + f"[observe_llm_execution] Failed to initialize client: {format_langfuse_error(e)}" ) return func(completion_config, query, **kwargs) @@ -227,22 +331,31 @@ def langfuse_call(fn, *args, **kwargs): return fn(*args, **kwargs) except Exception as e: logger.warning( - f"[observe_llm_execution] {getattr(fn, '__name__', 'operation')} failed: {e}" + f"[observe_llm_execution] {getattr(fn, '__name__', 'operation')} failed: {format_langfuse_error(e)}" ) failed = True return None trace = langfuse_call( - langfuse.trace, + langfuse.start_observation, + as_type="span", name="unified-llm-call", input=query.input, - tags=[completion_config.provider], ) + if trace: + langfuse_call( + set_trace_attributes, + trace, + name="unified-llm-call", + input=query.input, + tags=[completion_config.provider], + ) generation = None if trace: generation = langfuse_call( - trace.generation, + trace.start_observation, + as_type="generation", name=f"{completion_config.provider}-completion", input=query.input, model=completion_config.params.get("model"), @@ -255,7 +368,7 @@ def langfuse_call(fn, *args, **kwargs): if response: if generation: langfuse_call( - generation.end, + generation.update, output={ "status": "success", "output": extract_response_output(response.response.output), @@ -266,25 +379,31 @@ def langfuse_call(fn, *args, **kwargs): }, model=response.response.model, ) + langfuse_call(generation.end) if trace: langfuse_call( - trace.update, + set_trace_attributes, + trace, output={ "status": "success", "output": extract_response_output(response.response.output), }, session_id=session_id or response.response.conversation_id, ) + langfuse_call(trace.end) else: error_msg = error or "Unknown error" if generation: - langfuse_call(generation.end, output={"error": error_msg}) + langfuse_call(generation.update, output={"error": error_msg}) + langfuse_call(generation.end) if trace: langfuse_call( - trace.update, + set_trace_attributes, + trace, output={"status": "failure", "error": error_msg}, session_id=session_id, ) + langfuse_call(trace.end) langfuse_call(langfuse.flush) return response, error diff --git a/backend/app/crud/evaluations/langfuse.py b/backend/app/crud/evaluations/langfuse.py index ad257147f..134ba4c3c 100644 --- a/backend/app/crud/evaluations/langfuse.py +++ b/backend/app/crud/evaluations/langfuse.py @@ -14,6 +14,7 @@ from langfuse import Langfuse +from app.core.langfuse.langfuse import format_langfuse_error, set_trace_attributes from app.crud.evaluations.merge import compute_summary_scores from app.crud.evaluations.score import ( COSINE_SCORE_COMMENT, @@ -36,7 +37,7 @@ def _write_trace_score( ) -> None: """Write a single trace-level score to Langfuse. Failures propagate to the caller (no retry here).""" - langfuse.score( + langfuse.create_score( trace_id=trace_id, name=COSINE_SCORE_NAME, value=value, @@ -123,61 +124,78 @@ def create_langfuse_dataset_run( continue try: - with dataset_item.observe(run_name=run_name) as trace_id: - metadata = { - "ground_truth": ground_truth, - "item_id": item_id, + metadata = { + "ground_truth": ground_truth, + "item_id": item_id, + } + if response_id: + metadata["response_id"] = response_id + if question_id: + metadata["question_id"] = question_id + + item_metadata = getattr(dataset_item, "metadata", None) + if isinstance(item_metadata, dict): + item_category = item_metadata.get("category") + if item_category: + metadata["category"] = item_category + + # In v4 the root span is the trace. langfuse generates the trace id, + # which we read back to link the dataset run item below (v4 has no + # dataset_item.observe() context manager). + root = langfuse.start_observation( + as_type="span", + name="evaluation-item", + input={"question": question}, + ) + trace_id = root.trace_id + set_trace_attributes( + root, + name="evaluation-item", + input={"question": question}, + output={"answer": generated_output}, + metadata=metadata, + ) + + # Convert usage to v4 usage_details (int token counts only; v4 has + # no "unit" field). Cost tracking happens at the generation level. + usage = None + if usage_raw: + usage = { + "input": usage_raw.get("input_tokens", 0), + "output": usage_raw.get("output_tokens", 0), + "total": usage_raw.get("total_tokens", 0), } - if response_id: - metadata["response_id"] = response_id - if question_id: - metadata["question_id"] = question_id - - item_metadata = getattr(dataset_item, "metadata", None) - if isinstance(item_metadata, dict): - item_category = item_metadata.get("category") - if item_category: - metadata["category"] = item_category - - # Create trace with basic info - langfuse.trace( - id=trace_id, + + if usage and model: + generation = root.start_observation( + as_type="generation", + name="evaluation-response", input={"question": question}, - output={"answer": generated_output}, metadata=metadata, ) + generation.update( + output={"answer": generated_output}, + model=model, + usage_details=usage, + ) + generation.end() - # Convert usage to Langfuse format - usage = None - if usage_raw: - usage = { - "input": usage_raw.get("input_tokens", 0), - "output": usage_raw.get("output_tokens", 0), - "total": usage_raw.get("total_tokens", 0), - "unit": "TOKENS", - } + root.end() - # Create a generation within the trace for cost tracking - # Cost tracking happens at generation level, not trace level - if usage and model: - generation = langfuse.generation( - name="evaluation-response", - trace_id=trace_id, - input={"question": question}, - metadata=metadata, - ) - generation.end( - output={"answer": generated_output}, - model=model, - usage=usage, - ) + # Link the trace to the dataset item under this run (replaces the + # removed dataset_item.observe() context manager). + langfuse.api.dataset_run_items.create( + run_name=run_name, + dataset_item_id=item_id, + trace_id=trace_id, + ) - trace_id_mapping[item_id] = trace_id + trace_id_mapping[item_id] = trace_id except Exception as e: logger.error( f"[create_langfuse_dataset_run] Failed to create trace | " - f"item_id={item_id} | {e}", + f"item_id={item_id} | {format_langfuse_error(e)}", exc_info=True, ) continue @@ -193,7 +211,7 @@ def create_langfuse_dataset_run( except Exception as e: logger.error( f"[create_langfuse_dataset_run] Failed to create Langfuse dataset run | " - f"run_name={run_name} | {e}", + f"run_name={run_name} | {format_langfuse_error(e)}", exc_info=True, ) raise @@ -245,7 +263,7 @@ def update_traces_with_cosine_scores( except Exception as e: logger.error( f"[update_traces_with_cosine_scores] Failed to add score | " - f"trace_id={trace_id} | {e}", + f"trace_id={trace_id} | {format_langfuse_error(e)}", exc_info=True, ) failed_trace_ids.append(trace_id) @@ -310,7 +328,7 @@ def upload_item(item: dict[str, str], duplicate_num: int, question_id: str) -> b logger.warning( f"[upload_dataset_to_langfuse] Failed to upload item | " f"duplicate={duplicate_num + 1} | " - f"question={item['question'][:50]}... | {e}" + f"question={item['question'][:50]}... | {format_langfuse_error(e)}" ) return False @@ -357,7 +375,7 @@ def upload_item(item: dict[str, str], duplicate_num: int, question_id: str) -> b except Exception as e: logger.error( f"[upload_dataset_to_langfuse] Failed to upload dataset to Langfuse | " - f"dataset={dataset_name} | {e}", + f"dataset={dataset_name} | {format_langfuse_error(e)}", exc_info=True, ) raise @@ -432,7 +450,7 @@ def fetch_trace_scores_from_langfuse( except Exception as e: logger.warning( f"[fetch_trace_scores_from_langfuse] Run not found in Langfuse | " - f"dataset={dataset_name} | run={run_name} | error={e}" + f"dataset={dataset_name} | run={run_name} | error={format_langfuse_error(e)}" ) raise ValueError( f"Run '{run_name}' not found in Langfuse dataset '{dataset_name}'" @@ -463,7 +481,11 @@ def fetch_trace_scores_from_langfuse( def _fetch_single_trace(trace_id: str) -> TraceData | None: """Fetch a single trace from Langfuse and extract its data.""" - trace = langfuse.api.trace.get(trace_id) + # v4: request only the field groups we consume. Without `fields`, the + # API returns the full trace (every observation + metrics), which makes + # each per-trace read large enough to hit the client timeout. `core` is + # always included; we add `io` (input/output/metadata) and `scores`. + trace = langfuse.api.trace.get(trace_id, fields="core,io,scores") trace_data: TraceData = { "trace_id": trace_id, "question": "", @@ -550,7 +572,7 @@ def _fetch_single_trace(trace_id: str) -> TraceData | None: failed_trace_ids.append(trace_id) logger.warning( f"[fetch_trace_scores_from_langfuse] Failed to fetch trace | " - f"trace_id={trace_id} | error={e}" + f"trace_id={trace_id} | error={format_langfuse_error(e)}" ) if consecutive_failures >= max_consecutive_failures: @@ -606,7 +628,7 @@ def _fetch_single_trace(trace_id: str) -> TraceData | None: except Exception as e: logger.error( f"[fetch_trace_scores_from_langfuse] Failed to fetch trace scores | " - f"dataset={dataset_name} | run={run_name} | {e}", + f"dataset={dataset_name} | run={run_name} | {format_langfuse_error(e)}", exc_info=True, ) raise diff --git a/backend/app/services/response/response.py b/backend/app/services/response/response.py index 2c021649d..ecd8e6ac2 100644 --- a/backend/app/services/response/response.py +++ b/backend/app/services/response/response.py @@ -153,6 +153,11 @@ def generate_response( if tracer: tracer.log_error(error_message, response_id=request.response_id) + finally: + # Async path runs in a Celery worker; flush so spans aren't lost if the + # worker is killed before the OTel batch processor's timer fires. + tracer.flush() + return response, error_message diff --git a/backend/app/tests/core/test_langfuse/test_format_error.py b/backend/app/tests/core/test_langfuse/test_format_error.py new file mode 100644 index 000000000..13917d46e --- /dev/null +++ b/backend/app/tests/core/test_langfuse/test_format_error.py @@ -0,0 +1,58 @@ +"""Unit tests for format_langfuse_error. + +Langfuse 4.x's ``ApiError.__str__`` dumps the full HTTP response including every +response header. ``format_langfuse_error`` strips that noise so user-facing and +log messages only carry the actionable ``status_code`` and ``body`` (matching +the 2.x string format). +""" + +from langfuse.api import NotFoundError +from langfuse.api.core.api_error import ApiError + +from app.core.langfuse.langfuse import format_langfuse_error + +# The header dump is what bloats ApiError.__str__ in langfuse 4.x. +HEADERS = { + "date": "Fri, 05 Jun 2026 11:34:41 GMT", + "content-type": "application/json; charset=utf-8", + "etag": '"8eha70alug1r"', +} +BODY = {"message": "Dataset not found", "error": "LangfuseNotFoundError"} + + +def test_strips_headers_from_api_error(): + """The formatted message keeps status_code and body but drops headers.""" + exc = ApiError(status_code=404, headers=HEADERS, body=BODY) + + formatted = format_langfuse_error(exc) + + assert ( + formatted == "status_code: 404, body: {'message': 'Dataset not found', " + "'error': 'LangfuseNotFoundError'}" + ) + assert "headers:" not in formatted + assert "etag" not in formatted + + +def test_handles_not_found_error_subclass(): + """NotFoundError (an ApiError subclass, status 404) is also cleaned up.""" + exc = NotFoundError(body=BODY, headers=HEADERS) + + formatted = format_langfuse_error(exc) + + assert formatted.startswith("status_code: 404, body:") + assert "headers:" not in formatted + + +def test_raw_str_would_include_headers(): + """Guards the regression: the default __str__ does leak headers.""" + exc = ApiError(status_code=404, headers=HEADERS, body=BODY) + + assert "headers:" in str(exc) + assert "headers:" not in format_langfuse_error(exc) + + +def test_falls_back_to_str_for_non_api_exceptions(): + """Non-langfuse exceptions are returned via str() unchanged.""" + assert format_langfuse_error(ValueError("plain message")) == "plain message" + assert format_langfuse_error(RuntimeError("boom")) == "boom" diff --git a/backend/app/tests/core/test_langfuse/test_langfuse_tracer.py b/backend/app/tests/core/test_langfuse/test_langfuse_tracer.py index 020d17ffa..795ed616f 100644 --- a/backend/app/tests/core/test_langfuse/test_langfuse_tracer.py +++ b/backend/app/tests/core/test_langfuse/test_langfuse_tracer.py @@ -1,4 +1,4 @@ -"""Unit tests for LangfuseTracer class.""" +"""Unit tests for LangfuseTracer class (Langfuse v4 SDK).""" import pytest from unittest.mock import MagicMock, patch @@ -24,6 +24,24 @@ def _create(**overrides) -> dict: return _create() +def make_langfuse_mock() -> tuple[MagicMock, MagicMock, MagicMock]: + """Build a mock v4 Langfuse client plus its root span and generation. + + v4 flow: ``client.start_observation(as_type="span")`` -> root span; + ``root.start_observation(as_type="generation")`` -> generation. Trace-level + attributes are written via ``root._otel_span.set_attributes(...)`` (auto-mocked). + """ + client = MagicMock() + root_span = MagicMock() + root_span.trace_id = "trace-abc123" + generation = MagicMock() + root_span.start_observation.return_value = generation + client.start_observation.return_value = root_span + # No prior traces by default (session resume returns empty) + client.api.trace.list.return_value.data = [] + return client, root_span, generation + + @pytest.fixture def assistant_mock() -> Assistant: def _create(**overrides) -> Assistant: @@ -82,18 +100,16 @@ def test_langfuse_exception_sets_langfuse_to_none( assert tracer.langfuse is None @patch("app.core.langfuse.langfuse.Langfuse") - def test_fetch_traces_failure_keeps_tracer_enabled( + def test_session_resume_failure_keeps_tracer_enabled( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.fetch_traces.side_effect = Exception("Network error") - mock_langfuse_class.return_value = enabled_mock + client, _, _ = make_langfuse_mock() + client.api.trace.list.side_effect = Exception("Network error") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials, response_id="resp-123") assert tracer.langfuse is not None - assert tracer.langfuse.enabled is True @patch("app.core.langfuse.langfuse.Langfuse") def test_resumes_session_from_existing_traces( @@ -102,14 +118,14 @@ def test_resumes_session_from_existing_traces( existing_trace = MagicMock() existing_trace.session_id = "existing-session-456" - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.fetch_traces.return_value.data = [existing_trace] - mock_langfuse_class.return_value = enabled_mock + client, _, _ = make_langfuse_mock() + client.api.trace.list.return_value.data = [existing_trace] + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials, response_id="resp-123") assert tracer.session_id == "existing-session-456" + client.api.trace.list.assert_called_once_with(tags="resp-123") class TestLangfuseTracerMethodsDisabled: @@ -149,10 +165,9 @@ class TestLangfuseTracerMethodsFailure: def test_start_trace_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.side_effect = Exception("Trace creation failed") - mock_langfuse_class.return_value = enabled_mock + client, _, _ = make_langfuse_mock() + client.start_observation.side_effect = Exception("Trace creation failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -163,12 +178,9 @@ def test_start_trace_exception_is_caught( def test_start_generation_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.side_effect = Exception("Generation failed") - mock_langfuse_class.return_value = enabled_mock + client, root_span, _ = make_langfuse_mock() + root_span.start_observation.side_effect = Exception("Generation failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -180,15 +192,9 @@ def test_start_generation_exception_is_caught( def test_end_generation_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_generation = MagicMock() - mock_generation.end.side_effect = Exception("End failed") - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.return_value = mock_generation - mock_langfuse_class.return_value = enabled_mock + client, _, generation = make_langfuse_mock() + generation.update.side_effect = Exception("End failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -199,13 +205,9 @@ def test_end_generation_exception_is_caught( def test_update_trace_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_trace.update.side_effect = Exception("Update failed") - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - mock_langfuse_class.return_value = enabled_mock + client, root_span, _ = make_langfuse_mock() + root_span.end.side_effect = Exception("Update failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -215,10 +217,9 @@ def test_update_trace_exception_is_caught( def test_flush_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.flush.side_effect = Exception("Flush failed") - mock_langfuse_class.return_value = enabled_mock + client, _, _ = make_langfuse_mock() + client.flush.side_effect = Exception("Flush failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.flush() @@ -227,16 +228,10 @@ def test_flush_exception_is_caught( def test_log_error_exception_is_caught( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_trace.update.side_effect = Exception("Log error failed") - mock_generation = MagicMock() - mock_generation.end.side_effect = Exception("End failed") - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.return_value = mock_generation - mock_langfuse_class.return_value = enabled_mock + client, root_span, generation = make_langfuse_mock() + generation.update.side_effect = Exception("End failed") + root_span.end.side_effect = Exception("Log error failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -311,14 +306,8 @@ class TestLangfuseTracerSuccess: def test_full_tracing_flow( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_generation = MagicMock() - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.return_value = mock_generation - mock_langfuse_class.return_value = enabled_mock + client, root_span, generation = make_langfuse_mock() + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -331,20 +320,41 @@ def test_full_tracing_flow( tracer.update_trace(tags=["resp-123"], output={"status": "success"}) tracer.flush() - enabled_mock.trace.assert_called_once() - enabled_mock.generation.assert_called_once() - mock_generation.end.assert_called_once() - mock_trace.update.assert_called_once() - enabled_mock.flush.assert_called_once() + client.start_observation.assert_called_once() + root_span.start_observation.assert_called_once() + generation.update.assert_called_once() + generation.end.assert_called_once() + root_span.end.assert_called_once() + client.flush.assert_called_once() + + @patch("app.core.langfuse.langfuse.Langfuse") + def test_end_generation_converts_usage_to_usage_details( + self, mock_langfuse_class: MagicMock, valid_credentials: dict + ) -> None: + client, _, generation = make_langfuse_mock() + mock_langfuse_class.return_value = client + + tracer = LangfuseTracer(credentials=valid_credentials) + tracer.start_trace(name="test", input={"q": "hello"}) + tracer.start_generation(name="gen", input={"q": "hello"}) + tracer.end_generation( + output={"response": "world"}, + usage={"input": 10, "output": 20, "total": 30, "unit": "TOKENS"}, + model="gpt-4", + ) + + # v4 uses usage_details (int-only) and drops the v2 "unit" string field + _, kwargs = generation.update.call_args + assert kwargs["usage_details"] == {"input": 10, "output": 20, "total": 30} + assert kwargs["model"] == "gpt-4" @patch("app.core.langfuse.langfuse.Langfuse") def test_start_generation_without_trace_is_noop( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.side_effect = Exception("Trace failed") - mock_langfuse_class.return_value = enabled_mock + client, _, _ = make_langfuse_mock() + client.start_observation.side_effect = Exception("Trace failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) # Fails @@ -352,19 +362,14 @@ def test_start_generation_without_trace_is_noop( tracer.start_generation(name="gen", input={"q": "hello"}) assert tracer.generation is None - enabled_mock.generation.assert_not_called() @patch("app.core.langfuse.langfuse.Langfuse") def test_end_generation_without_generation_is_noop( self, mock_langfuse_class: MagicMock, valid_credentials: dict ) -> None: - mock_trace = MagicMock(id="trace-123") - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.side_effect = Exception("Generation failed") - mock_langfuse_class.return_value = enabled_mock + client, root_span, _ = make_langfuse_mock() + root_span.start_observation.side_effect = Exception("Generation failed") + mock_langfuse_class.return_value = client tracer = LangfuseTracer(credentials=valid_credentials) tracer.start_trace(name="test", input={"q": "hello"}) @@ -384,14 +389,8 @@ def test_openai_error_still_calls_tracer( valid_credentials: dict, assistant_mock: Assistant, ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_generation = MagicMock() - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.return_value = mock_generation - mock_langfuse_class.return_value = enabled_mock + client, root_span, generation = make_langfuse_mock() + mock_langfuse_class.return_value = client mock_client = MagicMock() mock_client.responses.create.side_effect = OpenAIError("API failed") @@ -411,8 +410,8 @@ def test_openai_error_still_calls_tracer( assert response is None assert "API failed" in error - enabled_mock.trace.assert_called_once() - enabled_mock.generation.assert_called_once() + client.start_observation.assert_called_once() + root_span.start_observation.assert_called_once() @patch("app.core.langfuse.langfuse.Langfuse") def test_success_calls_all_tracer_methods( @@ -421,14 +420,8 @@ def test_success_calls_all_tracer_methods( valid_credentials: dict, assistant_mock: Assistant, ) -> None: - mock_trace = MagicMock(id="trace-123") - mock_generation = MagicMock() - - enabled_mock = MagicMock() - enabled_mock.enabled = True - enabled_mock.trace.return_value = mock_trace - enabled_mock.generation.return_value = mock_generation - mock_langfuse_class.return_value = enabled_mock + client, root_span, generation = make_langfuse_mock() + mock_langfuse_class.return_value = client mock_response = MagicMock() mock_response.id = "resp-456" @@ -456,10 +449,11 @@ def test_success_calls_all_tracer_methods( assert response is not None assert error is None - enabled_mock.trace.assert_called_once() - enabled_mock.generation.assert_called_once() - mock_generation.end.assert_called_once() - mock_trace.update.assert_called_once() + client.start_observation.assert_called_once() + root_span.start_observation.assert_called_once() + generation.update.assert_called_once() + generation.end.assert_called_once() + root_span.end.assert_called_once() class TestProcessResponseIntegration: diff --git a/backend/app/tests/crud/evaluations/test_langfuse.py b/backend/app/tests/crud/evaluations/test_langfuse.py index 0ff45873f..0f6e09e6b 100644 --- a/backend/app/tests/crud/evaluations/test_langfuse.py +++ b/backend/app/tests/crud/evaluations/test_langfuse.py @@ -1,5 +1,5 @@ from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -11,24 +11,45 @@ ) +def make_dataset_run_mocks( + trace_ids: list[str], +) -> tuple[MagicMock, list[MagicMock], list[MagicMock]]: + """Build a mock v4 Langfuse client for create_langfuse_dataset_run. + + v4 flow: ``langfuse.start_observation(as_type="span")`` -> root span (trace), + ``root.start_observation(as_type="generation")`` -> generation for cost tracking, + then ``langfuse.api.dataset_run_items.create(...)`` links the trace to the run. + Dataset items are named ``item_1..item_N`` to match the test result item_ids. + """ + langfuse = MagicMock() + dataset = MagicMock() + items = [] + roots = [] + gens = [] + for i, trace_id in enumerate(trace_ids, start=1): + item = MagicMock() + item.id = f"item_{i}" + items.append(item) + + root = MagicMock() + root.trace_id = trace_id + gen = MagicMock() + root.start_observation.return_value = gen + roots.append(root) + gens.append(gen) + + dataset.items = items + langfuse.get_dataset.return_value = dataset + langfuse.start_observation.side_effect = roots + return langfuse, roots, gens + + class TestCreateLangfuseDatasetRun: """Test creating Langfuse dataset runs.""" def test_create_langfuse_dataset_run_success(self) -> None: """Test successfully creating a dataset run with traces.""" - mock_langfuse = MagicMock() - mock_dataset = MagicMock() - - mock_item1 = MagicMock() - mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" - - mock_item2 = MagicMock() - mock_item2.id = "item_2" - mock_item2.observe.return_value.__enter__.return_value = "trace_id_2" - - mock_dataset.items = [mock_item1, mock_item2] - mock_langfuse.get_dataset.return_value = mock_dataset + mock_langfuse, _, _ = make_dataset_run_mocks(["trace_id_1", "trace_id_2"]) results = [ { @@ -70,7 +91,13 @@ def test_create_langfuse_dataset_run_success(self) -> None: mock_langfuse.get_dataset.assert_called_once_with("test_dataset") mock_langfuse.flush.assert_called_once() - assert mock_langfuse.trace.call_count == 2 + # One root span (trace) per item, each linked to the run via the v4 API + assert mock_langfuse.start_observation.call_count == 2 + assert mock_langfuse.api.dataset_run_items.create.call_count == 2 + first_link = mock_langfuse.api.dataset_run_items.create.call_args_list[0] + assert first_link.kwargs["run_name"] == "test_run" + assert first_link.kwargs["dataset_item_id"] == "item_1" + assert first_link.kwargs["trace_id"] == "trace_id_1" def test_create_langfuse_dataset_run_skips_missing_items(self) -> None: """Test that missing dataset items are skipped.""" @@ -79,7 +106,6 @@ def test_create_langfuse_dataset_run_skips_missing_items(self) -> None: mock_item1 = MagicMock() mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" mock_dataset.items = [mock_item1] mock_langfuse.get_dataset.return_value = mock_dataset @@ -124,19 +150,12 @@ def test_create_langfuse_dataset_run_skips_missing_items(self) -> None: def test_create_langfuse_dataset_run_handles_trace_error(self) -> None: """Test that trace creation errors are handled gracefully.""" - mock_langfuse = MagicMock() - mock_dataset = MagicMock() - - mock_item1 = MagicMock() - mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" - - mock_item2 = MagicMock() - mock_item2.id = "item_2" - mock_item2.observe.side_effect = Exception("Trace creation failed") - - mock_dataset.items = [mock_item1, mock_item2] - mock_langfuse.get_dataset.return_value = mock_dataset + mock_langfuse, roots, _ = make_dataset_run_mocks(["trace_id_1", "trace_id_2"]) + # Second item's root span creation fails + mock_langfuse.start_observation.side_effect = [ + roots[0], + Exception("Trace creation failed"), + ] results = [ { @@ -194,22 +213,10 @@ def test_create_langfuse_dataset_run_empty_results(self) -> None: mock_langfuse.flush.assert_called_once() def test_create_langfuse_dataset_run_with_cost_tracking(self) -> None: - """Test that generation() is called with usage when model and usage are provided.""" - mock_langfuse = MagicMock() - mock_dataset = MagicMock() - mock_generation = MagicMock() - - mock_item1 = MagicMock() - mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" - - mock_item2 = MagicMock() - mock_item2.id = "item_2" - mock_item2.observe.return_value.__enter__.return_value = "trace_id_2" - - mock_dataset.items = [mock_item1, mock_item2] - mock_langfuse.get_dataset.return_value = mock_dataset - mock_langfuse.generation.return_value = mock_generation + """Test that a generation is created with usage when model and usage are provided.""" + mock_langfuse, roots, gens = make_dataset_run_mocks( + ["trace_id_1", "trace_id_2"] + ) results = [ { @@ -250,44 +257,37 @@ def test_create_langfuse_dataset_run_with_cost_tracking(self) -> None: assert trace_id_mapping["item_1"] == "trace_id_1" assert trace_id_mapping["item_2"] == "trace_id_2" - assert mock_langfuse.generation.call_count == 2 - - first_call = mock_langfuse.generation.call_args_list[0] - assert first_call.kwargs["name"] == "evaluation-response" - assert first_call.kwargs["trace_id"] == "trace_id_1" - assert first_call.kwargs["input"] == {"question": "What is 2+2?"} - assert first_call.kwargs["metadata"]["ground_truth"] == "4" - assert first_call.kwargs["metadata"]["response_id"] == "resp_123" - - assert mock_generation.end.call_count == 2 - - first_end_call = mock_generation.end.call_args_list[0] - assert first_end_call.kwargs["output"] == {"answer": "The answer is 4"} - assert first_end_call.kwargs["model"] == "gpt-4o" - assert first_end_call.kwargs["usage"] == { + # Each root span spawns one generation child for cost tracking + roots[0].start_observation.assert_called_once() + gen_call = roots[0].start_observation.call_args + assert gen_call.kwargs["as_type"] == "generation" + assert gen_call.kwargs["name"] == "evaluation-response" + assert gen_call.kwargs["input"] == {"question": "What is 2+2?"} + assert gen_call.kwargs["metadata"]["ground_truth"] == "4" + assert gen_call.kwargs["metadata"]["response_id"] == "resp_123" + + # v4: generation.update(...) then end(); usage_details is int-only (no "unit") + gens[0].update.assert_called_once() + update_call = gens[0].update.call_args + assert update_call.kwargs["output"] == {"answer": "The answer is 4"} + assert update_call.kwargs["model"] == "gpt-4o" + assert update_call.kwargs["usage_details"] == { "input": 69, "output": 258, "total": 327, - "unit": "TOKENS", } + gens[0].end.assert_called_once() mock_langfuse.get_dataset.assert_called_once_with("test_dataset") mock_langfuse.flush.assert_called_once() - assert mock_langfuse.trace.call_count == 2 + assert mock_langfuse.start_observation.call_count == 2 - def test_create_langfuse_dataset_run_with_question_id(self) -> None: - """Test that question_id is included in trace metadata.""" - mock_langfuse = MagicMock() - mock_dataset = MagicMock() - mock_generation = MagicMock() - - mock_item1 = MagicMock() - mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" - - mock_dataset.items = [mock_item1] - mock_langfuse.get_dataset.return_value = mock_dataset - mock_langfuse.generation.return_value = mock_generation + @patch("app.crud.evaluations.langfuse.set_trace_attributes") + def test_create_langfuse_dataset_run_with_question_id( + self, mock_set_trace_attributes: MagicMock + ) -> None: + """Test that question_id is included in trace and generation metadata.""" + mock_langfuse, roots, _ = make_dataset_run_mocks(["trace_id_1"]) results = [ { @@ -315,25 +315,20 @@ def test_create_langfuse_dataset_run_with_question_id(self) -> None: assert len(trace_id_mapping) == 1 - # Verify trace was called with question_id in metadata - trace_call = mock_langfuse.trace.call_args - assert trace_call.kwargs["metadata"]["question_id"] == 1 + # Verify trace-level metadata carries question_id via set_trace_attributes + trace_attr_call = mock_set_trace_attributes.call_args + assert trace_attr_call.kwargs["metadata"]["question_id"] == 1 - # Verify generation was called with question_id in metadata - generation_call = mock_langfuse.generation.call_args + # Verify the generation was also created with question_id in metadata + generation_call = roots[0].start_observation.call_args assert generation_call.kwargs["metadata"]["question_id"] == 1 - def test_create_langfuse_dataset_run_without_question_id(self) -> None: + @patch("app.crud.evaluations.langfuse.set_trace_attributes") + def test_create_langfuse_dataset_run_without_question_id( + self, mock_set_trace_attributes: MagicMock + ) -> None: """Test that traces work without question_id (backwards compatibility).""" - mock_langfuse = MagicMock() - mock_dataset = MagicMock() - - mock_item1 = MagicMock() - mock_item1.id = "item_1" - mock_item1.observe.return_value.__enter__.return_value = "trace_id_1" - - mock_dataset.items = [mock_item1] - mock_langfuse.get_dataset.return_value = mock_dataset + mock_langfuse, _, _ = make_dataset_run_mocks(["trace_id_1"]) # Results without question_id results = [ @@ -356,9 +351,9 @@ def test_create_langfuse_dataset_run_without_question_id(self) -> None: assert len(trace_id_mapping) == 1 - # Verify trace was called without question_id in metadata - trace_call = mock_langfuse.trace.call_args - assert "question_id" not in trace_call.kwargs["metadata"] + # Verify trace-level metadata has no question_id + trace_attr_call = mock_set_trace_attributes.call_args + assert "question_id" not in trace_attr_call.kwargs["metadata"] class TestUpdateTracesWithCosineScores: @@ -379,9 +374,9 @@ def test_update_traces_with_cosine_scores_success(self) -> None: ) assert failed == [] - assert mock_langfuse.score.call_count == 3 + assert mock_langfuse.create_score.call_count == 3 - calls = mock_langfuse.score.call_args_list + calls = mock_langfuse.create_score.call_args_list assert calls[0].kwargs["trace_id"] == "trace_1" assert calls[0].kwargs["name"] == "Cosine Similarity" assert calls[0].kwargs["value"] == 0.95 @@ -406,7 +401,7 @@ def test_update_traces_with_cosine_scores_unscoreable(self) -> None: ) assert failed == [] - calls = mock_langfuse.score.call_args_list + calls = mock_langfuse.create_score.call_args_list assert calls[1].kwargs["trace_id"] == "trace_2" assert calls[1].kwargs["value"] == 0 assert calls[1].kwargs["comment"] == "Cannot compute: empty_output" @@ -426,7 +421,7 @@ def test_update_traces_with_cosine_scores_missing_trace_id(self) -> None: ) assert failed == [] - assert mock_langfuse.score.call_count == 2 + assert mock_langfuse.create_score.call_count == 2 def test_update_traces_with_cosine_scores_reports_failure( self, @@ -439,7 +434,7 @@ def score_side_effect(*args: Any, **kwargs: Any) -> None: if kwargs.get("trace_id") == "trace_2": raise Exception("Score failed") - mock_langfuse.score.side_effect = score_side_effect + mock_langfuse.create_score.side_effect = score_side_effect per_item_scores = [ {"trace_id": "trace_1", "cosine_similarity": 0.95}, @@ -454,7 +449,7 @@ def score_side_effect(*args: Any, **kwargs: Any) -> None: # Only the failing trace is reported. assert failed == ["trace_2"] # No retries: one score call per trace. - assert mock_langfuse.score.call_count == 3 + assert mock_langfuse.create_score.call_count == 3 mock_langfuse.flush.assert_called_once() def test_update_traces_with_cosine_scores_empty_list(self) -> None: @@ -466,7 +461,7 @@ def test_update_traces_with_cosine_scores_empty_list(self) -> None: ) assert failed == [] - mock_langfuse.score.assert_not_called() + mock_langfuse.create_score.assert_not_called() mock_langfuse.flush.assert_called_once() @@ -855,6 +850,11 @@ def test_fetch_trace_scores_category_set_only_when_metadata_has_one(self) -> Non assert traces_by_id["trace_with_cat"]["category"] == "Health" assert "category" not in traces_by_id["trace_no_cat"] + # v4: trace.get must request only core,io,scores to avoid fetching the + # full trace (all observations/metrics) which would time out in production. + _, kwargs = mock_langfuse.api.trace.get.call_args + assert kwargs.get("fields") == "core,io,scores" + def test_fetch_trace_scores_with_categorical_scores(self) -> None: """Test fetching traces with categorical scores.""" mock_langfuse = MagicMock() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1897974b5..707add32e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "openai>=1.100.1", "pre-commit>=3.8.0", "openai_responses", - "langfuse==2.60.3", + "langfuse==4.7.1", "asgi-correlation-id>=4.3.4", "py-zerox>=0.0.7,<1.0.0", "pandas>=2.3.2", diff --git a/backend/uv.lock b/backend/uv.lock index a1bf28890..bc171bdb9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -338,7 +338,7 @@ requires-dist = [ { name = "indic-nlp-library", specifier = ">=0.92" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, { name = "jiwer", specifier = ">=3.1.0" }, - { name = "langfuse", specifier = "==2.60.3" }, + { name = "langfuse", specifier = "==4.7.1" }, { name = "litellm", specifier = ">=1.83.10" }, { name = "moto", extras = ["s3"], specifier = ">=5.1.1" }, { name = "numpy", specifier = ">=1.24.0" }, @@ -1866,21 +1866,21 @@ wheels = [ [[package]] name = "langfuse" -version = "2.60.3" +version = "4.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, { name = "backoff" }, { name = "httpx" }, - { name = "idna" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, - { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/7a/a998b48823a609af8f5096cb322a4ddfded01d565509cd6b511a2e5891ca/langfuse-2.60.3.tar.gz", hash = "sha256:171c0caf07a26282bd0403c6c15886ef1f447def42d6570684c94d6d9ae61d6e", size = 152467, upload-time = "2025-04-15T17:01:15.973Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/74/a6f1a99893ee6d1a69439ae7eb92f8fe8806103492dc26531d5942dbd3bf/langfuse-4.7.1.tar.gz", hash = "sha256:f9e262eceedb353b191c1da1f8452d1e8ebf52297ca20e160cda0206608e3a40", size = 320620, upload-time = "2026-05-29T18:06:22.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/6b/4d3bdea30ceb3e4cf3ac1a2f104ffc20b6caa636549874262b2fa8cedaec/langfuse-2.60.3-py3-none-any.whl", hash = "sha256:2b866c44f24d5f06b617d7f14f75a2e42577538b530e4e26dc6ad770d6d1399e", size = 275008, upload-time = "2025-04-15T17:01:13.799Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9a/bd3368f46b6c72ee2068b80536826b02ae86df53eff1c79941344503098f/langfuse-4.7.1-py3-none-any.whl", hash = "sha256:a4e59c81ad5e5b16a65d3849f4923ebc3ad6e67ec803ada83d50c0cb66149490", size = 562571, upload-time = "2026-05-29T18:06:20.517Z" }, ] [[package]] @@ -2455,6 +2455,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/28/e8eca94966fe9a1465f6094dc5ddc5398473682180279c94020bc23b4906/opentelemetry_exporter_otlp_proto_common-1.41.0.tar.gz", hash = "sha256:966bbce537e9edb166154779a7c4f8ab6b8654a03a28024aeaf1a3eacb07d6ee", size = 20411, upload-time = "2026-04-09T14:38:36.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/c4/78b9bf2d9c1d5e494f44932988d9d91c51a66b9a7b48adf99b62f7c65318/opentelemetry_exporter_otlp_proto_common-1.41.0-py3-none-any.whl", hash = "sha256:7a99177bf61f85f4f9ed2072f54d676364719c066f6d11f515acc6c745c7acf0", size = 18366, upload-time = "2026-04-09T14:38:15.135Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/63/d9f43cd75f3fabb7e01148c89cfa9491fc18f6580a6764c554ff7c953c46/opentelemetry_exporter_otlp_proto_http-1.41.0.tar.gz", hash = "sha256:dcd6e0686f56277db4eecbadd5262124e8f2cc739cadbc3fae3d08a12c976cf5", size = 24139, upload-time = "2026-04-09T14:38:38.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b5/a214cd907eedc17699d1c2d602288ae17cb775526df04db3a3b3585329d2/opentelemetry_exporter_otlp_proto_http-1.41.0-py3-none-any.whl", hash = "sha256:a9c4ee69cce9c3f4d7ee736ad1b44e3c9654002c0816900abbafd9f3cf289751", size = 22673, upload-time = "2026-04-09T14:38:18.349Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.62b0" @@ -2560,6 +2590,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/54/a73d688d969283f344de96c43366912addb94bdbef413bcebac22979545e/opentelemetry_instrumentation_requests-0.62b0-py3-none-any.whl", hash = "sha256:edf61785ecb3ec6923e33c24074c82067f286a418f817b2b82546956d120e6d6", size = 14209, upload-time = "2026-04-09T14:40:02.987Z" }, ] +[[package]] +name = "opentelemetry-proto" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/d9/08e3dc6156878713e8c811682bc76151f5fe1a3cb7f3abda3966fd56e71e/opentelemetry_proto-1.41.0.tar.gz", hash = "sha256:95d2e576f9fb1800473a3e4cfcca054295d06bdb869fda4dc9f4f779dc68f7b6", size = 45669, upload-time = "2026-04-09T14:38:45.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/8c/65ef7a9383a363864772022e822b5d5c6988e6f9dabeebb9278f5b86ebc3/opentelemetry_proto-1.41.0-py3-none-any.whl", hash = "sha256:b970ab537309f9eed296be482c3e7cca05d8aca8165346e929f658dbe153b247", size = 72074, upload-time = "2026-04-09T14:38:29.38Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.41.0" @@ -2930,17 +2972,17 @@ wheels = [ [[package]] name = "protobuf" -version = "7.35.0" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, - { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, - { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, - { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, - { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]]