Client Monitor

@observertc/client-monitor-js turns the raw, awkward output of RTCPeerConnection.getStats() into something you can act on: a navigable object model, per-interval derived metrics, 0–5 quality scores with reasons, and 46 detectors that raise and resolve 37 issue types with proper hysteresis instead of flapping on every tick.

It runs entirely in the browser, has two runtime dependencies, and needs no ObserveRTC server component — use it purely for local UI, or ship its ClientSample output to observer-js for cross-participant analysis.

Current version — 4.9.0

This documentation tracks 4.9.0. It is a large release: the detector layer was rebuilt around one rule — one detector class raises one issue type — which turned 27 classes into 46 and 27 issue types into 37, retired every group config key, and made the default score calculator a reading of the open issues rather than a second opinion formed from raw stats.

collectingPeriodInMs and samplingPeriodInMs both default to 5000 now (they were 2000 and 8000). See What changed in 4.9 before upgrading from 4.8 or earlier.

Install

npm install @observertc/client-monitor-js
yarn add @observertc/client-monitor-js

Runtime dependencies: eventemitter3 and ua-parser-js. Ships ESM with TypeScript declarations, tree-shakeable (sideEffects: false), targeting evergreen browsers. This release ships ClientSample schema 3.7.0.

Sixty-second start

import { ClientMonitor } from "@observertc/client-monitor-js";

const monitor = new ClientMonitor({
    clientId: "user-42",
    callId: "room-abc",
    collectingPeriodInMs: 5000,   // how often getStats() is polled  (default 5000)
    samplingPeriodInMs: 5000,     // how often a ClientSample is emitted (default 5000)
});

// Anything that produces WebRTC stats can be a source.
monitor.addSource(peerConnection);

// 1. Live metrics for your own UI.
monitor.on("stats-collected", () => {
    ui.render({
        sendingVideo: monitor.sendingVideoBitrate,
        receivingVideo: monitor.receivingVideoBitrate,
        rttMs: (monitor.avgRttInSec ?? 0) * 1000,
        score: monitor.score,
    });
});

// 2. Confirmed problems, with a start and an end.
monitor.on("issue", (issue) => log.warn(issue.type, issue.payload));
monitor.on("issue-resolved", (issue) => log.info(issue.type, "lasted", issue.payload?.durationInMs));

// 3. Telemetry for the backend.
monitor.on("sample-created", ({ sample }) => transport.send(sample));

// 4. Clean shutdown — still-open issues auto-resolve.
monitor.close();

That is the whole integration. Everything below is what you get for free, and how to bend it to your application.

What the library actually does for you

The short version

Raw getStats() gives you monotonically increasing counters. Almost every question you want to ask — “what is the bitrate?”, “is video frozen?”, “is the CPU the bottleneck?” — requires differencing those counters across ticks, normalising browser differences, and applying hysteresis so you do not alert on a single bad sample. That is the work this library does.

1. Counters become metrics

getStats() reports bytesReceived: 918273645. What you want is bits per second over the last interval. The library computes per-interval deltas and rates for every stat type and exposes them as plain properties:

monitor.sendingVideoBitrate;              // bps, aggregated across all peer connections
pcMonitor.deltaInboundPacketsLost;        // packets lost in this interval only
inboundRtp.ewmaFps;                       // smoothed frame rate
inboundRtp.fpsVolatility;                 // how unstable that frame rate is
inboundRtp.inventedSpeechRatio;           // share of audio NetEQ had to invent
outboundRtp.payloadBitrate;               // excludes headers and retransmissions
pcMonitor.statsClockTime;                 // the stats-time clock every window is aged on

See Derived metrics for the full catalogue.

2. Raw stats become a navigable object graph

Browser stats are a flat map of records joined by string ids. The library resolves those joins once and hands you objects that know their neighbours:

const track = monitor.tracks.find((t) => t.kind === "video" && t.direction === "outbound");
const rtp = track.highestLayer;               // the top simulcast layer (was getHighestLayer())
const remote = rtp.getRemoteInboundRtp();     // what the far end reports about it
const source = rtp.getMediaSource();          // the local camera feeding it

3. Symptoms become verdicts

46 detector classes watch the conditions that actually degrade calls, each with its own on/off thresholds so a single noisy tick cannot raise an alert. They are grouped into five categories by what the detection looks for:

CategoryQuestion it answersClasses
ConnectivityCan this endpoint establish and keep the path?9
Transport qualityThe path exists — is it carrying traffic well enough?8
Pipeline disruptionDid the media chain stop, or do two components disagree?15
Perceived qualityIs what the user sees and hears degraded?6
TelemetryWhat is this session’s shape, and what changed?8

Every issue-raising detector raises an issue when the condition starts and resolves it when the condition clears, enriching the resolution with durationInMs. That is the difference between “we saw congestion” and “congestion lasted 14 seconds on this peer connection”.

All detectors →  ·  Events & issues →

4. Quality becomes a number you can chart

DefaultScoreCalculator produces 0.0–5.0 scores for each track, each peer connection and the client as a whole, together with a breakdown of why:

monitor.on("score", ({ clientScore, currentReasons }) => {
    // clientScore: 3.2
    // currentReasons: { "decoder-bottleneck": 1.0, "transport-loss-sustained": 2.5 }
});

As of 4.9 the client score is 5 − RMSE across five dimensions — the transport, and inbound and outbound audio and video — and every charge is either an open issue or a named continuous reading. The whole calculator is replaceable; see Scoring.

5. Everything becomes a ClientSample

On the sampling period the monitor snapshots its entire state into a ClientSample: every peer connection, every stats record, the events and issues since the last sample, your metadata and your extension stats. That object is the input to observer-js and to your own storage.

Where it pays off

Support triage

A user says 'the call was bad at 3pm'. Issues carry start, end and duration, so you can answer what was wrong instead of guessing from averages.

In-call UX

Show a real network warning at the moment congestion is detected, and take it down when it resolves — not a spinner that never goes away.

Release regression

Score and issue rates per client build. Tag samples with your app version via attachments and compare releases directly.

Adaptive behaviour

React in the client: drop to audio-only on sustained uplink congestion, lower the encoding on cpulimitation, prompt a device change on a lost capture source.

Integration surface

Extension pointWhat you can do
SourcesRTCPeerConnection, a mediasoup Device (new transports hooked automatically) or a single mediasoup transport
DetectorsAdd your own, never construct a built-in (null on its key), or toggle any of them at runtime
Score calculatorReplace the scoring model wholesale while keeping the metric plumbing
Stats adaptersPre- and post-process the raw stats array — normalise, filter, or synthesize records
Extension stats providersInject application metrics (sync or async) into every sample
Declared track contextTell the library what the stats cannot: screen share, presented size, the paired video track
attachments / appDataPer-entity data that either ships with samples or stays local
LoggerRoute the library’s logs into your own logger, or silence it entirely

Documentation map

What changed in 4.9

Every break, every retired key, and the migration for each one.

Integrations

RTCPeerConnection, mediasoup, and logging setup.

Configuration

Every option and every detector block, with defaults.

Detectors

The taxonomy, the five design rules, and the index of all 46 classes.

Events & issues

The raise / update / resolve lifecycle, every issue type, and type-safe handling.

Scoring

How the default 0–5 score is computed, what it charges, and how to replace it.

Monitors & derived metrics

The object graph and every computed field on it.

Sampling & transport

Samples, stats adapters, extension stats, and delta codecs.

Recipes

Worked patterns: dashboards, adaptive UX, production tuning, frameworks.

API reference

Constructor, methods, properties and the full event table.

Resources