Skip to content

LLM Observability — Voir ce que ton LLM fait vraiment en prod

TL;DR Sans observability tu pilotes ton agent à l'aveugle. Tu as besoin de traces (qui appelle qui), spans (chaque étape : retrieve, rerank, generate, tool call), scores (qualité, hallucination, coût, latence) et datasets (replay des cas pourris). En 2026, le combo gagnant côté FR : Langfuse self-hosted sur Scaleway (souveraineté + open-source) + OpenTelemetry GenAI semconv pour parler le même langage que Datadog/Grafana. LangSmith reste imbattable si tu vis dans LangChain. Phoenix Arize est top pour le ML legacy + LLM. Helicone si tu veux un proxy zéro-code. Sans observability, un agent commercial dérape une semaine sans que personne ne voie rien. Avec, tu repères le prompt qui hallucine en 3h et tu factures un patch à 8k€.


🧠 Mental model

                ┌─────────────────────────────────────────┐
                │      USER REQUEST  (trace_id = abc)     │
                └─────────────────────────────────────────┘

       ┌──────────────────────────┼───────────────────────────┐
       ▼                          ▼                           ▼
 ┌──────────┐             ┌─────────────┐             ┌─────────────┐
 │ Span     │             │ Span        │             │ Span        │
 │ retrieve │             │ generate    │             │ tool: SQL   │
 │ 120ms    │             │ 1800ms      │             │ 45ms        │
 │ k=8 docs │             │ tok in=2400 │             │ rows=12     │
 │ score 0.7│             │ tok out=180 │             │             │
 └──────────┘             └─────────────┘             └─────────────┘
       │                          │                           │
       └──────────────┬───────────┴───────────────────────────┘

              ┌──────────────────┐
              │ SCORES attachés  │
              │ faithfulness 0.9 │
              │ helpfulness 0.8  │
              │ user_thumbs +1   │
              │ cost €0.012      │
              └──────────────────┘

Analogie : un LLM en prod sans observability c'est une boutique sans caméra ni caisse enregistreuse. Tu sais qu'il y a du trafic, tu sens vaguement que ça marche, mais quand un client gueule sur Trustpilot tu n'as rien pour reconstituer la transaction. Avec Langfuse tu as la vidéo, le ticket de caisse et le NPS du client tout au même endroit. Le trace_id c'est ton ticket de caisse, le span c'est chaque article scanné, le score c'est la satisfaction client à la sortie.

