Skip to content

@moqtap/collector 0.6

Measure MoQT delivery health from real viewers’ browsers.

npm | GitHub | sends data to moqtap Insight

@moqtap/collector watches every MoQT session your page opens over WebTransport, including sessions opened in a Web Worker, and reports, per track, how objects and groups actually arrived: rate, bitrate, stalls, missing groups, duplicates, reordering and how long control requests took to get an answer. You don’t change your player. You import the package, call init() with a key, and the numbers appear in Insight.

Terminal window
npm install @moqtap/collector
// main.ts — import it before the code that creates your player
import { init } from '@moqtap/collector'
import { startPlayer } from './player'
const vitals = init({
apiKey: 'mqt_pk_...',
context: {
actorId: currentUser.id,
contentId: 'live/room-42',
environment: 'production',
release: '4.2.1',
},
})
startPlayer()

That is a complete integration. Every MoQT session the page opens is measured and uploaded, and the session ends by itself when the page closes.

The collector wraps globalThis.WebTransport when the module is first imported, so import it before the first new WebTransport() runs. A connection opened earlier is invisible to it.

You don’t need the key at import time. Until init() is called, the collector sends nothing. A connection opened between the import and init() is still measured, including its setup and subscribe exchanges. Objects that arrived before init() are counted as early.dataChunks and early.dataBytes.

end() and discard() remove the wrapper and restore the original WebTransport.

  • Session: one playback context, by default the page. It ends when the page closes, or when you call end() or newSession().
  • Instance: one init(), normally one page load. A reload that passes the same sessionId is a second instance of the same session.
  • Connection: one WebTransport, in the page or in a linked worker. It opens and closes by itself and never ends the session.

A Web Worker that opens its own WebTransport is measured as part of the page’s session. The worker calls initWorker() and the page calls linkWorker(worker). The worker needs neither the key nor a connect-src entry. See Players that run in a Web Worker.

Every 10 seconds (metrics.intervalMs) the collector records, per track and direction:

SignalUse it to answer
Objects, payload bytes, header bytesIs this track delivering at the rate and bitrate I expect?
Inter-arrival and object-size histogramsIs delivery steady, or bursty with gaps between bursts?
Group cadence and how long each group stayed openWhat GOP interval is the publisher actually producing?
Group gapsDid any group IDs never arrive?
Stalls (count, total and longest, as a histogram)Did a viewer see playback freeze, and for how long?
Out-of-order and duplicate objectsIs the relay reordering or resending?
Status objects and parse failuresDid the publisher signal end-of-group or end-of-track? Did anything arrive that couldn’t be read?
Time blocked on writer.ready, bytes acknowledgedPublishers only: is the page waiting to send, and how much did the peer confirm?

Per session, it also records:

  • Control-request latency, from request to response, grouped by kind: setup, subscribe, fetch, announce, track status, publish.
  • The control messages themselves, with authorization tokens masked (see Privacy).
  • Each connection’s negotiated draft, whether it was publishing, subscribing or both, and why it closed.

Stalls are judged against each track’s own delivery rhythm, so a 2-second-GOP track and a 30 fps track are both measured fairly.

Acknowledged bytes are reported only in Chromium-based browsers.

Pass your own identifiers in context, or pass your own sessionId:

const vitals = init({
apiKey: 'mqt_pk_...',
sessionId: playbackSession.id, // optional; a UUID is generated if you leave it out
context: { actorId: user.id, contentId: stream.id },
})

ids() returns the session and instance IDs right away, before any data has arrived. Put them in your own logs or error reports to jump from a support ticket to the exact session in Insight:

const { sessionId, instanceId } = vitals.ids()
Sentry.setTag('moqtap.session', sessionId)

To find one connection, ask for the ID of a WebTransport you created, or list them all:

const conn = vitals.connectionIdOf(transport) // undefined for a transport it never saw
vitals.connections() // every connection in the page and its linked workers, open ones first

Each entry in connections() has id, realm (page or worker), origin, draft, contentId, state (connecting, open or closed) and, once closed, closeCause.

