@observertc/observer-js 1.0.0-beta.16 → 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 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. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
62
- 12. [Mediasoup router observation](#mediasoup-router-observation)
63
- 13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
64
- 14. [Injecting data into a client](#injecting-data-into-a-client)
65
- 15. [Logging](#logging)
66
- 16. [Design notes](#design-notes)
67
- 17. [Error-handling philosophy](#error-handling-philosophy)
68
- 18. [Development & extension guide](#development--extension-guide)
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: ObserverIssue }` | `call.addIssue(...)` (server-side detector finding) |
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,6 +500,7 @@ 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
506
  - `addCallDetector(name, config?): this` — register a call-scoped detector for every call created
@@ -509,6 +515,9 @@ Key members:
509
515
  - `close(): void`
510
516
  - `readonly detectors: Detectors` — observer-scoped registry. **Starts empty**; nothing is implicit
511
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
512
521
  - `readonly validators: Set<RunningValidator>` — normally empty; each removes itself on finishing
513
522
  - `readonly activeIssuesRegistry: ActiveIssuesRegistry` — the fleet's open client issues
514
523
  - `readonly observedCalls: Map<string, ObservedCall>`
@@ -536,7 +545,7 @@ Key members:
536
545
  - `readonly callId: string`, `appData: AppData`
537
546
  - `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
538
547
  - `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
539
- - `addIssue(issue: ObserverIssue): void` — raise a **call-level** issue → emits `call-issue`
548
+ - `addIssue(issue: Omit<CallIssue, 'scope'>): void` — raise a **call-level** finding → emits `call-issue`. `scope` is stamped for you
540
549
  - `addDetector(name, config?): this` — build a call-scoped detector onto this call only
541
550
  - `removeDetector(name): number` — remove it from this call, `close()`ing it. For one specific
542
551
  instance use `call.detectors.remove(detector)`
@@ -548,6 +557,7 @@ Key members:
548
557
  - aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
549
558
  `numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
550
559
  `clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
560
+ - `summary?: CallSummary` — the live record of this call, when [summaries](#call-summaries) are on
551
561
  - `update()`, `close()`
552
562
 
553
563
  ### `ObservedClient`
@@ -658,7 +668,7 @@ type PeerConnectionSample = {
658
668
  };
659
669
 
660
670
  type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
661
- type ClientIssue = { type: string; payload?: string; timestamp?: number }; // also used for call-issue
671
+ type ClientIssue = { type: string; payload?: string; timestamp?: number };
662
672
  type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
663
673
  type ExtensionStat = { type: string; payload?: string };
664
674
  ```
@@ -754,14 +764,14 @@ correlate **across** the clients of a call or the calls of a fleet, because that
754
764
  server can do better than a browser: per-client signals — packet loss, jitter, RTT, freezes — are
755
765
  already detected on the client and arrive on samples as `clientIssues` (surfaced via `client-issue`).
756
766
 
757
- Findings are raised as **`ObserverIssue`** — `{ type, timestamp, payload? }` — and the payload is the
758
- **object**, not a JSON string. A server-raised finding is delivered to an in-process handler, so
759
- 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:
760
770
 
761
771
  ```ts
762
772
  observer.on('call-issue', ({ observedCall, issue }) => {
763
773
  issue.payload; // the object; no JSON.parse
764
- issuePayloadOf(issue); // if you want to accept a string payload too
774
+ issue.conclusion?.faultDomain; // a first-class field, not payload.conclusion
765
775
  issuePayloadAsString(issue); // only at an edge that needs text (log, HTTP, queue)
766
776
  });
767
777
  ```
@@ -779,7 +789,7 @@ class MyCrossClientDetector implements Detector {
779
789
  update() { // called on every call.update()
780
790
  // …inspect this.call.observedClients across participants…
781
791
  if (/* condition only visible server-side */ false) {
782
- this.call.addIssue({ type: this.name, payload: JSON.stringify({ /* … */ }), timestamp: Date.now() });
792
+ this.call.addIssue({ type: this.name, payload: { /* … */ }, timestamp: Date.now() });
783
793
  // → emitted on the bus as 'call-issue'
784
794
  }
785
795
  }
@@ -1217,22 +1227,62 @@ observer.addValidator('remote-track-resolver');
1217
1227
  observer.addValidator('codec-consistency', { expected: { video: 'video/VP9', audio: 'audio/opus' } });
1218
1228
  ```
1219
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
+
1220
1266
  #### Conclusions
1221
1267
 
1222
- Every issue-driven finding carries a `conclusion` in its payload — the interpretation step, so the
1223
- person reading the alert doesn't have to perform it:
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:
1224
1270
 
1225
1271
  ```jsonc
1226
1272
  {
1227
1273
  "type": "CROSS_CALL_ISSUE_ONSET_BURST",
1228
- "issueType": "congestion",
1229
- "calls": 40, "affectedCalls": 6,
1230
- "perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ],
1274
+ "scope": "observer",
1275
+ "timestamp": 1739812345678,
1231
1276
  "conclusion": {
1232
1277
  "faultDomain": "infrastructure",
1233
1278
  "summary": "network congestion is open across independent calls at the same time — 6 of 40 calls (11/300 clients)",
1234
1279
  "recommendation": "check SFU egress bandwidth and host network saturation before looking at any single participant",
1235
1280
  "confidence": 0.85
1281
+ },
1282
+ "payload": {
1283
+ "issueType": "congestion",
1284
+ "calls": 40, "affectedCalls": 6,
1285
+ "perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ]
1236
1286
  }
1237
1287
  }
1238
1288
  ```
@@ -1317,6 +1367,109 @@ rather than trusting the flag alone: **"no subscribers" and "no resolver configu
1317
1367
  identical observation.** Without a resolver it would report every published track in the call as
1318
1368
  unconsumed.
1319
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
+
1320
1473
  ## Remote track resolution (mediasoup / SFU)
1321
1474
 
1322
1475
  In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**