@observertc/observer-js 1.0.0-beta.7 → 1.0.0-beta.9
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 +248 -104
- package/dist/index.d.mts +188 -3
- package/dist/index.d.ts +188 -3
- package/dist/index.js +397 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +396 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@observertc/observer-js)
|
|
4
4
|
[](https://github.com/observertc/observer-js/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
+
> **In one line:** feed it WebRTC `getStats()` snapshots, and get back a live, queryable model of
|
|
7
|
+
> every call plus a single typed event stream to react to.
|
|
8
|
+
|
|
6
9
|
`observer-js` is a **server-side Node.js library for monitoring WebRTC sessions**. A WebRTC
|
|
7
10
|
application (typically an SFU or a signaling/stats backend) feeds it `ClientSample` objects —
|
|
8
11
|
periodic snapshots of each participant's `RTCPeerConnection.getStats()` output plus
|
|
@@ -10,12 +13,30 @@ application events — and `observer-js` maintains a live, in-memory model of ev
|
|
|
10
13
|
participant, peer connection, and media stream, derives per-interval and cumulative metrics,
|
|
11
14
|
and emits a single, unified stream of typed events the application can react to.
|
|
12
15
|
|
|
16
|
+
**What you can do with it:**
|
|
17
|
+
|
|
18
|
+
- **Monitor calls live** — a queryable in-memory tree of every call, client, peer connection,
|
|
19
|
+
track, codec, ICE candidate and data channel, each holding current **and** cumulative metrics.
|
|
20
|
+
- **React on one event bus** — subscribe once on the `Observer`; every payload carries its full
|
|
21
|
+
ancestry (`call → client → peer connection → stat`), so you never walk the tree to subscribe.
|
|
22
|
+
- **Get derived metrics for free** — counter-reset-safe per-tick deltas, bitrates, jitter, RTT,
|
|
23
|
+
fraction-lost, remote-RTP (RTCP) correlation, and TURN/TCP usage from the selected candidate pair.
|
|
24
|
+
- **Correlate across an SFU** — link a publisher's outbound track to every subscriber's inbound
|
|
25
|
+
track (`RemoteTrackResolver`), and observe mediasoup routers/transports/producers/consumers
|
|
26
|
+
on the server side.
|
|
27
|
+
- **Detect server-only problems** — cross-client `Detector`s raise `call-issue`s for conditions no
|
|
28
|
+
single client can see (e.g. everyone in a call degrading at once).
|
|
29
|
+
- **Persist every sample** — per-client sinks (JSONL file, in-memory, or your own) for archival,
|
|
30
|
+
streaming, and offline replay.
|
|
31
|
+
- **Drop it in safely** — warn-don't-throw, a pluggable logger, dual **ESM + CommonJS**, and **no**
|
|
32
|
+
media-stack dependency in the core.
|
|
33
|
+
|
|
13
34
|
> **Status:** `1.0.0-beta`. The API described here is current and intended to be implemented
|
|
14
35
|
> against directly. This document is written to be self-sufficient: an engineer (or an AI
|
|
15
36
|
> agent) should be able to integrate the library, or develop it further, from this file alone.
|
|
16
37
|
> A companion doc, [`docs/logging.md`](./docs/logging.md), covers logging integration in depth.
|
|
17
38
|
|
|
18
|
-
> **Packaging:** server-side, **Node.js ≥
|
|
39
|
+
> **Packaging:** server-side, **Node.js ≥ 22**, shipped as a **dual ESM + CommonJS** build — so it
|
|
19
40
|
> works whether your project uses `import` (ESM) or `require()` (CommonJS). Everything — including
|
|
20
41
|
> the built-in file sink — is exported from the single `@observertc/observer-js` entry.
|
|
21
42
|
|
|
@@ -24,23 +45,21 @@ and emits a single, unified stream of typed events the application can react to.
|
|
|
24
45
|
## Table of contents
|
|
25
46
|
|
|
26
47
|
1. [Installation](#installation)
|
|
27
|
-
2. [
|
|
28
|
-
3. [
|
|
29
|
-
4. [
|
|
30
|
-
5. [
|
|
31
|
-
6. [
|
|
32
|
-
7. [
|
|
33
|
-
8. [
|
|
34
|
-
9. [
|
|
35
|
-
10. [
|
|
36
|
-
11. [
|
|
37
|
-
12. [
|
|
48
|
+
2. [Quick start](#quick-start)
|
|
49
|
+
3. [Data flow](#data-flow)
|
|
50
|
+
4. [Entity hierarchy](#entity-hierarchy)
|
|
51
|
+
5. [Ingestion: `accept()`, context & lifecycle](#ingestion-accept-context--lifecycle)
|
|
52
|
+
6. [Update policies](#update-policies)
|
|
53
|
+
7. [The event bus](#the-event-bus) ← the core of the API
|
|
54
|
+
8. [API reference](#api-reference)
|
|
55
|
+
9. [Schema types (`ClientSample`)](#schema-types-clientsample)
|
|
56
|
+
10. [Detectors (server-side extension point)](#detectors-server-side-extension-point)
|
|
57
|
+
11. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
|
|
58
|
+
12. [Mediasoup router observation](#mediasoup-router-observation)
|
|
38
59
|
13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
|
|
39
60
|
14. [Logging](#logging)
|
|
40
61
|
15. [Error-handling philosophy](#error-handling-philosophy)
|
|
41
|
-
16. [
|
|
42
|
-
17. [Development & extension guide](#development--extension-guide)
|
|
43
|
-
18. [Not yet implemented / roadmap](#not-yet-implemented--roadmap)
|
|
62
|
+
16. [Development & extension guide](#development--extension-guide)
|
|
44
63
|
|
|
45
64
|
---
|
|
46
65
|
|
|
@@ -52,7 +71,7 @@ npm install @observertc/observer-js
|
|
|
52
71
|
yarn add @observertc/observer-js
|
|
53
72
|
```
|
|
54
73
|
|
|
55
|
-
**Server-side, Node.js ≥
|
|
74
|
+
**Server-side, Node.js ≥ 22, dual ESM + CommonJS.** The package ships both module formats, so it
|
|
56
75
|
works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
|
|
57
76
|
|
|
58
77
|
```ts
|
|
@@ -72,31 +91,6 @@ produced on the client (e.g. by `@observertc/client-monitor-js`) conform to the
|
|
|
72
91
|
|
|
73
92
|
---
|
|
74
93
|
|
|
75
|
-
## Mental model
|
|
76
|
-
|
|
77
|
-
Five ideas are enough to use the whole library:
|
|
78
|
-
|
|
79
|
-
1. **One ingestion method.** `observer.accept(sample, context?)` is how data gets in.
|
|
80
|
-
Calls, clients, and peer connections are created automatically the first time their id
|
|
81
|
-
appears in a sample.
|
|
82
|
-
|
|
83
|
-
2. **A live entity tree.** `Observer → ObservedCall → ObservedClient → ObservedPeerConnection
|
|
84
|
-
→ {inbound/outbound RTP, tracks, data channels, ICE, codecs, …}`. Every node holds current
|
|
85
|
-
and cumulative metrics and is reachable by id through `Map`s on its parent.
|
|
86
|
-
|
|
87
|
-
3. **One event bus.** Everything worth subscribing to is emitted on the **`Observer`** itself
|
|
88
|
-
(it is an `EventEmitter`). Each event payload is an **object carrying the full ancestry**
|
|
89
|
-
of the entity it came from. You never have to walk the tree to subscribe.
|
|
90
|
-
|
|
91
|
-
4. **Pull or react.** You can read fields off the entities at any time (pull), and/or react to
|
|
92
|
-
events (push). The `*-updated` events fire on each processing tick.
|
|
93
|
-
|
|
94
|
-
5. **Warn, don't throw.** Operational problems (bad config, duplicate ids, closed entities,
|
|
95
|
-
malformed samples) never throw; they warn through the pluggable logger and degrade
|
|
96
|
-
gracefully (returning `undefined` or emitting `sample-rejected`).
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
94
|
## Quick start
|
|
101
95
|
|
|
102
96
|
```ts
|
|
@@ -297,7 +291,8 @@ These return `undefined` (and warn) when the parent is closed; `createObservedCa
|
|
|
297
291
|
"Update" means *recompute aggregated metrics and emit the `*-updated` event* at that level.
|
|
298
292
|
Both the observer and each call have a configurable trigger. Updates are **event-driven** — there
|
|
299
293
|
is no built-in timer. An app that wants a fixed cadence can call `observer.update()` /
|
|
300
|
-
`call.update()` from its own `setInterval`.
|
|
294
|
+
`call.update()` from its own `setInterval`. With `'none'`, **nothing auto-updates** — the level
|
|
295
|
+
updates only when the application calls the public `update()` itself.
|
|
301
296
|
|
|
302
297
|
**Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
|
|
303
298
|
|
|
@@ -305,6 +300,7 @@ is no built-in timer. An app that wants a fixed cadence can call `observer.updat
|
|
|
305
300
|
|--------|-------------------------------------|
|
|
306
301
|
| `update-on-any-call-updated` | any call updates |
|
|
307
302
|
| `update-when-all-call-updated` | every call has updated since the last observer update |
|
|
303
|
+
| `none` | never automatically — only when the app calls `observer.update()` |
|
|
308
304
|
|
|
309
305
|
**Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
|
|
310
306
|
`ObserverConfig.defaultCallUpdatePolicy`):
|
|
@@ -313,6 +309,7 @@ is no built-in timer. An app that wants a fixed cadence can call `observer.updat
|
|
|
313
309
|
|--------|--------------------------------|
|
|
314
310
|
| `update-on-any-client-updated` | any client in the call updates |
|
|
315
311
|
| `update-when-all-client-updated` | every client has updated since the last call update |
|
|
312
|
+
| `none` | never automatically — only when the app calls `call.update()` |
|
|
316
313
|
|
|
317
314
|
---
|
|
318
315
|
|
|
@@ -361,6 +358,16 @@ additional field(s) on top of that scope.
|
|
|
361
358
|
| `observer-closed` | — | `observer.close()` |
|
|
362
359
|
| `sample-rejected` | `{ reason: 'observer-closed' \| 'missing-callId' \| 'missing-clientId', sample: ClientSample }` | a sample was dropped by `accept()` |
|
|
363
360
|
|
|
361
|
+
#### Mediasoup level — scope `{ observer, observedMediasoupRouter }`
|
|
362
|
+
|
|
363
|
+
| Event | Extra | Fires when |
|
|
364
|
+
|-------|-------|-----------|
|
|
365
|
+
| `mediasoup-router-added` | — | `observer.createObservedMediasoupRouter(...)` registered a router |
|
|
366
|
+
| `mediasoup-router-matched-with-call` | `{ observedCall }` | the router was matched to a call — explicitly via `callId`, or by WebRTC-transport ↔ peer-connection correlation. Emitted **once per distinct call**; the observer stores **nothing** — your handler decides what to do |
|
|
367
|
+
| `mediasoup-router-removed` | — | the underlying mediasoup router closed (its `router.observer` `close` fired) |
|
|
368
|
+
|
|
369
|
+
See [Mediasoup router observation](#mediasoup-router-observation) for the full design and examples.
|
|
370
|
+
|
|
364
371
|
#### Call level — scope `{ observer, observedCall }`
|
|
365
372
|
|
|
366
373
|
| Event | Extra | Fires when |
|
|
@@ -437,7 +444,7 @@ listen to them, but prefer the bus equivalents above for application logic.
|
|
|
437
444
|
new Observer<AppData>(config?: ObserverConfig<AppData>)
|
|
438
445
|
|
|
439
446
|
type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
440
|
-
updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated';
|
|
447
|
+
updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'none';
|
|
441
448
|
defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
|
|
442
449
|
appData?: AppData;
|
|
443
450
|
closeClientIfIdleForMs?: number;
|
|
@@ -488,7 +495,7 @@ Key members:
|
|
|
488
495
|
|
|
489
496
|
```ts
|
|
490
497
|
type ObservedCallSettings<AppData = Record<string, unknown>> = {
|
|
491
|
-
updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated';
|
|
498
|
+
updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'none';
|
|
492
499
|
callId: string;
|
|
493
500
|
appData?: AppData;
|
|
494
501
|
closeCallIfEmptyForMs?: number;
|
|
@@ -617,6 +624,74 @@ mediasoup set `PRODUCER_*` / `CONSUMER_*` / `DATA_PRODUCER_*` / `DATA_CONSUMER_*
|
|
|
617
624
|
`MEDIA_DEVICES_SUPPORTED_CONSTRAINTS`, `USER_MEDIA_ERROR`, `LOCAL_SDP`, `OPERATION_SYSTEM`,
|
|
618
625
|
`ENGINE`, `PLATFORM`, `BROWSER`.
|
|
619
626
|
|
|
627
|
+
### Worked example: a real `ClientSample`
|
|
628
|
+
|
|
629
|
+
Two consecutive samples from one participant ("Guest" in room `qq0iwfnd`) of an
|
|
630
|
+
edumeet/mediasoup call show what actually flows through `accept()`: a rich **join snapshot**,
|
|
631
|
+
then lean **steady-state ticks**.
|
|
632
|
+
|
|
633
|
+
**Sample 1 — the join snapshot.** Carries the one-off lifecycle `clientEvents` and device
|
|
634
|
+
`clientMetaItems` alongside the first stats. (Abbreviated; ids and times are from the real log.)
|
|
635
|
+
|
|
636
|
+
```jsonc
|
|
637
|
+
{
|
|
638
|
+
"timestamp": 1780572332518,
|
|
639
|
+
"callId": "d3dbf2f5-79be-4cb8-9d43-fb404f07ef27",
|
|
640
|
+
"clientId": "c926983c-4468-4046-ae8c-a9cabe1a1868",
|
|
641
|
+
"score": 0, // no quality measured yet on the join tick
|
|
642
|
+
"attachments": { "displayName": "Guest", "roomId": "qq0iwfnd", "actualSessionId": "d3dbf2f5-…" },
|
|
643
|
+
|
|
644
|
+
"clientEvents": [ // chronological lifecycle (12 in the real sample)
|
|
645
|
+
{ "type": "CLIENT_JOINED", "timestamp": 1780572324515 },
|
|
646
|
+
{ "type": "PEER_CONNECTION_OPENED", "timestamp": 1780572326790 }, // pc=b81c8d9d (media)
|
|
647
|
+
{ "type": "ICE_GATHERING_STATE_CHANGED", "timestamp": 1780572326811 }, // → gathering
|
|
648
|
+
{ "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326812 }, // → connecting
|
|
649
|
+
{ "type": "PRODUCER_ADDED", "timestamp": 1780572326821 }, // producer=1abdaf82 (audio)
|
|
650
|
+
{ "type": "MEDIA_TRACK_ADDED", "timestamp": 1780572326821 }, // track=36ae42df (audio)
|
|
651
|
+
{ "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326827 }, // → connected
|
|
652
|
+
{ "type": "PRODUCER_ADDED", "timestamp": 1780572326837 }, // producer=ba06a35b (video)
|
|
653
|
+
{ "type": "DATA_PRODUCER_CREATED", "timestamp": 1780572326853 }
|
|
654
|
+
],
|
|
655
|
+
|
|
656
|
+
"clientMetaItems": [ // environment & devices, one-off (10 in the real sample)
|
|
657
|
+
{ "type": "USER_AGENT_DATA", "payload": "{…Chrome 148 / macOS…}" },
|
|
658
|
+
{ "type": "MEDIA_DEVICE", "payload": "{…\"BRIO 4K Stream Edition\"…}" }
|
|
659
|
+
// …mic / camera / speaker devices…
|
|
660
|
+
],
|
|
661
|
+
|
|
662
|
+
"peerConnections": [
|
|
663
|
+
{
|
|
664
|
+
"peerConnectionId": "b81c8d9d-…", // the media PC — Guest publishes to the SFU
|
|
665
|
+
"outboundRtps": [ /* audio + video */ ],
|
|
666
|
+
"outboundTracks": [ /* mic + camera: label, settings, capabilities */ ],
|
|
667
|
+
"remoteInboundRtps": [ /* RTCP feedback from the SFU */ ],
|
|
668
|
+
"codecs": [ /* … */ ], "iceTransports": [ /* … */ ],
|
|
669
|
+
"iceCandidatePairs": [ /* … */ ], "dataChannels": [ /* … */ ]
|
|
670
|
+
},
|
|
671
|
+
{ "peerConnectionId": "8635acb7-…", "peerConnectionTransports": [ /* … */ ] } // signaling-only PC
|
|
672
|
+
]
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
What `accept()` does with it, in order — each step emits on the bus with full ancestry:
|
|
677
|
+
|
|
678
|
+
1. lazily creates the `ObservedCall` → **`call-added`**;
|
|
679
|
+
2. creates the `ObservedClient` → **`client-added`**, then **`client-joined`** (from `CLIENT_JOINED`);
|
|
680
|
+
3. creates an `ObservedPeerConnection` per entry → **`peer-connection-added`** (×2 here);
|
|
681
|
+
4. creates an `ObservedOutboundTrack` per track → **`outbound-track-added`**, plus the matching
|
|
682
|
+
**`outbound-rtp-added`**;
|
|
683
|
+
5. replays the device list as **`client-metadata`** events and the lifecycle items as
|
|
684
|
+
**`client-event`**; and finally **`client-updated`** for the whole tick.
|
|
685
|
+
|
|
686
|
+
`attachments.roomId` lands on `observedClient.attachments` (read it on `client-updated`, **not** at
|
|
687
|
+
creation — see [Ingestion](#ingestion-accept-context--lifecycle)).
|
|
688
|
+
|
|
689
|
+
**Sample 2 — a steady-state tick** (~8 s later): same `callId` / `clientId`, **no** new
|
|
690
|
+
`clientEvents` or `clientMetaItems`, just refreshed `peerConnections` stats. Each PC now scores `5`
|
|
691
|
+
and the aggregate client `score` is `4.74` — a healthy call. This is the shape of nearly every
|
|
692
|
+
sample: each tick refreshes metrics and fires the `*-updated` events, while the heavy join
|
|
693
|
+
snapshot happens only once.
|
|
694
|
+
|
|
620
695
|
---
|
|
621
696
|
|
|
622
697
|
## Detectors (server-side extension point)
|
|
@@ -713,6 +788,135 @@ For the mediasoup factory, the application puts `producerId` / `consumerId` (and
|
|
|
713
788
|
|
|
714
789
|
---
|
|
715
790
|
|
|
791
|
+
## Mediasoup router observation
|
|
792
|
+
|
|
793
|
+
Everything above is built from the **client-reported** `ClientSample`. When you run a
|
|
794
|
+
[mediasoup](https://mediasoup.org) SFU you also have the **server's own** ground truth — its
|
|
795
|
+
routers, transports, producers, consumers and data channels, with exact lifetimes and state
|
|
796
|
+
transitions. `ObservedMediasoupRouter` captures that server-side view into a
|
|
797
|
+
**`MediasoupRouterSample`**, completely independent of the client sample pipeline.
|
|
798
|
+
|
|
799
|
+
### The concept
|
|
800
|
+
|
|
801
|
+
You hand the observer a live mediasoup `Router`; it attaches to mediasoup's own `observer` API and,
|
|
802
|
+
from then on, **passively records** the router's topology and lifecycle — with no polling and no
|
|
803
|
+
changes to your media code:
|
|
804
|
+
|
|
805
|
+
- new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, and ICE state
|
|
806
|
+
transitions;
|
|
807
|
+
- producers (codec, SSRCs/RIDs, `pause`/`resume`) and consumers (`pause`/`resume`,
|
|
808
|
+
`producerPaused`/`producerResumed`);
|
|
809
|
+
- data producers and data consumers;
|
|
810
|
+
- `createdAt` / `closedAt` for every entity above.
|
|
811
|
+
|
|
812
|
+
All of it accumulates on `observedMediasoupRouter.sample` (a `MediasoupRouterSample` — see
|
|
813
|
+
[`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts)). This is a *Sample*, not a
|
|
814
|
+
*Report*: it mirrors the naming of `ClientSample` and is yours to snapshot, persist, or correlate.
|
|
815
|
+
|
|
816
|
+
### Matching a router to a call — by **event**, not by storage
|
|
817
|
+
|
|
818
|
+
A router belongs to one or more calls, but **the observer does not store the router (or its sample)
|
|
819
|
+
on the `ObservedCall`.** Instead, when a match is found it emits
|
|
820
|
+
**`mediasoup-router-matched-with-call`** and steps back — *your application* decides what the
|
|
821
|
+
pairing means. Stamp the `callId` into the router's `appData`, build your own index, attach the
|
|
822
|
+
sample to the call in your database — whatever fits your system. The library stays unopinionated and
|
|
823
|
+
loosely coupled.
|
|
824
|
+
|
|
825
|
+
There are two ways a match is discovered, controlled by the settings you pass to
|
|
826
|
+
`createObservedMediasoupRouter`:
|
|
827
|
+
|
|
828
|
+
- **Explicit (`callId`)** — you already know the call. If that call currently exists,
|
|
829
|
+
`mediasoup-router-matched-with-call` fires immediately.
|
|
830
|
+
- **Implicit (`bindCallByWebRtcTransportId: true`)** — let the observer discover it. As peer
|
|
831
|
+
connections are observed (`peer-connection-added`), the observer checks whether the peer
|
|
832
|
+
connection's id is one of the router's WebRTC transport ids. On a hit, it's a match. It emits
|
|
833
|
+
**once per distinct call**, and keeps watching so additional calls sharing the router can still
|
|
834
|
+
match later. The internal listener is removed automatically when the router closes or the observer
|
|
835
|
+
closes.
|
|
836
|
+
|
|
837
|
+
When the underlying mediasoup router closes, its `close` propagates to `ObservedMediasoupRouter`,
|
|
838
|
+
which emits **`mediasoup-router-removed`**. That is your cue to do whatever cleanup or persistence
|
|
839
|
+
you want with the now-final `sample` — again, the observer itself keeps nothing.
|
|
840
|
+
|
|
841
|
+
### Options — `observer.createObservedMediasoupRouter(settings)`
|
|
842
|
+
|
|
843
|
+
| Field | Type | Required | Meaning |
|
|
844
|
+
|-------|------|----------|---------|
|
|
845
|
+
| `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer` |
|
|
846
|
+
| `routerId` | `string` | yes | your id for the router (the sample also carries `router.id`) |
|
|
847
|
+
| `appData` | `Record<string, unknown>` | no | application-owned bag on the `ObservedMediasoupRouter` (e.g. where you record the matched `callId`) |
|
|
848
|
+
| `attachments` | `Record<string, unknown>` | no | free-form data copied onto `sample.attachments` |
|
|
849
|
+
| `callId` | `string` | no | **explicit match**: emit `mediasoup-router-matched-with-call` now if this call exists |
|
|
850
|
+
| `bindCallByWebRtcTransportId` | `boolean` | no | **implicit match**: discover the call(s) by correlating WebRTC transport ids with peer-connection ids |
|
|
851
|
+
|
|
852
|
+
Returns the `ObservedMediasoupRouter`, or `undefined` if the observer is closed (a router with the
|
|
853
|
+
same id returns the existing instance — both warn).
|
|
854
|
+
|
|
855
|
+
Useful members on the returned object: `.sample` (the `MediasoupRouterSample`), `.appData`,
|
|
856
|
+
`.attachments` (getter over `sample.attachments`), `.webrtcTransportIds: Set<string>`, `.id`,
|
|
857
|
+
`.close()`.
|
|
858
|
+
|
|
859
|
+
### Example
|
|
860
|
+
|
|
861
|
+
```ts
|
|
862
|
+
import { Observer } from '@observertc/observer-js';
|
|
863
|
+
import type { ObservedMediasoupRouterScope, ObservedCallScope } from '@observertc/observer-js';
|
|
864
|
+
|
|
865
|
+
const observer = new Observer();
|
|
866
|
+
|
|
867
|
+
// 1) Feed client samples as usual so the observer knows about calls & peer connections.
|
|
868
|
+
// (e.g. transport-layer: observer.accept(clientSample, context))
|
|
869
|
+
|
|
870
|
+
// 2) Observe the SFU side. Let the observer discover which call this router serves.
|
|
871
|
+
const router = /* your mediasoup router */ undefined as any;
|
|
872
|
+
const observedRouter = observer.createObservedMediasoupRouter({
|
|
873
|
+
router,
|
|
874
|
+
routerId: router.id,
|
|
875
|
+
bindCallByWebRtcTransportId: true, // discover the call by peer-connection correlation
|
|
876
|
+
appData: {}, // we'll record the matched callId here
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// 3) The observer found a call for the router — WE decide what to do with the pairing.
|
|
880
|
+
observer.on('mediasoup-router-matched-with-call',
|
|
881
|
+
({ observedMediasoupRouter, observedCall }: ObservedMediasoupRouterScope & ObservedCallScope) => {
|
|
882
|
+
// e.g. remember the association on the router's appData…
|
|
883
|
+
observedMediasoupRouter.appData.callId = observedCall.callId;
|
|
884
|
+
// …or attach the live server sample to the call in your own store:
|
|
885
|
+
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.sample);
|
|
886
|
+
},
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
// 4) The router closed — WE decide what to persist/forward with the final sample.
|
|
890
|
+
observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
|
|
891
|
+
const callId = observedMediasoupRouter.appData.callId as string | undefined;
|
|
892
|
+
myStore.saveRouterSample(callId, observedMediasoupRouter.sample);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
// (optional) react to the router being registered at all:
|
|
896
|
+
observer.on('mediasoup-router-added', ({ observedMediasoupRouter }) => {
|
|
897
|
+
console.log('observing router', observedMediasoupRouter.id);
|
|
898
|
+
});
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
If you already know the call, skip discovery and match explicitly:
|
|
902
|
+
|
|
903
|
+
```ts
|
|
904
|
+
observer.createObservedMediasoupRouter({ router, routerId: router.id, callId });
|
|
905
|
+
// → `mediasoup-router-matched-with-call` fires immediately if `callId` is a known call
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### Why event-driven instead of storing on the call
|
|
909
|
+
|
|
910
|
+
- **Loose coupling.** The call model stays about client telemetry; the SFU view lives on its own
|
|
911
|
+
object and is associated only if and how *you* choose.
|
|
912
|
+
- **You own the association.** One router may serve multiple calls, a call may be served by multiple
|
|
913
|
+
routers, and the right place to keep that mapping is application-specific — so the observer hands
|
|
914
|
+
you the match and the final sample and gets out of the way.
|
|
915
|
+
- **No silent accumulation.** Nothing is appended to `ObservedCall`, so there is no hidden growth or
|
|
916
|
+
lifetime you have to reason about; the router sample lives exactly as long as you keep a reference.
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
716
920
|
## Sinks (per-client sample persistence)
|
|
717
921
|
|
|
718
922
|
A **sink** receives the samples a client accepts — for archival, streaming, or later offline
|
|
@@ -875,48 +1079,6 @@ are caught by `accept()` and surfaced as a warning.
|
|
|
875
1079
|
|
|
876
1080
|
---
|
|
877
1081
|
|
|
878
|
-
## Public exports
|
|
879
|
-
|
|
880
|
-
```ts
|
|
881
|
-
// Entry: src/index.ts
|
|
882
|
-
export { Observer } from './Observer';
|
|
883
|
-
export type { ObserverEvents, SampleRejectedReason, AcceptContext, CallAppDataFactory, ClientAppDataFactory } from './Observer';
|
|
884
|
-
export type { ObserverEventBase, ObservedCallScope, ObservedClientScope, ObservedPeerConnectionScope } from './ObserverEvents';
|
|
885
|
-
|
|
886
|
-
export { ObservedCall, ObservedClient, ObservedPeerConnection } from './…';
|
|
887
|
-
export { ObservedInboundTrack, ObservedOutboundTrack } from './…';
|
|
888
|
-
export { ObservedInboundRtp, ObservedOutboundRtp, ObservedRemoteInboundRtp, ObservedRemoteOutboundRtp } from './…';
|
|
889
|
-
export { ObservedMediaSource, ObservedMediaPlayout, ObservedCodec, ObservedCertificate, ObservedDataChannel } from './…';
|
|
890
|
-
export { ObservedIceCandidate, ObservedIceCandidatePair, ObservedIceTransport, ObservedPeerConnectionTransport } from './…';
|
|
891
|
-
|
|
892
|
-
export { ClientSample, ClientIssue, ClientEvent, ClientMetaData } from './schema/ClientSample';
|
|
893
|
-
export { ClientEventTypes } from './schema/ClientEventTypes';
|
|
894
|
-
export { ClientMetaTypes } from './schema/ClientMetaTypes';
|
|
895
|
-
|
|
896
|
-
export { ScoreCalculator } from './scores/ScoreCalculator';
|
|
897
|
-
export { Detectors } from './detectors/Detectors';
|
|
898
|
-
export type { Detector } from './detectors/Detector';
|
|
899
|
-
|
|
900
|
-
export { createLogger, setObserverLogger } from './common/logger';
|
|
901
|
-
export type { Logger, ObserverLogger } from './common/logger';
|
|
902
|
-
|
|
903
|
-
// sinks: base class (subclass it for a custom destination) + built-ins
|
|
904
|
-
export { ClientSampleSink } from './sinks/ClientSampleSink';
|
|
905
|
-
export type { ClientSampleSinkEvents, ClientSampleSinkFactory } from './sinks/ClientSampleSink';
|
|
906
|
-
export { JsonlFileSink, createJsonlFileSink, createJsonlFileSinkFactory } from './sinks/JsonlFileSink';
|
|
907
|
-
export type { JsonlFileSinkOptions, JsonlFileSinkFactoryOptions } from './sinks/JsonlFileSink';
|
|
908
|
-
export { InMemorySink, createInMemorySink } from './sinks/InMemorySink';
|
|
909
|
-
|
|
910
|
-
export { Middleware } from './common/Middleware';
|
|
911
|
-
|
|
912
|
-
// remote track correlation
|
|
913
|
-
export { RemoteTrackResolver } from './utils/RemoteTrackResolver';
|
|
914
|
-
export type { RemoteTrackResolvers, RemoteTrackResolverFactory } from './utils/RemoteTrackResolver';
|
|
915
|
-
export { createDefaultMediasoupRemoteTrackResolverFactory, createP2pRemoteTrackResolverFactory } from './utils/RemoteTrackResolverFactories';
|
|
916
|
-
```
|
|
917
|
-
|
|
918
|
-
---
|
|
919
|
-
|
|
920
1082
|
## Development & extension guide
|
|
921
1083
|
|
|
922
1084
|
```bash
|
|
@@ -966,24 +1128,6 @@ event map + scope types), `detectors/` (`Detector`, `Detectors`), `scores/`, `up
|
|
|
966
1128
|
|
|
967
1129
|
---
|
|
968
1130
|
|
|
969
|
-
## Not yet implemented / roadmap
|
|
970
|
-
|
|
971
|
-
For an agent continuing the work, these are explicitly **not** present yet:
|
|
972
|
-
|
|
973
|
-
- **Tests.** There is currently only a placeholder spec. The two `accept()` methods
|
|
974
|
-
(`ObservedClient`, `ObservedPeerConnection`) are the priority for characterization tests. (A
|
|
975
|
-
CI gate running lint + typecheck + test is already in place — see `.github/workflows/ci.yml`.)
|
|
976
|
-
- **Built-in detectors / quality classifier.** The registry exists; concrete server-side
|
|
977
|
-
detectors (e.g. producer→consumer delivery mismatch, quality outlier, asymmetric media) are
|
|
978
|
-
to be designed.
|
|
979
|
-
- **Per-tick snapshot API.** A serializable snapshot per `*-updated` tick to replace consuming
|
|
980
|
-
the fine-grained `*-updated` events (under consideration).
|
|
981
|
-
- **Monotonic-timestamp / clock-skew handling** for client clock jumps.
|
|
982
|
-
- **Batch `processSamples()`** entry point for offline analysis of recorded sample streams.
|
|
983
|
-
- **Expanded derived metrics** (jitter-buffer delay, concealment, freeze fraction, encode/decode
|
|
984
|
-
CPU, quality-limitation breakdown, etc.) and first-class producer/consumer & PC-direction
|
|
985
|
-
fields beyond `attachments`.
|
|
986
|
-
|
|
987
1131
|
## License
|
|
988
1132
|
|
|
989
1133
|
Apache-2.0. Part of the [ObserverTC](https://github.com/observertc) ecosystem.
|