Le mental shift à intégrer : un LLM est un système distribué. Une question = N sous-appels (retrieve, rerank, MCP tool, LLM call, guardrail, re-LLM call). Tu dois pouvoir zoomer du global (P95 latence cette semaine) au micro (le prompt exact envoyé à Claude pour le ticket #98342). Sans ça, debug = lecture des logs Cloudwatch à la pelle, 4h pour rien.


🛠️ Code minimal

Instrument une route NestJS qui fait un RAG simple avec Langfuse cloud (ou self-host : même SDK, juste l'URL change).

ts
// libs/observability/langfuse.client.ts
import { Langfuse } from "langfuse";

export const langfuse = new Langfuse({
  publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
  secretKey: process.env.LANGFUSE_SECRET_KEY!,
  baseUrl: process.env.LANGFUSE_HOST ?? "https://cloud.langfuse.com",
  flushAt: 5,
  flushInterval: 2000,
});
ts
// src/legal-assistant/legal-assistant.service.ts
import { Injectable } from "@nestjs/common";
import { langfuse } from "@app/observability";
import Anthropic from "@anthropic-ai/sdk";

@Injectable()
export class LegalAssistantService {
  private anthropic = new Anthropic();

  async ask(opts: { tenantId: string; userId: string; question: string }) {
    const trace = langfuse.trace({
      name: "legal.ask",
      userId: opts.userId,
      sessionId: `tenant:${opts.tenantId}`,
      input: { question: opts.question },
      tags: ["prod", "legal", `tenant:${opts.tenantId}`],
    });

    // 1. retrieve
    const retrieveSpan = trace.span({ name: "retrieve", input: { q: opts.question } });
    const docs = await this.retrieve(opts.question);
    retrieveSpan.end({ output: { count: docs.length, ids: docs.map(d => d.id) } });

    // 2. generate
    // NB: pas de `temperature` sur Opus 4.8 (param supprimé → HTTP 400).
    // On guide le modèle par le prompt + `output_config.effort`, pas par temperature.
    const gen = trace.generation({
      name: "claude.answer",
      model: "claude-opus-4-8",
      modelParameters: { max_tokens: 800, effort: "low" },
      input: [{ role: "user", content: this.buildPrompt(opts.question, docs) }],
    });

    const completion = await this.anthropic.messages.create({
      model: "claude-opus-4-8",
      max_tokens: 800,
      output_config: { effort: "low" },
      messages: [{ role: "user", content: this.buildPrompt(opts.question, docs) }],
    });

    const answer = completion.content[0].type === "text" ? completion.content[0].text : "";

    gen.end({
      output: answer,
      usage: {
        input: completion.usage.input_tokens,
        output: completion.usage.output_tokens,
        unit: "TOKENS",
      },
    });

    trace.update({ output: { answer } });
    return { answer, traceId: trace.id };
  }

  // ... retrieve(), buildPrompt() omitted
}

Ce qui se passe : chaque appel crée un trace parent + des spans/generations enfants. Langfuse calcule automatiquement le coût (à partir du model et des tokens), la latence (start/end), et expose tout dans une UI. Tu peux ensuite poster des scores (trace.score({ name: "user_thumbs", value: 1 })) ou créer des datasets à partir des traces réelles.


🎬 Cas d'usage concrets

Cas 1 — Assistant juridique en prod (cabinet d'avocats Paris, 60 collaborateurs)

Le client : cabinet d'affaires parisien, 60 avocats, déploiement d'un assistant interne qui répond à des questions de droit social en s'appuyant sur leur base interne (Convention collective, jurisprudence Cass. soc., notes internes). Stack : NestJS + Anthropic Claude + Qdrant.

Le problème observé sans observability : pendant 3 semaines, plusieurs associés se plaignent que "l'IA invente des arrêts". Aucune trace pour savoir lesquels, quand, pourquoi. Le CTO ne peut pas reproduire. Réputation interne en chute.

Avec Langfuse : tu instrumentes chaque question. Tu pousses un score faithfulness calculé par un LLM-as-judge (voir 02-eval-pipelines.md) qui compare la réponse aux docs récupérés. Tu filtres les traces avec faithfulness < 0.7. Tu découvres que le bug vient d'un chunking pourri sur les PDF scannés via OCR : 40% des chunks contiennent du bruit (numéros de page, sommaires). Tu re-chunkes proprement avec un meilleur pipeline. Le faithfulness moyen passe de 0.71 → 0.92 en une semaine. Tu factures le diagnostic + le fix : 12k€ pour 5 jours de boulot.

Ce que Langfuse t'a donné concrètement :

  • Filtre sur tenant=cabinet-x + score:faithfulness<0.7 → 23 traces en 24h.
  • Sur chaque trace, tu vois les chunks récupérés et le prompt exact.
  • Tu repères 8 chunks qui apparaissent en commun → racine du problème.
  • Tu construis un dataset "regression" de ces 23 questions et tu le rejoues après le fix.

Cas 2 — Agent commercial e-commerce qui dérape (DTC mode FR, ~50M€ CA)

Le client : marque DTC mode française, agent chatbot sur le site qui répond à des questions produit + gère pré-vente. Mistral Large 2 + outils internes (catalog API, stock API, devis API).

Le problème : un samedi soir le CEO te ping en panique : sur Twitter, un client a posté une capture où l'agent a promis une livraison gratuite + 30% de remise sur la collection Capsule (qui n'a aucune promo active). 200 commentaires. Marketing furieux.

Avec Langfuse + OpenTelemetry : tu retrouves la trace en 90 secondes via search sur le screenshot du user (sessionId extrait du widget). Tu vois la chaîne complète : l'agent a appelé l'outil getActivePromos qui a retourné une promo expirée (bug API) → l'agent a légitimement "promis" la réduction. Le problème n'est pas le LLM, c'est ton API qui sert des données stale (cache Redis pas invalidé après expiration). Tu fixes l'API. Tu ajoutes une guardrail : toute mention de "réduction/promo" déclenche un check secondaire contre la base de vérité. Tu fournis au client un post-mortem en 2h avec preuves. Crédibilité maintenue.

Cas 3 — Gouvernance RAG bancaire (banque de détail FR, RGPD strict)

Le client : middle-office d'une banque mutualiste FR. RAG sur procédures internes pour les conseillers. Contraintes : audit trail complet (qui a posé quelle question, quels docs ont été récupérés, quelle réponse, quand), traçabilité PII, conformité ANS HDS-like (souveraineté). Pas le droit d'utiliser Langfuse Cloud (US ownership).

Solution : Langfuse self-hosted sur Scaleway région Paris. Tu déploies Langfuse v3 (ClickHouse + Postgres + S3 Scaleway Object Storage). Tu instrumentes chaque appel avec le userId = matricule conseiller (pseudonymisé), tenantId = agence. Tu redires les PII (numéro de compte, nom client) avant d'envoyer à Langfuse via un middleware Presidio (cf 05-safety-guardrails.md). Tu exposes un dashboard "audit" en lecture seule au RSSI : nombre de requêtes/conseiller, top questions, taux d'escalade vers humain, anomalies.

Mission : 6 semaines, 35k€. Livrables : infra Terraform, instrumentation NestJS, dashboards Grafana + Langfuse, runbook RSSI, plan de tests de charge. Récurrent en mainsoutenance : 1500€/j à raison de 2j/mois.


🛠️ Exemple end-to-end — Déploiement Langfuse self-host sur Scaleway + instrumentation Python/NestJS + dashboard analytics par tenant

Contexte projet : tu pitches une mission "LLM Observability stack souveraine" à une scale-up FR. 8 semaines, 70k€ ferme. Tu livres :

  1. Une stack Langfuse self-hosted sur Scaleway (Paris).
  2. Un client TypeScript (NestJS) + Python (workers Celery) instrumentés.
  3. Un dashboard analytics par tenant exposé en SSO.
  4. Un runbook ops + alerting Datadog.

1) Infra Scaleway (Terraform, extrait)

hcl
# infra/scaleway/langfuse.tf
resource "scaleway_k8s_cluster" "obs" {
  name    = "obs-prod"
  region  = "fr-par"
  version = "1.31"
  cni     = "cilium"
}

resource "scaleway_rdb_instance" "langfuse_pg" {
  name           = "langfuse-pg"
  node_type      = "DB-DEV-S"
  engine         = "PostgreSQL-15"
  is_ha_cluster  = true
  region         = "fr-par"
  user_name      = "langfuse"
  password       = var.pg_password
}

resource "scaleway_object_bucket" "langfuse_events" {
  name   = "langfuse-events-prod-fr"
  region = "fr-par"
}

# ClickHouse via Helm chart (Bitnami) — pas managé sur Scaleway
# helm install clickhouse bitnami/clickhouse -n langfuse

2) Helm values pour Langfuse v3

yaml
# infra/langfuse/values.yaml
langfuse:
  image:
    repository: langfuse/langfuse
    tag: "3-latest"
  nodeEnv: production
  nextauth:
    url: https://obs.acme.fr
    secret: ${NEXTAUTH_SECRET}
  salt: ${ENCRYPTION_SALT}
  encryptionKey: ${ENCRYPTION_KEY}
  telemetryEnabled: false # opt-out télémétrie externe pour souveraineté

