From f7e6f30baf0c9f73b3af5d5291ec29767ed1121e Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Tue, 30 Jun 2026 09:39:21 +0530 Subject: [PATCH 1/3] chore: upgrade Langfuse SDK to 4.7.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Langfuse integration from the v2 SDK (2.60.3) to the OTel-based v4 SDK (4.7.1). Scope is the SDK upgrade only; evaluation feature work (score-fetching rewrite, dataset dedup, sample-index fan-out) is deferred to a separate PR. - core/langfuse: rewrite tracer + observe_llm_execution for v4 — explicit per-key clients (multi-tenant), start_observation/LangfuseSpan/ LangfuseGeneration, usage_details, set_trace_attributes via OTel keys, format_langfuse_error for concise ApiError logs. - crud/evaluations/langfuse: port the v2 calls that v4 removed — dataset_item.observe()/trace()/generation() -> start_observation + set_trace_attributes + dataset_run_items.create; langfuse.score -> create_score; trace.get(fields="core,io,scores") to avoid full-trace timeouts. - tests: v4-adapt tracer + crud langfuse suites; add format_error tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/core/langfuse/langfuse.py | 185 +++++++++++++--- backend/app/crud/evaluations/langfuse.py | 112 ++++++---- .../core/test_langfuse/test_format_error.py | 58 +++++ .../test_langfuse/test_langfuse_tracer.py | 190 ++++++++--------- .../tests/crud/evaluations/test_langfuse.py | 200 +++++++++--------- backend/pyproject.toml | 2 +- backend/uv.lock | 76 +++++-- 7 files changed, 527 insertions(+), 296 deletions(-) create mode 100644 backend/app/tests/core/test_langfuse/test_format_error.py diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index 49963a61a..18474d20d 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,11 +145,14 @@ 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}") @@ -79,7 +161,7 @@ def __init__( 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: @@ -107,7 +189,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 +197,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 +232,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 +301,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"), @@ -233,16 +333,25 @@ def langfuse_call(fn, *args, **kwargs): 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 +364,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 +375,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..f4d75b4ff 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 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,56 +124,73 @@ 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( @@ -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": "", 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]] From 8c43b23efed93efb797b1767f9fc2f661ca74b8e Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Tue, 30 Jun 2026 10:10:35 +0530 Subject: [PATCH 2/3] chore: route Langfuse error logs through format_langfuse_error The format_langfuse_error helper was defined in the SDK v4 upgrade but had no production callers, so Langfuse exception logs still rendered the full ApiError string (every HTTP response header). Wire the helper into all Langfuse-related except blocks across the tracer, observe_llm_execution, and the evaluations CRUD so logs keep only status_code and body. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/core/langfuse/langfuse.py | 14 +++++++++----- backend/app/crud/evaluations/langfuse.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index 18474d20d..15b6ae4b0 100644 --- a/backend/app/core/langfuse/langfuse.py +++ b/backend/app/core/langfuse/langfuse.py @@ -155,7 +155,9 @@ def __init__( 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 @@ -165,7 +167,9 @@ def __init__( 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}" @@ -180,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 @@ -313,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) @@ -327,7 +331,7 @@ 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 diff --git a/backend/app/crud/evaluations/langfuse.py b/backend/app/crud/evaluations/langfuse.py index f4d75b4ff..134ba4c3c 100644 --- a/backend/app/crud/evaluations/langfuse.py +++ b/backend/app/crud/evaluations/langfuse.py @@ -14,7 +14,7 @@ from langfuse import Langfuse -from app.core.langfuse.langfuse import set_trace_attributes +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, @@ -195,7 +195,7 @@ def create_langfuse_dataset_run( 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 @@ -211,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 @@ -263,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) @@ -328,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 @@ -375,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 @@ -450,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}'" @@ -572,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: @@ -628,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 From edf1397b3455df53d8f9803dcf60ad1db05b9bb5 Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Tue, 30 Jun 2026 12:34:14 +0530 Subject: [PATCH 3/3] fixing async traces issues --- backend/app/services/response/response.py | 5 +++++ 1 file changed, 5 insertions(+) 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