@observertc/client-monitor-js
Version:
ObserveRTC Client Integration Javascript Library
1,403 lines (1,065 loc) • 113 kB
Markdown
# @observertc/client-monitor-js
**JavaScript library to monitor WebRTC applications**
@observertc/client-monitor-js is a client-side library to monitor [WebRTCStats](https://www.w3.org/TR/webrtc-stats/) and integrate your app with ObserveRTC components.
[](https://badge.fury.io/js/@observertc%2Fclient-monitor-js)
[](https://opensource.org/licenses/Apache-2.0)
## Table of Contents
1. [Installation](#installation)
2. [Quick Start](#quick-start)
3. [Integrations](#integrations)
4. [Configuration](#configuration)
5. [ClientMonitor](#clientmonitor)
6. [Detectors](#detectors)
7. [Score Calculation](#score-calculation)
8. [Collecting and Adapting Stats](#collecting-and-adapting-stats)
9. [Sampling](#sampling)
10. [Events and Issues](#events-and-issues)
11. [WebRTC Stats Monitors](#webrtc-stats-monitors)
12. [Stats Adapters](#stats-adapters)
13. [Derived Metrics](#derived-metrics)
14. [Schema Reference](#schema-reference)
15. [Examples](#examples)
16. [Troubleshooting](#troubleshooting)
17. [API Reference](#api-reference)
18. [FAQ](#faq)
## Installation
```bash
npm install @observertc/client-monitor-js
```
or
```bash
yarn add @observertc/client-monitor-js
```
## Quick Start
```javascript
import { ClientMonitor } from "@observertc/client-monitor-js";
// Create a monitor with default configuration
const monitor = new ClientMonitor({
clientId: "my-client-id",
callId: "my-call-id",
collectingPeriodInMs: 2000,
samplingPeriodInMs: 4000,
});
// Add a peer connection to monitor
monitor.addSource(peerConnection);
// Listen for samples
monitor.on("sample-created", (sample) => {
console.log("Sample created:", sample);
// Send sample to your analytics backend
});
// Listen for issues
monitor.on("issue", (issue) => {
console.log("Issue detected:", issue);
});
// Close when done
monitor.close();
```
## Integrations
### RTCPeerConnection Integration
Direct integration with native WebRTC PeerConnections:
```javascript
import { ClientMonitor } from "@observertc/client-monitor-js";
const peerConnection = new RTCPeerConnection();
const monitor = new ClientMonitor();
// Add the peer connection for monitoring
monitor.addSource(peerConnection);
```
### Mediasoup Integration
```javascript
import { ClientMonitor } from "@observertc/client-monitor-js";
import mediasoup from "mediasoup-client";
const device = new mediasoup.Device();
const monitor = new ClientMonitor();
// Monitor the mediasoup device
monitor.addSource(device);
// The monitor will automatically detect new transports created after adding the device
const transport = device.createSendTransport(/* ... */);
// For transports created before adding the device, add them manually:
monitor.addSource(transport);
```
**Important**: When adding a mediasoup device, the monitor automatically hooks into the `newtransport` event to detect newly created transports. However, transports created before adding the device must be added manually.
### Logger Integration
Customize logging behavior by providing your own logger to `ClientMonitor`.
The same logger instance is propagated to source and monitor internals.
Log messages include module prefixes such as `[ClientMonitor]:` and `[Sources]:`.
If no logger is provided, the default logger logs `warn` and `error` to console and treats `trace`/`debug`/`info` as no-op.
#### Basic Custom Logger
```javascript
import { ClientMonitor, Logger } from "@observertc/client-monitor-js";
const customLogger: Logger = {
trace: (...args) => console.trace(...args),
debug: (...args) => console.debug(...args),
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
error: (...args) => console.error(...args),
};
const monitor = new ClientMonitor({
logger: customLogger,
});
```
#### Production Logger Adapter
Map your existing app logger to the `Logger` interface:
```javascript
import { ClientMonitor } from "@observertc/client-monitor-js";
import pino from "pino";
const appLogger = pino({ level: "info" });
const monitor = new ClientMonitor({
logger: {
trace: (...args) => appLogger.trace(...args),
debug: (...args) => appLogger.debug(...args),
info: (...args) => appLogger.info(...args),
warn: (...args) => appLogger.warn(...args),
error: (...args) => appLogger.error(...args),
},
});
```
#### Disable Logging
```javascript
const noop = () => {};
const monitor = new ClientMonitor({
logger: {
trace: noop,
debug: noop,
info: noop,
warn: noop,
error: noop,
},
});
```
## Configuration
The `ClientMonitor` accepts a comprehensive configuration object. All configuration options are optional except when specifically noted:
```javascript
import { ClientMonitor } from "@observertc/client-monitor-js";
const monitor = new ClientMonitor({
// Basic configuration (all optional)
clientId: "unique-client-id",
callId: "unique-call-id",
collectingPeriodInMs: 2000, // Default: 2000ms
samplingPeriodInMs: 4000, // Optional, no default
// Integration settings (optional with defaults)
integrateNavigatorMediaDevices: true, // Default: true
addClientJointEventOnCreated: true, // Default: true
addClientLeftEventOnClose: true, // Default: true
bufferingEventsForSamples: false, // Default: false
// Detector configurations (all optional).
//
// • Omit the key (or pass `undefined`) → defaults applied.
// • Pass `null` → detector is NOT constructed at all.
// • Pass an object → detector enabled with your overrides.
//
// After construction, every built-in detector also exposes a public `disabled`
// boolean flag — flip it at runtime to silence the detector without removing it.
audioDesyncDetector: {
fractionalCorrectionAlertOnThreshold: 0.1,
fractionalCorrectionAlertOffThreshold: 0.05,
},
congestionDetector: {
sensitivity: "medium", // 'low', 'medium', 'high'
},
cpuPerformanceDetector: {
incomingDecodedFramesRatioThresholds: {
alertOn: 0.7,
alertOff: 0.85,
minReceivedFrames: 10,
},
durationOfCollectingStatsThreshold: {
lowWatermark: 5000,
highWatermark: 10000,
},
encoderCpuLimitationShareThreshold: 0.3, // share of the interval spent CPU-limited
encodeTimeBudgetRatio: 0.8, // share of the per-frame budget encoding may use
},
dryInboundTrackDetector: { thresholdInMs: 5000 },
dryOutboundTrackDetector: { thresholdInMs: 5000 },
videoFreezesDetector: {},
playoutDiscrepancyDetector: {
lowSkewThreshold: 2,
highSkewThreshold: 5,
},
syntheticSamplesDetector: {
minSynthesizedSamplesDuration: 1000,
},
longPcConnectionEstablishmentDetector: {
thresholdInMs: 5000,
},
iceConnectivityDetector: {
disconnectedThresholdInMs: 5000, // how long `disconnected` must last before an issue
transportStallThresholdInMs: 5000, // sending but receiving nothing for this long
pathSwitchWindowInMs: 30000, // window for counting selected-path switches
pathSwitchThreshold: 3, // switches in that window => unstable path
iceRestartRecommendationThresholdInMs: 10000, // before recommending a restart
iceRestartRecommendationCooldownInMs: 15000, // min gap between recommendations
createEvent: true,
},
audioConcealmentDetector: {
onThreshold: 0.03, // Webex treats >3% concealment as significant, >5% as severe
offThreshold: 0.01,
windowInMs: 15000, // spans several collections even at a 5s collecting period
minSamplesInWindow: 24000,
},
jitterBufferStressDetector: {
targetDelayThresholdInMs: 200,
timeStretchThreshold: 0.02,
minConsecutiveTicks: 2,
},
decoderPerformanceDetector: {
decodeTimeBudgetRatio: 0.8, // share of the per-frame budget decoding may use
dropRatioThreshold: 0.1,
minFramesReceived: 10,
quietLossThreshold: 0.02, // above this, blame the network instead
minConsecutiveTicks: 2,
},
videoRecoveryDetector: {
windowInMs: 30000,
pliRateAlertOn: 0.5, // real-world storms run ~0.5-0.7 PLI/s sustained
pliRateAlertOff: 0.15,
recoveryFailedThresholdInMs: 5000,
recoveryFailedMinPliCount: 2,
},
stuckDecoderDetector: {
thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier x RTT)
rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
minStuckTicks: 2, // never judge on fewer observations than this
minBitrate: 10000, // bps below which this is a dry track, not a wedge
minPliCount: 2,
},
sourceEncoderBottleneckDetector: {
captureFpsRatioThreshold: 0.5,
minSourceFps: 5,
encodeFpsRatioThreshold: 0.7,
encodeTimeBudgetRatio: 0.8,
cpuLimitationShareThreshold: 0.3,
minConsecutiveTicks: 2,
},
captureFailureDetector: {
silenceThresholdInMs: 30000, // long on purpose: silence != a broken mic
silenceRmsThreshold: 0.001,
createEvent: true,
},
// Observations — these emit events and never raise issues.
codecChangeDetector: { createEvent: true },
videoResolutionChangeDetector: { createEvent: true },
simulcastLayerDetector: { createEvent: true },
statsGapDetector: {
gapRatioThreshold: 2, // multiple of collectingPeriodInMs that counts as a gap
minGapInMs: 5000, // a single missed short tick is jitter, not a gap
createEvent: true,
},
// To outright disable a detector at construction time, pass `null`:
// freezedVideoDetector: null,
// playoutDiscrepancyDetector: null,
// Application data (optional)
appData: {
userId: "user-123",
roomId: "room-456",
},
});
```
**Important**: You can create a monitor with minimal configuration or even no configuration at all:
```javascript
// Minimal configuration
const monitor = new ClientMonitor({
clientId: "my-client",
collectingPeriodInMs: 1000,
});
// No configuration (uses all defaults)
const monitor = new ClientMonitor();
```
## ClientMonitor
The `ClientMonitor` is the main class that orchestrates WebRTC monitoring, statistics collection, and anomaly detection.
### Core Features
- **Multi-source monitoring**: Supports RTCPeerConnection, mediasoup devices and transports
- **Automatic stats collection**: Periodically collects WebRTC statistics
- **Real-time anomaly detection**: Built-in detectors for common issues
- **Performance scoring**: Calculates quality scores for connections and tracks
- **Event generation**: Emits events for WebRTC state changes and issues
- **Sampling**: Creates periodic snapshots of the client state
### Public Methods
#### Core Methods
- **`addSource(source: RTCPeerConnection | MediasoupDevice | MediasoupTransport)`**: Adds a source for monitoring
- **`close()`**: Closes the monitor and stops all monitoring activities
- **`collect()`**: Manually collects stats from all monitored sources
- **`createSample()`**: Creates a client sample with current state
#### Configuration Methods
- **`setCollectingPeriod(periodInMs: number)`**: Updates the stats collection interval
- **`setSamplingPeriod(periodInMs: number)`**: Updates the sampling interval
- **`setScore(score: number, reasons?: Record<string, number>)`**: Manually sets the client score
#### Event & Issue Methods
- **`addEvent(event: ClientEvent)`**: Adds an immutable client event.
- **`addIssue({ type, payload?, timestamp? })`**: Adds a one-shot issue (fire-and-forget). Emits `'issue'` and buffers into the next sample but never enters the active store and cannot be resolved. Use this for incidents with no "ended" condition (e.g. `USER_MEDIA_ERROR`).
- **`raiseIssue(key, { type, payload?, timestamp? })`**: Creates or refreshes a stateful, resolvable issue keyed by `key`. Re-raising with the same key updates the entry in place and emits `'issue-updated'`. See the [Events and Issues](#events-and-issues) section for the full lifecycle.
- **`resolveIssue(key, { comment?, payload?, resolvedAt? })`**: Resolves a stateful issue by its key. `payload`, when supplied, overwrites the active payload — that's how built-in detectors enrich the resolution record with `durationInMs`. Emits `'issue-resolved'`.
- **`getActiveIssuesByType(type?)`**: Snapshot of currently active stateful issues, optionally filtered by `type`.
- **`isIssueActive(key)`**: `true` when a stateful issue with the given `key` is active.
- **`addMetaData(metaData: ClientMetaData)`**: Adds metadata.
- **`addExtensionStats(stats: ExtensionStat)`**: Adds custom extension stats.
#### Utility Methods
- **`getTrackMonitor(trackId: string)`**: Retrieves a track monitor by ID
- **`watchMediaDevices()`**: Integrates with navigator.mediaDevices
- **`fetchUserAgentData()`**: Fetches browser user agent information
### Properties
- **`score`**: Current client performance score (0.0-5.0)
- **`scoreReasons`**: Detailed score calculation reasons
- **`closed`**: Whether the monitor is closed
- **`config`**: Current configuration
- **`detectors`**: Detector management instance
- **`peerConnections`**: Array of monitored peer connections
- **`tracks`**: Array of monitored tracks
- **`activeIssues`**: `Map<string, RaisedClientIssue>` keyed by issue `key` — currently active stateful issues. Read-only by convention; use `getActiveIssuesByType` / `isIssueActive` instead of touching this directly.
## Detectors
Detectors are specialized components that monitor for specific anomalies and issues in WebRTC connections. Each detector focuses on a particular aspect of the connection quality.
### Built-in Detectors
#### AudioDesyncDetector
Detects audio synchronization issues by monitoring sample corrections.
**Triggers on:**
- Audio acceleration/deceleration corrections exceed thresholds
- Indicates audio-video sync problems
**Configuration:**
```javascript
audioDesyncDetector: {
fractionalCorrectionAlertOnThreshold: 0.1, // 10% correction rate triggers alert
fractionalCorrectionAlertOffThreshold: 0.05, // 5% correction rate clears alert
}
```
#### CongestionDetector
Monitors network congestion by analyzing available bandwidth vs. usage.
**Triggers on:**
- Available bandwidth falls below sending/receiving bitrates
- Network congestion conditions
**Configuration:**
```javascript
congestionDetector: {
sensitivity: 'medium', // 'low', 'medium', 'high'
}
```
#### CpuPerformanceDetector
Detects CPU performance issues affecting media processing.
**Triggers on:**
- Outbound RTP quality limitation reason is `'cpu'`
- Inbound decoded/received frames ratio drops below threshold (the decoder cannot keep up with received frames — a sign of decode-side CPU limitation)
- Stats collection takes too long (indicating CPU stress)
> **Why not FPS volatility?** Earlier versions inferred decode CPU pressure from frame-rate volatility. That false-triggered on content such as screen share, whose fps legitimately swings (e.g. 15 → 1 fps when the shared content goes static). The decoded/received ratio is robust to this: when fps drops legitimately, received and decoded frames drop together so the ratio stays near 1.0. An alert only fires when frames are received but not decoded.
**Configuration:**
```javascript
cpuPerformanceDetector: {
incomingDecodedFramesRatioThresholds: {
alertOn: 0.7, // alert ON when <70% of received frames are decoded
alertOff: 0.85, // alert OFF once >=85% are decoded again (hysteresis)
minReceivedFrames: 10, // skip intervals with fewer received frames (low-fps noise guard)
},
durationOfCollectingStatsThreshold: {
lowWatermark: 5000,
highWatermark: 10000,
},
}
```
#### DryInboundTrackDetector
Detects inbound tracks that stop receiving data.
**Triggers on:**
- Inbound track receives no data for specified duration
- Track stalling or connection issues
**Configuration:**
```javascript
dryInboundTrackDetector: {
thresholdInMs: 5000,
}
```
#### DryOutboundTrackDetector
Detects outbound tracks that stop sending data.
**Triggers on:**
- Outbound track sends no data for specified duration
- Local media issues or encoding problems
**Configuration:**
```javascript
dryOutboundTrackDetector: {
thresholdInMs: 5000,
}
```
#### FreezedVideoTrackDetector
Detects frozen video tracks.
**Triggers on:**
- Video frames stop updating
- Video freeze conditions
**Configuration:**
```javascript
videoFreezesDetector: {
}
```
#### PlayoutDiscrepancyDetector
Detects discrepancies between received and rendered frames.
**Triggers on:**
- Frame skew exceeds thresholds
- Video playout buffer issues
**Configuration:**
```javascript
playoutDiscrepancyDetector: {
lowSkewThreshold: 2,
highSkewThreshold: 5,
}
```
#### SynthesizedSamplesDetector
Detects when audio playout synthesizes samples due to missing data.
**Triggers on:**
- Synthesized audio samples exceed duration threshold
- Audio gaps requiring interpolation
**Configuration:**
```javascript
syntheticSamplesDetector: {
minSynthesizedSamplesDuration: 1000,
}
```
#### LongPcConnectionEstablishmentDetector
Detects slow peer connection establishment.
**Triggers on:**
- Peer connection takes too long to establish
- Connection setup issues
**Configuration:**
```javascript
longPcConnectionEstablishmentDetector: {
thresholdInMs: 5000,
}
```
#### IceConnectivityDetector
Runtime ICE and transport health, per ICE transport. Peer-connection setup latency
is **not** in scope — `LongPcConnectionEstablishmentDetector` covers that.
**Raises:**
- `ice-disconnected` — the transport stayed `disconnected` past
`disconnectedThresholdInMs`. Transient blips, which ICE usually heals on its
own, never raise an issue.
- `ice-connection-failed` — ICE reached `failed`, which is terminal for that
generation.
- `ice-transport-stalled` — deliberately narrow: raised only while this endpoint
is still **sending** on a succeeded pair of a connected transport but receives
nothing, and only after inbound traffic had previously been observed. "No
traffic in either direction" is *not* reported, because at peer-connection
level it cannot be told apart from a legitimately idle or paused connection.
- `unstable-ice-path` — the selected path switched `pathSwitchThreshold` times
within `pathSwitchWindowInMs`.
**Emits:**
- `'ice-restart-recommended'` — see below.
- `'ice-restart'` — a new ICE generation was inferred from a changed ICE
username fragment, with `outcome` of `'detected'`, `'recovered'` or
`'failed'`. A `connected → checking` transition alone is never treated as a
restart.
##### Recommending an ICE restart
The library detects **when** a restart is warranted; performing it stays with the
application. Only the application knows whether renegotiation is safe right now,
whether signalling is up, and whether it would rather tear the call down — so the
detector names the moment and gets out of the way.
A recommendation fires when ICE reaches `failed` (immediately — it never
self-heals), or when `disconnected`, an inbound stall, or an unfinished
establishment outlasts `iceRestartRecommendationThresholdInMs`. The `reason`
tells you which:
| `reason` | Meaning |
|---|---|
| `ice-failed` | ICE gave up on this generation. |
| `ice-disconnected` | `disconnected` outlasted the window in which ICE usually self-heals. |
| `transport-stalled` | ICE still reports connected, but the selected path stopped delivering. |
| `never-established` | The peer connection never finished connecting. Tracked from `connectionState`, which covers the DTLS handshake too — a connection can sit in `connecting` while every ICE transport reports `connected`. |
`LongPcConnectionEstablishmentDetector` reports that setup is *slow* at its own
(shorter) threshold; the `never-established` recommendation says it is not going
to happen on its own. The two thresholds form an escalation, not a duplicate
report. While a restart the application already started is in flight, the
detector stays quiet. Repeat recommendations are spaced
by `iceRestartRecommendationCooldownInMs`, and each carries a
`recommendationCount` and the current `iceGeneration` so you can back off after
repeated failed attempts.
```typescript
monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
if (3 <= recommendationCount) return rejoinTheCall(); // restarts are not helping
// your application decides — the library never restarts ICE itself
myRtcPeerConnection.restartIce();
// or, with mediasoup: ask the server for new ICE parameters and
// transport.restartIce({ iceParameters })
console.warn(`ICE restart recommended (${reason})`, peerConnectionMonitor.peerConnectionId);
});
```
#### AudioConcealmentDetector
Reports how the audio actually *sounded*, which packet loss does not. Opus and
NetEQ conceal a great deal of loss inaudibly, and conversely audio degrades
without dramatic loss when the jitter buffer misbehaves — so concealment is both
the more sensitive and the more specific signal.
The rate is **audible** concealment only: `concealedSamples` also rises during
ordinary silence, so `silentConcealedSamples` is subtracted before the detector
sees the number. Without that subtraction this would flag every quiet moment in
every call.
**Raises:** `audio-concealment`, with `concealmentRate`, `concealmentEventRate`
and a `burstiness` of `'bursty'` (many short clicks) or `'continuous'` (fewer,
longer dropouts) — they sound different and have different causes.
It stays silent while the remote track is paused, and while too few samples
arrived in the window to judge.
```javascript
audioConcealmentDetector: {
onThreshold: 0.03,
offThreshold: 0.01,
windowInMs: 5000,
minSamplesInWindow: 24000,
}
```
#### JitterBufferStressDetector
The complement to `AudioConcealmentDetector`: it separates "network jitter
absorbed cleanly" from "the jitter buffer ballooned, adding latency and
stretching audio to cope".
Both conditions are required, deliberately. A high target delay on its own means
NetEQ is *succeeding* — buying latency to hide jitter, with the user hearing
nothing wrong. Time stretching on its own is ordinary clock-drift correction. It
is the two together that mean the buffer is fighting the network and losing.
**Raises:** `audio-jitter-buffer-stress`.
```javascript
jitterBufferStressDetector: {
targetDelayThresholdInMs: 200,
timeStretchThreshold: 0.02,
minConsecutiveTicks: 2,
}
```
#### DecoderPerformanceDetector
The receive-side sibling of CPU limitation, and the piece that makes
network-versus-client attribution possible. Frames dropped because they never
arrived and frames dropped because the client could not decode them look
identical in a frame-rate chart, and the fixes are opposite — so this detector
fires only when the frames demonstrably *did* arrive: enough frames received,
loss below `quietLossThreshold`, and either decode time past the per-frame
budget or frames dropped after arrival.
The budget is derived from the stream's own frame rate (33 ms at 30 fps, 66 ms
at 15 fps), so a static screen share dropping to 1 fps does not trip it.
**Raises:** `video-decoder-overloaded`, carrying `decoderImplementation` and
`powerEfficientDecoder` — a software decoder on a codec the device can do in
hardware is the most actionable finding here.
```javascript
decoderPerformanceDetector: {
decodeTimeBudgetRatio: 0.8,
dropRatioThreshold: 0.1,
minFramesReceived: 10,
quietLossThreshold: 0.02,
minConsecutiveTicks: 2,
}
```
#### Video recovery (part of `FreezedVideoTrackDetector`)
`FreezedVideoTrackDetector` owns the whole freeze / repair domain: it derives
the track's freeze state (a freeze persists until frames render again, not just
until the next tick) and watches the repair loop — PLI/FIR out, keyframes back
in. The `videoRecoveryDetector` config gates the two repair-loop issues:
- `keyframe-storm` — a sustained PLI rate. Worth its own issue because it is
self-reinforcing: keyframes are several times the size of delta frames, so a
burst of them worsens exactly the congestion that provoked the PLIs.
- `video-recovery-failed` — PLIs going out repeatedly, the picture still
frozen, and `keyFramesDecoded` *not* advancing. This is the valuable one for
debugging an SFU: it says the repair request left the client and nothing came
back, which points at forwarding rather than at the first-hop network.
```javascript
videoRecoveryDetector: {
windowInMs: 30000,
pliRateAlertOn: 0.5,
pliRateAlertOff: 0.15,
recoveryFailedThresholdInMs: 5000,
recoveryFailedMinPliCount: 2,
}
```
#### StuckDecoderDetector
Detects the per-consumer decode wedge: RTP bytes keep arriving but no frame
ever decodes again — a corrupt or incomplete frame broke the decode chain, PLIs
go out continuously, keyframes may even be generated upstream, and this
consumer never assembles a usable frame until it is recreated.
The fingerprint is `bytesReceived` rising + `framesReceived` flat + `pliCount`
rising + `keyFramesDecoded` flat. The "bytes still flowing" condition is what
separates it from everything nearby: a dry track has no bytes, and
`video-recovery-failed` reports an unanswered repair request without saying
whether the pipe is dead or the decoder is. It reads only RTP deltas, so it
works regardless of browser freeze statistics.
A wedge never self-heals, so the wait only needs to outlast a *legitimate*
PLI → keyframe recovery — and that cost scales with the connection, not with a
fixed number of seconds. The effective wait is
`max(thresholdInMs, rttMultiplier × RTT)`, at least `minStuckTicks`
collections, with `minBitrate` (a rate, so it means the same thing at every
collecting period) confirming the stream is actually being delivered.
**Raises:** `stuck-decoder`, with a `variant` (`'assembly'`: no frame ever
reassembled; `'decode'`: frames assemble but never decode), the accumulated
dead bytes, and the PLI count since the wedge began.
**Mitigation hook:** recreating the consumer is the known workaround — listen
for the `stuck-decoder` monitor event:
```typescript
monitor.on('stuck-decoder', ({ trackMonitor, variant, deadBytesReceived }) => {
// the stream is being delivered but nothing decodes — recreate the consumer
recreateConsumerFor(trackMonitor.track.id);
});
```
```javascript
stuckDecoderDetector: {
thresholdInMs: 4000,
rttMultiplier: 15,
minStuckTicks: 2,
minBitrate: 10000,
minPliCount: 2,
}
```
#### SourceEncoderBottleneckDetector
Splits one symptom — "we are sending fewer frames than we should" — into its two
causes, which from RTP alone are indistinguishable:
- `capture-bottleneck` — the *source* never produced the frames. A camera
throttling in low light, an OS capture stall, a device the browser is quietly
downgrading. Nothing the encoder or the network can do about it.
- `encoder-bottleneck` — the source produced frames and the encoder could not
keep up. Carries `encodeTimePerFrameInMs`, `cpuLimitationShare`,
`encoderImplementation` and `powerEfficientEncoder`.
The discriminator is `MediaSourceMonitor.sourceFps` against what the highest
active layer actually encoded.
```javascript
sourceEncoderBottleneckDetector: {
captureFpsRatioThreshold: 0.5,
minSourceFps: 5,
encodeFpsRatioThreshold: 0.7,
encodeTimeBudgetRatio: 0.8,
cpuLimitationShareThreshold: 0.3,
minConsecutiveTicks: 2,
}
```
#### CaptureFailureDetector
Watches the source end of an outbound track, where several very common
user-visible failures originate and none of them show up in RTP.
**Raises:** `capture-track-ended` (the device is gone), `silent-audio-source`
(the microphone is live and producing nothing).
**Emits:** `'capture-track-ended'`, `'capture-track-muted'` (the OS or another
application took the device), plus the matching client events.
The silence threshold is 30 seconds by default, and long on purpose: a
microphone capturing digital silence and a person not talking are the same
measurement, and only duration separates them. The check requires the track to
be live, enabled and unmuted — a muted microphone is silent deliberately and is
reported as a mute, not a failure. The level comes from
`MediaSourceMonitor.rmsAudioLevel`, which integrates `totalAudioEnergy` over the
interval, rather than the instantaneous `audioLevel` that reads zero between
words.
```javascript
captureFailureDetector: {
silenceThresholdInMs: 30000,
silenceRmsThreshold: 0.001,
createEvent: true,
}
```
#### Observation detectors
These four emit events and **never raise issues** — they describe things that are
not faults but are the missing context in most investigations.
| Detector | Monitor event | Client event | What it answers |
|---|---|---|---|
| `CodecChangeDetector` | `codec-changed` | `CODEC_CHANGED` | "Why do all the bad calls use H264?" Compares `sdpFmtpLine` too, so a profile switch within one mime type is caught. |
| `VideoResolutionChangeDetector` | `video-resolution-changed` | `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder. On outbound tracks it carries `qualityLimitationReason`, which is what separates encoder adaptation from the application changing its constraints. Classified as `upgrade`, `downgrade` or `reshape` (an orientation flip). |
| `SimulcastLayerDetector` | `simulcast-layer-changed` | `SIMULCAST_LAYER_CHANGED` | Which layers are actually being sent. A layer counts as active only if it sent bytes — `active: true` with no bytes is the common shape of a layer the encoder quietly gave up on. |
| `StatsGapDetector` | `stats-collection-gap` | `STATS_COLLECTION_GAP` | Protects the monitor from itself: a backgrounded tab or sleeping device makes the next tick attribute a large accumulation to a short window. The gap is reported rather than corrected, because the counters cannot say when within it the traffic happened. |
### Custom Detectors
Create custom detectors by implementing the `Detector` interface:
```typescript
import { Detector, ClientMonitor } from "@observertc/client-monitor-js";
class CustomDetector implements Detector {
public readonly name = 'custom-detector';
/** Optional kill-switch honored by both `Detectors.update()` and this method. */
public disabled = false;
constructor(private monitor: ClientMonitor) {}
public update() {
if (this.disabled) return;
if (this.detectCustomCondition()) {
this.monitor.raiseIssue('custom-detector-singleton', {
type: 'custom-issue',
payload: { reason: 'Custom condition detected' },
});
}
}
private detectCustomCondition(): boolean {
// Your detection logic here
return false;
}
}
// Attach
const detector = new CustomDetector(monitor);
monitor.detectors.add(detector);
// Inspect
monitor.detectors.has('custom-detector'); // true
monitor.detectors.getByName('custom-detector'); // the instance
monitor.detectors.listOfNames; // ['cpu-performance-detector', 'custom-detector', ...]
// Runtime toggle
monitor.detectors.disable('custom-detector'); // detector stays attached but its update() is skipped
monitor.detectors.enable('custom-detector');
// Detach
monitor.detectors.remove(detector);
```
See [Controlling which detectors run](#controlling-which-detectors-run) for the full set of registry helpers.
## Score Calculation
The scoring system provides quantitative quality assessment ranging from 0.0 (worst) to 5.0 (best). The library includes a `DefaultScoreCalculator` implementation and allows custom score calculators via the `ScoreCalculator` interface.
### ScoreCalculator Interface
```typescript
interface ScoreCalculator {
update(): void;
encodeClientScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
encodePeerConnectionScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
encodeInboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
encodeInboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
encodeOutboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
encodeOutboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
}
```
### DefaultScoreCalculator Implementation
The default implementation calculates scores using a hierarchical weighted average approach:
#### Score Hierarchy
The client score is calculated as a weighted average of:
1. **Peer Connection Stability Scores** (based on RTT and packet loss)
2. **Track Quality Scores** (inbound/outbound audio/video tracks)
#### Client Score Calculation
```
Client Score = Σ(PC_Score × PC_Weight) / Σ(PC_Weight)
Where PC_Score = Track_Score_Avg × PC_Stability_Score
```
#### Peer Connection Stability Score
Based on Round Trip Time (RTT) and packet loss:
**RTT Penalties:**
- High RTT (150-300ms): -1.0 point
- Very High RTT (>300ms): -2.0 points
**Packet Loss Penalties:**
- 1-5% loss: -1.0 point
- 5-20% loss: -2.0 points
- > 20% loss: -5.0 points
#### Track Score Calculations
**Inbound Audio Track Score:**
- Based on normalized bitrate and packet loss
- Uses logarithmic bitrate normalization
- Exponential decay for packet loss impact
```javascript
normalizedBitrate = log10(max(bitrate, MIN_AUDIO_BITRATE) / MIN_AUDIO_BITRATE) / NORMALIZATION_FACTOR;
lossPenalty = exp(-packetLoss / 2);
score = min(MAX_SCORE, 5 * normalizedBitrate * lossPenalty);
```
**Inbound Video Track Score:**
- FPS volatility penalties
- Dropped frames penalties
- Frame corruption penalties
**Outbound Audio Track Score:**
- Similar to inbound, using sending bitrate
- Remote packet loss consideration
**Outbound Video Track Score:**
- Bitrate deviation from target penalties
- CPU limitation penalties
- Bitrate volatility penalties
- If `track.contentHint === 'screen'`, bitrate deviation and volatility penalties are skipped to better fit screen-share traffic patterns
### Score Reasons
Each score calculation includes detailed reasons for penalties:
```javascript
monitor.on("score", (event) => {
console.log("Client Score:", event.clientScore);
console.log("Score Reasons:", event.scoreReasons);
// Example reasons:
// {
// "high-rtt": 1.0,
// "high-packetloss": 2.0,
// "cpu-limitation": 2.0,
// "dropped-video-frames": 1.0
// }
});
```
### Custom Score Calculator
Implement your own scoring logic by implementing the `ScoreCalculator` interface:
```javascript
import { ScoreCalculator } from "@observertc/client-monitor-js";
class CustomScoreCalculator {
constructor(clientMonitor) {
this.clientMonitor = clientMonitor;
}
update() {
// Calculate peer connection scores
for (const pcMonitor of this.clientMonitor.peerConnections) {
this.calculatePeerConnectionScore(pcMonitor);
}
// Calculate track scores
for (const track of this.clientMonitor.tracks) {
this.calculateTrackScore(track);
}
// Calculate final client score
this.calculateClientScore();
}
calculatePeerConnectionScore(pcMonitor) {
const rttMs = (pcMonitor.avgRttInSec ?? 0) * 1000;
const fractionLost = pcMonitor.inboundRtps.reduce((acc, rtp) => acc + (rtp.fractionLost ?? 0), 0);
let score = 5.0;
const reasons = {};
// Custom RTT penalties
if (rttMs > 200) {
score -= 1.5;
reasons["custom-high-rtt"] = 1.5;
}
// Custom packet loss penalties
if (fractionLost > 0.02) {
score -= 2.0;
reasons["custom-packet-loss"] = 2.0;
}
pcMonitor.calculatedStabilityScore.value = Math.max(0, score);
pcMonitor.calculatedStabilityScore.reasons = reasons;
}
calculateTrackScore(trackMonitor) {
let score = 5.0;
const reasons = {};
if (trackMonitor.direction === "inbound" && trackMonitor.kind === "video") {
// Custom video quality scoring
const fps = trackMonitor.ewmaFps ?? 0;
if (fps < 15) {
score -= 2.0;
reasons["low-fps"] = 2.0;
}
}
trackMonitor.calculatedScore.value = Math.max(0, score);
trackMonitor.calculatedScore.reasons = reasons;
}
calculateClientScore() {
let totalScore = 0;
let totalWeight = 0;
const combinedReasons = {};
for (const pcMonitor of this.clientMonitor.peerConnections) {
if (pcMonitor.calculatedStabilityScore.value !== undefined) {
totalScore += pcMonitor.calculatedStabilityScore.value;
totalWeight += 1;
// Combine reasons
Object.assign(combinedReasons, pcMonitor.calculatedStabilityScore.reasons || {});
}
}
const clientScore = totalWeight > 0 ? totalScore / totalWeight : 5.0;
this.clientMonitor.setScore(clientScore, combinedReasons);
}
// Optional: Custom encoding for reasons
encodeClientScoreReasons(reasons) {
return JSON.stringify(reasons || {});
}
}
// Apply custom calculator
const monitor = new ClientMonitor();
monitor.scoreCalculator = new CustomScoreCalculator(monitor);
```
## Collecting and Adapting Stats
The monitor collects WebRTC statistics periodically and adapts them for consistent processing across different browsers and integrations.
### Stats Collection Process
1. **Collection Trigger**: Timer-based collection every `collectingPeriodInMs`
2. **Raw Stats Retrieval**: Calls `getStats()` on peer connections
3. **Stats Adaptation**: Applies browser-specific adaptations
4. **Monitor Updates**: Updates all relevant monitor objects
5. **Detector Updates**: Runs all attached detectors
6. **Score Calculation**: Updates performance scores
### Stats Adapters
Stats adapters handle browser-specific differences and integration requirements:
#### Browser Adaptations
- **Firefox**: Handles track identifier format differences
- **Chrome/Safari**: Handles various stats format variations
- **Mediasoup**: Filters probator tracks and adapts mediasoup-specific stats
#### Custom Stats Adapters
Add custom adaptation logic:
```javascript
monitor.statsAdapters.add((stats) => {
// Custom adaptation logic
return stats.map((stat) => {
if (stat.type === "inbound-rtp" && stat.trackIdentifier) {
// Custom track identifier handling
stat.trackIdentifier = stat.trackIdentifier.replace(/[{}]/g, "");
}
return stat;
});
});
```
### Extension Stats Providers
Extension stats providers allow you to inject custom application-specific statistics into the monitoring pipeline. These providers are called during each stats collection cycle and can return either synchronous or asynchronous results.
**What are Extension Stats?**
Extension stats are custom key-value pairs that you define to track application-specific metrics alongside WebRTC statistics. They are included in every sample created by the monitor and allow you to correlate WebRTC quality metrics with your own application data.
**Adding Extension Stats Providers:**
```javascript
// Synchronous provider
monitor.extensionStatsProviders.add(() => ({
type: "my-custom-metric",
payload: {
fps: currentFps,
bandwidth: availableBandwidth,
userEngagement: engagementScore,
},
}));
// Asynchronous provider
monitor.extensionStatsProviders.add(async () => {
const cpuUsage = await getCpuUsageMetrics();
return {
type: "system-metrics",
payload: {
cpu: cpuUsage,
memory: performance.memory?.usedJSHeapSize || 0,
},
};
});
```
**Provider Characteristics:**
- **Type**: Each provider must return an object with a `type` field (string identifier)
- **Payload**: Optional custom data object containing your metrics
- **Timing**: Providers are called during every stats collection cycle
- **Async Support**: Providers can be async and return promises
- **Error Handling**: Errors in providers are logged but don't stop the monitoring process
**Sample Integration:**
Extension stats are automatically included in every created sample:
```javascript
monitor.on("sample-created", (sample) => {
// sample.extensionStats contains all extension stats
// Example output:
// [
// { type: "my-custom-metric", payload: { fps: 30, bandwidth: 5000, ... } },
// { type: "system-metrics", payload: { cpu: 45, memory: 52428800 } }
// ]
console.log("Extension stats:", sample.extensionStats);
});
```
### Available WebRTC Stats
The monitor collects and processes all standard WebRTC statistics:
#### RTP Statistics
- **Inbound RTP**: Receiving stream statistics
- **Outbound RTP**: Sending stream statistics
- **Remote Inbound RTP**: Remote peer's receiving statistics
- **Remote Outbound RTP**: Remote peer's sending statistics
#### Connection Statistics
- **ICE Candidate**: ICE candidate information
- **ICE Candidate Pair**: ICE candidate pair statistics
- **ICE Transport**: ICE transport layer statistics
- **Certificate**: Security certificate information
#### Media Statistics
- **Codec**: Codec configuration and usage
- **Media Source**: Local media source statistics
- **Media Playout**: Audio playout statistics
- **Data Channel**: Data channel statistics
## Sampling
Sampling creates periodic snapshots (`ClientSample`) containing the complete state of the monitored client.
### Sample Structure
A `ClientSample` includes:
- **Client metadata**: clientId, callId, timestamp, score
- **Peer connection samples**: All monitored peer connections
- **Events**: Client events since last sample
- **Issues**: Detected issues since last sample
- **Extension stats**: Custom application statistics
### Automatic Sampling
Enable automatic sampling by setting `samplingPeriodInMs`:
```javascript
const monitor = new ClientMonitor({
collectingPeriodInMs: 2000,
samplingPeriodInMs: 4000, // Create sample every 4 seconds
});
monitor.on("sample-created", (sample) => {
console.log("Sample created:", sample);
// Send to analytics backend
sendToAnalytics(sample);
});
```
### Manual Sampling
Create samples on demand:
```javascript
const monitor = new ClientMonitor({
collectingPeriodInMs: 2000,
bufferingEventsForSamples: true, // Required for manual sampling
});
// Create sample manually
const sample = monitor.createSample();
if (sample) {
console.log("Manual sample:", sample);
}
```
### Sample Compression
For efficient data transmission and storage, ObserveRTC provides dedicated compression packages for `ClientSample` objects:
**@observertc/samples-encoder** - Compresses ClientSample objects for transmission:
```javascript
import { SamplesEncoder } from "@observertc/samples-encoder";
const encoder = new SamplesEncoder();
const sample = monitor.createSample();
// Encode the sample for efficient transmission
const encodedSample = encoder.encode(sample);
// Send compressed data over the network
fetch("/api/samples", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
},
body: encodedSample,
});
```
**@observertc/samples-decoder** - Decompresses received ClientSample objects:
```javascript
import { SamplesDecoder } from "@observertc/samples-decoder";
const decoder = new SamplesDecoder();
// Receive compressed sample data
const compressedData = await response.arrayBuffer();
// Decode back to ClientSample object
const decodedSample = decoder.decode(compressedData);
// Process the restored sample
console.log("Decoded sample:", decodedSample);
```
**Benefits of Using Compression:**
- **Reduced Bandwidth**: Compressed samples require significantly less network bandwidth
- **Faster Transmission**: Smaller payloads improve upload/download times
- **Storage Efficiency**: Compressed samples consume less storage space
- **Schema Consistency**: Ensures proper serialization/deserialization of all ClientSample fields
**Installation:**
```bash
# For encoding (client-side)
npm install @observertc/samples-encoder
# For decoding (server-side)
npm install @observertc/samples-decoder
# Both packages (if needed)
npm install @observertc/samples-encoder @observertc/samples-decoder
```
**Integration with ObserveRTC Stack:**
These compression packages are part of the broader ObserveRTC ecosystem and are designed to work seamlessly with:
- Client Monitor (sample generation)
- Observer Service (sample processing)
- Schema definitions (data consistency)
The compression format maintains full compatibility with the ObserveRTC schema definitions and can be used with any transport mechanism (WebSocket, HTTP REST, etc.).
## Events and Issues
`ClientMonitor` emits two different categories of notification: **issues**, which describe a problem state, and **events**, which describe a thing that happened. The two have different lifecycles and different APIs — picking the right one for your use case is the key to keeping your alerting code sane.
### Issues vs Events at a glance
| | Issue | Event |
|---|---|---|
| Represents | An ongoing or one-shot condition (network congestion, dry track, …) | A discrete thing that happened (peer joined, ICE candidate found, …) |
| Lifecycle | Can be **raised**, **updated**, **resolved** | Immutable record |
| Resolution | Yes (for the stateful flavor) | No |
| API | `addIssue` / `raiseIssue` / `resolveIssue` | `addEvent` |
| Sample buffer | `sample.clientIssues[]` | `sample.clientEvents[]` |
| Emitted events on `monitor.on(...)` | `'issue'`, `'issue-updated'`, `'issue-resolved'` | `'client-event'` |
The rest of this section drills into the issue lifecycle; events are a thin wrapper around `addEvent` and need no further explanation.
### Two flavors of issue
`ClientMonitor` distinguishes a **one-shot issue** (fire-and-forget) from a **raised issue** (stateful, resolvable). Pick the flavor that matches your situation:
| Flavor | Method | Has `key` | Enters `activeIssues` | Can be resolved | Typical use |
|---|---|---|---|---|---|
| One-shot | `addIssue({ type, payload?, timestamp? })` | no | no | no | A logged event-like incident with no "ended" condition — `USER_MEDIA_ERROR`, a one-off SDK warning, a one-time alert you want included in the next sample. |
| Stateful | `raiseIssue(key, { type, payload?, timestamp? })` | **yes (required)** | yes | yes (`resolveIssue(key, …)`) | Anything with a start and an end: congestion, CPU pressure, audio desync, video freeze, dry track. The detectors that ship with the library all use this flavor. |
You're always free to choose either. The library only insists that *if* you want to resolve later, you must have raised with a `key`.
### In-memory types
Both flavors share `type` and `payload`. The stateful flavor adds the identity (`key`) and timestamps:
```ts
type ClientIssuePayload = Record<string, unknown> | boolean | string | number;
// What addIssue produces.
type AddedClientIssue<T = ClientIssuePayload> = {
type: string;
payload?: T;
timestamp: number;
};
// What raiseIssue produces.
type RaisedClientIssue<T = ClientIssuePayload> = {
type: string;
key: string; // globally unique handle within this monitor
payload?: T;
raisedAt: number;
updatedAt: number; // bumped on every re-raise of the same key
};
// Discriminated union over the two flavors.
type ClientIssue<T = ClientIssuePayload> = AddedClientIssue<T> | RaisedClientIssue<T>;
// What 'issue-resolved' delivers.
type ResolvedClientIssue<T = ClientIssuePayload> = RaisedClientIssue<T> & {
resolvedAt: number;
comment?: string;
};
```
Narrow between the two by checking for `'key' in issue` — that's the discriminant.
> **Wire format**: `ClientSample.clientIssues[]` ships a stripped shape: `{ type, payload?: string (JSON-stringified), timestamp }`. The richer in-memory `id`-less, key-bearing object is a runtime concern; the server schema is unchanged.
### Lifecycle: the events you can listen to
```ts
monitor.on('issue', (issue: ClientIssue) => /* … */); // raised or added
monitor.on('issue-updated', (issue: RaisedClientIssue) => /* … */); // re-raise of an active key
monitor.on('issue-resolved', (issue: ResolvedClientIssue) => /* … */);
```
| Step | When it fires | What's delivered |
|---|---|---|
| `raiseIssue('x', { type: 't', payload: … })` for an **unknown** `x` | New stateful issue created and stored in `activeIssues` | `'issue'` event with the new `RaisedClientIssue` |
| `raiseIssue('x', …)` for an **already-active** `x` | Existing entry's payload + `updatedAt` are refreshed in place; no duplicate | `'issue-updated'` event |
| `addIssue({ type, payload })` | New one-shot issue created; **not** added to `activeIssues` | `'issue'` event |
| `resolveIssue('x', { c