@observertc/observer-js 1.2.0 → 1.3.0-rc.2
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 +149 -86
- package/dist/index.d.mts +237 -92
- package/dist/index.d.ts +237 -92
- package/dist/index.js +342 -150
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +340 -148
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -15
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>)`
|
|
@@ -1905,127 +1905,190 @@ observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMe
|
|
|
1905
1905
|
|
|
1906
1906
|
---
|
|
1907
1907
|
|
|
1908
|
-
## Sinks
|
|
1908
|
+
## Sinks
|
|
1909
1909
|
|
|
1910
|
-
A **sink**
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
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
|
-
|
|
1923
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1975
|
-
|
|
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
|
-
###
|
|
1974
|
+
### Client samples
|
|
1978
1975
|
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
1994
|
+
class HttpSink extends EventEmitter implements ClientSampleSink {
|
|
1995
|
+
readonly id: string;
|
|
1996
|
+
private readonly url: string;
|
|
2006
1997
|
private buffer: ClientSample[] = [];
|
|
2007
|
-
|
|
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
|
-
|
|
2021
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2027
|
-
|
|
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
|
|
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 `
|
|
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:**
|