Aller au contenu

TechniqueExpert

Observer un système LLM : playbooks de déploiement d'une pile open source (LLM, RAG, MCP)

Pour : ingénieurs et SRE · architectesPrérequis : Bonne connaissance d'OpenTelemetry et du Collector, notions sur les LLM, le RAG et MCP.

Mode de lecture

Cet article est l’étape « Mettre en production » de la progression : il suppose acquis les concepts du guide et la pratique des labs ; la méthode GenAI prend ensuite le relais pour les SLO de qualité, l’impact chiffré et la conformité. Il est aussi la référence du site pour les conventions sémantiques OpenTelemetry GenAI, leur historique et la règle de capture du contenu (section 2) ainsi que pour l’instrumentation de MCP (section 7).

Une application à base de modèle de langage échoue rarement en renvoyant un code 500. Elle échoue en renvoyant un 200 OK, en 3,4 secondes, avec une réponse fausse, construite à partir d’un document obsolète, après un appel d’outil qui a silencieusement retourné une liste vide.

L’APM classique voit la requête. Il ne voit aucune des quatre décisions qui ont produit la mauvaise réponse.

Question posée en exploitationCe que dit l’APM classiqueCe qu’il faut instrumenter
Pourquoi cette réponse est-elle fausse ?riencontenu du contexte, documents récupérés, prompt effectif
Pourquoi la facture a-t-elle doublé ?rienjetons d’entrée et de sortie, par modèle et par route
Pourquoi c’est lent depuis mardi ?latence HTTP globaledécomposition embeddings / recherche / reclassement / inférence
Cet outil a-t-il vraiment fonctionné ?code 200 du serveurrésultat de l’appel MCP, error.type, taille du retour
La qualité dérive-t-elle ?rienscores d’évaluation en ligne, corrélés à la trace

Trois plans distincts doivent être couverts et une pile qui n’en couvre qu’un laisse les deux autres dans l’ombre :

  • le plan d’inférence : appels modèle, jetons, latence, temps au premier jeton, coût ;
  • le plan de récupération : vectorisation, recherche, reclassement, assemblage du contexte ;
  • le plan des outils : appels MCP, franchissement de processus, erreurs d’outil.

Ce qui suit est une série de playbooks de déploiement, du socle minimal à la pile de production, avec des configurations complètes. Les briques sont open source ; pour Langfuse, seul le noyau l’est (voir la section 10).


Le point qui décide de la longévité de votre pile n’est pas le choix du backend, c’est le vocabulaire. Cette section est la référence du site sur ces conventions : les autres contenus y renvoient plutôt que d’en raconter l’histoire.

  • Les conventions gen_ai.* ont quitté le dépôt principal : la version 1.42.0 des conventions sémantiques (12 juin 2026) a déprécié les attributs, métriques, événements et spans gen_ai.*, openai.* et mcp.* et les a déplacés vers le dépôt open-telemetry/semantic-conventions-genai. Le dépôt principal a continué sans eux (1.43.0 en juillet 2026, 1.44.0 en août 2026).
  • Ce dépôt n’a pas encore de version taguée (vérifié le 4 octobre 2026). Son manifeste déclare un schéma de développement (gen-ai-dev/1.42.0-dev) et s’appuie sur les conventions générales 1.44.0. On ne peut donc pas épingler une version publiée : on épingle un commit.
  • Aucun attribut, span, métrique ou événement gen_ai.* n’est stable. Tous portent le statut Development. Les attributs généraux qu’ils réutilisent, comme error.type ou network.transport, sont stables.
  • Les attributs mcp.*, introduits en 1.39.0, ont suivi le même chemin : dépréciés dans le registre principal, ils vivent désormais dans le dépôt GenAI.

Les renommages déjà encaissés donnent le rythme :

VersionChangementConséquence en exploitation
1.37.0 (août 2025)gen_ai.system → gen_ai.provider.name ; les événements par message (gen_ai.user.message, gen_ai.choice…) cèdent la place aux attributs gen_ai.system_instructions, gen_ai.input.messages, gen_ai.output.messages et à l’événement gen_ai.client.inference.operation.detailsdouble lecture obligatoire pendant la transition ; règles de masquage du contenu à réécrire
1.38.0 (octobre 2025)événement gen_ai.evaluation.resultles scores de qualité entrent dans le bus
1.39.0 (janvier 2026)conventions MCP (mcp.*)les appels d’outils MCP deviennent instrumentables en standard
1.40.0 (février 2026)spans retrieval et attributs de cachele RAG devient instrumentable en standard
1.41.0 (avril 2026)invoke_agent scindé en variantes client et interne ; nom de l’outil dans le nom du span execute_tool ; métriques de temps au premier fragmentles tableaux de bord agent cassent
1.42.0 (juin 2026)sortie du dépôt principalplus de version de schéma publiée à citer
dépôt GenAI, branche principale, non publiéegen_ai.client.token.usage et l’attribut gen_ai.token.type remplacés par des compteurs gen_ai.client.inference.usage.* (ventilés par gen_ai.token.modality) et deux histogrammes par opération ; gen_ai.usage.cache_creation.input_tokens renommé gen_ai.usage.cache_write.input_tokensles règles de coût de la section 5.4 lisent l’ancien nom : à traduire au Collector le jour où votre instrumentation change

gen_ai.operation.name porte la nature de l’opération. Les valeurs qui vous concernent en pratique :

OpérationNom du spanCe qu’elle couvre
chatchat {gen_ai.request.model}appel d’inférence conversationnel
embeddingsembeddings {gen_ai.request.model}vectorisation d’une requête ou d’un document
retrievalretrieval {gen_ai.data_source.id}recherche dans une base vectorielle ou un index
execute_toolexecute_tool {gen_ai.tool.name}exécution d’un outil par l’agent
invoke_agentinvoke_agent {gen_ai.agent.name}tour d’agent complet

Les attributs à considérer comme le noyau dur :

