"""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, model: str | None = None, ) -> Iterator[Any]: """Open a Langfuse observation; yields the span object or a no-op. Pass `model=""` when `as_type="generation"` so Langfuse can label the trace and compute cost when it knows the model's pricing. Token usage is reported by the body via `span.update(usage_details=...)`. 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 kwargs: dict[str, Any] = { "name": name, "as_type": as_type, "input": input, "metadata": metadata or {}, } if model is not None: kwargs["model"] = model try: cm = lf.start_as_current_observation(**kwargs) 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