Agora

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:

start/agent_diagnostics.ts
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
}
  • status is 'executed', 'failed', or 'rejected' (a HITL denial, or an approval that expired).
  • toolCallId is the call's id, the same on every publication of it — key on it to deduplicate.
  • durationMs is how long the tool ran, measured inside its tool:<callId> checkpoint. It is set only when this process ran the tool; a decision (rejected) or an ask has none.
  • replayed: true marks 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 same durationMs is stored on the call's row as execution_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 } & AgentRunContext

code 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 } & AgentRunContext

Endings 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:

ChannelStart payloadEnd 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

config/agent.ts
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.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →