Technique
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.
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).
1. Le problème posé correctement
Section intitulée « 1. Le problème posé correctement »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 exploitation | Ce que dit l’APM classique | Ce qu’il faut instrumenter |
|---|---|---|
| Pourquoi cette réponse est-elle fausse ? | rien | contenu du contexte, documents récupérés, prompt effectif |
| Pourquoi la facture a-t-elle doublé ? | rien | jetons d’entrée et de sortie, par modèle et par route |
| Pourquoi c’est lent depuis mardi ? | latence HTTP globale | décomposition embeddings / recherche / reclassement / inférence |
| Cet outil a-t-il vraiment fonctionné ? | code 200 du serveur | résultat de l’appel MCP, error.type, taille du retour |
| La qualité dérive-t-elle ? | rien | scores 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).
2. Le socle sémantique : OpenTelemetry GenAI
Section intitulée « 2. Le socle sémantique : OpenTelemetry GenAI »2.1 État du standard en octobre 2026
Section intitulée « 2.1 État du standard en octobre 2026 »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 spansgen_ai.*,openai.*etmcp.*et les a déplacés vers le dépôtopen-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, commeerror.typeounetwork.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 :
| Version | Changement | Consé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.details | double lecture obligatoire pendant la transition ; règles de masquage du contenu à réécrire |
| 1.38.0 (octobre 2025) | événement gen_ai.evaluation.result | les 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 cache | le 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 fragment | les tableaux de bord agent cassent |
| 1.42.0 (juin 2026) | sortie du dépôt principal | plus de version de schéma publiée à citer |
| dépôt GenAI, branche principale, non publiée | gen_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_tokens | les règles de coût de la section 5.4 lisent l’ancien nom : à traduire au Collector le jour où votre instrumentation change |
2.2 Le vocabulaire utile
Section intitulée « 2.2 Le vocabulaire utile »gen_ai.operation.name porte la nature de l’opération. Les valeurs qui vous concernent en pratique :
| Opération | Nom du span | Ce qu’elle couvre |
|---|---|---|
chat | chat {gen_ai.request.model} | appel d’inférence conversationnel |
embeddings | embeddings {gen_ai.request.model} | vectorisation d’une requête ou d’un document |
retrieval | retrieval {gen_ai.data_source.id} | recherche dans une base vectorielle ou un index |
execute_tool | execute_tool {gen_ai.tool.name} | exécution d’un outil par l’agent |
invoke_agent | invoke_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 echecQuatre précisions, vérifiées sur le dépôt des conventions le 4 octobre 2026 :
gen_ai.provider.namea 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.ollaman’en fait pas partie : c’est une valeur personnalisée légitime, commeqdrantsur le span de récupération du playbook 3. Attention à Mistral AI, dont la valeur estmistral_aiet nonmistral.gen_ai.response.finish_reasonsest 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èrestop,length,content_filter,tool_call,compactioneterror: c’est bientool_callau singulier, non la valeur brutetool_callsde certains fournisseurs. Une raison attendue mais non reçue (génération en échec, flux interrompu) doit être rapportée commeerror.mcp.method.namesuit 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 (initializeen fait partie) :server/discovery 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’histogrammegen_ai.client.token.usage, ventilé pargen_ai.token.type; la branche principale du dépôt GenAI le remplace par les compteursgen_ai.client.inference.usage.input_tokensetgen_ai.client.inference.usage.output_tokens. Vérifiez lequel votre instrumentation émet : les règles de la section 5.4 sont écrites surgen_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_chunkexiste depuis 1.41.0.
Activation côté SDK :
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimentalTous 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.
2.3 Capture du contenu : la règle
Section intitulée « 2.3 Capture du contenu : la règle »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 :
- par défaut, aucune capture ;
- sur les spans, dans les attributs
gen_ai.system_instructions,gen_ai.input.messagesetgen_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 ; - 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.
2.4 Les attributs propres à l’application
Section intitulée « 2.4 Les attributs propres à l’application »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.
3. L’architecture cible
Section intitulée « 3. L’architecture cible »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.
4.1 Composants
Section intitulée « 4.1 Composants »| Rôle | Brique | Licence |
|---|---|---|
| Instrumentation | OpenLIT ou OpenLLMetry (Traceloop) | Apache-2.0 |
| Passerelle | OpenTelemetry Collector contrib | Apache-2.0 |
| Backend LLM | Langfuse (auto-hébergé) | MIT (noyau) |
4.2 Déployer Langfuse
Section intitulée « 4.2 Déployer Langfuse »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 :
git clone --depth 1 https://github.com/langfuse/langfuse.gitcd langfuse && docker compose up -dInterface 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 :
export LANGFUSE_BASIC_AUTH=$(printf '%s:%s' "$LANGFUSE_PUBLIC_KEY" "$LANGFUSE_SECRET_KEY" | base64 -w0)4.3 Le Collector
Section intitulée « 4.3 Le Collector »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]4.4 Instrumenter l’application
Section intitulée « 4.4 Instrumenter l’application »pip install openlitimport 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 :
OTEL_SERVICE_NAME=assistant-supportOTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318OTEL_EXPORTER_OTLP_PROTOCOL=http/protobufOTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimentalOTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=lab,service.version=1.4.24.5 Recette
Section intitulée « 4.5 Recette »Ne validez pas sur « je vois quelque chose dans l’interface ». Validez sur les attributs :
docker compose logs otel-collector | grep -E 'gen_ai\.(provider\.name|request\.model|usage\.)' | head -20| Contrôle | Attendu |
|---|---|
| Un span par appel modèle | nom chat <modele> |
| Jetons présents | gen_ai.usage.input_tokens et output_tokens non nuls |
| Fournisseur normalisé | gen_ai.provider.name renseigné, y compris sur les SDK anciens |
| Trace unique | tous les spans d’une requête partagent le même trace_id |
| Aucun contenu | ni 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.
5. Playbook 2 : Pile de production
Section intitulée « 5. Playbook 2 : Pile de production »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.
5.1 Topologie en deux étages
Section intitulée « 5.1 Topologie en deux étages »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: 43175.2 Échantillonnage à la queue
Section intitulée « 5.2 Échantillonnage à la queue »À 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: 5Les 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.
5.3 Dériver les métriques des spans
Section intitulée « 5.3 Dériver les métriques des spans »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_reasonLe 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.
5.4 Traduire les jetons en euros
Section intitulée « 5.4 Traduire les jetons en euros »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 :
# 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.
6. Playbook 3 : Instrumenter le RAG
Section intitulée « 6. Playbook 3 : Instrumenter le RAG »6.1 Ce qui casse réellement dans un RAG
Section intitulée « 6.1 Ce qui casse réellement dans un RAG »| Panne | Symptôme utilisateur | Signal qui la révèle | Signal qui ne la révèle pas |
|---|---|---|---|
| Récupération vide ou hors sujet | « il invente » | rag.documents.count, score du top-1 | latence, code HTTP |
| Contexte récupéré mais ignoré | « il répond à côté » | ratio documents cités / récupérés | tout le reste |
| Index périmé | « il donne l’ancienne procédure » | rag.index.version, âge de l’index | qualité du modèle |
| Contexte tronqué | réponse partielle | rag.context.truncated, finish_reasons=length | scores de récupération |
| Dérive de l’embedder | dégradation lente et diffuse | distribution des scores dans le temps | une 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.
6.2 Instrumentation
Section intitulée « 6.2 Instrumentation »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 reponseTrois 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.
6.3 Le tableau de bord minimal
Section intitulée « 6.3 Le tableau de bord minimal »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 contexteetiquettes = {"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 troncatureUne 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.
7. Playbook 4 : Instrumenter MCP
Section intitulée « 7. Playbook 4 : Instrumenter MCP »7.1 Le problème du franchissement de processus
Section intitulée « 7.1 Le problème du franchissement de processus »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 preserveeLe 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
7.2 Côté client
Section intitulée « 7.2 Côté client »from opentelemetry import tracefrom opentelemetry.propagate import injectfrom 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 reponse7.3 Côté serveur
Section intitulée « 7.3 Côté serveur »from opentelemetry import tracefrom opentelemetry.propagate import extractfrom 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.
7.4 Ce qu’il faut surveiller sur MCP
Section intitulée « 7.4 Ce qu’il faut surveiller sur MCP »| Indicateur | Découpage | Pourquoi |
|---|---|---|
| Taux d’échec par outil | error.type par gen_ai.tool.name | un outil qui échoue à 30 % dégrade l’agent sans erreur visible |
| Latence p95 par outil | mcp.method.name="tools/call" | un outil lent est multiplié par le nombre de tours d’agent |
| Appels par tour d’agent | spans MCP sous invoke_agent | détection des boucles d’appels d’outil |
| Outils appelés hors liste | gen_ai.tool.name hors liste blanche | dérive de prompt ou injection indirecte |
| Requêtes rejetées pour version de protocole | erreurs UnsupportedProtocolVersion par mcp.protocol.version | clients 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.
8. Playbook 5 : Fermer la boucle qualité
Section intitulée « 8. Playbook 5 : Fermer la boucle qualité »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
8.1 Hors ligne, dans l’intégration continue
Section intitulée « 8.1 Hors ligne, dans l’intégration continue »Un jeu de référence versionné, exécuté à chaque changement de prompt, de modèle, de découpage ou d’index.
pip install "ragas==0.4.3"import mathimport 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 baisse | Composant en cause |
|---|---|
context_recall | découpage, embedder, top_k trop faible |
context_precision | absence de reclassement, seuil de score trop permissif |
faithfulness | prompt système, modèle, contexte tronqué |
answer_relevancy | prompt système, reformulation de la question |
8.2 En ligne, sur un échantillon de production
Section intitulée « 8.2 En ligne, sur un échantillon de production »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 :
- É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.
- 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.
- 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.namedistinct 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ée | Risque | Où la traiter |
|---|---|---|
| Prompt utilisateur | données personnelles, secrets | capture désactivée par défaut, masquage au Collector |
| Complétion | restitution de données du contexte | même traitement, rétention courte |
| Documents récupérés | fuite du référentiel, contournement des droits | identifiants seulement, jamais le contenu |
| Arguments d’appel MCP | identifiants, jetons, chemins | liste blanche d’arguments, pas de capture en masse |
gen_ai.conversation.id | ré-identification par recoupement | pseudonymiser, 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 playbooks | Licence | Point de vigilance |
|---|---|---|
| OpenTelemetry Collector, Jaeger, Prometheus, OpenLIT, OpenLLMetry, Ragas | Apache-2.0 | aucun pour un usage interne |
| VictoriaMetrics | Apache-2.0 (édition communautaire, version cluster comprise) | downsampling, rétentions multiples, détection d’anomalies en édition entreprise |
| Grafana, Tempo, Loki | AGPL-3.0 | modifier 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 noyau | certaines 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.
11. Recette d’acceptation
Section intitulée « 11. Recette d’acceptation »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_idviaparams._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.
12. Conclusion
Section intitulée « 12. Conclusion »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.