@observertc/observer-js 1.0.0-beta.1 → 1.0.0-beta.10
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 +972 -383
- package/dist/index.d.mts +2952 -0
- package/dist/index.d.ts +2952 -0
- package/dist/index.js +4040 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3970 -0
- package/dist/index.mjs.map +1 -0
- package/llms-full.txt +1536 -0
- package/package.json +32 -16
- package/.eslintrc.json +0 -143
- package/.prettierignore +0 -5
- package/.prettierrc +0 -7
- package/jest.config.js +0 -9
- package/lib/ObservedCall.d.ts +0 -85
- package/lib/ObservedCall.d.ts.map +0 -1
- package/lib/ObservedCall.js +0 -219
- package/lib/ObservedCallEventMonitor.d.ts +0 -104
- package/lib/ObservedCallEventMonitor.d.ts.map +0 -1
- package/lib/ObservedCallEventMonitor.js +0 -314
- package/lib/ObservedCallSummary.d.ts +0 -18
- package/lib/ObservedCallSummary.d.ts.map +0 -1
- package/lib/ObservedCallSummary.js +0 -2
- package/lib/ObservedCertificate.d.ts +0 -19
- package/lib/ObservedCertificate.d.ts.map +0 -1
- package/lib/ObservedCertificate.js +0 -38
- package/lib/ObservedClient.d.ts +0 -127
- package/lib/ObservedClient.d.ts.map +0 -1
- package/lib/ObservedClient.js +0 -636
- package/lib/ObservedClientEventMonitor.d.ts +0 -91
- package/lib/ObservedClientEventMonitor.d.ts.map +0 -1
- package/lib/ObservedClientEventMonitor.js +0 -254
- package/lib/ObservedClientSummary.d.ts +0 -23
- package/lib/ObservedClientSummary.d.ts.map +0 -1
- package/lib/ObservedClientSummary.js +0 -2
- package/lib/ObservedCodec.d.ts +0 -22
- package/lib/ObservedCodec.d.ts.map +0 -1
- package/lib/ObservedCodec.js +0 -45
- package/lib/ObservedDataChannel.d.ts +0 -30
- package/lib/ObservedDataChannel.d.ts.map +0 -1
- package/lib/ObservedDataChannel.js +0 -75
- package/lib/ObservedIceCandidate.d.ts +0 -29
- package/lib/ObservedIceCandidate.d.ts.map +0 -1
- package/lib/ObservedIceCandidate.js +0 -59
- package/lib/ObservedIceCandidatePair.d.ts +0 -45
- package/lib/ObservedIceCandidatePair.d.ts.map +0 -1
- package/lib/ObservedIceCandidatePair.js +0 -127
- package/lib/ObservedIceTransport.d.ts +0 -36
- package/lib/ObservedIceTransport.d.ts.map +0 -1
- package/lib/ObservedIceTransport.js +0 -93
- package/lib/ObservedInboundRtp.d.ts +0 -92
- package/lib/ObservedInboundRtp.d.ts.map +0 -1
- package/lib/ObservedInboundRtp.js +0 -193
- package/lib/ObservedInboundTrack.d.ts +0 -32
- package/lib/ObservedInboundTrack.d.ts.map +0 -1
- package/lib/ObservedInboundTrack.js +0 -60
- package/lib/ObservedMediaPlayout.d.ts +0 -22
- package/lib/ObservedMediaPlayout.d.ts.map +0 -1
- package/lib/ObservedMediaPlayout.js +0 -42
- package/lib/ObservedMediaSource.d.ts +0 -28
- package/lib/ObservedMediaSource.d.ts.map +0 -1
- package/lib/ObservedMediaSource.js +0 -55
- package/lib/ObservedOutboundRtp.d.ts +0 -62
- package/lib/ObservedOutboundRtp.d.ts.map +0 -1
- package/lib/ObservedOutboundRtp.js +0 -144
- package/lib/ObservedOutboundTrack.d.ts +0 -32
- package/lib/ObservedOutboundTrack.d.ts.map +0 -1
- package/lib/ObservedOutboundTrack.js +0 -60
- package/lib/ObservedPeerConnection.d.ts +0 -207
- package/lib/ObservedPeerConnection.d.ts.map +0 -1
- package/lib/ObservedPeerConnection.js +0 -773
- package/lib/ObservedPeerConnectionTransport.d.ts +0 -17
- package/lib/ObservedPeerConnectionTransport.d.ts.map +0 -1
- package/lib/ObservedPeerConnectionTransport.js +0 -34
- package/lib/ObservedRemoteInboundRtp.d.ts +0 -30
- package/lib/ObservedRemoteInboundRtp.d.ts.map +0 -1
- package/lib/ObservedRemoteInboundRtp.js +0 -60
- package/lib/ObservedRemoteOutboundRtp.d.ts +0 -30
- package/lib/ObservedRemoteOutboundRtp.d.ts.map +0 -1
- package/lib/ObservedRemoteOutboundRtp.js +0 -60
- package/lib/ObservedTURN.d.ts +0 -31
- package/lib/ObservedTURN.d.ts.map +0 -1
- package/lib/ObservedTURN.js +0 -58
- package/lib/ObservedTurnServer.d.ts +0 -25
- package/lib/ObservedTurnServer.d.ts.map +0 -1
- package/lib/ObservedTurnServer.js +0 -59
- package/lib/Observer.d.ts +0 -65
- package/lib/Observer.d.ts.map +0 -1
- package/lib/Observer.js +0 -160
- package/lib/ObserverEventMonitor.d.ts +0 -138
- package/lib/ObserverEventMonitor.d.ts.map +0 -1
- package/lib/ObserverEventMonitor.js +0 -433
- package/lib/ObserverSummary.d.ts +0 -13
- package/lib/ObserverSummary.d.ts.map +0 -1
- package/lib/ObserverSummary.js +0 -2
- package/lib/common/Middleware.d.ts +0 -17
- package/lib/common/Middleware.d.ts.map +0 -1
- package/lib/common/Middleware.js +0 -60
- package/lib/common/SingleExecutor.d.ts +0 -3
- package/lib/common/SingleExecutor.d.ts.map +0 -1
- package/lib/common/SingleExecutor.js +0 -31
- package/lib/common/logger.d.ts +0 -17
- package/lib/common/logger.d.ts.map +0 -1
- package/lib/common/logger.js +0 -50
- package/lib/common/types.d.ts +0 -3
- package/lib/common/types.d.ts.map +0 -1
- package/lib/common/types.js +0 -3
- package/lib/common/utils.d.ts +0 -11
- package/lib/common/utils.d.ts.map +0 -1
- package/lib/common/utils.js +0 -63
- package/lib/detectors/Detector.d.ts +0 -5
- package/lib/detectors/Detector.d.ts.map +0 -1
- package/lib/detectors/Detector.js +0 -3
- package/lib/detectors/Detectors.d.ts +0 -11
- package/lib/detectors/Detectors.d.ts.map +0 -1
- package/lib/detectors/Detectors.js +0 -34
- package/lib/index.d.ts +0 -28
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js +0 -49
- package/lib/mediasoup/ObservedMediaRouter.d.ts +0 -10
- package/lib/mediasoup/ObservedMediaRouter.d.ts.map +0 -1
- package/lib/mediasoup/ObservedMediaRouter.js +0 -6
- package/lib/monitors/TurnUsageMonitor.d.ts +0 -1
- package/lib/monitors/TurnUsageMonitor.d.ts.map +0 -1
- package/lib/monitors/TurnUsageMonitor.js +0 -146
- package/lib/schema/ClientEventTypes.d.ts +0 -228
- package/lib/schema/ClientEventTypes.d.ts.map +0 -1
- package/lib/schema/ClientEventTypes.js +0 -41
- package/lib/schema/ClientMetaTypes.d.ts +0 -34
- package/lib/schema/ClientMetaTypes.d.ts.map +0 -1
- package/lib/schema/ClientMetaTypes.js +0 -16
- package/lib/schema/ClientSample.d.ts +0 -1333
- package/lib/schema/ClientSample.d.ts.map +0 -1
- package/lib/schema/ClientSample.js +0 -4
- package/lib/scores/CalculatedScore.d.ts +0 -6
- package/lib/scores/CalculatedScore.d.ts.map +0 -1
- package/lib/scores/CalculatedScore.js +0 -2
- package/lib/scores/DefaultCallScoreCalculator.d.ts +0 -7
- package/lib/scores/DefaultCallScoreCalculator.d.ts.map +0 -1
- package/lib/scores/DefaultCallScoreCalculator.js +0 -21
- package/lib/scores/ScoreCalculator.d.ts +0 -4
- package/lib/scores/ScoreCalculator.d.ts.map +0 -1
- package/lib/scores/ScoreCalculator.js +0 -2
- package/lib/updaters/CallUpdater.d.ts +0 -5
- package/lib/updaters/CallUpdater.d.ts.map +0 -1
- package/lib/updaters/CallUpdater.js +0 -2
- package/lib/updaters/ObserverUpdater.d.ts +0 -5
- package/lib/updaters/ObserverUpdater.d.ts.map +0 -1
- package/lib/updaters/ObserverUpdater.js +0 -2
- package/lib/updaters/OnAllCallObserverUpdater.d.ts +0 -15
- package/lib/updaters/OnAllCallObserverUpdater.d.ts.map +0 -1
- package/lib/updaters/OnAllCallObserverUpdater.js +0 -50
- package/lib/updaters/OnAllClientCallUpdater.d.ts +0 -15
- package/lib/updaters/OnAllClientCallUpdater.d.ts.map +0 -1
- package/lib/updaters/OnAllClientCallUpdater.js +0 -53
- package/lib/updaters/OnAnyCallObserverUpdater.d.ts +0 -12
- package/lib/updaters/OnAnyCallObserverUpdater.d.ts.map +0 -1
- package/lib/updaters/OnAnyCallObserverUpdater.js +0 -37
- package/lib/updaters/OnAnyClientCallUpdater.d.ts +0 -12
- package/lib/updaters/OnAnyClientCallUpdater.d.ts.map +0 -1
- package/lib/updaters/OnAnyClientCallUpdater.js +0 -36
- package/lib/updaters/OnIntervalUpdater.d.ts +0 -10
- package/lib/updaters/OnIntervalUpdater.d.ts.map +0 -1
- package/lib/updaters/OnIntervalUpdater.js +0 -17
- package/lib/updaters/Updater.d.ts +0 -6
- package/lib/updaters/Updater.d.ts.map +0 -1
- package/lib/updaters/Updater.js +0 -2
- package/lib/utils/MediasoupRemoteTrackResolver.d.ts +0 -25
- package/lib/utils/MediasoupRemoteTrackResolver.d.ts.map +0 -1
- package/lib/utils/MediasoupRemoteTrackResolver.js +0 -110
- package/lib/utils/RemoteTrackResolver.d.ts +0 -7
- package/lib/utils/RemoteTrackResolver.d.ts.map +0 -1
- package/lib/utils/RemoteTrackResolver.js +0 -2
- package/src/ObservedCall.ts +0 -338
- package/src/ObservedCallEventMonitor.ts +0 -392
- package/src/ObservedCallSummary.ts +0 -22
- package/src/ObservedCertificate.ts +0 -43
- package/src/ObservedClient.ts +0 -788
- package/src/ObservedClientEventMonitor.ts +0 -309
- package/src/ObservedClientSummary.ts +0 -24
- package/src/ObservedCodec.ts +0 -49
- package/src/ObservedDataChannel.ts +0 -85
- package/src/ObservedIceCandidate.ts +0 -64
- package/src/ObservedIceCandidatePair.ts +0 -134
- package/src/ObservedIceTransport.ts +0 -99
- package/src/ObservedInboundRtp.ts +0 -205
- package/src/ObservedInboundTrack.ts +0 -75
- package/src/ObservedMediaPlayout.ts +0 -49
- package/src/ObservedMediaSource.ts +0 -60
- package/src/ObservedOutboundRtp.ts +0 -156
- package/src/ObservedOutboundTrack.ts +0 -74
- package/src/ObservedPeerConnection.ts +0 -1088
- package/src/ObservedPeerConnectionTransport.ts +0 -38
- package/src/ObservedRemoteInboundRtp.ts +0 -64
- package/src/ObservedRemoteOutboundRtp.ts +0 -65
- package/src/ObservedTURN.ts +0 -86
- package/src/ObservedTurnServer.ts +0 -72
- package/src/Observer.ts +0 -247
- package/src/ObserverEventMonitor.ts +0 -551
- package/src/ObserverSummary.ts +0 -15
- package/src/common/Middleware.ts +0 -84
- package/src/common/SingleExecutor.ts +0 -36
- package/src/common/logger.ts +0 -82
- package/src/common/types.ts +0 -4
- package/src/common/utils.ts +0 -69
- package/src/detectors/Detector.ts +0 -6
- package/src/detectors/Detectors.ts +0 -38
- package/src/index.ts +0 -28
- package/src/mediasoup/ObservedMediaRouter.ts +0 -10
- package/src/monitors/TurnUsageMonitor.ts +0 -187
- package/src/schema/ClientEventTypes.ts +0 -280
- package/src/schema/ClientMetaTypes.ts +0 -41
- package/src/schema/ClientSample.ts +0 -1682
- package/src/scores/CalculatedScore.ts +0 -6
- package/src/scores/DefaultCallScoreCalculator.ts +0 -23
- package/src/scores/ScoreCalculator.ts +0 -3
- package/src/updaters/CallUpdater.ts +0 -5
- package/src/updaters/ObserverUpdater.ts +0 -5
- package/src/updaters/OnAllCallObserverUpdater.ts +0 -59
- package/src/updaters/OnAllClientCallUpdater.ts +0 -61
- package/src/updaters/OnAnyCallObserverUpdater.ts +0 -41
- package/src/updaters/OnAnyClientCallUpdater.ts +0 -39
- package/src/updaters/OnIntervalUpdater.ts +0 -19
- package/src/updaters/Updater.ts +0 -5
- package/src/utils/MediasoupRemoteTrackResolver.ts +0 -155
- package/src/utils/RemoteTrackResolver.ts +0 -12
- package/tsconfig.json +0 -19
- package/tslint.json +0 -6
package/README.md
CHANGED
|
@@ -1,23 +1,73 @@
|
|
|
1
|
-
# ObserverTC -
|
|
1
|
+
# ObserverTC — `@observertc/observer-js`
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@observertc/observer-js)
|
|
4
4
|
[](https://github.com/observertc/observer-js/blob/main/LICENSE)
|
|
5
5
|
|
|
6
|
-
|
|
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
|
+
|
|
9
|
+
`observer-js` is a **server-side Node.js library for monitoring WebRTC sessions**. A WebRTC
|
|
10
|
+
application (typically an SFU or a signaling/stats backend) feeds it `ClientSample` objects —
|
|
11
|
+
periodic snapshots of each participant's `RTCPeerConnection.getStats()` output plus
|
|
12
|
+
application events — and `observer-js` maintains a live, in-memory model of every call,
|
|
13
|
+
participant, peer connection, and media stream, derives per-interval and cumulative metrics,
|
|
14
|
+
and emits a single, unified stream of typed events the application can react to.
|
|
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
|
+
|
|
34
|
+
> **Status:** `1.0.0-beta`. The API described here is current and intended to be implemented
|
|
35
|
+
> against directly. This document is written to be self-sufficient: an engineer (or an AI
|
|
36
|
+
> agent) should be able to integrate the library, or develop it further, from this file alone.
|
|
37
|
+
> A companion doc, [`docs/logging.md`](./docs/logging.md), covers logging integration in depth.
|
|
38
|
+
|
|
39
|
+
> **Packaging:** server-side, **Node.js ≥ 22**, shipped as a **dual ESM + CommonJS** build — so it
|
|
40
|
+
> works whether your project uses `import` (ESM) or `require()` (CommonJS). Everything — including
|
|
41
|
+
> the built-in file sink — is exported from the single `@observertc/observer-js` entry.
|
|
42
|
+
|
|
43
|
+
> **For AI agents:** [`llms.txt`](./llms.txt) is a curated map of these docs (with a one-file
|
|
44
|
+
> [`llms-full.txt`](./llms-full.txt) export for one-shot ingestion); [`AGENTS.md`](./AGENTS.md)
|
|
45
|
+
> covers build/test commands and conventions for working **in** this repository. `llms-full.txt`
|
|
46
|
+
> ships in the npm package (so it's available wherever the library is installed); `llms.txt` and
|
|
47
|
+
> `AGENTS.md` live in the repo (and `llms.txt` belongs at the root of the docs site).
|
|
7
48
|
|
|
8
|
-
|
|
49
|
+
---
|
|
9
50
|
|
|
10
|
-
##
|
|
51
|
+
## Table of contents
|
|
52
|
+
|
|
53
|
+
1. [Installation](#installation)
|
|
54
|
+
2. [Quick start](#quick-start)
|
|
55
|
+
3. [Data flow](#data-flow)
|
|
56
|
+
4. [Entity hierarchy](#entity-hierarchy)
|
|
57
|
+
5. [Ingestion: `accept()`, context & lifecycle](#ingestion-accept-context--lifecycle)
|
|
58
|
+
6. [Update policies](#update-policies)
|
|
59
|
+
7. [The event bus](#the-event-bus) ← the core of the API
|
|
60
|
+
8. [API reference](#api-reference)
|
|
61
|
+
9. [Schema types (`ClientSample`)](#schema-types-clientsample)
|
|
62
|
+
10. [Detectors (server-side extension point)](#detectors-server-side-extension-point)
|
|
63
|
+
11. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
|
|
64
|
+
12. [Mediasoup router observation](#mediasoup-router-observation)
|
|
65
|
+
13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
|
|
66
|
+
14. [Logging](#logging)
|
|
67
|
+
15. [Error-handling philosophy](#error-handling-philosophy)
|
|
68
|
+
16. [Development & extension guide](#development--extension-guide)
|
|
11
69
|
|
|
12
|
-
|
|
13
|
-
- **Comprehensive Metrics**: Tracks a wide array of WebRTC statistics including RTT, jitter, packet loss, codecs, ICE states, TURN usage, bandwidth, and more.
|
|
14
|
-
- **Automatic Entity Management**: Can automatically create and manage call and client entities based on incoming data samples.
|
|
15
|
-
- **Issue Detection**: Built-in detectors for common WebRTC problems.
|
|
16
|
-
- **Quality Scoring**: Calculates quality scores for calls and clients.
|
|
17
|
-
- **Event-Driven**: Emits events for significant state changes, new entities, and detected issues.
|
|
18
|
-
- **Configurable Update Policies**: Flexible control over how and when metrics are processed and updated.
|
|
19
|
-
- **TypeScript Support**: Written in TypeScript, providing strong typing and intellisense.
|
|
20
|
-
- **Extensible**: Supports custom application data (`appData`) and integration with external schema definitions (e.g., `observertc/schemas`).
|
|
70
|
+
---
|
|
21
71
|
|
|
22
72
|
## Installation
|
|
23
73
|
|
|
@@ -27,510 +77,1049 @@ npm install @observertc/observer-js
|
|
|
27
77
|
yarn add @observertc/observer-js
|
|
28
78
|
```
|
|
29
79
|
|
|
30
|
-
|
|
80
|
+
**Server-side, Node.js ≥ 22, dual ESM + CommonJS.** The package ships both module formats, so it
|
|
81
|
+
works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
|
|
31
82
|
|
|
32
|
-
```
|
|
33
|
-
import { Observer,
|
|
34
|
-
|
|
83
|
+
```ts
|
|
84
|
+
import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
|
|
85
|
+
```
|
|
35
86
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
87
|
+
In an ESM project this resolves to the `.mjs` build; in a CommonJS project (where TypeScript
|
|
88
|
+
compiles your `import` down to `require()`) it resolves to the `.js` build. Everything is exported
|
|
89
|
+
from the single `@observertc/observer-js` entry. Written in TypeScript; ships type declarations for
|
|
90
|
+
both formats (`dist/index.d.ts` for `require`, `dist/index.d.mts` for `import`). Runtime
|
|
91
|
+
dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or
|
|
92
|
+
any transport — see [Logging](#logging).
|
|
93
|
+
|
|
94
|
+
`ClientSample` and friends are re-exported from this package, and are also published as the
|
|
95
|
+
shared schema in [`@observertc/schemas`](https://github.com/observertc/schemas); samples
|
|
96
|
+
produced on the client (e.g. by `@observertc/client-monitor-js`) conform to the same shape.
|
|
97
|
+
|
|
98
|
+
---
|
|
43
99
|
|
|
44
|
-
|
|
45
|
-
observer.on('newcall', (call) => {
|
|
46
|
-
console.log(`[Observer] New call detected: ${call.callId}`);
|
|
100
|
+
## Quick start
|
|
47
101
|
|
|
48
|
-
|
|
49
|
-
|
|
102
|
+
```ts
|
|
103
|
+
import { Observer, ClientSample } from '@observertc/observer-js';
|
|
50
104
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
105
|
+
// 1. Create an observer.
|
|
106
|
+
const observer = new Observer({
|
|
107
|
+
// when the observer aggregates call/client metrics:
|
|
108
|
+
updatePolicy: 'update-when-all-call-updated',
|
|
109
|
+
// default policy applied to calls created automatically by accept():
|
|
110
|
+
defaultCallUpdatePolicy: 'update-on-any-client-updated',
|
|
111
|
+
// optional auto-teardown:
|
|
112
|
+
closeCallIfEmptyForMs: 20_000,
|
|
113
|
+
closeClientIfIdleForMs: 60_000,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 2. Subscribe on the single bus. Every payload is an object with the ancestry.
|
|
117
|
+
observer.on('call-added', ({ observedCall }) => {
|
|
118
|
+
console.log('new call', observedCall.callId);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
observer.on('client-issue', ({ observedClient, issue }) => {
|
|
122
|
+
console.warn(`[${observedClient.clientId}] ${issue.type}`, issue.payload);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
observer.on('peer-connection-updated', ({ observedClient, observedPeerConnection }) => {
|
|
126
|
+
console.log(observedClient.clientId, 'RTT(ms):', observedPeerConnection.currentRttInMs);
|
|
127
|
+
});
|
|
55
128
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`[Call: ${call.callId}] Metrics updated. Score: ${call.score?.toFixed(1)}, Clients: ${call.numberOfClients}`
|
|
59
|
-
);
|
|
60
|
-
});
|
|
129
|
+
observer.on('sample-rejected', ({ reason, sample }) => {
|
|
130
|
+
console.warn('dropped a sample:', reason);
|
|
61
131
|
});
|
|
62
132
|
|
|
63
|
-
// 3.
|
|
64
|
-
//
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
// This is a placeholder for your actual transformation logic
|
|
68
|
-
const sample: ClientSample = {
|
|
69
|
-
callId,
|
|
70
|
-
clientId,
|
|
71
|
-
timestamp: Date.now(),
|
|
72
|
-
// ...populate with transformed stats from rawStats, adhering to the ClientSample schema
|
|
73
|
-
// from github.com/observertc/schemas
|
|
74
|
-
};
|
|
75
|
-
observer.accept(sample);
|
|
133
|
+
// 3. Feed samples. `context` (optional) is transient per-accept data, carried to the
|
|
134
|
+
// `*-updated` events this accept triggers (never written to appData).
|
|
135
|
+
function onClientStats(sample: ClientSample) {
|
|
136
|
+
observer.accept(sample, { studioVersion: '1.2.3' });
|
|
76
137
|
}
|
|
77
138
|
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
139
|
+
// 4. Tear down.
|
|
140
|
+
process.on('SIGINT', () => observer.close());
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Data flow
|
|
81
146
|
|
|
82
|
-
// 4. Cleanup when done
|
|
83
|
-
// process.on('SIGINT', () => observer.close());
|
|
84
147
|
```
|
|
148
|
+
client getStats() ──► ClientSample ──► observer.accept(sample, ctx?)
|
|
149
|
+
│
|
|
150
|
+
┌────────────────────────────────┘
|
|
151
|
+
▼
|
|
152
|
+
get-or-create ObservedCall ──► get-or-create ObservedClient ──► client.accept(sample, ctx)
|
|
153
|
+
│
|
|
154
|
+
per peerConnections[] in the sample
|
|
155
|
+
▼
|
|
156
|
+
get-or-create ObservedPeerConnection
|
|
157
|
+
.accept(pcSample, ctx) updates all sub-stats,
|
|
158
|
+
derives deltas/bitrates/RTT, correlates remote RTP
|
|
159
|
+
│
|
|
160
|
+
metrics roll up: PeerConnection → Client → Call → Observer
|
|
161
|
+
│
|
|
162
|
+
events emitted on the Observer bus ──► your handlers
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- A sample **must** have `callId` and `clientId` (the library sets them, or the app does). If
|
|
166
|
+
either is missing, the sample is dropped and `sample-rejected` is emitted.
|
|
167
|
+
- Sub-entities that stop appearing in samples are garbage-collected via a "visited"
|
|
168
|
+
mark-and-sweep on each `ObservedPeerConnection.accept()`, emitting the corresponding
|
|
169
|
+
`*-removed` events.
|
|
85
170
|
|
|
86
171
|
---
|
|
87
172
|
|
|
88
|
-
##
|
|
173
|
+
## Entity hierarchy
|
|
89
174
|
|
|
90
|
-
|
|
175
|
+
| Class | Created by | Keyed on its parent as | Holds |
|
|
176
|
+
|-------|-----------|------------------------|-------|
|
|
177
|
+
| `Observer` | `new Observer(config?)` | — (root) | `observedCalls: Map<string, ObservedCall>`, global counters, the event bus |
|
|
178
|
+
| `ObservedCall` | `observer.createObservedCall(settings)` / lazily by `accept` | `observedCalls` | `observedClients: Map<string, ObservedClient>`, call-wide metrics, `detectors`, `scoreCalculator` |
|
|
179
|
+
| `ObservedClient` | `call.createObservedClient(settings)` / lazily | `observedClients` | `observedPeerConnections: Map<string, ObservedPeerConnection>`, per-client metrics |
|
|
180
|
+
| `ObservedPeerConnection` | lazily, from `sample.peerConnections[]` | `observedPeerConnections` | the 15 sub-stat maps below, transport/RTT/bitrate metrics |
|
|
181
|
+
| Sub-stats | lazily, from the `PeerConnectionSample` | maps on the PC | individual WebRTC stat objects |
|
|
91
182
|
|
|
92
|
-
|
|
183
|
+
`ObservedPeerConnection` sub-stat maps (all `public readonly`):
|
|
93
184
|
|
|
94
|
-
|
|
185
|
+
```
|
|
186
|
+
observedCertificates, observedCodecs, observedDataChannels,
|
|
187
|
+
observedIceCandidates, observedIceCandidatesPair, observedIceTransports,
|
|
188
|
+
observedInboundRtps, observedInboundTracks, observedMediaPlayouts,
|
|
189
|
+
observedMediaSources, observedOutboundRtps, observedOutboundTracks,
|
|
190
|
+
observedPeerConnectionTransports, observedRemoteInboundRtps, observedRemoteOutboundRtps
|
|
191
|
+
```
|
|
95
192
|
|
|
96
|
-
|
|
193
|
+
Each sub-stat class (`ObservedInboundRtp`, `ObservedOutboundRtp`, `ObservedInboundTrack`,
|
|
194
|
+
`ObservedOutboundTrack`, `ObservedDataChannel`, `ObservedIceCandidate`,
|
|
195
|
+
`ObservedIceCandidatePair`, `ObservedIceTransport`, `ObservedCertificate`, `ObservedCodec`,
|
|
196
|
+
`ObservedMediaSource`, `ObservedMediaPlayout`, `ObservedPeerConnectionTransport`,
|
|
197
|
+
`ObservedRemoteInboundRtp`, `ObservedRemoteOutboundRtp`) mirrors the corresponding stat
|
|
198
|
+
fields from the schema plus derived fields (deltas, bitrates).
|
|
97
199
|
|
|
98
|
-
|
|
200
|
+
---
|
|
99
201
|
|
|
100
|
-
|
|
101
|
-
2. **Transformation**: These raw stats are transformed into the `ClientSample` schema (ideally from [observertc/schemas](https://github.com/observertc/schemas)).
|
|
102
|
-
3. **Ingestion**: The `ClientSample` is passed to the `observer.accept()` method.
|
|
103
|
-
4. **Processing**: `observer-js` processes the sample, updating or creating relevant entities (`ObservedCall`, `ObservedClient`, `ObservedPeerConnection`, etc.) and their metrics.
|
|
104
|
-
5. **Analysis**: Metrics are analyzed for issue detection and quality scoring.
|
|
105
|
-
6. **Events**: Events are emitted for significant state changes, new issues, or updates.
|
|
202
|
+
## Ingestion: `accept()`, context & lifecycle
|
|
106
203
|
|
|
107
|
-
|
|
204
|
+
### `observer.accept(sample, context?)`
|
|
108
205
|
|
|
109
|
-
|
|
110
|
-
- **`ObservedCall`**: Represents a distinct call session.
|
|
111
|
-
- **`ObservedClient`**: Represents an individual participant within a call.
|
|
112
|
-
- **`ObservedPeerConnection`**: Represents a WebRTC RTCPeerConnection of a client.
|
|
113
|
-
- **`ObservedInboundRtpStream` / `ObservedOutboundRtpStream`**: Tracks individual media streams.
|
|
114
|
-
- **`ObservedDataChannel`**: Tracks data channels.
|
|
115
|
-
- **`ObservedTURN`**: Tracks global TURN server usage metrics across the observer.
|
|
206
|
+
The single entry point. It:
|
|
116
207
|
|
|
117
|
-
|
|
208
|
+
1. drops + emits `sample-rejected` if the observer is closed;
|
|
209
|
+
2. runs the sample through the **global accept-middleware chain** (see below);
|
|
210
|
+
3. (chain terminal) drops + emits `sample-rejected` if `callId`/`clientId` is missing;
|
|
211
|
+
4. gets or lazily creates the `ObservedCall` and `ObservedClient` (their `appData` comes from the
|
|
212
|
+
configured factories, never from `context`);
|
|
213
|
+
5. delegates to `client.accept(sample, context)`, which fans out to each
|
|
214
|
+
`ObservedPeerConnection.accept(pcSample, context)`.
|
|
118
215
|
|
|
119
|
-
|
|
216
|
+
### Accept middlewares (global pre-dispatch hook)
|
|
120
217
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
`observer.addAcceptMiddleware(...)` registers middlewares run on **every** sample inside
|
|
219
|
+
`accept()`, in order, **before** the sample is dispatched to any call or client. Each middleware
|
|
220
|
+
gets a `{ sample, context }` payload; it can inspect or mutate the sample (set/normalize
|
|
221
|
+
`callId`/`clientId`, enrich, redact) or the context, then call `next(payload)` to continue.
|
|
222
|
+
**Not calling `next` drops the sample** — nothing is created and no event fires. A throwing
|
|
223
|
+
middleware is caught and warns (the sample is dropped), never crashing `accept()`.
|
|
124
224
|
|
|
125
|
-
|
|
225
|
+
```ts
|
|
226
|
+
import { Observer, AcceptMiddleware } from '@observertc/observer-js';
|
|
126
227
|
|
|
127
|
-
|
|
228
|
+
const observer = new Observer();
|
|
128
229
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
- Bandwidth estimations
|
|
230
|
+
// derive callId/clientId from the app's own attachment, before dispatch
|
|
231
|
+
const route: AcceptMiddleware = ({ sample }, next) => {
|
|
232
|
+
sample.callId ??= sample.attachments?.roomId as string;
|
|
233
|
+
sample.clientId ??= sample.attachments?.peerId as string;
|
|
234
|
+
next({ sample });
|
|
235
|
+
};
|
|
136
236
|
|
|
137
|
-
|
|
237
|
+
// drop samples from a blocklisted client (never dispatched)
|
|
238
|
+
const filter: AcceptMiddleware = (payload, next) => {
|
|
239
|
+
if (blocked.has(payload.sample.clientId)) return; // no next() => dropped
|
|
240
|
+
next(payload);
|
|
241
|
+
};
|
|
138
242
|
|
|
139
|
-
|
|
243
|
+
observer.addAcceptMiddleware(route, filter);
|
|
244
|
+
// observer.removeAcceptMiddleware(route);
|
|
245
|
+
```
|
|
140
246
|
|
|
141
|
-
|
|
247
|
+
This is a lightweight global injection point, distinct from the larger (not-yet-built)
|
|
248
|
+
`ClientSampleProcessor` pipeline in the roadmap. When no middleware is registered, `accept()`
|
|
249
|
+
dispatches directly with no overhead.
|
|
142
250
|
|
|
143
|
-
`
|
|
251
|
+
### `context` (the `AcceptContext`)
|
|
144
252
|
|
|
145
|
-
|
|
253
|
+
```ts
|
|
254
|
+
type AcceptContext = Record<string, unknown>;
|
|
255
|
+
```
|
|
146
256
|
|
|
147
|
-
|
|
257
|
+
A single, optional, free-form object threaded down the whole accept chain
|
|
258
|
+
(`Observer → Client → PeerConnection`). It is **transient request-scoped data** — temporary or
|
|
259
|
+
contextual information the application wants available while an update is processed.
|
|
148
260
|
|
|
149
|
-
|
|
261
|
+
`context` is **never written to `appData`** and is **not stored** on any entity. The two are
|
|
262
|
+
deliberately distinct:
|
|
150
263
|
|
|
151
|
-
|
|
264
|
+
- **`appData`** — application-assigned extra info that identifies/decorates an entity, fixed at
|
|
265
|
+
creation (via `settings.appData` or the `createCallAppData` / `createClientAppData` factories),
|
|
266
|
+
or assigned by the app on the `*-added` events. The library never changes it.
|
|
267
|
+
- **`context`** — passed per `accept()`, may differ on every call, and is carried straight
|
|
268
|
+
through to the `*-updated` events that the `accept()` triggers, then discarded.
|
|
152
269
|
|
|
153
|
-
|
|
270
|
+
`client-updated` and `peer-connection-updated` carry the exact context of that sample;
|
|
271
|
+
`call-updated` carries the context of the client `accept()` that drove the call update (absent
|
|
272
|
+
for interval- or teardown-driven call updates). When no context is given, the field is absent.
|
|
154
273
|
|
|
155
|
-
|
|
274
|
+
### Get-or-create helpers
|
|
156
275
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
defaultCallUpdateIntervalInMs?: number;
|
|
163
|
-
appData?: AppData; // Custom data for this observer instance
|
|
164
|
-
};
|
|
276
|
+
If you want to create/configure entities yourself before/without samples:
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const call = observer.getOrCreateObservedCall({ callId, appData }); // ObservedCall | undefined
|
|
280
|
+
const client = call?.getOrCreateObservedClient({ clientId, appData }); // ObservedClient | undefined
|
|
165
281
|
```
|
|
166
282
|
|
|
167
|
-
|
|
283
|
+
These return `undefined` (and warn) when the parent is closed; `createObservedCall`/
|
|
284
|
+
`createObservedClient` return the **existing** instance (and warn) if the id already exists.
|
|
168
285
|
|
|
169
|
-
|
|
170
|
-
new Observer<AppData>(config?: ObserverConfig<AppData>)
|
|
171
|
-
```
|
|
286
|
+
### Automatic teardown
|
|
172
287
|
|
|
173
|
-
- `
|
|
288
|
+
- `closeClientIfIdleForMs` — a client with no sample for this long auto-closes.
|
|
289
|
+
- `closeCallIfEmptyForMs` — a call with zero clients for this long auto-closes.
|
|
290
|
+
- Closing cascades down (call → clients → peer connections → sub-stats), unsubscribing
|
|
291
|
+
listeners and emitting the `*-closed` / `*-removed` events.
|
|
174
292
|
|
|
175
|
-
|
|
293
|
+
---
|
|
176
294
|
|
|
177
|
-
|
|
178
|
-
- `observedTURN: ObservedTURN`: Aggregated TURN metrics.
|
|
179
|
-
- `appData: AppData | undefined`: Custom application data.
|
|
180
|
-
- `closed: boolean`: True if `close()` has been called.
|
|
181
|
-
- Counters: `totalAddedCall`, `totalRemovedCall`, RTT buckets, `totalClientIssues`, `numberOfClientsUsingTurn`, `numberOfClients`, `numberOfPeerConnections`, etc.
|
|
295
|
+
## Update policies
|
|
182
296
|
|
|
183
|
-
|
|
297
|
+
"Update" means *recompute aggregated metrics and emit the `*-updated` event* at that level.
|
|
298
|
+
Both the observer and each call have a configurable trigger. Updates are **event-driven** — there
|
|
299
|
+
is no built-in timer. An app that wants a fixed cadence can call `observer.update()` /
|
|
300
|
+
`call.update()` from its own `setInterval`. With `'none'`, **nothing auto-updates** — the level
|
|
301
|
+
updates only when the application calls the public `update()` itself.
|
|
184
302
|
|
|
185
|
-
- `
|
|
186
|
-
- `getObservedCall<T>(callId: string): ObservedCall<T> | undefined`
|
|
187
|
-
- `accept(sample: ClientSample): void`: A convenience method to feed WebRTC stats. If `sample.callId` and `sample.clientId` are provided, it will route the sample to the appropriate `ObservedCall` and `ObservedClient`, creating them if they don't exist. The core sample processing for an existing client happens within the `ObservedClient`'s own `accept` or update mechanism.
|
|
188
|
-
- `update(): void`: Manually trigger an update cycle (behavior depends on `updatePolicy`).
|
|
189
|
-
- `close(): void`: Cleans up resources for the observer and all its calls.
|
|
190
|
-
- `createEventMonitor<CTX>(ctx?: CTX): ObserverEventMonitor<CTX>`: For contextual event listening.
|
|
303
|
+
**Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
|
|
191
304
|
|
|
192
|
-
|
|
305
|
+
| Policy | Triggers `observer.update()` when… |
|
|
306
|
+
|--------|-------------------------------------|
|
|
307
|
+
| `update-on-any-call-updated` | any call updates |
|
|
308
|
+
| `update-when-all-call-updated` | every call has updated since the last observer update |
|
|
309
|
+
| `none` | never automatically — only when the app calls `observer.update()` |
|
|
193
310
|
|
|
194
|
-
- `
|
|
195
|
-
|
|
196
|
-
- `'client-event' (client: ObservedClient, event: ClientEvent)`
|
|
197
|
-
- `'client-issue' (client: ObservedClient, issue: ClientIssue)`
|
|
198
|
-
- `'client-metadata' (client: ObservedClient, metadata: ClientMetaData)`
|
|
199
|
-
- `'client-extension-stats' (client: ObservedClient, stats: ExtensionStat)`
|
|
200
|
-
- `'update' ()`
|
|
201
|
-
- `'close' ()`
|
|
311
|
+
**Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
|
|
312
|
+
`ObserverConfig.defaultCallUpdatePolicy`):
|
|
202
313
|
|
|
203
|
-
|
|
314
|
+
| Policy | Triggers `call.update()` when… |
|
|
315
|
+
|--------|--------------------------------|
|
|
316
|
+
| `update-on-any-client-updated` | any client in the call updates |
|
|
317
|
+
| `update-when-all-client-updated` | every client has updated since the last call update |
|
|
318
|
+
| `none` | never automatically — only when the app calls `call.update()` |
|
|
204
319
|
|
|
205
|
-
|
|
320
|
+
---
|
|
206
321
|
|
|
207
|
-
|
|
322
|
+
## The event bus
|
|
208
323
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
324
|
+
This is the primary API. **Subscribe on the `Observer` instance** — it is the single emitter
|
|
325
|
+
for the entire hierarchy. The `ObservedCall` / `ObservedClient` / `ObservedPeerConnection`
|
|
326
|
+
objects are themselves `EventEmitter`s too, but those local events are reserved for internal
|
|
327
|
+
lifecycle/teardown wiring (see [Local lifecycle events](#local-lifecycle-events)); application
|
|
328
|
+
code should use the Observer bus.
|
|
329
|
+
|
|
330
|
+
### Payload shape: ancestry + subject
|
|
331
|
+
|
|
332
|
+
Every Observer event delivers exactly **one argument: a payload object**. The payload always
|
|
333
|
+
contains the ancestry from the observer down to the entity that raised it, plus any event-
|
|
334
|
+
specific subject:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
type ObserverEventBase = { observer: Observer };
|
|
338
|
+
type ObservedCallScope = ObserverEventBase & { observedCall: ObservedCall };
|
|
339
|
+
type ObservedClientScope = ObservedCallScope & { observedClient: ObservedClient };
|
|
340
|
+
type ObservedPeerConnectionScope = ObservedClientScope & { observedPeerConnection: ObservedPeerConnection };
|
|
217
341
|
```
|
|
218
342
|
|
|
219
|
-
**
|
|
343
|
+
So a peer-connection-level event hands you the observer, call, client, **and** peer connection:
|
|
220
344
|
|
|
221
|
-
|
|
222
|
-
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
- Counters: `totalAddedClients`, `totalRemovedClients`, `numberOfIssues`, RTT buckets, total bytes sent/received (audio/video/data), etc.
|
|
345
|
+
```ts
|
|
346
|
+
observer.on('inbound-rtp-added', ({ observer, observedCall, observedClient, observedPeerConnection, observedInboundRtp }) => {
|
|
347
|
+
// all five are present and correctly typed
|
|
348
|
+
});
|
|
349
|
+
```
|
|
227
350
|
|
|
228
|
-
|
|
351
|
+
`observer.on/off/once/emit` are fully typed against the event map — the handler argument is
|
|
352
|
+
inferred per event name.
|
|
353
|
+
|
|
354
|
+
### Event catalogue
|
|
355
|
+
|
|
356
|
+
All payloads include the ancestry for their level (above). The **Extra** column lists the
|
|
357
|
+
additional field(s) on top of that scope.
|
|
358
|
+
|
|
359
|
+
#### Observer level — scope `{ observer }`
|
|
360
|
+
|
|
361
|
+
| Event | Extra payload | Fires when |
|
|
362
|
+
|-------|---------------|-----------|
|
|
363
|
+
| `observer-updated` | — | `observer.update()` ran (per the observer update policy) |
|
|
364
|
+
| `observer-closed` | — | `observer.close()` |
|
|
365
|
+
| `sample-rejected` | `{ reason: 'observer-closed' \| 'missing-callId' \| 'missing-clientId', sample: ClientSample }` | a sample was dropped by `accept()` |
|
|
366
|
+
|
|
367
|
+
#### Mediasoup level — scope `{ observer, observedMediasoupRouter }`
|
|
368
|
+
|
|
369
|
+
| Event | Extra | Fires when |
|
|
370
|
+
|-------|-------|-----------|
|
|
371
|
+
| `mediasoup-router-added` | — | `observer.createObservedMediasoupRouter(...)` registered a router |
|
|
372
|
+
| `mediasoup-router-matched-with-peer-connection` | `{ observedCall, observedClient, observedPeerConnection }` | a newly added peer connection's id matched one of the router's WebRTC transport ids. |
|
|
373
|
+
| `mediasoup-router-removed` | — | the underlying mediasoup router closed (its `router.observer` `close` fired) |
|
|
374
|
+
|
|
375
|
+
See [Mediasoup router observation](#mediasoup-router-observation) for the full design and examples.
|
|
376
|
+
|
|
377
|
+
#### Call level — scope `{ observer, observedCall }`
|
|
378
|
+
|
|
379
|
+
| Event | Extra | Fires when |
|
|
380
|
+
|-------|-------|-----------|
|
|
381
|
+
| `call-added` | — | a call is created |
|
|
382
|
+
| `call-updated` | `{ context?: AcceptContext }` | `call.update()` ran |
|
|
383
|
+
| `call-closed` | — | the call closed |
|
|
384
|
+
| `call-empty` | — | last client left the call |
|
|
385
|
+
| `call-not-empty` | — | first client joined a previously-empty call |
|
|
386
|
+
| `call-issue` | `{ issue: ClientIssue }` | `call.addIssue(...)` (server-side detector finding) |
|
|
387
|
+
|
|
388
|
+
#### Client level — scope `{ observer, observedCall, observedClient }`
|
|
389
|
+
|
|
390
|
+
| Event | Extra | Fires when |
|
|
391
|
+
|-------|-------|-----------|
|
|
392
|
+
| `client-added` | — | a client is created |
|
|
393
|
+
| `client-sink-created` | `{ sink: ClientSampleSink }` | a per-client sink was created (only when `createClientSink` returns one); fires right after `client-added` |
|
|
394
|
+
| `client-updated` | `{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext }` | the client processed a sample |
|
|
395
|
+
| `client-closed` | — | the client closed |
|
|
396
|
+
| `client-joined` | — | first `CLIENT_JOINED` event seen |
|
|
397
|
+
| `client-left` | — | `CLIENT_LEFT` seen (or inferred on close) |
|
|
398
|
+
| `client-rejoined` | `{ timestamp: number }` | a later `CLIENT_JOINED` after an earlier join |
|
|
399
|
+
| `client-issue` | `{ issue: ClientIssue }` | a client-reported issue arrived, or `client.addIssue(...)` |
|
|
400
|
+
| `client-metadata` | `{ metaData: ClientMetaData }` | a client meta item arrived |
|
|
401
|
+
| `client-extension-stats` | `{ extensionStats: ExtensionStat }` | an app-defined extension stat arrived |
|
|
402
|
+
| `client-event` | `{ event: ClientEvent }` | any client event was processed |
|
|
403
|
+
|
|
404
|
+
#### Peer-connection level — scope `{ observer, observedCall, observedClient, observedPeerConnection }`
|
|
405
|
+
|
|
406
|
+
| Event | Extra | Notes |
|
|
407
|
+
|-------|-------|-------|
|
|
408
|
+
| `peer-connection-added` / `peer-connection-closed` | — | lifecycle of the PC |
|
|
409
|
+
| `peer-connection-updated` | `{ context?: AcceptContext }` | the PC processed a sample |
|
|
410
|
+
| `ice-connection-state-changed` / `ice-gathering-state-changed` / `connection-state-changed` | `{ state: string }` | driven by client events |
|
|
411
|
+
| `selected-candidate-pair-changed` | — | *declared; not currently emitted* |
|
|
412
|
+
| `inbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedInboundTrack }` | |
|
|
413
|
+
| `outbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedOutboundTrack }` | |
|
|
414
|
+
| `inbound-rtp-added` / `-updated` / `-removed` | `{ observedInboundRtp }` | `-updated` fires every tick |
|
|
415
|
+
| `outbound-rtp-added` / `-updated` / `-removed` | `{ observedOutboundRtp }` | `-updated` fires every tick |
|
|
416
|
+
| `remote-inbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteInboundRtp }` | |
|
|
417
|
+
| `remote-outbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteOutboundRtp }` | |
|
|
418
|
+
| `data-channel-added` / `-updated` / `-removed` | `{ observedDataChannel }` | |
|
|
419
|
+
| `ice-candidate-added` / `-updated` / `-removed` | `{ observedIceCandidate }` | |
|
|
420
|
+
| `ice-candidate-pair-added` / `-updated` / `-removed` | `{ observedIceCandidatePair }` | |
|
|
421
|
+
| `ice-transport-added` / `-updated` / `-removed` | `{ observedIceTransport }` | |
|
|
422
|
+
| `codec-added` / `-updated` / `-removed` | `{ observedCodec }` | |
|
|
423
|
+
| `media-source-added` / `-updated` / `-removed` | `{ observedMediaSource }` | |
|
|
424
|
+
| `media-playout-added` / `-updated` / `-removed` | `{ observedMediaPlayout }` | |
|
|
425
|
+
| `peer-connection-transport-added` / `-updated` / `-removed` | `{ observedPeerConnectionTransport }` | |
|
|
426
|
+
| `certificate-added` / `-updated` / `-removed` | `{ observedCertificate }` | |
|
|
427
|
+
|
|
428
|
+
> **Volume note.** The `*-updated` sub-stat events fire on every peer-connection `accept()`
|
|
429
|
+
> (i.e. per sample, per stream). For high-throughput servers, subscribe only to what you need,
|
|
430
|
+
> or read fields off the entities on `client-updated` / `call-updated` instead.
|
|
431
|
+
|
|
432
|
+
### Local lifecycle events
|
|
433
|
+
|
|
434
|
+
These remain on the individual entities (not the bus), for teardown/coordination. You can
|
|
435
|
+
listen to them, but prefer the bus equivalents above for application logic.
|
|
436
|
+
|
|
437
|
+
| Entity | Local events |
|
|
438
|
+
|--------|--------------|
|
|
439
|
+
| `ObservedCall` | `update`, `newclient`, `empty`, `not-empty`, `close` |
|
|
440
|
+
| `ObservedClient` | `update` (`sample`, `elapsedTimeInMs`), `close`, `joined`, `left` |
|
|
441
|
+
| `ObservedPeerConnection` | `removed-inbound-track`, `removed-outbound-track`, `close` |
|
|
229
442
|
|
|
230
|
-
|
|
231
|
-
- `getObservedClient<T>(clientId: string): ObservedClient<T> | undefined`
|
|
232
|
-
- `update(): void`
|
|
233
|
-
- `close(): void`
|
|
234
|
-
- `createEventMonitor<CTX>(ctx?: CTX): ObservedCallEventMonitor<CTX>`
|
|
443
|
+
---
|
|
235
444
|
|
|
236
|
-
|
|
445
|
+
## API reference
|
|
237
446
|
|
|
238
|
-
|
|
239
|
-
- `'empty' ()`: When the last client leaves.
|
|
240
|
-
- `'not-empty' ()`: When the first client joins an empty call.
|
|
241
|
-
- `'update' ()`
|
|
242
|
-
- `'close' ()`
|
|
447
|
+
### `Observer`
|
|
243
448
|
|
|
244
|
-
|
|
449
|
+
```ts
|
|
450
|
+
new Observer<AppData>(config?: ObserverConfig<AppData>)
|
|
245
451
|
|
|
246
|
-
|
|
452
|
+
type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
453
|
+
updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'none';
|
|
454
|
+
defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
|
|
455
|
+
appData?: AppData;
|
|
456
|
+
closeClientIfIdleForMs?: number;
|
|
457
|
+
closeCallIfEmptyForMs?: number;
|
|
458
|
+
// appData factories — run when an entity is created without explicit appData
|
|
459
|
+
// (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
|
|
460
|
+
createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
|
|
461
|
+
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
|
|
462
|
+
// sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
|
|
463
|
+
createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
|
|
464
|
+
// track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
|
|
465
|
+
createTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
|
|
466
|
+
};
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**appData factories.** Instead of pre-creating a call/client (or assigning on `call-added` /
|
|
470
|
+
`client-added`) just to enrich its `appData`, register a factory once. It runs in the entity's
|
|
471
|
+
constructor whenever it's created without an explicit `settings.appData` — including the lazy
|
|
472
|
+
creation inside `accept()`. The `client` factory receives the already-created parent
|
|
473
|
+
`observedCall`, so it can derive fields from it. `appData` is application-owned and is never
|
|
474
|
+
modified by the `accept()` context.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
const observer = new Observer({
|
|
478
|
+
createCallAppData: ({ callId }) => ({ callId, startedAt: Date.now(), region: 'eu' }),
|
|
479
|
+
createClientAppData: ({ clientId, observedCall }) => ({ clientId, region: observedCall.appData.region }),
|
|
480
|
+
});
|
|
481
|
+
```
|
|
247
482
|
|
|
248
|
-
|
|
483
|
+
Key members:
|
|
484
|
+
|
|
485
|
+
- `accept(sample: ClientSample, context?: AcceptContext): void`
|
|
486
|
+
- `addAcceptMiddleware(...mw: AcceptMiddleware[]): this` / `removeAcceptMiddleware(...mw): this` — global pre-dispatch sample hooks (see [Accept middlewares](#accept-middlewares-global-pre-dispatch-hook))
|
|
487
|
+
- `getObservedCall<T>(callId): ObservedCall<T> | undefined`
|
|
488
|
+
- `createObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
489
|
+
- `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
490
|
+
- `update(): void` — force an aggregation/`observer-updated` tick
|
|
491
|
+
- `close(): void`
|
|
492
|
+
- `readonly observedCalls: Map<string, ObservedCall>`
|
|
493
|
+
- `readonly observedTURN: ObservedTURN`
|
|
494
|
+
- `get appData()`, `get numberOfCalls()`
|
|
495
|
+
- counters: `numberOfClients`, `numberOfClientsUsingTurn`, `numberOfInboundRtpStreams`,
|
|
496
|
+
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `numberOfPeerConnections`,
|
|
497
|
+
`totalAddedCall`, `totalRemovedCall`, `closed`
|
|
498
|
+
- `on/off/once/emit` typed against the [event map](#event-catalogue)
|
|
499
|
+
|
|
500
|
+
### `ObservedCall`
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
type ObservedCallSettings<AppData = Record<string, unknown>> = {
|
|
504
|
+
updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'none';
|
|
505
|
+
callId: string;
|
|
506
|
+
appData?: AppData;
|
|
507
|
+
closeCallIfEmptyForMs?: number;
|
|
508
|
+
};
|
|
509
|
+
```
|
|
249
510
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
511
|
+
Key members:
|
|
512
|
+
|
|
513
|
+
- `readonly callId: string`, `appData: AppData`
|
|
514
|
+
- `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
|
|
515
|
+
- `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
|
|
516
|
+
- `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
|
|
517
|
+
- `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
|
|
518
|
+
- `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
|
|
519
|
+
- `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
|
|
520
|
+
- aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
|
|
521
|
+
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
|
|
522
|
+
`clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
|
|
523
|
+
- `update()`, `close()`
|
|
524
|
+
|
|
525
|
+
### `ObservedClient`
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
type ObservedClientSettings<AppData = Record<string, unknown>> = {
|
|
529
|
+
clientId: string;
|
|
530
|
+
appData?: AppData;
|
|
531
|
+
closeClientIfIdleForMs?: number;
|
|
255
532
|
};
|
|
256
533
|
```
|
|
257
534
|
|
|
258
|
-
|
|
535
|
+
Key members:
|
|
536
|
+
|
|
537
|
+
- `readonly clientId: string`, `appData: AppData`, `readonly call: ObservedCall`
|
|
538
|
+
- `readonly observedPeerConnections: Map<string, ObservedPeerConnection>`
|
|
539
|
+
- `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error`
|
|
540
|
+
- **Injection API** (queue app data to be merged into the next sample processing):
|
|
541
|
+
`injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`,
|
|
542
|
+
`injectExtensionStat(ExtensionStat)`, `injectAttachment(key, value)`
|
|
543
|
+
- **Direct add API** (process immediately): `addIssue(ClientIssue)`, `addMetadata(ClientMetaData)`,
|
|
544
|
+
`addExtensionStats(ExtensionStat)`
|
|
545
|
+
- Metrics (current/derived): `currentAvgRttInMs?`, `currentMinRttInMs?`, `currentMaxRttInMs?`,
|
|
546
|
+
`receivingAudioBitrate`, `receivingVideoBitrate`, `sendingAudioBitrate`, `sendingVideoBitrate`,
|
|
547
|
+
`usingTURN`, `usingTCP`, `availableIncomingBitrate`, `availableOutgoingBitrate`
|
|
548
|
+
- Counts: `numberOfInboundRtpStreams`, `numberOfOutboundRtpStreams`, `numberOfInbundTracks`,
|
|
549
|
+
`numberOfOutboundTracks`, `numberOfDataChannels`, `numberOfPeerConnections`
|
|
550
|
+
- Per-tick deltas: `deltaReceivedAudioBytes`, `deltaSentAudioBytes`, … (see source for the full set)
|
|
551
|
+
- Lifecycle: `joinedAt?`, `leftAt?`, `closedAt?`, `closed`, `get score()`
|
|
552
|
+
- Metadata: `browser?`, `engine?`, `platform?`, `operationSystem?`, `mediaDevices`, `mediaConstraints`
|
|
553
|
+
- `accept(sample, context?)`, `close()`
|
|
554
|
+
|
|
555
|
+
### `ObservedPeerConnection`
|
|
556
|
+
|
|
557
|
+
Key members:
|
|
558
|
+
|
|
559
|
+
- `readonly peerConnectionId: string`, `readonly client: ObservedClient`, `appData?`
|
|
560
|
+
- The 15 `observed*` sub-stat `Map`s (listed [above](#entity-hierarchy)), plus array getters:
|
|
561
|
+
`codecs`, `inboundRtps`, `outboundRtps`, `remoteInboundRtps`, `remoteOutboundRtps`,
|
|
562
|
+
`mediaSources`, `mediaPlayouts`, `dataChannels`, `peerConnectionTransports`, `iceTransports`,
|
|
563
|
+
`iceCandidates`, `iceCandidatePairs`, `certificates`, `selectedIceCandidatePairs`,
|
|
564
|
+
`selectedIceCandiadtePairForTurn`
|
|
565
|
+
- State: `connectionState?`, `iceConnectionState?`, `iceGatheringState?`, `usingTURN`, `usingTCP`
|
|
566
|
+
- Metrics: `currentRttInMs?`, `currentJitter?`, `availableIncomingBitrate`,
|
|
567
|
+
`availableOutgoingBitrate`, sending/receiving bitrates, packet rates, and `total*` / `delta*`
|
|
568
|
+
byte/packet counters
|
|
569
|
+
- `accept(pcSample, context?)`, `close()`, `get score()`
|
|
570
|
+
|
|
571
|
+
**Remote-RTP correlation (derived).** During `accept()`, receiver/sender reports are linked
|
|
572
|
+
to the local streams by `remoteId` (fallback SSRC) and surfaced as fields:
|
|
573
|
+
|
|
574
|
+
- on `ObservedOutboundRtp`: `remoteRttInMs?`, `remoteFractionLost?`, `remoteJitter?`, `remotePacketsLost?`
|
|
575
|
+
- on `ObservedInboundRtp`: `remoteRttInMs?`, `remoteBytesSent?`, `remotePacketsSent?`, `remoteTimestamp?`
|
|
576
|
+
|
|
577
|
+
These are reset each tick and only set when the matching remote report is present.
|
|
259
578
|
|
|
260
|
-
|
|
261
|
-
- `call: ObservedCall`: Reference to the parent call.
|
|
262
|
-
- `appData: AppData | undefined`
|
|
263
|
-
- `score: number | undefined`: Client quality score.
|
|
264
|
-
- `numberOfPeerConnections: number`
|
|
265
|
-
- `usingTURN: boolean`
|
|
266
|
-
- `observedPeerConnections: Map<string, ObservedPeerConnection>`
|
|
267
|
-
- Counters: `numberOfIssues`, RTT buckets, total bytes sent/received, `availableIncomingBitrate`, `availableOutgoingBitrate`, etc.
|
|
579
|
+
---
|
|
268
580
|
|
|
269
|
-
|
|
581
|
+
## Schema types (`ClientSample`)
|
|
582
|
+
|
|
583
|
+
The shape of an accepted sample (re-exported from this package; identical to
|
|
584
|
+
`@observertc/schemas`). Only the top level is shown — each stat object mirrors the standard
|
|
585
|
+
WebRTC `getStats()` dictionaries plus a few extensions.
|
|
586
|
+
|
|
587
|
+
```ts
|
|
588
|
+
type ClientSample = {
|
|
589
|
+
timestamp: number; // client wall-clock (ms epoch)
|
|
590
|
+
callId?: string; // set by you or the library
|
|
591
|
+
clientId?: string; // set by you or the library
|
|
592
|
+
score?: number; // optional client-computed score (0..5)
|
|
593
|
+
attachments?: Record<string, unknown>;
|
|
594
|
+
peerConnections?: PeerConnectionSample[];
|
|
595
|
+
clientEvents?: ClientEvent[];
|
|
596
|
+
clientIssues?: ClientIssue[];
|
|
597
|
+
clientMetaItems?: ClientMetaData[];
|
|
598
|
+
extensionStats?: ExtensionStat[];
|
|
599
|
+
};
|
|
270
600
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
601
|
+
type PeerConnectionSample = {
|
|
602
|
+
peerConnectionId: string;
|
|
603
|
+
attachments?: Record<string, unknown>; // e.g. { direction: 'send'|'recv', producerId, consumerId, label }
|
|
604
|
+
score?: number;
|
|
605
|
+
inboundTracks?; outboundTracks?;
|
|
606
|
+
codecs?;
|
|
607
|
+
inboundRtps?; remoteInboundRtps?;
|
|
608
|
+
outboundRtps?; remoteOutboundRtps?;
|
|
609
|
+
mediaSources?; mediaPlayouts?;
|
|
610
|
+
peerConnectionTransports?; dataChannels?;
|
|
611
|
+
iceTransports?; iceCandidates?; iceCandidatePairs?;
|
|
612
|
+
certificates?;
|
|
613
|
+
};
|
|
277
614
|
|
|
278
|
-
|
|
615
|
+
type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
616
|
+
type ClientIssue = { type: string; payload?: string; timestamp?: number }; // also used for call-issue
|
|
617
|
+
type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
618
|
+
type ExtensionStat = { type: string; payload?: string };
|
|
619
|
+
```
|
|
279
620
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
621
|
+
`payload` fields are JSON strings; the library parses the ones it understands.
|
|
622
|
+
|
|
623
|
+
**`ClientEventTypes`** (enum of known `event.type` values): `CLIENT_JOINED`, `CLIENT_LEFT`,
|
|
624
|
+
`PEER_CONNECTION_OPENED/CLOSED/STATE_CHANGED`, `MEDIA_TRACK_ADDED/REMOVED/MUTED/UNMUTED/RESUMED`,
|
|
625
|
+
`ICE_GATHERING_STATE_CHANGED`, `ICE_CONNECTION_STATE_CHANGED`, `DATA_CHANNEL_OPEN/CLOSED/ERROR`,
|
|
626
|
+
`NEGOTIATION_NEEDED`, `SIGNALING_STATE_CHANGE`, `ICE_CANDIDATE`, `ICE_CANDIDATE_ERROR`, and the
|
|
627
|
+
mediasoup set `PRODUCER_*` / `CONSUMER_*` / `DATA_PRODUCER_*` / `DATA_CONSUMER_*`.
|
|
628
|
+
|
|
629
|
+
**`ClientMetaTypes`** (enum of known meta `type` values): `MEDIA_CONSTRAINT`, `MEDIA_DEVICE`,
|
|
630
|
+
`MEDIA_DEVICES_SUPPORTED_CONSTRAINTS`, `USER_MEDIA_ERROR`, `LOCAL_SDP`, `OPERATION_SYSTEM`,
|
|
631
|
+
`ENGINE`, `PLATFORM`, `BROWSER`.
|
|
632
|
+
|
|
633
|
+
### Worked example: a real `ClientSample`
|
|
634
|
+
|
|
635
|
+
Two consecutive samples from one participant ("Guest" in room `qq0iwfnd`) of an
|
|
636
|
+
edumeet/mediasoup call show what actually flows through `accept()`: a rich **join snapshot**,
|
|
637
|
+
then lean **steady-state ticks**.
|
|
638
|
+
|
|
639
|
+
**Sample 1 — the join snapshot.** Carries the one-off lifecycle `clientEvents` and device
|
|
640
|
+
`clientMetaItems` alongside the first stats. (Abbreviated; ids and times are from the real log.)
|
|
641
|
+
|
|
642
|
+
```jsonc
|
|
643
|
+
{
|
|
644
|
+
"timestamp": 1780572332518,
|
|
645
|
+
"callId": "d3dbf2f5-79be-4cb8-9d43-fb404f07ef27",
|
|
646
|
+
"clientId": "c926983c-4468-4046-ae8c-a9cabe1a1868",
|
|
647
|
+
"score": 0, // no quality measured yet on the join tick
|
|
648
|
+
"attachments": { "displayName": "Guest", "roomId": "qq0iwfnd", "actualSessionId": "d3dbf2f5-…" },
|
|
649
|
+
|
|
650
|
+
"clientEvents": [ // chronological lifecycle (12 in the real sample)
|
|
651
|
+
{ "type": "CLIENT_JOINED", "timestamp": 1780572324515 },
|
|
652
|
+
{ "type": "PEER_CONNECTION_OPENED", "timestamp": 1780572326790 }, // pc=b81c8d9d (media)
|
|
653
|
+
{ "type": "ICE_GATHERING_STATE_CHANGED", "timestamp": 1780572326811 }, // → gathering
|
|
654
|
+
{ "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326812 }, // → connecting
|
|
655
|
+
{ "type": "PRODUCER_ADDED", "timestamp": 1780572326821 }, // producer=1abdaf82 (audio)
|
|
656
|
+
{ "type": "MEDIA_TRACK_ADDED", "timestamp": 1780572326821 }, // track=36ae42df (audio)
|
|
657
|
+
{ "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326827 }, // → connected
|
|
658
|
+
{ "type": "PRODUCER_ADDED", "timestamp": 1780572326837 }, // producer=ba06a35b (video)
|
|
659
|
+
{ "type": "DATA_PRODUCER_CREATED", "timestamp": 1780572326853 }
|
|
660
|
+
],
|
|
661
|
+
|
|
662
|
+
"clientMetaItems": [ // environment & devices, one-off (10 in the real sample)
|
|
663
|
+
{ "type": "USER_AGENT_DATA", "payload": "{…Chrome 148 / macOS…}" },
|
|
664
|
+
{ "type": "MEDIA_DEVICE", "payload": "{…\"BRIO 4K Stream Edition\"…}" }
|
|
665
|
+
// …mic / camera / speaker devices…
|
|
666
|
+
],
|
|
667
|
+
|
|
668
|
+
"peerConnections": [
|
|
669
|
+
{
|
|
670
|
+
"peerConnectionId": "b81c8d9d-…", // the media PC — Guest publishes to the SFU
|
|
671
|
+
"outboundRtps": [ /* audio + video */ ],
|
|
672
|
+
"outboundTracks": [ /* mic + camera: label, settings, capabilities */ ],
|
|
673
|
+
"remoteInboundRtps": [ /* RTCP feedback from the SFU */ ],
|
|
674
|
+
"codecs": [ /* … */ ], "iceTransports": [ /* … */ ],
|
|
675
|
+
"iceCandidatePairs": [ /* … */ ], "dataChannels": [ /* … */ ]
|
|
676
|
+
},
|
|
677
|
+
{ "peerConnectionId": "8635acb7-…", "peerConnectionTransports": [ /* … */ ] } // signaling-only PC
|
|
678
|
+
]
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
What `accept()` does with it, in order — each step emits on the bus with full ancestry:
|
|
286
683
|
|
|
287
|
-
|
|
684
|
+
1. lazily creates the `ObservedCall` → **`call-added`**;
|
|
685
|
+
2. creates the `ObservedClient` → **`client-added`**, then **`client-joined`** (from `CLIENT_JOINED`);
|
|
686
|
+
3. creates an `ObservedPeerConnection` per entry → **`peer-connection-added`** (×2 here);
|
|
687
|
+
4. creates an `ObservedOutboundTrack` per track → **`outbound-track-added`**, plus the matching
|
|
688
|
+
**`outbound-rtp-added`**;
|
|
689
|
+
5. replays the device list as **`client-metadata`** events and the lifecycle items as
|
|
690
|
+
**`client-event`**; and finally **`client-updated`** for the whole tick.
|
|
288
691
|
|
|
289
|
-
|
|
692
|
+
`attachments.roomId` lands on `observedClient.attachments` (read it on `client-updated`, **not** at
|
|
693
|
+
creation — see [Ingestion](#ingestion-accept-context--lifecycle)).
|
|
290
694
|
|
|
291
|
-
|
|
292
|
-
|
|
695
|
+
**Sample 2 — a steady-state tick** (~8 s later): same `callId` / `clientId`, **no** new
|
|
696
|
+
`clientEvents` or `clientMetaItems`, just refreshed `peerConnections` stats. Each PC now scores `5`
|
|
697
|
+
and the aggregate client `score` is `4.74` — a healthy call. This is the shape of nearly every
|
|
698
|
+
sample: each tick refreshes metrics and fires the `*-updated` events, while the heavy join
|
|
699
|
+
snapshot happens only once.
|
|
293
700
|
|
|
294
|
-
|
|
701
|
+
---
|
|
295
702
|
|
|
296
|
-
|
|
703
|
+
## Detectors (server-side extension point)
|
|
297
704
|
|
|
298
|
-
|
|
705
|
+
`observer-js` deliberately ships **no built-in detectors**. Per-client signals — packet loss,
|
|
706
|
+
jitter, RTT, freezes, etc. — are already detectable on the client and arrive on samples as
|
|
707
|
+
`clientIssues` (surfaced via `client-issue`). Server-side detection should focus on what only
|
|
708
|
+
the server can see by **correlating data across the clients of a call**.
|
|
299
709
|
|
|
300
|
-
|
|
710
|
+
The hook lives on **`ObservedCall`**:
|
|
301
711
|
|
|
302
|
-
|
|
712
|
+
```ts
|
|
713
|
+
import { Observer, Detector } from '@observertc/observer-js';
|
|
303
714
|
|
|
304
|
-
|
|
715
|
+
class MyCrossClientDetector implements Detector {
|
|
716
|
+
readonly name = 'my-detector';
|
|
717
|
+
constructor(private readonly call /* : ObservedCall */) {}
|
|
718
|
+
update() { // called on every call.update()
|
|
719
|
+
// …inspect this.call.observedClients across participants…
|
|
720
|
+
if (/* condition only visible server-side */ false) {
|
|
721
|
+
this.call.addIssue({ type: this.name, payload: JSON.stringify({ /* … */ }), timestamp: Date.now() });
|
|
722
|
+
// → emitted on the bus as 'call-issue'
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const observer = new Observer();
|
|
728
|
+
observer.on('call-added', ({ observedCall }) => {
|
|
729
|
+
observedCall.detectors.add(new MyCrossClientDetector(observedCall));
|
|
730
|
+
});
|
|
731
|
+
observer.on('call-issue', ({ observedCall, issue }) => { /* react */ });
|
|
732
|
+
```
|
|
305
733
|
|
|
306
|
-
|
|
307
|
-
- `peerConnections: RTCPeerConnectionStats[]`
|
|
308
|
-
- `inboundRtpStreams: RTCInboundRtpStreamStats[]`
|
|
309
|
-
- `outboundRtpStreams: RTCOutboundRtpStreamStats[]`
|
|
310
|
-
- `remoteInboundRtpStreams: RTCRemoteInboundRtpStreamStats[]`
|
|
311
|
-
- `remoteOutboundRtpStreams: RTCRemoteOutboundRtpStreamStats[]`
|
|
312
|
-
- `dataChannels: RTCDataChannelStats[]`
|
|
313
|
-
- `iceLocalCandidates: RTCIceCandidateStats[]`, `iceRemoteCandidates: RTCIceCandidateStats[]`, `iceCandidatePairs: RTCIceCandidatePairStats[]`
|
|
314
|
-
- `mediaSources: RTCAudioSourceStats[] / RTCVideoSourceStats[]`
|
|
315
|
-
- `tracks: RTCMediaStreamTrackStats[]`
|
|
316
|
-
- `certificates: RTCCertificateStats[]`
|
|
317
|
-
- `codecs: RTCCodecStats[]`
|
|
318
|
-
- `transports: RTCIceTransportStats[]` (or similar depending on spec version)
|
|
319
|
-
- `browser`, `engine`, `platform`, `os` (client environment metadata)
|
|
320
|
-
- `userMediaErrors`, `iceConnectionStates`, `connectionStates` (client-reported events/states)
|
|
321
|
-
- `extensionStats` (for custom data)
|
|
734
|
+
`Detector` interface and the registry:
|
|
322
735
|
|
|
323
|
-
|
|
736
|
+
```ts
|
|
737
|
+
interface Detector { readonly name: string; update(): void; }
|
|
324
738
|
|
|
325
|
-
|
|
739
|
+
class Detectors {
|
|
740
|
+
add(d: Detector): void;
|
|
741
|
+
remove(d: Detector): void;
|
|
742
|
+
clear(): void;
|
|
743
|
+
update(): void; // called by ObservedCall.update(); guards each detector in try/catch
|
|
744
|
+
get listOfNames(): string[];
|
|
745
|
+
}
|
|
746
|
+
```
|
|
326
747
|
|
|
327
|
-
|
|
748
|
+
---
|
|
328
749
|
|
|
329
|
-
|
|
750
|
+
## Remote track resolution (mediasoup / SFU)
|
|
330
751
|
|
|
331
|
-
**
|
|
752
|
+
In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
|
|
753
|
+
tracks (one **publisher** → many **subscribers**). Correlation is **opt-in** per observer: set
|
|
754
|
+
`ObserverConfig.createTrackResolver`, a factory invoked when each call is created that returns the
|
|
755
|
+
call's `RemoteTrackResolver` (or `undefined` for none).
|
|
332
756
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
757
|
+
`RemoteTrackResolver` is a generic, strategy-driven class. It subscribes to the bus (filtered to
|
|
758
|
+
its call) and links tracks by **publisher id** — the link key — maintaining the links directly on
|
|
759
|
+
the tracks: `inboundTrack.remoteOutboundTrack` and `outboundTrack.remoteInboundTracks: Set`.
|
|
336
760
|
|
|
337
|
-
|
|
761
|
+
```ts
|
|
762
|
+
import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
|
|
338
763
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
764
|
+
const observer = new Observer({
|
|
765
|
+
createTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
|
|
766
|
+
});
|
|
342
767
|
|
|
343
|
-
|
|
768
|
+
// later, given tracks (links are kept up to date as tracks come and go):
|
|
769
|
+
const source = inboundTrack.remoteOutboundTrack; // the publishing ObservedOutboundTrack
|
|
770
|
+
const receivers = [ ...outboundTrack.remoteInboundTracks ]; // the subscribing ObservedInboundTrack[]
|
|
771
|
+
```
|
|
344
772
|
|
|
345
|
-
- `
|
|
346
|
-
|
|
347
|
-
-
|
|
773
|
+
Two built-in factories ship: `createDefaultMediasoupRemoteTrackResolverFactory()` (publisher =
|
|
774
|
+
`attachments.producerId`, subscriber = `attachments.consumerId`) and
|
|
775
|
+
`createP2pRemoteTrackResolverFactory()` (matches by RTP **SSRC**, preserved end-to-end in p2p).
|
|
348
776
|
|
|
349
|
-
|
|
777
|
+
For any other topology, build a `RemoteTrackResolver` with your own key resolvers — the publisher
|
|
778
|
+
id is just whatever links a subscribed track to the published one:
|
|
350
779
|
|
|
351
|
-
|
|
780
|
+
```ts
|
|
781
|
+
import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
|
|
352
782
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
callId: 'call1',
|
|
360
|
-
appData: { meetingTitle: 'Team Sync', scheduledAt: new Date() },
|
|
783
|
+
const observer = new Observer({
|
|
784
|
+
createTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
|
|
785
|
+
resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
|
|
786
|
+
resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
|
|
787
|
+
resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
|
|
788
|
+
}),
|
|
361
789
|
});
|
|
362
|
-
console.log(call.appData?.meetingTitle);
|
|
363
790
|
```
|
|
364
791
|
|
|
365
|
-
|
|
792
|
+
For the mediasoup factory, the application puts `producerId` / `consumerId` (and optionally
|
|
793
|
+
`direction`, `label`) into the track `attachments`.
|
|
366
794
|
|
|
367
|
-
|
|
795
|
+
---
|
|
368
796
|
|
|
369
|
-
|
|
797
|
+
## Mediasoup router observation
|
|
798
|
+
|
|
799
|
+
Everything above is built from the **client-reported** `ClientSample`. When you run a
|
|
800
|
+
[mediasoup](https://mediasoup.org) SFU you also have the **server's own** ground truth — its
|
|
801
|
+
routers, transports, producers, consumers and data channels, with exact lifetimes and state
|
|
802
|
+
transitions. `ObservedMediasoupRouter` captures that server-side view into a
|
|
803
|
+
**`MediasoupRouterSample`**, completely independent of the client sample pipeline.
|
|
804
|
+
|
|
805
|
+
### The concept
|
|
806
|
+
|
|
807
|
+
You hand the observer a live mediasoup `Router`; it attaches to mediasoup's own `observer` API and,
|
|
808
|
+
from then on, **passively records** the router's topology and lifecycle — with no polling and no
|
|
809
|
+
changes to your media code:
|
|
810
|
+
|
|
811
|
+
- new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, and ICE state
|
|
812
|
+
transitions;
|
|
813
|
+
- producers (codec, SSRCs/RIDs, `pause`/`resume`) and consumers (`pause`/`resume`,
|
|
814
|
+
`producerPaused`/`producerResumed`);
|
|
815
|
+
- data producers and data consumers;
|
|
816
|
+
- `createdAt` / `closedAt` for every entity above.
|
|
817
|
+
|
|
818
|
+
All of it accumulates on `observedMediasoupRouter.sample` (a `MediasoupRouterSample` — see
|
|
819
|
+
[`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts)). This is a *Sample*, not a
|
|
820
|
+
*Report*: it mirrors the naming of `ClientSample` and is yours to snapshot, persist, or correlate.
|
|
821
|
+
|
|
822
|
+
### Matching peer connections — by **event**, not by storage
|
|
823
|
+
|
|
824
|
+
The observer correlates the SFU side with the client side **at the peer-connection level**: a
|
|
825
|
+
mediasoup WebRTC transport and a client's `RTCPeerConnection` share the same id, so whenever an
|
|
826
|
+
observed peer connection's id matches one of the router's WebRTC transport ids, that's a match.
|
|
827
|
+
|
|
828
|
+
**The observer does not store the router (or its sample) on any entity.** Instead, for **every**
|
|
829
|
+
matching peer connection it emits **`mediasoup-router-matched-with-peer-connection`** and steps
|
|
830
|
+
back — *your application* decides what the pairing means. The payload carries the full peer-connection
|
|
831
|
+
ancestry, so you get the router **and** the matched `observedPeerConnection`, `observedClient` and
|
|
832
|
+
`observedCall` in one place. Stamp the `routerId` into the peer connection's / client's `appData`,
|
|
833
|
+
build your own index, attach the server sample to the call in your database — whatever fits.
|
|
834
|
+
|
|
835
|
+
How it works: as peer connections are observed (`peer-connection-added`), the observer checks whether
|
|
836
|
+
the peer connection's id is one of the router's WebRTC transport ids. On a hit it emits — **once per
|
|
837
|
+
matching peer connection** (de-duplicated by peer-connection id) — and keeps watching, so a router
|
|
838
|
+
that serves many participants emits one match per participant's transport. The internal listener is
|
|
839
|
+
removed automatically when the router closes or the observer closes.
|
|
840
|
+
|
|
841
|
+
When the underlying mediasoup router closes, its `close` propagates to `ObservedMediasoupRouter`,
|
|
842
|
+
which emits **`mediasoup-router-removed`**. That is your cue to do whatever cleanup or persistence
|
|
843
|
+
you want with the now-final `sample` — again, the observer itself keeps nothing.
|
|
844
|
+
|
|
845
|
+
### Options — `observer.createObservedMediasoupRouter(settings)`
|
|
846
|
+
|
|
847
|
+
| Field | Type | Required | Meaning |
|
|
848
|
+
|-------|------|----------|---------|
|
|
849
|
+
| `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer` |
|
|
850
|
+
| `routerId` | `string` | yes | your id for the router (the sample also carries `router.id`) |
|
|
851
|
+
| `appData` | `Record<string, unknown>` | no | application-owned bag on the `ObservedMediasoupRouter` |
|
|
852
|
+
| `attachments` | `Record<string, unknown>` | no | free-form data copied onto `sample.attachments` |
|
|
853
|
+
|
|
854
|
+
Peer-connection matching is automatic — there is nothing to opt into. Returns the
|
|
855
|
+
`ObservedMediasoupRouter`, or `undefined` if the observer is closed (a router with the same id
|
|
856
|
+
returns the existing instance — both warn).
|
|
857
|
+
|
|
858
|
+
Useful members on the returned object: `.sample` (the `MediasoupRouterSample`), `.appData`,
|
|
859
|
+
`.attachments` (getter over `sample.attachments`), `.webrtcTransportIds: Set<string>`, `.id`,
|
|
860
|
+
`.close()`.
|
|
861
|
+
|
|
862
|
+
### Example
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { Observer } from '@observertc/observer-js';
|
|
866
|
+
import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from '@observertc/observer-js';
|
|
867
|
+
|
|
868
|
+
const observer = new Observer();
|
|
869
|
+
|
|
870
|
+
// 1) Feed client samples as usual so the observer knows about calls, clients & peer connections.
|
|
871
|
+
// (e.g. transport-layer: observer.accept(clientSample, context))
|
|
872
|
+
|
|
873
|
+
// 2) Observe the SFU side. The observer auto-matches peer connections to this router's transports.
|
|
874
|
+
const router = /* your mediasoup router */ undefined as any;
|
|
875
|
+
const observedRouter = observer.createObservedMediasoupRouter({ router, routerId: router.id });
|
|
876
|
+
|
|
877
|
+
// 3) Every peer connection whose id matches one of the router's WebRTC transport ids fires this —
|
|
878
|
+
// WE decide what to do with each pairing. The payload carries the full ancestry.
|
|
879
|
+
observer.on('mediasoup-router-matched-with-peer-connection',
|
|
880
|
+
({ observedMediasoupRouter, observedCall, observedClient, observedPeerConnection }:
|
|
881
|
+
ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
|
|
882
|
+
// e.g. remember which router serves this peer connection / client…
|
|
883
|
+
(observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
|
|
884
|
+
// …or index the server sample by 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
|
+
myStore.saveRouterSample(observedMediasoupRouter.sample);
|
|
892
|
+
});
|
|
370
893
|
|
|
371
|
-
|
|
372
|
-
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
- Storing application-specific identifiers (e.g., `userId`, `roomId`, `meetingType`).
|
|
377
|
-
- Configuration flags relevant to how your application interprets this entity.
|
|
378
|
-
- Descriptive information (e.g., `clientDeviceType`, `callRegion`).
|
|
894
|
+
// (optional) react to the router being registered at all:
|
|
895
|
+
observer.on('mediasoup-router-added', ({ observedMediasoupRouter }) => {
|
|
896
|
+
console.log('observing router', observedMediasoupRouter.id);
|
|
897
|
+
});
|
|
898
|
+
```
|
|
379
899
|
|
|
380
|
-
|
|
900
|
+
### Why event-driven instead of storing on the call
|
|
381
901
|
|
|
382
|
-
- **
|
|
383
|
-
|
|
384
|
-
- **
|
|
385
|
-
|
|
386
|
-
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
- Allowing different modules or plugins to associate their own private data with an observer entity without needing to modify its core `appData` type.
|
|
390
|
-
- Storing large binary data or complex objects that are not part of the core descriptive metadata.
|
|
902
|
+
- **Loose coupling.** The call model stays about client telemetry; the SFU view lives on its own
|
|
903
|
+
object and is associated only if and how *you* choose.
|
|
904
|
+
- **You own the association.** One router serves many peer connections (across clients and calls),
|
|
905
|
+
and the right place to keep that mapping is application-specific — so the observer hands you each
|
|
906
|
+
peer-connection match and the final sample, and gets out of the way.
|
|
907
|
+
- **No silent accumulation.** Nothing is appended to `ObservedCall`, so there is no hidden growth or
|
|
908
|
+
lifetime you have to reason about; the router sample lives exactly as long as you keep a reference.
|
|
391
909
|
|
|
392
|
-
|
|
910
|
+
---
|
|
393
911
|
|
|
394
|
-
-
|
|
395
|
-
- Core, descriptive metadata that is known at creation time or changes infrequently.
|
|
396
|
-
- Data that benefits from strong typing and is integral to your application's understanding of the entity.
|
|
397
|
-
- Use **`attachments`** for:
|
|
398
|
-
- Dynamic, temporary, or loosely structured data.
|
|
399
|
-
- Data added by different, potentially independent, parts of your system or plugins.
|
|
400
|
-
- Information that doesn't need to be part of the primary, typed `appData` schema.
|
|
912
|
+
## Sinks (per-client sample persistence)
|
|
401
913
|
|
|
402
|
-
|
|
914
|
+
A **sink** receives the samples a client accepts — for archival, streaming, or later offline
|
|
915
|
+
replay. Each `ObservedClient` gets its **own** sink, produced by the
|
|
916
|
+
`ObserverConfig.createClientSink` factory when the client is created (return `undefined` for no
|
|
917
|
+
sink). The client pushes every accepted sample to its sink, and `end()`s it on close.
|
|
403
918
|
|
|
404
|
-
|
|
919
|
+
### The `ClientSampleSink` base class
|
|
405
920
|
|
|
406
|
-
|
|
407
|
-
`
|
|
921
|
+
`ClientSampleSink` is an **abstract base class** (a typed `EventEmitter`). You create a sink by
|
|
922
|
+
**subclassing it** and implementing `write` and `end`. It is **object-mode**: `write` receives
|
|
923
|
+
the `ClientSample` *object*, so each sink decides how (or whether) to serialize it — JSON line,
|
|
924
|
+
protobuf, a remote POST body, an in-memory push, etc.
|
|
408
925
|
|
|
409
|
-
|
|
926
|
+
```ts
|
|
927
|
+
import { ClientSampleSink, ClientSample } from '@observertc/observer-js';
|
|
410
928
|
|
|
411
|
-
|
|
929
|
+
abstract class ClientSampleSink /* extends EventEmitter */ {
|
|
930
|
+
abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure
|
|
931
|
+
abstract end(): void; // flush; emit `close` when the destination is ready
|
|
412
932
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
933
|
+
// typed events (inherited): the listener signature is inferred from the event name
|
|
934
|
+
on(event: 'close' | 'finish' | 'drain', listener: () => void): this;
|
|
935
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
936
|
+
// ...and the matching `once` / `off` / `emit`
|
|
937
|
+
}
|
|
938
|
+
```
|
|
417
939
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
observer.on('newcall', (call) => {
|
|
426
|
-
console.log(`[Observer] New call: ${call.callId}`);
|
|
427
|
-
call.on('update', () => {
|
|
428
|
-
console.log(`[Call: ${call.callId}] Updated. Clients: ${call.numberOfClients}, Score: ${call.score?.toFixed(1)}`);
|
|
429
|
-
});
|
|
430
|
-
call.on('newclient', (client) => {
|
|
431
|
-
console.log(`[Call: ${call.callId}] New client: ${client.clientId}`);
|
|
432
|
-
client.on('update', () => {
|
|
433
|
-
// console.log(`[Client: ${client.clientId}] Updated. Score: ${client.score?.toFixed(1)}`);
|
|
434
|
-
});
|
|
435
|
-
client.on('issue', (issue) => {
|
|
436
|
-
console.warn(`[Client: ${client.clientId}] Issue: ${issue.type} - ${issue.severity} - ${issue.description}`);
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
});
|
|
940
|
+
| Event | Meaning |
|
|
941
|
+
|-------|---------|
|
|
942
|
+
| `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" |
|
|
943
|
+
| `error` | the destination failed |
|
|
944
|
+
| `finish` | `end()` was processed and queued data flushed (before `close`) |
|
|
945
|
+
| `drain` | the buffer drained after backpressure; safe to write more |
|
|
440
946
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
callId,
|
|
447
|
-
clientId,
|
|
448
|
-
timestamp: Date.now(),
|
|
449
|
-
// ... map all relevant stats fields ...
|
|
450
|
-
} as ClientSample; // Ensure all required fields are present
|
|
451
|
-
}
|
|
947
|
+
The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited),
|
|
948
|
+
`end()`s the sink when the client closes, and attaches an `error` listener so a failing sink
|
|
949
|
+
can't crash the process (it also catches throws from `write`/`end`). The application — which
|
|
950
|
+
created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't
|
|
951
|
+
awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**.
|
|
452
952
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
};
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
953
|
+
### Built-in sinks
|
|
954
|
+
|
|
955
|
+
```ts
|
|
956
|
+
import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
|
|
957
|
+
|
|
958
|
+
const observer = new Observer({
|
|
959
|
+
// one ./stats/<callId>__<clientId>.jsonl per client
|
|
960
|
+
createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
|
|
961
|
+
});
|
|
461
962
|
|
|
462
|
-
//
|
|
463
|
-
|
|
963
|
+
// React when a sink is created for a client:
|
|
964
|
+
observer.on('client-sink-created', ({ observedClient, sink }) => {
|
|
965
|
+
sink.on('close', () => {
|
|
966
|
+
// the file is fully flushed and its fd closed — ready to upload, move, etc.
|
|
967
|
+
});
|
|
968
|
+
});
|
|
464
969
|
```
|
|
465
970
|
|
|
466
|
-
|
|
971
|
+
| Export | Signature | Notes |
|
|
972
|
+
|--------|-----------|-------|
|
|
973
|
+
| `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
|
|
974
|
+
| `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
|
|
975
|
+
| `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready |
|
|
976
|
+
| `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` |
|
|
467
977
|
|
|
468
|
-
|
|
469
|
-
|
|
978
|
+
`serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for
|
|
979
|
+
the JSONL sinks (e.g. to redact or reshape before writing).
|
|
470
980
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
981
|
+
### Reading sink-specific info (e.g. the file path)
|
|
982
|
+
|
|
983
|
+
The bus hands you the sink as the base `ClientSampleSink`. To read information specific to a sink
|
|
984
|
+
type — for a file sink, where it was written — **narrow with `instanceof`** and read the sink's
|
|
985
|
+
public fields. `JsonlFileSink` exposes `path`:
|
|
986
|
+
|
|
987
|
+
```ts
|
|
988
|
+
import { JsonlFileSink } from '@observertc/observer-js';
|
|
476
989
|
|
|
477
|
-
|
|
478
|
-
|
|
990
|
+
observer.on('client-sink-created', ({ observedClient, sink }) => {
|
|
991
|
+
if (sink instanceof JsonlFileSink) {
|
|
992
|
+
const { path } = sink; // the file this client's samples go to
|
|
993
|
+
sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready
|
|
994
|
+
}
|
|
995
|
+
});
|
|
479
996
|
```
|
|
480
997
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
998
|
+
The general pattern: each concrete sink exposes whatever it wants as `public readonly` fields, and
|
|
999
|
+
consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same.
|
|
1000
|
+
|
|
1001
|
+
### Writing your own sink
|
|
1002
|
+
|
|
1003
|
+
Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
|
|
1004
|
+
destination (a remote endpoint, a message queue, an object store, …):
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js';
|
|
1008
|
+
|
|
1009
|
+
class HttpSink extends ClientSampleSink {
|
|
1010
|
+
private buffer: ClientSample[] = [];
|
|
1011
|
+
constructor(private readonly url: string) { super(); }
|
|
1012
|
+
|
|
1013
|
+
write(sample: ClientSample): boolean {
|
|
1014
|
+
this.buffer.push(sample); // batch; decide your own backpressure
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
end(): void {
|
|
1018
|
+
fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) })
|
|
1019
|
+
.then(() => this.emit('close')) // signal "destination ready"
|
|
1020
|
+
.catch((err) => this.emit('error', err));
|
|
1021
|
+
}
|
|
493
1022
|
}
|
|
494
|
-
```
|
|
495
1023
|
|
|
496
|
-
|
|
1024
|
+
const createClientSink: ClientSampleSinkFactory = ({ clientId, observedCall }) =>
|
|
1025
|
+
new HttpSink(`https://stats.example.com/${observedCall.callId}/${clientId}`);
|
|
497
1026
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
- **Event Listener Cleanup**: If dynamically adding/removing listeners, ensure they are properly removed (e.g., using `emitter.off()` or `emitter.removeListener()`) to prevent memory leaks, especially for short-lived monitored entities.
|
|
501
|
-
- **`ClientSample` Accuracy**: The quality of monitoring heavily depends on the completeness and correctness of the `ClientSample` data provided. Ensure thorough mapping from `getStats()`.
|
|
502
|
-
- **Update Policies**: Choose update policies carefully based on the desired granularity of updates and performance considerations.
|
|
1027
|
+
const observer = new Observer({ createClientSink });
|
|
1028
|
+
```
|
|
503
1029
|
|
|
504
|
-
|
|
1030
|
+
`observedClient.sink?` exposes the created sink; the `client-sink-created` event delivers it on
|
|
1031
|
+
the bus with full ancestry. `ClientSampleSinkFactory` is
|
|
1032
|
+
`(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`.
|
|
505
1033
|
|
|
506
|
-
|
|
507
|
-
- **No Events / Missing Updates**:
|
|
508
|
-
- Verify `observer.accept()` is being called with correctly formatted `ClientSample` data.
|
|
509
|
-
- Ensure `callId` and `clientId` in samples match expectations.
|
|
510
|
-
- Check if `updatePolicy` and `updateIntervalInMs` are configured as intended.
|
|
511
|
-
- **Debugging**: Utilize `console.log` within event handlers at different levels (Observer, Call, Client) to trace data flow and state changes. Use `appData` to add correlation IDs for easier debugging.
|
|
1034
|
+
## Logging
|
|
512
1035
|
|
|
513
|
-
|
|
1036
|
+
`observer-js` logs through a single, swappable sink. Out of the box it writes `debug` and
|
|
1037
|
+
above to `console` (verbose — install your own sink for production). Funnel everything into your
|
|
1038
|
+
logger:
|
|
514
1039
|
|
|
515
|
-
|
|
516
|
-
|
|
1040
|
+
```ts
|
|
1041
|
+
import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
|
|
517
1042
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
clientId: 'user1',
|
|
525
|
-
appData: { userId: 'u-123', role: 'admin' },
|
|
1043
|
+
setObserverLogger({
|
|
1044
|
+
trace: (m, ...a) => myLogger.trace(`[${m}]`, ...a),
|
|
1045
|
+
debug: (m, ...a) => myLogger.debug(`[${m}]`, ...a),
|
|
1046
|
+
info: (m, ...a) => myLogger.info(`[${m}]`, ...a),
|
|
1047
|
+
warn: (m, ...a) => myLogger.warn(`[${m}]`, ...a),
|
|
1048
|
+
error: (m, ...a) => myLogger.error(`[${m}]`, ...a),
|
|
526
1049
|
});
|
|
527
|
-
// client.appData will be typed as MyClientAppData | undefined
|
|
528
1050
|
```
|
|
529
1051
|
|
|
530
|
-
|
|
1052
|
+
`createLogger(moduleName)` is also exported for your own modules. See
|
|
1053
|
+
**[`docs/logging.md`](./docs/logging.md)** for pino / winston / console recipes, level
|
|
1054
|
+
filtering, per-module routing, and full silencing.
|
|
531
1055
|
|
|
532
|
-
|
|
1056
|
+
---
|
|
1057
|
+
|
|
1058
|
+
## Error-handling philosophy
|
|
1059
|
+
|
|
1060
|
+
The library **warns and degrades; it does not throw** on operational problems:
|
|
1061
|
+
|
|
1062
|
+
- `createObservedCall` / `createObservedClient` on a closed parent → warn + return `undefined`.
|
|
1063
|
+
- Duplicate id → warn + return the **existing** instance.
|
|
1064
|
+
- `accept()` on a closed client → warn + no-op.
|
|
1065
|
+
- Sample missing `callId`/`clientId`, or observer closed → `sample-rejected` event.
|
|
1066
|
+
- A throwing accept-middleware → warn + drop that sample (never crashes `accept()`).
|
|
1067
|
+
|
|
1068
|
+
Therefore `create*` and `getOrCreate*` return `T | undefined`; **guard the result.** The
|
|
1069
|
+
`Middleware` utility's internal invariants (e.g. calling `next()` twice) throw, but those throws
|
|
1070
|
+
are caught by `accept()` and surfaced as a warning.
|
|
1071
|
+
|
|
1072
|
+
---
|
|
1073
|
+
|
|
1074
|
+
## Development & extension guide
|
|
1075
|
+
|
|
1076
|
+
```bash
|
|
1077
|
+
yarn install
|
|
1078
|
+
yarn build # tsup → dist/ (dual ESM .mjs + CJS .js, single entry, .d.ts/.d.mts + sourcemaps)
|
|
1079
|
+
yarn lint # eslint -c .eslintrc.json "src/**/*.ts"
|
|
1080
|
+
yarn typecheck # tsc --noEmit
|
|
1081
|
+
yarn test # jest
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
The build is driven by [`tsup`](https://tsup.egoist.dev) (config in `tsup.config.ts`): a single
|
|
1085
|
+
entry (`src/index.ts`), dual ESM + CommonJS output to `dist/` (`index.mjs` / `index.js`) with
|
|
1086
|
+
`.d.mts` / `.d.ts` types and sourcemaps, targeting Node 20. CI (`.github/workflows/ci.yml`) runs
|
|
1087
|
+
lint + typecheck + **build** + test on every push/PR.
|
|
1088
|
+
|
|
1089
|
+
**Project layout** (`src/`): `Observer.ts`, `ObservedCall.ts`, `ObservedClient.ts`,
|
|
1090
|
+
`ObservedPeerConnection.ts`, the `Observed*` sub-stat classes, `ObserverEvents.ts` (the typed
|
|
1091
|
+
event map + scope types), `detectors/` (`Detector`, `Detectors`), `scores/`, `updaters/`
|
|
1092
|
+
(update-policy strategies), `utils/` (remote-track resolvers), `common/` (`logger`, `utils`,
|
|
1093
|
+
`Middleware`), `schema/` (sample/event/meta types), and `sinks/` (the `ClientSampleSink` base +
|
|
1094
|
+
`JsonlFileSink` / `InMemorySink`, re-exported from the package root).
|
|
1095
|
+
|
|
1096
|
+
**Conventions to follow when developing further:**
|
|
1097
|
+
|
|
1098
|
+
- *Single event bus.* New consumer-facing events go in `ObserverEvents.ts` with an object
|
|
1099
|
+
payload `[<Scope> & { …subject }]`, and are emitted via the component's
|
|
1100
|
+
`_notify(type, { ...this.eventScope, …subject })`. Each component has a precomputed
|
|
1101
|
+
`eventScope` field and a thin `_notify` wrapper around the right emitter. Keep purely internal
|
|
1102
|
+
coordination as **local** EventEmitter events (and remember to `off` them on close).
|
|
1103
|
+
- *Warn, don't throw* on operational/edge conditions; return `undefined` where a value can't be produced.
|
|
1104
|
+
- *Counter-reset-safe deltas.* When computing a delta from a cumulative counter, never emit a
|
|
1105
|
+
negative value (guard `curr >= prev`), to survive counter resets / SSRC reuse.
|
|
1106
|
+
- *Explicit accumulation.* The per-sample metric accumulation in `accept()` is intentionally
|
|
1107
|
+
explicit and not abstracted — match that style.
|
|
1108
|
+
- *Detectors are server-side.* Add cross-client detectors on `ObservedCall.detectors`; don't
|
|
1109
|
+
re-implement client-detectable signals.
|
|
1110
|
+
|
|
1111
|
+
**Recipes:**
|
|
1112
|
+
|
|
1113
|
+
- *Add an event:* add the key + payload to `ObserverEvents`; in the owning component call
|
|
1114
|
+
`this._notify('my-event', { ...this.eventScope, subject })`.
|
|
1115
|
+
- *Add a per-stream metric:* add the field to the relevant `Observed*Rtp`/track class, populate
|
|
1116
|
+
it in its `update()` (reset at the top of `update()` if it's per-tick), and read it from a
|
|
1117
|
+
`*-updated` handler.
|
|
1118
|
+
- *Add a detector:* implement `Detector`, register it on `call-added` via
|
|
1119
|
+
`observedCall.detectors.add(...)`, surface findings with `observedCall.addIssue(...)`.
|
|
1120
|
+
|
|
1121
|
+
---
|
|
533
1122
|
|
|
534
|
-
|
|
1123
|
+
## License
|
|
535
1124
|
|
|
536
|
-
|
|
1125
|
+
Apache-2.0. Part of the [ObserverTC](https://github.com/observertc) ecosystem.
|