@observertc/observer-js 1.0.0-beta.1 → 1.0.0-beta.10

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