Recipes
Production monitoring pipeline
A configuration tuned for cost and signal quality rather than debugging resolution.
import { ClientMonitor } from "@observertc/client-monitor-js";
import { ClientSampleEncoder } from "@observertc/samples-encoder";
// One encoder per client — it elides values that repeat between samples.
const encoder = new ClientSampleEncoder(session.participantId);
const monitor = new ClientMonitor({
clientId: session.participantId,
callId: session.roomId,
collectingPeriodInMs: 3000, // lower main-thread cost
samplingPeriodInMs: 10000, // less upload volume
congestionDetector: { sensitivity: "low" }, // only strong evidence
syntheticSamplesDetector: null, // not acted on — don't build it
appData: { appVersion: BUILD_VERSION },
});
monitor.attachments = {
appVersion: BUILD_VERSION,
region: session.region,
tier: user.plan,
};
monitor.addSource(peerConnection);
// Ship telemetry.
monitor.on("sample-created", (sample) => {
navigator.sendBeacon(`/api/samples/${session.participantId}`, encoder.encodeToBytes(sample));
});
// Route issues by how much you care.
monitor.on("issue", (issue) => {
if (issue.type === "congestion" || issue.type === "cpulimitation") {
alerting.notify(issue);
} else {
log.info("issue", issue.type, issue.payload);
}
});
// Episode durations are the useful analytics event, not the raise.
monitor.on("issue-resolved", (resolved) => {
analytics.track("quality_episode", {
type: resolved.type,
durationInMs: resolved.payload?.durationInMs,
});
});
window.addEventListener("pagehide", () => monitor.close());In-call quality indicator
activeIssues is a live set, so rendering from it gives you an indicator that is correct by
construction — it clears itself when the detector resolves the issue.
const SEVERITY = {
congestion: "critical",
cpulimitation: "critical",
"dry-inbound-track": "critical",
"dry-outbound-track": "critical",
"freezed-video-track": "warning",
"audio-desync": "warning",
"inbound-video-playout-discrepancy": "warning",
};
monitor.on("stats-collected", () => {
const active = monitor.getActiveIssuesByType();
const worst = active.reduce((acc, issue) => {
const level = SEVERITY[issue.type] ?? "info";
return level === "critical" ? "critical" : acc === "critical" ? acc : level;
}, "ok");
ui.setNetworkIndicator({
level: worst,
score: monitor.score,
detail: active.map((i) => i.type),
});
});Adaptive behaviour
Reacting to detector verdicts inside the client is often the highest-value use of the library — the fix happens before the user complains.
monitor.on("issue", async (issue) => {
switch (issue.type) {
case "congestion": {
// Shed the most expensive stream first.
await screenShare?.pause();
ui.toast("Network is congested — screen share paused");
break;
}
case "cpulimitation": {
// Reduce our own encoding cost.
await sender.setParameters({
...sender.getParameters(),
encodings: [{ maxBitrate: 300_000, scaleResolutionDownBy: 2 }],
});
break;
}
case "dry-outbound-track": {
// Almost always a local capture problem.
ui.promptDeviceCheck(issue.payload.trackId);
break;
}
}
});
monitor.on("issue-resolved", async (resolved) => {
if (resolved.type === "congestion") {
await screenShare?.resume();
ui.toast("Network recovered");
}
});Real-time dashboard data
Everything a per-participant quality dashboard needs, computed once per tick.
monitor.on("stats-collected", () => {
const snapshot = {
timestamp: Date.now(),
score: monitor.score,
scoreReasons: monitor.scoreReasons,
bitrates: {
sendingAudio: monitor.sendingAudioBitrate,
sendingVideo: monitor.sendingVideoBitrate,
receivingAudio: monitor.receivingAudioBitrate,
receivingVideo: monitor.receivingVideoBitrate,
},
capacity: {
availableIn: monitor.totalAvailableIncomingBitrate,
availableOut: monitor.totalAvailableOutgoingBitrate,
},
rttMs: (monitor.avgRttInSec ?? 0) * 1000,
connections: monitor.peerConnections.map((pc) => ({
id: pc.peerConnectionId,
iceState: pc.iceState,
turn: pc.usingTURN,
tcp: pc.usingTCP,
rttMs: (pc.avgRttInSec ?? 0) * 1000,
score: pc.calculatedStabilityScore?.value,
})),
tracks: monitor.tracks.map((t) => {
const rtp = t.direction === "inbound" ? t.getInboundRtp() : t.getHighestLayer();
return {
id: t.track.id,
kind: t.kind,
direction: t.direction,
bitrate: t.bitrate,
fps: rtp?.ewmaFps,
loss: t.fractionLost,
score: t.score,
label: t.attachments?.mediaType,
};
}),
activeIssues: monitor.getActiveIssuesByType().map((i) => ({
type: i.type,
key: i.key,
openForMs: Date.now() - i.raisedAt,
})),
};
dashboard.push(snapshot);
});Distinguishing screen share from camera
Tag tracks with attachments, then use the tag both locally and server-side.
const camTrack = await getCameraTrack();
const screenTrack = await getDisplayTrack();
monitor.getTrackMonitor(camTrack.id).attachments = { mediaType: "camera" };
monitor.getTrackMonitor(screenTrack.id).attachments = { mediaType: "screen-share" };Two things follow from this. The default score calculator already skips bitrate-deviation and
volatility penalties when track.contentHint === 'screen', so set the content hint as well:
screenTrack.contentHint = "detail"; // or "text" / "motion"
And you can suppress detectors that do not make sense for that content:
const screenMonitor = monitor.getTrackMonitor(screenTrack.id);
screenMonitor.detectors.disable("freezed-video-track-detector"); // a static slide is not a freeze
React
import { useEffect, useRef, useState } from "react";
import { ClientMonitor } from "@observertc/client-monitor-js";
export function useClientMonitor(peerConnection, { clientId, callId }) {
const monitorRef = useRef(null);
const [quality, setQuality] = useState({ score: 5, issues: [] });
useEffect(() => {
if (!peerConnection) return;
const monitor = new ClientMonitor({
clientId,
callId,
collectingPeriodInMs: 2000,
samplingPeriodInMs: 8000,
});
monitorRef.current = monitor;
monitor.addSource(peerConnection);
monitor.on("stats-collected", () => {
setQuality({
score: monitor.score ?? 5,
rttMs: (monitor.avgRttInSec ?? 0) * 1000,
issues: monitor.getActiveIssuesByType().map((i) => i.type),
});
});
monitor.on("sample-created", (sample) => {
fetch("/api/samples", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sample),
}).catch(() => {});
});
return () => {
monitor.close();
monitorRef.current = null;
};
}, [peerConnection, clientId, callId]);
return { quality, monitor: monitorRef };
}Express endpoint for samples
The minimal server side, before you add observer-js.
import express from "express";
const app = express();
app.use(express.json({ limit: "2mb" }));
app.post("/api/samples", (req, res) => {
const sample = req.body;
if (!sample?.clientId || !sample?.callId) return res.sendStatus(400);
store.append(sample); // your archive
observer.accept(sample); // live analysis
res.sendStatus(200);
});Troubleshooting
No stats are being collected
- Confirm a source was added:
monitor.peerConnections.length > 0. - For mediasoup, transports created before
addSource(device)must be added manually. - Check the peer connection is not already closed when you add it.
- Turn on logging (
logger: console) — the library warns on sources it cannot poll.
sample-created never fires
samplingPeriodInMsmust be set for automatic sampling. It has no default.- For manual
createSample(), you must also setbufferingEventsForSamples: true.
Too many issues / noisy alerts
- Lower
congestionDetector.sensitivityto'low'. - Raise
dryInboundTrackDetector.thresholdInMs/dryOutboundTrackDetector.thresholdInMs. - Pass
nullfor detectors you do not act on so they are never built. - Alert on
issue-resolvedwith a minimumdurationInMsinstead of onissue— short episodes are often not worth a page.
Scores look wrong for our product
The default model is tuned for camera-based conferencing. SetcontentHint on screen-share
tracks, and if your product is audio-first or otherwise unusual, replace the calculator — see
Scoring.High CPU from monitoring
- Increase
collectingPeriodInMs— this is the dominant cost, because it drivesgetStats(). - Watch
monitor.durationOfCollectingStatsInMs: if it is large, the tab is saturated for reasons other than the monitor, andcpulimitationwill be telling you so. - Remove detectors you do not use with
null. - Keep stats adapters cheap; move expensive work into async extension stats providers.
Memory growth over long calls
Callmonitor.close() when the call ends — it releases every monitor and auto-resolves open
issues. Remove your own event listeners too if the monitor outlives your component.Frequently asked
How often should stats be collected? The 2 s default suits most applications. Use 1 s while debugging, 3–5 s at high scale.
What is the difference between collectingPeriodInMs and samplingPeriodInMs?
Collecting drives detection and metric resolution; sampling drives how much telemetry you upload.
What is the performance impact?
Typically under 1 % CPU. The dominant cost is the periodic getStats() call, which is why the
period is configurable.
Does it work with React Native? It targets browsers with a standard WebRTC implementation. React Native needs WebRTC polyfills and may hit platform-specific gaps.
How do I handle multiple peer connections? Add each one as a source. Client-level metrics aggregate automatically.
What happens when a peer connection closes? The monitor cleans up its monitors and emits the corresponding events. Nothing to do manually.