diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df4cb6..354fb55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: node-version: 22 cache: yarn + - name: Test snapshot publisher + run: python3 -m unittest discover -s scripts -p 'test_*.py' + - name: Install run: yarn install --frozen-lockfile diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 85597a7..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Docs (GitHub Pages) - -# Publishes a minimal static site that serves `llms.txt` (the AI-doc index) at the site root, -# alongside the README, the CHANGELOG, and the `docs/` markdown — all verbatim -# (`.nojekyll`), so agents/crawlers can fetch them directly. Project Pages are served under a -# `//` subpath, so the discoverable file is e.g. -# https://.github.io/observer-js/llms.txt -# -# ONE-TIME MANUAL SETUP, required before the first run can succeed: -# repo → Settings → Pages → "Build and deployment" → Source: "GitHub Actions" -# -# There is no way to automate this from the workflow. `actions/configure-pages` has an `enablement` -# input that calls the API to create the Pages site, but that call needs repo-administration rights -# which `GITHUB_TOKEN` does not have and which `permissions:` cannot grant — it fails with -# "Resource not accessible by integration". - -on: - push: - branches: [master] - paths: - - 'llms.txt' - - 'README.md' - - 'CHANGELOG.md' - - 'AGENTS.md' - - 'docs/**' - - '.github/workflows/pages.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment; let an in-progress run finish. -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Assemble site - run: | - set -e - mkdir -p _site/docs - # AI-doc index + one-file export, served verbatim at the site root. - cp llms.txt _site/ - # Human/agent-readable markdown, served raw (no Jekyll processing). - cp README.md CHANGELOG.md _site/ - [ -f AGENTS.md ] && cp AGENTS.md _site/ || true - cp docs/*.md _site/docs/ 2>/dev/null || true - # Disable Jekyll so .txt/.md are served as-is (not converted to HTML). - touch _site/.nojekyll - # Minimal landing page linking the docs. - cat > _site/index.html <<'HTML' - - - - - - @observertc/observer-js — docs - - - -

@observertc/observer-js

-

Server-side Node.js library for monitoring WebRTC sessions.

-

For AI agents

- -

Documentation

- -

Source: github.com/ObserveRTC/observer-js · - Package: npm

