Sampling & transport
A sample is a snapshot of everything the monitor knows at one instant, serialised into the
ClientSample schema. It is the unit of telemetry: what you upload,
what you store, and what observer-js consumes.
What a sample contains
| Section | Content |
|---|---|
| Identity | clientId, callId, timestamp, score, scoreReasons, attachments |
peerConnections[] | Every monitored peer connection with all fifteen stats arrays |
clientEvents[] | Events recorded since the last sample |
clientIssues[] | Issues raised (and resolutions) since the last sample |
clientMetaItems[] | Device lists, browser/OS metadata, SDP, media constraints |
extensionStats[] | Whatever your extension stats providers returned |
Automatic sampling
Set samplingPeriodInMs and the monitor emits a sample on that cadence:
const monitor = new ClientMonitor({
collectingPeriodInMs: 2000,
samplingPeriodInMs: 4000,
});
monitor.on("sample-created", (sample) => {
transport.send(sample);
});Events and issues that occur between samples are buffered and all ship in the next one — a longer sampling period costs you timing resolution on the server, not data.
Manual sampling
Omit samplingPeriodInMs and call createSample() yourself. You must set
bufferingEventsForSamples: true, otherwise events and issues are not retained between calls.
const monitor = new ClientMonitor({
collectingPeriodInMs: 2000,
bufferingEventsForSamples: true,
});
// Drive sampling from your own scheduler, or on demand.
const sample = monitor.createSample();
if (sample) transport.send(sample);Manual sampling is the right choice when upload timing is not yours to decide — batching into an
existing telemetry channel, flushing on visibilitychange, or sampling only while a support agent
is watching.
Stats adapters
Adapters sit between getStats() and the monitors. Each one can rewrite the stats array before
the monitors consume it (adapt) and again after they have updated (postAdapt).
The processing flow
- Collect —
getStats()is called on every source. - Pre-adapt — every adapter’s
adapt(stats)runs, in registration order. - Update — monitors consume the adapted stats and recompute derived fields.
- Post-adapt — every adapter’s
postAdapt(stats)runs, for cross-stat work. - Detect & score — detectors run, then the score calculator.
Built-in adapters
Installed automatically based on the detected environment:
| Adapter | Applies to | Purpose |
|---|---|---|
Firefox94StatsAdapter | Firefox | Normalises mediaType → kind on RTP stats |
FirefoxTransportStatsAdapter | Firefox | Synthesizes transport stats from ICE candidate pairs |
| mediasoup probator filter | mediasoup sources | Drops probe traffic from the stats array |
Where adapters live
Adapters are registered per peer connection, on peerConnectionMonitor.statsAdapters. The
registry is keyed by the adapter’s name, so adding two adapters with the same name is a no-op
(the second is rejected with a warning).
monitor.on("stats-collected", () => {
for (const pc of monitor.peerConnections) {
if (!pc.statsAdapters.adapters.has("my-adapter")) {
pc.statsAdapters.add(myAdapter);
}
}
});A full adapter class
An adapter is an object with a name, a required adapt(stats) and an optional
postAdapt(stats). Errors thrown from either are caught and logged — a broken adapter cannot stop
collection.
class CorrelationAdapter {
name = "correlation-adapter";
adapt(stats) {
// Runs before monitors update — normalise, filter, annotate.
return stats.filter((s) => s.trackIdentifier !== "probator");
}
postAdapt(stats) {
// Runs after monitors update — synthesize records from several stats at once.
const pair = stats.find((s) => s.type === "candidate-pair" && s.state === "succeeded");
if (!pair?.availableIncomingBitrate) return stats;
const inboundTotal = stats
.filter((s) => s.type === "inbound-rtp")
.reduce((sum, s) => sum + (s.bitrate ?? 0), 0);
stats.push({
type: "custom-bandwidth",
id: "bandwidth-estimation",
timestamp: Date.now(),
estimatedBandwidth: pair.availableIncomingBitrate,
utilization: inboundTotal / pair.availableIncomingBitrate,
});
return stats;
}
}
const adapter = new CorrelationAdapter();
pcMonitor.statsAdapters.add(adapter);
// Remove by instance or by name.
pcMonitor.statsAdapters.remove(adapter);
pcMonitor.statsAdapters.remove("correlation-adapter");Extension stats are usually the better tool
If you want to add your own numbers to the telemetry, use an
extension stats provider rather than synthesizing stats records in
postAdapt. Adapters are for reshaping what the browser reported; providers are for what only
your application knows.
Adapters run on every tick
An adapter is on the hot path of every stats collection, on the main thread. Keep them cheap: filtering and field rewriting, not heavy computation. If you need expensive work, do it in an extension stats provider, which can be asynchronous.
Extension stats providers
Extension stats are your own metrics, injected into the pipeline and shipped in every sample. They exist so you can correlate WebRTC quality with things only your application knows: render frame rate, decode worker backlog, feature flags, a user-visible latency measurement.
// Synchronous.
monitor.extensionStatsProviders.add(() => ({
type: "render-metrics",
payload: {
uiFps: renderer.currentFps,
droppedTiles: renderer.droppedTiles,
},
}));
// Asynchronous — the collector awaits it.
monitor.extensionStatsProviders.add(async () => {
const cpu = await measureCpuUsage();
return {
type: "system-metrics",
payload: {
cpu,
heapUsed: performance.memory?.usedJSHeapSize ?? 0,
},
};
});| Property | Behaviour |
|---|---|
| Return shape | { type: string, payload?: object } |
| Timing | Called during every stats collection cycle |
| Async | Promises are awaited |
| Errors | Logged, never propagated — a broken provider cannot stop monitoring |
They arrive on the sample as-is:
monitor.on("sample-created", (sample) => {
sample.extensionStats;
// [
// { type: "render-metrics", payload: { uiFps: 58, droppedTiles: 0 } },
// { type: "system-metrics", payload: { cpu: 45, heapUsed: 52428800 } },
// ]
});On the server side these surface as client-extension-stats events on the
observer event bus.
Metadata
monitor.addMetaData({ type: "LOCAL_SDP", payload: JSON.stringify(offer) });
monitor.addEvent({ type: "RECORDING_STARTED", timestamp: Date.now() });Known metadata types (MEDIA_DEVICE, MEDIA_CONSTRAINT, USER_MEDIA_ERROR, LOCAL_SDP,
BROWSER, ENGINE, PLATFORM, OPERATION_SYSTEM, …) are recognised and interpreted by
observer-js; anything else passes through untouched.
Binary compression
JSON samples are readable but large. Two companion packages encode a ClientSample into the
compact protobuf representation defined by the schema and decode it again.
# Client side
npm install @observertc/samples-encoder
# Server side
npm install @observertc/samples-decoderEncoding is transport-agnostic — WebSocket, fetch, sendBeacon, or a message queue all work.
See sample-encoder-js and
sample-decoder-js.
Both sides are stateful, and versions must match
The encoder writes unchanging values — clientId, callId, peer connection and track ids — once
and omits them afterwards. That is where most of the saving comes from, and it means: one encoder
per client, one decoder per client stream, samples delivered in order. A restarted or shared
decoder does not throw; it produces samples with missing ids.
Protobuf field numbers are also derived from field order, so keep both packages on the same schema version — they are released in lockstep. See schema versions.
Reducing telemetry volume
In rough order of impact:
- Increase
samplingPeriodInMs. Ten seconds instead of four cuts upload volume by more than half with no loss of issue fidelity. - Use the binary encoder. Same information, far fewer bytes.
- Turn off detectors you do not act on with
nullin the configuration — fewer issues, less work per tick. - Filter in an adapter. If you never analyse certificates or codecs server-side, drop them before the sample is built.
- Sample conditionally. Switch to manual sampling and only upload when something interesting is happening — for example, always upload while any issue is active, and at a reduced rate otherwise:
setInterval(() => {
const interesting = monitor.getActiveIssuesByType().length > 0;
if (!interesting && Math.random() > 0.2) return; // 20% baseline sampling
const sample = monitor.createSample();
if (sample) transport.send(sample);
}, 5000);