INFERENCE
gen_ai.provider.name openai | anthropic | mistral_ai | aws.bedrock ...
(ollama, qdrant : valeurs personnalisees)
gen_ai.request.model modele demande
gen_ai.response.model modele reellement servi (peut differer)
gen_ai.usage.input_tokens entier
gen_ai.usage.output_tokens entier
gen_ai.response.finish_reasons tableau : stop | length | tool_call | error ...
gen_ai.conversation.id identifiant de conversation
RECUPERATION
gen_ai.data_source.id identifiant de la source
gen_ai.retrieval.top_k nombre de documents demandes
gen_ai.retrieval.query.text opt-in, contenu sensible
gen_ai.retrieval.documents opt-in, contenu sensible
MCP
mcp.method.name tools/call | resources/read | server/discover
mcp.protocol.version 2026-07-28 ...
mcp.session.id versions anterieures a 2026-07-28, HTTP seulement
network.transport pipe (stdio) | tcp (HTTP)
error.type renseigne uniquement en echec

Quatre précisions, vérifiées sur le dépôt des conventions le 4 octobre 2026 :

  • gen_ai.provider.name a une liste de valeurs bien connues (openai, anthropic, mistral_ai, aws.bedrock, azure.ai.openai, gcp.vertex_ai, cohere, deepseek, groq…) : si l’une s’applique, elle doit être utilisée ; sinon, une valeur personnalisée est permise. ollama n’en fait pas partie : c’est une valeur personnalisée légitime, comme qdrant sur le span de récupération du playbook 3. Attention à Mistral AI, dont la valeur est mistral_ai et non mistral.
  • gen_ai.response.finish_reasons est un tableau, une raison par choix renvoyé, sans liste fermée dans le registre (exemples : stop, length, error). Le schéma des messages de sortie énumère stop, length, content_filter, tool_call, compaction et error : c’est bien tool_call au singulier, non la valeur brute tool_calls de certains fournisseurs. Une raison attendue mais non reçue (génération en échec, flux interrompu) doit être rapportée comme error.
  • mcp.method.name suit la même règle des valeurs bien connues. Le registre reflète encore les méthodes antérieures à la spécification MCP 2026-07-28 (initialize en fait partie) : server/discover y est pour l’instant une valeur personnalisée.
  • Les métriques : deux couvrent l’essentiel du besoin quotidien, à mon avis. gen_ai.client.operation.duration (histogramme, en secondes) donne la latence par opération. Pour les jetons, les conventions décrivaient jusqu’à leur déménagement l’histogramme gen_ai.client.token.usage, ventilé par gen_ai.token.type ; la branche principale du dépôt GenAI le remplace par les compteurs gen_ai.client.inference.usage.input_tokens et gen_ai.client.inference.usage.output_tokens. Vérifiez lequel votre instrumentation émet : les règles de la section 5.4 sont écrites sur gen_ai.client.token.usage, c’est exactement le cas où la traduction au Collector vous protège. Pour le temps au premier fragment en streaming, gen_ai.client.operation.time_to_first_chunk existe depuis 1.41.0.

Activation côté SDK :

Fenêtre de terminal
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental

Tous les SDK n’honorent pas ce drapeau de la même façon. Vérifiez ce qui sort réellement avant de construire des tableaux de bord dessus : la commande de recette est donnée au playbook 1.

Les prompts, les instructions système et les réponses sont la donnée la plus sensible de la pile. Les conventions sont explicites : les instrumentations ne doivent pas capturer ce contenu par défaut et doivent offrir une option d’activation explicite (opt-in). Elles décrivent trois modes :

  1. par défaut, aucune capture ;
  2. sur les spans, dans les attributs gen_ai.system_instructions, gen_ai.input.messages et gen_ai.output.messages, tous de niveau Opt-In : adapté quand le volume reste maîtrisable et que la réglementation ne s’applique pas ou que le stockage s’y conforme, par exemple en préproduction ;
  3. dans un stockage externe, avec une simple référence sur le span : le mode recommandé en production, parce qu’il permet des contrôles d’accès distincts.

Le même contenu peut aussi voyager dans l’événement gen_ai.client.inference.operation.details, lui aussi Opt-In, qui le stocke indépendamment des traces. Les attributs gen_ai.retrieval.query.text, gen_ai.retrieval.documents et gen_ai.tool.definitions sont également Opt-In.

La règle retenue sur tout le site, dont les autres contenus reprennent les termes : la capture du contenu est désactivée par défaut en production ; elle s’active explicitement, par environnement, avec une rétention propre ; de préférence hors des attributs indexés des spans (événement ou journal dédié, ou stockage externe référencé), avec masquage au Collector ; et son absence se vérifie par une recherche effective dans chaque backend. La mise en œuvre, barrière applicative et barrière du Collector, est détaillée en section 9.

L’espace gen_ai.* est réservé aux conventions. Les attributs propres à une application utilisent un espace de noms à elle : dans ces playbooks llm.* et rag.*, ailleurs sur le site mttl.* ou app.*, jamais gen_ai.*. Un attribut maison placé sous gen_ai.* entrerait en collision avec la prochaine version de la convention.


Un seul bus, plusieurs consommateurs. C’est, à mon avis, la décision d’architecture la plus structurante.

+---------------------------------------------------------------+
| APPLICATION : agent, chaine RAG, client MCP |
| SDK OpenTelemetry + instrumentation GenAI |
+---------------------------------------------------------------+
v OTLP 4317 / 4318
+---------------------------------------------------------------+
| OTEL COLLECTOR : passerelle |
| normalisation > redaction > echantillonnage > derivation |
+---------------------------------------------------------------+
v v v v
+---------------------------------------------------------------+
| Tempo/Jaeger Prometheus/VM Loki/OpenSearch Langfuse |
| traces metriques journaux LLM + evals |
+---------------------------------------------------------------+
v
+---------------------------------------------------------------+
| GRAFANA : exploitation, cout, qualite |
+---------------------------------------------------------------+

Le principe non négociable : l’application n’émet que de l’OTLP. Elle ignore l’existence de Langfuse, de Tempo et de Prometheus. Le jour où vous changez de backend LLM, vous ne redéployez pas quarante services : vous modifiez la section exporters du Collector.

L’erreur symétrique, à éviter, consiste à faire cohabiter deux SDK d’instrumentation, celui du fournisseur d’observabilité LLM et celui d’OpenTelemetry. Vous obtenez deux arbres de traces disjoints, une double facturation d’ingestion et des identifiants de trace qui ne se corrèlent pas. Un seul SDK, une seule passerelle.


