Telemetry detectors

This is the reference for category 5. Nothing here judges the call. What it buys is the missing column in most investigations: which codec the bad calls were using, when the layer stopped being sent, whether the path moved just before the complaint.

Session     who, what call, what scope
Endpoint    what machine, browser and devices
Media       what is being encoded, sent and decoded
Transport   what path the media is travelling on
Lifecycle   what happened, and when — including to the instrument itself

Telemetry has no rung in the ladder, because it never fires. It is the context you read alongside categories 1–4, and the five sub-layers are a vocabulary for what kind of context you are reading.

Several of these layers are not carried by detectors at all. Session and Endpoint are sample fields, client meta items and peer-connection lifecycle events; Transport is carried half by a detector and half by SelectedIcePath, which is a monitor. They are documented here anyway, because a reader asking “how do I find out what kind of path this call used” needs the answer regardless of which mechanism provides it.

What makes something telemetry

The membership test is deliberately counterfactual:

Would raising an issue here ever be the right thing to do? If no, it is telemetry.

Not “does it raise one today”. That phrasing is what stops the category becoming a bucket for detectors nobody got around to finishing. Worked through the members:

  • IceTraversalDetector — a threshold on tuple changes is what UnstableIcePathDetector already owns, and it is a different claim. What is left is a single path move, which is what happens when Wi-Fi hands over to cellular. Reporting a successful handover as a fault would be wrong at any threshold.
  • CodecChangeDetector — H264 is not a fault, VP8 is not a fault, and a mid-call switch is renegotiation or a hardware encoder falling back to software: both are the system working.
  • VideoResolutionChangeDetector — the adaptation ladder moving is the adaptation ladder working. A picture genuinely too poor to watch already has an owner in PixelatedVideoDetector.
  • SimulcastLayerDetector — layers are meant to come and go.
  • CaptureTrackMutedDetectortrack.muted covers the deliberate system mute and the accidental device grab with one flag, and the library cannot tell them apart. Raising would file thousands of correct mutes as call failures.
  • StatsGapDetector — the subject is not the call, it is the measurement.
  • IceRestartDetector / IceRestartRecommendationDetector — a restart is what a healthy application does, so an issue would flag the recovery rather than the problem.

‘Raises no issue’ is not ‘is telemetry’

Category is decided by the question a class answers, not by whether it raises an issue. Ten classes in the library emit only events; eight are telemetry and two are not:

  • IcePathEstablishmentDetector is connectivity, layer 3 — “establishment is taking a long time” is not yet a claim that it failed, and that claim is a separate class.
  • AudioPlayoutSynthesisDetector is perceived quality — it fails the counterfactual test, because a listener hearing invented speech across a sustained window is a fault worth raising.

Filing either under telemetry would put a genuine finding somewhere nobody looks for findings.

The grid

LayerWhat carries itClass or mechanismEventConfig key
SessionSample fieldscreateSample()clientId, callId, attachments, timestamp(none)clientId, callId
SessionClient eventsaddClientJoinEvent() / addClientLeftEvent()CLIENT_JOINED, CLIENT_LEFTaddClientJointEventOnCreated, addClientLeftEventOnClose
EndpointClient meta itemsfetchUserAgentData()USER_AGENT_DATA (meta)(always, on creation)
EndpointClient meta itemswatchMediaDevices()MEDIA_DEVICE, MEDIA_DEVICES_SUPPORTED_CONSTRAINTS, USER_MEDIA_ERROR (meta)integrateNavigatorMediaDevices
MediaDetectorCodecChangeDetectorcodec-changed / CODEC_CHANGEDcodecChangeDetector
MediaDetectorVideoResolutionChangeDetectorvideo-resolution-changed / VIDEO_RESOLUTION_CHANGEDvideoResolutionChangeDetector
MediaDetectorSimulcastLayerDetectorsimulcast-layer-changed / SIMULCAST_LAYER_CHANGEDsimulcastLayerDetector
TransportDetectorIceTraversalDetectorice-tuple-changed (monitor event only)iceTraversalDetector
TransportMonitorSelectedIcePathice-path-changed / PEER_CONNECTION_ICE_PATH_CHANGED(always)
TransportDetectorIceRestartDetectorice-restart / ICE_RESTARTiceRestartDetector
TransportDetectorIceRestartRecommendationDetectorice-restart-recommended / ICE_RESTART_RECOMMENDEDiceRestartRecommendationDetector
TransportSample fieldsIceTransportMonitor.createSample() — roles, ciphers, ufrag(none)sendIceTransportMetadataOnChangeOnly
LifecycleDetectorCaptureTrackMutedDetectorcapture-track-muted / CAPTURE_TRACK_MUTEDcaptureTrackMutedDetector
LifecycleDetectorStatsGapDetectorstats-collection-gap / STATS_COLLECTION_GAPstatsGapDetector
LifecycleSource bindingwatchTabVisibility()TAB_VISIBILITY_CHANGEDwatchTabVisibility
LifecycleSource bindingspeer connection and track bindingsPEER_CONNECTION_OPENED, MEDIA_TRACK_ADDED, ICE_CANDIDATE_ERROR, …(per binding)

