Monitors & derived metrics

Every derived value is a property of the stats — a bitrate, a mean, a delta — and is published whether or not any detector reads it.

Three conventions run through all of them:

  • A value the browser did not report is undefined, never 0. “Nothing arrived” and “nothing was lost” must not look the same.
  • Means exclude streams that carried nothing this tick rather than counting them as healthy.
  • Every duration is stats time, aged on the gaps between stats reports rather than on Date.now().

The monitor tree

ClientMonitor
└── PeerConnectionMonitor
    ├── InboundTrackMonitor / OutboundTrackMonitor
    ├── InboundRtpMonitor / OutboundRtpMonitor
    ├── RemoteInboundRtpMonitor / RemoteOutboundRtpMonitor
    ├── IceTransportMonitor → IceCandidatePairMonitor → IceCandidateMonitor
    ├── CodecMonitor, MediaSourceMonitor, MediaPlayoutMonitor
    ├── DataChannelMonitor, CertificateMonitor
    └── PeerConnectionTransportMonitor

Reaching a monitor

Every collection getter returns a fresh array, so hold the result rather than calling it in a loop.

monitor.peerConnections;      // PeerConnectionMonitor[]
monitor.tracks;               // TrackMonitor[] — both directions, every connection
monitor.inboundRtps;
monitor.outboundRtps;
monitor.remoteInboundRtps;
monitor.remoteOutboundRtps;
monitor.iceTransports;
monitor.codecs;
monitor.certificates;

monitor.getPeerConnectionMonitor(peerConnectionId);
monitor.getTrackMonitor(trackId);                    // either direction
monitor.mappedPeerConnections;                       // the underlying Map, to iterate without allocating

const pc = monitor.peerConnections[0];
pc.getTrackMonitor(trackId);
pc.getInboundTrackMonitor(trackId);
pc.getOutboundTrackMonitor(trackId);

Walking the graph

getStats() reports a flat list of objects that reference each other by id. The monitors resolve those joins once, so “what codec is this inbound stream using, and which ICE transport carries it?” is two property accesses. Every accessor returns undefined when the browser did not report the link, or when the object it points at has gone away.

FromAccessorTo
any monitorgetPeerConnection()PeerConnectionMonitor
InboundRtpMonitorgetTrack()InboundTrackMonitor
getCodec()CodecMonitor
getRemoteOutboundRtp()the sender’s own view
getMediaPlayout()MediaPlayoutMonitor
getIceTransport(), getSelectedCandidatePair()the path carrying it
OutboundRtpMonitorgetTrack(), getCodec(), getMediaSource()
getRemoteInboundRtp()the receiver’s report about us
getIceTransport(), getSelectedCandidatePair()as above
InboundTrackMonitorgetInboundRtp()InboundRtpMonitor
getLinkedVideoTrack()the video track this audio track is paired with
OutboundTrackMonitorgetMediaSource()MediaSourceMonitor
getOutboundRtps()one per simulcast layer
highestLayerthe layer carrying the most bits
MediaSourceMonitorgetTrack(), getOutboundRtps()the track and its layers
IceTransportMonitorgetSelectedCandidatePair(), getSelectedIcePath()
getInboundRtps(), getOutboundRtps()the streams attributed to this transport
IceCandidatePairMonitorgetLocalCandidate(), getRemoteCandidate()IceCandidateMonitor
RemoteInboundRtpMonitorgetOutboundRtp()the local stream it reports on
RemoteOutboundRtpMonitorgetInboundRtp()the local stream it describes
const track = monitor.getTrackMonitor(trackId);
const rtp = track?.direction === 'inbound' ? track.getInboundRtp() : undefined;

const codec = rtp?.getCodec()?.mimeType;               // 'video/VP8'
const pair = rtp?.getSelectedCandidatePair();
const relayed = pair?.getRemoteCandidate()?.candidateType === 'relay';
const senderView = rtp?.getRemoteOutboundRtp();        // what the far end says it sent

Renamed in 4.9