A sessionId longer than 128 UTF-8 bytes, or containing control characters, is replaced by a generated one and reported as MQ1113. ids() returns the ID actually in use.

actorId is stored as given. If it identifies a person, treat it as personal data under your privacy policy, or pass an opaque ID instead.

Register a metric once on the page, then call observe() as often as you like, in the page or in a linked worker. Calling it more often doesn’t increase how much is uploaded.

vitals.defineMetric('timeToFirstFrame', { unit: 'ms', agg: 'gauge' })
vitals.defineMetric('decodeTime', { unit: 'ms', agg: 'histogram' })
vitals.defineMetric('droppedFrames', { unit: 'frames', agg: 'sum' })
video.requestVideoFrameCallback(function onFrame(now, meta) {
vitals.observe('decodeTime', meta.processingDuration * 1000, { codec: 'av1' })
video.requestVideoFrameCallback(onFrame)
})
aggRecordsGood for
sumthe total per intervalcounts: dropped frames, rebuffers, retries
gaugethe latest valuelevels: buffer length, current bitrate
histogramthe distributiontimings: decode time, time to first frame

Pass buckets (strictly ascending numbers) to a histogram to set your own boundaries.

Limits per session: 64 metrics, 8 labels per observation, 32 distinct label combinations per metric, label values truncated to 64 characters. Going over a limit is reported through onInternalError and never throws.

annotate() adds a timestamped marker to the session, with any JSON-serialisable data, and optionally the connection it is about:

player.on('qualitychange', (q) => vitals.annotate('quality-switch', { to: q.height }, conn))
player.on('seek', (t) => vitals.annotate('seek', { to: t }))

When the player moves to a different stream, either label the new connections or start a new session:

// Same session; connections opened from now on carry this content id.
vitals.setContext({ contentId: 'live/room-43' })
// One session per playback: ends this one, starts the next, returns the new ids.
const { sessionId } = vitals.newSession({ contentId: 'vod/episode-2' })

setContext() takes contentId and actorId. newSession() takes contentId and an optional sessionId of your own. Linked workers follow the page into the new session. After end() or discard(), newSession() starts nothing and returns the IDs unchanged.

Calling init() again while a collector is still running replaces it: the new one takes over, and MQ1004 is reported. Use newSession() instead.

Upload now, stop collecting, or honour an opt-out

Section titled “Upload now, stop collecting, or honour an opt-out”
await vitals.flush() // upload everything recorded so far; ends nothing
await vitals.end() // end the session, upload everything, restore WebTransport
await vitals.discard() // delete everything unsent and send nothing more

You need end() only when you stop collecting for good while the page stays open. Closing the page ends the session by itself. A native shell that kills its webview without a pagehide event should await vitals.flush() first.

Use discard() when the user withdraws consent. It deletes this session’s unsent data from the browser, including data saved for retry, and sends nothing.

flush() and end() wait at most 2 seconds for the upload (upload.drainDeadlineMs). Anything not sent by then stays saved in the browser and is retried.

All three are safe to call straight after init(), before its setup has finished. The request is applied as soon as setup completes. After end() or discard(), init() starts a new collector.

A worker has its own WebTransport, so the collector in the page can’t see it. Import the package in the worker as well, and link the two. The page names the worker:

// main.ts — the page
import { init } from '@moqtap/collector'
const vitals = init({ apiKey: 'mqt_pk_...' })
const worker = new Worker(new URL('./player.worker.ts', import.meta.url), { type: 'module' })
vitals.linkWorker(worker)

The worker calls initWorker(). It returns a probe at once, with no key and no await:

// player.worker.ts — the worker
import { initWorker } from '@moqtap/collector' // or the draft entry the page uses
const vitals = initWorker()
const transport = new WebTransport('https://relay.example.com/moq')
await transport.ready
vitals.annotate('joined', undefined, vitals.connectionIdOf(transport))

The worker’s annotate(), observe(), escalate() and resolve() go to the page’s session. Calls made before the worker is linked are kept, not lost. All configuration comes from the page’s init(). ids() in the worker returns null until it is linked, then the page’s IDs, and follows the page into a newSession().

