@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.mjs CHANGED
@@ -37,8 +37,8 @@ function createLogger(moduleName) {
37
37
  }
38
38
  }();
39
39
  }
40
- function setObserverLogger(logger9) {
41
- mainLogger = logger9;
40
+ function setObserverLogger(logger10) {
41
+ mainLogger = logger10;
42
42
  }
43
43
 
44
44
  // src/ObservedCall.ts
@@ -2632,6 +2632,10 @@ var ObservedClient = class extends EventEmitter2 {
2632
2632
  }
2633
2633
  close() {
2634
2634
  if (this.closed) return;
2635
+ if (this.closeTimer) {
2636
+ clearTimeout(this.closeTimer);
2637
+ this.closeTimer = void 0;
2638
+ }
2635
2639
  this._flushPendingInjections();
2636
2640
  const activeIssues = [...this.activeIssues.values()];
2637
2641
  for (const issue of activeIssues) {
@@ -2794,6 +2798,7 @@ var ObservedClient = class extends EventEmitter2 {
2794
2798
  this.closeTimer = setTimeout(() => {
2795
2799
  this.close();
2796
2800
  }, this.settings.closeClientIfIdleForMs);
2801
+ this.closeTimer.unref?.();
2797
2802
  }
2798
2803
  this._activeSample = void 0;
2799
2804
  try {
@@ -3265,6 +3270,24 @@ var Detectors = class {
3265
3270
  constructor(...detectors) {
3266
3271
  this._detectors = detectors;
3267
3272
  }
3273
+ /**
3274
+ * Every registered detector, in registration order.
3275
+ *
3276
+ * This is **the** way to get hold of an instance: `addDetector` / `addObserverDetector` are
3277
+ * chainable and return the owning entity, so the registry is where instances live. Read it to
3278
+ * inspect a detector's state, or to pick one out and {@link remove} it.
3279
+ *
3280
+ * A copy, not the live array — a caller iterating this while removing would otherwise skip
3281
+ * entries, and that is exactly what "remove the ones that look like X" does.
3282
+ */
3283
+ get instances() {
3284
+ return [...this._detectors];
3285
+ }
3286
+ /** Iterate the registry directly: `for (const detector of call.detectors)`. */
3287
+ [Symbol.iterator]() {
3288
+ return this.instances[Symbol.iterator]();
3289
+ }
3290
+ /** The names in registration order. Duplicates are meaningful — see {@link getAll}. */
3268
3291
  get listOfNames() {
3269
3292
  return this._detectors.map((d) => d.name);
3270
3293
  }
@@ -3274,12 +3297,48 @@ var Detectors = class {
3274
3297
  add(detector) {
3275
3298
  this._detectors.push(detector);
3276
3299
  }
3300
+ /** The first detector registered under `name`. See {@link getAll} when several can share one. */
3277
3301
  get(name) {
3278
3302
  return this._detectors.find((detector) => detector.name === name);
3279
3303
  }
3304
+ /**
3305
+ * Every detector registered under `name`.
3306
+ *
3307
+ * More than one is legitimate: `ClientPopulationIssueDetector` is meant to be added once per
3308
+ * `groupBy` axis, and two instances of it share a name.
3309
+ */
3310
+ getAll(name) {
3311
+ return this._detectors.filter((detector) => detector.name === name);
3312
+ }
3313
+ has(name) {
3314
+ return this._detectors.some((detector) => detector.name === name);
3315
+ }
3316
+ /** Remove one specific instance. Returns `false` if it was not registered here. */
3280
3317
  remove(detector) {
3281
- this._detectors = this._detectors.filter((d) => d !== detector);
3318
+ const remaining = this._detectors.filter((candidate) => candidate !== detector);
3319
+ if (remaining.length === this._detectors.length) return false;
3320
+ this._detectors = remaining;
3282
3321
  this._close(detector);
3322
+ return true;
3323
+ }
3324
+ /**
3325
+ * Remove **every** detector registered under `name`, returning how many were removed.
3326
+ *
3327
+ * All of them rather than the first, because a name can legitimately be registered more than once
3328
+ * (see {@link getAll}) and "remove the `client-population-issue-detector`" cannot sensibly mean
3329
+ * "remove whichever axis happens to be first in the array". Removing all of them is the only
3330
+ * behaviour that leaves the registry in a state the caller can predict from the name alone.
3331
+ *
3332
+ * Each removed detector gets `close()`, so trackers unsubscribe from the issue registry, bus
3333
+ * listeners drop, and timers clear — a detector removed without closing keeps being fed issues
3334
+ * forever.
3335
+ */
3336
+ removeByName(name) {
3337
+ const removed = this._detectors.filter((detector) => detector.name === name);
3338
+ if (removed.length === 0) return 0;
3339
+ this._detectors = this._detectors.filter((detector) => detector.name !== name);
3340
+ for (const detector of removed) this._close(detector);
3341
+ return removed.length;
3283
3342
  }
3284
3343
  update() {
3285
3344
  for (const detector of this._detectors) {
@@ -3397,7 +3456,6 @@ var UnconsumedTrackDetector = class _UnconsumedTrackDetector {
3397
3456
  type: UnconsumedTrackTypes.unconsumedPublishedTrack,
3398
3457
  timestamp: now,
3399
3458
  payload: {
3400
- type: UnconsumedTrackTypes.unconsumedPublishedTrack,
3401
3459
  trackId: outboundTrack.id,
3402
3460
  kind: outboundTrack.kind,
3403
3461
  publisherClientId: peerConnection?.client.clientId,
@@ -3543,7 +3601,6 @@ var TrackDeliveryMismatchDetector = class _TrackDeliveryMismatchDetector {
3543
3601
  type,
3544
3602
  timestamp: now,
3545
3603
  payload: {
3546
- type,
3547
3604
  trackId: outboundTrackId,
3548
3605
  publisherClientId: delivery.publisherClientId,
3549
3606
  publisherSending: delivery.publisherSending,
@@ -3796,12 +3853,9 @@ var CallConcurrentIssueDetector = class _CallConcurrentIssueDetector {
3796
3853
  this._call.addIssue({
3797
3854
  type: issueType,
3798
3855
  timestamp: now,
3856
+ conclusion,
3799
3857
  payload: {
3800
- type: issueType,
3801
3858
  issueType: type,
3802
- scope: "call",
3803
- conclusion,
3804
- callId: this._call.callId,
3805
3859
  clients: group.totalClients,
3806
3860
  affectedClients: group.clientIds.length,
3807
3861
  affectedRatio: group.affectedRatio,
@@ -3925,10 +3979,9 @@ var IssueFanOutDetector = class _IssueFanOutDetector {
3925
3979
  this._call.addIssue({
3926
3980
  type,
3927
3981
  timestamp: now,
3982
+ conclusion,
3928
3983
  payload: {
3929
- type,
3930
3984
  issueType,
3931
- conclusion,
3932
3985
  trackId: publisher.id,
3933
3986
  kind: publisher.kind,
3934
3987
  publisherClientId: publisher.getPeerConnection().client.clientId,
@@ -4075,18 +4128,14 @@ var PublisherFaultCorroborationDetector = class _PublisherFaultCorroborationDete
4075
4128
  this._call.addIssue({
4076
4129
  type: PublisherFaultTypes.corroboratedPublisherFault,
4077
4130
  timestamp: now,
4078
- payload: {
4079
- type: PublisherFaultTypes.corroboratedPublisherFault,
4080
- callId: this._call.callId,
4081
- ...fault,
4082
- conclusion: {
4083
- faultDomain: "published-track",
4084
- 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`,
4085
- recommendation: "the source is implicated, not inferred: check that publisher's capture, encoder and uplink before looking at the SFU or the receivers",
4086
- // Higher than any single-ended finding: two independent parties, one conclusion.
4087
- confidence: 0.9
4088
- }
4089
- }
4131
+ conclusion: {
4132
+ faultDomain: "published-track",
4133
+ 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`,
4134
+ recommendation: "the source is implicated, not inferred: check that publisher's capture, encoder and uplink before looking at the SFU or the receivers",
4135
+ // Higher than any single-ended finding: two independent parties, one conclusion.
4136
+ confidence: 0.9
4137
+ },
4138
+ payload: { ...fault }
4090
4139
  });
4091
4140
  }
4092
4141
  }
@@ -4142,6 +4191,14 @@ var ObservedCall = class extends EventEmitter3 {
4142
4191
  value: void 0
4143
4192
  };
4144
4193
  remoteTrackResolver;
4194
+ /**
4195
+ * The accumulating record of this call's life, or `undefined` when no summary was configured.
4196
+ *
4197
+ * Live — read it at any point during the call. It is also delivered once on `call-summary` when
4198
+ * the call closes. See `CallSummary`: an absent section means "not collected", never "nothing
4199
+ * happened".
4200
+ */
4201
+ summary;
4145
4202
  /**
4146
4203
  * Published tracks that currently have **no** subscriber linked to them.
4147
4204
  *
@@ -4181,6 +4238,12 @@ var ObservedCall = class extends EventEmitter3 {
4181
4238
  get score() {
4182
4239
  return this.calculatedScore.value;
4183
4240
  }
4241
+ /**
4242
+ * Build a call-scoped detector onto this call. Chainable.
4243
+ *
4244
+ * To get a handle on what was built — to inspect it, or to remove that exact instance later — read
4245
+ * it back off the registry: `call.detectors.getAll(name)`, or `call.detectors.instances`.
4246
+ */
4184
4247
  addDetector(name, config = {}) {
4185
4248
  if (this.closed) return this;
4186
4249
  let detector;
@@ -4213,20 +4276,61 @@ var ObservedCall = class extends EventEmitter3 {
4213
4276
  this.detectors.add(detector);
4214
4277
  return this;
4215
4278
  }
4279
+ /**
4280
+ * Start accumulating this call's summary, if the observer was configured for summaries.
4281
+ *
4282
+ * Called by `createObservedCall`; you should not need it. It takes no configuration of its own on
4283
+ * purpose: the collector subscribes to exactly the events the observer's `include` requires, so a
4284
+ * per-call section outside that set would be created and then never written to — an empty section
4285
+ * that reads as "nothing happened". One shape per observer is the only shape that can be filled.
4286
+ *
4287
+ * The collector builds it rather than this method, so the resolved configuration never has to
4288
+ * leave the one object that owns it. Returns `undefined` when summaries are off, and is
4289
+ * idempotent: an existing summary is kept, not restarted.
4290
+ */
4291
+ enableSummary() {
4292
+ return this.summary ??= this.observer.callSummaryCollector?.createSummary(this.callId);
4293
+ }
4294
+ /**
4295
+ * Remove a detector from **this call** by name, returning how many were removed.
4296
+ *
4297
+ * **Every** instance under the name goes — a name can legitimately be registered more than once.
4298
+ * When you want one of them specifically, go through the registry, which deals in instances:
4299
+ *
4300
+ * ```ts
4301
+ * const [ first ] = call.detectors.getAll('issue-fan-out-detector');
4302
+ *
4303
+ * call.detectors.remove(first);
4304
+ * ```
4305
+ *
4306
+ * Either route `close()`s the detector, so it unsubscribes from `activeIssuesRegistry` — without
4307
+ * that the registry keeps feeding a detector nobody is running any more, and its tracked set grows
4308
+ * for the life of the call.
4309
+ *
4310
+ * To stop building it on *future* calls too, use `observer.removeCallDetector(name)`.
4311
+ */
4312
+ removeDetector(name) {
4313
+ return this.detectors.removeByName(name);
4314
+ }
4216
4315
  /**
4217
4316
  * Raise a call-level (server-side) finding; surfaced on the Observer bus as `call-issue`.
4218
4317
  *
4219
- * `payload` takes an **object** — it is delivered to in-process handlers, so there is nothing to
4220
- * serialise for. Pass a string only if you already have one.
4318
+ * `payload` is an **object** and holds evidence only — it is delivered to an in-process handler,
4319
+ * so there is nothing to serialise for. `scope` is stamped here, and the `callId` is already on
4320
+ * the event, so neither belongs in the payload. Put the interpretation in `conclusion`.
4221
4321
  */
4222
4322
  addIssue(issue) {
4223
4323
  if (this.closed) return;
4224
- this._notify("call-issue", { ...this.eventScope, issue });
4324
+ this._notify("call-issue", { ...this.eventScope, issue: { ...issue, scope: "call" } });
4225
4325
  }
4226
4326
  close() {
4227
4327
  if (this.closed) return;
4228
4328
  this.update();
4229
4329
  this.closed = true;
4330
+ if (this.closeTimer) {
4331
+ clearTimeout(this.closeTimer);
4332
+ this.closeTimer = void 0;
4333
+ }
4230
4334
  let minSampleTimestamps;
4231
4335
  let maxSampleTimestamps;
4232
4336
  const clients = [...this.observedClients.values()];
@@ -4238,6 +4342,10 @@ var ObservedCall = class extends EventEmitter3 {
4238
4342
  if (this.startedAt === void 0) this.startedAt = minSampleTimestamps;
4239
4343
  if (this.endedAt === void 0) this.endedAt = maxSampleTimestamps;
4240
4344
  this.closedAt = Date.now();
4345
+ if (this.summary) {
4346
+ this.observer.callSummaryCollector?.finalise(this);
4347
+ this._notify("call-summary", { ...this.eventScope, summary: this.summary });
4348
+ }
4241
4349
  this.detectors.clear();
4242
4350
  this.activeIssuesRegistry.clear();
4243
4351
  this.emit("close");
@@ -4285,6 +4393,7 @@ var ObservedCall = class extends EventEmitter3 {
4285
4393
  this.closeTimer = setTimeout(() => {
4286
4394
  this.close();
4287
4395
  }, this.settings.closeCallIfEmptyForMs);
4396
+ this.closeTimer.unref?.();
4288
4397
  }
4289
4398
  }
4290
4399
  ++this.totalRemovedClients;
@@ -4997,6 +5106,166 @@ var ActiveIssuesRegistry = class {
4997
5106
  }
4998
5107
  };
4999
5108
 
5109
+ // src/summaries/CallSummary.ts
5110
+ var defaultCallSummaryConfig = {
5111
+ include: [],
5112
+ maxIssues: 500,
5113
+ maxClientIds: 1e4
5114
+ };
5115
+ function createCallSummary(callId, config) {
5116
+ const summary = { callId, attachments: {} };
5117
+ if (config.include.includes("clients")) {
5118
+ summary.clients = { clientIds: [], peak: 0, joined: 0, left: 0 };
5119
+ }
5120
+ if (config.include.includes("issues")) {
5121
+ summary.issues = [];
5122
+ }
5123
+ if (config.include.includes("turnServers")) {
5124
+ summary.turnServers = { serverUrls: [], clientsRelayed: 0 };
5125
+ }
5126
+ if (config.include.includes("scores")) {
5127
+ summary.scores = { samples: 0 };
5128
+ }
5129
+ return summary;
5130
+ }
5131
+
5132
+ // src/summaries/CallSummaryCollector.ts
5133
+ var logger8 = createLogger("CallSummaryCollector");
5134
+ var CallSummaryCollector = class {
5135
+ constructor(_observer, _config) {
5136
+ this._observer = _observer;
5137
+ this._config = _config;
5138
+ this._subscribeBuiltIns();
5139
+ this._subscribeEnrichers();
5140
+ }
5141
+ _observer;
5142
+ _config;
5143
+ _scratch = /* @__PURE__ */ new WeakMap();
5144
+ _listeners = [];
5145
+ _closed = false;
5146
+ /**
5147
+ * Build a summary for `callId` and start tracking it.
5148
+ *
5149
+ * Creating it here, rather than letting the call create one and hand it over, keeps the resolved
5150
+ * configuration inside the single object that owns it — and makes it impossible to end up with a
5151
+ * summary whose sections nobody subscribed to fill.
5152
+ */
5153
+ createSummary(callId) {
5154
+ const summary = createCallSummary(callId, this._config);
5155
+ this._scratch.set(summary, { scores: [], turnClientIds: /* @__PURE__ */ new Set() });
5156
+ return summary;
5157
+ }
5158
+ /**
5159
+ * Finalise `call`'s summary: fold in what only makes sense once, and stamp the closing times.
5160
+ *
5161
+ * Percentiles are computed here rather than on every update — a median recomputed per tick over a
5162
+ * growing array is quadratic work to produce a number nobody reads until the end.
5163
+ */
5164
+ finalise(call) {
5165
+ const summary = call.summary;
5166
+ if (!summary) return;
5167
+ const scratch = this._scratch.get(summary);
5168
+ summary.startedAt = call.startedAt;
5169
+ summary.endedAt = call.endedAt;
5170
+ summary.durationInMs = summary.startedAt !== void 0 && summary.endedAt !== void 0 ? Math.max(0, summary.endedAt - summary.startedAt) : void 0;
5171
+ summary.closedAt = Date.now();
5172
+ if (summary.scores && scratch) {
5173
+ summary.scores.samples = scratch.scores.length;
5174
+ if (0 < scratch.scores.length) {
5175
+ summary.scores.min = Math.min(...scratch.scores);
5176
+ summary.scores.max = Math.max(...scratch.scores);
5177
+ summary.scores.median = percentile(scratch.scores, 0.5);
5178
+ }
5179
+ }
5180
+ if (summary.turnServers && scratch) {
5181
+ summary.turnServers.clientsRelayed = scratch.turnClientIds.size;
5182
+ }
5183
+ }
5184
+ /** Drop every bus subscription. Called when the observer closes. */
5185
+ close() {
5186
+ if (this._closed) return;
5187
+ this._closed = true;
5188
+ for (const { event, listener } of this._listeners) {
5189
+ this._observer.off(event, listener);
5190
+ }
5191
+ this._listeners.length = 0;
5192
+ }
5193
+ /**
5194
+ * Subscribe `listener` to `event`, routed to the summary of the call the event names.
5195
+ *
5196
+ * The `observedCall` is read off the payload rather than closed over, which is what lets one
5197
+ * subscription serve every call.
5198
+ */
5199
+ _on(event, handler) {
5200
+ const listener = (...args) => {
5201
+ const summary = args[0].observedCall.summary;
5202
+ if (!summary) return;
5203
+ const scratch = this._scratch.get(summary);
5204
+ if (!scratch) return;
5205
+ try {
5206
+ handler(summary, scratch, ...args);
5207
+ } catch (err) {
5208
+ logger8.warn("A call-summary handler for %s threw; continuing. %o", event, err);
5209
+ }
5210
+ };
5211
+ this._observer.on(event, listener);
5212
+ this._listeners.push({ event, listener });
5213
+ }
5214
+ _subscribeBuiltIns() {
5215
+ const include = this._config.include;
5216
+ if (include.includes("clients")) {
5217
+ this._on("client-added", (summary, _scratch, { observedCall, observedClient }) => {
5218
+ const clients = summary.clients;
5219
+ if (!clients) return;
5220
+ clients.joined += 1;
5221
+ clients.peak = Math.max(clients.peak, observedCall.observedClients.size);
5222
+ if (clients.clientIds.length < this._config.maxClientIds) {
5223
+ clients.clientIds.push(observedClient.clientId);
5224
+ } else {
5225
+ summary.truncated = { ...summary.truncated, clientIds: (summary.truncated?.clientIds ?? 0) + 1 };
5226
+ }
5227
+ });
5228
+ this._on("client-closed", (summary) => {
5229
+ if (summary.clients) summary.clients.left += 1;
5230
+ });
5231
+ }
5232
+ if (include.includes("issues")) {
5233
+ this._on("call-issue", (summary, _scratch, { issue }) => {
5234
+ const issues = summary.issues;
5235
+ if (!issues) return;
5236
+ if (issues.length < this._config.maxIssues) issues.push(issue);
5237
+ else summary.truncated = { ...summary.truncated, issues: (summary.truncated?.issues ?? 0) + 1 };
5238
+ });
5239
+ }
5240
+ if (include.includes("scores") || include.includes("turnServers")) {
5241
+ this._on("call-updated", (summary, scratch, { observedCall }) => {
5242
+ if (summary.scores && observedCall.score !== void 0) scratch.scores.push(observedCall.score);
5243
+ if (!summary.turnServers) return;
5244
+ for (const clientId of observedCall.clientsUsedTurn) scratch.turnClientIds.add(clientId);
5245
+ for (const client of observedCall.observedClients.values()) {
5246
+ for (const peerConnection of client.observedPeerConnections.values()) {
5247
+ for (const pair of peerConnection.selectedIceCandiadtePairForTurn) {
5248
+ const url = pair.getLocalCandidate()?.url;
5249
+ if (url && !summary.turnServers.serverUrls.includes(url)) {
5250
+ summary.turnServers.serverUrls.push(url);
5251
+ }
5252
+ }
5253
+ }
5254
+ }
5255
+ });
5256
+ }
5257
+ }
5258
+ _subscribeEnrichers() {
5259
+ const enrich = this._config.enrich;
5260
+ if (!enrich) return;
5261
+ for (const name of Object.keys(enrich)) {
5262
+ const enricher = enrich[name];
5263
+ if (!enricher) continue;
5264
+ this._on(name, (summary, _scratch, ...args) => enricher(summary, ...args));
5265
+ }
5266
+ }
5267
+ };
5268
+
5000
5269
  // src/detectors/SfuCongestionDetector.ts
5001
5270
  var SfuCongestionDetector = class _SfuCongestionDetector {
5002
5271
  constructor(_observer, config = {}) {
@@ -5125,13 +5394,10 @@ var SfuCongestionDetector = class _SfuCongestionDetector {
5125
5394
  absoluteIncrease: evaluation.absoluteIncrease,
5126
5395
  relativeIncrease: evaluation.relativeIncrease
5127
5396
  };
5128
- this._observer.emit("observer-issue", {
5129
- issue: {
5130
- type: this._config.emittedObserverIssueType,
5131
- timestamp: Date.now(),
5132
- payload
5133
- },
5134
- observer: this._observer
5397
+ this._observer.addIssue({
5398
+ type: this._config.emittedObserverIssueType,
5399
+ timestamp: Date.now(),
5400
+ payload: { ...payload }
5135
5401
  });
5136
5402
  }
5137
5403
  /**
@@ -5245,11 +5511,9 @@ var ObserverConcurrentIssueDetector = class _ObserverConcurrentIssueDetector {
5245
5511
  this._observer.addIssue({
5246
5512
  type: issueType,
5247
5513
  timestamp: now,
5514
+ conclusion,
5248
5515
  payload: {
5249
- type: issueType,
5250
5516
  issueType: type,
5251
- scope: "observer",
5252
- conclusion,
5253
5517
  clients: group.totalClients,
5254
5518
  affectedClients: group.clientIds.length,
5255
5519
  affectedRatio: group.affectedRatio,
@@ -5422,16 +5686,13 @@ var ClientPopulationIssueDetector = class _ClientPopulationIssueDetector {
5422
5686
  this._observer.addIssue({
5423
5687
  type: ClientPopulationIssueTypes.clientPopulationIssue,
5424
5688
  timestamp: now,
5425
- payload: {
5426
- type: ClientPopulationIssueTypes.clientPopulationIssue,
5427
- ...rollup,
5428
- conclusion: {
5429
- faultDomain: "client-population",
5430
- 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})`,
5431
- recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5432
- confidence: this._confidenceOf(rollup)
5433
- }
5434
- }
5689
+ conclusion: {
5690
+ faultDomain: "client-population",
5691
+ 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})`,
5692
+ recommendation: "this is not an SFU symptom \u2014 look at what those clients share: a recent release, a browser version, or shared/virtualised hardware",
5693
+ confidence: this._confidenceOf(rollup)
5694
+ },
5695
+ payload: { ...rollup }
5435
5696
  });
5436
5697
  }
5437
5698
  }
@@ -5533,7 +5794,7 @@ var TurnServerHealthDetector = class _TurnServerHealthDetector {
5533
5794
  this._observer.addIssue({
5534
5795
  type: TurnServerHealthTypes.turnServerDegraded,
5535
5796
  timestamp: now,
5536
- payload: { type: TurnServerHealthTypes.turnServerDegraded, ...health, otherServers }
5797
+ payload: { ...health, otherServers }
5537
5798
  });
5538
5799
  }
5539
5800
  for (const serverUrl of [...this._streaks.keys()]) {
@@ -5641,7 +5902,6 @@ var TurnServerOutageDetector = class _TurnServerOutageDetector {
5641
5902
  type: TurnServerOutageTypes.turnServerOutage,
5642
5903
  timestamp: now,
5643
5904
  payload: {
5644
- type: TurnServerOutageTypes.turnServerOutage,
5645
5905
  serverUrl,
5646
5906
  peakClients: peak,
5647
5907
  currentClients: live,
@@ -5915,17 +6175,13 @@ var SimulcastReceiverValidator = class _SimulcastReceiverValidator {
5915
6175
  this._observer.addIssue({
5916
6176
  type: LOWEST_COMMON_DENOMINATOR_ISSUE,
5917
6177
  timestamp: decidedAt,
5918
- payload: {
5919
- type: LOWEST_COMMON_DENOMINATOR_ISSUE,
5920
- checks: this._checks,
5921
- ...outcome.evidence,
5922
- conclusion: {
5923
- faultDomain: "infrastructure",
5924
- summary: "one bad receiver is dragging a publisher's bitrate down for everyone \u2014 the SFU is not selecting layers per consumer",
5925
- 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",
5926
- confidence: 0.8
5927
- }
5928
- }
6178
+ conclusion: {
6179
+ faultDomain: "infrastructure",
6180
+ summary: "one bad receiver is dragging a publisher's bitrate down for everyone \u2014 the SFU is not selecting layers per consumer",
6181
+ 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",
6182
+ confidence: 0.8
6183
+ },
6184
+ payload: { checks: this._checks, ...outcome.evidence }
5929
6185
  });
5930
6186
  }
5931
6187
  this.onDone(this.report);
@@ -6028,17 +6284,13 @@ var RemoteTrackResolverValidator = class _RemoteTrackResolverValidator {
6028
6284
  this._observer.addIssue({
6029
6285
  type: UNRESOLVED_TRACK_LINKS_ISSUE,
6030
6286
  timestamp: decidedAt,
6031
- payload: {
6032
- type: UNRESOLVED_TRACK_LINKS_ISSUE,
6033
- checks: this._checks,
6034
- ...outcome.evidence,
6035
- conclusion: {
6036
- faultDomain: "infrastructure",
6037
- summary: "a RemoteTrackResolver is configured but has never linked a subscriber to a publisher",
6038
- 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",
6039
- confidence: 0.9
6040
- }
6041
- }
6287
+ conclusion: {
6288
+ faultDomain: "infrastructure",
6289
+ summary: "a RemoteTrackResolver is configured but has never linked a subscriber to a publisher",
6290
+ 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",
6291
+ confidence: 0.9
6292
+ },
6293
+ payload: { checks: this._checks, ...outcome.evidence }
6042
6294
  });
6043
6295
  }
6044
6296
  this.onDone(this.report);
@@ -6149,17 +6401,16 @@ var CodecConsistencyValidator = class _CodecConsistencyValidator {
6149
6401
  this._observer.addIssue({
6150
6402
  type: CODEC_MISMATCH_ISSUE,
6151
6403
  timestamp: decidedAt,
6404
+ conclusion: {
6405
+ faultDomain: "infrastructure",
6406
+ 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",
6407
+ 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",
6408
+ confidence: 0.85
6409
+ },
6152
6410
  payload: {
6153
- type: CODEC_MISMATCH_ISSUE,
6154
6411
  verdict: outcome.verdict,
6155
6412
  checks: this._checks,
6156
- evidence: outcome.evidence,
6157
- conclusion: {
6158
- faultDomain: "infrastructure",
6159
- 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",
6160
- 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",
6161
- confidence: 0.85
6162
- }
6413
+ evidence: outcome.evidence
6163
6414
  }
6164
6415
  });
6165
6416
  }
@@ -6173,7 +6424,7 @@ function mediaKindOf(mimeType) {
6173
6424
  }
6174
6425
 
6175
6426
  // src/Observer.ts
6176
- var logger8 = createLogger("Observer");
6427
+ var logger9 = createLogger("Observer");
6177
6428
  var Observer = class extends EventEmitter6 {
6178
6429
  observedTURN = new ObservedTURN();
6179
6430
  observedCalls = /* @__PURE__ */ new Map();
@@ -6229,6 +6480,14 @@ var Observer = class extends EventEmitter6 {
6229
6480
  * ```
6230
6481
  */
6231
6482
  callDetectorConfigs = /* @__PURE__ */ new Map();
6483
+ /**
6484
+ * Owns every call's summary: the resolved `config.callSummary`, the bus subscriptions that keep
6485
+ * the summaries current (one per event type, not one per call), and the summaries themselves.
6486
+ *
6487
+ * `undefined` when `config.callSummary` was absent or `null` — so its presence *is* the answer to
6488
+ * "are summaries on", and nothing is subscribed to anything.
6489
+ */
6490
+ callSummaryCollector;
6232
6491
  constructor(config = {}) {
6233
6492
  super();
6234
6493
  this.setMaxListeners(Infinity);
@@ -6240,6 +6499,12 @@ var Observer = class extends EventEmitter6 {
6240
6499
  closeClientIfIdleForMs: 6e4,
6241
6500
  ...config
6242
6501
  };
6502
+ if (this.config.callSummary) {
6503
+ this.callSummaryCollector = new CallSummaryCollector(this, {
6504
+ ...defaultCallSummaryConfig,
6505
+ ...this.config.callSummary
6506
+ });
6507
+ }
6243
6508
  }
6244
6509
  get numberOfCalls() {
6245
6510
  return this.observedCalls.size;
@@ -6247,6 +6512,12 @@ var Observer = class extends EventEmitter6 {
6247
6512
  get appData() {
6248
6513
  return this.config.appData;
6249
6514
  }
6515
+ /**
6516
+ * Build a cross-call detector onto `observer.detectors`. Chainable.
6517
+ *
6518
+ * To get a handle on what was built — to inspect it, or to remove that exact instance later — read
6519
+ * it back off the registry: `observer.detectors.getAll(name)`, or `observer.detectors.instances`.
6520
+ */
6250
6521
  addObserverDetector(name, config = {}) {
6251
6522
  if (this.closed) return this;
6252
6523
  let detector;
@@ -6272,7 +6543,7 @@ var Observer = class extends EventEmitter6 {
6272
6543
  break;
6273
6544
  }
6274
6545
  default: {
6275
- logger8.warn("Unknown detector name %s; skipping", name);
6546
+ logger9.warn("Unknown detector name %s; skipping", name);
6276
6547
  return this;
6277
6548
  }
6278
6549
  }
@@ -6290,10 +6561,41 @@ var Observer = class extends EventEmitter6 {
6290
6561
  this.callDetectorConfigs.set(name, config);
6291
6562
  return this;
6292
6563
  }
6293
- /** Stop building `name` on calls created from now on. Calls already open are untouched. */
6294
- removeCallDetector(name) {
6564
+ /**
6565
+ * Remove an observer-scoped detector by name, returning how many were removed.
6566
+ *
6567
+ * **Every** instance registered under the name goes, since a name can legitimately be registered
6568
+ * more than once (`ClientPopulationIssueDetector` is meant to be added once per `groupBy` axis).
6569
+ * When you want one of them specifically, go through the registry, which deals in instances:
6570
+ *
6571
+ * ```ts
6572
+ * const [ byBrowser, byOs ] = observer.detectors.getAll('client-population-issue-detector');
6573
+ *
6574
+ * observer.detectors.remove(byOs); // keeps the browser axis running
6575
+ * ```
6576
+ *
6577
+ * Either route `close()`s the detector, so it unsubscribes from the issue registry and drops any
6578
+ * timers or bus listeners it held.
6579
+ */
6580
+ removeObserverDetector(name) {
6581
+ return this.detectors.removeByName(name);
6582
+ }
6583
+ /**
6584
+ * Stop building `name` on calls created from now on.
6585
+ *
6586
+ * By default this also removes it from the calls **already open**, so that "remove this detector"
6587
+ * means the same thing whether you say it before or after a call started — the alternative leaves
6588
+ * a fleet where the detector is live on some calls and not others, decided by join time. Pass
6589
+ * `{ includeOpenCalls: false }` to change only what future calls are built with.
6590
+ *
6591
+ * Returns the number of live detector instances removed (`0` when only the config changed).
6592
+ */
6593
+ removeCallDetector(name, { includeOpenCalls = true } = {}) {
6295
6594
  this.callDetectorConfigs.delete(name);
6296
- return this;
6595
+ if (!includeOpenCalls) return 0;
6596
+ let removed = 0;
6597
+ for (const call of this.observedCalls.values()) removed += call.removeDetector(name);
6598
+ return removed;
6297
6599
  }
6298
6600
  /**
6299
6601
  * Start a structural check. It runs on each `observer.update()` until it can decide, reports once
@@ -6326,23 +6628,53 @@ var Observer = class extends EventEmitter6 {
6326
6628
  break;
6327
6629
  }
6328
6630
  if (!validator) {
6329
- logger8.warn("Unknown validator name %s; skipping", name);
6631
+ logger9.warn("Unknown validator name %s; skipping", name);
6330
6632
  return this;
6331
6633
  }
6332
6634
  this.validators.add(validator);
6333
6635
  return this;
6334
6636
  }
6637
+ /**
6638
+ * Stop a running validation, by name or by instance. Returns how many were cancelled.
6639
+ *
6640
+ * Cancelling is **not** silent discarding. The validator finishes with `inconclusive` and the given
6641
+ * `reason`, emits `validation-ready` like any other completion, and removes itself. That matters
6642
+ * because anything waiting on the verdict — a deploy gate, a dashboard, a promise — would otherwise
6643
+ * wait forever, and because "we stopped asking" is a materially different outcome from "we asked
6644
+ * and learned nothing", which is exactly what `inconclusive` with a reason records.
6645
+ *
6646
+ * ```ts
6647
+ * observer.cancelValidator('simulcast-receivers', 'sfu redeployed');
6648
+ *
6649
+ * // or one specific instance — `observer.validators` holds what is running
6650
+ * for (const validator of observer.validators) observer.cancelValidator(validator, 'shutting down');
6651
+ * ```
6652
+ *
6653
+ * Pass a real reason. The default tells the reader nothing they could not already infer.
6654
+ */
6655
+ cancelValidator(target, reason = "cancelled") {
6656
+ const running = [...this.validators];
6657
+ const matching = typeof target === "string" ? running.filter((validator) => validator.name === target) : running.filter((validator) => validator === target);
6658
+ for (const validator of matching) {
6659
+ try {
6660
+ validator.cancel(reason);
6661
+ } catch (err) {
6662
+ logger9.warn("Error cancelling validator %s: %o", validator.name, err);
6663
+ }
6664
+ }
6665
+ return matching.length;
6666
+ }
6335
6667
  getObservedCall(callId) {
6336
6668
  if (this.closed || !this.observedCalls.has(callId)) return;
6337
6669
  return this.observedCalls.get(callId);
6338
6670
  }
6339
6671
  createObservedCall(settings) {
6340
6672
  if (this.closed) {
6341
- logger8.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6673
+ logger9.warn("Attempted to create a call (callId: %s) on a closed observer", settings.callId);
6342
6674
  return void 0;
6343
6675
  }
6344
6676
  if (this.observedCalls.has(settings.callId)) {
6345
- logger8.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6677
+ logger9.warn("Observed Call with id %s already exists; returning the existing instance", settings.callId);
6346
6678
  return this.observedCalls.get(settings.callId);
6347
6679
  }
6348
6680
  const callSettings = {
@@ -6357,6 +6689,7 @@ var Observer = class extends EventEmitter6 {
6357
6689
  callActiveIssuesRegistry
6358
6690
  );
6359
6691
  observedCall.remoteTrackResolver = this.config.createRemoteTrackResolver?.(observedCall);
6692
+ observedCall.enableSummary();
6360
6693
  for (const [name, detectorConfig] of this.callDetectorConfigs) {
6361
6694
  observedCall.addDetector(name, detectorConfig);
6362
6695
  }
@@ -6379,11 +6712,11 @@ var Observer = class extends EventEmitter6 {
6379
6712
  }
6380
6713
  createObservedMediasoupRouter(settings) {
6381
6714
  if (this.closed) {
6382
- logger8.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6715
+ logger9.warn("Attempted to create mediasoup router (id: %d) on a closed observer", settings.router.id);
6383
6716
  return void 0;
6384
6717
  }
6385
6718
  if (this.observedMediasoupRouters.has(settings.router.id)) {
6386
- logger8.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6719
+ logger9.warn("Observed Mediasoup Router (id %s) already exists; returning the existing instance", settings.router.id);
6387
6720
  return this.observedMediasoupRouters.get(settings.router.id);
6388
6721
  }
6389
6722
  const observedMediasoupRouter = new ObservedMediasoupRouter(settings);
@@ -6414,13 +6747,14 @@ var Observer = class extends EventEmitter6 {
6414
6747
  }
6415
6748
  close() {
6416
6749
  if (this.closed) {
6417
- return logger8.debug("Attempted to close twice");
6750
+ return logger9.debug("Attempted to close twice");
6418
6751
  }
6419
6752
  this.closed = true;
6420
6753
  for (const call of [...this.observedCalls.values()]) call.close();
6421
6754
  this.detectors.clear();
6422
- for (const validator of [...this.validators]) validator.cancel();
6755
+ for (const validator of [...this.validators]) validator.cancel("observer closed");
6423
6756
  this.validators.clear();
6757
+ this.callSummaryCollector?.close();
6424
6758
  this.activeIssuesRegistry.clear();
6425
6759
  this._notify("observer-closed", { ...this.eventScope });
6426
6760
  }
@@ -6431,7 +6765,7 @@ var Observer = class extends EventEmitter6 {
6431
6765
  try {
6432
6766
  this.acceptMiddlewares.process({ sample, context });
6433
6767
  } catch (err) {
6434
- logger8.warn("An accept middleware threw; dropping the sample. %o", err);
6768
+ logger9.warn("An accept middleware threw; dropping the sample. %o", err);
6435
6769
  }
6436
6770
  if (!sample.callId) {
6437
6771
  this._notify("sample-rejected", { ...this.eventScope, reason: "missing-callId", sample });
@@ -6483,7 +6817,7 @@ var Observer = class extends EventEmitter6 {
6483
6817
  try {
6484
6818
  validator.update();
6485
6819
  } catch (err) {
6486
- logger8.warn("Error running validator %s: %o", validator.name, err);
6820
+ logger9.warn("Error running validator %s: %o", validator.name, err);
6487
6821
  }
6488
6822
  }
6489
6823
  this._notify("observer-updated", { ...this.eventScope });
@@ -6496,7 +6830,7 @@ var Observer = class extends EventEmitter6 {
6496
6830
  */
6497
6831
  addIssue(issue) {
6498
6832
  if (this.closed) return;
6499
- this._notify("observer-issue", { ...this.eventScope, issue });
6833
+ this._notify("observer-issue", { ...this.eventScope, issue: { ...issue, scope: "observer" } });
6500
6834
  }
6501
6835
  /** Emit an Observer-bus event. */
6502
6836
  _notify(type, ...args) {
@@ -6614,18 +6948,11 @@ var CallHealthAggregator = class {
6614
6948
  }
6615
6949
  };
6616
6950
 
6617
- // src/common/ObserverIssue.ts
6618
- function issuePayloadOf(issue) {
6619
- const payload = issue.payload;
6620
- if (payload === void 0) return void 0;
6621
- return typeof payload === "string" ? parseJsonObject(payload) : payload;
6622
- }
6951
+ // src/common/Issue.ts
6623
6952
  function issuePayloadAsString(issue) {
6624
- const payload = issue.payload;
6625
- if (payload === void 0) return void 0;
6626
- if (typeof payload === "string") return payload;
6953
+ if (issue.payload === void 0) return void 0;
6627
6954
  try {
6628
- return JSON.stringify(payload);
6955
+ return JSON.stringify(issue.payload);
6629
6956
  } catch {
6630
6957
  return void 0;
6631
6958
  }
@@ -6919,6 +7246,7 @@ export {
6919
7246
  CallConcurrentIssueDetector,
6920
7247
  CallConcurrentIssueTypes,
6921
7248
  CallHealthAggregator,
7249
+ CallSummaryCollector,
6922
7250
  ClientEventTypes,
6923
7251
  ClientMetaTypes,
6924
7252
  ClientPopulationIssueDetector,
@@ -6977,16 +7305,17 @@ export {
6977
7305
  concludeObserverIssue,
6978
7306
  correlation,
6979
7307
  counterDelta,
7308
+ createCallSummary,
6980
7309
  createDefaultMediasoupRemoteTrackResolverFactory,
6981
7310
  createInMemorySink,
6982
7311
  createJsonlFileSink,
6983
7312
  createJsonlFileSinkFactory,
6984
7313
  createLogger,
6985
7314
  createP2pRemoteTrackResolverFactory,
7315
+ defaultCallSummaryConfig,
6986
7316
  defaultClientHealthThresholds,
6987
7317
  isClientIssueResolutionEntry,
6988
7318
  issuePayloadAsString,
6989
- issuePayloadOf,
6990
7319
  mannKendall,
6991
7320
  mannKendallVerdict,
6992
7321
  median,