UNPKG

@observertc/client-monitor-js

Version:

ObserveRTC Client Integration Javascript Library

1,403 lines (1,065 loc) 113 kB
# @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. [![npm version](https://badge.fury.io/js/@observertc%2Fclient-monitor-js.svg)](https://badge.fury.io/js/@observertc%2Fclient-monitor-js) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](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