Call linkWorker() once per worker, including each replacement. With no argument, initWorker() links over the worker’s own global, which is what a plain new Worker() gives you.

If the worker also runs an RPC library such as Comlink on its global, give the link its own channel so neither side sees the other’s messages:

// the page
const { port1, port2 } = new MessageChannel()
worker.postMessage({ moqtapPort: port2 }, [port2])
vitals.linkWorker(port1)
// the worker, on receiving that message
const vitals = initWorker(event.data.moqtapPort)

vitals.linked in the worker resolves true once the page links it, or false after limits.linkTimeoutMs (5 s); after false the probe stays inert and sends nothing. You need to await it only for diagnostics. A worker the page links but that never answers is reported as an unlinked worker, and the rest of the session is unaffected.

Deploy the page and worker bundles together. A page links only a worker of the same collector release.

Without any call, the collector:

WhenIt
a connection closesrecords why, and uploads that connection’s records
the page is hiddenuploads what it holds
the page closesends the session and uploads the rest
the page enters the back/forward cacheuploads what it holds; the session goes on if the page is restored
an upload failsretries it
the page closes with an upload unsentsends it on the next page load on the same origin
a linked worker is terminatedrecords its connections closed, with what it last reported

Terminating a worker is safe at any time. Its open connections are recorded closed at once, with everything measured up to that point. A worker linked over a MessagePort and then terminated is counted gone after 10 seconds.

A tab killed without a pagehide event while visible loses at most the last 60 seconds.

detail sets the level for the whole session. escalate() raises it for a window that resolve() closes, and a flight-recorder trigger raises it to headers for the window it opens.

LevelUploads
baselineControl messages and the interval rollup
headersAlso one record per object: connection, track, stream, group ID, object ID, status and arrival time
headers+sizesAlso each object’s payload size
headers+dataThe same as headers+sizes: payloads never leave the device

If uploads fall behind, per-object records are dropped first, and the session records how many were dropped.

Any level above baseline counts as elevated time, toward your plan’s recording minutes (see Budget and plan limits). detail set above baseline in init() counts for the whole session.

The flight recorder keeps recent data in memory. When a trigger fires, it uploads per-object arrival times, group and object IDs, and sizes for the seconds before the problem, then raises the level to headers for the window that follows. Payloads are never uploaded.

const vitals = init({
apiKey: 'mqt_pk_...',
flightRecorder: {
depth: '32MB',
triggers: {
stall: { afterMs: 2000 },
cadence: { multiple: 3, minSamples: 20 },
},
},
})
TriggerFires when
stalla track has received nothing for afterMs (default 2000)
cadencea gap between objects exceeds multiple × the track’s median gap (default 3×), once at least minSamples gaps (default 20) have been seen

Because cadence is relative to each track’s own median, one setting works for a 2-second-GOP video track (fires after 6 s) and a 250 ms audio track (fires after 750 ms).

trackSwitch: {} is accepted, but never fires.

All triggers are off unless you list them. One trigger fires at most once every 30 seconds, per track and per session.

depth is a memory limit on the viewer’s device (a size string like '32MB' or a number of bytes). How many seconds it holds depends on the stream’s bitrate. The memory is used only when a trigger is set, in each page or worker that opens a WebTransport. A page that only links workers uses none.

The capture window closes when you call resolve(), after flightRecorder.windowMs (15 s by default), when the recorder’s memory has filled with data from after the trigger, or when the page unloads. The collector can’t tell when your player has recovered, so call resolve() from your player’s recovery event to end elevated time promptly:

player.on('playing', () => vitals.resolve())

resolve() is safe to call when no window is open, and safe to call twice.

escalate() opens a capture window from your own code, at the level you choose, with a reason that is shown on the session timeline. It applies to the page and every linked worker. resolve() closes it. Unlike trigger windows, a manual window has no timeout.

