@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.
Files changed (230) hide show
  1. package/README.md +790 -386
  2. package/dist/index.d.mts +2795 -0
  3. package/dist/index.d.ts +2795 -0
  4. package/dist/index.js +3765 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +3699 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/package.json +28 -15
  9. package/.eslintrc.json +0 -143
  10. package/.prettierignore +0 -5
  11. package/.prettierrc +0 -7
  12. package/jest.config.js +0 -9
  13. package/lib/ObservedCall.d.ts +0 -70
  14. package/lib/ObservedCall.d.ts.map +0 -1
  15. package/lib/ObservedCall.js +0 -196
  16. package/lib/ObservedCallEventMonitor.d.ts +0 -107
  17. package/lib/ObservedCallEventMonitor.d.ts.map +0 -1
  18. package/lib/ObservedCallEventMonitor.js +0 -321
  19. package/lib/ObservedCallSummary.d.ts +0 -18
  20. package/lib/ObservedCallSummary.d.ts.map +0 -1
  21. package/lib/ObservedCallSummary.js +0 -2
  22. package/lib/ObservedCertificate.d.ts +0 -19
  23. package/lib/ObservedCertificate.d.ts.map +0 -1
  24. package/lib/ObservedCertificate.js +0 -38
  25. package/lib/ObservedClient.d.ts +0 -115
  26. package/lib/ObservedClient.d.ts.map +0 -1
  27. package/lib/ObservedClient.js +0 -714
  28. package/lib/ObservedClientEventMonitor.d.ts +0 -91
  29. package/lib/ObservedClientEventMonitor.d.ts.map +0 -1
  30. package/lib/ObservedClientEventMonitor.js +0 -254
  31. package/lib/ObservedClientSummary.d.ts +0 -23
  32. package/lib/ObservedClientSummary.d.ts.map +0 -1
  33. package/lib/ObservedClientSummary.js +0 -2
  34. package/lib/ObservedCodec.d.ts +0 -22
  35. package/lib/ObservedCodec.d.ts.map +0 -1
  36. package/lib/ObservedCodec.js +0 -45
  37. package/lib/ObservedDataChannel.d.ts +0 -30
  38. package/lib/ObservedDataChannel.d.ts.map +0 -1
  39. package/lib/ObservedDataChannel.js +0 -75
  40. package/lib/ObservedIceCandidate.d.ts +0 -29
  41. package/lib/ObservedIceCandidate.d.ts.map +0 -1
  42. package/lib/ObservedIceCandidate.js +0 -59
  43. package/lib/ObservedIceCandidatePair.d.ts +0 -45
  44. package/lib/ObservedIceCandidatePair.d.ts.map +0 -1
  45. package/lib/ObservedIceCandidatePair.js +0 -127
  46. package/lib/ObservedIceTransport.d.ts +0 -36
  47. package/lib/ObservedIceTransport.d.ts.map +0 -1
  48. package/lib/ObservedIceTransport.js +0 -93
  49. package/lib/ObservedInboundRtp.d.ts +0 -92
  50. package/lib/ObservedInboundRtp.d.ts.map +0 -1
  51. package/lib/ObservedInboundRtp.js +0 -193
  52. package/lib/ObservedInboundTrack.d.ts +0 -34
  53. package/lib/ObservedInboundTrack.d.ts.map +0 -1
  54. package/lib/ObservedInboundTrack.js +0 -92
  55. package/lib/ObservedMediaPlayout.d.ts +0 -22
  56. package/lib/ObservedMediaPlayout.d.ts.map +0 -1
  57. package/lib/ObservedMediaPlayout.js +0 -42
  58. package/lib/ObservedMediaSource.d.ts +0 -28
  59. package/lib/ObservedMediaSource.d.ts.map +0 -1
  60. package/lib/ObservedMediaSource.js +0 -55
  61. package/lib/ObservedOutboundRtp.d.ts +0 -62
  62. package/lib/ObservedOutboundRtp.d.ts.map +0 -1
  63. package/lib/ObservedOutboundRtp.js +0 -144
  64. package/lib/ObservedOutboundTrack.d.ts +0 -34
  65. package/lib/ObservedOutboundTrack.d.ts.map +0 -1
  66. package/lib/ObservedOutboundTrack.js +0 -65
  67. package/lib/ObservedPeerConnection.d.ts +0 -207
  68. package/lib/ObservedPeerConnection.d.ts.map +0 -1
  69. package/lib/ObservedPeerConnection.js +0 -788
  70. package/lib/ObservedPeerConnectionTransport.d.ts +0 -17
  71. package/lib/ObservedPeerConnectionTransport.d.ts.map +0 -1
  72. package/lib/ObservedPeerConnectionTransport.js +0 -34
  73. package/lib/ObservedRemoteInboundRtp.d.ts +0 -30
  74. package/lib/ObservedRemoteInboundRtp.d.ts.map +0 -1
  75. package/lib/ObservedRemoteInboundRtp.js +0 -60
  76. package/lib/ObservedRemoteOutboundRtp.d.ts +0 -30
  77. package/lib/ObservedRemoteOutboundRtp.d.ts.map +0 -1
  78. package/lib/ObservedRemoteOutboundRtp.js +0 -60
  79. package/lib/ObservedTURN.d.ts +0 -31
  80. package/lib/ObservedTURN.d.ts.map +0 -1
  81. package/lib/ObservedTURN.js +0 -58
  82. package/lib/ObservedTurnServer.d.ts +0 -25
  83. package/lib/ObservedTurnServer.d.ts.map +0 -1
  84. package/lib/ObservedTurnServer.js +0 -59
  85. package/lib/Observer.d.ts +0 -62
  86. package/lib/Observer.d.ts.map +0 -1
  87. package/lib/Observer.js +0 -160
  88. package/lib/ObserverEventMonitor.d.ts +0 -141
  89. package/lib/ObserverEventMonitor.d.ts.map +0 -1
  90. package/lib/ObserverEventMonitor.js +0 -440
  91. package/lib/ObserverSummary.d.ts +0 -13
  92. package/lib/ObserverSummary.d.ts.map +0 -1
  93. package/lib/ObserverSummary.js +0 -2
  94. package/lib/Reports.d.ts +0 -85
  95. package/lib/Reports.d.ts.map +0 -1
  96. package/lib/Reports.js +0 -2
  97. package/lib/common/Middleware.d.ts +0 -17
  98. package/lib/common/Middleware.d.ts.map +0 -1
  99. package/lib/common/Middleware.js +0 -60
  100. package/lib/common/SingleExecutor.d.ts +0 -3
  101. package/lib/common/SingleExecutor.d.ts.map +0 -1
  102. package/lib/common/SingleExecutor.js +0 -31
  103. package/lib/common/logger.d.ts +0 -17
  104. package/lib/common/logger.d.ts.map +0 -1
  105. package/lib/common/logger.js +0 -50
  106. package/lib/common/types.d.ts +0 -3
  107. package/lib/common/types.d.ts.map +0 -1
  108. package/lib/common/types.js +0 -3
  109. package/lib/common/utils.d.ts +0 -11
  110. package/lib/common/utils.d.ts.map +0 -1
  111. package/lib/common/utils.js +0 -63
  112. package/lib/detectors/Detector.d.ts +0 -5
  113. package/lib/detectors/Detector.d.ts.map +0 -1
  114. package/lib/detectors/Detector.js +0 -3
  115. package/lib/detectors/Detectors.d.ts +0 -11
  116. package/lib/detectors/Detectors.d.ts.map +0 -1
  117. package/lib/detectors/Detectors.js +0 -34
  118. package/lib/index.d.ts +0 -29
  119. package/lib/index.d.ts.map +0 -1
  120. package/lib/index.js +0 -49
  121. package/lib/mediasoup/ObservedMediaRouter.d.ts +0 -10
  122. package/lib/mediasoup/ObservedMediaRouter.d.ts.map +0 -1
  123. package/lib/mediasoup/ObservedMediaRouter.js +0 -6
  124. package/lib/monitors/TurnUsageMonitor.d.ts +0 -1
  125. package/lib/monitors/TurnUsageMonitor.d.ts.map +0 -1
  126. package/lib/monitors/TurnUsageMonitor.js +0 -146
  127. package/lib/schema/ClientEventTypes.d.ts +0 -228
  128. package/lib/schema/ClientEventTypes.d.ts.map +0 -1
  129. package/lib/schema/ClientEventTypes.js +0 -41
  130. package/lib/schema/ClientMetaTypes.d.ts +0 -34
  131. package/lib/schema/ClientMetaTypes.d.ts.map +0 -1
  132. package/lib/schema/ClientMetaTypes.js +0 -16
  133. package/lib/schema/ClientSample.d.ts +0 -1333
  134. package/lib/schema/ClientSample.d.ts.map +0 -1
  135. package/lib/schema/ClientSample.js +0 -4
  136. package/lib/scores/CalculatedScore.d.ts +0 -6
  137. package/lib/scores/CalculatedScore.d.ts.map +0 -1
  138. package/lib/scores/CalculatedScore.js +0 -2
  139. package/lib/scores/DefaultCallScoreCalculator.d.ts +0 -7
  140. package/lib/scores/DefaultCallScoreCalculator.d.ts.map +0 -1
  141. package/lib/scores/DefaultCallScoreCalculator.js +0 -21
  142. package/lib/scores/ScoreCalculator.d.ts +0 -4
  143. package/lib/scores/ScoreCalculator.d.ts.map +0 -1
  144. package/lib/scores/ScoreCalculator.js +0 -2
  145. package/lib/updaters/CallUpdater.d.ts +0 -5
  146. package/lib/updaters/CallUpdater.d.ts.map +0 -1
  147. package/lib/updaters/CallUpdater.js +0 -2
  148. package/lib/updaters/ObserverUpdater.d.ts +0 -5
  149. package/lib/updaters/ObserverUpdater.d.ts.map +0 -1
  150. package/lib/updaters/ObserverUpdater.js +0 -2
  151. package/lib/updaters/OnAllCallObserverUpdater.d.ts +0 -15
  152. package/lib/updaters/OnAllCallObserverUpdater.d.ts.map +0 -1
  153. package/lib/updaters/OnAllCallObserverUpdater.js +0 -50
  154. package/lib/updaters/OnAllClientCallUpdater.d.ts +0 -15
  155. package/lib/updaters/OnAllClientCallUpdater.d.ts.map +0 -1
  156. package/lib/updaters/OnAllClientCallUpdater.js +0 -53
  157. package/lib/updaters/OnAnyCallObserverUpdater.d.ts +0 -12
  158. package/lib/updaters/OnAnyCallObserverUpdater.d.ts.map +0 -1
  159. package/lib/updaters/OnAnyCallObserverUpdater.js +0 -37
  160. package/lib/updaters/OnAnyClientCallUpdater.d.ts +0 -12
  161. package/lib/updaters/OnAnyClientCallUpdater.d.ts.map +0 -1
  162. package/lib/updaters/OnAnyClientCallUpdater.js +0 -36
  163. package/lib/updaters/OnIntervalUpdater.d.ts +0 -10
  164. package/lib/updaters/OnIntervalUpdater.d.ts.map +0 -1
  165. package/lib/updaters/OnIntervalUpdater.js +0 -17
  166. package/lib/updaters/Updater.d.ts +0 -6
  167. package/lib/updaters/Updater.d.ts.map +0 -1
  168. package/lib/updaters/Updater.js +0 -2
  169. package/lib/utils/MediasoupRemoteTrackResolver.d.ts +0 -25
  170. package/lib/utils/MediasoupRemoteTrackResolver.d.ts.map +0 -1
  171. package/lib/utils/MediasoupRemoteTrackResolver.js +0 -110
  172. package/lib/utils/RemoteTrackResolver.d.ts +0 -7
  173. package/lib/utils/RemoteTrackResolver.d.ts.map +0 -1
  174. package/lib/utils/RemoteTrackResolver.js +0 -2
  175. package/src/ObservedCall.ts +0 -308
  176. package/src/ObservedCallEventMonitor.ts +0 -402
  177. package/src/ObservedCallSummary.ts +0 -22
  178. package/src/ObservedCertificate.ts +0 -43
  179. package/src/ObservedClient.ts +0 -883
  180. package/src/ObservedClientEventMonitor.ts +0 -309
  181. package/src/ObservedClientSummary.ts +0 -24
  182. package/src/ObservedCodec.ts +0 -49
  183. package/src/ObservedDataChannel.ts +0 -85
  184. package/src/ObservedIceCandidate.ts +0 -64
  185. package/src/ObservedIceCandidatePair.ts +0 -134
  186. package/src/ObservedIceTransport.ts +0 -99
  187. package/src/ObservedInboundRtp.ts +0 -205
  188. package/src/ObservedInboundTrack.ts +0 -105
  189. package/src/ObservedMediaPlayout.ts +0 -49
  190. package/src/ObservedMediaSource.ts +0 -60
  191. package/src/ObservedOutboundRtp.ts +0 -156
  192. package/src/ObservedOutboundTrack.ts +0 -82
  193. package/src/ObservedPeerConnection.ts +0 -1104
  194. package/src/ObservedPeerConnectionTransport.ts +0 -38
  195. package/src/ObservedRemoteInboundRtp.ts +0 -64
  196. package/src/ObservedRemoteOutboundRtp.ts +0 -65
  197. package/src/ObservedTURN.ts +0 -86
  198. package/src/ObservedTurnServer.ts +0 -72
  199. package/src/Observer.ts +0 -248
  200. package/src/ObserverEventMonitor.ts +0 -561
  201. package/src/ObserverSummary.ts +0 -15
  202. package/src/Reports.ts +0 -87
  203. package/src/common/Middleware.ts +0 -84
  204. package/src/common/SingleExecutor.ts +0 -36
  205. package/src/common/logger.ts +0 -82
  206. package/src/common/types.ts +0 -4
  207. package/src/common/utils.ts +0 -69
  208. package/src/detectors/Detector.ts +0 -6
  209. package/src/detectors/Detectors.ts +0 -38
  210. package/src/index.ts +0 -32
  211. package/src/mediasoup/ObservedMediaRouter.ts +0 -10
  212. package/src/monitors/TurnUsageMonitor.ts +0 -187
  213. package/src/schema/ClientEventTypes.ts +0 -280
  214. package/src/schema/ClientMetaTypes.ts +0 -41
  215. package/src/schema/ClientSample.ts +0 -1682
  216. package/src/scores/CalculatedScore.ts +0 -6
  217. package/src/scores/DefaultCallScoreCalculator.ts +0 -23
  218. package/src/scores/ScoreCalculator.ts +0 -3
  219. package/src/updaters/CallUpdater.ts +0 -5
  220. package/src/updaters/ObserverUpdater.ts +0 -5
  221. package/src/updaters/OnAllCallObserverUpdater.ts +0 -59
  222. package/src/updaters/OnAllClientCallUpdater.ts +0 -61
  223. package/src/updaters/OnAnyCallObserverUpdater.ts +0 -41
  224. package/src/updaters/OnAnyClientCallUpdater.ts +0 -39
  225. package/src/updaters/OnIntervalUpdater.ts +0 -19
  226. package/src/updaters/Updater.ts +0 -5
  227. package/src/utils/MediasoupRemoteTrackResolver.ts +0 -155
  228. package/src/utils/RemoteTrackResolver.ts +0 -12
  229. package/tsconfig.json +0 -19
  230. package/tslint.json +0 -6
