99 lines
2.6 KiB
Python
99 lines
2.6 KiB
Python
"""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="<id>"` 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
|