@observertc/observer-js 1.2.0 → 1.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,7 +62,7 @@ and emits a single, unified stream of typed events the application can react to.
62
62
  11. [Call summaries](#call-summaries)
63
63
  12. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
64
64
  13. [Mediasoup router observation](#mediasoup-router-observation)
65
- 14. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
65
+ 14. [Sinks](#sinks)
66
66
  15. [Injecting data into a client](#injecting-data-into-a-client)
67
67
  16. [Logging](#logging)
68
68
  17. [Design notes](#design-notes)
@@ -83,14 +83,14 @@ yarn add @observertc/observer-js
83
83
  works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
84
84
 
85
85
  ```ts
86
- import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js';
86
+ import { Observer, ClientSample, JsonlFileSink } from '@observertc/observer-js';
87
87
  ```
88
88
 
89
89
  In an ESM project this resolves to the `.mjs` build; in a CommonJS project (where TypeScript
90
90
  compiles your `import` down to `require()`) it resolves to the `.js` build. Everything is exported
91
91
  from the single `@observertc/observer-js` entry. Written in TypeScript; ships type declarations for
92
92
  both formats (`dist/index.d.ts` for `require`, `dist/index.d.mts` for `import`). Runtime
93
- dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or
93
+ dependencies: `@bufbuild/protobuf`, `uuid`. The library does **not** bundle a logger or
94
94
  any transport — see [Logging](#logging).
95
95
 
96
96
  `ClientSample` and friends are re-exported from this package, and are also published as the
@@ -404,7 +404,7 @@ See [Mediasoup router observation](#mediasoup-router-observation) for the full d
404
404
  | Event | Extra | Fires when |
405
405
  |-------|-------|-----------|
406
406
  | `client-added` | — | a client is created |
407
- | `client-sink-created` | `{ sink: ClientSampleSink }` | a per-client sink was created (only when `createClientSink` returns one); fires right after `client-added` |
407
+ | `client-sink-created` | `{ sink: JsonlFileSink }` | the client's JSONL sample file was created (only when `createClientJsonlSink` returns a path); fires right after `client-added` |
408
408
  | `client-updated` | `{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext }` | the client processed a sample |
409
409
  | `client-closed` | — | the client closed |
410
410
  | `client-joined` | — | first `CLIENT_JOINED` event seen |
@@ -479,8 +479,8 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
479
479
  // (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
480
480
  createCallAppData?: (p: { callId: string; observer: Observer; acceptCtx?: AcceptContext }) => Record<string, unknown>;
481
481
  createClientAppData?: (p: { clientId: string; observedCall: ObservedCall; acceptCtx?: AcceptContext }) => Record<string, unknown>;
482
- // sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
483
- createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
482
+ // a JSONL file per client: return its path, or undefined for none (see Sinks).
483
+ createClientJsonlSink?: (p: { clientId: string; observedCall: ObservedCall }) => string | undefined;
484
484
  // remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
485
485
  createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
486
486
  };
@@ -591,7 +591,7 @@ Key members:
591
591
 
592
592
  - `readonly clientId: string`, `appData: AppData`, `readonly call: ObservedCall`
593
593
  - `readonly observedPeerConnections: Map<string, ObservedPeerConnection>`
594
- - `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error`
594
+ - `get sink(): Sink<ClientSample> | undefined` — the sink under its client id in `call.sinks` (see [Sinks](#sinks)); listen on it for `close`/`error`
595
595
  - **Injection API** (queue app data to be merged into the next sample processing):
596
596
  `injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`,
597
597
  `injectExtensionStat(ExtensionStat)`, `injectAttachment(attachments: Record<string, unknown>)`
@@ -1905,127 +1905,190 @@ observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMe
1905
1905
 
1906
1906
  ---
1907
1907
 
1908
- ## Sinks (per-client sample persistence)
1908
+ ## Sinks
1909
1909
 
1910
- A **sink** receives the samples a client accepts — for archival, streaming, or later offline
1911
- replay. Each `ObservedClient` gets its **own** sink, produced by the
1912
- `ObserverConfig.createClientSink` factory when the client is created (return `undefined` for no
1913
- sink). The client pushes every accepted sample to its sink, and `end()`s it on close.
1910
+ A **sink** is a destination for records — client samples, a mediasoup transport's `getStats()`,
1911
+ anything the application wants to keep. Sinks are agnostic about what passes through them; their
1912
+ **lifecycle is owned by the object they are registered on**, through that object's `sinks`
1913
+ registry. The library never polls: what is written, and when, is the application's.
1914
1914
 
1915
- ### The `ClientSampleSink` base class
1915
+ | Registry | Ended when | Typical use |
1916
+ |----------|-----------|-------------|
1917
+ | `observer.sinks` | the observer closes | worker- or process-level stats |
1918
+ | `observedCall.sinks` | the call closes | each client's sample sink (under its client id), call-level stats |
1919
+ | `observedMediasoupRouter.sinks` | the router closes | e.g. one file per pipe transport |
1916
1920
 
1917
- `ClientSampleSink` is an **abstract base class** (a typed `EventEmitter`). You create a sink by
1918
- **subclassing it** and implementing `write` and `end`. It is **object-mode**: `write` receives
1919
- the `ClientSample` *object*, so each sink decides how (or whether) to serialize it — JSON line,
1920
- protobuf, a remote POST body, an in-memory push, etc.
1921
+ `observer.sinks` is the parent of every call's and router's registry. **Sink ids are unique per
1922
+ observer**: creating a sink with an id that any registry of the observer already holds is refused
1923
+ (`create` returns `undefined`, and nothing is constructed). A registry sees its own sinks and its descendants', so `observer.sinks.write(id, record)`
1924
+ reaches any sink, and `observer.close()` ends every sink that is left — including those of routers
1925
+ that are still open, since the observer does not close mediasoup routers.
1921
1926
 
1922
- ```ts
1923
- import { ClientSampleSink, ClientSample } from '@observertc/observer-js';
1927
+ ### `SinkRegistry`
1928
+
1929
+ | Method | Does |
1930
+ |--------|------|
1931
+ | `create(SinkClass, settings)` | constructs `new SinkClass(settings)` and registers it under `settings.id` (generated when omitted); `undefined` if the id is taken |
1932
+ | `get<T>(id)` | the sink, if any (own or a descendant's) |
1933
+ | `write(id, record): boolean` | writes to the sink; `false` if there is none, or on backpressure |
1934
+ | `clear()` | ends every sink of the registry and its descendants |
1935
+ | `size`, `keys()`, `values()` | own and descendants' sinks |
1936
+
1937
+ There is no `remove`: **`sink.end()` closes a sink and removes it from its registry**, which frees
1938
+ its id. The registry attaches an `error` listener to each sink, so a failing destination is logged
1939
+ and never crashes the process.
1924
1940
 
1925
- abstract class ClientSampleSink /* extends EventEmitter */ {
1926
- abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure
1927
- abstract end(): void; // flush; emit `close` when the destination is ready
1941
+ ### The `Sink<T>` interface
1928
1942
 
1929
- // typed events (inherited): the listener signature is inferred from the event name
1930
- on(event: 'close' | 'finish' | 'drain', listener: () => void): this;
1931
- on(event: 'error', listener: (err: Error) => void): this;
1932
- // ...and the matching `once` / `off` / `emit`
1943
+ ```ts
1944
+ import type { Sink, SinkSettings } from '@observertc/observer-js';
1945
+
1946
+ interface Sink<T = unknown> { // typically: class X extends EventEmitter implements Sink<T>
1947
+ readonly id: string;
1948
+ write(record: T): boolean; // `false` once ended (nothing written), or on backpressure
1949
+ end(): void; // idempotent; MUST emit `ended` synchronously, then flush and emit `close`
1950
+ on / once / off(event, listener); // the events below
1933
1951
  }
1952
+
1953
+ type SinkSettings = { id: string }; // what `create` passes to the constructor, with your own settings
1934
1954
  ```
1935
1955
 
1956
+ An implementation must emit `ended` from `end()` — that is how its registry removes it and frees
1957
+ its id — refuse writes after it, and emit `close` / `error` for its destination.
1958
+
1936
1959
  | Event | Meaning |
1937
1960
  |-------|---------|
1961
+ | `ended` | `end()` was called: no more writes, removed from its registry |
1938
1962
  | `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" |
1939
1963
  | `error` | the destination failed |
1940
1964
  | `finish` | `end()` was processed and queued data flushed (before `close`) |
1941
1965
  | `drain` | the buffer drained after backpressure; safe to write more |
1942
1966
 
1943
- The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited),
1944
- `end()`s the sink when the client closes, and attaches an `error` listener so a failing sink
1945
- can't crash the process (it also catches throws from `write`/`end`). The application — which
1946
- created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't
1947
- awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**.
1948
-
1949
- ### Built-in sinks
1950
-
1951
- ```ts
1952
- import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
1953
-
1954
- const observer = new Observer({
1955
- // one ./stats/<callId>__<clientId>.jsonl per client
1956
- createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
1957
- });
1958
-
1959
- // React when a sink is created for a client:
1960
- observer.on('client-sink-created', ({ observedClient, sink }) => {
1961
- sink.on('close', () => {
1962
- // the file is fully flushed and its fd closed — ready to upload, move, etc.
1963
- });
1964
- });
1965
- ```
1966
-
1967
- | Export | Signature | Notes |
1968
- |--------|-----------|-------|
1969
- | `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
1970
- | `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
1971
- | `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready |
1972
- | `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` |
1967
+ `ClientSampleSink` is `Sink<ClientSample>`. Built-in implementations:
1973
1968
 
1974
- `serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for
1975
- the JSONL sinks (e.g. to redact or reshape before writing).
1969
+ | Export | Notes |
1970
+ |--------|-------|
1971
+ | `JsonlFileSink<T = ClientSample>` / `createJsonlFileSink<T>({ path, serializeSample? })` | appends one JSON line per record; wraps `fs.WriteStream` and re-emits its events; exposes `readonly path`. The directory **must exist** |
1972
+ | `InMemorySink` / `createInMemorySink(samples?)` | collects client samples into `.samples`; emits `close` on `end()` |
1976
1973
 
1977
- ### Reading sink-specific info (e.g. the file path)
1974
+ ### Client samples
1978
1975
 
1979
- The bus hands you the sink as the base `ClientSampleSink`. To read information specific to a sink
1980
- type — for a file sink, where it was written — **narrow with `instanceof`** and read the sink's
1981
- public fields. `JsonlFileSink` exposes `path`:
1976
+ A client writes every accepted sample to the sink registered under its **client id** in its call's
1977
+ registry (`observedClient.sink` returns it), and removes — ends — that sink when it closes. For the
1978
+ common case, a JSONL file per client, return a path from `createClientJsonlSink`:
1982
1979
 
1983
1980
  ```ts
1984
- import { JsonlFileSink } from '@observertc/observer-js';
1981
+ const observer = new Observer({
1982
+ // return undefined for no file
1983
+ createClientJsonlSink: ({ observedCall, clientId }) => `./stats/${observedCall.callId}__${clientId}.jsonl`,
1984
+ });
1985
1985
 
1986
1986
  observer.on('client-sink-created', ({ observedClient, sink }) => {
1987
- if (sink instanceof JsonlFileSink) {
1988
- const { path } = sink; // the file this client's samples go to
1989
- sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready
1990
- }
1987
+ sink.once('close', () => uploadFile(sink.path)); // flushed & fd closed → ready
1991
1988
  });
1992
1989
  ```
1993
1990
 
1994
- The general pattern: each concrete sink exposes whatever it wants as `public readonly` fields, and
1995
- consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same.
1996
-
1997
- ### Writing your own sink
1998
-
1999
- Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
2000
- destination (a remote endpoint, a message queue, an object store, …):
1991
+ For any other destination, create your own sink under the client id when the client is created:
2001
1992
 
2002
1993
  ```ts
2003
- import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js';
2004
-
2005
- class HttpSink extends ClientSampleSink {
1994
+ class HttpSink extends EventEmitter implements ClientSampleSink {
1995
+ readonly id: string;
1996
+ private readonly url: string;
2006
1997
  private buffer: ClientSample[] = [];
2007
- constructor(private readonly url: string) { super(); }
1998
+ private ended = false;
2008
1999
 
2000
+ constructor(settings: SinkSettings & { url: string }) {
2001
+ super();
2002
+ this.id = settings.id;
2003
+ this.url = settings.url;
2004
+ }
2009
2005
  write(sample: ClientSample): boolean {
2006
+ if (this.ended) return false;
2010
2007
  this.buffer.push(sample); // batch; decide your own backpressure
2011
2008
  return true;
2012
2009
  }
2013
2010
  end(): void {
2011
+ if (this.ended) return;
2012
+ this.ended = true;
2013
+ this.emit('ended'); // required: removes it from its registry
2014
2014
  fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) })
2015
2015
  .then(() => this.emit('close')) // signal "destination ready"
2016
2016
  .catch((err) => this.emit('error', err));
2017
2017
  }
2018
2018
  }
2019
2019
 
2020
- const createClientSink: ClientSampleSinkFactory = ({ clientId, observedCall }) =>
2021
- new HttpSink(`https://stats.example.com/${observedCall.callId}/${clientId}`);
2020
+ observer.on('client-added', ({ observedCall, observedClient }) => {
2021
+ observedCall.sinks.create(HttpSink, { id: observedClient.clientId, url: `https://stats.example.com/${observedClient.clientId}` });
2022
+ });
2023
+ ```
2024
+
2025
+ Client ids double as sink ids, so they must be unique across the observer's open calls (client
2026
+ monitors generate uuids). A client whose id is already a sink id gets no sample file, and a
2027
+ warning is logged.
2028
+
2029
+ The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited), so
2030
+ **backpressure and batching are the sink's concern**.
2031
+
2032
+ ### Extension stats
2033
+
2034
+ Stats the library does not collect itself can be kept as **extension stats** on the object they
2035
+ belong to — `observer` (e.g. a mediasoup worker's resource usage), `observedCall` (an SFU's view of
2036
+ the call) or `observedMediasoupRouter` (a pipe transport's `getStats()`): the last data per id, and
2037
+ optionally a sink that receives every data. The API is the same on all three; a call is shown.
2038
+
2039
+ ```ts
2040
+ // Register the id; with `createSink` (what `sinks.create` takes), a sink is created under the same id.
2041
+ call.createExtensionStats({ id: `${call.callId}:sfu`, createSink: [ JsonlFileSink<SfuStats>, { path: './stats/sfu.jsonl' } ] });
2042
+
2043
+ // Your own polling: keep the last data, and write it to the sink with the same id, if any.
2044
+ call.acceptExtensionStats(`${call.callId}:sfu`, await collectSfuStats(call.callId));
2045
+
2046
+ call.getExtensionStats<SfuStats>(`${call.callId}:sfu`); // the last data
2047
+ call.extensionStats.values; // Map<id, last data>
2048
+ ```
2022
2049
 
2023
- const observer = new Observer({ createClientSink });
2050
+ | Method | Behaviour |
2051
+ |--------|-----------|
2052
+ | `createExtensionStats({ id, createSink? })` | Registers `id`. With `createSink` — what `sinks.create` takes, `[SinkClass, settings]` — it also creates a sink under `id` (an `id` in the sink settings is ignored), ending and replacing a sink the owner already holds under `id`. Returns the created sink, or `undefined`: without `createSink`, on a closed owner, when `id` is already in `extensionStats.values` (created or accepted before), or when another object of the observer holds a sink with this id |
2053
+ | `acceptExtensionStats(id, data)` | Keeps `data` as the last data of `id` and writes it to the owner's sink with the same id, if there is one (a sink that throws is logged). `id` does not have to be created first. Dropped once the owner is closed |
2054
+ | `getExtensionStats<T>(id)` | The last data accepted for `id`, or `undefined` |
2055
+ | `extensionStats` | The owner's `ExtensionStatsRegistry`: `values` (`Map<id, last data>`), `closed`, `close()`, and `create` / `accept` / `get`, which the three methods above delegate to |
2056
+
2057
+ **Extension stats ids share the sink id namespace, so they must be unique per observer**: the sink
2058
+ of extension stats `x` is the sink with id `x`. Consequently:
2059
+
2060
+ - With `createSink`, a sink the owner already holds under the id is ended and replaced. If another
2061
+ object of the observer holds it, no sink is created and the data is only kept.
2062
+ - Without `createSink`, a sink you create later under the id (`sinks.create(SinkClass, { id, ... })`)
2063
+ receives the data accepted from then on.
2064
+ - An id accepted before it was created can no longer be created with `createSink`; give it a sink
2065
+ with `sinks.create` instead.
2066
+
2067
+ **Lifecycle.** When the owner (the call, the router or the observer) closes, it closes its
2068
+ `extensionStats`: data accepted afterwards is dropped, `createExtensionStats` returns `undefined`,
2069
+ and the extension stats' sinks are ended. The owner's other sinks end with its `sinks.clear()` as
2070
+ usual. `extensionStats.close()` can also be called directly; it ends only the extension stats'
2071
+ sinks.
2072
+
2073
+ ### Other records: e.g. mediasoup pipe transport stats
2074
+
2075
+ ```ts
2076
+ const observedRouter = observer.createObservedMediasoupRouter({ router })!;
2077
+
2078
+ observedRouter.on('transport-sample-added', ({ transport }) => {
2079
+ if (transport.type !== 'pipe') return;
2080
+
2081
+ observedRouter.sinks.create(JsonlFileSink<PipeTransportStats[]>, { id: transport.id, path: `./stats/${transport.id}.jsonl` });
2082
+
2083
+ // Your own polling. A write to an ended sink returns false, so a late getStats() is harmless.
2084
+ const timer = setInterval(async () => observedRouter.sinks.write(transport.id, await transport.getStats()), 5_000);
2085
+
2086
+ transport.observer.once('close', () => clearInterval(timer));
2087
+ });
2024
2088
  ```
2025
2089
 
2026
- `observedClient.sink?` exposes the created sink; the `client-sink-created` event delivers it on
2027
- the bus with full ancestry. `ClientSampleSinkFactory` is
2028
- `(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`.
2090
+ The files are ended when the router closes, which is usually the lifetime you want for a pipe
2091
+ transport's stats. To end one earlier, call `end()` on it: `observedRouter.sinks.get(transport.id)?.end()`.
2029
2092
 
2030
2093
  ---
2031
2094
 
@@ -2036,7 +2099,7 @@ client-reported `ClientSample` — a room id or display name, an application-lev
2036
2099
  (*"recording started"*), a server-detected issue, an extension stat, or a device/meta item.
2037
2100
  `ObservedClient` exposes **injection** methods that merge such data into the client's sample stream,
2038
2101
  so it updates the live model **and** is persisted to the client's
2039
- [sink](#sinks-per-client-sample-persistence) exactly like sampled data.
2102
+ [sink](#sinks) exactly like sampled data.
2040
2103
 
2041
2104
  | Method | Adds to the sample's | Surfaces as |
2042
2105
  |--------|----------------------|-------------|
@@ -2161,7 +2224,7 @@ event map + scope types), `detectors/` (`Detector`, `Detectors`, and one file pe
2161
2224
  `ActiveIssueTracker`, `ActiveIssuesRegistry`, `ObservedClientIssueRegistry`), `scores/`,
2162
2225
  `resolvers/` (remote-track resolvers), `utils/` (`stats`, `SlidingWindow`, `TrendTester`,
2163
2226
  `CallHealthAggregator`), `common/` (`logger`, `utils`, `Middleware`), `schema/`
2164
- (sample/event/meta types), and `sinks/` (the `ClientSampleSink` base + `JsonlFileSink` /
2227
+ (sample/event/meta types), and `sinks/` (the `Sink` interface, `SinkRegistry` + `JsonlFileSink` /
2165
2228
  `InMemorySink`, re-exported from the package root).
2166
2229
 
2167
2230
  **Conventions to follow when developing further:**