Entities & API reference

The hierarchy

ClassCreated byKeyed on its parent asHolds
Observernew Observer(config?)— (root)observedCalls, global counters, the event bus
ObservedCallobserver.createObservedCall(settings) or lazily by acceptobservedCallsobservedClients, call-wide metrics, detectors, scoreCalculator
ObservedClientcall.createObservedClient(settings) or lazilyobservedClientsobservedPeerConnections, per-client metrics
ObservedPeerConnectionlazily, from sample.peerConnections[]observedPeerConnectionsfifteen sub-stat maps, transport / RTT / bitrate metrics
Sub-statslazily, from the PeerConnectionSamplemaps on the PCindividual WebRTC stat objects plus derived fields

The fifteen sub-stat maps on ObservedPeerConnection, 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 schema fields plus derived deltas and bitrates.


Observer

new Observer<AppData>(config?: ObserverConfig<AppData>)

type ObserverConfig<AppData = Record<string, unknown>> = {
    // a call updates when any client does; the observer when any call does. Default true.
    autoUpdateOnCallUpdate?: boolean;

    appData?: AppData;

    closeClientIfIdleForMs?: number;
    closeCallIfEmptyForMs?: number;

    // appData factories — run when an entity is created without explicit appData.
    createCallAppData?: (p: { callId: string; observer: Observer }) => Record<string, unknown>;
    createClientAppData?: (p: { clientId: string; observedCall: ObservedCall }) => Record<string, unknown>;

    // per-client sink factory
    createClientSink?: (p: { clientId: string; observedCall: ObservedCall }) => ClientSampleSink | undefined;

    // per-call remote track resolver factory
    createRemoteTrackResolver?: (observedCall: ObservedCall) => RemoteTrackResolver | undefined;
};

Methods

MethodDescription
accept(sample, context?)The single ingestion entry point
addAcceptMiddleware(...mw) / removeAcceptMiddleware(...mw)Global pre-dispatch sample hooks
getObservedCall<T>(callId)ObservedCall<T> | undefined
createObservedCall<T>(settings)Returns the existing instance (with a warning) on duplicate id
getOrCreateObservedCall<T>(settings)Get or create
update()Force an aggregation tick and observer-updated
addObserverDetector(name, config?)Build a cross-call detector onto observer.detectors. Chainable
removeObserverDetector(name)Remove every instance under that name. Returns the count
addCallDetector(name, config?)Register a call-scoped detector for every call created from now on. Chainable
removeCallDetector(name, { includeOpenCalls? })Stop building it and, by default, drop it from open calls
addValidator(name, config?)Start a one-shot structural check. Chainable
cancelValidator(name | validator, reason?)Stop a running check; it finishes inconclusive with the reason
addIssue(issue)Raise an observer-level finding → observer-issue
createObservedMediasoupRouter(settings)Observe a live mediasoup router
close()Tear everything down

Properties

PropertyDescription
observedCallsMap<string, ObservedCall>
observedTURNAggregated TURN metrics across the fleet
detectorsObserver-scoped registry. Starts empty
callDetectorConfigsMap<name, config> — what addCallDetector recorded
validatorsSet<RunningValidator> — normally empty; each removes itself on finishing
activeIssuesRegistryThe fleet’s open client issues
appDataApplication data
numberOfCalls, numberOfClients, numberOfClientsUsingTurnLive counts
numberOfInboundRtpStreams, numberOfOutboundRtpStreamsLive counts
numberOfDataChannels, numberOfPeerConnectionsLive counts
totalAddedCall, totalRemovedCallCumulative counters
closedWhether close() has run

ObservedCall

type ObservedCallSettings<AppData = Record<string, unknown>> = {
    callId: string;
    appData?: AppData;
    // update this call whenever one of its clients accepts a sample. Default true.
    autoUpdateOnClientUpdate?: boolean;
    closeCallIfEmptyForMs?: number;
};

Members

MemberDescription
callId, appDataIdentity
observedClientsMap<string, ObservedClient>
numberOfClients, maxNumberOfClientsParticipant counts
getObservedClient<T>(clientId)| undefined
createObservedClient<T>(settings) / getOrCreateObservedClient<T>(settings)| undefined
addIssue(issue: ObserverIssue)Raise a call-level finding → call-issue
addDetector(name, config?)Build a call-scoped detector on this call only. Chainable
removeDetector(name)Remove and close() it. Returns the count
detectorsCall-scoped registry, empty by default
activeIssuesRegistryThis call’s open client issues; propagates into the observer’s
unconsumedOutboundTracksSet<ObservedOutboundTrack> — maintained by the resolver
remoteTrackResolver?Set from ObserverConfig.createRemoteTrackResolver at creation
scoreCalculator, score, calculatedScoreCall-level quality
numberOfIssues, numberOfPeerConnectionsAggregates
numberOfInboundRtpStreams, numberOfOutboundRtpStreams, numberOfDataChannelsAggregates
clientsUsedTurnSet<string> of client ids that relayed
startedAt?, endedAt?, closedAt?, closedLifecycle
update(), close()Control

