Diagnostics & tracing
The ten lifecycle events and four span channels the agent publishes — their exact names and payloads, what is deliberately redacted, and how to subscribe.
The agent publishes structured telemetry as it runs: point events for lifecycle moments, and spans for the operations worth timing. Both are opt-out (emitDiagnostics: false) and both are inert when @adonis-agora/diagnostics is not installed — the package holds no import of it.
They feed the Telescope "Agent" tab, but nothing stops you subscribing directly: pipe them to OpenTelemetry, alert on a failure rate, or log a run's shape during a debugging session.
Subscribing
Install the peer, then subscribe by library or by exact event:
import { onDiagnostic } from '@adonis-agora/diagnostics'
// Every agent event.
onDiagnostic('agent', (event) => {
logger.debug({ event: event.event, payload: event.payload }, 'agent')
})
// One event.
onDiagnostic('agent', 'run.finished', (event) => {
metrics.increment('agent.runs.finished')
})Both forms return an unsubscribe thunk. On the wire the channel names are agora:agent:<event>.
The events
Ten names, each with a fixed payload shape.
run.started
Fires at the top of a turn, before the quota check.
{ runId: string; threadId: string; actorId: string; persona?: string }It is published outside any checkpoint, so a durable run publishes it again each time it replays (a resume after an approval or a question). Count runs from their endings — run.finished, run.failed, run.cancelled — not from this.
message
Fires when an assistant message is persisted.
{ runId: string; threadId: string; role: 'user' | 'assistant'; textLength: number }Only `role: 'assistant'` is ever emitted
The type allows 'user', but the loop publishes only the assistant side. If you are counting messages from this event, you are counting replies.
tool-call
Note the hyphen — it is tool-call, not tool.call. Fires once per tool call, after it settles.
{
runId: string
toolCallId?: string
toolName: string
toolType: 'read' | 'action'
status: string
durationMs?: number
replayed?: true
}statusis'executed','failed', or'rejected'(a HITL denial, or an approval that expired).toolCallIdis the call's id, the same on every publication of it — key on it to deduplicate.durationMsis how long the tool ran, measured inside itstool:<callId>checkpoint. It is set only when this process ran the tool; a decision (rejected) or anaskhas none.replayed: truemarks a call whose checkpoint the durable runtime read back from its journal instead of running the tool. The attempt that ran it already published it, so skip these when counting calls. The samedurationMsis stored on the call's row asexecution_ms.
run.finished
Fires once, when the turn completes.
{
runId: string
threadId: string
steps: number
inputTokens: number
outputTokens: number
durationMs?: number // turn start → end
ttftMs?: number // turn start → first non-empty `text` frame on the stream
costUsd?: number // sum of the steps whose cost is known; absent when none was
modelId?: string // the last model call's model
// AgentRunContext — who and what the run is:
agentName?: string
actorId?: string
tenantRef?: string
parentRunId?: string // set on a delegation's child run: not a turn of its own
}ttftMs is the time to first token as the reader sees it: a frame an output gate held back counts when it was released. It is absent when the turn streamed no text. Both timings use the clock of the process running the turn, so a run resumed elsewhere measures from its resume. ttftMs and modelId are also stored on the run's row (ttft_ms, model_id) for the read-model.
run.failed
Fires when the loop throws.
{ runId: string; threadId: string; code: AgentStreamErrorCode; durationMs: number } & AgentRunContextcode is the same stable code the stream's error frame carries — quota_exceeded, output_rejected, structured_output_invalid, replay_diverged, model_no_output, or run_failed for anything else — and is stored on the run's row as error_code. The error itself is never on the event. A durable control-flow signal (suspending on an approval or a question, continue-as-new) is not an ending and publishes nothing. A failure the durable engine retries can publish more than once for the same run — key on runId.
run.cancelled
Fires when the run's abort signal fired — someone pressed Stop — before it finished.
{ runId: string; threadId: string; durationMs: number } & AgentRunContextEndings outside the loop are not published
run.failed and run.cancelled come from the loop. A run the dead-run reaper settles, or one cancelled from another process without its loop unwinding here, gets the row update but no event.
delegated
Fires when an orchestrator hands work to another agent. A denied delegation does not emit this — see Authorizing a delegation.
{ runId: string; fromAgent?: string; toAgent: string }retrieved
Fires once per inject-mode retrieval.
{ runId: string; queryLength: number; count: number }count: 0 is a zero-hit retrieval — the signal behind the Telescope RAG panel's zero-hit rate.
tool.retry
Fires once per transient retry — on the attempt that failed and is about to be retried, not on the final outcome.
{ runId: string; toolName: string; toolCallId: string; attempt: number; message: string }quota.exceeded
{ actorId: string; usedTokens: number; limitTokens: number }Never emitted today
This one is part of the typed contract but the loop does not publish it — it throws QuotaExceededError without emitting. Do not build an alert on it: an over-budget turn shows up as a run.failed with code: 'quota_exceeded'.
What is redacted, and why
Every payload above carries lengths and counts, never content. message carries textLength, not the text. retrieved carries queryLength, not the query. tool-call carries the name and status, not the input or output. run.failed carries the stable code, not the error message.
This is not squeamishness: diagnostics are the least-guarded surface in the stack. They fan out to log aggregators, APM vendors, and a browser-facing dashboard, all of which sit outside whatever access control the conversation itself has. A prompt or a tool result routinely carries exactly the data your governance layer exists to protect, so the safe default is that it never leaves the loop through this channel. The full content is in the governance read-model, behind the gate.
tool.retry is the one exception: it carries the failed attempt's error message, because a retry you cannot diagnose is not worth recording. Tool error messages are yours, so keep them free of caller data if that matters to you.
Spans
Four operations are traced with a full span lifecycle over node:diagnostics_channel:
| Channel | Start payload | End result |
|---|---|---|
agora:agent:turn | { runId } | { textLength } |
agora:agent:llm.turn | { runId, step } | { modelId?, inputTokens, outputTokens, textLength, toolCalls } |
agora:agent:tool.execution | { runId, toolCallId, toolName, toolType } | {} |
agora:agent:retrieval | { runId, queryLength, topK } | { count } |
Each publishes on five sub-channels — :start, :end, :asyncStart, :asyncEnd, :error — carrying an envelope with v, ts, lib, event, phase, spanId, traceId, and a phase-specific field (payload on start, durationMs on end, result on asyncEnd, error on error).
traceId is always the runId, so every span from one turn groups without any correlation work. Duration lands on asyncEnd (or error), which is the phase to read for timing.
tool.execution wraps the whole invocation including its transient retries, so its duration is wall-clock cost to the run, not the cost of the last attempt. Its result is deliberately empty — tool output never rides a span, for the reason above.
Spans are zero-cost when nobody is listening: with no subscriber on any sub-channel, the operation runs with no span id allocated and no publish. Subscribe with the raw builtin if you want them without the diagnostics package:
import diagnostics_channel from 'node:diagnostics_channel'
diagnostics_channel.subscribe('agora:agent:llm.turn:asyncEnd', (message) => {
otelHistogram.record(message.durationMs, { model: message.result?.modelId })
})Spans and durable replay
Spans are emitted from inside memoized steps, which durable replay skips — so a replayed run does not re-emit spans for work it is not redoing. One consequence: under the durable runner there is no root turn span, because a turn spans multiple replay slices. Group by traceId instead.
Turning it off
export default defineConfig({
emitDiagnostics: false,
})The default is true, and it is already a no-op when the diagnostics package is absent, so there is rarely a reason to set it. Do it if you have the package installed for another library and want the agent to stay quiet.
Multi-replica streaming (SQL, no Redis)
Keep a run's stream in the app's own database with the Lucid token sink, so any replica can serve or resume any run's SSE without Redis — plus the recipe for parked questions and approvals across replicas.
Testing
Drive the agent offline — the FakeModelProvider and echoScript, and in-memory doubles for the store, sink, quota, and governance queries, all from @adonis-agora/agent/testing.