API reference

new ClientMonitor(config?)

Every configuration field is optional — see Configuration for the full object and its defaults.

const monitor = new ClientMonitor(config?: ClientMonitorConfig);

ClientMonitor and AppliedClientMonitorConfig are generic over your appData shape.

Methods

Sources and lifecycle

MethodDescription
addSource(source)Adds an RTCPeerConnection, mediasoup Device or mediasoup transport
collect()Runs one stats collection immediately, outside the timer
createSample()Builds a ClientSample from current state; requires bufferingEventsForSamples when automatic sampling is off
close()Stops collection, releases monitors, auto-resolves open issues, emits 'close'

Configuration at runtime

MethodDescription
setCollectingPeriod(periodInMs)Changes the stats collection interval
setSamplingPeriod(periodInMs)Changes the sampling interval
setScore(score, ownReasons?, aggregatedReasons?)Sets the client score directly, bypassing the calculator
setInboundTrackContext(trackId, ctx)Declares what the stats cannot reveal about an inbound track
setOutboundTrackContext(trackId, ctx)The same for an outbound track

Events, issues and metadata

MethodDescription
addEvent(event)Records an immutable ClientEvent
addIssue({ type, payload?, timestamp? })Records a one-shot issue; emits 'issue', never enters activeIssues
raiseIssue(key, { type, payload?, timestamp? })Creates or refreshes a stateful issue; emits 'issue' or 'issue-updated'
resolveIssue(key, { comment?, payload?, resolvedAt? })Resolves a stateful issue; emits 'issue-resolved'
getActiveIssuesByType(type?)Snapshot of active stateful issues, optionally filtered
isIssueActive(key)true when an issue with that key is open
addMetaData(metaData)Records a ClientMetaData item
addExtensionStats(stats)Records an ExtensionStat; with an id, readable back

Utilities

MethodDescription
getPeerConnectionMonitor(peerConnectionId)Peer connection monitor by id
getTrackMonitor(trackId)Track monitor by MediaStreamTrack id, either direction
getExtensionStatsMonitor(id) / getExtensionStatsPayload(id)Reads application stats back off the monitor tree
watchMediaDevices()Integrates with navigator.mediaDevices
fetchUserAgentData()Resolves User-Agent Client Hints into client metadata

Properties

PropertyTypeDescription
scorenumber | undefinedCurrent client score, 0.0–5.0
scoreReasonsRecord<string, number> | undefinedThis entity’s own subtractions
scoreCalculatorScoreCalculatorThe implementation in use — assignable
cpuUtilizationnumber | undefinedThe reading behind cpulimitation, published whether or not it raised
createdAt / uptimeInMsnumberWhen this monitor started, and how long it has run
activeTabbooleanfalse while the tab is backgrounded
closedbooleanWhether close() has been called
configClientMonitorConfigThe effective configuration
detectorsDetectorsClient-level detector registry
statsAdaptersStatsAdaptersRegistry of stats adapters
extensionStatsProvidersSet<ExtensionStatProvider>Called on every collection
peerConnectionsPeerConnectionMonitor[]Monitored peer connections
mappedPeerConnectionsMap<string, PeerConnectionMonitor>Same, keyed by id
tracksTrackMonitor[]All monitored tracks, both directions
inboundRtps / outboundRtps…RtpMonitor[]Every RTP monitor across connections
remoteInboundRtps / remoteOutboundRtps…RtpMonitor[]The far end’s reports
iceTransports / codecs / certificatesmonitor arrays
mappedExtensionStatsMonitorsMap<string, ExtensionStatsMonitor>Application stats folded into the tree
activeIssuesMap<string, RaisedClientIssue>Open stateful issues, keyed by key
samplingSchemaVersionstringThe ClientSample schema this build emits — 3.7.0
attachmentsRecord<string, unknown>Shipped with every sample
appDataRecord<string, unknown>Local only, never shipped

Derived client-level metrics (sendingVideoBitrate, avgRttInSec, totalAvailableOutgoingBitrate, durationOfCollectingStatsInMs, …) are documented in Monitors & derived metrics.

Declared track context

Some of what decides a verdict is invisible to getStats().

monitor.setInboundTrackContext(trackId, {
    contentType: 'screenshare',     // no frame-rate penalties on static content
    motionType: 'lowmotion',
    videoTag: videoElement,         // presented size, re-measured every tick
    presentedResolution: { width: 1280, height: 720 },   // or declare it directly
    linkedVideoTrackId: videoTrackId,                    // enables AVDesyncPlayoutDetector
    paused: true,
    remoteOutboundTrackPaused: true,
});

monitor.setOutboundTrackContext(trackId, { contentType: 'camera', paused: false });

Declarations may be made before the track exists — signalling usually announces a guest’s screen share before a packet arrives — and are applied to whichever peer connection first manifests the track. They merge rather than replace: a field the call does not mention keeps its value, and a field passed as an explicit undefined is cleared. Those are different statements.

Outbound contentType is auto-detected from track.getSettings().displaySurface where the browser reports one.

Events