package/README.md CHANGED
@@ -1,23 +1,48 @@
1
- # ObserverTC - Observer JS
1
+ # ObserverTC — `@observertc/observer-js`
2
2
 
3
3
  [![NPM version](https://img.shields.io/npm/v/@observertc/observer-js.svg)](https://www.npmjs.com/package/@observertc/observer-js)
4
4
  [![License](https://img.shields.io/npm/l/@observertc/observer-js.svg)](https://github.com/observertc/observer-js/blob/main/LICENSE)
5
5
 
6
- `observer-js` is a Node.js library for monitoring WebRTC client data. It processes statistical samples from clients, organizes them into calls and participants, tracks a wide range of metrics, detects common issues, and calculates quality scores. This enables real-time insights into WebRTC session performance.
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
- This library is a core component of the ObserverTC ecosystem, designed to provide robust server-side monitoring capabilities for WebRTC applications.
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
- ## Features
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
- - **Hierarchical Data Model**: Organizes data into `Observer` -> `ObservedCall` -> `ObservedClient` -> `ObservedPeerConnection` and further into streams and data channels.
13
- - **Comprehensive Metrics**: Tracks a wide array of WebRTC statistics including RTT, jitter, packet loss, codecs, ICE states, TURN usage, bandwidth, and more.
14
- - **Automatic Entity Management**: Can automatically create and manage call and client entities based on incoming data samples.
15
- - **Issue Detection**: Built-in detectors for common WebRTC problems.
16
- - **Quality Scoring**: Calculates quality scores for calls and clients.
17
- - **Event-Driven**: Emits events for significant state changes, new entities, and detected issues.
18
- - **Configurable Update Policies**: Flexible control over how and when metrics are processed and updated.
19
- - **TypeScript Support**: Written in TypeScript, providing strong typing and intellisense.
20
- - **Extensible**: Supports custom application data (`appData`) and integration with external schema definitions (e.g., `observertc/schemas`).
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
- ## Quick Start
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
- ```typescript
33
- import { Observer, ObserverConfig } from '@observertc/observer-js';
34
- import { ClientSample } from '@observertc/schemas'; // Assuming you use the official schemas
58
+ ```ts
59
+ import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
60
+ ```
35
61
 
36
- // 1. Configure the Observer
37
- const observerConfig: ObserverConfig = {
38
- updatePolicy: 'update-on-interval',
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
+ 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
- // 2. Listen to events
45
- observer.on('newcall', (call) => {
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
- call.on('newclient', (client) => {
49
- console.log(`[Call: ${call.callId}] New client joined: ${client.clientId}`);
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
- client.on('issue', (issue) => {
52
- console.warn(`[Client: ${client.clientId}] Issue: ${issue.type} - ${issue.severity} - ${issue.description}`);
53
- });
54
- });
98
+ ---
55
99
 
56
- call.on('update', () => {
57
- console.log(
58
- `[Call: ${call.callId}] Metrics updated. Score: ${call.score?.toFixed(1)}, Clients: ${call.numberOfClients}`
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
- // 3. Process Client Samples
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
- }
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
- // Example usage:
79
- // const myRawClientStats = getStatsFromClient();
80
- // processClientStats(myRawClientStats, 'myMeeting123', 'userABC');
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. Cleanup when done
83
- // process.on('SIGINT', () => observer.close());
140
+ // 4. Tear down.
141
+ process.on('SIGINT', () => observer.close());
84
142
  ```
85
143
 
86
144
  ---
87
145
 
88
- ## Detailed Documentation
146
+ ## Data flow
89
147
 
90
- The following sections provide a comprehensive guide to `observer-js`.
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
- ### 1. General Description
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
- (This section is identical to the introductory paragraph at the top of this README)
172
+ ---
95
173
 
96
- ### 2. Core Concepts
174
+ ## Entity hierarchy
97
175
 
98
- #### 2.1. Data Flow
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
- 1. **Client-Side**: Your application collects WebRTC statistics (e.g., via `RTCPeerConnection.getStats()`).
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
- #### 2.2. Entity Hierarchy
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
- - **`Observer`**: The root object, managing multiple calls and global settings.
110
- - **`ObservedCall`**: Represents a distinct call session.
111
- - **`ObservedClient`**: Represents an individual participant within a call.
112
- - **`ObservedPeerConnection`**: Represents a WebRTC RTCPeerConnection of a client.
113
- - **`ObservedInboundRtpStream` / `ObservedOutboundRtpStream`**: Tracks individual media streams.
114
- - **`ObservedDataChannel`**: Tracks data channels.
115
- - **`ObservedTURN`**: Tracks global TURN server usage metrics across the observer.
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
- #### 2.3. Automatic Entity Creation
201
+ ---
118
202
 
119
- When `observer.accept(sample)` is called:
203
+ ## Ingestion: `accept()`, context & lifecycle
120
204
 
121
- - If an `ObservedCall` for `sample.callId` doesn't exist, it's typically created.
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
- #### 2.4. Metrics Aggregation
207
+ The single entry point. It:
126
208
 
127
- The library aggregates a wide array of metrics at each level of the hierarchy, including (but not limited to):
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
- - RTT, jitter, packet loss
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
- #### 2.5. Issue Detection
217
+ ```ts
218
+ type AcceptContext = Record<string, unknown>;
219
+ ```
138
220
 
139
- A `Detectors` system analyzes metrics to identify common WebRTC issues (e.g., high packet loss, low audio levels, frozen video, connection setup problems). Issues are reported via events.
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
- #### 2.6. Quality Scoring
225
+ `context` is **never written to `appData`** and is **not stored** on any entity. The two are
226
+ deliberately distinct:
142
227
 
143
- `ScoreCalculator` components assess the quality of calls and clients based on metrics and detected issues, typically resulting in a numerical score (e.g., 0.0 to 5.0).
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
- #### 2.7. Event-Driven Architecture
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
- The library uses Node.js `EventEmitter` to signal various occurrences, allowing applications to react to changes in real-time.
238
+ ### Get-or-create helpers
148
239
 
149
- ### 3. API Reference
240
+ If you want to create/configure entities yourself before/without samples:
150
241
 
151
- #### 3.1. `Observer`
242
+ ```ts
243
+ const call = observer.getOrCreateObservedCall({ callId, appData }); // ObservedCall | undefined
244
+ const client = call?.getOrCreateObservedClient({ clientId, appData }); // ObservedClient | undefined
245
+ ```
152
246
 
153
- Manages all monitored calls and global observer state.
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
- **Configuration (`ObserverConfig`)**
250
+ ### Automatic teardown
156
251
 
157
- ```typescript
158
- export type ObserverConfig<AppData extends Record<string, unknown> = Record<string, unknown>> = {
159
- updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'update-on-interval';
160
- updateIntervalInMs?: number; // Used if updatePolicy is 'update-on-interval'
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
- **Constructor**
257
+ ---
168
258
 
169
- ```typescript
170
- new Observer<AppData>(config?: ObserverConfig<AppData>)
171
- ```
259
+ ## Update policies
172
260
 
173
- - `config`: Optional. Defaults: `updatePolicy: 'update-when-all-call-updated'`.
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
- **Key Properties**
266
+ **Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
176
267
 
177
- - `observedCalls: Map<string, ObservedCall>`: Active calls.
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.
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
- **Key Methods**
274
+ **Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
275
+ `ObserverConfig.defaultCallUpdatePolicy`):
184
276
 
185
- - `createObservedCall<T>(settings: ObservedCallSettings<T>): ObservedCall<T>`
186
- - `getObservedCall<T>(callId: string): ObservedCall<T> | undefined`
187
- - `accept(sample: ClientSample): void`: A convenience method to feed WebRTC stats. If `sample.callId` and `sample.clientId` are provided, it will route the sample to the appropriate `ObservedCall` and `ObservedClient`, creating them if they don't exist. The core sample processing for an existing client happens within the `ObservedClient`'s own `accept` or update mechanism.
188
- - `update(): void`: Manually trigger an update cycle (behavior depends on `updatePolicy`).
189
- - `close(): void`: Cleans up resources for the observer and all its calls.
190
- - `createEventMonitor<CTX>(ctx?: CTX): ObserverEventMonitor<CTX>`: For contextual event listening.
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
- **Events (`ObserverEvents`)**
283
+ ---
193
284
 
194
- - `'newcall' (call: ObservedCall)`
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
- #### 3.2. `ObservedCall`
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
- Represents a single call session.
293
+ ### Payload shape: ancestry + subject
206
294
 
207
- **Configuration (`ObservedCallSettings`)**
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
- ```typescript
210
- export type ObservedCallSettings<AppData extends Record<string, unknown> = Record<string, unknown>> = {
211
- callId: string;
212
- appData?: AppData;
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
- };
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
- **Key Properties**
306
+ So a peer-connection-level event hands you the observer, call, client, **and** peer connection:
220
307
 
221
- - `callId: string`
222
- - `appData: AppData | undefined`
223
- - `numberOfClients: number`
224
- - `score: number | undefined`: Overall call quality score.
225
- - `observedClients: Map<string, ObservedClient>`
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
- **Key Methods**
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
- - `createObservedClient<T>(settings: ObservedClientSettings<T>): ObservedClient<T>`
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
- **Events (Emitted via `ObservedCall` instance)**
399
+ ## API reference
237
400
 
238
- - `'newclient' (client: ObservedClient)`
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
- #### 3.3. `ObservedClient`
403
+ ```ts
404
+ new Observer<AppData>(config?: ObserverConfig<AppData>)
245
405
 
246
- Represents a participant in a call.
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
- **Configuration (`ObservedClientSettings`)**
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
- ```typescript
251
- export type ObservedClientSettings<AppData extends Record<string, unknown> = Record<string, unknown>> = {
252
- clientId: string;
253
- appData?: AppData;
254
- // Potentially other client-specific settings
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
- **Key Properties**
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
- - `clientId: string`
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
- **Key Methods**
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
- - `accept(sample: ClientSample): void`: (Or a similar internal update method called by `Observer.accept` or `ObservedCall`) Processes a `ClientSample` specific to this client, updating its metrics, peer connections, streams, etc. This is the primary point where a client's detailed WebRTC statistics are processed.
272
- - `createObservedPeerConnection<T>(settings: ObservedPeerConnectionSettings<T>): ObservedPeerConnection<T>`
273
- - `getObservedPeerConnection<T>(peerConnectionId: string): ObservedPeerConnection<T> | undefined`
274
- - `update(): void`
275
- - `close(): void`
276
- - `createEventMonitor<CTX>(ctx?: CTX): ObservedClientEventMonitor<CTX>`
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
- **Events (Emitted via `ObservedClient` instance)**
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
- - `'joined' ()`
281
- - `'left' ()`
282
- - `'update' ()`
283
- - `'close' ()`
284
- - `'newpeerconnection' (pc: ObservedPeerConnection)`
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
- #### 3.4. `ObservedPeerConnection`
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
- Represents an `RTCPeerConnection`.
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
- - Tracks ICE connection state, data channel stats, stream stats.
292
- - Holds `ObservedInboundRtpStream`, `ObservedOutboundRtpStream`, and `ObservedDataChannel` instances.
594
+ ---
293
595
 
294
- #### 3.5. `ObservedInboundRtpStream` / `ObservedOutboundRtpStream`
596
+ ## Detectors (server-side extension point)
295
597
 
296
- - Track metrics for individual media streams (audio/video) like codec, packets lost/received, jitter, bytes, etc.
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
- #### 3.6. `ObservedDataChannel`
603
+ The hook lives on **`ObservedCall`**:
299
604
 
300
- - Tracks metrics for data channels like state, messages sent/received, bytes.
605
+ ```ts
606
+ import { Observer, Detector } from '@observertc/observer-js';
301
607
 
302
- #### 3.7. `ClientSample` (Schema)
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
- This is the primary input data structure passed to `observer.accept()`. It's a comprehensive object that should mirror the information obtainable from WebRTC `getStats()` and other client-side states. Key fields include:
627
+ `Detector` interface and the registry:
305
628
 
306
- - `callId`, `clientId`, `timestamp`
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)
629
+ ```ts
630
+ interface Detector { readonly name: string; update(): void; }
322
631
 
323
- _(Refer to the [observertc/schemas](https://github.com/observertc/schemas) repository, particularly the `ClientSample.ts` definition, for the exact and complete structure.)_
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
- ### 4. Configuration Possibilities
641
+ ---
326
642
 
327
- #### 4.1. Update Policies
643
+ ## Remote track resolution (mediasoup / SFU)
328
644
 
329
- Control how frequently entities re-calculate metrics and emit `update` events.
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
- **Observer Level (`ObserverConfig.updatePolicy`)**
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
- - `'update-on-any-call-updated'`: Observer updates if any of its calls update.
334
- - `'update-when-all-call-updated'`: Observer updates after all its calls update. (Default)
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
- **Call Level (`ObservedCallSettings.updatePolicy` or `ObserverConfig.defaultCallUpdatePolicy`)**
660
+ ```ts
661
+ interface RemoteTrackResolver {
662
+ resolveRemoteOutboundTrack(inboundTrack: ObservedInboundTrack): ObservedOutboundTrack | undefined;
663
+ resolveRemoteInboundTracks(outboundTrack: ObservedOutboundTrack): ObservedInboundTrack[] | undefined;
664
+ }
665
+ ```
338
666
 
339
- - `'update-on-any-client-updated'`: Call updates if any of its clients update.
340
- - `'update-when-all-client-updated'`: Call updates after all its clients update.
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
- #### 4.2. Intervals
670
+ ---
344
671
 
345
- - `ObserverConfig.updateIntervalInMs`
346
- - `ObserverConfig.defaultCallUpdateIntervalInMs`
347
- - `ObservedCallSettings.updateIntervalInMs`
672
+ ## Sinks (per-client sample persistence)
348
673
 
349
- #### 4.3. Application Data (`appData`)
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
- Associate custom context with `Observer`, `ObservedCall`, and `ObservedClient` instances using generics.
679
+ ### The `ClientSampleSink` base class
352
680
 
353
- ```typescript
354
- interface MyCallAppData {
355
- meetingTitle: string;
356
- scheduledAt: Date;
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
- #### 4.4. `appData` vs. Attachments
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 `observer-js` library provides two primary ways to associate custom information with its entities: `appData` and `attachments`. Understanding their distinct purposes is key for effective use.
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
- **`appData` (Application Data)**
713
+ ### Built-in sinks
370
714
 
371
- - **Purpose**: `appData` is designed to hold structured, typed, and relatively static metadata about an entity (`Observer`, `ObservedCall`, `ObservedClient`). This data is typically set at the time of entity creation and is directly accessible as a property of the entity instance.
372
- - **Typing**: It is strongly typed using generics (e.g., `Observer<MyObserverAppData>`). This provides type safety and autocompletion in TypeScript environments.
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
- **`attachments` (Arbitrary Attachments)**
718
+ const observer = new Observer({
719
+ // one ./stats/<callId>__<clientId>.jsonl per client
720
+ createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
721
+ });
381
722
 
382
- - **Purpose**: `attachments` (if implemented as a `Map<string, unknown>` or similar mechanism on entities) are meant for associating arbitrary, often dynamic, or less structured data with an entity. This can be useful for temporary state, inter-plugin communication, or data that doesn't fit neatly into a predefined `appData` schema.
383
- - **Typing**: Typically less strictly typed (e.g., `unknown` or `any` values in a Map). Consumers of attachments need to perform their own type checks or assertions.
384
- - **Mutability**: Designed to be more dynamic. Attachments can be added, updated, or removed throughout the entity's lifecycle.
385
- - **Accessibility**: Accessed via methods like `entity.setAttachment(key, value)`, `entity.getAttachment(key)`, `entity.removeAttachment(key)`.
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.
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
- **When to Use Which:**
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
- - Use **`appData`** for:
395
- - Core, descriptive metadata that is known at creation time or changes infrequently.
396
- - Data that benefits from strong typing and is integral to your application's understanding of the entity.
397
- - Use **`attachments`** for:
398
- - Dynamic, temporary, or loosely structured data.
399
- - Data added by different, potentially independent, parts of your system or plugins.
400
- - Information that doesn't need to be part of the primary, typed `appData` schema.
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
- If `attachments` are not yet a formal feature, this section can serve as a design consideration or be adapted if you introduce such a mechanism. If `attachments` are already present, ensure the description matches their actual implementation.
741
+ ### Reading sink-specific info (e.g. the file path)
403
742
 
404
- #### 4.5. Remote Track Resolution
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
- For SFU scenarios, especially with MediaSoup:
407
- `ObservedCallSettings.remoteTrackResolvePolicy: 'mediasoup-sfu'`
747
+ ```ts
748
+ import { JsonlFileSink } from '@observertc/observer-js';
408
749
 
409
- ### 5. Examples
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
- #### 5.1. Basic Observer Setup & Sample Ingestion
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
- ```typescript
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
- const observerConfig: ObserverConfig = {
419
- updatePolicy: 'update-on-interval',
420
- updateIntervalInMs: 5000,
421
- defaultCallUpdatePolicy: 'update-on-any-client-updated',
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
- });
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
- // Function to transform your app's WebRTC stats to ClientSample
442
- function mapStatsToClientSample(appStats: any, callId: string, clientId: string): ClientSample {
443
- // Detailed mapping logic here based on ClientSample.ts schema
444
- // from github.com/observertc/schemas
445
- return {
446
- callId,
447
- clientId,
448
- timestamp: Date.now(),
449
- // ... map all relevant stats fields ...
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
- // Example: Receiving stats and processing
454
- const rawStatsFromClient = {
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
- // Later, on application shutdown:
463
- // observer.close();
787
+ const observer = new Observer({ createClientSink });
464
788
  ```
465
789
 
466
- #### 5.2. Manual Call and Client Creation
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
- ```typescript
469
- // ... observer setup ...
794
+ ## Logging
470
795
 
471
- const call = observer.createObservedCall({
472
- callId: 'scheduled-webinar-456',
473
- updatePolicy: 'update-on-interval',
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
- const client1 = call.createObservedClient({ clientId: 'presenter-01' });
478
- // Samples for 'presenter-01' in call 'scheduled-webinar-456' will update this client.
479
- ```
800
+ ```ts
801
+ import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
480
802
 
481
- #### 5.3. Using Event Monitors for Contextual Logging
482
-
483
- ```typescript
484
- const call = observer.getObservedCall('meeting-alpha-123');
485
- if (call) {
486
- const callMonitor = call.createEventMonitor({ callId: call.callId, started: new Date() });
487
- callMonitor.on('client-joined', (client, context) => {
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
- ### 6. Best Practices
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
- - **Resource Management**: Always call `observer.close()`, `call.close()`, and `client.close()` when entities are no longer needed to free resources and stop timers.
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
- ### 7. Troubleshooting
818
+ ## Error-handling philosophy
505
819
 
506
- - **Memory Leaks**: Ensure `close()` is called on all entities. Check for unremoved event listeners.
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
- ### 8. TypeScript Support
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
- The library is written in TypeScript and provides type definitions.
516
- Use generics with `Observer`, `ObservedCall`, and `ObservedClient` to type your custom `appData`.
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
- ```typescript
519
- interface MyClientAppData {
520
- userId: string;
521
- role: 'admin' | 'user';
522
- }
523
- const client = call.createObservedClient<MyClientAppData>({
524
- clientId: 'user1',
525
- appData: { userId: 'u-123', role: 'admin' },
526
- });
527
- // client.appData will be typed as MyClientAppData | undefined
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
- ### 9. Contributing
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
- (Placeholder for contribution guidelines - e.g., link to CONTRIBUTING.md, coding standards, pull request process)
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
- ### 10. License
938
+ ## License
535
939
 
536
- This project is licensed under the [MIT License](LICENSE).
940
+ Apache-2.0. Part of the [ObserverTC](https://github.com/observertc) ecosystem.