@observertc/observer-js 1.0.0-beta.15 → 1.0.0-beta.17

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.js CHANGED
@@ -35,6 +35,7 @@ __export(src_exports, {
35
35
  CallConcurrentIssueDetector: () => CallConcurrentIssueDetector,
36
36
  CallConcurrentIssueTypes: () => CallConcurrentIssueTypes,
37
37
  CallHealthAggregator: () => CallHealthAggregator,
38
+ CallSummaryCollector: () => CallSummaryCollector,
38
39
  ClientEventTypes: () => ClientEventTypes,
39
40
  ClientMetaTypes: () => ClientMetaTypes,
40
41
  ClientPopulationIssueDetector: () => ClientPopulationIssueDetector,
@@ -93,16 +94,17 @@ __export(src_exports, {
93
94
  concludeObserverIssue: () => concludeObserverIssue,
94
95
  correlation: () => correlation,
95
96
  counterDelta: () => counterDelta,
97
+ createCallSummary: () => createCallSummary,
96
98
  createDefaultMediasoupRemoteTrackResolverFactory: () => createDefaultMediasoupRemoteTrackResolverFactory,
97
99
  createInMemorySink: () => createInMemorySink,
98
100
  createJsonlFileSink: () => createJsonlFileSink,
99
101
  createJsonlFileSinkFactory: () => createJsonlFileSinkFactory,
100
102
  createLogger: () => createLogger,
101
103
  createP2pRemoteTrackResolverFactory: () => createP2pRemoteTrackResolverFactory,
104
+ defaultCallSummaryConfig: () => defaultCallSummaryConfig,
102
105
  defaultClientHealthThresholds: () => defaultClientHealthThresholds,
103
106
  isClientIssueResolutionEntry: () => isClientIssueResolutionEntry,
104
107
  issuePayloadAsString: () => issuePayloadAsString,
105
- issuePayloadOf: () => issuePayloadOf,
106
108
  mannKendall: () => mannKendall,
107
109
  mannKendallVerdict: () => mannKendallVerdict,
108
110
  median: () => median,
@@ -155,8 +157,8 @@ function createLogger(moduleName) {
155
157
  }
156
158
  }();
157
159
  }
158
- function setObserverLogger(logger9) {
159
- mainLogger = logger9;
160
+ function setObserverLogger(logger10) {
161
+ mainLogger = logger10;
160
162
  }
161
163
 
162
164
  // src/ObservedCall.ts
@@ -2750,6 +2752,10 @@ var ObservedClient = class extends import_events2.EventEmitter {
2750
2752
  }
2751
2753
  close() {
2752
2754
  if (this.closed) return;
2755
+ if (this.closeTimer) {
2756
+ clearTimeout(this.closeTimer);
2757
+ this.closeTimer = void 0;
2758
+ }
2753
2759
  this._flushPendingInjections();
2754
2760
  const activeIssues = [...this.activeIssues.values()];
2755
2761
  for (const issue of activeIssues) {
@@ -2912,6 +2918,7 @@ var ObservedClient = class extends import_events2.EventEmitter {
2912
2918
  this.closeTimer = setTimeout(() => {
2913
2919
  this.close();
2914
2920
  }, this.settings.closeClientIfIdleForMs);
2921
+ this.closeTimer.unref?.();
2915
2922
  }
2916
2923
  this._activeSample = void 0;
2917
2924
  try {
@@ -3383,6 +3390,24 @@ var Detectors = class {
3383
3390
  constructor(...detectors) {
3384
3391
  this._detectors = detectors;
3385
3392
  }
3393
+ /**
3394
+ * Every registered detector, in registration order.
3395
+ *
3396
+ * This is **the** way to get hold of an instance: `addDetector` / `addObserverDetector` are
3397
+ * chainable and return the owning entity, so the registry is where instances live. Read it to
3398
+ * inspect a detector's state, or to pick one out and {@link remove} it.
3399
+ *
3400
+ * A copy, not the live array — a caller iterating this while removing would otherwise skip
3401
+ * entries, and that is exactly what "remove the ones that look like X" does.
3402
+ */
3403
+ get instances() {
3404
+ return [...this._detectors];
3405
+ }
3406
+ /** Iterate the registry directly: `for (const detector of call.detectors)`. */
3407
+ [Symbol.iterator]() {
3408
+ return this.instances[Symbol.iterator]();
3409
+ }
3410
+ /** The names in registration order. Duplicates are meaningful — see {@link getAll}. */
3386
3411
  get listOfNames() {
3387
3412
  return this._detectors.map((d) => d.name);
3388
3413
  }
@@ -3392,12 +3417,48 @@ var Detectors = class {
3392
3417
  add(detector) {
3393
3418
  this._detectors.push(detector);
3394
3419
  }
3420
+ /** The first detector registered under `name`. See {@link getAll} when several can share one. */
3395
3421
  get(name) {
3396
3422
  return this._detectors.find((detector) => detector.name === name);
3397
3423
  }
3424
+ /**
3425
+ * Every detector registered under `name`.
3426
+ *
3427
+ * More than one is legitimate: `ClientPopulationIssueDetector` is meant to be added once per
3428
+ * `groupBy` axis, and two instances of it share a name.
3429
+ */
3430
+ getAll(name) {
3431
+ return this._detectors.filter((detector) => detector.name === name);
3432
+ }
3433
+ has(name) {
3434
+ return this._detectors.some((detector) => detector.name === name);
3435
+ }
3436
+ /** Remove one specific instance. Returns `false` if it was not registered here. */
3398
3437
  remove(detector) {
3399
- this._detectors = this._detectors.filter((d) => d !== detector);
3438
+ const remaining = this._detectors.filter((candidate) => candidate !== detector);
3439
+ if (remaining.length === this._detectors.length) return false;
3440
+ this._detectors = remaining;
3400
3441
  this._close(detector);
3442
+ return true;
3443
+ }
3444
+ /**
3445
+ * Remove **every** detector registered under `name`, returning how many were removed.
3446
+ *
3447
+ * All of them rather than the first, because a name can legitimately be registered more than once
3448
+ * (see {@link getAll}) and "remove the `client-population-issue-detector`" cannot sensibly mean
3449
+ * "remove whichever axis happens to be first in the array". Removing all of them is the only
3450
+ * behaviour that leaves the registry in a state the caller can predict from the name alone.
3451
+ *
3452
+ * Each removed detector gets `close()`, so trackers unsubscribe from the issue registry, bus
3453
+ * listeners drop, and timers clear — a detector removed without closing keeps being fed issues
3454
+ * forever.
3455
+ */
3456
+ removeByName(name) {
3457
+ const removed = this._detectors.filter((detector) => detector.name === name);
3458
+ if (removed.length === 0) return 0;
3459
+ this._detectors = this._detectors.filter((detector) => detector.name !== name);
3460
+ for (const detector of removed) this._close(detector);
3461
+ return removed.length;
3401
3462
  }
3402
3463
  update() {
3403
3464
  for (const detector of this._detectors) {
@@ -3515,7 +3576,6 @@ var UnconsumedTrackDetector = class _UnconsumedTrackDetector {
3515
3576
  type: UnconsumedTrackTypes.unconsumedPublishedTrack,
3516
3577
  timestamp: now,
3517
3578
  payload: {
3518
- type: UnconsumedTrackTypes.unconsumedPublishedTrack,
3519
3579
  trackId: outboundTrack.id,
3520
3580
  kind: outboundTrack.kind,
3521
3581
  publisherClientId: peerConnection?.client.clientId,
@@ -3661,7 +3721,6 @@ var TrackDeliveryMismatchDetector = class _TrackDeliveryMismatchDetector {
3661
3721
  type,
3662
3722
  timestamp: now,
3663
3723
  payload: {
3664
- type,
3665
3724
  trackId: outboundTrackId,
3666
3725
  publisherClientId: delivery.publisherClientId,
3667
3726
  publisherSending: delivery.publisherSending,
@@ -3914,12 +3973,9 @@ var CallConcurrentIssueDetector = class _CallConcurrentIssueDetector {
3914
3973
  this._call.addIssue({
3915
3974
  type: issueType,
3916
3975
  timestamp: now,
3976
+ conclusion,
3917
3977
  payload: {
3918
- type: issueType,
3919
3978
  issueType: type,
3920
- scope: "call",
3921
- conclusion,
3922
- callId: this._call.callId,
3923
3979
  clients: group.totalClients,
3924
3980
  affectedClients: group.clientIds.length,
3925
3981
  affectedRatio: group.affectedRatio,
@@ -4043,10 +4099,9 @@ var IssueFanOutDetector = class _IssueFanOutDetector {
4043
4099
  this._call.addIssue({
4044
4100
  type,
4045
4101
  timestamp: now,
4102
+ conclusion,
4046
4103
  payload: {
4047
- type,
4048
4104
  issueType,
4049
- conclusion,
4050
4105
  trackId: publisher.id,
4051
4106
  kind: publisher.kind,
4052
4107
  publisherClientId: publisher.getPeerConnection().client.clientId,
@@ -4193,18 +4248,14 @@ var PublisherFaultCorroborationDetector = class _PublisherFaultCorroborationDete
4193
4248
  this._call.addIssue({
4194
4249
  type: PublisherFaultTypes.corroboratedPublisherFault,
4195
4250
  timestamp: now,
4196
- payload: {
4197
- type: PublisherFaultTypes.corroboratedPublisherFault,
4198
- callId: this._call.callId,
4199
- ...fault,
4200
- conclusion: {
4201
- faultDomain: "published-track",
4202
- summary: `${fault.publisherClientId} reports ${fault.publisherIssueTypes.join(", ")} on track ${fault.trackId} while ${fault.affectedReceivers} of ${fault.receivers} subscribers report ${fault.receiverIssueTypes.join(", ")} \u2014 both ends agree`,
4203
- recommendation: "the source is implicated, not inferred: check that publisher's capture, encoder and uplink before looking at the SFU or the receivers",
4204
- // Higher than any single-ended finding: two independent parties, one conclusion.
4205
- confidence: 0.9
4206
- }
4207
- }
4251
+ conclusion: {
4252
+ faultDomain: "published-track",
4253
+ summary: `${fault.publisherClientId} reports ${fault.publisherIssueTypes.join(", ")} on track ${fault.trackId} while ${fault.affectedReceivers} of ${fault.receivers} subscribers report ${fault.receiverIssueTypes.join(", ")} \u2014 both ends agree`,
4254
+ recommendation: "the source is implicated, not inferred: check that publisher's capture, encoder and uplink before looking at the SFU or the receivers",
4255
+ // Higher than any single-ended finding: two independent parties, one conclusion.
4256
+ confidence: 0.9
4257
+ },
4258
+ payload: { ...fault }
4208
4259
  });
4209
4260
  }
4210
4261
  }
@@ -4260,6 +4311,14 @@ var ObservedCall = class extends import_events3.EventEmitter {
4260
4311
  value: void 0
4261
4312
  };
4262
4313
  remoteTrackResolver;
4314
+ /**
4315
+ * The accumulating record of this call's life, or `undefined` when no summary was configured.
4316
+ *
4317
+ * Live — read it at any point during the call. It is also delivered once on `call-summary` when
4318
+ * the call closes. See `CallSummary`: an absent section means "not collected", never "nothing
4319
+ * happened".
4320
+ */
4321
+ summary;
4263
4322
  /**
4264
4323
  * Published tracks that currently have **no** subscriber linked to them.
4265
4324
  *
@@ -4299,6 +4358,12 @@ var ObservedCall = class extends import_events3.EventEmitter {
4299
4358
  get score() {
4300
4359
  return this.calculatedScore.value;
4301
4360
  }
4361
+ /**
4362
+ * Build a call-scoped detector onto this call. Chainable.
4363
+ *
4364
+ * To get a handle on what was built — to inspect it, or to remove that exact instance later — read
4365
+ * it back off the registry: `call.detectors.getAll(name)`, or `call.detectors.instances`.
4366
+ */
4302
4367
  addDetector(name, config = {}) {
4303
4368
  if (this.closed) return this;
4304
4369
  let detector;
@@ -4331,20 +4396,61 @@ var ObservedCall = class extends import_events3.EventEmitter {
4331
4396
  this.detectors.add(detector);
4332
4397
  return this;
4333
4398
  }
4399
+ /**
4400
+ * Start accumulating this call's summary, if the observer was configured for summaries.
4401
+ *
4402
+ * Called by `createObservedCall`; you should not need it. It takes no configuration of its own on
4403
+ * purpose: the collector subscribes to exactly the events the observer's `include` requires, so a
4404
+ * per-call section outside that set would be created and then never written to — an empty section
4405
+ * that reads as "nothing happened". One shape per observer is the only shape that can be filled.
4406
+ *
4407
+ * The collector builds it rather than this method, so the resolved configuration never has to
4408
+ * leave the one object that owns it. Returns `undefined` when summaries are off, and is
4409
+ * idempotent: an existing summary is kept, not restarted.
4410
+ */
4411
+ enableSummary() {
4412
+ return this.summary ??= this.observer.callSummaryCollector?.createSummary(this.callId);
4413
+ }
4414
+ /**
4415
+ * Remove a detector from **this call** by name, returning how many were removed.
4416
+ *
4417
+ * **Every** instance under the name goes — a name can legitimately be registered more than once.
4418
+ * When you want one of them specifically, go through the registry, which deals in instances:
4419
+ *
4420
+ * ```ts
4421
+ * const [ first ] = call.detectors.getAll('issue-fan-out-detector');
4422
+ *
4423
+ * call.detectors.remove(first);
4424
+ * ```
4425
+ *
4426
+ * Either route `close()`s the detector, so it unsubscribes from `activeIssuesRegistry` — without
4427
+ * that the registry keeps feeding a detector nobody is running any more, and its tracked set grows
4428
+ * for the life of the call.
4429
+ *
4430
+ * To stop building it on *future* calls too, use `observer.removeCallDetector(name)`.
4431
+ */
4432
+ removeDetector(name) {
4433
+ return this.detectors.removeByName(name);
4434
+ }
4334
4435
  /**
4335
4436
  * Raise a call-level (server-side) finding; surfaced on the Observer bus as `call-issue`.
4336
4437
  *
4337
- * `payload` takes an **object** — it is delivered to in-process handlers, so there is nothing to
4338
- * serialise for. Pass a string only if you already have one.
4438
+ * `payload` is an **object** and holds evidence only — it is delivered to an in-process handler,
4439
+ * so there is nothing to serialise for. `scope` is stamped here, and the `callId` is already on
4440
+ * the event, so neither belongs in the payload. Put the interpretation in `conclusion`.
4339
4441
  */
4340
4442
  addIssue(issue) {
4341
4443
  if (this.closed) return;
4342
- this._notify("call-issue", { ...this.eventScope, issue });
4444
+ this._notify("call-issue", { ...this.eventScope, issue: { ...issue, scope: "call" } });
4343
4445
  }
4344
4446
  close() {
4345
4447
  if (this.closed) return;
4346
4448
  this.update();
4347
4449
  this.closed = true;
4450
+ if (this.closeTimer) {
4451
+ clearTimeout(this.closeTimer);
4452
+ this.closeTimer = void 0;
4453
+ }
4348
4454
  let minSampleTimestamps;
4349
4455
  let maxSampleTimestamps;
4350
4456
  const clients = [...this.observedClients.values()];
@@ -4356,6 +4462,10 @@ var ObservedCall = class extends import_events3.EventEmitter {
4356
4462
  if (this.startedAt === void 0) this.startedAt = minSampleTimestamps;
4357
4463
  if (this.endedAt === void 0) this.endedAt = maxSampleTimestamps;
4358
4464
  this.closedAt = Date.now();
4465
+ if (this.summary) {
4466
+ this.observer.callSummaryCollector?.finalise(this);
4467
+ this._notify("call-summary", { ...this.eventScope, summary: this.summary });
4468
+ }
4359
4469
  this.detectors.clear();
4360
4470
  this.activeIssuesRegistry.clear();
4361
4471
  this.emit("close");
@@ -4403,6 +4513,7 @@ var ObservedCall = class extends import_events3.EventEmitter {
4403
4513
  this.closeTimer = setTimeout(() => {
4404
4514
  this.close();
4405
4515
  }, this.settings.closeCallIfEmptyForMs);
4516
+ this.closeTimer.unref?.();
4406
4517
  }
4407
4518
  }
4408
4519
  ++this.totalRemovedClients;
@@ -5115,6 +5226,166 @@ var ActiveIssuesRegistry = class {
5115
5226
  }
5116
5227
  };
5117
5228
 
5229
+ // src/summaries/CallSummary.ts
5230
+ var defaultCallSummaryConfig = {
5231
+ include: [],
5232
+ maxIssues: 500,
5233
+ maxClientIds: 1e4
5234
+ };
5235
+ function createCallSummary(callId, config) {
5236
+ const summary = { callId, attachments: {} };
5237
+ if (config.include.includes("clients")) {
5238
+ summary.clients = { clientIds: [], peak: 0, joined: 0, left: 0 };
5239
+ }
5240
+ if (config.include.includes("issues")) {
5241
+ summary.issues = [];
5242
+ }
5243
+ if (config.include.includes("turnServers")) {
5244
+ summary.turnServers = { serverUrls: [], clientsRelayed: 0 };
5245
+ }
5246
+ if (config.include.includes("scores")) {
5247
+ summary.scores = { samples: 0 };
5248
+ }
5249
+ return summary;
5250
+ }
5251
+
5252
+ // src/summaries/CallSummaryCollector.ts
5253
+ var logger8 = createLogger("CallSummaryCollector");
5254
+ var CallSummaryCollector = class {
5255
+ constructor(_observer, _config) {
5256
+ this._observer = _observer;
5257
+ this._config = _config;
5258
+ this._subscribeBuiltIns();
5259
+ this._subscribeEnrichers();
5260
+ }
5261
+ _observer;
5262
+ _config;
5263
+ _scratch = /* @__PURE__ */ new WeakMap();
5264
+ _listeners = [];
5265
+ _closed = false;
5266
+ /**
5267
+ * Build a summary for `callId` and start tracking it.
5268
+ *
5269
+ * Creating it here, rather than letting the call create one and hand it over, keeps the resolved
5270
+ * configuration inside the single object that owns it — and makes it impossible to end up with a
5271
+ * summary whose sections nobody subscribed to fill.
5272
+ */
5273
+ createSummary(callId) {
5274
+ const summary = createCallSummary(callId, this._config);
5275
+ this._scratch.set(summary, { scores: [], turnClientIds: /* @__PURE__ */ new Set() });
5276
+ return summary;
5277
+ }
5278
+ /**
5279
+ * Finalise `call`'s summary: fold in what only makes sense once, and stamp the closing times.
5280
+ *
5281
+ * Percentiles are computed here rather than on every update — a median recomputed per tick over a
5282
+ * growing array is quadratic work to produce a number nobody reads until the end.
5283
+ */
5284
+ finalise(call) {
5285
+ const summary = call.summary;
5286
+ if (!summary) return;
5287
+ const scratch = this._scratch.get(summary);
5288
+ summary.startedAt = call.startedAt;
5289
+ summary.endedAt = call.endedAt;
5290
+ summary.durationInMs = summary.startedAt !== void 0 && summary.endedAt !== void 0 ? Math.max(0, summary.endedAt - summary.startedAt) : void 0;
5291
+ summary.closedAt = Date.now();
5292
+ if (summary.scores && scratch) {
5293
+ summary.scores.samples = scratch.scores.length;
5294
+ if (0 < scratch.scores.length) {
5295
+ summary.scores.min = Math.min(...scratch.scores);
5296
+ summary.scores.max = Math.max(...scratch.scores);
5297
+ summary.scores.median = percentile(scratch.scores, 0.5);
5298
+ }
5299
+ }
5300
+ if (summary.turnServers && scratch) {
5301
+ summary.turnServers.clientsRelayed = scratch.turnClientIds.size;
5302
+ }
5303
+ }
5304
+ /** Drop every bus subscription. Called when the observer closes. */
5305
+ close() {
5306
+ if (this._closed) return;
5307
+ this._closed = true;
5308
+ for (const { event, listener } of this._listeners) {
5309
+ this._observer.off(event, listener);
5310
+ }
5311
+ this._listeners.length = 0;
5312
+ }
5313
+ /**
5314
+ * Subscribe `listener` to `event`, routed to the summary of the call the event names.
5315
+ *
5316
+ * The `observedCall` is read off the payload rather than closed over, which is what lets one
5317
+ * subscription serve every call.
5318
+ */
5319
+ _on(event, handler) {
5320
+ const listener = (...args) => {
5321
+ const summary = args[0].observedCall.summary;
5322
+ if (!summary) return;
5323
+ const scratch = this._scratch.get(summary);
5324
+ if (!scratch) return;
5325
+ try {
5326
+ handler(summary, scratch, ...args);
5327
+ } catch (err) {
5328
+ logger8.warn("A call-summary handler for %s threw; continuing. %o", event, err);
5329
+ }
5330
+ };
5331
+ this._observer.on(event, listener);
5332
+ this._listeners.push({ event, listener });
5333
+ }
5334
+ _subscribeBuiltIns() {
5335
+ const include = this._config.include;
5336
+ if (include.includes("clients")) {
5337
+ this._on("client-added", (summary, _scratch, { observedCall, observedClient }) => {
5338
+ const clients = summary.clients;
5339
+ if (!clients) return;
5340
+ clients.joined += 1;
5341
+ clients.peak = Math.max(clients.peak, observedCall.observedClients.size);
5342
+ if (clients.clientIds.length < this._config.maxClientIds) {
5343
+ clients.clientIds.push(observedClient.clientId);
5344
+ } else {
5345
+ summary.truncated = { ...summary.truncated, clientIds: (summary.truncated?.clientIds ?? 0) + 1 };
5346
+ }
5347
+ });
5348
+ this._on("client-closed", (summary) => {
5349
+ if (summary.clients) summary.clients.left += 1;
5350
+ });
5351
+ }
5352
+ if (include.includes("issues")) {
5353
+ this._on("call-issue", (summary, _scratch, { issue }) => {
5354
+ const issues = summary.issues;
5355
+ if (!issues) return;
5356
+ if (issues.length < this._config.maxIssues) issues.push(issue);
5357
+ else summary.truncated = { ...summary.truncated, issues: (summary.truncated?.issues ?? 0) + 1 };
5358
+ });
5359
+ }
5360
+ if (include.includes("scores") || include.includes("turnServers")) {
5361
+ this._on("call-updated", (summary, scratch, { observedCall }) => {
5362
+ if (summary.scores && observedCall.score !== void 0) scratch.scores.push(observedCall.score);
5363
+ if (!summary.turnServers) return;
5364
+ for (const clientId of observedCall.clientsUsedTurn) scratch.turnClientIds.add(clientId);
5365
+ for (const client of observedCall.observedClients.values()) {
5366
+ for (const peerConnection of client.observedPeerConnections.values()) {
5367
+ for (const pair of peerConnection.selectedIceCandiadtePairForTurn) {
5368
+ const url = pair.getLocalCandidate()?.url;
5369
+ if (url && !summary.turnServers.serverUrls.includes(url)) {
5370
+ summary.turnServers.serverUrls.push(url);
5371
+ }
5372
+ }
5373
+ }
5374
+ }
5375
+ });
5376
+ }
5377
+ }
5378
+ _subscribeEnrichers() {
5379
+ const enrich = this._config.enrich;
5380
+ if (!enrich) return;
5381
+ for (const name of Object.keys(enrich)) {
5382
+ const enricher = enrich[name];
5383
+ if (!enricher) continue;
5384
+ this._on(name, (summary, _scratch, ...args) => enricher(summary, ...args));
5385
+ }
5386
+ }
5387
+ };
5388
+
5118
5389
  // src/detectors/SfuCongestionDetector.ts
5119
5390
  var SfuCongestionDetector = class _SfuCongestionDetector {
5120
5391
  constructor(_observer, config = {}) {
@@ -5243,13 +5514,10 @@ var SfuCongestionDetector = class _SfuCongestionDetector {
5243
5514
  absoluteIncrease: evaluation.absoluteIncrease,
5244
5515
  relativeIncrease: evaluation.relativeIncrease
5245
5516
  };
5246
- this._observer.emit("observer-issue", {
5247
- issue: {
5248
- type: this._config.emittedObserverIssueType,
5249
- timestamp: Date.now(),
5250
- payload
5251
- },
5252
- observer: this._observer
5517
+ this._observer.addIssue({
5518
+ type: this._config.emittedObserverIssueType,
5519
+ timestamp: Date.now(),
5520
+ payload: { ...payload }
5253
5521
  });
5254
5522
  }
5255
5523
  /**
@@ -5363,11 +5631,9 @@ var ObserverConcurrentIssueDetector = class _ObserverConcurrentIssueDetector {
5363
5631
  this._observer.addIssue({
5364
5632
  type: issueType,
5365
5633
  timestamp: now,
5634
+ conclusion,
5366
5635
  payload: {
5367
- type: issueType,
5368
5636
  issueType: type,
5369
- scope: "observer",
5370
- conclusion,
5371
5637
  clients: group.totalClients,
5372
5638
  affectedClients: group.clientIds.length,
5373
5639
  affectedRatio: group.affectedRatio,
@@ -5540,16 +5806,13 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5540
5806
  this._observer.addIssue({
5541
5807
  type: ClientPopulationIssueTypes.clientPopulationIssue,
5542
5808
  timestamp: now,
5543
- payload: {
5544
- type: ClientPopulationIssueTypes.clientPopulationIssue,
5545
- ...rollup,
5546
- conclusion: {
5547
- faultDomain: "client-population",
5548
- summary: `'${issueType}' is ${this._riskText(rollup.relativeRisk)} more likely on ${population} than on the rest of the fleet (${rollup.affectedClients}/${rollup.clients} vs ${rollup.controlAffectedClients}/${rollup.controlClients})`,
5549
- recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5550
- confidence: this._confidenceOf(rollup)
5551
- }
5552
- }
5809
+ conclusion: {
5810
+ faultDomain: "client-population",
5811
+ summary: `'${issueType}' is ${this._riskText(rollup.relativeRisk)} more likely on ${population} than on the rest of the fleet (${rollup.affectedClients}/${rollup.clients} vs ${rollup.controlAffectedClients}/${rollup.controlClients})`,
5812
+ recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5813
+ confidence: this._confidenceOf(rollup)
5814
+ },
5815
+ payload: { ...rollup }
5553
5816
  });
5554
5817
  }
5555
5818
  }
@@ -5651,7 +5914,7 @@ var TurnServerHealthDetector = class _TurnServerHealthDetector {
5651
5914
  this._observer.addIssue({
5652
5915
  type: TurnServerHealthTypes.turnServerDegraded,
5653
5916
  timestamp: now,
5654
- payload: { type: TurnServerHealthTypes.turnServerDegraded, ...health, otherServers }
5917
+ payload: { ...health, otherServers }
5655
5918
  });
5656
5919
  }
5657
5920
  for (const serverUrl of [...this._streaks.keys()]) {
@@ -5759,7 +6022,6 @@ var TurnServerOutageDetector = class _TurnServerOutageDetector {
5759
6022
  type: TurnServerOutageTypes.turnServerOutage,
5760
6023
  timestamp: now,
5761
6024
  payload: {
5762
- type: TurnServerOutageTypes.turnServerOutage,
5763
6025
  serverUrl,
5764
6026
  peakClients: peak,
5765
6027
  currentClients: live,
@@ -6033,17 +6295,13 @@ var SimulcastReceiverValidator = class _SimulcastReceiverValidator {
6033
6295
  this._observer.addIssue({
6034
6296
  type: LOWEST_COMMON_DENOMINATOR_ISSUE,
6035
6297
  timestamp: decidedAt,
6036
- payload: {
6037
- type: LOWEST_COMMON_DENOMINATOR_ISSUE,
6038
- checks: this._checks,
6039
- ...outcome.evidence,
6040
- conclusion: {
6041
- faultDomain: "infrastructure",
6042
- summary: "one bad receiver is dragging a publisher's bitrate down for everyone \u2014 the SFU is not selecting layers per consumer",
6043
- recommendation: "check that simulcast/SVC is enabled and layers are chosen per consumer, and that the SFU terminates receiver reports instead of forwarding them; this is a build/config property, not a transient",
6044
- confidence: 0.8
6045
- }
6046
- }
6298
+ conclusion: {
6299
+ faultDomain: "infrastructure",
6300
+ summary: "one bad receiver is dragging a publisher's bitrate down for everyone \u2014 the SFU is not selecting layers per consumer",
6301
+ recommendation: "check that simulcast/SVC is enabled and layers are chosen per consumer, and that the SFU terminates receiver reports instead of forwarding them; this is a build/config property, not a transient",
6302
+ confidence: 0.8
6303
+ },
6304
+ payload: { checks: this._checks, ...outcome.evidence }
6047
6305
  });
6048
6306
  }
6049
6307
  this.onDone(this.report);
@@ -6146,17 +6404,13 @@ var RemoteTrackResolverValidator = class _RemoteTrackResolverValidator {
6146
6404
  this._observer.addIssue({
6147
6405
  type: UNRESOLVED_TRACK_LINKS_ISSUE,
6148
6406
  timestamp: decidedAt,
6149
- payload: {
6150
- type: UNRESOLVED_TRACK_LINKS_ISSUE,
6151
- checks: this._checks,
6152
- ...outcome.evidence,
6153
- conclusion: {
6154
- faultDomain: "infrastructure",
6155
- summary: "a RemoteTrackResolver is configured but has never linked a subscriber to a publisher",
6156
- recommendation: "check the id the resolver joins on (mediasoup producerId, or your own convention) \u2014 until it links, IssueFanOutDetector, TrackDeliveryMismatchDetector, UnconsumedTrackDetector and SimulcastReceiverValidator all silently do nothing",
6157
- confidence: 0.9
6158
- }
6159
- }
6407
+ conclusion: {
6408
+ faultDomain: "infrastructure",
6409
+ summary: "a RemoteTrackResolver is configured but has never linked a subscriber to a publisher",
6410
+ recommendation: "check the id the resolver joins on (mediasoup producerId, or your own convention) \u2014 until it links, IssueFanOutDetector, TrackDeliveryMismatchDetector, UnconsumedTrackDetector and SimulcastReceiverValidator all silently do nothing",
6411
+ confidence: 0.9
6412
+ },
6413
+ payload: { checks: this._checks, ...outcome.evidence }
6160
6414
  });
6161
6415
  }
6162
6416
  this.onDone(this.report);
@@ -6267,17 +6521,16 @@ var CodecConsistencyValidator = class _CodecConsistencyValidator {
6267
6521
  this._observer.addIssue({
6268
6522
  type: CODEC_MISMATCH_ISSUE,
6269
6523
  timestamp: decidedAt,
6524
+ conclusion: {
6525
+ faultDomain: "infrastructure",
6526
+ summary: outcome.verdict === "codec-split" ? "participants of one call are using different codecs \u2014 an SFU that forwards without transcoding cannot serve all of them" : "the deployment is consistently negotiating a codec other than the expected one",
6527
+ recommendation: outcome.verdict === "codec-split" ? "check codec preferences and any SDP munging; a split usually means one endpoint could not negotiate the preferred codec and the others were not renegotiated with it" : "check codec preferences and endpoint support \u2014 a silent fallback keeps working, at a higher bitrate than you budgeted for",
6528
+ confidence: 0.85
6529
+ },
6270
6530
  payload: {
6271
- type: CODEC_MISMATCH_ISSUE,
6272
6531
  verdict: outcome.verdict,
6273
6532
  checks: this._checks,
6274
- evidence: outcome.evidence,
6275
- conclusion: {
6276
- faultDomain: "infrastructure",
6277
- summary: outcome.verdict === "codec-split" ? "participants of one call are using different codecs \u2014 an SFU that forwards without transcoding cannot serve all of them" : "the deployment is consistently negotiating a codec other than the expected one",
6278
- recommendation: outcome.verdict === "codec-split" ? "check codec preferences and any SDP munging; a split usually means one endpoint could not negotiate the preferred codec and the others were not renegotiated with it" : "check codec preferences and endpoint support \u2014 a silent fallback keeps working, at a higher bitrate than you budgeted for",
6279
- confidence: 0.85
6280
- }
6533
+ evidence: outcome.evidence
6281
6534
  }
6282
6535
  });
6283
6536
  }
@@ -6291,7 +6544,7 @@ function mediaKindOf(mimeType) {
6291
6544
  }
6292
6545
 
6293
6546
  // src/Observer.ts
6294
- var logger8 = createLogger("Observer");
6547
+ var logger9 = createLogger("Observer");
6295
6548
  var Observer = class extends import_events6.EventEmitter {
6296
6549
  observedTURN = new ObservedTURN();
6297
6550
  observedCalls = /* @__PURE__ */ new Map();
@@ -6347,6 +6600,14 @@ var Observer = class extends import_events6.EventEmitter {
6347
6600
  * ```
6348
6601
  */
6349
6602
  callDetectorConfigs = /* @__PURE__ */ new Map();
6603
+ /**
6604
+ * Owns every call's summary: the resolved `config.callSummary`, the bus subscriptions that keep
6605
+ * the summaries current (one per event type, not one per call), and the summaries themselves.
6606
+ *
6607
+ * `undefined` when `config.callSummary` was absent or `null` — so its presence *is* the answer to
6608
+ * "are summaries on", and nothing is subscribed to anything.
6609
+ */
6610
+ callSummaryCollector;
6350
6611
  constructor(config = {}) {
6351
6612
  super();
6352
6613
  this.setMaxListeners(Infinity);
@@ -6358,6 +6619,12 @@ var Observer = class extends import_events6.EventEmitter {
6358
6619
  closeClientIfIdleForMs: 6e4,
6359
6620
  ...config
6360
6621
  };
6622
+ if (this.config.callSummary) {
6623
+ this.callSummaryCollector = new CallSummaryCollector(this, {
6624
+ ...defaultCallSummaryConfig,
6625
+ ...this.config.callSummary
6626
+ });
6627
+ }
6361
6628
  }
6362
6629
  get numberOfCalls() {
6363
6630
  return this.observedCalls.size;
@@ -6365,6 +6632,12 @@ var Observer = class extends import_events6.EventEmitter {
6365
6632
  get appData() {
6366
6633
  return this.config.appData;
6367
6634
  }
6635
+ /**
6636
+ * Build a cross-call detector onto `observer.detectors`. Chainable.
6637
+ *
6638
+ * To get a handle on what was built — to inspect it, or to remove that exact instance later — read
6639
+ * it back off the registry: `observer.detectors.getAll(name)`, or `observer.detectors.instances`.
6640
+ */
6368
6641
  addObserverDetector(name, config = {}) {
6369
6642
  if (this.closed) return this;
6370
6643
  let detector;
@@ -6390,7 +6663,7 @@ var Observer = class extends import_events6.EventEmitter {
6390
6663
  break;
6391
6664
  }
6392
6665
  default: {
6393
- logger8.warn("Unknown detector name %s; skipping", name);
6666
+ logger9.warn("Unknown detector name %s; skipping", name);
6394
6667
  return this;
6395
6668
  }
6396
6669
  }
@@ -6408,10 +6681,41 @@ var Observer = class extends import_events6.EventEmitter {
6408
6681
  this.callDetectorConfigs.set(name, config);
6409
6682
  return this;
6410
6683
  }
6411
- /** Stop building `name` on calls created from now on. Calls already open are untouched. */
6412
- removeCallDetector(name) {
6684
+ /**
6685
+ * Remove an observer-scoped detector by name, returning how many were removed.
6686
+ *
6687
+ * **Every** instance registered under the name goes, since a name can legitimately be registered
6688
+ * more than once (`ClientPopulationIssueDetector` is meant to be added once per `groupBy` axis).
6689
+ * When you want one of them specifically, go through the registry, which deals in instances:
6690
+ *
6691
+ * ```ts
6692
+ * const [ byBrowser, byOs ] = observer.detectors.getAll('client-population-issue-detector');
6693
+ *
6694
+ * observer.detectors.remove(byOs); // keeps the browser axis running
6695
+ * ```
6696
+ *
6697
+ * Either route `close()`s the detector, so it unsubscribes from the issue registry and drops any
6698
+ * timers or bus listeners it held.
6699
+ */
6700
+ removeObserverDetector(name) {
6701
+ return this.detectors.removeByName(name);
6702
+ }
6703
+ /**
6704
+ * Stop building `name` on calls created from now on.
6705
+ *
6706
+ * By default this also removes it from the calls **already open**, so that "remove this detector"
6707
+ * means the same thing whether you say it before or after a call started — the alternative leaves
6708
+ * a fleet where the detector is live on some calls and not others, decided by join time. Pass
6709
+ * `{ includeOpenCalls: false }` to change only what future calls are built with.
6710
+ *
6711
+ * Returns the number of live detector instances removed (`0` when only the config changed).
6712
+ */
6713
+ removeCallDetector(name, { includeOpenCalls = true } = {}) {
6413
6714
  this.callDetectorConfigs.delete(name);
6414
- return this;
6715
+ if (!includeOpenCalls) return 0;
6716
+ let removed = 0;
6717
+ for (const call of this.observedCalls.values()) removed += call.removeDetector(name);
6718
+ return removed;
6415
6719
  }
6416
6720
  /**
6417
6721
  * Start a structural check. It runs on each `observer.update()` until it can decide, reports once
@@ -6444,23 +6748,53 @@ var Observer = class extends import_events6.EventEmitter {
6444
6748
  break;
6445
6749
  }
6446
6750
  if (!validator) {
6447
- logger8.warn("Unknown validator name %s; skipping", name);
6751
+ logger9.warn("Unknown validator name %s; skipping", name);
6448
6752
  return this;
6449
6753
  }
6450
6754
  this.validators.add(validator);
6451
6755
  return this;
6452
6756
  }
6757
+ /**
6758
+ * Stop a running validation, by name or by instance. Returns how many were cancelled.
6759
+ *
6760
+ * Cancelling is **not** silent discarding. The validator finishes with `inconclusive` and the given
6761
+ * `reason`, emits `validation-ready` like any other completion, and removes itself. That matters
6762
+ * because anything waiting on the verdict — a deploy gate, a dashboard, a promise — would otherwise
6763
+ * wait forever, and because "we stopped asking" is a materially different outcome from "we asked
6764
+ * and learned nothing", which is exactly what `inconclusive` with a reason records.
6765
+ *
6766
+ * ```ts
6767
+ * observer.cancelValidator('simulcast-receivers', 'sfu redeployed');
6768
+ *
6769
+ * // or one specific instance — `observer.validators` holds what is running
6770
+ * for (const validator of observer.validators) observer.cancelValidator(validator, 'shutting down');
6771
+ * ```
6772
+ *
6773
+ * Pass a real reason. The default tells the reader nothing they could not already infer.
6774
+ */
6775
+ cancelValidator(target, reason = "cancelled") {
6776
+ const running = [...this.validators];
6777
+ const matching = typeof target === "string" ? running.filter((validator) => validator.name === target) : running.filter((validator) => validator === target);
6778
+ for (const validator of matching) {
6779
+ try {
6780
+ validator.cancel(reason);
6781
+ } catch (err) {
6782
+ logger9.warn("Error cancelling validator %s: %o", validator.name, err);
6783
+ }
6784
+ }
6785
+ return matching.length;
6786
+ }
6453
6787
  getObservedCall(callId) {
6454
6788
  if (this.closed || !this.observedCalls.has(callId)) return;
6455
6789
  return this.observedCalls.get(callId);
6456
6790
  }
6457
6791
  createObservedCall(settings) {
6458
6792
  if (this.closed) {
6459
- logger8.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6793
+ logger9.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6460
6794
  return void 0;
6461
6795
  }
6462
6796
  if (this.observedCalls.has(settings.callId)) {
6463
- logger8.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6797
+ logger9.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6464
6798
  return this.observedCalls.get(settings.callId);
6465
6799
  }
6466
6800
  const callSettings = {
@@ -6475,6 +6809,7 @@ var Observer = class extends import_events6.EventEmitter {
6475
6809
  callActiveIssuesRegistry
6476
6810
  );
6477
6811
  observedCall.remoteTrackResolver = this.config.createRemoteTrackResolver?.(observedCall);
6812
+ observedCall.enableSummary();
6478
6813
  for (const [name, detectorConfig] of this.callDetectorConfigs) {
6479
6814
  observedCall.addDetector(name, detectorConfig);
6480
6815
  }
@@ -6497,11 +6832,11 @@ var Observer = class extends import_events6.EventEmitter {
6497
6832
  }
6498
6833
  createObservedMediasoupRouter(settings) {
6499
6834
  if (this.closed) {
6500
- logger8.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6835
+ logger9.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6501
6836
  return void 0;
6502
6837
  }
6503
6838
  if (this.observedMediasoupRouters.has(settings.router.id)) {
6504
- logger8.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6839
+ logger9.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6505
6840
  return this.observedMediasoupRouters.get(settings.router.id);
6506
6841
  }
6507
6842
  const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
@@ -6532,13 +6867,14 @@ var Observer = class extends import_events6.EventEmitter {
6532
6867
  }
6533
6868
  close() {
6534
6869
  if (this.closed) {
6535
- return logger8.debug("Attempted to close twice");
6870
+ return logger9.debug("Attempted to close twice");
6536
6871
  }
6537
6872
  this.closed = true;
6538
6873
  for (const call of [...this.observedCalls.values()]) call.close();
6539
6874
  this.detectors.clear();
6540
- for (const validator of [...this.validators]) validator.cancel();
6875
+ for (const validator of [...this.validators]) validator.cancel("observer closed");
6541
6876
  this.validators.clear();
6877
+ this.callSummaryCollector?.close();
6542
6878
  this.activeIssuesRegistry.clear();
6543
6879
  this._notify("observer-closed", { ...this.eventScope });
6544
6880
  }
@@ -6549,7 +6885,7 @@ var Observer = class extends import_events6.EventEmitter {
6549
6885
  try {
6550
6886
  this.acceptMiddlewares.process({ sample, context });
6551
6887
  } catch (err) {
6552
- logger8.warn("An accept middleware threw; dropping the sample. %o", err);
6888
+ logger9.warn("An accept middleware threw; dropping the sample. %o", err);
6553
6889
  }
6554
6890
  if (!sample.callId) {
6555
6891
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -6601,7 +6937,7 @@ var Observer = class extends import_events6.EventEmitter {
6601
6937
  try {
6602
6938
  validator.update();
6603
6939
  } catch (err) {
6604
- logger8.warn("Error running validator %s: %o", validator.name, err);
6940
+ logger9.warn("Error running validator %s: %o", validator.name, err);
6605
6941
  }
6606
6942
  }
6607
6943
  this._notify("observer-updated", { ...this.eventScope });
@@ -6614,7 +6950,7 @@ var Observer = class extends import_events6.EventEmitter {
6614
6950
  */
6615
6951
  addIssue(issue) {
6616
6952
  if (this.closed) return;
6617
- this._notify("observer-issue", { ...this.eventScope, issue });
6953
+ this._notify("observer-issue", { ...this.eventScope, issue: { ...issue, scope: "observer" } });
6618
6954
  }
6619
6955
  /** Emit an Observer-bus event. */
6620
6956
  _notify(type, ...args) {
@@ -6732,18 +7068,11 @@ var CallHealthAggregator = class {
6732
7068
  }
6733
7069
  };
6734
7070
 
6735
- // src/common/ObserverIssue.ts
6736
- function issuePayloadOf(issue) {
6737
- const payload = issue.payload;
6738
- if (payload === void 0) return void 0;
6739
- return typeof payload === "string" ? parseJsonObject(payload) : payload;
6740
- }
7071
+ // src/common/Issue.ts
6741
7072
  function issuePayloadAsString(issue) {
6742
- const payload = issue.payload;
6743
- if (payload === void 0) return void 0;
6744
- if (typeof payload === "string") return payload;
7073
+ if (issue.payload === void 0) return void 0;
6745
7074
  try {
6746
- return JSON.stringify(payload);
7075
+ return JSON.stringify(issue.payload);
6747
7076
  } catch {
6748
7077
  return void 0;
6749
7078
  }
@@ -7038,6 +7367,7 @@ function createP2pRemoteTrackResolverFactory() {
7038
7367
  CallConcurrentIssueDetector,
7039
7368
  CallConcurrentIssueTypes,
7040
7369
  CallHealthAggregator,
7370
+ CallSummaryCollector,
7041
7371
  ClientEventTypes,
7042
7372
  ClientMetaTypes,
7043
7373
  ClientPopulationIssueDetector,
@@ -7096,16 +7426,17 @@ function createP2pRemoteTrackResolverFactory() {
7096
7426
  concludeObserverIssue,
7097
7427
  correlation,
7098
7428
  counterDelta,
7429
+ createCallSummary,
7099
7430
  createDefaultMediasoupRemoteTrackResolverFactory,
7100
7431
  createInMemorySink,
7101
7432
  createJsonlFileSink,
7102
7433
  createJsonlFileSinkFactory,
7103
7434
  createLogger,
7104
7435
  createP2pRemoteTrackResolverFactory,
7436
+ defaultCallSummaryConfig,
7105
7437
  defaultClientHealthThresholds,
7106
7438
  isClientIssueResolutionEntry,
7107
7439
  issuePayloadAsString,
7108
- issuePayloadOf,
7109
7440
  mannKendall,
7110
7441
  mannKendallVerdict,
7111
7442
  median,