In one line: feed it WebRTC
getStats()snapshots, and get back a live, queryable model of every call plus a single typed event stream to react to.
observer-js is a server-side Node.js library for monitoring WebRTC sessions. A WebRTC
application (typically an SFU or a signaling/stats backend) feeds it ClientSample objects —
periodic snapshots of each participant's RTCPeerConnection.getStats() output plus
application events — and observer-js maintains a live, in-memory model of every call,
participant, peer connection, and media stream, derives per-interval and cumulative metrics,
and emits a single, unified stream of typed events the application can react to.
What you can do with it:
- Monitor calls live — a queryable in-memory tree of every call, client, peer connection, track, codec, ICE candidate and data channel, each holding current and cumulative metrics.
- React on one event bus — subscribe once on the
Observer; every payload carries its full ancestry (call → client → peer connection → stat), so you never walk the tree to subscribe. - Get derived metrics for free — counter-reset-safe per-tick deltas, bitrates, jitter, RTT, fraction-lost, remote-RTP (RTCP) correlation, and TURN/TCP usage from the selected candidate pair.
- Correlate across an SFU — link a publisher's outbound track to every subscriber's inbound
track (
RemoteTrackResolver), and observe mediasoup routers/transports/producers/consumers on the server side. - Detect server-only problems — cross-client
Detectors raisecall-issues for conditions no single client can see (e.g. everyone in a call degrading at once). - Persist every sample — per-client sinks (JSONL file, in-memory, or your own) for archival, streaming, and offline replay.
- Drop it in safely — warn-don't-throw, a pluggable logger, dual ESM + CommonJS, and no media-stack dependency in the core.
Status:
1.0.0-beta. The API described here is current and intended to be implemented against directly. This document is written to be self-sufficient: an engineer (or an AI agent) should be able to integrate the library, or develop it further, from this file alone. A companion doc,docs/logging.md, covers logging integration in depth.
Packaging: server-side, Node.js ≥ 22, shipped as a dual ESM + CommonJS build — so it works whether your project uses
import(ESM) orrequire()(CommonJS). Everything — including the built-in file sink — is exported from the single@observertc/observer-jsentry.
For AI agents:
llms.txtis a curated map of these docs (it belongs at the root of the docs site);AGENTS.mdcovers build/test commands and the conventions for working in this repository.
- Installation
- Quick start
- Data flow
- Entity hierarchy
- Ingestion:
accept(), context & lifecycle - Update policies
- The event bus ← the core of the API
- API reference
- Schema types (
ClientSample) - Detectors (server-side extension point)
- Remote track resolution (mediasoup / SFU)
- Mediasoup router observation
- Sinks (per-client sample persistence)
- Injecting data into a client
- Logging
- Error-handling philosophy
- Development & extension guide
npm install @observertc/observer-js
# or
yarn add @observertc/observer-jsServer-side, Node.js ≥ 22, dual ESM + CommonJS. The package ships both module formats, so it works the same whether your project is ESM or CommonJS — your import line is unchanged either way:
import { Observer, ClientSample, createJsonlFileSinkFactory } 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
any transport — see Logging.
ClientSample and friends are re-exported from this package, and are also published as the
shared schema in @observertc/schemas; samples
produced on the client (e.g. by @observertc/client-monitor-js) conform to the same shape.
import { Observer, ClientSample } from '@observertc/observer-js';
// 1. Create an observer.
const observer = new Observer({
// when the observer aggregates call/client metrics:
updatePolicy: 'update-when-all-call-updated',
// default policy applied to calls created automatically by accept():
defaultCallUpdatePolicy: 'update-on-any-client-updated',
// optional auto-teardown:
closeCallIfEmptyForMs: 20_000,
closeClientIfIdleForMs: 60_000,
});
// 2. Subscribe on the single bus. Every payload is an object with the ancestry.
observer.on('call-added', ({ observedCall }) => {
console.log('new call', observedCall.callId);
});
observer.on('client-issue', ({ observedClient, issue }) => {
console.warn(`[${observedClient.clientId}] ${issue.type}`, issue.payload);
});
observer.on('peer-connection-updated', ({ observedClient, observedPeerConnection }) => {
console.log(observedClient.clientId, 'RTT(ms):', observedPeerConnection.currentRttInMs);
});
observer.on('sample-rejected', ({ reason, sample }) => {
console.warn('dropped a sample:', reason);
});
// 3. Feed samples. `context` (optional) is transient per-accept data, carried to the
// `*-updated` events this accept triggers (never written to appData).
function onClientStats(sample: ClientSample) {
observer.accept(sample, { studioVersion: '1.2.3' });
}
// 4. Tear down.
process.on('SIGINT', () => observer.close());client getStats() ──► ClientSample ──► observer.accept(sample, ctx?)
│
┌────────────────────────────────┘
▼
get-or-create ObservedCall ──► get-or-create ObservedClient ──► client.accept(sample, ctx)
│
per peerConnections[] in the sample
▼
get-or-create ObservedPeerConnection
.accept(pcSample, ctx) updates all sub-stats,
derives deltas/bitrates/RTT, correlates remote RTP
│
metrics roll up: PeerConnection → Client → Call → Observer
│
events emitted on the Observer bus ──► your handlers
- A sample must have
callIdandclientId(the library sets them, or the app does). If either is missing, the sample is dropped andsample-rejectedis emitted. - Sub-entities that stop appearing in samples are garbage-collected via a "visited"
mark-and-sweep on each
ObservedPeerConnection.accept(), emitting the corresponding*-removedevents.
| Class | Created by | Keyed on its parent as | Holds |
|---|---|---|---|
Observer |
new Observer(config?) |
— (root) | observedCalls: Map<string, ObservedCall>, global counters, the event bus |
ObservedCall |
observer.createObservedCall(settings) / lazily by accept |
observedCalls |
observedClients: Map<string, ObservedClient>, call-wide metrics, detectors, scoreCalculator |
ObservedClient |
call.createObservedClient(settings) / lazily |
observedClients |
observedPeerConnections: Map<string, ObservedPeerConnection>, per-client metrics |
ObservedPeerConnection |
lazily, from sample.peerConnections[] |
observedPeerConnections |
the 15 sub-stat maps below, transport/RTT/bitrate metrics |
| Sub-stats | lazily, from the PeerConnectionSample |
maps on the PC | individual WebRTC stat objects |
ObservedPeerConnection sub-stat maps (all public readonly):
observedCertificates, observedCodecs, observedDataChannels,
observedIceCandidates, observedIceCandidatesPair, observedIceTransports,
observedInboundRtps, observedInboundTracks, observedMediaPlayouts,
observedMediaSources, observedOutboundRtps, observedOutboundTracks,
observedPeerConnectionTransports, observedRemoteInboundRtps, observedRemoteOutboundRtps
Each sub-stat class (ObservedInboundRtp, ObservedOutboundRtp, ObservedInboundTrack,
ObservedOutboundTrack, ObservedDataChannel, ObservedIceCandidate,
ObservedIceCandidatePair, ObservedIceTransport, ObservedCertificate, ObservedCodec,
ObservedMediaSource, ObservedMediaPlayout, ObservedPeerConnectionTransport,
ObservedRemoteInboundRtp, ObservedRemoteOutboundRtp) mirrors the corresponding stat
fields from the schema plus derived fields (deltas, bitrates).
The single entry point. It:
- drops + emits
sample-rejectedif the observer is closed; - runs the sample through the global accept-middleware chain (see below);
- (chain terminal) drops + emits
sample-rejectedifcallId/clientIdis missing; - gets or lazily creates the
ObservedCallandObservedClient(theirappDatacomes from the configured factories, never fromcontext); - delegates to
client.accept(sample, context), which fans out to eachObservedPeerConnection.accept(pcSample, context).
observer.addAcceptMiddleware(...) registers middlewares run on every sample inside
accept(), in order, before the sample is dispatched to any call or client. Each middleware
gets a { sample, context } payload; it can inspect or mutate the sample (set/normalize
callId/clientId, enrich, redact) or the context, then call next(payload) to continue.
Not calling next drops the sample — nothing is created and no event fires. A throwing
middleware is caught and warns (the sample is dropped), never crashing accept().
import { Observer, AcceptMiddleware } from '@observertc/observer-js';
const observer = new Observer();
// derive callId/clientId from the app's own attachment, before dispatch
const route: AcceptMiddleware = ({ sample }, next) => {
sample.callId ??= sample.attachments?.roomId as string;
sample.clientId ??= sample.attachments?.peerId as string;
next({ sample });
};
// drop samples from a blocklisted client (never dispatched)
const filter: AcceptMiddleware = (payload, next) => {
if (blocked.has(payload.sample.clientId)) return; // no next() => dropped
next(payload);
};
observer.addAcceptMiddleware(route, filter);
// observer.removeAcceptMiddleware(route);This is a lightweight global injection point. When no middleware is registered, accept()
dispatches directly with no overhead.
type AcceptContext = Record<string, unknown>;A single, optional, free-form object threaded down the whole accept chain
(Observer → Client → PeerConnection). It is transient request-scoped data — temporary or
contextual information the application wants available while an update is processed.
context is never written to appData and is not stored on any entity. The two are
deliberately distinct:
appData— application-assigned extra info that identifies/decorates an entity, fixed at creation (viasettings.appDataor thecreateCallAppData/createClientAppDatafactories), or assigned by the app on the*-addedevents. The library never changes it.context— passed peraccept(), may differ on every call, and is carried straight through to the*-updatedevents that theaccept()triggers, then discarded.
client-updated and peer-connection-updated carry the exact context of that sample;
call-updated carries the context of the client accept() that drove the call update (absent
for interval- or teardown-driven call updates). When no context is given, the field is absent.
If you want to create/configure entities yourself before/without samples:
const call = observer.getOrCreateObservedCall({ callId, appData }); // ObservedCall | undefined
const client = call?.getOrCreateObservedClient({ clientId, appData }); // ObservedClient | undefinedThese return undefined (and warn) when the parent is closed; createObservedCall/
createObservedClient return the existing instance (and warn) if the id already exists.
closeClientIfIdleForMs— a client with no sample for this long auto-closes.closeCallIfEmptyForMs— a call with zero clients for this long auto-closes.- Closing cascades down (call → clients → peer connections → sub-stats), unsubscribing
listeners and emitting the
*-closed/*-removedevents.
"Update" means recompute aggregated metrics and emit the *-updated event at that level.
Both the observer and each call have a configurable trigger. Updates are event-driven — there
is no built-in timer. An app that wants a fixed cadence can call observer.update() /
call.update() from its own setInterval. With 'none', nothing auto-updates — the level
updates only when the application calls the public update() itself.
Observer-level (ObserverConfig.updatePolicy, default update-when-all-call-updated):
| Policy | Triggers observer.update() when… |
|---|---|
update-on-any-call-updated |
any call updates |
update-when-all-call-updated |
every call has updated since the last observer update |
none |
never automatically — only when the app calls observer.update() |
Call-level (ObservedCallSettings.updatePolicy, defaulted from
ObserverConfig.defaultCallUpdatePolicy):
| Policy | Triggers call.update() when… |
|---|---|
update-on-any-client-updated |
any client in the call updates |
update-when-all-client-updated |
every client has updated since the last call update |
none |
never automatically — only when the app calls call.update() |
This is the primary API. Subscribe on the Observer instance — it is the single emitter
for the entire hierarchy. The ObservedCall / ObservedClient / ObservedPeerConnection
objects are themselves EventEmitters too, but those local events are reserved for internal
lifecycle/teardown wiring (see Local lifecycle events); application
code should use the Observer bus.
Every Observer event delivers exactly one argument: a payload object. The payload always contains the ancestry from the observer down to the entity that raised it, plus any event- specific subject:
type ObserverEventBase = { observer: Observer };
type ObservedCallScope = ObserverEventBase & { observedCall: ObservedCall };
type ObservedClientScope = ObservedCallScope & { observedClient: ObservedClient };
type ObservedPeerConnectionScope = ObservedClientScope & { observedPeerConnection: ObservedPeerConnection };So a peer-connection-level event hands you the observer, call, client, and peer connection:
observer.on('inbound-rtp-added', ({ observer, observedCall, observedClient, observedPeerConnection, observedInboundRtp }) => {
// all five are present and correctly typed
});observer.on/off/once/emit are fully typed against the event map — the handler argument is
inferred per event name.
All payloads include the ancestry for their level (above). The Extra column lists the additional field(s) on top of that scope.
| Event | Extra payload | Fires when |
|---|---|---|
observer-updated |
— | observer.update() ran (per the observer update policy) |
observer-closed |
— | observer.close() |
sample-rejected |
{ reason: 'observer-closed' | 'missing-callId' | 'missing-clientId', sample: ClientSample } |
a sample was dropped by accept() |
observer-issue |
{ issue: ClientIssue } |
observer.addIssue(...) — a cross-call / SFU-wide finding (see observer-level detectors) |
| Event | Extra | Fires when |
|---|---|---|
mediasoup-router-added |
— | observer.createObservedMediasoupRouter(...) registered a router |
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. |
mediasoup-router-removed |
— | the underlying mediasoup router closed (its router.observer close fired) |
See Mediasoup router observation for the full design and examples.
| Event | Extra | Fires when |
|---|---|---|
call-added |
— | a call is created |
call-updated |
{ context?: AcceptContext } |
call.update() ran |
call-closed |
— | the call closed |
call-empty |
— | last client left the call |
call-not-empty |
— | first client joined a previously-empty call |
call-issue |
{ issue: ClientIssue } |
call.addIssue(...) (server-side detector finding) |
| 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-updated |
{ sample: ClientSample, elapsedTimeInMs: number, context?: AcceptContext } |
the client processed a sample |
client-closed |
— | the client closed |
client-joined |
— | first CLIENT_JOINED event seen |
client-left |
— | CLIENT_LEFT seen (or inferred on close) |
client-rejoined |
{ timestamp: number } |
a later CLIENT_JOINED after an earlier join |
client-issue |
{ issue: ClientIssue } |
a client-reported issue arrived, or client.addIssue(...). A keyed issue also opens an entry in observedClient.activeIssues |
client-issue-resolved |
{ resolvedIssue: ResolvedClientIssue } |
a stateful issue ended — the client sent its <type>-resolved companion, or the observer force-closed it. Carries the finished interval (durationInMs, resolvedBy) — see client issues |
client-metadata |
{ metaData: ClientMetaData } |
a client meta item arrived |
client-extension-stats |
{ extensionStats: ExtensionStat } |
an app-defined extension stat arrived |
client-event |
{ event: ClientEvent } |
any client event was processed |
| Event | Extra | Notes |
|---|---|---|
peer-connection-added / peer-connection-closed |
— | lifecycle of the PC |
peer-connection-updated |
{ context?: AcceptContext } |
the PC processed a sample |
ice-connection-state-changed / ice-gathering-state-changed / connection-state-changed |
{ state: string } |
driven by client events |
inbound-track-added / -updated / -removed / -muted / -unmuted |
{ observedInboundTrack } |
|
outbound-track-added / -updated / -removed / -muted / -unmuted |
{ observedOutboundTrack } |
|
inbound-rtp-added / -updated / -removed |
{ observedInboundRtp } |
-updated fires every tick |
outbound-rtp-added / -updated / -removed |
{ observedOutboundRtp } |
-updated fires every tick |
remote-inbound-rtp-added / -updated / -removed |
{ observedRemoteInboundRtp } |
|
remote-outbound-rtp-added / -updated / -removed |
{ observedRemoteOutboundRtp } |
|
data-channel-added / -updated / -removed |
{ observedDataChannel } |
|
ice-candidate-added / -updated / -removed |
{ observedIceCandidate } |
|
ice-candidate-pair-added / -updated / -removed |
{ observedIceCandidatePair } |
|
ice-transport-added / -updated / -removed |
{ observedIceTransport } |
|
codec-added / -updated / -removed |
{ observedCodec } |
|
media-source-added / -updated / -removed |
{ observedMediaSource } |
|
media-playout-added / -updated / -removed |
{ observedMediaPlayout } |
|
peer-connection-transport-added / -updated / -removed |
{ observedPeerConnectionTransport } |
|
certificate-added / -updated / -removed |
{ observedCertificate } |
Volume note. The
*-updatedsub-stat events fire on every peer-connectionaccept()(i.e. per sample, per stream). For high-throughput servers, subscribe only to what you need, or read fields off the entities onclient-updated/call-updatedinstead.
These remain on the individual entities (not the bus), for teardown/coordination. You can listen to them, but prefer the bus equivalents above for application logic.
| Entity | Local events |
|---|---|
ObservedCall |
update, newclient, empty, not-empty, close |
ObservedClient |
update (sample, elapsedTimeInMs), close, joined, left |
ObservedPeerConnection |
removed-inbound-track, removed-outbound-track, close |
new Observer<AppData>(config?: ObserverConfig<AppData>)
type ObserverConfig<AppData = Record<string, unknown>> = {
updatePolicy?: 'update-on-any-call-updated' | 'update-when-all-call-updated' | 'none';
defaultCallUpdatePolicy?: ObservedCallSettings['updatePolicy'];
appData?: AppData;
closeClientIfIdleForMs?: number;
closeCallIfEmptyForMs?: number;
// appData factories — run when an entity is created without explicit appData
// (incl. lazily by accept()). appData is application-owned; accept `context` never touches it.
createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;
// sink factory — produces a per-client sink that receives every accepted sample (see Sinks).
createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;
// remote-track-resolver factory — produces a call's RemoteTrackResolver (see Remote track resolution).
createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
};appData factories. Instead of pre-creating a call/client (or assigning on call-added /
client-added) just to enrich its appData, register a factory once. It runs in the entity's
constructor whenever it's created without an explicit settings.appData — including the lazy
creation inside accept(). The client factory receives the already-created parent
observedCall, so it can derive fields from it. appData is application-owned and is never
modified by the accept() context.
const observer = new Observer({
createCallAppData: ({ callId }) => ({ callId, startedAt: Date.now(), region: 'eu' }),
createClientAppData: ({ clientId, observedCall }) => ({ clientId, region: observedCall.appData.region }),
});Key members:
accept(sample: ClientSample, context?: AcceptContext): voidaddAcceptMiddleware(...mw: AcceptMiddleware[]): this/removeAcceptMiddleware(...mw): this— global pre-dispatch sample hooks (see Accept middlewares)getObservedCall<T>(callId): ObservedCall<T> | undefinedcreateObservedCall<T>(settings): ObservedCall<T> | undefinedgetOrCreateObservedCall<T>(settings): ObservedCall<T> | undefinedupdate(): void— force an aggregation/observer-updatedtickclose(): voidreadonly observedCalls: Map<string, ObservedCall>readonly observedTURN: ObservedTURNget appData(),get numberOfCalls()- counters:
numberOfClients,numberOfClientsUsingTurn,numberOfInboundRtpStreams,numberOfOutboundRtpStreams,numberOfDataChannels,numberOfPeerConnections,totalAddedCall,totalRemovedCall,closed on/off/once/emittyped against the event map
type ObservedCallSettings<AppData = Record<string, unknown>> = {
updatePolicy?: 'update-on-any-client-updated' | 'update-when-all-client-updated' | 'none';
callId: string;
appData?: AppData;
closeCallIfEmptyForMs?: number;
};Key members:
readonly callId: string,appData: AppDatareadonly observedClients: Map<string, ObservedClient>,get numberOfClients()getObservedClient<T>(clientId),createObservedClient<T>(settings),getOrCreateObservedClient<T>(settings)(all… | undefined)addIssue(issue: ClientIssue): void— raise a call-level issue → emitscall-issuereadonly detectors: Detectors— server-side detector registry (empty by default; see Detectors)scoreCalculator: ScoreCalculator,get score(),readonly calculatedScoreremoteTrackResolver?: RemoteTrackResolver— set fromObserverConfig.createRemoteTrackResolverat call creation (see Remote track resolution)- aggregates:
numberOfIssues,numberOfPeerConnections,numberOfInboundRtpStreams,numberOfOutboundRtpStreams,numberOfDataChannels,maxNumberOfClients,clientsUsedTurn: Set<string>,startedAt?,endedAt?,closedAt?,closed update(),close()
type ObservedClientSettings<AppData = Record<string, unknown>> = {
clientId: string;
appData?: AppData;
closeClientIfIdleForMs?: number;
};Key members:
readonly clientId: string,appData: AppData,readonly call: ObservedCallreadonly observedPeerConnections: Map<string, ObservedPeerConnection>readonly sink?: ClientSampleSink— the per-client sink (see Sinks), ifcreateClientSinkis configured; listen on it forclose/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<string, unknown>) - Direct add API (process immediately):
addIssue(ClientIssue),addMetadata(ClientMetaData),addExtensionStats(ExtensionStat) - Metrics (current/derived):
currentAvgRttInMs?,currentMinRttInMs?,currentMaxRttInMs?,receivingAudioBitrate,receivingVideoBitrate,sendingAudioBitrate,sendingVideoBitrate,usingTURN,usingTCP,availableIncomingBitrate,availableOutgoingBitrate - Counts:
numberOfInboundRtpStreams,numberOfOutboundRtpStreams,numberOfInbundTracks,numberOfOutboundTracks,numberOfDataChannels,numberOfPeerConnections - Per-tick deltas:
deltaReceivedAudioBytes,deltaSentAudioBytes, … (see source for the full set) - Lifecycle:
joinedAt?,leftAt?,closedAt?,closed,get score() - Metadata:
browser?,engine?,platform?,operationSystem?,mediaDevices,mediaConstraints accept(sample, context?),close()
Key members:
readonly peerConnectionId: string,readonly client: ObservedClient,appData?- The 15
observed*sub-statMaps (listed above), plus array getters:codecs,inboundRtps,outboundRtps,remoteInboundRtps,remoteOutboundRtps,mediaSources,mediaPlayouts,dataChannels,peerConnectionTransports,iceTransports,iceCandidates,iceCandidatePairs,certificates,selectedIceCandidatePairs,selectedIceCandiadtePairForTurn - State:
connectionState?,iceConnectionState?,iceGatheringState?,usingTURN,usingTCP - Metrics:
currentRttInMs?,iceRttInMs?,rtcpRttInMs?,sfuHopRttInMs?,currentJitter?,availableIncomingBitrate,availableOutgoingBitrate, sending/receiving bitrates, packet rates, andtotal*/delta*byte/packet counters accept(pcSample, context?),close(),get score()
Two different round trips — don't mix them. iceRttInMs comes from ICE/STUN consent checks and
measures the trip to whatever terminates ICE: in an SFU topology that is the SFU, so it is the
client↔SFU leg. rtcpRttInMs comes from RTCP receiver reports and is an end-to-end media-path
round trip. They are not interchangeable, and averaging them together produces a number that moves
as streams come and go for reasons unrelated to the network. currentRttInMs therefore prefers
RTCP and falls back to ICE — always one kind within a tick, never a blend. sfuHopRttInMs
(rtcp − ice) estimates everything past the SFU, which separates "this client's last mile is slow"
from "the path beyond the SFU is slow".
Counter-reset boundaries. Chrome resets an SSRC's cumulative counters when the codec switches
(crbug/webrtc/5361, open since 2015),
which otherwise shows up as a sawtooth spike or a negative bitrate. ObservedInboundRtp /
ObservedOutboundRtp therefore set counterResetBoundary on any tick where codecId,
encoder/decoderImplementation or scalabilityMode changed, and suppress every delta for that
tick. Without this, a room-wide codec rollout fires a synchronized fake-degradation alert across
every participant at once.
Remote-RTP correlation (derived). During accept(), receiver/sender reports are linked
to the local streams by remoteId (fallback SSRC) and surfaced as fields:
- on
ObservedOutboundRtp:remoteRttInMs?,remoteFractionLost?,remoteJitter?,remotePacketsLost? - on
ObservedInboundRtp:remoteRttInMs?,remoteBytesSent?,remotePacketsSent?,remoteTimestamp?
These are reset each tick and only set when the matching remote report is present.
The shape of an accepted sample (re-exported from this package; identical to
@observertc/schemas). Only the top level is shown — each stat object mirrors the standard
WebRTC getStats() dictionaries plus a few extensions.
type ClientSample = {
timestamp: number; // client wall-clock (ms epoch)
callId?: string; // set by you or the library
clientId?: string; // set by you or the library
score?: number; // optional client-computed score (0..5)
attachments?: Record<string, unknown>;
peerConnections?: PeerConnectionSample[];
clientEvents?: ClientEvent[];
clientIssues?: ClientIssue[];
clientMetaItems?: ClientMetaData[];
extensionStats?: ExtensionStat[];
};
type PeerConnectionSample = {
peerConnectionId: string;
attachments?: Record<string, unknown>; // e.g. { direction: 'send'|'recv', producerId, consumerId, label }
score?: number;
inboundTracks?; outboundTracks?;
codecs?;
inboundRtps?; remoteInboundRtps?;
outboundRtps?; remoteOutboundRtps?;
mediaSources?; mediaPlayouts?;
peerConnectionTransports?; dataChannels?;
iceTransports?; iceCandidates?; iceCandidatePairs?;
certificates?;
};
type ClientEvent = { type: string; payload?: string; timestamp?: number; /* +ids */ };
type ClientIssue = { type: string; payload?: string; timestamp?: number }; // also used for call-issue
type ClientMetaData = { type: string; payload?: string; timestamp?: number; /* +ids */ };
type ExtensionStat = { type: string; payload?: string };payload fields are JSON strings; the library parses the ones it understands.
ClientEventTypes (enum of known event.type values): CLIENT_JOINED, CLIENT_LEFT,
PEER_CONNECTION_OPENED/CLOSED/STATE_CHANGED, MEDIA_TRACK_ADDED/REMOVED/MUTED/UNMUTED/RESUMED,
ICE_GATHERING_STATE_CHANGED, ICE_CONNECTION_STATE_CHANGED, DATA_CHANNEL_OPEN/CLOSED/ERROR,
NEGOTIATION_NEEDED, SIGNALING_STATE_CHANGE, ICE_CANDIDATE, ICE_CANDIDATE_ERROR, and the
mediasoup set PRODUCER_* / CONSUMER_* / DATA_PRODUCER_* / DATA_CONSUMER_*.
ClientMetaTypes (enum of known meta type values): MEDIA_CONSTRAINT, MEDIA_DEVICE,
MEDIA_DEVICES_SUPPORTED_CONSTRAINTS, USER_MEDIA_ERROR, LOCAL_SDP, OPERATION_SYSTEM,
ENGINE, PLATFORM, BROWSER.
Two consecutive samples from one participant ("Guest" in room qq0iwfnd) of an
edumeet/mediasoup call show what actually flows through accept(): a rich join snapshot,
then lean steady-state ticks.
Sample 1 — the join snapshot. Carries the one-off lifecycle clientEvents and device
clientMetaItems alongside the first stats. (Abbreviated; ids and times are from the real log.)
What accept() does with it, in order — each step emits on the bus with full ancestry:
- lazily creates the
ObservedCall→call-added; - creates the
ObservedClient→client-added, thenclient-joined(fromCLIENT_JOINED); - creates an
ObservedPeerConnectionper entry →peer-connection-added(×2 here); - creates an
ObservedOutboundTrackper track →outbound-track-added, plus the matchingoutbound-rtp-added; - replays the device list as
client-metadataevents and the lifecycle items asclient-event; and finallyclient-updatedfor the whole tick.
attachments.roomId lands on observedClient.attachments (read it on client-updated, not at
creation — see Ingestion).
Sample 2 — a steady-state tick (~8 s later): same callId / clientId, no new
clientEvents or clientMetaItems, just refreshed peerConnections stats. Each PC now scores 5
and the aggregate client score is 4.74 — a healthy call. This is the shape of nearly every
sample: each tick refreshes metrics and fires the *-updated events, while the heavy join
snapshot happens only once.
observer-js deliberately ships no built-in detectors. Per-client signals — packet loss,
jitter, RTT, freezes, etc. — are already detectable on the client and arrive on samples as
clientIssues (surfaced via client-issue). Server-side detection should focus on what only
the server can see by correlating data across the clients of a call.
The hook lives on ObservedCall:
import { Observer, Detector } from '@observertc/observer-js';
class MyCrossClientDetector implements Detector {
readonly name = 'my-detector';
constructor(private readonly call /* : ObservedCall */) {}
update() { // called on every call.update()
// …inspect this.call.observedClients across participants…
if (/* condition only visible server-side */ false) {
this.call.addIssue({ type: this.name, payload: JSON.stringify({ /* … */ }), timestamp: Date.now() });
// → emitted on the bus as 'call-issue'
}
}
}
const observer = new Observer();
observer.on('call-added', ({ observedCall }) => {
observedCall.detectors.add(new MyCrossClientDetector(observedCall));
});
observer.on('call-issue', ({ observedCall, issue }) => { /* react */ });The most important thing to understand about detection in this library is what it deliberately
does not do. A client running
client-monitor-js already ships ~20 detectors
that decide what is wrong with that endpoint — congestion, cpulimitation, audio-concealment,
freezed-video-track, keyframe-storm, video-decoder-overloaded, stuck-decoder,
ice-disconnected, and so on. Those verdicts are better than anything re-derived from raw counters
server-side, because they carry hysteresis and multi-signal confirmation: audio-concealment
subtracts silent concealment (raw concealedSamples rises during ordinary silence, so a naive
detector flags every quiet moment); audio-jitter-buffer-stress requires the buffer to be grown
and NetEQ to be time-stretching (a grown buffer alone means NetEQ is succeeding);
ice-disconnected only fires once disconnected has persisted, so the blips ICE heals on its own
never surface.
observer-js does not repeat that work. Its job is the question no browser can answer: who else is in this state right now, what do they have in common, and where in publisher → SFU → subscriber does the fault begin?
From client-monitor-js 4.6.0 the whole issue lifecycle reaches the server. A stateful issue
arrives as two clientIssues[] entries sharing a key:
raise: { type: 'stuck-decoder', key, payload, timestamp: raisedAt }
resolution: { type: 'stuck-decoder-resolved', key, payload: { raisedAt, comment, …final }, timestamp: resolvedAt }
The observer opens an entry in observedClient.activeIssues on the raise and closes it on the
matching key, emitting client-issue-resolved with the finished interval. Handled for you:
- the
-resolvedsuffix is stripped, so both entries share one logicaltype; - a re-raise of a live key refreshes the payload without restarting
raisedAt; - keyless entries are one-shot — reported via
client-issue, never tracked; - issues still open when a client closes are force-resolved (
resolvedBy: 'client-closed'), and the registry additionally expires stale entries, so a crashed participant can't leave an issue "active" forever.
This turns point-in-time symptom reports into intervals, and that is the whole game. "Several clients reported congestion in the last 10 seconds" is a heuristic that has to guess whether the symptoms are still happening. "Several clients are congested right now, simultaneously" is ground truth, because the client says when the episode ends. Overlapping intervals are far stronger evidence of a shared cause than near-in-time reports.
observer.on('client-issue', ({ observedClient, issue }) => { /* opened (or one-shot) */ });
observer.on('client-issue-resolved', ({ resolvedIssue }) => {
resolvedIssue.type; // 'stuck-decoder' — suffix stripped
resolvedIssue.durationInMs; // how long the episode lasted
resolvedIssue.resolvedBy; // 'client' | 'timeout' | 'client-closed'
});
// the live per-client mirror
observedClient.activeIssues; // Map<key, ActiveClientIssue>import { IssueRegistry } from '@observertc/observer-js';
const registry = new IssueRegistry(observedCall); // or `observer` for cross-call scope
registry.cohortOf('congestion'); // { clientIds, affectedRatio, onsetSpreadInMs, … }
registry.cohorts(); // every shared issue type, largest cohort first
registry.byTrackIds(trackIds); // issues attributed to a published track's receiversonsetSpreadInMs is measured on the observer clock, never the client's. raisedAt comes from
each participant's own machine, and comparing those across clients makes clock skew look like a
synchronized infrastructure event.
Some findings only exist above call scope — "many calls on the same SFU degraded at once" is far
more actionable than fifty individual client alerts. The same registry exists on the Observer,
runs on every observer.update(), and raises findings through observer.addIssue(...), surfaced on
the bus as observer-issue:
observer.detectors.add({
name: 'sfu-wide-degradation',
update: () => {
const degradedCalls = [ ...observer.observedCalls.values() ].filter(isDegraded);
if (observer.numberOfCalls > 3 && degradedCalls.length / observer.numberOfCalls > 0.6) {
observer.addIssue({ type: 'SFU_WIDE_QUALITY_DEGRADATION', timestamp: Date.now() });
}
},
});
observer.on('observer-issue', ({ issue }) => alert(issue));The question a single browser can never answer is "did everyone receiving Alice see the same
degradation?". TrackDistributionAggregator answers it by walking the publisher→subscriber links
maintained by a RemoteTrackResolver and summarizing one
published track against all of its receivers:
import { TrackDistributionAggregator } from '@observertc/observer-js';
const aggregator = new TrackDistributionAggregator(observedCall);
for (const d of aggregator.aggregate()) {
d.trackId; // the published track
d.publisher.healthy; // is the source itself fine? (+ .reasons)
d.numberOfReceivers; // 17
d.numberOfDegradedReceivers; // 14
d.degradedRatio; // 0.82
d.fractionLost?.p95; // percentile summaries across receivers
d.freezes; // { affectedReceivers, total } — fan-out counters
d.plis;
d.receivers; // per-receiver entries with `degraded` + `reasons`
}Each receiver is judged against ReceiverHealthThresholds (loss, freezes, dropped frames,
concealment, jitter-buffer delay, RTT — override any of them). Summaries are medians and
percentiles, not means, because one participant at 1500 ms RTT would otherwise hide nine healthy
ones. The helpers behind it (percentile, median, summarize, counterDelta, SlidingWindow)
are exported for building your own detectors.
The second aggregation axis — the client axis. Where TrackDistributionAggregator asks "how was
this source delivered?", this asks "how is each participant doing, sending vs receiving?":
import { CallHealthAggregator } from '@observertc/observer-js';
const health = new CallHealthAggregator(observedCall).aggregate();
health.degradedRatio; // 0.82 — the number that distinguishes shared faults from individual ones
health.inboundDegradedRatio; // receiving side → egress/downstream suspicion
health.outboundDegradedRatio; // sending side → ingress suspicion
health.rttInMs?.median; // percentile rollups, never means
health.qualityLimitation; // { cpu, bandwidth, other } client counts
health.clients; // per-client entries with `reasons`, direction flags, TURN/TCPAll of them are opt-in — the library enables none by default. Register call-scoped ones on
observedCall.detectors and observer-scoped ones on observer.detectors:
🔗 marks detectors that require a
RemoteTrackResolver. They reason about a published track and its subscribers, so without the publisher↔subscriber links they see nothing and stay silent forever — which looks exactly like "no problems found". ConfigureObserverConfig.createRemoteTrackResolverbefore registering them.
Issue-driven (preferred — they consume the client's own verdicts):
| Detector | 🔗 | Scope | Raises |
|---|---|---|---|
ConcurrentIssueDetector |
call or observer | CONCURRENT_CLIENT_ISSUES, ISSUE_ONSET_BURST |
|
IssueFanOutDetector |
🔗 | call | PUBLISHED_TRACK_ISSUE_FAN_OUT, SINGLE_RECEIVER_ISSUE |
TrackDeliveryMismatchDetector |
🔗 | call | PUBLISHED_TRACK_NOT_DELIVERED, RECEIVER_TRACK_NOT_DELIVERED, PUBLISHER_TRACK_DRY |
Metric-driven (work without client issues — the fallback path, and the things no client issue can express):
| Detector | 🔗 | Scope | Raises |
|---|---|---|---|
WorstReceiverContagionDetector |
🔗 | call | WORST_RECEIVER_CONTAGION |
CommonSourceDegradationDetector |
🔗 | call | PUBLISHER_HEALTHY_SUBSCRIBERS_DEGRADED, PUBLISHER_DEGRADED_FOR_ALL_SUBSCRIBERS, SINGLE_SUBSCRIBER_DEGRADED, MULTIPLE_SUBSCRIBERS_DEGRADED |
PliAndFreezeFanOutDetector |
🔗 | call | PUBLISHER_PLI_STORM, PUBLISHED_VIDEO_FROZEN_FOR_MULTIPLE_RECEIVERS |
AudioImpairmentFanOutDetector |
🔗 | call | PUBLISHED_AUDIO_DEGRADED_FOR_MAJORITY, CALL_WIDE_AUDIO_JITTER_BUFFER_STRESS |
UnconsumedTrackDetector |
🔗 | call | UNCONSUMED_PUBLISHED_TRACK |
CallWideDegradationDetector |
call | CALL_WIDE_QUALITY_DEGRADATION, CALL_WIDE_INBOUND_DEGRADATION, CALL_WIDE_OUTBOUND_DEGRADATION |
|
IceDisruptionDetector |
call | CALL_ICE_DISRUPTION |
|
TurnServerHealthDetector |
observer | TURN_SERVER_DEGRADED |
If your clients report issues, the issue-driven pair subsumes the fan-out and ICE detectors —
IssueFanOutDetector covers freeze/PLI/concealment fan-out generically, and ConcurrentIssueDetector
with the ICE issue types replaces IceDisruptionDetector (and does it better: the client already
suppresses the transient blips ICE heals by itself). Run both families only while migrating.
A dry track ("no bytes are arriving") is the clearest symptom there is and, on its own, completely ambiguous. A receiver seeing silence cannot distinguish the camera was switched off from the SFU stopped forwarding from my own consumer wedged — all three look identical from the browser.
Joining the two ends of the published track resolves it:
| publisher | subscribers | verdict |
|---|---|---|
| sending | all dry | PUBLISHED_TRACK_NOT_DELIVERED — the forwarding path |
| sending | some dry | RECEIVER_TRACK_NOT_DELIVERED — those consumers (in mediasoup: recreate them) |
| dry | any dry | PUBLISHER_TRACK_DRY — the source stopped; not an SFU fault |
The publisher side is judged from both available signals: its own dry-outbound-track issue when the
client reports one, and the observed outbound RTP (deltaPacketsSent) as fallback and corroboration.
That combination is what makes the first row trustworthy — the server can state that packets
demonstrably left the publisher during the same interval in which every receiver got nothing.
This is the "SFU forwarding mismatch" check, and it needs no mediasoup instrumentation at all — the clients' own dry-track verdicts plus the resolver links are sufficient.
The one detector where the absence of links is the signal: a track still pushing packets whose
remoteInboundTracks set is empty, i.e. uplink and SFU ingress spent on media nobody receives
(everyone has the publisher hidden, a simulcast layer no viewer selects, or an app that forgot to
stop a track). It waits minUnconsumedDurationInMs first, since a gap between publishing and the
first subscription is normal at join time.
Note the trap this one has to guard against, and why it checks call.remoteTrackResolver at runtime
rather than trusting the flag alone: "no subscribers" and "no resolver configured" produce the
identical observation. Without a resolver it would report every published track in the call as
unconsumed.
In a correctly built SFU the RTCP feedback loop is terminated at the server: each receiver's reports drive what that receiver is sent. When the loop is relayed end-to-end instead, the publisher's bandwidth estimate collapses to the minimum across all receivers — so one participant on a bad 3G link silently downgrades the stream everyone sees. This is the "lowest common denominator" failure simulcast exists to prevent.
It's detected as a correlation over a window, not a threshold: the publisher's outbound bitrate moving in lockstep with the worst receiver's inbound bitrate, while the median receiver has headroom — and tracking the worst receiver more closely than the median (otherwise everyone is just moving together, which is ordinary adaptation). The damage is invisible from every endpoint: the publisher sees "my bitrate dropped", each healthy receiver sees "my video got worse", and only the server can see the causal link.
import {
ConcurrentIssueDetector, IssueFanOutDetector, WorstReceiverContagionDetector,
CallWideDegradationDetector, TurnServerHealthDetector,
} from '@observertc/observer-js';
observer.on('call-added', ({ observedCall }) => {
// issue-driven: correlate the verdicts the clients already reached
observedCall.detectors.add(new IssueFanOutDetector(observedCall));
observedCall.detectors.add(new ConcurrentIssueDetector(observedCall));
// metric-driven: things no client issue can express
observedCall.detectors.add(new WorstReceiverContagionDetector(observedCall));
observedCall.detectors.add(new CallWideDegradationDetector(observedCall));
});
// cross-call, so these go on the observer and raise `observer-issue`
observer.detectors.add(new TurnServerHealthDetector(observer));
observer.detectors.add(new ConcurrentIssueDetector(observer, {
issueTypes: [ 'ice-disconnected', 'ice-connection-failed', 'congestion' ],
}));
observer.on('call-issue', ({ observedCall, issue }) => handle(observedCall, issue));
observer.on('observer-issue', ({ issue }) => handle(undefined, issue));Every detector takes an options object to tune minReceivers/minClients, the ratio thresholds, the
per-entity health thresholds, and the debounce (consecutiveTicks, or windowMs + cooldownMs for
the window-based ones) — so an alert needs a condition to persist, not just appear in one sample.
Findings that depend on publisher↔subscriber links need a
RemoteTrackResolver; without one those detectors stay
silent. A detector may implement close() (called when it's removed or the call/observer closes) —
IceDisruptionDetector uses it to drop the bus listeners it subscribes with.
The first detector built on the aggregator. It classifies where a fault lies by comparing the source
against its receivers, and raises a call-issue whose type is one of:
| Finding | Meaning |
|---|---|
PUBLISHER_HEALTHY_SUBSCRIBERS_DEGRADED |
source egress fine, most receivers degraded → downstream / SFU suspected |
PUBLISHER_DEGRADED_FOR_ALL_SUBSCRIBERS |
the source itself is impaired → publisher-side |
SINGLE_SUBSCRIBER_DEGRADED |
one receiver suffers while the rest are fine → that receiver's network |
MULTIPLE_SUBSCRIBERS_DEGRADED |
several (but not most) receivers on the same source |
import { CommonSourceDegradationDetector } from '@observertc/observer-js';
observer.on('call-added', ({ observedCall }) => {
observedCall.detectors.add(new CommonSourceDegradationDetector(observedCall, {
minReceivers: 3, // ratios need a meaningful denominator
degradedRatioThreshold: 0.6, // "most receivers"
consecutiveTicks: 2, // must hold 2 ticks — avoids flapping on one bad sample
}));
});The call-issue payload carries the evidence: publisher health and reasons, receiver/degraded
counts, degradedRatio, affectedClientIds, freeze/PLI fan-out and the loss/bitrate summaries.
It requires a configured RemoteTrackResolver — with no links there is nothing to compare and the
detector stays silent.
Detector interface and the registry:
interface Detector { readonly name: string; update(): void; }
class Detectors {
add(d: Detector): void;
remove(d: Detector): void;
clear(): void;
update(): void; // called by ObservedCall.update(); guards each detector in try/catch
get listOfNames(): string[];
}In an SFU, one participant's outbound track is delivered to other participants as inbound
tracks (one publisher → many subscribers). Correlation is opt-in per observer: set
ObserverConfig.createRemoteTrackResolver, a factory invoked when each call is created that returns the
call's RemoteTrackResolver (or undefined for none).
RemoteTrackResolver is a generic, strategy-driven class. It subscribes to the bus (filtered to
its call) and links tracks by publisher id — the link key — maintaining the links directly on
the tracks: inboundTrack.remoteOutboundTrack and outboundTrack.remoteInboundTracks: Set.
import { Observer, createDefaultMediasoupRemoteTrackResolverFactory } from '@observertc/observer-js';
const observer = new Observer({
createRemoteTrackResolver: createDefaultMediasoupRemoteTrackResolverFactory(),
});
// later, given tracks (links are kept up to date as tracks come and go):
const source = inboundTrack.remoteOutboundTrack; // the publishing ObservedOutboundTrack
const receivers = [ ...outboundTrack.remoteInboundTracks ]; // the subscribing ObservedInboundTrack[]Two built-in factories ship: createDefaultMediasoupRemoteTrackResolverFactory() (publisher =
attachments.producerId, subscriber = attachments.consumerId) and
createP2pRemoteTrackResolverFactory() (matches by RTP SSRC, preserved end-to-end in p2p).
For any other topology, build a RemoteTrackResolver with your own key resolvers — the publisher
id is just whatever links a subscribed track to the published one:
import { Observer, RemoteTrackResolver } from '@observertc/observer-js';
const observer = new Observer({
createRemoteTrackResolver: (observedCall) => new RemoteTrackResolver(observedCall, {
resolveOutboundTrackPublisherId: (out) => out.attachments?.mediaId as string | undefined,
resolveInboundTrackPublisherId: (inb) => inb.attachments?.mediaId as string | undefined,
resolveInboundTrackSubscriberId: (inb) => inb.attachments?.subId as string | undefined, // optional
}),
});For the mediasoup factory, the application puts producerId / consumerId (and optionally
direction, label) into the track attachments.
Everything above is built from the client-reported ClientSample. When you run a
mediasoup SFU you also have the server's own ground truth — its
routers, transports, producers, consumers and data channels, with exact lifetimes and state
transitions. ObservedMediasoupRouter captures that server-side view into a
MediasoupRouterSample, completely independent of the client sample pipeline.
You hand the observer a live mediasoup Router; it attaches to mediasoup's own observer API and,
from then on, passively tracks the router's topology and lifecycle — with no polling and no
changes to your media code:
- new transports (
webrtc/plain/pipe/direct), their selectedtuple, ICE/DTLS/SCTP state transitions andconnectedAt; - producers (codec, SSRCs/RIDs,
pause/resume) and consumers (pause/resume,producerPaused/producerResumed); - data producers and data consumers;
createdAt/closedAtfor every entity above.
It keeps all of this in memory, in a single MediasoupRouterSample exposed as
observedRouter.sample — see src/schema/MediasoupRouter.ts. The
sample accumulates for the life of the router: closed transports/producers/consumers are kept
(with their closedAt set), not removed. Read it whenever you like — it's a plain object you own.
This is intentionally the simplest approach — everything lives in memory and nothing is sampled or evicted for you. That's fine for typical rooms, but be aware of the cost at scale:
- Consumers grow as O(N²) on a single flat router: with
Nparticipants each producing audio + video and consuming everyone else, the sample holds roughly2·N·(N−1)consumer records (≈ 19,800 forN= 100). - The sample is cumulative — closed entities and their
historyare retained — so it also grows with call duration and churn (renegotiation, simulcast layer changes, rejoins).
A 100-participant flat router can therefore reach tens of MB and keep growing. There is no built-in
sink, snapshotting, or eviction — by design. If you run large meetings, do your own sampling:
on your own cadence read observedRouter.sample (snapshot/serialize/persist what you need), drop what
you don't, and close routers you no longer track. (mediasoup also typically shards routers across
workers/cores, which keeps any one router small.)
The sample is yours to annotate. Every entity — the router, each transport, producer, consumer, data
producer and data consumer — has an attachments?: Record<string, unknown> slot, and there are three
ways to fill it, from most declarative to most ad-hoc.
1. enrich — mirror mediasoup's own appData. The common case: your application already keeps
participantId, purpose and similar on the mediasoup objects, and you want them on the sample.
Runs once per entity at creation, before the corresponding event:
observer.createObservedMediasoupRouter({
router,
enrich: {
producer: (producer) => ({ participantId: producer.appData.participantId, purpose: producer.appData.purpose }),
consumer: (consumer) => ({ subscriberId: consumer.appData.subscriberId }),
transport: (transport) => ({ role: transport.appData.role }),
},
});A throwing enricher is caught and logged — it can't take the router's bookkeeping down with it.
2. Lifecycle events — enrich on the fly. Each entity announces itself as
<entity>-sample-added and <entity>-sample-closed, carrying the live sample object (not a
copy) plus the mediasoup object it came from. Mutating it in the handler is the intended pattern:
observedRouter.on('producer-sample-added', ({ sample, producer, transport }) => {
sample.attachments = { ...sample.attachments, participantId: lookup(producer.id) };
});
observedRouter.on('producer-sample-closed', ({ sample }) => {
archive(sample); // its `closedAt` is set
});Events: transport-sample-added / -closed, producer-sample-added / -closed,
consumer-sample-added / -closed, data-producer-sample-added / -closed,
data-consumer-sample-added / -closed.
3. attachTo(id, attachments) — annotate later, from anywhere. When the knowledge arrives after
the entity did (a signalling message, a database lookup that resolved):
observedRouter.attachTo(producerId, { participantId, joinedFrom: 'mobile' }); // mergesIds are unique across mediasoup entity kinds, so one method covers all of them. It returns false
for an unknown id rather than failing quietly — which matters when application events race the
mediasoup ones. For direct access there are typed accessors: getTransportSample(id),
getProducerSample(id), getConsumerSample(id), getDataProducerSample(id),
getDataConsumerSample(id). They index the same objects the arrays hold, so a lookup is O(1)
instead of a sample.producers.find(...) scan.
observedRouter.sample is live — arrays grow and history entries are appended as the router runs,
so a report built directly on it keeps changing after you think you're done. Use snapshot() for
a detached deep copy:
const report = {
...observedRouter.snapshot(), // never moves again
generatedAt: Date.now(),
region: process.env.REGION,
};Note on typing. The sample types no longer carry a
Record<string, unknown>index signature. That signature allowed arbitrary top-level keys but also silently accepted typos on real fields and weakened autocomplete. Custom data belongs inattachments, which is typed as such. If you were assigning ad-hoc keys directly onto a sample object, move them intoattachments.
The observer correlates the SFU side with the client side at the peer-connection level: a
mediasoup WebRTC transport and a client's RTCPeerConnection share the same id, so whenever an
observed peer connection's id matches one of the router's WebRTC transport ids, that's a match.
The observer does not store the router (or its sample) on any entity. Instead, for every
matching peer connection it emits mediasoup-router-matched-with-peer-connection and steps
back — your application decides what the pairing means. The payload carries the full peer-connection
ancestry, so you get the router and the matched observedPeerConnection, observedClient and
observedCall in one place. Stamp the routerId into the peer connection's / client's appData,
build your own index, attach the server sample to the call in your database — whatever fits.
This matching is opt-in: pass matchPeerConnectionByWebRtcTransportId: true to
createObservedMediasoupRouter. When enabled, as peer connections are observed
(peer-connection-added) the observer checks whether the peer connection's id is one of the router's
WebRTC transport ids; on a hit it emits — once per matching peer connection — and keeps watching, so a
router serving many participants emits one match per participant's transport. When the flag is omitted
or false, no matching is performed and the event never fires. The internal listener is removed
automatically when the router closes or the observer closes.
Matching is forward-only by design, and that is sufficient because the lifecycle ordering is guaranteed, not racy:
ObservedMediasoupRouterworks purely by subscribing to mediasoup'sobserverAPI, so it can only see events that happen after it is created. You therefore create it the moment the router exists — before any transport is added to it — and it captures the rest going forward.- A mediasoup transport is always created on the server first; only then can the client connect
to it, produce/consume, and begin shipping
ClientSamples. So a peer connection — and thepeer-connection-addedevent it triggers — can never appear before its server-side WebRTC transport already exists (and has been observed by the router).
Put together: by the time a peer-connection-added fires, the router has already recorded that
transport's id in webrtcTransportIds, so a single forward-looking listener catches every match. No
back-scan of existing peer connections and no re-check on transport creation are needed — the
observer deliberately does not look backwards.
Your responsibility: call createObservedMediasoupRouter(...) as early as the router exists
(before transports are added or samples are accepted). If you register the router after its
transports are created or after the client's first sample, those events are already in the past and
the corresponding matches are missed.
When the underlying mediasoup router closes, its close propagates to ObservedMediasoupRouter,
which sets the sample's closedAt and emits mediasoup-router-removed — your cue to read /
persist the final observedRouter.sample and drop your reference to it.
| Field | Type | Required | Meaning |
|---|---|---|---|
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 |
appData |
Record<string, unknown> |
no | application-owned bag on the ObservedMediasoupRouter |
attachments |
Record<string, unknown> |
no | free-form data; carried on sample.attachments |
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 |
Peer-connection matching is off by default; enable it with
matchPeerConnectionByWebRtcTransportId: true. Returns the ObservedMediasoupRouter, or undefined
if the observer is closed (a router with the same id returns the existing instance — both warn).
Useful members on the returned object: .sample (the in-memory MediasoupRouterSample, with
createdAt / closedAt? on it), .appData, .attachments, .webrtcTransportIds: Set<string>,
.id, .close().
import { Observer, InMemorySink } from '@observertc/observer-js';
import type { ObservedMediasoupRouterScope, ObservedPeerConnectionScope } from '@observertc/observer-js';
const observer = new Observer();
// 1) Feed client samples as usual so the observer knows about calls, clients & peer connections.
// (e.g. transport-layer: observer.accept(clientSample, context))
// 2) Observe the SFU side; opt in to peer-connection matching. State accumulates in `.sample`.
const router = /* your mediasoup router */ undefined as any;
const observedRouter = observer.createObservedMediasoupRouter({
router,
matchPeerConnectionByWebRtcTransportId: true,
});
// For large meetings, sample it yourself on your own cadence (see "Memory & large meetings"):
// setInterval(() => persist(observedRouter.sample), 10_000);
// 3) Every peer connection whose id matches one of the router's WebRTC transport ids fires this —
// WE decide what to do with each pairing. The payload carries the full ancestry.
observer.on('mediasoup-router-matched-with-peer-connection',
({ observedMediasoupRouter, observedCall, observedPeerConnection }:
ObservedMediasoupRouterScope & ObservedPeerConnectionScope) => {
(observedPeerConnection.appData ??= {}).routerId = observedMediasoupRouter.id;
myStore.linkRouterToCall(observedCall.callId, observedMediasoupRouter.id);
},
);
// 4) The router closed — read/persist the final state, then drop your reference.
observer.on('mediasoup-router-removed', ({ observedMediasoupRouter }: ObservedMediasoupRouterScope) => {
persist(observedMediasoupRouter.sample); // its `closedAt` is set
});- Loose coupling. The call model stays about client telemetry; the SFU view lives on its own
ObservedMediasoupRouterand is associated only if and how you choose. - You own the association. One router serves many peer connections (across clients and calls), and the right place to keep that mapping is application-specific — so the observer hands you each peer-connection match and gets out of the way.
- You own the sampling. The router sample is plain in-memory state you read on your own terms; for large meetings, sample/persist it yourself (see Memory & large meetings) rather than relying on the library to evict — it deliberately doesn't.
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.
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.
import { ClientSampleSink, ClientSample } from '@observertc/observer-js';
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
// 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`
}| Event | Meaning |
|---|---|
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.
import { Observer, createJsonlFileSinkFactory } from '@observertc/observer-js';
const observer = new Observer({
// one ./stats/<callId>__<clientId>.jsonl per client
createClientSink: createJsonlFileSinkFactory({ directory: './stats' }),
});
// 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.
});
});| 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).
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:
import { JsonlFileSink } from '@observertc/observer-js';
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
}
});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.
Subclass ClientSampleSink and emit the lifecycle events yourself — for any non-file
destination (a remote endpoint, a message queue, an object store, …):
import { ClientSampleSink, ClientSample, ClientSampleSinkFactory } from '@observertc/observer-js';
class HttpSink extends ClientSampleSink {
private buffer: ClientSample[] = [];
constructor(private readonly url: string) { super(); }
write(sample: ClientSample): boolean {
this.buffer.push(sample); // batch; decide your own backpressure
return true;
}
end(): void {
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}`);
const observer = new Observer({ createClientSink });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.
Sometimes the application holds data that belongs on a client's record but isn't part of the
client-reported ClientSample — a room id or display name, an application-level event
("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 exactly like sampled data.
| Method | Adds to the sample's | Surfaces as |
|---|---|---|
injectAttachment(attachments) |
attachments (merged via Object.assign) |
observedClient.attachments |
injectEvent(event: ClientEvent) |
clientEvents |
client-event (plus any state the event drives) |
injectIssue(issue: ClientIssue) |
clientIssues |
client-issue |
injectMetaData(meta: ClientMetaData) |
clientMetaItems |
client-metadata |
injectExtensionStat(stat: ExtensionStat) |
extensionStats |
client-extension-stats |
Injection is timing-aware so nothing is dropped, regardless of when you call it:
- During a sample's processing — e.g. from inside a
client-updated/client-eventhandler, which run withinaccept()— the data is applied to the current sample immediately: reflected in entity state and written to the sink as part of that sample. - Between samples — the data is buffered and merged into the next
accept()'s sample. - On
close()with pending injections and no further sample — the buffer is flushed as a final synthetic sample (applied to state and written to the sink) before the sink is ended, so a last-moment injection is never lost.
In every case the injected data both updates the live ObservedClient and reaches the per-client
sink — the sink always receives the final, injection-merged sample (the sink write happens at the
end of accept(), after the merge).
// Enrich at creation from your app's knowledge of the participant. Injecting in `client-added`
// (which runs just before the first accept) lands on the first sample.
observer.on('client-added', ({ observedClient }) => {
observedClient.injectAttachment({ roomId: lookupRoomId(observedClient.clientId) });
});
// Application-level signals at any time:
const client = observer.getObservedCall(callId)?.getObservedClient(clientId);
client?.injectEvent({ type: 'RECORDING_STARTED', timestamp: Date.now() });
client?.injectIssue({ type: 'app-kicked-participant', timestamp: Date.now() });attachments are latest-wins (like sampled attachments): injecting a key overwrites its previous
value. appData is unaffected — injections flow into the sample/telemetry, not the app-owned
appData bag (see Ingestion).
observer-js logs through a single, swappable sink. Out of the box it writes debug and
above to console (verbose — install your own sink for production). Funnel everything into your
logger:
import { setObserverLogger, type ObserverLogger } from '@observertc/observer-js';
setObserverLogger({
trace: (m, ...a) => myLogger.trace(`[${m}]`, ...a),
debug: (m, ...a) => myLogger.debug(`[${m}]`, ...a),
info: (m, ...a) => myLogger.info(`[${m}]`, ...a),
warn: (m, ...a) => myLogger.warn(`[${m}]`, ...a),
error: (m, ...a) => myLogger.error(`[${m}]`, ...a),
});createLogger(moduleName) is also exported for your own modules. See
docs/logging.md for pino / winston / console recipes, level
filtering, per-module routing, and full silencing.
The library warns and degrades; it does not throw on operational problems:
createObservedCall/createObservedClienton a closed parent → warn + returnundefined.- Duplicate id → warn + return the existing instance.
accept()on a closed client → warn + no-op.- Sample missing
callId/clientId, or observer closed →sample-rejectedevent. - A throwing accept-middleware → warn + drop that sample (never crashes
accept()).
Therefore create* and getOrCreate* return T | undefined; guard the result. The
Middleware utility's internal invariants (e.g. calling next() twice) throw, but those throws
are caught by accept() and surfaced as a warning.
yarn install
yarn build # tsup → dist/ (dual ESM .mjs + CJS .js, single entry, .d.ts/.d.mts + sourcemaps)
yarn lint # eslint -c .eslintrc.json "src/**/*.ts"
yarn typecheck # tsc --noEmit
yarn test # jestThe build is driven by tsup (config in tsup.config.ts): a single
entry (src/index.ts), dual ESM + CommonJS output to dist/ (index.mjs / index.js) with
.d.mts / .d.ts types and sourcemaps, targeting Node 20. CI (.github/workflows/ci.yml) runs
lint + typecheck + build + test on every push/PR.
Project layout (src/): Observer.ts, ObservedCall.ts, ObservedClient.ts,
ObservedPeerConnection.ts, the Observed* sub-stat classes, ObserverEvents.ts (the typed
event map + scope types), detectors/ (Detector, Detectors), scores/, updaters/
(update-policy strategies), utils/ (remote-track resolvers), common/ (logger, utils,
Middleware), schema/ (sample/event/meta types), and sinks/ (the ClientSampleSink base +
JsonlFileSink / InMemorySink, re-exported from the package root).
Conventions to follow when developing further:
- Single event bus. New consumer-facing events go in
ObserverEvents.tswith an object payload[<Scope> & { …subject }], and are emitted via the component's_notify(type, { ...this.eventScope, …subject }). Each component has a precomputedeventScopefield and a thin_notifywrapper around the right emitter. Keep purely internal coordination as local EventEmitter events (and remember tooffthem on close). - Warn, don't throw on operational/edge conditions; return
undefinedwhere a value can't be produced. - Counter-reset-safe deltas. When computing a delta from a cumulative counter, never emit a
negative value (guard
curr >= prev), to survive counter resets / SSRC reuse. - Explicit accumulation. The per-sample metric accumulation in
accept()is intentionally explicit and not abstracted — match that style. - Detectors are server-side. Add cross-client detectors on
ObservedCall.detectors; don't re-implement client-detectable signals.
Recipes:
- Add an event: add the key + payload to
ObserverEvents; in the owning component callthis._notify('my-event', { ...this.eventScope, subject }). - Add a per-stream metric: add the field to the relevant
Observed*Rtp/track class, populate it in itsupdate()(reset at the top ofupdate()if it's per-tick), and read it from a*-updatedhandler. - Add a detector: implement
Detector, register it oncall-addedviaobservedCall.detectors.add(...), surface findings withobservedCall.addIssue(...).
Apache-2.0. Part of the ObserverTC ecosystem.
{ "timestamp": 1780572332518, "callId": "d3dbf2f5-79be-4cb8-9d43-fb404f07ef27", "clientId": "c926983c-4468-4046-ae8c-a9cabe1a1868", "score": 0, // no quality measured yet on the join tick "attachments": { "displayName": "Guest", "roomId": "qq0iwfnd", "actualSessionId": "d3dbf2f5-…" }, "clientEvents": [ // chronological lifecycle (12 in the real sample) { "type": "CLIENT_JOINED", "timestamp": 1780572324515 }, { "type": "PEER_CONNECTION_OPENED", "timestamp": 1780572326790 }, // pc=b81c8d9d (media) { "type": "ICE_GATHERING_STATE_CHANGED", "timestamp": 1780572326811 }, // → gathering { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326812 }, // → connecting { "type": "PRODUCER_ADDED", "timestamp": 1780572326821 }, // producer=1abdaf82 (audio) { "type": "MEDIA_TRACK_ADDED", "timestamp": 1780572326821 }, // track=36ae42df (audio) { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326827 }, // → connected { "type": "PRODUCER_ADDED", "timestamp": 1780572326837 }, // producer=ba06a35b (video) { "type": "DATA_PRODUCER_CREATED", "timestamp": 1780572326853 } ], "clientMetaItems": [ // environment & devices, one-off (10 in the real sample) { "type": "USER_AGENT_DATA", "payload": "{…Chrome 148 / macOS…}" }, { "type": "MEDIA_DEVICE", "payload": "{…\"BRIO 4K Stream Edition\"…}" } // …mic / camera / speaker devices… ], "peerConnections": [ { "peerConnectionId": "b81c8d9d-…", // the media PC — Guest publishes to the SFU "outboundRtps": [ /* audio + video */ ], "outboundTracks": [ /* mic + camera: label, settings, capabilities */ ], "remoteInboundRtps": [ /* RTCP feedback from the SFU */ ], "codecs": [ /* … */ ], "iceTransports": [ /* … */ ], "iceCandidatePairs": [ /* … */ ], "dataChannels": [ /* … */ ] }, { "peerConnectionId": "8635acb7-…", "peerConnectionTransports": [ /* … */ ] } // signaling-only PC ] }