@observertc/observer-js 1.0.0-beta.3 → 1.0.0-beta.4
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 +773 -388
- package/dist/index.d.mts +2793 -0
- package/dist/index.mjs +3719 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +21 -14
- 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 -70
- package/lib/ObservedCall.d.ts.map +0 -1
- package/lib/ObservedCall.js +0 -196
- package/lib/ObservedCallEventMonitor.d.ts +0 -107
- package/lib/ObservedCallEventMonitor.d.ts.map +0 -1
- package/lib/ObservedCallEventMonitor.js +0 -321
- 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 -115
- package/lib/ObservedClient.d.ts.map +0 -1
- package/lib/ObservedClient.js +0 -714
- 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 -34
- package/lib/ObservedInboundTrack.d.ts.map +0 -1
- package/lib/ObservedInboundTrack.js +0 -92
- 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 -34
- package/lib/ObservedOutboundTrack.d.ts.map +0 -1
- package/lib/ObservedOutboundTrack.js +0 -65
- package/lib/ObservedPeerConnection.d.ts +0 -207
- package/lib/ObservedPeerConnection.d.ts.map +0 -1
- package/lib/ObservedPeerConnection.js +0 -788
- 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 -62
- package/lib/Observer.d.ts.map +0 -1
- package/lib/Observer.js +0 -160
- package/lib/ObserverEventMonitor.d.ts +0 -141
- package/lib/ObserverEventMonitor.d.ts.map +0 -1
- package/lib/ObserverEventMonitor.js +0 -440
- package/lib/ObserverSummary.d.ts +0 -13
- package/lib/ObserverSummary.d.ts.map +0 -1
- package/lib/ObserverSummary.js +0 -2
- package/lib/Reports.d.ts +0 -85
- package/lib/Reports.d.ts.map +0 -1
- package/lib/Reports.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 -29
- 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 -308
- package/src/ObservedCallEventMonitor.ts +0 -402
- package/src/ObservedCallSummary.ts +0 -22
- package/src/ObservedCertificate.ts +0 -43
- package/src/ObservedClient.ts +0 -883
- 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 -105
- package/src/ObservedMediaPlayout.ts +0 -49
- package/src/ObservedMediaSource.ts +0 -60
- package/src/ObservedOutboundRtp.ts +0 -156
- package/src/ObservedOutboundTrack.ts +0 -82
- package/src/ObservedPeerConnection.ts +0 -1104
- 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 -248
- package/src/ObserverEventMonitor.ts +0 -561
- package/src/ObserverSummary.ts +0 -15
- package/src/Reports.ts +0 -87
- 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 -32
- 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,47 @@
|
|
|
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
|
-
`observer-js` is a Node.js library for monitoring WebRTC
|
|
6
|
+
`observer-js` is a **server-side Node.js library for monitoring WebRTC sessions**. A WebRTC
|
|
7
|
+
application (typically an SFU or a signaling/stats backend) feeds it `ClientSample` objects —
|
|
8
|
+
periodic snapshots of each participant's `RTCPeerConnection.getStats()` output plus
|
|
9
|
+
application events — and `observer-js` maintains a live, in-memory model of every call,
|
|
10
|
+
participant, peer connection, and media stream, derives per-interval and cumulative metrics,
|
|
11
|
+
and emits a single, unified stream of typed events the application can react to.
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
> **Status:** `1.0.0-beta`. The API described here is current and intended to be implemented
|
|
14
|
+
> against directly. This document is written to be self-sufficient: an engineer (or an AI
|
|
15
|
+
> agent) should be able to integrate the library, or develop it further, from this file alone.
|
|
16
|
+
> A companion doc, [`docs/logging.md`](./docs/logging.md), covers logging integration in depth.
|
|
9
17
|
|
|
10
|
-
|
|
18
|
+
> **Packaging:** the package is **ESM-only** and **server-side** (Node.js ≥ 16). Everything —
|
|
19
|
+
> including the built-in file sink — is exported from the single `@observertc/observer-js` entry.
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Table of contents
|
|
24
|
+
|
|
25
|
+
1. [Installation](#installation)
|
|
26
|
+
2. [Mental model](#mental-model)
|
|
27
|
+
3. [Quick start](#quick-start)
|
|
28
|
+
4. [Data flow](#data-flow)
|
|
29
|
+
5. [Entity hierarchy](#entity-hierarchy)
|
|
30
|
+
6. [Ingestion: `accept()`, context & lifecycle](#ingestion-accept-context--lifecycle)
|
|
31
|
+
7. [Update policies](#update-policies)
|
|
32
|
+
8. [The event bus](#the-event-bus) ← the core of the API
|
|
33
|
+
9. [API reference](#api-reference)
|
|
34
|
+
10. [Schema types (`ClientSample`)](#schema-types-clientsample)
|
|
35
|
+
11. [Detectors (server-side extension point)](#detectors-server-side-extension-point)
|
|
36
|
+
12. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
|
|
37
|
+
13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
|
|
38
|
+
14. [Logging](#logging)
|
|
39
|
+
15. [Error-handling philosophy](#error-handling-philosophy)
|
|
40
|
+
16. [Public exports](#public-exports)
|
|
41
|
+
17. [Development & extension guide](#development--extension-guide)
|
|
42
|
+
18. [Not yet implemented / roadmap](#not-yet-implemented--roadmap)
|
|
43
|
+
|
|
44
|
+
---
|
|
21
45
|
|
|
22
46
|
## Installation
|
|
23
47
|
|
|
@@ -27,510 +51,871 @@ npm install @observertc/observer-js
|
|
|
27
51
|
yarn add @observertc/observer-js
|
|
28
52
|
```
|
|
29
53
|
|
|
30
|
-
|
|
54
|
+
**ESM-only, server-side.** The package ships ES Modules (`import`, not `require()`) and targets
|
|
55
|
+
**Node.js ≥ 16**. Use it from ESM code (`"type": "module"`, or `.mjs`), or from TypeScript
|
|
56
|
+
compiled to ESM. Everything is exported from the single `@observertc/observer-js` entry:
|
|
31
57
|
|
|
32
|
-
```
|
|
33
|
-
import { Observer,
|
|
34
|
-
|
|
58
|
+
```ts
|
|
59
|
+
import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
|
|
60
|
+
```
|
|
35
61
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
updateIntervalInMs: 5000, // Update observer every 5 seconds
|
|
40
|
-
defaultCallUpdatePolicy: 'update-on-any-client-updated', // Calls update when any client sends data
|
|
41
|
-
};
|
|
42
|
-
const observer = new Observer(observerConfig);
|
|
62
|
+
Written in TypeScript; ships type declarations alongside the build (`dist/index.d.mts`). Runtime
|
|
63
|
+
dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or
|
|
64
|
+
any transport — see [Logging](#logging).
|
|
43
65
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
66
|
+
> **Note on `require()`.** Being ESM-only, the package can't be loaded with CommonJS
|
|
67
|
+
> `require('@observertc/observer-js')`; consume it with `import` (or `await import()` from a CJS
|
|
68
|
+
> module). If you need a CommonJS build, a dual ESM+CJS output is a small change — ask.
|
|
47
69
|
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
`ClientSample` and friends are re-exported from this package, and are also published as the
|
|
71
|
+
shared schema in [`@observertc/schemas`](https://github.com/observertc/schemas); samples
|
|
72
|
+
produced on the client (e.g. by `@observertc/client-monitor-js`) conform to the same shape.
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
console.warn(`[Client: ${client.clientId}] Issue: ${issue.type} - ${issue.severity} - ${issue.description}`);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
74
|
+
---
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
console.log(
|
|
58
|
-
`[Call: ${call.callId}] Metrics updated. Score: ${call.score?.toFixed(1)}, Clients: ${call.numberOfClients}`
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
76
|
+
## Mental model
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
// (ClientSample typically comes from your application after processing getStats() output)
|
|
65
|
-
function processClientStats(rawStats: any, callId: string, clientId: string) {
|
|
66
|
-
// Transform rawStats into the ClientSample format
|
|
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);
|
|
76
|
-
}
|
|
78
|
+
Five ideas are enough to use the whole library:
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
1. **One ingestion method.** `observer.accept(sample, context?)` is how data gets in.
|
|
81
|
+
Calls, clients, and peer connections are created automatically the first time their id
|
|
82
|
+
appears in a sample.
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
2. **A live entity tree.** `Observer → ObservedCall → ObservedClient → ObservedPeerConnection
|
|
85
|
+
→ {inbound/outbound RTP, tracks, data channels, ICE, codecs, …}`. Every node holds current
|
|
86
|
+
and cumulative metrics and is reachable by id through `Map`s on its parent.
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
3. **One event bus.** Everything worth subscribing to is emitted on the **`Observer`** itself
|
|
89
|
+
(it is an `EventEmitter`). Each event payload is an **object carrying the full ancestry**
|
|
90
|
+
of the entity it came from. You never have to walk the tree to subscribe.
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
4. **Pull or react.** You can read fields off the entities at any time (pull), and/or react to
|
|
93
|
+
events (push). The `*-updated` events fire on each processing tick.
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
5. **Warn, don't throw.** Operational problems (bad config, duplicate ids, closed entities,
|
|
96
|
+
malformed samples) never throw; they warn through the pluggable logger and degrade
|
|
97
|
+
gracefully (returning `undefined` or emitting `sample-rejected`).
|
|
91
98
|
|
|
92
|
-
|
|
99
|
+
---
|
|
93
100
|
|
|
94
|
-
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { Observer, ClientSample } from '@observertc/observer-js';
|
|
105
|
+
|
|
106
|
+
// 1. Create an observer.
|
|
107
|
+
const observer = new Observer({
|
|
108
|
+
// how often the observer aggregates call/client metrics:
|
|
109
|
+
updatePolicy: 'update-on-interval',
|
|
110
|
+
updateIntervalInMs: 5000,
|
|
111
|
+
// default policy applied to calls created automatically by accept():
|
|
112
|
+
defaultCallUpdatePolicy: 'update-on-any-client-updated',
|
|
113
|
+
// optional auto-teardown:
|
|
114
|
+
closeCallIfEmptyForMs: 20_000,
|
|
115
|
+
closeClientIfIdleForMs: 60_000,
|
|
116
|
+
});
|
|
95
117
|
|
|
96
|
-
|
|
118
|
+
// 2. Subscribe on the single bus. Every payload is an object with the ancestry.
|
|
119
|
+
observer.on('call-added', ({ observedCall }) => {
|
|
120
|
+
console.log('new call', observedCall.callId);
|
|
121
|
+
});
|
|
97
122
|
|
|
98
|
-
|
|
123
|
+
observer.on('client-issue', ({ observedClient, issue }) => {
|
|
124
|
+
console.warn(`[${observedClient.clientId}] ${issue.type}`, issue.payload);
|
|
125
|
+
});
|
|
99
126
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
127
|
+
observer.on('peer-connection-updated', ({ observedClient, observedPeerConnection }) => {
|
|
128
|
+
console.log(observedClient.clientId, 'RTT(ms):', observedPeerConnection.currentRttInMs);
|
|
129
|
+
});
|
|
106
130
|
|
|
107
|
-
|
|
131
|
+
observer.on('sample-rejected', ({ reason, sample }) => {
|
|
132
|
+
console.warn('dropped a sample:', reason);
|
|
133
|
+
});
|
|
108
134
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- **`ObservedDataChannel`**: Tracks data channels.
|
|
115
|
-
- **`ObservedTURN`**: Tracks global TURN server usage metrics across the observer.
|
|
135
|
+
// 3. Feed samples. `context` (optional) is transient per-accept data, carried to the
|
|
136
|
+
// `*-updated` events this accept triggers (never written to appData).
|
|
137
|
+
function onClientStats(sample: ClientSample) {
|
|
138
|
+
observer.accept(sample, { studioVersion: '1.2.3' });
|
|
139
|
+
}
|
|
116
140
|
|
|
117
|
-
|
|
141
|
+
// 4. Tear down.
|
|
142
|
+
process.on('SIGINT', () => observer.close());
|
|
143
|
+
```
|
|
118
144
|
|
|
119
|
-
|
|
145
|
+
---
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
- If an `ObservedClient` for `sample.clientId` within that call doesn't exist, it's typically created.
|
|
123
|
-
- Peer connections, streams, and data channels are similarly managed based on IDs in the sample.
|
|
147
|
+
## Data flow
|
|
124
148
|
|
|
125
|
-
|
|
149
|
+
```
|
|
150
|
+
client getStats() ──► ClientSample ──► observer.accept(sample, ctx?)
|
|
151
|
+
│
|
|
152
|
+
┌────────────────────────────────┘
|
|
153
|
+
▼
|
|
154
|
+
get-or-create ObservedCall ──► get-or-create ObservedClient ──► client.accept(sample, ctx)
|
|
155
|
+
│
|
|
156
|
+
per peerConnections[] in the sample
|
|
157
|
+
▼
|
|
158
|
+
get-or-create ObservedPeerConnection
|
|
159
|
+
.accept(pcSample, ctx) updates all sub-stats,
|
|
160
|
+
derives deltas/bitrates/RTT, correlates remote RTP
|
|
161
|
+
│
|
|
162
|
+
metrics roll up: PeerConnection → Client → Call → Observer
|
|
163
|
+
│
|
|
164
|
+
events emitted on the Observer bus ──► your handlers
|
|
165
|
+
```
|
|
126
166
|
|
|
127
|
-
|
|
167
|
+
- A sample **must** have `callId` and `clientId` (the library sets them, or the app does). If
|
|
168
|
+
either is missing, the sample is dropped and `sample-rejected` is emitted.
|
|
169
|
+
- Sub-entities that stop appearing in samples are garbage-collected via a "visited"
|
|
170
|
+
mark-and-sweep on each `ObservedPeerConnection.accept()`, emitting the corresponding
|
|
171
|
+
`*-removed` events.
|
|
128
172
|
|
|
129
|
-
|
|
130
|
-
- Bytes sent/received (audio, video, data)
|
|
131
|
-
- Codec information
|
|
132
|
-
- ICE connection details, TURN usage
|
|
133
|
-
- Stream/track states (muted, enabled)
|
|
134
|
-
- Frame rates, resolutions
|
|
135
|
-
- Bandwidth estimations
|
|
173
|
+
---
|
|
136
174
|
|
|
137
|
-
|
|
175
|
+
## Entity hierarchy
|
|
138
176
|
|
|
139
|
-
|
|
177
|
+
| Class | Created by | Keyed on its parent as | Holds |
|
|
178
|
+
|-------|-----------|------------------------|-------|
|
|
179
|
+
| `Observer` | `new Observer(config?)` | — (root) | `observedCalls: Map<string, ObservedCall>`, global counters, the event bus |
|
|
180
|
+
| `ObservedCall` | `observer.createObservedCall(settings)` / lazily by `accept` | `observedCalls` | `observedClients: Map<string, ObservedClient>`, call-wide metrics, `detectors`, `scoreCalculator` |
|
|
181
|
+
| `ObservedClient` | `call.createObservedClient(settings)` / lazily | `observedClients` | `observedPeerConnections: Map<string, ObservedPeerConnection>`, per-client metrics, `report` |
|
|
182
|
+
| `ObservedPeerConnection` | lazily, from `sample.peerConnections[]` | `observedPeerConnections` | the 15 sub-stat maps below, transport/RTT/bitrate metrics |
|
|
183
|
+
| Sub-stats | lazily, from the `PeerConnectionSample` | maps on the PC | individual WebRTC stat objects |
|
|
140
184
|
|
|
141
|
-
|
|
185
|
+
`ObservedPeerConnection` sub-stat maps (all `public readonly`):
|
|
142
186
|
|
|
143
|
-
|
|
187
|
+
```
|
|
188
|
+
observedCertificates, observedCodecs, observedDataChannels,
|
|
189
|
+
observedIceCandidates, observedIceCandidatesPair, observedIceTransports,
|
|
190
|
+
observedInboundRtps, observedInboundTracks, observedMediaPlayouts,
|
|
191
|
+
observedMediaSources, observedOutboundRtps, observedOutboundTracks,
|
|
192
|
+
observedPeerConnectionTransports, observedRemoteInboundRtps, observedRemoteOutboundRtps
|
|
193
|
+
```
|
|
144
194
|
|
|
145
|
-
|
|
195
|
+
Each sub-stat class (`ObservedInboundRtp`, `ObservedOutboundRtp`, `ObservedInboundTrack`,
|
|
196
|
+
`ObservedOutboundTrack`, `ObservedDataChannel`, `ObservedIceCandidate`,
|
|
197
|
+
`ObservedIceCandidatePair`, `ObservedIceTransport`, `ObservedCertificate`, `ObservedCodec`,
|
|
198
|
+
`ObservedMediaSource`, `ObservedMediaPlayout`, `ObservedPeerConnectionTransport`,
|
|
199
|
+
`ObservedRemoteInboundRtp`, `ObservedRemoteOutboundRtp`) mirrors the corresponding stat
|
|
200
|
+
fields from the schema plus derived fields (deltas, bitrates).
|
|
146
201
|
|
|
147
|
-
|
|
202
|
+
---
|
|
148
203
|
|
|
149
|
-
|
|
204
|
+
## Ingestion: `accept()`, context & lifecycle
|
|
150
205
|
|
|
151
|
-
|
|
206
|
+
### `observer.accept(sample, context?)`
|
|
152
207
|
|
|
153
|
-
|
|
208
|
+
The single entry point. It:
|
|
154
209
|
|
|
155
|
-
|
|
210
|
+
1. drops + emits `sample-rejected` if the observer is closed, or `callId`/`clientId` is missing;
|
|
211
|
+
2. gets or lazily creates the `ObservedCall` and `ObservedClient`;
|
|
212
|
+
3. shallow-merges `context` into the created/looked-up entity `appData`;
|
|
213
|
+
4. delegates to `client.accept(sample, context)`, which fans out to each
|
|
214
|
+
`ObservedPeerConnection.accept(pcSample, context)`.
|
|
156
215
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
|
|
162
|
-
defaultCallUpdateIntervalInMs?: number;
|
|
163
|
-
appData?: AppData; // Custom data for this observer instance
|
|
164
|
-
};
|
|
216
|
+
### `context` (the `AcceptContext`)
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
type AcceptContext = Record<string, unknown>;
|
|
165
220
|
```
|
|
166
221
|
|
|
167
|
-
|
|
222
|
+
A single, optional, free-form object threaded down the whole accept chain
|
|
223
|
+
(`Observer → Client → PeerConnection`). It is **transient request-scoped data** — temporary or
|
|
224
|
+
contextual information the application wants available while an update is processed.
|
|
168
225
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
```
|
|
226
|
+
`context` is **never written to `appData`** and is **not stored** on any entity. The two are
|
|
227
|
+
deliberately distinct:
|
|
172
228
|
|
|
173
|
-
-
|
|
229
|
+
- **`appData`** — application-assigned extra info that identifies/decorates an entity, fixed at
|
|
230
|
+
creation (via `settings.appData` or the `createCallAppData` / `createClientAppData` factories),
|
|
231
|
+
or assigned by the app on the `*-added` events. The library never changes it.
|
|
232
|
+
- **`context`** — passed per `accept()`, may differ on every call, and is carried straight
|
|
233
|
+
through to the `*-updated` events that the `accept()` triggers, then discarded.
|
|
174
234
|
|
|
175
|
-
|
|
235
|
+
`client-updated` and `peer-connection-updated` carry the exact context of that sample;
|
|
236
|
+
`call-updated` carries the context of the client `accept()` that drove the call update (absent
|
|
237
|
+
for interval- or teardown-driven call updates). When no context is given, the field is absent.
|
|
176
238
|
|
|
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.
|
|
239
|
+
### Get-or-create helpers
|
|
182
240
|
|
|
183
|
-
|
|
241
|
+
If you want to create/configure entities yourself before/without samples:
|
|
184
242
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
- `close(): void`: Cleans up resources for the observer and all its calls.
|
|
190
|
-
- `createEventMonitor<CTX>(ctx?: CTX): ObserverEventMonitor<CTX>`: For contextual event listening.
|
|
243
|
+
```ts
|
|
244
|
+
const call = observer.getOrCreateObservedCall({ callId, appData }); // ObservedCall | undefined
|
|
245
|
+
const client = call?.getOrCreateObservedClient({ clientId, appData }); // ObservedClient | undefined
|
|
246
|
+
```
|
|
191
247
|
|
|
192
|
-
|
|
248
|
+
These return `undefined` (and warn) when the parent is closed; `createObservedCall`/
|
|
249
|
+
`createObservedClient` return the **existing** instance (and warn) if the id already exists.
|
|
193
250
|
|
|
194
|
-
|
|
195
|
-
- `'call-updated' (call: ObservedCall)`
|
|
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' ()`
|
|
251
|
+
### Automatic teardown
|
|
202
252
|
|
|
203
|
-
|
|
253
|
+
- `closeClientIfIdleForMs` — a client with no sample for this long auto-closes.
|
|
254
|
+
- `closeCallIfEmptyForMs` — a call with zero clients for this long auto-closes.
|
|
255
|
+
- Closing cascades down (call → clients → peer connections → sub-stats), unsubscribing
|
|
256
|
+
listeners and emitting the `*-closed` / `*-removed` events.
|
|
204
257
|
|
|
205
|
-
|
|
258
|
+
---
|
|
206
259
|
|
|
207
|
-
|
|
260
|
+
## Update policies
|
|
208
261
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'update-on-interval';
|
|
214
|
-
updateIntervalInMs?: number; // Used if updatePolicy is 'update-on-interval'
|
|
215
|
-
remoteTrackResolvePolicy?: 'mediasoup-sfu'; // For specific SFU integration
|
|
216
|
-
};
|
|
217
|
-
```
|
|
262
|
+
"Update" means *recompute aggregated metrics and emit the `*-updated` event* at that level.
|
|
263
|
+
Both the observer and each call have a configurable trigger. The `update-on-interval` policy
|
|
264
|
+
**requires** an interval and this is enforced at compile time (a discriminated union); at
|
|
265
|
+
runtime a missing interval warns and falls back rather than throwing.
|
|
218
266
|
|
|
219
|
-
**
|
|
267
|
+
**Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
|
|
220
268
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
- `
|
|
226
|
-
- Counters: `totalAddedClients`, `totalRemovedClients`, `numberOfIssues`, RTT buckets, total bytes sent/received (audio/video/data), etc.
|
|
269
|
+
| Policy | Triggers `observer.update()` when… |
|
|
270
|
+
|--------|-------------------------------------|
|
|
271
|
+
| `update-on-any-call-updated` | any call updates |
|
|
272
|
+
| `update-when-all-call-updated` | every call has updated since the last observer update |
|
|
273
|
+
| `update-on-interval` | a timer fires (`updateIntervalInMs` required) |
|
|
227
274
|
|
|
228
|
-
**
|
|
275
|
+
**Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
|
|
276
|
+
`ObserverConfig.defaultCallUpdatePolicy`):
|
|
229
277
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
- `
|
|
278
|
+
| Policy | Triggers `call.update()` when… |
|
|
279
|
+
|--------|--------------------------------|
|
|
280
|
+
| `update-on-any-client-updated` | any client in the call updates |
|
|
281
|
+
| `update-when-all-client-updated` | every client has updated since the last call update |
|
|
282
|
+
| `update-on-interval` | a timer fires (`updateIntervalInMs` required) |
|
|
235
283
|
|
|
236
|
-
|
|
284
|
+
---
|
|
237
285
|
|
|
238
|
-
|
|
239
|
-
- `'empty' ()`: When the last client leaves.
|
|
240
|
-
- `'not-empty' ()`: When the first client joins an empty call.
|
|
241
|
-
- `'update' ()`
|
|
242
|
-
- `'close' ()`
|
|
286
|
+
## The event bus
|
|
243
287
|
|
|
244
|
-
|
|
288
|
+
This is the primary API. **Subscribe on the `Observer` instance** — it is the single emitter
|
|
289
|
+
for the entire hierarchy. The `ObservedCall` / `ObservedClient` / `ObservedPeerConnection`
|
|
290
|
+
objects are themselves `EventEmitter`s too, but those local events are reserved for internal
|
|
291
|
+
lifecycle/teardown wiring (see [Local lifecycle events](#local-lifecycle-events)); application
|
|
292
|
+
code should use the Observer bus.
|
|
245
293
|
|
|
246
|
-
|
|
294
|
+
### Payload shape: ancestry + subject
|
|
247
295
|
|
|
248
|
-
**
|
|
296
|
+
Every Observer event delivers exactly **one argument: a payload object**. The payload always
|
|
297
|
+
contains the ancestry from the observer down to the entity that raised it, plus any event-
|
|
298
|
+
specific subject:
|
|
249
299
|
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
};
|
|
300
|
+
```ts
|
|
301
|
+
type ObserverEventBase = { observer: Observer };
|
|
302
|
+
type ObservedCallScope = ObserverEventBase & { observedCall: ObservedCall };
|
|
303
|
+
type ObservedClientScope = ObservedCallScope & { observedClient: ObservedClient };
|
|
304
|
+
type ObservedPeerConnectionScope = ObservedClientScope & { observedPeerConnection: ObservedPeerConnection };
|
|
256
305
|
```
|
|
257
306
|
|
|
258
|
-
**
|
|
307
|
+
So a peer-connection-level event hands you the observer, call, client, **and** peer connection:
|
|
259
308
|
|
|
260
|
-
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
309
|
+
```ts
|
|
310
|
+
observer.on('inbound-rtp-added', ({ observer, observedCall, observedClient, observedPeerConnection, observedInboundRtp }) => {
|
|
311
|
+
// all five are present and correctly typed
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`observer.on/off/once/emit` are fully typed against the event map — the handler argument is
|
|
316
|
+
inferred per event name.
|
|
317
|
+
|
|
318
|
+
### Event catalogue
|
|
319
|
+
|
|
320
|
+
All payloads include the ancestry for their level (above). The **Extra** column lists the
|
|
321
|
+
additional field(s) on top of that scope.
|
|
322
|
+
|
|
323
|
+
#### Observer level — scope `{ observer }`
|
|
324
|
+
|
|
325
|
+
| Event | Extra payload | Fires when |
|
|
326
|
+
|-------|---------------|-----------|
|
|
327
|
+
| `observer-updated` | — | `observer.update()` ran (per the observer update policy) |
|
|
328
|
+
| `observer-closed` | — | `observer.close()` |
|
|
329
|
+
| `sample-rejected` | `{ reason: 'observer-closed' \| 'missing-callId' \| 'missing-clientId', sample: ClientSample }` | a sample was dropped by `accept()` |
|
|
330
|
+
|
|
331
|
+
#### Call level — scope `{ observer, observedCall }`
|
|
332
|
+
|
|
333
|
+
| Event | Extra | Fires when |
|
|
334
|
+
|-------|-------|-----------|
|
|
335
|
+
| `call-added` | — | a call is created |
|
|
336
|
+
| `call-updated` | `{ context?: AcceptContext }` | `call.update()` ran |
|
|
337
|
+
| `call-closed` | — | the call closed |
|
|
338
|
+
| `call-empty` | — | last client left the call |
|
|
339
|
+
| `call-not-empty` | — | first client joined a previously-empty call |
|
|
340
|
+
| `call-issue` | `{ issue: ClientIssue }` | `call.addIssue(...)` (server-side detector finding) |
|
|
341
|
+
|
|
342
|
+
#### Client level — scope `{ observer, observedCall, observedClient }`
|
|
343
|
+
|
|
344
|
+
| Event | Extra | Fires when |
|
|
345
|
+
|-------|-------|-----------|
|
|
346
|
+
| `client-added` | — | a client is created |
|
|
347
|
+
| `client-sink-created` | `{ sink: ClientSampleSink }` | a per-client sink was created (only when `createClientSink` returns one); fires right after `client-added` |
|
|
348
|
+
| `client-updated` | `{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext }` | the client processed a sample |
|
|
349
|
+
| `client-closed` | — | the client closed |
|
|
350
|
+
| `client-joined` | — | first `CLIENT_JOINED` event seen |
|
|
351
|
+
| `client-left` | — | `CLIENT_LEFT` seen (or inferred on close) |
|
|
352
|
+
| `client-rejoined` | `{ timestamp: number }` | a later `CLIENT_JOINED` after an earlier join |
|
|
353
|
+
| `client-issue` | `{ issue: ClientIssue }` | a client-reported issue arrived, or `client.addIssue(...)` |
|
|
354
|
+
| `client-metadata` | `{ metaData: ClientMetaData }` | a client meta item arrived |
|
|
355
|
+
| `client-extension-stats` | `{ extensionStats: ExtensionStat }` | an app-defined extension stat arrived |
|
|
356
|
+
| `client-event` | `{ event: ClientEvent }` | any client event was processed |
|
|
357
|
+
| `client-track-report` | `{ report: TrackReport }` | a track was removed; final per-track report |
|
|
358
|
+
|
|
359
|
+
#### Peer-connection level — scope `{ observer, observedCall, observedClient, observedPeerConnection }`
|
|
360
|
+
|
|
361
|
+
| Event | Extra | Notes |
|
|
362
|
+
|-------|-------|-------|
|
|
363
|
+
| `peer-connection-added` / `peer-connection-closed` | — | lifecycle of the PC |
|
|
364
|
+
| `peer-connection-updated` | `{ context?: AcceptContext }` | the PC processed a sample |
|
|
365
|
+
| `ice-connection-state-changed` / `ice-gathering-state-changed` / `connection-state-changed` | `{ state: string }` | driven by client events |
|
|
366
|
+
| `selected-candidate-pair-changed` | — | *declared; not currently emitted* |
|
|
367
|
+
| `inbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedInboundTrack }` | |
|
|
368
|
+
| `outbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedOutboundTrack }` | |
|
|
369
|
+
| `inbound-rtp-added` / `-updated` / `-removed` | `{ observedInboundRtp }` | `-updated` fires every tick |
|
|
370
|
+
| `outbound-rtp-added` / `-updated` / `-removed` | `{ observedOutboundRtp }` | `-updated` fires every tick |
|
|
371
|
+
| `remote-inbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteInboundRtp }` | |
|
|
372
|
+
| `remote-outbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteOutboundRtp }` | |
|
|
373
|
+
| `data-channel-added` / `-updated` / `-removed` | `{ observedDataChannel }` | |
|
|
374
|
+
| `ice-candidate-added` / `-updated` / `-removed` | `{ observedIceCandidate }` | |
|
|
375
|
+
| `ice-candidate-pair-added` / `-updated` / `-removed` | `{ observedIceCandidatePair }` | |
|
|
376
|
+
| `ice-transport-added` / `-updated` / `-removed` | `{ observedIceTransport }` | |
|
|
377
|
+
| `codec-added` / `-updated` / `-removed` | `{ observedCodec }` | |
|
|
378
|
+
| `media-source-added` / `-updated` / `-removed` | `{ observedMediaSource }` | |
|
|
379
|
+
| `media-playout-added` / `-updated` / `-removed` | `{ observedMediaPlayout }` | |
|
|
380
|
+
| `peer-connection-transport-added` / `-updated` / `-removed` | `{ observedPeerConnectionTransport }` | |
|
|
381
|
+
| `certificate-added` / `-updated` / `-removed` | `{ observedCertificate }` | |
|
|
382
|
+
|
|
383
|
+
> **Volume note.** The `*-updated` sub-stat events fire on every peer-connection `accept()`
|
|
384
|
+
> (i.e. per sample, per stream). For high-throughput servers, subscribe only to what you need,
|
|
385
|
+
> or read fields off the entities on `client-updated` / `call-updated` instead.
|
|
386
|
+
|
|
387
|
+
### Local lifecycle events
|
|
388
|
+
|
|
389
|
+
These remain on the individual entities (not the bus), for teardown/coordination. You can
|
|
390
|
+
listen to them, but prefer the bus equivalents above for application logic.
|
|
391
|
+
|
|
392
|
+
| Entity | Local events |
|
|
393
|
+
|--------|--------------|
|
|
394
|
+
| `ObservedCall` | `update`, `newclient`, `empty`, `not-empty`, `close` |
|
|
395
|
+
| `ObservedClient` | `update` (`sample`, `elapsedTimeInMs`), `close`, `joined`, `left` |
|
|
396
|
+
| `ObservedPeerConnection` | `removed-inbound-track`, `removed-outbound-track`, `close` |
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## API reference
|
|
401
|
+
|
|
402
|
+
### `Observer`
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
new Observer<AppData>(config?: ObserverConfig<AppData>)
|
|
406
|
+
|
|
407
|
+
type ObserverConfig<AppData = Record<string, unknown>> =
|
|
408
|
+
( { updatePolicy: 'update-on-interval'; updateIntervalInMs: number }
|
|
409
|
+
| { updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated'; updateIntervalInMs?: number }
|
|
410
|
+
) & {
|
|
411
|
+
defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
|
|
412
|
+
defaultCallUpdateIntervalInMs?: number;
|
|
413
|
+
appData?: AppData;
|
|
414
|
+
closeClientIfIdleForMs?: number;
|
|
415
|
+
closeCallIfEmptyForMs?: number;
|
|
416
|
+
// appData factories — run when an entity is created without explicit appData
|
|
417
|
+
// (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
|
|
418
|
+
createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
|
|
419
|
+
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
|
|
420
|
+
// sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
|
|
421
|
+
createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
|
|
422
|
+
};
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
**appData factories.** Instead of pre-creating a call/client (or assigning on `call-added` /
|
|
426
|
+
`client-added`) just to enrich its `appData`, register a factory once. It runs in the entity's
|
|
427
|
+
constructor whenever it's created without an explicit `settings.appData` — including the lazy
|
|
428
|
+
creation inside `accept()`. The `client` factory receives the already-created parent
|
|
429
|
+
`observedCall`, so it can derive fields from it. `appData` is application-owned and is never
|
|
430
|
+
modified by the `accept()` context.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
const observer = new Observer({
|
|
434
|
+
createCallAppData: ({ callId }) => ({ callId, startedAt: Date.now(), region: 'eu' }),
|
|
435
|
+
createClientAppData: ({ clientId, observedCall }) => ({ clientId, region: observedCall.appData.region }),
|
|
436
|
+
});
|
|
437
|
+
```
|
|
268
438
|
|
|
269
|
-
|
|
439
|
+
Key members:
|
|
270
440
|
|
|
271
|
-
- `accept(sample: ClientSample): void
|
|
272
|
-
- `
|
|
273
|
-
- `
|
|
274
|
-
- `
|
|
441
|
+
- `accept(sample: ClientSample, context?: AcceptContext): void`
|
|
442
|
+
- `getObservedCall<T>(callId): ObservedCall<T> | undefined`
|
|
443
|
+
- `createObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
444
|
+
- `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
|
|
445
|
+
- `update(): void` — force an aggregation/`observer-updated` tick
|
|
275
446
|
- `close(): void`
|
|
276
|
-
- `
|
|
447
|
+
- `readonly observedCalls: Map<string, ObservedCall>`
|
|
448
|
+
- `readonly observedTURN: ObservedTURN`
|
|
449
|
+
- `get appData()`, `get numberOfCalls()`
|
|
450
|
+
- counters: `numberOfClients`, `numberOfClientsUsingTurn`, `numberOfInboundRtpStreams`,
|
|
451
|
+
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `numberOfPeerConnections`,
|
|
452
|
+
`totalAddedCall`, `totalRemovedCall`, `closed`
|
|
453
|
+
- `on/off/once/emit` typed against the [event map](#event-catalogue)
|
|
454
|
+
|
|
455
|
+
### `ObservedCall`
|
|
456
|
+
|
|
457
|
+
```ts
|
|
458
|
+
type ObservedCallSettings<AppData = Record<string, unknown>> =
|
|
459
|
+
( { updatePolicy: 'update-on-interval'; updateIntervalInMs: number }
|
|
460
|
+
| { updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated'; updateIntervalInMs?: number }
|
|
461
|
+
) & {
|
|
462
|
+
callId: string;
|
|
463
|
+
appData?: AppData;
|
|
464
|
+
remoteTrackResolvePolicy?: 'p2p' | 'mediasoup-sfu' | 'none';
|
|
465
|
+
closeCallIfEmptyForMs?: number;
|
|
466
|
+
};
|
|
467
|
+
```
|
|
277
468
|
|
|
278
|
-
|
|
469
|
+
Key members:
|
|
470
|
+
|
|
471
|
+
- `readonly callId: string`, `appData: AppData`
|
|
472
|
+
- `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
|
|
473
|
+
- `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
|
|
474
|
+
- `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
|
|
475
|
+
- `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
|
|
476
|
+
- `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
|
|
477
|
+
- `remoteTrackResolver?: RemoteTrackResolver`
|
|
478
|
+
- aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
|
|
479
|
+
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
|
|
480
|
+
`clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
|
|
481
|
+
- `update()`, `close()`
|
|
482
|
+
|
|
483
|
+
### `ObservedClient`
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
type ObservedClientSettings<AppData = Record<string, unknown>> = {
|
|
487
|
+
clientId: string;
|
|
488
|
+
appData?: AppData;
|
|
489
|
+
closeClientIfIdleForMs?: number;
|
|
490
|
+
};
|
|
491
|
+
```
|
|
279
492
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
- `
|
|
283
|
-
- `
|
|
284
|
-
- `
|
|
285
|
-
-
|
|
493
|
+
Key members:
|
|
494
|
+
|
|
495
|
+
- `readonly clientId: string`, `appData: AppData`, `readonly call: ObservedCall`
|
|
496
|
+
- `readonly observedPeerConnections: Map<string, ObservedPeerConnection>`
|
|
497
|
+
- `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error`
|
|
498
|
+
- **Injection API** (queue app data to be merged into the next sample processing):
|
|
499
|
+
`injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`,
|
|
500
|
+
`injectExtensionStat(ExtensionStat)`, `injectAttachment(key, value)`
|
|
501
|
+
- **Direct add API** (process immediately): `addIssue(ClientIssue)`, `addMetadata(ClientMetaData)`,
|
|
502
|
+
`addExtensionStats(ExtensionStat)`
|
|
503
|
+
- Metrics (current/derived): `currentAvgRttInMs?`, `currentMinRttInMs?`, `currentMaxRttInMs?`,
|
|
504
|
+
`receivingAudioBitrate`, `receivingVideoBitrate`, `sendingAudioBitrate`, `sendingVideoBitrate`,
|
|
505
|
+
`usingTURN`, `usingTCP`, `availableIncomingBitrate`, `availableOutgoingBitrate`
|
|
506
|
+
- Counts: `numberOfInboundRtpStreams`, `numberOfOutboundRtpStreams`, `numberOfInbundTracks`,
|
|
507
|
+
`numberOfOutboundTracks`, `numberOfDataChannels`, `numberOfPeerConnections`
|
|
508
|
+
- Per-tick deltas: `deltaReceivedAudioBytes`, `deltaSentAudioBytes`, … (see source for the full set)
|
|
509
|
+
- Lifecycle: `joinedAt?`, `leftAt?`, `closedAt?`, `closed`, `get score()`
|
|
510
|
+
- Metadata: `browser?`, `engine?`, `platform?`, `operationSystem?`, `mediaDevices`, `mediaConstraints`
|
|
511
|
+
- `readonly report: ClientReport` — cumulative report (byte/packet totals + RTT & score distributions)
|
|
512
|
+
- `accept(sample, context?)`, `close()`
|
|
513
|
+
|
|
514
|
+
### `ObservedPeerConnection`
|
|
515
|
+
|
|
516
|
+
Key members:
|
|
517
|
+
|
|
518
|
+
- `readonly peerConnectionId: string`, `readonly client: ObservedClient`, `appData?`
|
|
519
|
+
- The 15 `observed*` sub-stat `Map`s (listed [above](#entity-hierarchy)), plus array getters:
|
|
520
|
+
`codecs`, `inboundRtps`, `outboundRtps`, `remoteInboundRtps`, `remoteOutboundRtps`,
|
|
521
|
+
`mediaSources`, `mediaPlayouts`, `dataChannels`, `peerConnectionTransports`, `iceTransports`,
|
|
522
|
+
`iceCandidates`, `iceCandidatePairs`, `certificates`, `selectedIceCandidatePairs`,
|
|
523
|
+
`selectedIceCandiadtePairForTurn`
|
|
524
|
+
- State: `connectionState?`, `iceConnectionState?`, `iceGatheringState?`, `usingTURN`, `usingTCP`
|
|
525
|
+
- Metrics: `currentRttInMs?`, `currentJitter?`, `availableIncomingBitrate`,
|
|
526
|
+
`availableOutgoingBitrate`, sending/receiving bitrates, packet rates, and `total*` / `delta*`
|
|
527
|
+
byte/packet counters
|
|
528
|
+
- `accept(pcSample, context?)`, `close()`, `get score()`
|
|
529
|
+
|
|
530
|
+
**Remote-RTP correlation (derived).** During `accept()`, receiver/sender reports are linked
|
|
531
|
+
to the local streams by `remoteId` (fallback SSRC) and surfaced as fields:
|
|
532
|
+
|
|
533
|
+
- on `ObservedOutboundRtp`: `remoteRttInMs?`, `remoteFractionLost?`, `remoteJitter?`, `remotePacketsLost?`
|
|
534
|
+
- on `ObservedInboundRtp`: `remoteRttInMs?`, `remoteBytesSent?`, `remotePacketsSent?`, `remoteTimestamp?`
|
|
535
|
+
|
|
536
|
+
These are reset each tick and only set when the matching remote report is present.
|
|
286
537
|
|
|
287
|
-
|
|
538
|
+
---
|
|
288
539
|
|
|
289
|
-
|
|
540
|
+
## Schema types (`ClientSample`)
|
|
541
|
+
|
|
542
|
+
The shape of an accepted sample (re-exported from this package; identical to
|
|
543
|
+
`@observertc/schemas`). Only the top level is shown — each stat object mirrors the standard
|
|
544
|
+
WebRTC `getStats()` dictionaries plus a few extensions.
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
type ClientSample = {
|
|
548
|
+
timestamp: number; // client wall-clock (ms epoch)
|
|
549
|
+
callId?: string; // set by you or the library
|
|
550
|
+
clientId?: string; // set by you or the library
|
|
551
|
+
score?: number; // optional client-computed score (0..5)
|
|
552
|
+
attachments?: Record<string, unknown>;
|
|
553
|
+
peerConnections?: PeerConnectionSample[];
|
|
554
|
+
clientEvents?: ClientEvent[];
|
|
555
|
+
clientIssues?: ClientIssue[];
|
|
556
|
+
clientMetaItems?: ClientMetaData[];
|
|
557
|
+
extensionStats?: ExtensionStat[];
|
|
558
|
+
};
|
|
290
559
|
|
|
291
|
-
|
|
292
|
-
|
|
560
|
+
type PeerConnectionSample = {
|
|
561
|
+
peerConnectionId: string;
|
|
562
|
+
attachments?: Record<string, unknown>; // e.g. { direction: 'send'|'recv', producerId, consumerId, label }
|
|
563
|
+
score?: number;
|
|
564
|
+
inboundTracks?; outboundTracks?;
|
|
565
|
+
codecs?;
|
|
566
|
+
inboundRtps?; remoteInboundRtps?;
|
|
567
|
+
outboundRtps?; remoteOutboundRtps?;
|
|
568
|
+
mediaSources?; mediaPlayouts?;
|
|
569
|
+
peerConnectionTransports?; dataChannels?;
|
|
570
|
+
iceTransports?; iceCandidates?; iceCandidatePairs?;
|
|
571
|
+
certificates?;
|
|
572
|
+
};
|
|
293
573
|
|
|
294
|
-
|
|
574
|
+
type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
575
|
+
type ClientIssue = { type: string; payload?: string; timestamp?: number }; // also used for call-issue
|
|
576
|
+
type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
|
|
577
|
+
type ExtensionStat = { type: string; payload?: string };
|
|
578
|
+
```
|
|
295
579
|
|
|
296
|
-
|
|
580
|
+
`payload` fields are JSON strings; the library parses the ones it understands.
|
|
297
581
|
|
|
298
|
-
|
|
582
|
+
**`ClientEventTypes`** (enum of known `event.type` values): `CLIENT_JOINED`, `CLIENT_LEFT`,
|
|
583
|
+
`PEER_CONNECTION_OPENED/CLOSED/STATE_CHANGED`, `MEDIA_TRACK_ADDED/REMOVED/MUTED/UNMUTED/RESUMED`,
|
|
584
|
+
`ICE_GATHERING_STATE_CHANGED`, `ICE_CONNECTION_STATE_CHANGED`, `DATA_CHANNEL_OPEN/CLOSED/ERROR`,
|
|
585
|
+
`NEGOTIATION_NEEDED`, `SIGNALING_STATE_CHANGE`, `ICE_CANDIDATE`, `ICE_CANDIDATE_ERROR`, and the
|
|
586
|
+
mediasoup set `PRODUCER_*` / `CONSUMER_*` / `DATA_PRODUCER_*` / `DATA_CONSUMER_*`.
|
|
299
587
|
|
|
300
|
-
|
|
588
|
+
**`ClientMetaTypes`** (enum of known meta `type` values): `MEDIA_CONSTRAINT`, `MEDIA_DEVICE`,
|
|
589
|
+
`MEDIA_DEVICES_SUPPORTED_CONSTRAINTS`, `USER_MEDIA_ERROR`, `LOCAL_SDP`, `OPERATION_SYSTEM`,
|
|
590
|
+
`ENGINE`, `PLATFORM`, `BROWSER`.
|
|
301
591
|
|
|
302
|
-
|
|
592
|
+
`Reports` exported: `ClientReport` (cumulative per-client totals + RTT/score distributions) and
|
|
593
|
+
`TrackReport` (per-track final report, delivered on `client-track-report`).
|
|
303
594
|
|
|
304
|
-
|
|
595
|
+
---
|
|
305
596
|
|
|
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)
|
|
597
|
+
## Detectors (server-side extension point)
|
|
322
598
|
|
|
323
|
-
|
|
599
|
+
`observer-js` deliberately ships **no built-in detectors**. Per-client signals — packet loss,
|
|
600
|
+
jitter, RTT, freezes, etc. — are already detectable on the client and arrive on samples as
|
|
601
|
+
`clientIssues` (surfaced via `client-issue`). Server-side detection should focus on what only
|
|
602
|
+
the server can see by **correlating data across the clients of a call**.
|
|
324
603
|
|
|
325
|
-
|
|
604
|
+
The hook lives on **`ObservedCall`**:
|
|
326
605
|
|
|
327
|
-
|
|
606
|
+
```ts
|
|
607
|
+
import { Observer, Detector } from '@observertc/observer-js';
|
|
328
608
|
|
|
329
|
-
|
|
609
|
+
class MyCrossClientDetector implements Detector {
|
|
610
|
+
readonly name = 'my-detector';
|
|
611
|
+
constructor(private readonly call /* : ObservedCall */) {}
|
|
612
|
+
update() { // called on every call.update()
|
|
613
|
+
// …inspect this.call.observedClients across participants…
|
|
614
|
+
if (/* condition only visible server-side */ false) {
|
|
615
|
+
this.call.addIssue({ type: this.name, payload: JSON.stringify({ /* … */ }), timestamp: Date.now() });
|
|
616
|
+
// → emitted on the bus as 'call-issue'
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
330
620
|
|
|
331
|
-
|
|
621
|
+
const observer = new Observer();
|
|
622
|
+
observer.on('call-added', ({ observedCall }) => {
|
|
623
|
+
observedCall.detectors.add(new MyCrossClientDetector(observedCall));
|
|
624
|
+
});
|
|
625
|
+
observer.on('call-issue', ({ observedCall, issue }) => { /* react */ });
|
|
626
|
+
```
|
|
332
627
|
|
|
333
|
-
|
|
334
|
-
- `'update-when-all-call-updated'`: Observer updates after all its calls update. (Default)
|
|
335
|
-
- `'update-on-interval'`: Observer updates at `ObserverConfig.updateIntervalInMs`.
|
|
628
|
+
`Detector` interface and the registry:
|
|
336
629
|
|
|
337
|
-
|
|
630
|
+
```ts
|
|
631
|
+
interface Detector { readonly name: string; update(): void; }
|
|
338
632
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
633
|
+
class Detectors {
|
|
634
|
+
add(d: Detector): void;
|
|
635
|
+
remove(d: Detector): void;
|
|
636
|
+
clear(): void;
|
|
637
|
+
update(): void; // called by ObservedCall.update(); guards each detector in try/catch
|
|
638
|
+
get listOfNames(): string[];
|
|
639
|
+
}
|
|
640
|
+
```
|
|
342
641
|
|
|
343
|
-
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## Remote track resolution (mediasoup / SFU)
|
|
344
645
|
|
|
345
|
-
|
|
346
|
-
- `
|
|
347
|
-
- `
|
|
646
|
+
In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
|
|
647
|
+
tracks. To correlate them server-side, set `remoteTrackResolvePolicy: 'mediasoup-sfu'` on the
|
|
648
|
+
call settings. The built-in `MediasoupRemoteTrackResolver` subscribes to the bus (filtered to
|
|
649
|
+
its call) and maps producer↔consumer using track `attachments` (`producerId`, `consumerId`):
|
|
348
650
|
|
|
349
|
-
|
|
651
|
+
```ts
|
|
652
|
+
const call = observer.createObservedCall({ callId, remoteTrackResolvePolicy: 'mediasoup-sfu' });
|
|
653
|
+
// later, given an inbound track:
|
|
654
|
+
const source = inboundTrack.getRemoteOutboundTrack(); // the producing ObservedOutboundTrack
|
|
655
|
+
const consumers = outboundTrack.getRemoteInboundTracks(); // the consuming ObservedInboundTrack[]
|
|
656
|
+
```
|
|
350
657
|
|
|
351
|
-
|
|
658
|
+
Custom topologies can implement the `RemoteTrackResolver` interface and assign
|
|
659
|
+
`call.remoteTrackResolver`:
|
|
352
660
|
|
|
353
|
-
```
|
|
354
|
-
interface
|
|
355
|
-
|
|
356
|
-
|
|
661
|
+
```ts
|
|
662
|
+
interface RemoteTrackResolver {
|
|
663
|
+
resolveRemoteOutboundTrack(inboundTrack: ObservedInboundTrack): ObservedOutboundTrack | undefined;
|
|
664
|
+
resolveRemoteInboundTracks(outboundTrack: ObservedOutboundTrack): ObservedInboundTrack[] | undefined;
|
|
357
665
|
}
|
|
358
|
-
const call = observer.createObservedCall<MyCallAppData>({
|
|
359
|
-
callId: 'call1',
|
|
360
|
-
appData: { meetingTitle: 'Team Sync', scheduledAt: new Date() },
|
|
361
|
-
});
|
|
362
|
-
console.log(call.appData?.meetingTitle);
|
|
363
666
|
```
|
|
364
667
|
|
|
365
|
-
|
|
668
|
+
The application is expected to put `direction` (`'send'`/`'recv'`), `producerId`, `consumerId`,
|
|
669
|
+
and `label` into `PeerConnectionSample.attachments` / track `attachments`.
|
|
366
670
|
|
|
367
|
-
|
|
671
|
+
---
|
|
368
672
|
|
|
369
|
-
|
|
673
|
+
## Sinks (per-client sample persistence)
|
|
370
674
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
- **Use Cases**:
|
|
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`).
|
|
675
|
+
A **sink** receives the samples a client accepts — for archival, streaming, or later offline
|
|
676
|
+
replay. Each `ObservedClient` gets its **own** sink, produced by the
|
|
677
|
+
`ObserverConfig.createClientSink` factory when the client is created (return `undefined` for no
|
|
678
|
+
sink). The client pushes every accepted sample to its sink, and `end()`s it on close.
|
|
379
679
|
|
|
380
|
-
|
|
680
|
+
### The `ClientSampleSink` base class
|
|
381
681
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
- **Use Cases**:
|
|
387
|
-
- Storing temporary state calculated by one part of your application to be read by another (e.g., a custom issue detector plugin might attach intermediate findings).
|
|
388
|
-
- Caching results of expensive computations related to the entity.
|
|
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.
|
|
682
|
+
`ClientSampleSink` is an **abstract base class** (a typed `EventEmitter`). You create a sink by
|
|
683
|
+
**subclassing it** and implementing `write` and `end`. It is **object-mode**: `write` receives
|
|
684
|
+
the `ClientSample` *object*, so each sink decides how (or whether) to serialize it — JSON line,
|
|
685
|
+
protobuf, a remote POST body, an in-memory push, etc.
|
|
391
686
|
|
|
392
|
-
|
|
687
|
+
```ts
|
|
688
|
+
import { ClientSampleSink, ClientSample } from '@observertc/observer-js';
|
|
393
689
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
690
|
+
abstract class ClientSampleSink /* extends EventEmitter */ {
|
|
691
|
+
abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure
|
|
692
|
+
abstract end(): void; // flush; emit `close` when the destination is ready
|
|
401
693
|
|
|
402
|
-
|
|
694
|
+
// typed events (inherited): the listener signature is inferred from the event name
|
|
695
|
+
on(event: 'close' | 'finish' | 'drain', listener: () => void): this;
|
|
696
|
+
on(event: 'error', listener: (err: Error) => void): this;
|
|
697
|
+
// ...and the matching `once` / `off` / `emit`
|
|
698
|
+
}
|
|
699
|
+
```
|
|
403
700
|
|
|
404
|
-
|
|
701
|
+
| Event | Meaning |
|
|
702
|
+
|-------|---------|
|
|
703
|
+
| `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" |
|
|
704
|
+
| `error` | the destination failed |
|
|
705
|
+
| `finish` | `end()` was processed and queued data flushed (before `close`) |
|
|
706
|
+
| `drain` | the buffer drained after backpressure; safe to write more |
|
|
405
707
|
|
|
406
|
-
|
|
407
|
-
`
|
|
708
|
+
The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited),
|
|
709
|
+
`end()`s the sink when the client closes, and attaches an `error` listener so a failing sink
|
|
710
|
+
can't crash the process (it also catches throws from `write`/`end`). The application — which
|
|
711
|
+
created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't
|
|
712
|
+
awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**.
|
|
408
713
|
|
|
409
|
-
###
|
|
714
|
+
### Built-in sinks
|
|
410
715
|
|
|
411
|
-
|
|
716
|
+
```ts
|
|
717
|
+
import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
|
|
412
718
|
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
719
|
+
const observer = new Observer({
|
|
720
|
+
// one ./stats/<callId>__<clientId>.jsonl per client
|
|
721
|
+
createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
|
|
722
|
+
});
|
|
417
723
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
};
|
|
423
|
-
const observer = new Observer(observerConfig);
|
|
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
|
-
});
|
|
724
|
+
// React when a sink is created for a client:
|
|
725
|
+
observer.on('client-sink-created', ({ observedClient, sink }) => {
|
|
726
|
+
sink.on('close', () => {
|
|
727
|
+
// the file is fully flushed and its fd closed — ready to upload, move, etc.
|
|
728
|
+
});
|
|
439
729
|
});
|
|
730
|
+
```
|
|
440
731
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
732
|
+
| Export | Signature | Notes |
|
|
733
|
+
|--------|-----------|-------|
|
|
734
|
+
| `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
|
|
735
|
+
| `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
|
|
736
|
+
| `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class, if you want to construct it directly |
|
|
737
|
+
| `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` |
|
|
738
|
+
|
|
739
|
+
`serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for
|
|
740
|
+
the JSONL sinks (e.g. to redact or reshape before writing).
|
|
741
|
+
|
|
742
|
+
### Writing your own sink
|
|
743
|
+
|
|
744
|
+
Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
|
|
745
|
+
destination (a remote endpoint, a message queue, an object store, …):
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js';
|
|
749
|
+
|
|
750
|
+
class HttpSink extends ClientSampleSink {
|
|
751
|
+
private buffer: ClientSample[] = [];
|
|
752
|
+
constructor(private readonly url: string) { super(); }
|
|
753
|
+
|
|
754
|
+
write(sample: ClientSample): boolean {
|
|
755
|
+
this.buffer.push(sample); // batch; decide your own backpressure
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
end(): void {
|
|
759
|
+
fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) })
|
|
760
|
+
.then(() => this.emit('close')) // signal "destination ready"
|
|
761
|
+
.catch((err) => this.emit('error', err));
|
|
762
|
+
}
|
|
451
763
|
}
|
|
452
764
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
/* ... your client's getStats() output ... */
|
|
456
|
-
};
|
|
457
|
-
const callId = 'meeting-alpha-123';
|
|
458
|
-
const clientId = 'user-xyz-789';
|
|
459
|
-
const sample = mapStatsToClientSample(rawStatsFromClient, callId, clientId);
|
|
460
|
-
observer.accept(sample);
|
|
765
|
+
const createClientSink: ClientSampleSinkFactory = ({ clientId, observedCall }) =>
|
|
766
|
+
new HttpSink(`https://stats.example.com/${observedCall.callId}/${clientId}`);
|
|
461
767
|
|
|
462
|
-
|
|
463
|
-
// observer.close();
|
|
768
|
+
const observer = new Observer({ createClientSink });
|
|
464
769
|
```
|
|
465
770
|
|
|
466
|
-
|
|
771
|
+
`observedClient.sink?` exposes the created sink; the `client-sink-created` event delivers it on
|
|
772
|
+
the bus with full ancestry. `ClientSampleSinkFactory` is
|
|
773
|
+
`(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`.
|
|
467
774
|
|
|
468
|
-
|
|
469
|
-
// ... observer setup ...
|
|
775
|
+
## Logging
|
|
470
776
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
updateIntervalInMs: 10000,
|
|
475
|
-
});
|
|
777
|
+
`observer-js` logs through a single, swappable sink. Out of the box it writes `debug` and
|
|
778
|
+
above to `console` (verbose — install your own sink for production). Funnel everything into your
|
|
779
|
+
logger:
|
|
476
780
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
```
|
|
781
|
+
```ts
|
|
782
|
+
import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
|
|
480
783
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
console.log(`EVENT_MONITOR (${context.callId}): Client ${client.clientId} joined at ${new Date()}`);
|
|
489
|
-
});
|
|
490
|
-
callMonitor.on('issue-detected', (client, issue, context) => {
|
|
491
|
-
console.error(`EVENT_MONITOR (${context.callId}): Issue on ${client.clientId} - ${issue.description}`);
|
|
492
|
-
});
|
|
493
|
-
}
|
|
784
|
+
setObserverLogger({
|
|
785
|
+
trace: (m, ...a) => myLogger.trace(`[${m}]`, ...a),
|
|
786
|
+
debug: (m, ...a) => myLogger.debug(`[${m}]`, ...a),
|
|
787
|
+
info: (m, ...a) => myLogger.info(`[${m}]`, ...a),
|
|
788
|
+
warn: (m, ...a) => myLogger.warn(`[${m}]`, ...a),
|
|
789
|
+
error: (m, ...a) => myLogger.error(`[${m}]`, ...a),
|
|
790
|
+
});
|
|
494
791
|
```
|
|
495
792
|
|
|
496
|
-
|
|
793
|
+
`createLogger(moduleName)` is also exported for your own modules. See
|
|
794
|
+
**[`docs/logging.md`](./docs/logging.md)** for pino / winston / console recipes, level
|
|
795
|
+
filtering, per-module routing, and full silencing.
|
|
497
796
|
|
|
498
|
-
|
|
499
|
-
- **Error Handling**: Wrap calls to library methods in `try...catch` blocks where appropriate, especially for operations that might throw errors based on state (e.g., creating an entity that already exists if not using `getOrCreate` patterns).
|
|
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.
|
|
797
|
+
---
|
|
503
798
|
|
|
504
|
-
|
|
799
|
+
## Error-handling philosophy
|
|
505
800
|
|
|
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.
|
|
801
|
+
The library **warns and degrades; it does not throw** on operational problems:
|
|
512
802
|
|
|
513
|
-
|
|
803
|
+
- Bad config (`update-on-interval` without an interval) → warn + safe fallback policy.
|
|
804
|
+
- `createObservedCall` / `createObservedClient` on a closed parent → warn + return `undefined`.
|
|
805
|
+
- Duplicate id → warn + return the **existing** instance.
|
|
806
|
+
- `accept()` on a closed client → warn + no-op.
|
|
807
|
+
- Sample missing `callId`/`clientId`, or observer closed → `sample-rejected` event.
|
|
514
808
|
|
|
515
|
-
|
|
516
|
-
|
|
809
|
+
Therefore `create*` and `getOrCreate*` return `T | undefined`; **guard the result.** The only
|
|
810
|
+
remaining `throw`s are internal invariants in the unused `Middleware` utility.
|
|
517
811
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## Public exports
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
// Entry: src/index.ts
|
|
818
|
+
export { Observer } from './Observer';
|
|
819
|
+
export type { ObserverEvents, SampleRejectedReason, AcceptContext, CallAppDataFactory, ClientAppDataFactory } from './Observer';
|
|
820
|
+
export type { ObserverEventBase, ObservedCallScope, ObservedClientScope, ObservedPeerConnectionScope } from './ObserverEvents';
|
|
821
|
+
|
|
822
|
+
export { ObservedCall, ObservedClient, ObservedPeerConnection } from './…';
|
|
823
|
+
export { ObservedInboundTrack, ObservedOutboundTrack } from './…';
|
|
824
|
+
export { ObservedInboundRtp, ObservedOutboundRtp, ObservedRemoteInboundRtp, ObservedRemoteOutboundRtp } from './…';
|
|
825
|
+
export { ObservedMediaSource, ObservedMediaPlayout, ObservedCodec, ObservedCertificate, ObservedDataChannel } from './…';
|
|
826
|
+
export { ObservedIceCandidate, ObservedIceCandidatePair, ObservedIceTransport, ObservedPeerConnectionTransport } from './…';
|
|
827
|
+
|
|
828
|
+
export { ClientSample, ClientIssue, ClientEvent, ClientMetaData } from './schema/ClientSample';
|
|
829
|
+
export { ClientEventTypes } from './schema/ClientEventTypes';
|
|
830
|
+
export { ClientMetaTypes } from './schema/ClientMetaTypes';
|
|
831
|
+
|
|
832
|
+
export { ScoreCalculator } from './scores/ScoreCalculator';
|
|
833
|
+
export { Detectors } from './detectors/Detectors';
|
|
834
|
+
export type { Detector } from './detectors/Detector';
|
|
835
|
+
|
|
836
|
+
export { createLogger, setObserverLogger } from './common/logger';
|
|
837
|
+
export type { Logger, ObserverLogger } from './common/logger';
|
|
838
|
+
|
|
839
|
+
// sinks: base class (subclass it for a custom destination) + built-ins
|
|
840
|
+
export { ClientSampleSink } from './sinks/ClientSampleSink';
|
|
841
|
+
export type { ClientSampleSinkEvents, ClientSampleSinkFactory } from './sinks/ClientSampleSink';
|
|
842
|
+
export { JsonlFileSink, createJsonlFileSink, createJsonlFileSinkFactory } from './sinks/JsonlFileSink';
|
|
843
|
+
export type { JsonlFileSinkOptions, JsonlFileSinkFactoryOptions } from './sinks/JsonlFileSink';
|
|
844
|
+
export { InMemorySink, createInMemorySink } from './sinks/InMemorySink';
|
|
845
|
+
|
|
846
|
+
export { Middleware } from './common/Middleware';
|
|
847
|
+
export type { TrackReport, ClientReport } from './Reports';
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
---
|
|
851
|
+
|
|
852
|
+
## Development & extension guide
|
|
853
|
+
|
|
854
|
+
```bash
|
|
855
|
+
yarn install
|
|
856
|
+
yarn build # tsup → dist/ (ESM: index + sinks, with .d.mts types & sourcemaps)
|
|
857
|
+
yarn lint # eslint -c .eslintrc.json "src/**/*.ts"
|
|
858
|
+
yarn typecheck # tsc --noEmit
|
|
859
|
+
yarn test # jest
|
|
528
860
|
```
|
|
529
861
|
|
|
530
|
-
|
|
862
|
+
The build is driven by [`tsup`](https://tsup.egoist.dev) (config in `tsup.config.ts`): a single
|
|
863
|
+
entry (`src/index.ts`), ESM output to `dist/` with `.d.mts` types and sourcemaps. CI
|
|
864
|
+
(`.github/workflows/ci.yml`) runs lint + typecheck + **build** + test on every push/PR.
|
|
865
|
+
|
|
866
|
+
**Project layout** (`src/`): `Observer.ts`, `ObservedCall.ts`, `ObservedClient.ts`,
|
|
867
|
+
`ObservedPeerConnection.ts`, the `Observed*` sub-stat classes, `ObserverEvents.ts` (the typed
|
|
868
|
+
event map + scope types), `detectors/` (`Detector`, `Detectors`), `scores/`, `updaters/`
|
|
869
|
+
(update-policy strategies), `utils/` (remote-track resolvers), `common/` (`logger`, `utils`,
|
|
870
|
+
`Middleware`), `schema/` (sample/event/meta types), and `sinks/` (the import-safe
|
|
871
|
+
`ClientSampleSink` base + the Node-only `JsonlFileSink` / `InMemorySink`, re-exported via the
|
|
872
|
+
`@observertc/observer-js/sinks` subpath).
|
|
873
|
+
|
|
874
|
+
**Conventions to follow when developing further:**
|
|
875
|
+
|
|
876
|
+
- *Single event bus.* New consumer-facing events go in `ObserverEvents.ts` with an object
|
|
877
|
+
payload `[<Scope> & { …subject }]`, and are emitted via the component's
|
|
878
|
+
`_notify(type, { ...this.eventScope, …subject })`. Each component has a precomputed
|
|
879
|
+
`eventScope` field and a thin `_notify` wrapper around the right emitter. Keep purely internal
|
|
880
|
+
coordination as **local** EventEmitter events (and remember to `off` them on close).
|
|
881
|
+
- *Warn, don't throw* on operational/edge conditions; return `undefined` where a value can't be produced.
|
|
882
|
+
- *Counter-reset-safe deltas.* When computing a delta from a cumulative counter, never emit a
|
|
883
|
+
negative value (guard `curr >= prev`), to survive counter resets / SSRC reuse.
|
|
884
|
+
- *Explicit accumulation.* The per-sample metric accumulation in `accept()` is intentionally
|
|
885
|
+
explicit and not abstracted — match that style.
|
|
886
|
+
- *Detectors are server-side.* Add cross-client detectors on `ObservedCall.detectors`; don't
|
|
887
|
+
re-implement client-detectable signals.
|
|
888
|
+
|
|
889
|
+
**Recipes:**
|
|
890
|
+
|
|
891
|
+
- *Add an event:* add the key + payload to `ObserverEvents`; in the owning component call
|
|
892
|
+
`this._notify('my-event', { ...this.eventScope, subject })`.
|
|
893
|
+
- *Add a per-stream metric:* add the field to the relevant `Observed*Rtp`/track class, populate
|
|
894
|
+
it in its `update()` (reset at the top of `update()` if it's per-tick), and read it from a
|
|
895
|
+
`*-updated` handler.
|
|
896
|
+
- *Add a detector:* implement `Detector`, register it on `call-added` via
|
|
897
|
+
`observedCall.detectors.add(...)`, surface findings with `observedCall.addIssue(...)`.
|
|
898
|
+
|
|
899
|
+
---
|
|
900
|
+
|
|
901
|
+
## Not yet implemented / roadmap
|
|
902
|
+
|
|
903
|
+
For an agent continuing the work, these are explicitly **not** present yet:
|
|
531
904
|
|
|
532
|
-
|
|
905
|
+
- **Tests.** There is currently only a placeholder spec. The two `accept()` methods
|
|
906
|
+
(`ObservedClient`, `ObservedPeerConnection`) are the priority for characterization tests. (A
|
|
907
|
+
CI gate running lint + typecheck + test is already in place — see `.github/workflows/ci.yml`.)
|
|
908
|
+
- **Built-in detectors / quality classifier.** The registry exists; concrete server-side
|
|
909
|
+
detectors (e.g. producer→consumer delivery mismatch, quality outlier, asymmetric media) are
|
|
910
|
+
to be designed.
|
|
911
|
+
- **Per-tick snapshot API.** A serializable snapshot per `*-updated` tick to replace consuming
|
|
912
|
+
the fine-grained `*-updated` events (under consideration).
|
|
913
|
+
- **Monotonic-timestamp / clock-skew handling** for client clock jumps.
|
|
914
|
+
- **Batch `processSamples()`** entry point for offline analysis of recorded sample streams.
|
|
915
|
+
- **Expanded derived metrics** (jitter-buffer delay, concealment, freeze fraction, encode/decode
|
|
916
|
+
CPU, quality-limitation breakdown, etc.) and first-class producer/consumer & PC-direction
|
|
917
|
+
fields beyond `attachments`.
|
|
533
918
|
|
|
534
|
-
|
|
919
|
+
## License
|
|
535
920
|
|
|
536
|
-
|
|
921
|
+
Apache-2.0. Part of the [ObserverTC](https://github.com/observertc) ecosystem.
|