4. Playbook 1 : Socle minimal en une demi-journée

Section intitulée « 4. Playbook 1 : Socle minimal en une demi-journée »

Objectif : voir la première trace LLM complète, avec jetons et coût, sans rien décider de définitif.

RôleBriqueLicence
InstrumentationOpenLIT ou OpenLLMetry (Traceloop)Apache-2.0
PasserelleOpenTelemetry Collector contribApache-2.0
Backend LLMLangfuse (auto-hébergé)MIT (noyau)

Langfuse v3 n’est pas un binaire unique : il lui faut PostgreSQL, ClickHouse, Redis et un stockage objet compatible S3. Ne reconstruisez pas cette composition à la main, partez de celle qui est maintenue :

Fenêtre de terminal
git clone --depth 1 https://github.com/langfuse/langfuse.git
Fenêtre de terminal
cd langfuse && docker compose up -d

Interface sur http://localhost:3000. Créez un projet, récupérez la paire de clés pk-lf-... / sk-lf-..., puis fabriquez l’en-tête d’authentification OTLP :

Fenêtre de terminal
export LANGFUSE_BASIC_AUTH=$(printf '%s:%s' "$LANGFUSE_PUBLIC_KEY" "$LANGFUSE_SECRET_KEY" | base64 -w0)

Langfuse expose un point d’entrée OTLP natif sur /api/public/otel, en HTTP uniquement, pas de gRPC. C’est le Collector qui absorbe cette contrainte : l’application continue de parler gRPC si elle le souhaite.

otel-collector.yaml :

receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
batch:
timeout: 2s
send_batch_size: 512
# Double lecture : gen_ai.system (< 1.37) -> gen_ai.provider.name
transform/semconv:
error_mode: ignore
trace_statements:
- set(span.attributes["gen_ai.provider.name"], span.attributes["gen_ai.system"])
where span.attributes["gen_ai.provider.name"] == nil
and span.attributes["gen_ai.system"] != nil
exporters:
otlphttp/langfuse:
endpoint: http://langfuse-web:3000/api/public/otel
headers:
Authorization: "Basic ${env:LANGFUSE_BASIC_AUTH}"
debug:
verbosity: detailed
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, transform/semconv, batch]
exporters: [otlphttp/langfuse, debug]
Fenêtre de terminal
pip install openlit
import openlit
openlit.init(
application_name="assistant-support",
environment="lab",
otlp_endpoint="http://otel-collector:4318",
capture_message_content=False, # lire la section 9 avant de passer a True
)

Variables d’environnement, à poser au niveau du déploiement plutôt que dans le code :

Fenêtre de terminal
OTEL_SERVICE_NAME=assistant-support
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=lab,service.version=1.4.2

Ne validez pas sur « je vois quelque chose dans l’interface ». Validez sur les attributs :

Fenêtre de terminal
docker compose logs otel-collector | grep -E 'gen_ai\.(provider\.name|request\.model|usage\.)' | head -20
ContrôleAttendu
Un span par appel modèlenom chat <modele>
Jetons présentsgen_ai.usage.input_tokens et output_tokens non nuls
Fournisseur normaliségen_ai.provider.name renseigné, y compris sur les SDK anciens
Trace uniquetous les spans d’une requête partagent le même trace_id
Aucun contenuni prompt ni complétion dans la sortie debug

Si le dernier point échoue, arrêtez-vous là et traitez la section 9 avant d’aller plus loin.


Le socle du playbook 1 ne tient pas en production : il envoie 100 % des traces, ne dérive aucune métrique et n’a qu’un seul consommateur.

L’échantillonnage à la queue exige que tous les spans d’une trace atterrissent sur la même instance de Collector. Avec plusieurs répliques, cela impose un premier étage de répartition par trace_id.

Quel que soit l’agent qui reçoit un span, la clé de répartition l’envoie vers la passerelle qui détient sa trace :

flowchart TB
  APP["Applications<br/>OTLP 4317 / 4318"]
  subgraph E1["Étage 1 : agents"]
    A1["Agent 1<br/>memory_limiter, batch,<br/>load_balancing"]
    A2["Agent 2<br/>memory_limiter, batch,<br/>load_balancing"]
  end
  subgraph E2["Étage 2 : passerelles"]
    G1["Passerelle A<br/>tail_sampling, rédaction,<br/>connectors"]
    G2["Passerelle B<br/>tail_sampling, rédaction,<br/>connectors"]
  end
  APP --> A1
  APP --> A2
  A1 -->|"trace X"| G1
  A2 -->|"trace X"| G1
  A1 -->|"trace Y"| G2
  A2 -->|"trace Y"| G2
  G1 --> TP["Tempo<br/>traces"]
  G1 --> VM["VictoriaMetrics<br/>métriques"]
  G1 --> LF["Langfuse<br/>LLM"]
  G2 --> TP
  G2 --> VM
  G2 --> LF

Étage 1, l’essentiel :

exporters:
# anciennement "loadbalancing", nom conserve comme alias deprecie
load_balancing:
routing_key: traceID
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: otel-gateway-headless.observability.svc.cluster.local
port: 4317

À mon avis, le tirage aléatoire est un mauvais choix ici : il jette autant d’incidents que de trafic nominal. La décision doit être prise une fois la trace complète.

processors:
tail_sampling:
decision_wait: 30s
num_traces: 100000
expected_new_traces_per_sec: 500
policies:
- name: erreurs
type: status_code
status_code:
status_codes: [ERROR]
- name: outil-en-echec
type: string_attribute
string_attribute:
key: error.type
values: [".+"]
enabled_regex_matching: true
- name: latence
type: latency
latency:
threshold_ms: 8000
- name: generation-anormalement-longue
type: numeric_attribute
numeric_attribute:
key: gen_ai.usage.output_tokens
min_value: 2000
# min_value: 0 et max_value: 0 seraient ignores (0 vaut "non renseigne"
# pour numeric_attribute) : on passe par une condition OTTL.
- name: recuperation-vide
type: ottl_condition
ottl_condition:
error_mode: ignore
span:
- span.attributes["rag.documents.count"] == 0
- name: nominal
type: probabilistic
probabilistic:
sampling_percentage: 5

