mirror of
https://github.com/Ladebeze66/devsite.git
synced 2026-05-11 16:56:26 +02:00
157 lines
5.5 KiB
Python
157 lines
5.5 KiB
Python
"""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}")
|