@observertc/observer-js 1.0.0-beta.3 → 1.0.0-beta.4

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