Les politiques sont en OU logique : une trace retenue par n’importe laquelle est conservée. Le résultat visé : 100 % du pathologique, 5 % du reste.

Plutôt que d’instrumenter deux fois, laissez le Collector fabriquer les métriques RED à partir des spans :

processors:
# gen_ai.response.finish_reasons est un tableau (un motif par choix) :
# on en tire une chaine, dans un attribut du modele interne.
transform/finish_reason:
error_mode: ignore
trace_statements:
- set(span.attributes["llm.finish_reason"], span.attributes["gen_ai.response.finish_reasons"][0])
where IsList(span.attributes["gen_ai.response.finish_reasons"])
- set(span.attributes["llm.finish_reason"], span.attributes["gen_ai.response.finish_reasons"])
where IsString(span.attributes["gen_ai.response.finish_reasons"])
connectors:
spanmetrics:
histogram:
explicit:
buckets: [100ms, 250ms, 500ms, 1s, 2s, 5s, 10s, 30s, 60s]
dimensions:
- name: gen_ai.provider.name
- name: gen_ai.request.model
- name: gen_ai.operation.name
- name: mcp.method.name
- name: deployment.environment.name
- name: llm.finish_reason

Le connecteur recopie la valeur de l’attribut telle quelle : un tableau resterait un tableau et l’étiquette obtenue après traduction vers Prometheus serait une forme sérialisée (du type ["length"]) que le filtre ="length" ne trouverait pas. D’où le processeur transform/finish_reason, qui en tire une chaîne. Il doit figurer dans le pipeline de traces avant l’exportateur spanmetrics. Il ne garde que le motif du premier choix, ce qui suffit pour les appels à une seule complétion (n=1), le cas courant. L’attribut produit appartient au modèle interne (llm.*) et non à gen_ai.*, selon la règle de la section 2.4.

Attention à la cardinalité. Chaque dimension multiplie le nombre de séries actives. Les six ci-dessus sont bornées. gen_ai.conversation.id, un identifiant d’utilisateur ou le nom d’un document récupéré ne le sont pas : ces valeurs appartiennent aux traces, jamais aux étiquettes de métriques. Le simulateur « Le coût se joue au Collector » chiffre l’effet de ces choix.

Prometheus ne connaît pas vos tarifs. Exposez-les comme une série statique, via le collecteur textfile de node_exporter ou un exportateur de vingt lignes. Les prix changent vite et varient selon les contrats : cet article n’en donne pas ; utilisez la grille datée de votre fournisseur, convertie dans votre devise à une date que vous notez. Remplacez chaque <...> par une valeur numérique en euros par jeton :

genai_prix.prom
# A renseigner depuis la grille datee de votre fournisseur (euros par jeton)
genai_prix_euro_par_token{model="grand-modele",token_type="input"} <prix_entree_grand>
genai_prix_euro_par_token{model="grand-modele",token_type="output"} <prix_sortie_grand>
genai_prix_euro_par_token{model="petit-modele",token_type="input"} <prix_entree_petit>
genai_prix_euro_par_token{model="petit-modele",token_type="output"} <prix_sortie_petit>

Tant que ce fichier n’existe pas, la jointure ci-dessous ne renvoie rien : genai:cout_euro_par_heure n’est pas produite, mais genai:jetons:rate5m l’est et vous pouvez alerter sur les jetons avec la même règle de rapport à la référence de sept jours.

Puis les règles d’enregistrement :

groups:
- name: genai-cout
interval: 60s
rules:
- record: genai:jetons:rate5m
expr: |
sum by (service_name, gen_ai_request_model, gen_ai_token_type) (
rate(gen_ai_client_token_usage_sum[5m])
)
- record: genai:cout_euro_par_heure
expr: |
sum by (service_name, gen_ai_request_model) (
genai:jetons:rate5m * 3600
* on (gen_ai_request_model, gen_ai_token_type) group_left()
label_replace(
label_replace(genai_prix_euro_par_token,
"gen_ai_request_model", "$1", "model", "(.*)"),
"gen_ai_token_type", "$1", "token_type", "(.*)")
)

Les alertes qui servent réellement :

- alert: CoutHoraireLLMAnormal
expr: |
genai:cout_euro_par_heure
> 3 * avg_over_time(genai:cout_euro_par_heure[7d] offset 1h)
for: 15m
annotations:
summary: "Cout LLM x3 vs reference 7 jours"
- alert: TroncatureDeGeneration
expr: |
sum(rate(traces_span_metrics_calls_total{
llm_finish_reason="length"}[10m]))
/ sum(rate(traces_span_metrics_calls_total{
gen_ai_operation_name="chat"}[10m])) > 0.05
for: 10m
annotations:
summary: "Plus de 5% des generations coupees par la limite de jetons"

La seconde alerte repose sur la dimension llm.finish_reason ajoutée en 5.3. À mon avis, elle est rarement posée alors qu’elle peut expliquer une partie des « réponses incomplètes » remontées par les utilisateurs.


PanneSymptôme utilisateurSignal qui la révèleSignal qui ne la révèle pas
Récupération vide ou hors sujet« il invente »rag.documents.count, score du top-1latence, code HTTP
Contexte récupéré mais ignoré« il répond à côté »ratio documents cités / récupéréstout le reste
Index périmé« il donne l’ancienne procédure »rag.index.version, âge de l’indexqualité du modèle
Contexte tronquéréponse partiellerag.context.truncated, finish_reasons=lengthscores de récupération
Dérive de l’embedderdégradation lente et diffusedistribution des scores dans le tempsune trace isolée

Les deux dernières lignes sont invisibles sur une trace unique. Elles n’apparaissent que dans la distribution, donc en métriques, pas en traces.

La chaîne se découpe en trois spans, pas un.

