From bd3a26a2afb857590fea534829036babe7b28ade Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 9 Apr 2026 23:30:44 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88(frontend)=20track=20WebRTC=20peer?= =?UTF-8?q?=20candidates=20in=20PostHog=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture selected ICE candidates for both subscriber and publisher peer connections. This enables correlation between survey feedback and connectivity setup, helping identify problematic network configurations. --- src/frontend/src/features/analytics/enums.ts | 1 + .../features/rooms/components/Conference.tsx | 30 +++++- .../src/features/rooms/components/Rating.tsx | 19 +++- .../livekit/hooks/useConnectionObserver.ts | 99 +++++++++++++++++++ .../useIsAdvancedConnectionObserverEnabled.ts | 12 +++ .../src/features/rooms/routes/Feedback.tsx | 13 ++- src/frontend/src/stores/connectionObserver.ts | 14 +++ 7 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useIsAdvancedConnectionObserverEnabled.ts diff --git a/src/frontend/src/features/analytics/enums.ts b/src/frontend/src/features/analytics/enums.ts index bf552bca..0f6e4f5d 100644 --- a/src/frontend/src/features/analytics/enums.ts +++ b/src/frontend/src/features/analytics/enums.ts @@ -4,4 +4,5 @@ export enum FeatureFlags { faceLandmarks = 'face-landmarks', noiseReduction = 'noise-reduction', subtitles = 'subtitles', + candidatePolling = 'candidate-polling', } diff --git a/src/frontend/src/features/rooms/components/Conference.tsx b/src/frontend/src/features/rooms/components/Conference.tsx index 30b484ad..2789a3ab 100644 --- a/src/frontend/src/features/rooms/components/Conference.tsx +++ b/src/frontend/src/features/rooms/components/Conference.tsx @@ -31,6 +31,8 @@ import { useConfig } from '@/api/useConfig' import { isFireFox } from '@/utils/livekit' import { useIsMobile } from '@/utils/useIsMobile' import { navigateTo } from '@/navigation/navigateTo' +import { useSnapshot } from 'valtio' +import { connectionObserverStore } from '@/stores/connectionObserver' export const Conference = ({ roomId, @@ -44,6 +46,8 @@ export const Conference = ({ const posthog = usePostHog() const { data: apiConfig } = useConfig() + const connectionObserverSnap = useSnapshot(connectionObserverStore) + const { userChoices: userConfig } = usePersistentUserChoices() as { userChoices: LocalUserChoices } @@ -228,13 +232,32 @@ export const Conference = ({ posthog.captureException(e) }} onDisconnected={(e) => { + const metadata = { + room_id: roomId, + pc_publisher: connectionObserverSnap.publisher && { + ...connectionObserverSnap.publisher, + }, + pc_subscriber: connectionObserverSnap.subscriber && { + ...connectionObserverSnap.subscriber, + }, + pc_publisher_changes_count: + connectionObserverSnap.publisherChangesCount, + pc_subscriber_changes_count: + connectionObserverSnap.subscriberChangesCount, + } + + connectionObserverStore.publisher = null + connectionObserverStore.publisherChangesCount = 0 + connectionObserverStore.subscriber = null + connectionObserverStore.subscriberChangesCount = 0 + switch (e) { case DisconnectReason.CLIENT_INITIATED: navigateTo( 'feedback', {}, { - state: { room_id: roomId }, + state: { ...metadata }, } ) return @@ -244,7 +267,10 @@ export const Conference = ({ 'feedback', {}, { - state: { reason: e, room_id: roomId }, + state: { + reason: e, + ...metadata, + }, } ) return diff --git a/src/frontend/src/features/rooms/components/Rating.tsx b/src/frontend/src/features/rooms/components/Rating.tsx index d405d961..c0cd9380 100644 --- a/src/frontend/src/features/rooms/components/Rating.tsx +++ b/src/frontend/src/features/rooms/components/Rating.tsx @@ -7,6 +7,7 @@ import { usePostHog } from 'posthog-js/react' import type { PostHog } from 'posthog-js' import { Button as RACButton } from 'react-aria-components' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' +import { CandidateInfo } from '@/stores/connectionObserver' const Card = styled('div', { base: { @@ -303,7 +304,19 @@ const AuthenticationMessage = ({ ) } -export const Rating = ({ roomId }: { roomId?: string }) => { +type RatingMetadata = { + room_id?: string + pc_publisher?: CandidateInfo + pc_subscriber?: CandidateInfo + pc_publisher_changes_count?: number + pc_subscriber_changes_count?: number +} + +export const Rating = ({ + metadata: metadataProp, +}: { + metadata: RatingMetadata +}) => { const isAnalyticsEnabled = useIsAnalyticsEnabled() const posthog = usePostHog() @@ -318,9 +331,9 @@ export const Rating = ({ roomId }: { roomId?: string }) => { const metadata = useMemo( () => ({ session_id: sessionId, - room_id: roomId, + ...metadataProp, }), - [roomId, sessionId] + [sessionId, metadataProp] ) if (!isAnalyticsEnabled) return diff --git a/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts b/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts index e354e68b..e6975952 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useConnectionObserver.ts @@ -10,6 +10,9 @@ import { connectionObserverStore } from '@/stores/connectionObserver' import { useConfig } from '@/api/useConfig' import { userPreferencesStore } from '@/stores/userPreferences' import { useSnapshot } from 'valtio' +import { useIsAdvancedConnectionObserverEnabled } from './useIsAdvancedConnectionObserverEnabled' + +const CANDIDATE_POLL_INTERVAL_MS = 5000 export const useConnectionObserver = () => { const room = useRoomContext() @@ -17,6 +20,8 @@ export const useConnectionObserver = () => { const { data } = useConfig() const isAnalyticsEnabled = useIsAnalyticsEnabled() + const isAdvancedConnectionObserverEnabled = + useIsAdvancedConnectionObserverEnabled() const userPreferencesSnap = useSnapshot(userPreferencesStore) @@ -67,6 +72,100 @@ export const useConnectionObserver = () => { userPreferencesSnap.is_idle_disconnect_modal_enabled, ]) + useEffect(() => { + if (!isAdvancedConnectionObserverEnabled) return + if (!room) return + + let interval: ReturnType | null = null + + const pollCandidate = async ( + label: 'publisher' | 'subscriber', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pc?: any + ) => { + if (!pc) return + + let stats: RTCStatsReport + try { + stats = await pc.getStats() + } catch { + return + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stats.forEach((report: any) => { + if ( + report.type === 'candidate-pair' && + report.state === 'succeeded' && + report.nominated + ) { + const remoteCandidate = stats.get(report.remoteCandidateId) + if (!remoteCandidate) return + + const next = { + type: remoteCandidate.candidateType, + address: remoteCandidate.address, + protocol: remoteCandidate.protocol, + } + + const current = connectionObserverStore[label] + + const hasChanged = + current?.type !== next.type || + current?.address !== next.address || + current?.protocol !== next.protocol + + if (hasChanged) { + connectionObserverStore[label] = next + const key = `${label}ChangesCount` as const + connectionObserverStore[key] = + (connectionObserverStore[key] || 0) + 1 + } + } + }) + } + + const poll = async () => { + const publisher = room.engine?.pcManager?.publisher + const subscriber = room.engine?.pcManager?.subscriber + + await Promise.all([ + pollCandidate('publisher', publisher), + pollCandidate('subscriber', subscriber), + ]) + } + + const startPolling = async () => { + if (interval) return // prevent duplicates + + // Initial snapshot + await poll() + + interval = setInterval(poll, CANDIDATE_POLL_INTERVAL_MS) + } + + const stopPolling = () => { + if (!interval) return + clearInterval(interval) + interval = null + } + + room.on(RoomEvent.Connected, startPolling) + room.on(RoomEvent.Reconnected, startPolling) + + room.on(RoomEvent.Reconnecting, stopPolling) + room.on(RoomEvent.Disconnected, stopPolling) + + return () => { + stopPolling() + + room.off(RoomEvent.Connected, startPolling) + room.off(RoomEvent.Reconnected, startPolling) + room.off(RoomEvent.Reconnecting, stopPolling) + room.off(RoomEvent.Disconnected, stopPolling) + } + }, [room, isAdvancedConnectionObserverEnabled]) + useEffect(() => { if (!isAnalyticsEnabled) return diff --git a/src/frontend/src/features/rooms/livekit/hooks/useIsAdvancedConnectionObserverEnabled.ts b/src/frontend/src/features/rooms/livekit/hooks/useIsAdvancedConnectionObserverEnabled.ts new file mode 100644 index 00000000..9849361d --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useIsAdvancedConnectionObserverEnabled.ts @@ -0,0 +1,12 @@ +import { useFeatureFlagEnabled } from 'posthog-js/react' +import { FeatureFlags } from '@/features/analytics/enums.ts' +import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled.ts' +import { isMobileBrowser } from '@livekit/components-core' + +export const useIsAdvancedConnectionObserverEnabled = () => { + const featureEnabled = useFeatureFlagEnabled(FeatureFlags.candidatePolling) + const isAnalyticsEnabled = useIsAnalyticsEnabled() + + const isMobile = isMobileBrowser() + return !isMobile && isAnalyticsEnabled && featureEnabled +} diff --git a/src/frontend/src/features/rooms/routes/Feedback.tsx b/src/frontend/src/features/rooms/routes/Feedback.tsx index 8d0b6e2f..d084e2ab 100644 --- a/src/frontend/src/features/rooms/routes/Feedback.tsx +++ b/src/frontend/src/features/rooms/routes/Feedback.tsx @@ -6,6 +6,7 @@ import { Rating } from '@/features/rooms/components/Rating.tsx' import { useLocation } from 'wouter' import { useMemo } from 'react' import { DisconnectReason } from 'livekit-client' +import { CandidateInfo } from '@/stores/connectionObserver.ts' // fixme - duplicated with home, refactor in a proper style const Heading = styled('h1', { @@ -43,9 +44,15 @@ export const FeedbackRoute = () => { } }, []) - const roomId = useMemo(() => { + const metadata = useMemo(() => { const state = window.history.state - return state?.room_id + return { + room_id: state?.room_id as string, + pc_publisher: state?.pc_publisher as CandidateInfo, + pc_publisher_changes_count: state?.pc_publisher_changes_count as number, + pc_subscriber: state?.pc_subscriber as CandidateInfo, + pc_subscriber_changes_count: state?.pc_subscriber_changes_count as number, + } }, []) const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved @@ -65,7 +72,7 @@ export const FeedbackRoute = () => { {t('feedback.home')} - + diff --git a/src/frontend/src/stores/connectionObserver.ts b/src/frontend/src/stores/connectionObserver.ts index 5149cd59..1ee48758 100644 --- a/src/frontend/src/stores/connectionObserver.ts +++ b/src/frontend/src/stores/connectionObserver.ts @@ -1,9 +1,23 @@ import { proxy } from 'valtio' +export type CandidateInfo = { + type: string + address: string + protocol: string +} + type State = { isIdleDisconnectModalOpen: boolean + publisher: CandidateInfo | null + publisherChangesCount: number + subscriber: CandidateInfo | null + subscriberChangesCount: number } export const connectionObserverStore = proxy({ isIdleDisconnectModalOpen: false, + publisher: null, + publisherChangesCount: 0, + subscriber: null, + subscriberChangesCount: 0, })