Ingestion & lifecycle

observer.accept(sample, context?)

The single entry point. In order, it:

  1. Drops the sample and emits sample-rejected if the observer is closed.
  2. Runs it through the accept-middleware chain, in registration order.
  3. Drops it and emits sample-rejected if callId or clientId is missing.
  4. Gets or lazily creates the ObservedCall and ObservedClient.
  5. Delegates to client.accept(sample, context), which fans out to each ObservedPeerConnection.accept(pcSample, context).
  6. Rolls metrics up peer connection → client → call → observer, running detectors and emitting events as it goes.
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

Sub-entities that stop appearing in samples are garbage-collected by a mark-and-sweep on each ObservedPeerConnection.accept(), emitting the corresponding *-removed events.

Accept middlewares

observer.addAcceptMiddleware(...) registers functions that run on every sample, before it reaches any call or client. Each receives { sample, context }, may mutate either, and calls next(payload) to continue.

Not calling next() drops the sample

Nothing is created, no event fires. That is the intended way to filter. A middleware that throws is caught and warned — the sample is dropped, accept() never crashes.

import { Observer, AcceptMiddleware } from "@observertc/observer-js";

const observer = new Observer();

// Derive ids from the application's own attachments, before dispatch.
const route: AcceptMiddleware = ({ sample }, next) => {
    sample.callId   ??= sample.attachments?.roomId as string;
    sample.clientId ??= sample.attachments?.peerId as string;
    next({ sample });
};

// Redact anything you must not persist.
const redact: AcceptMiddleware = (payload, next) => {
    delete payload.sample.attachments?.email;
    next(payload);
};

// Drop blocklisted clients entirely.
const filter: AcceptMiddleware = (payload, next) => {
    if (blocked.has(payload.sample.clientId)) return;   // no next() ⇒ dropped
    next(payload);
};

observer.addAcceptMiddleware(route, redact, filter);
observer.removeAcceptMiddleware(route);

When no middleware is registered, accept() dispatches directly with no overhead.

context vs appData

These are deliberately different, and confusing them is the most common integration mistake.

appDatacontext
OwnerThe applicationThe application
Set atEntity creation (settings.appData or a factory)Every accept() call
StoredYes, on the entityNo — discarded after the update
Changed by the libraryNevern/a
ReachesAnywhere you hold the entityOnly the *-updated events that this accept() triggers
type AcceptContext = Record<string, unknown>;

observer.accept(sample, { requestId, ingestNode: "eu-1", receivedAt: Date.now() });

observer.on("client-updated", ({ observedClient, sample, context }) => {
    tracing.record(context?.requestId, observedClient.clientId);
});

client-updated and peer-connection-updated carry the exact context of that sample. call-updated carries the context of the client accept() that drove it, and is absent for teardown-driven call updates.

appData factories

Rather than pre-creating entities just to enrich them, register a factory once. It runs in the entity’s constructor whenever one is created without an explicit settings.appData — including the lazy creation inside accept().

const observer = new Observer({
    createCallAppData:   ({ callId })                 => ({ callId, startedAt: Date.now(), region: "eu" }),
    createClientAppData: ({ clientId, observedCall }) => ({ clientId, region: observedCall.appData.region }),
});

The client factory receives the already-created parent call, so it can derive fields from it.

attachments arrive with the first sample, not at creation

observedClient.attachments (things like roomId and displayName the client put on the sample) is populated from the first accept(), after the entity was created. Read it on client-updated, not in a creation-time hook.

Get-or-create helpers

If you want to create and configure entities before or without samples:

const call   = observer.getOrCreateObservedCall({ callId, appData });      // ObservedCall | undefined
const client = call?.getOrCreateObservedClient({ clientId, appData });     // ObservedClient | undefined
  • getOrCreate* returns the existing instance if the id is known.
  • create* returns the existing instance and warns if the id already exists.
  • Both return undefined (and warn) when the parent is closed.

Guard the result. This is the library’s error-handling philosophy in practice — see below.

Automatic teardown

const observer = new Observer({
    closeClientIfIdleForMs: 60_000,   // a client with no sample for this long auto-closes
    closeCallIfEmptyForMs: 20_000,    // a call with zero clients for this long auto-closes
});

Both are also settable per entity via ObservedCallSettings.closeCallIfEmptyForMs and ObservedClientSettings.closeClientIfIdleForMs.

