Events & tracing
One event stream that tracing, metrics, and progress UIs all consume.
Every interesting moment in a run is an event. Tracing, streaming, progress UIs, and cost dashboards are all consumers of this one stream rather than separate features bolted onto the loop.
The built-in tracer
import { consoleTracer } from 'just-another-sdk'
await agent.run('Weather and time in Paris?', { onEvent: consoleTracer() })▶ run_m9x2k1p travel-assistant · anthropic/claude-opus-5
↳ get_weather {"city":"Paris"}
↳ get_time {"city":"Paris"}
→ {"city":"Paris","tempC":18,"summary":"clear"} 118ms
→ {"city":"Paris","localTime":"14:32"} 61ms
✗ get_air_quality {"error":"upstream 503","code":"tool_execution_error"} 12ms
✔ finish · 2 turns · 412 in / 63 out · 1.9sProp
Type
Tool values are redacted before printing.
Handling events yourself
onEvent is a plain function. Switch on type:
await agent.run(prompt, {
onEvent: (event) => {
switch (event.type) {
case 'tool.end':
metrics.histogram('tool_duration_ms', event.durationMs, {
tool: event.toolName,
error: String(event.isError),
})
break
case 'model.response':
metrics.increment('tokens_in', event.usage.inputTokens)
metrics.increment('tokens_out', event.usage.outputTokens)
break
case 'run.finish':
logger.info('run complete', {
runId: event.runId,
stopReason: event.stopReason,
turns: event.turns,
})
break
// Events are additive — always keep a default branch.
default:
break
}
},
})New event types will be added over time, so switch with a default branch
rather than assuming the union is exhaustive.
Two guarantees
A listener cannot break a run. Handlers are invoked inside a try/catch and
their exceptions are swallowed. Instrumentation is not allowed to change program
behaviour:
await agent.run(prompt, {
onEvent: () => {
throw new Error('my logger is broken')
},
})
// The run still completes normally.Emitting is synchronous. The loop never awaits a listener, so adding logging cannot slow down or reorder the agent's work. If you need to do something slow, queue it:
onEvent: (event) => {
void queue.push(event) // do not block the loop
}Event reference
Every event carries id, timestamp, runId, and agentName. After a
handoff, agentName is the agent that was acting when the
event was emitted, so a trace attributes work without joining anything.
| Type | When | Notable fields |
|---|---|---|
run.start | Always first | modelId, input, toolNames |
session.load | History loaded, before the first call | sessionId, messageCount, droppedCount, durationMs |
model.request | Before each model call | turn, messageCount, tools |
model.response | After each model call, before tools run | text, toolCalls, finishReason, usage, durationMs |
tool.start | Before a tool runs, arguments validated | toolName, toolCallId, input |
tool.end | After a tool finishes or fails | result, isError, durationMs |
handoff.start | The conversation moved to another agent | from, to, toolName, toolCallId, reason?, carriedCount |
handoff.refused | A transfer hit a loop-prevention limit | from, to, cause, reason |
session.save | New messages written back, on completion | sessionId, appendedCount, durationMs |
run.finish | Last event on a successful run | stopReason, text, turns, usage, durationMs, agentPath |
run.error | Last event when the run throws | error, turn |
text.delta | Streamed runs only | delta |
model.retry | A failed call will be attempted again | attempt, maxAttempts, error, delayMs, discardedText |
model.fallback | The chain moved to the next provider | fromModelId, toModelId, index, error |
output.invalid | An answer failed outputSchema | turn, attempt, maxAttempts, issues, repairing |
guardrail.triggered | A guardrail rejected, rewrote, or gated | guardrail, stage, action, toolName?, reason? |
approval.required | The run suspended for a human | turn, calls |
approval.resolved | A decision was applied on resume | toolCallId, toolName, approved, reason? |
For a tool-using run the order is deterministic:
run.start
model.request → model.response
tool.start … tool.start (one per call, all before execution)
tool.end … tool.end (one per call)
model.request → model.response
run.finishA streamed run is the same sequence with text.delta
interleaved, always between the request and the response for that turn:
run.start
model.request → text.delta* → model.response
tool.start … tool.end …
model.request → text.delta* → model.response
run.finishmodel.request fires once per turn, not once per attempt — retries and
fallbacks announce themselves with their own events instead.
A run that hands off slots the transition in after the transfer tool's result and before the receiving agent's first call:
run.start
model.request → model.response (agentName: triage)
tool.start → tool.end (transfer_to_billing)
handoff.start (triage → billing)
model.request → model.response (agentName: billing)
run.finish (agentPath: ['triage', 'billing'])There is no handoff.end: a handoff is a transition rather than a nested scope,
so the only honest close is run.finish.
A run with an outputSchema emits no text.delta at
all, and appends its own validation pass after the loop:
run.start
model.request → model.response
output.invalid (repairing: true)
model.request → model.response (the repair)
run.finishoutput.invalid fires on every failed attempt, including the last one —
unlike model.retry, which only fires when another attempt is coming. The final
failure is the one worth seeing in a trace, because the run is about to throw.
A run using a session brackets that sequence with two more:
run.start
session.summarize (only when a fold happened)
session.load (what the run actually starts with)
model.request → model.response
session.save (immediately before run.finish)
run.finishsession.load is emitted after any summarizing, so its counts describe the
history the run really begins with rather than an intermediate state.
session.save does not fire on a run that throws — nothing is persisted, on
purpose. See what gets saved, and when.
Undoing discarded text
When an attempt fails after streaming real tokens, its output is void and the
retry starts over. model.retry and model.fallback both carry discardedText:
exactly the characters already delivered that a renderer must remove.
if (event.type === 'model.retry') ui.removeLast(event.discardedText.length)It is empty in the common case — a rate limit or a refused connection fails before any bytes arrive.
Partial state from a failed run
A run that throws returns no RunResult, so events are how you recover what
happened before the failure:
const seen: AgentEvent[] = []
try {
await agent.run(prompt, { onEvent: (event) => seen.push(event) })
} catch (error) {
const toolCalls = seen.filter((event) => event.type === 'tool.end')
logger.error('run failed', {
error,
completedTools: toolCalls.length,
tokensSpent: seen
.filter((event) => event.type === 'model.response')
.reduce((total, event) => total + event.usage.totalTokens, 0),
})
}Typed handlers
EventOfType narrows a handler to one event type:
import type { EventOfType } from 'just-another-sdk'
function onToolEnd(event: EventOfType<'tool.end'>) {
// ^ fully typed: result, isError, durationMs
if (event.isError) alert(event.toolName)
}Traces without an event handler
RunResult.steps is the same information, recorded for you:
const result = await agent.run(prompt)
for (const step of result.steps) {
console.log(
`turn ${step.turn}: ${step.toolCalls.length} tool(s), ${step.durationMs}ms`,
)
console.log(
` usage: ${step.usage.inputTokens} in / ${step.usage.outputTokens} out`,
)
}Because the loop records every turn as it goes, tracing exporters are a formatter over data that already exists rather than new plumbing.
Coming next
Trace exporters for JSON and OpenTelemetry.