from opentelemetry import trace
tracer = trace.get_tracer("rag.pipeline")
INDEX = "kb-support-fr"
INDEX_VERSION = "v7"
MODELE = "grand-modele" # nom illustratif
def repondre(question: str) -> str:
with tracer.start_as_current_span("embeddings text-embedding-3-large") as sp:
sp.set_attribute("gen_ai.operation.name", "embeddings")
sp.set_attribute("gen_ai.provider.name", "openai")
sp.set_attribute("gen_ai.request.model", "text-embedding-3-large")
vecteur = embedder.encode(question)
with tracer.start_as_current_span(f"retrieval {INDEX}") as sp:
sp.set_attribute("gen_ai.operation.name", "retrieval")
sp.set_attribute("gen_ai.data_source.id", INDEX)
sp.set_attribute("gen_ai.provider.name", "qdrant")
sp.set_attribute("gen_ai.retrieval.top_k", 8)
sp.set_attribute("rag.index.version", INDEX_VERSION)
docs = store.search(vecteur, k=8)
sp.set_attribute("rag.documents.count", len(docs))
sp.set_attribute("rag.score.top", docs[0].score if docs else 0.0)
sp.set_attribute("rag.score.min_kept", docs[-1].score if docs else 0.0)
# identifiants, jamais le contenu
sp.set_attribute("rag.document.ids", [d.id for d in docs][:8])
with tracer.start_as_current_span(f"chat {MODELE}") as sp:
contexte, tronque = assembler(docs, budget=12_000)
sp.set_attribute("rag.context.chars", len(contexte))
sp.set_attribute("rag.context.truncated", tronque)
reponse = client.chat(question, contexte)
sp.set_attribute("rag.documents.cited", compter_citations(reponse, docs))
return reponse

Trois choix méritent d’être explicités.

rag.index.version sur chaque span de récupération. Sans cet attribut, une réindexation ratée est indétectable a posteriori : vous verrez une dégradation de qualité sans pouvoir la dater ni l’attribuer. Avec lui, une seule requête suffit à confirmer la corrélation.

Les identifiants de documents, pas leur contenu. Un identifiant est borné, non personnel et suffit à rejouer la récupération. Le contenu du fragment est volumineux, souvent confidentiel et n’a rien à faire dans un span par défaut. L’attribut gen_ai.retrieval.documents existe dans la convention, il y est classé opt-in ; ce classement est un avertissement, pas une formalité.

rag.documents.cited. C’est à mon avis la mesure la plus utile de toute la chaîne. Le ratio cited / count mesure l’utilisation effective du contexte. Un ratio qui s’effondre alors que les scores de récupération restent bons signale que le problème est passé du récupérateur au générateur, typiquement après un changement de modèle ou de prompt système.

Les attributs de span ne deviennent pas des métriques tout seuls. Les séries rag_* ci-dessous sont des métriques applicatives à émettre vous-même, avec le SDK OpenTelemetry, à côté des attributs de span :

from opentelemetry import metrics
meter = metrics.get_meter("rag.pipeline")
documents = meter.create_histogram(
"rag.documents.count",
explicit_bucket_boundaries_advisory=[0, 1, 2, 4, 8])
score_top = meter.create_histogram(
"rag.score.top",
explicit_bucket_boundaries_advisory=[0.2, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0])
assemblages = meter.create_counter("rag.context.truncated")
# dans repondre(), apres la recherche puis apres l'assemblage du contexte
etiquettes = {"rag.index.version": INDEX_VERSION}
documents.record(len(docs), etiquettes)
score_top.record(docs[0].score if docs else 0.0, etiquettes)
assemblages.add(1, {**etiquettes, "truncated": str(tronque).lower()})

Une fois exportées en OTLP vers un backend compatible Prometheus, avec la traduction de noms habituelle (points remplacés par des soulignés, suffixe _total pour les compteurs, sans unité déclarée), elles donnent les requêtes suivantes :

sum(rate(rag_documents_count_bucket{le=~"0(\\.0)?"}[5m]))
/ sum(rate(rag_documents_count_count[5m])) -> taux de recuperation vide
histogram_quantile(0.5, sum by (le, rag_index_version) (
rate(rag_score_top_bucket[30m]))) -> mediane du score top-1
sum(rate(rag_context_truncated_total{truncated="true"}[15m]))
/ sum(rate(rag_context_truncated_total[15m])) -> taux de troncature

Une chute de la médiane du score top-1 qui coïncide avec un changement de rag_index_version est un diagnostic, pas une hypothèse.


Un serveur MCP en stdio est un processus séparé qui communique en JSON-RPC sur des tubes. Il n’y a pas d’en-têtes HTTP, donc pas de traceparent là où les propagateurs OpenTelemetry le cherchent d’habitude. Sans traitement spécifique, chaque appel d’outil produit une trace orpheline : vous avez la latence, vous avez perdu le lien avec la conversation qui l’a déclenchée.

La réponse est normalisée. Le SEP-414 du protocole MCP, intégré à la spécification 2026-07-28, documente l’usage de params._meta comme porteur du contexte W3C, avec une exception explicite à la règle de préfixage DNS des clés.

{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": { "location": "New York" },
"_meta": {
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"
}
}
}

Les clés sont traceparent, tracestate et baggage, sans préfixe. Une implémentation qui écrirait io.modelcontextprotocol.traceparent casserait la corrélation avec tout l’écosystème existant.

CLIENT MCP : agent SERVEUR MCP : outil
+-------------------------------+ +-------------------------------+
| span kind = CLIENT | | span kind = SERVER |
| nom : tools/call get_weather | | nom : tools/call get_weather |
| mcp.method.name = tools/call | | mcp.method.name = tools/call |
| mcp.protocol.version | | mcp.protocol.version |
| = 2026-07-28 | | = 2026-07-28 |
| network.transport = pipe | | network.transport = pipe |
+-------------------------------+ +-------------------------------+
JSON-RPC : params._meta.traceparent = 00-<trace>-<span>-01
meme trace_id des deux cotes, relation parent-enfant preservee

Le même mécanisme, déroulé dans le temps sur un tour d’agent complet :

sequenceDiagram
  participant U as Utilisateur
  participant A as Agent, client MCP
  participant L as LLM
  participant S as Serveur MCP
  U->>A: question
  A->>L: chat, span CLIENT
  L-->>A: finish_reason tool_call
  A->>S: tools/call avec params._meta.traceparent
  Note over A,S: même trace_id, le span SERVER est enfant du span CLIENT
  S-->>A: résultat, ou erreur avec error.type
  A->>L: chat avec le résultat de l'outil
  L-->>A: réponse finale
  A-->>U: réponse
