devsite/llm-api/observability.py
2026-04-23 12:21:56 +02:00

157 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Observabilité GrasBot via Langfuse — init client + helpers de tracing.
Conçu pour être **optionnel** : si les variables d'environnement Langfuse ne sont pas
définies, le module expose un client *no-op* (dummy) qui ignore silencieusement tous
les appels. Ainsi l'API FastAPI reste fonctionnelle même sans instance Langfuse, et
les dev qui clonent le repo n'ont rien à configurer pour tester `/ask` localement.
Chargement des variables d'environnement :
1. On appelle `load_dotenv()` qui lit `llm-api/.env` s'il existe.
2. On lit `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, et l'URL (priorité à
`LANGFUSE_BASE_URL` — c'est ce que l'UI Langfuse recopie dans ses snippets —
puis fallback sur `LANGFUSE_HOST` pour compatibilité avec le SDK standard).
3. Si les 3 sont présentes → vrai client Langfuse. Sinon → no-op.
Voir `docs-site-interne/langfuse-observability.md` pour l'architecture et ce qu'on trace.
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from dotenv import load_dotenv
# --------------------------------------------------------------------------
# 1. Chargement du .env local (à côté de ce fichier, donc llm-api/.env)
# --------------------------------------------------------------------------
_ENV_PATH = Path(__file__).resolve().parent / ".env"
load_dotenv(_ENV_PATH)
# --------------------------------------------------------------------------
# 2. Client no-op (fallback si Langfuse n'est pas configuré)
# --------------------------------------------------------------------------
class _NullSpan:
"""Remplace un span Langfuse quand l'instrumentation est désactivée."""
def update(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def update_trace(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def score(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def end(self, **kwargs: Any) -> None: # noqa: D401
"""No-op."""
def __enter__(self) -> "_NullSpan":
return self
def __exit__(self, *exc: Any) -> None:
return None
class _NullLangfuse:
"""Client Langfuse factice : toutes les méthodes sont des no-op."""
enabled = False
@contextmanager
def start_as_current_span(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
@contextmanager
def start_as_current_observation(self, *args: Any, **kwargs: Any) -> Iterator[_NullSpan]:
yield _NullSpan()
def update_current_trace(self, **kwargs: Any) -> None:
pass
def update_current_span(self, **kwargs: Any) -> None:
pass
def score_current_trace(self, **kwargs: Any) -> None:
pass
def score_current_observation(self, **kwargs: Any) -> None:
pass
def flush(self) -> None:
pass
# --------------------------------------------------------------------------
# 3. Résolution de l'URL + construction du client
# --------------------------------------------------------------------------
def _resolve_host() -> str | None:
"""Priorité LANGFUSE_BASE_URL → LANGFUSE_HOST (compat SDK)."""
return os.environ.get("LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_HOST")
def _build_client() -> Any:
"""Tente de construire le vrai client Langfuse. Retourne _NullLangfuse en cas d'échec."""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
host = _resolve_host()
if not (public_key and secret_key and host):
missing = [
name
for name, value in (
("LANGFUSE_PUBLIC_KEY", public_key),
("LANGFUSE_SECRET_KEY", secret_key),
("LANGFUSE_BASE_URL/HOST", host),
)
if not value
]
print(
f" Langfuse désactivé — variables manquantes : {', '.join(missing)}. "
"L'API fonctionne normalement, aucun trace ne sera envoyée."
)
return _NullLangfuse()
try:
from langfuse import Langfuse
client = Langfuse(
public_key=public_key,
secret_key=secret_key,
host=host,
)
print(f"✅ Langfuse initialisé (host={host})")
return client
except Exception as exc: # pragma: no cover — défensif
print(
f"⚠️ Langfuse init failed ({exc.__class__.__name__}: {exc}). "
"L'API continue de fonctionner sans observabilité."
)
return _NullLangfuse()
# Singleton : un seul client pour toute la durée du process.
langfuse = _build_client()
# --------------------------------------------------------------------------
# 4. Helper pour obtenir l'attribut `enabled` quel que soit le client
# --------------------------------------------------------------------------
def is_enabled() -> bool:
"""True si le vrai client Langfuse tourne (utile pour skip des calculs coûteux)."""
# Le vrai client n'expose pas `.enabled` ; on vérifie par type.
return not isinstance(langfuse, _NullLangfuse)
# --------------------------------------------------------------------------
# 5. Flush côté shutdown (évite de perdre les dernières traces)
# --------------------------------------------------------------------------
def flush() -> None:
"""À appeler au shutdown de l'API pour forcer l'envoi des traces en buffer."""
try:
langfuse.flush()
except Exception as exc:
print(f"⚠️ Langfuse flush error : {exc}")