postgresql:
  enabled: false # on utilise Scaleway RDB
  external:
    host: ${PG_HOST}
    user: langfuse
    password: ${PG_PASSWORD}

clickhouse:
  enabled: true
  auth:
    username: langfuse
    password: ${CH_PASSWORD}
  persistence:
    size: 500Gi
    storageClass: scw-bssd

s3:
  endpoint: https://s3.fr-par.scw.cloud
  bucket: langfuse-events-prod-fr
  region: fr-par
  accessKeyId: ${SCW_ACCESS_KEY}
  secretAccessKey: ${SCW_SECRET_KEY}
  forcePathStyle: true

3) Instrumentation NestJS (TypeScript)

ts
// libs/observability/observability.module.ts
import { Global, Module } from "@nestjs/common";
import { Langfuse } from "langfuse";

@Global()
@Module({
  providers: [
    {
      provide: "LANGFUSE",
      useFactory: () =>
        new Langfuse({
          publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
          secretKey: process.env.LANGFUSE_SECRET_KEY!,
          baseUrl: process.env.LANGFUSE_HOST!, // https://obs.acme.fr
          release: process.env.GIT_SHA,
          environment: process.env.NODE_ENV,
        }),
    },
  ],
  exports: ["LANGFUSE"],
})
export class ObservabilityModule {}
ts
// libs/observability/trace.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Inject,
  Injectable,
  NestInterceptor,
} from "@nestjs/common";
import { Langfuse } from "langfuse";
import { Observable, tap, catchError, throwError } from "rxjs";

@Injectable()
export class TraceInterceptor implements NestInterceptor {
  constructor(@Inject("LANGFUSE") private lf: Langfuse) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const req = ctx.switchToHttp().getRequest();
    const trace = this.lf.trace({
      name: `${req.method} ${req.route?.path ?? req.url}`,
      userId: req.user?.id,
      sessionId: req.headers["x-session-id"],
      tags: [`tenant:${req.user?.tenantId ?? "anon"}`, req.headers["x-app"] ?? "api"],
      metadata: { ip_hash: req.ipHash, ua: req.headers["user-agent"] },
    });
    req.lfTrace = trace;

    return next.handle().pipe(
      tap((res) => trace.update({ output: { ok: true, sample: this.sample(res) } })),
      catchError((err) => {
        trace.update({ output: { ok: false, error: err.message }, level: "ERROR" });
        return throwError(() => err);
      })
    );
  }

  private sample(res: unknown): unknown {
    const s = JSON.stringify(res ?? {});
    return s.length > 2000 ? s.slice(0, 2000) + "...<truncated>" : res;
  }
}

4) Wrapper LLM générique (production-grade)

Le wrapper que tu déploies en mission n'est pas un simple try/catch. Il porte les patterns qu'un senior attend : client Anthropic réutilisé (pas de re-instanciation par appel), maxRetries + timeout par appel, exceptions typées (RateLimit/Overloaded/APIStatus) recopiées dans le span pour que tu filtres level:ERROR par type, et surtout les vrais champs de coût (cache_read_input_tokens, cache_creation_input_tokens) — sinon Langfuse calcule un coût faux dès que tu actives le prompt caching.

ts
// libs/observability/llm-trace.ts
import Anthropic, {
  RateLimitError,
  OverloadedError,
  APITimeoutError,
  APIStatusError,
} from "@anthropic-ai/sdk";
import { LangfuseTraceClient } from "langfuse";

// Un seul client process-wide : pool de connexions + retries SDK partagés.
const anthropic = new Anthropic({ maxRetries: 3 });

export async function tracedClaudeCall(opts: {
  trace: LangfuseTraceClient;
  name: string;
  model: string; // ex: "claude-opus-4-8"
  messages: Anthropic.MessageParam[];
  system?: string | Anthropic.TextBlockParam[];
  maxTokens?: number;
  effort?: "low" | "medium" | "high" | "max";
}) {
  const gen = opts.trace.generation({
    name: opts.name,
    model: opts.model,
    // PAS de temperature : supprimée sur Opus 4.8 (HTTP 400). On pilote par effort.
    modelParameters: { max_tokens: opts.maxTokens ?? 800, effort: opts.effort ?? "low" },
    input: opts.messages,
  });

  try {
    const res = await anthropic.messages.create(
      {
        model: opts.model,
        max_tokens: opts.maxTokens ?? 800,
        thinking: { type: "adaptive" },
        output_config: { effort: opts.effort ?? "low" },
        system: opts.system,
        messages: opts.messages,
      },
      { timeout: 60_000 }, // budget latence par appel, indépendant du retry SDK
    );

    const text = res.content.find((b) => b.type === "text")?.text ?? "";

    // Recopie TOUS les champs usage : sans cache_read/creation, le coût Langfuse est faux.
    gen.end({
      output: text,
      usage: {
        input: res.usage.input_tokens,
        output: res.usage.output_tokens,
        cache_read_input_tokens: res.usage.cache_read_input_tokens ?? 0,
        cache_creation_input_tokens: res.usage.cache_creation_input_tokens ?? 0,
        unit: "TOKENS",
      },
      metadata: { stop_reason: res.stop_reason },
    });
    return { text, usage: res.usage, stopReason: res.stop_reason };
  } catch (err) {
    // Classe l'erreur pour pouvoir filtrer Langfuse par type (rate_limit vs overloaded vs timeout).
    const errType =
      err instanceof RateLimitError ? "rate_limit"
      : err instanceof OverloadedError ? "overloaded"
      : err instanceof APITimeoutError ? "timeout"
      : err instanceof APIStatusError ? `api_${err.status}`
      : "unknown";
    gen.end({
      output: { error: (err as Error).message, errType },
      level: "ERROR",
      metadata: { errType },
    });
    throw err;
  }
}

