@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/README.md +254 -25
- package/dist/index.d.mts +554 -140
- package/dist/index.d.ts +554 -140
- package/dist/index.js +436 -105
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +433 -104
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,14 +58,15 @@ and emits a single, unified stream of typed events the application can react to.
|
|
|
58
58
|
8. [API reference](#api-reference)
|
|
59
59
|
9. [Schema types (`ClientSample`)](#schema-types-clientsample)
|
|
60
60
|
10. [Detectors (server-side extension point)](#detectors-server-side-extension-point)
|
|
61
|
-
11. [
|
|
62
|
-
12. [
|
|
63
|
-
13. [
|
|
64
|
-
14. [
|
|
65
|
-
15. [
|
|
66
|
-
16. [
|
|
67
|
-
17. [
|
|
68
|
-
18. [
|
|
61
|
+
11. [Call summaries](#call-summaries)
|
|
62
|
+
12. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
|
|
63
|
+
13. [Mediasoup router observation](#mediasoup-router-observation)
|
|
64
|
+
14. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
|
|
65
|
+
15. [Injecting data into a client](#injecting-data-into-a-client)
|
|
66
|
+
16. [Logging](#logging)
|
|
67
|
+
17. [Design notes](#design-notes)
|
|
68
|
+
18. [Error-handling philosophy](#error-handling-philosophy)
|
|
69
|
+
19. [Development & extension guide](#development--extension-guide)
|
|
69
70
|
|
|
70
71
|
---
|
|
71
72
|
|
|
@@ -391,7 +392,8 @@ See [Mediasoup router observation](#mediasoup-router-observation) for the full d
|
|
|
391
392
|
| `call-closed` | — | the call closed |
|
|
392
393
|
| `call-empty` | — | last client left the call |
|
|
393
394
|
| `call-not-empty` | — | first client joined a previously-empty call |
|
|
394
|
-
| `call-issue` | `{ issue:
|
|
395
|
+
| `call-issue` | `{ issue: CallIssue }` | `call.addIssue(...)` (server-side detector finding) |
|
|
396
|
+
| `call-summary` | `{ summary: CallSummary }` | the call is closing and a [summary](#call-summaries) was configured. Emitted **inside** `close()`, while the call is still reachable |
|
|
395
397
|
|
|
396
398
|
#### Client level — scope `{ observer, observedCall, observedClient }`
|
|
397
399
|
|
|
@@ -463,6 +465,9 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
|
463
465
|
appData?: AppData;
|
|
464
466
|
closeClientIfIdleForMs?: number;
|
|
465
467
|
closeCallIfEmptyForMs?: number;
|
|
468
|
+
// accumulate a per-call summary (see Call summaries). Absent or null = off, and nothing
|
|
469
|
+
// subscribes to anything. `{}` is valid: a summary with no built-in sections.
|
|
470
|
+
callSummary?: Partial<CallSummaryConfig> | null;
|
|
466
471
|
// appData factories — run when an entity is created without explicit appData
|
|
467
472
|
// (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
|
|
468
473
|
createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
|
|
@@ -495,14 +500,24 @@ Key members:
|
|
|
495
500
|
- `getObservedCall<T>(callId): ObservedCall<T> | undefined`
|
|
496
501
|
- `createObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
497
502
|
- `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
503
|
+
- `addIssue(issue: Omit<ObserverIssue, 'scope'>): void` — raise an **observer-level** finding → emits `observer-issue`. `scope` is stamped for you
|
|
498
504
|
- `update(): void` — force an aggregation/`observer-updated` tick
|
|
499
505
|
- `addObserverDetector(name, config?): this` — build a cross-call detector onto `observer.detectors`
|
|
500
|
-
- `addCallDetector(name, config?): this`
|
|
501
|
-
|
|
506
|
+
- `addCallDetector(name, config?): this` — register a call-scoped detector for every call created
|
|
507
|
+
from now on
|
|
508
|
+
- `removeCallDetector(name, { includeOpenCalls? }): number` — stop building it, and (by default) drop
|
|
509
|
+
it from calls already open. Returns how many live instances were removed
|
|
510
|
+
- `removeObserverDetector(name): number` — remove an observer-scoped detector. For one specific
|
|
511
|
+
instance use `observer.detectors.remove(detector)`
|
|
502
512
|
- `addValidator(name, config?): this` — start a one-shot structural check
|
|
513
|
+
- `cancelValidator(name | validator, reason?): number` — stop a running check; it finishes
|
|
514
|
+
`inconclusive` with the reason and emits `validation-ready`
|
|
503
515
|
- `close(): void`
|
|
504
516
|
- `readonly detectors: Detectors` — observer-scoped registry. **Starts empty**; nothing is implicit
|
|
505
517
|
- `readonly callDetectorConfigs: Map<name, config>` — what `addCallDetector` recorded
|
|
518
|
+
- `readonly callSummaryCollector?: CallSummaryCollector` — owns the resolved `config.callSummary`,
|
|
519
|
+
the summary subscriptions, and the summaries. `undefined` when summaries are off, which is the
|
|
520
|
+
only place that answer lives
|
|
506
521
|
- `readonly validators: Set<RunningValidator>` — normally empty; each removes itself on finishing
|
|
507
522
|
- `readonly activeIssuesRegistry: ActiveIssuesRegistry` — the fleet's open client issues
|
|
508
523
|
- `readonly observedCalls: Map<string, ObservedCall>`
|
|
@@ -530,8 +545,10 @@ Key members:
|
|
|
530
545
|
- `readonly callId: string`, `appData: AppData`
|
|
531
546
|
- `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
|
|
532
547
|
- `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
|
|
533
|
-
- `addIssue(issue:
|
|
548
|
+
- `addIssue(issue: Omit<CallIssue, 'scope'>): void` — raise a **call-level** finding → emits `call-issue`. `scope` is stamped for you
|
|
534
549
|
- `addDetector(name, config?): this` — build a call-scoped detector onto this call only
|
|
550
|
+
- `removeDetector(name): number` — remove it from this call, `close()`ing it. For one specific
|
|
551
|
+
instance use `call.detectors.remove(detector)`
|
|
535
552
|
- `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
|
|
536
553
|
- `readonly activeIssuesRegistry: ActiveIssuesRegistry` — this call's open client issues, propagating into the observer's
|
|
537
554
|
- `readonly unconsumedOutboundTracks: Set<ObservedOutboundTrack>` — maintained by the resolver
|
|
@@ -540,6 +557,7 @@ Key members:
|
|
|
540
557
|
- aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
|
|
541
558
|
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
|
|
542
559
|
`clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
|
|
560
|
+
- `summary?: CallSummary` — the live record of this call, when [summaries](#call-summaries) are on
|
|
543
561
|
- `update()`, `close()`
|
|
544
562
|
|
|
545
563
|
### `ObservedClient`
|
|
@@ -650,7 +668,7 @@ type PeerConnectionSample = {
|
|
|
650
668
|
};
|
|
651
669
|
|
|
652
670
|
type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
653
|
-
type ClientIssue = { type: string; payload?: string; timestamp?: number };
|
|
671
|
+
type ClientIssue = { type: string; payload?: string; timestamp?: number };
|
|
654
672
|
type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
655
673
|
type ExtensionStat = { type: string; payload?: string };
|
|
656
674
|
```
|
|
@@ -746,14 +764,14 @@ correlate **across** the clients of a call or the calls of a fleet, because that
|
|
|
746
764
|
server can do better than a browser: per-client signals — packet loss, jitter, RTT, freezes — are
|
|
747
765
|
already detected on the client and arrive on samples as `clientIssues` (surfaced via `client-issue`).
|
|
748
766
|
|
|
749
|
-
Findings are raised as **`ObserverIssue`** — `{ type,
|
|
750
|
-
|
|
751
|
-
there is nothing to serialise for:
|
|
767
|
+
Findings are raised as **`CallIssue`** or **`ObserverIssue`** — both share `IssueBase`: `{ type,
|
|
768
|
+
timestamp, conclusion?, payload? }` — and the payload is the **object**, not a JSON string. A
|
|
769
|
+
server-raised finding is delivered to an in-process handler, so there is nothing to serialise for:
|
|
752
770
|
|
|
753
771
|
```ts
|
|
754
772
|
observer.on('call-issue', ({ observedCall, issue }) => {
|
|
755
773
|
issue.payload; // the object; no JSON.parse
|
|
756
|
-
|
|
774
|
+
issue.conclusion?.faultDomain; // a first-class field, not payload.conclusion
|
|
757
775
|
issuePayloadAsString(issue); // only at an edge that needs text (log, HTTP, queue)
|
|
758
776
|
});
|
|
759
777
|
```
|
|
@@ -771,7 +789,7 @@ class MyCrossClientDetector implements Detector {
|
|
|
771
789
|
update() { // called on every call.update()
|
|
772
790
|
// …inspect this.call.observedClients across participants…
|
|
773
791
|
if (/* condition only visible server-side */ false) {
|
|
774
|
-
this.call.addIssue({ type: this.name, payload:
|
|
792
|
+
this.call.addIssue({ type: this.name, payload: { /* … */ }, timestamp: Date.now() });
|
|
775
793
|
// → emitted on the bus as 'call-issue'
|
|
776
794
|
}
|
|
777
795
|
}
|
|
@@ -959,12 +977,63 @@ observer.addObserverDetector('turn-server-outage-detector', { minClientsAtPeak:
|
|
|
959
977
|
observer.addCallDetector('call-concurrent-issue-detector', {
|
|
960
978
|
issueTypes: [ 'congestion', 'ice-disconnected' ],
|
|
961
979
|
});
|
|
962
|
-
observer.removeCallDetector('unconsumed-track-detector'); // undo, for future calls
|
|
963
|
-
|
|
964
980
|
// one specific call
|
|
965
981
|
observedCall.addDetector('issue-fan-out-detector', { issueTypes: [ 'freezed-video-track' ] });
|
|
966
982
|
```
|
|
967
983
|
|
|
984
|
+
Every `add*` is **chainable** — it returns the owning entity:
|
|
985
|
+
|
|
986
|
+
```ts
|
|
987
|
+
observer
|
|
988
|
+
.addObserverDetector('turn-server-health-detector')
|
|
989
|
+
.addObserverDetector('turn-server-outage-detector', { minClientsAtPeak: 10 })
|
|
990
|
+
.addValidator('remote-track-resolver');
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
#### Removing them
|
|
994
|
+
|
|
995
|
+
By **name**, on the entity — which removes *every* instance under that name:
|
|
996
|
+
|
|
997
|
+
```ts
|
|
998
|
+
observer.removeObserverDetector('turn-server-outage-detector'); // → 1
|
|
999
|
+
observer.removeCallDetector('call-concurrent-issue-detector'); // stops it everywhere
|
|
1000
|
+
observedCall.removeDetector('issue-fan-out-detector'); // this call only
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
By **instance**, through the registry — which is where instances live, since `add*` returns the
|
|
1004
|
+
entity rather than the detector:
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
observer
|
|
1008
|
+
.addObserverDetector('client-population-issue-detector', { issueTypes: [ 'cpulimitation' ], groupBy: 'browser' })
|
|
1009
|
+
.addObserverDetector('client-population-issue-detector', { issueTypes: [ 'cpulimitation' ], groupBy: 'operationSystem' });
|
|
1010
|
+
|
|
1011
|
+
const [ byBrowser, byOs ] = observer.detectors.getAll('client-population-issue-detector');
|
|
1012
|
+
|
|
1013
|
+
observer.detectors.remove(byOs); // keeps the browser axis running
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
`Detectors` is a small collection: `instances` (a copy, in registration order), `listOfNames`,
|
|
1017
|
+
`size`, `get(name)`, `getAll(name)`, `has(name)`, `add(detector)`, `remove(detector)`,
|
|
1018
|
+
`removeByName(name)`, `clear()`, and it is iterable — `for (const detector of call.detectors)`.
|
|
1019
|
+
`instances` being a copy is deliberate: removing while iterating the live array would skip entries,
|
|
1020
|
+
and "drop the ones that look like X" is the most natural thing to want to write.
|
|
1021
|
+
|
|
1022
|
+
Two things worth knowing:
|
|
1023
|
+
|
|
1024
|
+
- **By name removes every instance under it**, not the first. A name can legitimately be registered
|
|
1025
|
+
more than once — `ClientPopulationIssueDetector` is meant to be added once per `groupBy` axis — and
|
|
1026
|
+
"remove whichever is first in the array" is not something a caller can predict from a name. Go via
|
|
1027
|
+
`detectors.getAll(name)` + `detectors.remove(instance)` when you mean one of them.
|
|
1028
|
+
- **`removeCallDetector` affects calls already open, by default.** Otherwise whether a detector runs
|
|
1029
|
+
would depend on when a call happened to join, which is not a state anyone can reason about. Pass
|
|
1030
|
+
`{ includeOpenCalls: false }` to change only what future calls are built with.
|
|
1031
|
+
|
|
1032
|
+
Every removal path calls the detector's `close()`, so it unsubscribes from the issue registry and
|
|
1033
|
+
drops any timers or bus listeners. A detector removed without closing would keep being fed matching
|
|
1034
|
+
issues for the life of the call — invisible, unbounded, and it would still look healthy if you
|
|
1035
|
+
inspected it.
|
|
1036
|
+
|
|
968
1037
|
Detectors are named by their kebab-case `NAME`, and the name types the config — an unknown name or a
|
|
969
1038
|
key that belongs to a different detector will not compile. Each detector owns its defaults in its own
|
|
970
1039
|
constructor, beside the doc explaining what each threshold means; there is no central table to keep
|
|
@@ -1093,6 +1162,23 @@ onDeploy(() => observer.addValidator('simulcast-receivers')); // check again
|
|
|
1093
1162
|
finishing. There is no revalidation timer: a deploy, not elapsed time, is what makes a structural
|
|
1094
1163
|
verdict stale, so re-checking means starting another.
|
|
1095
1164
|
|
|
1165
|
+
**Cancelling.** A check that has not decided can be stopped, by name or by instance:
|
|
1166
|
+
|
|
1167
|
+
```ts
|
|
1168
|
+
observer.cancelValidator('simulcast-receivers', 'sfu redeployed');
|
|
1169
|
+
|
|
1170
|
+
// or one specific instance — `observer.validators` holds what is running
|
|
1171
|
+
for (const validator of observer.validators) observer.cancelValidator(validator, 'shutting down');
|
|
1172
|
+
```
|
|
1173
|
+
|
|
1174
|
+
Cancelling is **not** silent discarding. The validator finishes `inconclusive` with your reason,
|
|
1175
|
+
emits `validation-ready` like any other completion, and removes itself. That matters twice over:
|
|
1176
|
+
anything waiting on the verdict would otherwise wait forever, and *"we stopped asking"* is a
|
|
1177
|
+
materially different outcome from *"we asked and learned nothing"* — which is exactly what an
|
|
1178
|
+
`inconclusive` carrying a reason records. Pass a real reason; the default tells the reader nothing
|
|
1179
|
+
they could not already infer. `observer.close()` cancels whatever is still running with
|
|
1180
|
+
`'observer closed'`.
|
|
1181
|
+
|
|
1096
1182
|
| Validator | `addValidator` name | Question | Also raises |
|
|
1097
1183
|
|-----------|---------------------|----------|-------------|
|
|
1098
1184
|
| `SimulcastReceiverValidator` 🔗 | `simulcast-receivers` | Does the SFU pick layers per receiver, or drag the publisher down to the worst one? | `WORST_RECEIVER_CONTAGION` |
|
|
@@ -1141,22 +1227,62 @@ observer.addValidator('remote-track-resolver');
|
|
|
1141
1227
|
observer.addValidator('codec-consistency', { expected: { video: 'video/VP9', audio: 'audio/opus' } });
|
|
1142
1228
|
```
|
|
1143
1229
|
|
|
1230
|
+
#### `CallIssue` vs `ObserverIssue`
|
|
1231
|
+
|
|
1232
|
+
Server-raised findings come in two kinds, distinguished by the scope that raised them:
|
|
1233
|
+
|
|
1234
|
+
| | raised by | delivered as | `scope` |
|
|
1235
|
+
|---|---|---|---|
|
|
1236
|
+
| `CallIssue` | `observedCall.addIssue(...)` | `call-issue` | `'call'` |
|
|
1237
|
+
| `ObserverIssue` | `observer.addIssue(...)` | `observer-issue` | `'observer'` |
|
|
1238
|
+
|
|
1239
|
+
Both share `IssueBase` — `type`, `timestamp`, `conclusion?`, `payload?` — and `Issue` is the union,
|
|
1240
|
+
discriminated on `scope`.
|
|
1241
|
+
|
|
1242
|
+
```ts
|
|
1243
|
+
observer.on('call-issue', ({ observedCall, issue }) => {
|
|
1244
|
+
issue.scope; // 'call'
|
|
1245
|
+
observedCall.callId; // the call — NOT repeated in the payload
|
|
1246
|
+
issue.conclusion?.faultDomain;
|
|
1247
|
+
issue.payload; // evidence only
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
observer.on('observer-issue', ({ issue }) => {
|
|
1251
|
+
issue.scope; // 'observer'
|
|
1252
|
+
});
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
`scope` is stamped by `addIssue` rather than asked of the detector: it is a fact about *where the
|
|
1256
|
+
finding was raised*, which the entity knows and a detector should not have to restate. Having it on
|
|
1257
|
+
the issue — not merely implied by which event fired — keeps a finding self-describing once it leaves
|
|
1258
|
+
the bus, into a shared handler, a log line or a queue.
|
|
1259
|
+
|
|
1260
|
+
**The payload is evidence and nothing else.** It no longer repeats `type`, `scope`, or the `callId`
|
|
1261
|
+
already carried by the event, and `conclusion` was lifted out of it to a first-class field. A payload
|
|
1262
|
+
that restates its own envelope invites the two to disagree — and they did, because nothing kept them
|
|
1263
|
+
in step. `payload` is always an object (the `string` form is gone, along with `issuePayloadOf`); use
|
|
1264
|
+
`issuePayloadAsString(issue)` at a boundary that genuinely needs text.
|
|
1265
|
+
|
|
1144
1266
|
#### Conclusions
|
|
1145
1267
|
|
|
1146
|
-
Every issue-driven finding carries a `conclusion`
|
|
1147
|
-
|
|
1268
|
+
Every issue-driven finding carries a `conclusion` — the interpretation step, so the person reading
|
|
1269
|
+
the alert doesn't have to perform it. It sits **beside** the evidence, not inside it:
|
|
1148
1270
|
|
|
1149
1271
|
```jsonc
|
|
1150
1272
|
{
|
|
1151
1273
|
"type": "CROSS_CALL_ISSUE_ONSET_BURST",
|
|
1152
|
-
"
|
|
1153
|
-
"
|
|
1154
|
-
"perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ],
|
|
1274
|
+
"scope": "observer",
|
|
1275
|
+
"timestamp": 1739812345678,
|
|
1155
1276
|
"conclusion": {
|
|
1156
1277
|
"faultDomain": "infrastructure",
|
|
1157
1278
|
"summary": "network congestion is open across independent calls at the same time — 6 of 40 calls (11/300 clients)",
|
|
1158
1279
|
"recommendation": "check SFU egress bandwidth and host network saturation before looking at any single participant",
|
|
1159
1280
|
"confidence": 0.85
|
|
1281
|
+
},
|
|
1282
|
+
"payload": {
|
|
1283
|
+
"issueType": "congestion",
|
|
1284
|
+
"calls": 40, "affectedCalls": 6,
|
|
1285
|
+
"perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ]
|
|
1160
1286
|
}
|
|
1161
1287
|
}
|
|
1162
1288
|
```
|
|
@@ -1241,6 +1367,109 @@ rather than trusting the flag alone: **"no subscribers" and "no resolver configu
|
|
|
1241
1367
|
identical observation.** Without a resolver it would report every published track in the call as
|
|
1242
1368
|
unconsumed.
|
|
1243
1369
|
|
|
1370
|
+
## Call summaries
|
|
1371
|
+
|
|
1372
|
+
Everything else in this library is about *now*. Detectors answer "is something wrong right now",
|
|
1373
|
+
validators answer a structural question once, and both read state the call throws away when it ends.
|
|
1374
|
+
A **call summary** is the one thing that outlives the call: who was in it, what was raised against
|
|
1375
|
+
it, how it scored — the questions asked *after* the meeting, by support, by billing, by whoever is
|
|
1376
|
+
writing the incident note.
|
|
1377
|
+
|
|
1378
|
+
It is configured on the observer, at construction:
|
|
1379
|
+
|
|
1380
|
+
```ts
|
|
1381
|
+
const observer = new Observer({
|
|
1382
|
+
callSummary: {
|
|
1383
|
+
include: [ 'clients', 'issues', 'turnServers', 'scores' ],
|
|
1384
|
+
},
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
observer.on('call-summary', ({ summary }) => archive(summary));
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
Omit `callSummary`, or set it to `null`, and there are no summaries and **not one extra bus
|
|
1391
|
+
subscription**. Pass an object — `{}` is valid — and every call this observer creates carries one.
|
|
1392
|
+
|
|
1393
|
+
> **Why construction-time, when detectors are added per call?** A summary is a record of what
|
|
1394
|
+
> happened, and a record you can switch on halfway through is a record with a hole in it. Calls that
|
|
1395
|
+
> started before the switch would carry different sections from calls that started after, with
|
|
1396
|
+
> nothing on either to say which. One shape for every call, or none.
|
|
1397
|
+
|
|
1398
|
+
### Sections are opt-in, and absence means "not collected"
|
|
1399
|
+
|
|
1400
|
+
`include` picks from four built-ins, and **the default is `[]`** — none of them:
|
|
1401
|
+
|
|
1402
|
+
| Section | Contains |
|
|
1403
|
+
|---------|----------|
|
|
1404
|
+
| `clients` | `clientIds` (join order), `peak`, `joined`, `left`. Identifiers and counts only |
|
|
1405
|
+
| `issues` | `CallIssue[]`, in the order raised, capped by `maxIssues` |
|
|
1406
|
+
| `turnServers` | `serverUrls` that carried media, and `clientsRelayed` |
|
|
1407
|
+
| `scores` | `min` / `max` / `median` of the call score, and `samples` |
|
|
1408
|
+
|
|
1409
|
+
**A missing section means it was never collected — never "nothing happened".** Reading
|
|
1410
|
+
`summary.issues === undefined` as "this call was clean" is the one misreading this type invites, so
|
|
1411
|
+
there is no default-empty section to make it easy. This is the same rule as `inconclusive` on a
|
|
1412
|
+
validator: silence is not success.
|
|
1413
|
+
|
|
1414
|
+
The `clients` section is deliberately identifiers and counts. Anything *about* a client — browser,
|
|
1415
|
+
platform, region — is already on `observedClient` while the call is live, and belongs in
|
|
1416
|
+
`attachments` via an enricher if you want it kept; see below.
|
|
1417
|
+
|
|
1418
|
+
### Enrichers: fold in anything, from any call-scoped event
|
|
1419
|
+
|
|
1420
|
+
```ts
|
|
1421
|
+
new Observer({
|
|
1422
|
+
callSummary: {
|
|
1423
|
+
include: [ 'issues' ],
|
|
1424
|
+
enrich: {
|
|
1425
|
+
'client-joined': (summary, { observedClient }) => {
|
|
1426
|
+
// serialisable facts only — the region string, never the live object it came from
|
|
1427
|
+
((summary.attachments.regions ??= []) as string[]).push(String(observedClient.appData.region));
|
|
1428
|
+
},
|
|
1429
|
+
},
|
|
1430
|
+
},
|
|
1431
|
+
});
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
Each enricher is typed against its own event's payload. Only **call-scoped** events are accepted —
|
|
1435
|
+
the ones carrying an `observedCall`. An enricher on `observer-issue` or `validation-ready` will not
|
|
1436
|
+
compile, because there is no single call to attribute a fleet-wide fact to, and quietly writing it
|
|
1437
|
+
into every open summary would be worse than a type error.
|
|
1438
|
+
|
|
1439
|
+
The library never writes to `summary.attachments`, so nothing you put there can collide with a
|
|
1440
|
+
section added in a future version.
|
|
1441
|
+
|
|
1442
|
+
> **Why `attachments` and not `appData`.** `appData` is live working state hung off an entity for
|
|
1443
|
+
> that entity's lifetime, and it may hold things that cannot be serialised — a mediasoup router, a
|
|
1444
|
+
> socket. A summary is the opposite: it outlives the call so it can be **shipped**, and it reaches
|
|
1445
|
+
> you on `call-summary` while the call it describes is being torn down, so an unserialisable value
|
|
1446
|
+
> in it points at something already gone. Same contract as `attachments` on a `ClientSample`: read
|
|
1447
|
+
> the live object off `observedCall` / `observedClient` in the enricher, attach what serialises —
|
|
1448
|
+
> the router's `id`, not the router. An enricher that throws is logged and skipped — a summary is a
|
|
1449
|
+
side-channel, and nothing about a call should break because a field could not be recorded.
|
|
1450
|
+
|
|
1451
|
+
### Caps announce what they dropped
|
|
1452
|
+
|
|
1453
|
+
`maxIssues` (default `500`) and `maxClientIds` (default `10_000`) bound the two unbounded lists.
|
|
1454
|
+
When either bites, `summary.truncated` appears with the shortfall — present **only** when something
|
|
1455
|
+
was actually dropped. That is what makes dropping safe: the true count is recoverable as
|
|
1456
|
+
`issues.length + (truncated?.issues ?? 0)`. A silently truncated summary is worse than no summary,
|
|
1457
|
+
because someone will count `issues.length` and report it as the issue count.
|
|
1458
|
+
|
|
1459
|
+
`issues` is the plain array, with no derived tallies alongside it. A count is `issues.length` and a
|
|
1460
|
+
per-type count is one `filter` — both cheaper at the call site than kept correct here.
|
|
1461
|
+
|
|
1462
|
+
### Reading it
|
|
1463
|
+
|
|
1464
|
+
`observedCall.summary` is live: read it at any point during the call. It is also delivered once on
|
|
1465
|
+
`call-summary`, emitted **inside** `close()` while the call is still in `observer.observedCalls` —
|
|
1466
|
+
after that the call is gone and there is nothing left to ask. `observer.close()` closes its calls
|
|
1467
|
+
first and its collector afterwards, so every summary still makes it out.
|
|
1468
|
+
|
|
1469
|
+
Cost is **one bus listener per subscribed event type, for the whole observer** — not one per call. A
|
|
1470
|
+
per-call design would be quadratic in concurrent calls: at 500 calls and eight events, 4 000
|
|
1471
|
+
listeners each doing 500 no-op invocations per event. Percentiles are computed once, at close.
|
|
1472
|
+
|
|
1244
1473
|
## Remote track resolution (mediasoup / SFU)
|
|
1245
1474
|
|
|
1246
1475
|
In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
|