Every detector row now names a key of its own. Two used to be awkward and no longer are: IceTraversalDetector was the one detector registered unconditionally, silenceable only by name, and CaptureTrackMutedDetector used to come and go with the two capture detectors that do raise issues — which is rarely what an application silencing mute noise actually wants.

The shape they share: first observation is a baseline

Every change-reporting class treats the first value it sees as a baseline and reports nothing for it. Without that, every session would report a change in its first seconds, for every track, for nothing. A codec that was VP8 from the first tick did not change to VP8.

What that costs, stated plainly

A change stream tells you every transition and never tells you the starting state. A consumer reading only codec-changed events cannot answer “what codec was this call using” for the overwhelming majority of calls, because most calls change codec zero times.

FactChange eventWhere the initial state is
Codeccodec-changedPeerConnectionSample.codecs, via inboundRtps[].codecId / outboundRtps[].codecId
Resolutionvideo-resolution-changedinboundRtps[] / outboundRtps[] frameWidth / frameHeight
Simulcast layerssimulcast-layer-changedoutboundRtps[] — one row per encoding, with rid, active, scalabilityMode
Selected ICE pathice-tuple-changediceCandidatePairs[] + iceCandidates[], via iceTransports[].selectedCandidatePairId
Muted capturecapture-track-mutedMEDIA_TRACK_ADDED’s muted field, plus MEDIA_TRACK_MUTED / _UNMUTED

Sample plus events is the complete picture; either alone is not.

One further caveat: addEvent() returns immediately when the monitor is not sampling and bufferingEventsForSamples is false (the default). With samplingPeriodInMs unset or zero, no client event is recorded at all — while the detector’s own monitor event still fires. A monitor configured to collect but not sample therefore has working telemetry on the event emitter and an empty client-event stream, which is easy to mistake for a detector that is not working.

Session

Session identity is carried by the sample itself: clientId, callId, timestamp, and attachments — a free-form Record<string, unknown> for what the library cannot know, typically roomId, userId and displayName.

clientId and callId are settable at any time (monitor.clientId = …), which matters for the common shape where the monitor is constructed before the application knows which room the user is joining. Samples created before they are set carry undefined and a server-side correlation has to stitch them.

close() creates one final sample after adding the leave event, so CLIENT_LEFT ships rather than dying in the buffer.

Endpoint

fetchUserAgentData() runs once on construction and adds a USER_AGENT_DATA meta item with the parsed browser, engine, OS, device and CPU records. The same call sets monitor.browser, which is not merely descriptive: setting it installs the per-browser stats adapters on every peer connection, so what the endpoint is changes what the library can see. Browsers outside the recognised set are set to unknown rather than guessed at.

watchMediaDevices() reports MEDIA_DEVICE per enumerated device, MEDIA_DEVICES_SUPPORTED_CONSTRAINTS once, and USER_MEDIA_ERROR when a getUserMedia call rejects. The last is the one worth noticing: a permission denial or an OverconstrainedError is the reason a great many “nothing worked” sessions have no media at all, and it is invisible in getStats() because no track was ever created to have stats about.

Media

CodecChangeDetectorcodec-changed

Which codec a track is using, and when that changes. The codec in use is the missing column in nearly every aggregate quality question — why the bad calls cluster on H264, whether AV1 is being negotiated anywhere at all, whether a hardware encoder quietly fell back to software mid-call.

A change is a difference in either mimeType or sdpFmtpLine. Comparing the mime type alone would miss an H264 profile-level-id switch, which is a real codec change with real consequences — a different profile is a different decoder path and a different bitrate efficiency — and would otherwise be completely invisible.

codecChangeDetector: { createEvent: true }

The cost is genuinely negligible, which is why this is on by default: a codec changes once or twice in a call if it changes at all, unlike a per-tick metric.

VideoResolutionChangeDetectorvideo-resolution-changed

Direction is classified by pixel count: upgrade when it rises, downgrade when it falls, and reshape when the count is unchanged but the dimensions are not — an orientation change on mobile, typically. A zero or absent frame size is a stream that has not produced a frame yet rather than a downgrade to nothing, and is refused.