Pourquoi ça compte en prod : quand le P95 explose à 3h du matin, tu veux pouvoir filtrer Langfuse sur metadata.errType:overloaded (→ c'est Anthropic, tu actives le fallback Haiku) vs errType:rate_limit (→ c'est ton quota TPM, tu augmentes le tier ou tu throttles) vs errType:timeout (→ ton prompt a explosé, regarde input_tokens). Un wrapper qui logge juste error.message ne te donne aucun de ces 3 diagnostics.

Le piège du retry invisible (cost + latence) : maxRetries: 3 sur le client Anthropic fait que le SDK retry automatiquement les 429/5xx/529 avec backoff exponentiel — et c'est exactement ce que tu veux pour la résilience. Mais ces retries sont invisibles depuis le span : tu crées une generation, le SDK boucle silencieusement 1 à 4 fois en interne, et tu ne vois que l'appel qui a fini par réussir. Conséquences senior à connaître :

  • Latence : la generation Langfuse mesure le temps total y compris les retries + backoff. Un span claude.answer à 9s avec input_tokens normal n'est pas forcément un prompt lent — c'est peut-être 2 retries overloaded à 3s d'attente chacun. Pour distinguer, tu n'as pas l'info dans le span seul : c'est pour ça que tu veux aussi l'OTel exporter (qui, lui, peut instrumenter chaque tentative HTTP via les hooks du SDK) en parallèle de la trace métier Langfuse.
  • Coût : Anthropic ne facture que la tentative qui produit des tokens. Un 429/529 avant output n'est pas facturé, donc le retry ne double pas le coût côté facture. Le piège est inverse : si toi tu rejoues l'appel au niveau applicatif (try/catch maison qui re-appelle tracedClaudeCall) en plus du retry SDK, tu crées deux generation distinctes pour une seule réponse facturée → ton dashboard double-compte. Règle : un seul niveau de retry. Laisse le SDK gérer le transport (maxRetries), ne re-wrappe pas par-dessus.
  • Timeout vs retry : le { timeout: 60_000 } est un budget par tentative, pas global. Avec maxRetries: 3, le wall-clock worst-case est ~4 × 60s + backoff. Si ta route HTTP a un timeout gateway à 30s, tu coupes le client avant que le SDK ait fini de retry. Aligne les deux : timeout par appel × (maxRetries+1) ≤ budget gateway, ou baisse maxRetries sur les routes synchrones user-facing et garde-le élevé sur les workers batch.

5) Worker Python instrumenté (batch nocturne)

python
# workers/ingest_pdf.py
from langfuse import Langfuse
from langfuse.decorators import observe, langfuse_context
import anthropic

lf = Langfuse(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ["LANGFUSE_HOST"],
)

@observe(name="ingest.pdf", capture_input=False)
def process_pdf(tenant_id: str, file_id: str, content: bytes) -> dict:
    langfuse_context.update_current_trace(
        tags=[f"tenant:{tenant_id}", "worker:ingest"],
        metadata={"file_id": file_id, "bytes": len(content)},
    )
    chunks = chunk_pdf(content)
    summary = summarize(chunks, tenant_id=tenant_id, file_id=file_id)
    return {"chunks": len(chunks), "summary": summary}

@observe(name="claude.summarize", as_type="generation")
def summarize(chunks, tenant_id, file_id):
    client = anthropic.Anthropic()
    prompt = "\n\n".join(c.text for c in chunks[:30])
    res = client.messages.create(
        model="claude-haiku-4-5",  # cheap tier (1$/5$ par Mtok) pour du résumé batch
        max_tokens=400,
        messages=[{"role": "user", "content": f"Summarize:\n{prompt}"}],
    )
    langfuse_context.update_current_observation(
        usage={"input": res.usage.input_tokens, "output": res.usage.output_tokens, "unit": "TOKENS"}
    )
    return res.content[0].text

6) Dashboard analytics par tenant (Grafana + ClickHouse direct)

Tu exposes une datasource ClickHouse en lecture seule (rôle SQL analytics_ro) sur la base Langfuse. Tu construis un dashboard Grafana par tenant via templating :

sql
-- Variable Grafana: $tenant ($__searchFilter)
-- Panel: requests / day
SELECT
  toDate(timestamp) AS day,
  count() AS reqs
FROM traces
WHERE has(tags, concat('tenant:', '$tenant'))
  AND timestamp >= now() - INTERVAL 30 DAY
GROUP BY day ORDER BY day;

-- Panel: cost € / day
SELECT
  toDate(start_time) AS day,
  sum(total_cost) AS eur
FROM observations
WHERE type = 'GENERATION'
  AND project_id = '$project'
  AND has(tags, concat('tenant:', '$tenant'))
GROUP BY day ORDER BY day;

-- Panel: p95 latency
SELECT
  toDate(start_time) AS day,
  quantile(0.95)(dateDiff('millisecond', start_time, end_time)) AS p95_ms
FROM observations
WHERE type = 'GENERATION' AND has(tags, concat('tenant:', '$tenant'))
GROUP BY day ORDER BY day;

7) Alerting Datadog (via OTel bridge)

Tu actives le SDK Langfuse OTel exporter (déjà natif depuis v2.50) qui envoie aussi vers Datadog APM. Tu crées 3 monitors :

  • p95(llm.latency) > 4s 10min → Slack #incidents.
  • sum(llm.cost) > 200€/h → PagerDuty.
  • rate(llm.errors) > 2% 5min → Slack.

8) Runbook (extrait)

# Runbook: Latence LLM élevée (P95 > 4s)
1. Vérifier statut Anthropic: https://status.anthropic.com
2. Si OK → Grafana dashboard "LLM ops" → filtrer modèle, vérifier:
   - Token in/out moyen (input prompt explose ?)
   - Tool calls (loop infini ?)
3. Si toujours pas clair → Langfuse → search traces dernière heure
   triées par latencyMs desc → ouvrir top 5.
4. Si MCP tool lent → vérifier APM Datadog côté backend cible.
5. Mitigation rapide: feature flag `model=haiku` activé (cf 07-routing).

