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 minimal tracer
- Span lifetime
- Error spans
- OpenTelemetry
- Distributed propagation
- Nested calls
run_id¶
- A
uuid4().hexstring, created once per public call (predict_batch,system_one,Router.predict,ONNXAgent.system_one).Router.predict_batchcreates one per request instead, therun_idaRouter.predictcall for that request would have had. - Shared by every predict-event hook of that call:
on_predict_start,on_predict_endandon_errorsee onePredictContext, so onerun_id. - Not shared with
on_route,on_loadoron_evict. Each is dispatched with a context of its own, so the count oneRouter.predicthands out is the number of contexts that call makes: two when the checkpoint is already resident (on_routeplus the prediction), three when it has to load one (the load addson_load), and four when that load also evicts an LRU checkpoint (on_evictadds another). Three is the cold path; a warm Router gives two per call. Correlate a routing decision with its prediction throughctx.decision, whichon_predict_startalso carries, not throughrun_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.
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¶
- API reference: the full
PredictContext. - Patterns and anti-patterns: non-blocking tracing.