qualityLimitationReason is the point of the event. From the resolution alone, “the encoder dropped resolution because of bandwidth or CPU” and “the application changed its constraints” are identical, and confusing them sends an investigation in exactly the wrong direction. The field is only meaningful outbound; on the receive side a resolution change usually means the SFU switched which simulcast layer it forwards.

On an outbound simulcast track only the highest layer is followed, since the track legitimately carries several resolutions at once.

Two payloads, one confusing field name

The monitor event’s field is direction (the upgrade/downgrade/reshape classification), while the client event calls that field change and uses direction for inbound/outbound. They are different names for different things in the two payloads, and reading one shape into the other is a mistake the naming invites.

SimulcastLayerDetectorsimulcast-layer-changed

When the set of simulcast layers an outbound video track is actually sending changes. This is a fact about encoder behaviour under bandwidth and CPU pressure, and it is otherwise completely invisible: an SFU-side “why is this participant blurry” investigation has no client-side record that the high layer stopped being produced at all.

A layer counts as active only when the encoding is not explicitly disabled and it actually sent bytes in the interval. Trusting active alone would hide exactly the transition worth reporting: active: true with no bytes is the common real-world shape of a layer the encoder has quietly given up on.

Layers are named by rid where the application sets one and by SSRC otherwise. While the producer is paused the baseline is discarded entirely, so resuming re-establishes it rather than reporting the pause and the resume as two layer changes — this is the only member of the category with a pause gate, and it is there because a paused producer sends no bytes on any layer.

scalabilityMode rides along in the per-layer snapshot and is worth having — it separates true simulcast from SVC, and L1T3 from L3T3 — but it is absent on Safari. Treat undefined as “not reported” rather than as “no scalability mode configured”.

Transport

IceTraversalDetectorice-tuple-changed

That the set of selected ICE candidate pairs changed — the network path underneath the call moved, which is what a user experiences as the brief cut-out when Wi-Fi hands over to cellular, a VPN comes up, or a NAT rebinding forces a new pair.

It holds a Set<string> of tuples (localAddress:localPort:remoteAddress:remotePort:protocol) and diffs it both ways each tick. Because the tuple is computed in one place — on the candidate pair monitor — this detector and the connectivity detectors can never disagree about what the selected path is.

It emits the monitor event only: this fact does not reach the sample through this detector at all. iceTraversalDetector: {} enables it, null disables it.

One raw signal, two categories

The selected candidate pair changing is a single observation. Read once, it is a fact about the session: the path moved, here is when. Read as a rate — three or more switches inside thirty seconds — it is a claim that the path is oscillating rather than migrating, which is an issue with a threshold and a resolve condition, raised by UnstableIcePathDetector from the same stats independently. Neither reads the other’s conclusion. That is not duplication; it is the difference between recording a fact and judging it.

SelectedIcePath — the path monitor

Not a detector. One per ICE transport with a selected candidate pair, reachable as pcMonitor.selectedIcePaths (or selectedIcePath for the BUNDLE case). It is the single authoritative interpretation of “what path is this peer connection actually using”, and it holds no copies of candidate data — every descriptive getter reads through the linked monitors, so the path can never disagree with the stats it was built from.

IcePathKind is direct, turn-udp, turn-tcp, turn-tls or turn-unknown, derived candidate-type-first. turn-unknown means TURN is definitely in use but the browser did not expose how the endpoint reaches the TURN server.

Each tick it classifies the transition: initial-selection, direct-to-relay, relay-to-direct, relay-protocol-changed, turn-server-changed, or a plain path-changed. Unlike IceTraversalDetector it does report the initial selection — as a transition with no from — which is what makes the event stream able to answer “what path did this call start on” at all.

What it computes and keeps live on the object (none of it reaches the sample):

FieldWhat it holds
durationsMilliseconds spent in each IcePathKind
relayDurationInMsThe four relay kinds’ durations summed
timeToFirstRelayInMsFrom path creation to the first relay selection; undefined if never
pathSwitches, directToRelaySwitches, relayToDirectSwitchesSwitch counts after the initial selection
relayProtocolSwitches, turnServerSwitchesSame TURN server on a different transport; a different TURN server
totalBytesSent / Received, totalPacketsSent / ReceivedTraffic observed across the path’s life
relayBytesSent / Received, relayBytesRatioThe portion of that which travelled over a relay
getSwitchCountSince(timestamp)Switches at or after a wall-clock instant, from a bounded 64-entry ring

The restart classes

IceRestartDetector and IceRestartRecommendationDetector are telemetry by category and documented with the ladder they describe — see connectivity detectors. They are listed here so a reader working from the category rather than the subject can find them: eight classes are categorised telemetry, six of them documented on this page.

