Documentation

Traçage

Chaque hook d’un appel reçoit le même PredictContext.run_id, donc un traceur peut corréler les événements de start, de fin et d’erreur (et tout span qu’il ouvre) sans tenir sa propre comptabilité.

run_id

  • Une chaîne uuid4().hex, créée une fois par appel public (predict_batch, system_one, Router.predict, ONNXAgent.system_one). Router.predict_batch en crée un par requête à la place, le run_id qu’aurait eu un appel Router.predict pour cette requête.
  • Partagé par chaque hook de cet appel, y compris on_error et on_predict_end.
  • Ni global ni persisté : il identifie un appel au sein du processus. Mets-le dans tes logs et tes charges utiles sortantes pour corréler entre systèmes.
  • Distinct par appel, donc deux appels n’entrent jamais en collision.
call A:  run_id=1a2b...  start ──┐
                                 ├─ end
                          error ─┘
call B:  run_id=9f8e...  start ─── end

Un traceur minimal

Garde une table de run_id vers le span que tu as ouvert au start, et ferme-le à la fin ou à l’erreur.

import time

class Tracer:
    def __init__(self):
        self.spans = {}

    def on_predict_start(self, ctx):
        self.spans[ctx.run_id] = {
            "model": ctx.model,
            "started_at": ctx.started_at,
        }

    def on_predict_end(self, ctx):
        span = self.spans.pop(ctx.run_id, None)
        if span is None:
            return
        emit_span(
            name="laya.predict",
            run_id=ctx.run_id,
            model=ctx.model,
            duration_ms=ctx.elapsed_ms,
            usage=ctx.usage,
            ok=ctx.error is None,
        )

    def on_error(self, ctx):
        # on_error runs before on_predict_end; leaving the span for on_predict_end is fine,
        # or close it here if you prefer.
        pass

agent = laya.load("convaiinnovations/laya", hooks=[Tracer()])

Comme on_predict_end tourne toujours, c’est l’endroit naturel pour fermer un span, et il peut voir ctx.error sur le chemin d’échec.

Durée de vie du span

on_predict_start ──► open span (run_id, model, started_at)
      │
      ├─ inference
      │
on_error ──► record ctx.error on the span
      │
on_predict_end ──► close span (elapsed_ms, usage, ok)

Spans d’erreur

on_predict_end tourne sur le chemin d’échec avec ctx.error défini, donc un seul site de fermeture gère les deux :

def on_predict_end(self, ctx):
    span = self.spans.pop(ctx.run_id, None)
    if span is None:
        return
    if ctx.error is not None:
        span["status"] = "error"
        span["error_type"] = type(ctx.error).__name__
        span["error_message"] = str(ctx.error)
    span["duration_ms"] = ctx.elapsed_ms
    emit(span)

Si tu n’implémentes que on_error, souviens-toi qu’il se déclenche avant on_predict_end ; ne ferme pas le span dans les deux ou tu compteras double.

OpenTelemetry

L’exemple examples/hooks/otel.py enregistre des compteurs et un histogramme. Pour de vrais spans, pilote l’API OTel depuis le traceur. Les hooks sont synchrones, alors utilise l’exportateur synchrone (ou mets en file et exporte depuis un worker).

from opentelemetry import trace

tracer = trace.get_tracer("laya")

class OTelHooks:
    def __init__(self):
        self.spans = {}

    def on_predict_start(self, ctx):
        span = tracer.start_span("laya.predict", attributes={"laya.run_id": ctx.run_id, "laya.model": ctx.model})
        self.spans[ctx.run_id] = span

    def on_predict_end(self, ctx):
        span = self.spans.pop(ctx.run_id, None)
        if span is None:
            return
        if ctx.usage:
            span.set_attribute("laya.input_tokens", ctx.usage["input_tokens"])
        if ctx.error is not None:
            span.record_exception(ctx.error)
            span.set_status(trace.Status(trace.StatusCode.ERROR))
        span.end()

laya.load("convaiinnovations/laya", hooks=[OTelHooks()], hooks_raise=False)

Définis hooks_raise=False pour qu’une panne du traceur ne fasse jamais échouer une requête.

Propagation distribuée

run_id est une simple chaîne, alors inclus-le dans tout ce qui quitte le processus : lignes de log, charge utile envoyée à un service d’audit, ou en-tête HTTP si une décision déclenche un appel en aval.

def audit(ctx):
    requests.post(
        "https://audit.example/decisions",
        json=record(ctx),
        headers={"X-Laya-Run-Id": ctx.run_id},
        timeout=2,
    )

Souviens-toi qu’un appel bloquant comme celui-ci bloque le thread appelant ; mets-le en file à la place quand tu sers en parallèle. Voir motifs.

Appels imbriqués

Un hook qui rappelle predict démarre un nouvel appel avec un nouveau run_id. Le parent et l’enfant sont indépendants sauf si tu les lies toi-même. Capture l’id du parent et passe-le :

def enrich(ctx):
    for state in ctx.states:                     # a hook sees every state of the call
        child = enricher.predict(state, EXTRA_QUESTIONS)
        record_child_span(parent_run_id=ctx.run_id, child_run_id=child.get("run_id"))

Un run_id parent, un appel enfant par état. Un corps qui lit ctx.states[0] lie la première décision d’un lot et abandonne silencieusement les autres.

Protège contre la récursion (voir anti-motifs) ; la garde la plus simple est un agent enricher séparé sans hooks.

Voir aussi