diff --git a/backend/app/core/langfuse/langfuse.py b/backend/app/core/langfuse/langfuse.py index 15b6ae4b0..7d43c4bac 100644 --- a/backend/app/core/langfuse/langfuse.py +++ b/backend/app/core/langfuse/langfuse.py @@ -1,5 +1,6 @@ import json import logging +import threading import uuid from collections.abc import Callable from functools import wraps @@ -9,6 +10,7 @@ from langfuse import Langfuse, LangfuseOtelSpanAttributes from langfuse._client.span import LangfuseGeneration, LangfuseSpan from langfuse.api.core.api_error import ApiError +from opentelemetry.sdk.trace import TracerProvider from app.models.llm import ( AudioOutput, @@ -20,6 +22,25 @@ logger = logging.getLogger(__name__) +_LANGFUSE_TRACER_PROVIDER: TracerProvider | None = None +_LANGFUSE_TRACER_PROVIDER_LOCK = threading.Lock() + + +def get_langfuse_tracer_provider() -> TracerProvider: + """Isolated OTel provider for Langfuse so LLM spans never reach Sentry. + + Sentry's SpanProcessor sits on the global provider and exports every span + with no per-project filter; sharing it surfaces each Langfuse span in Sentry + as a duplicate root trace. Langfuse's own processors filter by public_key, so + one shared isolated provider stays multi-tenant safe. + """ + global _LANGFUSE_TRACER_PROVIDER + if _LANGFUSE_TRACER_PROVIDER is None: + with _LANGFUSE_TRACER_PROVIDER_LOCK: + if _LANGFUSE_TRACER_PROVIDER is None: + _LANGFUSE_TRACER_PROVIDER = TracerProvider() + return _LANGFUSE_TRACER_PROVIDER + def format_langfuse_error(exc: Exception) -> str: """Return a concise message for a Langfuse SDK exception. @@ -153,6 +174,7 @@ def __init__( secret_key=credentials["secret_key"], host=credentials["host"], tracing_enabled=True, # This ensures the client is active + tracer_provider=get_langfuse_tracer_provider(), ) except Exception as e: logger.warning( @@ -311,6 +333,7 @@ def wrapper( public_key=credentials.get("public_key"), secret_key=credentials.get("secret_key"), host=credentials.get("host"), + tracer_provider=get_langfuse_tracer_provider(), ) logger.info( f"[observe_llm_execution] Tracing enabled | session_id={session_id or 'auto'}" diff --git a/backend/app/tests/core/test_langfuse/test_tracer_provider.py b/backend/app/tests/core/test_langfuse/test_tracer_provider.py new file mode 100644 index 000000000..3ad3c1de3 --- /dev/null +++ b/backend/app/tests/core/test_langfuse/test_tracer_provider.py @@ -0,0 +1,22 @@ +"""Unit tests for get_langfuse_tracer_provider. + +Langfuse spans must not land on the global OTel provider — Sentry's SpanProcessor +lives there and re-exports every span as a duplicate root trace. +""" + +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider + +from app.core.langfuse.langfuse import get_langfuse_tracer_provider + + +def test_returns_sdk_tracer_provider() -> None: + assert isinstance(get_langfuse_tracer_provider(), TracerProvider) + + +def test_is_cached_across_calls() -> None: + assert get_langfuse_tracer_provider() is get_langfuse_tracer_provider() + + +def test_is_not_the_global_provider() -> None: + assert get_langfuse_tracer_provider() is not trace.get_tracer_provider() diff --git a/backend/app/tests/test_utils.py b/backend/app/tests/test_utils.py index df08ce733..7aac18032 100644 --- a/backend/app/tests/test_utils.py +++ b/backend/app/tests/test_utils.py @@ -15,8 +15,10 @@ from app.utils import ( APIResponse, ValidationErrorDetail, + _build_langfuse_client, download_audio_bytes, generate_eval_completion_email, + get_langfuse_client, handle_openai_error, mask_string, require_organization_for_project, @@ -459,3 +461,72 @@ def test_no_error_omits_error_block(self) -> None: ) # The error block is only rendered when error_message is truthy assert "Error:" not in data.html_content + + +# --------------------------------------------------------------------------- +# Langfuse client +# --------------------------------------------------------------------------- +LANGFUSE_CREDENTIALS = { + "public_key": "pk-lf-test", + "secret_key": "sk-lf-test", + "host": "https://cloud.langfuse.com", +} + + +class TestBuildLangfuseClient: + @patch("app.utils.Langfuse") + def test_builds_client_on_isolated_tracer_provider(self, mock_langfuse) -> None: + from app.core.langfuse.langfuse import get_langfuse_tracer_provider + + client = _build_langfuse_client(LANGFUSE_CREDENTIALS) + + assert client is mock_langfuse.return_value + kwargs = mock_langfuse.call_args.kwargs + assert kwargs["public_key"] == "pk-lf-test" + assert kwargs["secret_key"] == "sk-lf-test" + assert kwargs["host"] == "https://cloud.langfuse.com" + assert kwargs["timeout"] == 60 + assert kwargs["tracer_provider"] is get_langfuse_tracer_provider() + + +class TestGetLangfuseClient: + @patch("app.utils.Langfuse") + @patch("app.utils.get_provider_credential") + def test_returns_configured_client(self, mock_credential, mock_langfuse) -> None: + mock_credential.return_value = LANGFUSE_CREDENTIALS + + client = get_langfuse_client(session=MagicMock(), org_id=1, project_id=2) + + assert client is mock_langfuse.return_value + assert mock_credential.call_args.kwargs["provider"] == "langfuse" + + @pytest.mark.parametrize( + "credentials", + [None, {"public_key": "pk-lf-test"}], + ids=["missing", "incomplete"], + ) + @patch("app.utils.get_provider_credential") + def test_raises_400_without_complete_credentials( + self, mock_credential, credentials + ) -> None: + from fastapi import HTTPException + + mock_credential.return_value = credentials + + with pytest.raises(HTTPException) as exc_info: + get_langfuse_client(session=MagicMock(), org_id=1, project_id=2) + assert exc_info.value.status_code == 400 + + @patch("app.utils.Langfuse", side_effect=ValueError("bad host")) + @patch("app.utils.get_provider_credential") + def test_raises_500_when_client_construction_fails( + self, mock_credential, mock_langfuse + ) -> None: + from fastapi import HTTPException + + mock_credential.return_value = LANGFUSE_CREDENTIALS + + with pytest.raises(HTTPException) as exc_info: + get_langfuse_client(session=MagicMock(), org_id=1, project_id=2) + assert exc_info.value.status_code == 500 + assert "bad host" in exc_info.value.detail diff --git a/backend/app/utils.py b/backend/app/utils.py index 7c9f83d1c..65c40431c 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -348,11 +348,14 @@ def get_anthropic_client(session: Session, org_id: int, project_id: int) -> Anth def _build_langfuse_client(credentials: dict[str, Any]) -> Langfuse: + from app.core.langfuse.langfuse import get_langfuse_tracer_provider + return Langfuse( public_key=credentials["public_key"], secret_key=credentials["secret_key"], host=credentials["host"], timeout=60, + tracer_provider=get_langfuse_tracer_provider(), )