from opentelemetry import trace
from opentelemetry.propagate import inject
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("mcp")
VERSION_MCP = "2026-07-28"
def appeler_outil(message: dict, serveur: str):
methode = message["method"]
with tracer.start_as_current_span(
f"{methode} {serveur}", kind=SpanKind.CLIENT
) as sp:
sp.set_attribute("mcp.method.name", methode)
sp.set_attribute("mcp.protocol.version", VERSION_MCP)
sp.set_attribute("network.transport", "pipe")
meta = message.setdefault("params", {}).setdefault("_meta", {})
# depuis 2026-07-28, chaque requete porte sa version de protocole
# et les capacites du client (deux cles obligatoires)
meta["io.modelcontextprotocol/protocolVersion"] = VERSION_MCP
meta.setdefault("io.modelcontextprotocol/clientCapabilities", {})
inject(meta) # traceparent SANS prefixe DNS, SEP-414
reponse = transport.send(message)
if "error" in reponse:
sp.set_attribute("error.type", str(reponse["error"].get("code")))
sp.set_status(trace.StatusCode.ERROR)
return reponse
from opentelemetry import trace
from opentelemetry.propagate import extract
from opentelemetry.trace import SpanKind
tracer = trace.get_tracer("mcp")
def traiter(message: dict):
porteur = (message.get("params") or {}).get("_meta") or {}
ctx = extract(porteur) # lit traceparent / tracestate / baggage
methode = message["method"]
with tracer.start_as_current_span(
f"{methode} {NOM_SERVEUR}", context=ctx, kind=SpanKind.SERVER
) as sp:
sp.set_attribute("mcp.method.name", methode)
version = porteur.get("io.modelcontextprotocol/protocolVersion")
if version:
sp.set_attribute("mcp.protocol.version", version)
sp.set_attribute("network.transport", "pipe")
if methode == "tools/call":
sp.set_attribute("gen_ai.tool.name", message["params"]["name"])
return executer(message)

Si votre client ou votre serveur utilise un SDK MCP officiel, une partie de ce travail est peut-être déjà faite en amont et des instrumentations tierces existent également. Vérifiez avant d’instrumenter à la main : une double instrumentation produit des spans en double et fausse tous les comptages.

Sessions et poignée de main. La spécification MCP 2026-07-28 rend le protocole sans état : elle supprime la poignée de main initialize, les sessions protocolaires et l’en-tête Mcp-Session-Id. Chaque requête porte sa version de protocole et les capacités du client dans _meta et un serveur annonce les versions qu’il accepte par la méthode server/discover. L’identifiant de session n’a d’ailleurs jamais figuré dans le message JSON-RPC : dans les versions antérieures, c’était un en-tête HTTP du transport Streamable HTTP, absent en stdio. Si vous devez encore servir des clients antérieurs à 2026-07-28 en HTTP, lisez mcp.session.id dans cet en-tête, pas dans le message.

IndicateurDécoupagePourquoi
Taux d’échec par outilerror.type par gen_ai.tool.nameun outil qui échoue à 30 % dégrade l’agent sans erreur visible
Latence p95 par outilmcp.method.name="tools/call"un outil lent est multiplié par le nombre de tours d’agent
Appels par tour d’agentspans MCP sous invoke_agentdétection des boucles d’appels d’outil
Outils appelés hors listegen_ai.tool.name hors liste blanchedérive de prompt ou injection indirecte
Requêtes rejetées pour version de protocoleerreurs UnsupportedProtocolVersion par mcp.protocol.versionclients ou serveurs pas encore migrés vers 2026-07-28

La quatrième ligne mérite un mot. Un serveur MCP est une surface d’exécution atteignable par le contenu que traite le modèle. L’observabilité MCP n’est donc pas seulement un sujet de performance : la liste des outils réellement appelés, comparée à la liste des outils attendus pour une route donnée, est, à mon avis, un signal de sécurité important. C’est aussi l’un des rares endroits où une injection indirecte peut devenir visible.


Sans mesure de qualité, la pile vous dit que tout va bien pendant que la pertinence s’effondre.

Les deux étages de la boucle, hors ligne avant le déploiement et en ligne après, se répondent :

flowchart TB
  CH["Changement<br/>prompt, modèle,<br/>découpage, index"] --> CI{"Hors ligne en CI<br/>jeu de référence"}
  CI -->|"sous le seuil"| BL["Déploiement<br/>bloqué"]
  CI -->|"seuils tenus"| PR["Production"]
  PR --> EC["Échantillon 1 à 3 %<br/>juge épinglé"]
  EC --> EV["gen_ai.evaluation.result<br/>corrélé au span"]
  EV --> AL{"Alerte<br/>de dérive"}
  AL -->|"oui"| DG["Diagnostic<br/>index, prompt, modèle"]
  DG --> CH
  DG -.->|"nouveaux cas"| CI

Un jeu de référence versionné, exécuté à chaque changement de prompt, de modèle, de découpage ou d’index.

Fenêtre de terminal
pip install "ragas==0.4.3"
import math
import statistics
from ragas import evaluate
# Import historique, accepte par evaluate() en 0.4.3 avec un avertissement
# de depreciation (les nouvelles metriques vivent dans ragas.metrics.collections).
from ragas.metrics import (
faithfulness, answer_relevancy,
context_precision, context_recall,
)
resultat = evaluate(
dataset=jeu_de_reference, # question, contexte, reponse, verite
metrics=[faithfulness, answer_relevancy,
context_precision, context_recall],
)
SEUILS = {"faithfulness": 0.85, "context_recall": 0.80}
for metrique, seuil in SEUILS.items():
# resultat[metrique] est la liste des scores par exemple : on compare la moyenne
scores = [s for s in resultat[metrique] if s is not None and not math.isnan(s)]
moyenne = statistics.fmean(scores) if scores else 0.0
if moyenne < seuil:
raise SystemExit(f"REGRESSION {metrique}: {moyenne:.3f} < {seuil}")

Les seuils de 0,85 et 0,80 sont des exemples, à caler sur vos propres mesures. Les quatre métriques ne mesurent pas la même chose et le diagnostic dépend de laquelle décroche :

Métrique en baisseComposant en cause
context_recalldécoupage, embedder, top_k trop faible
context_precisionabsence de reclassement, seuil de score trop permissif
faithfulnessprompt système, modèle, contexte tronqué
answer_relevancyprompt système, reformulation de la question