OutboundTrackMonitor.getHighestLayer() is now the property highestLayer. PeerConnectionMonitor.attributeRtpToTransport() is withdrawn — hasInboundMedia, hasInboundVideo and hasOutboundMedia answer what the detectors used it for, and IceTransportMonitor.getInboundRtps() / getOutboundRtps() are a plain transportId lookup.

Client level

monitor.sendingAudioBitrate;      // bps, aggregated across every peer connection
monitor.sendingVideoBitrate;
monitor.receivingAudioBitrate;
monitor.receivingVideoBitrate;

monitor.totalAvailableIncomingBitrate;
monitor.totalAvailableOutgoingBitrate;

monitor.avgRttInSec;                      // mean across connections
monitor.score;                            // 0.0–5.0, undefined until it settles
monitor.scoreReasons;                     // this entity's own subtractions
monitor.cpuUtilization;                   // the reading behind `cpulimitation`, published either way
monitor.durationOfCollectingStatsInMs;    // how long the collection took — wall clock, on purpose
monitor.createdAt;  monitor.uptimeInMs;   // how long this monitor has been running
monitor.activeTab;                        // false while the tab is backgrounded

Peer connection level

pc.sendingAudioBitrate;  pc.sendingVideoBitrate;
pc.receivingAudioBitrate; pc.receivingVideoBitrate;

// Means over the streams that actually carried packets this tick — `undefined`
// rather than 0 when none did.
pc.avgInboundFractionLost;      // mean interval inbound loss fraction (0..1)
pc.avgOutboundFractionLost;     // mean loss the far end reported for what we send
pc.avgInboundJitterInMs;        // published deliberately without a detector

// Sums kept for backwards compatibility
pc.outboundFractionLost;  pc.inboundFractionalLost;

// Round trip — two different measurements, never blended
pc.avgRttInSec;                 // rtcpRttInSec ?? iceRttInSec
pc.ewmaRttInSec;                // EWMA of whichever of those is reporting (α = 0.1)

// Pacer and jitter-buffer facts the capacity detectors read
pc.avgPacketSendDelayInMs;
pc.avgInboundVideoJitterBufferDelayInMs;
pc.availableOutgoingBitrate;    // undefined where no selected pair reported one
pc.qualityLimitationReason;     // most limiting reason across streams that sent something

// Deltas
pc.deltaInboundPacketsLost;  pc.deltaInboundPacketsReceived;
pc.deltaOutboundPacketsSent;
pc.deltaAudioBytesSent;  pc.deltaVideoBytesSent;  pc.deltaDataChannelBytesSent;

// Stats time, not wall clock: this collection's newest timestamp minus the previous one's.
pc.deltaTime;
pc.statsClockTime;              // accumulated stats time — the clock every window is aged on

// Topology and state
pc.usingTURN;  pc.usingTCP;  pc.iceState;
pc.connectingStartedAt;  pc.connectedAt;
pc.congested;  pc.uplinkCongested;  pc.downlinkCongested;
pc.hasInboundMedia;  pc.hasInboundVideo;  pc.hasOutboundMedia;
pc.selectedIcePath;  pc.selectedIcePaths;
pc.issues;                      // the IssueRegistry for this peer connection
pc.slicedWindow;                // the shared window every pc-level detector reads
pc.calculatedStabilityScore;    // { value, reasons, weight }

Withdrawn in 4.9

highestSeenSendingBitrate, highestSeenReceivingBitrate, highestSeenAvailableIncomingBitrate and highestSeenAvailableOutgoingBitrate are gone. The congestion detectors hold their own DecayingMaxEstimator, which forgets — a rolling maximum that never decays holds a finding open against a peak the path no longer reaches.

Track level

// Inbound
inboundTrack.bitrate;  inboundTrack.jitter;  inboundTrack.fractionLost;
inboundTrack.calculatedScore;      // { value, reasons, weight }
inboundTrack.issues;               // this track's IssueRegistry
inboundTrack.slicedWindow;
inboundTrack.frameFlowState;       // 'continuous' | 'choppy' | 'frozen'
inboundTrack.decodeBudgetUtilization;
inboundTrack.quantizationDegradation;
inboundTrack.displayMagnification; // sqrt(presented area / decoded area), unbounded
inboundTrack.linkedVideoPlayoutDiffInMs;
inboundTrack.contentType;  inboundTrack.motionType;   // read-only getters over the declared context
inboundTrack.presentedResolution;  inboundTrack.videoTag;
inboundTrack.paused;  inboundTrack.remoteOutboundTrackPaused;