try {
await player.recover()
} catch (err) {
vitals.escalate('headers+sizes', `recover failed: ${err.message}`)
} finally {
vitals.resolve()
}

apiKey is the only required option.

init({
apiKey: 'mqt_pk_...',
endpoint: 'https://ingest.example.com/v1/ingest',
metrics: { intervalMs: 5_000 },
upload: { intervalMs: 30_000 },
budget: { elevatedMinutes: 30 },
onInternalError: (err) => console.warn('[moqtap]', err),
})
OptionDefaultWhat it controls
apiKey—Your Insight project key. Required.
endpointhttps://moqgest.com/v1/ingestWhere uploads go. Set it to use your own ingest or a proxy.
sessionIdrandom UUIDYour own session ID, up to 128 UTF-8 bytes.
contextemptyactorId, contentId, environment, release.
detail'baseline'The level the whole session runs at. See Detail levels.
metrics.intervalMs10000How often a rollup closes.
metrics.maxTracks1024Tracks followed per session. Tracks over the limit are counted, not silently dropped.
flightRecorder.depth'32MB'Memory the recorder may use, per page or worker that opens a WebTransport.
flightRecorder.triggersnonestall, cadence. All off unless listed.
flightRecorder.windowMs15000How long a trigger’s capture window stays open without resolve().
budget.elevatedMinutes60Elevated time allowed per session. When it’s used up the session drops to baseline and refuses further escalation.
upload.intervalMs60000Upload at least this often.
upload.byteThreshold32768Upload early once this many bytes are waiting.
upload.earlyFlushesMs[45000]Extra uploads at these offsets after the session is ready.
upload.maxAttempts5Attempts per upload round. An upload that runs out of attempts stays queued and is retried.
upload.timeoutMs10000How long one upload request may go unanswered.
upload.drainDeadlineMs2000How long flush() and end() wait for uploads to finish.
storage.quotaBytes8388608Space in IndexedDB for data waiting to upload.
privacy.maskAuthParamstrueMask authorization tokens before anything is stored or sent.
limits.linkTimeoutMs5000How long a worker waits for the page to link it.
limits.*see DEFAULTSSafety caps: concurrent transports (8), control messages per second (5,000), the pre-init() buffer (1 MB).
onInternalErrornoneReceives configuration problems and internal errors. The collector never throws into your code after init() returns.

The exported DEFAULTS object holds every default, and DEFAULT_PROVENANCE says where each one came from.

The upload options set how often the page uploads. It also uploads when a connection closes, when the page is hidden, on flush() and end(), and when the page closes.

Data that can’t be sent is kept in IndexedDB (database moqtap-collector) and sent by the next page load on the same origin. If ingest is unreachable, slow or failing, your player isn’t affected.

Workers use neither the network nor IndexedDB. Everything they record is uploaded by the page.

The collector only connects to endpoint, with fetch() and navigator.sendBeacon(). Add the endpoint’s origin to connect-src:

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

If you set endpoint to your own domain, 'self' already covers it.

Nothing else in your policy needs to change. The collector uses no eval, new Function, injected <script>, blob: worker or runtime chunk loading.

MoQT’s wire format differs between drafts. The collector supports drafts 07 through 22. @moqtap/collector includes a decoder for every one of them. If your relays use one draft, import that draft’s entry instead. It has the same API and includes only that decoder:

import { init } from '@moqtap/collector/draft22'

Each entry, from @moqtap/collector/draft07 to @moqtap/collector/draft22, exports init and initWorker, and installs the wrapper on import as the root entry does. Use the same entry in the page and its workers. A connection that negotiates a different draft is reported as draft-mismatch and isn’t decoded, rather than being measured with the wrong decoder.

Measured with bun build --target=browser --minify and gzip -9 (bun run size in the package):

What the browser downloadsgzipped
@moqtap/collector, every draft64.7 KB
@moqtap/collector/draftNN, one draft44.3–46.8 KB

The collector loads no code at runtime, so it works in single-file bundles, IIFE worker builds and pages whose CSP blocks runtime chunk loading, with no extra setup.

