Telemetry

Trace A2 with OpenTelemetry, and the spans it reports.

server/orders.ts
import { createServer } from 'experimental-a2/server'
import { otel } from 'experimental-a2/otel'
import { orders } from '@/contracts'

export const ordersServer = createServer({
  contract: orders,
  telemetry: otel(),
  // ...store, scheduler, and consumers
})

With any OpenTelemetry setup registered (on Vercel, @vercel/otel), one trace shows the request, a2.append, the inline a2.drain, an a2.event span per consumer run, and the appends those runs make. Failures mark their a2.event span, and a2.event.outcome = dead_lettered is the attribute to alert on. Agent servers add a2.ai.* spans.

experimental-a2/otel

otel(options?: { tracer?: Tracer; tracerName?: string }): A2Telemetry

OpenTelemetry instrumentation for a server (@opentelemetry/api is a peer dependency). Pass it as telemetry. Spans nest through the active context, so the happy path lands in one trace. A2Telemetry is a one-method interface: anything with span(name, attributes, fn) works, and otel() is the adapter A2 ships.

The span catalogue

This catalogue is a contract: renames and additions are breaking for dashboards and are called out in the package's CHANGELOG.md. All spans carry a2.contract and a2.session_id. Core spans describe the durable runtime; a2.ai.* spans describe the AI runtime's consumers.

LayerSpanWraps
Corea2.appendvalidation + the store write (one batch)
Corea2.drainone drain pass, including the consumer runs it starts
Corea2.eventone claimed head, including the consumer's run and its completion
Corea2.claimone claimAvailable store operation
Corea2.commitone completeAttempt or failAttempt store operation
Corea2.stateone state() read: snapshot-plus-tail load + reducer fold
Corea2.snapshotone background batch of checkpoint writes
Corea2.redriveone session.redrive(): the store operation and starting the session's wakeup
Corea2.consumeone session.consume(), including the drains it runs and the waits between them
AIa2.ai.controlapplying an input/work report/session-close decision and preparing its returned events
AIa2.ai.generateone generation run, including Core calls and model/SDK work
AIa2.ai.contextconstructing model messages, including tool prompt reconstruction when uncached
AIa2.ai.compactionautomatic summarization or the custom compaction callback
AIa2.ai.toolone durable tool run, including application execution
AIa2.ai.ackwaiting for a persisted work report's canonical events and control receipt

Durations include nested work: a2.event contains its AI run and later commit, AI runs call Core, and a2.state includes reducer code. Acknowledgements overlap control processing, claims can cover several events, and snapshots run in the background, so summing spans, or subtracting summed children, does not yield exclusive Core or AI overhead. Model and application I/O is included; its child spans have the detail.

Attributes arrive in two waves: at start (passed to span()) and mid-span (via handle.setAttribute, for outcomes and counts known only after the work ran). A recorder must merge both; the ones you alert on are all mid-span.

