Skip to content

@moqtap/collector

Per-track MoQT delivery health from real sessions, measured at the WebTransport seam.

GitHub · the library behind moqtap Insight

Why it installs that early: the hook has to be in place before the first new WebTransport() in your application, and configuration — your API key, your endpoint — is frequently not known until later. Installing dormant and configuring afterwards is what lets both be true. The ordering is the reason; the dormancy is the safeguard.

Version 0.1.0, not yet published to npm. The wire envelope and the record schemas are still moving; treat both as unstable until 1.0. Everything below describes the library as built — you cannot npm install it today.

It reads MoQT wire structure off the WebTransport seam: object and group ids, byte counts, arrival times, control-message frames, and the negotiated draft. What goes to your endpoint is a rollup — per-track counters and fixed-boundary histograms on an interval — plus the control-plane frames, and, only when you raise the detail level, object headers.

A baseline session ships control-plane bytes plus an interval rollup: 1.8 KB gzipped per ten minutes on the captures it was measured against. That rollup carries, per track and per direction:

SignalShapeWhat it answers
Object count, bytes, rate, bitratecounters and meansHow much is arriving, and how fast
Object size, inter-arrival, delivery timehistogramsWhether the distribution is bimodal — an average hides the relay problem
Out-of-order, duplicates, status codescountersWhether the transport is reordering or the publisher is repeating
Group cadence, group gaps, group open durationhistogram and countersObserved GOP interval, missing group ids, and how long a group stayed open
Stall count, total and maximumcountersThe closest thing MoQT has to a QoE number
Blocked send time, acknowledged bytessums, send sideHow long writer.ready kept the publisher waiting, and what the peer actually acknowledged
Control-exchange latency and error codeshistograms and setsRequest to matching response, per message kind, with codes preserved verbatim
Request-id headroomminimumDistance to MAX_REQUEST_ID — MoQT-specific, invisible to your own player, and free

Percentiles are never computed on the device. Histograms go out as fixed log-spaced buckets that merge by elementwise addition, and every mean ships its denominator.

Object payloads. On the counting path they are skipped outright: the decoder walks each header, adds to counters, and advances past the payload without copying it out of the page’s buffer or retaining it. The flight-recorder ring is the only place raw payload bytes are held at all, and that ring is memory-only, continuously overwritten, never persisted and never uploaded — what crosses out of it on a trigger is derived timings, not the bytes that produced them.

Track names, namespaces and status strings. Rollup buckets are keyed by the number already on the wire — the track alias for subgroup and datagram streams, the request id for fetch streams — tagged with direction and an epoch that increments when the control plane rebinds a live alias. Names are joined server-side from control bytes that were shipped anyway.

Authorization token values, and that is on by default. SETUP and SUBSCRIBE parameters carry bearer tokens alongside namespaces and track names, and a collector that shipped those verbatim would have turned a debugging tool into a credential-exfiltration path. Every Authorization Token value is overwritten at the point of parse, per draft, using the codec that already knows which parameter carries what — not by a regex over bytes, which would be both leaky and unstable.

What survives is the fact, not the value: the parameter, its Alias Type, its Token Alias and its Token Type all stay, so a session that failed to authenticate still looks different from one that never tried, and a token reused across messages is still visible as the same alias. The frame keeps its exact length, and nothing downstream is ever handed a structure containing a token, because the frame is masked before it is decoded.

Set privacy: { maskAuthParams: false } if you have decided your tokens may travel to your endpoint. Nothing else turns it off: a value that merely looks false, out of JSON or an environment variable, is reported and ignored.

Terminal window
npm install @moqtap/collector @moqtap/codec

@moqtap/codec is a peer dependency — install it alongside. The collector pulls in one draft’s decoder from it, not the whole codec.

import { init } from '@moqtap/collector'
const insight = init({
apiKey: 'pk_live_...',
endpoint: 'https://ingest.example.com/v1/ingest',
// What this session is about. actorId is whoever is at this end — a viewer,
// a broadcaster or a service. It is not hashed and it is not bounded.
context: {
actorId: currentUser.id,
contentId: 'live/room-42',
environment: 'production',
release: '4.2.1',
},
})

