@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 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 are specialized components that monitor for specific anomalies and issues in WebRTC connections. Each detector focuses on a particular aspect of the connection quality.
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
- ### Built-in Detectors
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
- #### AudioDesyncDetector
390
+ ### Detector overview
391
391
 
392
- Detects audio synchronization issues by monitoring sample corrections.
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
- **Triggers on:**
421
+ #### AudioConcealmentDetector
395
422
 
396
- - Audio acceleration/deceleration corrections exceed thresholds
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
- **Configuration:**
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
- audioDesyncDetector: {
403
- fractionalCorrectionAlertOnThreshold: 0.1, // 10% correction rate triggers alert
404
- fractionalCorrectionAlertOffThreshold: 0.05, // 5% correction rate clears alert
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
- #### CongestionDetector
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
- Monitors network congestion by analyzing available bandwidth vs. usage.
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
- **Triggers on:**
448
+ #### JitterBufferStressDetector
413
449
 
414
- - Available bandwidth falls below sending/receiving bitrates
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
- **Configuration:**
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
- congestionDetector: {
421
- sensitivity: 'medium', // 'low', 'medium', 'high'
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
- #### CpuPerformanceDetector
426
-
427
- Detects CPU performance issues affecting media processing.
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
- **Triggers on:**
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
- - Outbound RTP quality limitation reason is `'cpu'`
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
- > **Why not FPS volatility?** Earlier versions inferred decode CPU pressure from frame-rate volatility. That false-triggered on content such as screen share, whose fps legitimately swings (e.g. 15 → 1 fps when the shared content goes static). The decoded/received ratio is robust to this: when fps drops legitimately, received and decoded frames drop together so the ratio stays near 1.0. An alert only fires when frames are received but not decoded.
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
- **Configuration:**
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
- cpuPerformanceDetector: {
441
- incomingDecodedFramesRatioThresholds: {
442
- alertOn: 0.7, // alert ON when <70% of received frames are decoded
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
- #### DryInboundTrackDetector
483
+ ```typescript
484
+ monitor.on('audio-desync-track', ({ trackMonitor }) => {
485
+ diagnostics.flag('audio-desync', trackMonitor.track.id);
486
+ });
487
+ ```
454
488
 
455
- Detects inbound tracks that stop receiving data.
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
- **Triggers on:**
491
+ #### SynthesizedSamplesDetector
458
492
 
459
- - Inbound track receives no data for specified duration
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
- **Configuration:**
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
- dryInboundTrackDetector: {
466
- thresholdInMs: 5000,
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
- #### DryOutboundTrackDetector
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
- - Outbound track sends no data for specified duration
477
- - Local media issues or encoding problems
506
+ ---
478
507
 
479
- **Configuration:**
480
-
481
- ```javascript
482
- dryOutboundTrackDetector: {
483
- thresholdInMs: 5000,
484
- }
485
- ```
508
+ ### Video detectors
486
509
 
487
510
  #### FreezedVideoTrackDetector
488
511
 
489
- Detects frozen video tracks.
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
- **Triggers on:**
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
- - Video frames stop updating
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
- #### PlayoutDiscrepancyDetector
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
- Detects discrepancies between received and rendered frames.
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
- **Triggers on:**
541
+ #### DecoderPerformanceDetector
508
542
 
509
- - Frame skew exceeds thresholds
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
- **Configuration:**
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
- playoutDiscrepancyDetector: {
516
- lowSkewThreshold: 2,
517
- highSkewThreshold: 5,
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
- #### SynthesizedSamplesDetector
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
- Detects when audio playout synthesizes samples due to missing data.
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
- **Triggers on:**
566
+ #### StuckDecoderDetector
526
567
 
527
- - Synthesized audio samples exceed duration threshold
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
- **Configuration:**
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
- syntheticSamplesDetector: {
534
- minSynthesizedSamplesDuration: 1000,
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
- #### LongPcConnectionEstablishmentDetector
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
- Detects slow peer connection establishment.
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
- **Triggers on:**
592
+ #### PlayoutDiscrepancyDetector
543
593
 
544
- - Peer connection takes too long to establish
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
- **Configuration:**
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
- longPcConnectionEstablishmentDetector: {
551
- thresholdInMs: 5000,
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
- #### IceConnectivityDetector
556
-
557
- Runtime ICE and transport health, per ICE transport. Peer-connection setup latency
558
- is **not** in scope — `LongPcConnectionEstablishmentDetector` covers that.
559
-
560
- **Raises:**
605
+ ```typescript
606
+ monitor.on('inbound-video-playout-discrepancy', ({ trackMonitor }) => {
607
+ videoSinks.reattach(trackMonitor.track.id);
608
+ });
609
+ ```
561
610
 
562
- - `ice-disconnected` — the transport stayed `disconnected` past
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
- **Emits:**
613
+ ---
576
614
 
577
- - `'ice-restart-recommended'` — see below.
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
- ##### Recommending an ICE restart
617
+ #### DryInboundTrackDetector / DryOutboundTrackDetector
584
618
 
585
- The library detects **when** a restart is warranted; performing it stays with the
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
- A recommendation fires when ICE reaches `failed` (immediately — it never
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
- | `reason` | Meaning |
596
- |---|---|
597
- | `ice-failed` | ICE gave up on this generation. |
598
- | `ice-disconnected` | `disconnected` outlasted the window in which ICE usually self-heals. |
599
- | `transport-stalled` | ICE still reports connected, but the selected path stopped delivering. |
600
- | `never-established` | The peer connection never finished connecting. Tracked from `connectionState`, which covers the DTLS handshake too — a connection can sit in `connecting` while every ICE transport reports `connected`. |
601
-
602
- `LongPcConnectionEstablishmentDetector` reports that setup is *slow* at its own
603
- (shorter) threshold; the `never-established` recommendation says it is not going
604
- to happen on its own. The two thresholds form an escalation, not a duplicate
605
- report. While a restart the application already started is in flight, the
606
- detector stays quiet. Repeat recommendations are spaced
607
- by `iceRestartRecommendationCooldownInMs`, and each carries a
608
- `recommendationCount` and the current `iceGeneration` so you can back off after
609
- repeated failed attempts.
623
+ ```javascript
624
+ dryInboundTrackDetector: { thresholdInMs: 5000 },
625
+ dryOutboundTrackDetector: { thresholdInMs: 5000 },
626
+ ```
610
627
 
611
628
  ```typescript
612
- monitor.on('ice-restart-recommended', ({ peerConnectionMonitor, reason, recommendationCount }) => {
613
- if (3 <= recommendationCount) return rejoinTheCall(); // restarts are not helping
614
-
615
- // your application decides — the library never restarts ICE itself
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
- #### AudioConcealmentDetector
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
- Reports how the audio actually *sounded*, which packet loss does not. Opus and
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
- The rate is **audible** concealment only: `concealedSamples` also rises during
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
- **Raises:** `audio-concealment`, with `concealmentRate`, `concealmentEventRate`
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
- It stays silent while the remote track is paused, and while too few samples
640
- arrived in the window to judge.
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
- audioConcealmentDetector: {
644
- onThreshold: 0.03,
645
- offThreshold: 0.01,
646
- windowInMs: 5000,
647
- minSamplesInWindow: 24000,
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
- #### JitterBufferStressDetector
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
- The complement to `AudioConcealmentDetector`: it separates "network jitter
654
- absorbed cleanly" from "the jitter buffer ballooned, adding latency and
655
- stretching audio to cope".
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
- Both conditions are required, deliberately. A high target delay on its own means
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
- **Raises:** `audio-jitter-buffer-stress`.
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
- jitterBufferStressDetector: {
666
- targetDelayThresholdInMs: 200,
667
- timeStretchThreshold: 0.02,
668
- minConsecutiveTicks: 2,
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
- #### DecoderPerformanceDetector
673
-
674
- The receive-side sibling of CPU limitation, and the piece that makes
675
- network-versus-client attribution possible. Frames dropped because they never
676
- arrived and frames dropped because the client could not decode them look
677
- identical in a frame-rate chart, and the fixes are opposite — so this detector
678
- fires only when the frames demonstrably *did* arrive: enough frames received,
679
- loss below `quietLossThreshold`, and either decode time past the per-frame
680
- budget or frames dropped after arrival.
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
- The budget is derived from the stream's own frame rate (33 ms at 30 fps, 66 ms
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
- **Raises:** `video-decoder-overloaded`, carrying `decoderImplementation` and
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
- ```javascript
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
- #### Video recovery (part of `FreezedVideoTrackDetector`)
695
+ #### CongestionDetector
700
696
 
701
- `FreezedVideoTrackDetector` owns the whole freeze / repair domain: it derives
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
- - `keyframe-storm` — a sustained PLI rate. Worth its own issue because it is
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
- videoRecoveryDetector: {
716
- windowInMs: 30000,
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
- #### StuckDecoderDetector
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
- Detects the per-consumer decode wedge: RTP bytes keep arriving but no frame
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
- The fingerprint is `bytesReceived` rising + `framesReceived` flat + `pliCount`
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
- A wedge never self-heals, so the wait only needs to outlast a *legitimate*
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
- **Raises:** `stuck-decoder`, with a `variant` (`'assembly'`: no frame ever
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
- **Mitigation hook:** recreating the consumer is the known workaround — listen
750
- for the `stuck-decoder` monitor event:
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('stuck-decoder', ({ trackMonitor, variant, deadBytesReceived }) => {
754
- // the stream is being delivered but nothing decodes — recreate the consumer
755
- recreateConsumerFor(trackMonitor.track.id);
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
- stuckDecoderDetector: {
761
- thresholdInMs: 4000,
762
- rttMultiplier: 15,
763
- minStuckTicks: 2,
764
- minBitrate: 10000,
765
- minPliCount: 2,
747
+ longPcConnectionEstablishmentDetector: {
748
+ thresholdInMs: 5000,
749
+ createEvent: true,
766
750
  }
767
751
  ```
768
752
 
769
- #### SourceEncoderBottleneckDetector
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
- Splits one symptom — "we are sending fewer frames than we should" — into its two
772
- causes, which from RTP alone are indistinguishable:
755
+ #### IceConnectivityDetector
773
756
 
774
- - `capture-bottleneck` — the *source* never produced the frames. A camera
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
- The discriminator is `MediaSourceMonitor.sourceFps` against what the highest
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
- ```javascript
785
- sourceEncoderBottleneckDetector: {
786
- captureFpsRatioThreshold: 0.5,
787
- minSourceFps: 5,
788
- encodeFpsRatioThreshold: 0.7,
789
- encodeTimeBudgetRatio: 0.8,
790
- cpuLimitationShareThreshold: 0.3,
791
- minConsecutiveTicks: 2,
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
- #### CaptureFailureDetector
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
- Watches the source end of an outbound track, where several very common
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
- **Raises:** `capture-track-ended` (the device is gone), `silent-audio-source`
801
- (the microphone is live and producing nothing).
790
+ #### IceTupleChangeDetector
802
791
 
803
- **Emits:** `'capture-track-ended'`, `'capture-track-muted'` (the OS or another
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
- The silence threshold is 30 seconds by default, and long on purpose: a
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
- ```javascript
816
- captureFailureDetector: {
817
- silenceThresholdInMs: 30000,
818
- silenceRmsThreshold: 0.001,
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
- #### Observation detectors
824
-
825
- These four emit events and **never raise issues** — they describe things that are
826
- not faults but are the missing context in most investigations.
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
- | Detector | Monitor event | Client event | What it answers |
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. Resolutions are not currently included in the sample buffer (only in the realtime `'issue-resolved'` event). If you reconstruct state server-side from samples only, treat each `clientIssues` entry as "the issue started here" and apply your own correlation logic.
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 includes several built-in adapters that are automatically applied based on browser detection:
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
- #### Firefox Adapters
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
- - **Firefox94StatsAdapter**: Normalizes `mediaType` to `kind` field for RTP stats
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
- #### Browser-Specific Adaptations
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
- Stats adapters are automatically added based on detected browser:
2053
+ #### ChromeStatsAdapter (Chrome, Edge, Opera)
2047
2054
 
2048
- ```javascript
2049
- // Automatically applied for Firefox
2050
- if (browser.name === "firefox") {
2051
- pcMonitor.statsAdapters.add(new Firefox94StatsAdapter());
2052
- pcMonitor.statsAdapters.add(new FirefoxTransportStatsAdapter());
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:
@@ -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.2.0";
22
+ static readonly samplingSchemaVersion = "3.3.0";
23
23
  readonly mappedPeerConnections: Map<string, PeerConnectionMonitor>;
24
24
  readonly detectors: Detectors;
25
25
  readonly clientEventPayloadProvider: ClientEventPayloadProvider;
@@ -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,6 @@
1
+ import { RtcStats } from "../schema/W3cStatsIdentifiers";
2
+ import { StatsAdapter } from "./StatsAdapter";
3
+ export declare class ChromeStatsAdapter implements StatsAdapter {
4
+ readonly name = "chromeStatsAdapter";
5
+ adapt(stats: RtcStats[]): RtcStats[];
6
+ }
@@ -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,6 @@
1
+ import { RtcStats } from "../schema/W3cStatsIdentifiers";
2
+ import { StatsAdapter } from "./StatsAdapter";
3
+ export declare class SafariStatsAdapter implements StatsAdapter {
4
+ readonly name = "safariStatsAdapter";
5
+ adapt(stats: RtcStats[]): RtcStats[];
6
+ }
@@ -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.2.0";
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.2.0";
1
+ export const schemaVersion="3.3.0";
@@ -1 +1 @@
1
- import{fetchUserAgentData as e}from"./fetchUserAgentData";import{PeerConnectionMonitor as i}from"../monitors/PeerConnectionMonitor";import{watchMediaDevices as n}from"./watchMediaDevice";import{RtcPeerConnectionStatsCollector as t}from"../collectors/RtcPeerConnectionStatsCollector";import{MediasoupTransportStatsCollector as o}from"../collectors/MediasoupTransportStatsCollector";import{Firefox94StatsAdapter as r}from"../adapters/Firefox94StatsAdapter";import{FirefoxTransportStatsAdapter as s}from"../adapters/FirefoxTransportStatsAdapter";import{RtcPeerConnectionBinding as a}from"./RtcPeerConnectionBinding";import{MediasoupTransportBinding as d}from"./MediasoupTransportBinding";import{MediasoupDeviceBinding as c}from"./MediasoupDeviceBinding";const p="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(`[${p}]:`,"RTCPeerConnection already exists in sources, not adding again");const{peerConnectionId:n=crypto.randomUUID(),peerConnection:o}=e,r=new i(n,new t(o,this.monitor),this.monitor,this.logger),s=new a(o,r);s.bind(),r.once("close",(()=>{this._peerConnectionBindings.delete(n)})),this._peerConnectionBindings.set(n,s),this._addPeerConnectionMonitor(r)}removeRTCPeerConnection(e){const i=[...this._peerConnectionBindings.values()].find((i=>i.peerConnection===e));i?i.unbind():this.logger.warn(`[${p}]:`,"RTCPeerConnection not found in sources, cannot remove")}addMediasoupDevice(e){if(this._mediasoupDeviceBindings.find((i=>i.device===e)))return this.logger.warn(`[${p}]:`,"Mediasoup device already exists in sources, not adding again"),this;const i=new c(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(`[${p}]:`,"Mediasoup device not found in sources, cannot remove"),this)}addMediasoupTransport(e){if(this._mediasoupTransportBindings.has(e.id))return this.logger.warn(`[${p}]:`,`Mediasoup transport (${e.id}) already exists in sources, not adding again`),this;const n={direction:e.direction},t=new i(e.id,new o(e,this.monitor),this.monitor,this.logger,n),r=new d(e,t);return t.once("close",(()=>{this._mediasoupTransportBindings.delete(e.id)})),this._mediasoupTransportBindings.set(e.id,r),r.bind(),this._addPeerConnectionMonitor(t),this}removeMediasoupTransport(e){const i=this._mediasoupTransportBindings.get(e.id);return i?(i.unbind(),this._mediasoupTransportBindings.delete(e.id),this):(this.logger.warn(`[${p}]:`,`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{n(this.monitor,this.logger),this.mediaDevicesAreWatched=!0}catch(e){this.logger.error(`[${p}]:`,"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":case"safari":case"unknown":break;case"firefox":e.statsAdapters.add(new r),e.statsAdapters.add(new s)}}}
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@observertc/client-monitor-js",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "ObserveRTC Client Integration Javascript Library",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,5 +0,0 @@
1
- import { RtcStats } from "../schema/W3cStatsIdentifiers";
2
- export declare class Firefox94StatsAdapter {
3
- readonly name = "firefox94StatsAdapter";
4
- adapt(rtcStats: RtcStats[]): RtcStats[];
5
- }
@@ -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}}