16 KiB
Observability
open-multi-agent exposes three telemetry layers: live progress events, structured trace spans, and a static post-run dashboard.
Run identity and outcome
Every top-level execution (runAgent, runTeam, runTasks, runFromPlan,
runConsensus, and restore) returns an identity even when onTrace is not
configured:
const result = await orchestrator.runTasks(team, tasks, { runId: 'order-42' })
result.identity // { runId, attempt, traceId, rootSpanId, links? } at runtime
result.status // { code, message? } at runtime
result.errorInfo // redacted, JSON-safe details on failures
runId identifies a logical run and can be supplied by the caller (1-128
characters). attempt starts at 1. Each execution attempt gets a new 32-hex
traceId and 16-hex rootSpanId. Restore preserves runId, increments
attempt, generates new trace/root IDs, and links to the previous attempt when
restoring a v2 checkpoint.
Status codes are ok, error, cancelled, timeout, budget_exhausted,
rejected, and skipped. Existing success fields remain available and are
derived from status.code === 'ok'; cancellations and whole-run timeouts are
therefore no longer reported as successful runs. A rejected consensus verdict
is still an ok execution outcome—the verdict is a domain result, not a runtime
failure.
For source compatibility in the first 1.x release, the new result fields are
optional in TypeScript declarations, but every runtime result from these APIs
includes identity and status.
TraceRecord schema v2
The core package exports the TraceRecord schema used by the internal
OBS-1B runtime. A span produces span_start, zero or more span_event
records, and exactly one self-contained span_end. Records carry
schemaVersion: 2, a unique recordId, a per-trace strictly increasing
sequence, the run identity, W3C-compatible trace/span IDs, timestamps,
status, safe attributes, and optional links.
The hierarchy uses parent relationships for lifecycle containment and links for non-tree relationships:
- the run root contains coordinator, task, consensus, checkpoint, and callback operations;
- task attempts are agent children, with LLM and tool calls below the agent;
- DAG prerequisites use
depends_onlinks; - delegated agents are children of the
delegate_to_agenttool and link back to the task; - coordinator synthesis uses
consumedlinks to task spans; - checkpoint restore starts a new trace whose root links
continued_fromthe prior root.
span_end repeats the span kind, name, start time, final attributes, links,
status, and structured error so it remains useful if a start/event record is
lost later in the delivery pipeline. Close is idempotent and the first end
wins.
OBS-2 adds a public sink/exporter lifecycle. The runtime is activated by either
observability.sinks or the existing onTrace path. Without either option, no
child TraceRecord objects or attributes are constructed; top-level
identity/status still exist.
Streaming notifications are span events rather than zero-duration child
spans in v2. TTFT is recorded only by a genuinely streaming provider path;
the current aggregated chat() path never substitutes total latency for
TTFT. Legacy agent_stream callback events remain unchanged.
Sinks, exporters, and ownership
TraceSink is the synchronous hot-path contract. Its emit(record): void
method accepts a record quickly and is never awaited by Agent, Task, or Run
execution. TraceExporter is the asynchronous batch-delivery contract used by
BatchingTraceSink; network or storage I/O belongs there. A sink or exporter
failure changes observability statistics and diagnostics, never the business
result.
import {
BatchingTraceSink,
OpenMultiAgent,
type TraceExporter,
} from '@open-multi-agent/core'
const exporter: TraceExporter = {
async export(records, signal) {
// Send the batch using `signal`; return a delivered prefix count.
return { status: 'success', exported: records.length }
},
}
const sink = new BatchingTraceSink(exporter)
const orchestrator = new OpenMultiAgent({
observability: { sinks: [sink] },
})
An exporter result has success, retryable, or permanent failure status.
exported is the successfully delivered prefix of the supplied batch. A short
success is a permanent partial result; a short retryable result retries only
the unexported suffix. Rejected or timed-out exporter promises are contained by
the batching sink.
Core also exports CompositeSink, FilteringSink,
SensitiveDataProcessor, and LegacyCallbackTraceSink, both from the package
root and the explicit @open-multi-agent/core/observability subpath.
TraceStore query and reference storage
TraceStore is the storage-medium-independent persistence and query contract
for v2 TraceRecord batches. Core includes InMemoryTraceStore as the reference
implementation and TraceStoreExporter as the bridge into
BatchingTraceSink:
import {
BatchingTraceSink,
InMemoryTraceStore,
TraceStoreExporter,
} from '@open-multi-agent/core/observability'
const store = new InMemoryTraceStore()
const sink = new BatchingTraceSink(new TraceStoreExporter(store))
// Configure sink under observability.sinks, run work, then flush its watermark.
await sink.forceFlush({ timeoutMs: 1_000 })
const page = await store.queryRuns({
status: ['error', 'timeout'],
agent: ['researcher'],
limit: 50,
})
InMemoryTraceStore is not durable and is not a production database. It is
intended for unit tests, local inspection, and short-lived processes. It has no
filesystem or database dependency, and it returns copies rather than mutable
references to its internal records.
Append, schema, and materialization
append(records) accepts a batch atomically: validation failure leaves the
whole batch invisible. Schema major 2 is supported; another major is rejected
with TraceStoreError(code = 'UNSUPPORTED_SCHEMA_VERSION'). Unknown fields on
schema-v2 records are retained so additive minor evolution can round-trip.
recordId is the idempotency key. Re-exporting a previously accepted batch is
a successful no-op for those records. If the same span receives more than one
distinct span_end, the first accepted end wins and append returns a
duplicate_span_end diagnostic; the later end cannot change the materialized
Agent or Run outcome.
Materialization sorts records within each trace by sequence, independent of
arrival order. An end record is self-contained, so an end-only span is complete.
A start with no accepted end remains incomplete. A logical runId groups all
attempts and trace identities, including restore continuations. Parentage and
continued_from, depends_on, delegated_from, and consumed links are
preserved on materialized spans. The run terminal status is the latest attempt's
actual root end status; it is absent when that record is missing and is never
invented as ok.
Run summaries include start/end/duration, attempts and trace/root identity, terminal status when present, agent/task/model/provider values actually found in attributes, LLM token and cost facts, incomplete state, and schema version. Only LLM end records contribute token/cost totals, avoiding double-counting agent or run rollups.
Filters, ordering, and opaque cursors
queryRuns supports combined runId, ISO time range, status, agent, task ID,
model, and provider filters. startedAfter is inclusive; startedBefore is
exclusive. The default page size is 50 (maximum 500). Stable ordering is
(startedAt, runId), descending by default; started_asc reverses both parts
of that key, so equal timestamps always have a deterministic tie-breaker.
The cursor is opaque, store-instance-specific, and bound to the filter/order
that created it. Invalid, tampered, or mismatched cursors reject with
TraceStoreError(code = 'INVALID_CURSOR'). The first page captures an append
revision: records appended while later pages are read do not appear in that
pagination walk, preventing duplicates and omissions. A fresh query sees them.
Deletes are immediately consistent and invalidate outstanding cursors; the
append snapshot does not claim a transaction across concurrent delete or
retention operations.
Delete and retention
deleteRun(runId) removes every attempt for one logical run. delete(query)
bulk-deletes logical runs using the same frozen filters as queries, without
cursor, order, or limit. Both are idempotent, and successful deletion is
immediately reflected by getRun and queryRuns.
Retention accepts maxAgeMs, maxRuns, and an optional terminal-status scope.
Age uses the injected store clock and run start time. maxRuns keeps the newest
matching runs by the same stable ordering; age and count deletion sets are
combined. A status-only policy deletes runs with those actual terminal statuses
and does not match incomplete runs with no status. Deletion order is oldest
first with runId as the tie-breaker, and repeated application is safe.
TraceStore retention only affects that store. It cannot delete copies already exported to OTel or another vendor.
TraceStore is not RunStore or CheckpointStore
TraceStore is append/query telemetry and can be best-effort. A future RunStore is the authoritative durable state machine with CAS, lease, suspend, and resume semantics; TraceStore deliberately provides none of those. CheckpointStore persists execution state used to restore work. Losing telemetry must not roll back a durable run, and deleting traces must not imply deleting checkpoints or shared memory.
Flush and shutdown
forceFlush({ timeoutMs }) captures an acceptance watermark. It promises that
records accepted before that call have been exported or explicitly counted as
failed/dropped; records accepted afterward do not delay that call. It then
delegates to the exporter's optional forceFlush. Results are ok, partial,
timeout, or error and include cumulative accepted/exported/dropped/failed
counts.
shutdown({ timeoutMs }) atomically stops acceptance, flushes the cutoff, then
shuts down the exporter. It is idempotent: concurrent and repeated calls share
the first result. An emit after the cutoff is dropped and diagnosed. Timeout
is a total lifecycle deadline, not an exception thrown into Agent execution.
The creator of a sink owns its lifecycle. OMA does not automatically shut
down injected sinks, install process signal handlers, call process.exit(), or
assume that a sink is exclusive to one orchestrator.
// Serverless/FaaS: flush this invocation, keep a shared singleton usable.
const result = await orchestrator.runAgent(agent, prompt)
const telemetry = await sink.forceFlush({ timeoutMs: 1_500 })
return { result, telemetry: telemetry.status }
// Short-lived CLI: finish delivery before natural process exit.
try {
await main()
await sink.forceFlush({ timeoutMs: 5_000 })
} finally {
await sink.shutdown({ timeoutMs: 5_000 })
}
// Long-lived server: application-owned graceful shutdown.
async function stopServer() {
await sink.shutdown({ timeoutMs: 10_000 })
await server.close()
}
// Register stopServer with your server/process framework if desired.
Queue, retry, drop, and diagnostics
BatchingTraceSink defaults are bounded:
| Setting | Default |
|---|---|
| queued records | 2,048 |
| queued bytes | 16 MiB |
| one record | 256 KiB |
| batch records | 512 |
| scheduled delay | 5 seconds |
| export timeout | 30 seconds |
| retries after the first attempt | 3 |
| retry backoff | 1 second, exponential ×2, equal jitter, capped at 30 seconds |
Only retryable results, rejected exporter promises, and export timeouts are
retried. Retry/backoff happens in the transport worker and never blocks
business execution. Queue admission is non-blocking. When capacity is needed,
the sink drops oldest records in this priority order: stream_chunk, other
events, span_start, then self-contained span_end. Oversize records are
rejected before acceptance.
getStats() reports accepted, exported, retried, failed, dropped, queued record
count/bytes, and a payload-free last error code. Built-in diagnostics never
include a record, prompt, tool payload, or raw exception. They default to one
console.warn per sink+code per 60 seconds; pass diagnostics: 'silent'
explicitly to a built-in sink to disable warnings, or use onDiagnostic.
Diagnostic handler throws are swallowed without recursively producing another
diagnostic.
Progress Events
Use onProgress when you need lightweight lifecycle events for logs, terminal output, or a live UI.
const orchestrator = new OpenMultiAgent({
onProgress: (event) => {
console.log(event.type, event.task ?? event.agent ?? '')
},
})
Common event types include task_start, task_complete, task_retry, task_skipped, agent_start, agent_complete, budget_exceeded, and error. A task_retry event's data.nextDelayMs is the actual, post-jitter delay before the next attempt, not the nominal backoff schedule.
Trace Spans
onTrace remains source- and runtime-compatible for existing users. Each event
still carries its UUID spanId, optional UUID parentId, duration, token
counts, and the same best-effort-redacted legacy tool I/O.
const orchestrator = new OpenMultiAgent({
onTrace: async (span) => {
await traceSink.write(span)
},
})
Forward trace spans to OpenTelemetry, Datadog, Honeycomb, Langfuse, or your own run database only after deciding what data is safe for that sink. See integrations/trace-observability for a runnable example.
The seven-member TraceEvent union and completion/event timing are unchanged.
Internally, LegacyCallbackTraceSink maps v2 records back to the exact legacy
event object. Synchronous callback throws and asynchronous rejections remain
isolated and cannot become unhandled rejections. onTrace is not marked
deprecated in this release; the 1.x compatibility window remains open while
users can migrate transport code to observability.sinks at their own pace.
Span parentage is best-effort and uses the causal structure known to the runtime. In team runs, worker agent spans point to their task span, and LLM/tool/stream spans point to the agent span. Root spans such as top-level agent runs omit parentId.
Post-Run Dashboard
renderTeamRunDashboard(result) returns a static HTML page that visualizes the executed task DAG with timing, token usage, per-task status, and task details.
import { writeFileSync } from 'node:fs'
import { renderTeamRunDashboard } from '@open-multi-agent/core'
const result = await orchestrator.runTeam(team, goal)
writeFileSync('run.html', renderTeamRunDashboard(result))
The library does not write files by itself. The CLI can write dashboard HTML for you with oma run --dashboard; see docs/cli.md.
The generated dashboard is self-contained and does not load remote scripts, stylesheets, or fonts. Sensitive-looking values in the embedded run payload are redacted before rendering.
What to Persist
For production runs, persist enough data to reconstruct a failure without replaying the entire job:
TeamRunResult.tasksfor the executed DAG and task states.TeamRunResult.totalTokenUsagefor cost attribution.result.identityandresult.statusas the stable run lookup and outcome.onTracespans for LLM calls and tool executions, keyed by legacyrunId+spanId.- The rendered dashboard HTML when you need a shareable post-mortem artifact.
Redaction scope. The redaction noted above applies to telemetry — trace spans and the dashboard payload. It does not cover persisted run state: shared-memory writes and checkpoint saves store agent output verbatim. To scrub secrets there, wrap the durable store with
RedactingStore.
Default privacy boundary
The v2 instrumentation does not collect prompts, completions, tool arguments,
or tool results by default. OpenMultiAgent wraps configured v2 sinks in a
SensitiveDataProcessor; its optional observability.capture policy is the
only core-controlled content opt-in. Structured credential fields are removed
even when content capture is enabled. Chain-of-thought/reasoning content,
signed reasoning blocks, and <thinking> text are never captured by OMA
instrumentation; numeric reasoning-token counts may be recorded.
Legacy onTrace keeps its existing redacted tool input/output fields for
compatibility, so its privacy surface is intentionally broader than the v2
default. Trace processing does not redact checkpoints or shared memory.