Le jeu de référence ne contient pas les questions que vos utilisateurs poseront demain. Il faut juger un échantillon du trafic réel. Le principe commun du site, détaillé au guide, section 5.7 : c’est le nombre de jugements par segment et par période qui compte, pas le pourcentage. Un taux de 1 à 3 % est un exemple de point de départ, à ajuster pour obtenir assez de jugements par fenêtre d’alerte ; chaque jugement étant lui-même un appel modèle, le coût limite vite ce taux.

Le résultat s’écrit dans le bus sous forme d’événement gen_ai.evaluation.result, introduit en 1.38.0, corrélé au span d’origine. Un événement ne devient pas une métrique tout seul : le service d’évaluation émet en plus un histogramme applicatif, par exemple meter.create_histogram("genai.evaluation.score") enregistré avec l’attribut evaluation_name, dont les bornes incluent le seuil de qualité (par exemple explicit_bucket_boundaries_advisory=[0.5, 0.6, 0.7, 0.8, 0.9, 1.0]). Il devient ainsi une métrique alertable.

L’alerte porte sur une proportion, pas sur une moyenne : la part des réponses jugées dont le score dépasse le seuil, comparée à une cible. Une moyenne peut rester stable pendant qu’une fraction croissante des réponses passe sous le seuil.

- alert: SLOQualiteFidelite
expr: |
(
1 - sum(rate(genai_evaluation_score_bucket{evaluation_name="faithfulness", le="0.8"}[6h]))
/ sum(rate(genai_evaluation_score_count{evaluation_name="faithfulness"}[6h]))
) < 0.95
and sum(increase(genai_evaluation_score_count{evaluation_name="faithfulness"}[6h])) >= 100
for: 30m
annotations:
summary: "Moins de 95% des reponses jugees au-dessus de 0,8 en fidelite sur 6h"

Le seuil (0,8), la cible (95 %) et le minimum de 100 jugements sont des exemples. Cette règle compare encore une valeur ponctuelle à la cible : la méthode GenAI, partie IV compare la borne inférieure de l’intervalle de confiance (borne de Wilson), dimensionne l’échantillon et en tire budget d’erreur et burn rate multi-fenêtre. C’est elle qui fait référence pour les SLO de qualité.

Trois précautions, dans l’ordre d’importance :

  1. Épinglez la version du modèle juge. Un juge qui change de version décale tous vos scores et vous passerez une semaine à chercher une régression applicative qui n’existe pas.
  2. Ne jugez pas avec le modèle jugé. Un modèle juge tend à favoriser ses propres productions : ce biais d’auto-préférence a été mesuré par Panickssery, Bowman et Feng (NeurIPS 2024) sur des tâches de résumé (XSUM, CNN/DailyMail), avec une ampleur qui varie selon les modèles.
  3. Budgétez le juge. Par exemple, à 3 % de 500 000 requêtes par jour, cela fait 15 000 appels quotidiens, qui apparaîtront dans le tableau de bord de coût du playbook 2. Étiquetez-les avec un service.name distinct pour ne pas les confondre avec le trafic applicatif.

9. Confidentialité : ne pas transformer l’observabilité en fuite

Section intitulée « 9. Confidentialité : ne pas transformer l’observabilité en fuite »

C’est le point où une pile d’observabilité LLM devient un incident.

Les prompts et les complétions contiennent, par construction, ce que l’utilisateur a écrit : données personnelles, données de santé, éléments contractuels, parfois un secret collé dans une fenêtre de discussion. Les documents récupérés par le RAG contiennent le référentiel interne, y compris ce que l’utilisateur n’avait pas le droit de consulter. Tout cela est répliqué, indexé et conservé.

DonnéeRisqueOù la traiter
Prompt utilisateurdonnées personnelles, secretscapture désactivée par défaut, masquage au Collector
Complétionrestitution de données du contextemême traitement, rétention courte
Documents récupérésfuite du référentiel, contournement des droitsidentifiants seulement, jamais le contenu
Arguments d’appel MCPidentifiants, jetons, cheminsliste blanche d’arguments, pas de capture en masse
gen_ai.conversation.idré-identification par recoupementpseudonymiser, jamais en étiquette de métrique

La règle, posée en section 2.3 : le contenu est désactivé par défaut et activé explicitement, par environnement, avec une rétention propre. Le drapeau existe côté SDK (capture_message_content, ou une variable OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT selon les implémentations ; le nom varie encore, vérifiez le vôtre). Il ne suffit pas : un drapeau se remet à true par accident, au détour d’une session de débogage. La deuxième barrière est dans le Collector et celle-là ne se contourne pas depuis une application.

processors:
transform/confidentialite:
error_mode: ignore
trace_statements:
# 1. Masquage des motifs les plus courants
- replace_pattern(span.attributes["gen_ai.input.messages"],
"[\\w.+-]+@[\\w-]+\\.[\\w.]{2,}", "[courriel]")
- replace_pattern(span.attributes["gen_ai.input.messages"],
"\\b(?:\\d[ -]?){13,19}\\b", "[carte]")
- replace_pattern(span.attributes["gen_ai.output.messages"],
"[\\w.+-]+@[\\w-]+\\.[\\w.]{2,}", "[courriel]")
# 2. Suppression hors des environnements autorises
- delete_key(span.attributes, "gen_ai.input.messages")
where resource.attributes["deployment.environment.name"] == "production"
- delete_key(span.attributes, "gen_ai.output.messages")
where resource.attributes["deployment.environment.name"] == "production"
- delete_key(span.attributes, "gen_ai.retrieval.documents")

Selon la version de votre instrumentation, le contenu peut arriver non pas en attribut de span mais en enregistrement de journal, les événements GenAI. replace_pattern n’agit que sur une valeur de type chaîne : si votre instrumentation émet les messages sous forme structurée, le masquage ne s’applique pas et seule la suppression protège. Dupliquez enfin les mêmes règles dans log_statements, en remplaçant le préfixe span. par log. : un pipeline traces propre doublé d’un pipeline logs non filtré est, à mon avis, le piège le plus facile à manquer dans cette section.


10. Choisir les briques : la question des licences

