Leçon 2 — Observer chaque appel

Tracer chaque appel avec OpenTelemetry

5 min de lecture

Tracer chaque appel avec OpenTelemetry

Trace, span, attributs

OpenTelemetry (OTel) est un standard ouvert pour produire des traces, métriques et journaux, indépendamment de l'outil qui les affiche.

  • Une trace raconte une requête de bout en bout (par exemple : réception HTTP → recherche → appel LLM → réponse).
  • Un span est une étape de cette trace, avec un début, une fin et des attributs (des paires clé / valeur).
  • Un exporteur envoie les spans terminés quelque part : console, fichier, Cloud Trace, Jaeger…
sequenceDiagram
  participant A as "Application"
  participant W as "call_llm"
  participant G as "Gemini"
  participant X as "Exporteur"
  A->>W: call_llm(router, v1, demande)
  W->>W: ouvre le span chat gemini-3.5-flash-lite
  W->>G: generate_content
  G-->>W: texte et usage_metadata
  W->>W: attributs tokens, coût, statut
  W->>X: span terminé
  W-->>A: réponse et trace_id

Les conventions sémantiques gen_ai

Pour que n'importe quel outil comprenne vos spans, OTel définit des conventions sémantiques : des noms d'attributs standard. Pour l'IA générative :

AttributExempleSens
gen_ai.operation.namechattype d'opération
gen_ai.provider.namegcp.gemini ou gcp.vertex_aifournisseur
gen_ai.request.modelgemini-3.5-flash-litemodèle demandé
gen_ai.request.temperature0paramètre de génération
gen_ai.usage.input_tokens100tokens d'entrée
gen_ai.usage.output_tokens20tokens de sortie
error.typeRuntimeErrortype d'erreur, si échec

On ajoute nos attributs métier sous un préfixe à nous (app.*) : identifiant et version du prompt, empreinte, coût, nombre de tentatives.

files/llm_wrapper.py (extrait)
    with _tracer.start_as_current_span(f"chat {pv.model}", kind=SpanKind.CLIENT) as span:
        span.set_attributes({"gen_ai.operation.name": "chat", "gen_ai.provider.name": provider,
                             "gen_ai.request.model": pv.model,
                             "gen_ai.request.temperature": pv.config.get("temperature", -1),
                             "app.prompt.id": pv.prompt_id, "app.prompt.version": pv.version,
                             "app.prompt.sha256": pv.sha256})

Le span est de type CLIENT : notre code est le client d'un service distant. Après l'appel, on ajoute les tokens et le coût ; en cas d'erreur, span.set_status(Status(StatusCode.ERROR, ...)) colore la trace en rouge dans les outils.

Pourquoi utiliser gen_ai.usage.input_tokens plutôt qu'un nom maison comme tokens_entree ?

En local : un fichier, une ligne par span

files/llm_wrapper.py (extrait)
    elif exporter == "console":
        out = open("traces.jsonl", "a", encoding="utf-8")
        provider.add_span_processor(SimpleSpanProcessor(
            ConsoleSpanExporter(out=out, formatter=lambda s: s.to_json(indent=None) + "\n")))

SimpleSpanProcessor exporte chaque span dès qu'il se termine : pratique en local. En production, BatchSpanProcessor les regroupe en arrière-plan pour ne pas ralentir les requêtes.

Sur Google Cloud : Cloud Trace

Avec OTEL_EXPORTER=gcp, le wrapper utilise l'exporteur Cloud Trace :

pip install opentelemetry-exporter-gcp-trace
gcloud services enable cloudtrace.googleapis.com --project=MON_PROJET
OTEL_EXPORTER=gcp GOOGLE_CLOUD_PROJECT=MON_PROJET python regression.py --version v1

Le compte de service de l'application doit avoir le rôle Agent Cloud Trace (roles/cloudtrace.agent). Sur Cloud Run, l'identité du service suffit, sans clé. Les traces apparaissent dans la console, page Trace Explorer, filtrables par attribut (par exemple app.prompt.version:v2).

En production, quel processeur de spans préférer et pourquoi ?

Exercice : tester la trace sans réseau

Écrivez un test pytest qui appelle call_llm avec un faux modèle et vérifie, grâce à InMemorySpanExporter, que le span contient gen_ai.usage.input_tokens == 100 et app.prompt.version == "v1".

Voir la solution
files/test_llmops.py (extrait)
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import llm_wrapper

EXPORTER = InMemorySpanExporter()
llm_wrapper.setup_tracing(span_exporter=EXPORTER)

def test_call_is_traced(tmp_path, monkeypatch):
    monkeypatch.setattr(llm_wrapper, "LOG_PATH", str(tmp_path / "calls.jsonl"))
    fake = lambda model, system, prompt, config: ('{"service":"it","urgence":"haute"}', 100, 20)
    llm_wrapper.call_llm("router", "v1", generate=fake, demande="x")
    span = EXPORTER.get_finished_spans()[0]
    assert span.attributes["gen_ai.usage.input_tokens"] == 100
    assert span.attributes["app.prompt.version"] == "v1"

Le paramètre generate du wrapper permet d'injecter un faux modèle : le test tourne en CI sans clé.

Résultat obtenu

Un span réel de traces.jsonl, produit le 2026-10-15 pendant le lab (extrait, ligne reformatée) :

{"name": "chat gemini-3.5-flash-lite",
 "context": {"trace_id": "0x3120a671504368d55f8a8668c5cde572", "span_id": "0x7d857361a829b656"},
 "start_time": "2026-10-15T14:40:09.556792Z", "end_time": "2026-10-15T14:40:10.530532Z",
 "status": {"status_code": "UNSET"},
 "attributes": {"gen_ai.operation.name": "chat", "gen_ai.provider.name": "gcp.gemini",
                "gen_ai.request.model": "gemini-3.5-flash-lite", "gen_ai.request.temperature": 0,
                "app.prompt.id": "router", "app.prompt.version": "v1", "app.prompt.sha256": "7dc8e870b139",
                "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 20,
                "app.cost_usd": 8e-05, "app.attempts": 1},
 "resource": {"attributes": {"service.name": "assistant-interne", ...}}}

Je sais faire

  • Expliquer trace, span, attribut et exporteur
  • Utiliser les attributs standard gen_ai.* et un préfixe maison pour le métier
  • Exporter en local dans un fichier et vers Cloud Trace sur GCP
  • Tester l'instrumentation sans réseau avec InMemorySpanExporter
Suivant