@observertc/observer-js 1.0.0-beta.16 → 1.0.0-beta.18
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 +200 -34
- package/dist/index.d.mts +440 -139
- package/dist/index.d.ts +440 -139
- package/dist/index.js +298 -129
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +295 -128
- 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
|
|
|
@@ -261,7 +262,10 @@ deliberately distinct:
|
|
|
261
262
|
|
|
262
263
|
- **`appData`** — application-assigned extra info that identifies/decorates an entity, fixed at
|
|
263
264
|
creation (via `settings.appData` or the `createCallAppData` / `createClientAppData` factories),
|
|
264
|
-
or assigned by the app on the `*-added` events. The library never changes it.
|
|
265
|
+
or assigned by the app on the `*-added` events. The library never changes it. The factories
|
|
266
|
+
**receive the context of the `accept()` that triggered the creation**, so a fact carried on the
|
|
267
|
+
context can be baked into `appData` at birth — but it is copied by the factory, deliberately, not
|
|
268
|
+
written across by the library.
|
|
265
269
|
- **`context`** — passed per `accept()`, may differ on every call, and is carried straight
|
|
266
270
|
through to the `*-updated` events that the `accept()` triggers, then discarded.
|
|
267
271
|
|
|
@@ -391,7 +395,8 @@ See [Mediasoup router observation](#mediasoup-router-observation) for the full d
|
|
|
391
395
|
| `call-closed` | — | the call closed |
|
|
392
396
|
| `call-empty` | — | last client left the call |
|
|
393
397
|
| `call-not-empty` | — | first client joined a previously-empty call |
|
|
394
|
-
| `call-issue` | `{ issue:
|
|
398
|
+
| `call-issue` | `{ issue: CallIssue }` | `call.addIssue(...)` (server-side detector finding) |
|
|
399
|
+
| `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
400
|
|
|
396
401
|
#### Client level — scope `{ observer, observedCall, observedClient }`
|
|
397
402
|
|
|
@@ -463,10 +468,13 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
|
463
468
|
appData?: AppData;
|
|
464
469
|
closeClientIfIdleForMs?: number;
|
|
465
470
|
closeCallIfEmptyForMs?: number;
|
|
471
|
+
// accumulate a per-call summary (see Call summaries). Absent or null = off, and nothing
|
|
472
|
+
// subscribes to anything. `{}` is valid: a summary with no built-in sections.
|
|
473
|
+
callSummary?: Partial<CallSummaryConfig> | null;
|
|
466
474
|
// appData factories — run when an entity is created without explicit appData
|
|
467
475
|
// (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
|
|
468
|
-
createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
|
|
469
|
-
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
|
|
476
|
+
createCallAppData?: (p: { callId: string; observer: Observer; acceptCtx?: AcceptContext }) => Record<string, unknown>;
|
|
477
|
+
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall; acceptCtx?: AcceptContext }) => Record<string, unknown>;
|
|
470
478
|
// sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
|
|
471
479
|
createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
|
|
472
480
|
// remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
|
|
@@ -475,26 +483,37 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
|
475
483
|
```
|
|
476
484
|
|
|
477
485
|
**appData factories.** Instead of pre-creating a call/client (or assigning on `call-added` /
|
|
478
|
-
`client-added`) just to enrich its `appData`, register a factory once. It runs
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
486
|
+
`client-added`) just to enrich its `appData`, register a factory once. It runs whenever the entity
|
|
487
|
+
is created without an explicit `settings.appData` — including the lazy creation inside `accept()`.
|
|
488
|
+
The `client` factory receives the already-created parent `observedCall`, so it can derive fields
|
|
489
|
+
from it.
|
|
490
|
+
|
|
491
|
+
Both also receive **`acceptCtx`**: the [`AcceptContext`](#context-the-acceptcontext) of the
|
|
492
|
+
`accept()` that caused the creation, or `undefined` when you created the entity yourself. This is
|
|
493
|
+
what lets an [accept middleware](#accept-middlewares-global-pre-dispatch-hook) resolve something
|
|
494
|
+
once — a tenant, a trace id — and have it land in `appData` at birth, instead of every factory
|
|
495
|
+
re-deriving it from the sample.
|
|
483
496
|
|
|
484
497
|
```ts
|
|
485
498
|
const observer = new Observer({
|
|
486
|
-
createCallAppData: ({ callId })
|
|
487
|
-
createClientAppData: ({ clientId, observedCall }) => ({ clientId,
|
|
499
|
+
createCallAppData: ({ callId, acceptCtx }) => ({ callId, startedAt: Date.now(), tenant: acceptCtx?.tenant }),
|
|
500
|
+
createClientAppData: ({ clientId, observedCall }) => ({ clientId, tenant: observedCall.appData.tenant }),
|
|
488
501
|
});
|
|
502
|
+
|
|
503
|
+
observer.accept(sample, { tenant: 'acme' });
|
|
489
504
|
```
|
|
490
505
|
|
|
506
|
+
`appData` stays application-owned: the context is *offered* to the factory, never written across by
|
|
507
|
+
the library, and it is still not stored on any entity.
|
|
508
|
+
|
|
491
509
|
Key members:
|
|
492
510
|
|
|
493
511
|
- `accept(sample: ClientSample, context?: AcceptContext): void`
|
|
494
512
|
- `addAcceptMiddleware(...mw: AcceptMiddleware[]): this` / `removeAcceptMiddleware(...mw): this` — global pre-dispatch sample hooks (see [Accept middlewares](#accept-middlewares-global-pre-dispatch-hook))
|
|
495
513
|
- `getObservedCall<T>(callId): ObservedCall<T> | undefined`
|
|
496
|
-
- `createObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
497
|
-
- `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
514
|
+
- `createObservedCall<T>(settings, acceptCtx?): ObservedCall<T> | undefined`
|
|
515
|
+
- `getOrCreateObservedCall<T>(settings, acceptCtx?): ObservedCall<T> | undefined`
|
|
516
|
+
- `addIssue(issue: Omit<ObserverIssue, 'scope'>): void` — raise an **observer-level** finding → emits `observer-issue`. `scope` is stamped for you
|
|
498
517
|
- `update(): void` — force an aggregation/`observer-updated` tick
|
|
499
518
|
- `addObserverDetector(name, config?): this` — build a cross-call detector onto `observer.detectors`
|
|
500
519
|
- `addCallDetector(name, config?): this` — register a call-scoped detector for every call created
|
|
@@ -509,6 +528,9 @@ Key members:
|
|
|
509
528
|
- `close(): void`
|
|
510
529
|
- `readonly detectors: Detectors` — observer-scoped registry. **Starts empty**; nothing is implicit
|
|
511
530
|
- `readonly callDetectorConfigs: Map<name, config>` — what `addCallDetector` recorded
|
|
531
|
+
- `readonly callSummaryCollector?: CallSummaryCollector` — owns the resolved `config.callSummary`,
|
|
532
|
+
the summary subscriptions, and the summaries. `undefined` when summaries are off, which is the
|
|
533
|
+
only place that answer lives
|
|
512
534
|
- `readonly validators: Set<RunningValidator>` — normally empty; each removes itself on finishing
|
|
513
535
|
- `readonly activeIssuesRegistry: ActiveIssuesRegistry` — the fleet's open client issues
|
|
514
536
|
- `readonly observedCalls: Map<string, ObservedCall>`
|
|
@@ -535,8 +557,8 @@ Key members:
|
|
|
535
557
|
|
|
536
558
|
- `readonly callId: string`, `appData: AppData`
|
|
537
559
|
- `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
|
|
538
|
-
- `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
|
|
539
|
-
- `addIssue(issue:
|
|
560
|
+
- `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings, acceptCtx?)`, `getOrCreateObservedClient<T>(settings, acceptCtx?)` (all `… | undefined`)
|
|
561
|
+
- `addIssue(issue: Omit<CallIssue, 'scope'>): void` — raise a **call-level** finding → emits `call-issue`. `scope` is stamped for you
|
|
540
562
|
- `addDetector(name, config?): this` — build a call-scoped detector onto this call only
|
|
541
563
|
- `removeDetector(name): number` — remove it from this call, `close()`ing it. For one specific
|
|
542
564
|
instance use `call.detectors.remove(detector)`
|
|
@@ -548,6 +570,7 @@ Key members:
|
|
|
548
570
|
- aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
|
|
549
571
|
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
|
|
550
572
|
`clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
|
|
573
|
+
- `summary?: CallSummary` — the live record of this call, when [summaries](#call-summaries) are on
|
|
551
574
|
- `update()`, `close()`
|
|
552
575
|
|
|
553
576
|
### `ObservedClient`
|
|
@@ -658,7 +681,7 @@ type PeerConnectionSample = {
|
|
|
658
681
|
};
|
|
659
682
|
|
|
660
683
|
type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
661
|
-
type ClientIssue = { type: string; payload?: string; timestamp?: number };
|
|
684
|
+
type ClientIssue = { type: string; payload?: string; timestamp?: number };
|
|
662
685
|
type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
663
686
|
type ExtensionStat = { type: string; payload?: string };
|
|
664
687
|
```
|
|
@@ -754,14 +777,14 @@ correlate **across** the clients of a call or the calls of a fleet, because that
|
|
|
754
777
|
server can do better than a browser: per-client signals — packet loss, jitter, RTT, freezes — are
|
|
755
778
|
already detected on the client and arrive on samples as `clientIssues` (surfaced via `client-issue`).
|
|
756
779
|
|
|
757
|
-
Findings are raised as **`ObserverIssue`** — `{ type,
|
|
758
|
-
|
|
759
|
-
there is nothing to serialise for:
|
|
780
|
+
Findings are raised as **`CallIssue`** or **`ObserverIssue`** — both share `IssueBase`: `{ type,
|
|
781
|
+
timestamp, conclusion?, payload? }` — and the payload is the **object**, not a JSON string. A
|
|
782
|
+
server-raised finding is delivered to an in-process handler, so there is nothing to serialise for:
|
|
760
783
|
|
|
761
784
|
```ts
|
|
762
785
|
observer.on('call-issue', ({ observedCall, issue }) => {
|
|
763
786
|
issue.payload; // the object; no JSON.parse
|
|
764
|
-
|
|
787
|
+
issue.conclusion?.faultDomain; // a first-class field, not payload.conclusion
|
|
765
788
|
issuePayloadAsString(issue); // only at an edge that needs text (log, HTTP, queue)
|
|
766
789
|
});
|
|
767
790
|
```
|
|
@@ -779,7 +802,7 @@ class MyCrossClientDetector implements Detector {
|
|
|
779
802
|
update() { // called on every call.update()
|
|
780
803
|
// …inspect this.call.observedClients across participants…
|
|
781
804
|
if (/* condition only visible server-side */ false) {
|
|
782
|
-
this.call.addIssue({ type: this.name, payload:
|
|
805
|
+
this.call.addIssue({ type: this.name, payload: { /* … */ }, timestamp: Date.now() });
|
|
783
806
|
// → emitted on the bus as 'call-issue'
|
|
784
807
|
}
|
|
785
808
|
}
|
|
@@ -1217,22 +1240,62 @@ observer.addValidator('remote-track-resolver');
|
|
|
1217
1240
|
observer.addValidator('codec-consistency', { expected: { video: 'video/VP9', audio: 'audio/opus' } });
|
|
1218
1241
|
```
|
|
1219
1242
|
|
|
1243
|
+
#### `CallIssue` vs `ObserverIssue`
|
|
1244
|
+
|
|
1245
|
+
Server-raised findings come in two kinds, distinguished by the scope that raised them:
|
|
1246
|
+
|
|
1247
|
+
| | raised by | delivered as | `scope` |
|
|
1248
|
+
|---|---|---|---|
|
|
1249
|
+
| `CallIssue` | `observedCall.addIssue(...)` | `call-issue` | `'call'` |
|
|
1250
|
+
| `ObserverIssue` | `observer.addIssue(...)` | `observer-issue` | `'observer'` |
|
|
1251
|
+
|
|
1252
|
+
Both share `IssueBase` — `type`, `timestamp`, `conclusion?`, `payload?` — and `Issue` is the union,
|
|
1253
|
+
discriminated on `scope`.
|
|
1254
|
+
|
|
1255
|
+
```ts
|
|
1256
|
+
observer.on('call-issue', ({ observedCall, issue }) => {
|
|
1257
|
+
issue.scope; // 'call'
|
|
1258
|
+
observedCall.callId; // the call — NOT repeated in the payload
|
|
1259
|
+
issue.conclusion?.faultDomain;
|
|
1260
|
+
issue.payload; // evidence only
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
observer.on('observer-issue', ({ issue }) => {
|
|
1264
|
+
issue.scope; // 'observer'
|
|
1265
|
+
});
|
|
1266
|
+
```
|
|
1267
|
+
|
|
1268
|
+
`scope` is stamped by `addIssue` rather than asked of the detector: it is a fact about *where the
|
|
1269
|
+
finding was raised*, which the entity knows and a detector should not have to restate. Having it on
|
|
1270
|
+
the issue — not merely implied by which event fired — keeps a finding self-describing once it leaves
|
|
1271
|
+
the bus, into a shared handler, a log line or a queue.
|
|
1272
|
+
|
|
1273
|
+
**The payload is evidence and nothing else.** It no longer repeats `type`, `scope`, or the `callId`
|
|
1274
|
+
already carried by the event, and `conclusion` was lifted out of it to a first-class field. A payload
|
|
1275
|
+
that restates its own envelope invites the two to disagree — and they did, because nothing kept them
|
|
1276
|
+
in step. `payload` is always an object (the `string` form is gone, along with `issuePayloadOf`); use
|
|
1277
|
+
`issuePayloadAsString(issue)` at a boundary that genuinely needs text.
|
|
1278
|
+
|
|
1220
1279
|
#### Conclusions
|
|
1221
1280
|
|
|
1222
|
-
Every issue-driven finding carries a `conclusion`
|
|
1223
|
-
|
|
1281
|
+
Every issue-driven finding carries a `conclusion` — the interpretation step, so the person reading
|
|
1282
|
+
the alert doesn't have to perform it. It sits **beside** the evidence, not inside it:
|
|
1224
1283
|
|
|
1225
1284
|
```jsonc
|
|
1226
1285
|
{
|
|
1227
1286
|
"type": "CROSS_CALL_ISSUE_ONSET_BURST",
|
|
1228
|
-
"
|
|
1229
|
-
"
|
|
1230
|
-
"perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ],
|
|
1287
|
+
"scope": "observer",
|
|
1288
|
+
"timestamp": 1739812345678,
|
|
1231
1289
|
"conclusion": {
|
|
1232
1290
|
"faultDomain": "infrastructure",
|
|
1233
1291
|
"summary": "network congestion is open across independent calls at the same time — 6 of 40 calls (11/300 clients)",
|
|
1234
1292
|
"recommendation": "check SFU egress bandwidth and host network saturation before looking at any single participant",
|
|
1235
1293
|
"confidence": 0.85
|
|
1294
|
+
},
|
|
1295
|
+
"payload": {
|
|
1296
|
+
"issueType": "congestion",
|
|
1297
|
+
"calls": 40, "affectedCalls": 6,
|
|
1298
|
+
"perCall": [ { "callId": "…", "affectedClients": 4, "totalClients": 9 } ]
|
|
1236
1299
|
}
|
|
1237
1300
|
}
|
|
1238
1301
|
```
|
|
@@ -1317,6 +1380,109 @@ rather than trusting the flag alone: **"no subscribers" and "no resolver configu
|
|
|
1317
1380
|
identical observation.** Without a resolver it would report every published track in the call as
|
|
1318
1381
|
unconsumed.
|
|
1319
1382
|
|
|
1383
|
+
## Call summaries
|
|
1384
|
+
|
|
1385
|
+
Everything else in this library is about *now*. Detectors answer "is something wrong right now",
|
|
1386
|
+
validators answer a structural question once, and both read state the call throws away when it ends.
|
|
1387
|
+
A **call summary** is the one thing that outlives the call: who was in it, what was raised against
|
|
1388
|
+
it, how it scored — the questions asked *after* the meeting, by support, by billing, by whoever is
|
|
1389
|
+
writing the incident note.
|
|
1390
|
+
|
|
1391
|
+
It is configured on the observer, at construction:
|
|
1392
|
+
|
|
1393
|
+
```ts
|
|
1394
|
+
const observer = new Observer({
|
|
1395
|
+
callSummary: {
|
|
1396
|
+
include: [ 'clients', 'issues', 'turnServers', 'scores' ],
|
|
1397
|
+
},
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
observer.on('call-summary', ({ summary }) => archive(summary));
|
|
1401
|
+
```
|
|
1402
|
+
|
|
1403
|
+
Omit `callSummary`, or set it to `null`, and there are no summaries and **not one extra bus
|
|
1404
|
+
subscription**. Pass an object — `{}` is valid — and every call this observer creates carries one.
|
|
1405
|
+
|
|
1406
|
+
> **Why construction-time, when detectors are added per call?** A summary is a record of what
|
|
1407
|
+
> happened, and a record you can switch on halfway through is a record with a hole in it. Calls that
|
|
1408
|
+
> started before the switch would carry different sections from calls that started after, with
|
|
1409
|
+
> nothing on either to say which. One shape for every call, or none.
|
|
1410
|
+
|
|
1411
|
+
### Sections are opt-in, and absence means "not collected"
|
|
1412
|
+
|
|
1413
|
+
`include` picks from four built-ins, and **the default is `[]`** — none of them:
|
|
1414
|
+
|
|
1415
|
+
| Section | Contains |
|
|
1416
|
+
|---------|----------|
|
|
1417
|
+
| `clients` | `clientIds` (join order), `peak`, `joined`, `left`. Identifiers and counts only |
|
|
1418
|
+
| `issues` | `CallIssue[]`, in the order raised, capped by `maxIssues` |
|
|
1419
|
+
| `turnServers` | `serverUrls` that carried media, and `clientsRelayed` |
|
|
1420
|
+
| `scores` | `min` / `max` / `median` of the call score, and `samples` |
|
|
1421
|
+
|
|
1422
|
+
**A missing section means it was never collected — never "nothing happened".** Reading
|
|
1423
|
+
`summary.issues === undefined` as "this call was clean" is the one misreading this type invites, so
|
|
1424
|
+
there is no default-empty section to make it easy. This is the same rule as `inconclusive` on a
|
|
1425
|
+
validator: silence is not success.
|
|
1426
|
+
|
|
1427
|
+
The `clients` section is deliberately identifiers and counts. Anything *about* a client — browser,
|
|
1428
|
+
platform, region — is already on `observedClient` while the call is live, and belongs in
|
|
1429
|
+
`attachments` via an enricher if you want it kept; see below.
|
|
1430
|
+
|
|
1431
|
+
### Enrichers: fold in anything, from any call-scoped event
|
|
1432
|
+
|
|
1433
|
+
```ts
|
|
1434
|
+
new Observer({
|
|
1435
|
+
callSummary: {
|
|
1436
|
+
include: [ 'issues' ],
|
|
1437
|
+
enrich: {
|
|
1438
|
+
'client-joined': (summary, { observedClient }) => {
|
|
1439
|
+
// serialisable facts only — the region string, never the live object it came from
|
|
1440
|
+
((summary.attachments.regions ??= []) as string[]).push(String(observedClient.appData.region));
|
|
1441
|
+
},
|
|
1442
|
+
},
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
Each enricher is typed against its own event's payload. Only **call-scoped** events are accepted —
|
|
1448
|
+
the ones carrying an `observedCall`. An enricher on `observer-issue` or `validation-ready` will not
|
|
1449
|
+
compile, because there is no single call to attribute a fleet-wide fact to, and quietly writing it
|
|
1450
|
+
into every open summary would be worse than a type error.
|
|
1451
|
+
|
|
1452
|
+
The library never writes to `summary.attachments`, so nothing you put there can collide with a
|
|
1453
|
+
section added in a future version.
|
|
1454
|
+
|
|
1455
|
+
> **Why `attachments` and not `appData`.** `appData` is live working state hung off an entity for
|
|
1456
|
+
> that entity's lifetime, and it may hold things that cannot be serialised — a mediasoup router, a
|
|
1457
|
+
> socket. A summary is the opposite: it outlives the call so it can be **shipped**, and it reaches
|
|
1458
|
+
> you on `call-summary` while the call it describes is being torn down, so an unserialisable value
|
|
1459
|
+
> in it points at something already gone. Same contract as `attachments` on a `ClientSample`: read
|
|
1460
|
+
> the live object off `observedCall` / `observedClient` in the enricher, attach what serialises —
|
|
1461
|
+
> the router's `id`, not the router. An enricher that throws is logged and skipped — a summary is a
|
|
1462
|
+
side-channel, and nothing about a call should break because a field could not be recorded.
|
|
1463
|
+
|
|
1464
|
+
### Caps announce what they dropped
|
|
1465
|
+
|
|
1466
|
+
`maxIssues` (default `500`) and `maxClientIds` (default `10_000`) bound the two unbounded lists.
|
|
1467
|
+
When either bites, `summary.truncated` appears with the shortfall — present **only** when something
|
|
1468
|
+
was actually dropped. That is what makes dropping safe: the true count is recoverable as
|
|
1469
|
+
`issues.length + (truncated?.issues ?? 0)`. A silently truncated summary is worse than no summary,
|
|
1470
|
+
because someone will count `issues.length` and report it as the issue count.
|
|
1471
|
+
|
|
1472
|
+
`issues` is the plain array, with no derived tallies alongside it. A count is `issues.length` and a
|
|
1473
|
+
per-type count is one `filter` — both cheaper at the call site than kept correct here.
|
|
1474
|
+
|
|
1475
|
+
### Reading it
|
|
1476
|
+
|
|
1477
|
+
`observedCall.summary` is live: read it at any point during the call. It is also delivered once on
|
|
1478
|
+
`call-summary`, emitted **inside** `close()` while the call is still in `observer.observedCalls` —
|
|
1479
|
+
after that the call is gone and there is nothing left to ask. `observer.close()` closes its calls
|
|
1480
|
+
first and its collector afterwards, so every summary still makes it out.
|
|
1481
|
+
|
|
1482
|
+
Cost is **one bus listener per subscribed event type, for the whole observer** — not one per call. A
|
|
1483
|
+
per-call design would be quadratic in concurrent calls: at 500 calls and eight events, 4 000
|
|
1484
|
+
listeners each doing 500 no-op invocations per event. Percentiles are computed once, at close.
|
|
1485
|
+
|
|
1320
1486
|
## Remote track resolution (mediasoup / SFU)
|
|
1321
1487
|
|
|
1322
1488
|
In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
|