Skip to content

Tracing

Every predict-event hook of one call receives the same PredictContext.run_id, so a tracer can correlate the start, end and error events (and any spans it opens) without keeping its own bookkeeping.

run_id

  • A uuid4().hex string, created once per public call (predict_batch, system_one, Router.predict, ONNXAgent.system_one). Router.predict_batch creates one per request instead, the run_id a Router.predict call for that request would have had.
  • Shared by every predict-event hook of that call: on_predict_start, on_predict_end and on_error see one PredictContext, so one run_id.
  • Not shared with on_route, on_load or on_evict. Each is dispatched with a context of its own, so the count one Router.predict hands out is the number of contexts that call makes: two when the checkpoint is already resident (on_route plus the prediction), three when it has to load one (the load adds on_load), and four when that load also evicts an LRU checkpoint (on_evict adds another). Three is the cold path; a warm Router gives two per call. Correlate a routing decision with its prediction through ctx.decision, which on_predict_start also carries, not through run_id.
  • Not global and not persisted: it identifies a call within the process. Put it in your logs and outbound payloads to correlate across systems.
  • Distinct per call, so two calls never collide.
call A:  run_id=1a2b...  start ──┐
                                 ├─ end
                          error ─┘
call B:  run_id=9f8e...  start ─── end

A minimal tracer

Keep a map from run_id to the span you opened at start, and close it at end or error.

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()])

Because on_predict_end always runs, it is the natural place to close a span, and it can see ctx.error on the failure path.

Span lifetime

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)

Error spans

on_predict_end runs on the failure path with ctx.error set, so one close site handles both:

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)

If you only implement on_error, remember it fires before on_predict_end; do not close the span in both or you will double-count.

OpenTelemetry

The example examples/hooks/otel.py records counters and a histogram. For real spans, drive the OTel API from the tracer. Hooks are synchronous, so use the synchronous exporter (or enqueue and export from a 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)

Set hooks_raise=False so a tracer outage never fails a request.

Distributed propagation

run_id is a plain string, so include it in whatever leaves the process: log lines, the payload sent to an audit service, or an HTTP header if a decision triggers a downstream call.

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

Remember that a blocking call like this stalls the calling thread; enqueue it instead when serving concurrently. See patterns.

Nested calls

A hook that calls predict again starts a new call with a new run_id. The parent and child are independent unless you link them yourself. The child's run_id lives on the child's own PredictContext, not on the payload predict returns, so capture it with a per-call hook:

def enrich(ctx):
    for state in ctx.states:                     # a hook sees every state of the call
        def link(child_ctx, parent_run_id=ctx.run_id):
            record_child_span(parent_run_id=parent_run_id, child_run_id=child_ctx.run_id)

        enricher.predict(state, EXTRA_QUESTIONS, on_predict_start=link)

One parent run_id, one child call per state. A body that reads ctx.states[0] links the first decision of a batch and silently drops the rest.

Guard against recursion (see anti-patterns); the easiest guard is a separate enricher agent with no hooks.

See also