@observertc/observer-js 1.1.0 → 1.2.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 +160 -86
- package/dist/index.d.mts +530 -133
- package/dist/index.d.ts +530 -133
- package/dist/index.js +799 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +790 -175
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -16
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
|
|
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,
|
|
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`, `
|
|
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:
|
|
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
|
-
//
|
|
483
|
-
|
|
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
|
-
- `
|
|
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>)`
|
|
@@ -1126,6 +1126,7 @@ counters** — that is the rule the whole design hangs on:
|
|
|
1126
1126
|
| `PublisherFaultCorroborationDetector` | 🔗 | call | `CORROBORATED_PUBLISHER_FAULT` |
|
|
1127
1127
|
| `TrackDeliveryMismatchDetector` | 🔗 | call | `PUBLISHED_TRACK_NOT_DELIVERED`, `RECEIVER_TRACK_NOT_DELIVERED`, `PUBLISHER_TRACK_DRY` |
|
|
1128
1128
|
| `UnconsumedTrackDetector` | 🔗 | call | `UNCONSUMED_PUBLISHED_TRACK` |
|
|
1129
|
+
| `AudioEchoDetector` | 🔗¹ | call | `AUDIO_ECHO_SUSPECTED` |
|
|
1129
1130
|
| `ClientPopulationIssueDetector` | | **observer** | `CLIENT_POPULATION_ISSUE` |
|
|
1130
1131
|
| `SfuCongestionDetector` | | **observer** | `sfu-congestion` |
|
|
1131
1132
|
| `TurnServerHealthDetector` | | **observer** | `TURN_SERVER_DEGRADED` |
|
|
@@ -1168,6 +1169,16 @@ What each adds that no endpoint can know:
|
|
|
1168
1169
|
from the **same SFU** — the finding's meaning is "these clients share only that server".
|
|
1169
1170
|
- **`TrackDeliveryMismatchDetector`** — *are the two ends of a track disagreeing?*
|
|
1170
1171
|
- **`UnconsumedTrackDetector`** — *is anyone actually subscribed?* (reads the resolver's silence)
|
|
1172
|
+
- **`AudioEchoDetector`** — *is someone sending the others' voices back into the call?* Compares
|
|
1173
|
+
each outbound audio track's per-tick level (`ObservedOutboundTrack.rmsAudioLevel`) with the
|
|
1174
|
+
loudest other participant's, bucket by bucket. An echo is active whenever the source speaks, and
|
|
1175
|
+
louder when the source is louder; turn-taking fails the first test and cross-talk the second.
|
|
1176
|
+
When both tracks of a pair pass, it reports the one with the client's `loopback-audio-input`
|
|
1177
|
+
issue open, else the one that follows more reliably, else the quieter one (`ambiguousDirection`).
|
|
1178
|
+
Works at the clients' sampling granularity, so it catches sustained echo (a `Monitor of …`
|
|
1179
|
+
loopback device, speakers into an open mic) rather than faint echo.
|
|
1180
|
+
¹ The resolver is needed only for publishers whose browser reports no media-source audio level
|
|
1181
|
+
(Firefox): their level is then read from what their receivers heard.
|
|
1171
1182
|
- **`TurnServerHealthDetector`** — *does trouble cluster on one relay?*
|
|
1172
1183
|
- **`TurnServerOutageDetector`** — covers the case the health detector structurally cannot. The
|
|
1173
1184
|
health detector groups clients by the server relaying them and asks how many report issues — it
|
|
@@ -1894,127 +1905,190 @@ observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMe
|
|
|
1894
1905
|
|
|
1895
1906
|
---
|
|
1896
1907
|
|
|
1897
|
-
## Sinks
|
|
1908
|
+
## Sinks
|
|
1898
1909
|
|
|
1899
|
-
A **sink**
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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.
|
|
1903
1914
|
|
|
1904
|
-
|
|
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 |
|
|
1905
1920
|
|
|
1906
|
-
`
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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.
|
|
1910
1926
|
|
|
1911
|
-
|
|
1912
|
-
|
|
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.
|
|
1913
1940
|
|
|
1914
|
-
|
|
1915
|
-
abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure
|
|
1916
|
-
abstract end(): void; // flush; emit `close` when the destination is ready
|
|
1941
|
+
### The `Sink<T>` interface
|
|
1917
1942
|
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
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
|
|
1922
1951
|
}
|
|
1952
|
+
|
|
1953
|
+
type SinkSettings = { id: string }; // what `create` passes to the constructor, with your own settings
|
|
1923
1954
|
```
|
|
1924
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
|
+
|
|
1925
1959
|
| Event | Meaning |
|
|
1926
1960
|
|-------|---------|
|
|
1961
|
+
| `ended` | `end()` was called: no more writes, removed from its registry |
|
|
1927
1962
|
| `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" |
|
|
1928
1963
|
| `error` | the destination failed |
|
|
1929
1964
|
| `finish` | `end()` was processed and queued data flushed (before `close`) |
|
|
1930
1965
|
| `drain` | the buffer drained after backpressure; safe to write more |
|
|
1931
1966
|
|
|
1932
|
-
|
|
1933
|
-
`end()`s the sink when the client closes, and attaches an `error` listener so a failing sink
|
|
1934
|
-
can't crash the process (it also catches throws from `write`/`end`). The application — which
|
|
1935
|
-
created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't
|
|
1936
|
-
awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**.
|
|
1937
|
-
|
|
1938
|
-
### Built-in sinks
|
|
1939
|
-
|
|
1940
|
-
```ts
|
|
1941
|
-
import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
|
|
1942
|
-
|
|
1943
|
-
const observer = new Observer({
|
|
1944
|
-
// one ./stats/<callId>__<clientId>.jsonl per client
|
|
1945
|
-
createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
|
|
1946
|
-
});
|
|
1947
|
-
|
|
1948
|
-
// React when a sink is created for a client:
|
|
1949
|
-
observer.on('client-sink-created', ({ observedClient, sink }) => {
|
|
1950
|
-
sink.on('close', () => {
|
|
1951
|
-
// the file is fully flushed and its fd closed — ready to upload, move, etc.
|
|
1952
|
-
});
|
|
1953
|
-
});
|
|
1954
|
-
```
|
|
1955
|
-
|
|
1956
|
-
| Export | Signature | Notes |
|
|
1957
|
-
|--------|-----------|-------|
|
|
1958
|
-
| `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) |
|
|
1959
|
-
| `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` |
|
|
1960
|
-
| `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready |
|
|
1961
|
-
| `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:
|
|
1962
1968
|
|
|
1963
|
-
|
|
1964
|
-
|
|
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()` |
|
|
1965
1973
|
|
|
1966
|
-
###
|
|
1974
|
+
### Client samples
|
|
1967
1975
|
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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`:
|
|
1971
1979
|
|
|
1972
1980
|
```ts
|
|
1973
|
-
|
|
1981
|
+
const observer = new Observer({
|
|
1982
|
+
// return undefined for no file
|
|
1983
|
+
createClientJsonlSink: ({ observedCall, clientId }) => `./stats/${observedCall.callId}__${clientId}.jsonl`,
|
|
1984
|
+
});
|
|
1974
1985
|
|
|
1975
1986
|
observer.on('client-sink-created', ({ observedClient, sink }) => {
|
|
1976
|
-
|
|
1977
|
-
const { path } = sink; // the file this client's samples go to
|
|
1978
|
-
sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready
|
|
1979
|
-
}
|
|
1987
|
+
sink.once('close', () => uploadFile(sink.path)); // flushed & fd closed → ready
|
|
1980
1988
|
});
|
|
1981
1989
|
```
|
|
1982
1990
|
|
|
1983
|
-
|
|
1984
|
-
consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same.
|
|
1985
|
-
|
|
1986
|
-
### Writing your own sink
|
|
1987
|
-
|
|
1988
|
-
Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file
|
|
1989
|
-
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:
|
|
1990
1992
|
|
|
1991
1993
|
```ts
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1994
|
+
class HttpSink extends EventEmitter implements ClientSampleSink {
|
|
1995
|
+
readonly id: string;
|
|
1996
|
+
private readonly url: string;
|
|
1995
1997
|
private buffer: ClientSample[] = [];
|
|
1996
|
-
|
|
1998
|
+
private ended = false;
|
|
1997
1999
|
|
|
2000
|
+
constructor(settings: SinkSettings & { url: string }) {
|
|
2001
|
+
super();
|
|
2002
|
+
this.id = settings.id;
|
|
2003
|
+
this.url = settings.url;
|
|
2004
|
+
}
|
|
1998
2005
|
write(sample: ClientSample): boolean {
|
|
2006
|
+
if (this.ended) return false;
|
|
1999
2007
|
this.buffer.push(sample); // batch; decide your own backpressure
|
|
2000
2008
|
return true;
|
|
2001
2009
|
}
|
|
2002
2010
|
end(): void {
|
|
2011
|
+
if (this.ended) return;
|
|
2012
|
+
this.ended = true;
|
|
2013
|
+
this.emit('ended'); // required: removes it from its registry
|
|
2003
2014
|
fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) })
|
|
2004
2015
|
.then(() => this.emit('close')) // signal "destination ready"
|
|
2005
2016
|
.catch((err) => this.emit('error', err));
|
|
2006
2017
|
}
|
|
2007
2018
|
}
|
|
2008
2019
|
|
|
2009
|
-
|
|
2010
|
-
|
|
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
|
+
```
|
|
2011
2049
|
|
|
2012
|
-
|
|
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
|
+
});
|
|
2013
2088
|
```
|
|
2014
2089
|
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
`(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()`.
|
|
2018
2092
|
|
|
2019
2093
|
---
|
|
2020
2094
|
|
|
@@ -2025,7 +2099,7 @@ client-reported `ClientSample` — a room id or display name, an application-lev
|
|
|
2025
2099
|
(*"recording started"*), a server-detected issue, an extension stat, or a device/meta item.
|
|
2026
2100
|
`ObservedClient` exposes **injection** methods that merge such data into the client's sample stream,
|
|
2027
2101
|
so it updates the live model **and** is persisted to the client's
|
|
2028
|
-
[sink](#sinks
|
|
2102
|
+
[sink](#sinks) exactly like sampled data.
|
|
2029
2103
|
|
|
2030
2104
|
| Method | Adds to the sample's | Surfaces as |
|
|
2031
2105
|
|--------|----------------------|-------------|
|
|
@@ -2150,7 +2224,7 @@ event map + scope types), `detectors/` (`Detector`, `Detectors`, and one file pe
|
|
|
2150
2224
|
`ActiveIssueTracker`, `ActiveIssuesRegistry`, `ObservedClientIssueRegistry`), `scores/`,
|
|
2151
2225
|
`resolvers/` (remote-track resolvers), `utils/` (`stats`, `SlidingWindow`, `TrendTester`,
|
|
2152
2226
|
`CallHealthAggregator`), `common/` (`logger`, `utils`, `Middleware`), `schema/`
|
|
2153
|
-
(sample/event/meta types), and `sinks/` (the `
|
|
2227
|
+
(sample/event/meta types), and `sinks/` (the `Sink` interface, `SinkRegistry` + `JsonlFileSink` /
|
|
2154
2228
|
`InMemorySink`, re-exported from the package root).
|
|
2155
2229
|
|
|
2156
2230
|
**Conventions to follow when developing further:**
|