AttributeSpanWhenValues
a2.append.sourcea2.appendstartexternal | consumer
a2.append.modea2.appendstartinline | dispatch
a2.append.typesa2.appendstartcomma-joined event types
a2.append.counta2.appendstartbatch size
a2.append.armeda2.appendmidfalse when the scheduler arm failed and this append degraded to append-driven healing
a2.drain.outcomea2.drainmidsettled | busy | stalled | failed
a2.drain.processeda2.drainmidevents processed this pass
a2.claim.outcomea2.claimmidclaimed | busy | settled
a2.claim.counta2.claimmidnumber of claimed events, or zero
a2.commit.operationa2.commitstartcomplete | fail
a2.commit.indexa2.commitstartevent log position
a2.commit.attempta2.commitstartdurable 1-based attempt ordinal
a2.commit.outcomea2.commitmidcompleted | failed | dead_lettered | superseded
a2.event.typea2.eventstartthe event's type
a2.event.indexa2.eventstartlog position
a2.event.ida2.eventstartevent id
a2.event.consumera2.eventstartthe consumer running the event
a2.event.attempta2.eventstartsame durable 1-based ordinal as ctx.attempt
a2.event.lanea2.eventstartstored lane value; absent for concurrent unlaned work
a2.event.handleda2.eventstartnormally true; false when a claimed head's consumer is not registered
a2.event.age_msa2.eventstartmilliseconds since the event was appended, when its run starts: how far behind its consumer is in time
a2.event.laga2.eventstartevents appended after this one when it was claimed: how far behind its consumer is in the log. Absent when the store doesn't report its head
a2.event.outcomea2.eventmidprocessed | failed | dead_lettered | superseded | surrendered
a2.event.aborteda2.eventmidtrue when abortOn fired during the run
a2.state.reducera2.statestartthe reducer's name
a2.state.snapshota2.statemidhit | miss | rejected (schema guard discarded it) | memory (served from the server's in-memory state)
a2.state.foldeda2.statemidevents folded past the snapshot
a2.state.indexa2.statemidthe frontier the returned state reflects
a2.snapshot.reducera2.snapshotstartthe reducer's name
a2.snapshot.indexa2.snapshotstartgreatest checkpoint index in the write batch
a2.snapshot.counta2.snapshotstartnumber of checkpoints in the write batch
a2.consume.consumera2.consumestartthe consumer being consumed
a2.consume.througha2.consumemidthe index it runs through
a2.consume.indexa2.consumemidthe index it handled every event through
a2.consume.drainsa2.consumemiddrains it ran to get there
a2.redrive.indexa2.redrivestartlog position of the event being redriven
a2.redrive.consumera2.redrivestartthe consumer the event is dead for
a2.redrive.actiona2.redrivestartretry | skip
a2.redrive.redrivena2.redrivemidtrue when the event was dead-lettered and is now resolved
a2.redrive.armeda2.redrivemidfalse when the scheduler arm failed and the wakeup degraded to append-driven healing
a2.ai.control.typea2.ai.controlstartai.control.requested | ai.work.reported | ai.session.closed
a2.ai.control.ida2.ai.controlstarttriggering event id
a2.ai.control.attempta2.ai.controlstartdurable 1-based attempt ordinal
a2.ai.generate.request_ida2.ai.generatestartgeneration request event id
a2.ai.generate.attempta2.ai.generatestartdurable 1-based attempt ordinal
a2.ai.context.request_ida2.ai.contextstartgeneration request event id
a2.ai.context.phasea2.ai.contextstartgeneration | refresh | tool
a2.ai.compaction.request_ida2.ai.compactionstartgeneration request event id
a2.ai.compaction.generation_ida2.ai.compactionstartgeneration attempt id
a2.ai.compaction.policya2.ai.compactionstartautomatic | custom
a2.ai.tool.request_ida2.ai.toolstartoriginating generation request event id
a2.ai.tool.call_ida2.ai.toolstarttool call id
a2.ai.tool.namea2.ai.toolstarttool name
a2.ai.tool.attempta2.ai.toolstartdurable 1-based attempt ordinal
a2.ai.ack.request_ida2.ai.ackstartreporting worker event id
a2.ai.ack.report_ida2.ai.ackstartwork report event id, matching the control span's id
  • Consumer lag: group a2.event spans by a2.event.consumer. a2.event.lag is how many events the session had appended after the one it claimed, and a2.event.age_ms how old that event was when its run started. A consumer that keeps up keeps both near zero; a rising a2.event.lag means it is falling behind its log.
  • Drain outcomes: settled means nothing actionable is left; busy means live per-event claims or a failed event's retry hold remain; stalled means a caught failure in this drain needs a later retry. failed appears only on scheduler deliveries: the drain threw after arming its successor watchdog, the span records the error, and that successor retries the session.
  • A failing consumer run marks its a2.event span with the exception and error status. Alert on a2.event.outcome = dead_lettered. A dead-lettered event blocks only later events in its lane, until session.redrive() resolves it; other lanes stay eligible.
  • superseded means a stale completion or failure did not change the log.