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