Closing cascades downward — call → clients → peer connections → sub-stats — unsubscribing listeners and emitting *-closed and *-removed events. Sinks are end()ed, open client issues are force-resolved with resolvedBy: 'client-closed', and running validators are cancelled.

Set both

Without these, a crashed participant leaves a client object alive forever and its open issues never close, which quietly poisons every cross-client detector. The registry does expire stale issue entries as a backstop, but idle timeouts are the real fix.

When things update

“Update” means: recompute aggregated metrics, run the detectors, emit the *-updated event at that level. Updates are event-driven — there is no built-in timer.

A call is updated when any of its clients is updated. The observer is updated when any of its calls is updated.

Composed, the observer updates exactly when any client anywhere updates. Two booleans, both defaulting to true, let you break a link in that chain:

SettingWhereEffect when false
autoUpdateOnClientUpdateObservedCallSettingsthe call updates only when you call call.update()
autoUpdateOnCallUpdateObserverConfigthe observer updates only when you call observer.update()

An application that wants a fixed cadence sets both to false and drives updates itself:

const observer = new Observer({ autoUpdateOnCallUpdate: false });

setInterval(() => observer.update(), 5_000);

Observer-scoped detectors run nowhere else

Observer-scoped detectors and validators run only inside observer.update(). If you disable auto-update and never call update(), they never run.

Removed in the 1.0.0 line

The updatePolicy / defaultCallUpdatePolicy enums ('update-on-any-…', 'update-when-all-…', 'update-on-interval') and the pluggable Updater are gone. “Update when all clients have updated” sounds appealing and deadlocks on the first client that stops sending — one silent participant froze the whole call’s aggregation until it timed out.

Error handling

The library warns and degrades; it does not throw on operational problems:

SituationBehaviour
createObservedCall / createObservedClient on a closed parentwarn, return undefined
Duplicate idwarn, return the existing instance
accept() on a closed clientwarn, no-op
Sample missing callId / clientIdsample-rejected with reason: 'missing-callId' | 'missing-clientId'
Observer closedsample-rejected with reason: 'observer-closed'
Middleware throwswarn, drop that sample
Sink write / end throwscaught, surfaced on the sink’s error event
observer.on("sample-rejected", ({ reason, sample }) => {
    metrics.increment("observer.sample_rejected", { reason });
    if (reason !== "observer-closed") deadLetter.write(sample);
});

A worked sample

What a real first sample looks like and what accept() does with it. This is one participant joining an SFU call:

{
  "timestamp": 1780572332518,
  "callId":   "d3dbf2f5-79be-4cb8-9d43-fb404f07ef27",
  "clientId": "c926983c-4468-4046-ae8c-a9cabe1a1868",
  "score": 0,
  "attachments": { "displayName": "Guest", "roomId": "qq0iwfnd" },

  "clientEvents": [
    { "type": "CLIENT_JOINED",                 "timestamp": 1780572324515 },
    { "type": "PEER_CONNECTION_OPENED",        "timestamp": 1780572326790 },
    { "type": "ICE_GATHERING_STATE_CHANGED",   "timestamp": 1780572326811 },
    { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326812 },
    { "type": "PRODUCER_ADDED",                "timestamp": 1780572326821 },
    { "type": "MEDIA_TRACK_ADDED",             "timestamp": 1780572326821 },
    { "type": "PEER_CONNECTION_STATE_CHANGED", "timestamp": 1780572326827 }
  ],

  "clientMetaItems": [
    { "type": "USER_AGENT_DATA", "payload": "{…Chrome 148 / macOS…}" },
    { "type": "MEDIA_DEVICE",    "payload": "{…\"BRIO 4K Stream Edition\"…}" }
  ],

  "peerConnections": [
    {
      "peerConnectionId": "b81c8d9d-…",
      "outboundRtps":      [ /* audio + video */ ],
      "outboundTracks":    [ /* mic + camera */ ],
      "remoteInboundRtps": [ /* RTCP feedback from the SFU */ ],
      "codecs": [], "iceTransports": [], "iceCandidatePairs": [], "dataChannels": []
    },
    { "peerConnectionId": "8635acb7-…", "peerConnectionTransports": [] }
  ]
}

In order, this emits: call-addedclient-addedclient-joinedpeer-connection-added (×2) → outbound-track-added and outbound-rtp-added per track → client-metadata per device item → client-event per lifecycle item → client-updated.

Every subsequent sample is much leaner: the same ids, no new events or metadata, just refreshed peerConnections stats. Those ticks refresh metrics and fire the *-updated events; the heavy join snapshot happens once.