🎯 Patterns courants

  • Trace per request, span per step : un trace par requête utilisateur, un span/generation par opération coûteuse (retrieve, rerank, LLM, tool). Ne pas créer un trace par MCP tool : ça casse le contexte.
  • Tags hiérarchiques : env:prod, tenant:acme, feature:legal-qa, version:v3.2. Permet d'isoler en 2 clics.
  • userId + sessionId : userId = identité utilisateur (pseudo), sessionId = conversation. Indispensable pour reconstruire un thread.
  • Sample large output : ne pas stocker 50MB de JSON dans la trace, tronque proprement (> 2-5 KB → <truncated>).
  • Score asynchrone : push les scores LLM-as-judge depuis un worker, pas en synchrone (latence).
  • Datasets dérivés de prod : capture les traces taggées bad-answer → dataset golden pour CI eval.
  • Cost attribution : pousser tenantId en tag → Grafana groupe par tenant → facturation usage-based facile.
  • PII redaction avant push : middleware Presidio FR (numéros SS, IBAN, NIR) en hook beforeSend.
  • OTel parallèle : un SDK Langfuse direct plus un export OTel vers Datadog → tu as l'un pour le qualitatif (prompts, scores), l'autre pour le quantitatif (P95, error rate, alerting).

🔄 Versions & écosystème 2026

  • Langfuse v3 (mai 2025 → toujours majeure en 2026) : ClickHouse + Postgres + S3, scale à des millions de traces/jour. SDKs TS et Python matures. Self-host gratuit, cloud à 59$/mois entry. OTel-native depuis v3.10.
  • LangSmith : reste la référence si tu vis dans LangChain/LangGraph. Intègre tracing, prompt hub, eval, dataset. Cloud only sauf Enterprise. Pricing à l'utilisateur + au trace.
  • Phoenix Arize (open-source) : excellent si tu mixes ML classique + LLM. Visualisation embeddings très forte. Local + cloud.
  • Helicone : proxy LLM zéro-code (tu changes juste la base URL). Bien pour démarrer vite. Moins puissant en eval/datasets.
  • Datadog LLM Observability : top si la boîte est déjà sur Datadog. Cher mais zéro friction côté ops.
  • OpenTelemetry GenAI semconv (stable depuis fin 2025) : norme partagée. Tous les SDK majeurs (Anthropic, OpenAI, Google) émettent du tracing OTel natif. À adopter pour ne pas se lock-in.
  • Logfire (Pydantic) : intéressant pour les stacks Python lourdes.
  • Weights & Biases Weave : si l'équipe a déjà W&B pour le ML.

Le bon défaut FR souverain en 2026 : Langfuse self-host Scaleway + OTel exporter vers Grafana/Datadog. Tu gardes la main sur les données, tu parles standard, tu peux migrer.

OpenTelemetry GenAI semconv — les attributs que tu dois connaître

Quand on dit "parle OTel", ça veut dire émettre des spans dont les attributs suivent la GenAI semantic convention (stable fin 2025). C'est ça qui te rend portable : Datadog, Grafana, Honeycomb, n'importe quel backend OTLP sait afficher et alerter sur ces attributs sans config custom. Un senior connaît le mapping de tête parce qu'il dashboard dessus :

Attribut OTel GenAISensÉquivalent Langfuse
gen_ai.systemanthropic / openai / …(implicite via le model)
gen_ai.request.modelclaude-opus-4-8generation.model
gen_ai.response.modelmodèle réellement servi (peut différer)métadonnée
gen_ai.usage.input_tokenstokens d'entrée facturés plein tarifusage.input
gen_ai.usage.output_tokenstokens de sortieusage.output
gen_ai.operation.namechat / embeddings / generate_contentgeneration.name
gen_ai.request.max_tokensbudget de sortiemodelParameters.max_tokens
error.typeoverloaded / rate_limit / timeoutmetadata.errType

Le point qui pique : la convention GenAI n'a pas d'attribut standard pour cache_read_input_tokens / cache_creation_input_tokens au moment où tu lis ça (le prompt caching est plus récent que la semconv). Donc si tu pousses uniquement de l'OTel "pur" vers Datadog, ton coût Datadog est faux dès que tu actives le caching — exactement le même bug que côté Langfuse (cf. section Cost attribution). Tu les pousses en attributs custom (anthropic.usage.cache_read_input_tokens) et tu recalcules le coût toi-même dans une recording rule Datadog/PromQL. Conclusion d'archi : l'OTel te donne le quantitatif portable (P95, error rate, débit) ; Langfuse te donne le coût correct attribué + le qualitatif (prompts, scores). Tu ne remplaces pas l'un par l'autre, tu les fais coexister.


⚠️ Pitfalls

  1. Tracer les secrets : tu logges par mégarde un IBAN ou un token JWT dans input. Toujours redire avant trace.create.
  2. Bloquer la requête si Langfuse est down : le SDK doit être fire-and-forget. Si tu await flush() en synchrone, un outage Langfuse te casse la prod. Utiliser flushAt/flushInterval async.
  3. Trop de traces : tracer chaque embed batch de 5000 docs → 5000 traces inutiles. Sample : 100% sur les routes user-facing, 1% sur les workers volumineux.
  4. Pas de userId ni sessionId : tu te retrouves avec 1M traces anonymes, impossible de retrouver l'incident d'un client précis.
  5. Cost mismatch : Langfuse calcule le coût avec ses prix en cache. Si tu utilises Bedrock/Azure/prompt caching, il faut override usage avec les vrais montants.
  6. Confondre trace et span : un trace = 1 requête utilisateur. Un span = 1 étape. Ne pas créer 1 trace par span (illisible).
  7. Oublier l'output : si tu termines un span sans output, tu vois la latence mais pas ce qui s'est passé. end({ output }) toujours.
  8. PII en clair pour le DPO : self-host sans redaction → le DPO interdit l'usage en prod. Implémente Presidio dès le jour 1.
  9. ClickHouse mal sizé : 500GB minimum si tu fais du sérieux. Sinon retention 7j max et tu perds les regressions historiques.
  10. Ignorer le coût Langfuse Cloud à l'échelle : à 5M traces/mois, tu paies cher. À ce volume, self-host devient ROI-positif.

