Skip to content
You are reading the docs for Backstory 0.2 (private beta). Behavior may change before general availability; the changelog lists every change.

WebRTC call quality

For products built around a call — telehealth visits, patient monitoring, remote interpreting — the DOM stream cannot explain the failures users actually report. The SDK patches RTCPeerConnection and navigator.mediaDevices and records call state and quality metrics on the same timeline as everything else.

Backstory.init({
projectKey: "pk_live_…",
webrtc: { enabled: true },
});

The collector is a lazy module (backstory-webrtc.js), fetched only when enabled, so the core bundle is unaffected. It can also be switched on centrally from project settings without a deploy.

OptionTypeDefaultNotes
enabledbooleanfalseMaster switch. Loads the lazy module when true
sampleIntervalSecnumber5Seconds between emitted quality samples at full health. getStats() is polled internally at 1 Hz regardless; this controls what is sent
captureSdpbooleanfalseCapture redacted session descriptions. Never honored in strict privacy mode
emitOnThresholdCrossbooleantrueEmit a sample immediately when a detector threshold is crossed, even while sampling is thinned
maxPeerConnectionsnumber8Connections tracked at once. Extras are counted but not sampled

Emitted immediately, never thinned, because they are rare and individually valuable.

EventWhen
pc_createdA peer connection was constructed. ICE servers are counted, never recorded verbatim
connection_state, ice_state, ice_gathering, signaling_stateAny state transition, with the time spent in the previous state
ice_candidate_errorA STUN or TURN server was unreachable or refused, with the error code
candidate_pair_changeThe media path changed, with local and remote candidate types and whether it is now relayed
track_added, track_muted, track_unmuted, track_endedTrack lifecycle. A remote track muting is the freeze the user sees; unmute carries how long it lasted
codec_change, resolution_changeEncoder or decoder adaptation mid-call
ice_restartThe call is recovering from a network change
media_acquiredCapture succeeded, with resolution and frame rate
media_errorCapture failed. The most actionable event we record; see the table below
device_inventory, device_changeDevice counts at start, and any change mid-session (a headset unplugged)
data_channelChannel label and state. Payloads are never captured
pc_closedWith the call duration

Media errors are classified by DOMException name, which is a fixed and far more useful vocabulary than the message text:

ClassDOMExceptionMeaning
permission_deniedNotAllowedErrorThe user or a policy blocked access
device_in_useNotReadableErrorAnother application holds the camera or microphone
device_not_foundNotFoundErrorNo such device present
constraints_unsatisfiedOverconstrainedErrorThe requested constraints cannot be met
aborted, security, type_errorAbortError, SecurityError, TypeErrorHardware failure, insecure context, empty constraints

Polled from getStats() at 1 Hz, differenced on the device, and emitted as a compact per-track delta. What arrives is already the computed answer.

FieldMeaning
lossPctPacket loss over the window, 0 to 100
rttMsRound trip time from the selected candidate pair
jitterMs, jitterBufferMsNetwork instability, and average jitter buffer delay
freezes, freezeMsVideo freeze count and total duration. Derived from frame deltas on browsers that do not report them directly
frameDropPctDropped over decoded frames
concealmentPctAudio samples the decoder invented to cover gaps. Above roughly 5% is audible
audioLevel0 to 1, from the receiver or the local source
fps, w, hFrame rate and resolution
kbps, availableOutgoingKbps, availableIncomingKbpsThroughput and the congestion controller’s estimate
qualityLimitation, limitedByCpuPct, limitedByBandwidthPctWhether the device or the network was the constraint
nackCount, pliCount, keyFramesRetransmission and recovery churn
codecNegotiated codec MIME type
stalledNo packets moved on a live track in the window

Each sample also carries the connection state, the candidate types, whether the path is relayed, and the network type.

Quality sampling degrades with the recorder’s memory and bandwidth governor rather than switching off abruptly.

Governor levelIntervalBehavior
Healthy5 sFull sampling
Constrained15 sLower cadence
Degraded30 sOnly samples that cross a threshold
Survival, pausedoffLifecycle events only
DataBehavior
Audio and video framesNever captured
ICE candidate addressesDropped. Only candidate type, protocol, network type, and a per-session salted hash are kept
Session descriptions (SDP)Off by default; never in strict. When enabled, a=candidate, c=, a=ice-ufrag, and a=ice-pwd lines are stripped and the remainder runs through the PII detectors
deviceIdAlways hashed. It is a fingerprinting surface
Device labelsRecorded as the browser supplies them
Data channel payloadsNever captured; label and state only

Thirteen deterministic detectors run server-side on every call, cluster across sessions, and route through the normal notification rules. Thresholds are tunable per project.

DetectorDefault ruleSeverity
webrtc_connect_failureNever reached connected within 15 s, or reached failedCritical
webrtc_one_way_mediaOne direction moved zero packets for 6 s while the other flowed and both were negotiatedCritical
webrtc_media_permission_deniedNotAllowedErrorHigh
webrtc_device_unavailableNotReadableError or NotFoundErrorHigh
webrtc_freezeA single freeze over 2 s, or more than 5% of the call frozenHigh
webrtc_audio_dropoutConcealment above 5% sustained 10 sHigh
webrtc_reconnect_storm3 or more ICE restarts or path changes within 60 sHigh
webrtc_call_abandonedEnded inside 30 s with a quality score below 60High
webrtc_packet_lossAbove 5% sustained 10 sMedium
webrtc_high_latencyRound trip time above 300 ms sustained 15 sMedium
webrtc_cpu_limitedEncoder CPU-limited for more than 20% of the callMedium
webrtc_low_resolutionBelow 240 px high for 20 s after a better resolution was seenLow
webrtc_relay_fallbackThe selected path is a TURN relayInfo

