@observertc/client-monitor-js 4.5.0 → 4.6.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 +362 -313
- package/dist/ClientMonitor.d.ts +1 -1
- package/dist/ClientMonitor.js +1 -1
- package/dist/ClientMonitorConfig.d.ts +1 -0
- 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/index.d.ts +5 -0
- package/dist/index.js +1 -1
- package/dist/schema/ClientSample.d.ts +2 -1
- package/dist/schema/ClientSample.js +1 -1
- package/dist/sources/Sources.js +1 -1
- package/package.json +1 -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/README.md
CHANGED
|
@@ -383,454 +383,453 @@ The `ClientMonitor` is the main class that orchestrates WebRTC monitoring, stati
|
|
|
383
383
|
|
|
384
384
|
## Detectors
|
|
385
385
|
|
|
386
|
-
Detectors
|
|
386
|
+
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
387
|
|
|
388
|
-
|
|
388
|
+
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
389
|
|
|
390
|
-
|
|
390
|
+
### Detector overview
|
|
391
391
|
|
|
392
|
-
|
|
392
|
+
| Detector | Watches | Reports | Good for |
|
|
393
|
+
|---|---|---|---|
|
|
394
|
+
| [`AudioConcealmentDetector`](#audioconcealmentdetector) | inbound audio | issue `audio-concealment` | How the audio actually *sounded* — catches degradation packet loss numbers miss |
|
|
395
|
+
| [`JitterBufferStressDetector`](#jitterbufferstressdetector) | inbound audio | issue `audio-jitter-buffer-stress` | The jitter buffer adding latency *and* stretching audio — delay the user hears |
|
|
396
|
+
| [`AudioDesyncDetector`](#audiodesyncdetector) | inbound audio | issue `audio-desync` | Playback drifting out of sync through heavy sample correction |
|
|
397
|
+
| [`SynthesizedSamplesDetector`](#synthesizedsamplesdetector) | audio playout | event `synthesized-audio` | The playout device injecting synthesized audio |
|
|
398
|
+
| [`FreezedVideoTrackDetector`](#freezedvideotrackdetector) | inbound video | issues `freezed-video-track`, `keyframe-storm`, `video-recovery-failed` | Frozen pictures and a repair loop that stopped working |
|
|
399
|
+
| [`DecoderPerformanceDetector`](#decoderperformancedetector) | inbound video | issue `video-decoder-overloaded` | Frames arrived but this device cannot decode them in time |
|
|
400
|
+
| [`StuckDecoderDetector`](#stuckdecoderdetector) | inbound video | issue `stuck-decoder` | RTP flowing, nothing decoding — the wedge only recreating the consumer fixes |
|
|
401
|
+
| [`PlayoutDiscrepancyDetector`](#playoutdiscrepancydetector) | inbound video | issue `inbound-video-playout-discrepancy` | Frames received but not rendered — a rendering pipeline backlog |
|
|
402
|
+
| [`DryInboundTrackDetector` / `DryOutboundTrackDetector`](#dryinboundtrackdetector--dryoutboundtrackdetector) | tracks | issues `dry-inbound-track`, `dry-outbound-track` | A track that should be flowing but carries no bytes at all |
|
|
403
|
+
| [`SourceEncoderBottleneckDetector`](#sourceencoderbottleneckdetector) | outbound video | issues `capture-bottleneck`, `encoder-bottleneck` | Whether the *camera* or the *encoder* is the reason you send fewer frames |
|
|
404
|
+
| [`CaptureFailureDetector`](#capturefailuredetector) | outbound tracks | issues `capture-track-ended`, `silent-audio-source` | Vanished devices and microphones producing pure silence |
|
|
405
|
+
| [`CongestionDetector`](#congestiondetector) | peer connection | issue `congestion` | Bandwidth-limited sending corroborated by RTT / loss |
|
|
406
|
+
| [`CpuPerformanceDetector`](#cpuperformancedetector) | whole client | issue `cpulimitation` | The device running out of CPU for encode/decode |
|
|
407
|
+
| [`LongPcConnectionEstablishmentDetector`](#longpcconnectionestablishmentdetector) | peer connection | event `too-long-pc-connection-establishment` | Connection setup taking suspiciously long |
|
|
408
|
+
| [`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 |
|
|
409
|
+
| [`IceTupleChangeDetector`](#icetuplechangedetector) | ICE transports | event `ice-tuple-changed` | The low-level signal that the selected network tuple changed |
|
|
410
|
+
| [`CodecChangeDetector`](#observation-detectors) | tracks | event `codec-changed` / `CODEC_CHANGED` | Which codec/profile is actually in use, and when it changed |
|
|
411
|
+
| [`VideoResolutionChangeDetector`](#observation-detectors) | video tracks | event `video-resolution-changed` / `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder, with the *reason* attached |
|
|
412
|
+
| [`SimulcastLayerDetector`](#observation-detectors) | outbound video | event `simulcast-layer-changed` / `SIMULCAST_LAYER_CHANGED` | Which simulcast layers are actually being sent |
|
|
413
|
+
| [`StatsGapDetector`](#observation-detectors) | the monitor itself | event `stats-collection-gap` / `STATS_COLLECTION_GAP` | Backgrounded-tab gaps that would otherwise read as network spikes |
|
|
414
|
+
|
|
415
|
+
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.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
### Audio detectors
|
|
393
420
|
|
|
394
|
-
|
|
421
|
+
#### AudioConcealmentDetector
|
|
395
422
|
|
|
396
|
-
|
|
397
|
-
- Indicates audio-video sync problems
|
|
423
|
+
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
424
|
|
|
399
|
-
**
|
|
425
|
+
**Use the result:** show a "poor audio from X" indicator on the affected participant's tile; the `burstiness` field tells you whether to describe it as choppiness (`bursty`) or dropouts (`continuous`). Server-side, the issue lifecycle gives you exact audible-degradation windows per participant.
|
|
400
426
|
|
|
401
427
|
```javascript
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
428
|
+
audioConcealmentDetector: {
|
|
429
|
+
onThreshold: 0.03, // audible concealment share that raises (Webex: >3% = significant)
|
|
430
|
+
offThreshold: 0.01, // share below which it resolves (hysteresis)
|
|
431
|
+
windowInMs: 15000, // sliding window; spans several ticks even at 5s collection
|
|
432
|
+
minSamplesInWindow: 24000, // don't judge on less than ~0.5s of 48kHz audio
|
|
405
433
|
}
|
|
406
434
|
```
|
|
407
435
|
|
|
408
|
-
|
|
436
|
+
```typescript
|
|
437
|
+
monitor.on('audio-concealment', ({ trackMonitor, concealmentRate, concealmentEventRate }) => {
|
|
438
|
+
// the user is HEARING this — mark the participant's tile
|
|
439
|
+
ui.setAudioQualityWarning(trackMonitor.track.id, { rate: concealmentRate });
|
|
440
|
+
});
|
|
441
|
+
monitor.on('issue-resolved', (issue) => {
|
|
442
|
+
if (issue.type === 'audio-concealment') ui.clearAudioQualityWarning(/* by key */);
|
|
443
|
+
});
|
|
444
|
+
```
|
|
409
445
|
|
|
410
|
-
|
|
446
|
+
**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
447
|
|
|
412
|
-
|
|
448
|
+
#### JitterBufferStressDetector
|
|
413
449
|
|
|
414
|
-
-
|
|
415
|
-
- Network congestion conditions
|
|
450
|
+
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
451
|
|
|
417
|
-
**
|
|
452
|
+
**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
453
|
|
|
419
454
|
```javascript
|
|
420
|
-
|
|
421
|
-
|
|
455
|
+
jitterBufferStressDetector: {
|
|
456
|
+
targetDelayThresholdInMs: 200, // >200ms added delay is noticeable degradation
|
|
457
|
+
timeStretchThreshold: 0.02, // share of samples stretched/compressed
|
|
458
|
+
minConsecutiveTicks: 2, // sustained, not a one-tick blip
|
|
422
459
|
}
|
|
423
460
|
```
|
|
424
461
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
462
|
+
```typescript
|
|
463
|
+
monitor.on('audio-jitter-buffer-stress', ({ trackMonitor, targetDelayInMs }) => {
|
|
464
|
+
log.info(`audio delayed ~${Math.round(targetDelayInMs)}ms by jitter buffering`, trackMonitor.track.id);
|
|
465
|
+
});
|
|
466
|
+
```
|
|
428
467
|
|
|
429
|
-
**
|
|
468
|
+
**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
469
|
|
|
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)
|
|
470
|
+
#### AudioDesyncDetector
|
|
434
471
|
|
|
435
|
-
|
|
472
|
+
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
473
|
|
|
437
|
-
**
|
|
474
|
+
**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
475
|
|
|
439
476
|
```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
|
-
},
|
|
477
|
+
audioDesyncDetector: {
|
|
478
|
+
fractionalCorrectionAlertOnThreshold: 0.1, // >10% of samples corrected raises
|
|
479
|
+
fractionalCorrectionAlertOffThreshold: 0.05, // <5% resolves
|
|
450
480
|
}
|
|
451
481
|
```
|
|
452
482
|
|
|
453
|
-
|
|
483
|
+
```typescript
|
|
484
|
+
monitor.on('audio-desync-track', ({ trackMonitor }) => {
|
|
485
|
+
diagnostics.flag('audio-desync', trackMonitor.track.id);
|
|
486
|
+
});
|
|
487
|
+
```
|
|
454
488
|
|
|
455
|
-
|
|
489
|
+
**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
490
|
|
|
457
|
-
|
|
491
|
+
#### SynthesizedSamplesDetector
|
|
458
492
|
|
|
459
|
-
-
|
|
460
|
-
- Track stalling or connection issues
|
|
493
|
+
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
494
|
|
|
462
|
-
**
|
|
495
|
+
**Use the result:** sustained synthesized playout with otherwise healthy inbound stats points at the *output* path — suggest the user switch audio output device.
|
|
463
496
|
|
|
464
497
|
```javascript
|
|
465
|
-
|
|
466
|
-
|
|
498
|
+
syntheticSamplesDetector: {
|
|
499
|
+
minSynthesizedSamplesDuration: 0, // ms of synthesized audio per interval before reporting
|
|
500
|
+
createEvent: true, // also buffer EXCESSIVE_SYNTHESIZED_AUDIO into samples
|
|
467
501
|
}
|
|
468
502
|
```
|
|
469
503
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
Detects outbound tracks that stop sending data.
|
|
473
|
-
|
|
474
|
-
**Triggers on:**
|
|
504
|
+
**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/)
|
|
475
505
|
|
|
476
|
-
|
|
477
|
-
- Local media issues or encoding problems
|
|
506
|
+
---
|
|
478
507
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
```javascript
|
|
482
|
-
dryOutboundTrackDetector: {
|
|
483
|
-
thresholdInMs: 5000,
|
|
484
|
-
}
|
|
485
|
-
```
|
|
508
|
+
### Video detectors
|
|
486
509
|
|
|
487
510
|
#### FreezedVideoTrackDetector
|
|
488
511
|
|
|
489
|
-
|
|
512
|
+
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:
|
|
490
513
|
|
|
491
|
-
|
|
514
|
+
- `freezed-video-track` — the picture is frozen (config: `videoFreezesDetector`).
|
|
515
|
+
- `keyframe-storm` — sustained PLI rate; self-reinforcing congestion, since keyframes are large (config: `videoRecoveryDetector`).
|
|
516
|
+
- `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`).
|
|
492
517
|
|
|
493
|
-
-
|
|
494
|
-
- Video freeze conditions
|
|
495
|
-
|
|
496
|
-
**Configuration:**
|
|
518
|
+
**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.
|
|
497
519
|
|
|
498
520
|
```javascript
|
|
499
|
-
videoFreezesDetector: {
|
|
521
|
+
videoFreezesDetector: {}, // freeze issue on/off ({} = defaults, null = off)
|
|
522
|
+
videoRecoveryDetector: {
|
|
523
|
+
windowInMs: 30000, // window for PLI/keyframe rates
|
|
524
|
+
pliRateAlertOn: 0.5, // real-world storms run ~0.5-0.7 PLI/s sustained
|
|
525
|
+
pliRateAlertOff: 0.15,
|
|
526
|
+
recoveryFailedThresholdInMs: 5000, // frozen + PLIs out + no keyframe for this long
|
|
527
|
+
recoveryFailedMinPliCount: 2, // proof we actually asked for repair
|
|
500
528
|
}
|
|
501
529
|
```
|
|
502
530
|
|
|
503
|
-
|
|
531
|
+
```typescript
|
|
532
|
+
monitor.on('freezed-video-track', ({ trackMonitor }) => ui.showFreezeOverlay(trackMonitor.track.id));
|
|
533
|
+
monitor.on('video-recovery-failed', ({ trackMonitor, pliCountSinceStalled }) => {
|
|
534
|
+
// we asked for a keyframe repeatedly and nothing came back — not a local problem
|
|
535
|
+
reportToServer('recovery-failed', trackMonitor.track.id, { pliCountSinceStalled });
|
|
536
|
+
});
|
|
537
|
+
```
|
|
504
538
|
|
|
505
|
-
|
|
539
|
+
**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/)
|
|
506
540
|
|
|
507
|
-
|
|
541
|
+
#### DecoderPerformanceDetector
|
|
508
542
|
|
|
509
|
-
|
|
510
|
-
- Video playout buffer issues
|
|
543
|
+
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.
|
|
511
544
|
|
|
512
|
-
**
|
|
545
|
+
**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.
|
|
513
546
|
|
|
514
547
|
```javascript
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
548
|
+
decoderPerformanceDetector: {
|
|
549
|
+
decodeTimeBudgetRatio: 0.8, // share of the per-frame budget (1000/fps) decode may use
|
|
550
|
+
dropRatioThreshold: 0.1, // frames dropped after arriving
|
|
551
|
+
minFramesReceived: 10, // don't judge starved intervals (e.g. static screen share)
|
|
552
|
+
quietLossThreshold: 0.02, // above this, the network is the better explanation
|
|
553
|
+
minConsecutiveTicks: 2,
|
|
518
554
|
}
|
|
519
555
|
```
|
|
520
556
|
|
|
521
|
-
|
|
557
|
+
```typescript
|
|
558
|
+
monitor.on('video-decoder-overloaded', ({ trackMonitor, decodeTimePerFrameInMs, frameBudgetInMs }) => {
|
|
559
|
+
// frames are arriving; this device can't keep up — lower the decode load
|
|
560
|
+
sfuClient.preferLayer(trackMonitor.track.id, 'low');
|
|
561
|
+
});
|
|
562
|
+
```
|
|
522
563
|
|
|
523
|
-
|
|
564
|
+
**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/)
|
|
524
565
|
|
|
525
|
-
|
|
566
|
+
#### StuckDecoderDetector
|
|
526
567
|
|
|
527
|
-
-
|
|
528
|
-
- Audio gaps requiring interpolation
|
|
568
|
+
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.
|
|
529
569
|
|
|
530
|
-
**
|
|
570
|
+
**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
571
|
|
|
532
572
|
```javascript
|
|
533
|
-
|
|
534
|
-
|
|
573
|
+
stuckDecoderDetector: {
|
|
574
|
+
thresholdInMs: 4000, // floor; effective wait = max(this, rttMultiplier × RTT)
|
|
575
|
+
rttMultiplier: 15, // high-RTT paths get more time to recover legitimately
|
|
576
|
+
minStuckTicks: 2, // never judge on fewer observations
|
|
577
|
+
minBitrate: 10000, // bps below which this is a dry track, not a wedge
|
|
578
|
+
minPliCount: 2, // the browser must be asking for repair
|
|
535
579
|
}
|
|
536
580
|
```
|
|
537
581
|
|
|
538
|
-
|
|
582
|
+
```typescript
|
|
583
|
+
monitor.on('stuck-decoder', async ({ trackMonitor, variant, deadBytesReceived }) => {
|
|
584
|
+
// the stream is delivered but nothing decodes — recreate the consumer
|
|
585
|
+
await sfuClient.recreateConsumerFor(trackMonitor.track.id);
|
|
586
|
+
reportToServer('stuck-decoder', { variant, deadBytesReceived });
|
|
587
|
+
});
|
|
588
|
+
```
|
|
539
589
|
|
|
540
|
-
|
|
590
|
+
**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
591
|
|
|
542
|
-
|
|
592
|
+
#### PlayoutDiscrepancyDetector
|
|
543
593
|
|
|
544
|
-
|
|
545
|
-
- Connection setup issues
|
|
594
|
+
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
595
|
|
|
547
|
-
**
|
|
596
|
+
**Use the result:** re-attach the media element or recreate the `<video>` sink; this is a local rendering problem, not a network one.
|
|
548
597
|
|
|
549
598
|
```javascript
|
|
550
|
-
|
|
551
|
-
|
|
599
|
+
playoutDiscrepancyDetector: {
|
|
600
|
+
lowSkewThreshold: 2, // frames of skew at which the issue resolves
|
|
601
|
+
highSkewThreshold: 5, // frames of skew at which it raises
|
|
552
602
|
}
|
|
553
603
|
```
|
|
554
604
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
**Raises:**
|
|
605
|
+
```typescript
|
|
606
|
+
monitor.on('inbound-video-playout-discrepancy', ({ trackMonitor }) => {
|
|
607
|
+
videoSinks.reattach(trackMonitor.track.id);
|
|
608
|
+
});
|
|
609
|
+
```
|
|
561
610
|
|
|
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`.
|
|
611
|
+
**Sources:** [W3C webrtc-stats](https://www.w3.org/TR/webrtc-stats/)
|
|
574
612
|
|
|
575
|
-
|
|
613
|
+
---
|
|
576
614
|
|
|
577
|
-
|
|
578
|
-
- `'ice-restart'` — a new ICE generation was inferred from a changed ICE
|
|
579
|
-
username fragment, with `outcome` of `'detected'`, `'recovered'` or
|
|
580
|
-
`'failed'`. A `connected → checking` transition alone is never treated as a
|
|
581
|
-
restart.
|
|
615
|
+
### Track activity
|
|
582
616
|
|
|
583
|
-
|
|
617
|
+
#### DryInboundTrackDetector / DryOutboundTrackDetector
|
|
584
618
|
|
|
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.
|
|
619
|
+
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.
|
|
589
620
|
|
|
590
|
-
|
|
591
|
-
self-heals), or when `disconnected`, an inbound stall, or an unfinished
|
|
592
|
-
establishment outlasts `iceRestartRecommendationThresholdInMs`. The `reason`
|
|
593
|
-
tells you which:
|
|
621
|
+
**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.
|
|
594
622
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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.
|
|
623
|
+
```javascript
|
|
624
|
+
dryInboundTrackDetector: { thresholdInMs: 5000 },
|
|
625
|
+
dryOutboundTrackDetector: { thresholdInMs: 5000 },
|
|
626
|
+
```
|
|
610
627
|
|
|
611
628
|
```typescript
|
|
612
|
-
monitor.on('
|
|
613
|
-
if (
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
myRtcPeerConnection.restartIce();
|
|
617
|
-
// or, with mediasoup: ask the server for new ICE parameters and
|
|
618
|
-
// transport.restartIce({ iceParameters })
|
|
619
|
-
console.warn(`ICE restart recommended (${reason})`, peerConnectionMonitor.peerConnectionId);
|
|
629
|
+
monitor.on('dry-inbound-track', async ({ trackMonitor }) => {
|
|
630
|
+
if (!(await sfuClient.isProducerPaused(trackMonitor.track.id))) {
|
|
631
|
+
await sfuClient.resubscribe(trackMonitor.track.id);
|
|
632
|
+
}
|
|
620
633
|
});
|
|
621
634
|
```
|
|
622
635
|
|
|
623
|
-
|
|
636
|
+
**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/)
|
|
624
637
|
|
|
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.
|
|
638
|
+
---
|
|
629
639
|
|
|
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.
|
|
640
|
+
### Send side
|
|
634
641
|
|
|
635
|
-
|
|
636
|
-
and a `burstiness` of `'bursty'` (many short clicks) or `'continuous'` (fewer,
|
|
637
|
-
longer dropouts) — they sound different and have different causes.
|
|
642
|
+
#### SourceEncoderBottleneckDetector
|
|
638
643
|
|
|
639
|
-
|
|
640
|
-
|
|
644
|
+
Splits "we are sending fewer frames than we should" into its two causes, indistinguishable from RTP alone: `capture-bottleneck` (the camera/OS never produced the frames) and `encoder-bottleneck` (the source is healthy; the encoder fell behind).
|
|
645
|
+
|
|
646
|
+
**Use the result:** capture-bottleneck → the fix is at the device (suggest lowering capture constraints, closing other camera apps; lighting can throttle cameras). Encoder-bottleneck → reduce encode load: drop the top simulcast layer, lower resolution/framerate, disable background effects. The payload carries `encoderImplementation`, `cpuLimitationShare` and the fps pair for the report.
|
|
641
647
|
|
|
642
648
|
```javascript
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
649
|
+
sourceEncoderBottleneckDetector: {
|
|
650
|
+
captureFpsRatioThreshold: 0.5, // source below 50% of configured fps = starving
|
|
651
|
+
minSourceFps: 5, // absolute floor when getSettings() has no frameRate
|
|
652
|
+
encodeFpsRatioThreshold: 0.7, // encoder below 70% of source fps = behind
|
|
653
|
+
encodeTimeBudgetRatio: 0.8, // encode time per frame vs the frame budget
|
|
654
|
+
cpuLimitationShareThreshold: 0.3, // share of interval explicitly CPU-limited
|
|
655
|
+
minConsecutiveTicks: 2,
|
|
648
656
|
}
|
|
649
657
|
```
|
|
650
658
|
|
|
651
|
-
|
|
659
|
+
```typescript
|
|
660
|
+
monitor.on('capture-bottleneck', ({ trackMonitor, sourceFps, expectedFps }) => {
|
|
661
|
+
ui.hintCameraTrouble(trackMonitor.track.id, { sourceFps, expectedFps });
|
|
662
|
+
});
|
|
663
|
+
monitor.on('encoder-bottleneck', () => sender.dropTopSimulcastLayer());
|
|
664
|
+
```
|
|
652
665
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
666
|
+
**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/)
|
|
667
|
+
|
|
668
|
+
#### CaptureFailureDetector
|
|
656
669
|
|
|
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.
|
|
670
|
+
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
671
|
|
|
662
|
-
**
|
|
672
|
+
**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
673
|
|
|
664
674
|
```javascript
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
675
|
+
captureFailureDetector: {
|
|
676
|
+
silenceThresholdInMs: 30000, // long on purpose: silence ≠ broken until it persists
|
|
677
|
+
silenceRmsThreshold: 0.001, // interval-integrated RMS, not the flickery audioLevel
|
|
678
|
+
createEvent: true, // also buffer CAPTURE_TRACK_ENDED / _MUTED into samples
|
|
669
679
|
}
|
|
670
680
|
```
|
|
671
681
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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.
|
|
682
|
+
```typescript
|
|
683
|
+
monitor.on('silent-audio-source', ({ trackMonitor, silentForInMs }) => {
|
|
684
|
+
ui.showBanner("We can't hear you — check your microphone", { switchDeviceAction: true });
|
|
685
|
+
});
|
|
686
|
+
monitor.on('capture-track-ended', () => ui.openDevicePicker('audioinput'));
|
|
687
|
+
```
|
|
681
688
|
|
|
682
|
-
|
|
683
|
-
at 15 fps), so a static screen share dropping to 1 fps does not trip it.
|
|
689
|
+
**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/)
|
|
684
690
|
|
|
685
|
-
|
|
686
|
-
`powerEfficientDecoder` — a software decoder on a codec the device can do in
|
|
687
|
-
hardware is the most actionable finding here.
|
|
691
|
+
---
|
|
688
692
|
|
|
689
|
-
|
|
690
|
-
decoderPerformanceDetector: {
|
|
691
|
-
decodeTimeBudgetRatio: 0.8,
|
|
692
|
-
dropRatioThreshold: 0.1,
|
|
693
|
-
minFramesReceived: 10,
|
|
694
|
-
quietLossThreshold: 0.02,
|
|
695
|
-
minConsecutiveTicks: 2,
|
|
696
|
-
}
|
|
697
|
-
```
|
|
693
|
+
### Connection & client health
|
|
698
694
|
|
|
699
|
-
####
|
|
695
|
+
#### CongestionDetector
|
|
700
696
|
|
|
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:
|
|
697
|
+
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.
|
|
705
698
|
|
|
706
|
-
|
|
707
|
-
self-reinforcing: keyframes are several times the size of delta frames, so a
|
|
708
|
-
burst of them worsens exactly the congestion that provoked the PLIs.
|
|
709
|
-
- `video-recovery-failed` — PLIs going out repeatedly, the picture still
|
|
710
|
-
frozen, and `keyFramesDecoded` *not* advancing. This is the valuable one for
|
|
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.
|
|
699
|
+
**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.
|
|
713
700
|
|
|
714
701
|
```javascript
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
pliRateAlertOn: 0.5,
|
|
718
|
-
pliRateAlertOff: 0.15,
|
|
719
|
-
recoveryFailedThresholdInMs: 5000,
|
|
720
|
-
recoveryFailedMinPliCount: 2,
|
|
702
|
+
congestionDetector: {
|
|
703
|
+
sensitivity: 'medium', // 'high': any bw-limitation | 'medium': + RTT rise | 'low': + >5% loss
|
|
721
704
|
}
|
|
722
705
|
```
|
|
723
706
|
|
|
724
|
-
|
|
707
|
+
```typescript
|
|
708
|
+
monitor.on('congestion', ({ availableOutgoingBitrate, maxSendingBitrate }) => {
|
|
709
|
+
sender.capBitrate(Math.min(availableOutgoingBitrate, maxSendingBitrate * 0.8));
|
|
710
|
+
ui.setNetworkIndicator('poor');
|
|
711
|
+
});
|
|
712
|
+
```
|
|
725
713
|
|
|
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.
|
|
714
|
+
**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
715
|
|
|
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.
|
|
716
|
+
#### CpuPerformanceDetector
|
|
737
717
|
|
|
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.
|
|
718
|
+
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.
|
|
744
719
|
|
|
745
|
-
**
|
|
746
|
-
reassembled; `'decode'`: frames assemble but never decode), the accumulated
|
|
747
|
-
dead bytes, and the PLI count since the wedge began.
|
|
720
|
+
**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.
|
|
748
721
|
|
|
749
|
-
|
|
750
|
-
|
|
722
|
+
```javascript
|
|
723
|
+
cpuPerformanceDetector: {
|
|
724
|
+
incomingDecodedFramesRatioThresholds: { alertOn: 0.7, alertOff: 0.85, minReceivedFrames: 10 },
|
|
725
|
+
durationOfCollectingStatsThreshold: { lowWatermark: 5000, highWatermark: 10000 },
|
|
726
|
+
encoderCpuLimitationShareThreshold: 0.3, // share of interval spent CPU-limited
|
|
727
|
+
encodeTimeBudgetRatio: 0.8, // encode ms per frame vs 1000/fps budget
|
|
728
|
+
}
|
|
729
|
+
```
|
|
751
730
|
|
|
752
731
|
```typescript
|
|
753
|
-
monitor.on('
|
|
754
|
-
|
|
755
|
-
|
|
732
|
+
monitor.on('cpulimitation', () => effects.disableBackgroundBlur());
|
|
733
|
+
monitor.on('issue-resolved', (issue) => {
|
|
734
|
+
if (issue.type === 'cpulimitation') effects.restore();
|
|
756
735
|
});
|
|
757
736
|
```
|
|
758
737
|
|
|
738
|
+
**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/)
|
|
739
|
+
|
|
740
|
+
#### LongPcConnectionEstablishmentDetector
|
|
741
|
+
|
|
742
|
+
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.
|
|
743
|
+
|
|
744
|
+
**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.
|
|
745
|
+
|
|
759
746
|
```javascript
|
|
760
|
-
|
|
761
|
-
thresholdInMs:
|
|
762
|
-
|
|
763
|
-
minStuckTicks: 2,
|
|
764
|
-
minBitrate: 10000,
|
|
765
|
-
minPliCount: 2,
|
|
747
|
+
longPcConnectionEstablishmentDetector: {
|
|
748
|
+
thresholdInMs: 5000,
|
|
749
|
+
createEvent: true,
|
|
766
750
|
}
|
|
767
751
|
```
|
|
768
752
|
|
|
769
|
-
|
|
753
|
+
**Sources:** [RTCPeerConnection.connectionState (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState) · [ICE (BlogGeek.me glossary)](https://bloggeek.me/webrtcglossary/ice/)
|
|
770
754
|
|
|
771
|
-
|
|
772
|
-
causes, which from RTP alone are indistinguishable:
|
|
755
|
+
#### IceConnectivityDetector
|
|
773
756
|
|
|
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`.
|
|
757
|
+
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).
|
|
780
758
|
|
|
781
|
-
|
|
782
|
-
active layer actually encoded.
|
|
759
|
+
**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`.
|
|
783
760
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
761
|
+
| `reason` | Meaning |
|
|
762
|
+
|---|---|
|
|
763
|
+
| `ice-failed` | ICE gave up on this generation — restart immediately. |
|
|
764
|
+
| `ice-disconnected` | `disconnected` outlasted the self-heal window. |
|
|
765
|
+
| `transport-stalled` | ICE says connected, but the selected path stopped delivering. |
|
|
766
|
+
| `never-established` | The connection never finished connecting (covers stuck DTLS). |
|
|
767
|
+
|
|
768
|
+
```javascript
|
|
769
|
+
iceConnectivityDetector: {
|
|
770
|
+
disconnectedThresholdInMs: 5000, // how long `disconnected` may self-heal
|
|
771
|
+
transportStallThresholdInMs: 5000, // sending-but-not-receiving tolerance
|
|
772
|
+
pathSwitchWindowInMs: 30000, // window for counting selected-path switches
|
|
773
|
+
pathSwitchThreshold: 3, // switches in the window => unstable path
|
|
774
|
+
iceRestartRecommendationThresholdInMs: 10000,
|
|
775
|
+
iceRestartRecommendationCooldownInMs: 15000,
|
|
776
|
+
createEvent: true, // buffer ICE_RESTART / _RECOMMENDED into samples
|
|
792
777
|
}
|
|
793
778
|
```
|
|
794
779
|
|
|
795
|
-
|
|
780
|
+
```typescript
|
|
781
|
+
monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
|
|
782
|
+
if (recommendationCount >= 3) return session.rejoin(); // restarts are not helping
|
|
783
|
+
rtcPeerConnection.restartIce(); // or mediasoup transport.restartIce()
|
|
784
|
+
});
|
|
785
|
+
monitor.on('ice-restart', ({ outcome }) => metrics.count(`ice-restart.${outcome}`));
|
|
786
|
+
```
|
|
796
787
|
|
|
797
|
-
|
|
798
|
-
user-visible failures originate and none of them show up in RTP.
|
|
788
|
+
**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)
|
|
799
789
|
|
|
800
|
-
|
|
801
|
-
(the microphone is live and producing nothing).
|
|
790
|
+
#### IceTupleChangeDetector
|
|
802
791
|
|
|
803
|
-
|
|
804
|
-
application took the device), plus the matching client events.
|
|
792
|
+
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.
|
|
805
793
|
|
|
806
|
-
|
|
807
|
-
microphone capturing digital silence and a person not talking are the same
|
|
808
|
-
measurement, and only duration separates them. The check requires the track to
|
|
809
|
-
be live, enabled and unmuted — a muted microphone is silent deliberately and is
|
|
810
|
-
reported as a mute, not a failure. The level comes from
|
|
811
|
-
`MediaSourceMonitor.rmsAudioLevel`, which integrates `totalAudioEnergy` over the
|
|
812
|
-
interval, rather than the instantaneous `audioLevel` that reads zero between
|
|
813
|
-
words.
|
|
794
|
+
**Use the result:** debugging and logging — a tuple change with no `ice-path-changed` classification usually means a port change on the same interface.
|
|
814
795
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
796
|
+
**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/)
|
|
797
|
+
|
|
798
|
+
---
|
|
799
|
+
|
|
800
|
+
### Observation detectors
|
|
801
|
+
|
|
802
|
+
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.
|
|
803
|
+
|
|
804
|
+
| Detector | Monitor event / client event | Use the result for |
|
|
805
|
+
|---|---|---|
|
|
806
|
+
| `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. |
|
|
807
|
+
| `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). |
|
|
808
|
+
| `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. |
|
|
809
|
+
| `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. |
|
|
810
|
+
|
|
811
|
+
```javascript
|
|
812
|
+
codecChangeDetector: { createEvent: true },
|
|
813
|
+
videoResolutionChangeDetector: { createEvent: true },
|
|
814
|
+
simulcastLayerDetector: { createEvent: true },
|
|
815
|
+
statsGapDetector: {
|
|
816
|
+
gapRatioThreshold: 2, // multiple of collectingPeriodInMs that counts as a gap
|
|
817
|
+
minGapInMs: 5000, // a single missed short tick is jitter, not a gap
|
|
819
818
|
createEvent: true,
|
|
820
|
-
}
|
|
819
|
+
},
|
|
821
820
|
```
|
|
822
821
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
822
|
+
```typescript
|
|
823
|
+
monitor.on('video-resolution-changed', ({ trackMonitor, direction, to, qualityLimitationReason }) => {
|
|
824
|
+
if (trackMonitor.direction === 'outbound' && direction === 'downgrade' && qualityLimitationReason === 'cpu') {
|
|
825
|
+
// the encoder is shrinking the picture because of CPU, not bandwidth
|
|
826
|
+
effects.disableBackgroundBlur();
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
monitor.on('stats-collection-gap', ({ gapInMs }) => metrics.markUnreliableWindow(gapInMs));
|
|
830
|
+
```
|
|
827
831
|
|
|
828
|
-
|
|
829
|
-
|---|---|---|---|
|
|
830
|
-
| `CodecChangeDetector` | `codec-changed` | `CODEC_CHANGED` | "Why do all the bad calls use H264?" Compares `sdpFmtpLine` too, so a profile switch within one mime type is caught. |
|
|
831
|
-
| `VideoResolutionChangeDetector` | `video-resolution-changed` | `VIDEO_RESOLUTION_CHANGED` | The adaptation ladder. On outbound tracks it carries `qualityLimitationReason`, which is what separates encoder adaptation from the application changing its constraints. Classified as `upgrade`, `downgrade` or `reshape` (an orientation flip). |
|
|
832
|
-
| `SimulcastLayerDetector` | `simulcast-layer-changed` | `SIMULCAST_LAYER_CHANGED` | Which layers are actually being sent. A layer counts as active only if it sent bytes — `active: true` with no bytes is the common shape of a layer the encoder quietly gave up on. |
|
|
833
|
-
| `StatsGapDetector` | `stats-collection-gap` | `STATS_COLLECTION_GAP` | Protects the monitor from itself: a backgrounded tab or sleeping device makes the next tick attribute a large accumulation to a short window. The gap is reported rather than corrected, because the counters cannot say when within it the traffic happened. |
|
|
832
|
+
**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
833
|
|
|
835
834
|
### Custom Detectors
|
|
836
835
|
|
|
@@ -1740,7 +1739,16 @@ If you want a detector outright gone (not just silenced), call `detectors.remove
|
|
|
1740
1739
|
|
|
1741
1740
|
### Sample-channel behavior
|
|
1742
1741
|
|
|
1743
|
-
Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]`. **Re-raises do not add a new entry** — they emit `'issue-updated'` to live listeners but the sample buffer is unchanged.
|
|
1742
|
+
Every `addIssue` and every `raiseIssue` adds an entry to the next `ClientSample.clientIssues[]`. **Re-raises do not add a new entry** — they emit `'issue-updated'` to live listeners but the sample buffer is unchanged.
|
|
1743
|
+
|
|
1744
|
+
**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:
|
|
1745
|
+
|
|
1746
|
+
```
|
|
1747
|
+
raise: { type: 'stuck-decoder', key, payload, timestamp: raisedAt }
|
|
1748
|
+
resolution: { type: 'stuck-decoder-resolved', key, payload: { raisedAt, comment, ...resolution }, timestamp: resolvedAt }
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
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
1752
|
|
|
1745
1753
|
### Event listeners cheat-sheet
|
|
1746
1754
|
|
|
@@ -2034,25 +2042,66 @@ Stats adapters provide a powerful mechanism to customize how WebRTC statistics a
|
|
|
2034
2042
|
|
|
2035
2043
|
### Built-in Adapters
|
|
2036
2044
|
|
|
2037
|
-
The library
|
|
2045
|
+
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
2046
|
|
|
2039
|
-
|
|
2047
|
+
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
2048
|
|
|
2041
|
-
|
|
2042
|
-
- **FirefoxTransportStatsAdapter**: Creates transport stats from ICE candidate pairs when native transport stats are missing
|
|
2049
|
+
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
2050
|
|
|
2044
|
-
|
|
2051
|
+
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
2052
|
|
|
2046
|
-
|
|
2053
|
+
#### ChromeStatsAdapter (Chrome, Edge, Opera)
|
|
2047
2054
|
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2055
|
+
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.
|
|
2056
|
+
|
|
2057
|
+
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.
|
|
2058
|
+
|
|
2059
|
+
#### SafariStatsAdapter
|
|
2060
|
+
|
|
2061
|
+
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).
|
|
2062
|
+
|
|
2063
|
+
Maps: legacy `candidate-pair.state` spellings → the spec enum (`inprogress` → `in-progress`, `cancelled` → `failed`).
|
|
2064
|
+
|
|
2065
|
+
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.
|
|
2066
|
+
|
|
2067
|
+
#### FirefoxStatsAdapter
|
|
2068
|
+
|
|
2069
|
+
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.
|
|
2070
|
+
|
|
2071
|
+
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.
|
|
2072
|
+
|
|
2073
|
+
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.
|
|
2074
|
+
|
|
2075
|
+
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.
|
|
2076
|
+
|
|
2077
|
+
#### Deviations the adapters do not correct
|
|
2078
|
+
|
|
2079
|
+
These fields are absent because the browser does not measure them, and nothing in the report can stand in without guessing:
|
|
2080
|
+
|
|
2081
|
+
- `inbound-rtp.framesRendered` — no engine emits it.
|
|
2082
|
+
- `remote-inbound-rtp.packetsReceived` — Chromium and WebKit never emit it.
|
|
2083
|
+
- Firefox: `qualityLimitationReason`/`Durations`, `totalPacketSendDelay`, `targetBitrate`, `media-source` audio levels, `media-playout` reports, `remote-outbound-rtp` round-trip time.
|
|
2084
|
+
- Safari: `media-playout` reports (so `playoutId` and audio-playout metrics are unavailable) and the `address` on host/peer-reflexive ICE candidates, which WebKit nulls.
|
|
2085
|
+
- 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.
|
|
2086
|
+
|
|
2087
|
+
#### Adding a version-scoped adapter
|
|
2088
|
+
|
|
2089
|
+
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).
|
|
2090
|
+
|
|
2091
|
+
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.
|
|
2092
|
+
|
|
2093
|
+
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:
|
|
2094
|
+
|
|
2095
|
+
```typescript
|
|
2096
|
+
case "firefox": {
|
|
2097
|
+
pcMonitor.statsAdapters.add(new FirefoxStatsAdapter());
|
|
2098
|
+
if (majorVersion < 142) pcMonitor.statsAdapters.add(new FirefoxJitterUnitsAdapter());
|
|
2099
|
+
break;
|
|
2053
2100
|
}
|
|
2054
2101
|
```
|
|
2055
2102
|
|
|
2103
|
+
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.
|
|
2104
|
+
|
|
2056
2105
|
### Custom Stats Adapters
|
|
2057
2106
|
|
|
2058
2107
|
Create custom adapters by implementing the `StatsAdapter` interface:
|
package/dist/ClientMonitor.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type ExtensionStatProvider = () => {
|
|
|
19
19
|
payload?: Record<string, unknown>;
|
|
20
20
|
}>;
|
|
21
21
|
export declare class ClientMonitor<AppData extends Record<string, unknown> = Record<string, unknown>> extends EventEmitter<ClientMonitorEvents> {
|
|
22
|
-
static readonly samplingSchemaVersion = "3.
|
|
22
|
+
static readonly samplingSchemaVersion = "3.3.0";
|
|
23
23
|
readonly mappedPeerConnections: Map<string, PeerConnectionMonitor>;
|
|
24
24
|
readonly detectors: Detectors;
|
|
25
25
|
readonly clientEventPayloadProvider: ClientEventPayloadProvider;
|
package/dist/ClientMonitor.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{schemaVersion as e}from"./schema/ClientSample";import{createLogger as t}from"./utils/logger";import i from"eventemitter3";import{ClientEventTypes as s}from"./schema/ClientEventTypes";import{Sources as o}from"./sources/Sources";import{Detectors as n}from"./detectors/Detectors";import{CpuPerformanceDetector as r}from"./detectors/CpuPerformanceDetector";import{StatsGapDetector as a}from"./detectors/StatsGapDetector";import{DefaultScoreCalculator as c}from"./scores/DefaultScoreCalculator";import{inferSourceType as l}from"./sources/inferSourceType";import{ClientEventPayloadProvider as d}from"./sources/ClientEventPayloadProvider";const h="ClientMonitor";export class ClientMonitor extends i{static samplingSchemaVersion=e;mappedPeerConnections=new Map;detectors;clientEventPayloadProvider=new d;extensionStatsProviders=new Set;activeIssues=new Map;scoreCalculator;logger;closed=!1;lastSampledAt=0;lastCollectingStatsAt=0;cpuPerformanceAlertOn=!1;sendingAudioBitrate=-1;sendingVideoBitrate=-1;receivingAudioBitrate=-1;receivingVideoBitrate=-1;totalAvailableIncomingBitrate=-1;totalAvailableOutgoingBitrate=-1;avgRttInSec=-1;score=5;scoreReasons;_browser;_sources;_timer;_samplingTick=0;_collectingCounter=0;_clientEvents=[];_clientMetaItems=[];_clientIssues=[];_extensionStats=[];durationOfCollectingStatsInMs=0;config;attachments;constructor(e){super();const i=e??{};this.logger=i.logger??t();const s=(e,t)=>void 0===e?t:e;this.config={...i,collectingPeriodInMs:i.collectingPeriodInMs??2e3,samplingPeriodInMs:i.samplingPeriodInMs??8e3,integrateNavigatorMediaDevices:i.integrateNavigatorMediaDevices??!0,addClientJointEventOnCreated:i.addClientJointEventOnCreated??!0,addClientLeftEventOnClose:i.addClientLeftEventOnClose??!0,videoFreezesDetector:s(i.videoFreezesDetector,{}),dryInboundTrackDetector:s(i.dryInboundTrackDetector,{thresholdInMs:5e3}),dryOutboundTrackDetector:s(i.dryOutboundTrackDetector,{thresholdInMs:5e3}),audioDesyncDetector:s(i.audioDesyncDetector,{fractionalCorrectionAlertOffThreshold:.05,fractionalCorrectionAlertOnThreshold:.1}),syntheticSamplesDetector:s(i.syntheticSamplesDetector,{minSynthesizedSamplesDuration:0}),congestionDetector:s(i.congestionDetector,{sensitivity:"medium"}),cpuPerformanceDetector:s(i.cpuPerformanceDetector,{incomingDecodedFramesRatioThresholds:{alertOn:.7,alertOff:.85,minReceivedFrames:10},durationOfCollectingStatsThreshold:{lowWatermark:5e3,highWatermark:1e4},encoderCpuLimitationShareThreshold:.3,encodeTimeBudgetRatio:.8}),audioConcealmentDetector:s(i.audioConcealmentDetector,{onThreshold:.03,offThreshold:.01,windowInMs:15e3,minSamplesInWindow:24e3}),jitterBufferStressDetector:s(i.jitterBufferStressDetector,{targetDelayThresholdInMs:200,timeStretchThreshold:.02,minConsecutiveTicks:2}),decoderPerformanceDetector:s(i.decoderPerformanceDetector,{decodeTimeBudgetRatio:.8,dropRatioThreshold:.1,minFramesReceived:10,quietLossThreshold:.02,minConsecutiveTicks:2}),videoRecoveryDetector:s(i.videoRecoveryDetector,{windowInMs:3e4,pliRateAlertOn:.5,pliRateAlertOff:.15,recoveryFailedThresholdInMs:5e3,recoveryFailedMinPliCount:2}),sourceEncoderBottleneckDetector:s(i.sourceEncoderBottleneckDetector,{captureFpsRatioThreshold:.5,minSourceFps:5,encodeFpsRatioThreshold:.7,encodeTimeBudgetRatio:.8,cpuLimitationShareThreshold:.3,minConsecutiveTicks:2}),simulcastLayerDetector:s(i.simulcastLayerDetector,{createEvent:!0}),captureFailureDetector:s(i.captureFailureDetector,{silenceThresholdInMs:3e4,silenceRmsThreshold:.001,createEvent:!0}),codecChangeDetector:s(i.codecChangeDetector,{createEvent:!0}),videoResolutionChangeDetector:s(i.videoResolutionChangeDetector,{createEvent:!0}),stuckDecoderDetector:s(i.stuckDecoderDetector,{thresholdInMs:4e3,rttMultiplier:15,minStuckTicks:2,minBitrate:1e4,minPliCount:2}),statsGapDetector:s(i.statsGapDetector,{gapRatioThreshold:2,minGapInMs:5e3,createEvent:!0}),playoutDiscrepancyDetector:s(i.playoutDiscrepancyDetector,{lowSkewThreshold:2,highSkewThreshold:5}),longPcConnectionEstablishmentDetector:s(i.longPcConnectionEstablishmentDetector,{thresholdInMs:5e3,createEvent:!0}),iceConnectivityDetector:s(i.iceConnectivityDetector,{disconnectedThresholdInMs:5e3,transportStallThresholdInMs:5e3,createEvent:!0,pathSwitchWindowInMs:3e4,pathSwitchThreshold:3,iceRestartRecommendationThresholdInMs:1e4,iceRestartRecommendationCooldownInMs:15e3}),bufferingEventsForSamples:i.bufferingEventsForSamples??!1,appData:i.appData??{}},this._sources=new o(this,this.logger),this.scoreCalculator=new c(this),this.setCollectingPeriod(this.config.collectingPeriodInMs),this.config.samplingPeriodInMs&&this.setSamplingPeriod(this.config.samplingPeriodInMs),!0===this.config.addClientJointEventOnCreated&&this.addClientJoinEvent(),this.config.integrateNavigatorMediaDevices&&this._sources.watchNavigatorMediaDevices();try{this._sources.fetchUserAgentData()}catch(e){this.logger.error(`[${h}]:`,"Failed to fetch user agent data",e)}this.detectors=new n,null!==this.config.cpuPerformanceDetector&&this.detectors.add(new r(this)),null!==this.config.statsGapDetector&&this.detectors.add(new a(this))}get clientId(){return this.config.clientId}set clientId(e){this.config.clientId=e}get callId(){return this.config.callId}set callId(e){this.config.callId=e}get appData(){return this.config.appData}set appData(e){this.config.appData=e}set browser(e){if(!this.closed&&e){this._browser&&this.logger.warn(`[${h}]:`,"Browser info is already set on ClientMonitor, overwriting it"),this._browser=e;for(const e of this.peerConnections)this._sources.addStatsAdapters(e)}}set onsamplecreated(e){this.once("close",(()=>this.off("sample-created",e))),this.on("sample-created",e)}set onstatscollected(e){this.once("close",(()=>this.off("stats-collected",e))),this.on("stats-collected",e)}set onclientevent(e){this.once("close",(()=>this.off("client-event",e))),this.on("client-event",e)}set onissue(e){this.once("close",(()=>this.off("issue",e))),this.on("issue",e)}get browser(){return this._browser}close(){if(!this.closed){clearInterval(this._timer),this._timer=void 0;for(const e of[...this.activeIssues.keys()])this.resolveIssue(e,{comment:"monitor closed before issue could be resolved"});this.config.addClientLeftEventOnClose&&this.addClientLeftEvent({}),0<this._samplingTick&&this.createSample(),this.closed=!0,this.emit("close")}}on(e,t){return super.on(e,t),this}once(e,t){return super.once(e,t),this}off(e,t){return super.off(e,t),this}emit(e,...t){return super.emit(e,...t)}async collect(){if(this.closed)return this.logger.warn(`[${h}]:`,"ClientMonitor is closed, cannot collect stats"),[];this.lastCollectingStatsAt=Date.now();const e=[];if(await Promise.all([...this.peerConnections.map((async t=>{try{const i=await t.collect();e.push([t.peerConnectionId,i])}catch(e){this.logger.error(`[${h}]:`,`Failed to get stats from peer connection ${t.peerConnectionId}`,e)}})),...[...this.extensionStatsProviders.values()].map((async e=>{try{const t=await e();this.addExtensionStats(t)}catch(e){this.logger.error(`[${h}]:`,"Failed to get extension stats",e)}}))]),this.sendingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingAudioBitrate??0)),0),this.sendingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingVideoBitrate??0)),0),this.receivingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingAudioBitrate??0)),0),this.receivingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingVideoBitrate??0)),0),this.totalAvailableIncomingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableIncomingBitrate??0)),0),this.totalAvailableOutgoingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableOutgoingBitrate??0)),0),this.avgRttInSec=0<this.peerConnections.length?this.peerConnections.reduce(((e,t)=>e+(t.avgRttInSec??0)),0)/this.peerConnections.length:-1,this.durationOfCollectingStatsInMs=Date.now()-this.lastCollectingStatsAt,this.tracks.forEach((e=>e.update())),this.detectors.update(),this.scoreCalculator.update(),this.emit("stats-collected",{clientMonitor:this,startedAt:this.lastCollectingStatsAt,collectedStats:e,durationOfCollectingStatsInMs:this.durationOfCollectingStatsInMs}),0<this._samplingTick){++this._collectingCounter%this._samplingTick===0&&this.createSample()}return e}getPeerConnectionMonitor(e){return this.mappedPeerConnections.get(e)}setScore(e,t){this.closed||(this.score=e,this.scoreReasons=t,this.emit("score",{clientMonitor:this,clientScore:e,currentReasons:t??{}}))}createSample(){if(this.closed)return;const e={clientId:this.clientId,timestamp:Date.now(),callId:this.callId,attachments:this.attachments,peerConnections:this.peerConnections.map((e=>e.createSample())),clientEvents:this._clientEvents,clientMetaItems:this._clientMetaItems,clientIssues:this._clientIssues,extensionStats:this._extensionStats,score:this.score};this._clientEvents=[],this._clientMetaItems=[],this._clientIssues=[],this._extensionStats=[];const t=Date.now();return e?(this.emit("sample-created",{clientMonitor:this,sample:e}),this.lastSampledAt=t,e):void 0}addPeerConnectionMonitor(e){if(!this.closed){if(this.mappedPeerConnections.has(e.peerConnectionId))return this.logger.warn(`[${h}]:`,`PeerConnectionMonitor with id ${e.peerConnectionId} already exists`);e.once("close",(()=>{this.mappedPeerConnections.delete(e.peerConnectionId)})),this.mappedPeerConnections.set(e.peerConnectionId,e),this.emit("new-peerconnnection-monitor",{peerConnectionMonitor:e,clientMonitor:this})}}addClientJoinEvent(e){this.closed||this.addEvent({type:s.CLIENT_JOINED,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addClientLeftEvent(e){this.closed||this.addEvent({type:s.CLIENT_LEFT,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addEvent(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now(),i=e.payload?JSON.stringify(e.payload):void 0;this._clientEvents.push({...e,payload:i,timestamp:t}),this.emit("client-event",{...e,payload:e.payload,timestamp:t})}addIssue(e){if(this.closed)return;const t=e.timestamp??Date.now(),i={type:e.type,payload:e.payload,timestamp:t};return this._bufferIssueForSample(i.type,i.payload,t),this.emit("issue",i),i}raiseIssue(e,t){if(this.closed)return;const i=t.timestamp??Date.now(),s=this.activeIssues.get(e);if(s)return s.type=t.type,s.payload=t.payload,s.updatedAt=i,this.emit("issue-updated",s),s;const o={type:t.type,key:e,payload:t.payload,raisedAt:i,updatedAt:i};return this.activeIssues.set(o.key,o),this._bufferIssueForSample(o.type,o.payload,i),this.emit("issue",o),o}resolveIssue(e,t){if(this.closed)return;const i=this.activeIssues.get(e);if(!i)return;this.activeIssues.delete(e),t.payload&&(i.payload=t.payload);const s={...i,resolvedAt:t.resolvedAt??Date.now(),comment:t.comment};return this.emit("issue-resolved",s),s}getActiveIssuesByType(e){const t=[];for(const i of this.activeIssues.values())void 0!==e&&i.type!==e||t.push(i);return t}isIssueActive(e){return this.activeIssues.has(e)}_bufferIssueForSample(e,t,i){(this._samplingTick||this.config.bufferingEventsForSamples)&&this._clientIssues.push({type:e,payload:void 0===t?void 0:JSON.stringify(t),timestamp:i})}addMetaData(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now();this._clientMetaItems.push({type:e.type,payload:e.payload?JSON.stringify(e.payload):void 0,timestamp:t}),this.emit("meta",{...e,payload:e.payload,timestamp:t})}addExtensionStats(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.payload?JSON.stringify(e.payload):void 0;this._extensionStats.push({type:e.type,payload:t}),this.emit("extension-stats",{...e,payload:e.payload})}addSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot add source to closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.addRTCPeerConnection({peerConnection:e});break;case"mediasoup-device":this._sources.addMediasoupDevice(e);break;case"mediasoup-transport":this._sources.addMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e)}}removeSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot remove source from closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.removeRTCPeerConnection(e);break;case"mediasoup-device":this._sources.removeMediasoupDevice(e);break;case"mediasoup-transport":this._sources.removeMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e)}}fetchUserAgentData(){return this._sources.fetchUserAgentData()}watchNavigatorMediaDevices(){this._sources.watchNavigatorMediaDevices()}get peerConnections(){return[...this.mappedPeerConnections.values()]}get codecs(){return[...this.peerConnections.flatMap((e=>e.codecs))]}get inboundRtps(){return[...this.peerConnections.flatMap((e=>e.inboundRtps))]}get outboundRtps(){return[...this.peerConnections.flatMap((e=>e.outboundRtps))]}get remoteInboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteInboundRtps))]}get remoteOutboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteOutboundRtps))]}get mediaSources(){return[...this.peerConnections.flatMap((e=>e.mediaSources))]}get mediaPlayouts(){return[...this.peerConnections.flatMap((e=>e.mediaPlayouts))]}get dataChannels(){return[...this.peerConnections.flatMap((e=>e.dataChannels))]}get iceCandidatePairs(){return[...this.peerConnections.flatMap((e=>e.iceCandidatePairs))]}get iceCandidates(){return[...this.peerConnections.flatMap((e=>e.iceCandidates))]}get iceTransports(){return[...this.peerConnections.flatMap((e=>e.iceTransports))]}get certificates(){return[...this.peerConnections.flatMap((e=>e.certificates))]}get tracks(){return[...this.peerConnections.flatMap((e=>e.tracks))]}getTrackMonitor(e){return this.getInboundTrackMonitor(e)??this.getOutboundTrackMonitor(e)}getInboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedInboundTracks.has(e)))?.mappedInboundTracks.get(e)}getOutboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedOutboundTracks.has(e)))?.mappedOutboundTracks.get(e)}setCollectingPeriod(e){this._timer&&clearInterval(this._timer),this._timer=void 0,this.config.collectingPeriodInMs=e;try{if(!this.config.collectingPeriodInMs)return;this._timer=setInterval((()=>{this.collect().catch((e=>this.logger.error(`[${h}]:`,e)))}),this.config.collectingPeriodInMs)}finally{this._setSamplingTick()}}setSamplingPeriod(e){this.config.samplingPeriodInMs=e,this._setSamplingTick()}_setSamplingTick(){void 0!==this.config.collectingPeriodInMs&&void 0!==this.config.samplingPeriodInMs?this.config.collectingPeriodInMs<1||this.config.samplingPeriodInMs<1?this._samplingTick=0:(this.config.samplingPeriodInMs%this.config.collectingPeriodInMs!==0&&this.logger.warn(`[${h}]:`,`The samplingPeriodInMs (${this.config.samplingPeriodInMs}) should be a multiple of collectingPeriodInMs (${this.config.collectingPeriodInMs}), otherwise the sampling will not be accurate`),this._samplingTick=Math.max(1,Math.floor(this.config.samplingPeriodInMs/this.config.collectingPeriodInMs))):this._samplingTick=0}}
|
|
1
|
+
import{schemaVersion as e}from"./schema/ClientSample";import{createLogger as t}from"./utils/logger";import i from"eventemitter3";import{ClientEventTypes as s}from"./schema/ClientEventTypes";import{Sources as o}from"./sources/Sources";import{Detectors as n}from"./detectors/Detectors";import{CpuPerformanceDetector as r}from"./detectors/CpuPerformanceDetector";import{StatsGapDetector as a}from"./detectors/StatsGapDetector";import{DefaultScoreCalculator as c}from"./scores/DefaultScoreCalculator";import{inferSourceType as l}from"./sources/inferSourceType";import{ClientEventPayloadProvider as d}from"./sources/ClientEventPayloadProvider";const h="ClientMonitor";export class ClientMonitor extends i{static samplingSchemaVersion=e;mappedPeerConnections=new Map;detectors;clientEventPayloadProvider=new d;extensionStatsProviders=new Set;activeIssues=new Map;scoreCalculator;logger;closed=!1;lastSampledAt=0;lastCollectingStatsAt=0;cpuPerformanceAlertOn=!1;sendingAudioBitrate=-1;sendingVideoBitrate=-1;receivingAudioBitrate=-1;receivingVideoBitrate=-1;totalAvailableIncomingBitrate=-1;totalAvailableOutgoingBitrate=-1;avgRttInSec=-1;score=5;scoreReasons;_browser;_sources;_timer;_samplingTick=0;_collectingCounter=0;_clientEvents=[];_clientMetaItems=[];_clientIssues=[];_extensionStats=[];durationOfCollectingStatsInMs=0;config;attachments;constructor(e){super();const i=e??{};this.logger=i.logger??t();const s=(e,t)=>void 0===e?t:e;this.config={...i,collectingPeriodInMs:i.collectingPeriodInMs??2e3,samplingPeriodInMs:i.samplingPeriodInMs??8e3,integrateNavigatorMediaDevices:i.integrateNavigatorMediaDevices??!0,addClientJointEventOnCreated:i.addClientJointEventOnCreated??!0,addClientLeftEventOnClose:i.addClientLeftEventOnClose??!0,videoFreezesDetector:s(i.videoFreezesDetector,{}),dryInboundTrackDetector:s(i.dryInboundTrackDetector,{thresholdInMs:5e3}),dryOutboundTrackDetector:s(i.dryOutboundTrackDetector,{thresholdInMs:5e3}),audioDesyncDetector:s(i.audioDesyncDetector,{fractionalCorrectionAlertOffThreshold:.05,fractionalCorrectionAlertOnThreshold:.1}),syntheticSamplesDetector:s(i.syntheticSamplesDetector,{minSynthesizedSamplesDuration:0}),congestionDetector:s(i.congestionDetector,{sensitivity:"medium"}),cpuPerformanceDetector:s(i.cpuPerformanceDetector,{incomingDecodedFramesRatioThresholds:{alertOn:.7,alertOff:.85,minReceivedFrames:10},durationOfCollectingStatsThreshold:{lowWatermark:5e3,highWatermark:1e4},encoderCpuLimitationShareThreshold:.3,encodeTimeBudgetRatio:.8}),audioConcealmentDetector:s(i.audioConcealmentDetector,{onThreshold:.03,offThreshold:.01,windowInMs:15e3,minSamplesInWindow:24e3}),jitterBufferStressDetector:s(i.jitterBufferStressDetector,{targetDelayThresholdInMs:200,timeStretchThreshold:.02,minConsecutiveTicks:2}),decoderPerformanceDetector:s(i.decoderPerformanceDetector,{decodeTimeBudgetRatio:.8,dropRatioThreshold:.1,minFramesReceived:10,quietLossThreshold:.02,minConsecutiveTicks:2}),videoRecoveryDetector:s(i.videoRecoveryDetector,{windowInMs:3e4,pliRateAlertOn:.5,pliRateAlertOff:.15,recoveryFailedThresholdInMs:5e3,recoveryFailedMinPliCount:2}),sourceEncoderBottleneckDetector:s(i.sourceEncoderBottleneckDetector,{captureFpsRatioThreshold:.5,minSourceFps:5,encodeFpsRatioThreshold:.7,encodeTimeBudgetRatio:.8,cpuLimitationShareThreshold:.3,minConsecutiveTicks:2}),simulcastLayerDetector:s(i.simulcastLayerDetector,{createEvent:!0}),captureFailureDetector:s(i.captureFailureDetector,{silenceThresholdInMs:3e4,silenceRmsThreshold:.001,createEvent:!0}),codecChangeDetector:s(i.codecChangeDetector,{createEvent:!0}),videoResolutionChangeDetector:s(i.videoResolutionChangeDetector,{createEvent:!0}),stuckDecoderDetector:s(i.stuckDecoderDetector,{thresholdInMs:4e3,rttMultiplier:15,minStuckTicks:2,minBitrate:1e4,minPliCount:2}),statsGapDetector:s(i.statsGapDetector,{gapRatioThreshold:2,minGapInMs:5e3,createEvent:!0}),playoutDiscrepancyDetector:s(i.playoutDiscrepancyDetector,{lowSkewThreshold:2,highSkewThreshold:5}),longPcConnectionEstablishmentDetector:s(i.longPcConnectionEstablishmentDetector,{thresholdInMs:5e3,createEvent:!0}),iceConnectivityDetector:s(i.iceConnectivityDetector,{disconnectedThresholdInMs:5e3,transportStallThresholdInMs:5e3,createEvent:!0,pathSwitchWindowInMs:3e4,pathSwitchThreshold:3,iceRestartRecommendationThresholdInMs:1e4,iceRestartRecommendationCooldownInMs:15e3}),bufferingEventsForSamples:i.bufferingEventsForSamples??!1,sendResolvedIssuesToServer:i.sendResolvedIssuesToServer??!0,appData:i.appData??{}},this._sources=new o(this,this.logger),this.scoreCalculator=new c(this),this.setCollectingPeriod(this.config.collectingPeriodInMs),this.config.samplingPeriodInMs&&this.setSamplingPeriod(this.config.samplingPeriodInMs),!0===this.config.addClientJointEventOnCreated&&this.addClientJoinEvent(),this.config.integrateNavigatorMediaDevices&&this._sources.watchNavigatorMediaDevices();try{this._sources.fetchUserAgentData()}catch(e){this.logger.error(`[${h}]:`,"Failed to fetch user agent data",e)}this.detectors=new n,null!==this.config.cpuPerformanceDetector&&this.detectors.add(new r(this)),null!==this.config.statsGapDetector&&this.detectors.add(new a(this))}get clientId(){return this.config.clientId}set clientId(e){this.config.clientId=e}get callId(){return this.config.callId}set callId(e){this.config.callId=e}get appData(){return this.config.appData}set appData(e){this.config.appData=e}set browser(e){if(!this.closed&&e){this._browser&&this.logger.warn(`[${h}]:`,"Browser info is already set on ClientMonitor, overwriting it"),this._browser=e;for(const e of this.peerConnections)this._sources.addStatsAdapters(e)}}set onsamplecreated(e){this.once("close",(()=>this.off("sample-created",e))),this.on("sample-created",e)}set onstatscollected(e){this.once("close",(()=>this.off("stats-collected",e))),this.on("stats-collected",e)}set onclientevent(e){this.once("close",(()=>this.off("client-event",e))),this.on("client-event",e)}set onissue(e){this.once("close",(()=>this.off("issue",e))),this.on("issue",e)}get browser(){return this._browser}close(){if(!this.closed){clearInterval(this._timer),this._timer=void 0;for(const e of[...this.activeIssues.keys()])this.resolveIssue(e,{comment:"monitor closed before issue could be resolved"});this.config.addClientLeftEventOnClose&&this.addClientLeftEvent({}),0<this._samplingTick&&this.createSample(),this.closed=!0,this.emit("close")}}on(e,t){return super.on(e,t),this}once(e,t){return super.once(e,t),this}off(e,t){return super.off(e,t),this}emit(e,...t){return super.emit(e,...t)}async collect(){if(this.closed)return this.logger.warn(`[${h}]:`,"ClientMonitor is closed, cannot collect stats"),[];this.lastCollectingStatsAt=Date.now();const e=[];if(await Promise.all([...this.peerConnections.map((async t=>{try{const i=await t.collect();e.push([t.peerConnectionId,i])}catch(e){this.logger.error(`[${h}]:`,`Failed to get stats from peer connection ${t.peerConnectionId}`,e)}})),...[...this.extensionStatsProviders.values()].map((async e=>{try{const t=await e();this.addExtensionStats(t)}catch(e){this.logger.error(`[${h}]:`,"Failed to get extension stats",e)}}))]),this.sendingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingAudioBitrate??0)),0),this.sendingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.sendingVideoBitrate??0)),0),this.receivingAudioBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingAudioBitrate??0)),0),this.receivingVideoBitrate=this.peerConnections.reduce(((e,t)=>e+(t.receivingVideoBitrate??0)),0),this.totalAvailableIncomingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableIncomingBitrate??0)),0),this.totalAvailableOutgoingBitrate=this.peerConnections.reduce(((e,t)=>e+(t.totalAvailableOutgoingBitrate??0)),0),this.avgRttInSec=0<this.peerConnections.length?this.peerConnections.reduce(((e,t)=>e+(t.avgRttInSec??0)),0)/this.peerConnections.length:-1,this.durationOfCollectingStatsInMs=Date.now()-this.lastCollectingStatsAt,this.tracks.forEach((e=>e.update())),this.detectors.update(),this.scoreCalculator.update(),this.emit("stats-collected",{clientMonitor:this,startedAt:this.lastCollectingStatsAt,collectedStats:e,durationOfCollectingStatsInMs:this.durationOfCollectingStatsInMs}),0<this._samplingTick){++this._collectingCounter%this._samplingTick===0&&this.createSample()}return e}getPeerConnectionMonitor(e){return this.mappedPeerConnections.get(e)}setScore(e,t){this.closed||(this.score=e,this.scoreReasons=t,this.emit("score",{clientMonitor:this,clientScore:e,currentReasons:t??{}}))}createSample(){if(this.closed)return;const e={clientId:this.clientId,timestamp:Date.now(),callId:this.callId,attachments:this.attachments,peerConnections:this.peerConnections.map((e=>e.createSample())),clientEvents:this._clientEvents,clientMetaItems:this._clientMetaItems,clientIssues:this._clientIssues,extensionStats:this._extensionStats,score:this.score};this._clientEvents=[],this._clientMetaItems=[],this._clientIssues=[],this._extensionStats=[];const t=Date.now();return e?(this.emit("sample-created",{clientMonitor:this,sample:e}),this.lastSampledAt=t,e):void 0}addPeerConnectionMonitor(e){if(!this.closed){if(this.mappedPeerConnections.has(e.peerConnectionId))return this.logger.warn(`[${h}]:`,`PeerConnectionMonitor with id ${e.peerConnectionId} already exists`);e.once("close",(()=>{this.mappedPeerConnections.delete(e.peerConnectionId)})),this.mappedPeerConnections.set(e.peerConnectionId,e),this.emit("new-peerconnnection-monitor",{peerConnectionMonitor:e,clientMonitor:this})}}addClientJoinEvent(e){this.closed||this.addEvent({type:s.CLIENT_JOINED,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addClientLeftEvent(e){this.closed||this.addEvent({type:s.CLIENT_LEFT,payload:{...e?.payload},timestamp:e?.timestamp??Date.now()})}addEvent(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now(),i=e.payload?JSON.stringify(e.payload):void 0;this._clientEvents.push({...e,payload:i,timestamp:t}),this.emit("client-event",{...e,payload:e.payload,timestamp:t})}addIssue(e){if(this.closed)return;const t=e.timestamp??Date.now(),i={type:e.type,payload:e.payload,timestamp:t};return this._bufferIssueForSample(i.type,i.payload,t),this.emit("issue",i),i}raiseIssue(e,t){if(this.closed)return;const i=t.timestamp??Date.now(),s=this.activeIssues.get(e);if(s)return s.type=t.type,s.payload=t.payload,s.updatedAt=i,this.emit("issue-updated",s),s;const o={type:t.type,key:e,payload:t.payload,raisedAt:i,updatedAt:i};return this.activeIssues.set(o.key,o),this._bufferIssueForSample(o.type,o.payload,i,this.config.sendResolvedIssuesToServer?o.key:void 0),this.emit("issue",o),o}resolveIssue(e,t){if(this.closed)return;const i=this.activeIssues.get(e);if(!i)return;this.activeIssues.delete(e),t.payload&&(i.payload=t.payload);const s={...i,resolvedAt:t.resolvedAt??Date.now(),comment:t.comment};if(this.emit("issue-resolved",s),this.config.sendResolvedIssuesToServer){const e="object"==typeof t.payload&&null!==t.payload?t.payload:{};this._bufferIssueForSample(`${i.type}-resolved`,{raisedAt:i.raisedAt,comment:t.comment,...e},s.resolvedAt,i.key)}return s}getActiveIssuesByType(e){const t=[];for(const i of this.activeIssues.values())void 0!==e&&i.type!==e||t.push(i);return t}isIssueActive(e){return this.activeIssues.has(e)}_bufferIssueForSample(e,t,i,s){(this._samplingTick||this.config.bufferingEventsForSamples)&&this._clientIssues.push({type:e,key:s,payload:void 0===t?void 0:JSON.stringify(t),timestamp:i})}addMetaData(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.timestamp??Date.now();this._clientMetaItems.push({type:e.type,payload:e.payload?JSON.stringify(e.payload):void 0,timestamp:t}),this.emit("meta",{...e,payload:e.payload,timestamp:t})}addExtensionStats(e){if(this.closed)return;if(!this._samplingTick&&!this.config.bufferingEventsForSamples)return;const t=e.payload?JSON.stringify(e.payload):void 0;this._extensionStats.push({type:e.type,payload:t}),this.emit("extension-stats",{...e,payload:e.payload})}addSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot add source to closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.addRTCPeerConnection({peerConnection:e});break;case"mediasoup-device":this._sources.addMediasoupDevice(e);break;case"mediasoup-transport":this._sources.addMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot add source to ClientMonitor, because it is not a valid source",e)}}removeSource(e,t){if(this.closed)return this.logger.warn(`[${h}]:`,"Cannot remove source from closed ClientMonitor");if(!t&&!(t=l(e)))return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e);switch(t){case"RTCPeerConnection":this._sources.removeRTCPeerConnection(e);break;case"mediasoup-device":this._sources.removeMediasoupDevice(e);break;case"mediasoup-transport":this._sources.removeMediasoupTransport(e);break;default:return this.logger.warn(`[${h}]:`,"Cannot remove source from ClientMonitor, because it is not a valid source",e)}}fetchUserAgentData(){return this._sources.fetchUserAgentData()}watchNavigatorMediaDevices(){this._sources.watchNavigatorMediaDevices()}get peerConnections(){return[...this.mappedPeerConnections.values()]}get codecs(){return[...this.peerConnections.flatMap((e=>e.codecs))]}get inboundRtps(){return[...this.peerConnections.flatMap((e=>e.inboundRtps))]}get outboundRtps(){return[...this.peerConnections.flatMap((e=>e.outboundRtps))]}get remoteInboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteInboundRtps))]}get remoteOutboundRtps(){return[...this.peerConnections.flatMap((e=>e.remoteOutboundRtps))]}get mediaSources(){return[...this.peerConnections.flatMap((e=>e.mediaSources))]}get mediaPlayouts(){return[...this.peerConnections.flatMap((e=>e.mediaPlayouts))]}get dataChannels(){return[...this.peerConnections.flatMap((e=>e.dataChannels))]}get iceCandidatePairs(){return[...this.peerConnections.flatMap((e=>e.iceCandidatePairs))]}get iceCandidates(){return[...this.peerConnections.flatMap((e=>e.iceCandidates))]}get iceTransports(){return[...this.peerConnections.flatMap((e=>e.iceTransports))]}get certificates(){return[...this.peerConnections.flatMap((e=>e.certificates))]}get tracks(){return[...this.peerConnections.flatMap((e=>e.tracks))]}getTrackMonitor(e){return this.getInboundTrackMonitor(e)??this.getOutboundTrackMonitor(e)}getInboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedInboundTracks.has(e)))?.mappedInboundTracks.get(e)}getOutboundTrackMonitor(e){return this.peerConnections.find((t=>t.mappedOutboundTracks.has(e)))?.mappedOutboundTracks.get(e)}setCollectingPeriod(e){this._timer&&clearInterval(this._timer),this._timer=void 0,this.config.collectingPeriodInMs=e;try{if(!this.config.collectingPeriodInMs)return;this._timer=setInterval((()=>{this.collect().catch((e=>this.logger.error(`[${h}]:`,e)))}),this.config.collectingPeriodInMs)}finally{this._setSamplingTick()}}setSamplingPeriod(e){this.config.samplingPeriodInMs=e,this._setSamplingTick()}_setSamplingTick(){void 0!==this.config.collectingPeriodInMs&&void 0!==this.config.samplingPeriodInMs?this.config.collectingPeriodInMs<1||this.config.samplingPeriodInMs<1?this._samplingTick=0:(this.config.samplingPeriodInMs%this.config.collectingPeriodInMs!==0&&this.logger.warn(`[${h}]:`,`The samplingPeriodInMs (${this.config.samplingPeriodInMs}) should be a multiple of collectingPeriodInMs (${this.config.collectingPeriodInMs}), otherwise the sampling will not be accurate`),this._samplingTick=Math.max(1,Math.floor(this.config.samplingPeriodInMs/this.config.collectingPeriodInMs))):this._samplingTick=0}}
|
|
@@ -115,6 +115,7 @@ export type AppliedClientMonitorConfig<AppData extends Record<string, unknown> =
|
|
|
115
115
|
iceRestartRecommendationThresholdInMs: number;
|
|
116
116
|
iceRestartRecommendationCooldownInMs: number;
|
|
117
117
|
} | null;
|
|
118
|
+
sendResolvedIssuesToServer?: boolean;
|
|
118
119
|
appData: AppData;
|
|
119
120
|
};
|
|
120
121
|
export type ClientMonitorConfig<AppData extends Record<string, unknown> = Record<string, unknown>> = Partial<AppliedClientMonitorConfig<AppData>> & {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as t from"../schema/W3cStatsIdentifiers";import{foldField as e,foldLegacyTrackStats as a,foldMediaTypeIntoKind as o,inferCodecId as r,inferOutboundMediaSourceId as s,inferRtpCrossReferences as p,inferTransportId as d,isRtpStreamStats as n}from"./adapterTools";export class ChromeStatsAdapter{name="chromeStatsAdapter";adapt(i){const c=a(i);for(const a of c){if(!a||"string"!=typeof a.type)continue;const r=a;n(a)?o(r):a.type!==t.StatsType.localCandidate&&a.type!==t.StatsType.remoteCandidate||e(r,"ip","address")}return s(c),d(c),p(c),r(c),c}}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { RtcStats } from "../schema/W3cStatsIdentifiers";
|
|
2
|
+
import { StatsAdapter } from "./StatsAdapter";
|
|
3
|
+
export declare class FirefoxStatsAdapter implements StatsAdapter {
|
|
4
|
+
readonly name = "firefoxStatsAdapter";
|
|
5
|
+
private readonly _transportStats;
|
|
6
|
+
private _selectedCandidatePair?;
|
|
7
|
+
private _accumulatedAt?;
|
|
8
|
+
adapt(stats: RtcStats[]): RtcStats[];
|
|
9
|
+
private _addReconstructedTransport;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as t from"../schema/W3cStatsIdentifiers";import{foldField as e,foldMediaTypeIntoKind as a,inferCodecId as s,inferOutboundMediaSourceId as i,inferRtpCrossReferences as d,inferTransportId as r,isRtpStreamStats as n}from"./adapterTools";export class FirefoxStatsAdapter{name="firefoxStatsAdapter";_transportStats={type:"transport",timestamp:0,id:"",packetsSent:0,packetsReceived:0,bytesSent:0,bytesReceived:0,selectedCandidatePairId:void 0,selectedCandidatePairChanges:-1};_selectedCandidatePair;_accumulatedAt;adapt(c){for(const s of c){if(!s||"string"!=typeof s.type)continue;const i=s;n(s)?(a(i),s.type===t.StatsType.inboundRtp&&e(i,"discardedPackets","packetsDiscarded")):s.type===t.StatsType.candidatePair&&"cancelled"===i.state&&(i.state="failed")}return this._addReconstructedTransport(c),i(c),r(c),d(c),s(c),c}_addReconstructedTransport(e){let a;for(const s of e){if(s.type===t.StatsType.transport)return;if(s.type!==t.StatsType.candidatePair)continue;const e=s;e.selected&&(a=e)}if(!a)return;if(!(this._accumulatedAt===a.timestamp&&this._selectedCandidatePair?.id===a.id)){if(this._selectedCandidatePair?.id!==a.id)this._transportStats.selectedCandidatePairChanges=(this._transportStats.selectedCandidatePairChanges??0)+1,this._transportStats.selectedCandidatePairId=a.id,this._transportStats.id=a.transportId??"transport_0";else{const t=(a.packetsReceived??0)-(this._selectedCandidatePair.packetsReceived??0),e=(a.packetsSent??0)-(this._selectedCandidatePair.packetsSent??0),s=(a.bytesReceived??0)-(this._selectedCandidatePair.bytesReceived??0),i=(a.bytesSent??0)-(this._selectedCandidatePair.bytesSent??0);0<s&&(this._transportStats.bytesReceived=(this._transportStats.bytesReceived??0)+s),0<i&&(this._transportStats.bytesSent=(this._transportStats.bytesSent??0)+i),0<t&&(this._transportStats.packetsReceived=(this._transportStats.packetsReceived??0)+t),0<e&&(this._transportStats.packetsSent=(this._transportStats.packetsSent??0)+e)}this._selectedCandidatePair=a,this._accumulatedAt=a.timestamp}this._transportStats.timestamp=a.timestamp,e.push(this._transportStats)}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as t from"../schema/W3cStatsIdentifiers";import{foldField as a,foldLegacyTrackStats as e,foldMediaTypeIntoKind as s,inferCodecId as r,inferOutboundMediaSourceId as n,inferRtpCrossReferences as i,inferTransportId as o,isRtpStreamStats as c}from"./adapterTools";export class SafariStatsAdapter{name="safariStatsAdapter";adapt(d){const p=e(d);for(const e of p){if(!e||"string"!=typeof e.type)continue;const r=e;if(c(e))s(r);else switch(e.type){case t.StatsType.dataChannel:a(r,"datachannelid","dataChannelIdentifier");break;case t.StatsType.candidatePair:"inprogress"===r.state?r.state="in-progress":"cancelled"===r.state&&(r.state="failed")}}return n(p),o(p),i(p),r(p),p}}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { RtcStats } from "../schema/W3cStatsIdentifiers";
|
|
2
|
+
export declare function foldField(stat: any, legacyField: string, standardField: string): void;
|
|
3
|
+
export declare function foldMediaTypeIntoKind(stat: any): void;
|
|
4
|
+
export declare function isRtpStreamStats(stat: RtcStats): boolean;
|
|
5
|
+
export declare function foldLegacyTrackStats(stats: RtcStats[]): RtcStats[];
|
|
6
|
+
export declare function inferOutboundMediaSourceId(stats: RtcStats[]): void;
|
|
7
|
+
export declare function inferTransportId(stats: RtcStats[]): void;
|
|
8
|
+
export declare function inferRtpCrossReferences(stats: RtcStats[]): void;
|
|
9
|
+
export declare function inferCodecId(stats: RtcStats[]): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import*as t from"../schema/W3cStatsIdentifiers";export function foldField(t,e,o){void 0!==t[e]&&(void 0===t[o]&&(t[o]=t[e]),delete t[e])}export function foldMediaTypeIntoKind(t){foldField(t,"mediaType","kind")}const e=[t.StatsType.inboundRtp,t.StatsType.outboundRtp,t.StatsType.remoteInboundRtp,t.StatsType.remoteOutboundRtp];export function isRtpStreamStats(t){return e.includes(t.type)}const o=["trackIdentifier","framesReceived","framesDropped","frameWidth","frameHeight","freezeCount","totalFreezesDuration","pauseCount","totalPausesDuration","audioLevel","totalAudioEnergy","totalSamplesDuration","jitterBufferDelay","jitterBufferEmittedCount"];export function foldLegacyTrackStats(e){let n;for(const o of e)o.type===t.StatsType.track&&(n??=new Map).set(o.id,o);if(!n)return e.some((e=>e.type===t.StatsType.stream))?e.filter((e=>e.type!==t.StatsType.stream)):e;for(const i of e){const e=i;if(void 0===e.trackId)continue;if(i.type!==t.StatsType.inboundRtp&&i.type!==t.StatsType.outboundRtp)continue;const d=n.get(e.trackId);if(d){if(i.type===t.StatsType.inboundRtp)for(const t of o)void 0===e[t]&&void 0!==d[t]&&(e[t]=d[t]);void 0===e.trackIdentifier&&void 0!==d.trackIdentifier&&(e.trackIdentifier=d.trackIdentifier)}delete e.trackId}return e.filter((e=>e.type!==t.StatsType.track&&e.type!==t.StatsType.stream))}export function inferOutboundMediaSourceId(e){let o;for(const n of e){if(n.type!==t.StatsType.outboundRtp)continue;const i=n;if(void 0!==i.mediaSourceId)continue;let d;if(o??=e.filter((e=>e.type===t.StatsType.mediaSource)),void 0!==i.trackIdentifier&&(d=o.find((t=>t.trackIdentifier===i.trackIdentifier))),!d){const t=o.filter((t=>t.kind===i.kind));1===t.length&&(d=t[0])}d&&(i.mediaSourceId=d.id)}}const n=[t.StatsType.inboundRtp,t.StatsType.outboundRtp,t.StatsType.remoteInboundRtp,t.StatsType.remoteOutboundRtp,t.StatsType.codec,t.StatsType.candidatePair,t.StatsType.localCandidate,t.StatsType.remoteCandidate];export function inferTransportId(e){let o;for(const n of e)if(n.type===t.StatsType.transport){if(void 0!==o)return;o=n.id}if(void 0!==o)for(const t of e){const e=t;void 0===e.transportId&&(n.includes(t.type)&&(e.transportId=o))}}const i=[{from:t.StatsType.outboundRtp,to:t.StatsType.remoteInboundRtp,field:"remoteId"},{from:t.StatsType.remoteInboundRtp,to:t.StatsType.outboundRtp,field:"localId"},{from:t.StatsType.inboundRtp,to:t.StatsType.remoteOutboundRtp,field:"remoteId"},{from:t.StatsType.remoteOutboundRtp,to:t.StatsType.inboundRtp,field:"localId"}];export function inferRtpCrossReferences(t){const e=new Map;for(const o of t){if(!isRtpStreamStats(o))continue;const t=o.ssrc;if(void 0===t)continue;const n=`${o.type}:${t}`,i=e.get(n);i?i.push(o):e.set(n,[o])}for(const o of t){const t=o;if(void 0!==t.ssrc)for(const{from:n,to:d,field:r}of i){if(o.type!==n||void 0!==t[r])continue;const i=e.get(`${d}:${t.ssrc}`),p=1===i?.length?i[0]:void 0;p&&(t[r]=p.id)}}}export function inferCodecId(e){let o;for(const n of e){if(!isRtpStreamStats(n))continue;const i=n;if(void 0!==i.codecId||void 0===i.kind)continue;o??=e.filter((e=>e.type===t.StatsType.codec));let d=o.filter((t=>"string"==typeof t.mimeType&&t.mimeType.toLowerCase().startsWith(`${i.kind}/`)));if(void 0!==i.transportId){const t=d.filter((t=>void 0===t.transportId||t.transportId===i.transportId));t.length>0&&(d=t)}if(d.some((t=>void 0!==t.codecType))){const e=n.type===t.StatsType.outboundRtp||n.type===t.StatsType.remoteInboundRtp?"encode":"decode",o=d.filter((t=>void 0===t.codecType||t.codecType===e));o.length>0&&(d=o)}1===d.length&&(i.codecId=d[0].id)}}
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,11 @@ export { ClientMonitor, } from "./ClientMonitor";
|
|
|
19
19
|
export type { ExtensionStatProvider } from "./ClientMonitor";
|
|
20
20
|
export type { ClientMonitorConfig } from "./ClientMonitorConfig";
|
|
21
21
|
export type { ClientEvent, ClientIssue, ClientIssuePayload, AddedClientIssue, RaisedClientIssue, ResolvedClientIssue, UpdatedClientIssue, ClientMetaData, SampleCreatedEventPayload, StatsCollectedEventPayload, SynthesizedAudioEventPayload, InboundVideoPlayoutDiscrepancyEventPayload, CongestionEventPayload, AudioDesyncTrackEventPayload, FreezedVideoTrackEventPayload, DryInboundTrackEventPayload, DryOutboundTrackEventPayload, IceTupleChangedEventPayload, IcePathChangedEventPayload, NewSelectedIcePathEventPayload, IceRestartEventPayload, IceRestartRecommendedEventPayload, TooLongPcConnectionEstablishmentEventPayload, AudioConcealmentEventPayload, AudioJitterBufferStressEventPayload, VideoDecoderOverloadedEventPayload, KeyframeStormEventPayload, VideoRecoveryFailedEventPayload, CaptureBottleneckEventPayload, EncoderBottleneckEventPayload, CaptureTrackEndedEventPayload, CaptureTrackMutedEventPayload, SilentAudioSourceEventPayload, SimulcastLayerChangedEventPayload, CodecChangedEventPayload, VideoResolutionChangedEventPayload, StatsCollectionGapEventPayload, StuckDecoderEventPayload, ScoreEventPayload, ClientMonitorEvents, NewCertificateMonitorEventPayload, NewCodecMonitorEventPayload, NewDataChannelMonitorEventPayload, NewIceCandidateMonitorPayload, NewIceCandidatePairMonitorEventPayload, NewIceTransportMonitorEventPayload, NewInboundRtpMonitorEventPayload, NewInboundTrackMonitorEventPayload, NewMediaPlayoutMonitorEventPayload, NewMediaSourceMonitorEventPayload, NewOutboundRtpMonitorEventPayload, NewOutboundTrackMonitorEventPayload, NewPeerConnectionTransportMonitorEventPayload, NewRemoteInboundRtpMonitorEventPayload, NewRemoteOutboundRtpMonitorEventPayload, NewPeerConnectionMonitorEventPayload, } from "./ClientMonitorEvents";
|
|
22
|
+
export type { StatsAdapter } from './adapters/StatsAdapter';
|
|
23
|
+
export { StatsAdapters } from './adapters/StatsAdapters';
|
|
24
|
+
export { ChromeStatsAdapter } from './adapters/ChromeStatsAdapter';
|
|
25
|
+
export { SafariStatsAdapter } from './adapters/SafariStatsAdapter';
|
|
26
|
+
export { FirefoxStatsAdapter } from './adapters/FirefoxStatsAdapter';
|
|
22
27
|
export type { Detector, } from './detectors/Detector';
|
|
23
28
|
export { Detectors } from './detectors/Detectors';
|
|
24
29
|
export { isClientMonitorIssue, isClientMonitorResolvedIssue, } from './ClientMonitorIssues';
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export{CertificateMonitor}from"./monitors/CertificateMonitor";export{CodecMonitor}from"./monitors/CodecMonitor";export{DataChannelMonitor}from"./monitors/DataChannelMonitor";export{IceCandidateMonitor}from"./monitors/IceCandidateMonitor";export{IceCandidatePairMonitor}from"./monitors/IceCandidatePairMonitor";export{IceTransportMonitor}from"./monitors/IceTransportMonitor";export{InboundRtpMonitor}from"./monitors/InboundRtpMonitor";export{InboundTrackMonitor}from"./monitors/InboundTrackMonitor";export{MediaPlayoutMonitor}from"./monitors/MediaPlayoutMonitor";export{MediaSourceMonitor}from"./monitors/MediaSourceMonitor";export{OutboundRtpMonitor}from"./monitors/OutboundRtpMonitor";export{OutboundTrackMonitor}from"./monitors/OutboundTrackMonitor";export{PeerConnectionMonitor}from"./monitors/PeerConnectionMonitor";export{PeerConnectionTransportMonitor}from"./monitors/PeerConnectionTransportMonitor";export{RemoteInboundRtpMonitor}from"./monitors/RemoteInboundRtpMonitor";export{RemoteOutboundRtpMonitor}from"./monitors/RemoteOutboundRtpMonitor";export{ClientMonitor}from"./ClientMonitor";export{Detectors}from"./detectors/Detectors";export{isClientMonitorIssue,isClientMonitorResolvedIssue}from"./ClientMonitorIssues";export{AudioConcealmentDetector}from"./detectors/AudioConcealmentDetector";export{JitterBufferStressDetector}from"./detectors/JitterBufferStressDetector";export{DecoderPerformanceDetector}from"./detectors/DecoderPerformanceDetector";export{SourceEncoderBottleneckDetector}from"./detectors/SourceEncoderBottleneckDetector";export{SimulcastLayerDetector}from"./detectors/SimulcastLayerDetector";export{CaptureFailureDetector}from"./detectors/CaptureFailureDetector";export{CodecChangeDetector}from"./detectors/CodecChangeDetector";export{VideoResolutionChangeDetector}from"./detectors/VideoResolutionChangeDetector";export{StatsGapDetector}from"./detectors/StatsGapDetector";export{StuckDecoderDetector}from"./detectors/StuckDecoderDetector";export{IceConnectivityDetector}from"./detectors/IceConnectivityDetector";export{SelectedIcePath}from"./monitors/SelectedIcePath";
|
|
1
|
+
export{CertificateMonitor}from"./monitors/CertificateMonitor";export{CodecMonitor}from"./monitors/CodecMonitor";export{DataChannelMonitor}from"./monitors/DataChannelMonitor";export{IceCandidateMonitor}from"./monitors/IceCandidateMonitor";export{IceCandidatePairMonitor}from"./monitors/IceCandidatePairMonitor";export{IceTransportMonitor}from"./monitors/IceTransportMonitor";export{InboundRtpMonitor}from"./monitors/InboundRtpMonitor";export{InboundTrackMonitor}from"./monitors/InboundTrackMonitor";export{MediaPlayoutMonitor}from"./monitors/MediaPlayoutMonitor";export{MediaSourceMonitor}from"./monitors/MediaSourceMonitor";export{OutboundRtpMonitor}from"./monitors/OutboundRtpMonitor";export{OutboundTrackMonitor}from"./monitors/OutboundTrackMonitor";export{PeerConnectionMonitor}from"./monitors/PeerConnectionMonitor";export{PeerConnectionTransportMonitor}from"./monitors/PeerConnectionTransportMonitor";export{RemoteInboundRtpMonitor}from"./monitors/RemoteInboundRtpMonitor";export{RemoteOutboundRtpMonitor}from"./monitors/RemoteOutboundRtpMonitor";export{ClientMonitor}from"./ClientMonitor";export{StatsAdapters}from"./adapters/StatsAdapters";export{ChromeStatsAdapter}from"./adapters/ChromeStatsAdapter";export{SafariStatsAdapter}from"./adapters/SafariStatsAdapter";export{FirefoxStatsAdapter}from"./adapters/FirefoxStatsAdapter";export{Detectors}from"./detectors/Detectors";export{isClientMonitorIssue,isClientMonitorResolvedIssue}from"./ClientMonitorIssues";export{AudioConcealmentDetector}from"./detectors/AudioConcealmentDetector";export{JitterBufferStressDetector}from"./detectors/JitterBufferStressDetector";export{DecoderPerformanceDetector}from"./detectors/DecoderPerformanceDetector";export{SourceEncoderBottleneckDetector}from"./detectors/SourceEncoderBottleneckDetector";export{SimulcastLayerDetector}from"./detectors/SimulcastLayerDetector";export{CaptureFailureDetector}from"./detectors/CaptureFailureDetector";export{CodecChangeDetector}from"./detectors/CodecChangeDetector";export{VideoResolutionChangeDetector}from"./detectors/VideoResolutionChangeDetector";export{StatsGapDetector}from"./detectors/StatsGapDetector";export{StuckDecoderDetector}from"./detectors/StuckDecoderDetector";export{IceConnectivityDetector}from"./detectors/IceConnectivityDetector";export{SelectedIcePath}from"./monitors/SelectedIcePath";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const schemaVersion = "3.
|
|
1
|
+
export declare const schemaVersion = "3.3.0";
|
|
2
2
|
export type ExtensionStat = {
|
|
3
3
|
type: string;
|
|
4
4
|
payload?: string;
|
|
@@ -13,6 +13,7 @@ export type ClientMetaData = {
|
|
|
13
13
|
};
|
|
14
14
|
export type ClientIssue = {
|
|
15
15
|
type: string;
|
|
16
|
+
key?: string;
|
|
16
17
|
payload?: string;
|
|
17
18
|
timestamp?: number;
|
|
18
19
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const schemaVersion="3.
|
|
1
|
+
export const schemaVersion="3.3.0";
|
package/dist/sources/Sources.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{fetchUserAgentData as e}from"./fetchUserAgentData";import{PeerConnectionMonitor as i}from"../monitors/PeerConnectionMonitor";import{watchMediaDevices as
|
|
1
|
+
import{fetchUserAgentData as e}from"./fetchUserAgentData";import{PeerConnectionMonitor as i}from"../monitors/PeerConnectionMonitor";import{watchMediaDevices as t}from"./watchMediaDevice";import{RtcPeerConnectionStatsCollector as n}from"../collectors/RtcPeerConnectionStatsCollector";import{MediasoupTransportStatsCollector as o}from"../collectors/MediasoupTransportStatsCollector";import{ChromeStatsAdapter as r}from"../adapters/ChromeStatsAdapter";import{FirefoxStatsAdapter as s}from"../adapters/FirefoxStatsAdapter";import{SafariStatsAdapter as a}from"../adapters/SafariStatsAdapter";import{RtcPeerConnectionBinding as d}from"./RtcPeerConnectionBinding";import{MediasoupTransportBinding as c}from"./MediasoupTransportBinding";import{MediasoupDeviceBinding as p}from"./MediasoupDeviceBinding";const h="Sources";export class Sources{monitor;logger;mediaDevicesAreWatched=!1;userAgentMetaDataSent=!1;userAgentStatsAdapterAdded=!1;mediasoupStatsAdapterAdded=!1;_peerConnectionBindings=new Map;_mediasoupTransportBindings=new Map;_mediasoupDeviceBindings=[];constructor(e,i){this.monitor=e,this.logger=i}addRTCPeerConnection(e){if([...this._peerConnectionBindings.values()].find((i=>i.peerConnection===e.peerConnection)))return this.logger.warn(`[${h}]:`,"RTCPeerConnection already exists in sources, not adding again");const{peerConnectionId:t=crypto.randomUUID(),peerConnection:o}=e,r=new i(t,new n(o,this.monitor),this.monitor,this.logger),s=new d(o,r);s.bind(),r.once("close",(()=>{this._peerConnectionBindings.delete(t)})),this._peerConnectionBindings.set(t,s),this._addPeerConnectionMonitor(r)}removeRTCPeerConnection(e){const i=[...this._peerConnectionBindings.values()].find((i=>i.peerConnection===e));i?i.unbind():this.logger.warn(`[${h}]:`,"RTCPeerConnection not found in sources, cannot remove")}addMediasoupDevice(e){if(this._mediasoupDeviceBindings.find((i=>i.device===e)))return this.logger.warn(`[${h}]:`,"Mediasoup device already exists in sources, not adding again"),this;const i=new p(e,this);return this._mediasoupDeviceBindings.push(i),i.bind(),this}removeMediasoupDevice(e){const i=this._mediasoupDeviceBindings.find((i=>i.device===e));return i?(i.unbind(),this._mediasoupDeviceBindings=this._mediasoupDeviceBindings.filter((e=>e!==i)),this):(this.logger.warn(`[${h}]:`,"Mediasoup device not found in sources, cannot remove"),this)}addMediasoupTransport(e){if(this._mediasoupTransportBindings.has(e.id))return this.logger.warn(`[${h}]:`,`Mediasoup transport (${e.id}) already exists in sources, not adding again`),this;const t={direction:e.direction},n=new i(e.id,new o(e,this.monitor),this.monitor,this.logger,t),r=new c(e,n);return n.once("close",(()=>{this._mediasoupTransportBindings.delete(e.id)})),this._mediasoupTransportBindings.set(e.id,r),r.bind(),this._addPeerConnectionMonitor(n),this}removeMediasoupTransport(e){const i=this._mediasoupTransportBindings.get(e.id);return i?(i.unbind(),this._mediasoupTransportBindings.delete(e.id),this):(this.logger.warn(`[${h}]:`,`Mediasoup transport (${e.id}) not found in sources, cannot remove`),this)}fetchUserAgentData(){const i=e(this.logger);if(i){if(this.userAgentMetaDataSent||(this.monitor.addMetaData({type:"USER_AGENT_DATA",payload:{...i}}),this.userAgentMetaDataSent=!0),!this.userAgentStatsAdapterAdded&&(this.userAgentStatsAdapterAdded=!0,!this.monitor.browser&&i.browser)){const e=i.browser.name.toLowerCase();["chrome","firefox","safari","edge"].includes(e)?this.monitor.browser={name:e,version:i.browser?.version}:this.monitor.browser={name:"unknown",version:"unknown"}}return i}}watchNavigatorMediaDevices(){if(!this.mediaDevicesAreWatched)try{t(this.monitor,this.logger),this.mediaDevicesAreWatched=!0}catch(e){this.logger.error(`[${h}]:`,"Failed to watch media devices",e)}}_addPeerConnectionMonitor(e){this.monitor.addPeerConnectionMonitor(e),this.addStatsAdapters(e)}addStatsAdapters(e){if(this.monitor.browser)switch(this.monitor.browser.name){case"chrome":case"edge":case"opera":e.statsAdapters.add(new r);break;case"safari":e.statsAdapters.add(new a);break;case"firefox":e.statsAdapters.add(new s)}}}
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import*as t from"../schema/W3cStatsIdentifiers";export class Firefox94StatsAdapter{name="firefox94StatsAdapter";adapt(e){for(const a of e){if(!a)continue;const e=a.type,o=a;e&&"string"==typeof e&&(e!==t.StatsType.inboundRtp&&e!==t.StatsType.outboundRtp&&e!==t.StatsType.remoteInboundRtp&&e!==t.StatsType.remoteOutboundRtp||o.mediaType&&!o.kind&&(o.kind=o.mediaType,delete o.mediaType))}return e}}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { IceTransportStats } from "../schema/ClientSample";
|
|
2
|
-
import { RtcStats } from "../schema/W3cStatsIdentifiers";
|
|
3
|
-
import { StatsAdapter } from "./StatsAdapter";
|
|
4
|
-
export declare class FirefoxTransportStatsAdapter implements StatsAdapter {
|
|
5
|
-
readonly name = "FirefoxTransportStatsAdapter";
|
|
6
|
-
private _selectedCandidatePair?;
|
|
7
|
-
stats: IceTransportStats & RtcStats;
|
|
8
|
-
constructor();
|
|
9
|
-
adapt(stats: RtcStats[]): RtcStats[];
|
|
10
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import*as t from"../schema/W3cStatsIdentifiers";export class FirefoxTransportStatsAdapter{name="FirefoxTransportStatsAdapter";_selectedCandidatePair;stats={type:"transport",timestamp:0,id:"",packetsSent:0,packetsReceived:0,bytesSent:0,bytesReceived:0,selectedCandidatePairId:void 0,selectedCandidatePairChanges:-1};constructor(){}adapt(e){let s,a=!1;for(const i of e){if(i.type===t.StatsType.transport){a=!0;break}if(i.type!==t.StatsType.candidatePair)continue;const e=i;if(e.selected){s=e;break}}if(a||!s)return e;if(this._selectedCandidatePair?.id!==s.id)this.stats.selectedCandidatePairChanges=(this.stats.selectedCandidatePairChanges??0)+1,this.stats.selectedCandidatePairId=s.id,this.stats.id=s.transportId??"transport_0";else{const t=(s.packetsReceived??0)-(this._selectedCandidatePair.packetsReceived??0),e=(s.packetsSent??0)-(this._selectedCandidatePair.packetsSent??0),a=(s.bytesReceived??0)-(this._selectedCandidatePair.bytesReceived??0),i=(s.bytesSent??0)-(this._selectedCandidatePair.bytesSent??0);0<a&&(this.stats.bytesReceived=(this.stats.bytesReceived??0)+a),0<i&&(this.stats.bytesSent=(this.stats.bytesSent??0)+i),0<t&&(this.stats.packetsReceived=(this.stats.packetsReceived??0)+t),0<e&&(this.stats.packetsSent=(this.stats.packetsSent??0)+e)}return this._selectedCandidatePair=s,this.stats.timestamp=s.timestamp,e.push(this.stats),e}}
|