That is a complete baseline session. Baseline is always on and always the same; everything above it is a dial you turn.

Configuration is grouped by what you are deciding, not by which module reads it: metrics (how often the rollup closes, how many tracks it follows), flightRecorder (depth, triggers, and how long a triggered window stays open), budget (elevated minutes), upload (when to flush, how hard to retry, when to give up), storage, privacy, and limits — which means what it says, safety valves a healthy integration never touches.

const insight = init({
apiKey: 'pk_live_...',
endpoint: 'https://ingest.example.com/v1/ingest',
metrics: { intervalMs: 10_000 },
budget: { elevatedMinutes: 60 },
upload: { intervalMs: 60_000, byteThreshold: 32 * 1024 },
})
// Folded at fixed cost per interval regardless of sample count.
insight.defineMetric('decodeMs', { unit: 'ms', agg: 'histogram' })
insight.observe('decodeMs', frame.decodeDuration)
// A marker in the timeline.
insight.annotate('quality-switch', { to: '720p' })
// Session, connection and actor ids — synchronous, available before the first
// object arrives, so you can put them in your own logs and join later.
const { sessionId, connectionId } = insight.ids()

Two verbs, and they differ in exactly one thing.

await insight.stop() // flush what is buffered, then tear down
await insight.abort() // drop everything, transmit nothing further, tear down

abort() is the one to call when the reason you are stopping is that you no longer want the data to leave the device — a consent withdrawal, an opt-out, a test fixture. It clears this session’s persisted queue unconditionally. stop() clears only what it successfully sent: a stop() that fails to reach the network leaves the backlog persisted for the next page load rather than destroying it, because a backlog is largest exactly when the session was most worth having.

init({
apiKey,
endpoint,
detail: 'baseline', // 'baseline' | 'headers' | 'headers+sizes' | 'headers+data'
})

Raising detail changes how much is shipped, not how precisely anything is measured. Against real draft-14 captures, headers is 191× the control plane and headers+sizes is 282×. That range is too wide for one flat price, which is why elevation is metered and baseline is not.

Elevation can also be raised from code:

insight.escalate('headers+sizes', 'user reported a stall')
// ...once the incident is over:
insight.resolve()

Elevation is billed as a capture window, in whole seconds, rounded down, with a one-second minimum. A window that lasts 4.9 s bills 4 s, one that lasts 200 ms bills 1 s, and two 200 ms windows bill 2 s — the rounding is per window, not per session.

resolve() is the primary way a window closes. It is safe to call with nothing open and safe to call twice, so it belongs in the catch block beside the call that raised detail. A window also closes when the recorder’s ring fills, when the page unloads, and — for a window a trigger opened — after flightRecorder.windowMs, 15 s by default. There is deliberately no automatic “the fault recovered” close: the collector sees objects arriving, not a rebuffer ending or a seek completing, and a collector that guessed would close early on a stall that was still happening and hold open through one that had ended.

A ring of raw wire bytes — payloads included — held in memory only, never persisted, never uploaded as-is, continuously overwritten, with nothing parsed out of it in the ordinary case. On a trigger it is re-parsed at full resolution and yields per-object wire timings for the window before the event. Armed costs nothing and is not billable; a trigger opens a capture window, and that is.

init({
apiKey,
endpoint,
flightRecorder: {
depth: '32MB', // a memory budget on your user's device, bounded in bytes
triggers: {
stall: { afterMs: 2000 },
cadence: { multiple: 3, minSamples: 20 }, // 3× this track's own median
},
},
})

All triggers are absent by default: automated mode ships off, so a fresh install never generates spend you did not ask for. The cadence trigger is a multiple of the track’s own observed median interval, so one config key fires at 6,000 ms on a 2 s GOP and at 750 ms on a 250 ms one without your telling it your GOP length — and it does not fire at all until that median is warm.

MoQT is pre-RFC and the wire format differs per draft, so a decoder has to match the negotiated protocol. Drafts 07 through 20 are supported. The collector reads session.protocol, then loads only that draft, behind a static literal specifier a bundler can follow.

Measured 2026-09-07, gzipped:

bundlegzipped
static entry — everything init() reaches31.5 KB
entry plus all five lazy chunks35.0 KB
one draft’s chunk (collector adapter + codec decoder)6.0–6.6 KB
@moqtap/codec root — all fourteen drafts, do not import39.6 KB

For builds that cannot dynamic-import — a strict CSP with no chunk loading, a single-file bundler — pin the drafts instead. The pin selects among the same literal specifiers the bundler can already see; it substitutes nothing, and the import becomes eager at init:

init({ apiKey, endpoint, drafts: [20, 19] })

If the pin and the negotiated protocol disagree, the adapter is withheld entirely. Two incompatible varint families disagree on the same bytes and return plausible wrong numbers, and a dashboard full of plausible wrong numbers is worse than one with a gap in it.

@moqtap/collector/draft07 through @moqtap/collector/draft20 are the draft-partitioned entry points for referencing one draft’s adapter directly.

The only thing that ever leaves the page is an upload to the endpoint you configure: a fetch() POST on the main path, and a navigator.sendBeacon() for the session tail at pagehide. Both are governed by connect-src. Add the origin of your endpoint to it.

For endpoint: 'https://ingest.example.com/v1/ingest', the exact directive is:

Content-Security-Policy: connect-src 'self' https://ingest.example.com;

Scheme and host, no path. If you already send the collector to your own domain, 'self' alone is enough and no change is needed at all.

Nothing else in your policy has to move. There is no eval, no new Function, no injected <script> element, and no blob: worker, so script-src, worker-src and unsafe-eval are untouched. The one nuance is import(): the collector loads the decoder for the negotiated draft with a dynamic import, which your bundler emits as an ordinary chunk served from your own origin, so an existing script-src 'self' — or a 'strict-dynamic' policy — already covers it. If your build cannot emit chunks at all, pin the draft instead and no dynamic import happens.

A dedicated Worker that opens its own WebTransport is a separate JavaScript realm with its own globalThis, so the page’s hook cannot see it. Call initWorker() inside the worker; it handshakes over the MessagePort you already have. Nothing is rewritten, no blob: URL is substituted for your worker, and if the worker never imports the collector the handshake message is ignored.

The page names the target itself, exactly once, with linkWorker(). That is a security decision rather than an omission: the only automatic form available would be a message listener on the page’s own global, which also receives cross-origin postMessage from any frame or opener — replying to one of those would post your apiKey to whoever asked.

It fails closed. A session whose worker never handshook is marked partial on its setup and terminal records rather than being reported as complete. Reporting a partial session as whole is worse than reporting nothing.

Every threshold is a config key with a recorded default, including the ones a specification would ordinarily hard-code: the flush schedule, the byte threshold, the concurrent-transport cap, the storage quota, the dormant ring’s own size. Read them from DEFAULTS, and each one’s provenance — including "guess, pending field data", which is a legitimate answer — from DEFAULT_PROVENANCE.

Where a bound would clip real data, the collector counts and reports instead of clipping. Over-counting is visible; truncation is not, and a clipped session looks exactly like a short one to the developer who came to find it.

Every error this package reports carries a code, and the runtime message is the code plus the offending value:

MQ2101: 1.5
│ └ the value that was rejected
└ the code

The wording lives in ERROR-CODES.md, not in the bundle. That is deliberate: an explanation worth reading is longer than any message a library should make every page carry, and a code costs six bytes. The same content ships as error-codes.json for tooling.

Configuration problems are reported rather than thrown — init() throws for exactly three things, a missing config object, a missing apiKey and a missing endpoint — and arrive as { key, code, got } on onConfigProblem, so you can switch on the code rather than parse a sentence.

Codes are permanent. A retired code is never reissued for a new meaning.

The collector sits in the data path, and says so. What it guarantees is that it does not alter, reorder or delay the traffic passing through — and that guarantee is tested rather than asserted: a reference player runs with the collector against healthy, blackholed, tarpitted and erroring ingest, and no object may be lost or measurably delayed in any of those cases.

Functional Source License 1.1, MIT Future License (FSL-1.1-MIT) — not MIT today, unlike @moqtap/codec and @moqtap/trace.

Embed it in your application, commercial or not, modify it, ship it to your users. The one thing it withholds is publishing a competing product built from this code. Two years after each release, that release becomes MIT.