Every call also gets a composite quality score from 0 to 100, weighted toward loss and freezes. The same function computes it in the SDK and on the server, so a detector and a dashboard can never disagree.

Every detector is available as a recording trigger, named webrtc:<kind>. Use them in conditional recording to store only sessions where a call went wrong, and in monitor mode to record a burst around each problem.

Backstory.init({
projectKey: "pk_live_…",
webrtc: { enabled: true },
recording: {
mode: "conditional",
// Store the session if the call misbehaves, otherwise discard it.
triggers: ["webrtc:connect_failure", "webrtc:one_way_media", "webrtc:freeze"],
// Telehealth monitoring: a dropped feed records the minute before it happened.
monitorModeAfterMinutes: 15,
monitorTriggers: ["webrtc:freeze", "webrtc:reconnect_storm", "webrtc:connect_failure"],
},
});

See recording controls for how conditional recording and monitor mode work.

If your app constructs a peer connection before Backstory.init() runs (a call joined from a deep link, for instance), the constructor patch cannot see it. Hand it over explicitly:

const pc = new RTCPeerConnection(config);
Backstory.webrtc.attach(pc); // safe to call more than once

Backstory.webrtc.list() returns the tracked connections with their current state, relay status, latency, loss, frame rate, and quality score, which is useful in your own diagnostics UI and is what the debug panel renders.

Run pnpm --filter @backstory/sdk demo, open http://localhost:5500, and choose Run complete test in the WebRTC section. The loopback uses real peer connections and records connection setup, audio/video tracks, a data channel, one-second quality samples, a 3.5-second video freeze, an ICE restart, an unsatisfied camera constraint, and a clean hangup.

Open the resulting session and select Media in DevTools. The session header also shows a WebRTC health badge when calls are present; selecting it opens Media directly. Freeze bands align with the replay playhead, while the summary shows total freeze time, the worst call-quality score, failed connections, and device errors. Track rows expose codec, resolution, frame rate, loss, jitter, bitrate, dropped frames, recovery requests, stalls, and audio concealment when the browser reports them.

The native SDKs instrument the platform WebRTC stacks and normalize into the same metric shape, so one detector set and one Calls view serve web and mobile.

// iOS
Backstory.start(options)
Backstory.webrtc.attach(peerConnection) // RTCPeerConnection from the WebRTC framework
// Android
Backstory.start(context, options)
Backstory.webrtc().attach(peerConnection)
// Flutter
Backstory.webrtc.attach(peerConnection);
// React Native
import { Backstory } from "@backstory/react-native";
Backstory.webrtc.attach(pc);

The web SDK currently needs access to the underlying RTCPeerConnection. Initialize Backstory before the call vendor so newly constructed connections are patched automatically, or use Backstory.webrtc.attach(peerConnection) when the vendor exposes one. Vendors that expose neither the connection nor a standards-based stats hook cannot provide call metrics to the web SDK yet.

EndpointPurpose
GET /v1/callsList calls with quality score, setup time, relay status, and issues. Filterable by network type, browser, device, release, quality range, and whether the call connected
GET /v1/calls/{id}One call with the full metric series and event markers
GET /v1/sessions/{id}/callsCalls within a session, used by the Media panel
GET /v1/calls/overviewConnect failure rate, relay rate, setup time percentiles, quality distribution, top failure reasons, and cohort breakdowns

Troubleshooting: what a user reports, and what proves it

Section titled “Troubleshooting: what a user reports, and what proves it”
They sayLook atDetector
“It was choppy”freezes and freezeMs on the inbound video track; frameDropPctwebrtc_freeze
“They could not hear me”Outbound audio packets against the remote report; stalled; audioLevel at zero while unmutedwebrtc_one_way_media
“The video never appeared”Connection state never reaching connected; ice_candidate_error; setup timewebrtc_connect_failure
“My camera does not work”media_error with class device_in_use or permission_deniedwebrtc_device_unavailable, webrtc_media_permission_denied
“The audio kept cutting out”concealmentPct on the inbound audio trackwebrtc_audio_dropout
“It was blurry the whole time”w and h on the outbound track, plus qualityLimitationwebrtc_low_resolution, webrtc_cpu_limited
“We kept talking over each other”rttMs and jitterBufferMswebrtc_high_latency
“It dropped and came back repeatedly”ice_restart and candidate_pair_change countswebrtc_reconnect_storm
“It only breaks at the office”localCandidate and remoteCandidate types, and relayedwebrtc_relay_fallback

getStats() field coverage varies. Every field is feature-detected and omitted rather than zero-filled when unavailable, and freezes are derived from frame deltas where freezeCount is missing. Chromium browsers provide the fullest set, including the quality limitation reason; Safari and Firefox provide fewer fields, so some detectors are less sensitive there. The detectors themselves degrade gracefully: a missing input means the rule does not fire, never a false positive.