October 26, 202625 minutes
In The Bigger Picture, we separated connectivity from transport quality and the media pipeline. This time, let’s work through connectivity: what to check on the client, what each observation means, and how ObserveRTC’s detectors help us keep track of it. The five connectivity layers are reachability, discovery and traversal, path establishment, secure transport, and path continuity.
A participant clicks Join. Their camera preview works, the room opens, and the application says “Connecting…”
[Signaling] Descriptions and candidates delivered and applied
|
v
[Reachability] Can we reach the ICE infrastructure?
|
v
[Discovery] Local candidates available; gathering state known
|
v
[Establishment] Successful pairs, nomination, selected pair
|
v
[Transport] Secure transport reaches connected
|
v
[Continuity] State, traffic, selected path, restart outcomes
Signaling needs application logs. Check media progress separately.
Figure 1. Follow the connection from signaling through ICE and DTLS, then watch whether the established path stays connected.
For a failed join, I would start with the last thing we can show actually happened. Did the offer and answer reach both endpoints? Did they exchange ICE candidates? Were connectivity checks getting responses? Was a candidate pair selected, or did a working path disappear later?
WebRTC uses an offer and answer to negotiate the session, but your application delivers them. With trickle ICE, it also delivers newly gathered candidates. So the first question we need to ask before investigating the network path: was the offer received and applied, was an answer sent back, and did the candidates reach the right peer connection?
An open WebSocket doesn’t prove SDP offers are accepted and answered. We need to record failures from setLocalDescription(), setRemoteDescription(), and addIceCandidate().
ObserveRTC doesn’t detect signaling delivery failures. Its native binding reads descriptions for media context, but your application logs have to tell us whether the offer, answer, and candidates arrived and were applied. Keep those records alongside the monitor’s timeline.
ICE gathering finds the local possibilities. The ICE specification, RFC 8445, distinguishes host addresses, server-reflexive addresses discovered using STUN, relayed addresses allocated through TURN, and peer-reflexive addresses learned during checks.
In the statistics, those candidate types appear as host, srflx, relay, and prflx. A host candidate comes from a local interface. STUN helps discover the address mapped by a NAT, while TURN provides a relay address for forwarding traffic when a direct path won’t work.
Let’s start with what gathering produced. While it’s still running, an empty candidate list may simply mean we need to wait. But if gathering is finished and the list is still empty, there are no local candidates to try.
The participant is ready to join, but their browser hasn’t found a local address it can use for the connection. There may be someone waiting at the other end; we haven’t reached the point where we can try a path to them. An unavailable network interface or a restrictive candidate policy could leave us here. If the application allows only relay candidates, a problem obtaining a TURN allocation could do the same—even while ordinary websites still load.
Client Monitor’s IceReachabilityDetector starts in Watching. It raises no-available-ice-candidate only when gathering is complete, no local candidates exist, and the connection has never been observed connected. It normally waits 6,000 ms, configured through iceReachabilityDetector.thresholdInMs. A disconnected or failed connection bypasses that wait, but still needs completed gathering and an empty candidate list. Missing gathering state or ongoing gathering leaves it watching.
[Watching] -- pending / candidate present (reset wait) --> [Watching]
[Watching] -- empty gathering + timeout/failure --> [Raised]
[Raised] -- candidate found: resolve issue, reset wait --> [Watching]
[Raised] -- connected/closed: resolve issue --> [Done]
[Watching] -- connected/closed --> [Done]
Empty gathering: gathering complete, zero local candidates, never connected.
Timeout/failure: 6,000 ms of stats time without candidates OR disconnected/failed.
A candidate appearing does not prove connection success. It ends the empty-candidate
issue; monitoring continues until connected or closed.
Figure 2. Reachability detection, from watching to a raised issue or completed checks.
The wait includes stats time before gathering finishes, so an attempt that ends empty can raise immediately at completion. A candidate appearing resets the wait and resolves an open issue; monitoring continues because the connection hasn’t necessarily succeeded. Connected or closed ends the checks and resolves any open issue. To investigate the empty inventory, check the application’s candidate policy, ICE-server configuration and gathering errors—the finding doesn’t identify a faulty server.
The participant joins successfully, but their traffic goes through a relay. That’s a usable route, not a failed call. TURN provides an allocated address and forwards traffic when the direct alternatives don’t work. The route can also change during the call: Wi-Fi drops, a VPN comes up, or another candidate pair takes over. Before calling that a problem, we need to know what changed and whether the participant noticed an interruption.
Client Monitor’s IceTraversalDetector compares the set of selected network tuples: local address and port, remote address and port, and protocol. If that set changes after a nonempty set was observed, it emits ice-tuple-changed. It raises no issue and has no timeout or recovery threshold: this is Telemetry, recording a change rather than declaring a fault. Enable it with iceTraversalDetector: {} or omit it with null.
[Compare] -- unchanged / previous set empty --> [Compare]
[Compare] -- changed + previous set nonempty --> [Notify]
[Notify] -- next collection --> [Compare]
Compare the set of selected local/remote address, port and protocol tuples.
Notify emits ice-tuple-changed; it never raises an issue.
A nonempty set becoming empty counts as a change. Growth from empty is skipped.
Figure 3. Selected-path changes produce telemetry, not a raised issue.
Growth from an empty set is skipped; losing every selected tuple counts as a change. The related SelectedIcePath model supplies ice-path-changed events for initial selection, direct-to-relay, relay-to-direct, changes of TURN server or relay protocol, and other path changes. The traversal detector only reports that the tuple set changed.
Path kinds are direct, turn-udp, turn-tcp, turn-tls, and turn-unknown, classified from the local candidate. Local direct doesn’t rule out relay use at the remote endpoint. For a relay, relayProtocol describes the client-to-TURN leg; candidate protocol can still be UDP when that leg uses TCP or TLS. Candidate type takes precedence over a turn: URL, and missing candidate details limit the classification.
Once local and remote candidates are available, ICE sends connectivity checks. A candidate pair’s succeeded state records a successful check; nomination and selection identify which usable path is chosen. Don’t count every successful pair as a separate active route. Follow the transport’s selected pair and its candidate references in the WebRTC statistics.
The practical approach in rtcStats’ guide to diagnosing ICE failures is helpful here: inspect the candidate pairs and check responses alongside the state. The label failed gets you to the right part of the timeline; the attempted paths help you investigate it.
The participant hasn’t joined yet, but the browser is still trying. A slow attempt deserves attention before we call it a failure. The useful next step is to locate the delay: gathering candidates, checking paths, or finishing the secure transport.
Client Monitor’s IcePathEstablishmentDetector times each stretch of peer connection connecting and emits ice-path-establishment-slow once it reaches 5,000 ms by default. Set icePathEstablishmentDetector.thresholdInMs to change that wait. It raises no issue: the event says setup is slow, not that it has failed. Any exit from connecting resets the clock and re-arms it, so later attempts are measured separately.
[Timing] -- Below threshold --> [Timing]
[Timing] -- Connecting + timeout --> [Notified]
[Notified] -- Exit connecting --> [Timing]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 4. A slow-establishment event fires once per connecting attempt; any exit re-arms it.
The event’s stalledStage points to the next check: ice-gathering when no transport exists and gathering is running; ice-checking when a transport reports ICE new/checking; dtls when ICE is connected/completed, or a selected pair succeeded where ICE state is absent. Otherwise it’s unknown. An ICE new/checking transport takes precedence when several transports exist. The event includes sustainedForInMs; createEvent: true also buffers LONG_PC_CONNECTION_ESTABLISHMENT into samples, while false leaves only the live event.
The browser has addresses to try, but none of its observed checks has produced a usable winner. The participant stays outside the call. We need to separate this from an empty candidate list—and from a path ICE already established while something later in setup is waiting.
Client Monitor’s IceEstablishmentFailedDetector raises ice-establishment-failed after 15,000 ms with local candidates but no observed connection success or pair nomination/success. Configure iceEstablishmentFailedDetector.thresholdInMs, preferably above the slow-event threshold. It does not require terminal browser failed, or even connecting. Observing either a nominated pair or a succeeded pair latches establishment success for this detector’s lifetime; observing connection connected does too.
[Watching] -- No candidates / waiting --> [Watching]
[Watching] -- Candidates + timeout --> [Raised]
[Watching] / [Raised] -- Success / closed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 5. Candidates exist, but no establishment success has been observed within the configured wait.
Success resolves an open issue and ends first-establishment checks; closing the peer connection resolves it too. Zero candidates resets accumulation before a finding, but doesn’t close an issue already raised. Pair resets and ICE restarts don’t erase the success latches: this detector watches first establishment, not each generation.
The payload captures current candidate counts by type, pair states, pair count and accumulated time. Host-only candidates suggest checking whether STUN/TURN were configured and attempted; server-reflexive candidates without relay suggest checking expected TURN allocation. Relay candidates without a winner suggest checking remote candidate delivery and responses. These are investigation leads, not proof of a server outage.
Once ICE finds a working pair, DTLS-SRTP uses a DTLS handshake to establish SRTP keying material. If ICE succeeds but setup still doesn’t complete, check this handshake. The detectors below distinguish an explicit DTLS failure from a handshake that keeps waiting.
The endpoints can reach each other, but they’re not on speaking terms yet. The secure hello has ended with a failed DTLS handshake. This is where we look at certificate fingerprints, DTLS roles and endpoint logs to understand why the secure connection couldn’t be established.
Client Monitor’s DtlsHandshakeFailedDetector raises dtls-handshake-failed and emits the matching event immediately on dtlsState: 'failed'. It doesn’t require healthy ICE. Configure dtlsHandshakeFailedDetector with {} to enable it or null to omit it.
[Watching] -- No DTLS failure --> [Watching]
[Watching] -- DTLS failed --> [Raised]
[Raised] -- DTLS connected --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 6. A reported DTLS failure raises immediately; retrying alone does not resolve it.
DTLS connected or transport removal resolves the issue; new/connecting doesn’t. An ICE restart alone doesn’t guarantee a new handshake or repair its configuration.
The network path is ready, but the secure hello seems to have met with silence. The participant keeps watching the spinner while DTLS remains unfinished, without an explicit failure. Did the handshake packets reach the other endpoint, and did replies come back? We need evidence from both sides before deciding who gave whom the silent treatment.
Client Monitor’s DtlsHandshakeStalledDetector raises dtls-handshake-stalled after 6,000 ms of DTLS new/connecting over ICE connected/completed. A succeeded selected pair substitutes only when ICE state is absent. It skips the first transport tick; tune the wait through dtlsHandshakeStalledDetector.stalledThresholdInMs.
[Watching] -- No proof / waiting --> [Watching]
[Watching] -- ICE ready + DTLS timeout --> [Raised]
[Raised] -- DTLS connected / failed --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 7. Healthy ICE plus a sustained unfinished DTLS handshake raises a stall finding.
DTLS connected/failed or transport removal resolves the stall. Missing ICE proof, missing/closed DTLS state, or an observed restart resets timing without closing an open finding.
Now the participant joined and the call worked. Then the audio cuts out while they walk away from Wi-Fi.
This time, retain the history. Which transport had worked? Did its selected pair change? Did ICE report a loss of connectivity, or do the state fields still look fine?
Established ICE paths also use periodic STUN exchanges for consent freshness: permission to keep sending. That is separate from proving that useful media reaches playback.
The WebRTC ICE transport states distinguish disconnected, a potentially transient loss, from failed. ObserveRTC gives them separate findings, with separate recovery behavior.
The conversation was flowing, then the other person disappeared mid-sentence. Perhaps their Wi-Fi is handing over and they’ll be back in a moment. ICE reports disconnected, but hasn’t given up. We give the interruption a little time before treating it as a sustained outage.
Client Monitor’s IceDisconnectedDetector raises ice-disconnected after transport ICE stays disconnected for 5,000 ms, configured through iceDisconnectedDetector.disconnectedThresholdInMs. The clock uses transport stats intervals; any exit resets the accumulation. It raises once per open episode, recording transport and pair IDs, DTLS state, disconnected time and the observed ICE generation.
[Watching] -- Brief interruption --> [Watching]
[Watching] -- Disconnected + timeout --> [Raised]
[Raised] -- Recovered / restart --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 8. A sustained disconnection raises once; failure does not count as recovery.
ICE connected/completed resolves the issue. A changed local username fragment—falling back to the selected local candidate’s fragment—resolves it and restarts timing for the new generation. Transport removal resolves it too. Falling to failed resets the timer but leaves the issue open. Tune the threshold against your application’s reconnecting experience: a five-second outage can still recover without a restart.
This time, ICE has given up on the current generation: the transport reports failed. Did we lose someone who was already in the conversation, or did they never make it in? Both can end with the same state, so we need the connection’s history before deciding what to investigate.
Client Monitor’s IceConnectionFailedDetector raises ice-connection-failed on the first observation of transport ICE failed, without a time threshold. Enable iceConnectionFailedDetector with {} or omit it with null. The payload’s everConnected distinguishes a transport observed connected/completed earlier from one that never reached those states under monitoring. Attach early: the monitor cannot reconstruct missing history.
[Watching] -- No ICE failure --> [Watching]
[Watching] -- ICE failed --> [Raised]
[Raised] -- Recovered / restart --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 9. An ICE failed state raises immediately, with history showing whether the transport worked earlier.
ICE connected/completed, an observed fragment change, or transport removal resolves the issue. A new generation can fail and raise again. This finding can overlap ice-establishment-failed during setup or an earlier ice-disconnected; correlate their transport IDs and intervals rather than counting each as a separate outage.
We’re still sending, but nothing is coming back. ICE insists we’re connected, yet a path that was receiving traffic has gone quiet. Before asking whether we’re talking to ourselves, check that the application still expects incoming media—a deliberately paused subscription doesn’t owe us a reply.
Client Monitor’s IceTransportStalledDetector raises ice-transport-stalled after 5,000 ms of guarded receiving-side silence, controlled by iceTransportStalledDetector.transportStallThresholdInMs. ICE must be connected/completed, its selected pair succeeded, both byte deltas available, and outgoing bytes positive while incoming bytes make no progress. Earlier inbound traffic must have been observed on the transport, and an inbound RTP report must still be attributed to it. Those guards exclude an entirely idle path, an initially silent path and a send-only SFU uplink.
[Watching] -- Idle / waiting --> [Watching]
[Watching] -- Guarded silence + timeout --> [Raised]
[Raised] -- Traffic / path exit / restart --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 10. Only a previously receiving, still-sending transport with inbound RTP can meet the stall condition.
Incoming bytes, losing the connected-path condition, a detected restart, or transport removal resolves the issue. Restart also clears the prior-inbound latch. Missing byte deltas pause judgement; sending stopping or inbound RTP reports disappearing resets accumulation without closing an existing issue. The payload carries deltas, stall time, generation, pair ID and available RTT/last-packet time.
Candidate-pair bytes exclude ICE connectivity checks, so this isn’t a count of unanswered consent requests. An inbound RTP report also doesn’t prove the subscription is still wanted. The taxonomy keeps this asymmetric finding in Connectivity; BlockedStunRequestsDetector, BlockedOutboundMediaDetector and BlockedInboundMediaDetector belong to Transport Quality’s delivery-reliability group.
ICE picks a path, then another, then changes its mind again. One switch may keep the conversation going during a network change. Repeated switches deserve a closer look, especially if they line up with interruptions. Is the path causing trouble, or is the browser moving because another path already did?
Client Monitor’s UnstableIcePathDetector raises unstable-ice-path at three selected-pair switches within a 30,000 ms window by default. Configure unstableIcePathDetector.pathSwitchThreshold and pathSwitchWindowInMs. Each interval counts the larger of a changed selectedCandidatePairId and the native pair-change counter delta, where reported. Taking the larger avoids double-counting; the native counter can catch a path leaving and returning between collections. Losing a previously selected ID can count too.
[Watching] -- Below threshold --> [Watching]
[Watching] -- Switch threshold --> [Raised]
[Raised] -- Quiet window --> [Watching]
[Watching] / [Raised] -- Removed --> [Done]
See the accompanying detector explanation for exact guards, timing and reset behavior.
Figure 11. Count switches within each tumbling window; a quieter complete window resolves the finding.
The stats-time window tumbles rather than slides: reaching the threshold raises, and a closing window below it resolves. Switches split across a boundary may not raise; transport removal resolves, while an ICE restart has no special reset. Without the native counter, between-collection flaps can be missed. Compare the recorded switch count, path identity and path-change events with media progress—the browser may be switching to escape another problem.
Hypothetical example. A participant is in a two-way call when their network changes. Video stops and the application still expects the subscription to play. The selected pair keeps sending bytes, but incoming bytes stop. ICE continues to report connected. At five seconds of observed asymmetry, the transport-stalled issue opens. At ten seconds, Client Monitor recommends a restart.
Two detectors help us follow what happens next. IceRestartRecommendationDetector says when another attempt may be useful. IceRestartDetector records evidence that it began and how ICE responded. Both are Telemetry: neither raises an issue or performs recovery.
Hypothetical recovery observations; defaults shown
0 s Incoming bytes stop; outgoing bytes increase; ICE connected
|
5 s Stall finding opens: guarded asymmetry persisted
|
10 s Restart recommended; application decides whether to act
|
Later Restart detected, then ICE recovered
| Fragment change and ICE outcome observed
Later still Media resumes; measure traffic and decoding independently
Figure 12. A recommendation, an observed restart, and returning media are separate events. Times illustrate the default thresholds; collection cadence affects when they appear.
The ice-restart-recommended event arrives with reason transport-stalled, the condition duration, transport identity, observed generation and recommendation count. Before acting, the application checks that the subscription hasn’t been paused, signaling is available, and another negotiation isn’t running. An ICE restart starts a new ICE generation with new credentials; the application still has to exchange them through its signaling flow.
For this example, suppose monitor watches one native pc, signaling is the application’s existing WebSocket, and the normal negotiation handler already creates and exchanges offers and answers. This listener requests at most one restart for the stalled path:
let restartRequested = false;
monitor.on('ice-restart-recommended', ({ reason }) => {
if (reason !== 'transport-stalled' || restartRequested) return;
if (signaling.readyState !== WebSocket.OPEN) return;
if (pc.signalingState !== 'stable') return;
restartRequested = true;
pc.restartIce(); // The normal negotiation handler exchanges the new offer.
});
monitor.on('ice-restart', ({ iceGeneration, outcome }) => {
console.log('ICE restart', iceGeneration, outcome);
});This is one bounded recovery attempt, not a complete retry controller. An SFU SDK may own negotiation and expose its own restart API. In either integration, requesting a restart isn’t evidence that the new credentials reached the remote endpoint.
On a later collection, the local ICE username fragment changes. IceRestartDetector emits ice-restart with outcome: 'detected' and an incremented observed generation. It reads the transport’s local fragment, falling back to the selected local candidate’s fragment. The first observation only establishes a baseline; entering checking alone isn’t a restart, and missing fragments leave the detector silent.
Suppose the new generation later reaches ICE connected. The detector emits recovered; had it reached failed, the outcome would be failed. While it remains checking, no outcome is invented. Pending outcomes are settled before a changed fragment starts another generation, so the state in that first changed-fragment collection doesn’t immediately settle the new attempt. Tracking is per transport and is discarded when that transport disappears.
In our example, incoming bytes return, but video takes another two seconds to resume decoding. Record that separately. ICE recovered before media did. If a restart fails, keep its generation and outcome and let the application’s retry policy decide what follows. A working retry helps the participant; it doesn’t explain why the old path stopped receiving.
The recommendation detector also handles three other situations. Transport ICE failed recommends immediately with ice-failed. Sustained disconnection recommends ice-disconnected; it shares the 10,000 ms default wait with the guarded transport-stall condition. A separate peer-connection branch recommends never-established after 10,000 ms in connecting, yielding to transports already failed/disconnected. This includes attempts delayed at DTLS and doesn’t use the establishment-failure detector’s lifetime-success latch. Each reason is derived from raw evidence, independently of the other detectors’ findings.
In iceRestartRecommendationDetector, iceRestartRecommendationThresholdInMs controls the transport wait and restartRecommendationThresholdInMs the connecting wait. Their respective cooldowns, iceRestartRecommendationCooldownInMs and restartRecommendationCooldownInMs, default to 15,000 ms. Conditions use stats time; cooldowns use wall time. Transport reasons share per-transport rate limiting; the connecting branch has its own. Recovery clears the incident’s cooldown. A fragment change suppresses transport recommendations while the new generation is pending, until connected/completed or failed; missing fragments limit this suppression, and the separate connecting branch remains eligible.
Repeated recommendations don’t count actual restarts—the application may not have acted. With createEvent: true, the recommendation block also buffers ICE_RESTART_RECOMMENDED; iceRestartDetector.createEvent similarly buffers ICE_RESTART. Both flags are true in their default blocks. Keep the recommendation, observed attempt, ICE outcome and media-return times together so the recovery history doesn’t collapse into a single “reconnected” label.
The current public API starts with ClientMonitor and addSource(). Attach it before setup if you want the setup history. The older createClientMonitor() examples in Power-up getStats for Client Monitoring explain the monitoring approach, but aren’t the API used by this source baseline.
import { ClientMonitor } from '@observertc/client-monitor-js';
const monitor = new ClientMonitor({
collectingPeriodInMs: 1000,
samplingPeriodInMs: 5000,
icePathEstablishmentDetector: { thresholdInMs: 5000, createEvent: true },
iceEstablishmentFailedDetector: { thresholdInMs: 15000 },
iceDisconnectedDetector: { disconnectedThresholdInMs: 5000 },
});
// Use the connection your application will negotiate.
const pc = new RTCPeerConnection();
monitor.on('issue', (issue) => {
console.log('Finding opened', issue.type, issue.payload);
});
monitor.on('issue-resolved', (issue) => {
console.log('Finding closed', issue.type, issue.payload);
});
monitor.on('ice-path-establishment-slow', (event) => {
console.log('Setup is slow', event.stalledStage, event.sustainedForInMs);
});
monitor.on('ice-restart-recommended', (event) => {
// Pass this to your application's recovery controller.
// It owns signaling, negotiation serialization, and retry limits.
console.log('Consider recovery', event.reason, event.peerConnectionId);
});
monitor.on('ice-restart', (event) => {
console.log('Observed restart', event.iceGeneration, event.outcome);
});
monitor.addSource(pc);
// Continue with your normal track setup and offer/answer exchange.
// Call monitor.close() when monitoring the session ends.
The empty RTCPeerConnection configuration keeps this example focused on monitoring; use your application’s real ICE servers and credentials. Monitoring doesn’t provide a TURN server or negotiate the connection for you.
A supplied detector block replaces that block’s defaults; include every required threshold and any optional flag you want enabled. In particular, omitting createEvent from a supplied block stops its buffered client event, while its live monitor event still fires. These twelve connectivity-related classes are enabled by default at this source revision. To omit one, pass null for its own config key, for example unstableIcePathDetector: null. There is no current iceConnectivityDetector group key. Old names don’t silently select the replacement classes.
For recovery itself, the browser’s pc.restartIce() method requests an ICE restart and triggers negotiation as needed. Your application still has to create and exchange the offer/answer through its normal negotiation handling. Don’t put an unconditional restart in every issue listener: one outage can produce several findings, and your signaling stack or SFU SDK may own the appropriate restart API.
These detectors run on collections, not on a timer which fires at exactly five seconds. With a one-second collection period, a five-second condition is reported at a collection that has accumulated enough valid stats time. A five-second collection period is much coarser. Short state changes between observations may be missed.
Most sustain clocks here sum the monitor’s deltaTime. That avoids treating wall-clock advance alone as evidence, but it doesn’t eliminate gaps: a later valid stats timestamp can span a long collection delay. The detector can’t reconstruct every transition inside that interval. Keep stats-collection-gap telemetry, timestamps, and collection cadence alongside findings. Don’t assume all these clocks reset after a gap.
Many resolved payloads add durationInMs using wall time since the finding was raised. That isn’t necessarily the full outage duration, and it isn’t the same quantity as the stats-time sustain included when it opened. Preserve both, and avoid summing overlapping issue durations into participant downtime.
No issue can mean healthy, too brief to trigger, disabled, or unobservable. The implementation doesn’t expose a uniform missing-input signal across all these detectors. Missing iceState blocks several continuity checks; the DTLS stall detector has a selected-pair fallback, but that doesn’t make every detector equally portable. Missing username fragments hide restart detection. Missing candidate fields limit path classification.
Keep raw evidence with its availability. An absent value shouldn’t become a zero, and removal of a monitored transport shouldn’t become a successful recovery in your dashboard. At session shutdown, retain the terminal application event as well: several transport detectors return early on a closed peer connection, so closing the monitor isn’t evidence of protocol recovery.
For each report, the useful record is the peer connection and transport identity, selected pair and candidate details, browser/source coverage, configuration, application intent, and surrounding state changes. An issue gives that investigation a starting point. It doesn’t replace it.
Once a path is established, we can ask how well it carries traffic: capacity, delay, loss, and delivery failures. That’s transport quality, and it’s the next stop in this series.