type ClientMonitorEvents = {
    // Collection & sampling
    'stats-collected': [{
        clientMonitor: ClientMonitor;
        startedAt: number;
        durationOfCollectingStatsInMs: number;
        collectedStats: [string, RtcStats[]][];
    }];
    'sample-created': [{ clientMonitor: ClientMonitor; sample: ClientSample }];

    // Scoring
    'score': [{ clientMonitor: ClientMonitor; clientScore: number; currentReasons: Record<string, number> }];

    // Issue lifecycle
    'issue':          [ClientIssue];
    'issue-updated':  [RaisedClientIssue];
    'issue-resolved': [ResolvedClientIssue];

    // Application records
    'client-event': [ClientEvent];

    // One entry per detector event — see Events & issues for the full list
    'uplink-congestion':   [UplinkCongestionEventPayload];
    'downlink-congestion': [DownlinkCongestionEventPayload];
    'video-flow-disrupted':[VideoFlowEventPayload];
    // …

    'close': [];
};

Every payload but issue carries the emitting clientMonitor. The complete event table is in Events & issues.

Interfaces

Detector

interface Detector {
    readonly name: string;
    disabled?: boolean;
    includeIssueInSample?: boolean;
    update(): void;
}

Classes that can compute it also expose a public inputsUnavailable: boolean. It is deliberately not on the interface — that contract carries only what the registry needs to run a detector and what an application needs to toggle one — so reading it means naming the class:

pcMonitor.detectors.getByName<TransportDelayDetector>('transport-delay-detector')?.inputsUnavailable;

Detectors

detectors.size;  detectors.listOfNames;
detectors.has(name);  detectors.getByName<T>(name);
detectors.find(pred);  detectors.filter(pred);
detectors.add(detector);  detectors.remove(detector);  detectors.clear();
detectors.disable(name);  detectors.enable(name);  detectors.isEnabled(name);
detectors.disableAll();   detectors.enableAll();
for (const d of detectors) { /* … */ }

Registries exist on ClientMonitor, PeerConnectionMonitor, IceTransportMonitor, both track monitors and MediaPlayoutMonitor. Lookup by name is exact and there is no alias table — a retired name returns undefined / false.

IssueRegistry

Every monitor owns the issues raised against it. (New in 4.9.)

monitorIssues.hasType(type);
monitorIssues.getByType(type);
monitorIssues.getFirstPayloadByType(type);
monitorIssues.size;

ScoreCalculator

interface ScoreCalculator {
    update(): void;
}

That is the whole contract as of 4.9 — the encode*ScoreReasons hooks are gone, along with every other score internal that used to be reachable from outside the package. See Scoring.

StatsAdapter

interface StatsAdapter {
    readonly name: string;
    preAdapt?(stats: RtcStats[]): RtcStats[];    // before the built-ins
    postAdapt?(stats: RtcStats[]): RtcStats[];   // after them
}

ExtensionStatProvider

type ExtensionStatProvider = () =>
    | { type: string; id?: string; payload?: Record<string, unknown> }
    | Promise<{ type: string; id?: string; payload?: Record<string, unknown> }>;

Logger

interface Logger {
    trace(...args: unknown[]): void;
    debug(...args: unknown[]): void;
    info(...args: unknown[]): void;
    warn(...args: unknown[]): void;
    error(...args: unknown[]): void;
}

Exported types

Each built-in detector’s class, its <ClassName>Config type and its issue payload type are exported from the package root, along with the discriminated unions and guards:

import {
    ClientMonitor,

    // unions and guards
    ClientMonitorIssue,
    ClientMonitorResolvedIssue,
    ClientMonitorIssueType,
    isClientMonitorIssue,
    isClientMonitorResolvedIssue,

    // a detector, its config type and its payload type
    StuckDecoderDetector,
    StuckDecoderIssuePayload,
} from '@observertc/client-monitor-js';

import type { StuckDecoderDetectorConfig } from '@observertc/client-monitor-js';

Shared estimators introduced in 4.9 are exported too: SlicedWindow, DecayingMaxEstimator and FrugalQuantileEstimator.

Class exports retired in 4.9

IceTupleChangeDetector, LongPcConnectionEstablishmentDetector, LongPcConnectionEstablishmentStage and NoAvailableIceCandidateDetector are removed — import IceTraversalDetector, IcePathEstablishmentDetector, IcePathEstablishmentStage and IceReachabilityDetector. The classes that were split (IcePathStabilityDetector, DtlsHandshakeDetector, CaptureFailureDetector, MediaPipelineDetector) never had an alias to remove: a class that raised four issues cannot be aliased onto one that raises a single one without lying about what it does. Import the part you meant — see What changed in 4.9.

Release candidates

Stable releases come from npm as usual. Every push to develop publishes a release candidate as X.Y.Z-rc.<N>; depend on the next dist-tag to track them:

"dependencies": { "@observertc/client-monitor-js": "next" }

next always points at the newest RC across all version lines, so the dependency never has to be edited when the line bumps.

A caret range cannot track RCs

"^4.9.0" resolves to the stable 4.9.0 and silently excludes every RC — a range with no prerelease in it never matches prerelease versions. "^4.9.1-rc.5" does match RCs, but only of 4.9.1, so it stops updating the moment the line bumps.

Full history: CHANGELOG on GitHub.