ObservedClient

type ObservedClientSettings<AppData = Record<string, unknown>> = {
    clientId: string;
    appData?: AppData;
    closeClientIfIdleForMs?: number;
};

Identity and structure

MemberDescription
clientId, appData, callIdentity and parent
observedPeerConnectionsMap<string, ObservedPeerConnection>
attachmentsPopulated from the sample — read it on client-updated
sink?The per-client sink, if createClientSink is configured
activeIssuesLive registry of this client’s open issues, keyed by issue.key

Injection API

Queue application data to be merged into the client’s sample stream — it updates the live model and reaches the sink. See Injection.

observedClient.injectEvent(event);
observedClient.injectIssue(issue);
observedClient.injectMetaData(meta);
observedClient.injectExtensionStat(stat);
observedClient.injectAttachment({ roomId });

Direct-add variants process immediately instead of queueing: addIssue, addMetadata, addExtensionStats.

Metrics

GroupMembers
RTTcurrentAvgRttInMs?, currentMinRttInMs?, currentMaxRttInMs?
BitratesreceivingAudioBitrate, receivingVideoBitrate, sendingAudioBitrate, sendingVideoBitrate
CapacityavailableIncomingBitrate, availableOutgoingBitrate
TopologyusingTURN, usingTCP
CountsnumberOfInboundRtpStreams, numberOfOutboundRtpStreams, numberOfInbundTracks, numberOfOutboundTracks, numberOfDataChannels, numberOfPeerConnections
DeltasdeltaReceivedAudioBytes, deltaSentAudioBytes, …
Environmentbrowser?, engine?, platform?, operationSystem?, mediaDevices, mediaConstraints
LifecyclejoinedAt?, leftAt?, closedAt?, closed, score
Controlaccept(sample, context?), close()

browser / platform are what population detectors group by

ClientPopulationIssueDetector groups by exactly these fields. They are populated from clientMetaItems, which client-monitor-js sends automatically — so this works out of the box as long as you are not stripping metadata in a middleware.


ObservedPeerConnection

Members

GroupMembers
IdentitypeerConnectionId, client, appData?
Sub-stat mapsthe fifteen observed* maps listed above
Array getterscodecs, inboundRtps, outboundRtps, remoteInboundRtps, remoteOutboundRtps, mediaSources, mediaPlayouts, dataChannels, peerConnectionTransports, iceTransports, iceCandidates, iceCandidatePairs, certificates, selectedIceCandidatePairs, selectedIceCandiadtePairForTurn
StateconnectionState?, iceConnectionState?, iceGatheringState?, usingTURN, usingTCP
RTTcurrentRttInMs?, iceRttInMs?, rtcpRttInMs?, sfuHopRttInMs?
QualitycurrentJitter?, availableIncomingBitrate, availableOutgoingBitrate, sending/receiving bitrates, packet rates, total* and delta* byte/packet counters
Controlaccept(pcSample, context?), close(), score

Two different round trips — do not mix them

iceRttInMs vs rtcpRttInMs

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 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 appears as a sawtooth spike or a negative bitrate.

ObservedInboundRtp and ObservedOutboundRtp set counterResetBoundary on any tick where codecId, encoderImplementation / 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

During accept(), RTCP receiver and sender reports are linked to the local streams by remoteId (falling back to SSRC) and surfaced as fields. They are reset each tick and only set when the matching remote report is present.

OnFields
ObservedOutboundRtpremoteRttInMs?, remoteFractionLost?, remoteJitter?, remotePacketsLost?
ObservedInboundRtpremoteRttInMs?, remoteBytesSent?, remotePacketsSent?, remoteTimestamp?

When a RemoteTrackResolver is configured, tracks carry the publisher ↔ subscriber links directly:

outboundTrack.remoteInboundTracks;      // Set<ObservedInboundTrack> — every subscriber of this source
inboundTrack.remoteOutboundTrack;       // the publisher, or undefined if unlinked
inboundTrack.getInboundRtp();           // that receiver's RTP stats
observedCall.unconsumedOutboundTracks;  // published tracks with no subscriber at all

CallHealthAggregator

A ready-made per-call rollup along the client axis: how is each participant doing, sending versus receiving?

import { CallHealthAggregator } from "@observertc/observer-js";

const health = new CallHealthAggregator(observedCall).aggregate();

health.degradedRatio;          // 0.82 — 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/TCP

Percentiles, not means

Everything here rolls up as percentiles. One participant on a satellite link drags a mean RTT somewhere useless; the median plus the degraded ratio tells you whether the room is broken or one person is.

Statistics helpers

Exported for building your own aggregations and detectors:

import {
    percentile, median, summarize,
    counterDelta, robustZScore,
    SlidingWindow, TrendTester,
} from "@observertc/observer-js";