Tracer et mesurer chaque appel au modèle selon les conventions gen_ai
Chez Ondine, deux équipes ont instrumenté leurs assistants chacune de son côté. L'une publie llm_latency_ms, l'autre model_call_seconds ; l'une compte les tokens de réflexion à part, l'autre pas du tout. Le jour où la direction demande la consommation totale de tokens, il faut une requête différente par équipe, et aucun tableau de bord communautaire ne fonctionne. Les conventions sémantiques OpenTelemetry pour l'IA générative (préfixe gen_ai) existent pour cela : des noms, des unités et des attributs communs à tous les fournisseurs et tous les frameworks, y compris ADK qui les émet déjà.
Le mécanisme : un span et quatre instruments par appel
Pour chaque appel au modèle, le service produit un span (une étape chronométrée d'une trace) et alimente des métriques (des agrégats sans détail individuel) :
flowchart TB Q["POST /question<br/>span SERVER (FastAPI)"] --> C["chat gemini-3.5-flash-lite<br/>span CLIENT"] C --> D["gen_ai.client.operation.duration<br/>histogramme, secondes"] C --> I["gen_ai.client.inference.usage.input_tokens<br/>compteur, cache compris"] C --> O["gen_ai.client.inference.usage.output_tokens<br/>compteur, réflexion comprise"] C --> R["...cache_read.input_tokens<br/>...reasoning.output_tokens<br/>sous-ensembles"]
Les conventions GenAI ont quitté le dépôt principal de spécifications pour un dépôt dédié, open-telemetry/semantic-conventions-genai, et restent au statut Development (relevé le 2026-10-15). Elles changent encore : le compteur de tokens est passé d'un histogramme unique gen_ai.client.token.usage à une famille de compteurs gen_ai.client.inference.usage.*. ADK 2.10 émet encore l'ancien histogramme ; ce service émet les nouveaux compteurs. Épinglez la version des conventions que vous suivez et testez les noms (section « Mise en œuvre », étape 4).
Les champs de Gemini ne correspondent pas un pour un à la convention :
usage_metadata de Gemini | Convention | Conversion |
|---|---|---|
prompt_token_count | input_tokens | identique : Gemini inclut déjà les tokens lus en cache |
candidates_token_count | output_tokens | ajouter thoughts_token_count : la convention compte la réflexion dans la sortie, Gemini la sépare |
cached_content_token_count | cache_read.input_tokens | sous-ensemble de l'entrée |
thoughts_token_count | reasoning.output_tokens | sous-ensemble de la sortie |
Oublier la réflexion, c'est sous-estimer la sortie, et donc le coût, des modèles qui réfléchissent : un simple « bonjour » à gemini-3.8-flash a consommé 4 tokens de réponse et 246 tokens de réflexion lors d'un essai sur Vertex AI le 2026-10-16.
La mise en œuvre
1. Les fournisseurs, injectables
def configurer(*, span_exporter=None, metric_reader=None, log_exporter=None, globaux=True) -> Telemetrie:
"""Sans argument : exportateurs OTLP/HTTP (OTEL_EXPORTER_OTLP_ENDPOINT). Les tests injectent les leurs."""
ressource = Resource.create({"service.name": SETTINGS.service, "service.version": "1.0.0",
"service.instance.id": f"{socket.gethostname()}-{os.getpid()}",
"deployment.environment.name": SETTINGS.environnement})
traces = TracerProvider(resource=ressource)
traces.add_span_processor(BatchSpanProcessor(span_exporter or OTLPSpanExporter()))
...
duree = m.create_histogram("gen_ai.client.operation.duration", unit="s",
explicit_bucket_boundaries_advisory=BORNES_DUREE)
tokens_entree = m.create_counter("gen_ai.client.inference.usage.input_tokens", unit="{token}")Trois choix :
service.instance.iddistingue deux processus du même service. Sans lui, deux répliques écrivent la même série Prometheus et leurs compteurs s'écrasent ; le test de bout en bout s'en sert aussi pour isoler les mesures de son propre processus (leçon 4).- Les bornes de l'histogramme sont celles que recommande la convention (de 0,01 à 81,92 secondes, en doublant). Les bornes par défaut du SDK vont de 0 à 10 000 : pensées pour des millisecondes, elles mettraient tous les appels dans le premier intervalle.
- Les exportateurs injectables permettent aux tests de remplacer OTLP par des exportateurs en mémoire, sans réseau.
2. L'appel instrumenté
def appeler(tel, question, *, modele=None, nom=None) -> ReponseModele:
attrs = attributs_appel(nom) # gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model
with tel.tracer.start_as_current_span(f"chat {nom}", kind=SpanKind.CLIENT,
attributes={**attrs, "server.address": serveur}) as span:
debut = time.perf_counter()
try:
r = (modele or modele_par_defaut()).generer(nom, question)
except Exception as e:
type_erreur = str(e.code) if isinstance(e, ErreurModele) else type(e).__qualname__
tel.duree.record(time.perf_counter() - debut, {**attrs, "error.type": type_erreur})
span.set_status(Status(StatusCode.ERROR, type_erreur))
raise
tel.duree.record(time.perf_counter() - debut, {**attrs, "gen_ai.response.model": r.modele_servi})
cout = enregistrer_usage(tel, attrs, nom, r.entree, r.sortie, r.cache, r.reflexion)- Le nom du span suit la convention :
{opération} {modèle}. gen_ai.provider.namevautgcp.geminiavec une clé AI Studio etgcp.vertex_aiquandGOOGLE_GENAI_USE_VERTEXAI=TRUE: les deux facturations restent séparables.- Les erreurs sont mesurées dans le même histogramme, distinguées par
error.type(429,503, ou le nom de l'exception). Le taux d'erreur se calcule alors sans compteur supplémentaire, et un appel qui échoue après 30 secondes pèse dans la latence, comme pour l'utilisateur qui l'a attendu. - Les tokens ne sont comptés qu'en cas de succès. La convention recommande d'enregistrer aussi les échecs, avec la valeur 0 et
gen_ai.token.modality="unknown"; ce service s'en écarte délibérément, pour que la modalitéunknownn'apparaisse pas dans les labels. Le nombre d'échecs reste lisible dans l'histogramme de durée.
3. Ce qui reste hors des métriques
L'identifiant du client va sur le span (assistant.client_id), jamais en label de métrique. Chaque valeur distincte d'un label crée une série Prometheus : 60 000 clients, c'est 60 000 séries par métrique et par bucket d'histogramme. Le span, lui, est stocké une fois, et Tempo le retrouve par recherche.
Le texte du prompt et de la réponse ne quitte jamais le service : le code ne le met dans aucun attribut, et le collecteur supprime gen_ai.input.messages et gen_ai.output.messages si une bibliothèque les ajoute (leçon 1).
4. Tester les noms sans réseau
def test_appel_reussi_conventions_genai(tel):
r = llm.appeler(tel, "Comment résilier ?", modele=llm.ModeleFaux(0.0, graine=1, dormir=False),
nom="gemini-3.5-flash-lite")
tel.fournisseurs[0].force_flush() # BatchSpanProcessor : exporter avant de lire
(span,) = tel.spans.get_finished_spans()
assert span.name == "chat gemini-3.5-flash-lite" and span.kind == SpanKind.CLIENT
m = points(tel.lecteur)
assert m["gen_ai.client.inference.usage.input_tokens"][0].value == r.entreeModeleFaux simule latences (loi log-normale, médiane d'environ 0,67 s), tokens et erreurs 503 à un taux réglable. Il sert aux tests, à la démonstration d'alerte et à l'intégration continue, sans clé ni coût.
Résultat obtenu
$ make test
.......... [100%]
10 passed, 2 warnings in 1.15sUn span réel lu dans Tempo le 2026-10-16, service exécuté sur Vertex AI (nom d'hôte masqué) :
resource : service.name=assistant-support, service.instance.id=poste-dev-2022412,
deployment.environment.name=lab, telemetry.sdk.version=1.42.1
chat gemini-3.5-flash-lite SPAN_KIND_CLIENT 1,14 s
gen_ai.operation.name chat
gen_ai.provider.name gcp.vertex_ai
gen_ai.request.model gemini-3.5-flash-lite
gen_ai.response.model gemini-3.5-flash-lite
gen_ai.usage.input_tokens 55
gen_ai.usage.output_tokens 54
assistant.cost_usd 0.0001515
server.address aiplatform.googleapis.comCôté Prometheus, les noms sont traduits par l'exportateur du collecteur : points remplacés par des soulignés, unité ajoutée en suffixe, _total sur les compteurs.
| Instrument OpenTelemetry | Série Prometheus |
|---|---|
gen_ai.client.operation.duration (s) | gen_ai_client_operation_duration_seconds_bucket, _count, _sum |
gen_ai.client.inference.usage.input_tokens ({token}) | gen_ai_client_inference_usage_input_tokens_total |
assistant.llm.cost.usd ({USD}) | assistant_llm_cost_usd_total |
Une unité entre accolades est une annotation : Prometheus ne l'ajoute pas au nom. Avec unit="USD", la série se serait appelée assistant_llm_cost_USD_total.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Prometheus ralentit, la mémoire grimpe chaque jour | identifiant client, de session ou de question en label de métrique | identifiants sur les spans ; labels de métriques limités à des valeurs bornées (modèle, résultat, code d'erreur) |
| Coût sous-estimé d'un tiers sur un modèle qui réfléchit | thoughts_token_count oublié dans la sortie | sortie = candidats + réflexion, testé |
| Les dernières secondes de données manquent après chaque déploiement | fournisseurs non arrêtés : les tampons du BatchSpanProcessor et du lecteur périodique ne sont pas vidés | arreter() dans le lifespan de FastAPI |
| Un panneau devient vide après une mise à jour d'une bibliothèque | conventions au statut Development, nom de métrique changé | versions épinglées, test qui vérifie les noms émis et ceux du tableau de bord |
| Le texte des conversations apparaît dans Tempo | une instrumentation tierce capture le contenu par défaut | suppression au collecteur, en plus du réglage de la bibliothèque |
La règle de décision
Suivez les conventions gen_ai pour tout ce qu'elles couvrent (durée, tokens, attributs de span) : vous héritez des tableaux de bord et des intégrations qui les connaissent, et les métriques d'ADK s'additionnent aux vôtres. Préfixez par le nom de votre application (assistant.) ce qu'elles ne couvrent pas, comme le coût. Surveillez le statut des conventions à chaque montée de version : tant qu'elles sont en Development, un renommage est possible.
Pratique
Le produit veut un graphe du nombre de questions par client pour repérer les gros utilisateurs. Un développeur propose d'ajouter le label client_id au compteur de tokens. Que faites-vous ?
Chaque client devient une série par métrique : des dizaines de milliers de séries, qui font grossir la mémoire de Prometheus. Réduire la rétention ne réduit pas le nombre de séries actives. La question « qui consomme le plus » est une analyse ponctuelle : les spans, qui portent l'identifiant, y répondent sans coût permanent.
Après passage de gemini-3.5-flash-lite à gemini-3.8-flash, le nombre de tokens de sortie sur le tableau de bord est stable mais la facture double. Quelle est l'explication la plus probable ?
Le nouveau modèle réfléchit ; Gemini rapporte cette réflexion dans un champ séparé, facturé au prix de la sortie. Un compteur qui l'ignore reste stable pendant que la facture monte. Une perte d'échantillons ferait baisser les tokens affichés, pas seulement la facture.
Exercice. Ajoutez l'attribut gen_ai.request.max_tokens au span de chaque appel, et un test qui vérifie sa présence.
Voir la solution
Dans llm.appeler, l'attribut va sur le span, pas dans attrs (qui sert aussi de labels aux métriques : inutile d'y ajouter une dimension) :
with tel.tracer.start_as_current_span(f"chat {nom}", kind=SpanKind.CLIENT,
attributes={**attrs, "server.address": serveur, "gen_ai.request.max_tokens": 1024}) as span:La valeur doit venir de la même constante que celle passée à GenerateContentConfig(max_output_tokens=...), sinon le span ment. Test :
def test_max_tokens_sur_le_span(tel):
llm.appeler(tel, "x", modele=llm.ModeleFaux(0.0, graine=1, dormir=False))
tel.fournisseurs[0].force_flush()
(span,) = tel.spans.get_finished_spans()
assert span.attributes["gen_ai.request.max_tokens"] == 1024- Je sais convertir
usage_metadatade Gemini vers les compteursgen_ai.client.inference.usage.*. - Je sais pourquoi les erreurs sont mesurées dans l'histogramme de durée avec
error.type. - Je sais choisir ce qui va en label de métrique et ce qui reste sur le span.
- Je sais tester l'instrumentation avec des exportateurs en mémoire.