What the sample already carries

IceTransportMonitor.createSample() ships iceRole, iceLocalUsernameFragment, localCertificateId, remoteCertificateId, tlsVersion, dtlsCipher, dtlsRole and srtpCipher as static metadata: under sendIceTransportMetadataOnChangeOnly (default true) they are emitted in the first sample and again only when one of them changes — which for the username fragment is exactly at an ICE restart.

Absence means unchanged, not unknown

A consumer must keep the last seen value per transport id. Set sendIceTransportMetadataOnChangeOnly: false to restore every-sample emission.

Lifecycle

CaptureTrackMutedDetectorcapture-track-muted

The moment something outside the application took the capture device away: track.muted flipped to true. The OS grabbed the microphone for a system call, another application claimed exclusive camera access, the lid closed, the privacy shutter moved, the device slept.

This is not the application’s own mute — that is track.enabled, which the application sets and therefore already knows about. track.muted is the browser’s statement that the source has stopped delivering data.

It reads the DOM track object and nothing from the stats, which makes it one of the few detectors that would still work with no getStats() output whatsoever. Only the false → true transition is reported; a track already muted when monitoring began is refused, because it may have been muted since before the call.

The three capture classes, and why they are three:

ClassCategoryRaisesBecause
CaptureTrackMutedDetectorTelemetry(event only)track.muted covers the deliberate system mute and the accidental device grab with one flag. Most mutes are correct
CaptureSourceLostDetectorPipeline disruptioncapture-source-lostreadyState: 'ended' is terminal and never intentional mid-call
SilentAudioSourceDetectorPipeline disruptionsilent-audio-sourceA live, unmuted, enabled microphone producing digital silence for a sustained span is a broken capture chain

What separates them is not where they sit in the stack — all three watch the same capture stage — but whether the observation could ever be the user doing something correct. Ended and silent could not; muted routinely is.

The value of the mute event is a timestamp: the record of when capture stopped, next to which the silence and dry-track findings that follow stop looking mysterious.

StatsGapDetectorstats-collection-gap

That stats collection itself ran late. This is telemetry about the instrument, not the call, and it is the only signal in the library that changes how you read the other signals.

Every rate the library reports is a delta divided by an elapsed time, and all of them assume collection happened roughly on schedule. When the tab is backgrounded, the device sleeps, or the main thread is blocked long enough, counters keep advancing while the monitor is not looking, and the first tick afterwards attributes a large accumulation to a short window — which reads as a network event that never happened. Rather than trying to correct for it, the gap is reported so a consumer can discount that interval.

statsGapDetector: {
    gapRatioThreshold: 2,  // multiple of collectingPeriodInMs that counts as a gap
    minGapInMs: 5000,      // a single missed short tick is jitter, not a gap
    createEvent: true,
}

An overrun must clear both the ratio and the absolute floor. With the default 5-second floor and a 200 ms collecting period, a 500 ms tick is 2.5× over and correctly says nothing.

It is deliberately the one detector that measures wall-clock time. Design rule 3 exists so a backgrounded tab does not credit itself with a minute of watching a condition nobody observed; applying it here would be meaningless, because how late the library ran is exactly what this detector exists to measure, and stats time is by construction the clock that cannot see it. The rule and this detector are not in tension: the rule protects verdicts about the call, and this detector reports the monitor’s scheduling as its subject.

A stats-collection-gap next to a burst of pipeline issues is very often the explanation for the burst rather than a co-symptom of it.

The client-event stream

Most of the Lifecycle layer is not detectors at all. The source bindings forward peer-connection and track events into addEvent() as they happen: PEER_CONNECTION_OPENED / CLOSED, MEDIA_TRACK_ADDED / REMOVED / MUTED / UNMUTED, ICE_GATHERING_STATE_CHANGED, ICE_CONNECTION_STATE_CHANGED, PEER_CONNECTION_STATE_CHANGED, SIGNALING_STATE_CHANGE, NEGOTIATION_NEEDED, ICE_CANDIDATE, ICE_CANDIDATE_ERROR, the data-channel events, and the mediasoup producer and consumer events where a mediasoup transport is bound.

TAB_VISIBILITY_CHANGED sits with them and does double duty: it keeps monitor.activeTab in sync so the pause-aware detectors can stand down, and it puts every transition in the sample stream so a reader can see exactly when the tab went to the background and came back. Where no usable document exists — SSR, workers, react-native — the watcher logs and leaves activeTab at true, because a missing watcher must never look like a hidden tab.

This stream is telemetry by every part of the definition. It is not a detector because there is nothing to detect: the browser already said it.