From 8615bf879c6536c723780f2bf22dfea134460b15 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 7 Aug 2026 17:46:54 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88(frontend)=20capture=20media=20diag?= =?UTF-8?q?nostics=20on=20media=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attach a media diagnostics snapshot to the room event handler for media exceptions. The snapshot captures the state of the user's setup at the moment of the error (available devices, permission state, active tracks, etc.), so support has enough context to troubleshoot user issues without asking them to reproduce. --- CHANGELOG.md | 4 + .../src/features/analytics/telemetry.ts | 86 +++++++++++++++++++ .../features/rooms/components/Conference.tsx | 22 ++++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a77db67..ba9ce616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- 📈(frontend) capture media diagnostics on media errors + ### Changed - ♻️(frontend) encapsulate error tracking behind a telemetry module diff --git a/src/frontend/src/features/analytics/telemetry.ts b/src/frontend/src/features/analytics/telemetry.ts index 5d87a1b3..60bce040 100644 --- a/src/frontend/src/features/analytics/telemetry.ts +++ b/src/frontend/src/features/analytics/telemetry.ts @@ -52,3 +52,89 @@ export const reportError = ( console.warn(`[${logCode}]`, e, extraInfo) } } + +export interface DeviceSnapshot { + cam_count: number + mic_count: number + out_count: number + labels_visible: boolean + saved_cam_present: boolean | null + saved_mic_present: boolean | null + saved_video_device_id_set: boolean + saved_audio_device_id_set: boolean + audio_enabled: boolean | null + video_enabled: boolean | null + cam_permission: PermissionState | 'unknown' + mic_permission: PermissionState | 'unknown' +} + +/** Reads the persisted LiveKit user choices without importing the store. */ +const readPersistedChoices = (): { + videoDeviceId?: string + audioDeviceId?: string + videoEnabled?: boolean + audioEnabled?: boolean +} => { + try { + return JSON.parse(localStorage.getItem('lk-user-choices') ?? '{}') + } catch { + return {} + } +} + +const queryPermission = async ( + name: 'camera' | 'microphone' +): Promise => { + try { + const status = await navigator.permissions.query({ + name: name as PermissionName, + }) + return status.state + } catch { + return 'unknown' + } +} + +export const deviceSnapshot = async (): Promise => { + const choices = readPersistedChoices() + let devices: MediaDeviceInfo[] = [] + try { + devices = await navigator.mediaDevices.enumerateDevices() + } catch { + /* snapshot stays partial */ + } + const ofKind = (k: MediaDeviceKind) => devices.filter((d) => d.kind === k) + const present = (k: MediaDeviceKind, id?: string) => + id ? ofKind(k).some((d) => d.deviceId === id) : null + + const [cam_permission, mic_permission] = await Promise.all([ + queryPermission('camera'), + queryPermission('microphone'), + ]) + + return { + cam_count: ofKind('videoinput').length, + mic_count: ofKind('audioinput').length, + out_count: ofKind('audiooutput').length, + labels_visible: devices.some((d) => !!d.label), + saved_cam_present: present('videoinput', choices.videoDeviceId), + saved_mic_present: present('audioinput', choices.audioDeviceId), + saved_video_device_id_set: !!choices.videoDeviceId, + saved_audio_device_id_set: !!choices.audioDeviceId, + audio_enabled: choices.audioEnabled ?? null, + video_enabled: choices.videoEnabled ?? null, + cam_permission, + mic_permission, + } +} + +export const captureMediaEvent = async ( + event: + | 'media-device-error' + | 'media-acquisition' + | 'media-device-topology' + | 'media-device-success', + props: Record +) => { + captureEvent(event, { ...props, ...(await deviceSnapshot()) }) +} diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 92e517ed..63762bb7 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -26,7 +26,11 @@ import { css } from '@/styled-system/css' import { BackgroundProcessorFactory } from '../livekit/components/blur' import { LocalUserChoices } from '@/stores/userChoices' import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert' -import { captureEvent, reportError } from '@/features/analytics/telemetry' +import { + captureEvent, + reportError, + captureMediaEvent, +} from '@/features/analytics/telemetry' import { useConfig } from '@/api/useConfig' import { isFireFox } from '@/utils/livekit' import { useIsMobile } from '@/utils/useIsMobile' @@ -235,6 +239,7 @@ export const Conference = ({ })} onError={(e) => { reportError('livekit_room_error', e, { + path: 'connect_publish', failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error', }) }} @@ -297,8 +302,19 @@ export const Conference = ({ } }} onMediaDeviceFailure={(e, kind) => { - if (e == MediaDeviceFailure.DeviceInUse && !!kind) { - setMediaDeviceError({ error: e, kind }) + if (!e || !kind) return + void captureMediaEvent('media-device-error', { + log_code: 'media_devices_error_event', + path: 'connect_publish', + failure: e, + kind, + }) + switch (e) { + case MediaDeviceFailure.DeviceInUse: + setMediaDeviceError({ error: e, kind }) + break + default: + break } }} >