Uploaded:

  • Per-track counters and histograms.
  • The session’s control messages, with authorization token values masked.
  • Your context, custom metrics and annotations.
  • While the level is above baseline: per-object header records and flight-recorder data (timings, IDs, status and, from headers+sizes, sizes).

Never uploaded:

  • Object payloads. The flight recorder’s memory stays on the device. headers+data uploads nothing more than headers+sizes.
  • Authorization token values. Whether a token was sent, its type and its alias are kept, so a failed authentication can still be diagnosed. To send token values, set privacy: { maskAuthParams: false }. Only a real boolean false turns masking off; a string like "false" is reported as MQ1102 and ignored.

Baseline collection is always on and does not use recording minutes.

Time above baseline counts toward your plan’s monthly recording minutes, in whole seconds, rounded down, with a one-second minimum per window. A 4.9-second window counts 4 seconds; two 200 ms windows count 2 seconds. budget.elevatedMinutes caps elevated time per session. Nothing over a plan’s limits is charged; the limits per plan are on the pricing page.

vitals.usage() returns the collector’s own count of bytes sent per level and elevated seconds. Use it to estimate recording minutes while you tune triggers. Insight counts what it receives, not this figure.

init() throws only in three cases:

CodeCause
MQ1001no config object
MQ1002no apiKey
MQ1003endpoint was given but isn’t a usable URL string

Every other problem, such as a mistyped key or an out-of-range number, is reported and the default is used. To handle these in code, pass onConfigProblem as the second argument:

init(config, {
onConfigProblem: ({ key, code, got }) => {
console.warn(`moqtap config ${key}: ${code}`, got)
},
})

Runtime errors arrive at onInternalError as Error objects whose message is the code followed by the offending value, for example MQ1101: metrics.intervalMs -1. Every code is explained in ERROR-CODES.md, and the package ships the same list as error-codes.json. Codes are never reused for a different meaning.

initWorker() never throws, and vitals.linked never rejects.

Open the browser’s developer tools, play a stream, and filter the Network panel for moqgest.com (or your own endpoint). Uploads come from the page, never from a worker. The first one goes out as soon as a session is ready.

There are no upload requests at all. The collector never saw a connection:

  • The player opens its WebTransport in a Web Worker. This is the most common cause. The collector in the page can’t see a worker’s connections. Add initWorker() in the worker and linkWorker(worker) in the page, as in Players that run in a Web Worker. If you’re not sure, search your player’s code for new Worker(.
  • The collector is imported too late. A connection opened before the import is invisible. See Import order.
  • The player isn’t using WebTransport. A player that falls back to WebSocket isn’t measured.

A worker’s connections are missing. Check, in the worker, what await vitals.linked resolves to:

  • false: the page never linked it. Call linkWorker() on the page for every worker, including each replacement, and on the same port the worker passed to initWorker().
  • The page and the worker were built from different collector releases. Deploy both bundles together.

A connection shows draft-mismatch. It negotiated a draft the imported entry doesn’t decode. Import the root entry, or the entry for the draft your relays use, in both the page and its workers.

Requests fail before reaching the network. Your Content Security Policy blocks them, and the console names the directive. Add the endpoint’s origin to connect-src, as in Content Security Policy.

Requests are answered 401 or 403. Ingest refused them:

  • 401: the key is wrong, or it was created in the last two minutes and isn’t live yet.
  • 403: the key is disabled, or your page’s origin isn’t on the key’s allowed origins.

Uploads answered 202 are accepted, and the session appears in Insight within a minute or two.

  • A browser with WebTransport. Pages are served over HTTPS, which WebTransport requires anyway.
  • CompressionStream for compressed uploads. Without it, uploads are sent uncompressed and MQ2201 is reported.

The moqtap browser extension shows whether the collector is present on a page and whether init() has been called.

FSL-1.1-MIT. You can use it in any application, commercial or not, modify it and ship it to your users. You can’t use the code to build a competing product. Each release becomes MIT two years after it is published.

@moqtap/codec, @moqtap/inspect and @moqtap/trace are MIT.