"""Langfuse SDK wrapper — single source of truth for the client and spans. Used by every layer (tools, analyses, plan, runtime, evals), which is why it lives at the api/ root rather than under any one layer. Gracefully returns no-op spans when Langfuse keys aren't configured, so local dev without Langfuse still works. """ from __future__ import annotations import logging from contextlib import contextmanager from typing import Any, Iterator from api.config import get_settings logger = logging.getLogger("nvi.langfuse") _client: Any = None def get_client() -> Any | None: global _client if _client is not None: return _client s = get_settings() if not s.langfuse_public_key or not s.langfuse_secret_key: return None try: from langfuse import Langfuse _client = Langfuse( public_key=s.langfuse_public_key, secret_key=s.langfuse_secret_key, host=s.langfuse_host, ) return _client except Exception as e: logger.warning("langfuse init failed: %s", e) return None class _NullSpan: """No-op stand-in when Langfuse isn't configured.""" def update(self, **_: Any) -> None: ... def end(self, **_: Any) -> None: ... def __enter__(self) -> "_NullSpan": return self def __exit__(self, *_: Any) -> None: ... @contextmanager def span( name: str, *, as_type: str = "span", input: Any = None, metadata: dict[str, Any] | None = None, ) -> Iterator[Any]: """Open a Langfuse observation; yields the span object or a no-op. Exceptions raised by the *body* of the `with` block propagate up untouched. Only Langfuse's own setup failures fall through to a no-op span — we never want to mask a real error. """ lf = get_client() if lf is None: yield _NullSpan() return try: cm = lf.start_as_current_observation( name=name, as_type=as_type, input=input, metadata=metadata or {} ) except Exception as e: logger.warning("langfuse setup for %s failed: %s", name, e) yield _NullSpan() return with cm as s: yield s def flush() -> None: lf = get_client() if lf is not None: try: lf.flush() except Exception: pass