Observer

@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.

Install

npm install @observertc/observer-js
import { 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.

Sixty-second start

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.

The five ideas

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.

1. One ingestion method

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.

2. One event bus

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 →

3. Lazy entities

Observer
└── ObservedCall
    └── ObservedClient
        └── ObservedPeerConnection
            ├── ObservedInboundRtp / ObservedOutboundRtp
            ├── ObservedInboundTrack / ObservedOutboundTrack
            ├── ObservedRemoteInboundRtp / ObservedRemoteOutboundRtp
            ├── ObservedIceTransport / ObservedIceCandidate / ObservedIceCandidatePair
            ├── ObservedCodec / ObservedMediaSource / ObservedMediaPlayout
            ├── ObservedDataChannel / ObservedCertificate
            └── ObservedPeerConnectionTransport

Every 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 →

4. Warn, don’t throw

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*.

5. Nothing implicit

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 →

What the server sees that a client cannot

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:

Is it the room or the person?

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.

Is it the publisher or the receiver?

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.

Is it our infrastructure?

The same symptom across independent calls shares no room, no publisher and no host — only the servers. That makes the finding conclusive.

Is our deployment built correctly?

One-shot validators answer structural questions: does the SFU adapt layers per receiver, is everyone on the codec you think you negotiated.

Issues arrive as intervals, not point-in-time reports

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.

Extension points

PointWhat it does
Accept middlewaresInspect, mutate or drop every sample before dispatch — route ids, redact, filter
DetectorsCross-client and cross-call detection raising call-issue / observer-issue
ValidatorsOne-shot structural checks that report once and remove themselves
Call summariesThe one record that outlives a call — who was in it, what was raised, how it scored
SinksPer-client persistence of every accepted sample (JSONL, in-memory, or your own)
RemoteTrackResolverPublisher ↔ subscriber track correlation for any SFU topology
mediasoup router observationThe server’s own ground truth, independent of client samples
Injection APIMerge application events, issues, metadata and attachments into a client’s sample stream
appData factoriesPopulate application data at entity creation, with the accept context available
LoggerRoute the library’s logs into pino, winston, or nothing

Documentation map

What changed in 1.0

Every break since the betas, and the migration for each.

Ingestion & lifecycle

accept(), middlewares, context vs appData, teardown, and when things update.

The event bus

The complete typed event catalogue and payload shapes.

Entities & API reference

Observer, call, client, peer connection — members, metrics and methods.

Detectors

The ten built-ins, the issue registry, conclusions, and writing your own.

Validators

One-shot structural checks for simulcast, resolver wiring and codec consistency.

Call summaries

The record that outlives the call, and why it is configured at construction.

SFU integration

Remote track resolution and mediasoup router observation.

Sinks & injection

Persisting samples per client, injecting app data, and logging.

Recipes

End-to-end patterns: HTTP ingestion, alerting, dashboards, archival.

Resources