From de0307539c0874cb0989204a3eecf20e8f44777a Mon Sep 17 00:00:00 2001 From: James Magahern Date: Thu, 11 Jun 2026 10:06:56 -0700 Subject: [PATCH] Improve frame sync telemetry --- public/app.js | 170 ++++++++++++++++++++++++++++-- server/index.js | 268 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 425 insertions(+), 13 deletions(-) diff --git a/public/app.js b/public/app.js index 22870e0..83cf552 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,8 @@ const elements = { const context = elements.canvas.getContext('2d', { alpha: false, desynchronized: true }); const FRAME_LATE_GRACE_SECONDS = 0.25; +const FRAME_CLOCK_SEND_INTERVAL_MS = 250; +const CLIENT_TELEMETRY_INTERVAL_MS = 5000; const MAX_PENDING_FRAME_QUEUE_SECONDS = 2; const MAX_DECODED_FRAME_QUEUE_SECONDS = 3; const MIN_PENDING_FRAME_QUEUE = 12; @@ -73,8 +75,11 @@ const state = { frameWatchdogTimer: 0, lastFramePacketAt: 0, lastFramePaintedAt: 0, + lastFrameClockSentAt: 0, + lastClientTelemetrySentAt: 0, lastPlaybackRestartAt: 0, lastRenderErrorAt: 0, + clientTelemetry: createClientTelemetry(), }; void loadEntryLists(); @@ -259,6 +264,7 @@ elements.audio.addEventListener('pause', () => { }); elements.audio.addEventListener('playing', () => { + noteClientTelemetry('audioPlaying'); elements.loader.hidden = state.frameCount > 0; clearPlayerMessage(); scheduleControlsHide(); @@ -266,6 +272,7 @@ elements.audio.addEventListener('playing', () => { elements.audio.addEventListener('waiting', () => { if (state.session) { + noteClientTelemetry('audioWaiting'); elements.loader.hidden = false; } }); @@ -275,12 +282,15 @@ elements.audio.addEventListener('timeupdate', () => { }); elements.audio.addEventListener('ended', () => { + noteClientTelemetry('audioEnded'); syncPlayhead(); setControlsVisible(true); }); elements.audio.addEventListener('error', () => { if (state.session) { + noteClientTelemetry('audioErrors'); + sendClientTelemetry({ force: true, reason: 'audio_error' }); showPlayerMessage('Audio failed'); setControlsVisible(true); } @@ -335,6 +345,7 @@ function connectPlaybackStreams() { state.streamGeneration = streamGeneration; state.lastPlaybackRestartAt = now; state.lastFramePacketAt = now; + state.lastFrameClockSentAt = 0; if (state.websocket) { state.websocket.close(1000, 'client reconnecting'); @@ -446,6 +457,8 @@ function restartPlaybackStreams(reason = 'frame_stalled') { } console.warn(`Restarting playback streams: ${reason}`); + noteClientTelemetry('playbackRestarts'); + sendClientTelemetry({ force: true, reason }); if (state.seekable && Number.isFinite(state.duration) && state.duration > 0) { state.lastPlaybackRestartAt = Date.now(); @@ -491,6 +504,12 @@ function handleFramePacket(packet, streamGeneration) { return; } + if (isLateFrame(timestamp)) { + noteLateFrameTelemetry('lateFramePackets', timestamp); + state.lastFramePacketAt = Date.now(); + return; + } + state.lastFramePacketAt = Date.now(); state.pendingFrames.push({ timestamp, jpeg: new Uint8Array(packet, 8), streamGeneration }); trimPendingFrameQueue(); @@ -521,6 +540,70 @@ function handleControlMessage(rawMessage) { } } +function sendFrameClock() { + if (!state.session || !state.websocket || state.websocket.readyState !== WebSocket.OPEN || elements.audio.readyState === 0) { + return; + } + + const now = Date.now(); + + if (now - state.lastFrameClockSentAt < FRAME_CLOCK_SEND_INTERVAL_MS) { + return; + } + + const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0; + + state.lastFrameClockSentAt = now; + + try { + state.websocket.send(JSON.stringify({ type: 'clock', currentTime })); + } catch { + state.lastFrameClockSentAt = 0; + } +} + +function sendClientTelemetry({ force = false, reason = 'periodic' } = {}) { + if (!state.websocket || state.websocket.readyState !== WebSocket.OPEN) { + return; + } + + const now = Date.now(); + + if (!force && now - state.lastClientTelemetrySentAt < CLIENT_TELEMETRY_INTERVAL_MS) { + return; + } + + state.lastClientTelemetrySentAt = now; + + const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0; + const telemetry = state.clientTelemetry; + const payload = { + type: 'telemetry', + reason, + currentTime, + paused: elements.audio.paused, + readyState: elements.audio.readyState, + pendingFrames: state.pendingFrames.length, + decodedFrames: state.frames.length, + paintedFrames: state.frameCount, + lastFramePacketAgeMs: state.lastFramePacketAt > 0 ? now - state.lastFramePacketAt : null, + lastFramePaintAgeMs: state.lastFramePaintedAt > 0 ? now - state.lastFramePaintedAt : null, + hidden: document.hidden, + online: navigator.onLine, + counters: telemetry.counters, + max: telemetry.max, + }; + + state.clientTelemetry = createClientTelemetry(); + + try { + state.websocket.send(JSON.stringify(payload)); + } catch { + state.clientTelemetry = mergeClientTelemetry(state.clientTelemetry, telemetry); + state.lastClientTelemetrySentAt = 0; + } +} + function startRenderLoop() { if (state.raf) { return; @@ -528,6 +611,8 @@ function startRenderLoop() { const render = () => { try { + sendFrameClock(); + sendClientTelemetry(); drawReadyFrames(); syncPlayhead(); } catch (error) { @@ -549,7 +634,7 @@ function drawReadyFrames() { return; } - dropLateDecodedFrames({ keepNewest: true }); + dropLateDecodedFrames(); const frameLeadSeconds = 1 / Math.max(1, state.session.options.fps); const targetTime = elements.audio.currentTime + frameLeadSeconds; @@ -559,6 +644,7 @@ function drawReadyFrames() { const frame = state.frames.shift(); if (frameToDraw) { + noteClientTelemetry('readyFramesSkippedForNewer'); releaseImage(frameToDraw.bitmap); } @@ -575,6 +661,8 @@ function drawReadyFrames() { state.currentBitmap = frameToDraw.bitmap; drawBitmap(frameToDraw.bitmap); + noteClientTelemetry('framesPainted'); + noteFrameLagTelemetry('paintLagMs', frameToDraw.timestamp); state.lastFramePaintedAt = Date.now(); elements.loader.hidden = true; clearPlayerMessage(); @@ -601,7 +689,7 @@ async function pumpFrameDecodeQueue() { try { while (state.pendingFrames.length > 0) { - dropLatePendingFrames({ keepNewest: true }); + dropLatePendingFrames(); const frame = state.pendingFrames.shift(); @@ -610,10 +698,12 @@ async function pumpFrameDecodeQueue() { } if (frame.streamGeneration !== state.streamGeneration) { + noteClientTelemetry('staleGenerationPendingFrames'); continue; } - if (isLateFrame(frame.timestamp) && state.pendingFrames.length > 0) { + if (isLateFrame(frame.timestamp)) { + noteLateFrameTelemetry('latePendingFramesBeforeDecode', frame.timestamp); continue; } @@ -621,16 +711,19 @@ async function pumpFrameDecodeQueue() { try { bitmap = await decodeImageWithTimeout(new Blob([frame.jpeg], { type: 'image/jpeg' })); - } catch { + } catch (error) { + noteClientTelemetry(error.message === 'Image decode timed out.' ? 'imageDecodeTimeouts' : 'imageDecodeFailures'); continue; } if (frame.streamGeneration !== state.streamGeneration) { + noteClientTelemetry('staleGenerationDecodedFrames'); releaseImage(bitmap); continue; } - if (isLateFrame(frame.timestamp) && (state.pendingFrames.length > 0 || state.frames.length > 0)) { + if (isLateFrame(frame.timestamp)) { + noteLateFrameTelemetry('latePendingFramesAfterDecode', frame.timestamp); releaseImage(bitmap); continue; } @@ -651,18 +744,20 @@ async function pumpFrameDecodeQueue() { } function trimPendingFrameQueue() { - dropLatePendingFrames({ keepNewest: true }); + dropLatePendingFrames(); const maxQueuedFrames = getFrameQueueLimit(MAX_PENDING_FRAME_QUEUE_SECONDS, MIN_PENDING_FRAME_QUEUE); const overflow = state.pendingFrames.length - maxQueuedFrames; if (overflow > 0) { + noteClientTelemetry('pendingQueueOverflowFrames', overflow); + noteClientTelemetryMax('pendingQueuePeakFrames', state.pendingFrames.length); state.pendingFrames.splice(0, overflow); } } function trimFrameQueue() { - dropLateDecodedFrames({ keepNewest: true }); + dropLateDecodedFrames(); const maxQueuedFrames = getFrameQueueLimit(MAX_DECODED_FRAME_QUEUE_SECONDS, MIN_DECODED_FRAME_QUEUE); const overflow = state.frames.length - maxQueuedFrames; @@ -672,6 +767,8 @@ function trimFrameQueue() { } const removed = state.frames.splice(0, overflow); + noteClientTelemetry('decodedQueueOverflowFrames', overflow); + noteClientTelemetryMax('decodedQueuePeakFrames', state.frames.length + overflow); for (const frame of removed) { releaseImage(frame.bitmap); @@ -687,6 +784,8 @@ function dropLatePendingFrames({ keepNewest = false } = {}) { } if (removeCount > 0) { + noteClientTelemetry('latePendingFrames', removeCount); + noteFrameLagTelemetry('latePendingFramesMaxLagMs', state.pendingFrames[0]?.timestamp); state.pendingFrames.splice(0, removeCount); } } @@ -704,6 +803,8 @@ function dropLateDecodedFrames({ keepNewest = false } = {}) { } const removed = state.frames.splice(0, removeCount); + noteClientTelemetry('lateDecodedFrames', removeCount); + noteFrameLagTelemetry('lateDecodedFramesMaxLagMs', removed[0]?.timestamp); for (const frame of removed) { releaseImage(frame.bitmap); @@ -724,7 +825,57 @@ function isLateFrame(timestamp) { return timestamp < currentTime - FRAME_LATE_GRACE_SECONDS; } +function noteLateFrameTelemetry(name, timestamp, count = 1) { + noteClientTelemetry(name, count); + noteFrameLagTelemetry(`${name}MaxLagMs`, timestamp); +} + +function noteFrameLagTelemetry(name, timestamp) { + if (!Number.isFinite(timestamp) || elements.audio.readyState === 0) { + return; + } + + const currentTime = Number.isFinite(elements.audio.currentTime) ? elements.audio.currentTime : 0; + noteClientTelemetryMax(name, Math.max(0, currentTime - timestamp) * 1000); +} + +function createClientTelemetry() { + return { + counters: {}, + max: {}, + }; +} + +function noteClientTelemetry(name, count = 1) { + if (!state.clientTelemetry || !Number.isFinite(count) || count <= 0) { + return; + } + + state.clientTelemetry.counters[name] = (state.clientTelemetry.counters[name] ?? 0) + count; +} + +function noteClientTelemetryMax(name, value) { + if (!state.clientTelemetry || !Number.isFinite(value)) { + return; + } + + state.clientTelemetry.max[name] = Math.max(state.clientTelemetry.max[name] ?? 0, Math.round(value)); +} + +function mergeClientTelemetry(target, source) { + for (const [name, count] of Object.entries(source.counters)) { + target.counters[name] = (target.counters[name] ?? 0) + count; + } + + for (const [name, value] of Object.entries(source.max)) { + target.max[name] = Math.max(target.max[name] ?? 0, value); + } + + return target; +} + function stopSession({ showEntry: shouldShowEntry = true } = {}) { + sendClientTelemetry({ force: true, reason: 'stop_session' }); state.generation += 1; state.streamGeneration += 1; state.session = null; @@ -736,6 +887,9 @@ function stopSession({ showEntry: shouldShowEntry = true } = {}) { state.seekPreviewTime = 0; state.lastFramePacketAt = 0; state.lastFramePaintedAt = 0; + state.lastFrameClockSentAt = 0; + state.lastClientTelemetrySentAt = 0; + state.clientTelemetry = createClientTelemetry(); clearHideControlsTimer(); clearFrameWatchdog(); @@ -808,7 +962,7 @@ async function decodeImageWithTimeout(blob) { window.clearTimeout(timeoutId); if (!image) { - throw new Error('Image decode failed.'); + throw new Error(timedOut ? 'Image decode timed out.' : 'Image decode failed.'); } return image; diff --git a/server/index.js b/server/index.js index a1c504a..2b4577a 100644 --- a/server/index.js +++ b/server/index.js @@ -34,6 +34,8 @@ const FAVORITES_PATH = process.env.FAVORITES_PATH ?? path.join(__dirname, '..', const FAVORITES_LIMIT = clampInteger(process.env.FAVORITES_LIMIT, 50, 1, 200); const SESSION_TTL_MS = 60 * 60 * 1000; const PLAYBACK_READY_TIMEOUT_MS = 15 * 1000; +const CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS = 0.25; +const FRAME_CONDITION_LOG_INTERVAL_MS = 5000; const MAX_WS_BUFFER_BYTES = clampInteger(process.env.MAX_WS_BUFFER_BYTES, 2 * 1024 * 1024, 128 * 1024, 64 * 1024 * 1024); const MAX_AUDIO_QUEUE_BYTES = clampInteger(process.env.MAX_AUDIO_QUEUE_BYTES, 4 * 1024 * 1024, 256 * 1024, 128 * 1024 * 1024); const MAX_RELAY_BRANCH_QUEUE_BYTES = clampInteger(process.env.MAX_RELAY_BRANCH_QUEUE_BYTES, 8 * 1024 * 1024, 512 * 1024, 256 * 1024 * 1024); @@ -818,6 +820,8 @@ function streamSplitFrames(websocket, session) { let skippedFrames = 0; let stopReason = 'process_exit'; let serverClosingWebSocket = false; + let clientAudioTime = null; + const frameConditions = createFrameConditionLogger(label); const frameSender = createLatestFrameSender(websocket, { onError: () => { stopReason = 'websocket_send_error'; @@ -826,6 +830,9 @@ function streamSplitFrames(websocket, session) { onSkip: () => { skippedFrames += 1; }, + onQueue: (event) => { + frameConditions.senderQueue(event); + }, }); const frameParser = createJpegFrameParser(handleJpegFrame); @@ -883,6 +890,7 @@ function streamSplitFrames(websocket, session) { } logInfo(`frames closed kind=${label} reason=${stopReason} frames=${frameIndex} skippedFrames=${skippedFrames}`); + frameConditions.flush('close', true); }); websocket.on('close', () => { @@ -899,6 +907,10 @@ function streamSplitFrames(websocket, session) { stopProcess(ffmpeg); }); + websocket.on('message', (data, isBinary) => { + clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime); + }); + function handleFrameData(chunk) { frameParser.write(chunk); } @@ -907,6 +919,12 @@ function streamSplitFrames(websocket, session) { const timestamp = frameIndex / fps; frameIndex += 1; + if (isFrameLateForClient(timestamp, clientAudioTime)) { + skippedFrames += 1; + frameConditions.clientClockSkip(timestamp, clientAudioTime); + return true; + } + const sendResult = frameSender.send(timestamp, jpeg); if (sendResult === 'closed') { @@ -943,9 +961,11 @@ function createRelayPlayback(session) { let frameExitSignal = null; let frameEndSent = false; let frameSender = null; + let clientAudioTime = null; let audioResponseFinished = false; let serverClosingWebSocket = false; let readyTimer = null; + const frameConditions = createFrameConditionLogger(label); const audioStderr = createFfmpegStderrTail(); const frameStderr = createFfmpegStderrTail(); const frameParser = createJpegFrameParser(handleJpegFrame); @@ -1012,6 +1032,9 @@ function createRelayPlayback(session) { onSkip: () => { skippedFrames += 1; }, + onQueue: (event) => { + frameConditions.senderQueue(event); + }, }); sendJson(websocket, { @@ -1037,6 +1060,10 @@ function createRelayPlayback(session) { } }); + websocket.on('message', (data, isBinary) => { + clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime); + }); + maybeStart(); }, }; @@ -1187,6 +1214,12 @@ function createRelayPlayback(session) { const timestamp = frameIndex / fps; frameIndex += 1; + if (isFrameLateForClient(timestamp, clientAudioTime)) { + skippedFrames += 1; + frameConditions.clientClockSkip(timestamp, clientAudioTime); + return true; + } + const sendResult = frameSender?.send(timestamp, jpeg) ?? 'closed'; if (sendResult === 'closed') { @@ -1278,6 +1311,7 @@ function createRelayPlayback(session) { } logInfo(`relay closed kind=${label} reason=${stopReason} sourceBytes=${sourceBytes} frames=${frameIndex} skippedFrames=${skippedFrames} audioInputPeakBytes=${audioInput?.peakBytes ?? 0} frameInputPeakBytes=${frameInput?.peakBytes ?? 0}`); + frameConditions.flush('close', true); } function clearReadyTimer() { @@ -1310,6 +1344,8 @@ function createPlayback(session) { let closed = false; let readyTimer = null; let frameSender = null; + let clientAudioTime = null; + const frameConditions = createFrameConditionLogger(label); const stderrTail = createFfmpegStderrTail(); const frameParser = createJpegFrameParser(handleJpegFrame); @@ -1371,6 +1407,9 @@ function createPlayback(session) { onSkip: () => { skippedFrames += 1; }, + onQueue: (event) => { + frameConditions.senderQueue(event); + }, }); sendJson(websocket, { @@ -1396,6 +1435,10 @@ function createPlayback(session) { } }); + websocket.on('message', (data, isBinary) => { + clientAudioTime = handleFrameClientMessage(data, isBinary, label, clientAudioTime); + }); + maybeStart(); }, }; @@ -1525,6 +1568,12 @@ function createPlayback(session) { const timestamp = frameIndex / fps; frameIndex += 1; + if (isFrameLateForClient(timestamp, clientAudioTime)) { + skippedFrames += 1; + frameConditions.clientClockSkip(timestamp, clientAudioTime); + return true; + } + const sendResult = frameSender?.send(timestamp, jpeg) ?? 'closed'; if (sendResult === 'closed') { @@ -1575,6 +1624,7 @@ function createPlayback(session) { } logInfo(`playback closed kind=${label} reason=${stopReason} frames=${frameIndex} skippedFrames=${skippedFrames} audioQueuePeakBytes=${audioQueuePeakBytes} audioBackpressureCount=${audioBackpressureCount} audioDrainCount=${audioDrainCount}`); + frameConditions.flush('close', true); } function clearReadyTimer() { @@ -1678,7 +1728,7 @@ function createJpegFrameParser(onFrame) { } } -function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => {} } = {}) { +function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => {}, onQueue = () => {} } = {}) { let sending = false; let latestFrame = null; let pumpTimer = null; @@ -1691,8 +1741,21 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => const packetBytes = 8 + getJpegFrameByteLength(jpeg); - if (sending || isWebSocketOverFrameBudget(websocket, packetBytes)) { - queueLatestFrame(timestamp, jpeg); + if (sending) { + queueLatestFrame(timestamp, jpeg, { + reason: 'send_in_progress', + packetBytes, + bufferedAmount: websocket.bufferedAmount, + }); + return 'queued'; + } + + if (isWebSocketOverFrameBudget(websocket, packetBytes)) { + queueLatestFrame(timestamp, jpeg, { + reason: 'websocket_buffer_budget', + packetBytes, + bufferedAmount: websocket.bufferedAmount, + }); return 'queued'; } @@ -1700,12 +1763,15 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => }, }; - function queueLatestFrame(timestamp, jpeg) { - if (latestFrame) { + function queueLatestFrame(timestamp, jpeg, event) { + const replacing = Boolean(latestFrame); + + if (replacing) { onSkip(); } latestFrame = { timestamp, jpeg }; + onQueue({ ...event, replacing }); schedulePump(); } @@ -1774,6 +1840,198 @@ function createLatestFrameSender(websocket, { onError = () => {}, onSkip = () => } } +function handleFrameClientMessage(data, isBinary, label, currentClientAudioTime) { + const message = parseFrameClientMessage(data, isBinary); + + if (!message) { + return currentClientAudioTime; + } + + if (message.type === 'clock') { + return message.currentTime; + } + + if (message.type === 'telemetry') { + logClientTelemetry(label, message); + } + + return currentClientAudioTime; +} + +function parseFrameClientMessage(data, isBinary) { + if (isBinary) { + return null; + } + + let message; + + try { + const raw = typeof data === 'string' ? data : data.toString('utf8'); + message = JSON.parse(raw); + } catch { + return null; + } + + if (message?.type === 'clock') { + const currentTime = Number(message.currentTime); + + if (!Number.isFinite(currentTime)) { + return null; + } + + return { + type: 'clock', + currentTime: clampNumber(currentTime, 0, Number.MAX_SAFE_INTEGER), + }; + } + + if (message?.type === 'telemetry') { + return { + type: 'telemetry', + reason: sanitizeTelemetryText(message.reason, 'periodic'), + currentTime: finiteTelemetryNumber(message.currentTime), + paused: Boolean(message.paused), + readyState: finiteTelemetryNumber(message.readyState), + pendingFrames: finiteTelemetryNumber(message.pendingFrames), + decodedFrames: finiteTelemetryNumber(message.decodedFrames), + paintedFrames: finiteTelemetryNumber(message.paintedFrames), + lastFramePacketAgeMs: finiteTelemetryNumber(message.lastFramePacketAgeMs), + lastFramePaintAgeMs: finiteTelemetryNumber(message.lastFramePaintAgeMs), + hidden: Boolean(message.hidden), + online: Boolean(message.online), + counters: sanitizeTelemetryMap(message.counters), + max: sanitizeTelemetryMap(message.max), + }; + } + + return null; +} + +function isFrameLateForClient(timestamp, clientAudioTime) { + return Number.isFinite(clientAudioTime) && timestamp < clientAudioTime - CLIENT_CLOCK_FRAME_LATE_GRACE_SECONDS; +} + +function createFrameConditionLogger(label) { + let counters = {}; + let max = {}; + let lastLoggedAt = 0; + + return { + senderQueue(event) { + incrementCounter(`senderQueue_${event.reason}`); + + if (event.replacing) { + incrementCounter('senderReplacedLatest'); + } + + recordMax('wsBufferedBytes', event.bufferedAmount); + recordMax('framePacketBytes', event.packetBytes); + flush('periodic'); + }, + clientClockSkip(timestamp, clientAudioTime) { + incrementCounter('clientClockSkips'); + recordMax('clientClockLagMs', (clientAudioTime - timestamp) * 1000); + flush('periodic'); + }, + flush, + }; + + function incrementCounter(name, count = 1) { + counters[name] = (counters[name] ?? 0) + count; + } + + function recordMax(name, value) { + if (!Number.isFinite(value)) { + return; + } + + max[name] = Math.max(max[name] ?? 0, Math.round(value)); + } + + function flush(reason = 'periodic', force = false) { + const now = Date.now(); + + if (!force && now - lastLoggedAt < FRAME_CONDITION_LOG_INTERVAL_MS) { + return; + } + + if (Object.keys(counters).length === 0 && Object.keys(max).length === 0) { + return; + } + + logInfo(`frame conditions kind=${label} reason=${reason} counters=${formatTelemetryMap(counters)} max=${formatTelemetryMap(max)}`); + counters = {}; + max = {}; + lastLoggedAt = now; + } +} + +function logClientTelemetry(label, telemetry) { + logInfo( + `client telemetry kind=${label}` + + ` reason=${telemetry.reason}` + + ` currentTime=${formatTelemetryNumber(telemetry.currentTime)}` + + ` paused=${telemetry.paused}` + + ` readyState=${formatTelemetryNumber(telemetry.readyState)}` + + ` pendingFrames=${formatTelemetryNumber(telemetry.pendingFrames)}` + + ` decodedFrames=${formatTelemetryNumber(telemetry.decodedFrames)}` + + ` paintedFrames=${formatTelemetryNumber(telemetry.paintedFrames)}` + + ` lastFramePacketAgeMs=${formatTelemetryNumber(telemetry.lastFramePacketAgeMs)}` + + ` lastFramePaintAgeMs=${formatTelemetryNumber(telemetry.lastFramePaintAgeMs)}` + + ` hidden=${telemetry.hidden}` + + ` online=${telemetry.online}` + + ` counters=${formatTelemetryMap(telemetry.counters)}` + + ` max=${formatTelemetryMap(telemetry.max)}`, + ); +} + +function sanitizeTelemetryMap(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const sanitized = {}; + + for (const [key, rawValue] of Object.entries(value).slice(0, 40)) { + const name = sanitizeTelemetryText(key, '').replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 80); + const number = finiteTelemetryNumber(rawValue); + + if (name && number !== null) { + sanitized[name] = number; + } + } + + return sanitized; +} + +function sanitizeTelemetryText(value, fallback) { + const text = typeof value === 'string' ? value : fallback; + return oneLine(text).replace(/"/g, '').slice(0, 120); +} + +function finiteTelemetryNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function formatTelemetryMap(value) { + const entries = Object.entries(value); + + if (entries.length === 0) { + return 'none'; + } + + return entries.map(([key, entryValue]) => `${key}=${formatTelemetryNumber(entryValue)}`).join(','); +} + +function formatTelemetryNumber(value) { + if (!Number.isFinite(value)) { + return 'null'; + } + + return Math.round(value * 1000) / 1000; +} + function isWebSocketOverFrameBudget(websocket, packetBytes) { return websocket.bufferedAmount > 0 && websocket.bufferedAmount + packetBytes > MAX_WS_BUFFER_BYTES; }