@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.
Status
Section titled “Status”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.
What it does
Section titled “What it does”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:
| Signal | Shape | What it answers |
|---|---|---|
| Object count, bytes, rate, bitrate | counters and means | How much is arriving, and how fast |
| Object size, inter-arrival, delivery time | histograms | Whether the distribution is bimodal — an average hides the relay problem |
| Out-of-order, duplicates, status codes | counters | Whether the transport is reordering or the publisher is repeating |
| Group cadence, group gaps, group open duration | histogram and counters | Observed GOP interval, missing group ids, and how long a group stayed open |
| Stall count, total and maximum | counters | The closest thing MoQT has to a QoE number |
| Blocked send time, acknowledged bytes | sums, send side | How long writer.ready kept the publisher waiting, and what the peer actually acknowledged |
| Control-exchange latency and error codes | histograms and sets | Request to matching response, per message kind, with codes preserved verbatim |
| Request-id headroom | minimum | Distance 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.
What never leaves the device
Section titled “What never leaves the device”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.
Install
Section titled “Install”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.
Quick start
Section titled “Quick start”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 },})Your own numbers
Section titled “Your own numbers”// 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()Stopping
Section titled “Stopping”Two verbs, and they differ in exactly one thing.
await insight.stop() // flush what is buffered, then tear downawait insight.abort() // drop everything, transmit nothing further, tear downabort() 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.
Detail, and what it costs
Section titled “Detail, and what it costs”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.
The flight recorder
Section titled “The flight recorder”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.
Drafts and bundle size
Section titled “Drafts and bundle size”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:
| bundle | gzipped |
|---|---|
static entry — everything init() reaches | 31.5 KB |
| entry plus all five lazy chunks | 35.0 KB |
| one draft’s chunk (collector adapter + codec decoder) | 6.0–6.6 KB |
@moqtap/codec root — all fourteen drafts, do not import | 39.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.
Content Security Policy
Section titled “Content Security Policy”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.
Workers
Section titled “Workers”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.
Limits are named, not constant
Section titled “Limits are named, not constant”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.
Errors
Section titled “Errors”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 codeThe 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.
Non-interference
Section titled “Non-interference”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.
License
Section titled “License”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.