💰 Pricing / ROI client

Coût Langfuse :

  • Cloud Hobby : gratuit jusqu'à 50k observations/mois.
  • Cloud Pro : 59$/mois + 10$ / 100k observations.
  • Cloud Team : 499$/mois (3M observations incluses).
  • Self-host : gratuit (open-source MIT). Coûts infra : Scaleway K8s + RDB + Object Storage ≈ 180-400€/mois selon volume.

ROI typique mission :

  • Audit + setup Langfuse self-host + instrumentation NestJS sur 1 produit existant : 15-25 k€ (1-2 semaines).
  • Stack obs complète (self-host + Python + dashboards Grafana SSO + alerting) : 35-70 k€ (4-8 semaines).
  • Maintenance/run : 1500-2000€/j, 1-3 j/mois.

Pitch ROI au client : un incident LLM en prod sans observability = 4-8h de panique + dégât d'image. Un seul incident évité (ou résolu en 30 min) = mission payée. Sur 12 mois, l'observability économise typiquement 30-60 k€ de "perdu en production" + débloque la conformité (DPO, RSSI).


🧮 Cost attribution — défendre le chiffre

Le coût LLM est la première métrique qu'un CFO te demandera de justifier. Si Langfuse affiche un coût qui ne colle pas à la facture Anthropic, ta crédibilité tombe. Voici comment un senior raisonne sur ce chiffre.

Le modèle de coût canonique (Anthropic 2026, USD / million tokens) :

ModèleInputOutputCache write (5 min)Cache read
claude-opus-4-85 $25 $6,25 $ (1,25×)0,50 $ (0,1×)
claude-sonnet-4-63 $15 $3,75 $0,30 $
claude-haiku-4-51 $5 $1,25 $0,10 $

Le coût d'une generation n'est pas input_tokens × prix_input + output_tokens × prix_output. C'est :

coût = input_tokens          × prix_input
     + output_tokens         × prix_output
     + cache_creation_tokens × prix_input × 1,25
     + cache_read_tokens     × prix_input × 0,10

Le piège #1 du cost mismatch : Langfuse calcule le coût à partir de son catalogue de prix interne et des champs usage que tu lui pousses. Si ton wrapper ne pousse que input/output (cf. la section wrapper ci-dessus), alors tout token servi depuis le cache est facturé plein tarif input dans Langfuse, alors qu'Anthropic te le facture à 0,1×. Sur un agent avec un gros system prompt caché (typique RAG : 4k tokens de contexte stable réutilisés à chaque tour), tu peux surestimer le coût Langfuse de 5 à 8×. Le CFO regarde la facture Anthropic, voit 1 200 €, voit ton dashboard à 9 000 €, et tu passes pour un amateur. Pousse les 4 champs usage, toujours.

Le piège #2 — la souveraineté du prix : si le client tourne sur Bedrock, Vertex ou Azure, ou s'il a un contrat enterprise négocié, les prix diffèrent du catalogue public. Langfuse applique son catalogue par défaut → override explicite via le modèle custom ou en injectant directement le total_cost calculé côté wrapper. Ne laisse jamais Langfuse "deviner" le prix sur un déploiement non-standard.

Mental model du senior : Langfuse est ta source de vérité pour le coût attribué (par tenant, par feature, par user), la console Anthropic est ta source de vérité pour le coût absolu. Les deux doivent réconcilier à ±5 % en fin de mois. Si l'écart dépasse 5 %, c'est un bug d'instrumentation (champs usage manquants, sampling qui sous-compte, double-comptage de retries), pas un bug de prix — et tu le débugges avant la réunion budget, pas pendant.


🧪 Testing / Eval

  • Smoke test obs : à chaque déploiement, route /healthz/obs qui crée un trace dummy et vérifie via API Langfuse que le trace remonte (< 60s sinon alert).
  • Sampling de traces : test que ton sampling marche (1% workers vs 100% user-facing) via metadata.sampling_rate.
  • Replay golden dataset : prends 30 traces tagguées golden et rejoue-les en CI, compare scores.
  • Load test : artillery sur 10k req/min, vérifier que le flush async ne sature pas la mémoire NestJS.
  • DR test : couper Langfuse 1h, vérifier que la prod tourne (mode dégradé, pas de blocage).

🔁 Quand utiliser / éviter

Utiliser dès jour 1 :

  • N'importe quel agent ou RAG qui sort de POC.
  • Multi-tenant facturé à l'usage (cost attribution).
  • Domaine réglementé (banque, santé, legal, public).
  • Équipe > 2 ingés (sans obs, le bus factor te tue).

Éviter / minimal-only :

  • Pur POC interne 2 semaines : un simple logger.info(prompt, response) suffit.
  • Script one-shot CLI sans utilisateur final.
  • Application strictement on-prem sans réseau (mais Langfuse self-host airgapped existe).

🧩 Bonus — Patterns avancés vus en mission FR

A. Cross-service tracing distribué

Quand ton agent appelle un backend NestJS, qui appelle un microservice Python (FastAPI worker), qui appelle un MCP server, tu veux UN seul trace qui les traverse tous. La méthode :

  1. Tu génères le trace_id au point d'entrée (gateway, edge).
  2. Tu le propages via les headers W3C : traceparent et tracestate.
  3. Chaque service downstream lit ces headers et crée un span enfant.
  4. Langfuse v3 supporte nativement la trace import via OTel headers.
ts
// libs/observability/propagate.ts
export function injectTraceHeaders(traceId: string, parentSpanId: string): Record<string, string> {
  // version-traceid-parentid-flags
  return {
    traceparent: `00-${traceId.padStart(32, "0")}-${parentSpanId.padStart(16, "0")}-01`,
  };
}

