@observertc/observer-js 1.0.0-beta.13 → 1.0.0-beta.14

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/dist/index.d.mts CHANGED
@@ -52,6 +52,12 @@ type ClientIssue = {
52
52
  */
53
53
  type: string;
54
54
  /**
55
+ * Identity of a **stateful** issue, shared by its raise entry and its `<type>-resolved`
56
+ * companion, so a server can open an active issue on the raise and close it on the match
57
+ * (client-monitor-js >= 4.6.0, `sendResolvedIssuesToServer`). One-shot issues have no key.
58
+ */
59
+ key?: string;
60
+ /**
55
61
  * The value associated with the event, if applicable.
56
62
  */
57
63
  payload?: string;
@@ -1585,6 +1591,24 @@ declare class ObservedOutboundRtp implements OutboundRtpStats {
1585
1591
  bitPerPixel: number;
1586
1592
  deltaPacketsSent: number;
1587
1593
  deltaBytesSent: number;
1594
+ deltaFramesSent: number;
1595
+ deltaFramesEncoded: number;
1596
+ deltaKeyFramesEncoded: number;
1597
+ deltaNackCount: number;
1598
+ deltaPliCount: number;
1599
+ deltaFirCount: number;
1600
+ deltaRetransmittedPacketsSent: number;
1601
+ deltaRetransmittedBytesSent: number;
1602
+ deltaEncodeTime: number;
1603
+ deltaQualityLimitationResolutionChanges: number;
1604
+ /**
1605
+ * `true` when the codec, encoder implementation or scalability mode changed in this tick.
1606
+ *
1607
+ * Chrome resets `packetsSent`/`bytesSent` on the SSRC when the codec switches
1608
+ * (crbug.com/webrtc/5361), producing sawtooth spikes and negative bitrates. All deltas for the
1609
+ * tick are suppressed so a codec change is never mistaken for a traffic event.
1610
+ */
1611
+ counterResetBoundary: boolean;
1588
1612
  remoteRttInMs?: number;
1589
1613
  remoteFractionLost?: number;
1590
1614
  remoteJitter?: number;
@@ -1752,6 +1776,46 @@ declare class ObservedInboundRtp implements InboundRtpStats {
1752
1776
  deltaBytesReceived: number;
1753
1777
  deltaReceivedSamples: number;
1754
1778
  deltaSilentConcealedSamples: number;
1779
+ deltaConcealedSamples: number;
1780
+ deltaConcealmentEvents: number;
1781
+ deltaFreezeCount: number;
1782
+ deltaFreezesDuration: number;
1783
+ deltaPliCount: number;
1784
+ deltaNackCount: number;
1785
+ deltaFirCount: number;
1786
+ deltaPacketsDiscarded: number;
1787
+ deltaFramesDecoded: number;
1788
+ deltaFramesReceived: number;
1789
+ deltaFramesRendered: number;
1790
+ deltaFramesDropped: number;
1791
+ deltaKeyFramesDecoded: number;
1792
+ deltaDecodeTime: number;
1793
+ deltaJitterBufferDelay: number;
1794
+ deltaJitterBufferEmittedCount: number;
1795
+ deltaRetransmittedPacketsReceived: number;
1796
+ deltaFecPacketsReceived: number;
1797
+ deltaFecPacketsDiscarded: number;
1798
+ deltaPausesDuration: number;
1799
+ /**
1800
+ * Mean jitter-buffer delay for the frames/samples emitted in this tick (seconds), derived from
1801
+ * the cumulative `jitterBufferDelay` / `jitterBufferEmittedCount` pair — the only correct way to
1802
+ * read those two counters.
1803
+ */
1804
+ jitterBufferDelayInMs?: number;
1805
+ /** Fraction of the samples received in this tick that were concealed (0..1). */
1806
+ concealmentRatio?: number;
1807
+ /** Fraction of the frames received in this tick that were dropped before rendering (0..1). */
1808
+ framesDroppedRatio?: number;
1809
+ /**
1810
+ * `true` when the codec or decoder implementation changed in this tick.
1811
+ *
1812
+ * Chrome resets `packetsReceived`/`bytesReceived` on an SSRC when the codec switches
1813
+ * (crbug.com/webrtc/5361, open since 2015), which shows up as a sawtooth spike or a negative
1814
+ * rate. Every delta in this tick is therefore suppressed to `0` rather than reported as traffic
1815
+ * — otherwise a room-wide codec rollout produces a synchronized fake-degradation alert across
1816
+ * every participant at once.
1817
+ */
1818
+ counterResetBoundary: boolean;
1755
1819
  remoteRttInMs?: number;
1756
1820
  remoteBytesSent?: number;
1757
1821
  remotePacketsSent?: number;
@@ -1941,7 +2005,34 @@ declare class ObservedPeerConnection extends EventEmitter {
1941
2005
  sendingVideoBitrate: number;
1942
2006
  receivingAudioBitrate: number;
1943
2007
  receivingVideoBitrate: number;
2008
+ /**
2009
+ * Median round-trip time of the tick, in ms.
2010
+ *
2011
+ * Prefers {@link rtcpRttInMs} and falls back to {@link iceRttInMs}, so within one tick it always
2012
+ * reports **one** kind of round trip. It used to be the median of both mixed together, which was
2013
+ * a bug: the mixing ratio changed as streams came and went, so the value moved for reasons that
2014
+ * had nothing to do with the network.
2015
+ */
1944
2016
  currentRttInMs?: number;
2017
+ /**
2018
+ * RTT measured by ICE/STUN consent checks, in ms — the trip to **whatever terminates ICE**. In an
2019
+ * SFU topology that is the SFU, so this is the client↔SFU leg, not client↔client.
2020
+ */
2021
+ iceRttInMs?: number;
2022
+ /**
2023
+ * RTT reported by RTCP receiver reports, in ms — an **end-to-end** media-path round trip.
2024
+ *
2025
+ * Not the same trip as {@link iceRttInMs}; the difference between the two is roughly the far side
2026
+ * of the SFU (see {@link sfuHopRttInMs}).
2027
+ */
2028
+ rtcpRttInMs?: number;
2029
+ /**
2030
+ * `rtcpRttInMs - iceRttInMs`, when both are known — an estimate of everything *past* the SFU.
2031
+ *
2032
+ * Useful for splitting "this client's own last mile is slow" (high `iceRttInMs`) from "the path
2033
+ * beyond the SFU is slow" (low ICE, high hop).
2034
+ */
2035
+ sfuHopRttInMs?: number;
1945
2036
  currentJitter?: number;
1946
2037
  usingTCP: boolean;
1947
2038
  usingTURN: boolean;
@@ -2036,6 +2127,72 @@ type OperationSystem = {
2036
2127
  version: string;
2037
2128
  };
2038
2129
 
2130
+ /** The suffix client-monitor-js appends to the type of a resolution entry. */
2131
+ declare const RESOLVED_ISSUE_SUFFIX = "-resolved";
2132
+ /**
2133
+ * A **stateful** client issue the server currently believes to be open.
2134
+ *
2135
+ * client-monitor-js (>= 4.6.0, `sendResolvedIssuesToServer`) puts an issue's whole lifecycle on the
2136
+ * wire as two entries sharing a `key`:
2137
+ *
2138
+ * ```
2139
+ * raise: { type: 'stuck-decoder', key, payload, timestamp: raisedAt }
2140
+ * resolution: { type: 'stuck-decoder-resolved', key, payload: { raisedAt, comment, …final }, timestamp: resolvedAt }
2141
+ * ```
2142
+ *
2143
+ * The observer opens an `ActiveClientIssue` on the raise and closes it on the matching key, which
2144
+ * turns a stream of point-in-time symptom reports into **intervals**. That is what makes the
2145
+ * difference between "several clients reported congestion in the last 10 seconds" (a guess built on
2146
+ * an arbitrary window) and "several clients are congested *right now, simultaneously*" — the latter
2147
+ * being real evidence of a shared cause.
2148
+ *
2149
+ * Issues still active when the client monitor closes are auto-resolved by the client, so a clean
2150
+ * departure does not leak. An unclean one (crash, network death) can, which is why the registry
2151
+ * also expires entries — see `IssueRegistry`.
2152
+ */
2153
+ type ActiveClientIssue = {
2154
+ /** Identity shared by the raise and its resolution. Unique per client. */
2155
+ key: string;
2156
+ /** The issue type **without** the `-resolved` suffix (e.g. `'congestion'`). */
2157
+ type: string;
2158
+ /** The client that reported it. */
2159
+ clientId: string;
2160
+ /** When the client raised it (client clock). */
2161
+ raisedAt: number;
2162
+ /** When the observer first saw it (server clock) — skew-free, use this for cross-client timing. */
2163
+ observedAt: number;
2164
+ /** Parsed raise payload, when it was JSON. */
2165
+ payload?: Record<string, unknown>;
2166
+ /** `payload.peerConnectionId`, when present — most client detectors report it. */
2167
+ peerConnectionId?: string;
2168
+ /** `payload.trackId`, when present — the join key to a track (and thus to a publisher). */
2169
+ trackId?: string;
2170
+ };
2171
+ /** A closed interval: an {@link ActiveClientIssue} plus how it ended. */
2172
+ type ResolvedClientIssue = ActiveClientIssue & {
2173
+ /** When the client resolved it (client clock). */
2174
+ resolvedAt: number;
2175
+ /** Observer-clock resolution time. */
2176
+ observedResolvedAt: number;
2177
+ /** `resolvedAt - raisedAt` as reported by the client, else derived from observer clocks. */
2178
+ durationInMs: number;
2179
+ /** Free-form note passed to `resolveIssue`. */
2180
+ comment?: string;
2181
+ /** Payload explicitly passed at resolution (the built-in detectors pass their final payload). */
2182
+ resolutionPayload?: Record<string, unknown>;
2183
+ /**
2184
+ * How the interval ended: the client said so, the observer expired it, or the client left
2185
+ * without resolving.
2186
+ */
2187
+ resolvedBy: 'client' | 'timeout' | 'client-closed';
2188
+ };
2189
+ /** `true` when the entry is a resolution companion rather than a raise. */
2190
+ declare function isResolutionEntry(issue: ClientIssue): boolean;
2191
+ /** Strip the `-resolved` suffix, so both entries of a lifecycle share one logical type. */
2192
+ declare function baseIssueType(type: string): string;
2193
+ /** Best-effort parse of the JSON-string payload the schema carries. */
2194
+ declare function parseIssuePayload(payload?: string): Record<string, unknown> | undefined;
2195
+
2039
2196
  /** The lifecycle events a sink may emit (a subset of Node's writable-stream events). */
2040
2197
  type ClientSampleSinkEvents = {
2041
2198
  /** The destination is fully written and closed (e.g. a file flushed and its fd closed). */
@@ -2103,11 +2260,11 @@ type RtpCodecParameters = {
2103
2260
  parameter?: string;
2104
2261
  }[];
2105
2262
  };
2106
- type SampleHistoryItem<T extends string> = Record<string, unknown> & {
2263
+ type SampleHistoryItem<T extends string> = {
2107
2264
  type: T;
2108
2265
  timestamp: number;
2109
2266
  };
2110
- type MediasoupRouterSample = Record<string, unknown> & {
2267
+ type MediasoupRouterSample = {
2111
2268
  routerId: string;
2112
2269
  attachments: Record<string, unknown>;
2113
2270
  createdAt: number;
@@ -2151,7 +2308,9 @@ type MediasoupDirectTransportSample = {
2151
2308
  type: 'direct';
2152
2309
  history: MediasoupDirectTransportSampleEventMap[];
2153
2310
  };
2154
- type MediasoupTransportSample = Record<string, unknown> & {
2311
+ type MediasoupTransportSample = {
2312
+ /** Free-form application data. Attach anything here — see `ObservedMediasoupRouter`. */
2313
+ attachments?: Record<string, unknown>;
2155
2314
  id: string;
2156
2315
  createdAt: number;
2157
2316
  connectedAt?: number;
@@ -2167,7 +2326,9 @@ type MediasoupProducerSampleEventMap = {
2167
2326
  type MediasoupProducerSampleEvent = {
2168
2327
  [K in keyof MediasoupProducerSampleEventMap]: SampleHistoryItem<K>;
2169
2328
  }[keyof MediasoupProducerSampleEventMap];
2170
- type MediasoupProducerSample = Record<string, unknown> & {
2329
+ type MediasoupProducerSample = {
2330
+ /** Free-form application data. Attach anything here — see `ObservedMediasoupRouter`. */
2331
+ attachments?: Record<string, unknown>;
2171
2332
  id: string;
2172
2333
  transportId: string;
2173
2334
  createdAt: number;
@@ -2191,7 +2352,9 @@ type MediasoupConsumerSampleEventMap = {
2191
2352
  type MediasoupConsumerSampleEvent = {
2192
2353
  [K in keyof MediasoupConsumerSampleEventMap]: SampleHistoryItem<K>;
2193
2354
  }[keyof MediasoupConsumerSampleEventMap];
2194
- type MediasoupConsumerSample = Record<string, unknown> & {
2355
+ type MediasoupConsumerSample = {
2356
+ /** Free-form application data. Attach anything here — see `ObservedMediasoupRouter`. */
2357
+ attachments?: Record<string, unknown>;
2195
2358
  id: string;
2196
2359
  producerId: string;
2197
2360
  transportId: string;
@@ -2200,7 +2363,9 @@ type MediasoupConsumerSample = Record<string, unknown> & {
2200
2363
  kind: 'audio' | 'video';
2201
2364
  history: MediasoupConsumerSampleEvent[];
2202
2365
  };
2203
- type MediasoupDataProducerSample = Record<string, unknown> & {
2366
+ type MediasoupDataProducerSample = {
2367
+ /** Free-form application data. Attach anything here — see `ObservedMediasoupRouter`. */
2368
+ attachments?: Record<string, unknown>;
2204
2369
  id: string;
2205
2370
  transportId: string;
2206
2371
  createdAt: number;
@@ -2208,7 +2373,9 @@ type MediasoupDataProducerSample = Record<string, unknown> & {
2208
2373
  label: string;
2209
2374
  protocol: string;
2210
2375
  };
2211
- type MediasoupDataConsumerSample = Record<string, unknown> & {
2376
+ type MediasoupDataConsumerSample = {
2377
+ /** Free-form application data. Attach anything here — see `ObservedMediasoupRouter`. */
2378
+ attachments?: Record<string, unknown>;
2212
2379
  id: string;
2213
2380
  dataProducerId: string;
2214
2381
  transportId: string;
@@ -2218,13 +2385,86 @@ type MediasoupDataConsumerSample = Record<string, unknown> & {
2218
2385
  protocol: string;
2219
2386
  };
2220
2387
 
2388
+ /**
2389
+ * Declarative enrichment: return the `attachments` to stamp onto an entity's sample the moment it is
2390
+ * created. Called once per entity, before the corresponding `*-sample-added` event.
2391
+ *
2392
+ * The mediasoup object is handed in, so the common case — mirroring mediasoup's own `appData`, where
2393
+ * applications already keep `participantId`, `purpose` and friends — is a one-liner. Returning
2394
+ * `undefined` attaches nothing.
2395
+ */
2396
+ type MediasoupSampleEnricher = {
2397
+ transport?: (transport: types.Transport) => Record<string, unknown> | undefined;
2398
+ producer?: (producer: types.Producer, transport: types.Transport) => Record<string, unknown> | undefined;
2399
+ consumer?: (consumer: types.Consumer, transport: types.Transport) => Record<string, unknown> | undefined;
2400
+ dataProducer?: (dataProducer: types.DataProducer, transport: types.Transport) => Record<string, unknown> | undefined;
2401
+ dataConsumer?: (dataConsumer: types.DataConsumer, transport: types.Transport) => Record<string, unknown> | undefined;
2402
+ };
2221
2403
  type ObservedMediasoupRouterSettings<AppData extends Record<string, unknown> = Record<string, unknown>> = {
2222
2404
  router: types.Router;
2223
2405
  appData?: AppData;
2224
2406
  attachments?: Record<string, unknown>;
2407
+ /** Stamp `attachments` onto each entity sample as it is created. See {@link MediasoupSampleEnricher}. */
2408
+ enrich?: MediasoupSampleEnricher;
2225
2409
  };
2410
+ /**
2411
+ * Lifecycle hooks for building your own report.
2412
+ *
2413
+ * Each entity announces itself as `<entity>-sample-added` when it appears and
2414
+ * `<entity>-sample-closed` when it goes away, carrying **the live sample object** plus the mediasoup
2415
+ * object it came from. Mutating `sample.attachments` inside a handler is the intended way to extend
2416
+ * a sample on the fly — the object you receive is the one held in `observedRouter.sample`, not a copy.
2417
+ */
2226
2418
  type ObservedMediasoupRouterEvents = {
2227
2419
  close: [];
2420
+ 'transport-sample-added': [{
2421
+ sample: MediasoupTransportSample;
2422
+ transport: types.Transport;
2423
+ }];
2424
+ 'transport-sample-closed': [{
2425
+ sample: MediasoupTransportSample;
2426
+ transport: types.Transport;
2427
+ }];
2428
+ 'producer-sample-added': [{
2429
+ sample: MediasoupProducerSample;
2430
+ producer: types.Producer;
2431
+ transport: types.Transport;
2432
+ }];
2433
+ 'producer-sample-closed': [{
2434
+ sample: MediasoupProducerSample;
2435
+ producer: types.Producer;
2436
+ transport: types.Transport;
2437
+ }];
2438
+ 'consumer-sample-added': [{
2439
+ sample: MediasoupConsumerSample;
2440
+ consumer: types.Consumer;
2441
+ transport: types.Transport;
2442
+ }];
2443
+ 'consumer-sample-closed': [{
2444
+ sample: MediasoupConsumerSample;
2445
+ consumer: types.Consumer;
2446
+ transport: types.Transport;
2447
+ }];
2448
+ 'data-producer-sample-added': [{
2449
+ sample: MediasoupDataProducerSample;
2450
+ dataProducer: types.DataProducer;
2451
+ transport: types.Transport;
2452
+ }];
2453
+ 'data-producer-sample-closed': [{
2454
+ sample: MediasoupDataProducerSample;
2455
+ dataProducer: types.DataProducer;
2456
+ transport: types.Transport;
2457
+ }];
2458
+ 'data-consumer-sample-added': [{
2459
+ sample: MediasoupDataConsumerSample;
2460
+ dataConsumer: types.DataConsumer;
2461
+ transport: types.Transport;
2462
+ }];
2463
+ 'data-consumer-sample-closed': [{
2464
+ sample: MediasoupDataConsumerSample;
2465
+ dataConsumer: types.DataConsumer;
2466
+ transport: types.Transport;
2467
+ }];
2228
2468
  };
2229
2469
  declare interface ObservedMediasoupRouter {
2230
2470
  on<U extends keyof ObservedMediasoupRouterEvents>(event: U, listener: (...args: ObservedMediasoupRouterEvents[U]) => void): this;
@@ -2250,9 +2490,36 @@ declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> =
2250
2490
  readonly sample: MediasoupRouterSample;
2251
2491
  readonly webrtcTransportIds: Set<string>;
2252
2492
  closed: boolean;
2493
+ private readonly _transportSamples;
2494
+ private readonly _producerSamples;
2495
+ private readonly _consumerSamples;
2496
+ private readonly _dataProducerSamples;
2497
+ private readonly _dataConsumerSamples;
2498
+ private readonly _enrich?;
2253
2499
  constructor(settings: ObservedMediasoupRouterSettings<AppData>);
2254
2500
  get id(): string;
2255
2501
  get attachments(): Record<string, unknown>;
2502
+ getTransportSample(id: string): MediasoupTransportSample | undefined;
2503
+ getProducerSample(id: string): MediasoupProducerSample | undefined;
2504
+ getConsumerSample(id: string): MediasoupConsumerSample | undefined;
2505
+ getDataProducerSample(id: string): MediasoupDataProducerSample | undefined;
2506
+ getDataConsumerSample(id: string): MediasoupDataConsumerSample | undefined;
2507
+ /**
2508
+ * Merge `attachments` into an entity's sample, whichever kind it is.
2509
+ *
2510
+ * Ids are unique across mediasoup entity kinds, so one method covers all of them. Returns `false`
2511
+ * when the id is unknown — a real answer instead of failing quietly, which matters when the
2512
+ * annotation is driven by application events that may race the mediasoup ones.
2513
+ */
2514
+ attachTo(id: string, attachments: Record<string, unknown>): boolean;
2515
+ /**
2516
+ * A **detached deep copy** of the current sample — the basis for building your own report.
2517
+ *
2518
+ * `this.sample` is live: its arrays grow and its `history` entries are appended as the router
2519
+ * runs, so a report built directly on it keeps changing after you think you're done. This returns
2520
+ * a snapshot that never moves.
2521
+ */
2522
+ snapshot(): MediasoupRouterSample;
2256
2523
  close(): void;
2257
2524
  addTransport: (transport: types.Transport) => void;
2258
2525
  addWebRtcTransport(transport: types.WebRtcTransport): void;
@@ -2264,6 +2531,18 @@ declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> =
2264
2531
  addDataProducer(transport: types.Transport, dataProducer: types.DataProducer): void;
2265
2532
  addDataConsumer(transport: types.Transport, dataConsumer: types.DataConsumer): void;
2266
2533
  private attachRouterListeners;
2534
+ private _addTransportSample;
2535
+ private _addProducerSample;
2536
+ private _addConsumerSample;
2537
+ private _addDataProducerSample;
2538
+ private _addDataConsumerSample;
2539
+ /**
2540
+ * Run an enricher and merge what it returns.
2541
+ *
2542
+ * Takes a thunk rather than a value so the **invocation** is inside the guard — application code
2543
+ * runs here, and a throwing enricher must not take the router's bookkeeping down with it.
2544
+ */
2545
+ private _applyEnrichment;
2267
2546
  private _attachTransportObserverListeners;
2268
2547
  }
2269
2548
 
@@ -2304,6 +2583,10 @@ type ObserverEvents = {
2304
2583
  reason: SampleRejectedReason;
2305
2584
  sample: ClientSample;
2306
2585
  }];
2586
+ /** An observer-scoped (cross-call / SFU-wide) finding raised by `observer.addIssue(...)`. */
2587
+ 'observer-issue': [ObserverEventBase & {
2588
+ issue: ClientIssue;
2589
+ }];
2307
2590
  'mediasoup-router-added': [ObservedMediasoupRouterScope];
2308
2591
  'mediasoup-router-removed': [ObservedMediasoupRouterScope];
2309
2592
  'mediasoup-router-matched-with-peer-connection': [ObservedMediasoupRouterScope & ObservedPeerConnectionScope];
@@ -2332,6 +2615,14 @@ type ObserverEvents = {
2332
2615
  'client-issue': [ObservedClientScope & {
2333
2616
  issue: ClientIssue;
2334
2617
  }];
2618
+ /**
2619
+ * A stateful client issue ended — the client sent its `<type>-resolved` companion, or the
2620
+ * observer force-closed it because the client went away. Carries the finished **interval**
2621
+ * (`raisedAt` → `resolvedAt`, `durationInMs`).
2622
+ */
2623
+ 'client-issue-resolved': [ObservedClientScope & {
2624
+ resolvedIssue: ResolvedClientIssue;
2625
+ }];
2335
2626
  'client-metadata': [ObservedClientScope & {
2336
2627
  metaData: ClientMetaData;
2337
2628
  }];
@@ -2582,6 +2873,16 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
2582
2873
  numberOfScoreMeasurements: number;
2583
2874
  readonly mediaDevices: MediaDeviceInfo[];
2584
2875
  issues: ClientIssue[];
2876
+ /**
2877
+ * The client's currently **open** stateful issues, keyed by `ClientIssue.key` — the server-side
2878
+ * mirror of the client monitor's own active-issue map (client-monitor-js >= 4.6.0).
2879
+ *
2880
+ * This turns point-in-time symptom reports into intervals, which is what lets detectors ask
2881
+ * "are these clients broken *at the same time*" instead of "did they both report something
2882
+ * recently". Entries are opened by a raise, closed by the matching `<type>-resolved` entry, and
2883
+ * force-closed when the client closes.
2884
+ */
2885
+ readonly activeIssues: Map<string, ActiveClientIssue>;
2585
2886
  private _pendingInjections;
2586
2887
  private _activeSample?;
2587
2888
  private closeTimer?;
@@ -2596,8 +2897,28 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
2596
2897
  injectExtensionStat(stat: ExtensionStat): void;
2597
2898
  injectAttachment(attachments: Record<string, unknown>): void;
2598
2899
  addMetadata(metadata: ClientMetaData): void;
2900
+ /**
2901
+ * Process one `clientIssues[]` entry.
2902
+ *
2903
+ * Entries come in two flavours (client-monitor-js >= 4.6.0):
2904
+ *
2905
+ * - a **raise** — opens an {@link ActiveClientIssue} under `issue.key` and emits `client-issue`;
2906
+ * - a **resolution** — `type` ends in `-resolved` and carries the same `key`; it closes the
2907
+ * matching active issue and emits `client-issue-resolved`.
2908
+ *
2909
+ * Keyless entries are one-shot: reported, never tracked. A re-raise of a key already active
2910
+ * refreshes the payload rather than opening a second interval.
2911
+ */
2599
2912
  addIssue(issue: ClientIssue): void;
2600
2913
  addExtensionStats(stats: ExtensionStat): void;
2914
+ /**
2915
+ * Close every still-open issue, e.g. because the client is going away. Emits
2916
+ * `client-issue-resolved` for each with the given `resolvedBy`, so correlators relying on the
2917
+ * active set don't keep a departed client's issues open forever.
2918
+ */
2919
+ resolveActiveIssues(resolvedBy?: ResolvedClientIssue['resolvedBy']): void;
2920
+ /** Close the active issue a `<type>-resolved` entry refers to, and announce the finished interval. */
2921
+ private _resolveIssue;
2601
2922
  private _processClientEvent;
2602
2923
  private _updatePeerConnection;
2603
2924
  private _mergePendingInjections;
@@ -2665,6 +2986,12 @@ interface Detector {
2665
2986
  readonly name: string;
2666
2987
  /** Called on every entity update; may raise issues via the entity it observes. */
2667
2988
  update(): void;
2989
+ /**
2990
+ * Optional teardown, called when the detector is removed from its registry (or the registry is
2991
+ * cleared, which happens when the owning call/observer closes). Implement it when the detector
2992
+ * subscribes to events or holds timers, so it doesn't leak.
2993
+ */
2994
+ close?(): void;
2668
2995
  }
2669
2996
 
2670
2997
  declare class Detectors {
@@ -2675,6 +3002,7 @@ declare class Detectors {
2675
3002
  remove(detector: Detector): void;
2676
3003
  update(): void;
2677
3004
  clear(): void;
3005
+ private _close;
2678
3006
  }
2679
3007
 
2680
3008
  type ObservedCallUpdateConfig = {
@@ -2844,6 +3172,13 @@ declare class Observer<AppData extends Record<string, unknown> = Record<string,
2844
3172
  numberOfPeerConnections: number;
2845
3173
  /** Global, pre-dispatch middleware chain run on every accepted sample. */
2846
3174
  readonly acceptMiddlewares: MiddlewareProcessor<AcceptMiddlewarePayload>;
3175
+ /**
3176
+ * Observer-scoped detector registry (ships empty), run on every `observer.update()` — the place
3177
+ * for findings that span **calls**, e.g. "many calls on the same SFU degraded at once". Detectors
3178
+ * raise findings with `observer.addIssue(...)`, surfaced on the bus as `observer-issue`.
3179
+ * (For findings within a single call use `observedCall.detectors`.)
3180
+ */
3181
+ readonly detectors: Detectors;
2847
3182
  constructor(config?: ObserverConfig<AppData>);
2848
3183
  get numberOfCalls(): number;
2849
3184
  get appData(): AppData | undefined;
@@ -2856,6 +3191,11 @@ declare class Observer<AppData extends Record<string, unknown> = Record<string,
2856
3191
  close(): void;
2857
3192
  accept(sample: ClientSample, context?: AcceptContext): void;
2858
3193
  update(): void;
3194
+ /**
3195
+ * Raise an observer-scoped (cross-call / SFU-wide) finding. Emitted on the bus as
3196
+ * `observer-issue`. Intended for `observer.detectors`, but the application may call it too.
3197
+ */
3198
+ addIssue(issue: ClientIssue): void;
2859
3199
  /** Emit an Observer-bus event. */
2860
3200
  private _notify;
2861
3201
  }
@@ -2894,6 +3234,911 @@ declare enum ClientEventTypes {
2894
3234
  DATA_CONSUMER_CLOSED = "DATA_CONSUMER_CLOSED"
2895
3235
  }
2896
3236
 
3237
+ /**
3238
+ * Small statistics helpers used by the aggregators and detectors.
3239
+ *
3240
+ * Rationale: a mean is a poor summary for call telemetry — one participant with a 1500 ms RTT
3241
+ * skews the average for nine healthy ones. Detectors should reason with medians, high percentiles
3242
+ * and "affected ratios" instead.
3243
+ */
3244
+ /** A distribution summary of a numeric sample set. */
3245
+ type StatsSummary = {
3246
+ count: number;
3247
+ min: number;
3248
+ max: number;
3249
+ mean: number;
3250
+ median: number;
3251
+ p75: number;
3252
+ p95: number;
3253
+ };
3254
+ /**
3255
+ * The p-th percentile (0..1) using linear interpolation between closest ranks.
3256
+ * Returns `undefined` for an empty input.
3257
+ */
3258
+ declare function percentile(values: number[], p: number): number | undefined;
3259
+ /** The median (50th percentile). `undefined` for an empty input. */
3260
+ declare function median(values: number[]): number | undefined;
3261
+ /** Summarize a numeric sample set. Returns `undefined` for an empty input. */
3262
+ declare function summarize(values: number[]): StatsSummary | undefined;
3263
+ /**
3264
+ * A counter-reset-safe delta: the increase of a cumulative counter between two observations.
3265
+ *
3266
+ * Returns `0` when the counter went backwards (reset / SSRC reuse) or when either side is missing.
3267
+ * NOTE the guard is `>=` on **defined** values — a previous value of `0` is a perfectly valid
3268
+ * baseline, so `0 -> 5` correctly yields `5` (using a truthiness check here silently drops the
3269
+ * first interval of every counter, which is exactly when the first loss/freeze event happens).
3270
+ */
3271
+ declare function counterDelta(previous: number | undefined, current: number | undefined): number;
3272
+ /** An entry retained by {@link SlidingWindow}. */
3273
+ type SlidingWindowEntry<T> = {
3274
+ timestamp: number;
3275
+ value: T;
3276
+ };
3277
+ /**
3278
+ * A time-bounded ring buffer used by detectors that reason over a window ("N of M clients degraded
3279
+ * within 10 s"). Entries older than `windowMs` are evicted on write and on read.
3280
+ */
3281
+ declare class SlidingWindow<T> {
3282
+ readonly windowMs: number;
3283
+ /** Optional hard cap on retained entries, to bound memory on very chatty inputs. */
3284
+ readonly maxEntries: number;
3285
+ private _entries;
3286
+ constructor(windowMs: number,
3287
+ /** Optional hard cap on retained entries, to bound memory on very chatty inputs. */
3288
+ maxEntries?: number);
3289
+ /** Add an entry (defaults to `Date.now()`), then evict anything outside the window. */
3290
+ add(value: T, timestamp?: number): void;
3291
+ /** The entries still inside the window, oldest first. */
3292
+ entries(now?: number): SlidingWindowEntry<T>[];
3293
+ /** The values still inside the window, oldest first. */
3294
+ values(now?: number): T[];
3295
+ get size(): number;
3296
+ clear(): void;
3297
+ private _evict;
3298
+ }
3299
+
3300
+ /**
3301
+ * Thresholds deciding when a single receiver counts as "degraded". A receiver is degraded when it
3302
+ * trips **any** of these in the current tick.
3303
+ */
3304
+ type ReceiverHealthThresholds = {
3305
+ /** Fraction of packets lost in the tick (0..1). */
3306
+ fractionLost: number;
3307
+ /** Freezes observed in the tick. */
3308
+ freezeCount: number;
3309
+ /** Fraction of received frames dropped before rendering (0..1). */
3310
+ framesDroppedRatio: number;
3311
+ /** Fraction of received audio samples concealed (0..1). */
3312
+ concealmentRatio: number;
3313
+ /** Mean jitter-buffer delay of the tick (ms). */
3314
+ jitterBufferDelayInMs: number;
3315
+ /** Round-trip time reported for the receiving peer connection (ms). */
3316
+ rttInMs: number;
3317
+ };
3318
+ declare const defaultReceiverHealthThresholds: ReceiverHealthThresholds;
3319
+ /** The per-receiver view the aggregator builds for one subscribed track. */
3320
+ type ReceiverDistributionEntry = {
3321
+ observedInboundTrack: ObservedInboundTrack;
3322
+ clientId: string;
3323
+ peerConnectionId: string;
3324
+ degraded: boolean;
3325
+ /** Why it was marked degraded (empty when healthy). */
3326
+ reasons: string[];
3327
+ bitrate: number;
3328
+ fractionLost?: number;
3329
+ jitter?: number;
3330
+ rttInMs?: number;
3331
+ jitterBufferDelayInMs?: number;
3332
+ concealmentRatio?: number;
3333
+ framesDroppedRatio?: number;
3334
+ deltaFreezeCount: number;
3335
+ deltaPliCount: number;
3336
+ deltaNackCount: number;
3337
+ deltaPacketsReceived: number;
3338
+ };
3339
+ /** The publisher side of the distribution. */
3340
+ type PublisherDistributionEntry = {
3341
+ observedOutboundTrack: ObservedOutboundTrack;
3342
+ clientId: string;
3343
+ peerConnectionId: string;
3344
+ /** `true` when the publisher's own egress looks fine (so degradation is downstream). */
3345
+ healthy: boolean;
3346
+ reasons: string[];
3347
+ bitrate: number;
3348
+ /** Loss reported back by the SFU/remote via RTCP (0..1). */
3349
+ remoteFractionLost?: number;
3350
+ remoteRttInMs?: number;
3351
+ qualityLimitationReason?: string;
3352
+ deltaPacketsSent: number;
3353
+ };
3354
+ /**
3355
+ * One published track and everything observed about how it was delivered to its subscribers.
3356
+ * This is the primitive most cross-client detectors are built on.
3357
+ */
3358
+ type ObservedTrackDistribution = {
3359
+ trackId: string;
3360
+ kind: string;
3361
+ publisher: PublisherDistributionEntry;
3362
+ receivers: ReceiverDistributionEntry[];
3363
+ numberOfReceivers: number;
3364
+ numberOfHealthyReceivers: number;
3365
+ numberOfDegradedReceivers: number;
3366
+ /** degradedReceivers / receivers (0..1); `0` when there are no receivers. */
3367
+ degradedRatio: number;
3368
+ /** Distribution summaries across receivers (undefined when no receiver reported the metric). */
3369
+ bitrate?: StatsSummary;
3370
+ fractionLost?: StatsSummary;
3371
+ jitter?: StatsSummary;
3372
+ rttInMs?: StatsSummary;
3373
+ jitterBufferDelayInMs?: StatsSummary;
3374
+ concealmentRatio?: StatsSummary;
3375
+ /** Fan-out counters: how many receivers saw the symptom, and the total across them. */
3376
+ freezes: {
3377
+ affectedReceivers: number;
3378
+ total: number;
3379
+ };
3380
+ plis: {
3381
+ affectedReceivers: number;
3382
+ total: number;
3383
+ };
3384
+ concealment: {
3385
+ affectedReceivers: number;
3386
+ };
3387
+ };
3388
+ /**
3389
+ * Builds {@link ObservedTrackDistribution}s by walking
3390
+ * `ObservedOutboundTrack.remoteInboundTracks` — the publisher → subscribers links maintained by a
3391
+ * `RemoteTrackResolver`. Without a configured resolver there are no links and the aggregator
3392
+ * yields nothing.
3393
+ *
3394
+ * It is stateless per call: build it once and call `aggregate()` on each `call.update()`.
3395
+ */
3396
+ declare class TrackDistributionAggregator {
3397
+ private readonly _call;
3398
+ readonly thresholds: ReceiverHealthThresholds;
3399
+ constructor(_call: ObservedCall, thresholds?: ReceiverHealthThresholds);
3400
+ /** Aggregate every published track in the call that currently has linked subscribers. */
3401
+ aggregate(): ObservedTrackDistribution[];
3402
+ /** Aggregate a single published track, or `undefined` when it has no linked subscribers. */
3403
+ aggregateTrack(outboundTrack: ObservedOutboundTrack): ObservedTrackDistribution | undefined;
3404
+ private _publisherEntry;
3405
+ private _receiverEntry;
3406
+ }
3407
+
3408
+ /** Thresholds deciding when a client counts as degraded on the receiving / sending side. */
3409
+ type ClientHealthThresholds = {
3410
+ /** Inbound loss fraction across the client's received streams (0..1). */
3411
+ inboundFractionLost: number;
3412
+ /** Loss fraction reported back about the client's sent streams via RTCP (0..1). */
3413
+ outboundFractionLost: number;
3414
+ /** Round-trip time (ms). */
3415
+ rttInMs: number;
3416
+ /** Freezes observed across the client's inbound video in the tick. */
3417
+ freezeCount: number;
3418
+ /** Concealment fraction across the client's inbound audio (0..1). */
3419
+ concealmentRatio: number;
3420
+ };
3421
+ declare const defaultClientHealthThresholds: ClientHealthThresholds;
3422
+ /** The per-client health view, split by direction. */
3423
+ type ClientHealth = {
3424
+ observedClient: ObservedClient;
3425
+ clientId: string;
3426
+ /** Receiving (download) side is impaired. */
3427
+ inboundDegraded: boolean;
3428
+ /** Sending (upload) side is impaired. */
3429
+ outboundDegraded: boolean;
3430
+ /** `inboundDegraded || outboundDegraded`. */
3431
+ degraded: boolean;
3432
+ reasons: string[];
3433
+ inboundFractionLost?: number;
3434
+ outboundFractionLost?: number;
3435
+ rttInMs?: number;
3436
+ deltaFreezeCount: number;
3437
+ concealmentRatio?: number;
3438
+ /** Quality-limitation reasons seen on this client's outbound video ('cpu' | 'bandwidth' | …). */
3439
+ qualityLimitationReasons: string[];
3440
+ usingTURN: boolean;
3441
+ usingTCP: boolean;
3442
+ };
3443
+ /** Call-level rollup of the per-client health, using percentiles rather than means. */
3444
+ type CallHealth = {
3445
+ callId: string;
3446
+ clients: ClientHealth[];
3447
+ numberOfClients: number;
3448
+ numberOfDegradedClients: number;
3449
+ numberOfInboundDegradedClients: number;
3450
+ numberOfOutboundDegradedClients: number;
3451
+ /** degradedClients / clients (0..1). */
3452
+ degradedRatio: number;
3453
+ inboundDegradedRatio: number;
3454
+ outboundDegradedRatio: number;
3455
+ rttInMs?: StatsSummary;
3456
+ inboundFractionLost?: StatsSummary;
3457
+ concealmentRatio?: StatsSummary;
3458
+ /** How many clients reported each quality-limitation reason on their outbound video. */
3459
+ qualityLimitation: {
3460
+ cpu: number;
3461
+ bandwidth: number;
3462
+ other: number;
3463
+ };
3464
+ freezes: {
3465
+ affectedClients: number;
3466
+ total: number;
3467
+ };
3468
+ };
3469
+ /**
3470
+ * Aggregates a call along the **client** axis (as `TrackDistributionAggregator` does along the
3471
+ * publisher→subscriber axis): per-client health split into sending vs receiving, plus percentile
3472
+ * rollups and "affected ratio" counts for the whole call.
3473
+ *
3474
+ * Build once per call and call `aggregate()` on each `call.update()`.
3475
+ */
3476
+ declare class CallHealthAggregator {
3477
+ private readonly _call;
3478
+ readonly thresholds: ClientHealthThresholds;
3479
+ constructor(_call: ObservedCall, thresholds?: ClientHealthThresholds);
3480
+ aggregate(): CallHealth;
3481
+ private _clientHealth;
3482
+ }
3483
+
3484
+ /** The finding types this detector raises (as `call-issue.type`). */
3485
+ declare const CommonSourceDegradationTypes: {
3486
+ /** The publisher's egress looks fine, yet most subscribers are degraded → downstream/SFU suspected. */
3487
+ readonly publisherHealthySubscribersDegraded: "PUBLISHER_HEALTHY_SUBSCRIBERS_DEGRADED";
3488
+ /** The publisher itself is impaired and every subscriber sees it → source-side problem. */
3489
+ readonly publisherDegradedForAllSubscribers: "PUBLISHER_DEGRADED_FOR_ALL_SUBSCRIBERS";
3490
+ /** Exactly one subscriber is degraded while the rest are fine → that receiver's own problem. */
3491
+ readonly singleSubscriberDegraded: "SINGLE_SUBSCRIBER_DEGRADED";
3492
+ /** Several (but not most) subscribers degraded on the same source. */
3493
+ readonly multipleSubscribersDegraded: "MULTIPLE_SUBSCRIBERS_DEGRADED";
3494
+ };
3495
+ type CommonSourceDegradationDetectorConfig = {
3496
+ /** Minimum subscribers before a ratio is meaningful. Default `3`. */
3497
+ minReceivers: number;
3498
+ /** degradedRatio at/above which the problem is treated as common to the source. Default `0.6`. */
3499
+ degradedRatioThreshold: number;
3500
+ /** Per-receiver health thresholds (forwarded to the aggregator). */
3501
+ thresholds?: Partial<ReceiverHealthThresholds>;
3502
+ /**
3503
+ * Consecutive ticks a condition must hold before an issue is raised, to avoid flapping on a
3504
+ * single bad sample. Default `2`.
3505
+ */
3506
+ consecutiveTicks: number;
3507
+ };
3508
+ /**
3509
+ * Compares a published track against **all** of its subscribers and decides where the fault lies.
3510
+ *
3511
+ * This is the detector that only a server-side observer can run: a single browser cannot know
3512
+ * whether the other participants receiving the same source see the same thing. Requires a
3513
+ * `RemoteTrackResolver` (`ObserverConfig.createTrackResolver`) — without publisher↔subscriber links
3514
+ * there is nothing to compare and the detector stays silent.
3515
+ */
3516
+ declare class CommonSourceDegradationDetector implements Detector {
3517
+ private readonly _call;
3518
+ readonly name = "common-source-degradation-detector";
3519
+ private readonly _config;
3520
+ private readonly _aggregator;
3521
+ /** trackId -> consecutive ticks the same finding held. */
3522
+ private readonly _streaks;
3523
+ /** The distributions computed on the most recent `update()` (handy for dashboards). */
3524
+ lastDistributions: ObservedTrackDistribution[];
3525
+ constructor(_call: ObservedCall, config?: Partial<CommonSourceDegradationDetectorConfig>);
3526
+ update(): void;
3527
+ private _classify;
3528
+ private _payload;
3529
+ }
3530
+
3531
+ declare const CallWideDegradationTypes: {
3532
+ /** Most participants degraded at once → shared cause (call/SFU/network), not individual endpoints. */
3533
+ readonly callWideQualityDegradation: "CALL_WIDE_QUALITY_DEGRADATION";
3534
+ /** Most participants' **receiving** side degraded → downstream/egress suspected. */
3535
+ readonly callWideInboundDegradation: "CALL_WIDE_INBOUND_DEGRADATION";
3536
+ /** Most participants' **sending** side degraded → ingress suspected. */
3537
+ readonly callWideOutboundDegradation: "CALL_WIDE_OUTBOUND_DEGRADATION";
3538
+ };
3539
+ type CallWideDegradationDetectorConfig = {
3540
+ /** Minimum participants before a ratio means anything. Default `3`. */
3541
+ minClients: number;
3542
+ /** Affected-client ratio at/above which the call is considered call-wide degraded. Default `0.5`. */
3543
+ degradedRatioThreshold: number;
3544
+ /** Per-client health thresholds. */
3545
+ thresholds?: Partial<ClientHealthThresholds>;
3546
+ /** Consecutive ticks the condition must hold before raising. Default `2`. */
3547
+ consecutiveTicks: number;
3548
+ };
3549
+ /**
3550
+ * Raises a call-level finding when a **majority of participants** are degraded in the same window —
3551
+ * the signal that something shared is wrong rather than one person's Wi-Fi.
3552
+ *
3553
+ * It also splits by direction, because that is what makes the finding actionable: if most clients'
3554
+ * receiving side is bad the suspicion is egress/downstream; if most clients' sending side is bad it
3555
+ * points at ingress. Reported with medians/percentiles, never means, so a single 1500 ms outlier
3556
+ * can't manufacture (or mask) a call-wide alert.
3557
+ */
3558
+ declare class CallWideDegradationDetector implements Detector {
3559
+ private readonly _call;
3560
+ readonly name = "call-wide-degradation-detector";
3561
+ private readonly _config;
3562
+ private readonly _aggregator;
3563
+ private _streak?;
3564
+ /** The health rollup computed on the most recent `update()`. */
3565
+ lastHealth?: CallHealth;
3566
+ constructor(_call: ObservedCall, config?: Partial<CallWideDegradationDetectorConfig>);
3567
+ update(): void;
3568
+ private _classify;
3569
+ private _payload;
3570
+ }
3571
+
3572
+ declare const PliAndFreezeFanOutTypes: {
3573
+ /** Many receivers of the same publisher requested keyframes at once. */
3574
+ readonly publisherPliStorm: "PUBLISHER_PLI_STORM";
3575
+ /** Many receivers of the same publisher froze at once. */
3576
+ readonly publishedVideoFrozenForMultipleReceivers: "PUBLISHED_VIDEO_FROZEN_FOR_MULTIPLE_RECEIVERS";
3577
+ };
3578
+ type PliAndFreezeFanOutDetectorConfig = {
3579
+ /** Minimum receivers of the track before a fan-out ratio is meaningful. Default `3`. */
3580
+ minReceivers: number;
3581
+ /** Fraction of receivers that must be affected within the window. Default `0.5`. */
3582
+ affectedRatioThreshold: number;
3583
+ /** The correlation window (ms) symptoms are counted over. Default `10_000`. */
3584
+ windowMs: number;
3585
+ /** Minimum PLIs across receivers inside the window before a storm is declared. Default `5`. */
3586
+ minPliCount: number;
3587
+ /** Re-arm time (ms) before the same finding can be raised again for a track. Default `30_000`. */
3588
+ cooldownMs: number;
3589
+ thresholds?: Partial<ReceiverHealthThresholds>;
3590
+ };
3591
+ /**
3592
+ * Detects **fan-out** symptoms: one publisher's stream causing many receivers to request keyframes
3593
+ * (PLI) or to freeze within the same window.
3594
+ *
3595
+ * A single receiver sending PLIs is unremarkable — it lost some packets. Nineteen of twenty
3596
+ * receivers of the *same* source doing it inside ten seconds is not twenty coincidental network
3597
+ * faults; it points at the source's output, the SFU's forwarding, or a keyframe/burst problem
3598
+ * upstream. That distinction requires seeing every subscriber of a track at once, which is why this
3599
+ * belongs server-side.
3600
+ *
3601
+ * Symptoms are accumulated in a sliding window rather than judged per tick, because a burst is
3602
+ * spread over a few samples.
3603
+ */
3604
+ declare class PliAndFreezeFanOutDetector implements Detector {
3605
+ private readonly _call;
3606
+ readonly name = "pli-and-freeze-fan-out-detector";
3607
+ private readonly _config;
3608
+ private readonly _aggregator;
3609
+ private readonly _windows;
3610
+ constructor(_call: ObservedCall, config?: Partial<PliAndFreezeFanOutDetectorConfig>);
3611
+ update(): void;
3612
+ close(): void;
3613
+ private _evaluate;
3614
+ private _windowOf;
3615
+ }
3616
+
3617
+ declare const AudioImpairmentFanOutTypes: {
3618
+ /** Most receivers of one microphone are concealing audio → the problem follows that source. */
3619
+ readonly publishedAudioDegradedForMajority: "PUBLISHED_AUDIO_DEGRADED_FOR_MAJORITY";
3620
+ /** Receivers across the call are under jitter-buffer pressure → shared delivery problem. */
3621
+ readonly callWideAudioJitterBufferStress: "CALL_WIDE_AUDIO_JITTER_BUFFER_STRESS";
3622
+ };
3623
+ type AudioImpairmentFanOutDetectorConfig = {
3624
+ /** Minimum receivers of a track before a ratio is meaningful. Default `3`. */
3625
+ minReceivers: number;
3626
+ /** Fraction of receivers that must be impaired. Default `0.6`. */
3627
+ affectedRatioThreshold: number;
3628
+ /** Jitter-buffer delay (ms) above which a receiver counts as stressed. Default `500`. */
3629
+ jitterBufferDelayInMs: number;
3630
+ /** Minimum audio receivers in the call before the call-wide check runs. Default `5`. */
3631
+ minCallReceivers: number;
3632
+ /** Consecutive ticks the condition must hold before raising. Default `2`. */
3633
+ consecutiveTicks: number;
3634
+ thresholds?: Partial<ReceiverHealthThresholds>;
3635
+ };
3636
+ /**
3637
+ * Audio-side fan-out analysis, using the concealment / jitter-buffer metrics WebRTC exposes on the
3638
+ * receiver (NetEQ's own account of how hard it is working to keep audio smooth).
3639
+ *
3640
+ * Two questions a browser can't answer:
3641
+ *
3642
+ * 1. *Does the impairment follow the source?* If every receiver of Alice's microphone is concealing
3643
+ * ~20% of samples while every receiver of Bob's is at ~0.1%, the fault is on Alice's path, not on
3644
+ * the receivers.
3645
+ * 2. *Is the whole call's audio delivery under pressure?* One receiver with a huge jitter buffer is
3646
+ * their own network; fifteen of twenty at once is shared.
3647
+ */
3648
+ declare class AudioImpairmentFanOutDetector implements Detector {
3649
+ private readonly _call;
3650
+ readonly name = "audio-impairment-fan-out-detector";
3651
+ private readonly _config;
3652
+ private readonly _aggregator;
3653
+ private readonly _trackStreaks;
3654
+ private _callStreak;
3655
+ constructor(_call: ObservedCall, config?: Partial<AudioImpairmentFanOutDetectorConfig>);
3656
+ update(): void;
3657
+ close(): void;
3658
+ private _evaluateCallWide;
3659
+ }
3660
+
3661
+ declare const IceDisruptionTypes: {
3662
+ /** Many participants lost/failed their ICE connection inside the same window. */
3663
+ readonly callIceDisruption: "CALL_ICE_DISRUPTION";
3664
+ };
3665
+ type IceDisruptionDetectorConfig = {
3666
+ /** Minimum participants before a ratio is meaningful. Default `3`. */
3667
+ minClients: number;
3668
+ /** Fraction of participants that must be disrupted within the window. Default `0.5`. */
3669
+ affectedRatioThreshold: number;
3670
+ /** Correlation window (ms). Default `10_000`. */
3671
+ windowMs: number;
3672
+ /** Re-arm time (ms) before raising again. Default `60_000`. */
3673
+ cooldownMs: number;
3674
+ };
3675
+ /**
3676
+ * Detects an **ICE disruption storm**: many participants of a call losing connectivity inside the
3677
+ * same short window.
3678
+ *
3679
+ * One client losing ICE is routine (they walked out of Wi-Fi range). Twenty-eight of thirty-five
3680
+ * doing it within five seconds is an infrastructure event, and that conclusion is only reachable by
3681
+ * correlating clients — which is the whole point of doing it here rather than in the browser.
3682
+ *
3683
+ * ### Prefer the issue-driven path
3684
+ *
3685
+ * This detector works from **raw ICE state transitions**, which is the fallback for clients that do
3686
+ * not report issues. If your clients run client-monitor-js >= 4.6.0, prefer
3687
+ * `ConcurrentIssueDetector` configured with the ICE issue types instead:
3688
+ *
3689
+ * ```ts
3690
+ * new ConcurrentIssueDetector(observedCall, {
3691
+ * issueTypes: [ 'ice-disconnected', 'ice-connection-failed', 'ice-transport-stalled', 'unstable-ice-path' ],
3692
+ * });
3693
+ * ```
3694
+ *
3695
+ * The client's own detector is better at deciding *whether* a transport is really disrupted: it
3696
+ * raises `ice-disconnected` only once `disconnected` has persisted past a threshold, so the transient
3697
+ * blips ICE routinely heals on its own never produce an issue at all, and it distinguishes a terminal
3698
+ * `failed` from a stalled transport from an unstable path. This detector cannot make those
3699
+ * distinctions — a raw `disconnected` that recovers in 200 ms looks identical to one that never does.
3700
+ * It also gains resolution intervals, so "they all recovered together" becomes observable.
3701
+ *
3702
+ * ### Why this one subscribes to the bus
3703
+ *
3704
+ * ICE transitions are discrete events that can occur and revert between two `update()` ticks;
3705
+ * polling `iceConnectionState` per tick would miss short flaps. It therefore implements `close()`
3706
+ * to drop its listeners (called automatically when the call closes or the detector is removed).
3707
+ */
3708
+ declare class IceDisruptionDetector implements Detector {
3709
+ private readonly _call;
3710
+ readonly name = "ice-disruption-detector";
3711
+ private readonly _config;
3712
+ /** clientId -> the last time that client was seen disrupted, inside the window. */
3713
+ private readonly _disruptions;
3714
+ private _lastRaisedAt;
3715
+ private readonly _onIceStateChanged;
3716
+ private readonly _onConnectionStateChanged;
3717
+ constructor(_call: ObservedCall, config?: Partial<IceDisruptionDetectorConfig>);
3718
+ update(): void;
3719
+ close(): void;
3720
+ }
3721
+
3722
+ declare const TurnServerHealthTypes: {
3723
+ /** One TURN server's clients are degraded while other servers' clients are fine. */
3724
+ readonly turnServerDegraded: "TURN_SERVER_DEGRADED";
3725
+ };
3726
+ type TurnServerHealthDetectorConfig = {
3727
+ /** Minimum clients on a server before a ratio is meaningful. Default `5`. */
3728
+ minClientsPerServer: number;
3729
+ /** Fraction of a server's clients that must be degraded. Default `0.5`. */
3730
+ degradedRatioThreshold: number;
3731
+ /** RTT (ms) above which a relayed peer connection counts as degraded. Default `400`. */
3732
+ rttInMs: number;
3733
+ /** Inbound loss fraction above which a relayed peer connection counts as degraded. Default `0.03`. */
3734
+ fractionLost: number;
3735
+ /** Consecutive ticks the condition must hold before raising. Default `2`. */
3736
+ consecutiveTicks: number;
3737
+ /** Re-arm time (ms) before raising again for the same server. Default `60_000`. */
3738
+ cooldownMs: number;
3739
+ };
3740
+ /** The per-server view this detector builds. */
3741
+ type TurnServerHealth = {
3742
+ serverUrl: string;
3743
+ peerConnections: number;
3744
+ degradedPeerConnections: number;
3745
+ degradedRatio: number;
3746
+ rttInMs?: StatsSummary;
3747
+ fractionLost?: StatsSummary;
3748
+ affectedClientIds: string[];
3749
+ };
3750
+ /**
3751
+ * An **observer-level** detector that groups relayed peer connections by the TURN server carrying
3752
+ * them and compares the servers against each other.
3753
+ *
3754
+ * Counting TURN usage is not useful on its own; knowing that `turn-eu-1` has 22 of 30 clients in
3755
+ * trouble while `turn-eu-2` has 1 of 34 is. Because the comparison spans calls, it lives on
3756
+ * `observer.detectors` and raises `observer-issue` — a single actionable alert instead of fifty
3757
+ * per-client ones.
3758
+ */
3759
+ declare class TurnServerHealthDetector implements Detector {
3760
+ private readonly _observer;
3761
+ readonly name = "turn-server-health-detector";
3762
+ private readonly _config;
3763
+ private readonly _streaks;
3764
+ private readonly _lastRaisedAt;
3765
+ /** The per-server rollup computed on the most recent `update()`. */
3766
+ lastServers: TurnServerHealth[];
3767
+ constructor(_observer: Observer, config?: Partial<TurnServerHealthDetectorConfig>);
3768
+ update(): void;
3769
+ close(): void;
3770
+ private _serverHealth;
3771
+ }
3772
+
3773
+ /** A snapshot of everyone currently reporting one issue type. */
3774
+ type IssueCohort = {
3775
+ /** The issue type, without the `-resolved` suffix. */
3776
+ type: string;
3777
+ /** The open intervals of that type, one per (client, key). */
3778
+ issues: ActiveClientIssue[];
3779
+ /** Distinct clients with at least one open interval of this type. */
3780
+ clientIds: string[];
3781
+ /** `clientIds.length / totalClients` (0..1). */
3782
+ affectedRatio: number;
3783
+ /** Clients considered for the ratio. */
3784
+ totalClients: number;
3785
+ /**
3786
+ * Spread of the onsets, in **observer** time (ms) — `max(observedAt) - min(observedAt)`.
3787
+ *
3788
+ * Measured on the observer clock on purpose: `raisedAt` comes from each client's own clock, and
3789
+ * comparing those across machines makes clock skew look like an infrastructure event. A small
3790
+ * spread means the issues began together, which is the signature of a shared cause.
3791
+ */
3792
+ onsetSpreadInMs: number;
3793
+ /** The earliest onset (observer clock). */
3794
+ firstObservedAt: number;
3795
+ };
3796
+ /** Options controlling how long an unresolved issue is trusted. */
3797
+ type IssueRegistryConfig = {
3798
+ /**
3799
+ * Drop an active issue this long after it was opened, even without a resolution (ms).
3800
+ *
3801
+ * A safety net for the case the lifecycle can't cover: a client that dies without its monitor
3802
+ * running `close()` never sends the `-resolved` companion, and a stuck "active" issue would make
3803
+ * every concurrency detector fire forever. Default `120_000`.
3804
+ */
3805
+ maxIssueAgeInMs: number;
3806
+ };
3807
+ /**
3808
+ * Read-side index over the **active** client issues in a call (or across an observer's calls).
3809
+ *
3810
+ * `ObservedClient.activeIssues` holds the raw per-client state; this puts the questions detectors
3811
+ * actually ask on top of it:
3812
+ *
3813
+ * - *who currently has issue X?* → {@link cohortOf}
3814
+ * - *which issue types are shared by several clients right now?* → {@link cohorts}
3815
+ * - *which receivers of this published track are broken?* → {@link byTrackIds}
3816
+ *
3817
+ * The distinction that matters is **concurrency**. A window-based count ("N clients reported
3818
+ * congestion in the last 10 s") is a heuristic that has to guess whether the symptoms are still
3819
+ * happening; an active-issue set is ground truth, because the client tells the server when the
3820
+ * episode ends. Overlapping intervals are much stronger evidence of a common cause than
3821
+ * near-in-time reports.
3822
+ *
3823
+ * It is a *view*, holding no state of its own beyond configuration, so it can be constructed per
3824
+ * detector and queried on every `update()`.
3825
+ */
3826
+ declare class IssueRegistry {
3827
+ private readonly _source;
3828
+ private readonly _config;
3829
+ constructor(_source: ObservedCall | Observer, config?: Partial<IssueRegistryConfig>);
3830
+ /** Every open issue in scope, with stale entries filtered out. */
3831
+ activeIssues(now?: number): ActiveClientIssue[];
3832
+ /** The number of clients in scope — the denominator for every ratio. */
3833
+ get totalClients(): number;
3834
+ /** The cohort for one issue type (empty `issues` when nobody has it open). */
3835
+ cohortOf(type: string, now?: number): IssueCohort;
3836
+ /** One cohort per issue type currently open somewhere in scope, largest first. */
3837
+ cohorts(now?: number): IssueCohort[];
3838
+ /**
3839
+ * Open issues attributed to any of the given track ids — the join that turns a receiver-side
3840
+ * issue into a statement about a **published** track (`trackId` → inbound track →
3841
+ * `remoteOutboundTrack` → publisher).
3842
+ */
3843
+ byTrackIds(trackIds: Iterable<string>, now?: number): ActiveClientIssue[];
3844
+ /** Open issues reported by one client. */
3845
+ byClientId(clientId: string, now?: number): ActiveClientIssue[];
3846
+ private _toCohort;
3847
+ /** Works for both scopes: a call yields its clients, an observer yields every call's clients. */
3848
+ private _clients;
3849
+ }
3850
+
3851
+ declare const ConcurrentIssueTypes: {
3852
+ /** Several participants have the same issue open **at the same time**. */
3853
+ readonly concurrentClientIssues: "CONCURRENT_CLIENT_ISSUES";
3854
+ /** Those issues also *began* together — the signature of one infrastructure event. */
3855
+ readonly issueOnsetBurst: "ISSUE_ONSET_BURST";
3856
+ };
3857
+ type ConcurrentIssueDetectorConfig = {
3858
+ /**
3859
+ * Only consider these issue types. Empty (default) means every type the clients report — which
3860
+ * is usually what you want, since the detector is generic over the client's vocabulary.
3861
+ */
3862
+ issueTypes: string[];
3863
+ /** Minimum participants in scope before a ratio is meaningful. Default `3`. */
3864
+ minClients: number;
3865
+ /** Minimum distinct clients sharing the open issue. Default `3`. */
3866
+ minAffectedClients: number;
3867
+ /** Fraction of participants that must share it. Default `0.5`. */
3868
+ affectedRatioThreshold: number;
3869
+ /**
3870
+ * When the onsets of a qualifying cohort fall within this span (ms), the finding is escalated to
3871
+ * `ISSUE_ONSET_BURST` — they didn't just overlap, they started together. Default `2_000`.
3872
+ */
3873
+ onsetBurstWindowInMs: number;
3874
+ /** Re-arm time (ms) per issue type. Default `60_000`. */
3875
+ cooldownMs: number;
3876
+ /** Forwarded to the {@link IssueRegistry} (stale-issue expiry). */
3877
+ registry?: Partial<IssueRegistryConfig>;
3878
+ };
3879
+ /**
3880
+ * Raises a finding when **several participants have the same issue open simultaneously**.
3881
+ *
3882
+ * This is the generic replacement for a family of symptom-specific detectors. The client already
3883
+ * decides *what* is wrong for itself — `congestion`, `ice-disconnected`, `audio-concealment`,
3884
+ * `video-decoder-overloaded`, and so on — with detectors that have hysteresis and multi-signal
3885
+ * confirmation behind them. Re-deriving those verdicts from raw counters server-side would be
3886
+ * strictly worse. What the server uniquely knows is *how many other participants are in the same
3887
+ * state right now*, which is exactly the difference between "one person's Wi-Fi" and "our problem".
3888
+ *
3889
+ * Concurrency is judged from the **active issue set** (open interval per key), not from a sliding
3890
+ * window of recent reports. That distinction matters: a window has to guess whether a symptom is
3891
+ * still happening, whereas an interval is closed by the client when the episode actually ends
3892
+ * (client-monitor-js >= 4.6.0 ships the `<type>-resolved` companion for this purpose).
3893
+ *
3894
+ * When the onsets also cluster inside `onsetBurstWindowInMs`, the finding is escalated to
3895
+ * `ISSUE_ONSET_BURST`: participants degrading *together within a couple of seconds* is far more
3896
+ * likely to be a deploy, a TURN failover or a link flap than a coincidence. Onsets are compared on
3897
+ * the **observer clock** (`observedAt`), never on client clocks, because clock skew between
3898
+ * machines would otherwise masquerade as a synchronized event.
3899
+ *
3900
+ * Works at both scopes — pass an `ObservedCall` for "this meeting", or the `Observer` for
3901
+ * "everything this SFU is serving".
3902
+ */
3903
+ declare class ConcurrentIssueDetector implements Detector {
3904
+ private readonly _scope;
3905
+ readonly name = "concurrent-issue-detector";
3906
+ private readonly _config;
3907
+ private readonly _registry;
3908
+ private readonly _lastRaisedAt;
3909
+ private readonly _isObserverScope;
3910
+ /** The cohorts that qualified on the most recent `update()`. */
3911
+ lastCohorts: IssueCohort[];
3912
+ constructor(_scope: ObservedCall | Observer, config?: Partial<ConcurrentIssueDetectorConfig>);
3913
+ update(): void;
3914
+ close(): void;
3915
+ private _raise;
3916
+ }
3917
+
3918
+ declare const IssueFanOutTypes: {
3919
+ /** Most receivers of one published track have the same issue open → the fault follows the source. */
3920
+ readonly publishedTrackIssueFanOut: "PUBLISHED_TRACK_ISSUE_FAN_OUT";
3921
+ /** Exactly one receiver of a track has it → that receiver's own problem. */
3922
+ readonly singleReceiverIssue: "SINGLE_RECEIVER_ISSUE";
3923
+ };
3924
+ type IssueFanOutDetectorConfig = {
3925
+ /** Only consider these issue types. Empty (default) = every type the receivers report. */
3926
+ issueTypes: string[];
3927
+ /** Minimum receivers of the track before a ratio is meaningful. Default `3`. */
3928
+ minReceivers: number;
3929
+ /** Fraction of a track's receivers that must share the issue. Default `0.6`. */
3930
+ affectedRatioThreshold: number;
3931
+ /** Also report the "only one receiver is affected" case. Default `true`. */
3932
+ reportSingleReceiver: boolean;
3933
+ /** Re-arm time (ms) per (track, issue type). Default `60_000`. */
3934
+ cooldownMs: number;
3935
+ registry?: Partial<IssueRegistryConfig>;
3936
+ };
3937
+ /**
3938
+ * Attributes **client-reported issues to the published track they are about**, then asks how far
3939
+ * the problem fans out across that track's receivers.
3940
+ *
3941
+ * The join is what makes this possible: a receiver-side issue payload carries `trackId` (the client
3942
+ * detectors report it for every track-scoped issue), the observer resolves that to an inbound track,
3943
+ * and `RemoteTrackResolver` links the inbound track to the `remoteOutboundTrack` that published it.
3944
+ * With the whole subscriber set of one source in hand, the verdict is straightforward and is the
3945
+ * single most useful thing a server can say:
3946
+ *
3947
+ * - **most receivers of Alice's track are affected** → the fault is on Alice's path — her uplink, the
3948
+ * SFU's ingress, or its forwarding of that stream. Corroborated by whether the publisher's own
3949
+ * egress looks healthy.
3950
+ * - **one receiver of Alice's track is affected** → that receiver's downlink. Nothing to do with
3951
+ * Alice, even though the symptom is reported against her stream.
3952
+ *
3953
+ * Note this is deliberately generic over the issue vocabulary: `freezed-video-track`,
3954
+ * `keyframe-storm`, `audio-concealment`, `video-decoder-overloaded`, `stuck-decoder` and anything a
3955
+ * custom client detector invents all fan out the same way, so one mechanism replaces a family of
3956
+ * symptom-specific detectors.
3957
+ */
3958
+ declare class IssueFanOutDetector implements Detector {
3959
+ private readonly _call;
3960
+ readonly name = "issue-fan-out-detector";
3961
+ private readonly _config;
3962
+ private readonly _registry;
3963
+ private readonly _aggregator;
3964
+ private readonly _lastRaisedAt;
3965
+ constructor(_call: ObservedCall, config?: Partial<IssueFanOutDetectorConfig>);
3966
+ update(): void;
3967
+ close(): void;
3968
+ /** Exposed for tests/dashboards: the distributions this detector reasons over. */
3969
+ distributionsOf(): ObservedOutboundTrack[];
3970
+ }
3971
+
3972
+ declare const WorstReceiverContagionTypes: {
3973
+ /**
3974
+ * A publisher's sending bitrate is tracking its **worst** receiver — one bad downlink is
3975
+ * dragging the quality everyone else gets.
3976
+ */
3977
+ readonly worstReceiverContagion: "WORST_RECEIVER_CONTAGION";
3978
+ };
3979
+ type WorstReceiverContagionDetectorConfig = {
3980
+ /** Minimum receivers before the comparison means anything. Default `3`. */
3981
+ minReceivers: number;
3982
+ /** Observation window (ms) the correlation is computed over. Default `30_000`. */
3983
+ windowMs: number;
3984
+ /** Minimum samples inside the window before judging. Default `4`. */
3985
+ minSamples: number;
3986
+ /**
3987
+ * How closely the publisher's bitrate must track the worst receiver's, relative to the spread
3988
+ * between the worst and the median receiver. Default `0.75`.
3989
+ */
3990
+ trackingRatioThreshold: number;
3991
+ /**
3992
+ * The worst receiver must be this much worse than the median receiver before the situation even
3993
+ * counts as "one bad apple" (0..1 of the median). Default `0.5` — i.e. at most half.
3994
+ */
3995
+ outlierRatioThreshold: number;
3996
+ /** Re-arm time (ms) per track. Default `120_000`. */
3997
+ cooldownMs: number;
3998
+ };
3999
+ /**
4000
+ * Detects the classic SFU misconfiguration: **the sender adapting to the worst receiver**.
4001
+ *
4002
+ * In a correctly built SFU the RTCP feedback loop is *terminated* at the server — each receiver's
4003
+ * reports drive what that receiver is sent, and the publisher encodes for the server, not for the
4004
+ * unluckiest participant. When the loop is instead relayed end to end, the publisher's bandwidth
4005
+ * estimate collapses to the minimum across all receivers, so a single participant on a bad 3G link
4006
+ * silently downgrades the stream *everyone* sees. Simulcast exists precisely to prevent this
4007
+ * "lowest common denominator" outcome, and its absence (or an SFU that forwards RR/REMB verbatim)
4008
+ * reproduces it.
4009
+ *
4010
+ * The signature is a correlation, not a threshold, so it is judged over a window: the publisher's
4011
+ * outbound bitrate moving in lockstep with the *worst* receiver's inbound bitrate, while the median
4012
+ * receiver has ample headroom. A publisher that drops because of its own uplink or CPU shows no such
4013
+ * relationship — every receiver falls together and there is no outlier to track.
4014
+ *
4015
+ * This is arguably the most valuable thing an observer can detect, because the damage is invisible
4016
+ * from every individual endpoint: the publisher sees "my bitrate went down", each healthy receiver
4017
+ * sees "my video got worse", and nobody can see the causal link except the server.
4018
+ */
4019
+ declare class WorstReceiverContagionDetector implements Detector {
4020
+ private readonly _call;
4021
+ readonly name = "worst-receiver-contagion-detector";
4022
+ private readonly _config;
4023
+ private readonly _aggregator;
4024
+ private readonly _windows;
4025
+ private readonly _lastRaisedAt;
4026
+ constructor(_call: ObservedCall, config?: Partial<WorstReceiverContagionDetectorConfig>);
4027
+ update(): void;
4028
+ close(): void;
4029
+ private _windowOf;
4030
+ }
4031
+
4032
+ declare const TrackDeliveryMismatchTypes: {
4033
+ /**
4034
+ * The source is sending, but **none** of its subscribers are receiving → the media is being lost
4035
+ * between the publisher and the receivers. In an SFU that means the forwarding path.
4036
+ */
4037
+ readonly publishedTrackNotDelivered: "PUBLISHED_TRACK_NOT_DELIVERED";
4038
+ /**
4039
+ * The source is sending and most subscribers are fine, but **some** are dry → those consumers are
4040
+ * broken individually (in mediasoup, the usual mitigation is recreating the consumer).
4041
+ */
4042
+ readonly receiverTrackNotDelivered: "RECEIVER_TRACK_NOT_DELIVERED";
4043
+ /**
4044
+ * The source itself stopped producing, so its subscribers being dry is expected and **not** an
4045
+ * SFU fault. Reported so the other two verdicts can be trusted as *not* being this.
4046
+ */
4047
+ readonly publisherTrackDry: "PUBLISHER_TRACK_DRY";
4048
+ };
4049
+ type TrackDeliveryMismatchDetectorConfig = {
4050
+ /** The receiver-side issue type meaning "no media arriving". Default `'dry-inbound-track'`. */
4051
+ dryInboundIssueType: string;
4052
+ /** The publisher-side issue type meaning "not producing". Default `'dry-outbound-track'`. */
4053
+ dryOutboundIssueType: string;
4054
+ /** Minimum subscribers before "all of them" means anything. Default `2`. */
4055
+ minReceivers: number;
4056
+ /** Fraction of subscribers that must be dry to call it a whole-track delivery failure. Default `1`. */
4057
+ allReceiversRatio: number;
4058
+ /** Re-arm time (ms) per (track, verdict). Default `60_000`. */
4059
+ cooldownMs: number;
4060
+ registry?: Partial<IssueRegistryConfig>;
4061
+ };
4062
+ /**
4063
+ * Answers **"is the media actually getting through?"** by joining the two ends of a published track.
4064
+ *
4065
+ * A dry track is the clearest possible symptom — no bytes are arriving — but on its own it is
4066
+ * ambiguous, and the ambiguity is precisely what a single endpoint cannot resolve. A receiver seeing
4067
+ * silence cannot tell whether the camera was switched off, the SFU stopped forwarding, or its own
4068
+ * consumer wedged. All three look identical from the browser.
4069
+ *
4070
+ * With the publisher↔subscriber links this becomes a three-way decision:
4071
+ *
4072
+ * | publisher | subscribers | verdict |
4073
+ * |---|---|---|
4074
+ * | sending | **all** dry | `PUBLISHED_TRACK_NOT_DELIVERED` — the SFU/forwarding path |
4075
+ * | sending | **some** dry | `RECEIVER_TRACK_NOT_DELIVERED` — those consumers (recreate them) |
4076
+ * | dry | any dry | `PUBLISHER_TRACK_DRY` — the source stopped; not an SFU fault |
4077
+ *
4078
+ * The publisher side is judged from **both** signals available: its own `dry-outbound-track` issue
4079
+ * when the client reports one, and — as the fallback, and the corroboration when it does not — the
4080
+ * observed outbound RTP (`deltaPacketsSent`). That combination is what makes the first row
4081
+ * trustworthy: the server can state that packets demonstrably left the publisher during the same
4082
+ * interval in which every receiver got nothing.
4083
+ *
4084
+ * This is the "SFU forwarding mismatch" check, and notably it needs **no** mediasoup instrumentation
4085
+ * — the client's own dry-track verdicts plus the resolver links are sufficient.
4086
+ */
4087
+ declare class TrackDeliveryMismatchDetector implements Detector {
4088
+ private readonly _call;
4089
+ readonly name = "track-delivery-mismatch-detector";
4090
+ private readonly _config;
4091
+ private readonly _registry;
4092
+ private readonly _aggregator;
4093
+ private readonly _lastRaisedAt;
4094
+ constructor(_call: ObservedCall, config?: Partial<TrackDeliveryMismatchDetectorConfig>);
4095
+ update(): void;
4096
+ close(): void;
4097
+ }
4098
+
4099
+ declare const UnconsumedTrackTypes: {
4100
+ /** A track is being published to the SFU that nobody is subscribed to — pure wasted uplink. */
4101
+ readonly unconsumedPublishedTrack: "UNCONSUMED_PUBLISHED_TRACK";
4102
+ };
4103
+ type UnconsumedTrackDetectorConfig = {
4104
+ /** How long a track must stay unconsumed while sending before reporting (ms). Default `30_000`. */
4105
+ minUnconsumedDurationInMs: number;
4106
+ /** Ignore tracks below this bitrate — a trickle isn't worth an alert (bps). Default `50_000`. */
4107
+ minBitrate: number;
4108
+ /** Re-arm time (ms) per track. Default `300_000`. */
4109
+ cooldownMs: number;
4110
+ };
4111
+ /**
4112
+ * Finds tracks that are **published but consumed by nobody** — uplink and SFU ingress spent on media
4113
+ * that is never forwarded anywhere.
4114
+ *
4115
+ * This is the one detector that reads the resolver's *silence* as the signal: an outbound track with
4116
+ * an empty `remoteInboundTracks` set, still pushing packets. The usual causes are a participant
4117
+ * publishing while everyone has them hidden or muted-in-UI, a simulcast layer no viewer's bandwidth
4118
+ * ever selects, or an application that forgot to stop a track after the last subscriber left.
4119
+ *
4120
+ * It is deliberately slow to fire: `minUnconsumedDurationInMs` must elapse with the track still
4121
+ * sending, because a brief gap between publishing and the first subscription is completely normal at
4122
+ * join time.
4123
+ *
4124
+ * ### Careful: this detector is only sound with a resolver
4125
+ *
4126
+ * "No subscribers" and "no resolver configured" produce the identical observation — an empty link
4127
+ * set. Without a `RemoteTrackResolver` this would report *every* published track in the call as
4128
+ * unconsumed, so it checks `call.remoteTrackResolver` at runtime and does nothing without one.
4129
+ */
4130
+ declare class UnconsumedTrackDetector implements Detector {
4131
+ private readonly _call;
4132
+ readonly name = "unconsumed-track-detector";
4133
+ private readonly _config;
4134
+ /** trackId -> when it was first seen sending with no subscribers. */
4135
+ private readonly _unconsumedSince;
4136
+ private readonly _lastRaisedAt;
4137
+ constructor(_call: ObservedCall, config?: Partial<UnconsumedTrackDetectorConfig>);
4138
+ update(): void;
4139
+ close(): void;
4140
+ }
4141
+
2897
4142
  interface Logger {
2898
4143
  trace(...args: any[]): void;
2899
4144
  debug(...args: any[]): void;
@@ -2965,4 +4210,4 @@ declare function createInMemorySink(samples?: ClientSample[]): InMemorySink;
2965
4210
  declare function createDefaultMediasoupRemoteTrackResolverFactory(): RemoteTrackResolverFactory;
2966
4211
  declare function createP2pRemoteTrackResolverFactory(): RemoteTrackResolverFactory;
2967
4212
 
2968
- export { type AcceptContext, type AcceptMiddleware, type AcceptMiddlewarePayload, type CallAppDataFactory, type ClientAppDataFactory, type ClientEvent, ClientEventTypes, type ClientIssue, type ClientMetaData, ClientMetaTypes, type ClientSample, ClientSampleSink, type ClientSampleSinkEvents, type ClientSampleSinkFactory, type Detector, Detectors, InMemorySink, JsonlFileSink, type JsonlFileSinkFactoryOptions, type JsonlFileSinkOptions, type Logger, type MediasoupConsumerSample, type MediasoupConsumerSampleEvent, type MediasoupDataConsumerSample, type MediasoupDataProducerSample, type MediasoupDirectTransportSample, type MediasoupDirectTransportSampleEventMap, type MediasoupPipeTransportSample, type MediasoupPipeTransportSampleEventMap, type MediasoupPlainTransportSample, type MediasoupPlainTransportSampleEventMap, type MediasoupProducerSample, type MediasoupProducerSampleEvent, type MediasoupRouterSample, type MediasoupTransportSample, type MediasoupWebRtcTransportSample, type MediasoupWebRtcTransportSampleEventMap, type Middleware, ObservedCall, type ObservedCallScope, ObservedCertificate, ObservedClient, type ObservedClientScope, ObservedCodec, ObservedDataChannel, ObservedIceCandidate, ObservedIceCandidatePair, ObservedIceTransport, ObservedInboundRtp, ObservedInboundTrack, ObservedMediaPlayout, ObservedMediaSource, ObservedMediasoupRouter, type ObservedMediasoupRouterEvents, type ObservedMediasoupRouterScope, type ObservedMediasoupRouterSettings, ObservedOutboundRtp, ObservedOutboundTrack, ObservedPeerConnection, type ObservedPeerConnectionScope, ObservedPeerConnectionTransport, ObservedRemoteInboundRtp, ObservedRemoteOutboundRtp, Observer, type ObserverEventBase, type ObserverEvents, type ObserverLogger, RemoteTrackResolver, type RemoteTrackResolverFactory, type RemoteTrackResolvers, type SampleRejectedReason, type ScoreCalculator, createDefaultMediasoupRemoteTrackResolverFactory, createInMemorySink, createJsonlFileSink, createJsonlFileSinkFactory, createLogger, createP2pRemoteTrackResolverFactory, setObserverLogger };
4213
+ export { type AcceptContext, type AcceptMiddleware, type AcceptMiddlewarePayload, type ActiveClientIssue, AudioImpairmentFanOutDetector, type AudioImpairmentFanOutDetectorConfig, AudioImpairmentFanOutTypes, type CallAppDataFactory, type CallHealth, CallHealthAggregator, CallWideDegradationDetector, type CallWideDegradationDetectorConfig, CallWideDegradationTypes, type ClientAppDataFactory, type ClientEvent, ClientEventTypes, type ClientHealth, type ClientHealthThresholds, type ClientIssue, type ClientMetaData, ClientMetaTypes, type ClientSample, ClientSampleSink, type ClientSampleSinkEvents, type ClientSampleSinkFactory, CommonSourceDegradationDetector, type CommonSourceDegradationDetectorConfig, CommonSourceDegradationTypes, ConcurrentIssueDetector, type ConcurrentIssueDetectorConfig, ConcurrentIssueTypes, type Detector, Detectors, IceDisruptionDetector, type IceDisruptionDetectorConfig, IceDisruptionTypes, InMemorySink, type IssueCohort, IssueFanOutDetector, type IssueFanOutDetectorConfig, IssueFanOutTypes, IssueRegistry, type IssueRegistryConfig, JsonlFileSink, type JsonlFileSinkFactoryOptions, type JsonlFileSinkOptions, type Logger, type MediasoupConsumerSample, type MediasoupConsumerSampleEvent, type MediasoupDataConsumerSample, type MediasoupDataProducerSample, type MediasoupDirectTransportSample, type MediasoupDirectTransportSampleEventMap, type MediasoupPipeTransportSample, type MediasoupPipeTransportSampleEventMap, type MediasoupPlainTransportSample, type MediasoupPlainTransportSampleEventMap, type MediasoupProducerSample, type MediasoupProducerSampleEvent, type MediasoupRouterSample, type MediasoupSampleEnricher, type MediasoupTransportSample, type MediasoupWebRtcTransportSample, type MediasoupWebRtcTransportSampleEventMap, type Middleware, ObservedCall, type ObservedCallScope, ObservedCertificate, ObservedClient, type ObservedClientScope, ObservedCodec, ObservedDataChannel, ObservedIceCandidate, ObservedIceCandidatePair, ObservedIceTransport, ObservedInboundRtp, ObservedInboundTrack, ObservedMediaPlayout, ObservedMediaSource, ObservedMediasoupRouter, type ObservedMediasoupRouterEvents, type ObservedMediasoupRouterScope, type ObservedMediasoupRouterSettings, ObservedOutboundRtp, ObservedOutboundTrack, ObservedPeerConnection, type ObservedPeerConnectionScope, ObservedPeerConnectionTransport, ObservedRemoteInboundRtp, ObservedRemoteOutboundRtp, type ObservedTrackDistribution, Observer, type ObserverEventBase, type ObserverEvents, type ObserverLogger, PliAndFreezeFanOutDetector, type PliAndFreezeFanOutDetectorConfig, PliAndFreezeFanOutTypes, type PublisherDistributionEntry, RESOLVED_ISSUE_SUFFIX, type ReceiverDistributionEntry, type ReceiverHealthThresholds, RemoteTrackResolver, type RemoteTrackResolverFactory, type RemoteTrackResolvers, type ResolvedClientIssue, type SampleRejectedReason, type ScoreCalculator, SlidingWindow, type SlidingWindowEntry, type StatsSummary, TrackDeliveryMismatchDetector, type TrackDeliveryMismatchDetectorConfig, TrackDeliveryMismatchTypes, TrackDistributionAggregator, type TurnServerHealth, TurnServerHealthDetector, type TurnServerHealthDetectorConfig, TurnServerHealthTypes, UnconsumedTrackDetector, type UnconsumedTrackDetectorConfig, UnconsumedTrackTypes, WorstReceiverContagionDetector, type WorstReceiverContagionDetectorConfig, WorstReceiverContagionTypes, baseIssueType, counterDelta, createDefaultMediasoupRemoteTrackResolverFactory, createInMemorySink, createJsonlFileSink, createJsonlFileSinkFactory, createLogger, createP2pRemoteTrackResolverFactory, defaultClientHealthThresholds, defaultReceiverHealthThresholds, isResolutionEntry, median, parseIssuePayload, percentile, setObserverLogger, summarize };