Compare commits

...

4 Commits

Author SHA1 Message Date
Florent Chehab 13c75697c7 (summary) add hostname to analytics properties
This helps track down what was the source of events.
This can be usefull when checking perf of different workers for instance.
2026-08-14 14:45:18 +02:00
lebaudantoine 8d000fc6d9 📈(frontend) stop double-reporting media device failures
Only report `Other` `MediaDeviceFailure` cases as they genuinely
need investigation.

Make sure we do not report the same situation both as a media event
and as a media exception when it is already handled.
2026-08-14 14:04:35 +02:00
lebaudantoine b7abd0ae6e 🐛(frontend) generalize screen-share error modal beyond macOS
The screen-share error modal was tailored to macOS and did not work
correctly on other operating systems.

Make it OS-aware so it also handles Windows properly, showing the
right guidance for each platform.

Also open the OS settings link in a new tab, so the user is not
disconnected from the ongoing meeting when following it.
2026-08-14 11:22:40 +02:00
lebaudantoine 40e4f17c65 🐛(frontend) stop reporting screen-share denials as errors
Add a small helper that classifies a `getDisplayMedia` failure as a
user, browser, or OS permission denial, or returns null when it is
a genuine error.

Chromium reports denials with explicit, non-localized messages:

* "Permission denied by user" when the user cancels or dismisses
  the source picker.
* "Permission denied by system" when the OS blocks capture (e.g.
  the macOS Screen Recording privacy setting).
* Plain "Permission denied" for browser-level blocks (site
  settings, enterprise policy, permissions-policy).

Firefox and Safari use generic `NotAllowedError` messages, which
fall into the "browser" bucket.

