runtime:diagnostics
What the runtime is doing, and how long it took. Spans with deterministic ends, filtered in the host and delivered in batches.
--allow-diagnostics buys timings, kinds, counts, the handle inventory and loop metrics, with attributes empty. --allow-diagnostics-detail populates attributes and implies the first. Status: Available.
There is no per-operation JavaScript callback. An event nothing is subscribed to — or one every filter rejects — costs an integer compare in Rust and never reaches JavaScript. Delivery is once per subscription per loop turn, never once per record, and no resource object is ever handed out.
Import
import { subscribe } from "runtime:diagnostics"; const sub = subscribe({ kinds: ["op"], minDuration: 10 }, ({ records, dropped }) => { for (const r of records) console.log(r.name, (r.endedAt - r.startedAt).toFixed(1), "ms"); if (dropped) console.warn(`${dropped} records lost`); });
Exports
| Export | Type | Description | Example |
|---|---|---|---|
subscribe(filter, onBatch) | (Filter, (Batch) => void) => Subscription | Receives batches matching filter. | subscribe({}, (b) => …) |
inventory() | () => { handles } | Host handles this agent owns. | inventory().handles |
metrics() | () => Metrics | Pull-only loop numbers. | metrics().loopLagMs.p99 |
span(name, options?) | (string, { attributes? }) => Span | A measurement. Records its parent; nests nothing. | span("checkout").end() |
span(name, options, fn) | (string, options, () => R) => R | Active for fn, so the work inside nests under it. Ends when fn returns or its promise settles. | span("load", {}, () => work()) |
default | object | An aggregate of all named exports. |
Subscription.close() delivers whatever is buffered, then stops. It is idempotent. Span has end(), fail() and cancel(); ending twice records once.
The two span forms
span("checkout").end(); // times a region; nests nothing await span("checkout", {}, () => work()); // …and everything `work` does
Only the callback form is active. A handle that made itself active would still be active in its caller — calling an async function runs its body on the caller's stack up to the first await — so the caller's next operation would nest under a span it had nothing to do with. Same split as OpenTelemetry's startSpan / startActiveSpan.
Filter
| Field | Type | Description |
|---|---|---|
kinds | string[] | "op", "timer", "user", "request", "tick". Omitted means every kind. |
minDuration | number | Milliseconds. Shorter records are discarded host-side. |
sample | number | 0..1, applied per trace so a trace is whole or absent. |
bufferSize | number | Default 4096, per subscription. |
Overflow drops the newest and reports the count on the next batch. A tick record is never dropped — in an overloaded turn it is what explains the overload.
Record
Field names follow OpenTelemetry, so an exporter attaches with no translation.
| Field | Type | Description |
|---|---|---|
id | number | Unique in the agent. User spans share this id space. |
parentId | number | null | The span this ran inside, or null at a root. Same id space as id, so records form a tree. |
traceId | string | null | The trace it belongs to. |
name | string | Op name, timer function, or user span name. |
kind | string | "op", "timer", "user", "request", "tick". |
source | "runtime" | "user" | Who opened it. |
scheduledAt | number | When the work became runnable. |
startedAt | number | When it started. |
endedAt | number | When it finished. |
status | string | "ok", "error", "cancelled". A failure is recorded, not dropped. |
statusMessage | string | null | Why it failed. Payload — needs diagnostics-detail, like attributes. |
attributes | object | Empty without diagnostics-detail. |
tick | number | Which turn of the loop it landed in. |
startedAt - scheduledAt is always queue delay. For a timer that is lag past its deadline — setTimeout(fn, 50) firing on time reports ~0, not 50. For an op the two are equal: there is no boundary the host can observe, and that is reported rather than invented.
The span tree
Records nest. parentId names the span a record ran inside, in the same id space as id, so a set of records is a tree an exporter can walk.
request/GET 1.73ms ← runtime:http opens one per request ├─ user/load-user 0.67ms ← span("load-user", {}, fn) │ ├─ op/fs_write 0.11ms │ └─ op/fs_read 0.06ms ├─ op/fs_remove 0.06ms └─ op/http_respond 0.03ms
Nesting follows the async scope, not the call stack: an op issued six awaits deep inside a request still nests under it.
| Root of a request | runtime:http opens a request span, named for the method. |
| Root elsewhere | Whatever ran with no enclosing span. |
| Filtering | Independent of nesting — { kinds: ["op"] } still gets ops nested under a request whose own record was filtered away. |
| Task lineage | A different question, answered by currentTask().parentId in runtime:context. |
Exporting
To ship these to a collector, use --otel — the runtime exports, and your program needs no capability and no code. runtime:diagnostics is for reading spans inside your program.
Loop-tick attribution
subscribe({}, ({ records }) => { const turns = new Map(records.filter((r) => r.kind === "tick").map((r) => [r.tick, r])); for (const r of records) { const turn = turns.get(r.tick); if (!turn) continue; // 40ms in a 45ms turn was slow. 40ms in a 400ms turn waited. console.log(r.name, `${r.endedAt - r.startedAt}ms of a ${turn.endedAt - turn.startedAt}ms turn`); } });
Metrics
metrics(); // { tick, ticks, // tickDurationMs: { count, min, max, mean, p50, p99 }, // loopLagMs: { count, min, max, mean, p50, p99 }, // gc: { count, pauseMs: { … } }, // process: { rss, cpu, uptime } }
loopLagMs is how long the loop was not running between turns. An idle loop is parked, so read it beside tickDurationMs: a large gap with short turns is an idle process, a large gap with long turns is a loop that cannot keep up.
gc is this isolate's collections and how long each stopped it. Beside the loop numbers because a GC pause is loop lag — the isolate is stopped for the whole of one, so a collection lands in loopLagMs with nothing else to explain it. Per isolate, so a worker's are its own.
process is the whole process — every agent together:
| Field | |
|---|---|
rss | resident bytes: every agent's heap, V8 itself, the runtime. What a container's memory limit is compared against |
cpu | CPU milliseconds across every thread. Exceeds the wall clock on a busy multi-core process |
uptime | ms since the process started |
The per-agent half is runtime:process — memoryUsage(), cpuTime(), uptime(), no capability, because they describe the caller. These describe the agents around it, so they sit behind diagnostics.
Inventory
The handles this agent owns — an id and a kind, never the resource.
inventory().handles; // [{ kind: "HTTP server", count: 1, ids: [1] }]
Not the same as "live": listeners, HTTP servers, WebSockets and in-flight requests are deliberately never released by the ownership registry, so those kinds over-report.
Coming from Node
| Node | Here |
|---|---|
createHook({ init, before, after, destroy }) | subscribe(filter, onBatch) |
a manual live-resource Map | inventory() |
AsyncResource | Removed |
| — | scheduledAt, tick, per-trace sampling |
Errors
| Error | When |
|---|---|
NotAllowedError | Any export without diagnostics. |
TypeError | An unknown kinds entry, a non-function onBatch, a non-string span name, non-object attributes. |
RangeError | minDuration < 0, sample outside 0..1, bufferSize < 1. |