// Outbound
outboundTrack.bitrate;
outboundTrack.sendingPacketRate;
outboundTrack.remoteReceivedPacketRate;
outboundTrack.jitter;  outboundTrack.fractionLost;    // as the far end reported them
outboundTrack.highestLayer;                            // was getHighestLayer()
outboundTrack.settings;  outboundTrack.videoCaptureSettingsChanged;
outboundTrack.calculatedScore;  outboundTrack.issues;

The context fields are read-only getters as of 4.9 — write them with ClientMonitor.setInboundTrackContext() / setOutboundTrackContext(), or trackMonitor.setContext().

Inbound RTP

// Rates
inboundRtp.bitrate;  inboundRtp.packetRate;  inboundRtp.fractionLost;
inboundRtp.bitPerPixel;                 // bitrate / (width × height × fps)

// Video timing
inboundRtp.avgFramesPerSec;
inboundRtp.ewmaFps;
inboundRtp.interFrameDelayVariation;    // frame-timing stability (lower is better)
inboundRtp.fpsVolatility;               // deprecated: prefer interFrameDelayVariation

// Audio — the "how did it sound" set
inboundRtp.inventedSpeechRatio;         // share NetEQ invented this interval — silence excluded
inboundRtp.concealmentEventRate;
inboundRtp.timeStretchRate;             // share of samples stretched or compressed
inboundRtp.avgJitterBufferDelayInMs;    // latency the buffer actually added, per sample
inboundRtp.jitterBufferTargetDelayInMs; // what NetEQ is aiming for
inboundRtp.discardRate;                 // packets that arrived too late to use
inboundRtp.estimatedPlayoutTimestamp;   // the sender's NTP time of the last playable sample

// Video decode cost and recovery pressure
inboundRtp.decodeTimePerFrameInMs;
inboundRtp.droppedFrameRatio;           // this interval's share (was `dropRatio`)
inboundRtp.renderRatio;                 // frames rendered vs decoded
inboundRtp.keyFrameRate;  inboundRtp.pliRate;  inboundRtp.firRate;  inboundRtp.nackRate;
inboundRtp.retransmissionRatio;
inboundRtp.avgQpPerFrame;

// Deltas
inboundRtp.deltaPacketsLost;  inboundRtp.deltaPacketsReceived;  inboundRtp.deltaBytesReceived;
inboundRtp.deltaFramesReceived;  inboundRtp.deltaFramesDecoded;  inboundRtp.deltaFramesRendered;
inboundRtp.deltaKeyFramesDecoded;  inboundRtp.deltaPliCount;
inboundRtp.deltaJitterBufferDelay;  inboundRtp.deltaCorruptionProbability;
inboundRtp.deltaTime;

Renamed and removed in 4.9

concealmentRateinventedSpeechRatio, and it now excludes silent concealment. dropRatiodroppedFrameRatio, and it is this interval’s share rather than the call’s. isFreezed is gone — InboundVideoFlowStateDetector owns the verdict and publishes it as InboundTrackMonitor.frameFlowState.

Every delta is counter-reset safe: a counter that goes backwards (SSRC reuse, an ICE restart, a stats-object replacement) yields 0 rather than a negative value, so no rate derived from it can go negative. packetsLost legitimately decreases when a late packet arrives, so the guard is not merely defensive there.

Outbound RTP

outboundRtp.bitrate;
outboundRtp.payloadBitrate;             // excludes headers and retransmissions
outboundRtp.packetRate;
outboundRtp.bitPerPixel;

outboundRtp.encodeTimePerFrameInMs;     // the most direct send-side CPU signal
outboundRtp.avgQpPerFrame;
outboundRtp.avgPacketSendDelayInMs;     // per-packet pacer delay
outboundRtp.retransmissionRatio;  outboundRtp.retransmittedPacketRatio;
outboundRtp.keyFrameRate;  outboundRtp.nackRate;  outboundRtp.pliRate;  outboundRtp.firRate;

