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

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 +1032 -381
  2. package/dist/index.d.mts +2956 -0
  3. package/dist/index.d.ts +2956 -0
  4. package/dist/index.js +4095 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/index.mjs +4025 -0
  7. package/dist/index.mjs.map +1 -0
  8. package/llms-full.txt +1598 -0
  9. package/package.json +33 -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/llms-full.txt ADDED
@@ -0,0 +1,1598 @@
1
+ # observer-js (@observertc/observer-js) — full documentation
2
+
3
+ > Single-file export of the project's documentation for one-shot LLM ingestion.
4
+ > Concatenates: README.md, docs/logging.md, CHANGELOG.md. Generated by scripts/build-llms-full.mjs —
5
+ > the canonical, always-current copies live in the repository; see llms.txt for the link index.
6
+
7
+ ---
8
+
9
+ # ============================================================================
10
+ # README.md
11
+ # ============================================================================
12
+
13
+ # ObserverTC — `@observertc/observer-js`
14
+
15
+ [![NPM version](https://img.shields.io/npm/v/@observertc/observer-js.svg)](https://www.npmjs.com/package/@observertc/observer-js)
16
+ [![License](https://img.shields.io/npm/l/@observertc/observer-js.svg)](https://github.com/observertc/observer-js/blob/main/LICENSE)
17
+
18
+ > **In one line:** feed it WebRTC `getStats()` snapshots, and get back a live, queryable model of
19
+ > every call plus a single typed event stream to react to.
20
+
21
+ `observer-js` is a **server-side Node.js library for monitoring WebRTC sessions**. A WebRTC
22
+ application (typically an SFU or a signaling/stats backend) feeds it `ClientSample` objects —
23
+ periodic snapshots of each participant's `RTCPeerConnection.getStats()` output plus
24
+ application events — and `observer-js` maintains a live, in-memory model of every call,
25
+ participant, peer connection, and media stream, derives per-interval and cumulative metrics,
26
+ and emits a single, unified stream of typed events the application can react to.
27
+
28
+ **What you can do with it:**
29
+
30
+ - **Monitor calls live** — a queryable in-memory tree of every call, client, peer connection,
31
+ track, codec, ICE candidate and data channel, each holding current **and** cumulative metrics.
32
+ - **React on one event bus** — subscribe once on the `Observer`; every payload carries its full
33
+ ancestry (`call → client → peer connection → stat`), so you never walk the tree to subscribe.
34
+ - **Get derived metrics for free** — counter-reset-safe per-tick deltas, bitrates, jitter, RTT,
35
+ fraction-lost, remote-RTP (RTCP) correlation, and TURN/TCP usage from the selected candidate pair.
36
+ - **Correlate across an SFU** — link a publisher's outbound track to every subscriber's inbound
37
+ track (`RemoteTrackResolver`), and observe mediasoup routers/transports/producers/consumers
38
+ on the server side.
39
+ - **Detect server-only problems** — cross-client `Detector`s raise `call-issue`s for conditions no
40
+ single client can see (e.g. everyone in a call degrading at once).
41
+ - **Persist every sample** — per-client sinks (JSONL file, in-memory, or your own) for archival,
42
+ streaming, and offline replay.
43
+ - **Drop it in safely** — warn-don't-throw, a pluggable logger, dual **ESM + CommonJS**, and **no**
44
+ media-stack dependency in the core.
45
+
46
+ > **Status:** `1.0.0-beta`. The API described here is current and intended to be implemented
47
+ > against directly. This document is written to be self-sufficient: an engineer (or an AI
48
+ > agent) should be able to integrate the library, or develop it further, from this file alone.
49
+ > A companion doc, [`docs/logging.md`](./docs/logging.md), covers logging integration in depth.
50
+
51
+ > **Packaging:** server-side, **Node.js ≥ 22**, shipped as a **dual ESM + CommonJS** build — so it
52
+ > works whether your project uses `import` (ESM) or `require()` (CommonJS). Everything — including
53
+ > the built-in file sink — is exported from the single `@observertc/observer-js` entry.
54
+
55
+ > **For AI agents:** [`llms.txt`](./llms.txt) is a curated map of these docs (with a one-file
56
+ > [`llms-full.txt`](./llms-full.txt) export for one-shot ingestion); [`AGENTS.md`](./AGENTS.md)
57
+ > covers build/test commands and conventions for working **in** this repository. `llms-full.txt`
58
+ > ships in the npm package (so it's available wherever the library is installed); `llms.txt` and
59
+ > `AGENTS.md` live in the repo (and `llms.txt` belongs at the root of the docs site).
60
+
61
+ ---
62
+
63
+ ## Table of contents
64
+
65
+ 1. [Installation](#installation)
66
+ 2. [Quick start](#quick-start)
67
+ 3. [Data flow](#data-flow)
68
+ 4. [Entity hierarchy](#entity-hierarchy)
69
+ 5. [Ingestion: `accept()`, context & lifecycle](#ingestion-accept-context--lifecycle)
70
+ 6. [Update policies](#update-policies)
71
+ 7. [The event bus](#the-event-bus) ← the core of the API
72
+ 8. [API reference](#api-reference)
73
+ 9. [Schema types (`ClientSample`)](#schema-types-clientsample)
74
+ 10. [Detectors (server-side extension point)](#detectors-server-side-extension-point)
75
+ 11. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
76
+ 12. [Mediasoup router observation](#mediasoup-router-observation)
77
+ 13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
78
+ 14. [Injecting data into a client](#injecting-data-into-a-client)
79
+ 15. [Logging](#logging)
80
+ 16. [Error-handling philosophy](#error-handling-philosophy)
81
+ 17. [Development & extension guide](#development--extension-guide)
82
+
83
+ ---
84
+
85
+ ## Installation
86
+
87
+ ```bash
88
+ npm install @observertc/observer-js
89
+ # or
90
+ yarn add @observertc/observer-js
91
+ ```
92
+
93
+ **Server-side, Node.js ≥ 22, dual ESM + CommonJS.** The package ships both module formats, so it
94
+ works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
95
+
96
+ ```ts
97
+ import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
98
+ ```
99
+
100
+ In an ESM project this resolves to the `.mjs` build; in a CommonJS project (where TypeScript
101
+ compiles your `import` down to `require()`) it resolves to the `.js` build. Everything is exported
102
+ from the single `@observertc/observer-js` entry. Written in TypeScript; ships type declarations for
103
+ both formats (`dist/index.d.ts` for `require`, `dist/index.d.mts` for `import`). Runtime
104
+ dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or
105
+ any transport — see [Logging](#logging).
106
+
107
+ `ClientSample` and friends are re-exported from this package, and are also published as the
108
+ shared schema in [`@observertc/schemas`](https://github.com/observertc/schemas); samples
109
+ produced on the client (e.g. by `@observertc/client-monitor-js`) conform to the same shape.
110
+
111
+ ---
112
+
113
+ ## Quick start
114
+
115
+ ```ts
116
+ import { Observer, ClientSample } from '@observertc/observer-js';
117
+
118
+ // 1. Create an observer.
119
+ const observer = new Observer({
120
+ // when the observer aggregates call/client metrics:
121
+ updatePolicy: 'update-when-all-call-updated',
122
+ // default policy applied to calls created automatically by accept():
123
+ defaultCallUpdatePolicy: 'update-on-any-client-updated',
124
+ // optional auto-teardown:
125
+ closeCallIfEmptyForMs: 20_000,
126
+ closeClientIfIdleForMs: 60_000,
127
+ });
128
+
129
+ // 2. Subscribe on the single bus. Every payload is an object with the ancestry.
130
+ observer.on('call-added', ({ observedCall }) => {
131
+ console.log('new call', observedCall.callId);
132
+ });
133
+
134
+ observer.on('client-issue', ({ observedClient, issue }) => {
135
+ console.warn(`[${observedClient.clientId}] ${issue.type}`, issue.payload);
136
+ });
137
+
138
+ observer.on('peer-connection-updated', ({ observedClient, observedPeerConnection }) => {
139
+ console.log(observedClient.clientId, 'RTT(ms):', observedPeerConnection.currentRttInMs);
140
+ });
141
+
142
+ observer.on('sample-rejected', ({ reason, sample }) => {
143
+ console.warn('dropped a sample:', reason);
144
+ });
145
+
146
+ // 3. Feed samples. `context` (optional) is transient per-accept data, carried to the
147
+ // `*-updated` events this accept triggers (never written to appData).
148
+ function onClientStats(sample: ClientSample) {
149
+ observer.accept(sample, { studioVersion: '1.2.3' });
150
+ }
151
+
152
+ // 4. Tear down.
153
+ process.on('SIGINT', () => observer.close());
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Data flow
159
+
160
+ ```
161
+ client getStats() ──► ClientSample ──► observer.accept(sample, ctx?)
162
+ │
163
+ ┌────────────────────────────────┘
164
+ ▼
165
+ get-or-create ObservedCall ──► get-or-create ObservedClient ──► client.accept(sample, ctx)
166
+ │
167
+ per peerConnections[] in the sample
168
+ ▼
169
+ get-or-create ObservedPeerConnection
170
+ .accept(pcSample, ctx) updates all sub-stats,
171
+ derives deltas/bitrates/RTT, correlates remote RTP
172
+ │
173
+ metrics roll up: PeerConnection → Client → Call → Observer
174
+ │
175
+ events emitted on the Observer bus ──► your handlers
176
+ ```
177
+
178
+ - A sample **must** have `callId` and `clientId` (the library sets them, or the app does). If
179
+ either is missing, the sample is dropped and `sample-rejected` is emitted.
180
+ - Sub-entities that stop appearing in samples are garbage-collected via a "visited"
181
+ mark-and-sweep on each `ObservedPeerConnection.accept()`, emitting the corresponding
182
+ `*-removed` events.
183
+
184
+ ---
185
+
186
+ ## Entity hierarchy
187
+
188
+ | Class | Created by | Keyed on its parent as | Holds |
189
+ |-------|-----------|------------------------|-------|
190
+ | `Observer` | `new Observer(config?)` | — (root) | `observedCalls: Map<string, ObservedCall>`, global counters, the event bus |
191
+ | `ObservedCall` | `observer.createObservedCall(settings)` / lazily by `accept` | `observedCalls` | `observedClients: Map<string, ObservedClient>`, call-wide metrics, `detectors`, `scoreCalculator` |
192
+ | `ObservedClient` | `call.createObservedClient(settings)` / lazily | `observedClients` | `observedPeerConnections: Map<string, ObservedPeerConnection>`, per-client metrics |
193
+ | `ObservedPeerConnection` | lazily, from `sample.peerConnections[]` | `observedPeerConnections` | the 15 sub-stat maps below, transport/RTT/bitrate metrics |
194
+ | Sub-stats | lazily, from the `PeerConnectionSample` | maps on the PC | individual WebRTC stat objects |
195
+
196
+ `ObservedPeerConnection` sub-stat maps (all `public readonly`):
197
+
198
+ ```
199
+ observedCertificates, observedCodecs, observedDataChannels,
200
+ observedIceCandidates, observedIceCandidatesPair, observedIceTransports,
201
+ observedInboundRtps, observedInboundTracks, observedMediaPlayouts,
202
+ observedMediaSources, observedOutboundRtps, observedOutboundTracks,
203
+ observedPeerConnectionTransports, observedRemoteInboundRtps, observedRemoteOutboundRtps
204
+ ```
205
+
206
+ Each sub-stat class (`ObservedInboundRtp`, `ObservedOutboundRtp`, `ObservedInboundTrack`,
207
+ `ObservedOutboundTrack`, `ObservedDataChannel`, `ObservedIceCandidate`,
208
+ `ObservedIceCandidatePair`, `ObservedIceTransport`, `ObservedCertificate`, `ObservedCodec`,
209
+ `ObservedMediaSource`, `ObservedMediaPlayout`, `ObservedPeerConnectionTransport`,
210
+ `ObservedRemoteInboundRtp`, `ObservedRemoteOutboundRtp`) mirrors the corresponding stat
211
+ fields from the schema plus derived fields (deltas, bitrates).
212
+
213
+ ---
214
+
215
+ ## Ingestion: `accept()`, context & lifecycle
216
+
217
+ ### `observer.accept(sample, context?)`
218
+
219
+ The single entry point. It:
220
+
221
+ 1. drops + emits `sample-rejected` if the observer is closed;
222
+ 2. runs the sample through the **global accept-middleware chain** (see below);
223
+ 3. (chain terminal) drops + emits `sample-rejected` if `callId`/`clientId` is missing;
224
+ 4. gets or lazily creates the `ObservedCall` and `ObservedClient` (their `appData` comes from the
225
+ configured factories, never from `context`);
226
+ 5. delegates to `client.accept(sample, context)`, which fans out to each
227
+ `ObservedPeerConnection.accept(pcSample, context)`.
228
+
229
+ ### Accept middlewares (global pre-dispatch hook)
230
+
231
+ `observer.addAcceptMiddleware(...)` registers middlewares run on **every** sample inside
232
+ `accept()`, in order, **before** the sample is dispatched to any call or client. Each middleware
233
+ gets a `{ sample, context }` payload; it can inspect or mutate the sample (set/normalize
234
+ `callId`/`clientId`, enrich, redact) or the context, then call `next(payload)` to continue.
235
+ **Not calling `next` drops the sample** — nothing is created and no event fires. A throwing
236
+ middleware is caught and warns (the sample is dropped), never crashing `accept()`.
237
+
238
+ ```ts
239
+ import { Observer, AcceptMiddleware } from '@observertc/observer-js';
240
+
241
+ const observer = new Observer();
242
+
243
+ // derive callId/clientId from the app's own attachment, before dispatch
244
+ const route: AcceptMiddleware = ({ sample }, next) => {
245
+ sample.callId ??= sample.attachments?.roomId as string;
246
+ sample.clientId ??= sample.attachments?.peerId as string;
247
+ next({ sample });
248
+ };
249
+
250
+ // drop samples from a blocklisted client (never dispatched)
251
+ const filter: AcceptMiddleware = (payload, next) => {
252
+ if (blocked.has(payload.sample.clientId)) return; // no next() => dropped
253
+ next(payload);
254
+ };
255
+
256
+ observer.addAcceptMiddleware(route, filter);
257
+ // observer.removeAcceptMiddleware(route);
258
+ ```
259
+
260
+ This is a lightweight global injection point, distinct from the larger (not-yet-built)
261
+ `ClientSampleProcessor` pipeline in the roadmap. When no middleware is registered, `accept()`
262
+ dispatches directly with no overhead.
263
+
264
+ ### `context` (the `AcceptContext`)
265
+
266
+ ```ts
267
+ type AcceptContext = Record<string, unknown>;
268
+ ```
269
+
270
+ A single, optional, free-form object threaded down the whole accept chain
271
+ (`Observer → Client → PeerConnection`). It is **transient request-scoped data** — temporary or
272
+ contextual information the application wants available while an update is processed.
273
+
274
+ `context` is **never written to `appData`** and is **not stored** on any entity. The two are
275
+ deliberately distinct:
276
+
277
+ - **`appData`** — application-assigned extra info that identifies/decorates an entity, fixed at
278
+ creation (via `settings.appData` or the `createCallAppData` / `createClientAppData` factories),
279
+ or assigned by the app on the `*-added` events. The library never changes it.
280
+ - **`context`** — passed per `accept()`, may differ on every call, and is carried straight
281
+ through to the `*-updated` events that the `accept()` triggers, then discarded.
282
+
283
+ `client-updated` and `peer-connection-updated` carry the exact context of that sample;
284
+ `call-updated` carries the context of the client `accept()` that drove the call update (absent
285
+ for interval- or teardown-driven call updates). When no context is given, the field is absent.
286
+
287
+ ### Get-or-create helpers
288
+
289
+ If you want to create/configure entities yourself before/without samples:
290
+
291
+ ```ts
292
+ const call = observer.getOrCreateObservedCall({ callId, appData }); // ObservedCall | undefined
293
+ const client = call?.getOrCreateObservedClient({ clientId, appData }); // ObservedClient | undefined
294
+ ```
295
+
296
+ These return `undefined` (and warn) when the parent is closed; `createObservedCall`/
297
+ `createObservedClient` return the **existing** instance (and warn) if the id already exists.
298
+
299
+ ### Automatic teardown
300
+
301
+ - `closeClientIfIdleForMs` — a client with no sample for this long auto-closes.
302
+ - `closeCallIfEmptyForMs` — a call with zero clients for this long auto-closes.
303
+ - Closing cascades down (call → clients → peer connections → sub-stats), unsubscribing
304
+ listeners and emitting the `*-closed` / `*-removed` events.
305
+
306
+ ---
307
+
308
+ ## Update policies
309
+
310
+ "Update" means *recompute aggregated metrics and emit the `*-updated` event* at that level.
311
+ Both the observer and each call have a configurable trigger. Updates are **event-driven** — there
312
+ is no built-in timer. An app that wants a fixed cadence can call `observer.update()` /
313
+ `call.update()` from its own `setInterval`. With `'none'`, **nothing auto-updates** — the level
314
+ updates only when the application calls the public `update()` itself.
315
+
316
+ **Observer-level** (`ObserverConfig.updatePolicy`, default `update-when-all-call-updated`):
317
+
318
+ | Policy | Triggers `observer.update()` when… |
319
+ |--------|-------------------------------------|
320
+ | `update-on-any-call-updated` | any call updates |
321
+ | `update-when-all-call-updated` | every call has updated since the last observer update |
322
+ | `none` | never automatically — only when the app calls `observer.update()` |
323
+
324
+ **Call-level** (`ObservedCallSettings.updatePolicy`, defaulted from
325
+ `ObserverConfig.defaultCallUpdatePolicy`):
326
+
327
+ | Policy | Triggers `call.update()` when… |
328
+ |--------|--------------------------------|
329
+ | `update-on-any-client-updated` | any client in the call updates |
330
+ | `update-when-all-client-updated` | every client has updated since the last call update |
331
+ | `none` | never automatically — only when the app calls `call.update()` |
332
+
333
+ ---
334
+
335
+ ## The event bus
336
+
337
+ This is the primary API. **Subscribe on the `Observer` instance** — it is the single emitter
338
+ for the entire hierarchy. The `ObservedCall` / `ObservedClient` / `ObservedPeerConnection`
339
+ objects are themselves `EventEmitter`s too, but those local events are reserved for internal
340
+ lifecycle/teardown wiring (see [Local lifecycle events](#local-lifecycle-events)); application
341
+ code should use the Observer bus.
342
+
343
+ ### Payload shape: ancestry + subject
344
+
345
+ Every Observer event delivers exactly **one argument: a payload object**. The payload always
346
+ contains the ancestry from the observer down to the entity that raised it, plus any event-
347
+ specific subject:
348
+
349
+ ```ts
350
+ type ObserverEventBase = { observer: Observer };
351
+ type ObservedCallScope = ObserverEventBase & { observedCall: ObservedCall };
352
+ type ObservedClientScope = ObservedCallScope & { observedClient: ObservedClient };
353
+ type ObservedPeerConnectionScope = ObservedClientScope & { observedPeerConnection: ObservedPeerConnection };
354
+ ```
355
+
356
+ So a peer-connection-level event hands you the observer, call, client, **and** peer connection:
357
+
358
+ ```ts
359
+ observer.on('inbound-rtp-added', ({ observer, observedCall, observedClient, observedPeerConnection, observedInboundRtp }) => {
360
+ // all five are present and correctly typed
361
+ });
362
+ ```
363
+
364
+ `observer.on/off/once/emit` are fully typed against the event map — the handler argument is
365
+ inferred per event name.
366
+
367
+ ### Event catalogue
368
+
369
+ All payloads include the ancestry for their level (above). The **Extra** column lists the
370
+ additional field(s) on top of that scope.
371
+
372
+ #### Observer level — scope `{ observer }`
373
+
374
+ | Event | Extra payload | Fires when |
375
+ |-------|---------------|-----------|
376
+ | `observer-updated` | — | `observer.update()` ran (per the observer update policy) |
377
+ | `observer-closed` | — | `observer.close()` |
378
+ | `sample-rejected` | `{ reason: 'observer-closed' \| 'missing-callId' \| 'missing-clientId', sample: ClientSample }` | a sample was dropped by `accept()` |
379
+
380
+ #### Mediasoup level — scope `{ observer, observedMediasoupRouter }`
381
+
382
+ | Event | Extra | Fires when |
383
+ |-------|-------|-----------|
384
+ | `mediasoup-router-added` | — | `observer.createObservedMediasoupRouter(...)` registered a router |
385
+ | `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. **Opt-in** via `matchPeerConnectionByWebRtcTransportId: true`. |
386
+ | `mediasoup-router-removed` | — | the underlying mediasoup router closed (its `router.observer` `close` fired) |
387
+
388
+ See [Mediasoup router observation](#mediasoup-router-observation) for the full design and examples.
389
+
390
+ #### Call level — scope `{ observer, observedCall }`
391
+
392
+ | Event | Extra | Fires when |
393
+ |-------|-------|-----------|
394
+ | `call-added` | — | a call is created |
395
+ | `call-updated` | `{ context?: AcceptContext }` | `call.update()` ran |
396
+ | `call-closed` | — | the call closed |
397
+ | `call-empty` | — | last client left the call |
398
+ | `call-not-empty` | — | first client joined a previously-empty call |
399
+ | `call-issue` | `{ issue: ClientIssue }` | `call.addIssue(...)` (server-side detector finding) |
400
+
401
+ #### Client level — scope `{ observer, observedCall, observedClient }`
402
+
403
+ | Event | Extra | Fires when |
404
+ |-------|-------|-----------|
405
+ | `client-added` | — | a client is created |
406
+ | `client-sink-created` | `{ sink: ClientSampleSink }` | a per-client sink was created (only when `createClientSink` returns one); fires right after `client-added` |
407
+ | `client-updated` | `{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext }` | the client processed a sample |
408
+ | `client-closed` | — | the client closed |
409
+ | `client-joined` | — | first `CLIENT_JOINED` event seen |
410
+ | `client-left` | — | `CLIENT_LEFT` seen (or inferred on close) |
411
+ | `client-rejoined` | `{ timestamp: number }` | a later `CLIENT_JOINED` after an earlier join |
412
+ | `client-issue` | `{ issue: ClientIssue }` | a client-reported issue arrived, or `client.addIssue(...)` |
413
+ | `client-metadata` | `{ metaData: ClientMetaData }` | a client meta item arrived |
414
+ | `client-extension-stats` | `{ extensionStats: ExtensionStat }` | an app-defined extension stat arrived |
415
+ | `client-event` | `{ event: ClientEvent }` | any client event was processed |
416
+
417
+ #### Peer-connection level — scope `{ observer, observedCall, observedClient, observedPeerConnection }`
418
+
419
+ | Event | Extra | Notes |
420
+ |-------|-------|-------|
421
+ | `peer-connection-added` / `peer-connection-closed` | — | lifecycle of the PC |
422
+ | `peer-connection-updated` | `{ context?: AcceptContext }` | the PC processed a sample |
423
+ | `ice-connection-state-changed` / `ice-gathering-state-changed` / `connection-state-changed` | `{ state: string }` | driven by client events |
424
+ | `selected-candidate-pair-changed` | — | *declared; not currently emitted* |
425
+ | `inbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedInboundTrack }` | |
426
+ | `outbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedOutboundTrack }` | |
427
+ | `inbound-rtp-added` / `-updated` / `-removed` | `{ observedInboundRtp }` | `-updated` fires every tick |
428
+ | `outbound-rtp-added` / `-updated` / `-removed` | `{ observedOutboundRtp }` | `-updated` fires every tick |
429
+ | `remote-inbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteInboundRtp }` | |
430
+ | `remote-outbound-rtp-added` / `-updated` / `-removed` | `{ observedRemoteOutboundRtp }` | |
431
+ | `data-channel-added` / `-updated` / `-removed` | `{ observedDataChannel }` | |
432
+ | `ice-candidate-added` / `-updated` / `-removed` | `{ observedIceCandidate }` | |
433
+ | `ice-candidate-pair-added` / `-updated` / `-removed` | `{ observedIceCandidatePair }` | |
434
+ | `ice-transport-added` / `-updated` / `-removed` | `{ observedIceTransport }` | |
435
+ | `codec-added` / `-updated` / `-removed` | `{ observedCodec }` | |
436
+ | `media-source-added` / `-updated` / `-removed` | `{ observedMediaSource }` | |
437
+ | `media-playout-added` / `-updated` / `-removed` | `{ observedMediaPlayout }` | |
438
+ | `peer-connection-transport-added` / `-updated` / `-removed` | `{ observedPeerConnectionTransport }` | |
439
+ | `certificate-added` / `-updated` / `-removed` | `{ observedCertificate }` | |
440
+
441
+ > **Volume note.** The `*-updated` sub-stat events fire on every peer-connection `accept()`
442
+ > (i.e. per sample, per stream). For high-throughput servers, subscribe only to what you need,
443
+ > or read fields off the entities on `client-updated` / `call-updated` instead.
444
+
445
+ ### Local lifecycle events
446
+
447
+ These remain on the individual entities (not the bus), for teardown/coordination. You can
448
+ listen to them, but prefer the bus equivalents above for application logic.
449
+
450
+ | Entity | Local events |
451
+ |--------|--------------|
452
+ | `ObservedCall` | `update`, `newclient`, `empty`, `not-empty`, `close` |
453
+ | `ObservedClient` | `update` (`sample`, `elapsedTimeInMs`), `close`, `joined`, `left` |
454
+ | `ObservedPeerConnection` | `removed-inbound-track`, `removed-outbound-track`, `close` |
455
+
456
+ ---
457
+
458
+ ## API reference
459
+
460
+ ### `Observer`
461
+
462
+ ```ts
463
+ new Observer<AppData>(config?: ObserverConfig<AppData>)
464
+
465
+ type ObserverConfig<AppData = Record<string, unknown>> = {
466
+ updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'none';
467
+ defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
468
+ appData?: AppData;
469
+ closeClientIfIdleForMs?: number;
470
+ closeCallIfEmptyForMs?: number;
471
+ // appData factories — run when an entity is created without explicit appData
472
+ // (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
473
+ createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
474
+ createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
475
+ // sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
476
+ createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
477
+ // track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
478
+ createTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
479
+ };
480
+ ```
481
+
482
+ **appData factories.** Instead of pre-creating a call/client (or assigning on `call-added` /
483
+ `client-added`) just to enrich its `appData`, register a factory once. It runs in the entity's
484
+ constructor whenever it's created without an explicit `settings.appData` — including the lazy
485
+ creation inside `accept()`. The `client` factory receives the already-created parent
486
+ `observedCall`, so it can derive fields from it. `appData` is application-owned and is never
487
+ modified by the `accept()` context.
488
+
489
+ ```ts
490
+ const observer = new Observer({
491
+ createCallAppData: ({ callId }) => ({ callId, startedAt: Date.now(), region: 'eu' }),
492
+ createClientAppData: ({ clientId, observedCall }) => ({ clientId, region: observedCall.appData.region }),
493
+ });
494
+ ```
495
+
496
+ Key members:
497
+
498
+ - `accept(sample: ClientSample, context?: AcceptContext): void`
499
+ - `addAcceptMiddleware(...mw: AcceptMiddleware[]): this` / `removeAcceptMiddleware(...mw): this` — global pre-dispatch sample hooks (see [Accept middlewares](#accept-middlewares-global-pre-dispatch-hook))
500
+ - `getObservedCall<T>(callId): ObservedCall<T> | undefined`
501
+ - `createObservedCall<T>(settings): ObservedCall<T> | undefined`
502
+ - `getOrCreateObservedCall<T>(settings): ObservedCall<T> | undefined`
503
+ - `update(): void` — force an aggregation/`observer-updated` tick
504
+ - `close(): void`
505
+ - `readonly observedCalls: Map<string, ObservedCall>`
506
+ - `readonly observedTURN: ObservedTURN`
507
+ - `get appData()`, `get numberOfCalls()`
508
+ - counters: `numberOfClients`, `numberOfClientsUsingTurn`, `numberOfInboundRtpStreams`,
509
+ `numberOfOutboundRtpStreams`, `numberOfDataChannels`, `numberOfPeerConnections`,
510
+ `totalAddedCall`, `totalRemovedCall`, `closed`
511
+ - `on/off/once/emit` typed against the [event map](#event-catalogue)
512
+
513
+ ### `ObservedCall`
514
+
515
+ ```ts
516
+ type ObservedCallSettings<AppData = Record<string, unknown>> = {
517
+ updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'none';
518
+ callId: string;
519
+ appData?: AppData;
520
+ closeCallIfEmptyForMs?: number;
521
+ };
522
+ ```
523
+
524
+ Key members:
525
+
526
+ - `readonly callId: string`, `appData: AppData`
527
+ - `readonly observedClients: Map<string, ObservedClient>`, `get numberOfClients()`
528
+ - `getObservedClient<T>(clientId)`, `createObservedClient<T>(settings)`, `getOrCreateObservedClient<T>(settings)` (all `… | undefined`)
529
+ - `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
530
+ - `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
531
+ - `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
532
+ - `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
533
+ - aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
534
+ `numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
535
+ `clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
536
+ - `update()`, `close()`
537
+
538
+ ### `ObservedClient`
539
+
540
+ ```ts
541
+ type ObservedClientSettings<AppData = Record<string, unknown>> = {
542
+ clientId: string;
543
+ appData?: AppData;
544
+ closeClientIfIdleForMs?: number;
545
+ };
546
+ ```
547
+
548
+ Key members:
549
+
550
+ - `readonly clientId: string`, `appData: AppData`, `readonly call: ObservedCall`
551
+ - `readonly observedPeerConnections: Map<string, ObservedPeerConnection>`
552
+ - `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error`
553
+ - **Injection API** (queue app data to be merged into the next sample processing):
554
+ `injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`,
555
+ `injectExtensionStat(ExtensionStat)`, `injectAttachment(attachments: Record<string, unknown>)`
556
+ - **Direct add API** (process immediately): `addIssue(ClientIssue)`, `addMetadata(ClientMetaData)`,
557
+ `addExtensionStats(ExtensionStat)`
558
+ - Metrics (current/derived): `currentAvgRttInMs?`, `currentMinRttInMs?`, `currentMaxRttInMs?`,
559
+ `receivingAudioBitrate`, `receivingVideoBitrate`, `sendingAudioBitrate`, `sendingVideoBitrate`,
560
+ `usingTURN`, `usingTCP`, `availableIncomingBitrate`, `availableOutgoingBitrate`
561
+ - Counts: `numberOfInboundRtpStreams`, `numberOfOutboundRtpStreams`, `numberOfInbundTracks`,
562
+ `numberOfOutboundTracks`, `numberOfDataChannels`, `numberOfPeerConnections`
563
+ - Per-tick deltas: `deltaReceivedAudioBytes`, `deltaSentAudioBytes`, … (see source for the full set)
564
+ - Lifecycle: `joinedAt?`, `leftAt?`, `closedAt?`, `closed`, `get score()`
565
+ - Metadata: `browser?`, `engine?`, `platform?`, `operationSystem?`, `mediaDevices`, `mediaConstraints`
566
+ - `accept(sample, context?)`, `close()`
567
+
568
+ ### `ObservedPeerConnection`
569
+
570
+ Key members:
571
+
572
+ - `readonly peerConnectionId: string`, `readonly client: ObservedClient`, `appData?`
573
+ - The 15 `observed*` sub-stat `Map`s (listed [above](#entity-hierarchy)), plus array getters:
574
+ `codecs`, `inboundRtps`, `outboundRtps`, `remoteInboundRtps`, `remoteOutboundRtps`,
575
+ `mediaSources`, `mediaPlayouts`, `dataChannels`, `peerConnectionTransports`, `iceTransports`,
576
+ `iceCandidates`, `iceCandidatePairs`, `certificates`, `selectedIceCandidatePairs`,
577
+ `selectedIceCandiadtePairForTurn`
578
+ - State: `connectionState?`, `iceConnectionState?`, `iceGatheringState?`, `usingTURN`, `usingTCP`
579
+ - Metrics: `currentRttInMs?`, `currentJitter?`, `availableIncomingBitrate`,
580
+ `availableOutgoingBitrate`, sending/receiving bitrates, packet rates, and `total*` / `delta*`
581
+ byte/packet counters
582
+ - `accept(pcSample, context?)`, `close()`, `get score()`
583
+
584
+ **Remote-RTP correlation (derived).** During `accept()`, receiver/sender reports are linked
585
+ to the local streams by `remoteId` (fallback SSRC) and surfaced as fields:
586
+
587
+ - on `ObservedOutboundRtp`: `remoteRttInMs?`, `remoteFractionLost?`, `remoteJitter?`, `remotePacketsLost?`
588
+ - on `ObservedInboundRtp`: `remoteRttInMs?`, `remoteBytesSent?`, `remotePacketsSent?`, `remoteTimestamp?`
589
+
590
+ These are reset each tick and only set when the matching remote report is present.
591
+
592
+ ---
593
+
594
+ ## Schema types (`ClientSample`)
595
+
596
+ The shape of an accepted sample (re-exported from this package; identical to
597
+ `@observertc/schemas`). Only the top level is shown — each stat object mirrors the standard
598
+ WebRTC `getStats()` dictionaries plus a few extensions.
599
+
600
+ ```ts
601
+ type ClientSample = {
602
+ timestamp: number; // client wall-clock (ms epoch)
603
+ callId?: string; // set by you or the library
604
+ clientId?: string; // set by you or the library
605
+ score?: number; // optional client-computed score (0..5)
606
+ attachments?: Record<string, unknown>;
607
+ peerConnections?: PeerConnectionSample[];
608
+ clientEvents?: ClientEvent[];
609
+ clientIssues?: ClientIssue[];
610
+ clientMetaItems?: ClientMetaData[];
611
+ extensionStats?: ExtensionStat[];
612
+ };
613
+
614
+ type PeerConnectionSample = {
615
+ peerConnectionId: string;
616
+ attachments?: Record<string, unknown>; // e.g. { direction: 'send'|'recv', producerId, consumerId, label }
617
+ score?: number;
618
+ inboundTracks?; outboundTracks?;
619
+ codecs?;
620
+ inboundRtps?; remoteInboundRtps?;
621
+ outboundRtps?; remoteOutboundRtps?;
622
+ mediaSources?; mediaPlayouts?;
623
+ peerConnectionTransports?; dataChannels?;
624
+ iceTransports?; iceCandidates?; iceCandidatePairs?;
625
+ certificates?;
626
+ };
627
+
628
+ type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
629
+ type ClientIssue = { type: string; payload?: string; timestamp?: number }; // also used for call-issue
630
+ type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
631
+ type ExtensionStat = { type: string; payload?: string };
632
+ ```
633
+
634
+ `payload` fields are JSON strings; the library parses the ones it understands.
635
+
636
+ **`ClientEventTypes`** (enum of known `event.type` values): `CLIENT_JOINED`, `CLIENT_LEFT`,
637
+ `PEER_CONNECTION_OPENED/CLOSED/STATE_CHANGED`, `MEDIA_TRACK_ADDED/REMOVED/MUTED/UNMUTED/RESUMED`,
638
+ `ICE_GATHERING_STATE_CHANGED`, `ICE_CONNECTION_STATE_CHANGED`, `DATA_CHANNEL_OPEN/CLOSED/ERROR`,
639
+ `NEGOTIATION_NEEDED`, `SIGNALING_STATE_CHANGE`, `ICE_CANDIDATE`, `ICE_CANDIDATE_ERROR`, and the
640
+ mediasoup set `PRODUCER_*` / `CONSUMER_*` / `DATA_PRODUCER_*` / `DATA_CONSUMER_*`.
641
+
642
+ **`ClientMetaTypes`** (enum of known meta `type` values): `MEDIA_CONSTRAINT`, `MEDIA_DEVICE`,
643
+ `MEDIA_DEVICES_SUPPORTED_CONSTRAINTS`, `USER_MEDIA_ERROR`, `LOCAL_SDP`, `OPERATION_SYSTEM`,
644
+ `ENGINE`, `PLATFORM`, `BROWSER`.
645
+
646
+ ### Worked example: a real `ClientSample`
647
+
648
+ Two consecutive samples from one participant ("Guest" in room `qq0iwfnd`) of an
649
+ edumeet/mediasoup call show what actually flows through `accept()`: a rich **join snapshot**,
650
+ then lean **steady-state ticks**.
651
+
652
+ **Sample 1 — the join snapshot.** Carries the one-off lifecycle `clientEvents` and device
653
+ `clientMetaItems` alongside the first stats. (Abbreviated; ids and times are from the real log.)
654
+
655
+ ```jsonc
656
+ {
657
+ "timestamp": 1780572332518,
658
+ "callId": "d3dbf2f5-79be-4cb8-9d43-fb404f07ef27",
659
+ "clientId": "c926983c-4468-4046-ae8c-a9cabe1a1868",
660
+ "score": 0, // no quality measured yet on the join tick
661
+ "attachments": { "displayName": "Guest", "roomId": "qq0iwfnd", "actualSessionId": "d3dbf2f5-…" },
662
+
663
+ "clientEvents": [ // chronological lifecycle (12 in the real sample)
664
+ { "type": "CLIENT_JOINED", "timestamp": 1780572324515 },
665
+ { "type": "PEER_CONNECTION_OPENED", "timestamp": 1780572326790 }, // pc=b81c8d9d (media)
666
+ { "type": "ICE_GATHERING_STATE_CHANGED", "timestamp": 1780572326811 }, // → gathering
667
+ { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326812 }, // → connecting
668
+ { "type": "PRODUCER_ADDED", "timestamp": 1780572326821 }, // producer=1abdaf82 (audio)
669
+ { "type": "MEDIA_TRACK_ADDED", "timestamp": 1780572326821 }, // track=36ae42df (audio)
670
+ { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326827 }, // → connected
671
+ { "type": "PRODUCER_ADDED", "timestamp": 1780572326837 }, // producer=ba06a35b (video)
672
+ { "type": "DATA_PRODUCER_CREATED", "timestamp": 1780572326853 }
673
+ ],
674
+
675
+ "clientMetaItems": [ // environment & devices, one-off (10 in the real sample)
676
+ { "type": "USER_AGENT_DATA", "payload": "{…Chrome 148 / macOS…}" },
677
+ { "type": "MEDIA_DEVICE", "payload": "{…\"BRIO 4K Stream Edition\"…}" }
678
+ // …mic / camera / speaker devices…
679
+ ],
680
+
681
+ "peerConnections": [
682
+ {
683
+ "peerConnectionId": "b81c8d9d-…", // the media PC — Guest publishes to the SFU
684
+ "outboundRtps": [ /* audio + video */ ],
685
+ "outboundTracks": [ /* mic + camera: label, settings, capabilities */ ],
686
+ "remoteInboundRtps": [ /* RTCP feedback from the SFU */ ],
687
+ "codecs": [ /* … */ ], "iceTransports": [ /* … */ ],
688
+ "iceCandidatePairs": [ /* … */ ], "dataChannels": [ /* … */ ]
689
+ },
690
+ { "peerConnectionId": "8635acb7-…", "peerConnectionTransports": [ /* … */ ] } // signaling-only PC
691
+ ]
692
+ }
693
+ ```
694
+
695
+ What `accept()` does with it, in order — each step emits on the bus with full ancestry:
696
+
697
+ 1. lazily creates the `ObservedCall` → **`call-added`**;
698
+ 2. creates the `ObservedClient` → **`client-added`**, then **`client-joined`** (from `CLIENT_JOINED`);
699
+ 3. creates an `ObservedPeerConnection` per entry → **`peer-connection-added`** (×2 here);
700
+ 4. creates an `ObservedOutboundTrack` per track → **`outbound-track-added`**, plus the matching
701
+ **`outbound-rtp-added`**;
702
+ 5. replays the device list as **`client-metadata`** events and the lifecycle items as
703
+ **`client-event`**; and finally **`client-updated`** for the whole tick.
704
+
705
+ `attachments.roomId` lands on `observedClient.attachments` (read it on `client-updated`, **not** at
706
+ creation — see [Ingestion](#ingestion-accept-context--lifecycle)).
707
+
708
+ **Sample 2 — a steady-state tick** (~8 s later): same `callId` / `clientId`, **no** new
709
+ `clientEvents` or `clientMetaItems`, just refreshed `peerConnections` stats. Each PC now scores `5`
710
+ and the aggregate client `score` is `4.74` — a healthy call. This is the shape of nearly every
711
+ sample: each tick refreshes metrics and fires the `*-updated` events, while the heavy join
712
+ snapshot happens only once.
713
+
714
+ ---
715
+
716
+ ## Detectors (server-side extension point)
717
+
718
+ `observer-js` deliberately ships **no built-in detectors**. Per-client signals — packet loss,
719
+ jitter, RTT, freezes, etc. — are already detectable on the client and arrive on samples as
720
+ `clientIssues` (surfaced via `client-issue`). Server-side detection should focus on what only
721
+ the server can see by **correlating data across the clients of a call**.
722
+
723
+ The hook lives on **`ObservedCall`**:
724
+
725
+ ```ts
726
+ import { Observer, Detector } from '@observertc/observer-js';
727
+
728
+ class MyCrossClientDetector implements Detector {
729
+ readonly name = 'my-detector';
730
+ constructor(private readonly call /* : ObservedCall */) {}
731
+ update() { // called on every call.update()
732
+ // …inspect this.call.observedClients across participants…
733
+ if (/* condition only visible server-side */ false) {
734
+ this.call.addIssue({ type: this.name, payload: JSON.stringify({ /* … */ }), timestamp: Date.now() });
735
+ // → emitted on the bus as 'call-issue'
736
+ }
737
+ }
738
+ }
739
+
740
+ const observer = new Observer();
741
+ observer.on('call-added', ({ observedCall }) => {
742
+ observedCall.detectors.add(new MyCrossClientDetector(observedCall));
743
+ });
744
+ observer.on('call-issue', ({ observedCall, issue }) => { /* react */ });
745
+ ```
746
+
747
+ `Detector` interface and the registry:
748
+
749
+ ```ts
750
+ interface Detector { readonly name: string; update(): void; }
751
+
752
+ class Detectors {
753
+ add(d: Detector): void;
754
+ remove(d: Detector): void;
755
+ clear(): void;
756
+ update(): void; // called by ObservedCall.update(); guards each detector in try/catch
757
+ get listOfNames(): string[];
758
+ }
759
+ ```
760
+
761
+ ---
762
+
763
+ ## Remote track resolution (mediasoup / SFU)
764
+
765
+ In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
766
+ tracks (one **publisher** → many **subscribers**). Correlation is **opt-in** per observer: set
767
+ `ObserverConfig.createTrackResolver`, a factory invoked when each call is created that returns the
768
+ call's `RemoteTrackResolver` (or `undefined` for none).
769
+
770
+ `RemoteTrackResolver` is a generic, strategy-driven class. It subscribes to the bus (filtered to
771
+ its call) and links tracks by **publisher id** — the link key — maintaining the links directly on
772
+ the tracks: `inboundTrack.remoteOutboundTrack` and `outboundTrack.remoteInboundTracks: Set`.
773
+
774
+ ```ts
775
+ import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
776
+
777
+ const observer = new Observer({
778
+ createTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
779
+ });
780
+
781
+ // later, given tracks (links are kept up to date as tracks come and go):
782
+ const source = inboundTrack.remoteOutboundTrack; // the publishing ObservedOutboundTrack
783
+ const receivers = [ ...outboundTrack.remoteInboundTracks ]; // the subscribing ObservedInboundTrack[]
784
+ ```
785
+
786
+ Two built-in factories ship: `createDefaultMediasoupRemoteTrackResolverFactory()` (publisher =
787
+ `attachments.producerId`, subscriber = `attachments.consumerId`) and
788
+ `createP2pRemoteTrackResolverFactory()` (matches by RTP **SSRC**, preserved end-to-end in p2p).
789
+
790
+ For any other topology, build a `RemoteTrackResolver` with your own key resolvers — the publisher
791
+ id is just whatever links a subscribed track to the published one:
792
+
793
+ ```ts
794
+ import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
795
+
796
+ const observer = new Observer({
797
+ createTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
798
+ resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
799
+ resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
800
+ resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
801
+ }),
802
+ });
803
+ ```
804
+
805
+ For the mediasoup factory, the application puts `producerId` / `consumerId` (and optionally
806
+ `direction`, `label`) into the track `attachments`.
807
+
808
+ ---
809
+
810
+ ## Mediasoup router observation
811
+
812
+ Everything above is built from the **client-reported** `ClientSample`. When you run a
813
+ [mediasoup](https://mediasoup.org) SFU you also have the **server's own** ground truth — its
814
+ routers, transports, producers, consumers and data channels, with exact lifetimes and state
815
+ transitions. `ObservedMediasoupRouter` captures that server-side view into a
816
+ **`MediasoupRouterSample`**, completely independent of the client sample pipeline.
817
+
818
+ ### The concept
819
+
820
+ You hand the observer a live mediasoup `Router`; it attaches to mediasoup's own `observer` API and,
821
+ from then on, **passively records** the router's topology and lifecycle — with no polling and no
822
+ changes to your media code:
823
+
824
+ - new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, and ICE state
825
+ transitions;
826
+ - producers (codec, SSRCs/RIDs, `pause`/`resume`) and consumers (`pause`/`resume`,
827
+ `producerPaused`/`producerResumed`);
828
+ - data producers and data consumers;
829
+ - `createdAt` / `closedAt` for every entity above.
830
+
831
+ All of it accumulates on `observedMediasoupRouter.sample` (a `MediasoupRouterSample` — see
832
+ [`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts)). This is a *Sample*, not a
833
+ *Report*: it mirrors the naming of `ClientSample` and is yours to snapshot, persist, or correlate.
834
+
835
+ ### Matching peer connections — by **event**, not by storage
836
+
837
+ The observer correlates the SFU side with the client side **at the peer-connection level**: a
838
+ mediasoup WebRTC transport and a client's `RTCPeerConnection` share the same id, so whenever an
839
+ observed peer connection's id matches one of the router's WebRTC transport ids, that's a match.
840
+
841
+ **The observer does not store the router (or its sample) on any entity.** Instead, for **every**
842
+ matching peer connection it emits **`mediasoup-router-matched-with-peer-connection`** and steps
843
+ back — *your application* decides what the pairing means. The payload carries the full peer-connection
844
+ ancestry, so you get the router **and** the matched `observedPeerConnection`, `observedClient` and
845
+ `observedCall` in one place. Stamp the `routerId` into the peer connection's / client's `appData`,
846
+ build your own index, attach the server sample to the call in your database — whatever fits.
847
+
848
+ This matching is **opt-in**: pass `matchPeerConnectionByWebRtcTransportId: true` to
849
+ `createObservedMediasoupRouter`. When enabled, as peer connections are observed
850
+ (`peer-connection-added`) the observer checks whether the peer connection's id is one of the router's
851
+ WebRTC transport ids; on a hit it emits — once per matching peer connection — and keeps watching, so a
852
+ router serving many participants emits one match per participant's transport. When the flag is omitted
853
+ or `false`, no matching is performed and the event never fires. The internal listener is removed
854
+ automatically when the router closes or the observer closes.
855
+
856
+ When the underlying mediasoup router closes, its `close` propagates to `ObservedMediasoupRouter`,
857
+ which emits **`mediasoup-router-removed`**. That is your cue to do whatever cleanup or persistence
858
+ you want with the now-final `sample` — again, the observer itself keeps nothing.
859
+
860
+ ### Options — `observer.createObservedMediasoupRouter(settings)`
861
+
862
+ | Field | Type | Required | Meaning |
863
+ |-------|------|----------|---------|
864
+ | `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer` |
865
+ | `routerId` | `string` | yes | your id for the router (the sample also carries `router.id`) |
866
+ | `appData` | `Record<string, unknown>` | no | application-owned bag on the `ObservedMediasoupRouter` |
867
+ | `attachments` | `Record<string, unknown>` | no | free-form data copied onto `sample.attachments` |
868
+ | `matchPeerConnectionByWebRtcTransportId` | `boolean` | no | opt in to peer-connection matching: emit `mediasoup-router-matched-with-peer-connection` for each peer connection whose id matches one of the router's WebRTC transport ids. Omitted / `false` → no matching, the event never fires |
869
+
870
+ Peer-connection matching is **off by default**; enable it with
871
+ `matchPeerConnectionByWebRtcTransportId: true`. Returns the `ObservedMediasoupRouter`, or `undefined`
872
+ if the observer is closed (a router with the same id returns the existing instance — both warn).
873
+
874
+ Useful members on the returned object: `.sample` (the `MediasoupRouterSample`), `.appData`,
875
+ `.attachments` (getter over `sample.attachments`), `.webrtcTransportIds: Set<string>`, `.id`,
876
+ `.close()`.
877
+
878
+ ### Example
879
+
880
+ ```ts
881
+ import { Observer } from '@observertc/observer-js';
882
+ import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from '@observertc/observer-js';
883
+
884
+ const observer = new Observer();
885
+
886
+ // 1) Feed client samples as usual so the observer knows about calls, clients & peer connections.
887
+ // (e.g. transport-layer: observer.accept(clientSample, context))
888
+
889
+ // 2) Observe the SFU side, opting in to peer-connection matching for this router's transports.
890
+ const router = /* your mediasoup router */ undefined as any;
891
+ const observedRouter = observer.createObservedMediasoupRouter({
892
+ router,
893
+ routerId: router.id,
894
+ matchPeerConnectionByWebRtcTransportId: true,
895
+ });
896
+
897
+ // 3) Every peer connection whose id matches one of the router's WebRTC transport ids fires this —
898
+ // WE decide what to do with each pairing. The payload carries the full ancestry.
899
+ observer.on('mediasoup-router-matched-with-peer-connection',
900
+ ({ observedMediasoupRouter, observedCall, observedClient, observedPeerConnection }:
901
+ ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
902
+ // e.g. remember which router serves this peer connection / client…
903
+ (observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
904
+ // …or index the server sample by call in your own store:
905
+ myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.sample);
906
+ },
907
+ );
908
+
909
+ // 4) The router closed — WE decide what to persist/forward with the final sample.
910
+ observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
911
+ myStore.saveRouterSample(observedMediasoupRouter.sample);
912
+ });
913
+
914
+ // (optional) react to the router being registered at all:
915
+ observer.on('mediasoup-router-added', ({ observedMediasoupRouter }) => {
916
+ console.log('observing router', observedMediasoupRouter.id);
917
+ });
918
+ ```
919
+
920
+ ### Why event-driven instead of storing on the call
921
+
922
+ - **Loose coupling.** The call model stays about client telemetry; the SFU view lives on its own
923
+ object and is associated only if and how *you* choose.
924
+ - **You own the association.** One router serves many peer connections (across clients and calls),
925
+ and the right place to keep that mapping is application-specific — so the observer hands you each
926
+ peer-connection match and the final sample, and gets out of the way.
927
+ - **No silent accumulation.** Nothing is appended to `ObservedCall`, so there is no hidden growth or
928
+ lifetime you have to reason about; the router sample lives exactly as long as you keep a reference.
929
+
930
+ ---
931
+
932
+ ## Sinks (per-client sample persistence)
933
+
934
+ A **sink** receives the samples a client accepts — for archival, streaming, or later offline
935
+ replay. Each `ObservedClient` gets its **own** sink, produced by the
936
+ `ObserverConfig.createClientSink` factory when the client is created (return `undefined` for no
937
+ sink). The client pushes every accepted sample to its sink, and `end()`s it on close.
938
+
939
+ ### The `ClientSampleSink` base class
940
+
941
+ `ClientSampleSink` is an **abstract base class** (a typed `EventEmitter`). You create a sink by
942
+ **subclassing it** and implementing `write` and `end`. It is **object-mode**: `write` receives
943
+ the `ClientSample` *object*, so each sink decides how (or whether) to serialize it — JSON line,
944
+ protobuf, a remote POST body, an in-memory push, etc.
945
+
946
+ ```ts
947
+ import { ClientSampleSink, ClientSample } from '@observertc/observer-js';
948
+
949
+ abstract class ClientSampleSink /* extends EventEmitter */ {
950
+ abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure
951
+ abstract end(): void; // flush; emit `close` when the destination is ready
952
+
953
+ // typed events (inherited): the listener signature is inferred from the event name
954
+ on(event: 'close' | 'finish' | 'drain', listener: () => void): this;
955
+ on(event: 'error', listener: (err: Error) => void): this;
956
+ // ...and the matching `once` / `off` / `emit`
957
+ }
958
+ ```
959
+
960
+ | Event | Meaning |
961
+ |-------|---------|
962
+ | `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" |
963
+ | `error` | the destination failed |
964
+ | `finish` | `end()` was processed and queued data flushed (before `close`) |
965
+ | `drain` | the buffer drained after backpressure; safe to write more |
966
+
967
+ The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited),
968
+ `end()`s the sink when the client closes, and attaches an `error` listener so a failing sink
969
+ can't crash the process (it also catches throws from `write`/`end`). The application — which
970
+ created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't
971
+ awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**.
972
+
973
+ ### Built-in sinks
974
+
975
+ ```ts
976
+ import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
977
+
978
+ const observer = new Observer({
979
+ // one ./stats/<callId>__<clientId>.jsonl per client
980
+ createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
981
+ });
982
+
983
+ // React when a sink is created for a client:
984
+ observer.on('client-sink-created', ({ observedClient, sink }) => {
985
+ sink.on('close', () => {
986
+ // the file is fully flushed and its fd closed — ready to upload, move, etc.
987
+ });
988
+ });
989
+ ```
990
+
991
+ | Export | Signature | Notes |
992
+ |--------|-----------|-------|
993
+ | `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
994
+ | `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
995
+ | `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready |
996
+ | `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` |
997
+
998
+ `serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for
999
+ the JSONL sinks (e.g. to redact or reshape before writing).
1000
+
1001
+ ### Reading sink-specific info (e.g. the file path)
1002
+
1003
+ The bus hands you the sink as the base `ClientSampleSink`. To read information specific to a sink
1004
+ type — for a file sink, where it was written — **narrow with `instanceof`** and read the sink's
1005
+ public fields. `JsonlFileSink` exposes `path`:
1006
+
1007
+ ```ts
1008
+ import { JsonlFileSink } from '@observertc/observer-js';
1009
+
1010
+ observer.on('client-sink-created', ({ observedClient, sink }) => {
1011
+ if (sink instanceof JsonlFileSink) {
1012
+ const { path } = sink; // the file this client's samples go to
1013
+ sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready
1014
+ }
1015
+ });
1016
+ ```
1017
+
1018
+ The general pattern: each concrete sink exposes whatever it wants as `public readonly` fields, and
1019
+ consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same.
1020
+
1021
+ ### Writing your own sink
1022
+
1023
+ Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
1024
+ destination (a remote endpoint, a message queue, an object store, …):
1025
+
1026
+ ```ts
1027
+ import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js';
1028
+
1029
+ class HttpSink extends ClientSampleSink {
1030
+ private buffer: ClientSample[] = [];
1031
+ constructor(private readonly url: string) { super(); }
1032
+
1033
+ write(sample: ClientSample): boolean {
1034
+ this.buffer.push(sample); // batch; decide your own backpressure
1035
+ return true;
1036
+ }
1037
+ end(): void {
1038
+ fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) })
1039
+ .then(() => this.emit('close')) // signal "destination ready"
1040
+ .catch((err) => this.emit('error', err));
1041
+ }
1042
+ }
1043
+
1044
+ const createClientSink: ClientSampleSinkFactory = ({ clientId, observedCall }) =>
1045
+ new HttpSink(`https://stats.example.com/${observedCall.callId}/${clientId}`);
1046
+
1047
+ const observer = new Observer({ createClientSink });
1048
+ ```
1049
+
1050
+ `observedClient.sink?` exposes the created sink; the `client-sink-created` event delivers it on
1051
+ the bus with full ancestry. `ClientSampleSinkFactory` is
1052
+ `(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`.
1053
+
1054
+ ---
1055
+
1056
+ ## Injecting data into a client
1057
+
1058
+ Sometimes the application holds data that belongs on a client's record but isn't part of the
1059
+ client-reported `ClientSample` — a room id or display name, an application-level event
1060
+ (*"recording started"*), a server-detected issue, an extension stat, or a device/meta item.
1061
+ `ObservedClient` exposes **injection** methods that merge such data into the client's sample stream,
1062
+ so it updates the live model **and** is persisted to the client's
1063
+ [sink](#sinks-per-client-sample-persistence) exactly like sampled data.
1064
+
1065
+ | Method | Adds to the sample's | Surfaces as |
1066
+ |--------|----------------------|-------------|
1067
+ | `injectAttachment(attachments)` | `attachments` (merged via `Object.assign`) | `observedClient.attachments` |
1068
+ | `injectEvent(event: ClientEvent)` | `clientEvents` | `client-event` (plus any state the event drives) |
1069
+ | `injectIssue(issue: ClientIssue)` | `clientIssues` | `client-issue` |
1070
+ | `injectMetaData(meta: ClientMetaData)` | `clientMetaItems` | `client-metadata` |
1071
+ | `injectExtensionStat(stat: ExtensionStat)` | `extensionStats` | `client-extension-stats` |
1072
+
1073
+ ### When the injected data lands
1074
+
1075
+ Injection is timing-aware so nothing is dropped, regardless of *when* you call it:
1076
+
1077
+ - **During a sample's processing** — e.g. from inside a `client-updated` / `client-event` handler,
1078
+ which run within `accept()` — the data is applied to the **current** sample immediately: reflected
1079
+ in entity state and written to the sink as part of that sample.
1080
+ - **Between samples** — the data is buffered and merged into the **next** `accept()`'s sample.
1081
+ - **On `close()` with pending injections and no further sample** — the buffer is flushed as a final
1082
+ synthetic sample (applied to state and written to the sink) before the sink is ended, so a
1083
+ last-moment injection is never lost.
1084
+
1085
+ In every case the injected data both updates the live `ObservedClient` and reaches the per-client
1086
+ sink — the sink always receives the final, **injection-merged** sample (the sink write happens at the
1087
+ end of `accept()`, after the merge).
1088
+
1089
+ ### Example
1090
+
1091
+ ```ts
1092
+ // Enrich at creation from your app's knowledge of the participant. Injecting in `client-added`
1093
+ // (which runs just before the first accept) lands on the first sample.
1094
+ observer.on('client-added', ({ observedClient }) => {
1095
+ observedClient.injectAttachment({ roomId: lookupRoomId(observedClient.clientId) });
1096
+ });
1097
+
1098
+ // Application-level signals at any time:
1099
+ const client = observer.getObservedCall(callId)?.getObservedClient(clientId);
1100
+ client?.injectEvent({ type: 'RECORDING_STARTED', timestamp: Date.now() });
1101
+ client?.injectIssue({ type: 'app-kicked-participant', timestamp: Date.now() });
1102
+ ```
1103
+
1104
+ `attachments` are latest-wins (like sampled `attachments`): injecting a key overwrites its previous
1105
+ value. `appData` is unaffected — injections flow into the sample/telemetry, not the app-owned
1106
+ `appData` bag (see [Ingestion](#ingestion-accept-context--lifecycle)).
1107
+
1108
+ ## Logging
1109
+
1110
+ `observer-js` logs through a single, swappable sink. Out of the box it writes `debug` and
1111
+ above to `console` (verbose — install your own sink for production). Funnel everything into your
1112
+ logger:
1113
+
1114
+ ```ts
1115
+ import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
1116
+
1117
+ setObserverLogger({
1118
+ trace: (m, ...a) => myLogger.trace(`[${m}]`, ...a),
1119
+ debug: (m, ...a) => myLogger.debug(`[${m}]`, ...a),
1120
+ info: (m, ...a) => myLogger.info(`[${m}]`, ...a),
1121
+ warn: (m, ...a) => myLogger.warn(`[${m}]`, ...a),
1122
+ error: (m, ...a) => myLogger.error(`[${m}]`, ...a),
1123
+ });
1124
+ ```
1125
+
1126
+ `createLogger(moduleName)` is also exported for your own modules. See
1127
+ **[`docs/logging.md`](./docs/logging.md)** for pino / winston / console recipes, level
1128
+ filtering, per-module routing, and full silencing.
1129
+
1130
+ ---
1131
+
1132
+ ## Error-handling philosophy
1133
+
1134
+ The library **warns and degrades; it does not throw** on operational problems:
1135
+
1136
+ - `createObservedCall` / `createObservedClient` on a closed parent → warn + return `undefined`.
1137
+ - Duplicate id → warn + return the **existing** instance.
1138
+ - `accept()` on a closed client → warn + no-op.
1139
+ - Sample missing `callId`/`clientId`, or observer closed → `sample-rejected` event.
1140
+ - A throwing accept-middleware → warn + drop that sample (never crashes `accept()`).
1141
+
1142
+ Therefore `create*` and `getOrCreate*` return `T | undefined`; **guard the result.** The
1143
+ `Middleware` utility's internal invariants (e.g. calling `next()` twice) throw, but those throws
1144
+ are caught by `accept()` and surfaced as a warning.
1145
+
1146
+ ---
1147
+
1148
+ ## Development & extension guide
1149
+
1150
+ ```bash
1151
+ yarn install
1152
+ yarn build # tsup → dist/ (dual ESM .mjs + CJS .js, single entry, .d.ts/.d.mts + sourcemaps)
1153
+ yarn lint # eslint -c .eslintrc.json "src/**/*.ts"
1154
+ yarn typecheck # tsc --noEmit
1155
+ yarn test # jest
1156
+ ```
1157
+
1158
+ The build is driven by [`tsup`](https://tsup.egoist.dev) (config in `tsup.config.ts`): a single
1159
+ entry (`src/index.ts`), dual ESM + CommonJS output to `dist/` (`index.mjs` / `index.js`) with
1160
+ `.d.mts` / `.d.ts` types and sourcemaps, targeting Node 20. CI (`.github/workflows/ci.yml`) runs
1161
+ lint + typecheck + **build** + test on every push/PR.
1162
+
1163
+ **Project layout** (`src/`): `Observer.ts`, `ObservedCall.ts`, `ObservedClient.ts`,
1164
+ `ObservedPeerConnection.ts`, the `Observed*` sub-stat classes, `ObserverEvents.ts` (the typed
1165
+ event map + scope types), `detectors/` (`Detector`, `Detectors`), `scores/`, `updaters/`
1166
+ (update-policy strategies), `utils/` (remote-track resolvers), `common/` (`logger`, `utils`,
1167
+ `Middleware`), `schema/` (sample/event/meta types), and `sinks/` (the `ClientSampleSink` base +
1168
+ `JsonlFileSink` / `InMemorySink`, re-exported from the package root).
1169
+
1170
+ **Conventions to follow when developing further:**
1171
+
1172
+ - *Single event bus.* New consumer-facing events go in `ObserverEvents.ts` with an object
1173
+ payload `[<Scope> & { …subject }]`, and are emitted via the component's
1174
+ `_notify(type, { ...this.eventScope, …subject })`. Each component has a precomputed
1175
+ `eventScope` field and a thin `_notify` wrapper around the right emitter. Keep purely internal
1176
+ coordination as **local** EventEmitter events (and remember to `off` them on close).
1177
+ - *Warn, don't throw* on operational/edge conditions; return `undefined` where a value can't be produced.
1178
+ - *Counter-reset-safe deltas.* When computing a delta from a cumulative counter, never emit a
1179
+ negative value (guard `curr >= prev`), to survive counter resets / SSRC reuse.
1180
+ - *Explicit accumulation.* The per-sample metric accumulation in `accept()` is intentionally
1181
+ explicit and not abstracted — match that style.
1182
+ - *Detectors are server-side.* Add cross-client detectors on `ObservedCall.detectors`; don't
1183
+ re-implement client-detectable signals.
1184
+
1185
+ **Recipes:**
1186
+
1187
+ - *Add an event:* add the key + payload to `ObserverEvents`; in the owning component call
1188
+ `this._notify('my-event', { ...this.eventScope, subject })`.
1189
+ - *Add a per-stream metric:* add the field to the relevant `Observed*Rtp`/track class, populate
1190
+ it in its `update()` (reset at the top of `update()` if it's per-tick), and read it from a
1191
+ `*-updated` handler.
1192
+ - *Add a detector:* implement `Detector`, register it on `call-added` via
1193
+ `observedCall.detectors.add(...)`, surface findings with `observedCall.addIssue(...)`.
1194
+
1195
+ ---
1196
+
1197
+ ## License
1198
+
1199
+ Apache-2.0. Part of the [ObserverTC](https://github.com/observertc) ecosystem.
1200
+
1201
+ ---
1202
+
1203
+ # ============================================================================
1204
+ # docs/logging.md
1205
+ # ============================================================================
1206
+
1207
+ # Logging in `@observertc/observer-js`
1208
+
1209
+ `observer-js` writes diagnostic logs from its internal modules (the `Observer`, each
1210
+ `Observed*` entity, the updaters, etc.). All of that output is routed through a single,
1211
+ swappable sink so that the host application stays in full control of *where* logs go,
1212
+ *what level* is kept, and *how* they are formatted. This document explains the model and
1213
+ gives copy-paste recipes for funneling the logs into `pino`, `winston`, plain `console`,
1214
+ or nothing at all.
1215
+
1216
+ ## TL;DR
1217
+
1218
+ ```ts
1219
+ import { setObserverLogger } from '@observertc/observer-js';
1220
+
1221
+ // Funnel every observer-js log line into your own logger.
1222
+ setObserverLogger({
1223
+ trace: (module, ...args) => myLogger.trace(`[${module}]`, ...args),
1224
+ debug: (module, ...args) => myLogger.debug(`[${module}]`, ...args),
1225
+ info: (module, ...args) => myLogger.info(`[${module}]`, ...args),
1226
+ warn: (module, ...args) => myLogger.warn(`[${module}]`, ...args),
1227
+ error: (module, ...args) => myLogger.error(`[${module}]`, ...args),
1228
+ });
1229
+ ```
1230
+
1231
+ That's the whole integration surface. The rest of this document is detail.
1232
+
1233
+ ## The model
1234
+
1235
+ There are two interfaces and two functions, all exported from the package root:
1236
+
1237
+ ```ts
1238
+ import {
1239
+ createLogger, // (moduleName: string) => Logger
1240
+ setObserverLogger, // (logger: ObserverLogger) => void
1241
+ type Logger,
1242
+ type ObserverLogger,
1243
+ } from '@observertc/observer-js';
1244
+ ```
1245
+
1246
+ - **`Logger`** — what each internal module holds. Five level methods, each variadic:
1247
+ `trace`, `debug`, `info`, `warn`, `error`, all `(...args: any[]) => void`.
1248
+ - **`ObserverLogger`** — the single *sink* every `Logger` forwards to. Same five levels,
1249
+ but each receives the originating **module name** as the first argument:
1250
+ `(module: string, ...args: any[]) => void`.
1251
+
1252
+ Internally, each file does:
1253
+
1254
+ ```ts
1255
+ const logger = createLogger('ObservedPeerConnection');
1256
+ // ...
1257
+ logger.warn('Received sample without callId. %o', sample);
1258
+ ```
1259
+
1260
+ `createLogger(moduleName)` returns a `Logger` whose every call forwards to the process-wide
1261
+ `ObserverLogger`, injecting `moduleName`. So the line above ultimately calls:
1262
+
1263
+ ```ts
1264
+ observerLogger.warn('ObservedPeerConnection', 'Received sample without callId. %o', sample);
1265
+ ```
1266
+
1267
+ `setObserverLogger(...)` replaces that process-wide sink. One call reroutes **all** logging
1268
+ from **every** `Observer` instance in the process.
1269
+
1270
+ ```
1271
+ ObservedCall ─┐
1272
+ ObservedClient ┤ createLogger('<module>') ──► the single ObserverLogger ──► your logger
1273
+ Observer ──────┘ (per module) (set via setObserverLogger)
1274
+ ```
1275
+
1276
+ ## Default behavior (important)
1277
+
1278
+ If you never call `setObserverLogger`, the built-in sink writes to `console`:
1279
+
1280
+ | Level | Default destination |
1281
+ |-------|---------------------|
1282
+ | `trace` | dropped (no-op) |
1283
+ | `debug` | `console.log` |
1284
+ | `info` | `console.info` |
1285
+ | `warn` | `console.warn` |
1286
+ | `error` | `console.error` |
1287
+
1288
+ …each prefixed with `"[LEVEL] <module>"`. This means **the default is fairly verbose
1289
+ (`debug` and up go to the console).** For anything beyond local experimentation you should
1290
+ install your own sink with an appropriate level — see below.
1291
+
1292
+ ## Message format
1293
+
1294
+ Internal log calls use Node/`console`-style printf placeholders (`%s`, `%o`, `%d`, `%j`)
1295
+ followed by the substitution values, and sometimes a trailing object:
1296
+
1297
+ ```ts
1298
+ logger.warn('Observed Call with id %s already exists; returning the existing instance', callId);
1299
+ logger.warn('Received sample without clientId %o', sample);
1300
+ ```
1301
+
1302
+ Your `ObserverLogger` receives these as `(module, formatString, ...values)`. A logger that
1303
+ understands printf placeholders (`console`, `pino`) can pass them straight through; for
1304
+ others, format them yourself with `util.format` (recipe below).
1305
+
1306
+ ## When can I call `setObserverLogger`?
1307
+
1308
+ Any time. The sink is read on every log call, not captured at import, so a later
1309
+ `setObserverLogger(...)` immediately affects all subsequent logs. There is no ordering
1310
+ requirement relative to constructing an `Observer`. Note it is **global/process-wide**:
1311
+ the last call wins and applies to all `Observer` instances.
1312
+
1313
+ ---
1314
+
1315
+ ## Recipes
1316
+
1317
+ ### 1. Silence everything
1318
+
1319
+ ```ts
1320
+ import { setObserverLogger } from '@observertc/observer-js';
1321
+
1322
+ const noop = () => undefined;
1323
+ setObserverLogger({ trace: noop, debug: noop, info: noop, warn: noop, error: noop });
1324
+ ```
1325
+
1326
+ ### 2. Console, but only `warn` and above
1327
+
1328
+ ```ts
1329
+ import { setObserverLogger } from '@observertc/observer-js';
1330
+
1331
+ setObserverLogger({
1332
+ trace: () => undefined,
1333
+ debug: () => undefined,
1334
+ info: () => undefined,
1335
+ warn: (module, ...args) => console.warn(`[WARN] ${module}`, ...args),
1336
+ error: (module, ...args) => console.error(`[ERROR] ${module}`, ...args),
1337
+ });
1338
+ ```
1339
+
1340
+ ### 3. pino (recommended for servers)
1341
+
1342
+ `observer-js` does not depend on `pino` — you bring your own. pino understands printf
1343
+ placeholders, and passing `{ module }` as the merging object keeps the module name as a
1344
+ structured field (so you can filter by it later).
1345
+
1346
+ ```ts
1347
+ import pino from 'pino';
1348
+ import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
1349
+
1350
+ const root = pino({ level: 'warn', name: 'observer-js' });
1351
+
1352
+ const adapter: ObserverLogger = {
1353
+ trace: (module, ...args) => root.trace({ module }, ...args),
1354
+ debug: (module, ...args) => root.debug({ module }, ...args),
1355
+ info: (module, ...args) => root.info({ module }, ...args),
1356
+ warn: (module, ...args) => root.warn({ module }, ...args),
1357
+ error: (module, ...args) => root.error({ module }, ...args),
1358
+ };
1359
+
1360
+ setObserverLogger(adapter);
1361
+ ```
1362
+
1363
+ Level filtering is handled by pino (`level: 'warn'` above), so the dropped levels cost
1364
+ almost nothing. If you prefer a dedicated child logger per module:
1365
+
1366
+ ```ts
1367
+ const children = new Map<string, pino.Logger>();
1368
+ const child = (module: string) =>
1369
+ children.get(module) ?? children.set(module, root.child({ module })).get(module)!;
1370
+
1371
+ const adapter: ObserverLogger = {
1372
+ trace: (m, ...a) => child(m).trace(...a),
1373
+ debug: (m, ...a) => child(m).debug(...a),
1374
+ info: (m, ...a) => child(m).info(...a),
1375
+ warn: (m, ...a) => child(m).warn(...a),
1376
+ error: (m, ...a) => child(m).error(...a),
1377
+ };
1378
+ setObserverLogger(adapter);
1379
+ ```
1380
+
1381
+ ### 4. winston
1382
+
1383
+ winston's level methods don't do printf substitution the same way, so format the message
1384
+ with `util.format` first:
1385
+
1386
+ ```ts
1387
+ import { format } from 'node:util';
1388
+ import { createLogger as createWinston, transports, format as wformat } from 'winston';
1389
+ import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
1390
+
1391
+ const w = createWinston({
1392
+ level: 'warn',
1393
+ transports: [new transports.Console()],
1394
+ format: wformat.json(),
1395
+ });
1396
+
1397
+ const adapter: ObserverLogger = {
1398
+ trace: (module, ...args) => w.silly(format(...args), { module }),
1399
+ debug: (module, ...args) => w.debug(format(...args), { module }),
1400
+ info: (module, ...args) => w.info(format(...args), { module }),
1401
+ warn: (module, ...args) => w.warn(format(...args), { module }),
1402
+ error: (module, ...args) => w.error(format(...args), { module }),
1403
+ };
1404
+
1405
+ setObserverLogger(adapter);
1406
+ ```
1407
+
1408
+ ### 5. Generic / any logger (`util.format`)
1409
+
1410
+ The most portable adapter — works with any logger that takes a single string:
1411
+
1412
+ ```ts
1413
+ import { format } from 'node:util';
1414
+ import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
1415
+
1416
+ const toLine = (module: string, args: unknown[]) => `[${module}] ${format(...args)}`;
1417
+
1418
+ const adapter: ObserverLogger = {
1419
+ trace: (module, ...args) => myLogger.trace(toLine(module, args)),
1420
+ debug: (module, ...args) => myLogger.debug(toLine(module, args)),
1421
+ info: (module, ...args) => myLogger.info(toLine(module, args)),
1422
+ warn: (module, ...args) => myLogger.warn(toLine(module, args)),
1423
+ error: (module, ...args) => myLogger.error(toLine(module, args)),
1424
+ };
1425
+
1426
+ setObserverLogger(adapter);
1427
+ ```
1428
+
1429
+ ### 6. Filter or re-route specific modules
1430
+
1431
+ Because the sink receives the module name, you can route or drop per module:
1432
+
1433
+ ```ts
1434
+ const NOISY = new Set(['ObservedPeerConnection']);
1435
+
1436
+ setObserverLogger({
1437
+ trace: () => undefined,
1438
+ debug: (module, ...args) => { if (!NOISY.has(module)) root.debug({ module }, ...args); },
1439
+ info: (module, ...args) => root.info({ module }, ...args),
1440
+ warn: (module, ...args) => root.warn({ module }, ...args),
1441
+ error: (module, ...args) => root.error({ module }, ...args),
1442
+ });
1443
+ ```
1444
+
1445
+ ## Reusing the funnel for your own modules
1446
+
1447
+ `createLogger` is exported, so your application code can emit through the same sink and
1448
+ inherit whatever routing/level you configured with `setObserverLogger`:
1449
+
1450
+ ```ts
1451
+ import { createLogger } from '@observertc/observer-js';
1452
+
1453
+ const logger = createLogger('my-app:ingest');
1454
+ logger.info('accepted %d samples in %dms', count, elapsedMs);
1455
+ ```
1456
+
1457
+ ## Notes & caveats
1458
+
1459
+ - **Global, not per-instance.** `setObserverLogger` sets one process-wide sink shared by
1460
+ every `Observer`. There is currently no per-`Observer` logger override.
1461
+ - **Level filtering belongs in your logger.** `observer-js` always calls the sink for
1462
+ `debug`/`info`/`warn`/`error` (and `trace`, though the default drops it); decide what to
1463
+ keep inside your `ObserverLogger` (or, with pino/winston, via their `level`).
1464
+ - **No bundled transport.** `observer-js` never imports `pino`, `winston`, or any logging
1465
+ library — you wire in whichever you already use.
1466
+ - **`trace`** is intended for very high-frequency, per-sample diagnostics; keep it dropped
1467
+ in production.
1468
+
1469
+ ## Type reference
1470
+
1471
+ ```ts
1472
+ interface Logger {
1473
+ trace(...args: any[]): void;
1474
+ debug(...args: any[]): void;
1475
+ info(...args: any[]): void;
1476
+ warn(...args: any[]): void;
1477
+ error(...args: any[]): void;
1478
+ }
1479
+
1480
+ interface ObserverLogger {
1481
+ trace(module: string, ...args: any[]): void;
1482
+ debug(module: string, ...args: any[]): void;
1483
+ info(module: string, ...args: any[]): void;
1484
+ warn(module: string, ...args: any[]): void;
1485
+ error(module: string, ...args: any[]): void;
1486
+ }
1487
+
1488
+ function createLogger(moduleName: string): Logger;
1489
+ function setObserverLogger(logger: ObserverLogger): void;
1490
+ ```
1491
+
1492
+ ---
1493
+
1494
+ # ============================================================================
1495
+ # CHANGELOG.md
1496
+ # ============================================================================
1497
+
1498
+ # Changelog
1499
+
1500
+ ## 1.0.0
1501
+
1502
+ First stable release of `@observertc/observer-js` — a server-side Node.js library that turns a
1503
+ stream of WebRTC `ClientSample`s into a live, queryable model of every call, with a single typed
1504
+ event bus and pluggable extension points. This entry states the full set of features and concepts
1505
+ the 1.0.0 API provides.
1506
+
1507
+ ### Core model
1508
+
1509
+ - **Entity hierarchy.** `Observer → ObservedCall → ObservedClient → ObservedPeerConnection →`
1510
+ sub-stats (inbound/outbound RTP, remote inbound/outbound RTP, inbound/outbound tracks, codecs,
1511
+ data channels, ICE transports/candidates/candidate-pairs, certificates, media sources, media
1512
+ playouts, peer-connection transports). Every node holds current + cumulative metrics and is
1513
+ reachable by id through `Map`s on its parent.
1514
+ - **One ingestion method.** `observer.accept(sample, context?)` is the only way data gets in.
1515
+ Calls, clients and peer connections are **created lazily** the first time their id appears.
1516
+ - **Single event bus.** Everything worth subscribing to is emitted on the **`Observer`** itself,
1517
+ with a payload object that carries the full ancestry (observer → call → client → peer connection)
1518
+ down to the subject. `on`/`off`/`once`/`emit` are fully typed against the event map. Local
1519
+ EventEmitter lifecycle events remain on each component for internal teardown wiring.
1520
+ - **Pull or react.** Read fields off the entities at any time, and/or subscribe to events.
1521
+
1522
+ ### Ingestion & configuration
1523
+
1524
+ - **`accept(sample, context?)`** with a transient, free-form `AcceptContext` that is threaded down
1525
+ the accept chain and carried to the `*-updated` events of that pass — and **never** written to
1526
+ `appData`.
1527
+ - **`appData` vs `context`.** `appData` is application-owned, fixed at entity creation (via
1528
+ `settings.appData` or the `createCallAppData` / `createClientAppData` factories) and never mutated
1529
+ by the library; `context` is per-accept and ephemeral.
1530
+ - **Accept middlewares.** `observer.acceptMiddlewares` — a global, pre-dispatch chain run on every
1531
+ sample before it reaches any call/client (inspect, mutate/normalize ids, enrich).
1532
+ - **Event-driven update policies.** Observer and call aggregation triggers are
1533
+ `update-on-any-…-updated` / `update-when-all-…-updated` / `none`. There are **no internal timers**;
1534
+ with `none` (or to drive a fixed cadence) the app calls the public `observer.update()` /
1535
+ `call.update()` itself.
1536
+ - **Automatic teardown.** Optional `closeClientIfIdleForMs` and `closeCallIfEmptyForMs`; closing
1537
+ cascades down the tree and emits the matching `*-closed` / `*-removed` events.
1538
+
1539
+ ### Derived metrics
1540
+
1541
+ - **Counter-reset-safe deltas.** Per-tick deltas never go negative across counter resets / SSRC
1542
+ reuse (guarded `curr >= prev`).
1543
+ - **Per-stream metrics**: bitrates, packet/byte deltas, jitter, fraction-lost, RTT, etc.
1544
+ - **Remote-RTP correlation.** Receiver/sender RTCP reports are linked to the local stream by
1545
+ `remoteId`/SSRC and surfaced as `remote*` fields (e.g. `remoteRttInMs`, `remoteFractionLost` on
1546
+ outbound; `remoteRttInMs`, `remoteBytesSent` on inbound).
1547
+ - **TURN/TCP usage** derived from the selected ICE candidate pair (`usingTURN` / `usingTCP`), with a
1548
+ call-level `ObservedTURN` view.
1549
+
1550
+ ### Optional track correlation
1551
+
1552
+ - **`RemoteTrackResolver`** — a generic, strategy-driven resolver that links a published (outbound)
1553
+ track to the subscribed (inbound) tracks carrying it (**one publisher → many subscribers**) by a
1554
+ **publisher id** (the link key). Links are maintained directly on the tracks
1555
+ (`inboundTrack.remoteOutboundTrack`, `outboundTrack.remoteInboundTracks`).
1556
+ - **Opt-in via `ObserverConfig.createTrackResolver`**, invoked per call. Built-in factories:
1557
+ `createDefaultMediasoupRemoteTrackResolverFactory()` (producerId/consumerId attachments) and
1558
+ `createP2pRemoteTrackResolverFactory()` (RTP SSRC). Custom topologies supply their own
1559
+ publisher/subscriber id resolvers.
1560
+
1561
+ ### Per-client sample sinks
1562
+
1563
+ - **`ClientSampleSink`** — an object-mode, `EventEmitter`-based base class (`write(sample)`,
1564
+ `end()`, typed `close`/`error`/`finish`/`drain` events). One sink per `ObservedClient`, produced
1565
+ by `ObserverConfig.createClientSink`; the `client-sink-created` event delivers it on the bus.
1566
+ - **Built-ins:** `JsonlFileSink` (+ `createJsonlFileSink` / `createJsonlFileSinkFactory`, exposing
1567
+ `path` and a custom `serializeSample`) and the env-agnostic `InMemorySink`.
1568
+
1569
+ ### Detectors & call-level issues
1570
+
1571
+ - **`Detectors`** — a server-side extension-point registry on `ObservedCall` (ships empty; runs each
1572
+ registered detector on `call.update()`, isolating throws). `call.addIssue(...)` raises a
1573
+ call-level issue surfaced on the bus as `call-issue`; client-reported issues surface as
1574
+ `client-issue`.
1575
+
1576
+ ### Logging
1577
+
1578
+ - **Pluggable logger** — a single swappable sink (`setObserverLogger`, `createLogger`) with a
1579
+ documented funnel for pino/winston/console (`docs/logging.md`).
1580
+
1581
+ ### Error-handling philosophy
1582
+
1583
+ - **Warn, don't throw** on operational/edge conditions. `create*` / `getOrCreate*` return
1584
+ `T | undefined` (closed parent → `undefined` + warn; duplicate id → existing instance + warn).
1585
+ Missing `callId`/`clientId` or a closed observer → a `sample-rejected` event.
1586
+
1587
+ ### Packaging & tooling
1588
+
1589
+ - **Server-side, Node.js ≥ 22.** Shipped as a **dual ESM + CommonJS** build (single entry) via
1590
+ `tsup`, with `.d.ts`/`.d.mts` types and sourcemaps; works with both `import` and `require()`.
1591
+ - **CI gate** (lint + typecheck + build + tested with a **coverage threshold**) and **npm Trusted
1592
+ Publishing (OIDC) with provenance**.
1593
+
1594
+ ### Documentation
1595
+
1596
+ - A self-sufficient `README.md`; `docs/logging.md`; and design docs for the roadmap, track
1597
+ correlation & detectors, the call-level-detector analysis, and the (deferred) report-generation
1598
+ approach.