@observertc/observer-js 1.0.0-beta.10 → 1.0.0-beta.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -55
- package/dist/index.d.mts +24 -8
- package/dist/index.d.ts +24 -8
- package/dist/index.js +145 -162
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +145 -162
- package/dist/index.mjs.map +1 -1
- package/llms-full.txt +153 -57
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -63,9 +63,10 @@ and emits a single, unified stream of typed events the application can react to.
|
|
|
63
63
|
11. [Remote track resolution (mediasoup / SFU)](#remote-track-resolution-mediasoup--sfu)
|
|
64
64
|
12. [Mediasoup router observation](#mediasoup-router-observation)
|
|
65
65
|
13. [Sinks (per-client sample persistence)](#sinks-per-client-sample-persistence)
|
|
66
|
-
14. [
|
|
67
|
-
15. [
|
|
68
|
-
16. [
|
|
66
|
+
14. [Injecting data into a client](#injecting-data-into-a-client)
|
|
67
|
+
15. [Logging](#logging)
|
|
68
|
+
16. [Error-handling philosophy](#error-handling-philosophy)
|
|
69
|
+
17. [Development & extension guide](#development--extension-guide)
|
|
69
70
|
|
|
70
71
|
---
|
|
71
72
|
|
|
@@ -244,8 +245,7 @@ observer.addAcceptMiddleware(route, filter);
|
|
|
244
245
|
// observer.removeAcceptMiddleware(route);
|
|
245
246
|
```
|
|
246
247
|
|
|
247
|
-
This is a lightweight global injection point
|
|
248
|
-
`ClientSampleProcessor` pipeline in the roadmap. When no middleware is registered, `accept()`
|
|
248
|
+
This is a lightweight global injection point. When no middleware is registered, `accept()`
|
|
249
249
|
dispatches directly with no overhead.
|
|
250
250
|
|
|
251
251
|
### `context` (the `AcceptContext`)
|
|
@@ -369,7 +369,7 @@ additional field(s) on top of that scope.
|
|
|
369
369
|
| Event | Extra | Fires when |
|
|
370
370
|
|-------|-------|-----------|
|
|
371
371
|
| `mediasoup-router-added` | — | `observer.createObservedMediasoupRouter(...)` registered a router |
|
|
372
|
-
| `mediasoup-router-matched-with-peer-connection` | `{ observedCall, observedClient, observedPeerConnection }` | a newly added peer connection's id matched one of the router's WebRTC transport ids. |
|
|
372
|
+
| `mediasoup-router-matched-with-peer-connection` | `{ observedCall, observedClient, observedPeerConnection }` | a newly added peer connection's id matched one of the router's WebRTC transport ids. **Opt-in** via `matchPeerConnectionByWebRtcTransportId: true`. |
|
|
373
373
|
| `mediasoup-router-removed` | — | the underlying mediasoup router closed (its `router.observer` `close` fired) |
|
|
374
374
|
|
|
375
375
|
See [Mediasoup router observation](#mediasoup-router-observation) for the full design and examples.
|
|
@@ -408,7 +408,6 @@ See [Mediasoup router observation](#mediasoup-router-observation) for the full d
|
|
|
408
408
|
| `peer-connection-added` / `peer-connection-closed` | — | lifecycle of the PC |
|
|
409
409
|
| `peer-connection-updated` | `{ context?: AcceptContext }` | the PC processed a sample |
|
|
410
410
|
| `ice-connection-state-changed` / `ice-gathering-state-changed` / `connection-state-changed` | `{ state: string }` | driven by client events |
|
|
411
|
-
| `selected-candidate-pair-changed` | — | *declared; not currently emitted* |
|
|
412
411
|
| `inbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedInboundTrack }` | |
|
|
413
412
|
| `outbound-track-added` / `-updated` / `-removed` / `-muted` / `-unmuted` | `{ observedOutboundTrack }` | |
|
|
414
413
|
| `inbound-rtp-added` / `-updated` / `-removed` | `{ observedInboundRtp }` | `-updated` fires every tick |
|
|
@@ -461,8 +460,8 @@ type ObserverConfig<AppData = Record<string, unknown>> = {
|
|
|
461
460
|
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
|
|
462
461
|
// sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
|
|
463
462
|
createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
|
|
464
|
-
// track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
|
|
465
|
-
|
|
463
|
+
// remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
|
|
464
|
+
createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
|
|
466
465
|
};
|
|
467
466
|
```
|
|
468
467
|
|
|
@@ -516,7 +515,7 @@ Key members:
|
|
|
516
515
|
- `addIssue(issue: ClientIssue): void` — raise a **call-level** issue → emits `call-issue`
|
|
517
516
|
- `readonly detectors: Detectors` — server-side detector registry (empty by default; see [Detectors](#detectors-server-side-extension-point))
|
|
518
517
|
- `scoreCalculator: ScoreCalculator`, `get score()`, `readonly calculatedScore`
|
|
519
|
-
- `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.
|
|
518
|
+
- `remoteTrackResolver?: RemoteTrackResolver` — set from `ObserverConfig.createRemoteTrackResolver` at call creation (see [Remote track resolution](#remote-track-resolution-mediasoup--sfu))
|
|
520
519
|
- aggregates: `numberOfIssues`, `numberOfPeerConnections`, `numberOfInboundRtpStreams`,
|
|
521
520
|
`numberOfOutboundRtpStreams`, `numberOfDataChannels`, `maxNumberOfClients`,
|
|
522
521
|
`clientsUsedTurn: Set<string>`, `startedAt?`, `endedAt?`, `closedAt?`, `closed`
|
|
@@ -539,7 +538,7 @@ Key members:
|
|
|
539
538
|
- `readonly sink?: ClientSampleSink` — the per-client sink (see [Sinks](#sinks-per-client-sample-persistence)), if `createClientSink` is configured; listen on it for `close`/`error`
|
|
540
539
|
- **Injection API** (queue app data to be merged into the next sample processing):
|
|
541
540
|
`injectEvent(ClientEvent)`, `injectIssue(ClientIssue)`, `injectMetaData(ClientMetaData)`,
|
|
542
|
-
`injectExtensionStat(ExtensionStat)`, `injectAttachment(
|
|
541
|
+
`injectExtensionStat(ExtensionStat)`, `injectAttachment(attachments: Record<string, unknown>)`
|
|
543
542
|
- **Direct add API** (process immediately): `addIssue(ClientIssue)`, `addMetadata(ClientMetaData)`,
|
|
544
543
|
`addExtensionStats(ExtensionStat)`
|
|
545
544
|
- Metrics (current/derived): `currentAvgRttInMs?`, `currentMinRttInMs?`, `currentMaxRttInMs?`,
|
|
@@ -751,7 +750,7 @@ class Detectors {
|
|
|
751
750
|
|
|
752
751
|
In an SFU, one participant's **outbound** track is delivered to other participants as **inbound**
|
|
753
752
|
tracks (one **publisher** → many **subscribers**). Correlation is **opt-in** per observer: set
|
|
754
|
-
`ObserverConfig.
|
|
753
|
+
`ObserverConfig.createRemoteTrackResolver`, a factory invoked when each call is created that returns the
|
|
755
754
|
call's `RemoteTrackResolver` (or `undefined` for none).
|
|
756
755
|
|
|
757
756
|
`RemoteTrackResolver` is a generic, strategy-driven class. It subscribes to the bus (filtered to
|
|
@@ -762,7 +761,7 @@ the tracks: `inboundTrack.remoteOutboundTrack` and `outboundTrack.remoteInboundT
|
|
|
762
761
|
import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
|
|
763
762
|
|
|
764
763
|
const observer = new Observer({
|
|
765
|
-
|
|
764
|
+
createRemoteTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
|
|
766
765
|
});
|
|
767
766
|
|
|
768
767
|
// later, given tracks (links are kept up to date as tracks come and go):
|
|
@@ -781,7 +780,7 @@ id is just whatever links a subscribed track to the published one:
|
|
|
781
780
|
import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
|
|
782
781
|
|
|
783
782
|
const observer = new Observer({
|
|
784
|
-
|
|
783
|
+
createRemoteTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
|
|
785
784
|
resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
|
|
786
785
|
resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
|
|
787
786
|
resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
|
|
@@ -805,19 +804,37 @@ transitions. `ObservedMediasoupRouter` captures that server-side view into a
|
|
|
805
804
|
### The concept
|
|
806
805
|
|
|
807
806
|
You hand the observer a live mediasoup `Router`; it attaches to mediasoup's own `observer` API and,
|
|
808
|
-
from then on, **passively
|
|
807
|
+
from then on, **passively tracks** the router's topology and lifecycle — with no polling and no
|
|
809
808
|
changes to your media code:
|
|
810
809
|
|
|
811
|
-
- new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`,
|
|
812
|
-
transitions
|
|
810
|
+
- new transports (`webrtc` / `plain` / `pipe` / `direct`), their selected `tuple`, ICE/DTLS/SCTP
|
|
811
|
+
state transitions and `connectedAt`;
|
|
813
812
|
- producers (codec, SSRCs/RIDs, `pause`/`resume`) and consumers (`pause`/`resume`,
|
|
814
813
|
`producerPaused`/`producerResumed`);
|
|
815
814
|
- data producers and data consumers;
|
|
816
815
|
- `createdAt` / `closedAt` for every entity above.
|
|
817
816
|
|
|
818
|
-
|
|
819
|
-
[`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts)
|
|
820
|
-
|
|
817
|
+
It keeps all of this **in memory**, in a single `MediasoupRouterSample` exposed as
|
|
818
|
+
`observedRouter.sample` — see [`src/schema/MediasoupRouter.ts`](./src/schema/MediasoupRouter.ts). The
|
|
819
|
+
sample **accumulates for the life of the router**: closed transports/producers/consumers are kept
|
|
820
|
+
(with their `closedAt` set), not removed. Read it whenever you like — it's a plain object you own.
|
|
821
|
+
|
|
822
|
+
### Memory & large meetings
|
|
823
|
+
|
|
824
|
+
This is intentionally the **simplest** approach — everything lives in memory and nothing is sampled
|
|
825
|
+
or evicted for you. That's fine for typical rooms, but be aware of the cost at scale:
|
|
826
|
+
|
|
827
|
+
- **Consumers grow as O(N²)** on a single flat router: with `N` participants each producing audio +
|
|
828
|
+
video and consuming everyone else, the sample holds roughly `2·N·(N−1)` consumer records (≈ 19,800
|
|
829
|
+
for `N` = 100).
|
|
830
|
+
- The sample is **cumulative** — closed entities and their `history` are retained — so it also grows
|
|
831
|
+
with call duration and churn (renegotiation, simulcast layer changes, rejoins).
|
|
832
|
+
|
|
833
|
+
A 100-participant flat router can therefore reach tens of MB and keep growing. There is **no built-in
|
|
834
|
+
sink, snapshotting, or eviction** — by design. **If you run large meetings, do your own sampling:**
|
|
835
|
+
on your own cadence read `observedRouter.sample` (snapshot/serialize/persist what you need), drop what
|
|
836
|
+
you don't, and close routers you no longer track. (mediasoup also typically shards routers across
|
|
837
|
+
workers/cores, which keeps any one router small.)
|
|
821
838
|
|
|
822
839
|
### Matching peer connections — by **event**, not by storage
|
|
823
840
|
|
|
@@ -832,37 +849,62 @@ ancestry, so you get the router **and** the matched `observedPeerConnection`, `o
|
|
|
832
849
|
`observedCall` in one place. Stamp the `routerId` into the peer connection's / client's `appData`,
|
|
833
850
|
build your own index, attach the server sample to the call in your database — whatever fits.
|
|
834
851
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
852
|
+
This matching is **opt-in**: pass `matchPeerConnectionByWebRtcTransportId: true` to
|
|
853
|
+
`createObservedMediasoupRouter`. When enabled, as peer connections are observed
|
|
854
|
+
(`peer-connection-added`) the observer checks whether the peer connection's id is one of the router's
|
|
855
|
+
WebRTC transport ids; on a hit it emits — once per matching peer connection — and keeps watching, so a
|
|
856
|
+
router serving many participants emits one match per participant's transport. When the flag is omitted
|
|
857
|
+
or `false`, no matching is performed and the event never fires. The internal listener is removed
|
|
858
|
+
automatically when the router closes or the observer closes.
|
|
859
|
+
|
|
860
|
+
### Ordering contract — observe the router first
|
|
861
|
+
|
|
862
|
+
Matching is **forward-only by design**, and that is sufficient because the lifecycle ordering is
|
|
863
|
+
**guaranteed, not racy**:
|
|
864
|
+
|
|
865
|
+
- `ObservedMediasoupRouter` works purely by **subscribing to mediasoup's `observer` API**, so it can
|
|
866
|
+
only see events that happen *after* it is created. You therefore create it the moment the router
|
|
867
|
+
exists — **before** any transport is added to it — and it captures the rest going forward.
|
|
868
|
+
- A mediasoup transport is always created **on the server first**; only then can the client connect
|
|
869
|
+
to it, produce/consume, and begin shipping `ClientSample`s. So a peer connection — and the
|
|
870
|
+
`peer-connection-added` event it triggers — can never appear before its server-side WebRTC
|
|
871
|
+
transport already exists (and has been observed by the router).
|
|
872
|
+
|
|
873
|
+
Put together: by the time a `peer-connection-added` fires, the router has already recorded that
|
|
874
|
+
transport's id in `webrtcTransportIds`, so a single forward-looking listener catches every match. No
|
|
875
|
+
back-scan of existing peer connections and no re-check on transport creation are needed — the
|
|
876
|
+
observer deliberately does **not** look backwards.
|
|
877
|
+
|
|
878
|
+
**Your responsibility:** call `createObservedMediasoupRouter(...)` as early as the router exists
|
|
879
|
+
(before transports are added or samples are accepted). If you register the router *after* its
|
|
880
|
+
transports are created or after the client's first sample, those events are already in the past and
|
|
881
|
+
the corresponding matches are missed.
|
|
840
882
|
|
|
841
883
|
When the underlying mediasoup router closes, its `close` propagates to `ObservedMediasoupRouter`,
|
|
842
|
-
which emits **`mediasoup-router-removed
|
|
843
|
-
|
|
884
|
+
which sets the sample's `closedAt` and emits **`mediasoup-router-removed`** — your cue to read /
|
|
885
|
+
persist the final `observedRouter.sample` and drop your reference to it.
|
|
844
886
|
|
|
845
887
|
### Options — `observer.createObservedMediasoupRouter(settings)`
|
|
846
888
|
|
|
847
889
|
| Field | Type | Required | Meaning |
|
|
848
890
|
|-------|------|----------|---------|
|
|
849
|
-
| `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer` |
|
|
850
|
-
| `routerId` | `string` | yes | your id for the router (the sample also carries `router.id`) |
|
|
891
|
+
| `router` | `mediasoup.types.Router` | yes | the live router to observe; the observer attaches to `router.observer`. `.id` and the sample's `routerId` come from `router.id` |
|
|
851
892
|
| `appData` | `Record<string, unknown>` | no | application-owned bag on the `ObservedMediasoupRouter` |
|
|
852
|
-
| `attachments` | `Record<string, unknown>` | no | free-form data
|
|
893
|
+
| `attachments` | `Record<string, unknown>` | no | free-form data; carried on `sample.attachments` |
|
|
894
|
+
| `matchPeerConnectionByWebRtcTransportId` | `boolean` | no | opt in to peer-connection matching: emit `mediasoup-router-matched-with-peer-connection` for each peer connection whose id matches one of the router's WebRTC transport ids. Omitted / `false` → no matching, the event never fires |
|
|
853
895
|
|
|
854
|
-
Peer-connection matching is
|
|
855
|
-
`ObservedMediasoupRouter`, or `undefined`
|
|
856
|
-
returns the existing instance — both warn).
|
|
896
|
+
Peer-connection matching is **off by default**; enable it with
|
|
897
|
+
`matchPeerConnectionByWebRtcTransportId: true`. Returns the `ObservedMediasoupRouter`, or `undefined`
|
|
898
|
+
if the observer is closed (a router with the same id returns the existing instance — both warn).
|
|
857
899
|
|
|
858
|
-
Useful members on the returned object: `.sample` (the `MediasoupRouterSample
|
|
859
|
-
|
|
860
|
-
`.close()`.
|
|
900
|
+
Useful members on the returned object: `.sample` (the in-memory `MediasoupRouterSample`, with
|
|
901
|
+
`createdAt` / `closedAt?` on it), `.appData`, `.attachments`, `.webrtcTransportIds: Set<string>`,
|
|
902
|
+
`.id`, `.close()`.
|
|
861
903
|
|
|
862
904
|
### Example
|
|
863
905
|
|
|
864
906
|
```ts
|
|
865
|
-
import { Observer } from '@observertc/observer-js';
|
|
907
|
+
import { Observer, InMemorySink } from '@observertc/observer-js';
|
|
866
908
|
import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from '@observertc/observer-js';
|
|
867
909
|
|
|
868
910
|
const observer = new Observer();
|
|
@@ -870,42 +912,42 @@ const observer = new Observer();
|
|
|
870
912
|
// 1) Feed client samples as usual so the observer knows about calls, clients & peer connections.
|
|
871
913
|
// (e.g. transport-layer: observer.accept(clientSample, context))
|
|
872
914
|
|
|
873
|
-
// 2) Observe the SFU side
|
|
915
|
+
// 2) Observe the SFU side; opt in to peer-connection matching. State accumulates in `.sample`.
|
|
874
916
|
const router = /* your mediasoup router */ undefined as any;
|
|
875
|
-
const observedRouter = observer.createObservedMediasoupRouter({
|
|
917
|
+
const observedRouter = observer.createObservedMediasoupRouter({
|
|
918
|
+
router,
|
|
919
|
+
matchPeerConnectionByWebRtcTransportId: true,
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// For large meetings, sample it yourself on your own cadence (see "Memory & large meetings"):
|
|
923
|
+
// setInterval(() => persist(observedRouter.sample), 10_000);
|
|
876
924
|
|
|
877
925
|
// 3) Every peer connection whose id matches one of the router's WebRTC transport ids fires this —
|
|
878
926
|
// WE decide what to do with each pairing. The payload carries the full ancestry.
|
|
879
927
|
observer.on('mediasoup-router-matched-with-peer-connection',
|
|
880
|
-
({ observedMediasoupRouter, observedCall,
|
|
928
|
+
({ observedMediasoupRouter, observedCall, observedPeerConnection }:
|
|
881
929
|
ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
|
|
882
|
-
// e.g. remember which router serves this peer connection / client…
|
|
883
930
|
(observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
|
|
884
|
-
|
|
885
|
-
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.sample);
|
|
931
|
+
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.id);
|
|
886
932
|
},
|
|
887
933
|
);
|
|
888
934
|
|
|
889
|
-
// 4) The router closed —
|
|
935
|
+
// 4) The router closed — read/persist the final state, then drop your reference.
|
|
890
936
|
observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
|
|
891
|
-
|
|
892
|
-
});
|
|
893
|
-
|
|
894
|
-
// (optional) react to the router being registered at all:
|
|
895
|
-
observer.on('mediasoup-router-added', ({ observedMediasoupRouter }) => {
|
|
896
|
-
console.log('observing router', observedMediasoupRouter.id);
|
|
937
|
+
persist(observedMediasoupRouter.sample); // its `closedAt` is set
|
|
897
938
|
});
|
|
898
939
|
```
|
|
899
940
|
|
|
900
|
-
### Why event-driven instead of storing on the call
|
|
941
|
+
### Why event-driven matching instead of storing on the call
|
|
901
942
|
|
|
902
943
|
- **Loose coupling.** The call model stays about client telemetry; the SFU view lives on its own
|
|
903
|
-
|
|
944
|
+
`ObservedMediasoupRouter` and is associated only if and how *you* choose.
|
|
904
945
|
- **You own the association.** One router serves many peer connections (across clients and calls),
|
|
905
946
|
and the right place to keep that mapping is application-specific — so the observer hands you each
|
|
906
|
-
peer-connection match and
|
|
907
|
-
- **
|
|
908
|
-
|
|
947
|
+
peer-connection match and gets out of the way.
|
|
948
|
+
- **You own the sampling.** The router sample is plain in-memory state you read on your own terms;
|
|
949
|
+
for large meetings, sample/persist it yourself (see [Memory & large meetings](#memory--large-meetings))
|
|
950
|
+
rather than relying on the library to evict — it deliberately doesn't.
|
|
909
951
|
|
|
910
952
|
---
|
|
911
953
|
|
|
@@ -1031,6 +1073,60 @@ const observer = new Observer({ createClientSink });
|
|
|
1031
1073
|
the bus with full ancestry. `ClientSampleSinkFactory` is
|
|
1032
1074
|
`(p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined`.
|
|
1033
1075
|
|
|
1076
|
+
---
|
|
1077
|
+
|
|
1078
|
+
## Injecting data into a client
|
|
1079
|
+
|
|
1080
|
+
Sometimes the application holds data that belongs on a client's record but isn't part of the
|
|
1081
|
+
client-reported `ClientSample` — a room id or display name, an application-level event
|
|
1082
|
+
(*"recording started"*), a server-detected issue, an extension stat, or a device/meta item.
|
|
1083
|
+
`ObservedClient` exposes **injection** methods that merge such data into the client's sample stream,
|
|
1084
|
+
so it updates the live model **and** is persisted to the client's
|
|
1085
|
+
[sink](#sinks-per-client-sample-persistence) exactly like sampled data.
|
|
1086
|
+
|
|
1087
|
+
| Method | Adds to the sample's | Surfaces as |
|
|
1088
|
+
|--------|----------------------|-------------|
|
|
1089
|
+
| `injectAttachment(attachments)` | `attachments` (merged via `Object.assign`) | `observedClient.attachments` |
|
|
1090
|
+
| `injectEvent(event: ClientEvent)` | `clientEvents` | `client-event` (plus any state the event drives) |
|
|
1091
|
+
| `injectIssue(issue: ClientIssue)` | `clientIssues` | `client-issue` |
|
|
1092
|
+
| `injectMetaData(meta: ClientMetaData)` | `clientMetaItems` | `client-metadata` |
|
|
1093
|
+
| `injectExtensionStat(stat: ExtensionStat)` | `extensionStats` | `client-extension-stats` |
|
|
1094
|
+
|
|
1095
|
+
### When the injected data lands
|
|
1096
|
+
|
|
1097
|
+
Injection is timing-aware so nothing is dropped, regardless of *when* you call it:
|
|
1098
|
+
|
|
1099
|
+
- **During a sample's processing** — e.g. from inside a `client-updated` / `client-event` handler,
|
|
1100
|
+
which run within `accept()` — the data is applied to the **current** sample immediately: reflected
|
|
1101
|
+
in entity state and written to the sink as part of that sample.
|
|
1102
|
+
- **Between samples** — the data is buffered and merged into the **next** `accept()`'s sample.
|
|
1103
|
+
- **On `close()` with pending injections and no further sample** — the buffer is flushed as a final
|
|
1104
|
+
synthetic sample (applied to state and written to the sink) before the sink is ended, so a
|
|
1105
|
+
last-moment injection is never lost.
|
|
1106
|
+
|
|
1107
|
+
In every case the injected data both updates the live `ObservedClient` and reaches the per-client
|
|
1108
|
+
sink — the sink always receives the final, **injection-merged** sample (the sink write happens at the
|
|
1109
|
+
end of `accept()`, after the merge).
|
|
1110
|
+
|
|
1111
|
+
### Example
|
|
1112
|
+
|
|
1113
|
+
```ts
|
|
1114
|
+
// Enrich at creation from your app's knowledge of the participant. Injecting in `client-added`
|
|
1115
|
+
// (which runs just before the first accept) lands on the first sample.
|
|
1116
|
+
observer.on('client-added', ({ observedClient }) => {
|
|
1117
|
+
observedClient.injectAttachment({ roomId: lookupRoomId(observedClient.clientId) });
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Application-level signals at any time:
|
|
1121
|
+
const client = observer.getObservedCall(callId)?.getObservedClient(clientId);
|
|
1122
|
+
client?.injectEvent({ type: 'RECORDING_STARTED', timestamp: Date.now() });
|
|
1123
|
+
client?.injectIssue({ type: 'app-kicked-participant', timestamp: Date.now() });
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
`attachments` are latest-wins (like sampled `attachments`): injecting a key overwrites its previous
|
|
1127
|
+
value. `appData` is unaffected — injections flow into the sample/telemetry, not the app-owned
|
|
1128
|
+
`appData` bag (see [Ingestion](#ingestion-accept-context--lifecycle)).
|
|
1129
|
+
|
|
1034
1130
|
## Logging
|
|
1035
1131
|
|
|
1036
1132
|
`observer-js` logs through a single, swappable sink. Out of the box it writes `debug` and
|
package/dist/index.d.mts
CHANGED
|
@@ -2232,15 +2232,28 @@ declare interface ObservedMediasoupRouter {
|
|
|
2232
2232
|
once<U extends keyof ObservedMediasoupRouterEvents>(event: U, listener: (...args: ObservedMediasoupRouterEvents[U]) => void): this;
|
|
2233
2233
|
emit<U extends keyof ObservedMediasoupRouterEvents>(event: U, ...args: ObservedMediasoupRouterEvents[U]): boolean;
|
|
2234
2234
|
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Observes a live mediasoup `Router` by subscribing to its `observer` API and **accumulates** its
|
|
2237
|
+
* topology and lifecycle into an in-memory `MediasoupRouterSample` (`observedRouter.sample`):
|
|
2238
|
+
* transports, producers, consumers, data producers/consumers, their state-change history and
|
|
2239
|
+
* `createdAt` / `closedAt`. The sample grows for the life of the router (closed entities are kept,
|
|
2240
|
+
* with their `closedAt` set) and is yours to read, snapshot, or persist.
|
|
2241
|
+
*
|
|
2242
|
+
* NOTE: this is intentionally the simplest approach — everything is held in memory. For very large
|
|
2243
|
+
* routers (e.g. ~100 participants producing and consuming on one router, where consumers grow as
|
|
2244
|
+
* O(N²)) this can become substantial; in that case do your own periodic sampling/persistence and
|
|
2245
|
+
* discard what you don't need (see the README's "Memory & large meetings" note).
|
|
2246
|
+
*/
|
|
2235
2247
|
declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> = Record<string, unknown>> extends EventEmitter {
|
|
2236
2248
|
readonly router: types.Router;
|
|
2237
|
-
readonly sample: MediasoupRouterSample;
|
|
2238
2249
|
appData: AppData;
|
|
2250
|
+
readonly sample: MediasoupRouterSample;
|
|
2239
2251
|
readonly webrtcTransportIds: Set<string>;
|
|
2240
|
-
get attachments(): Record<string, unknown>;
|
|
2241
2252
|
closed: boolean;
|
|
2242
2253
|
constructor(settings: ObservedMediasoupRouterSettings<AppData>);
|
|
2243
2254
|
get id(): string;
|
|
2255
|
+
get attachments(): Record<string, unknown>;
|
|
2256
|
+
close(): void;
|
|
2244
2257
|
addTransport: (transport: types.Transport) => void;
|
|
2245
2258
|
addWebRtcTransport(transport: types.WebRtcTransport): void;
|
|
2246
2259
|
addPlainTransport(transport: types.PlainTransport): void;
|
|
@@ -2250,7 +2263,6 @@ declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> =
|
|
|
2250
2263
|
addConsumer(transport: types.Transport, consumer: types.Consumer): void;
|
|
2251
2264
|
addDataProducer(transport: types.Transport, dataProducer: types.DataProducer): void;
|
|
2252
2265
|
addDataConsumer(transport: types.Transport, dataConsumer: types.DataConsumer): void;
|
|
2253
|
-
close(): void;
|
|
2254
2266
|
private attachRouterListeners;
|
|
2255
2267
|
private _attachTransportObserverListeners;
|
|
2256
2268
|
}
|
|
@@ -2570,7 +2582,8 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
|
|
|
2570
2582
|
numberOfScoreMeasurements: number;
|
|
2571
2583
|
readonly mediaDevices: MediaDeviceInfo[];
|
|
2572
2584
|
issues: ClientIssue[];
|
|
2573
|
-
private
|
|
2585
|
+
private _pendingInjections;
|
|
2586
|
+
private _activeSample?;
|
|
2574
2587
|
private closeTimer?;
|
|
2575
2588
|
constructor(settings: ObservedClientSettings<AppData>, call: ObservedCall);
|
|
2576
2589
|
get numberOfPeerConnections(): number;
|
|
@@ -2581,13 +2594,14 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
|
|
|
2581
2594
|
injectEvent(event: ClientEvent): void;
|
|
2582
2595
|
injectIssue(issue: ClientIssue): void;
|
|
2583
2596
|
injectExtensionStat(stat: ExtensionStat): void;
|
|
2584
|
-
injectAttachment(
|
|
2597
|
+
injectAttachment(attachments: Record<string, unknown>): void;
|
|
2585
2598
|
addMetadata(metadata: ClientMetaData): void;
|
|
2586
2599
|
addIssue(issue: ClientIssue): void;
|
|
2587
2600
|
addExtensionStats(stats: ExtensionStat): void;
|
|
2588
2601
|
private _processClientEvent;
|
|
2589
2602
|
private _updatePeerConnection;
|
|
2590
|
-
private
|
|
2603
|
+
private _mergePendingInjections;
|
|
2604
|
+
private _flushPendingInjections;
|
|
2591
2605
|
/** Emit an Observer-bus event scoped to this client (or a peer connection under it). */
|
|
2592
2606
|
private _notify;
|
|
2593
2607
|
}
|
|
@@ -2803,7 +2817,7 @@ type ObserverConfig<AppData extends Record<string, unknown> = Record<string, unk
|
|
|
2803
2817
|
* (`createDefaultMediasoupRemoteTrackResolverFactory()` / `createP2pRemoteTrackResolverFactory()`)
|
|
2804
2818
|
* or build a `RemoteTrackResolver` with custom publisher/subscriber id resolvers.
|
|
2805
2819
|
*/
|
|
2806
|
-
|
|
2820
|
+
createRemoteTrackResolver?: RemoteTrackResolverFactory;
|
|
2807
2821
|
};
|
|
2808
2822
|
declare interface Observer {
|
|
2809
2823
|
on<U extends keyof ObserverEvents>(event: U, listener: (...args: ObserverEvents[U]) => void): this;
|
|
@@ -2836,7 +2850,9 @@ declare class Observer<AppData extends Record<string, unknown> = Record<string,
|
|
|
2836
2850
|
getObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(callId: string): ObservedCall<T> | undefined;
|
|
2837
2851
|
createObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedCallSettings<T>): ObservedCall<T> | undefined;
|
|
2838
2852
|
getOrCreateObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedCallSettings<T>): ObservedCall<T> | undefined;
|
|
2839
|
-
createObservedMediasoupRouter<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedMediasoupRouterSettings<T>
|
|
2853
|
+
createObservedMediasoupRouter<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedMediasoupRouterSettings<T> & {
|
|
2854
|
+
matchPeerConnectionByWebRtcTransportId?: boolean;
|
|
2855
|
+
}): ObservedMediasoupRouter<Record<string, unknown>> | undefined;
|
|
2840
2856
|
close(): void;
|
|
2841
2857
|
accept(sample: ClientSample, context?: AcceptContext): void;
|
|
2842
2858
|
update(): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -2232,15 +2232,28 @@ declare interface ObservedMediasoupRouter {
|
|
|
2232
2232
|
once<U extends keyof ObservedMediasoupRouterEvents>(event: U, listener: (...args: ObservedMediasoupRouterEvents[U]) => void): this;
|
|
2233
2233
|
emit<U extends keyof ObservedMediasoupRouterEvents>(event: U, ...args: ObservedMediasoupRouterEvents[U]): boolean;
|
|
2234
2234
|
}
|
|
2235
|
+
/**
|
|
2236
|
+
* Observes a live mediasoup `Router` by subscribing to its `observer` API and **accumulates** its
|
|
2237
|
+
* topology and lifecycle into an in-memory `MediasoupRouterSample` (`observedRouter.sample`):
|
|
2238
|
+
* transports, producers, consumers, data producers/consumers, their state-change history and
|
|
2239
|
+
* `createdAt` / `closedAt`. The sample grows for the life of the router (closed entities are kept,
|
|
2240
|
+
* with their `closedAt` set) and is yours to read, snapshot, or persist.
|
|
2241
|
+
*
|
|
2242
|
+
* NOTE: this is intentionally the simplest approach — everything is held in memory. For very large
|
|
2243
|
+
* routers (e.g. ~100 participants producing and consuming on one router, where consumers grow as
|
|
2244
|
+
* O(N²)) this can become substantial; in that case do your own periodic sampling/persistence and
|
|
2245
|
+
* discard what you don't need (see the README's "Memory & large meetings" note).
|
|
2246
|
+
*/
|
|
2235
2247
|
declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> = Record<string, unknown>> extends EventEmitter {
|
|
2236
2248
|
readonly router: types.Router;
|
|
2237
|
-
readonly sample: MediasoupRouterSample;
|
|
2238
2249
|
appData: AppData;
|
|
2250
|
+
readonly sample: MediasoupRouterSample;
|
|
2239
2251
|
readonly webrtcTransportIds: Set<string>;
|
|
2240
|
-
get attachments(): Record<string, unknown>;
|
|
2241
2252
|
closed: boolean;
|
|
2242
2253
|
constructor(settings: ObservedMediasoupRouterSettings<AppData>);
|
|
2243
2254
|
get id(): string;
|
|
2255
|
+
get attachments(): Record<string, unknown>;
|
|
2256
|
+
close(): void;
|
|
2244
2257
|
addTransport: (transport: types.Transport) => void;
|
|
2245
2258
|
addWebRtcTransport(transport: types.WebRtcTransport): void;
|
|
2246
2259
|
addPlainTransport(transport: types.PlainTransport): void;
|
|
@@ -2250,7 +2263,6 @@ declare class ObservedMediasoupRouter<AppData extends Record<string, unknown> =
|
|
|
2250
2263
|
addConsumer(transport: types.Transport, consumer: types.Consumer): void;
|
|
2251
2264
|
addDataProducer(transport: types.Transport, dataProducer: types.DataProducer): void;
|
|
2252
2265
|
addDataConsumer(transport: types.Transport, dataConsumer: types.DataConsumer): void;
|
|
2253
|
-
close(): void;
|
|
2254
2266
|
private attachRouterListeners;
|
|
2255
2267
|
private _attachTransportObserverListeners;
|
|
2256
2268
|
}
|
|
@@ -2570,7 +2582,8 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
|
|
|
2570
2582
|
numberOfScoreMeasurements: number;
|
|
2571
2583
|
readonly mediaDevices: MediaDeviceInfo[];
|
|
2572
2584
|
issues: ClientIssue[];
|
|
2573
|
-
private
|
|
2585
|
+
private _pendingInjections;
|
|
2586
|
+
private _activeSample?;
|
|
2574
2587
|
private closeTimer?;
|
|
2575
2588
|
constructor(settings: ObservedClientSettings<AppData>, call: ObservedCall);
|
|
2576
2589
|
get numberOfPeerConnections(): number;
|
|
@@ -2581,13 +2594,14 @@ declare class ObservedClient<AppData extends Record<string, unknown> = Record<st
|
|
|
2581
2594
|
injectEvent(event: ClientEvent): void;
|
|
2582
2595
|
injectIssue(issue: ClientIssue): void;
|
|
2583
2596
|
injectExtensionStat(stat: ExtensionStat): void;
|
|
2584
|
-
injectAttachment(
|
|
2597
|
+
injectAttachment(attachments: Record<string, unknown>): void;
|
|
2585
2598
|
addMetadata(metadata: ClientMetaData): void;
|
|
2586
2599
|
addIssue(issue: ClientIssue): void;
|
|
2587
2600
|
addExtensionStats(stats: ExtensionStat): void;
|
|
2588
2601
|
private _processClientEvent;
|
|
2589
2602
|
private _updatePeerConnection;
|
|
2590
|
-
private
|
|
2603
|
+
private _mergePendingInjections;
|
|
2604
|
+
private _flushPendingInjections;
|
|
2591
2605
|
/** Emit an Observer-bus event scoped to this client (or a peer connection under it). */
|
|
2592
2606
|
private _notify;
|
|
2593
2607
|
}
|
|
@@ -2803,7 +2817,7 @@ type ObserverConfig<AppData extends Record<string, unknown> = Record<string, unk
|
|
|
2803
2817
|
* (`createDefaultMediasoupRemoteTrackResolverFactory()` / `createP2pRemoteTrackResolverFactory()`)
|
|
2804
2818
|
* or build a `RemoteTrackResolver` with custom publisher/subscriber id resolvers.
|
|
2805
2819
|
*/
|
|
2806
|
-
|
|
2820
|
+
createRemoteTrackResolver?: RemoteTrackResolverFactory;
|
|
2807
2821
|
};
|
|
2808
2822
|
declare interface Observer {
|
|
2809
2823
|
on<U extends keyof ObserverEvents>(event: U, listener: (...args: ObserverEvents[U]) => void): this;
|
|
@@ -2836,7 +2850,9 @@ declare class Observer<AppData extends Record<string, unknown> = Record<string,
|
|
|
2836
2850
|
getObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(callId: string): ObservedCall<T> | undefined;
|
|
2837
2851
|
createObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedCallSettings<T>): ObservedCall<T> | undefined;
|
|
2838
2852
|
getOrCreateObservedCall<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedCallSettings<T>): ObservedCall<T> | undefined;
|
|
2839
|
-
createObservedMediasoupRouter<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedMediasoupRouterSettings<T>
|
|
2853
|
+
createObservedMediasoupRouter<T extends Record<string, unknown> = Record<string, unknown>>(settings: ObservedMediasoupRouterSettings<T> & {
|
|
2854
|
+
matchPeerConnectionByWebRtcTransportId?: boolean;
|
|
2855
|
+
}): ObservedMediasoupRouter<Record<string, unknown>> | undefined;
|
|
2840
2856
|
close(): void;
|
|
2841
2857
|
accept(sample: ClientSample, context?: AcceptContext): void;
|
|
2842
2858
|
update(): void;
|