// What the encoder spent THIS interval doing, 0..1 — unlike the raw
// qualityLimitationDurations accumulators, this can be compared to a threshold.
outboundRtp.qualityLimitationDurationShares;
// => { none: 0.25, cpu: 0.75, bandwidth: 0, other: 0 }

outboundRtp.deltaPacketsSent;  outboundRtp.deltaBytesSent;  outboundRtp.deltaFramesEncoded;

Remote RTP

// Remote inbound — what the far end reports about the stream we send
remoteInboundRtp.packetRate;
remoteInboundRtp.deltaPacketsLost;
remoteInboundRtp.deltaFractionLost;
remoteInboundRtp.avgRoundTripTimeInSec;   // totalRoundTripTime / roundTripTimeMeasurements
                                          // — `roundTripTime` alone is one noisy measurement

// Remote outbound — what the far end reports about the stream we receive
remoteOutboundRtp.bitrate;
remoteOutboundRtp.deltaPacketsSent;

ICE transport, candidate pairs and data channels

iceTransport.sendingBitrate;  iceTransport.receivingBitrate;
iceTransport.deltaBytesSent;  iceTransport.deltaBytesReceived;
iceTransport.deltaPacketsSent;  iceTransport.deltaPacketsReceived;
iceTransport.deltaSelectedCandidatePairChanges;   // from the browser's own counter, where reported
iceTransport.everConnected;                       // latched the first time it read connected
iceTransport.detectors;                           // the three Blocked* detectors live here

candidatePair.availableIncomingBitrate;
candidatePair.availableOutgoingBitrate;
candidatePair.deltaResponsesReceived;             // the STUN consent counter
candidatePair.tuple;                              // local:port:remote:port:protocol

dataChannel.deltaBytesSent;  dataChannel.deltaBytesReceived;

Media source and playout

mediaSource.deltaFrames;      // frames the capture source produced this interval
mediaSource.sourceFps;        // …as a rate — compare against what the encoder managed
mediaSource.rmsAudioLevel;    // RMS over the interval, from totalAudioEnergy — unlike
                              // `audioLevel` it does not read zero between words
mediaSource.getOutboundRtps();

mediaPlayout.deltaSynthesizedSamplesDuration;
mediaPlayout.deltaSamplesDuration;
mediaPlayout.synthesizedSamplesRatio;      // synthesized share of the interval, 0..1
mediaPlayout.playoutDelayPerSampleInMs;    // `totalPlayoutDelay` grows forever; this can be
                                           // compared to a threshold

Extension stats

Anything your application measures can be folded into the monitor tree and read back off it.

monitor.addExtensionStats({
    type: 'render-stats',
    id: 'tile-42',                  // giving an id is what makes it readable back
    payload: { droppedFrames: 3, canvasFps: 24 },
});

monitor.getExtensionStatsPayload<{ droppedFrames: number }>('tile-42')?.droppedFrames;  // 3
monitor.getExtensionStatsMonitor('tile-42')?.timestamp;
monitor.mappedExtensionStatsMonitors;

It is a current-value store, not a history. Each id holds only the most recent payload, and a monitor is dropped one collection after the id stops being reported. To report every collection without wiring a timer, register a provider — providers are awaited as part of each collection, so their values land in the same tick as the getStats() they sit beside:

monitor.extensionStatsProviders.add(async () => ({
    type: 'render-stats',
    id: 'tile-42',
    payload: { canvasFps: renderer.fps },
}));

Reading them

monitor.on('stats-collected', () => {
    console.log('sending:', monitor.sendingAudioBitrate + monitor.sendingVideoBitrate);

    for (const pc of monitor.peerConnections) {
        console.log(pc.peerConnectionId, 'RTT', (pc.avgRttInSec ?? 0) * 1000, 'ms');

        for (const track of pc.mappedInboundTracks.values()) {
            if (track.kind !== 'video') continue;
            const rtp = track.getInboundRtp();
            console.log('fps', rtp?.ewmaFps, 'bpp', rtp?.bitPerPixel, 'flow', track.frameFlowState);
        }
    }
});