Section intitulée « 10. Choisir les briques : la question des licences »

Le paysage des outils et leurs licences ont une seule maison sur le site : le chapitre 6 du guide. Ne restent ici que les points dont dépendent les playbooks.

Brique des playbooksLicencePoint de vigilance
OpenTelemetry Collector, Jaeger, Prometheus, OpenLIT, OpenLLMetry, RagasApache-2.0aucun pour un usage interne
VictoriaMetricsApache-2.0 (édition communautaire, version cluster comprise)downsampling, rétentions multiples, détection d’anomalies en édition entreprise
Grafana, Tempo, LokiAGPL-3.0modifier la brique et l’ouvrir à des utilisateurs par le réseau, même sans la distribuer, oblige à leur proposer le code source modifié
Langfuse (playbook 1)MIT pour le noyaucertaines fonctions sous licence commerciale ; appartient à ClickHouse depuis janvier 2026 (annonce de ClickHouse)
Arize Phoenix (kit des labs)Elastic License 2.0 (source disponible)pas open source au sens de l’OSI : interdiction d’offrir le produit en service géré à des tiers ; sans effet pour un usage interne

Pour choisir la brique du plan LLM, appliquez les trois mêmes critères à toutes les options : la licence, la possibilité d’auto-héberger et la capacité à recevoir de l’OTLP avec les conventions gen_ai.* sans SDK propriétaire. Dans tous les cas, gardez votre pile existante pour le reste : vous ne dupliquez pas Tempo et Prometheus, vous ajoutez un consommateur au bus.


Le critère que je recommande de retenir avant tous les autres : partir d’une plainte utilisateur et remonter jusqu’à la cause en moins de cinq minutes, sans accès à la machine de production.

  • Une requête utilisateur produit une seule trace, du point d’entrée HTTP jusqu’au serveur MCP inclus.
  • Les jetons d’entrée et de sortie sont présents sur 100 % des spans d’inférence.
  • Le coût horaire par service et par modèle est affiché et alerté sur écart à la référence hebdomadaire.
  • Les spans de récupération portent rag.documents.count, le score du top-1 et la version d’index.
  • Le ratio documents cités / documents récupérés est mesuré.
  • Les spans MCP client et serveur partagent le même trace_id via params._meta.traceparent.
  • Le taux d’échec est visible par outil MCP, pas seulement en agrégat.
  • Le taux de générations coupées par la limite de jetons est suivi.
  • Les prompts et complétions sont absents des backends de production, vérifié par recherche effective.
  • Un score de qualité en ligne existe, alimenté par un échantillon, avec un modèle juge épinglé.
  • L’échantillonnage conserve 100 % des erreurs et des traces lentes.
  • Aucun attribut non borné ne sert d’étiquette de métrique.
  • Le passage d’un backend à un autre ne demande aucune modification applicative.

Si la dernière case n’est pas cochée, vous n’avez pas déployé une pile d’observabilité : vous avez déployé un client propriétaire avec des étapes supplémentaires.


Trois idées à retenir, dans cet ordre.

Le vocabulaire prime sur l’outillage. Les conventions GenAI sont en mouvement : dépôt déplacé, aucun attribut stable, renommages réguliers. Instrumentez vers OpenTelemetry, mais construisez vos tableaux de bord et vos alertes sur un modèle interne que vous maîtrisez et absorbez les changements dans le Collector.

Le RAG et MCP sont les deux plans que les piles oublient. L’inférence est instrumentée par défaut par toutes les bibliothèques. La récupération et les appels d’outils ne le sont pas et ce sont, à mon avis, eux qui produisent une bonne part des mauvaises réponses. Un score de récupération, une version d’index et un traceparent dans _meta : trois éléments, une demi-journée de travail en ordre de grandeur et une partie importante des incidents devient diagnosticable.

L’observabilité d’un système LLM est un traitement de données personnelles. Une trace complète contient ce que l’utilisateur a écrit et ce que le référentiel interne contenait. La capture de contenu se décide donc comme une mesure de conformité, avec une barrière côté application et une seconde côté Collector, testée par recherche effective dans les backends. Une pile qui expose les prompts à toute l’équipe d’exploitation n’est pas un progrès d’observabilité, c’est un incident en attente de déclaration.

Suite de la progression : VictoriaMetrics pour les LLM pour le backend de métriques de production, puis la méthode GenAI pour les SLO de qualité, l’impact chiffré et la conformité. Pour revenir aux concepts : le guide ; pour pratiquer sur une pile complète : les labs.

Révisé le 2 octobre 2026 : prix retirés, section MCP mise à jour pour la spécification 2026-07-28 (plus de sessions protocolaires ni de poignée de main initialize, version de protocole dans _meta), dimension llm.finish_reason ajoutée au connecteur spanmetrics, métriques rag_* et genai_evaluation_score données comme métriques applicatives à émettre, barrière Ragas corrigée et épinglée en 0.4.3, exportateur renommé load_balancing, politique « récupération vide » passée en condition OTTL, instructions OTTL écrites avec chemins préfixés, prix rendus illustratifs, licence de VictoriaMetrics corrigée, rachat de Langfuse par ClickHouse mentionné, choix de la brique LLM reformulé sur critères explicites avec alternatives, portée de l’AGPL précisée, biais d’auto-préférence des juges sourcé, estimations requalifiées en avis ou ordres de grandeur, clé clientCapabilities ajoutée au client MCP, filtre le adapté à la normalisation de Prometheus 3.

Révisé le 4 octobre 2026 : page passée en MDX avec l’encart de progression, section 2 devenue la référence du site sur les conventions OpenTelemetry GenAI (historique des versions daté et vérifié sur le dépôt des conventions, valeurs bien connues de gen_ai.provider.name avec mistral_ai et ollama présenté comme valeur personnalisée, tool_call au singulier dans gen_ai.response.finish_reasons, remplacement en cours de gen_ai.client.token.usage, noms des spans execute_tool et invoke_agent, règle de capture du contenu, espace de noms des attributs maison), alerte qualité reformulée en proportion avec renvoi à la méthode, échantillonnage du juge rattaché au principe du guide, section licences réduite aux briques des playbooks avec renvoi au guide, liens vers le guide, les labs et la méthode.