// Service A (NestJS) appelle Service B (Python)
await fetch(`http://worker/process`, {
  method: "POST",
  headers: injectTraceHeaders(trace.id, span.id),
  body: JSON.stringify(payload),
});
python
# Service B (FastAPI)
from langfuse.decorators import observe, langfuse_context

@app.post("/process")
async def process(req: Request):
    tp = req.headers.get("traceparent")
    if tp:
        parts = tp.split("-")
        # langfuse_context.update_current_trace permet l'enfant
        langfuse_context.update_current_trace(metadata={"parent_traceparent": tp})
    ...

Résultat : tu cliques sur 1 trace Langfuse et tu vois la chaîne NestJS → Python → MCP → LLM en un seul flow visuel.

B. Prompts versioning

Langfuse a un module Prompts qui sert de SoR (source of record) des prompts en prod. Tu pushes une version (v3.2), tu fais des A/B (cf 02-eval), tu rollback en 1 clic.

ts
// libs/prompts/prompts.service.ts
import { Langfuse } from "langfuse";

@Injectable()
export class PromptsService {
  constructor(@Inject("LANGFUSE") private lf: Langfuse) {}

  async get(name: string, version?: string) {
    const prompt = await this.lf.getPrompt(name, version);
    return prompt.compile({ /* vars */ });
  }
}

Cron-warming au boot évite la latence du premier fetch.

C. Cost attribution multi-niveaux

Tags hiérarchiques tenant > workspace > project > user. Tu peux groupbig par n'importe quel niveau dans Grafana :

sql
SELECT
  splitByChar(':', tag)[2] AS tenant,
  sum(total_cost) AS eur
FROM observations
WHERE has(tags, 'env:prod')
GROUP BY tenant ORDER BY eur DESC LIMIT 20;

D. Sampling adaptatif

100% sur user-facing, 1% sur batch, 100% sur les traces signalées "bad" (user thumbs-down). Tu n'oublies aucune erreur tout en gardant les coûts maîtrisés.

ts
// libs/observability/sampler.ts
export function shouldSample(opts: { route: string; userFlag?: "good" | "bad" }) {
  if (opts.userFlag === "bad") return true;
  if (opts.route.startsWith("/api/chat")) return true; // user-facing
  if (opts.route.startsWith("/workers/")) return Math.random() < 0.01; // 1%
  return Math.random() < 0.1;
}

Quand un user crée un ticket Zendesk / Freshdesk depuis le chat, tu pousses trace_id dans le ticket. Le support clique → ouverture Langfuse direct sur la conversation. Pratique pour les freelances qui gèrent aussi le support N2.


🏋️ Exercices

Progression du "j'instrumente X" vers "je rends la stack production-grade, je la casse, je la défends". Chaque exercice suppose que le précédent tourne.

Exercice 1 — Instrumente un RAG de bout en bout

Objectif : sur une route NestJS qui fait retrieve → generate, produire un trace avec un span retrieve et une generation Claude, coût et latence calculés automatiquement, visibles dans Langfuse.

Indice/Solution : reprends tracedClaudeCall de la section wrapper. Le span retrieve doit end({ output: { count, ids } }) ; la generation doit pousser les 4 champs usage (input, output, cache_read, cache_creation). Vérifie dans l'UI que le coût affiché est non nul et que la latence du span = end - start.

Exercice 2 — Score asynchrone faithfulness + dataset de régression

Objectif : pousser depuis un worker un score faithfulness (0-1) calculé par un LLM-as-judge Haiku, puis construire un dataset des traces avec faithfulness < 0.7.

Indice/Solution : le score se pousse via trace.score({ name: "faithfulness", value }) hors du chemin synchrone (BullMQ / Celery), sinon tu rajoutes la latence du judge à la requête user. Le judge compare la réponse aux docs.ids récupérés (que tu as loggés en exercice 1). Filtre score:faithfulness<0.7, exporte en dataset. Piège : si tu n'as pas loggé les chunks récupérés dans le span retrieve, tu ne peux pas calculer faithfulness a posteriori — l'instrumentation doit précéder le besoin.

Exercice 3 — Sampling adaptatif + DR test (casse-la)

Objectif : implémenter le sampling 100 % user-facing / 1 % workers / 100 % sur thumbs-down, puis couper Langfuse 1h et prouver que la prod ne tombe pas.

