@observertc/observer-js is the server half of ObserveRTC. A WebRTC application — typically an SFU
or a stats backend — feeds it ClientSample objects, and it maintains
an 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.
It answers the questions a browser structurally cannot: who else is in this state right now, what do they have in common, and where in publisher → SFU → subscriber does the fault begin?
Current version — 1.0.0
1.0.0 is the first stable release, and the API described here follows semver from now on: a
breaking change means a 2.0.0. Node.js ≥ 22, dual ESM + CommonJS from a single entry point.
Coming from a beta? Several things are gone rather than deprecated: the updatePolicy enum and the
pluggable Updater, detector configuration in ObserverConfig, auto-created detectors, and the
per-detector registry option. ObservedInboundRtp.bitrate is now bits per second (it was
1000× smaller than its outbound twin). See what changed in 1.0.
npm install @observertc/observer-jsimport { Observer, ClientSample, createJsonlFileSinkFactory } from "@observertc/observer-js";The same import line works in ESM and CommonJS projects — in ESM it resolves to the .mjs build, in
CommonJS to the .js build, with type declarations for both.
Runtime dependencies: @bufbuild/protobuf, events, uuid. mediasoup is an optional peer
dependency (>=3.11.0), needed only for router observation.
No logger and no transport are bundled. This release ships ClientSample schema 3.7.0.
import { Observer, ClientSample } from "@observertc/observer-js";
const observer = new Observer({
closeCallIfEmptyForMs: 20_000,
closeClientIfIdleForMs: 60_000,
});
// One bus. Every payload carries its full 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("client-issue-resolved", ({ resolvedIssue }) => {
console.info(resolvedIssue.type, "lasted", resolvedIssue.durationInMs, "ms");
});
observer.on("peer-connection-updated", ({ observedClient, observedPeerConnection }) => {
metrics.gauge("rtt_ms", observedPeerConnection.currentRttInMs, {
client: observedClient.clientId,
});
});
observer.on("sample-rejected", ({ reason }) => console.warn("dropped a sample:", reason));
// One ingestion method.
function onClientStats(sample: ClientSample) {
observer.accept(sample, { studioVersion: "1.2.3" });
}
process.on("SIGINT", () => observer.close());Entities are created lazily by id — you never pre-create a call or a client.
If you read nothing else
One ingestion method. One event bus. Lazy entities. Warn, don’t throw. Nothing implicit. Everything below is an elaboration of those five.
observer.accept(sample, context?);accept() runs the global middleware chain, gets or creates the call and the client by id, fans the
sample out to each peer connection, updates every derived metric, runs detectors, and emits. There
is no second entry point, no queue to drain, and no timer inside the library.
Subscribe on the Observer. Every payload is a single object carrying the ancestry from the
observer down to the entity that raised the event:
observer.on("inbound-rtp-added", ({ observer, observedCall, observedClient, observedPeerConnection, observedInboundRtp }) => {
// all five, correctly typed
});You never walk the tree to attach a listener. The event bus →
Observer
└── ObservedCall
└── ObservedClient
└── ObservedPeerConnection
├── ObservedInboundRtp / ObservedOutboundRtp
├── ObservedInboundTrack / ObservedOutboundTrack
├── ObservedRemoteInboundRtp / ObservedRemoteOutboundRtp
├── ObservedIceTransport / ObservedIceCandidate / ObservedIceCandidatePair
├── ObservedCodec / ObservedMediaSource / ObservedMediaPlayout
├── ObservedDataChannel / ObservedCertificate
└── ObservedPeerConnectionTransportEvery node is created the first time it appears in a sample and garbage-collected when it stops appearing. Entities that go idle auto-close on a timeout you configure. Entities & API →
Operational problems degrade rather than crash: create* returns T | undefined, a malformed
sample emits sample-rejected, a throwing middleware drops that one sample. Guard the result of
create* / getOrCreate*.
A new Observer() has zero detectors. There is no default detector set and no detector
configuration in ObserverConfig — an application says what it wants to watch, or it watches
nothing. A detector nobody asked for is a detector nobody will act on. Detectors →
This is the whole reason the library exists. A client running
client-monitor-js already decides what is wrong with that endpoint,
with hysteresis and multi-signal confirmation behind every verdict.
If a condition is detectable on the client, the client’s issue is the source of truth.
observer-js does not repeat that work. It correlates across participants:
Several participants congested at the same moment is a different incident from one person's Wi-Fi — and only the server can tell them apart.
Join a published track to every subscriber of it. If all of them see a freeze, the source or the forwarding path is at fault; if one does, it is that consumer.
The same symptom across independent calls shares no room, no publisher and no host — only the servers. That makes the finding conclusive.
One-shot validators answer structural questions: does the SFU adapt layers per receiver, is everyone on the codec you think you negotiated.
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 — the raise, and a <type>-resolved companion. The
observer pairs them and emits client-issue-resolved with durationInMs and resolvedBy.
That turns “several clients reported congestion in the last 10 seconds” — a heuristic that has to guess whether the symptoms are still happening — into “several clients are congested right now, simultaneously”, which is ground truth. Overlapping intervals are much stronger evidence of a shared cause than near-in-time reports.
| Point | What it does |
|---|---|
| Accept middlewares | Inspect, mutate or drop every sample before dispatch — route ids, redact, filter |
| Detectors | Cross-client and cross-call detection raising call-issue / observer-issue |
| Validators | One-shot structural checks that report once and remove themselves |
| Call summaries | The one record that outlives a call — who was in it, what was raised, how it scored |
| Sinks | Per-client persistence of every accepted sample (JSONL, in-memory, or your own) |
RemoteTrackResolver | Publisher ↔ subscriber track correlation for any SFU topology |
| mediasoup router observation | The server’s own ground truth, independent of client samples |
| Injection API | Merge application events, issues, metadata and attachments into a client’s sample stream |
appData factories | Populate application data at entity creation, with the accept context available |
| Logger | Route the library’s logs into pino, winston, or nothing |
Every break since the betas, and the migration for each.
accept(), middlewares, context vs appData, teardown, and when things update.
The complete typed event catalogue and payload shapes.
Observer, call, client, peer connection — members, metrics and methods.
The ten built-ins, the issue registry, conclusions, and writing your own.
One-shot structural checks for simulcast, resolver wiring and codec consistency.
The record that outlives the call, and why it is configured at construction.
Remote track resolution and mediasoup router observation.
Persisting samples per client, injecting app data, and logging.
End-to-end patterns: HTTP ingestion, alerting, dashboards, archival.