Firefox additionally does not map macOS Screen Recording (TCC)
blocks to `NotAllowedError`: the OS silently returns no capturable
sources, so `getDisplayMedia` rejects with `NotFoundError` ("The
object can not be found here."). Same quirk as the mic/cam OS blocks
handled in `useWatchMediaDeviceErrors` via `isLikelySystemNotFound`.

Behavior on a denied screen-share permission:

* Denials are expected outcomes (picker cancelled by the user, OS
  privacy settings, enterprise policy…) and no longer surface as
  exceptions in error tracking; capture an analytics event instead.
* Only OS-level blocks get the modal, since it explains how to
  unblock them.
2026-08-14 11:22:40 +02:00
11 changed files with 105 additions and 40 deletions
+4
View File
@@ -12,6 +12,7 @@ and this project adheres to
- 🔥(frontend) drop unused vendored ConnectionObserver - 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines - 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed ### Fixed
@@ -21,6 +22,9 @@ and this project adheres to
- 🐛(frontend) implement hysteresis band for the control bar layout - 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift - 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog - 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
@@ -23,6 +23,7 @@ export type LogCode =
| 'livekit_room_error' | 'livekit_room_error'
| 'device_switch_failure' | 'device_switch_failure'
| 'permission_poll_failure' | 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families // non-media families
| 'participant_mute_api_failure' | 'participant_mute_api_failure'
| 'permissions_api_failure' | 'permissions_api_failure'
@@ -137,6 +138,7 @@ export const captureMediaEvent = async (
| 'media-device-success' | 'media-device-success'
| 'device-not-found' | 'device-not-found'
| 'permissions-denied' | 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected' | 'silent-mic-detected'
| 'silent-mic-analyser-unavailable' | 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered' | 'silent-mic-recovered'
@@ -225,9 +225,10 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
}) })
}} }}
onConnected={async () => { onConnected={async () => {
@@ -1,6 +1,13 @@
import { A, Button, Dialog, P } from '@/primitives' import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { getOS, type OS } from '@/utils/os'
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
macos:
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
}
// todo - refactor it into a generic system // todo - refactor it into a generic system
export const ScreenShareErrorModal = ({ export const ScreenShareErrorModal = ({
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
onClose: () => void onClose: () => void
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' }) const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1 const os = getOS()
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
return ( return (
<Dialog <Dialog
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
<> <>
<P> <P>
{t('message')}{' '} {t('message')}{' '}
{isMac && ( {settingsHref && (
<> <>
{t('macInstructions')}{' '} {t('settingsInstructions')}{' '}
<A <A
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" href={settingsHref}
target="_blank"
color="primary" color="primary"
aria-label={t('macSystemPreferences') + '-' + t('newTab')} aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
> >
{t('macSystemPreferences')} {t(`settingsLabel.${os}`)}
</A> </A>
.{' '} .{' '}
</> </>
@@ -50,15 +50,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => { useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error) const failure = MediaDeviceFailure.getFailure(error)
if (!failure || !kind) return if (!failure) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', { void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event', log_code: 'media_devices_error_event',
path: 'connect_publish', path: 'connect_publish',
failure, failure,
kind, kind: kind ?? 'unknown',
}) })
}
if (!kind) return
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) { switch (failure) {
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
@@ -11,7 +11,9 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording' import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver' import { ConnectionObserver } from '../components/ConnectionObserver'
import { reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
import { isFireFox } from '@/utils/livekit'
import { MediaStateObserver } from '../components/MediaStateObserver' import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer' import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
@@ -36,6 +38,20 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
SettingsComponent?: React.ComponentType SettingsComponent?: React.ComponentType
} }
const getScreenSharePermissionDeniedScope = (
error: Error
): 'system' | 'user' | 'browser' | null => {
if (error.name === 'NotAllowedError') {
if (/by system/i.test(error.message)) return 'system'
if (/by user/i.test(error.message)) return 'user'
return 'browser'
}
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
return 'system'
}
return null
}
/** /**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application. * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
@@ -61,6 +77,34 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false) const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
source,
error,
}: {
source: Track.Source
error: Error
}) => {
if (source === Track.Source.ScreenShare) {
const scope = getScreenSharePermissionDeniedScope(error)
if (scope) {
if (scope === 'system') setIsShareErrorVisible(true)
void captureMediaEvent('screen-share-permission-denied', {
at: 'ControlBar.onDeviceError',
source,
error_name: error.name,
error_message: error.message,
denied_scope: scope,
os: getOS(),
})
return
}
}
reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError',
source,
})
}
return ( return (
<> <>
<RoomMetadataSynchronizer /> <RoomMetadataSynchronizer />
@@ -92,21 +136,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<StageLayout /> <StageLayout />
)} )}
</RoomContentArea> </RoomContentArea>
<ControlBar <ControlBar onDeviceError={handleDeviceError} />
onDeviceError={(e) => {
reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel /> <SidePanel />
</> </>
)} )}
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "Bildschirmfreigabe nicht möglich", "title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich", "ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.", "message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
"macInstructions": "Gehe zu den", "settingsInstructions": "Gehe zu den",
"macSystemPreferences": "Systemeinstellungen", "settingsLabel": {
"macos": "Systemeinstellungen",
"windows": "Windows-Datenschutzeinstellungen"
},
"helpLinkText": "Weitere Informationen findest du unter", "helpLinkText": "Weitere Informationen findest du unter",
"helpLinkLabel": "Bildschirmfreigabeproblem", "helpLinkLabel": "Bildschirmfreigabeproblem",
"closeButton": "Schließen", "closeButton": "Schließen",
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "Unable to share your screen", "title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen", "ariaLabel": "Unable to share your screen",
"message": "Your browser may not be allowed to record the screen on your computer.", "message": "Your browser may not be allowed to record the screen on your computer.",
"macInstructions": "Go to your", "settingsInstructions": "Go to your",
"macSystemPreferences": "System Preferences", "settingsLabel": {
"macos": "System Preferences",
"windows": "Windows privacy settings"
},
"helpLinkText": "To learn more, see", "helpLinkText": "To learn more, see",
"helpLinkLabel": "Presentation issue", "helpLinkLabel": "Presentation issue",
"closeButton": "Dismiss", "closeButton": "Dismiss",
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "Impossible de partager votre écran", "title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran", "ariaLabel": "Impossible de partager votre écran",
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.", "message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
"macInstructions": "Accèdez à vos", "settingsInstructions": "Accédez à vos",
"macSystemPreferences": "Préférences système", "settingsLabel": {
"macos": "Préférences système",
"windows": "paramètres de confidentialité Windows"
},
"helpLinkText": "Pour en savoir plus, consulter", "helpLinkText": "Pour en savoir plus, consulter",
"helpLinkLabel": "Problème de présentation", "helpLinkLabel": "Problème de présentation",
"closeButton": "Ignorer", "closeButton": "Ignorer",
+5 -2
View File
@@ -214,8 +214,11 @@
"title": "Kan uw scherm niet delen", "title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen", "ariaLabel": "Kan uw scherm niet delen",
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.", "message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
"macInstructions": "Ga naar uw", "settingsInstructions": "Ga naar uw",
"macSystemPreferences": "Systeemvoorkeuren", "settingsLabel": {
"macos": "Systeemvoorkeuren",
"windows": "Windows-privacyinstellingen"
},
"helpLinkText": "Meer informatie, zie", "helpLinkText": "Meer informatie, zie",
"helpLinkLabel": "Presentatieprobleem", "helpLinkLabel": "Presentatieprobleem",
"closeButton": "Negeren", "closeButton": "Negeren",
+6
View File
@@ -1,6 +1,7 @@
"""Analytics classes.""" """Analytics classes."""
import json import json
import socket
import time import time
from collections import Counter from collections import Counter
from functools import lru_cache from functools import lru_cache
@@ -43,6 +44,11 @@ class Analytics:
if self.is_disabled: if self.is_disabled:
return return
# We add hostname to help track down the source of events
properties = properties or {}
if not properties.get("hostname"):
properties = {**properties, "hostname": socket.gethostname()}
try: try:
self._client.capture( self._client.capture(
event_name, distinct_id=distinct_id, properties=properties event_name, distinct_id=distinct_id, properties=properties