- - - HTML - echo "Assembled _site:"; ls -R _site - - # NOTE no `enablement: true`. That option asks the API to CREATE the Pages site, which needs - # repo-administration rights the workflow's GITHUB_TOKEN does not have and cannot be granted - # through `permissions:` — it fails with "Resource not accessible by integration". Enabling - # Pages is a one-time manual step; see the header comment. - - name: Setup Pages - uses: actions/configure-pages@v6 - - - name: Upload artifact - uses: actions/upload-pages-artifact@v5 - with: - path: _site - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 3f3fd55..114c953 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -1,92 +1,75 @@ name: Publish on: - push: - branches: - - master - - develop + workflow_run: + workflows: [Snapshot package] + types: [completed] + workflow_call: + inputs: + release-ref: + required: true + type: string -# Required for npm Trusted Publishing (OIDC). `id-token: write` lets the job mint the -# short-lived OIDC token npm exchanges for publish rights, and enables provenance attestations. -# No NPM_TOKEN secret is used anymore. permissions: contents: read - id-token: write jobs: - publish: + snapshot: + if: >- + github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' + permissions: + actions: read + contents: read + id-token: write runs-on: ubuntu-latest steps: + # Use the trusted default-branch scripts, never execute code from the PR here. - uses: actions/checkout@v6 - - # NOTE deliberately no `registry-url`. That input makes setup-node write an `.npmrc` containing - # //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} - # and yarn 1 resolves env placeholders when it *parses* config, so it throws - # "Failed to replace env in config: ${NODE_AUTH_TOKEN}" before installing anything — and - # `NODE_AUTH_TOKEN` no longer exists, because publishing moved to Trusted Publishing (OIDC). - # npm defaults to registry.npmjs.org anyway, and mints its own short-lived credential. + with: + persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 + - run: npm install -g npm@11 + - name: Download tested PR package + uses: actions/download-artifact@v7 + with: + name: snapshot-package + path: ${{ runner.temp }}/snapshot-artifact + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + - name: Publish next npm snapshot + env: + SNAPSHOT_ARTIFACT: ${{ runner.temp }}/snapshot-artifact + SNAPSHOT_RUN_ID: ${{ github.event.workflow_run.id }} + SNAPSHOT_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/snapshot.npmrc + run: python3 scripts/publish-snapshot.py - # Trusted Publishing requires npm >= 11.5.1, which is newer than the npm bundled with Node 22. - - name: Upgrade npm - run: npm install -g npm@latest - - - name: Install - run: yarn install --frozen-lockfile - - - name: Build - run: yarn build - - - name: Test - run: yarn test --ci - - - name: Resolve context - id: ctx - shell: bash + release: + if: github.event_name == 'workflow_dispatch' + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.release-ref }} + persist-credentials: false + # No registry-url: Yarn 1 cannot resolve an unset NODE_AUTH_TOKEN. + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: yarn + - run: npm install -g npm@11 + - run: yarn install --frozen-lockfile + - run: yarn lint && yarn typecheck && yarn build && yarn test --ci + - name: Publish version to npm run: | VERSION="$(node -p "require('./package.json').version")" - echo "branch=${GITHUB_REF##*/}" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - - # Skip (don't fail) if this exact version is already on npm — avoids a red build when a - # master push didn't bump the version. (develop publishes a unique snapshot, so it's exempt.) - - name: Check if version already published - id: published - if: steps.ctx.outputs.branch == 'master' - shell: bash - run: | - NAME="$(node -p "require('./package.json').name")" - if npm view "$NAME@${{ steps.ctx.outputs.version }}" version >/dev/null 2>&1; then - echo "Version $NAME@${{ steps.ctx.outputs.version }} already published — skipping publish." - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # master -> always "latest", whatever the version looks like. - # - # master is the release line: whatever is on it is what `npm install @observertc/observer-js` - # should give you. The dist-tag is therefore a property of the *branch*, not of the version - # string — a prerelease version on master is still the current release. - # - # Note npm permits a prerelease version under "latest"; it is a convention that stops it, not a - # rule. The consequence is deliberate and worth being clear about: while `package.json` reads - # e.g. 1.0.0-beta.20, a plain `npm install` resolves to that beta. Publish prereleases you do - # *not* want installed by default from a branch other than master. - # - # Auth is via OIDC (trusted publisher); provenance is attached automatically. - - name: Publish - if: steps.ctx.outputs.branch == 'master' && steps.published.outputs.exists == 'false' - run: npm publish --access public --ignore-scripts --tag latest - - # develop -> publish a per-commit release candidate under a custom dist-tag. - - name: Make snapshot version - if: steps.ctx.outputs.branch != 'master' - run: npm version prerelease --preid=${{ steps.ctx.outputs.short_sha }} --git-tag-version false - - - name: Publish (release candidate) - if: steps.ctx.outputs.branch != 'master' - run: npm publish --access public --ignore-scripts --tag "$(echo "develop-${{ steps.ctx.outputs.version }}-rc" | sed 's/[.]//g')" + TAG=latest + if [[ "$VERSION" == *-* ]]; then TAG=beta; fi + npm publish --access public --ignore-scripts --tag "$TAG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3544aa6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Exact version to publish, without v (for example 1.3.0)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + prepare: + if: github.ref == 'refs/heads/master' + permissions: + contents: write + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.prepare.outputs.tag }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: yarn + - run: yarn install --frozen-lockfile + - name: Finalize version and changelog + id: prepare + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + node scripts/prepare-release.cjs + echo "tag=v$RELEASE_VERSION" >> "$GITHUB_OUTPUT" + - run: yarn lint && yarn typecheck && yarn build && yarn test --ci + - name: Commit and tag release + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add package.json CHANGELOG.md + git commit -m "chore: release $RELEASE_VERSION" + git tag -a "v$RELEASE_VERSION" -m "Release $RELEASE_VERSION" + git push --atomic origin HEAD:master "v$RELEASE_VERSION" + + publish: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/publish.yaml + with: + release-ref: ${{ needs.prepare.outputs.tag }} + + release: + needs: [prepare, publish] + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.tag }} + - name: Extract release notes + run: node scripts/prepare-release.cjs --notes + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + run: | + FLAGS=() + if [[ "$RELEASE_TAG" == *-* ]]; then FLAGS+=(--prerelease); fi + gh release create "$RELEASE_TAG" --verify-tag --title "$RELEASE_TAG" --notes-file release-notes.md "${FLAGS[@]}" diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000..cd66cfe --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,32 @@ +name: Snapshot package + +on: + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: yarn + - run: yarn install --frozen-lockfile + - run: yarn lint && yarn typecheck && yarn build && yarn test --ci + - name: Pack tested snapshot + run: | + mkdir -p "$RUNNER_TEMP/snapshot-package" + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/snapshot-package" + - uses: actions/upload-artifact@v7 + with: + name: snapshot-package + path: ${{ runner.temp }}/snapshot-package/*.tgz + if-no-files-found: error + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md index 48ef5b3..40cf63a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,8 +38,10 @@ yarn test:coverage # jest --coverage (enforces the coverage thresholds in j `prepublishOnly` runs lint + typecheck; `prepare` runs the build. CI (`.github/workflows/ci.yml`) runs lint + typecheck + **build** + `test:coverage` on every push/PR. -Publishing (`.github/workflows/publish.yaml`) uses npm **Trusted Publishing (OIDC) + provenance** on -pushes to `master` (prerelease versions publish under the `beta` dist-tag). +Snapshots (`.github/workflows/snapshot.yml`) build PRs targeting `master`; +`publish.yaml` publishes the tested artifact to npm as `-rc.` under `snapshot`. Manual `Release` on `master` takes an exact version, commits the finalized changelog +and package version, tags it, publishes through npm Trusted Publishing, and creates a GitHub release. +See `CONTRIBUTING.md` for setup and recovery. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ada45..5dfdf1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,207 @@ # Changelog +## Unreleased + +### Sinks and `SinkRegistry` (new) + +Every observed object that has a lifecycle now owns a `sinks` registry, so any number of sinks can +be attached to it and are ended with it. The sinks are agnostic about what passes through them; +the library never polls — what is written, and when, is the application's. + +```ts +const sink = observedRouter.sinks.create(JsonlFileSink, { id: transport.id, path }); +observedRouter.sinks.write(transport.id, await transport.getStats()); // or sink.write(...) +// Ended when the router closes; call sink.end() to end it earlier. +``` + +- `Sink`: an interface — `id`, `write(record)`, `end()`, and the events `ended` / `close` / + `error` / `finish` / `drain`. `end()` must emit `ended` synchronously (that removes the sink from + its registry) and later writes must be refused. `JsonlFileSink` and + `InMemorySink` are the built-in implementations; `ClientSampleSink` is now the type + `Sink`. `SinkSettings` (`{ id }`) is what `create` passes to a sink's constructor. +- `SinkRegistry`: `create(SinkClass, settings)` constructs `new SinkClass({ ...settings, id })` + and registers it (settings typed from the constructor; id generated when omitted), `get(id)`, `write(id, record)`, `clear()`, `size`, `keys()`, `values()`. There is no + `remove`: `sink.end()` removes a sink from its registry. The registry attaches an `error` + listener, so a failing sink is logged, not thrown. +- `observer.sinks` is the parent of every `observedCall.sinks` and `observedMediasoupRouter.sinks`. + **Sink ids are unique per observer**: `create` returns `undefined` for an id held by any registry + of the observer, before constructing anything (so no file is opened). A registry sees its own and + its descendants' sinks, so a parent's `get` / `write` / `clear` reach a child's sinks. +- Lifecycle: a router's sinks are ended when it closes, a call's when it closes, and + `observer.close()` ends every sink that is left, including those of routers that are still open + (the observer does not close routers). +- A client writes its samples to the sink under its client id in `observedCall.sinks` + (`observedClient.sink` is now a getter for it) and ends it when the client closes. Client ids + are therefore sink ids and must be unique across the observer's open calls. +- `ObserverConfig.saveClientSamples` decides where each client's samples are saved — exactly one of + two per-client callbacks, called with `{ id, observedCall, clientId }` (the same `jsonlPath` / + `sink` pair as `createExtensionStats`): + - `{ jsonlPath: (p) => string | undefined }`: the library creates a `JsonlFileSink` at the + returned path under the client id; + - `{ sink: (p) => Sink | undefined }`: your sink, whose `id` must be `p.id`. + + **A client's sample sink id is its client id** (`p.id`); `jsonlPath` uses it for you. Sink ids + are unique per observer, so client ids must be unique across the observer's open calls (client + monitors generate uuids); a client whose id is already a sink id gets no sink, with a warning. + `undefined` gives that client no sink; both is a compile error. The callback is called only when + the id is free; a sink with a different id is ended and not used. + `client-sink-created` announces it (`sink: Sink`). +- `SinkRegistry.createFromDestination(id, destination)` registers what a `SinkDestination` + describes (`saveClientSamples` and `createExtensionStats` use it). +- `SinkRegistry.add(sink)` registers a sink constructed elsewhere under its own id, and + `owns(id)` tells whether an id was added to that registry itself rather than to a descendant. +- `JsonlFileSink` and `createJsonlFileSink` take any record type. + +**Breaking** (see *Migrating from 1.2* below): + +- `ObserverConfig.createClientSink` is replaced by `saveClientSamples` (`{ jsonlPath }` or + `{ sink }`, each a per-client callback); sinks implement the `Sink` interface (below). +- `ClientSampleSink` is no longer a class to extend: implement `Sink` (e.g. + `extends EventEmitter implements ClientSampleSink`), take `SinkSettings` in the constructor, and + emit `ended` from `end()`. `JsonlFileSink` and `InMemorySink` implement the interface instead of + extending a base class. `JsonlFileSinkOptions` gained `id`; `new InMemorySink(samples)` is now + `new InMemorySink({ id?, samples? })`. +- `createJsonlFileSinkFactory`, `JsonlFileSinkFactoryOptions` and `ClientSampleSinkFactory` are + removed: use `saveClientSamples: { jsonlPath }`. +- `client-sink-created` carries a `Sink`. +- `ObservedClient.sink` is a getter (read-only, as before). + +#### Migrating from 1.2 + +**JSONL files per client** — `createClientSink: createJsonlFileSinkFactory(...)` becomes +`saveClientSamples: { jsonlPath }`, a callback returning the file path. The factory's default file +name was `${callId}__${clientId}.jsonl`; a `getFileName` option becomes the body of the callback. +For `serializeSample`, use `saveClientSamples: { sink: ({ id }) => new JsonlFileSink({ id, path, serializeSample }) }`. + +```ts +// 1.2 +new Observer({ createClientSink: createJsonlFileSinkFactory({ directory: './stats' }) }); + +// 1.3 +new Observer({ + saveClientSamples: { + jsonlPath: ({ observedCall, clientId }) => path.join('./stats', `${observedCall.callId}__${clientId}.jsonl`), + }, +}); +``` + +**Any other client sink** — `createClientSink` becomes `saveClientSamples: { sink }`; pass the `id` it receives (the client id) to the sink. + +```ts +// 1.2 +new Observer({ createClientSink: ({ clientId, observedCall }) => new HttpSink(urlOf(observedCall.callId, clientId)) }); + +// 1.3 +new Observer({ + saveClientSamples: { sink: ({ id, clientId, observedCall }) => new HttpSink({ id, url: urlOf(observedCall.callId, clientId) }) }, +}); +``` + +**Custom sink classes** — `class HttpSink extends ClientSampleSink` becomes an implementation of +the interface. The constructor receives the settings object, including `id`: + +```ts +// 1.2 +class HttpSink extends ClientSampleSink { + constructor(private readonly url: string) { super(); } + write(sample: ClientSample) { /* ... */ return true; } + end() { /* flush, then */ this.emit('close'); } +} + +// 1.3 +class HttpSink extends EventEmitter implements ClientSampleSink { + readonly id: string; + private readonly url: string; + private ended = false; + + constructor(settings: SinkSettings & { url: string }) { + super(); + this.id = settings.id; + this.url = settings.url; + } + write(sample: ClientSample) { + if (this.ended) return false; // refuse writes after end() + /* ... */ + return true; + } + end() { + if (this.ended) return; + this.ended = true; + this.emit('ended'); // required: removes it from its registry + /* flush, then */ this.emit('close'); + } +} +``` + +**`client-sink-created`** — still fires right after `client-added` for the sink created through +`saveClientSamples`, typed `Sink`; narrow (`sink instanceof JsonlFileSink`) to read `sink.path`. + +**Unchanged:** `observedClient.sink` still returns the client's sink, and the client still ends it +when it closes. + +### Extension stats on `Observer`, `ObservedCall` and `ObservedMediasoupRouter` (new) + +The last data per id, for stats the library does not collect itself, written to the owner's sink +with the same id when there is one. The same API on all three owners: + +```ts +call.createExtensionStats({ id: 'call-1:sfu', jsonlPath: './stats/sfu.jsonl' }); // or sink: new MySink({ id: 'call-1:sfu' }) +call.acceptExtensionStats('call-1:sfu', await collectSfuStats()); // the application polls +call.getExtensionStats('call-1:sfu'); // the last data +call.extensionStats.values; // Map +``` + +- `createExtensionStats({ id, jsonlPath? | sink? })` registers the id, optionally with a sink: at + most one of `jsonlPath` (a `JsonlFileSink` is created at that path under `id`) and `sink` (your + own `Sink`, whose `id` must be `id`); both is a compile error. Either ends and replaces a sink the + owner itself holds under `id` (never a descendant's). Returns the sink, or `undefined`: neither + given, a `sink` with another id, a closed owner, an id already in `extensionStats.values` (created + or accepted before), or the id held as a sink by another object of the observer. A refused `sink` + is ended. +- `SinkDestination` — `{ jsonlPath: string } | { sink: Sink }`, exclusive — is exported; it + is what `createExtensionStats` takes besides `id`. `SaveClientSamplesConfig` and + `ClientSinkParams` (the callbacks' parameter) are exported too. +- `acceptExtensionStats(id, data)` keeps `data` in `extensionStats.values` and writes it + to the sink with the same id, if any — also one created later with `sinks.create`. It does not + require `createExtensionStats` first; an id accepted first gets a sink through `sinks.create`. +- `getExtensionStats(id)` returns the last data. +- Extension stats ids share the sink id namespace, so they must be unique per observer. +- `extensionStats` on each owner is its `ExtensionStatsRegistry` (`create`, `accept`, `get`, + `values`, `closed`, `close()`), which the three methods above delegate to. The owner closes it when + it closes: data accepted afterwards is dropped, `create` is refused, and the extension stats' + sinks are ended (the owner's other sinks end with its `sinks.clear()`). +- `ExtensionStatsRegistry`, `SinkConstructor`, `SinkCreateSettings` and + `CreateExtensionStatsSettings` are exported. + +### `RollingJsonlFileSink` (new) + +A JSONL sink for long-lived owners, such as an observer's extension stats over days: it writes to a +sequence of files and rolls to the next before a write that would exceed `maxBytes`, once the +current file has been open for `maxAgeMs`, or on `roll()`. `path({ index, openedAt })` names each +file, and `file-closed` (`path`, `index`) announces each finished one in order; `close` fires after +the last. Limits are checked on write, so no timers run and an empty file is never rolled. +`JsonlFileSink` is unchanged. Exported with `RollingJsonlFileSinkOptions`, +`RollingJsonlFileSinkEvents` and `RollingJsonlFileInfo`. + +### Fixed: outbound track republished on the same `MediaStreamTrack` + +A producer recreated on the same track (reconnection, toggling audio processing) left the +`ObservedOutboundTrack` linked to the old media source and RTPs, so its stats, the detectors and +the remote track resolver read stale data. Now a new media source for the same track id replaces +the track object (`outbound-track-removed`, then `outbound-track-added`, same `id`), and +`getOutboundRtps()` is looked up on every call. + +Thanks to Alessio Bazzica for reporting it. + +### Other + +- `percentileOfHistogram` narrows the bucket holding the observed `min` / `max` to the observed + range before interpolating, so a distribution inside one bucket (40–49 ms in [40, 60)) is no + longer reported as its maximum. +- `examples/client-summaries.ts` (`yarn example:summaries`): a per-client record read off + `ObservedClient`, folded into the `CallSummary` with enrichers, including a call-level RTT p95 from + the clients' combined `rttHistogram`. + ## 1.2.0 — 2026-10-13 ### `ObservedClient` session totals diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d96a038..b30cf32 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,3 +23,38 @@ Once you are ready with your changes: - Send us a [pull request](https://help.github.com/articles/creating-a-pull-request) + +## Snapshots and releases + +PRs targeting `master` build and test the package in Snapshot package. Publish then +publishes that artifact to npm as `@observertc/observer-js@-rc.`, +under the `snapshot` dist-tag. The base version is the next minor after npm's +`latest` release, regardless of the PR's package version. For example, `latest` at +`1.2.0` (or `1.2.9`) produces `1.3.0-rc.1`, then `1.3.0-rc.2`. Once `1.3.0` is +released as `latest`, snapshots target `1.4.0-rc.1`. The counter uses the highest existing rc +number on npm for that base version; simultaneous runs retry version collisions. +Install with `npm install @observertc/observer-js@snapshot` or the exact rc version. +The snapshot version is never committed or tagged, and `latest` is untouched. + +No external snapshot app or npm token is needed. The publisher runs separately on +`workflow_run`, so fork PR builds never receive publishing credentials or execute +scripts in the publishing job. Both workflows and the publisher script must be on +the default branch before automatic snapshots can run. Pushes and merges do not +publish packages. The retired Pages workflow has been removed. + +To release, merge the changes into `master`, then open Actions → Release → Run workflow, +select `master`, and enter the exact version (for example `1.3.0` or `1.4.0-beta.1`). +Keep release notes under `## Unreleased` in `CHANGELOG.md`. The workflow validates the +version and notes, runs checks, commits the package version and dated changelog section, +creates `v`, publishes to npm, and creates a GitHub release using those same notes. +Stable versions use `latest`; prereleases use `beta`. A fresh Unreleased section is retained. + +npm Trusted Publishing must authorize `publish.yaml` for snapshots and `release.yml` +for manual releases in `ObserveRTC/observer-js`. npm checks the calling workflow +for reusable workflows. Both publishers must allow direct `npm publish`. No npm token is needed. Repository rules must allow +`github-actions[bot]` to push the release commit to master and create tags. Manual dispatch +requires the workflow to exist on the default branch; releases run only from master. + +If publishing or GitHub release creation fails after the tag is pushed, fix the cause and +use **Re-run failed jobs** on that run. Do not start a fresh release for the same version: +npm versions are immutable and the release preparation rejects existing tags. diff --git a/README.md b/README.md index 92e7be4..268a8d9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ and emits a single, unified stream of typed events the application can react to. 11. [Call summaries](#call-summaries) 12. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu) 13. [Mediasoup router observation](#mediasoup-router-observation) -14. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence) +14. [Sinks](#sinks) 15. [Injecting data into a client](#injecting-data-into-a-client) 16. [Logging](#logging) 17. [Design notes](#design-notes) @@ -83,14 +83,14 @@ yarn add @observertc/observer-js works the same whether your project is ESM or CommonJS — your import line is unchanged either way: ```ts -import { Observer, ClientSample, createJsonlFileSinkFactory } from '@observertc/observer-js'; +import { Observer, ClientSample, JsonlFileSink } from '@observertc/observer-js'; ``` In an ESM project this resolves to the `.mjs` build; in a CommonJS project (where TypeScript compiles your `import` down to `require()`) it resolves to the `.js` build. Everything is exported from the single `@observertc/observer-js` entry. Written in TypeScript; ships type declarations for both formats (`dist/index.d.ts` for `require`, `dist/index.d.mts` for `import`). Runtime -dependencies: `@bufbuild/protobuf`, `events`, `uuid`. The library does **not** bundle a logger or +dependencies: `@bufbuild/protobuf`, `uuid`. The library does **not** bundle a logger or any transport — see [Logging](#logging). `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 | Event | Extra | Fires when | |-------|-------|-----------| | `client-added` | — | a client is created | -| `client-sink-created` | `{ sink: ClientSampleSink }` | a per-client sink was created (only when `createClientSink` returns one); fires right after `client-added` | +| `client-sink-created` | `{ sink: Sink }` | the client's sample sink was created through `saveClientSamples`; fires right after `client-added` | | `client-updated` | `{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext }` | the client processed a sample | | `client-closed` | — | the client closed | | `client-joined` | — | first `CLIENT_JOINED` event seen | @@ -479,8 +479,11 @@ type ObserverConfig> = { // (incl. lazily by accept()). appData is application-owned; accept `context` never touches it. createCallAppData?: (p: { callId: string; observer: Observer; acceptCtx?: AcceptContext }) => Record; createClientAppData?: (p: { clientId: string; observedCall: ObservedCall; acceptCtx?: AcceptContext }) => Record; - // sink factory — produces a per-client sink that receives every accepted sample (see Sinks). - createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined; + // a JSONL file per client: return its path, or undefined for none (see Sinks). + // where each client's samples are saved: exactly one of jsonlPath / sink, each a per-client callback (see Sinks). + saveClientSamples?: + | { jsonlPath: (p: { id: string; clientId: string; observedCall: ObservedCall }) => string | undefined } + | { sink: (p: { id: string; clientId: string; observedCall: ObservedCall }) => Sink | undefined }; // remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution). createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined; }; @@ -591,7 +594,7 @@ Key members: - `readonly clientId: string`, `appData: AppData`, `readonly call: ObservedCall` - `readonly observedPeerConnections: Map` -- `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error` +- `get sink(): Sink | undefined` — the sink under its client id in `call.sinks` (see [Sinks](#sinks)); listen on it for `close`/`error` - **Injection API** (queue app data to be merged into the next sample processing): `injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`, `injectExtensionStat(ExtensionStat)`, `injectAttachment(attachments: Record)` @@ -1905,127 +1908,256 @@ observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMe --- -## Sinks (per-client sample persistence) +## Sinks -A **sink** receives the samples a client accepts — for archival, streaming, or later offline -replay. Each `ObservedClient` gets its **own** sink, produced by the -`ObserverConfig.createClientSink` factory when the client is created (return `undefined` for no -sink). The client pushes every accepted sample to its sink, and `end()`s it on close. +A **sink** is a destination for records — client samples, a mediasoup transport's `getStats()`, +anything the application wants to keep. Sinks are agnostic about what passes through them; their +**lifecycle is owned by the object they are registered on**, through that object's `sinks` +registry. The library never polls: what is written, and when, is the application's. -### The `ClientSampleSink` base class +| Registry | Ended when | Typical use | +|----------|-----------|-------------| +| `observer.sinks` | the observer closes | worker- or process-level stats | +| `observedCall.sinks` | the call closes | each client's sample sink (under its client id), call-level stats | +| `observedMediasoupRouter.sinks` | the router closes | e.g. one file per pipe transport | -`ClientSampleSink` is an **abstract base class** (a typed `EventEmitter`). You create a sink by -**subclassing it** and implementing `write` and `end`. It is **object-mode**: `write` receives -the `ClientSample` *object*, so each sink decides how (or whether) to serialize it — JSON line, -protobuf, a remote POST body, an in-memory push, etc. +`observer.sinks` is the parent of every call's and router's registry. **Sink ids are unique per +observer**: creating a sink with an id that any registry of the observer already holds is refused +(`create` returns `undefined`, and nothing is constructed). A registry sees its own sinks and its descendants', so `observer.sinks.write(id, record)` +reaches any sink, and `observer.close()` ends every sink that is left — including those of routers +that are still open, since the observer does not close mediasoup routers. -```ts -import { ClientSampleSink, ClientSample } from '@observertc/observer-js'; +### `SinkRegistry` + +| Method | Does | +|--------|------| +| `create(SinkClass, settings)` | constructs `new SinkClass(settings)` and registers it under `settings.id` (generated when omitted); `undefined` if the id is taken | +| `add(sink): boolean` | registers a sink constructed elsewhere under its own `id`; `false` if the id is taken (the sink stays yours to end). Prefer `create` for sinks that open a resource on construction: it checks the id first | +| `createFromDestination(id, destination)` | registers the sink a `SinkDestination` describes under `id`: `{ jsonlPath }` creates a `JsonlFileSink` (the id is checked first), `{ sink }` adds your sink (its `id` must be `id`). `undefined` if refused; a refused `sink` is ended | +| `owns(id): boolean` | whether `id` was added to this registry itself, not to a descendant | +| `get(id)` | the sink, if any (own or a descendant's) | +| `write(id, record): boolean` | writes to the sink; `false` if there is none, or on backpressure | +| `clear()` | ends every sink of the registry and its descendants | +| `size`, `keys()`, `values()` | own and descendants' sinks | -abstract class ClientSampleSink /* extends EventEmitter */ { - abstract write(sample: ClientSample): boolean; // accept one sample; `false` = backpressure - abstract end(): void; // flush; emit `close` when the destination is ready +There is no `remove`: **`sink.end()` closes a sink and removes it from its registry**, which frees +its id. The registry attaches an `error` listener to each sink, so a failing destination is logged +and never crashes the process. - // typed events (inherited): the listener signature is inferred from the event name - on(event: 'close' | 'finish' | 'drain', listener: () => void): this; - on(event: 'error', listener: (err: Error) => void): this; - // ...and the matching `once` / `off` / `emit` +### The `Sink` interface + +```ts +import type { Sink, SinkSettings } from '@observertc/observer-js'; + +interface Sink { // typically: class X extends EventEmitter implements Sink + readonly id: string; // unique per observer + write(record: T): boolean; // `false` once ended (nothing written), or on backpressure + end(): void; // idempotent; MUST emit `ended` synchronously, then flush and emit `close` + on / once / off(event, listener); // the events below } + +type SinkSettings = { id: string }; // what `create` passes to the constructor, with your own settings ``` +An implementation must emit `ended` from `end()` — that is how its registry removes it and frees +its id — refuse writes after it, and emit `close` / `error` for its destination. + | Event | Meaning | |-------|---------| +| `ended` | `end()` was called: no more writes, removed from its registry | | `close` | the destination is fully written and closed (e.g. a file flushed and its fd closed) — "ready" | | `error` | the destination failed | | `finish` | `end()` was processed and queued data flushed (before `close`) | | `drain` | the buffer drained after backpressure; safe to write more | -The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited), -`end()`s the sink when the client closes, and attaches an `error` listener so a failing sink -can't crash the process (it also catches throws from `write`/`end`). The application — which -created the sink — listens for `close` (destination ready) and `error`. Because `write` isn't -awaited in the `accept()` hot path, **backpressure and batching are the sink's concern**. +`ClientSampleSink` is `Sink`. Built-in implementations: -### Built-in sinks +| Export | Notes | +|--------|-------| +| `JsonlFileSink` / `createJsonlFileSink({ path, serializeSample? })` | appends one JSON line per record; wraps `fs.WriteStream` and re-emits its events; exposes `readonly path`. The directory **must exist** | +| `RollingJsonlFileSink` | JSONL over a sequence of files for long-lived owners: rolls on `maxBytes`, `maxAgeMs` or `roll()`; `file-closed` per finished file. See [Rolling files](#rolling-files) | +| `InMemorySink` / `createInMemorySink(samples?)` | collects client samples into `.samples`; emits `close` on `end()` | -```ts -import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js'; - -const observer = new Observer({ - // one ./stats/__.jsonl per client - createClientSink: createJsonlFileSinkFactory({ directory: './stats' }), -}); +### Client samples -// React when a sink is created for a client: -observer.on('client-sink-created', ({ observedClient, sink }) => { - sink.on('close', () => { - // the file is fully flushed and its fd closed — ready to upload, move, etc. - }); -}); -``` +A client writes every accepted sample to the sink registered under its **client id** in its call's +registry (`observedClient.sink` returns it), and ends that sink when it closes. The +**`saveClientSamples`** config option decides it — exactly one of two per-client callbacks, the same +`jsonlPath` / `sink` pair as `createExtensionStats` takes: -| Export | Signature | Notes | -|--------|-----------|-------| -| `createJsonlFileSinkFactory` | `({ directory, flags?, getFileName?, serializeSample? }) => ClientSampleSinkFactory` | per-client JSONL files; path defaults to `${callId}__${clientId}.jsonl` under `directory` (which **must exist**) | -| `createJsonlFileSink` | `({ path, flags?, serializeSample? }) => ClientSampleSink` | a single JSONL file; wraps `fs.WriteStream` and re-emits its `close`/`finish`/`drain`/`error` | -| `JsonlFileSink` | `class extends ClientSampleSink` | the underlying class; exposes `readonly path` so a `close` handler knows which file is ready | -| `createInMemorySink` / `InMemorySink` | `(samples?: ClientSample[]) => InMemorySink` | collects the accepted **sample objects** into `.samples: ClientSample[]`; emits `close` on `end()` | - -`serializeSample?: (sample: ClientSample) => string` overrides the default `JSON.stringify` for -the JSONL sinks (e.g. to redact or reshape before writing). - -### Reading sink-specific info (e.g. the file path) - -The bus hands you the sink as the base `ClientSampleSink`. To read information specific to a sink -type — for a file sink, where it was written — **narrow with `instanceof`** and read the sink's -public fields. `JsonlFileSink` exposes `path`: +| `saveClientSamples` | Called per client with `{ id, observedCall, clientId }`, returns | Sink | +|---------------------|------------------------------------------------------------------|------| +| `{ jsonlPath }` | the path of the client's file, or `undefined` | a `JsonlFileSink` at that path, created by the library under the client id | +| `{ sink }` | your `Sink`, or `undefined` | that sink; its `id` must be `id` | ```ts -import { JsonlFileSink } from '@observertc/observer-js'; +const observer = new Observer({ + saveClientSamples: { + jsonlPath: ({ observedCall, clientId }) => `./stats/${observedCall.callId}__${clientId}.jsonl`, + }, +}); observer.on('client-sink-created', ({ observedClient, sink }) => { - if (sink instanceof JsonlFileSink) { - const { path } = sink; // the file this client's samples go to - sink.once('close', () => uploadFile(path)); // close = flushed & fd closed → ready - } + if (sink instanceof JsonlFileSink) sink.once('close', () => uploadFile(sink.path)); // flushed & fd closed → ready }); ``` -The general pattern: each concrete sink exposes whatever it wants as `public readonly` fields, and -consumers narrow (`instanceof YourSink`) to read them. Your own sinks do the same. +**A client's sample sink id is its client id.** The `id` the callbacks receive is that id, and +with `jsonlPath` the library uses it for you. Sink ids are unique per observer, so **client ids +must be unique across the observer's open calls** — client monitors generate uuids, so this +normally holds. A client whose id is already a sink id gets no sink, and a warning is logged. -### Writing your own sink +The callback is called only when the id is free, so a clash opens no file; `undefined` gives that +client no sink. A returned sink with a different id is ended and not used. Giving both `jsonlPath` +and `sink` is a compile error (and refused at runtime). For a JSONL file with a custom serializer, +use `sink: ({ id }) => new JsonlFileSink({ id, path, serializeSample })`. -Subclass `ClientSampleSink` and emit the lifecycle events yourself — for any non-file -destination (a remote endpoint, a message queue, an object store, …): +Any other destination works the same way — return your own implementation: ```ts -import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js'; - -class HttpSink extends ClientSampleSink { +class HttpSink extends EventEmitter implements ClientSampleSink { + readonly id: string; + private readonly url: string; private buffer: ClientSample[] = []; - constructor(private readonly url: string) { super(); } + private ended = false; + constructor(settings: SinkSettings & { url: string }) { + super(); + this.id = settings.id; + this.url = settings.url; + } write(sample: ClientSample): boolean { + if (this.ended) return false; this.buffer.push(sample); // batch; decide your own backpressure return true; } end(): void { + if (this.ended) return; + this.ended = true; + this.emit('ended'); // required: removes it from its registry fetch(this.url, { method: 'POST', body: JSON.stringify(this.buffer) }) .then(() => this.emit('close')) // signal "destination ready" .catch((err) => this.emit('error', err)); } } -const createClientSink: ClientSampleSinkFactory = ({ clientId, observedCall }) => - new HttpSink(`https://stats.example.com/${observedCall.callId}/${clientId}`); +new Observer({ + saveClientSamples: { + sink: ({ id, clientId }) => new HttpSink({ id, url: `https://stats.example.com/${clientId}` }), // id: the client id + }, +}); + +// Or later, on `client-added`, if the client has no sink yet: +// observedCall.sinks.create(HttpSink, { id: observedClient.clientId, url }) +``` + +Client ids double as sink ids, so they must be unique across the observer's open calls (client +monitors generate uuids). A client whose id is already a sink id gets no sample file, and a +warning is logged. + +The library calls `write(sample)` **synchronously** per accepted sample (it is not awaited), so +**backpressure and batching are the sink's concern**. + +### Extension stats + +Stats the library does not collect itself can be kept as **extension stats** on the object they +belong to — `observer` (e.g. a mediasoup worker's resource usage), `observedCall` (an SFU's view of +the call) or `observedMediasoupRouter` (a pipe transport's `getStats()`): the last data per id, and +optionally a sink that receives every data. The API is the same on all three; a call is shown. + +```ts +// Register the id, with a JSONL file that receives every data accepted for it. +call.createExtensionStats({ id: `${call.callId}:sfu`, jsonlPath: './stats/sfu.jsonl' }); -const observer = new Observer({ createClientSink }); +// Or with a sink of your own; its id must be the extension stats id. +call.createExtensionStats({ id: `${call.callId}:metrics`, sink: new HttpSink({ id: `${call.callId}:metrics`, url }) }); + +// Your own polling: keep the last data, and write it to the sink with the same id, if any. +call.acceptExtensionStats(`${call.callId}:sfu`, await collectSfuStats(call.callId)); + +call.getExtensionStats(`${call.callId}:sfu`); // the last data +call.extensionStats.values; // Map +``` + +| Method | Behaviour | +|--------|-----------| +| `createExtensionStats({ id, jsonlPath? \| sink? })` | Registers `id`, optionally with a sink — at most one of: `jsonlPath`, the path of a JSONL file, for which a `JsonlFileSink` is created under `id`; or `sink`, a `Sink` of your own, whose `id` must be `id`. Either ends and replaces a sink the owner itself already holds under `id`. Returns the sink, or `undefined`: with neither, on a closed owner, when `id` is already in `extensionStats.values` (created or accepted before), with a `sink` whose id differs, or when another object of the observer holds a sink with this id. Giving both is a compile error (refused at runtime). A refused `sink` is ended; for `jsonlPath`, the id is checked before the file is opened | +| `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 | +| `getExtensionStats(id)` | The last data accepted for `id`, or `undefined` | +| `extensionStats` | The owner's `ExtensionStatsRegistry`: `values` (`Map`), `closed`, `close()`, and `create` / `accept` / `get`, which the three methods above delegate to | + +**Extension stats ids share the sink id namespace, so they must be unique per observer**: the sink +of extension stats `x` is the sink with id `x`. Consequently: + +- With `jsonlPath` or `sink`, a sink the owner itself already holds under the id is ended and replaced. A sink another object holds under it is never touched: no sink is created and the data + is only kept. +- With neither, a sink you add later under the id (`sinks.create(SinkClass, { id, ... })` or + `sinks.add(sink)`) receives the data accepted from then on. +- An id accepted before it was created can no longer be created with a sink; give it one with + `sinks.create` / `sinks.add` instead. + +**Lifecycle.** When the owner (the call, the router or the observer) closes, it closes its +`extensionStats`: data accepted afterwards is dropped, `createExtensionStats` returns `undefined`, +and the extension stats' sinks are ended. The owner's other sinks end with its `sinks.clear()` as +usual. `extensionStats.close()` can also be called directly; it ends only the extension stats' +sinks. + +### Rolling files + +`JsonlFileSink` writes one file for the sink's whole life — right for calls and clients, which come +and go. An owner that lives for days or months, such as the observer itself, needs its records cut +into several files. `RollingJsonlFileSink` does that: it writes JSONL to a sequence of files, closing +the current one and opening the next (*rolling*) before a write that would exceed `maxBytes`, once +the current file has been open for `maxAgeMs`, or when `roll()` is called. + +```ts +const sink = new RollingJsonlFileSink({ + id: 'sfu-cpu', + path: ({ index, openedAt }) => `./stats/sfu-cpu-${openedAt}-${index}.jsonl`, + maxBytes: 100 * 1024 * 1024, + maxAgeMs: 60 * 60 * 1000, +}); + +sink.on('file-closed', (path, index) => uploadFile(path)); // flushed & fd closed → ready + +observer.createExtensionStats({ id: 'sfu-cpu', sink }); +setInterval(async () => observer.acceptExtensionStats('sfu-cpu', await readCpu()), 5_000).unref(); +``` + +| Option / member | Meaning | +|-----------------|---------| +| `path({ index, openedAt })` | the path of each file, called when it is opened (`index` 0, 1, 2, …; `openedAt` = `Date.now()`). Must give a new path per `index` | +| `maxBytes?`, `maxAgeMs?` | roll before a write that would make the file larger than `maxBytes`, or once it has been open for `maxAgeMs` | +| `serializeSample?` | custom serializer; defaults to `JSON.stringify` | +| `roll()` | close the current file and continue in a new one | +| `path`, `index` | the file currently written to | + +- Nothing is dropped: each record goes to exactly one file. +- The limits are checked on `write`, so the sink runs no timers: a file is not rolled while nothing + is written to it, and an empty file is never rolled (`roll()` on one is a no-op). +- `file-closed` (`path`, `index`) fires for every finished file, in index order, the last one + included. `close` fires once, after `end()` has closed every file. The owner ends the sink as + usual — here when the observer closes. + +### Other records: e.g. mediasoup pipe transport stats + +```ts +const observedRouter = observer.createObservedMediasoupRouter({ router })!; + +observedRouter.on('transport-sample-added', ({ transport }) => { + if (transport.type !== 'pipe') return; + + observedRouter.sinks.create(JsonlFileSink, { id: transport.id, path: `./stats/${transport.id}.jsonl` }); + + // Your own polling. A write to an ended sink returns false, so a late getStats() is harmless. + const timer = setInterval(async () => observedRouter.sinks.write(transport.id, await transport.getStats()), 5_000); + + transport.observer.once('close', () => clearInterval(timer)); +}); ``` -`observedClient.sink?` exposes the created sink; the `client-sink-created` event delivers it on -the bus with full ancestry. `ClientSampleSinkFactory` is -`(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`. +The files are ended when the router closes, which is usually the lifetime you want for a pipe +transport's stats. To end one earlier, call `end()` on it: `observedRouter.sinks.get(transport.id)?.end()`. --- @@ -2036,7 +2168,7 @@ client-reported `ClientSample` — a room id or display name, an application-lev (*"recording started"*), a server-detected issue, an extension stat, or a device/meta item. `ObservedClient` exposes **injection** methods that merge such data into the client's sample stream, so it updates the live model **and** is persisted to the client's -[sink](#sinks-per-client-sample-persistence) exactly like sampled data. +[sink](#sinks) exactly like sampled data. | Method | Adds to the sample's | Surfaces as | |--------|----------------------|-------------| @@ -2161,7 +2293,7 @@ event map + scope types), `detectors/` (`Detector`, `Detectors`, and one file pe `ActiveIssueTracker`, `ActiveIssuesRegistry`, `ObservedClientIssueRegistry`), `scores/`, `resolvers/` (remote-track resolvers), `utils/` (`stats`, `SlidingWindow`, `TrendTester`, `CallHealthAggregator`), `common/` (`logger`, `utils`, `Middleware`), `schema/` -(sample/event/meta types), and `sinks/` (the `ClientSampleSink` base + `JsonlFileSink` / +(sample/event/meta types), and `sinks/` (the `Sink` interface, `SinkRegistry` + `JsonlFileSink` / `InMemorySink`, re-exported from the package root). **Conventions to follow when developing further:** diff --git a/examples/client-summaries.ts b/examples/client-summaries.ts new file mode 100644 index 0000000..a4bcb73 --- /dev/null +++ b/examples/client-summaries.ts @@ -0,0 +1,251 @@ +/** + * Per-client and per-call summaries. + * + * Shows how to turn what an `ObservedClient` accumulated over its session into a record you can + * save, and how to fold those records into the call's `CallSummary` with an enricher: + * + * ObservedClient session totals ──client-closed──▶ enricher ──▶ summary.attachments + * │ + * ObservedCall.close() ──▶ call-summary ◀────────────────────────────┘ + * + * Run it with: yarn example:summaries + * + * Nothing here needs a helper object next to the client: every value is read off `ObservedClient` + * at the moment it closes (`rttSummary`, `scoreSummary`, `issueHistogram`, the `total*` counters + * and the `numberOfSamplesOver*` path counters). The histograms are also kept raw, so the call + * level can combine them bucket by bucket — percentiles of a call cannot be averaged from the + * percentiles of its clients. + */ +import { + Observer, + ObservedClient, + percentileOfHistogram, + RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS, + RTT_HISTOGRAM_NUMBER_OF_BUCKETS, + type CallSummary, + type ClientSample, + type RttSummary, + type ScoreSummary, +} from '../src'; + +/* ------------------------------------------------------------------------------------------------ + * 1. The record the application saves per client. + * ---------------------------------------------------------------------------------------------- */ + +type ClientPath = 'udp' | 'tcp' | 'turn-udp' | 'turn-tcp' | 'mixed' | 'unknown'; + +type ClientStatsSummary = { + clientId: string; + samples: number; + browser?: string; + os?: string; + path: ClientPath; + rtt: RttSummary; + maxRttInMs?: number; + score: ScoreSummary; + inboundPacketsLost: number; + inboundPacketsReceived: number; + inboundLossPct?: number; + issues: Record; +}; + +/** The `path` label is an application decision, so it is derived here rather than in the library. */ +function pathOf(client: ObservedClient): ClientPath { + const used: ClientPath[] = []; + + if (0 < client.numberOfSamplesOverUdp) used.push('udp'); + if (0 < client.numberOfSamplesOverTcp) used.push('tcp'); + if (0 < client.numberOfSamplesOverTurnUdp) used.push('turn-udp'); + if (0 < client.numberOfSamplesOverTurnTcp) used.push('turn-tcp'); + + if (used.length === 0) return 'unknown'; + + return used.length === 1 ? used[0] : 'mixed'; +} + +function summarizeClient(client: ObservedClient): ClientStatsSummary { + const lost = client.totalInboundPacketsLost; + const received = client.totalInboundPacketsReceived; + + return { + clientId: client.clientId, + samples: client.acceptedSamples, + browser: client.browser ? `${client.browser.name} ${client.browser.version}` : undefined, + os: client.operationSystem ? `${client.operationSystem.name} ${client.operationSystem.version}` : undefined, + path: pathOf(client), + // Both are frozen: safe to store as they are. + rtt: client.rttSummary, + maxRttInMs: client.maxRttInMs, + score: client.scoreSummary, + inboundPacketsLost: lost, + inboundPacketsReceived: received, + inboundLossPct: 0 < lost + received ? (lost / (lost + received)) * 100 : undefined, + // Copied: `issueHistogram` is live and has no prototype, so spread it into a plain object. + issues: { ...client.issueHistogram }, + }; +} + +/* ------------------------------------------------------------------------------------------------ + * 2. The call-level state the enrichers keep in `summary.attachments`. + * ---------------------------------------------------------------------------------------------- */ + +type CallAttachments = { + clients: ClientStatsSummary[]; + rttHistogram: number[]; + issues: Record; + regions: string[]; +}; + +/** Typed access to the enricher-owned part of the summary, created on first use. */ +function attachmentsOf(summary: CallSummary): CallAttachments { + return (summary.attachments.app ??= { + clients: [], + rttHistogram: new Array(RTT_HISTOGRAM_NUMBER_OF_BUCKETS).fill(0), + issues: {}, + regions: [], + } satisfies CallAttachments) as CallAttachments; +} + +/* ------------------------------------------------------------------------------------------------ + * 3. The observer, with built-in sections plus enrichers. + * ---------------------------------------------------------------------------------------------- */ + +const observer = new Observer({ + closeCallIfEmptyForMs: undefined, + callSummary: { + // Built-in sections: who was there, every call issue, TURN relays, the call score. + include: [ 'clients', 'issues', 'turnServers', 'scores' ], + enrich: { + // Low-frequency event, once per client: enrich from `appData` the application set. + 'client-added': (summary, { observedClient }) => { + const region = observedClient.appData.region; + + if (typeof region === 'string' && !attachmentsOf(summary).regions.includes(region)) { + attachmentsOf(summary).regions.push(region); + } + }, + + // Fires once per client, also for every client still open when the call closes, and + // always before `call-summary`. This is where a client's session is complete. + 'client-closed': (summary, { observedClient }) => { + const app = attachmentsOf(summary); + + app.clients.push(summarizeClient(observedClient)); + + observedClient.rttHistogram.forEach((count, bucket) => { + app.rttHistogram[bucket] += count; + }); + for (const [ type, count ] of Object.entries(observedClient.issueHistogram)) { + app.issues[type] = (app.issues[type] ?? 0) + count; + } + }, + }, + }, +}); + +observer.on('call-summary', ({ summary }) => { + const app = attachmentsOf(summary); + const callRttP95 = percentileOfHistogram(app.rttHistogram, RTT_HISTOGRAM_BUCKET_WIDTH_IN_MS, 0.95, { + max: Math.max(...app.clients.map((c) => c.maxRttInMs ?? 0)), + openEnded: true, + }); + + // in production: archive(summary) — it is plain, serialisable data. + console.log(JSON.stringify({ + callId: summary.callId, + durationInMs: summary.durationInMs, + clients: summary.clients, + callScore: summary.scores, + callRttP95InMs: callRttP95, + issuesByType: app.issues, + regions: app.regions, + perClient: app.clients, + }, null, 2)); +}); + +/* ------------------------------------------------------------------------------------------------ + * 4. Synthetic traffic. In production these come from client-monitor-js. + * ---------------------------------------------------------------------------------------------- */ + +let now = Date.now(); + +type Tick = { + rttInMs: number, + received: number, + lost: number, + score: number, + relay?: 'udp' | 'tcp', + issues?: ClientSample['clientIssues'], + events?: ClientSample['clientEvents'], +}; + +function sampleOf(clientId: string, tick: Tick): ClientSample { + const timestamp = now; + const pcId = `${clientId}-pc`; + const local = tick.relay + ? { id: 'local', timestamp, candidateType: 'relay', protocol: 'udp', relayProtocol: tick.relay, url: 'turn:turn.example.com:3478' } + : { id: 'local', timestamp, candidateType: 'srflx', protocol: 'udp' }; + + return { + callId: 'call-1', + clientId, + timestamp, + score: tick.score, + clientIssues: tick.issues, + clientEvents: tick.events, + clientMetaItems: [ + { type: 'BROWSER', payload: JSON.stringify({ name: 'Chrome', version: '141' }) }, + { type: 'OPERATION_SYSTEM', payload: JSON.stringify({ name: 'macOS', version: '15.6' }) }, + ], + peerConnections: [ { + peerConnectionId: pcId, + inboundRtps: [ { timestamp, id: 'in', ssrc: 1, kind: 'audio', trackIdentifier: `${clientId}-in`, packetsReceived: tick.received, packetsLost: tick.lost, bytesReceived: tick.received * 120 } ], + inboundTracks: [ { timestamp, id: `${clientId}-in`, kind: 'audio' } ], + mediaSources: [ { timestamp, id: 'ms', kind: 'audio', trackIdentifier: `${clientId}-out` } ], + outboundRtps: [ { timestamp, id: 'out', ssrc: 2, kind: 'audio', trackIdentifier: `${clientId}-out`, mediaSourceId: 'ms', packetsSent: tick.received, bytesSent: tick.received * 120 } ], + outboundTracks: [ { timestamp, id: `${clientId}-out`, kind: 'audio' } ], + remoteInboundRtps: [ { timestamp, id: 'rin', ssrc: 2, kind: 'audio', roundTripTime: tick.rttInMs / 1000 } ], + iceCandidates: [ local, { id: 'remote', timestamp, candidateType: 'host', protocol: 'udp' } ], + iceCandidatePairs: [ { id: 'pair', timestamp, localCandidateId: 'local', remoteCandidateId: 'remote', nominated: true, bytesReceived: 0, bytesSent: 0 } ], + iceTransports: [ { id: 'transport', timestamp, selectedCandidatePairId: 'pair' } ], + } ], + } as unknown as ClientSample; +} + +const call = observer.createObservedCall({ callId: 'call-1' })!; + +call.createObservedClient({ clientId: 'alice', appData: { region: 'eu-north' } }); +call.createObservedClient({ clientId: 'bob', appData: { region: 'us-east' } }); + +// The packet counters are cumulative, as `getStats()` reports them. +let bobLost = 0; + +for (let i = 0; i < 60; ++i) { + now += 1000; + + // CLIENT_JOINED / CLIENT_LEFT give the call its `startedAt` / `endedAt`, hence `durationInMs`. + const events = () => (i === 0 ? [ { type: 'CLIENT_JOINED', timestamp: now } ] : i === 59 ? [ { type: 'CLIENT_LEFT', timestamp: now } ] : undefined); + + // alice: healthy, direct UDP. + observer.accept(sampleOf('alice', { rttInMs: 40 + (i % 10), received: (i + 1) * 50, lost: 0, score: 4.6, events: events() })); + + // bob: congested for a while, falls back to TURN over TCP halfway through. + const congested = 20 <= i && i < 35; + + bobLost += congested ? 8 : (i % 10 === 0 ? 1 : 0); + observer.accept(sampleOf('bob', { + events: events(), + rttInMs: congested ? 350 + (i * 5) : 120, + received: (i + 1) * 50, + lost: bobLost, + score: congested ? 2.1 : 4.0, + relay: 30 <= i ? 'tcp' : undefined, + issues: i === 20 ? [ { type: 'network-congestion', key: 'bob:network-congestion', timestamp: now } ] + : i === 35 ? [ { type: 'network-congestion-resolved', key: 'bob:network-congestion', timestamp: now } ] + : undefined, + })); +} + +// Closing the call closes its clients (each fires `client-closed`), then emits `call-summary`. +call.close(); +observer.close(); diff --git a/package.json b/package.json index 9d132ca..bcf0159 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test:coverage": "jest --config jest.config.js --coverage", "example": "ts-node --compiler-options '{\"module\":\"commonjs\"}' examples/sfu-observer.ts", "example:detectors": "ts-node --compiler-options '{\"module\":\"commonjs\"}' examples/detectors.ts", + "example:summaries": "ts-node --compiler-options '{\"module\":\"commonjs\"}' examples/client-summaries.ts", "bench": "ts-node --compiler-options '{\"module\":\"commonjs\"}' bench/detectors.bench.ts" }, "keywords": [ diff --git a/scripts/prepare-release.cjs b/scripts/prepare-release.cjs new file mode 100644 index 0000000..b472255 --- /dev/null +++ b/scripts/prepare-release.cjs @@ -0,0 +1,26 @@ +const fs = require('node:fs'); +const { execFileSync } = require('node:child_process'); +const semver = require('semver'); + +const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const changelog = fs.readFileSync('CHANGELOG.md', 'utf8'); +if (process.argv.includes('--notes')) { + const heading = `## ${pkg.version} — `; + const start = changelog.indexOf(heading); + if (start < 0) throw new Error('Release section not found'); + const body = changelog.slice(changelog.indexOf('\n', start) + 1); + fs.writeFileSync('release-notes.md', body.split(/^## /m)[0].trim() + '\n'); +} else { + const version = process.env.RELEASE_VERSION; + if (!version || semver.valid(version) !== version) throw new Error('Provide an exact SemVer without a v prefix'); + if (semver.lt(version, pkg.version)) throw new Error('Version must not go backwards'); + const tags = execFileSync('git', ['tag', '--list', `v${version}`], { encoding: 'utf8' }).trim(); + if (tags) throw new Error(`Tag v${version} already exists`); + const match = /^## Unreleased\s*\n([\s\S]*?)(?=^## |$(?![\s\S]))/m.exec(changelog); + if (!match || !match[1].trim()) throw new Error('Unreleased changelog must contain release notes'); + if (changelog.includes(`## ${version} — `)) throw new Error('Changelog already contains this version'); + const date = new Date().toISOString().slice(0, 10); + fs.writeFileSync('CHANGELOG.md', changelog.replace(match[0], `## Unreleased\n\n## ${version} — ${date}\n\n${match[1].trim()}\n\n`)); + pkg.version = version; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); +} diff --git a/scripts/publish-snapshot.py b/scripts/publish-snapshot.py new file mode 100644 index 0000000..7a84ba5 --- /dev/null +++ b/scripts/publish-snapshot.py @@ -0,0 +1,117 @@ +"""Publish a tested PR tarball without running PR code in the trusted job.""" +import json +import os +from pathlib import Path, PurePosixPath +import re +import subprocess +import tarfile +import tempfile +import time +from urllib.request import Request, urlopen + +PACKAGE = '@observertc/observer-js' +REGISTRY = 'https://registry.npmjs.org' + + +def next_version(base, versions): + pattern = re.compile(re.escape(base) + r'-rc\.(0|[1-9][0-9]*)$') + numbers = [int(match[1]) for version in versions if (match := pattern.fullmatch(version))] + return f'{base}-rc.{max(numbers, default=0) + 1}' + + +def next_minor(latest): + match = re.fullmatch(r'(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)', latest) + if not match: + raise ValueError('npm latest must point to a stable SemVer version') + return f'{match[1]}.{int(match[2]) + 1}.0' + + +def registry_metadata(): + request = Request(f'{REGISTRY}/@observertc%2fobserver-js', headers={ + 'Accept': 'application/json', 'Cache-Control': 'no-cache', + }) + # Registry/auth/network errors must fail rather than reset the counter. + with urlopen(request, timeout=60) as response: + return json.load(response) + + +def unpack(archive, destination): + with tarfile.open(archive, 'r:gz') as package: + for member in package: + path = PurePosixPath(member.name) + if (path.is_absolute() or '..' in path.parts or not path.parts + or path.parts[0] != 'package'): + raise ValueError(f'Invalid package path: {member.name}') + if not (member.isfile() or member.isdir()): + raise ValueError(f'Unsupported package entry: {member.name}') + # Never let PR-provided npm configuration reach the publishing job. + if path.name == '.npmrc': + continue + target = destination.joinpath(*path.parts) + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with package.extractfile(member) as source: + target.write_bytes(source.read()) + target.chmod(member.mode & 0o777) + + +def publish(artifact_dir): + archives = list(Path(artifact_dir).glob('*.tgz')) + if len(archives) != 1: + raise ValueError('Expected exactly one tested package tarball') + run = f"{os.environ['SNAPSHOT_RUN_ID']}/{os.environ['SNAPSHOT_RUN_ATTEMPT']}" + with tempfile.TemporaryDirectory(prefix='observer-snapshot-') as temp: + unpack(archives[0], Path(temp)) + directory = Path(temp) / 'package' + manifest_path = directory / 'package.json' + manifest = json.loads(manifest_path.read_text()) + if manifest['name'] != PACKAGE: + raise ValueError(f'Expected package {PACKAGE}') + manifest.pop('scripts', None) + manifest.pop('publishConfig', None) + manifest.pop('workspaces', None) + manifest['repository'] = { + 'type': 'git', 'url': 'git+https://github.com/ObserveRTC/observer-js.git', + } + manifest['observerSnapshotRun'] = run + # Concurrent PRs may read the same next number. Retry only confirmed + # collisions; authentication, network and other publish errors fail. + metadata = registry_metadata() + base = next_minor(metadata['dist-tags']['latest']) + for attempt in range(10): + if attempt: + metadata = registry_metadata() + versions = metadata['versions'] + existing = next((v for v, data in versions.items() + if data.get('observerSnapshotRun') == run), None) + if existing: + print(f'Already published {PACKAGE}@{existing}', flush=True) + return + version = next_version(base, versions) + manifest['version'] = version + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') + result = subprocess.run([ + 'npm', 'publish', '--registry', REGISTRY, '--access', 'public', + '--ignore-scripts', '--tag', 'snapshot', + ], cwd=directory, check=False) + if result.returncode == 0: + install = f'npm install {PACKAGE}@{version}' + print(install, flush=True) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write(f'Published snapshot: `{install}`\n') + return + published = registry_metadata()['versions'].get(version) + if published is not None and published.get('observerSnapshotRun') == run: + print(f'Confirmed published {PACKAGE}@{version}', flush=True) + return + if published is None: + raise RuntimeError(f'npm publish failed for {version} (exit {result.returncode})') + time.sleep(min(attempt + 1, 5)) + raise RuntimeError('Too many concurrent snapshot version collisions; re-run this job') + + +if __name__ == '__main__': + publish(os.environ['SNAPSHOT_ARTIFACT']) diff --git a/scripts/test_publish_snapshot.py b/scripts/test_publish_snapshot.py new file mode 100644 index 0000000..aaab8a5 --- /dev/null +++ b/scripts/test_publish_snapshot.py @@ -0,0 +1,115 @@ +import importlib.util +import io +import json +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('snapshot', Path(__file__).with_name('publish-snapshot.py')) +snapshot = importlib.util.module_from_spec(spec) +spec.loader.exec_module(snapshot) + + +def metadata(versions, latest='1.2.0'): + return {'dist-tags': {'latest': latest}, 'versions': versions} + + +class SnapshotTests(unittest.TestCase): + def test_next_minor_uses_latest_stable_release(self): + self.assertEqual(snapshot.next_minor('1.2.0'), '1.3.0') + self.assertEqual(snapshot.next_minor('1.2.9'), '1.3.0') + self.assertEqual(snapshot.next_minor('1.3.0'), '1.4.0') + self.assertEqual(snapshot.next_minor('0.99.42'), '0.100.0') + for invalid in ['1.3.0-beta.1', 'v1.2.0', 'garbage', '01.2.0']: + with self.subTest(invalid=invalid), self.assertRaises(ValueError): + snapshot.next_minor(invalid) + + def test_counter_is_per_base_and_numeric(self): + versions = ['1.3.0-rc.2', '1.3.0-rc.10', '1.4.0-rc.99', '1.3.0-beta.50', '1.3.0'] + self.assertEqual(snapshot.next_version('1.3.0', versions), '1.3.0-rc.11') + self.assertEqual(snapshot.next_version('2.0.0', versions), '2.0.0-rc.1') + + def archive(self, directory, name='@observertc/observer-js'): + archive = Path(directory) / 'snapshot.tgz' + manifest = { + 'name': name, 'version': '1.2.0', + 'scripts': {'prepublishOnly': 'exit 1'}, + 'publishConfig': {'registry': 'https://example.invalid'}, + } + with tarfile.open(archive, 'w:gz') as tar: + for path, data in [('package/package.json', json.dumps(manifest)), + ('package/.npmrc', 'registry=https://example.invalid')]: + content = data.encode() + member = tarfile.TarInfo(path) + member.size = len(content) + tar.addfile(member, io.BytesIO(content)) + return archive + + def test_publish_retries_collision_and_removes_pr_configuration(self): + seen = [] + + def npm(args, cwd, check): + manifest = json.loads((cwd / 'package.json').read_text()) + seen.append(manifest['version']) + self.assertNotIn('scripts', manifest) + self.assertNotIn('publishConfig', manifest) + self.assertFalse((cwd / '.npmrc').exists()) + self.assertIn('--ignore-scripts', args) + self.assertEqual(args[-2:], ['--tag', 'snapshot']) + return type('Result', (), {'returncode': 1 if len(seen) == 1 else 0})() + + with tempfile.TemporaryDirectory() as temp: + self.archive(temp) + with patch.dict('os.environ', {'SNAPSHOT_RUN_ID': '123', 'SNAPSHOT_RUN_ATTEMPT': '1'}), \ + patch.object(snapshot, 'registry_metadata', side_effect=[ + metadata({}), metadata({'1.3.0-rc.1': {}}), metadata({'1.3.0-rc.1': {}}), + ]), patch.object(snapshot.subprocess, 'run', side_effect=npm), \ + patch.object(snapshot.time, 'sleep'): + snapshot.publish(temp) + self.assertEqual(seen, ['1.3.0-rc.1', '1.3.0-rc.2']) + + def test_already_published_run_is_not_published_again(self): + with tempfile.TemporaryDirectory() as temp: + self.archive(temp) + with patch.dict('os.environ', {'SNAPSHOT_RUN_ID': '123', 'SNAPSHOT_RUN_ATTEMPT': '1'}), \ + patch.object(snapshot, 'registry_metadata', return_value=metadata({ + '1.3.0-rc.7': {'observerSnapshotRun': '123/1'}, + })), patch.object(snapshot.subprocess, 'run') as npm: + snapshot.publish(temp) + npm.assert_not_called() + + def test_publish_error_is_not_treated_as_collision(self): + with tempfile.TemporaryDirectory() as temp: + self.archive(temp) + with patch.dict('os.environ', {'SNAPSHOT_RUN_ID': '123', 'SNAPSHOT_RUN_ATTEMPT': '1'}), \ + patch.object(snapshot, 'registry_metadata', return_value=metadata({})), \ + patch.object(snapshot.subprocess, 'run', return_value=type('Result', (), {'returncode': 1})()): + with self.assertRaisesRegex(RuntimeError, 'npm publish failed'): + snapshot.publish(temp) + + def test_rejects_path_traversal_and_links(self): + for name, kind in [('../escape', tarfile.REGTYPE), + ('/absolute', tarfile.REGTYPE), + ('package/link', tarfile.SYMTYPE)]: + with self.subTest(name=name), tempfile.TemporaryDirectory() as temp: + archive = Path(temp) / 'bad.tgz' + with tarfile.open(archive, 'w:gz') as tar: + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = '/tmp' + tar.addfile(member) + with self.assertRaises(ValueError): + snapshot.unpack(archive, Path(temp) / 'out') + + def test_wrong_package_is_rejected(self): + with tempfile.TemporaryDirectory() as temp: + self.archive(temp, '@other/package') + with patch.dict('os.environ', {'SNAPSHOT_RUN_ID': '123', 'SNAPSHOT_RUN_ATTEMPT': '1'}): + with self.assertRaisesRegex(ValueError, 'Expected package'): + snapshot.publish(temp) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/ObservedCall.ts b/src/ObservedCall.ts index 11c8186..f8bdfec 100644 --- a/src/ObservedCall.ts +++ b/src/ObservedCall.ts @@ -1,4 +1,9 @@ import { EventEmitter } from 'node:events'; +import { SinkRegistry } from './sinks/SinkRegistry'; +import { ExtensionStatsRegistry } from './sinks/ExtensionStatsRegistry'; +import type { CreateExtensionStatsSettings } from './sinks/ExtensionStatsRegistry'; +import type { Sink, ClientSinkParams } from './sinks/Sink'; +import type { ClientSample } from './schema/ClientSample'; import { createLogger } from './common/logger'; import { ObservedClient, ObservedClientEvents, ObservedClientSettings } from './ObservedClient'; import { Observer } from './Observer'; @@ -107,6 +112,9 @@ export class ObservedCall = Record; public constructor( @@ -119,6 +127,8 @@ export class ObservedCall = Record = Record = Record(settings: CreateExtensionStatsSettings): Sink | undefined { + return this.extensionStats.create(settings); + } + + public acceptExtensionStats(id: string, data: unknown): void { + this.extensionStats.accept(id, data); + } + + public getExtensionStats(id: string): T | undefined { + return this.extensionStats.get(id); + } + public getObservedClient = Record>(clientId: string): ObservedClient | undefined { if (this.closed || !this.observedClients.has(clientId)) return; @@ -359,11 +385,13 @@ export class ObservedCall = Record = Record | undefined { + const config = this.observer.config.saveClientSamples as { + jsonlPath?: (params: ClientSinkParams) => string | undefined, + sink?: (params: ClientSinkParams) => Sink | undefined, + } | undefined; + + if (!config || (!config.jsonlPath && !config.sink)) return undefined; + if (config.jsonlPath && config.sink) { + logger.warn('saveClientSamples: give either jsonlPath or sink, not both; no client samples are saved'); + + return undefined; + } + + // Sink ids are unique per observer: checked before the factory runs, so a clash opens no file. + if (this.observer.sinks.get(clientId)) { + logger.warn('No sample sink for client %s in call %s: sink id %s is already in use', clientId, this.callId, clientId); + + return undefined; + } + + const params: ClientSinkParams = { id: clientId, observedCall: this, clientId }; + + if (config.jsonlPath) { + const jsonlPath = config.jsonlPath(params); + + return jsonlPath === undefined ? undefined : this.sinks.createFromDestination(clientId, { jsonlPath }); + } + + const sink = config.sink?.(params); + + return sink ? this.sinks.createFromDestination(clientId, { sink }) : undefined; + } + /** Emit an Observer-bus event scoped to this call. */ private _notify(type: K, ...args: ObserverEvents[K]): void { this.observer.emit(type, ...args); diff --git a/src/ObservedClient.ts b/src/ObservedClient.ts index f3efb23..1114989 100644 --- a/src/ObservedClient.ts +++ b/src/ObservedClient.ts @@ -11,7 +11,7 @@ import { normalizeScoreReasons, parseJsonAs, parseJsonObject } from './common/ut import { CalculatedScore } from './scores/CalculatedScore'; import type { AcceptContext } from './Observer'; import type { ObserverEvents, ObservedClientScope } from './ObserverEvents'; -import type { ClientSampleSink } from './sinks/ClientSampleSink'; +import type { Sink } from './sinks/Sink'; import { percentileOfHistogram } from './utils/stats'; import { ObservedClientIssueRegistry } from './issues/ObservedClientIssueRegistry'; import { ActiveClientIssue, RESOLVED_ISSUE_SUFFIX, baseIssueType, isClientIssueResolutionEntry } from './issues/ActiveClientIssue'; @@ -77,9 +77,6 @@ export class ObservedClient = Record(); - /** Per-client sink (from `ObserverConfig.createClientSink`), if any. */ - public readonly sink?: ClientSampleSink; - /** Ancestry base shared by all Observer-bus events originating at this client. */ public readonly eventScope: ObservedClientScope; public readonly calculatedScore: CalculatedScore = { @@ -253,7 +250,6 @@ export class ObservedClient = Record = Record logger.warn('ClientSampleSink error for client %s: %o', this.clientId, err); - - this.sink.once('close', () => this.sink?.off('error', onError)); - this.sink.on('error', onError); - } this.activeIssues = activeIssues; } /** The distinct `publicAddress`es currently known by this client's open peer connections. */ + /** + * The sink this client's samples are written to: whatever is registered under its client id in + * `call.sinks` (the one created through `saveClientSamples`, or a sink the application added), if any. + */ + public get sink(): Sink | undefined { + return this.call.sinks.get>(this.clientId); + } + public get publicAddresses(): string[] { const result = new Set(); @@ -395,7 +393,7 @@ export class ObservedClient = Record = Record = Record = R public readonly webrtcTransportIds = new Set(); public closed = false; + public readonly sinks: SinkRegistry; + public readonly extensionStats: ExtensionStatsRegistry; + // Id -> sample indexes over the arrays in `this.sample`, so an entity can be reached in O(1) // instead of scanning (`sample.producers.find(...)`). They hold the *same* objects as the arrays. private readonly _transportSamples = new Map(); @@ -103,14 +110,21 @@ export class ObservedMediasoupRouter = R private readonly _dataConsumerSamples = new Map(); private readonly _enrich?: MediasoupSampleEnricher; + /** + * @param parentSinks the parent of `sinks`: `observer.sinks`, passed by + * `Observer.createObservedMediasoupRouter`. Internal; a router constructed directly has none. + */ public constructor( settings: ObservedMediasoupRouterSettings, + parentSinks?: SinkRegistry, ) { super(); this.router = settings.router; this.appData = (settings.appData ?? {}) as AppData; this._enrich = settings.enrich; + this.sinks = new SinkRegistry(parentSinks); + this.extensionStats = new ExtensionStatsRegistry(this.router.id, this.sinks); this.sample = { routerId: this.router.id, attachments: settings.attachments ?? {}, @@ -204,9 +218,23 @@ export class ObservedMediasoupRouter = R this.closed = true; if (this.sample.closedAt === undefined) this.sample.closedAt = Date.now(); + this.extensionStats.close(); + this.sinks.clear(); this.emit('close'); } + public createExtensionStats(settings: CreateExtensionStatsSettings): Sink | undefined { + return this.extensionStats.create(settings); + } + + public acceptExtensionStats(id: string, data: unknown): void { + this.extensionStats.accept(id, data); + } + + public getExtensionStats(id: string): T | undefined { + return this.extensionStats.get(id); + } + public addTransport = (transport: types.Transport) => { let transportType = transport.type; diff --git a/src/ObservedOutboundTrack.ts b/src/ObservedOutboundTrack.ts index d74a303..a8225ef 100644 --- a/src/ObservedOutboundTrack.ts +++ b/src/ObservedOutboundTrack.ts @@ -84,7 +84,6 @@ export class ObservedOutboundTrack implements OutboundTrackSample { public readonly id: string, public readonly kind: MediaKind, private readonly _peerConnection: ObservedPeerConnection, - private readonly _outboundRtps?: ObservedOutboundRtp[], private readonly _mediaSource?: ObservedMediaSource, ) { // no-op @@ -110,8 +109,13 @@ export class ObservedOutboundTrack implements OutboundTrackSample { return this._peerConnection; } - public getOutboundRtps() { - return this._outboundRtps; + /** The outbound RTPs of this track's media source, looked up on every call. */ + public getOutboundRtps(): ObservedOutboundRtp[] | undefined { + const mediaSource = this._mediaSource; + + if (!mediaSource) return undefined; + + return [ ...this._peerConnection.observedOutboundRtps.values() ].filter((rtp) => rtp.mediaSourceId === mediaSource.id); } public getMediaSource() { @@ -137,7 +141,7 @@ export class ObservedOutboundTrack implements OutboundTrackSample { this.remoteRttInMs = undefined; this.qualityLimitationReason = undefined; - for (const rtp of this._outboundRtps ?? []) { + for (const rtp of this.getOutboundRtps() ?? []) { this.bitrate = (this.bitrate ?? 0) + rtp.bitrate; this.deltaPacketsSent = (this.deltaPacketsSent ?? 0) + rtp.deltaPacketsSent; diff --git a/src/ObservedPeerConnection.ts b/src/ObservedPeerConnection.ts index 4c45a0a..666ba63 100644 --- a/src/ObservedPeerConnection.ts +++ b/src/ObservedPeerConnection.ts @@ -743,9 +743,7 @@ export class ObservedPeerConnection extends EventEmitter { for (const observedOutboundTrack of [ ...this.observedOutboundTracks.values() ]) { if (observedOutboundTrack.visited) continue; - this.observedOutboundTracks.delete(observedOutboundTrack.id); - this.emit('removed-outbound-track', observedOutboundTrack); - this._notify('outbound-track-removed', { ...this.eventScope, observedOutboundTrack }); + this._removeOutboundTrack(observedOutboundTrack); } for (const observedPeerConnectionTransport of [ ...this.observedPeerConnectionTransports.values() ]) { if (observedPeerConnectionTransport.visited) continue; @@ -1078,6 +1076,14 @@ export class ObservedPeerConnection extends EventEmitter { private _updateOutboundTrackSample(stats: OutboundTrackSample) { let observedOutboundTrack = this.observedOutboundTracks.get(stats.id); + // The newest media source of this track (Maps keep insertion order). + const mediaSource = [ ...this.observedMediaSources.values() ].filter((source) => source.trackIdentifier === stats.id).pop(); + + // A new media source for the same track id means a republication: replace the track object. + if (observedOutboundTrack && observedOutboundTrack.getMediaSource() !== mediaSource) { + this._removeOutboundTrack(observedOutboundTrack); + observedOutboundTrack = undefined; + } if (!observedOutboundTrack) { if (!stats.timestamp || !stats.id || !stats.kind) { @@ -1086,17 +1092,13 @@ export class ObservedPeerConnection extends EventEmitter { stats ); } - const observedMediaSource = [ ...this.observedMediaSources.values() ].find((mediaSource) => mediaSource.trackIdentifier === stats.id); - const outboundRtps = observedMediaSource - ? [ ...this.observedOutboundRtps.values() ].filter((outboundRtp) => outboundRtp.mediaSourceId === observedMediaSource?.id) : undefined; observedOutboundTrack = new ObservedOutboundTrack( stats.timestamp, stats.id, stats.kind as MediaKind, this, - outboundRtps, - observedMediaSource, + mediaSource, ); observedOutboundTrack.update(stats); @@ -1111,6 +1113,12 @@ export class ObservedPeerConnection extends EventEmitter { return observedOutboundTrack; } + private _removeOutboundTrack(observedOutboundTrack: ObservedOutboundTrack) { + this.observedOutboundTracks.delete(observedOutboundTrack.id); + this.emit('removed-outbound-track', observedOutboundTrack); + this._notify('outbound-track-removed', { ...this.eventScope, observedOutboundTrack }); + } + private _updatePeerConnectionTransportStats(stats: PeerConnectionTransportStats) { let observedPeerConnectionTransport = this.observedPeerConnectionTransports.get(stats.id); diff --git a/src/Observer.ts b/src/Observer.ts index 95e9922..6a5c434 100644 --- a/src/Observer.ts +++ b/src/Observer.ts @@ -5,9 +5,12 @@ import { ClientSample } from './schema/ClientSample'; import { ObservedTURN } from './ObservedTURN'; import type { RemoteTrackResolverFactory } from './resolvers/RemoteTrackResolver'; import type { ObserverEvents, ObserverEventBase, ObservedMediasoupRouterScope } from './ObserverEvents'; -import type { ClientSampleSinkFactory } from './sinks/ClientSampleSink'; import { Middleware, MiddlewareProcessor } from './common/Middleware'; import { ObservedMediasoupRouter, ObservedMediasoupRouterSettings } from './ObservedMediasoupRouter'; +import { SinkRegistry } from './sinks/SinkRegistry'; +import { ExtensionStatsRegistry } from './sinks/ExtensionStatsRegistry'; +import type { CreateExtensionStatsSettings } from './sinks/ExtensionStatsRegistry'; +import type { Sink, ClientSinkParams } from './sinks/Sink'; import { AvailableCallScopeDetectorsConfigs, AvailableObserverScopeDetectorsConfigs, Detectors } from './detectors/Detectors'; import type { AvailableValidatorConfigs } from './validators/Validators'; import type { ValidationReport, RunningValidator } from './validators/Validator'; @@ -58,6 +61,11 @@ export type CallAppDataFactory = (params: { callId: string, observer: Observer, /** Produces the initial `appData` for a client created without an explicit `appData`. */ export type ClientAppDataFactory = (params: { clientId: string, observedCall: ObservedCall, acceptCtx?: AcceptContext }) => Record; +/** `ObserverConfig.saveClientSamples`: exactly one of the two per-client callbacks. */ +export type SaveClientSamplesConfig = + | { jsonlPath: (params: ClientSinkParams) => string | undefined, sink?: never } + | { sink: (params: ClientSinkParams) => Sink | undefined, jsonlPath?: never }; + export type ObserverConfig = Record> = { /** @@ -228,11 +236,15 @@ export type ObserverConfig = Record = Record(); public readonly observedMediasoupRouters = new Map(); + public readonly sinks = new SinkRegistry(); + public readonly extensionStats = new ExtensionStatsRegistry('observer', this.sinks); + /** Ancestry base shared by all Observer-bus events originating at the observer. */ public readonly eventScope: ObserverEventBase = { observer: this }; public readonly config: ObserverConfig; @@ -646,7 +661,7 @@ export class Observer = Record = Record(settings: CreateExtensionStatsSettings): Sink | undefined { + return this.extensionStats.create(settings); + } + + public acceptExtensionStats(id: string, data: unknown): void { + this.extensionStats.accept(id, data); + } + + public getExtensionStats(id: string): T | undefined { + return this.extensionStats.get(id); + } + public accept(sample: ClientSample, context?: AcceptContext): void { if (this.closed) { return this._notify('sample-rejected', { ...this.eventScope, reason: 'observer-closed', sample }); diff --git a/src/ObserverEvents.ts b/src/ObserverEvents.ts index e1e798b..2c1c688 100644 --- a/src/ObserverEvents.ts +++ b/src/ObserverEvents.ts @@ -23,7 +23,7 @@ import type { CallSummary } from './summaries/CallSummary'; import type { ValidationReport } from './validators/Validator'; import type { ResolvedActiveClientIssue } from './issues/ActiveClientIssue'; // ClientIssue doubles as the generic issue shape ({ type, payload?, timestamp? }) for call-level issues too. -import type { ClientSampleSink } from './sinks/ClientSampleSink'; +import type { Sink } from './sinks/Sink'; import { ObservedMediasoupRouter } from './ObservedMediasoupRouter'; /** @@ -99,7 +99,7 @@ export type ObserverEvents = { // client level 'client-added': [ObservedClientScope]; - 'client-sink-created': [ObservedClientScope & { sink: ClientSampleSink }]; + 'client-sink-created': [ObservedClientScope & { sink: Sink }]; 'client-updated': [ObservedClientScope & { sample: ClientSample, elapsedTimeInMs: number }]; 'client-closed': [ObservedClientScope]; 'client-joined': [ObservedClientScope]; diff --git a/src/index.ts b/src/index.ts index 3ab7b0f..b933554 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export type { ObserverEvents, SampleRejectedReason, AcceptContext, CallAppDataFactory, ClientAppDataFactory, AcceptMiddleware, AcceptMiddlewarePayload } from './Observer'; +export type { ObserverEvents, SampleRejectedReason, AcceptContext, CallAppDataFactory, ClientAppDataFactory, AcceptMiddleware, AcceptMiddlewarePayload, SaveClientSamplesConfig } from './Observer'; export type { ObserverEventBase, ObservedCallScope, @@ -295,21 +295,32 @@ export { TrendTester } from './utils/TrendTester'; export type { TrendTesterConfig } from './utils/TrendTester'; export { createLogger, setObserverLogger } from './common/logger'; export type { Logger, ObserverLogger } from './common/logger'; -// Per-client sample sinks. `ClientSampleSink` is the base class to subclass for a custom -// destination; `JsonlFileSink` / `InMemorySink` are the built-ins. +// Sinks. `Sink` (and `ClientSampleSink = Sink`) is the interface to implement for a custom destination; +// `JsonlFileSink` / `RollingJsonlFileSink` / `InMemorySink` are the built-ins; `SinkRegistry` owns their lifecycle. export { - ClientSampleSink, + SinkRegistry, + ExtensionStatsRegistry, JsonlFileSink, createJsonlFileSink, - createJsonlFileSinkFactory, + RollingJsonlFileSink, InMemorySink, createInMemorySink, } from './sinks'; export type { + Sink, + SinkEvents, + SinkSettings, + SinkDestination, + ClientSinkParams, + SinkConstructor, + SinkCreateSettings, + CreateExtensionStatsSettings, + ClientSampleSink, ClientSampleSinkEvents, - ClientSampleSinkFactory, JsonlFileSinkOptions, - JsonlFileSinkFactoryOptions, + RollingJsonlFileSinkOptions, + RollingJsonlFileSinkEvents, + RollingJsonlFileInfo, } from './sinks'; export { Middleware } from './common/Middleware'; // `Observer.acceptMiddlewares` is a public field of this type, so the type has to be nameable. diff --git a/src/resolvers/RemoteTrackResolver.ts b/src/resolvers/RemoteTrackResolver.ts index 7b1647e..66cbc4b 100644 --- a/src/resolvers/RemoteTrackResolver.ts +++ b/src/resolvers/RemoteTrackResolver.ts @@ -37,6 +37,8 @@ export type RemoteTrackResolvers = { */ export class RemoteTrackResolver { private readonly _publisherIdToOutboundTrack = new Map(); + // The publisher id each outbound track was indexed under, so removal does not depend on its current attachments. + private readonly _outboundTrackPublisherIds = new Map(); private readonly _subscriberIdToInboundTrack = new Map(); /** @@ -96,6 +98,7 @@ export class RemoteTrackResolver { observer.off('outbound-track-updated', onOutboundUpdated); this._pendingInboundTracks.clear(); this._pendingOutboundTracks.clear(); + this._outboundTrackPublisherIds.clear(); }); observer.on('inbound-track-added', onInboundAdded); @@ -190,6 +193,7 @@ export class RemoteTrackResolver { this._pendingOutboundTracks.delete(outboundTrack); this._publisherIdToOutboundTrack.set(publisherId, outboundTrack); + this._outboundTrackPublisherIds.set(outboundTrack, publisherId); // Unconsumed until a subscriber links below — a new publisher legitimately starts this way. this.observedCall.unconsumedOutboundTracks.add(outboundTrack); @@ -210,8 +214,9 @@ export class RemoteTrackResolver { private _removeOutboundTrack(outboundTrack: ObservedOutboundTrack) { this._pendingOutboundTracks.delete(outboundTrack); - const publisherId = this.resolvers.resolveOutboundTrackPublisherId(outboundTrack); + const publisherId = this._outboundTrackPublisherIds.get(outboundTrack); + this._outboundTrackPublisherIds.delete(outboundTrack); // Only clear the map entry if it still points at this track (guards republish races). if (publisherId && this._publisherIdToOutboundTrack.get(publisherId) === outboundTrack) { this._publisherIdToOutboundTrack.delete(publisherId); diff --git a/src/sinks/ClientSampleSink.ts b/src/sinks/ClientSampleSink.ts index bcc01b0..8bc209b 100644 --- a/src/sinks/ClientSampleSink.ts +++ b/src/sinks/ClientSampleSink.ts @@ -1,58 +1,17 @@ -import { EventEmitter } from 'node:events'; +import type { Sink, SinkEvents } from './Sink'; import type { ClientSample } from '../schema/ClientSample'; -import type { ObservedCall } from '../ObservedCall'; -/** The lifecycle events a sink may emit (a subset of Node's writable-stream events). */ -export type ClientSampleSinkEvents = { - - /** The destination is fully written and closed (e.g. a file flushed and its fd closed). */ - close: []; - - /** The destination failed. */ - error: [Error]; - - /** `end()` was processed and all queued data was flushed (before `close`). */ - finish: []; - - /** The buffer drained after backpressure; safe to write more. */ - drain: []; -}; - -export declare interface ClientSampleSink { - on(event: E, listener: (...args: ClientSampleSinkEvents[E]) => void): this; - once(event: E, listener: (...args: ClientSampleSinkEvents[E]) => void): this; - off(event: E, listener: (...args: ClientSampleSinkEvents[E]) => void): this; - emit(event: E, ...args: ClientSampleSinkEvents[E]): boolean; -} +/** The lifecycle events a client sample sink may emit. Same as {@link SinkEvents}. */ +export type ClientSampleSinkEvents = SinkEvents; /** - * A per-client destination for accepted samples — a typed `EventEmitter` base class. One sink is - * created per `ObservedClient` (via `ObserverConfig.createClientSink`). It is **object-mode**: - * `write` receives the `ClientSample` itself, so each sink decides how to serialize it (JSON - * line, protobuf, msgpack, a remote payload, …). + * A per-client destination for accepted samples. Return one from `ObserverConfig.saveClientSamples.sink` + * (with the `id` it receives), or create one under the client id with + * `observedCall.sinks.create(MySink, { id: clientId, ... })`. * - * The library calls `write(sample)` synchronously per accepted sample (not awaited), `end()`s the - * sink when the client closes, and attaches an `error` listener so a failing sink can't crash the - * process. The application — which created the sink — listens for `close` (destination ready, - * e.g. a file flushed and its fd closed) and `error`. - * - * Subclass this and implement `write`/`end` (and `emit` your lifecycle events). Built-in sinks - * (`JsonlFileSink`, `InMemorySink`) and their factories are exported from the package root. - */ -export abstract class ClientSampleSink extends EventEmitter { - /** Accept one sample; the sink serializes it however it likes. `false` signals backpressure. */ - public abstract write(sample: ClientSample): boolean; - - /** Finish writing; the sink flushes and (if applicable) emits `close`. */ - public abstract end(): void; -} - -/** - * Produces a sink for a newly-created client, or `undefined` for no sink. Receives the - * client id and its (already-created) parent call, so the destination can be derived - * from `callId` / `clientId`. + * The library calls `write(sample)` synchronously per accepted sample (not awaited) and `end()`s + * the sink when the client closes; the registry attaches an `error` listener so a failing sink + * can't crash the process. Because `write` isn't awaited, backpressure and batching are the sink's + * concern. */ -export type ClientSampleSinkFactory = (params: { - clientId: string, - observedCall: ObservedCall, -}) => ClientSampleSink | undefined; +export type ClientSampleSink = Sink; diff --git a/src/sinks/ExtensionStatsRegistry.ts b/src/sinks/ExtensionStatsRegistry.ts new file mode 100644 index 0000000..0c36c19 --- /dev/null +++ b/src/sinks/ExtensionStatsRegistry.ts @@ -0,0 +1,93 @@ +import type { SinkRegistry } from './SinkRegistry'; +import type { Sink, SinkDestination } from './Sink'; +import { createLogger } from '../common/logger'; + +const logger = createLogger('ExtensionStats'); + +/** + * The settings of `createExtensionStats` on an observer, a call or a mediasoup router: the `id`, + * and at most one of `jsonlPath` and `sink` (a `SinkDestination`). + */ +export type CreateExtensionStatsSettings = { + + /** Unique per observer: it is also the id of the sink that receives the stats. */ + id: string, +} & (SinkDestination | { jsonlPath?: never, sink?: never }); + +/** + * The extension stats of one object — `observer.extensionStats`, `observedCall.extensionStats`, + * `observedMediasoupRouter.extensionStats`: the last data per id (`values`), written to the + * object's sink with the same id. The owner closes it when it closes. + */ +export class ExtensionStatsRegistry { + /** The last data accepted for each extension stats id. */ + public readonly values = new Map(); + public closed = false; + + public constructor( + /** The owner's id (call id, router id, or `observer`), used in log messages. */ + public readonly id: string, + private readonly _sinks: SinkRegistry, + ) { + } + + /** + * Refuse further `create` / `accept`, and end the extension stats' sinks (the sinks whose id is + * an extension stats id). Other sinks of the owner, e.g. client sample sinks, are left to the + * owner's `sinks.clear()`. Called by the owner when it closes. + */ + public close(): void { + if (this.closed) return; + + this.closed = true; + + for (const id of this.values.keys()) { + this._sinks.get(id)?.end(); + } + } + + public create(settings: CreateExtensionStatsSettings): Sink | undefined { + const { id, jsonlPath, sink } = settings as { id: string, jsonlPath?: string, sink?: Sink }; + // A given sink is the owner's from here: refused, it is ended rather than left open. + const refuse = (reason: string) => { + logger.warn('Extension stats %s on %s: %s', id, this.id, reason); + sink?.end(); + + return undefined; + }; + + if (this.closed) return refuse('the owner is closed'); + if (this.values.has(id)) return refuse('the id already exists'); + if (jsonlPath !== undefined && sink) return refuse('give either jsonlPath or sink, not both'); + if (sink && sink.id !== id) return refuse(`the sink id ${sink.id} differs from the extension stats id`); + + this.values.set(id, undefined); + + if (jsonlPath === undefined && !sink) return undefined; + + // Replace a sink this owner holds under the id; never one of a descendant's. + if (this._sinks.owns(id)) this._sinks.get(id)?.end(); + + return this._sinks.createFromDestination(id, settings as SinkDestination); + } + + public accept(id: string, data: unknown): void { + if (this.closed) { + logger.debug('Extension stats %s dropped: %s is closed', id, this.id); + + return; + } + + this.values.set(id, data); + + try { + this._sinks.get(id)?.write(data); + } catch (err) { + logger.warn('Failed to write extension stats %s to sink: %o', id, err); + } + } + + public get(id: string): T | undefined { + return this.values.get(id) as T | undefined; + } +} diff --git a/src/sinks/InMemorySink.ts b/src/sinks/InMemorySink.ts index a039d98..9b841d8 100644 --- a/src/sinks/InMemorySink.ts +++ b/src/sinks/InMemorySink.ts @@ -1,26 +1,50 @@ -import { ClientSampleSink } from './ClientSampleSink'; +import { EventEmitter } from 'node:events'; +import type { Sink, SinkEvents, SinkSettings } from './Sink'; import type { ClientSample } from '../schema/ClientSample'; +export declare interface InMemorySink { + on(event: E, listener: (...args: SinkEvents[E]) => void): this; + once(event: E, listener: (...args: SinkEvents[E]) => void): this; + off(event: E, listener: (...args: SinkEvents[E]) => void): this; + emit(event: E, ...args: SinkEvents[E]): boolean; +} + /** - * A `ClientSampleSink` that collects the accepted sample objects into an array. Useful for - * tests and offline replay. Emits `close` on `end()`. + * A client sample sink that collects the accepted sample objects into an array. Useful for tests + * and offline replay. Emits `ended` and `close` on `end()`. */ -export class InMemorySink extends ClientSampleSink { - public constructor(public readonly samples: ClientSample[] = []) { +export class InMemorySink extends EventEmitter implements Sink { + public readonly id: string; + public readonly samples: ClientSample[]; + + private _ended = false; + + public constructor(settings: Partial & { samples?: ClientSample[] } = {}) { super(); + this.id = settings.id ?? 'in-memory'; + this.samples = settings.samples ?? []; + } + + public get ended(): boolean { + return this._ended; } public write(sample: ClientSample): boolean { + if (this._ended) return false; this.samples.push(sample); return true; } public end(): void { + if (this._ended) return; + + this._ended = true; + this.emit('ended'); this.emit('close'); } } export function createInMemorySink(samples?: ClientSample[]): InMemorySink { - return new InMemorySink(samples); + return new InMemorySink({ samples }); } diff --git a/src/sinks/JsonlFileSink.ts b/src/sinks/JsonlFileSink.ts index 93dea09..6fba3df 100644 --- a/src/sinks/JsonlFileSink.ts +++ b/src/sinks/JsonlFileSink.ts @@ -1,31 +1,44 @@ import * as fs from 'fs'; -import * as path from 'path'; -import { ClientSampleSink, ClientSampleSinkFactory } from './ClientSampleSink'; +import { EventEmitter } from 'node:events'; +import type { Sink, SinkEvents, SinkSettings } from './Sink'; import type { ClientSample } from '../schema/ClientSample'; -export type JsonlFileSinkOptions = { +export type JsonlFileSinkOptions = Partial & { /** Absolute or relative file path to append JSONL lines to. */ path: string, /** Optional custom serializer; defaults to `JSON.stringify`. */ - serializeSample?: (sample: ClientSample) => string, + serializeSample?: (record: T) => string, }; /** - * A sink that serializes each sample to a JSON line and appends it to a file. It wraps an + * A sink that serializes each record to a JSON line and appends it to a file. Defaults to client + * samples, but takes any record type: `sinks.create(JsonlFileSink, { id, path })`. It wraps an * `fs.WriteStream` and re-emits its lifecycle events, so `close` fires once the file is flushed * and its descriptor is closed (file ready) and `error` surfaces file errors. */ -export class JsonlFileSink extends ClientSampleSink { +export declare interface JsonlFileSink { + on(event: E, listener: (...args: SinkEvents[E]) => void): this; + once(event: E, listener: (...args: SinkEvents[E]) => void): this; + off(event: E, listener: (...args: SinkEvents[E]) => void): this; + emit(event: E, ...args: SinkEvents[E]): boolean; +} + +export class JsonlFileSink extends EventEmitter implements Sink { + public readonly id: string; + /** The file this sink writes to. Read it (e.g. in a `close` handler) to upload/move the file. */ public readonly path: string; private readonly _stream: fs.WriteStream; - private readonly _serializeSample: (sample: ClientSample) => string; + private readonly _serializeSample: (record: T) => string; + private _ended = false; - public constructor(options: JsonlFileSinkOptions) { + public constructor(options: JsonlFileSinkOptions) { super(); + // A registry always passes an id; a standalone sink falls back to its path. + this.id = options.id ?? options.path; this.path = options.path; this._stream = fs.createWriteStream(this.path, { flags: 'a' }); @@ -34,47 +47,29 @@ export class JsonlFileSink extends ClientSampleSink { this._stream.on('drain', () => this.emit('drain')); this._stream.on('error', (err) => this.emit('error', err)); - this._serializeSample = options.serializeSample ?? ((sample) => JSON.stringify(sample)); + this._serializeSample = options.serializeSample ?? ((record) => JSON.stringify(record)); + } + + public get ended(): boolean { + return this._ended; } - public write(sample: ClientSample): boolean { - return this._stream.write(`${this._serializeSample(sample)}\n`); + public write(record: T): boolean { + if (this._ended) return false; + + return this._stream.write(`${this._serializeSample(record)}\n`); } public end(): void { + if (this._ended) return; + + this._ended = true; + this.emit('ended'); this._stream.end(); } } /** Build a JSONL file sink for a single path. */ -export function createJsonlFileSink(options: JsonlFileSinkOptions): ClientSampleSink { - return new JsonlFileSink(options); -} - -export type JsonlFileSinkFactoryOptions = { - - /** Directory the per-client files are written into (must exists). */ - directory: string, - - /** Builds the file name from the call/client ids. Defaults to `${callId}__${clientId}.jsonl`. */ - getFileName?: (params: { callId: string, clientId: string }) => string, - - /** Optional custom serializer; defaults to `JSON.stringify`. */ - serializeSample?: (sample: ClientSample) => string, -}; - -/** - * Convenience factory that writes one JSONL file per client, with the path derived from - * `callId` / `clientId`. Plug into `ObserverConfig.createClientSink`. - */ -export function createJsonlFileSinkFactory(options: JsonlFileSinkFactoryOptions): ClientSampleSinkFactory { - - return ({ clientId, observedCall }) => { - const filename = options.getFileName?.({ callId: observedCall.callId, clientId }) ?? `${observedCall.callId}__${clientId}.jsonl`; - - return createJsonlFileSink({ - path: path.join(options.directory, filename), - serializeSample: options.serializeSample, - }); - }; +export function createJsonlFileSink(options: JsonlFileSinkOptions): JsonlFileSink { + return new JsonlFileSink(options); } diff --git a/src/sinks/RollingJsonlFileSink.ts b/src/sinks/RollingJsonlFileSink.ts new file mode 100644 index 0000000..b2c4456 --- /dev/null +++ b/src/sinks/RollingJsonlFileSink.ts @@ -0,0 +1,160 @@ +import * as fs from 'fs'; +import { EventEmitter } from 'node:events'; +import type { Sink, SinkEvents, SinkSettings } from './Sink'; + +/** The file a `RollingJsonlFileSink` is about to open: the sequence number and when it opens. */ +export type RollingJsonlFileInfo = { + + /** 0 for the first file, then 1, 2, … */ + index: number, + + /** `Date.now()` when the file is opened. */ + openedAt: number, +}; + +export type RollingJsonlFileSinkOptions = Partial & { + + /** The path of each file, called when it is opened. Must give a new path per `index`. */ + path: (file: RollingJsonlFileInfo) => string, + + /** Roll before a write that would make the current file larger than this, in bytes. */ + maxBytes?: number, + + /** Roll before a write once the current file has been open for this long, in ms. */ + maxAgeMs?: number, + + /** Optional custom serializer; defaults to `JSON.stringify`. */ + serializeSample?: (record: T) => string, +}; + +export type RollingJsonlFileSinkEvents = { + ended: SinkEvents['ended']; + close: SinkEvents['close']; + error: SinkEvents['error']; + finish: SinkEvents['finish']; + drain: SinkEvents['drain']; + + /** A file is complete: flushed and its descriptor closed. Fires for every file, the last one included. */ + 'file-closed': [path: string, index: number], +}; + +export declare interface RollingJsonlFileSink { + on(event: E, listener: (...args: RollingJsonlFileSinkEvents[E]) => void): this; + once(event: E, listener: (...args: RollingJsonlFileSinkEvents[E]) => void): this; + off(event: E, listener: (...args: RollingJsonlFileSinkEvents[E]) => void): this; + emit(event: E, ...args: RollingJsonlFileSinkEvents[E]): boolean; +} + +/** + * A JSONL file sink for long-lived owners (e.g. an observer's extension stats over days): it writes + * to a sequence of files, closing the current one and opening the next — *rolling* — when it would + * exceed `maxBytes`, when it has been open for `maxAgeMs`, or when `roll()` is called. Nothing is + * dropped: each record goes to exactly one file. + * + * The limits are checked on `write`, so a file is never rolled while nothing is written to it, and + * an empty file is never rolled. `file-closed` announces each finished file (ready to upload or + * move), in index order; `close` fires once, after every file is closed by `end()`. + */ +export class RollingJsonlFileSink extends EventEmitter implements Sink { + public readonly id: string; + + private readonly _options: RollingJsonlFileSinkOptions; + private readonly _serializeSample: (record: T) => string; + private _stream!: fs.WriteStream; + private _path!: string; + private _index = -1; + private _openedAt = 0; + private _bytes = 0; + private _ended = false; + // Resolves once every file closed so far has closed and been announced, in index order: the + // streams close independently, so a later file could otherwise be announced before an earlier one. + private _closing: Promise = Promise.resolve(); + + public constructor(options: RollingJsonlFileSinkOptions) { + super(); + this._options = options; + this._serializeSample = options.serializeSample ?? ((record) => JSON.stringify(record)); + this._open(); + // A registry always passes an id; a standalone sink falls back to its first path. + this.id = options.id ?? this._path; + } + + /** The file currently written to. */ + public get path(): string { + return this._path; + } + + /** The index of the file currently written to. */ + public get index(): number { + return this._index; + } + + public get ended(): boolean { + return this._ended; + } + + public write(record: T): boolean { + if (this._ended) return false; + + const line = `${this._serializeSample(record)}\n`; + const bytes = Buffer.byteLength(line); + const { maxBytes, maxAgeMs } = this._options; + + if (maxAgeMs !== undefined && maxAgeMs <= Date.now() - this._openedAt) this.roll(); + if (maxBytes !== undefined && maxBytes < this._bytes + bytes) this.roll(); + + this._bytes += bytes; + + return this._stream.write(line); + } + + /** Close the current file and continue in a new one. A no-op on an empty file or an ended sink. */ + public roll(): void { + if (this._ended || this._bytes === 0) return; + + this._closeCurrent(); + this._open(); + } + + /** No more writes: emits `ended`, closes the current file (`file-closed`), then emits `close`. */ + public end(): void { + if (this._ended) return; + + this._ended = true; + this.emit('ended'); + this._closeCurrent(); + void this._closing.then(() => { + this.emit('finish'); + this.emit('close'); + }); + } + + private _open(): void { + ++this._index; + this._openedAt = Date.now(); + this._bytes = 0; + this._path = this._options.path({ index: this._index, openedAt: this._openedAt }); + + const stream = fs.createWriteStream(this._path, { flags: 'a' }); + + stream.on('drain', () => this.emit('drain')); + stream.on('error', (err) => this.emit('error', err)); + this._stream = stream; + } + + private _closeCurrent(): void { + const stream = this._stream; + const path = this._path; + const index = this._index; + // 'error' also ends the wait: a failed file is reported through 'error' and still announced. + const closed = new Promise((resolve) => { + stream.once('close', () => resolve()); + stream.once('error', () => resolve()); + }); + + this._closing = Promise.all([ this._closing, closed ]).then(() => { + this.emit('file-closed', path, index); + }); + stream.end(); + } +} diff --git a/src/sinks/Sink.ts b/src/sinks/Sink.ts new file mode 100644 index 0000000..c3a5047 --- /dev/null +++ b/src/sinks/Sink.ts @@ -0,0 +1,73 @@ +/** The lifecycle events a sink emits. */ +export type SinkEvents = { + + /** + * `end()` was called: the sink accepts no more writes. **Required**: a sink emits it + * synchronously, once, from `end()` — it is how its registry removes it and frees its id. + */ + ended: []; + + /** The destination is fully written and closed (e.g. a file flushed and its fd closed). */ + close: []; + + /** The destination failed. */ + error: [Error]; + + /** All queued data was flushed (before `close`). */ + finish: []; + + /** The buffer drained after backpressure; safe to write more. */ + drain: []; +}; + +/** What a registry passes to a sink's constructor. Implementations extend it with their own settings. */ +export type SinkSettings = { + + /** Unique per observer. The registry generates one (uuid v4) when it is omitted. */ + id: string; +}; + +/** + * A destination for records. It is **object-mode** and agnostic about what passes through it: + * `write` receives the record itself, and the implementation decides how to serialize it (JSON + * line, protobuf, a remote payload, …). `JsonlFileSink` and `InMemorySink` are the built-in + * implementations; `ClientSampleSink` is `Sink`. + * + * Create sinks with a registry's `create(SinkClass, settings)`, which constructs + * `new SinkClass({ ...settings, id })` and registers it on the object that owns its lifecycle. + * `end()` closes a sink and — through the `ended` event — removes it from its registry; the owner + * ends whatever is left when it closes. + * + * An implementation typically `extends EventEmitter implements Sink`, and must: + * - emit `ended` synchronously, once, from `end()` (and make a second `end()` a no-op); + * - refuse writes after `end()` (return `false`); + * - emit `close` when the destination is ready, and `error` when it fails. + */ +export interface Sink { + readonly id: string; + + /** Accept one record. `false` once ended (nothing written), or on backpressure. */ + write(record: T): boolean; + + /** No more writes; emit `ended`, then flush and emit `close`. Idempotent. */ + end(): void; + + // Listener args are untyped so that a plain `EventEmitter` satisfies the interface; see + // `SinkEvents` for each event's arguments (the built-in sinks declare them fully typed). + /* eslint-disable @typescript-eslint/no-explicit-any */ + on(event: keyof SinkEvents, listener: (...args: any[]) => void): this; + once(event: keyof SinkEvents, listener: (...args: any[]) => void): this; + off(event: keyof SinkEvents, listener: (...args: any[]) => void): this; + /* eslint-enable @typescript-eslint/no-explicit-any */ +} + +/** + * Where records go, exactly one of: `jsonlPath`, the path of a JSONL file (a `JsonlFileSink` is + * created there), or `sink`, a sink of your own. Giving both is a compile error. + */ +export type SinkDestination = + | { jsonlPath: string, sink?: never } + | { sink: Sink, jsonlPath?: never }; + +/** What the `saveClientSamples` callbacks receive: `id` is the client's sample sink id, the client id. */ +export type ClientSinkParams = { id: string, observedCall: Call, clientId: string }; diff --git a/src/sinks/SinkRegistry.ts b/src/sinks/SinkRegistry.ts new file mode 100644 index 0000000..ee54f10 --- /dev/null +++ b/src/sinks/SinkRegistry.ts @@ -0,0 +1,178 @@ +import type { Sink, SinkDestination } from './Sink'; +import { JsonlFileSink } from './JsonlFileSink'; +import { v4 as uuidv4 } from 'uuid'; +import { createLogger } from '../common/logger'; + +const logger = createLogger('SinkRegistry'); + +/** A sink class `SinkRegistry.create` can construct: its constructor takes one settings object (with `id`). */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SinkConstructor = new(settings: any) => Sink; + +/** The settings `create` takes for `SinkClass`: its constructor's, with `id` optional. */ +export type SinkCreateSettings = Omit[0], 'id'> & { id?: string }; + +/** + * The sinks of one object, ended by that object's lifecycle. `create(SinkClass, settings)` builds a + * sink and registers it; `sink.end()` closes it and removes it; the owner calls `clear()` when it + * closes, ending whatever is left. + * + * Registries form a tree like the issue registries: `observer.sinks` is the parent of every + * `observedCall.sinks` and `observedMediasoupRouter.sinks`. **A sink id is unique per observer**: + * `create` refuses an id that any registry of the tree holds. Each registry sees its own sinks and + * its descendants', so `get`, `write` and `clear` on a parent reach a child's sinks too. + * + * A client's sample sink is the one under its client id in its call's registry. + */ +export class SinkRegistry { + // This registry's sinks and its descendants'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly _sinks = new Map>(); + + // The ids of the sinks added to this registry itself (not to a descendant). + private readonly _ownIds = new Set(); + + public constructor( + public readonly parent?: SinkRegistry, + ) { + } + + /** Number of open sinks in this registry and its descendants. */ + public get size(): number { + return this._sinks.size; + } + + public keys(): IterableIterator { + return this._sinks.keys(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public values(): IterableIterator> { + return this._sinks.values(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public get = Sink>(id: string): T | undefined { + return this._sinks.get(id) as T | undefined; + } + + /** + * Construct `new SinkClass(settings)` and register it. The id (`settings.id`, generated when + * omitted) is checked first, so nothing is constructed — no file is opened — when it is taken: + * `undefined` is returned and a warning logged. + */ + public create(SinkClass: C, settings: SinkCreateSettings): InstanceType | undefined { + const lineage = this._lineage(); + const id = settings.id ?? uuidv4(); + + if (lineage[lineage.length - 1]._sinks.has(id)) { + logger.warn('Sink id %s is already in use; sink ids must be unique per observer. No sink was created', id); + + return undefined; + } + + const sink = new SinkClass({ ...settings, id }) as InstanceType; + + this._register(sink, lineage); + + return sink; + } + + /** + * Register a sink constructed elsewhere, under its own `id`. Returns `false`, registering nothing, + * when the id is already in use in the observer (the sink is then the caller's to end). Prefer + * `create` when the sink opens a resource on construction: it checks the id first. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public add(sink: Sink): boolean { + const lineage = this._lineage(); + + if (lineage[lineage.length - 1]._sinks.has(sink.id)) { + logger.warn('Sink id %s is already in use; sink ids must be unique per observer. The sink was not added', sink.id); + + return false; + } + + this._register(sink, lineage); + + return true; + } + + /** + * Register the sink a `SinkDestination` describes, under `id`: for `jsonlPath`, a `JsonlFileSink` + * is created (the id is checked before the file is opened); a `sink` is added, and its `id` must + * be `id`. Returns `undefined` when the id is taken, the ids differ, or both are given (possible + * from JavaScript); a given `sink` that is not registered is ended. + */ + public createFromDestination(id: string, destination: SinkDestination): Sink | undefined { + const { jsonlPath, sink } = destination as { jsonlPath?: string, sink?: Sink }; + const refuse = (reason: string, ...args: unknown[]) => { + logger.warn(`Sink ${id}: ${reason}`, ...args); + sink?.end(); + + return undefined; + }; + + if (jsonlPath !== undefined && sink) return refuse('give either jsonlPath or sink, not both'); + if (jsonlPath !== undefined) return this.create(JsonlFileSink, { id, path: jsonlPath }); + if (!sink) return undefined; + if (sink.id !== id) return refuse('the sink id %s must be %s; it is ended', sink.id, id); + if (!this.add(sink)) return refuse('the id is already in use; the sink is ended'); + + return sink; + } + + /** Whether the sink `id` was added to this registry itself, rather than to a descendant. */ + public owns(id: string): boolean { + return this._ownIds.has(id); + } + + /** Write `record` to the sink `id`. `false` when there is no such sink, or on backpressure. */ + public write(id: string, record: unknown): boolean { + const sink = this._sinks.get(id); + + if (!sink) { + logger.debug('No sink %s; record dropped', id); + + return false; + } + + return sink.write(record); + } + + /** End every sink of this registry and of its descendants. */ + public clear(): void { + // Snapshot first: each `end()` removes its sink from the map. + for (const [ id, sink ] of [ ...this._sinks.entries() ]) { + try { + sink.end(); + } catch (err) { + logger.warn('Sink %s failed to end: %o', id, err); + } + } + } + + /** This registry and its ancestors, up to the root. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _register(sink: Sink, lineage: SinkRegistry[]): void { + const id = sink.id; + + // An EventEmitter 'error' with no listener throws; a failing destination must not crash the process. + const onError = (err: Error) => logger.warn('Sink %s failed: %o', id, err); + + sink.on('error', onError); + sink.once('close', () => sink.off('error', onError)); + sink.once('ended', () => { + if (this._sinks.get(id) === sink) this._ownIds.delete(id); + for (const registry of lineage) { + if (registry._sinks.get(id) === sink) registry._sinks.delete(id); + } + }); + this._ownIds.add(id); + for (const registry of lineage) registry._sinks.set(id, sink); + } + + private _lineage(): SinkRegistry[] { + return [ this, ...(this.parent?._lineage() ?? []) ]; + } +} diff --git a/src/sinks/index.ts b/src/sinks/index.ts index 0b3949c..e8d3448 100644 --- a/src/sinks/index.ts +++ b/src/sinks/index.ts @@ -1,6 +1,12 @@ -// Internal barrel for the per-client sample sinks, re-exported from the package root. -export { ClientSampleSink } from './ClientSampleSink'; -export type { ClientSampleSinkEvents, ClientSampleSinkFactory } from './ClientSampleSink'; -export { JsonlFileSink, createJsonlFileSink, createJsonlFileSinkFactory } from './JsonlFileSink'; -export type { JsonlFileSinkOptions, JsonlFileSinkFactoryOptions } from './JsonlFileSink'; +// Internal barrel for the sinks, re-exported from the package root. +export type { Sink, SinkEvents, SinkSettings, SinkDestination, ClientSinkParams } from './Sink'; +export { SinkRegistry } from './SinkRegistry'; +export type { SinkConstructor, SinkCreateSettings } from './SinkRegistry'; +export type { ClientSampleSink, ClientSampleSinkEvents } from './ClientSampleSink'; +export { JsonlFileSink, createJsonlFileSink } from './JsonlFileSink'; +export type { JsonlFileSinkOptions } from './JsonlFileSink'; +export { RollingJsonlFileSink } from './RollingJsonlFileSink'; +export type { RollingJsonlFileSinkOptions, RollingJsonlFileSinkEvents, RollingJsonlFileInfo } from './RollingJsonlFileSink'; export { InMemorySink, createInMemorySink } from './InMemorySink'; +export { ExtensionStatsRegistry } from './ExtensionStatsRegistry'; +export type { CreateExtensionStatsSettings } from './ExtensionStatsRegistry'; diff --git a/src/utils/stats.ts b/src/utils/stats.ts index 5738034..2414c94 100644 --- a/src/utils/stats.ts +++ b/src/utils/stats.ts @@ -426,7 +426,9 @@ function erf(x: number): number { * holds the values in `[i * bucketWidth, (i + 1) * bucketWidth)`. Interpolates linearly inside * the bucket the rank falls in, so the error is at most one bucket width. With `openEnded`, the * last bucket holds every value above its lower bound, and a percentile falling in it is reported - * as `max`. The estimate is clamped to `[min, max]` when given. `undefined` for an empty histogram. + * as `max`. With `min`/`max` (the observed extremes), the bucket holding them is narrowed to the + * observed range before interpolating, so the estimate never leaves `[min, max]`. `undefined` for an + * empty histogram. */ export function percentileOfHistogram( histogram: readonly number[], @@ -451,9 +453,12 @@ export function percentileOfHistogram( } if (options.openEnded && index === histogram.length - 1 && options.max !== undefined) return options.max; - const estimate = (index * bucketWidth) + (((rank - cumulative) / count) * bucketWidth); + // Narrow the bucket to the observed range, so a distribution inside one bucket (40–49 ms in + // [40, 60)) is interpolated over 40–49 rather than over the whole bucket and then clamped. + const lower = Math.max(index * bucketWidth, options.min ?? -Infinity); + const upper = Math.min((index + 1) * bucketWidth, options.max ?? Infinity); - return Math.min(Math.max(estimate, options.min ?? estimate), options.max ?? estimate); + return lower + (((rank - cumulative) / count) * Math.max(0, upper - lower)); } return options.max; diff --git a/tests/Observer.spec.ts b/tests/Observer.spec.ts index 0645215..fcd7320 100644 --- a/tests/Observer.spec.ts +++ b/tests/Observer.spec.ts @@ -206,20 +206,21 @@ describe('Observer', () => { }); describe('per-client sinks', () => { - it('creates a sink via createClientSink and emits client-sink-created', () => { - const observer = new Observer({ - createClientSink: () => createInMemorySink(), - }); - let eventSink: unknown; + it('uses a sink added under the client id on client-added', () => { + const observer = new Observer(); - observer.on('client-sink-created', ({ sink }) => { eventSink = sink; }); + observer.on('client-added', ({ observedCall, observedClient }) => { + observedCall.sinks.create(InMemorySink, { id: observedClient.clientId }); + }); observer.accept(makeSample({})); - const client = observer.getObservedCall('call-1')?.getObservedClient('client-1'); + const call = observer.getObservedCall('call-1'); + const client = call?.getObservedClient('client-1'); expect(client?.sink).toBeInstanceOf(InMemorySink); - expect(eventSink).toBe(client?.sink); + expect(client?.sink).toBe(call?.sinks.get('client-1')); + expect((client?.sink as InMemorySink).samples).toHaveLength(1); observer.close(); }); diff --git a/tests/extensionStats.spec.ts b/tests/extensionStats.spec.ts new file mode 100644 index 0000000..4d2060b --- /dev/null +++ b/tests/extensionStats.spec.ts @@ -0,0 +1,265 @@ +import { EventEmitter } from 'node:events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Observer } from '../src/Observer'; +import { JsonlFileSink } from '../src/sinks/JsonlFileSink'; +import { InMemorySink } from '../src/sinks/InMemorySink'; +import type { ClientSample } from '../src/schema/ClientSample'; + +type SfuStats = { bitrate: number }; + +function setup() { + const observer = new Observer({ closeCallIfEmptyForMs: undefined }); + const call = observer.createObservedCall({ callId: 'call-1' })!; + + return { observer, call }; +} + +// InMemorySink is typed for client samples; the records here are arbitrary. +const records = (sink: InMemorySink) => sink.samples as unknown[]; + +describe('ObservedCall extension stats', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-stats-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('keeps the last data per id, with no sink required', () => { + const { observer, call } = setup(); + + expect(call.createExtensionStats({ id: 'sfu' })).toBeUndefined(); + expect(call.extensionStats.values.has('sfu')).toBe(true); + expect(call.getExtensionStats('sfu')).toBeUndefined(); + + call.acceptExtensionStats('sfu', { bitrate: 1 }); + call.acceptExtensionStats('sfu', { bitrate: 2 }); + // Not created first: accepted all the same. + call.acceptExtensionStats('other', { bitrate: 3 }); + + expect(call.getExtensionStats('sfu')).toEqual({ bitrate: 2 }); + expect(call.getExtensionStats('other')).toEqual({ bitrate: 3 }); + observer.close(); + }); + + it('writes every accepted data to the sink created with it, which ends with the call', async () => { + const { observer, call } = setup(); + const file = path.join(dir, 'sfu.jsonl'); + const sink = call.createExtensionStats({ id: 'sfu', jsonlPath: file })! as JsonlFileSink; + + expect(sink).toBeInstanceOf(JsonlFileSink); + expect(sink.id).toBe('sfu'); + expect(call.sinks.get('sfu')).toBe(sink); + + call.acceptExtensionStats('sfu', { bitrate: 1 }); + call.acceptExtensionStats('sfu', { bitrate: 2 }); + + const flushed = new Promise((resolve) => sink.once('close', () => resolve())); + + call.close(); + await flushed; + + expect(fs.readFileSync(file, 'utf8').trim().split('\n').map((line) => JSON.parse(line))).toEqual([ { bitrate: 1 }, { bitrate: 2 } ]); + expect(call.getExtensionStats('sfu')).toEqual({ bitrate: 2 }); + observer.close(); + }); + + it('replaces a sink the call already holds under the id when created with createSink', () => { + const { observer, call } = setup(); + const existing = call.sinks.create(InMemorySink, { id: 'sfu' })!; + const created = call.createExtensionStats({ id: 'sfu', sink: new InMemorySink({ id: 'sfu' }) })! as InMemorySink; + + expect(existing.ended).toBe(true); + expect(call.sinks.get('sfu')).toBe(created); + + call.acceptExtensionStats('sfu', { bitrate: 1 }); + expect(records(created)).toEqual([ { bitrate: 1 } ]); + expect(records(existing)).toEqual([]); + observer.close(); + }); + + it('writes to a sink created later under the same id, from then on', () => { + const { observer, call } = setup(); + + call.createExtensionStats({ id: 'sfu' }); + call.acceptExtensionStats('sfu', { bitrate: 1 }); + + const later = call.sinks.create(InMemorySink, { id: 'sfu' })!; + + call.acceptExtensionStats('sfu', { bitrate: 2 }); + expect(records(later)).toEqual([ { bitrate: 2 } ]); + observer.close(); + }); + + it('creates no sink when another object of the observer holds the id, and nothing on a closed call', () => { + const { observer, call } = setup(); + const other = observer.createObservedCall({ callId: 'call-2' })!; + const held = other.sinks.create(InMemorySink, { id: 'sfu' })!; + + expect(call.createExtensionStats({ id: 'sfu', sink: new InMemorySink({ id: 'sfu' }) })).toBeUndefined(); + expect(held.ended).toBe(false); + + // The data is still kept on the call; it just has no sink here. + call.acceptExtensionStats('sfu', { bitrate: 1 }); + expect(call.getExtensionStats('sfu')).toEqual({ bitrate: 1 }); + expect(records(held)).toEqual([]); + + call.close(); + expect(call.createExtensionStats({ id: 'late', sink: new InMemorySink({ id: 'late' }) })).toBeUndefined(); + call.acceptExtensionStats('sfu', { bitrate: 2 }); + expect(call.getExtensionStats('sfu')).toEqual({ bitrate: 1 }); + observer.close(); + }); + + it('keeps a client sample sink apart from extension stats with a different id', () => { + const { observer, call } = setup(); + + call.createExtensionStats({ id: 'sfu', sink: new InMemorySink({ id: 'sfu' }) }); + observer.on('client-added', ({ observedCall, observedClient }) => { + observedCall.sinks.create(InMemorySink, { id: observedClient.clientId }); + }); + observer.accept({ callId: 'call-1', clientId: 'alice', timestamp: Date.now() } as ClientSample); + call.acceptExtensionStats('sfu', { bitrate: 1 }); + + expect(records(call.sinks.get('sfu')!)).toEqual([ { bitrate: 1 } ]); + expect(call.sinks.get('alice')!.samples).toHaveLength(1); + observer.close(); + }); +}); + +describe('extension stats ids', () => { + it('refuses to create an id twice, or one already accepted', () => { + const { observer, call } = setup(); + + call.createExtensionStats({ id: 'sfu' }); + expect(call.createExtensionStats({ id: 'sfu', sink: new InMemorySink({ id: 'sfu' }) })).toBeUndefined(); + expect(call.sinks.get('sfu')).toBeUndefined(); + + call.acceptExtensionStats('accepted-first', { bitrate: 1 }); + expect(call.createExtensionStats({ id: 'accepted-first', sink: new InMemorySink({ id: 'accepted-first' }) })).toBeUndefined(); + + // The way to give it a sink afterwards. + const sink = call.sinks.create(InMemorySink, { id: 'accepted-first' })!; + + call.acceptExtensionStats('accepted-first', { bitrate: 2 }); + expect(records(sink)).toEqual([ { bitrate: 2 } ]); + observer.close(); + }); +}); + +describe('ObservedMediasoupRouter extension stats', () => { + const fakeRouter = () => ({ id: 'router-1', observer: new EventEmitter() }) as any; + + it('keeps the last data, writes it to its sink, and ends the sink when the router closes', () => { + const observer = new Observer(); + const router = fakeRouter(); + const observedRouter = observer.createObservedMediasoupRouter({ router })!; + const sink = observedRouter.createExtensionStats({ id: 'pipe-1', sink: new InMemorySink({ id: 'pipe-1' }) })! as InMemorySink; + + observedRouter.acceptExtensionStats('pipe-1', [ { bytesSent: 1 } ]); + observedRouter.acceptExtensionStats('pipe-1', [ { bytesSent: 2 } ]); + + expect(observedRouter.getExtensionStats('pipe-1')).toEqual([ { bytesSent: 2 } ]); + expect(observedRouter.extensionStats.values.size).toBe(1); + expect(records(sink)).toEqual([ [ { bytesSent: 1 } ], [ { bytesSent: 2 } ] ]); + + // Unique per observer: the call cannot take the router's id. + const call = observer.createObservedCall({ callId: 'call-1' })!; + + expect(call.createExtensionStats({ id: 'pipe-1', sink: new InMemorySink({ id: 'pipe-1' }) })).toBeUndefined(); + expect(sink.ended).toBe(false); + + router.observer.emit('close'); + expect(sink.ended).toBe(true); + observedRouter.acceptExtensionStats('pipe-1', [ { bytesSent: 3 } ]); + expect(observedRouter.getExtensionStats('pipe-1')).toEqual([ { bytesSent: 2 } ]); + expect(observedRouter.createExtensionStats({ id: 'late' })).toBeUndefined(); + observer.close(); + }); +}); + +describe('Observer extension stats', () => { + it('keeps the last data, writes it to its sink, and ends the sink when the observer closes', () => { + const observer = new Observer(); + const sink = observer.createExtensionStats({ id: 'worker-1', sink: new InMemorySink({ id: 'worker-1' }) })! as InMemorySink; + + observer.acceptExtensionStats('worker-1', { cpu: 12 }); + + expect(observer.getExtensionStats('worker-1')).toEqual({ cpu: 12 }); + expect(observer.sinks.get('worker-1')).toBe(sink); + expect(records(sink)).toEqual([ { cpu: 12 } ]); + + observer.close(); + expect(sink.ended).toBe(true); + observer.acceptExtensionStats('worker-1', { cpu: 99 }); + expect(observer.getExtensionStats('worker-1')).toEqual({ cpu: 12 }); + }); +}); + +describe('ExtensionStatsRegistry.close', () => { + it('ends only the extension stats sinks of its owner', () => { + const observer = new Observer({ closeCallIfEmptyForMs: undefined }); + const call = observer.createObservedCall({ callId: 'call-1' })!; + const statsSink = observer.createExtensionStats({ id: 'worker-1', sink: new InMemorySink({ id: 'worker-1' }) })! as InMemorySink; + const otherSink = observer.sinks.create(InMemorySink, { id: 'other' })!; + const callSink = call.createExtensionStats({ id: 'sfu', sink: new InMemorySink({ id: 'sfu' }) })! as InMemorySink; + + observer.extensionStats.close(); + + expect(statsSink.ended).toBe(true); + expect([ otherSink.ended, callSink.ended ]).toEqual([ false, false ]); + expect(observer.createExtensionStats({ id: 'late' })).toBeUndefined(); + observer.close(); + expect([ otherSink.ended, callSink.ended ]).toEqual([ true, true ]); + }); +}); + +describe('createExtensionStats settings', () => { + it('refuses a sink whose id differs, ending it, and opens no file for a path whose id is taken', () => { + const { observer, call } = setup(); + const mismatched = new InMemorySink({ id: 'not-b' }); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-stats-')); + const other = observer.createObservedCall({ callId: 'call-2' })!; + + other.sinks.create(InMemorySink, { id: 'taken' }); + expect(call.createExtensionStats({ id: 'taken', jsonlPath: path.join(dir, 'taken.jsonl') })).toBeUndefined(); + expect(fs.existsSync(path.join(dir, 'taken.jsonl'))).toBe(false); + fs.rmSync(dir, { recursive: true, force: true }); + + expect(call.createExtensionStats({ id: 'b', sink: mismatched })).toBeUndefined(); + expect(mismatched.ended).toBe(true); + expect(call.sinks.get('not-b')).toBeUndefined(); + observer.close(); + }); + + it('refuses jsonlPath and sink together (from JavaScript), ending the given sink and opening no file', () => { + const { observer, call } = setup(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'extension-stats-')); + const given = new InMemorySink({ id: 'both' }); + // Only reachable without the type check: the settings type makes both a compile error. + const settings = { id: 'both', jsonlPath: path.join(dir, 'both.jsonl'), sink: given } as unknown as { id: string, sink: InMemorySink }; + + expect(call.createExtensionStats(settings)).toBeUndefined(); + expect(given.ended).toBe(true); + expect(fs.existsSync(path.join(dir, 'both.jsonl'))).toBe(false); + expect(call.extensionStats.values.has('both')).toBe(false); + fs.rmSync(dir, { recursive: true, force: true }); + observer.close(); + }); + + it('never ends a descendant\'s sink: the observer cannot replace a call\'s sink under the same id', () => { + const { observer, call } = setup(); + const callSink = call.sinks.create(InMemorySink, { id: 'shared' })!; + const given = new InMemorySink({ id: 'shared' }); + + expect(observer.createExtensionStats({ id: 'shared', sink: given })).toBeUndefined(); + expect(callSink.ended).toBe(false); + expect(given.ended).toBe(true); + expect(call.sinks.get('shared')).toBe(callSink); + observer.close(); + }); +}); diff --git a/tests/injection.spec.ts b/tests/injection.spec.ts index 719aa92..23a862d 100644 --- a/tests/injection.spec.ts +++ b/tests/injection.spec.ts @@ -5,8 +5,10 @@ import { makeSample } from './helpers/samples'; function setup() { let sink: InMemorySink | undefined; - const observer = new Observer({ - createClientSink: () => (sink = new InMemorySink()), + const observer = new Observer(); + + observer.on('client-added', ({ observedCall, observedClient }) => { + sink = observedCall.sinks.create(InMemorySink, { id: observedClient.clientId }); }); // First accept creates the call + client (and the sink) and persists the first sample. diff --git a/tests/outboundTrackRepublish.spec.ts b/tests/outboundTrackRepublish.spec.ts new file mode 100644 index 0000000..894f90a --- /dev/null +++ b/tests/outboundTrackRepublish.spec.ts @@ -0,0 +1,173 @@ +import { Observer } from '../src/Observer'; +import { createDefaultMediasoupRemoteTrackResolverFactory } from '../src/resolvers/RemoteTrackResolverFactories'; +import type { ObservedOutboundTrack } from '../src/ObservedOutboundTrack'; +import type { ClientSample, PeerConnectionSample } from '../src/schema/ClientSample'; +import { makeSample, nextTimestamp } from './helpers/samples'; + +/** + * A producer recreated with the same MediaStreamTrack (reconnection, toggling audio processing): the + * new sender reports a new media source and new outbound RTPs with the same `trackIdentifier`, while + * the client keeps reporting the old ones with frozen counters. + */ + +const TRACK_ID = 'mic-track'; + +type Publication = { mediaSourceId: string, ssrc: number, producerId: string, packetsSent: number }; + +function publisherSample(publications: Publication[], current: Publication): ClientSample { + const timestamp = nextTimestamp(); + const pc = { + peerConnectionId: 'pub-pc', + mediaSources: publications.map((p) => ({ timestamp, id: p.mediaSourceId, kind: 'audio', trackIdentifier: TRACK_ID })), + outboundRtps: publications.map((p) => ({ + timestamp, id: `rtp-${p.ssrc}`, ssrc: p.ssrc, kind: 'audio', mediaSourceId: p.mediaSourceId, + packetsSent: p.packetsSent, bytesSent: p.packetsSent * 100, + })), + outboundTracks: [ { timestamp, id: TRACK_ID, kind: 'audio', attachments: { producerId: current.producerId } } ], + } as unknown as PeerConnectionSample; + + return { timestamp, callId: 'call-1', clientId: 'publisher', peerConnections: [ pc ] }; +} + +const consumerSample = (producerId: string) => makeSample({ + clientId: 'consumer', + peerConnections: [ { peerConnectionId: 'sub-pc', inbound: [ { trackId: `in-${producerId}`, attachments: { producerId, consumerId: `c-${producerId}` } } ] } ], +}); + +function setup() { + const observer = new Observer({ createRemoteTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(), closeCallIfEmptyForMs: undefined }); + const added: ObservedOutboundTrack[] = []; + const removed: ObservedOutboundTrack[] = []; + + observer.on('outbound-track-added', ({ observedOutboundTrack }) => added.push(observedOutboundTrack)); + observer.on('outbound-track-removed', ({ observedOutboundTrack }) => removed.push(observedOutboundTrack)); + + const track = () => observer.getObservedCall('call-1')!.getObservedClient('publisher')! + .observedPeerConnections.get('pub-pc')!.observedOutboundTracks.get(TRACK_ID)!; + + return { observer, added, removed, track }; +} + +describe('outbound track republished on the same MediaStreamTrack', () => { + it('gets a new object bound to the new media source and RTPs, while the old one is removed', () => { + const { observer, added, removed, track } = setup(); + const p1: Publication = { mediaSourceId: 'ms-1', ssrc: 1, producerId: 'P1', packetsSent: 100 }; + + observer.accept(publisherSample([ p1 ], p1)); + observer.accept(publisherSample([ { ...p1, packetsSent: 200 } ], p1)); + + const first = track(); + + expect(first.getMediaSource()?.id).toBe('ms-1'); + expect(first.getOutboundRtps()?.map((rtp) => rtp.ssrc)).toEqual([ 1 ]); + + // Republished: ms-2 / ssrc 2 / P2 appear; ms-1 / ssrc 1 stay reported, frozen. + const p2: Publication = { mediaSourceId: 'ms-2', ssrc: 2, producerId: 'P2', packetsSent: 10 }; + + observer.accept(publisherSample([ { ...p1, packetsSent: 200 }, p2 ], p2)); + observer.accept(publisherSample([ { ...p1, packetsSent: 200 }, { ...p2, packetsSent: 60 } ], p2)); + + const second = track(); + + expect(second).not.toBe(first); + expect(second.id).toBe(TRACK_ID); + expect(second.getMediaSource()?.id).toBe('ms-2'); + expect(second.getOutboundRtps()?.map((rtp) => rtp.ssrc)).toEqual([ 2 ]); + expect(second.deltaPacketsSent).toBe(50); + expect(added).toEqual([ first, second ]); + expect(removed).toEqual([ first ]); + + // The old object keeps its own publication. + expect(first.getMediaSource()?.id).toBe('ms-1'); + observer.close(); + }); + + it('does not rotate while the media source stays the same, and includes RTPs that appear later', () => { + const { observer, added, removed, track } = setup(); + const p1: Publication = { mediaSourceId: 'ms-1', ssrc: 1, producerId: 'P1', packetsSent: 100 }; + const layer: Publication = { mediaSourceId: 'ms-1', ssrc: 11, producerId: 'P1', packetsSent: 5 }; + + observer.accept(publisherSample([ p1 ], p1)); + observer.accept(publisherSample([ p1, layer ], p1)); + + expect(track().getOutboundRtps()?.map((rtp) => rtp.ssrc)).toEqual([ 1, 11 ]); + expect(added).toHaveLength(1); + expect(removed).toHaveLength(0); + observer.close(); + }); + + it('replaces the track object when its first media source appears after it', () => { + const { observer, added, removed, track } = setup(); + const timestamp = nextTimestamp(); + + observer.accept({ + timestamp, callId: 'call-1', clientId: 'publisher', + peerConnections: [ { peerConnectionId: 'pub-pc', outboundTracks: [ { timestamp, id: TRACK_ID, kind: 'audio', attachments: { producerId: 'P1' } } ] } as unknown as PeerConnectionSample ], + }); + + const first = track(); + + expect(first.getMediaSource()).toBeUndefined(); + + const p1: Publication = { mediaSourceId: 'ms-1', ssrc: 1, producerId: 'P1', packetsSent: 100 }; + + observer.accept(publisherSample([ p1 ], p1)); + + expect(track()).not.toBe(first); + expect(track().getMediaSource()?.id).toBe('ms-1'); + expect(added).toHaveLength(2); + expect(removed).toEqual([ first ]); + observer.close(); + }); + + it('re-pairs consumers by producerId: P1 consumers unlink, P2 consumers link to the new object', () => { + const { observer, track } = setup(); + const p1: Publication = { mediaSourceId: 'ms-1', ssrc: 1, producerId: 'P1', packetsSent: 100 }; + + observer.accept(publisherSample([ p1 ], p1)); + observer.accept(consumerSample('P1')); + + const first = track(); + const call = observer.getObservedCall('call-1')!; + const resolver = call.remoteTrackResolver!; + + expect(first.remoteInboundTracks.size).toBe(1); + expect(resolver.getOutboundTrackByPublisherId('P1')).toBe(first); + + const p2: Publication = { mediaSourceId: 'ms-2', ssrc: 2, producerId: 'P2', packetsSent: 10 }; + + observer.accept(publisherSample([ p1, p2 ], p2)); + + const second = track(); + + // The old producer's entry is released, even though the track's attachments now say P2. + expect(resolver.getOutboundTrackByPublisherId('P1')).toBeUndefined(); + expect(resolver.getOutboundTrackByPublisherId('P2')).toBe(second); + expect(first.remoteInboundTracks.size).toBe(0); + + observer.accept(consumerSample('P2')); + expect(second.remoteInboundTracks.size).toBe(1); + observer.close(); + }); + + it('releases the publisher id the track was indexed under when it is removed, even if its producerId changed since', () => { + const { observer, track } = setup(); + const p1: Publication = { mediaSourceId: 'ms-1', ssrc: 1, producerId: 'P1', packetsSent: 100 }; + + observer.accept(publisherSample([ p1 ], p1)); + + const resolver = observer.getObservedCall('call-1')!.remoteTrackResolver!; + const first = track(); + + expect(resolver.getOutboundTrackByPublisherId('P1')).toBe(first); + + // Same media source, but the attachments now report another producer. + observer.accept(publisherSample([ p1 ], { ...p1, producerId: 'P-other' })); + expect(track()).toBe(first); + + // The publisher stops reporting the track: it is removed, and the P1 entry with it. + observer.accept({ timestamp: nextTimestamp(), callId: 'call-1', clientId: 'publisher', peerConnections: [ { peerConnectionId: 'pub-pc' } as unknown as PeerConnectionSample ] }); + expect(resolver.getOutboundTrackByPublisherId('P1')).toBeUndefined(); + observer.close(); + }); +}); diff --git a/tests/rollingJsonlFileSink.spec.ts b/tests/rollingJsonlFileSink.spec.ts new file mode 100644 index 0000000..928ef90 --- /dev/null +++ b/tests/rollingJsonlFileSink.spec.ts @@ -0,0 +1,127 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Observer } from '../src/Observer'; +import { RollingJsonlFileSink } from '../src/sinks/RollingJsonlFileSink'; + +let dir: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rolling-')); +}); +afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +const fileOf = (index: number) => path.join(dir, `stats-${index}.jsonl`); +const readLines = (file: string) => fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((line) => JSON.parse(line)); + +/** End the sink and collect every `file-closed`, resolving on `close` (after the last file). */ +function endAndCollect(sink: RollingJsonlFileSink, closed: { path: string, index: number }[]) { + return new Promise((resolve) => { + sink.once('close', () => resolve()); + sink.end(); + }).then(() => closed); +} + +describe('RollingJsonlFileSink', () => { + it('rolls before a write that would exceed maxBytes, keeping every record in exactly one file', async () => { + const closed: { path: string, index: number }[] = []; + // Each line is `{"n":}\n` = 8 bytes: two lines per 16-byte file. + const sink = new RollingJsonlFileSink<{ n: number }>({ path: ({ index }) => fileOf(index), maxBytes: 16 }); + + sink.on('file-closed', (path, index) => { + closed.push({ path, index }); + }); + for (let n = 0; n < 5; ++n) expect(sink.write({ n })).toBe(true); + + expect(sink.index).toBe(2); + await endAndCollect(sink, closed); + + expect(closed).toEqual([ { path: fileOf(0), index: 0 }, { path: fileOf(1), index: 1 }, { path: fileOf(2), index: 2 } ]); + expect(readLines(fileOf(0))).toEqual([ { n: 0 }, { n: 1 } ]); + expect(readLines(fileOf(1))).toEqual([ { n: 2 }, { n: 3 } ]); + expect(readLines(fileOf(2))).toEqual([ { n: 4 } ]); + }); + + it('rolls on write once the file has been open for maxAgeMs, never while idle', async () => { + let now = 1_000_000; + + jest.spyOn(Date, 'now').mockImplementation(() => now); + + const opened: number[] = []; + const sink = new RollingJsonlFileSink({ + path: ({ index, openedAt }) => { + opened.push(openedAt); + + return fileOf(index); + }, + maxAgeMs: 60_000, + }); + + sink.write({ n: 1 }); + now += 59_999; + sink.write({ n: 2 }); + expect(sink.index).toBe(0); + + // An hour of silence: no file is opened until something is written. + now += 3_600_000; + expect(sink.index).toBe(0); + sink.write({ n: 3 }); + expect(sink.index).toBe(1); + expect(opened).toEqual([ 1_000_000, 1_000_000 + 59_999 + 3_600_000 ]); + + await endAndCollect(sink, []); + expect(readLines(fileOf(0))).toEqual([ { n: 1 }, { n: 2 } ]); + expect(readLines(fileOf(1))).toEqual([ { n: 3 } ]); + }); + + it('rolls on roll(), which is a no-op on an empty file; refuses writes once ended', async () => { + const closed: { path: string, index: number }[] = []; + const sink = new RollingJsonlFileSink({ path: ({ index }) => fileOf(index) }); + + sink.on('file-closed', (path, index) => { + closed.push({ path, index }); + }); + sink.roll(); + expect(sink.index).toBe(0); + + sink.write({ n: 1 }); + sink.roll(); + expect(sink.index).toBe(1); + expect(sink.path).toBe(fileOf(1)); + sink.write({ n: 2 }); + + await endAndCollect(sink, closed); + + expect(sink.ended).toBe(true); + expect(sink.write({ n: 3 })).toBe(false); + sink.roll(); + expect(closed.map((file) => file.index)).toEqual([ 0, 1 ]); + expect(readLines(fileOf(1))).toEqual([ { n: 2 } ]); + }); + + it('works as an observer extension stats sink, ended when the observer closes', async () => { + const observer = new Observer(); + const sink = new RollingJsonlFileSink({ id: 'sfu-cpu', path: ({ index }) => fileOf(index), maxBytes: 30 }); + + expect(observer.createExtensionStats({ id: 'sfu-cpu', sink })).toBe(sink); + + const closed: number[] = []; + + sink.on('file-closed', (_path, index) => { + closed.push(index); + }); + + for (let cpu = 10; cpu < 13; ++cpu) observer.acceptExtensionStats('sfu-cpu', { cpu }); + + const done = new Promise((resolve) => sink.once('close', () => resolve())); + + observer.close(); + await done; + + expect(closed).toEqual([ 0, 1 ]); + expect([ ...readLines(fileOf(0)), ...readLines(fileOf(1)) ]).toEqual([ { cpu: 10 }, { cpu: 11 }, { cpu: 12 } ]); + }); +}); diff --git a/tests/sinkRegistry.spec.ts b/tests/sinkRegistry.spec.ts new file mode 100644 index 0000000..9dd3326 --- /dev/null +++ b/tests/sinkRegistry.spec.ts @@ -0,0 +1,277 @@ +import { EventEmitter } from 'node:events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Observer } from '../src/Observer'; +import type { Sink, SinkSettings } from '../src/sinks/Sink'; +import { SinkRegistry } from '../src/sinks/SinkRegistry'; +import type { ClientSampleSink } from '../src/sinks/ClientSampleSink'; +import { JsonlFileSink } from '../src/sinks/JsonlFileSink'; +import { InMemorySink } from '../src/sinks/InMemorySink'; +import type { ClientSample } from '../src/schema/ClientSample'; + +/** Records what happened to it. Counts constructions so a refused `create` can be checked. */ +class RecordingSink extends EventEmitter implements Sink { + public static constructed = 0; + public readonly id: string; + public readonly records: unknown[] = []; + public readonly label?: string; + public endCalls = 0; + public ended = false; + + public constructor(settings: SinkSettings & { label?: string }) { + super(); + this.id = settings.id; + this.label = settings.label; + ++RecordingSink.constructed; + } + + public write(record: unknown): boolean { + if (this.ended) return false; + this.records.push(record); + + return true; + } + + public end(): void { + if (this.ended) return; + this.ended = true; + ++this.endCalls; + this.emit('ended'); + this.emit('close'); + } +} + +/* Fakes for the mediasoup `types.*` objects, as in mediasoupRouter.spec.ts. */ +const fakeRouter = (id = 'router-1') => ({ id, observer: new EventEmitter() }) as any; +const fakePipeTransport = (id: string) => ({ + id, + type: 'pipe', + tuple: { localAddress: '10.0.0.1', localPort: 4000, protocol: 'udp' }, + observer: new EventEmitter(), +}) as any; + +const sample = (clientId: string): ClientSample => ({ callId: 'call-1', clientId, timestamp: Date.now() } as ClientSample); + +describe('SinkRegistry', () => { + it('creates and registers a sink, writes to it by id, and drops it once the sink is ended', () => { + const registry = new SinkRegistry(); + const sink = registry.create(RecordingSink, { id: 'a', label: 'x' })!; + + expect(sink).toBeInstanceOf(RecordingSink); + expect(sink.id).toBe('a'); + expect(sink.label).toBe('x'); + expect(registry.get('a')).toBe(sink); + expect(registry.write('a', { n: 1 })).toBe(true); + expect(registry.write('missing', { n: 2 })).toBe(false); + + sink.end(); + sink.end(); + + expect(sink.ended).toBe(true); + expect(sink.endCalls).toBe(1); + expect(registry.get('a')).toBeUndefined(); + expect(registry.write('a', { n: 3 })).toBe(false); + expect(sink.write({ n: 4 })).toBe(false); + expect(sink.records).toEqual([ { n: 1 } ]); + }); + + it('generates an id when none is given', () => { + const registry = new SinkRegistry(); + const sink = registry.create(RecordingSink, {})!; + + expect(sink.id).toMatch(/^[0-9a-f-]{36}$/); + expect(registry.get(sink.id)).toBe(sink); + }); + + it('keeps ids unique across the tree without constructing the refused sink, and lets a parent reach its descendants\' sinks', () => { + const root = new SinkRegistry(); + const callA = new SinkRegistry(root); + const callB = new SinkRegistry(root); + const aliceA = callA.create(RecordingSink, { id: 'alice' })!; + const before = RecordingSink.constructed; + + // Taken in callA: refused in the same registry, a sibling and the parent — nothing constructed. + expect(callA.create(RecordingSink, { id: 'alice' })).toBeUndefined(); + expect(callB.create(RecordingSink, { id: 'alice' })).toBeUndefined(); + expect(root.create(RecordingSink, { id: 'alice' })).toBeUndefined(); + expect(RecordingSink.constructed).toBe(before); + + const own = root.create(RecordingSink, { id: 'own' })!; + + expect(callA.create(RecordingSink, { id: 'own' })).toBeUndefined(); + + // A parent addresses its descendants' sinks; a child does not see its siblings' or its parent's. + expect(root.get('alice')).toBe(aliceA); + expect(root.write('alice', { n: 1 })).toBe(true); + expect(aliceA.records).toEqual([ { n: 1 } ]); + expect(callB.get('alice')).toBeUndefined(); + expect(callB.get('own')).toBeUndefined(); + expect([ root.size, callA.size, callB.size ]).toEqual([ 2, 1, 0 ]); + + // Ending a sink frees its id everywhere. + aliceA.end(); + expect(root.get('alice')).toBeUndefined(); + + const aliceB = callB.create(RecordingSink, { id: 'alice' })!; + + root.clear(); + expect([ aliceB.endCalls, own.endCalls ]).toEqual([ 1, 1 ]); + expect([ root.size, callA.size, callB.size ]).toEqual([ 0, 0, 0 ]); + }); + + it('keeps a failing sink from crashing the process', () => { + const sink = new SinkRegistry().create(RecordingSink, { id: 'a' })!; + + expect(() => sink.emit('error', new Error('disk full'))).not.toThrow(); + }); +}); + +describe('SinkRegistry.add', () => { + it('registers a sink constructed elsewhere under its own id, and refuses a taken id', () => { + const root = new SinkRegistry(); + const call = new SinkRegistry(root); + const first = new InMemorySink({ id: 'mine' }); + const second = new InMemorySink({ id: 'mine' }); + + expect(call.add(first)).toBe(true); + expect(root.get('mine')).toBe(first); + expect(root.add(second)).toBe(false); + expect(second.ended).toBe(false); + + first.end(); + expect(call.get('mine')).toBeUndefined(); + expect(root.add(second)).toBe(true); + root.clear(); + expect(second.ended).toBe(true); + }); +}); + +describe('sinks on the observed objects', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sinks-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a client\'s samples to the sink under its id in the call, and ends it when the client closes', () => { + const sinks: InMemorySink[] = []; + const observer = new Observer({ closeCallIfEmptyForMs: undefined }); + + observer.on('client-added', ({ observedCall, observedClient }) => { + sinks.push(observedCall.sinks.create(InMemorySink, { id: observedClient.clientId })!); + }); + observer.accept(sample('alice')); + + const call = observer.getObservedCall('call-1')!; + const client = call.getObservedClient('alice')!; + + expect(client.sink).toBe(sinks[0]); + expect(sinks[0].samples).toHaveLength(1); + + let closed = 0; + + sinks[0].on('close', () => ++closed); + client.close(); + + expect(sinks[0].ended).toBe(true); + expect(call.sinks.get('alice')).toBeUndefined(); + expect(closed).toBe(1); + + observer.close(); + expect(closed).toBe(1); + }); + + it('ends a router\'s sinks when the router closes, and lets the app end one when its transport closes', async () => { + const observer = new Observer(); + const router = fakeRouter(); + const observedRouter = observer.createObservedMediasoupRouter({ router })!; + const pipe1 = fakePipeTransport('pipe-1'); + const pipe2 = fakePipeTransport('pipe-2'); + + observedRouter.on('transport-sample-closed', ({ transport }) => observedRouter.sinks.get(transport.id)?.end()); + router.observer.emit('newtransport', pipe1); + router.observer.emit('newtransport', pipe2); + + const file1 = path.join(dir, 'pipe-1.jsonl'); + const sink1 = observedRouter.sinks.create(JsonlFileSink<{ bytesSent: number }[]>, { id: 'pipe-1', path: file1 })!; + const sink2 = observedRouter.sinks.create(RecordingSink, { id: 'pipe-2' })!; + + observedRouter.sinks.write('pipe-1', [ { bytesSent: 1 } ]); + sink1.write([ { bytesSent: 2 } ]); + + const flushed = new Promise((resolve) => sink1.once('close', () => resolve())); + + pipe1.observer.emit('close'); + await flushed; + + expect(observedRouter.sinks.get('pipe-1')).toBeUndefined(); + expect(fs.readFileSync(file1, 'utf8').trim().split('\n').map((line) => JSON.parse(line))).toEqual([ [ { bytesSent: 1 } ], [ { bytesSent: 2 } ] ]); + expect(sink2.ended).toBe(false); + + // mediasoup closes the router's transports first, then the router. + router.observer.emit('close'); + expect(sink2.endCalls).toBe(1); + observer.close(); + }); + + it('ends the call\'s own sinks when the call closes, and every remaining sink when the observer closes', () => { + const observer = new Observer({ closeCallIfEmptyForMs: undefined }); + const call = observer.createObservedCall({ callId: 'call-1' })!; + const observedRouter = observer.createObservedMediasoupRouter({ router: fakeRouter() })!; + const callSink = call.sinks.create(RecordingSink, { id: 'sfu' })!; + const routerSink = observedRouter.sinks.create(RecordingSink, { id: 'router-stats' })!; + const observerSink = observer.sinks.create(RecordingSink, { id: 'worker' })!; + + call.close(); + expect([ callSink.ended, routerSink.ended, observerSink.ended ]).toEqual([ true, false, false ]); + + // observer.close() does not close mediasoup routers, but it does end their sinks. + observer.close(); + expect([ callSink.endCalls, routerSink.endCalls, observerSink.endCalls ]).toEqual([ 1, 1, 1 ]); + expect(observedRouter.closed).toBe(false); + }); + + it('takes custom ClientSampleSink implementations', () => { + class HttpLikeSink extends EventEmitter implements ClientSampleSink { + public readonly id: string; + public readonly received: ClientSample[] = []; + public readonly url: string; + public ended = false; + + public constructor(settings: SinkSettings & { url: string }) { + super(); + this.id = settings.id; + this.url = settings.url; + } + + public write(s: ClientSample): boolean { + this.received.push(s); + + return true; + } + + public end(): void { + if (this.ended) return; + this.ended = true; + this.emit('ended'); + this.emit('close'); + } + } + let custom: HttpLikeSink | undefined; + const observer = new Observer(); + + observer.on('client-added', ({ observedCall, observedClient }) => { + custom = observedCall.sinks.create(HttpLikeSink, { id: observedClient.clientId, url: `https://x/${observedClient.clientId}` }); + }); + observer.accept(sample('bob')); + observer.close(); + + expect(custom?.url).toBe('https://x/bob'); + expect(custom?.received).toHaveLength(1); + expect(custom?.ended).toBe(true); + }); +}); diff --git a/tests/sinks.spec.ts b/tests/sinks.spec.ts index 9340573..ce45fdf 100644 --- a/tests/sinks.spec.ts +++ b/tests/sinks.spec.ts @@ -2,8 +2,8 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { InMemorySink, createInMemorySink } from '../src/sinks/InMemorySink'; -import { JsonlFileSink, createJsonlFileSink, createJsonlFileSinkFactory } from '../src/sinks/JsonlFileSink'; -import type { ObservedCall } from '../src/ObservedCall'; +import { JsonlFileSink, createJsonlFileSink } from '../src/sinks/JsonlFileSink'; +import { Observer } from '../src/Observer'; import { makeSample } from './helpers/samples'; describe('InMemorySink', () => { @@ -72,12 +72,94 @@ describe('JsonlFileSink', () => { expect(fs.readFileSync(filePath, 'utf8').trim()).toBe('id=zed'); }); - it('factory derives the per-client file path from callId/clientId', async () => { - const factory = createJsonlFileSinkFactory({ directory: dir }); - const sink = factory({ clientId: 'cl', observedCall: { callId: 'cid' } as ObservedCall }) as JsonlFileSink; + it('saveClientSamples.jsonlPath: each client writes its samples to the JSONL file at the returned path, announced by client-sink-created', async () => { + const observer = new Observer({ + saveClientSamples: { + jsonlPath: ({ observedCall, clientId }) => (clientId === 'skip' ? undefined : path.join(dir, `${observedCall.callId}__${clientId}.jsonl`)), + }, + }); + const created: JsonlFileSink[] = []; - expect(sink.path).toBe(path.join(dir, 'cid__cl.jsonl')); - await flush(sink); + observer.on('client-sink-created', ({ sink }) => created.push(sink as JsonlFileSink)); + observer.accept(makeSample({ callId: 'cid', clientId: 'cl' })); + observer.accept(makeSample({ callId: 'cid', clientId: 'skip' })); + + const call = observer.getObservedCall('cid')!; + + expect(created.map((s) => s.path)).toEqual([ path.join(dir, 'cid__cl.jsonl') ]); + expect(call.sinks.get('cl')).toBe(created[0]); + expect(call.getObservedClient('skip')?.sink).toBeUndefined(); + + const flushed = flush(created[0]); + + observer.close(); + await flushed; + + const lines = fs.readFileSync(path.join(dir, 'cid__cl.jsonl'), 'utf8').trim().split('\n'); + + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]).clientId).toBe('cl'); + }); + + it('saveClientSamples.sink: uses the returned sink when its id is the given id (the client id), ends it otherwise', () => { + const sinks: InMemorySink[] = []; + const ids: string[] = []; + const observer = new Observer({ + saveClientSamples: { sink: ({ id, clientId }) => { + ids.push(id); + const sink = new InMemorySink({ id: clientId === 'wrong' ? 'not-the-client-id' : id }); + + sinks.push(sink); + + return sink; + } }, + }); + + observer.accept(makeSample({ callId: 'c', clientId: 'ok' })); + observer.accept(makeSample({ callId: 'c', clientId: 'wrong' })); + + const call = observer.getObservedCall('c')!; + + expect(call.getObservedClient('ok')?.sink).toBe(sinks[0]); + expect(call.sinks.get('ok')).toBe(sinks[0]); + expect(ids).toEqual([ 'ok', 'wrong' ]); + expect(sinks[0].samples).toHaveLength(1); + expect(call.getObservedClient('wrong')?.sink).toBeUndefined(); + expect(sinks[1].ended).toBe(true); + observer.close(); + }); + + it('saveClientSamples with both jsonlPath and sink (from JavaScript) saves nothing', () => { + let called = 0; + const config = { + jsonlPath: () => { ++called; return path.join(dir, 'both.jsonl'); }, + sink: ({ id }: { id: string }) => { ++called; return new InMemorySink({ id }); }, + }; + // Only reachable without the type check: the config type makes both a compile error. + const observer = new Observer({ saveClientSamples: config as unknown as { jsonlPath: () => string } }); + + observer.accept(makeSample({ callId: 'c', clientId: 'x' })); + + expect(observer.getObservedCall('c')?.getObservedClient('x')?.sink).toBeUndefined(); + expect(called).toBe(0); + expect(fs.existsSync(path.join(dir, 'both.jsonl'))).toBe(false); + observer.close(); + }); + + it('saveClientSamples is not called for a client whose id is already a sink id in another call', () => { + const observer = new Observer({ + saveClientSamples: { + jsonlPath: ({ observedCall, clientId }) => path.join(dir, `${observedCall.callId}__${clientId}.jsonl`), + }, + }); + + observer.accept(makeSample({ callId: 'c1', clientId: 'dup' })); + observer.accept(makeSample({ callId: 'c2', clientId: 'dup' })); + + expect(observer.getObservedCall('c1')?.getObservedClient('dup')?.sink).toBeDefined(); + expect(observer.getObservedCall('c2')?.getObservedClient('dup')?.sink).toBeUndefined(); + expect(fs.existsSync(path.join(dir, 'c2__dup.jsonl'))).toBe(false); + observer.close(); }); it('createJsonlFileSink builds a JsonlFileSink', async () => {