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 } }} >