Indice/Solution : reprends shouldSample. Le DR test est le vrai sujet : un await flush() synchrone dans le hot path = un outage Langfuse qui casse ta prod. Démontre le mode fire-and-forget (flushAt/flushInterval async, jamais d'await bloquant). Coupe le LANGFUSE_HOST (firewall ou mauvaise URL), envoie 100 req, vérifie : 200 OK côté user, traces perdues côté obs, aucune exception remontée à l'utilisateur. Si une seule requête user échoue à cause de Langfuse down, l'exercice est raté.

Exercice 4 — Défends le chiffre du coût (réconciliation)

Objectif : faire coller le coût Langfuse agrégé sur 7 jours à la facture console Anthropic à ±5 %.

Indice/Solution : c'est l'exercice "defend the number" de la section Cost attribution. Active le prompt caching sur un gros system prompt (cache_control sur le préfixe stable), génère du trafic, puis compare. Tu vas voir Langfuse surestimer si ton wrapper ne pousse pas cache_read_input_tokens. Corrige le wrapper, re-mesure. Si l'écart reste > 5 %, cherche : retries double-comptés (un appel qui retry 2× crée-t-il 2 generations ?), sampling qui sous-compte les workers, ou prix catalogue ≠ prix contrat. Produis un tableau de réconciliation par tenant.

Exercice 5 — Cross-service distributed tracing

Objectif : un seul trace Langfuse qui traverse Gateway → NestJS → worker Python (FastAPI) → MCP tool → LLM, visible comme un flow unique.

Indice/Solution : propage traceparent (W3C) au point d'entrée, chaque service downstream lit le header et crée un span enfant (cf. section Bonus A). Le piège : si un service oublie de propager le header, tu obtiens 2 traces orphelines au lieu d'une — instrumente un test d'intégration qui assert qu'un seul trace_id apparaît pour une requête traversant les 4 services.

Exercice 6 — PII redaction avant push (conformité DPO)

Objectif : garantir qu'aucun IBAN/NIR/numéro de compte n'atteint Langfuse, même self-hosted, et le prouver à un DPO.

Indice/Solution : middleware Presidio FR en hook beforeSend (ou redaction côté wrapper avant trace.update). Le test de défense : injecte 50 requêtes contenant des PII synthétiques connus, dump la base ClickHouse Langfuse, grep les patterns IBAN/NIR — zéro hit attendu. Bonus production-grade : redige aussi les output (la réponse du LLM peut recracher un IBAN présent dans un chunk), pas seulement les input. Un DPO qui trouve un seul IBAN en clair bloque la mise en prod.

Exercice 7 — Portabilité OTel : prouve que tu n'es pas locked-in (défends le standard)

Objectif : exporter tes spans LLM en OTLP vers un collector neutre (Grafana Tempo ou un otel-collector Docker), et prouver que les attributs respectent la GenAI semconv — donc qu'un backend tiers les lit sans config custom.

Indice/Solution : active l'OTel exporter (Langfuse v3 OTLP, ou instrumente le wrapper directement avec l'OpenTelemetry SDK). Sur un span claude.answer, assert que gen_ai.request.model == "claude-opus-4-8", gen_ai.usage.input_tokens/output_tokens sont présents, et gen_ai.operation.name == "chat". Le vrai piège est le coût : la semconv n'a pas d'attribut standard pour cache_read_input_tokens. Active le prompt caching, regarde le coût recalculé côté Datadog/Grafana à partir des seuls attributs standard — il sera surévalué de 5-8×, exactement comme côté Langfuse. Corrige en poussant anthropic.usage.cache_read_input_tokens en attribut custom + une recording rule qui réintègre le 0,1× du cache. Tu produis un span qui est à la fois portable (standard) et correct au coût (custom là où le standard manque) — c'est la défense complète de la stack "OTel + Langfuse coexistent".


🎤 En entretien

  • "Quelle est la différence entre un trace, un span et une generation, et pourquoi ne pas tout mettre en traces ?" → Un trace = une requête utilisateur (la racine) ; un span = une étape (retrieve, tool) ; une generation = un span typé LLM qui porte le calcul de coût. Un trace par étape rend le flow illisible et casse la corrélation user/session ; un span par requête te fait perdre le coût agrégé. Trace per request, span per step.

  • "Ton dashboard affiche 9 000 € de coût LLM ce mois, la facture Anthropic dit 1 200 €. Que s'est-il passé ?" → Quasi-certainement le wrapper ne pousse pas cache_read_input_tokens : les tokens servis depuis le prompt cache (facturés 0,1× par Anthropic) sont comptés plein tarif input côté obs. Sur un RAG avec gros system prompt caché, ça surestime de 5-8×. Fix : pousser les 4 champs usage ; réconcilier à ±5 % avec la console.

  • "Langfuse tombe en prod. Que se passe-t-il pour tes utilisateurs ?" → Rien, si l'instrumentation est fire-and-forget (flushAt/flushInterval async, jamais d'await flush() bloquant dans le hot path). L'observability ne doit jamais être sur le chemin critique de la requête — un outage obs dégrade la visibilité, pas le service. C'est un invariant qu'on teste explicitement (DR test : couper Langfuse 1h).

  • "On est une banque FR sous RGPD strict, pas le droit au cloud US. Quelle stack d'observability LLM ?" → Langfuse self-hosted (ClickHouse + Postgres + S3) sur Scaleway région Paris, télémétrie externe opt-out, PII redigées avant push via Presidio (hook beforeSend, input et output), audit trail en lecture seule pour le RSSI, OTel exporter en parallèle vers Datadog/Grafana pour le quantitatif. Souveraineté + standard ouvert = pas de lock-in et conformité DPO dès le jour 1.

  • "Pourquoi instrumenter avec un SDK Langfuse ET de l'OTel ? C'est pas redondant ?" → Non, ils ne couvrent pas la même chose. L'OTel GenAI semconv te donne le quantitatif portable et alertable (P95, error rate, débit, gen_ai.usage.*) que n'importe quel backend OTLP comprend sans config — c'est ton lock-in insurance. Le SDK Langfuse te donne le qualitatif (prompt exact, chunks récupérés, scores LLM-as-judge, datasets) et surtout le coût correct : la semconv GenAI n'a pas encore d'attribut standard pour cache_read/creation_input_tokens, donc un coût calculé en OTel pur est faux dès qu'on active le prompt caching. Le bon pattern senior : OTel pour les dashboards ops + alerting, Langfuse pour le debug métier + cost attribution + eval.

  • "Ton span LLM affiche 9s de latence mais input_tokens est normal. Qu'est-ce qui se passe ?" → Le span mesure le wall-clock y compris les retries SDK. Avec maxRetries: 3, le client Anthropic peut boucler en silence sur des 529 overloaded (3s de backoff chacun) sans que ça apparaisse comme un appel séparé dans la trace — tu vois une seule generation à 9s. Le span métier seul ne le montre pas ; c'est pour ça qu'on veut l'OTel en parallèle (instrumente chaque tentative HTTP). Côté coût rassure-toi : Anthropic ne facture pas un 529 avant output, donc le retry ne double pas la facture — sauf si tu as toi-même un try/catch maison qui re-appelle par-dessus le retry SDK, et là tu crées deux generation pour une réponse facturée. Un seul niveau de retry.


🔗 Liens

Bibliothèque tech perso — Achref