@observertc/client-monitor-js 4.6.1-e133dc3.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +843 -391
- package/dist/ClientMonitor.d.ts +20 -11
- package/dist/ClientMonitor.js +1 -1
- package/dist/ClientMonitorConfig.d.ts +30 -12
- package/dist/ClientMonitorEvents.d.ts +28 -5
- package/dist/ClientMonitorIssues.d.ts +34 -1
- package/dist/ClientMonitorIssues.js +1 -1
- package/dist/adapters/ChromeStatsAdapter.d.ts +6 -0
- package/dist/adapters/ChromeStatsAdapter.js +1 -0
- package/dist/adapters/FirefoxStatsAdapter.d.ts +10 -0
- package/dist/adapters/FirefoxStatsAdapter.js +1 -0
- package/dist/adapters/SafariStatsAdapter.d.ts +6 -0
- package/dist/adapters/SafariStatsAdapter.js +1 -0
- package/dist/adapters/adapterTools.d.ts +9 -0
- package/dist/adapters/adapterTools.js +1 -0
- package/dist/detectors/AudioConcealmentDetector.d.ts +1 -5
- package/dist/detectors/AudioConcealmentDetector.js +1 -1
- package/dist/detectors/AudioDesyncDetector.d.ts +1 -1
- package/dist/detectors/AudioDesyncDetector.js +1 -1
- package/dist/detectors/BlockedTransportDetector.d.ts +34 -0
- package/dist/detectors/BlockedTransportDetector.js +1 -0
- package/dist/detectors/CaptureFailureDetector.d.ts +1 -1
- package/dist/detectors/CaptureFailureDetector.js +1 -1
- package/dist/detectors/CongestionDetector.d.ts +1 -0
- package/dist/detectors/CongestionDetector.js +1 -1
- package/dist/detectors/CpuPerformanceDetector.d.ts +3 -0
- package/dist/detectors/CpuPerformanceDetector.js +1 -1
- package/dist/detectors/DecoderPerformanceDetector.d.ts +1 -0
- package/dist/detectors/DecoderPerformanceDetector.js +1 -1
- package/dist/detectors/Detector.d.ts +1 -0
- package/dist/detectors/DryInboundTrackDetector.d.ts +1 -1
- package/dist/detectors/DryInboundTrackDetector.js +1 -1
- package/dist/detectors/DryOutboundTrackDetector.d.ts +1 -1
- package/dist/detectors/DryOutboundTrackDetector.js +1 -1
- package/dist/detectors/EncoderPerformanceDetector.d.ts +38 -0
- package/dist/detectors/EncoderPerformanceDetector.js +1 -0
- package/dist/detectors/FreezedVideoTrackDetector.d.ts +8 -7
- package/dist/detectors/FreezedVideoTrackDetector.js +1 -1
- package/dist/detectors/IceConnectivityDetector.d.ts +1 -0
- package/dist/detectors/IceConnectivityDetector.js +1 -1
- package/dist/detectors/InboundFrameSupplyDetector.d.ts +29 -0
- package/dist/detectors/InboundFrameSupplyDetector.js +1 -0
- package/dist/detectors/JitterBufferStressDetector.d.ts +1 -0
- package/dist/detectors/JitterBufferStressDetector.js +1 -1
- package/dist/detectors/MediaPipelineDetector.d.ts +37 -0
- package/dist/detectors/MediaPipelineDetector.js +1 -0
- package/dist/detectors/NoAvailableIceCandidateDetector.d.ts +28 -0
- package/dist/detectors/NoAvailableIceCandidateDetector.js +1 -0
- package/dist/detectors/OutboundFrameSupplyDetector.d.ts +29 -0
- package/dist/detectors/OutboundFrameSupplyDetector.js +1 -0
- package/dist/detectors/PlayoutDiscrepancyDetector.d.ts +4 -1
- package/dist/detectors/PlayoutDiscrepancyDetector.js +1 -1
- package/dist/detectors/SimulcastLayerDetector.js +1 -1
- package/dist/detectors/StuckDecoderDetector.d.ts +1 -1
- package/dist/detectors/StuckDecoderDetector.js +1 -1
- package/dist/detectors/SynthesizedSamplesDetector.js +1 -1
- package/dist/index.d.ts +25 -5
- package/dist/index.js +1 -1
- package/dist/monitors/IceCandidateMonitor.d.ts +1 -0
- package/dist/monitors/IceCandidateMonitor.js +1 -1
- package/dist/monitors/IceCandidatePairMonitor.js +1 -1
- package/dist/monitors/InboundRtpMonitor.d.ts +3 -0
- package/dist/monitors/InboundRtpMonitor.js +1 -1
- package/dist/monitors/InboundTrackMonitor.d.ts +22 -1
- package/dist/monitors/InboundTrackMonitor.js +1 -1
- package/dist/monitors/MediaSourceMonitor.js +1 -1
- package/dist/monitors/OutboundTrackMonitor.d.ts +8 -0
- package/dist/monitors/OutboundTrackMonitor.js +1 -1
- package/dist/monitors/PeerConnectionMonitor.d.ts +6 -0
- package/dist/monitors/PeerConnectionMonitor.js +1 -1
- package/dist/monitors/SelectedIcePath.js +1 -1
- package/dist/monitors/TrackMonitor.d.ts +3 -2
- package/dist/schema/ClientEventTypes.d.ts +54 -49
- package/dist/schema/ClientEventTypes.js +1 -1
- package/dist/schema/ClientSample.d.ts +10 -9
- package/dist/schema/ClientSample.js +1 -1
- package/dist/scores/CalculatedScore.d.ts +6 -66
- package/dist/scores/CalculatedScore.js +1 -1
- package/dist/scores/DefaultScoreCalculator.d.ts +35 -2
- package/dist/scores/DefaultScoreCalculator.js +1 -1
- package/dist/scores/ScoreCalculator.d.ts +0 -6
- package/dist/scores/utils.d.ts +1 -0
- package/dist/scores/utils.js +1 -0
- package/dist/sources/ClientEventPayloadProvider.d.ts +5 -3
- package/dist/sources/ClientEventPayloadProvider.js +1 -1
- package/dist/sources/MediasoupTransportBinding.d.ts +1 -0
- package/dist/sources/MediasoupTransportBinding.js +1 -1
- package/dist/sources/RtcPeerConnectionBinding.js +1 -1
- package/dist/sources/Sources.d.ts +2 -0
- package/dist/sources/Sources.js +1 -1
- package/dist/sources/watchTabVisibility.d.ts +3 -0
- package/dist/sources/watchTabVisibility.js +1 -0
- package/dist/utils/common.d.ts +1 -0
- package/dist/utils/common.js +1 -1
- package/package.json +2 -1
- package/dist/adapters/Firefox94StatsAdapter.d.ts +0 -5
- package/dist/adapters/Firefox94StatsAdapter.js +0 -1
- package/dist/adapters/FirefoxTransportStatsAdapter.d.ts +0 -10
- package/dist/adapters/FirefoxTransportStatsAdapter.js +0 -1
- package/dist/detectors/SourceEncoderBottleneckDetector.d.ts +0 -48
- package/dist/detectors/SourceEncoderBottleneckDetector.js +0 -1
package/README.md
CHANGED
|
@@ -40,6 +40,26 @@ or
|
|
|
40
40
|
yarn add @observertc/client-monitor-js
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
### Release candidates
|
|
44
|
+
|
|
45
|
+
Every push to `develop` publishes a release candidate as `X.Y.Z-rc.<N>`, where `N` increases with every build. Depend on the **`next` dist-tag** to track them:
|
|
46
|
+
|
|
47
|
+
```jsonc
|
|
48
|
+
// package.json
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@observertc/client-monitor-js": "next"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`next` always points at the newest RC across all version lines, so this dependency never has to be edited when the line bumps from `4.7.x` to `4.8.x`. Per-line tags (`develop-470-rc`, `develop-460-rc`, ...) are still maintained if you want to stay on one line.
|
|
55
|
+
|
|
56
|
+
**Do not use a caret range to track RCs** — it cannot work, for two separate reasons rooted in how semver ranges treat prereleases:
|
|
57
|
+
|
|
58
|
+
- `"^4.6.0"` resolves to the stable `4.6.0` and silently excludes every RC. A range with no prerelease in it never matches prerelease versions.
|
|
59
|
+
- `"^4.7.1-rc.5"` does match RCs, but only of `4.7.1` — it will never see `4.8.1-rc.N`, so it stops updating the moment the line bumps.
|
|
60
|
+
|
|
61
|
+
Historically RCs were published as `X.Y.Z-<git-sha>.0`. Semver compares prerelease identifiers as ASCII strings and git SHAs have no chronological order, so the "highest" RC of that scheme was effectively random — a caret range on one of them resolved to an arbitrary older build and never moved. Those versions are still published and untouched, but they are superseded: any `rc.N` sorts above all of them.
|
|
62
|
+
|
|
43
63
|
## Quick Start
|
|
44
64
|
|
|
45
65
|
```javascript
|
|
@@ -223,10 +243,11 @@ const monitor = new ClientMonitor({
|
|
|
223
243
|
|
|
224
244
|
dryInboundTrackDetector: { thresholdInMs: 5000 },
|
|
225
245
|
dryOutboundTrackDetector: { thresholdInMs: 5000 },
|
|
226
|
-
videoFreezesDetector: {},
|
|
246
|
+
videoFreezesDetector: { minConsecutiveTicks: 2 },
|
|
227
247
|
playoutDiscrepancyDetector: {
|
|
228
|
-
|
|
229
|
-
|
|
248
|
+
lowSkewRatio: 0.1,
|
|
249
|
+
highSkewRatio: 0.25,
|
|
250
|
+
minFramesReceived: 10,
|
|
230
251
|
},
|
|
231
252
|
syntheticSamplesDetector: {
|
|
232
253
|
minSynthesizedSamplesDuration: 1000,
|
|
@@ -243,6 +264,20 @@ const monitor = new ClientMonitor({
|
|
|
243
264
|
iceRestartRecommendationCooldownInMs: 15000, // min gap between recommendations
|
|
244
265
|
createEvent: true,
|
|
245
266
|
},
|
|
267
|
+
blockedTransportDetector: {
|
|
268
|
+
thresholdInMs: 5000, // how long the STUN-ok-but-media-blocked discrepancy must persist
|
|
269
|
+
minMediaBitrateBps: 10000, // "producer is demonstrably producing" bar
|
|
270
|
+
maxReturnBitrateBps: 2000, // at or below this, the return path is STUN-only
|
|
271
|
+
maxSendShare: 0.1, // transport send below this share of produced => not leaving
|
|
272
|
+
stunFreshnessInMs: 10000, // how recent a STUN response must be to count as verified
|
|
273
|
+
},
|
|
274
|
+
noAvailableIceCandidateDetector: {
|
|
275
|
+
thresholdInMs: 6000, // grace for `new`/`connecting` with zero local candidates
|
|
276
|
+
},
|
|
277
|
+
mediaPipelineDetector: {
|
|
278
|
+
thresholdInMs: 4000, // how long a broken pipeline boundary must persist
|
|
279
|
+
minTransportReceiveBitrateBps: 20000, // above this, incoming transport traffic must demux
|
|
280
|
+
},
|
|
246
281
|
|
|
247
282
|
audioConcealmentDetector: {
|
|
248
283
|
onThreshold: 0.03, // Webex treats >3% concealment as significant, >5% as severe
|
|
@@ -272,21 +307,30 @@ const monitor = new ClientMonitor({
|
|
|
272
307
|
stuckDecoderDetector: {
|
|
273
308
|
thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier x RTT)
|
|
274
309
|
rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
|
|
275
|
-
|
|
276
|
-
minBitrate: 10000, // bps below which this is a dry track, not a wedge
|
|
310
|
+
minBitrate: 10000, // bps below which this is a dry track, not a wedge
|
|
277
311
|
minPliCount: 2,
|
|
278
312
|
},
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
313
|
+
// Frame supply: is whatever produces this track's frames delivering what it
|
|
314
|
+
// should? Average over a duration, compare, judge. The config *types* live
|
|
315
|
+
// in the detector files; the defaults are here with every other detector's.
|
|
316
|
+
outboundFrameSupplyDetector: {
|
|
317
|
+
durationInMs: 15000, // average the capture device over this long ...
|
|
318
|
+
captureFpsRatioThreshold: 0.9, // ... then require 90% of the configured fps
|
|
319
|
+
},
|
|
320
|
+
encoderPerformanceDetector: {
|
|
321
|
+
encodeFpsRatioThreshold: 0.7, // encoder below 70% of source fps = behind
|
|
322
|
+
encodeTimeBudgetRatio: 0.8, // encode time per frame vs the frame budget
|
|
323
|
+
cpuLimitationShareThreshold: null, // null = ignore the browser's CPU-limited signal
|
|
324
|
+
minConsecutiveTicks: 2, // two reads agreeing, not a span of time
|
|
325
|
+
},
|
|
326
|
+
inboundFrameSupplyDetector: {
|
|
327
|
+
durationInMs: 15000, // average the decoder over this long ...
|
|
328
|
+
decodeFpsRatioThreshold: 0.9, // ... then require 90% of what arrived
|
|
329
|
+
minReceivedFps: 5, // too thin a stream to judge a decoder on
|
|
286
330
|
},
|
|
287
331
|
captureFailureDetector: {
|
|
288
|
-
silenceThresholdInMs:
|
|
289
|
-
silenceRmsThreshold: 0.
|
|
332
|
+
silenceThresholdInMs: 60000, // long on purpose: silence != a broken mic
|
|
333
|
+
silenceRmsThreshold: 0.0001,
|
|
290
334
|
createEvent: true,
|
|
291
335
|
},
|
|
292
336
|
|
|
@@ -383,454 +427,623 @@ The `ClientMonitor` is the main class that orchestrates WebRTC monitoring, stati
|
|
|
383
427
|
|
|
384
428
|
## Detectors
|
|
385
429
|
|
|
386
|
-
Detectors
|
|
430
|
+
Detectors turn the collected stats into *verdicts*. Each one watches a specific failure mode and reports through up to three channels: **stateful issues** (raised when the condition starts, resolved when it clears — with the full lifecycle shipped to the server, see [Sample-channel behavior](#sample-channel-behavior)), **monitor events** (realtime, for the application to act on), and **client events** (buffered into samples for server-side correlation).
|
|
387
431
|
|
|
388
|
-
|
|
432
|
+
Configuration follows one convention everywhere: omit a detector's config key to get defaults, pass `null` to not construct it at all, or flip the instance's `disabled` flag at runtime to silence it without removing it (see [Controlling which detectors run](#controlling-which-detectors-run)).
|
|
389
433
|
|
|
390
|
-
|
|
434
|
+
### Detector overview
|
|
391
435
|
|
|
392
|
-
|
|
436
|
+
| Detector | Watches | Reports | Good for |
|
|
437
|
+
|---|---|---|---|
|
|
438
|
+
| [`AudioConcealmentDetector`](#audioconcealmentdetector) | inbound audio | issue `audio-concealment` | How the audio actually *sounded* — catches degradation packet loss numbers miss |
|
|
439
|
+
| [`JitterBufferStressDetector`](#jitterbufferstressdetector) | inbound audio | issue `audio-jitter-buffer-stress` | The jitter buffer adding latency *and* stretching audio — delay the user hears |
|
|
440
|
+
| [`AudioDesyncDetector`](#audiodesyncdetector) | inbound audio | issue `audio-desync` | Playback drifting out of sync through heavy sample correction |
|
|
441
|
+
| [`SynthesizedSamplesDetector`](#synthesizedsamplesdetector) | audio playout | event `synthesized-audio` | The playout device injecting synthesized audio |
|
|
442
|
+
| [`FreezedVideoTrackDetector`](#freezedvideotrackdetector) | inbound video | issues `freezed-video-track`, `keyframe-storm`, `video-recovery-failed` | Frozen pictures and a repair loop that stopped working |
|
|
443
|
+
| [`DecoderPerformanceDetector`](#decoderperformancedetector) | inbound video | issue `video-decoder-overloaded` | Frames arrived but this device cannot decode them in time |
|
|
444
|
+
| [`StuckDecoderDetector`](#stuckdecoderdetector) | inbound video | issue `stuck-decoder` | RTP flowing, nothing decoding — the wedge only recreating the consumer fixes |
|
|
445
|
+
| [`PlayoutDiscrepancyDetector`](#playoutdiscrepancydetector) | inbound video | issue `inbound-video-playout-discrepancy` | Frames received but not rendered — a rendering pipeline backlog |
|
|
446
|
+
| [`DryInboundTrackDetector` / `DryOutboundTrackDetector`](#dryinboundtrackdetector--dryoutboundtrackdetector) | tracks | issues `dry-inbound-track`, `dry-outbound-track` | A track that should be flowing but carries no bytes at all |
|
|
447
|
+
| [`OutboundFrameSupplyDetector`](#outboundframesupplydetector) | outbound video | issue `capture-bottleneck` | The camera is not delivering the frames it was configured for — caught *while it degrades*, not once it has stopped |
|
|
448
|
+
| [`EncoderPerformanceDetector`](#encoderperformancedetector) | outbound video | issue `encoder-bottleneck` | The camera is delivering and the encoder cannot keep up with it |
|
|
449
|
+
| [`InboundFrameSupplyDetector`](#inboundframesupplydetector) | inbound video | issue `decoder-bottleneck` | Frames arrived and the decoder did not turn enough of them into pictures |
|
|
450
|
+
| [`CaptureFailureDetector`](#capturefailuredetector) | outbound tracks | issues `capture-track-ended`, `silent-audio-source` | Vanished devices and microphones producing pure silence |
|
|
451
|
+
| [`CongestionDetector`](#congestiondetector) | peer connection | issue `congestion` | Bandwidth-limited sending corroborated by RTT / loss |
|
|
452
|
+
| [`CpuPerformanceDetector`](#cpuperformancedetector) | whole client | issue `cpulimitation` | The device running out of CPU for encode/decode |
|
|
453
|
+
| [`LongPcConnectionEstablishmentDetector`](#longpcconnectionestablishmentdetector) | peer connection | event `too-long-pc-connection-establishment` | Connection setup taking suspiciously long |
|
|
454
|
+
| [`IceConnectivityDetector`](#iceconnectivitydetector) | ICE transports | issues `ice-disconnected`, `ice-connection-failed`, `ice-transport-stalled`, `unstable-ice-path`; events `ice-restart`, `ice-restart-recommended` | Runtime ICE health and *when* an ICE restart is warranted |
|
|
455
|
+
| [`BlockedTransportDetector`](#blockedtransportdetector) | ICE transports | issue `blocked-transport` | STUN passes but media does not — the firewall / policy-middlebox signature |
|
|
456
|
+
| [`NoAvailableIceCandidateDetector`](#noavailableicecandidatedetector) | peer connection | issue `no-available-ice-candidate` | Zero local ICE candidates while the connection falls over — no usable network at all |
|
|
457
|
+
| [`MediaPipelineDetector`](#mediapipelinedetector) | peer connection | issue `media-pipeline-stalled` | The first broken stage of the media pipeline nothing else covers: encoded frames never leave the sender, or transport traffic never demuxes |
|
|
458
|
+
| [`IceTupleChangeDetector`](#icetuplechangedetector) | ICE transports | event `ice-tuple-changed` | The low-level signal that the selected network tuple changed |
|
|
459
|
+
| [`CodecChangeDetector`](#observation-detectors) | tracks | event `codec-changed` / `CODEC_CHANGED` | Which codec/profile is actually in use, and when it changed |
|
|
460
|
+
| [`VideoResolutionChangeDetector`](#observation-detectors) | video tracks | event `video-resolution-changed` / `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder, with the *reason* attached |
|
|
461
|
+
| [`SimulcastLayerDetector`](#observation-detectors) | outbound video | event `simulcast-layer-changed` / `SIMULCAST_LAYER_CHANGED` | Which simulcast layers are actually being sent |
|
|
462
|
+
| [`StatsGapDetector`](#observation-detectors) | the monitor itself | event `stats-collection-gap` / `STATS_COLLECTION_GAP` | Backgrounded-tab gaps that would otherwise read as network spikes |
|
|
463
|
+
|
|
464
|
+
The last four are **observations**: they emit events and never raise issues, because what they report is not a fault — it is the missing context in most investigations.
|
|
465
|
+
|
|
466
|
+
### Which issues belong in the sample
|
|
467
|
+
|
|
468
|
+
Every issue-raising detector exposes a runtime flag next to `disabled`:
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
/** like `disabled`, flippable at runtime */
|
|
472
|
+
public includeIssueInSample = true;
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
When flipped to `false`, the detector keeps working locally — monitor events fire and the issue lifecycle (`activeIssues`, `'issue'` / `'issue-resolved'`) is maintained — but neither the raise entry nor the resolution entry is buffered into the `ClientSample`. (`raiseIssue` / `addIssue` accept the same thing directly via `includeInSample` for custom issues.)
|
|
476
|
+
|
|
477
|
+
In case shrinking down the sample size is something your application wants, the table below is the useful thing to know: it says for every issue whether the server can **derive the same verdict from one component's stats that the sample already carries** (all the load-bearing counters are monotonic totals, so a server holding consecutive samples can recompute every delta). Issues that are derivable are the safe candidates for `includeIssueInSample = false`; issues that are not derivable join stats across components, depend on state that never reaches the sample (`MediaStreamTrack.muted`, `getSettings()`, connection-state transitions), or live in sub-sampling-period timing — switch those off and the information is gone.
|
|
478
|
+
|
|
479
|
+
| Detector | Issue | Derivable from one component's sampled stats? | From what |
|
|
480
|
+
| --- | --- | --- | --- |
|
|
481
|
+
| `FreezedVideoTrackDetector` | `freezed-video-track` | **Yes** | `inbound-rtp` `freezeCount`, `totalFreezesDuration` |
|
|
482
|
+
| `FreezedVideoTrackDetector` | `keyframe-storm` | **Yes** | `inbound-rtp` `pliCount`, `firCount`, `keyFramesDecoded` |
|
|
483
|
+
| `FreezedVideoTrackDetector` | `video-recovery-failed` | No | tick-level sequencing of freeze + PLI + keyframe counters |
|
|
484
|
+
| `AudioDesyncDetector` | `audio-desync` | **Yes** | `inbound-rtp` inserted/removed sample totals |
|
|
485
|
+
| `AudioConcealmentDetector` | `audio-concealment` | **Yes** | `inbound-rtp` `concealedSamples`, `silentConcealedSamples` |
|
|
486
|
+
| `JitterBufferStressDetector` | `audio-jitter-buffer-stress` | **Yes** (approx.) | `inbound-rtp` jitter-buffer totals; the consecutive-tick nuance is lost |
|
|
487
|
+
| `SynthesizedSamplesDetector` | event only | **Yes** | `media-playout` synthesized-sample totals |
|
|
488
|
+
| `PlayoutDiscrepancyDetector` | `inbound-video-playout-discrepancy` | **Yes** | `inbound-rtp` `framesReceived` vs `framesRendered` |
|
|
489
|
+
| `DecoderPerformanceDetector` | `video-decoder-overloaded` | Partially | `inbound-rtp` decode/drop totals; frame-budget + quiet-loss guards are coarser at the sampling period |
|
|
490
|
+
| `StuckDecoderDetector` | `stuck-decoder` | No | tick-level bytes-up/frames-flat/PLI-up fingerprint; drives consumer recreation |
|
|
491
|
+
| `DryInboundTrackDetector` | `dry-inbound-track` | No | guards read `MediaStreamTrack.muted`/`readyState` + remote pause state — not in the sample |
|
|
492
|
+
| `DryOutboundTrackDetector` | `dry-outbound-track` | No | same non-sampled track-state guards |
|
|
493
|
+
| `CaptureFailureDetector` | `capture-track-ended` | No | `MediaStreamTrack` `ended` event — no stats representation |
|
|
494
|
+
| `CaptureFailureDetector` | `silent-audio-source` | No | energy totals are sampled, but the live/enabled/unmuted guards are not |
|
|
495
|
+
| `OutboundFrameSupplyDetector` | `capture-bottleneck` | No | the frame rate is a counter differenced against measured elapsed time, and the guards read `track.getSettings()`, pause state, screen-share content type and live track state — none of it reconstructable from a sample |
|
|
496
|
+
| `EncoderPerformanceDetector` | `encoder-bottleneck` | No | joins the media source's frame rate with the highest active layer's encode time and CPU-limitation shares per collecting tick, and chains off whether `capture-bottleneck` is active |
|
|
497
|
+
| `InboundFrameSupplyDetector` | `decoder-bottleneck` | No | differences `framesDecoded` against `framesReceived` per collecting tick, behind pause and live-track guards that are not sampled |
|
|
498
|
+
| `CongestionDetector` | `congestion` | Mostly | `candidate-pair` available bitrates + `outbound-rtp` `qualityLimitationReason` — two components, but both sampled |
|
|
499
|
+
| `CpuPerformanceDetector` | `cpulimitation` | No | joins send-side and receive-side evidence plus `durationOfCollectingStatsInMs`, which is not sampled |
|
|
500
|
+
| `IceConnectivityDetector` | `ice-disconnected`, `ice-connection-failed`, `ice-transport-stalled`, `unstable-ice-path` | No | state transitions and episode timing happen *between* samples |
|
|
501
|
+
| `BlockedTransportDetector` | `blocked-transport` | No | joins candidate-pair STUN counters + transport bytes + outbound-rtp bitrate per collecting tick |
|
|
502
|
+
| `NoAvailableIceCandidateDetector` | `no-available-ice-candidate` | No | connection-state jumps + gathering state; with no network the next sample may never leave the device |
|
|
503
|
+
| `MediaPipelineDetector` | `media-pipeline-stalled` | No | cross-references outbound-rtp vs its own packet counters and transport bytes vs inbound-rtp bytes per collecting tick |
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
---
|
|
507
|
+
|
|
508
|
+
### Audio detectors
|
|
393
509
|
|
|
394
|
-
|
|
510
|
+
#### AudioConcealmentDetector
|
|
395
511
|
|
|
396
|
-
|
|
397
|
-
- Indicates audio-video sync problems
|
|
512
|
+
Reports how the audio actually *sounded*. Opus + NetEQ conceal a lot of loss inaudibly, and audio also degrades without dramatic loss — so the **audible** concealment share (silent concealment subtracted) is both more sensitive and more specific than packet loss. Judged over a sliding window, since concealment is bursty.
|
|
398
513
|
|
|
399
|
-
**
|
|
514
|
+
**Use the result:** show a "poor audio from X" indicator on the affected participant's tile. Server-side, the issue lifecycle gives you exact audible-degradation windows per participant.
|
|
400
515
|
|
|
401
516
|
```javascript
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
517
|
+
audioConcealmentDetector: {
|
|
518
|
+
onThreshold: 0.03, // audible concealment share that raises (Webex: >3% = significant)
|
|
519
|
+
offThreshold: 0.01, // share below which it resolves (hysteresis)
|
|
520
|
+
windowInMs: 15000, // sliding window; spans several ticks even at 5s collection
|
|
521
|
+
minSamplesInWindow: 24000, // don't judge on less than ~0.5s of 48kHz audio
|
|
405
522
|
}
|
|
406
523
|
```
|
|
407
524
|
|
|
408
|
-
|
|
525
|
+
```typescript
|
|
526
|
+
monitor.on('audio-concealment', ({ trackMonitor, concealmentRate }) => {
|
|
527
|
+
// the user is HEARING this — mark the participant's tile
|
|
528
|
+
ui.setAudioQualityWarning(trackMonitor.track.id, { rate: concealmentRate });
|
|
529
|
+
});
|
|
530
|
+
monitor.on('issue-resolved', (issue) => {
|
|
531
|
+
if (issue.type === 'audio-concealment') ui.clearAudioQualityWarning(/* by key */);
|
|
532
|
+
});
|
|
533
|
+
```
|
|
409
534
|
|
|
410
|
-
|
|
535
|
+
**Sources:** [Voice quality monitoring (Webex)](https://help.webex.com/article/kqh7le/Voice-quality-monitoring) · [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
411
536
|
|
|
412
|
-
|
|
537
|
+
#### JitterBufferStressDetector
|
|
413
538
|
|
|
414
|
-
-
|
|
415
|
-
- Network congestion conditions
|
|
539
|
+
Fires only when the jitter buffer's target delay has grown **and** NetEQ is time-stretching audio. Either alone is the system working (buying latency to hide jitter is success); together they are added delay the user actually hears.
|
|
416
540
|
|
|
417
|
-
**
|
|
541
|
+
**Use the result:** for latency-sensitive products, surface a "your connection is adding delay" hint; there is nothing to fix client-side, so the main value is attribution — this participant's audio lag is *their network jitter*, not your platform.
|
|
418
542
|
|
|
419
543
|
```javascript
|
|
420
|
-
|
|
421
|
-
|
|
544
|
+
jitterBufferStressDetector: {
|
|
545
|
+
targetDelayThresholdInMs: 200, // >200ms added delay is noticeable degradation
|
|
546
|
+
timeStretchThreshold: 0.02, // share of samples stretched/compressed
|
|
547
|
+
minConsecutiveTicks: 2, // sustained, not a one-tick blip
|
|
422
548
|
}
|
|
423
549
|
```
|
|
424
550
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
551
|
+
```typescript
|
|
552
|
+
monitor.on('audio-jitter-buffer-stress', ({ trackMonitor, targetDelayInMs }) => {
|
|
553
|
+
log.info(`audio delayed ~${Math.round(targetDelayInMs)}ms by jitter buffering`, trackMonitor.track.id);
|
|
554
|
+
});
|
|
555
|
+
```
|
|
428
556
|
|
|
429
|
-
**
|
|
557
|
+
**Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [NetEQ (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/neteq/) · [RTCRtpReceiver.jitterBufferTarget (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCRtpReceiver/jitterBufferTarget)
|
|
430
558
|
|
|
431
|
-
|
|
432
|
-
- Inbound decoded/received frames ratio drops below threshold (the decoder cannot keep up with received frames — a sign of decode-side CPU limitation)
|
|
433
|
-
- Stats collection takes too long (indicating CPU stress)
|
|
559
|
+
#### AudioDesyncDetector
|
|
434
560
|
|
|
435
|
-
|
|
561
|
+
Detects heavy sample correction (acceleration/deceleration) on an inbound audio track — the signature of playback drifting and being yanked back, which the user perceives as warbly or out-of-sync audio.
|
|
436
562
|
|
|
437
|
-
**
|
|
563
|
+
**Use the result:** correlate with lip-sync complaints; persistent desync on one track is usually the far end's capture clock, so route the report to *that* participant's diagnostics rather than the listener's.
|
|
438
564
|
|
|
439
565
|
```javascript
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
alertOff: 0.85, // alert OFF once >=85% are decoded again (hysteresis)
|
|
444
|
-
minReceivedFrames: 10, // skip intervals with fewer received frames (low-fps noise guard)
|
|
445
|
-
},
|
|
446
|
-
durationOfCollectingStatsThreshold: {
|
|
447
|
-
lowWatermark: 5000,
|
|
448
|
-
highWatermark: 10000,
|
|
449
|
-
},
|
|
566
|
+
audioDesyncDetector: {
|
|
567
|
+
fractionalCorrectionAlertOnThreshold: 0.1, // >10% of samples corrected raises
|
|
568
|
+
fractionalCorrectionAlertOffThreshold: 0.05, // <5% resolves
|
|
450
569
|
}
|
|
451
570
|
```
|
|
452
571
|
|
|
453
|
-
|
|
572
|
+
```typescript
|
|
573
|
+
monitor.on('audio-desync-track', ({ trackMonitor }) => {
|
|
574
|
+
diagnostics.flag('audio-desync', trackMonitor.track.id);
|
|
575
|
+
});
|
|
576
|
+
```
|
|
454
577
|
|
|
455
|
-
|
|
578
|
+
**Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
456
579
|
|
|
457
|
-
|
|
580
|
+
#### SynthesizedSamplesDetector
|
|
458
581
|
|
|
459
|
-
-
|
|
460
|
-
- Track stalling or connection issues
|
|
582
|
+
Watches `media-playout` for synthesized (concealment/generated) samples injected at the playout device level, and emits `'synthesized-audio'` plus the `EXCESSIVE_SYNTHESIZED_AUDIO` client event when the duration in one interval exceeds the configured minimum.
|
|
461
583
|
|
|
462
|
-
**
|
|
584
|
+
**Use the result:** sustained synthesized playout with otherwise healthy inbound stats points at the *output* path — suggest the user switch audio output device.
|
|
463
585
|
|
|
464
586
|
```javascript
|
|
465
|
-
|
|
466
|
-
|
|
587
|
+
syntheticSamplesDetector: {
|
|
588
|
+
minSynthesizedSamplesDuration: 0, // ms of synthesized audio per interval before reporting
|
|
589
|
+
createEvent: true, // also buffer EXCESSIVE_SYNTHESIZED_AUDIO into samples
|
|
467
590
|
}
|
|
468
591
|
```
|
|
469
592
|
|
|
470
|
-
|
|
593
|
+
**Sources:** [How WebRTC's NetEQ jitter buffer provides smooth audio (webrtcHacks)](https://webrtchacks.com/how-webrtcs-neteq-jitter-buffer-provides-smooth-audio/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
594
|
+
|
|
595
|
+
---
|
|
471
596
|
|
|
472
|
-
|
|
597
|
+
### Video detectors
|
|
598
|
+
|
|
599
|
+
#### FreezedVideoTrackDetector
|
|
473
600
|
|
|
474
|
-
|
|
601
|
+
Owns the whole freeze/repair domain of an inbound video track. It derives the freeze state (a freeze persists until frames actually render again) and watches the repair loop — PLI/FIR out, keyframes back in:
|
|
475
602
|
|
|
476
|
-
-
|
|
477
|
-
-
|
|
603
|
+
- `freezed-video-track` — the picture is frozen (config: `videoFreezesDetector`).
|
|
604
|
+
- `keyframe-storm` — sustained PLI rate; self-reinforcing congestion, since keyframes are large (config: `videoRecoveryDetector`).
|
|
605
|
+
- `video-recovery-failed` — PLIs going out, picture still frozen, keyframes not advancing: the repair request left the client and nothing came back, which points at SFU forwarding (config: `videoRecoveryDetector`).
|
|
478
606
|
|
|
479
|
-
**
|
|
607
|
+
**Use the result:** on `freezed-video-track`, overlay a spinner/last-frame treatment on the tile. `video-recovery-failed` is your escalation signal — pair it with [`stuck-decoder`](#stuckdecoderdetector): if both fire, recreate the consumer; if only recovery fails (no bytes checked here), the producer or SFU forwarding needs the look.
|
|
480
608
|
|
|
481
609
|
```javascript
|
|
482
|
-
|
|
483
|
-
|
|
610
|
+
videoFreezesDetector: { minConsecutiveTicks: 2 }, // consecutive frozen intervals before an issue (null = off)
|
|
611
|
+
videoRecoveryDetector: {
|
|
612
|
+
windowInMs: 30000, // window for PLI/keyframe rates
|
|
613
|
+
pliRateAlertOn: 0.5, // real-world storms run ~0.5-0.7 PLI/s sustained
|
|
614
|
+
pliRateAlertOff: 0.15,
|
|
615
|
+
recoveryFailedThresholdInMs: 5000, // frozen + PLIs out + no keyframe for this long
|
|
616
|
+
recoveryFailedMinPliCount: 2, // proof we actually asked for repair
|
|
484
617
|
}
|
|
485
618
|
```
|
|
486
619
|
|
|
487
|
-
|
|
620
|
+
```typescript
|
|
621
|
+
monitor.on('freezed-video-track', ({ trackMonitor }) => ui.showFreezeOverlay(trackMonitor.track.id));
|
|
622
|
+
monitor.on('video-recovery-failed', ({ trackMonitor, pliCountSinceStalled }) => {
|
|
623
|
+
// we asked for a keyframe repeatedly and nothing came back — not a local problem
|
|
624
|
+
reportToServer('recovery-failed', trackMonitor.track.id, { pliCountSinceStalled });
|
|
625
|
+
});
|
|
626
|
+
```
|
|
488
627
|
|
|
489
|
-
|
|
628
|
+
**Sources:** [PLI: Picture Loss Indication (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/pli/) · [RFC 4585: RTP/AVPF (PLI/FIR)](https://datatracker.ietf.org/doc/html/rfc4585) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
490
629
|
|
|
491
|
-
|
|
630
|
+
#### DecoderPerformanceDetector
|
|
492
631
|
|
|
493
|
-
|
|
494
|
-
- Video freeze conditions
|
|
632
|
+
Blames this device only when frames demonstrably *arrived* — healthy receive rate, quiet loss — but decode time overran a budget derived from the stream's own frame rate, or frames were dropped after arrival. This is the detector that separates "the network dropped it" from "the client could not decode it": same chart, opposite fixes.
|
|
495
633
|
|
|
496
|
-
**
|
|
634
|
+
**Use the result:** reduce decode load — subscribe to lower simulcast layers, cap the number of rendered videos, or pause off-screen tiles. The payload's `decoderImplementation` / `powerEfficientDecoder` tell you whether a software decoder is doing work the hardware could.
|
|
497
635
|
|
|
498
636
|
```javascript
|
|
499
|
-
|
|
637
|
+
decoderPerformanceDetector: {
|
|
638
|
+
decodeTimeBudgetRatio: 0.8, // share of the per-frame budget (1000/fps) decode may use
|
|
639
|
+
dropRatioThreshold: 0.1, // frames dropped after arriving
|
|
640
|
+
minFramesReceived: 10, // don't judge starved intervals (e.g. static screen share)
|
|
641
|
+
quietLossThreshold: 0.02, // above this, the network is the better explanation
|
|
642
|
+
minConsecutiveTicks: 2,
|
|
500
643
|
}
|
|
501
644
|
```
|
|
502
645
|
|
|
503
|
-
|
|
646
|
+
```typescript
|
|
647
|
+
monitor.on('video-decoder-overloaded', ({ trackMonitor, decodeTimePerFrameInMs, frameBudgetInMs }) => {
|
|
648
|
+
// frames are arriving; this device can't keep up — lower the decode load
|
|
649
|
+
sfuClient.preferLayer(trackMonitor.track.id, 'low');
|
|
650
|
+
});
|
|
651
|
+
```
|
|
504
652
|
|
|
505
|
-
|
|
653
|
+
**Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
506
654
|
|
|
507
|
-
|
|
655
|
+
#### InboundFrameSupplyDetector
|
|
508
656
|
|
|
509
|
-
-
|
|
510
|
-
- Video playout buffer issues
|
|
657
|
+
The receive-side counterpart of `capture-bottleneck`: frames arrived and the decoder did not turn enough of them into pictures. Raises `decoder-bottleneck`.
|
|
511
658
|
|
|
512
|
-
**
|
|
659
|
+
**The rule, in full:** add up the frames that arrived and the frames that were decoded; once `durationInMs` has accumulated, compare them. Decoded below `decodeFpsRatioThreshold` of arrived → raise. At or above → resolve. Start a new window.
|
|
513
660
|
|
|
514
661
|
```javascript
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
662
|
+
inboundFrameSupplyDetector: {
|
|
663
|
+
durationInMs: 15000, // average the decoder over this long ...
|
|
664
|
+
decodeFpsRatioThreshold: 0.9, // ... then require 90% of what arrived
|
|
665
|
+
minReceivedFps: 5, // too thin a stream to judge a decoder on
|
|
518
666
|
}
|
|
519
667
|
```
|
|
520
668
|
|
|
521
|
-
|
|
669
|
+
**The bar is the arrival rate, never the sender's.** Frames that never arrived are the network's story — `FreezedVideoTrackDetector` and the peer connection's loss reasons tell it — so a stream throttled to 5fps that decodes cleanly is silent. That is also what separates it from [`DecoderPerformanceDetector`](#decoderperformancedetector), which asks whether decoding *cost* too much over consecutive ticks: that one is about the price of decoding, this one about frames going missing. Both firing at once is the honest answer when both are true.
|
|
522
670
|
|
|
523
|
-
|
|
671
|
+
**Use the result:** the client cannot decode what it was handed — drop to a lower simulcast layer, or ask the SFU for one.
|
|
524
672
|
|
|
525
|
-
|
|
673
|
+
```typescript
|
|
674
|
+
monitor.on('decoder-bottleneck', ({ trackMonitor, decodedFps, receivedFps }) => {
|
|
675
|
+
sfu.requestLowerLayer(trackMonitor.track.id, { decodedFps, receivedFps });
|
|
676
|
+
});
|
|
677
|
+
```
|
|
526
678
|
|
|
527
|
-
|
|
528
|
-
- Audio gaps requiring interpolation
|
|
679
|
+
**What it refuses to judge**, because a low decode rate there is legitimate: a backgrounded tab, a paused consumer, a paused remote sender, a track that is not live and unmuted, and a stream thinner than `minReceivedFps`. The window restarts after a collection gap.
|
|
529
680
|
|
|
530
|
-
|
|
681
|
+
#### StuckDecoderDetector
|
|
682
|
+
|
|
683
|
+
Catches the per-consumer decode wedge: RTP bytes keep arriving while nothing decodes and PLIs fire continuously — a corrupted/incomplete frame broke the decode chain and it never recovers on its own. The wait is adaptive (`max(thresholdInMs, rttMultiplier × RTT)` plus a minimum number of stuck ticks), and the `minBitrate` floor separates it from a merely starved track.
|
|
684
|
+
|
|
685
|
+
**Use the result:** **recreate the consumer** — that is the known mitigation, and this detector fires exactly and only when it applies (delivery confirmed, output zero). The payload's `variant` separates an `assembly` wedge (no frame ever reassembled) from a `decode` wedge, and `deadBytesReceived` quantifies the waste for the report.
|
|
531
686
|
|
|
532
687
|
```javascript
|
|
533
|
-
|
|
534
|
-
|
|
688
|
+
stuckDecoderDetector: {
|
|
689
|
+
thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier × RTT)
|
|
690
|
+
rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
|
|
691
|
+
minBitrate: 10000, // bps below which this is a dry track, not a wedge
|
|
692
|
+
minPliCount: 2, // the browser must be asking for repair
|
|
535
693
|
}
|
|
536
694
|
```
|
|
537
695
|
|
|
538
|
-
|
|
696
|
+
```typescript
|
|
697
|
+
monitor.on('stuck-decoder', async ({ trackMonitor, variant, deadBytesReceived }) => {
|
|
698
|
+
// the stream is delivered but nothing decodes — recreate the consumer
|
|
699
|
+
await sfuClient.recreateConsumerFor(trackMonitor.track.id);
|
|
700
|
+
reportToServer('stuck-decoder', { variant, deadBytesReceived });
|
|
701
|
+
});
|
|
702
|
+
```
|
|
539
703
|
|
|
540
|
-
|
|
704
|
+
**Sources:** [PLI: Picture Loss Indication (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/pli/) · [RFC 4585: RTP/AVPF (PLI/FIR)](https://datatracker.ietf.org/doc/html/rfc4585) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
541
705
|
|
|
542
|
-
|
|
706
|
+
#### PlayoutDiscrepancyDetector
|
|
543
707
|
|
|
544
|
-
|
|
545
|
-
- Connection setup issues
|
|
708
|
+
Detects a growing skew between frames *received* and frames *rendered* on an inbound video track — decode succeeded, but the rendering pipeline is falling behind.
|
|
546
709
|
|
|
547
|
-
**
|
|
710
|
+
**Use the result:** re-attach the media element or recreate the `<video>` sink; this is a local rendering problem, not a network one.
|
|
548
711
|
|
|
549
712
|
```javascript
|
|
550
|
-
|
|
551
|
-
|
|
713
|
+
playoutDiscrepancyDetector: {
|
|
714
|
+
lowSkewRatio: 0.1, // share of received frames at which the issue resolves
|
|
715
|
+
highSkewRatio: 0.25, // share of received frames at which it raises
|
|
716
|
+
minFramesReceived: 10, // below this the interval carries too few frames to judge
|
|
552
717
|
}
|
|
553
718
|
```
|
|
554
719
|
|
|
555
|
-
|
|
720
|
+
```typescript
|
|
721
|
+
monitor.on('inbound-video-playout-discrepancy', ({ trackMonitor }) => {
|
|
722
|
+
videoSinks.reattach(trackMonitor.track.id);
|
|
723
|
+
});
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**Sources:** [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
727
|
+
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
### Track activity
|
|
556
731
|
|
|
557
|
-
|
|
558
|
-
is **not** in scope — `LongPcConnectionEstablishmentDetector` covers that.
|
|
732
|
+
#### DryInboundTrackDetector / DryOutboundTrackDetector
|
|
559
733
|
|
|
560
|
-
|
|
734
|
+
Raise `dry-inbound-track` / `dry-outbound-track` when a track that should be flowing carries no bytes at all past a threshold. This is *starvation* — contrast with [`stuck-decoder`](#stuckdecoderdetector), where bytes flow and nothing decodes.
|
|
561
735
|
|
|
562
|
-
|
|
563
|
-
`disconnectedThresholdInMs`. Transient blips, which ICE usually heals on its
|
|
564
|
-
own, never raise an issue.
|
|
565
|
-
- `ice-connection-failed` — ICE reached `failed`, which is terminal for that
|
|
566
|
-
generation.
|
|
567
|
-
- `ice-transport-stalled` — deliberately narrow: raised only while this endpoint
|
|
568
|
-
is still **sending** on a succeeded pair of a connected transport but receives
|
|
569
|
-
nothing, and only after inbound traffic had previously been observed. "No
|
|
570
|
-
traffic in either direction" is *not* reported, because at peer-connection
|
|
571
|
-
level it cannot be told apart from a legitimately idle or paused connection.
|
|
572
|
-
- `unstable-ice-path` — the selected path switched `pathSwitchThreshold` times
|
|
573
|
-
within `pathSwitchWindowInMs`.
|
|
736
|
+
**Use the result:** inbound dry → verify the producer is not paused, then resubscribe/reconsume; outbound dry → check the local track (`muted`, `enabled`, `readyState`) and the transport before blaming the network.
|
|
574
737
|
|
|
575
|
-
|
|
738
|
+
```javascript
|
|
739
|
+
dryInboundTrackDetector: { thresholdInMs: 5000 },
|
|
740
|
+
dryOutboundTrackDetector: { thresholdInMs: 5000 },
|
|
741
|
+
```
|
|
576
742
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
743
|
+
```typescript
|
|
744
|
+
monitor.on('dry-inbound-track', async ({ trackMonitor }) => {
|
|
745
|
+
if (!(await sfuClient.isProducerPaused(trackMonitor.track.id))) {
|
|
746
|
+
await sfuClient.resubscribe(trackMonitor.track.id);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
```
|
|
582
750
|
|
|
583
|
-
|
|
751
|
+
**Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
584
752
|
|
|
585
|
-
|
|
586
|
-
application. Only the application knows whether renegotiation is safe right now,
|
|
587
|
-
whether signalling is up, and whether it would rather tear the call down — so the
|
|
588
|
-
detector names the moment and gets out of the way.
|
|
753
|
+
---
|
|
589
754
|
|
|
590
|
-
|
|
591
|
-
self-heals), or when `disconnected`, an inbound stall, or an unfinished
|
|
592
|
-
establishment outlasts `iceRestartRecommendationThresholdInMs`. The `reason`
|
|
593
|
-
tells you which:
|
|
755
|
+
### Send side
|
|
594
756
|
|
|
595
|
-
|
|
596
|
-
|---|---|
|
|
597
|
-
| `ice-failed` | ICE gave up on this generation. |
|
|
598
|
-
| `ice-disconnected` | `disconnected` outlasted the window in which ICE usually self-heals. |
|
|
599
|
-
| `transport-stalled` | ICE still reports connected, but the selected path stopped delivering. |
|
|
600
|
-
| `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`. |
|
|
601
|
-
|
|
602
|
-
`LongPcConnectionEstablishmentDetector` reports that setup is *slow* at its own
|
|
603
|
-
(shorter) threshold; the `never-established` recommendation says it is not going
|
|
604
|
-
to happen on its own. The two thresholds form an escalation, not a duplicate
|
|
605
|
-
report. While a restart the application already started is in flight, the
|
|
606
|
-
detector stays quiet. Repeat recommendations are spaced
|
|
607
|
-
by `iceRestartRecommendationCooldownInMs`, and each carries a
|
|
608
|
-
`recommendationCount` and the current `iceGeneration` so you can back off after
|
|
609
|
-
repeated failed attempts.
|
|
757
|
+
#### OutboundFrameSupplyDetector
|
|
610
758
|
|
|
611
|
-
|
|
612
|
-
monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
|
|
613
|
-
if (3 <= recommendationCount) return rejoinTheCall(); // restarts are not helping
|
|
759
|
+
Is the capture device delivering the frames the track was configured to capture? The send-side mirror of [`InboundFrameSupplyDetector`](#inboundframesupplydetector), which asks the same of the decoder. Raises `capture-bottleneck`.
|
|
614
760
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
761
|
+
**The rule, in full:** add up the frames the source delivered and the time it had to deliver them; once `durationInMs` has accumulated, compare the average against `getSettings().frameRate`. Below `captureFpsRatioThreshold` of it → raise. At or above → resolve. Start again. Two running totals, no history buffer.
|
|
762
|
+
|
|
763
|
+
```javascript
|
|
764
|
+
outboundFrameSupplyDetector: {
|
|
765
|
+
durationInMs: 15000, // average the capture device over this long ...
|
|
766
|
+
captureFpsRatioThreshold: 0.9, // ... then require 90% of the configured fps
|
|
767
|
+
}
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
**Why average rather than threshold each tick.** A camera that is failing rather than merely busy dips and recovers: 150 frames per 5s tick becomes 132, back to 150, then 97. Tick by tick most of it looks fine. The average over 15s does not — 26.3fps against a configured 30 — so it raises while the camera is still delivering, about half a minute before this one stopped entirely. Averaging also weights *how far* the source fell short rather than merely how often.
|
|
771
|
+
|
|
772
|
+
**Why a duration and not a tick count.** What matters here is that the device stayed short for a stretch of time that means something. A tick count would mean six seconds at a 2s collecting period and thirty at a 10s one. ([`EncoderPerformanceDetector`](#encoderperformancedetector) is the other way round, and says why.)
|
|
773
|
+
|
|
774
|
+
**The rate is always the counter, never `mediaSource.framesPerSecond`.** `sourceFps` is the frame counter differenced against *measured* elapsed time. The browser's own figure is coarse and smooths this exact stutter away — it can read `30` across an interval that actually delivered 132 frames in five seconds. When `sourceFps` is undefined the counter restarted, and a restart is not a measurement.
|
|
775
|
+
|
|
776
|
+
**No baseline, no judgement.** If the browser does not report `getSettings().frameRate`, nothing is substituted for it: there is no rate for the measurement to fall short *of*, so the check stays quiet.
|
|
777
|
+
|
|
778
|
+
**What it refuses to judge**, because a low frame rate there is legitimate: a backgrounded tab (`ClientMonitor.activeTab === false`), a paused or stopped sender, and screen shares, whose frame rate is content-driven (a still document delivers nothing). If you capture a moving surface that should be watched, declare it with `monitor.setOutboundTrackContext(trackId, { contentType: 'camera' })`. The totals also restart after a settings change or a collection gap — that threshold is derived from `collectingPeriodInMs` rather than configured.
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
monitor.on('capture-bottleneck', ({ trackMonitor, sourceFps, expectedFps }) => {
|
|
782
|
+
ui.hintCameraTrouble(trackMonitor.track.id, { sourceFps, expectedFps });
|
|
620
783
|
});
|
|
621
784
|
```
|
|
622
785
|
|
|
623
|
-
|
|
786
|
+
**Threshold caveat.** `0.9` over 15s came from two captured sessions — one failure, one control, one camera model. They catch that failure and stay silent on that control, and are otherwise unvalidated: treat `capture-bottleneck` as observation-grade until a corpus sets the numbers.
|
|
624
787
|
|
|
625
|
-
|
|
626
|
-
NetEQ conceal a great deal of loss inaudibly, and conversely audio degrades
|
|
627
|
-
without dramatic loss when the jitter buffer misbehaves — so concealment is both
|
|
628
|
-
the more sensitive and the more specific signal.
|
|
788
|
+
**Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
629
789
|
|
|
630
|
-
|
|
631
|
-
ordinary silence, so `silentConcealedSamples` is subtracted before the detector
|
|
632
|
-
sees the number. Without that subtraction this would flag every quiet moment in
|
|
633
|
-
every call.
|
|
790
|
+
#### EncoderPerformanceDetector
|
|
634
791
|
|
|
635
|
-
|
|
636
|
-
and a `burstiness` of `'bursty'` (many short clicks) or `'continuous'` (fewer,
|
|
637
|
-
longer dropouts) — they sound different and have different causes.
|
|
792
|
+
Given a capture source that *is* delivering, is the encoder keeping up with it? The send-side mirror of [`DecoderPerformanceDetector`](#decoderperformancedetector). Raises `encoder-bottleneck`.
|
|
638
793
|
|
|
639
|
-
|
|
640
|
-
arrived in the window to judge.
|
|
794
|
+
Any one of three signals is enough: the highest active layer encodes below `encodeFpsRatioThreshold` of what the source delivered, encoding one frame costs more than `encodeTimeBudgetRatio` of the per-frame budget (`1000 / sourceFps`), or — only if you configure it — the browser reported itself CPU-limited for more than `cpuLimitationShareThreshold` of the interval.
|
|
641
795
|
|
|
642
796
|
```javascript
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
797
|
+
encoderPerformanceDetector: {
|
|
798
|
+
encodeFpsRatioThreshold: 0.7, // encoder below 70% of source fps = behind
|
|
799
|
+
encodeTimeBudgetRatio: 0.8, // encode time per frame vs the frame budget
|
|
800
|
+
cpuLimitationShareThreshold: null, // null = ignore the browser's CPU-limited signal
|
|
801
|
+
minConsecutiveTicks: 2, // two reads agreeing, not a span of time
|
|
648
802
|
}
|
|
649
803
|
```
|
|
650
804
|
|
|
651
|
-
|
|
805
|
+
**Everything is measured against what the source actually delivered**, never against what the track was configured to capture at. An encoder handed 3fps and emitting 3fps is doing its job perfectly; comparing it to a configured 30 would call that a catastrophic failure. Whether the source itself is short is the *other* detector's question — and while its `capture-bottleneck` is active, this one says nothing at all. The frames were never there to encode. The two issues are mutually exclusive by construction.
|
|
806
|
+
|
|
807
|
+
That chain is read from the issue rather than shared through a field: this detector checks `ClientMonitor.isIssueActive('capture-bottleneck-track-<id>')`. `OutboundTrackMonitor` registers the capture detector first and `Detectors.update()` preserves registration order, so the verdict is same-tick.
|
|
808
|
+
|
|
809
|
+
**Why `minConsecutiveTicks` here and a duration on the capture side.** They answer different questions. A tick count is a *confidence* floor — every signal above is a per-interval ratio that a single stats read can get wrong, so what is wanted is two independent reads agreeing, which is two samples whatever the collecting period happens to be. A duration is a *persistence* bar — the capture case, where the device has to stay short long enough to matter. `DecoderPerformanceDetector` and `JitterBufferStressDetector` use ticks for the same reason this one does.
|
|
810
|
+
|
|
811
|
+
**Why `cpuLimitationShareThreshold` defaults to `null`.** `CpuPerformanceDetector` already reports CPU limitation as its own `cpulimitation` issue, and the useful thing to do with the two is correlate them — `encoder-bottleneck` and `cpulimitation` firing together is evidence the encoder is CPU-bound. That inference is only worth something while `encoder-bottleneck` is derived *without* reading the same signal; wire the CPU share in here too and the correlation becomes tautological. Set a number (`0.3` is a reasonable one) if you would rather have the extra sensitivity than the independent evidence.
|
|
812
|
+
|
|
813
|
+
**Use the result:** reduce encode load — drop the top simulcast layer, lower resolution or frame rate, disable background effects. The payload carries `encoderImplementation`, `cpuLimitationShare` and the fps pair for the report.
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
monitor.on('encoder-bottleneck', () => sender.dropTopSimulcastLayer());
|
|
817
|
+
```
|
|
652
818
|
|
|
653
|
-
|
|
654
|
-
absorbed cleanly" from "the jitter buffer ballooned, adding latency and
|
|
655
|
-
stretching audio to cope".
|
|
819
|
+
#### CaptureFailureDetector
|
|
656
820
|
|
|
657
|
-
|
|
658
|
-
NetEQ is *succeeding* — buying latency to hide jitter, with the user hearing
|
|
659
|
-
nothing wrong. Time stretching on its own is ordinary clock-drift correction. It
|
|
660
|
-
is the two together that mean the buffer is fighting the network and losing.
|
|
821
|
+
Watches the source end of outbound tracks: the device is gone (`capture-track-ended`), the OS or another app took it (`capture-track-muted` event), or a live, unmuted microphone has produced nothing but silence for a long stretch (`silent-audio-source` — the threshold is deliberately long, because only duration separates a dead mic from a quiet person).
|
|
661
822
|
|
|
662
|
-
**
|
|
823
|
+
**Use the result:** `capture-track-ended` → open the device picker. `silent-audio-source` → the classic "are you speaking? we can't hear you" banner, with a shortcut to switch microphone.
|
|
663
824
|
|
|
664
825
|
```javascript
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
826
|
+
captureFailureDetector: {
|
|
827
|
+
silenceThresholdInMs: 60000, // long on purpose: silence ≠ broken until it persists
|
|
828
|
+
silenceRmsThreshold: 0.0001, // interval-integrated RMS, not the flickery audioLevel
|
|
829
|
+
createEvent: true, // also buffer CAPTURE_TRACK_ENDED / _MUTED into samples
|
|
669
830
|
}
|
|
670
831
|
```
|
|
671
832
|
|
|
672
|
-
|
|
833
|
+
```typescript
|
|
834
|
+
monitor.on('silent-audio-source', ({ trackMonitor, silentForInMs }) => {
|
|
835
|
+
ui.showBanner("We can't hear you — check your microphone", { switchDeviceAction: true });
|
|
836
|
+
});
|
|
837
|
+
monitor.on('capture-track-ended', () => ui.openDevicePicker('audioinput'));
|
|
838
|
+
```
|
|
673
839
|
|
|
674
|
-
|
|
675
|
-
network-versus-client attribution possible. Frames dropped because they never
|
|
676
|
-
arrived and frames dropped because the client could not decode them look
|
|
677
|
-
identical in a frame-rate chart, and the fixes are opposite — so this detector
|
|
678
|
-
fires only when the frames demonstrably *did* arrive: enough frames received,
|
|
679
|
-
loss below `quietLossThreshold`, and either decode time past the per-frame
|
|
680
|
-
budget or frames dropped after arrival.
|
|
840
|
+
**Sources:** [MediaStreamTrack mute event (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/mute_event) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
681
841
|
|
|
682
|
-
|
|
683
|
-
at 15 fps), so a static screen share dropping to 1 fps does not trip it.
|
|
842
|
+
---
|
|
684
843
|
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
844
|
+
### Connection & client health
|
|
845
|
+
|
|
846
|
+
#### CongestionDetector
|
|
847
|
+
|
|
848
|
+
Detects network congestion on a peer connection: bandwidth-limited outbound streams, corroborated (depending on `sensitivity`) by an RTT jump over its own EWMA baseline or by outbound loss. The RTT it reads never mixes RTCP and ICE measurements.
|
|
849
|
+
|
|
850
|
+
**Use the result:** reduce what you send — lower simulcast layers or cap the bitrate — and show a network-quality indicator. The event payload carries the available bitrates plus the maxima seen before congestion, which sizes *how much* to back off.
|
|
688
851
|
|
|
689
852
|
```javascript
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
dropRatioThreshold: 0.1,
|
|
693
|
-
minFramesReceived: 10,
|
|
694
|
-
quietLossThreshold: 0.02,
|
|
695
|
-
minConsecutiveTicks: 2,
|
|
853
|
+
congestionDetector: {
|
|
854
|
+
sensitivity: 'medium', // 'high': any bw-limitation | 'medium': + RTT rise | 'low': + >5% loss
|
|
696
855
|
}
|
|
697
856
|
```
|
|
698
857
|
|
|
699
|
-
|
|
858
|
+
```typescript
|
|
859
|
+
monitor.on('congestion', ({ availableOutgoingBitrate, maxSendingBitrate }) => {
|
|
860
|
+
sender.capBitrate(Math.min(availableOutgoingBitrate, maxSendingBitrate * 0.8));
|
|
861
|
+
ui.setNetworkIndicator('poor');
|
|
862
|
+
});
|
|
863
|
+
```
|
|
700
864
|
|
|
701
|
-
|
|
702
|
-
the track's freeze state (a freeze persists until frames render again, not just
|
|
703
|
-
until the next tick) and watches the repair loop — PLI/FIR out, keyframes back
|
|
704
|
-
in. The `videoRecoveryDetector` config gates the two repair-loop issues:
|
|
865
|
+
**Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
705
866
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
debugging an SFU: it says the repair request left the client and nothing came
|
|
712
|
-
back, which points at forwarding rather than at the first-hop network.
|
|
867
|
+
#### CpuPerformanceDetector
|
|
868
|
+
|
|
869
|
+
Client-wide CPU pressure: outbound streams explicitly CPU-limited (instantaneous label *and* sustained duration shares), encode time per frame over budget, inbound decode falling behind receive, or stats collection itself slowing down.
|
|
870
|
+
|
|
871
|
+
**Use the result:** shed load in order of user impact — disable background blur/effects first, then reduce rendered remote videos, then lower capture resolution. Resolve restores them.
|
|
713
872
|
|
|
714
873
|
```javascript
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
recoveryFailedMinPliCount: 2,
|
|
874
|
+
cpuPerformanceDetector: {
|
|
875
|
+
incomingDecodedFramesRatioThresholds: { alertOn: 0.7, alertOff: 0.85, minReceivedFrames: 10 },
|
|
876
|
+
durationOfCollectingStatsThreshold: { lowWatermark: 5000, highWatermark: 10000 },
|
|
877
|
+
encoderCpuLimitationShareThreshold: 0.3, // share of interval spent CPU-limited
|
|
878
|
+
encodeTimeBudgetRatio: 0.8, // encode ms per frame vs 1000/fps budget
|
|
721
879
|
}
|
|
722
880
|
```
|
|
723
881
|
|
|
724
|
-
|
|
882
|
+
```typescript
|
|
883
|
+
monitor.on('cpulimitation', () => effects.disableBackgroundBlur());
|
|
884
|
+
monitor.on('issue-resolved', (issue) => {
|
|
885
|
+
if (issue.type === 'cpulimitation') effects.restore();
|
|
886
|
+
});
|
|
887
|
+
```
|
|
725
888
|
|
|
726
|
-
|
|
727
|
-
ever decodes again — a corrupt or incomplete frame broke the decode chain, PLIs
|
|
728
|
-
go out continuously, keyframes may even be generated upstream, and this
|
|
729
|
-
consumer never assembles a usable frame until it is recreated.
|
|
889
|
+
**Sources:** [Power-up getStats for client monitoring (webrtcHacks)](https://webrtchacks.com/power-up-getstats-for-client-monitoring/) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
730
890
|
|
|
731
|
-
|
|
732
|
-
rising + `keyFramesDecoded` flat. The "bytes still flowing" condition is what
|
|
733
|
-
separates it from everything nearby: a dry track has no bytes, and
|
|
734
|
-
`video-recovery-failed` reports an unanswered repair request without saying
|
|
735
|
-
whether the pipe is dead or the decoder is. It reads only RTP deltas, so it
|
|
736
|
-
works regardless of browser freeze statistics.
|
|
891
|
+
#### LongPcConnectionEstablishmentDetector
|
|
737
892
|
|
|
738
|
-
|
|
739
|
-
PLI → keyframe recovery — and that cost scales with the connection, not with a
|
|
740
|
-
fixed number of seconds. The effective wait is
|
|
741
|
-
`max(thresholdInMs, rttMultiplier × RTT)`, at least `minStuckTicks`
|
|
742
|
-
collections, with `minBitrate` (a rate, so it means the same thing at every
|
|
743
|
-
collecting period) confirming the stream is actually being delivered.
|
|
893
|
+
Emits `'too-long-pc-connection-establishment'` (and the `LONG_PC_CONNECTION_ESTABLISHMENT` client event) when a peer connection stays in `connecting` past the threshold. Re-arms on any exit from `connecting`, so slow *retries* are reported too.
|
|
744
894
|
|
|
745
|
-
**
|
|
746
|
-
reassembled; `'decode'`: frames assemble but never decode), the accumulated
|
|
747
|
-
dead bytes, and the PLI count since the wedge began.
|
|
895
|
+
**Use the result:** show "connecting is taking longer than usual"; if it repeats, retry with `iceTransportPolicy: 'relay'` to test whether direct connectivity is the blocker. The [`never-established`](#iceconnectivitydetector) restart recommendation is this detector's escalation.
|
|
748
896
|
|
|
749
|
-
|
|
750
|
-
|
|
897
|
+
```javascript
|
|
898
|
+
longPcConnectionEstablishmentDetector: {
|
|
899
|
+
thresholdInMs: 5000,
|
|
900
|
+
createEvent: true,
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
**Sources:** [RTCPeerConnection.connectionState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState) · [ICE (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/ice/)
|
|
905
|
+
|
|
906
|
+
#### IceConnectivityDetector
|
|
907
|
+
|
|
908
|
+
Runtime ICE and transport health, per ICE transport (a peer connection without BUNDLE has several, and they fail independently). Raises `ice-disconnected` (only after the threshold — transient blips self-heal), `ice-connection-failed` (immediately — `failed` is terminal for the generation), `ice-transport-stalled` (still sending, receiving nothing, after inbound had been seen) and `unstable-ice-path` (selected path flapping).
|
|
909
|
+
|
|
910
|
+
**Use the result — the restart loop:** the library names *when* an ICE restart is warranted; performing it is the application's job. `'ice-restart-recommended'` carries a `reason` and a `recommendationCount` so you can escalate to a full rejoin when restarts stop helping; `'ice-restart'` then reports whether the restart you performed `recovered` or `failed`.
|
|
911
|
+
|
|
912
|
+
| `reason` | Meaning |
|
|
913
|
+
|---|---|
|
|
914
|
+
| `ice-failed` | ICE gave up on this generation — restart immediately. |
|
|
915
|
+
| `ice-disconnected` | `disconnected` outlasted the self-heal window. |
|
|
916
|
+
| `transport-stalled` | ICE says connected, but the selected path stopped delivering. |
|
|
917
|
+
| `never-established` | The connection never finished connecting (covers stuck DTLS). |
|
|
918
|
+
|
|
919
|
+
```javascript
|
|
920
|
+
iceConnectivityDetector: {
|
|
921
|
+
disconnectedThresholdInMs: 5000, // how long `disconnected` may self-heal
|
|
922
|
+
transportStallThresholdInMs: 5000, // sending-but-not-receiving tolerance
|
|
923
|
+
pathSwitchWindowInMs: 30000, // window for counting selected-path switches
|
|
924
|
+
pathSwitchThreshold: 3, // switches in the window => unstable path
|
|
925
|
+
iceRestartRecommendationThresholdInMs: 10000,
|
|
926
|
+
iceRestartRecommendationCooldownInMs: 15000,
|
|
927
|
+
createEvent: true, // buffer ICE_RESTART / _RECOMMENDED into samples
|
|
928
|
+
}
|
|
929
|
+
```
|
|
751
930
|
|
|
752
931
|
```typescript
|
|
753
|
-
monitor.on('
|
|
754
|
-
|
|
755
|
-
|
|
932
|
+
monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
|
|
933
|
+
if (recommendationCount >= 3) return session.rejoin(); // restarts are not helping
|
|
934
|
+
rtcPeerConnection.restartIce(); // or mediasoup transport.restartIce()
|
|
756
935
|
});
|
|
936
|
+
monitor.on('ice-restart', ({ outcome }) => metrics.count(`ice-restart.${outcome}`));
|
|
757
937
|
```
|
|
758
938
|
|
|
939
|
+
**Sources:** [ICE restart: recovering connectivity (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/ice-restart/) · [RTCPeerConnection.restartIce (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/restartIce) · [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) · [RFC 7675: STUN consent freshness](https://datatracker.ietf.org/doc/html/rfc7675)
|
|
940
|
+
|
|
941
|
+
#### BlockedTransportDetector
|
|
942
|
+
|
|
943
|
+
The firewall signature: a middlebox that lets ICE/STUN through but blocks the media itself. Every connectivity signal looks healthy — the candidate pair is `succeeded`, consent checks keep passing, `iceConnectionState` is `connected` — yet the call carries nothing. The existing detectors structurally miss this case: STUN consent responses count into the pair's `bytesReceived`, so the pair never looks dry and the inbound-stall check never fires, while the dry-track detectors see outbound-rtp counters advancing and stay silent.
|
|
944
|
+
|
|
945
|
+
Raises `blocked-transport` (per ICE transport) when, sustained for `thresholdInMs`, all three hold: STUN demonstrably alive (`responsesReceived` advanced within `stunFreshnessInMs`), the application demonstrably producing (outbound RTP on the transport ≥ `minMediaBitrateBps`), and the media demonstrably not traversing. The payload's `evidence` field says which discrepancy was observed:
|
|
946
|
+
|
|
947
|
+
| `evidence` | Meaning |
|
|
948
|
+
|---|---|
|
|
949
|
+
| `media-not-leaving-transport` | RTP senders produce bytes but the transport's own send counter barely moves — host firewall, blocked socket, dead route. |
|
|
950
|
+
| `no-return-traffic` | Media leaves at full rate, STUN answers, but nothing except STUN comes back — not even RTCP. Classic DPI / UDP-throttling firewall. |
|
|
951
|
+
|
|
952
|
+
The detector judges the *sending* side, where the client holds both halves of the proof. A firewall blocking only the receive direction shows up on the remote peer's sending side, or as a dry inbound track here.
|
|
953
|
+
|
|
759
954
|
```javascript
|
|
760
|
-
|
|
761
|
-
thresholdInMs:
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
955
|
+
blockedTransportDetector: {
|
|
956
|
+
thresholdInMs: 5000, // discrepancy persistence before raising
|
|
957
|
+
minMediaBitrateBps: 10000, // below this the transport is legitimately quiet
|
|
958
|
+
maxReturnBitrateBps: 2000, // at/below this the return path counts as STUN-only
|
|
959
|
+
maxSendShare: 0.1, // transport send under this share of produced => blocked on send
|
|
960
|
+
stunFreshnessInMs: 10000, // consent checks run ~5s; must comfortably exceed one interval
|
|
766
961
|
}
|
|
767
962
|
```
|
|
768
963
|
|
|
769
|
-
|
|
964
|
+
**Use the result:** tell the user their network blocks media (a TURN/TLS fallback or a network change is the fix, an ICE restart on the same path is not), and correlate server-side: many `blocked-transport` clients on one corporate network is a firewall policy, not N user problems.
|
|
965
|
+
|
|
966
|
+
**Sources:** [RFC 7675: STUN consent freshness](https://datatracker.ietf.org/doc/html/rfc7675) · [RTCIceCandidatePairStats (W3C webrtc-stats)](https://www.w3.org/TR/webrtc-stats/#candidatepair-dict*) · [WebRTC and firewalls (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/firewall/)
|
|
770
967
|
|
|
771
|
-
|
|
772
|
-
causes, which from RTP alone are indistinguishable:
|
|
968
|
+
#### NoAvailableIceCandidateDetector
|
|
773
969
|
|
|
774
|
-
|
|
775
|
-
throttling in low light, an OS capture stall, a device the browser is quietly
|
|
776
|
-
downgrading. Nothing the encoder or the network can do about it.
|
|
777
|
-
- `encoder-bottleneck` — the source produced frames and the encoder could not
|
|
778
|
-
keep up. Carries `encodeTimePerFrameInMs`, `cpuLimitationShare`,
|
|
779
|
-
`encoderImplementation` and `powerEfficientEncoder`.
|
|
970
|
+
The other end of the connectivity spectrum: the client cannot even *begin* to connect because ICE gathering produced **zero local candidates**. A healthy establishment gathers a host candidate within milliseconds — even without internet, any up interface yields one. Zero candidates while the connection state jumps from `new`/`connecting` straight to `disconnected`/`failed` means there was nothing to connect *with*: no interface, airplane mode, a VPN that tore down every route. This is a different diagnosis from every other ICE issue — those describe a path that existed and stopped working; this one says no path was ever possible.
|
|
780
971
|
|
|
781
|
-
|
|
782
|
-
active layer actually encoded.
|
|
972
|
+
Raises `no-available-ice-candidate` (per peer connection) immediately on `disconnected`/`failed` with zero local candidates on a never-connected PC, and after `thresholdInMs` when the PC just sits in `new`/`connecting` with nothing gathered. Resolves when a local candidate appears or the connection reaches `connected`. Never fires on a connection that once connected — mid-call network loss belongs to `IceConnectivityDetector`.
|
|
783
973
|
|
|
784
974
|
```javascript
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
minSourceFps: 5,
|
|
788
|
-
encodeFpsRatioThreshold: 0.7,
|
|
789
|
-
encodeTimeBudgetRatio: 0.8,
|
|
790
|
-
cpuLimitationShareThreshold: 0.3,
|
|
791
|
-
minConsecutiveTicks: 2,
|
|
975
|
+
noAvailableIceCandidateDetector: {
|
|
976
|
+
thresholdInMs: 6000, // grace for `new`/`connecting` before the sustained variant raises
|
|
792
977
|
}
|
|
793
978
|
```
|
|
794
979
|
|
|
795
|
-
|
|
980
|
+
**Use the result:** skip the ICE-restart dance entirely — recommend the user check their connection; on the server, treat the client as offline-at-join rather than call-quality-degraded.
|
|
796
981
|
|
|
797
|
-
|
|
798
|
-
user-visible failures originate and none of them show up in RTP.
|
|
982
|
+
**Sources:** [RTCPeerConnection.connectionState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState) · [RTCPeerConnection.iceGatheringState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState) · [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445)
|
|
799
983
|
|
|
800
|
-
|
|
801
|
-
(the microphone is live and producing nothing).
|
|
984
|
+
#### MediaPipelineDetector
|
|
802
985
|
|
|
803
|
-
|
|
804
|
-
application took the device), plus the matching client events.
|
|
986
|
+
The pipeline stage classifier: media moves through a fixed chain (capture → encoder → RTP sender → transport → wire, mirrored on receive), every stage has a monotonic counter proving progress, and a disruption is locatable as the *first* boundary where the upstream counter advances and the downstream one does not. Most boundaries are owned by specialist detectors; this one raises `media-pipeline-stalled` for the two nothing else covers:
|
|
805
987
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
interval, rather than the instantaneous `audioLevel` that reads zero between
|
|
813
|
-
words.
|
|
988
|
+
| `stage` | Direction | Meaning |
|
|
989
|
+
|---|---|---|
|
|
990
|
+
| `rtp-sender` | send | `deltaFramesEncoded > 0` while `deltaPacketsSent === 0` — an encoded frame always packetizes, so a sustained violation is a wedged sender/pacer (seen after `replaceTrack` races and simulcast reconfigurations). Guarded by track live + unmuted + layer active. |
|
|
991
|
+
| `transport-demux` | receive | The ICE transport receives ≥ `minTransportReceiveBitrateBps` — well above what RTCP + STUN can explain — while every inbound RTP of the transport stays flat: traffic arrives that never demuxes (SSRC mismatch after renegotiation, a consumer against a dead producer). Requires at least one inbound RTP to exist. |
|
|
992
|
+
|
|
993
|
+
The payload carries `suspectedIssueTypes` — the specialist issues active on this peer connection at raise time — so one entry both localizes the stage and links the detailed evidence. Registered last among the peer-connection detectors for exactly that reason.
|
|
814
994
|
|
|
815
995
|
```javascript
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
createEvent: true,
|
|
996
|
+
mediaPipelineDetector: {
|
|
997
|
+
thresholdInMs: 4000, // how long a broken boundary must persist
|
|
998
|
+
minTransportReceiveBitrateBps: 20000, // above this, incoming traffic must demux
|
|
820
999
|
}
|
|
821
1000
|
```
|
|
822
1001
|
|
|
823
|
-
|
|
1002
|
+
**Use the result:** `rtp-sender` → renegotiate or replace the sender (the encoder is fine, the pipe after it is wedged); `transport-demux` → recreate the consumers / re-signal SSRCs (the network is fine, the demux is not).
|
|
824
1003
|
|
|
825
|
-
|
|
826
|
-
not faults but are the missing context in most investigations.
|
|
1004
|
+
#### IceTupleChangeDetector
|
|
827
1005
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1006
|
+
The low-level primitive under the path detectors: emits `'ice-tuple-changed'` whenever the set of selected `local:remote` network tuples changes. Always registered; `SelectedIcePath` classifies *what kind of* change it was, and only `IceConnectivityDetector` raises issues.
|
|
1007
|
+
|
|
1008
|
+
**Use the result:** debugging and logging — a tuple change with no `ice-path-changed` classification usually means a port change on the same interface.
|
|
1009
|
+
|
|
1010
|
+
**Sources:** [RFC 8445: ICE](https://datatracker.ietf.org/doc/html/rfc8445) · [TURN server: when you need it and what it costs (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/turn/) · [RTCIceCandidateStats.relayProtocol (MDN)](https://developer.mozilla.org/docs/Web/API/RTCIceCandidateStats/relayProtocol) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
1011
|
+
|
|
1012
|
+
---
|
|
1013
|
+
|
|
1014
|
+
### Observation detectors
|
|
1015
|
+
|
|
1016
|
+
These four emit events and **never raise issues** — they record context that is not a fault but is the missing column in most investigations. Each has a single config option, `createEvent` (default `true`), which buffers the matching client event into samples for server-side use.
|
|
1017
|
+
|
|
1018
|
+
| Detector | Monitor event / client event | Use the result for |
|
|
1019
|
+
|---|---|---|
|
|
1020
|
+
| `CodecChangeDetector` | `codec-changed` / `CODEC_CHANGED` | Answering "why do all the bad calls use H264" — compares `sdpFmtpLine` too, so an H264 profile switch is caught. Fires once or twice per call. |
|
|
1021
|
+
| `VideoResolutionChangeDetector` | `video-resolution-changed` / `VIDEO_RESOLUTION_CHANGED` | Following the adaptation ladder. On outbound tracks the event carries `qualityLimitationReason` — the field that separates encoder adaptation from your own constraint changes. Classified `upgrade` / `downgrade` / `reshape` (orientation flip). |
|
|
1022
|
+
| `SimulcastLayerDetector` | `simulcast-layer-changed` / `SIMULCAST_LAYER_CHANGED` | Debugging "why is this participant blurry": a layer counts as active only if it *sent bytes*, so a layer the encoder quietly gave up on becomes visible. |
|
|
1023
|
+
| `StatsGapDetector` | `stats-collection-gap` / `STATS_COLLECTION_GAP` | Discounting the metrics right after a backgrounded-tab / sleep gap instead of reading them as a network spike. |
|
|
1024
|
+
|
|
1025
|
+
```javascript
|
|
1026
|
+
codecChangeDetector: { createEvent: true },
|
|
1027
|
+
videoResolutionChangeDetector: { createEvent: true },
|
|
1028
|
+
simulcastLayerDetector: { createEvent: true },
|
|
1029
|
+
statsGapDetector: {
|
|
1030
|
+
gapRatioThreshold: 2, // multiple of collectingPeriodInMs that counts as a gap
|
|
1031
|
+
minGapInMs: 5000, // a single missed short tick is jitter, not a gap
|
|
1032
|
+
createEvent: true,
|
|
1033
|
+
},
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
```typescript
|
|
1037
|
+
monitor.on('video-resolution-changed', ({ trackMonitor, direction, to, qualityLimitationReason }) => {
|
|
1038
|
+
if (trackMonitor.direction === 'outbound' && direction === 'downgrade' && qualityLimitationReason === 'cpu') {
|
|
1039
|
+
// the encoder is shrinking the picture because of CPU, not bandwidth
|
|
1040
|
+
effects.disableBackgroundBlur();
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
monitor.on('stats-collection-gap', ({ gapInMs }) => metrics.markUnreliableWindow(gapInMs));
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
**Sources:** [Simulcast (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/simulcast/) · [Page Visibility API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) · [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
834
1047
|
|
|
835
1048
|
### Custom Detectors
|
|
836
1049
|
|
|
@@ -881,21 +1094,66 @@ monitor.detectors.remove(detector);
|
|
|
881
1094
|
|
|
882
1095
|
See [Controlling which detectors run](#controlling-which-detectors-run) for the full set of registry helpers.
|
|
883
1096
|
|
|
1097
|
+
---
|
|
1098
|
+
|
|
1099
|
+
### Replaying a captured session
|
|
1100
|
+
|
|
1101
|
+
Detector thresholds are only as good as the sessions they were checked against,
|
|
1102
|
+
so the library ships a replay harness: a captured session is fed back through a
|
|
1103
|
+
real `ClientMonitor` on a virtual clock, producing the same monitors, derived
|
|
1104
|
+
fields, issues, events and samples the live run produced — against current or
|
|
1105
|
+
experimental thresholds.
|
|
1106
|
+
|
|
1107
|
+
The input is JSONL, one captured collection tick per line, described by the
|
|
1108
|
+
`ReplayEntry` type in `tests/helpers/StatsReplayer.ts`. Producing the lines is
|
|
1109
|
+
not this library's business: a server-side capture, an app-side listener on
|
|
1110
|
+
`'stats-collected'`, or a script synthesizing a scenario all work.
|
|
1111
|
+
|
|
1112
|
+
**From the command line:**
|
|
1113
|
+
|
|
1114
|
+
```bash
|
|
1115
|
+
npm run replay -- tests/fixtures/degrading-camera.jsonl
|
|
1116
|
+
npm run replay -- session.jsonl --only capture-bottleneck,encoder-bottleneck
|
|
1117
|
+
npm run replay -- session.jsonl --config '{"outboundFrameSupplyDetector":{ ... }}'
|
|
1118
|
+
cat session.jsonl | npm run replay -- - --pretty
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
Everything on stdout is NDJSON — one `{"record":"issue",...}` object per
|
|
1122
|
+
detector fire, carrying the issue's own timestamp plus the `tick` and
|
|
1123
|
+
`tickTimestamp` that locate it in the input, then a closing `summary` record
|
|
1124
|
+
with per-type counts. Progress and warnings go to stderr, so the output pipes
|
|
1125
|
+
straight into `jq`, a notebook, or a corpus runner sweeping thresholds across
|
|
1126
|
+
many sessions. Detectors that are opt-in in production are **enabled** by
|
|
1127
|
+
default under replay: the point of a replay is to see what they would have said.
|
|
1128
|
+
`--help` lists every flag.
|
|
1129
|
+
|
|
1130
|
+
**From a spec** — drop the file in `tests/fixtures/` and use `replayFixture`:
|
|
1131
|
+
|
|
1132
|
+
```typescript
|
|
1133
|
+
import { replayFixture } from './helpers/replayFixture';
|
|
1134
|
+
|
|
1135
|
+
const run = await replayFixture('degrading-camera');
|
|
1136
|
+
|
|
1137
|
+
expect(run.issueTypes.has('capture-bottleneck')).toBe(true);
|
|
1138
|
+
run.close();
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
Its second argument is config overrides, so the same capture can be replayed
|
|
1142
|
+
against different thresholds to find where a detector flips. For full control
|
|
1143
|
+
— multiple monitors, tick-by-tick assertions, real-time replay — use
|
|
1144
|
+
`StatsReplayer` directly; `tests/fixtures/README.md` has the details.
|
|
1145
|
+
|
|
884
1146
|
## Score Calculation
|
|
885
1147
|
|
|
886
1148
|
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.
|
|
887
1149
|
|
|
1150
|
+
> **Full reference:** every reason key, threshold, ramp and formula is documented in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md). This section is the overview.
|
|
1151
|
+
|
|
888
1152
|
### ScoreCalculator Interface
|
|
889
1153
|
|
|
890
1154
|
```typescript
|
|
891
1155
|
interface ScoreCalculator {
|
|
892
1156
|
update(): void;
|
|
893
|
-
encodeClientScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
894
|
-
encodePeerConnectionScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
895
|
-
encodeInboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
896
|
-
encodeInboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
897
|
-
encodeOutboundAudioScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
898
|
-
encodeOutboundVideoScoreReasons?<T extends Record<string, number>>(reasons?: T): string;
|
|
899
1157
|
}
|
|
900
1158
|
```
|
|
901
1159
|
|
|
@@ -920,69 +1178,200 @@ Where PC_Score = Track_Score_Avg × PC_Stability_Score
|
|
|
920
1178
|
|
|
921
1179
|
#### Peer Connection Stability Score
|
|
922
1180
|
|
|
923
|
-
Based on Round Trip Time (RTT) and packet loss:
|
|
1181
|
+
Based on Round Trip Time (RTT), jitter and packet loss. RTT and jitter are penalized **separately** — a long path and a jittery path are different problems with different fixes, and the score reasons say which one it is:
|
|
1182
|
+
|
|
1183
|
+
**RTT Penalties (`high-rtt`)** — one reason key, two magnitudes, like jitter and loss:
|
|
1184
|
+
|
|
1185
|
+
- 150-300ms average RTT: -1.0 point
|
|
1186
|
+
- \>300ms average RTT: -2.0 points
|
|
924
1187
|
|
|
925
|
-
**
|
|
1188
|
+
**Jitter Penalties (`high-jitter`)** — measured jitter averaged over the streams that reported one:
|
|
926
1189
|
|
|
927
|
-
-
|
|
928
|
-
-
|
|
1190
|
+
- 30-100ms average jitter: -1.0 point
|
|
1191
|
+
- \>100ms average jitter: -2.0 points
|
|
929
1192
|
|
|
930
|
-
**Packet Loss Penalties
|
|
1193
|
+
**Packet Loss Penalties (`high-packetloss`)** — the per-interval `deltaFractionLost`, **averaged** across streams (a raw sum would read ten streams at 1% each as 10%):
|
|
931
1194
|
|
|
932
1195
|
- 1-5% loss: -1.0 point
|
|
933
1196
|
- 5-20% loss: -2.0 points
|
|
934
1197
|
- > 20% loss: -5.0 points
|
|
935
1198
|
|
|
1199
|
+
**Loss and jitter are attributed here and nowhere else.** They are properties of the *path*, shared by every stream on the transport, so track scores do not subtract for them — see [Track Score Calculations](#track-score-calculations).
|
|
1200
|
+
|
|
1201
|
+
**Only streams carrying media measure the path.** Both averages skip any stream that shows no evidence of carrying media in the interval: under `MIN_PATH_SAMPLE_BITRATE` (8 kbps) *and* under `MIN_PATH_SAMPLE_PACKETS` (25 packets) *and* delivering no frames. An SFU's bandwidth-probation stream — mediasoup sends one on `mid: "probator"` — is a handful of deliberately discardable packets with no frames, and its loss and jitter figures are not measurements of anything: observed at ~2 kbps with ~50% "loss" and ~490 ms "jitter" while the real streams beside it ran at 0% loss and 2 ms jitter. Averaged in with equal weight it used to pin the connection at the minimum score for an entire session.
|
|
1202
|
+
|
|
1203
|
+
#### Normalized penalty ramps
|
|
1204
|
+
|
|
1205
|
+
Most metric-driven penalties are **normalized to `0..1`**: nothing is subtracted while the metric stays at or below an *activation threshold*, then the penalty ramps up linearly and saturates at `1.0` at a *saturation point*:
|
|
1206
|
+
|
|
1207
|
+
```
|
|
1208
|
+
penalty(value) = clamp((value − activation) / (saturation − activation), 0, 1)
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
The activation/saturation constants are `public static readonly` on `DefaultScoreCalculator`. Penalties that are effectively binary (a frozen picture, a CPU-limited encoder) stay stepped. The tables in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md) list every ramp.
|
|
1212
|
+
|
|
936
1213
|
#### Track Score Calculations
|
|
937
1214
|
|
|
1215
|
+
**Track scores measure what the user perceived, not what the network did.** Freezes, low and volatile fps, dropped frames, pixelation, concealment, time-stretch and jitter-buffer delay are all measurements of damage. Packet loss and jitter are *causes*, they are properties of the path rather than of any one track, and they are attributed once on the peer connection — so no track penalty subtracts for them. A server attributing a degradation joins a track's symptoms to its peer connection's path reasons, which arrive in the same sample.
|
|
1216
|
+
|
|
938
1217
|
**Inbound Audio Track Score:**
|
|
939
1218
|
|
|
940
|
-
- Based on normalized bitrate and
|
|
941
|
-
-
|
|
942
|
-
-
|
|
1219
|
+
- Based on normalized bitrate. **Packet loss is not subtracted here** — it belongs to the peer connection; what the loss *did* to the audio is measured directly as concealment and time-stretch below
|
|
1220
|
+
- When the audio detectors run, their windowed, hysteresis-guarded verdicts **gate** additional penalties, and the current per-tick metric **scales** them as a normalized `0..1` ramp starting at the detector's own configured threshold:
|
|
1221
|
+
- `audio-concealment` issue active → scaled by `concealmentRate` (detector `onThreshold` → 0.10)
|
|
1222
|
+
- `audio-jitter-buffer-stress` issue active → `high-jitter-buffer-delay`, scaled by `jitterBufferTargetDelayInMs` (detector `targetDelayThresholdInMs` → 500 ms)
|
|
1223
|
+
- `audio-desync` issue active → `audio-time-stretch`, scaled by `timeStretchRate` (detector `fractionalCorrectionAlertOnThreshold` → 0.3)
|
|
1224
|
+
- A tick where the metric dipped back under the threshold contributes no penalty even while hysteresis keeps the issue open
|
|
1225
|
+
- Without the detectors the score falls back to the pure loss decay
|
|
943
1226
|
|
|
944
1227
|
```javascript
|
|
945
1228
|
normalizedBitrate = log10(max(bitrate, MIN_AUDIO_BITRATE) / MIN_AUDIO_BITRATE) / NORMALIZATION_FACTOR;
|
|
946
|
-
|
|
947
|
-
score = min(MAX_SCORE, 5 * normalizedBitrate * lossPenalty);
|
|
1229
|
+
score = min(MAX_SCORE, 5 * normalizedBitrate) - issuePenalties;
|
|
948
1230
|
```
|
|
949
1231
|
|
|
950
1232
|
**Inbound Video Track Score:**
|
|
951
1233
|
|
|
952
|
-
- FPS volatility
|
|
953
|
-
-
|
|
954
|
-
-
|
|
1234
|
+
- FPS volatility (`volatile-fps`, normalized 0–1): activation 0.1, saturation 0.2 — *skipped for screen share*
|
|
1235
|
+
- Sustained low fps while frames are flowing (`low-fps`, ewma fps < 10): -1.0 — *skipped for screen share*
|
|
1236
|
+
- Dropped frames (`dropped-video-frames`, normalized 0–1): activation 10%, saturation 20% of frames dropped instead of rendered
|
|
1237
|
+
- Frame corruptions (`video-frame-corruptions`, normalized 0–1): per-interval corruption probability, activation 0.05, saturation 0.5
|
|
1238
|
+
- Frozen picture (`frozen-video`, from the freeze state the detector derives): -2.0
|
|
1239
|
+
- Pixelation (`pixelated-video`): ramps 0→1 from the codec's activation QP to its saturation QP (`VIDEO_QP_THRESHOLDS`), from the mean quantizer of the frames actually decoded (`avgQpPerFrame`, from the inbound `qpSum`) — then multiplied by a weight chosen by **how big the picture is shown**, so the reason ranges 0–3.0.
|
|
1240
|
+
|
|
1241
|
+
**A large pixelated video is charged harder, deliberately.** The same QP is punishing at full screen and nearly invisible in a grid thumbnail, because what the eye resolves is the coded block's size on screen. So the weight is not symmetric — the big video is the one the viewer is complaining about:
|
|
1242
|
+
|
|
1243
|
+
```
|
|
1244
|
+
magnification = sqrt((presentedW * presentedH) / (decodedW * decodedH))
|
|
1245
|
+
>= 1.5 -> weight 3.0 | 0.75..1.5 -> weight 2.0 | < 0.75 -> weight 0.5
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
For vp8 standard motion (band 40 → 80), the same stream at QP 60 costs **0.25** in a thumbnail, **1.0** in a grid tile and **1.5** in speaker view; at saturation, **3.0**. No clamp is needed — the tiers are flat. **No presented resolution, no adjustment** (the ordinary 2.0 applies).
|
|
1249
|
+
|
|
1250
|
+
```typescript
|
|
1251
|
+
monitor.setInboundTrackContext(trackId, { presentedResolution: { width: 1280, height: 720 } }); // device pixels
|
|
1252
|
+
monitor.setInboundTrackContext(trackId, { videoTag }); // or hand over the element — re-measured every tick
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
The `videoTag` route measures the element's layout box (`clientWidth`/`clientHeight` × `devicePixelRatio`) with the frame's aspect ratio fitted into it as `object-fit: contain` does; an application using `object-fit: cover` should declare the resolution itself. [Full table](docs/SCORE_CALCULATIONS.md#a-large-pixelated-video-is-charged-harder-deliberately).
|
|
1256
|
+
|
|
1257
|
+
```typescript
|
|
1258
|
+
monitor.setInboundTrackContext(trackId, { presentedResolution: { width: 1280, height: 720 } }); // device pixels
|
|
1259
|
+
monitor.setInboundTrackContext(trackId, { videoTag }); // or hand over the element — re-measured every tick
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
The `videoTag` route measures the element's layout box (`clientWidth`/`clientHeight` × `devicePixelRatio`) with the frame's aspect ratio fitted into it, as `object-fit: contain` does; an application using `object-fit: cover` should declare the resolution itself. **No presented resolution, no shift** — the shipped band is used as written, and nothing is substituted for the missing number.
|
|
1263
|
+
|
|
1264
|
+
QP is the encoder stating how coarsely it had to quantize, so it measures the blockiness and detail loss the viewer is looking at. Bitrate cannot: the same 500 kbps is generous for a static talking head and starvation for a fast pan, and nothing observable separates those two from bits alone. **Where the browser does not report `qpSum` for the codec in use, the reason is simply absent** — no judgement is better than one inferred from bitrate.
|
|
1265
|
+
|
|
1266
|
+
QP scales are codec-specific and *not* comparable as fractions of their ranges — H.264 runs 0–51, VP8 0–127, VP9 0–255 — so each codec carries its own pair, and an unrecognised codec yields no judgement. The shipped values are literature starting points, not measurements of any deployment; calibrate against your own corpus:
|
|
1267
|
+
|
|
1268
|
+
**Motion class.** The same quantizer is not equally visible on all content: fast movement masks compression artifacts, while a slide or a still face shows every blocked edge. `VIDEO_QP_THRESHOLDS` is therefore indexed `[codec][motionType]` — note the bands run the *opposite* way to bitrate, since high-motion content needs more bits to reach a given QP yet tolerates a higher one once there. Nothing in the stats reveals motion, so the application declares it; undeclared, screen share is judged as `lowmotion` (blocked text is a hard failure) and everything else as `standard`:
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
monitor.setInboundTrackContext(trackId, { motionType: 'highmotion' }); // by id, works before the track exists
|
|
1272
|
+
monitor.getInboundTrackMonitor(track.id)?.setContext({ motionType: 'lowmotion' });
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
```typescript
|
|
1276
|
+
import { VIDEO_QP_THRESHOLDS } from '@observertc/client-monitor-js';
|
|
1277
|
+
|
|
1278
|
+
VIDEO_QP_THRESHOLDS.vp8!.standard = { activation: 45, saturation: 90 };
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
Every penalty ramp on `DefaultScoreCalculator` is a mutable static and can be retuned the same way.
|
|
1282
|
+
|
|
1283
|
+
Whether an inbound video track is a screen share is decided by `InboundTrackMonitor.contentType` — same mechanism as the outbound side (see below), except a received track exposes no `displaySurface` to auto-detect from, so the application declares it:
|
|
1284
|
+
|
|
1285
|
+
```typescript
|
|
1286
|
+
monitor.getInboundTrackMonitor(track.id)?.setContext({ contentType: 'screenshare' });
|
|
1287
|
+
```
|
|
955
1288
|
|
|
956
1289
|
**Outbound Audio Track Score:**
|
|
957
1290
|
|
|
958
1291
|
- Similar to inbound, using sending bitrate
|
|
959
1292
|
- Remote packet loss consideration
|
|
960
1293
|
|
|
961
|
-
**Outbound Video Track Score:**
|
|
1294
|
+
**Outbound Video Track Score (camera):**
|
|
1295
|
+
|
|
1296
|
+
- Bitrate deviation from target (`high-deviation-from-target-bitrate`, normalized 0–1): activation 5%, saturation 15% under target, gated on the absolute shortfall also exceeding `max(20 kbps, 5% of target)`
|
|
1297
|
+
- Quality-limitation penalties from the **interval duration shares** (the instantaneous `qualityLimitationReason` flickers): cpu share ≥30% → -2.0 (`cpu-limitation`), bandwidth share ≥50% → -1.0 (`bandwidth-limitation`, milder — BWE adaptation is the system working); instantaneous reason used as fallback when shares are unavailable
|
|
1298
|
+
- Bitrate volatility (`high-volatile-bitrate`, normalized 0–1): activation 0.1, saturation 0.2
|
|
1299
|
+
|
|
1300
|
+
**Outbound Video Track Score (screen share):**
|
|
1301
|
+
|
|
1302
|
+
Decided by `OutboundTrackMonitor.contentType`, **never** by `track.contentHint` (applications set `'detail'`/`'text'` on camera tracks too, so the hint is not a reliable screen-share signal). The flag is auto-detected only from `track.getSettings().displaySurface` — present exclusively on display capture — and otherwise declared by the application:
|
|
1303
|
+
|
|
1304
|
+
```typescript
|
|
1305
|
+
monitor.getOutboundTrackMonitor(track.id)?.setContext({ contentType: 'screenshare' });
|
|
1306
|
+
```
|
|
1307
|
+
|
|
1308
|
+
`getInbound/OutboundTrackMonitor(id)?.setContext(...)` requires the track's monitor to already exist, which only happens on the first stats tick after the track appears on a peer connection. When the application knows earlier — signaling announces a guest's upcoming screen-share track before any media arrives — declare it by track id on the client monitor instead; it is applied immediately if the monitor exists, and otherwise held pending and picked up the moment the track manifests on any peer connection:
|
|
1309
|
+
|
|
1310
|
+
```typescript
|
|
1311
|
+
monitor.setOutboundTrackContext(trackId, { contentType: 'screenshare' });
|
|
1312
|
+
monitor.setInboundTrackContext(trackId, { contentType: 'screenshare', motionType: 'lowmotion' });
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
Both **merge**: fields omitted from the argument keep whatever was declared before, in the pending state as well as on a live monitor, so a content type declared from signaling survives a later call that only attaches the video element. Passing a field as an explicit `undefined` means "not declared here" rather than "reset"; assign the monitor's field directly to clear it.
|
|
1316
|
+
|
|
1317
|
+
For screen-share tracks, sharpness is the quality: fps and bitrate volatility are meaningless on mostly-static content (VBR drops to ~zero between changes), so deviation/volatility penalties are skipped entirely. Instead:
|
|
962
1318
|
|
|
963
|
-
-
|
|
964
|
-
-
|
|
965
|
-
- Bitrate volatility penalties
|
|
966
|
-
- If `track.contentHint === 'screen'`, bitrate deviation and volatility penalties are skipped to better fit screen-share traffic patterns
|
|
1319
|
+
- Quality-limitation duration share penalties (same as camera)
|
|
1320
|
+
- Encoded resolution downscaled vs. the captured surface (`downscaled-screenshare`): encoded area < ½ of source area → -1.0, < ¼ → -2.0 — the point where shared text stops being readable
|
|
967
1321
|
|
|
968
1322
|
### Score Reasons
|
|
969
1323
|
|
|
970
|
-
|
|
1324
|
+
Every penalty the `DefaultScoreCalculator` applies is captured as a **reason**: a map from a reason key to the points it subtracted (`Record<string, number>`). The reasons are the explanation of the score — a `3.0` alone says something is wrong; `{ "frozen-video": 2.0 }` says *what*. They are produced by default, attributed to the entity that caused them, and readable in three places:
|
|
971
1325
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1326
|
+
**1. The realtime `'score'` event** — carries the client-level aggregate of the current tick's reasons across every peer connection and track (as `currentReasons`):
|
|
1327
|
+
|
|
1328
|
+
```typescript
|
|
1329
|
+
monitor.on("score", ({ clientScore, currentReasons }) => {
|
|
1330
|
+
console.log("Client Score:", clientScore);
|
|
1331
|
+
console.log("Score Reasons:", currentReasons);
|
|
1332
|
+
// Example (normalized penalties carry fractional magnitudes):
|
|
977
1333
|
// {
|
|
978
|
-
// "high-rtt": 1.0,
|
|
979
|
-
// "high-
|
|
980
|
-
// "
|
|
981
|
-
// "
|
|
1334
|
+
// "high-rtt": 1.0, // pc: raw RTT above 150ms
|
|
1335
|
+
// "high-jitter": 0.25, // inbound video: jitter 40ms, ramp 20->100ms
|
|
1336
|
+
// "high-packetloss": 2.0, // pc: avg delta fraction lost 5-20%
|
|
1337
|
+
// "cpu-limitation": 2.0, // outbound video: cpu-limited >=30% of the interval
|
|
1338
|
+
// "bandwidth-limitation": 1.0, // outbound video: bandwidth-limited >=50%
|
|
1339
|
+
// "frozen-video": 2.0, // inbound video: picture currently frozen
|
|
1340
|
+
// "audio-concealment": 0.5, // inbound audio: issue active, rate midway to saturation
|
|
1341
|
+
// "downscaled-screenshare": 2.0, // screenshare sent below 1/4 of source area
|
|
1342
|
+
// "dropped-video-frames": 0.4 // inbound video: 14% frames dropped, ramp 10->20%
|
|
982
1343
|
// }
|
|
983
1344
|
});
|
|
984
1345
|
```
|
|
985
1346
|
|
|
1347
|
+
**2. On the monitors** — each entity holds only its *own* reasons, so a low track score is explained on the track, not on the peer connection:
|
|
1348
|
+
|
|
1349
|
+
```typescript
|
|
1350
|
+
pcMonitor.scoreReasons; // rtt / jitter / packetloss only
|
|
1351
|
+
monitor.getInboundTrackMonitor(id)?.scoreReasons; // e.g. frozen-video, audio-concealment
|
|
1352
|
+
monitor.getOutboundTrackMonitor(id)?.scoreReasons; // e.g. cpu-limitation, downscaled-screenshare
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
`ClientMonitor.scoreReasons` follows the same rule: it holds the client's **own** reasons, and there are none today — the client score subtracts nothing directly, being a weighted aggregate of the peer-connection and track scores. So it stays undefined.
|
|
1356
|
+
|
|
1357
|
+
The aggregated view lives on the `'score'` **event** instead, as `currentReasons` — every component's reasons summed by key. Score and reasons are kept separate on purpose: the event gives an application the whole picture to react to, while each monitor's `scoreReasons` stays scoped to what that entity itself caused.
|
|
1358
|
+
|
|
1359
|
+
```typescript
|
|
1360
|
+
monitor.on('score', ({ clientScore, currentReasons }) => {
|
|
1361
|
+
// currentReasons: { 'high-rtt': 1.0, 'pixelated-video': 0.27 } — the aggregate
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
monitor.scoreReasons; // the client's OWN reasons — undefined today
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
**3. In the samples — every entity ships only its own reasons.** The peer-connection and track sample entries carry `scoreReasons` as a **record of reason key → subtracted points** (`Record<string, number>`), so a degraded score explains itself on the wire, magnitudes included. The field is omitted when there is nothing to explain.
|
|
1368
|
+
|
|
1369
|
+
The **client sample entry carries no reasons**, because the client score subtracts nothing of its own. Shipping the aggregate there would put every reason on the wire a second time in the same sample, and would read as though the client itself were pixelating or losing packets when the cause was one inbound track. A server reconstructs the client-level view in post-analysis by re-aggregating the components of the same sample — the information is not lost, only sent once. If a client-level penalty is ever added it lands on `ClientMonitor.scoreReasons` like any other component's, and ships automatically.
|
|
1370
|
+
|
|
1371
|
+
Set `sendScoreReasonsToServer: false` in the config to drop the reasons from the wire entirely — the scores themselves and the realtime event are unaffected.
|
|
1372
|
+
|
|
1373
|
+
The full key set — with every threshold, ramp and what each reason means for the user experience — is documented in [docs/SCORE_CALCULATIONS.md](./docs/SCORE_CALCULATIONS.md); the type union is exported as `DefaultScoreCalculatorSubtractionReason`.
|
|
1374
|
+
|
|
986
1375
|
### Custom Score Calculator
|
|
987
1376
|
|
|
988
1377
|
Implement your own scoring logic by implementing the `ScoreCalculator` interface:
|
|
@@ -1253,66 +1642,76 @@ if (sample) {
|
|
|
1253
1642
|
|
|
1254
1643
|
### Sample Compression
|
|
1255
1644
|
|
|
1256
|
-
|
|
1645
|
+
`ClientSample` objects compress well because consecutive samples are nearly identical — the same tracks, the same peer connections, counters that moved a little. Two codec packages exploit that by encoding **each sample as the delta from the previous one**; pick one by the wire format you want:
|
|
1646
|
+
|
|
1647
|
+
| Package | Wire format | Use it when |
|
|
1648
|
+
| --- | --- | --- |
|
|
1649
|
+
| `@observertc/samples-protobuf-codec` | Protobuf binary | You want the smallest payload and already speak protobuf on the server. |
|
|
1650
|
+
| `@observertc/samples-json-codec` | JSON | You want zero dependencies (~2 KB gzipped) and a payload you can read in a log. |
|
|
1257
1651
|
|
|
1258
|
-
|
|
1652
|
+
Both expose the same shape: a `ClientSampleEncoder`, a `ClientSampleDecoder`, and a `createClientSampleCodec()` factory that returns a matched pair.
|
|
1653
|
+
|
|
1654
|
+
**Encoding on the client:**
|
|
1259
1655
|
|
|
1260
1656
|
```javascript
|
|
1261
|
-
import {
|
|
1657
|
+
import { ClientSampleEncoder } from "@observertc/samples-protobuf-codec";
|
|
1262
1658
|
|
|
1263
|
-
const encoder = new
|
|
1264
|
-
const sample = monitor.createSample();
|
|
1659
|
+
const encoder = new ClientSampleEncoder();
|
|
1265
1660
|
|
|
1266
|
-
|
|
1267
|
-
const
|
|
1661
|
+
monitor.on("sample-created", ({ sample }) => {
|
|
1662
|
+
const encoded = encoder.encode(sample);
|
|
1268
1663
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
},
|
|
1275
|
-
body: encodedSample,
|
|
1664
|
+
fetch("/api/samples", {
|
|
1665
|
+
method: "POST",
|
|
1666
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
1667
|
+
body: encoded,
|
|
1668
|
+
});
|
|
1276
1669
|
});
|
|
1277
1670
|
```
|
|
1278
1671
|
|
|
1279
|
-
|
|
1672
|
+
**Decoding on the server:**
|
|
1280
1673
|
|
|
1281
1674
|
```javascript
|
|
1282
|
-
import {
|
|
1675
|
+
import { ClientSampleDecoder } from "@observertc/samples-protobuf-codec";
|
|
1283
1676
|
|
|
1284
|
-
|
|
1677
|
+
// one decoder per client connection — see "Delta encoding is stateful" below
|
|
1678
|
+
const decoder = new ClientSampleDecoder();
|
|
1285
1679
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1680
|
+
const sample = decoder.decode(new Uint8Array(await request.arrayBuffer()));
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
**Delta encoding is stateful, and that has three consequences:**
|
|
1684
|
+
|
|
1685
|
+
1. **One encoder per client, one decoder per client.** Each holds the previous sample as its baseline. Sharing an encoder across clients, or decoding two clients' streams through one decoder, produces garbage rather than an error.
|
|
1686
|
+
2. **Samples must be decoded in the order they were encoded.** A dropped or reordered payload desynchronises the pair. Use `tryDecode()` where the transport can lose messages — it returns `undefined` instead of throwing, so you can drop the sample and wait for the next resync rather than tearing down the connection.
|
|
1687
|
+
3. **Call `reset()` on both sides when a client reconnects.** The encoder starts a fresh baseline; the decoder must be told to expect one.
|
|
1288
1688
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1689
|
+
**Transport-specific helpers.** Where the payload has to survive a text channel, each codec carries its own:
|
|
1690
|
+
|
|
1691
|
+
```javascript
|
|
1692
|
+
// protobuf: base64 for text transports, or the raw protobuf message
|
|
1693
|
+
encoder.encodeToBase64(sample); decoder.decodeBase64(text);
|
|
1694
|
+
encoder.encodeToMessage(sample); decoder.decodeFromMessage(message);
|
|
1291
1695
|
|
|
1292
|
-
//
|
|
1293
|
-
|
|
1696
|
+
// json: a plain JSON-serialisable delta
|
|
1697
|
+
encoder.encodeToJson(sample); decoder.decodeJson(json);
|
|
1698
|
+
decoder.tryDecodeJson(json);
|
|
1294
1699
|
```
|
|
1295
1700
|
|
|
1296
|
-
**
|
|
1701
|
+
**Errors** are `ProtobufCodecError` / `JsonCodecError`, each carrying a `code` and a `context` describing what failed — a schema mismatch and a desynchronised baseline report differently, which is what you want in a log.
|
|
1297
1702
|
|
|
1298
|
-
|
|
1299
|
-
- **Faster Transmission**: Smaller payloads improve upload/download times
|
|
1300
|
-
- **Storage Efficiency**: Compressed samples consume less storage space
|
|
1301
|
-
- **Schema Consistency**: Ensures proper serialization/deserialization of all ClientSample fields
|
|
1703
|
+
**Schema compatibility.** Both codecs export a `schemaVersion` describing the `ClientSample` shape they were built against. This monitor ships schema **3.6.0** (`ClientMonitor.samplingSchemaVersion`). Check the two agree before deploying — a codec built against an older schema silently drops fields the monitor now sends.
|
|
1302
1704
|
|
|
1303
1705
|
**Installation:**
|
|
1304
1706
|
|
|
1305
1707
|
```bash
|
|
1306
|
-
#
|
|
1307
|
-
npm install @observertc/samples-
|
|
1308
|
-
|
|
1309
|
-
# For decoding (server-side)
|
|
1310
|
-
npm install @observertc/samples-decoder
|
|
1311
|
-
|
|
1312
|
-
# Both packages (if needed)
|
|
1313
|
-
npm install @observertc/samples-encoder @observertc/samples-decoder
|
|
1708
|
+
# choose one
|
|
1709
|
+
npm install @observertc/samples-protobuf-codec
|
|
1710
|
+
npm install @observertc/samples-json-codec
|
|
1314
1711
|
```
|
|
1315
1712
|
|
|
1713
|
+
Both packages ship CommonJS and ESM builds with type definitions.
|
|
1714
|
+
|
|
1316
1715
|
**Integration with ObserveRTC Stack:**
|
|
1317
1716
|
These compression packages are part of the broader ObserveRTC ecosystem and are designed to work seamlessly with:
|
|
1318
1717
|
|
|
@@ -1385,7 +1784,7 @@ type ResolvedClientIssue<T = ClientIssuePayload> = RaisedClientIssue<T> & {
|
|
|
1385
1784
|
|
|
1386
1785
|
Narrow between the two by checking for `'key' in issue` — that's the discriminant.
|
|
1387
1786
|
|
|
1388
|
-
> **Wire format
|
|
1787
|
+
> **Wire format** (schema 3.5.0): `ClientSample.clientIssues[]` ships a stripped shape: `{ type, key?, payload?: Record<string, boolean | string | number>, timestamp }`. Payloads are flat records of primitives on the wire — never pre-serialised JSON strings — so nothing is stringified per issue or per event, and the server reads payload fields directly.
|
|
1389
1788
|
|
|
1390
1789
|
### Lifecycle: the events you can listen to
|
|
1391
1790
|
|
|
@@ -1436,7 +1835,7 @@ Most built-in detectors raise their own stateful issue with a typed payload, emi
|
|
|
1436
1835
|
| `dry-inbound-track` | Inbound bytes stay flat for `thresholdInMs` | Bytes start flowing again | `'dry-inbound-track'` | `DryInboundTrackIssuePayload` |
|
|
1437
1836
|
| `dry-outbound-track` | Outbound bytes stay flat for `thresholdInMs` | Bytes start flowing again | `'dry-outbound-track'` | `DryOutboundTrackIssuePayload` |
|
|
1438
1837
|
| `freezed-video-track` | `freezeCount` increases | No new freezes for one tick | `'freezed-video-track'` | `FreezedVideoTrackIssuePayload` |
|
|
1439
|
-
| `inbound-video-playout-discrepancy` | `framesReceived - framesRendered >
|
|
1838
|
+
| `inbound-video-playout-discrepancy` | `(framesReceived - framesRendered) / framesReceived > highSkewRatio` | Ratio drops below `lowSkewRatio` | `'inbound-video-playout-discrepancy'` | `PlayoutDiscrepancyIssuePayload` |
|
|
1440
1839
|
| `ice-disconnected` | An ICE transport stayed `disconnected` past `disconnectedThresholdInMs` | ICE reconnects, or the transport goes away | — | `IceDisconnectedIssuePayload` |
|
|
1441
1840
|
| `ice-connection-failed` | An ICE transport reached `failed` | ICE reconnects (typically after a restart) | — | `IceConnectionFailedIssuePayload` |
|
|
1442
1841
|
| `ice-transport-stalled` | Still sending on a succeeded pair of a connected transport, but receiving nothing for `transportStallThresholdInMs` | Inbound traffic resumes | — | `IceTransportStalledIssuePayload` |
|
|
@@ -1446,8 +1845,9 @@ Most built-in detectors raise their own stateful issue with a typed payload, emi
|
|
|
1446
1845
|
| `video-decoder-overloaded` | Frames arrived and loss was quiet, but decode time overran the frame budget or frames were dropped after arrival | The decoder keeps up again | `'video-decoder-overloaded'` | `DecoderPerformanceIssuePayload` |
|
|
1447
1846
|
| `keyframe-storm` | Sustained PLI rate above `pliRateAlertOn` | Rate falls below `pliRateAlertOff` | `'keyframe-storm'` | `KeyframeStormIssuePayload` |
|
|
1448
1847
|
| `video-recovery-failed` | PLIs sent, picture frozen, `keyFramesDecoded` not advancing for `recoveryFailedThresholdInMs` | A keyframe arrives or the freeze ends | `'video-recovery-failed'` | `VideoRecoveryFailedIssuePayload` |
|
|
1449
|
-
| `capture-bottleneck` |
|
|
1450
|
-
| `
|
|
1848
|
+
| `capture-bottleneck` | the capture device averaged under `captureFpsRatioThreshold` of the configured frame rate over `durationInMs` | the next average comes back at or above it | `'capture-bottleneck'` | `CaptureBottleneckIssuePayload` |
|
|
1849
|
+
| `decoder-bottleneck` | the decoder averaged under `decodeFpsRatioThreshold` of the frames that arrived over `durationInMs` | the next average comes back at or above it | `'decoder-bottleneck'` | `DecoderBottleneckIssuePayload` |
|
|
1850
|
+
| `encoder-bottleneck` | A delivering source outran the encoder for `durationInMs` continuously | The encoder keeps up again | `'encoder-bottleneck'` | `EncoderBottleneckIssuePayload` |
|
|
1451
1851
|
| `capture-track-ended` | The outbound track's device reached `ended` | — (terminal) | `'capture-track-ended'` | `CaptureTrackEndedIssuePayload` |
|
|
1452
1852
|
| `silent-audio-source` | A live, enabled, unmuted microphone produced silence for `silenceThresholdInMs` | Audio appears, or the track stops capturing | `'silent-audio-source'` | `SilentAudioSourceIssuePayload` |
|
|
1453
1853
|
| `stuck-decoder` | RTP bytes flowing, nothing decoding, PLIs firing, for `thresholdInMs` | Frames decode again | `'stuck-decoder'` | `StuckDecoderIssuePayload` |
|
|
@@ -1684,7 +2084,7 @@ new ClientMonitor({
|
|
|
1684
2084
|
});
|
|
1685
2085
|
```
|
|
1686
2086
|
|
|
1687
|
-
Already running and want to flip a detector on/off without restarting the monitor? Every built-in detector exposes a `public disabled = false` field, and every layer's `detectors` registry exposes ergonomic helpers for finding and toggling them.
|
|
2087
|
+
Already running and want to flip a detector on/off without restarting the monitor? Every built-in detector exposes a `public disabled = false` field, and every layer's `detectors` registry exposes ergonomic helpers for finding and toggling them. Issue-raising detectors additionally expose `public includeIssueInSample = true` — flip it to `false` to keep a detector running locally (events, `activeIssues`) while excluding its issues from the samples shipped to the server; see [Which issues belong in the sample](#which-issues-belong-in-the-sample).
|
|
1688
2088
|
|
|
1689
2089
|
`Detectors` (the registry attached as `monitor.detectors`, `peerConnectionMonitor.detectors`, `inboundTrackMonitor.detectors`, `outboundTrackMonitor.detectors`, `mediaPlayoutMonitor.detectors`) offers:
|
|
1690
2090
|
|
|
@@ -1740,7 +2140,16 @@ If you want a detector outright gone (not just silenced), call `detectors.remove
|
|
|
1740
2140
|
|
|
1741
2141
|
### Sample-channel behavior
|
|
1742
2142
|
|
|
1743
|
-
Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]
|
|
2143
|
+
Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]` — unless the issue was raised with `includeInSample: false` (what a detector's `includeIssueInSample = false` compiles down to), in which case neither the raise nor its resolution reaches the sample. **Re-raises do not add a new entry** — they emit `'issue-updated'` to live listeners but the sample buffer is unchanged.
|
|
2144
|
+
|
|
2145
|
+
**The issue lifecycle reaches the sample too** (`sendResolvedIssuesToServer`, default `true`). The purpose: the server keeps an on-the-fly mirror of each client's currently *active* issues and can correlate across clients or act immediately (recreate a consumer, recommend a rejoin) instead of only ever learning that issues started. On the wire, both entries of a stateful issue carry the schema-level `key` — the identity the server opens and closes on:
|
|
2146
|
+
|
|
2147
|
+
```
|
|
2148
|
+
raise: { type: 'stuck-decoder', key, payload, timestamp: raisedAt }
|
|
2149
|
+
resolution: { type: 'stuck-decoder-resolved', key, payload: { raisedAt, comment, ...resolution }, timestamp: resolvedAt }
|
|
2150
|
+
```
|
|
2151
|
+
|
|
2152
|
+
The resolution's payload carries only what was **explicitly passed** to `resolveIssue`, flattened — the built-in detectors pass their final payload, so fields like `durationInMs` appear here, while a bare resolve carries just `raisedAt` and `comment`. The raise-time payload is not repeated; the server already has it from the raise entry. `raisedAt` equals the raise entry's `timestamp` — a secondary join for consumers that do not store keys. Issues still active at `close()` are auto-resolved and reach the final sample. Servers switching on issue `type` should ignore or handle the `-resolved` suffix; one-shot `addIssue` entries have no lifecycle and no `key`. Pass `sendResolvedIssuesToServer: false` to restore the previous wire format exactly (raise entries only, no `key`); the realtime `'issue-resolved'` event is emitted either way.
|
|
1744
2153
|
|
|
1745
2154
|
### Event listeners cheat-sheet
|
|
1746
2155
|
|
|
@@ -1768,6 +2177,7 @@ monitor.on('keyframe-storm', (e) => { /* PLIs feeding the c
|
|
|
1768
2177
|
monitor.on('video-recovery-failed', (e) => { /* we asked for a keyframe; nothing came back */ });
|
|
1769
2178
|
monitor.on('stuck-decoder', (e) => { /* RTP flowing, nothing decodes — recreate the consumer */ });
|
|
1770
2179
|
monitor.on('capture-bottleneck', (e) => { /* the camera never produced the frames */ });
|
|
2180
|
+
monitor.on('decoder-bottleneck', (e) => { /* frames arrived; the decoder could not decode them */ });
|
|
1771
2181
|
monitor.on('encoder-bottleneck', (e) => { /* the source did; the encoder could not keep up */ });
|
|
1772
2182
|
monitor.on('capture-track-ended', (e) => { /* the device is gone */ });
|
|
1773
2183
|
monitor.on('capture-track-muted', (e) => { /* the OS or another app took it */ });
|
|
@@ -2034,25 +2444,66 @@ Stats adapters provide a powerful mechanism to customize how WebRTC statistics a
|
|
|
2034
2444
|
|
|
2035
2445
|
### Built-in Adapters
|
|
2036
2446
|
|
|
2037
|
-
The library
|
|
2447
|
+
Every engine deviates from the [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/) specification — legacy aliases, spec-removed members, missing dictionaries, renamed fields. The library ships one normalizing adapter per browser family, applied automatically based on the detected browser, so the monitors (and everything downstream — detectors, samples, your own code) always see stats as close to the standard shape as possible. Each fix feature-detects from the report itself rather than parsing browser versions, so an adapter applied to an already-conformant report is a no-op.
|
|
2038
2448
|
|
|
2039
|
-
|
|
2449
|
+
Adapters do exactly three things: **fold** a value into the standard field it provably belongs to (a renamed member, a legacy report carrying the same measurement), **infer references** — the `*Id` fields that wire one report to another — and **map** legacy enum spellings onto the values the monitors accept.
|
|
2040
2450
|
|
|
2041
|
-
|
|
2042
|
-
- **FirefoxTransportStatsAdapter**: Creates transport stats from ICE candidate pairs when native transport stats are missing
|
|
2451
|
+
Inferring a reference is safe where computing a measurement is not. A reference is a structural link, and the report graph either determines it or it doesn't; when it doesn't, the field is left unset rather than guessed. Measured values are never invented: a number a browser omits stays omitted, because an approximation is indistinguishable downstream from a measurement and a detector cannot tell that it is judging a guess.
|
|
2043
2452
|
|
|
2044
|
-
|
|
2453
|
+
Nothing is thrown away except a value that survives elsewhere — a member folded into its standard name, or a legacy report whose contents were relocated. Members the spec dropped but a browser still fills (a candidate pair's `priority`, Chromium's `contentType`, Firefox's `selected`) are left on the stat: the browser measured them, monitors copy through whatever they receive, and removing them would only destroy information.
|
|
2045
2454
|
|
|
2046
|
-
|
|
2455
|
+
#### ChromeStatsAdapter (Chrome, Edge, Opera)
|
|
2047
2456
|
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2457
|
+
Folds: `mediaType` → `kind` (the legacy alias, still emitted on every RTP report); `ip` → `address` on ICE candidate reports (Chromium emits both spellings with identical values); the deprecated `track`/`stream` reports and their `trackId` reference (Chrome ≤ M111) → the matching `inbound-rtp` fields.
|
|
2458
|
+
|
|
2459
|
+
Infers: `mediaSourceId`, `transportId`, the `remoteId`/`localId` cross-references and `codecId` when absent — normally a no-op on Chromium, kept as a safety net for older versions and for stats arriving through a relay that dropped them.
|
|
2460
|
+
|
|
2461
|
+
#### SafariStatsAdapter
|
|
2462
|
+
|
|
2463
|
+
Folds: the deprecated `track` reports (Safari ≤ 16.x) → `inbound-rtp` — most importantly `trackIdentifier`, absent on `inbound-rtp` before Safari 16.4, without which a stream cannot be bound to its `MediaStreamTrack` at all, plus freeze/pause counters, frame geometry and audio levels; `mediaType` → `kind`; `data-channel.datachannelid` → `dataChannelIdentifier` (Safari ≤ 17.6).
|
|
2464
|
+
|
|
2465
|
+
Maps: legacy `candidate-pair.state` spellings → the spec enum (`inprogress` → `in-progress`, `cancelled` → `failed`).
|
|
2466
|
+
|
|
2467
|
+
Infers: `codec.transportId`, spec-required but unfilled through Safari 17.3; `inbound-rtp.remoteId`, which WebKit dropped in Safari 16.4 through 16.6, severing an inbound stream from the sender's clock and RTCP round trip; plus `mediaSourceId` and `codecId` where older WebKit omits them.
|
|
2468
|
+
|
|
2469
|
+
#### FirefoxStatsAdapter
|
|
2470
|
+
|
|
2471
|
+
Folds: `mediaType` → `kind`; the non-standard `discardedPackets` alias → `packetsDiscarded`. Maps `candidate-pair.state: 'cancelled'` → `'failed'`. Brace-wrapped `{uuid}` track identifiers are intentionally left alone — Firefox wraps `MediaStreamTrack.id` the same way, so they match the application's track ids exactly as emitted.
|
|
2472
|
+
|
|
2473
|
+
Reconstructs the whole `transport` report, which Firefox ships none of before Firefox 153, from the `candidate-pair` marked `selected` — accumulating that pair's measured packet and byte counters, and carrying the totals across a selected-pair change rather than jumping back to the new pair's own counters, so ICE-level monitoring behaves the same across browsers. Every number comes from the pair the browser reported. A no-op as soon as a native transport report is present.
|
|
2474
|
+
|
|
2475
|
+
Reference inference matters most here, since Firefox omits the most: `outbound-rtp.mediaSourceId`, never emitted, and the link through which a sent stream reaches its source and its `MediaStreamTrack` — resolved by kind when a single source of that kind exists (so simulcast encodings all resolve to it), left unset when a camera and a screen share make it ambiguous. Also `transportId` on RTP, codec and ICE reports, absent before Firefox 153 — resolved to the sole transport, native or reconstructed — plus the `remoteId`/`localId` cross-references and `codecId`, which respects the `encode`/`decode` direction Firefox tags on codec entries.
|
|
2476
|
+
|
|
2477
|
+
This is the one stateful adapter, since the reconstructed transport accumulates across ticks: one instance per peer connection, and it should see every tick. Re-adapting the same tick is harmless — the accumulation is keyed on the collection timestamp.
|
|
2478
|
+
|
|
2479
|
+
#### Deviations the adapters do not correct
|
|
2480
|
+
|
|
2481
|
+
These fields are absent because the browser does not measure them, and nothing in the report can stand in without guessing:
|
|
2482
|
+
|
|
2483
|
+
- `inbound-rtp.framesRendered` — no engine emits it.
|
|
2484
|
+
- `remote-inbound-rtp.packetsReceived` — Chromium and WebKit never emit it.
|
|
2485
|
+
- Firefox: `qualityLimitationReason`/`Durations`, `totalPacketSendDelay`, `targetBitrate`, `media-source` audio levels, `media-playout` reports, `remote-outbound-rtp` round-trip time.
|
|
2486
|
+
- Safari: `media-playout` reports (so `playoutId` and audio-playout metrics are unavailable) and the `address` on host/peer-reflexive ICE candidates, which WebKit nulls.
|
|
2487
|
+
- Chromium: `candidate-pair.requestsSent` counts only STUN checks sent before the first response — every later check lands in `consentRequestsSent`, so the sum of the two is the real total. `inbound-rtp.packetsDiscarded` is audio-only.
|
|
2488
|
+
|
|
2489
|
+
#### Adding a version-scoped adapter
|
|
2490
|
+
|
|
2491
|
+
There is deliberately one adapter per browser family, not one per browser version. Every fix guards on the data — fold `mediaType` if it is there, fill `transportId` if it is missing, rebuild the `transport` report if none is present — which is why a single Firefox adapter covers Firefox 96 through 155 without knowing which it is talking to. Most spec deviations are of the form "this field only exists from version N", and a presence check handles those for free, with no version matrix to maintain across the boundaries each engine has (Firefox at 96, 104, 106, 135, 142, 153, 154; Safari at 16.4, 17.0, 17.4, 18.0).
|
|
2492
|
+
|
|
2493
|
+
A version gate is warranted only when the report cannot answer the question: the same field, present in every version, *meaning* something different in a range — a unit change, a counter switching from monotonic to per-interval, or a value that is actively wrong in known builds. None of the deviations handled today are of that kind. Prefer the data whenever it can answer, because a reported user-agent version is the less trustworthy signal: Edge, Opera and Brave lag Chromium and do not report its version, WebViews version themselves oddly, and UA reduction freezes minor versions, so a version gate can be wrong about the engine in a way a presence check cannot.
|
|
2494
|
+
|
|
2495
|
+
When one is genuinely needed, register it alongside the browser adapter rather than gating inside shared code — `Sources.addStatsAdapters` already has the browser name and version, and `StatsAdapters` composes adapters in registration order:
|
|
2496
|
+
|
|
2497
|
+
```typescript
|
|
2498
|
+
case "firefox": {
|
|
2499
|
+
pcMonitor.statsAdapters.add(new FirefoxStatsAdapter());
|
|
2500
|
+
if (majorVersion < 142) pcMonitor.statsAdapters.add(new FirefoxJitterUnitsAdapter());
|
|
2501
|
+
break;
|
|
2053
2502
|
}
|
|
2054
2503
|
```
|
|
2055
2504
|
|
|
2505
|
+
Name it after the deviation it corrects, not the version that introduced it. `FirefoxJitterUnitsAdapter` still says what it does after the next boundary moves; `Firefox94StatsAdapter` — this library's former adapter, which despite its name ran on every Firefox version — said nothing at all.
|
|
2506
|
+
|
|
2056
2507
|
### Custom Stats Adapters
|
|
2057
2508
|
|
|
2058
2509
|
Create custom adapters by implementing the `StatsAdapter` interface:
|
|
@@ -2739,8 +3190,9 @@ class MonitoringDashboard {
|
|
|
2739
3190
|
}
|
|
2740
3191
|
|
|
2741
3192
|
setupEventListeners() {
|
|
2742
|
-
this.monitor.on("score", ({ clientScore,
|
|
2743
|
-
|
|
3193
|
+
this.monitor.on("score", ({ clientScore, currentReasons }) => {
|
|
3194
|
+
// currentReasons is the AGGREGATE: every component's reasons summed
|
|
3195
|
+
this.updateScoreDisplay(clientScore, currentReasons);
|
|
2744
3196
|
});
|
|
2745
3197
|
|
|
2746
3198
|
this.monitor.on("congestion", ({ availableIncomingBitrate, availableOutgoingBitrate }) => {
|
|
@@ -2931,7 +3383,7 @@ interface ClientMonitorEvents {
|
|
|
2931
3383
|
durationOfCollectingStatsInMs: number;
|
|
2932
3384
|
collectedStats: [string, RTCStats[]][];
|
|
2933
3385
|
}) => void;
|
|
2934
|
-
score: (data: { clientScore: number;
|
|
3386
|
+
score: (data: { clientScore: number; currentReasons: Record<string, number> }) => void;
|
|
2935
3387
|
issue: (issue: ClientIssue) => void;
|
|
2936
3388
|
congestion: (data: CongestionEvent) => void;
|
|
2937
3389
|
close: () => void;
|
|
@@ -2957,7 +3409,7 @@ interface ClientMonitorEvents {
|
|
|
2957
3409
|
**A**:
|
|
2958
3410
|
|
|
2959
3411
|
1. Increase sampling period
|
|
2960
|
-
2. Use
|
|
3412
|
+
2. Use a delta codec (@observertc/samples-protobuf-codec or @observertc/samples-json-codec)
|
|
2961
3413
|
3. Filter samples before sending
|
|
2962
3414
|
4. Disable unnecessary detectors
|
|
2963
3415
|
|