runtime:diagnostics

What the runtime is doing, and how long it took. Spans with deterministic ends, filtered in the host and delivered in batches.

Capability: diagnostics — or diagnostics-detail

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

JavaScript
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

ExportTypeDescriptionExample
subscribe(filter, onBatch)(Filter, (Batch) => void) => SubscriptionReceives batches matching filter.subscribe({}, (b) => …)
inventory()() => { handles }Host handles this agent owns.inventory().handles
metrics()() => MetricsPull-only loop numbers.metrics().loopLagMs.p99
span(name, options?)(string, { attributes? }) => SpanA measurement. Records its parent; nests nothing.span("checkout").end()
span(name, options, fn)(string, options, () => R) => RActive for fn, so the work inside nests under it. Ends when fn returns or its promise settles.span("load", {}, () => work())
defaultobjectAn 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

JavaScript
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

FieldTypeDescription
kindsstring[]"op", "timer", "user", "request", "tick". Omitted means every kind.
minDurationnumberMilliseconds. Shorter records are discarded host-side.
samplenumber0..1, applied per trace so a trace is whole or absent.
bufferSizenumberDefault 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.

FieldTypeDescription
idnumberUnique in the agent. User spans share this id space.
parentIdnumber | nullThe span this ran inside, or null at a root. Same id space as id, so records form a tree.
traceIdstring | nullThe trace it belongs to.
namestringOp name, timer function, or user span name.
kindstring"op", "timer", "user", "request", "tick".
source"runtime" | "user"Who opened it.
scheduledAtnumberWhen the work became runnable.
startedAtnumberWhen it started.
endedAtnumberWhen it finished.
statusstring"ok", "error", "cancelled". A failure is recorded, not dropped.
statusMessagestring | nullWhy it failed. Payload — needs diagnostics-detail, like attributes.
attributesobjectEmpty without diagnostics-detail.
ticknumberWhich turn of the loop it landed in.

startedAt - scheduledAt is always queue delay. For a timer that is lag past its deadlinesetTimeout(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.

TEXT
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 requestruntime:http opens a request span, named for the method.
Root elsewhereWhatever ran with no enclosing span.
FilteringIndependent of nesting — { kinds: ["op"] } still gets ops nested under a request whose own record was filtered away.
Task lineageA 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

JavaScript
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

JavaScript
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
rssresident bytes: every agent's heap, V8 itself, the runtime. What a container's memory limit is compared against
cpuCPU milliseconds across every thread. Exceeds the wall clock on a busy multi-core process
uptimems since the process started

The per-agent half is runtime:processmemoryUsage(), 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.

JavaScript
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

NodeHere
createHook({ init, before, after, destroy })subscribe(filter, onBatch)
a manual live-resource Mapinventory()
AsyncResourceRemoved
scheduledAt, tick, per-trace sampling

Errors

ErrorWhen
NotAllowedErrorAny export without diagnostics.
TypeErrorAn unknown kinds entry, a non-function onBatch, a non-string span name, non-object attributes.
RangeErrorminDuration < 0, sample outside 0..1, bufferSize < 1.
Last updated on
Edit this page