From 9d4d42ba6589bc850ef1e2ab3dbb3af449591ba9 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Tue, 6 Jan 2026 01:45:28 +0300 Subject: [PATCH 1/9] feat: added telemetry page --- apps/client/src/App.tsx | 2 + apps/client/src/pages/JoinPage.tsx | 6 +- apps/client/src/pages/SessionPage.tsx | 628 ++++++++++++-------------- apps/client/src/pages/Telemetry.tsx | 275 +++++++++++ apps/client/src/util/telemetryDB.ts | 185 ++++++++ package-lock.json | 16 + 6 files changed, 773 insertions(+), 339 deletions(-) create mode 100644 apps/client/src/pages/Telemetry.tsx create mode 100644 apps/client/src/util/telemetryDB.ts diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c4ed178..086f817 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -18,6 +18,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import JoinPage from './pages/JoinPage' import SessionPage from './pages/SessionPage' import Arch from './pages/Arch' +import Telemetry from './pages/Telemetry' import { SessionProvider } from './contexts/SessionContext' import { SocketProvider } from './sockets/SocketContext' @@ -30,6 +31,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/apps/client/src/pages/JoinPage.tsx b/apps/client/src/pages/JoinPage.tsx index 699b37c..4fcafb3 100644 --- a/apps/client/src/pages/JoinPage.tsx +++ b/apps/client/src/pages/JoinPage.tsx @@ -342,8 +342,8 @@ export default function JoinPage() { {roomLimits.sessionDurationMinutes !== 1 ? 's' : ''} and session size is limited to{' '} {roomLimits.maxUsersPerRoom} participants. {commitHash && ( - <> - Commit:{' '} + + Commit: {commitHash} - + )} {error &&
{error}
} diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index ea4e72a..ffbed71 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -74,7 +74,7 @@ import { MOQtailClient } from 'moqtail-ts/client' import { NetworkTelemetry, ClockNormalizer } from 'moqtail-ts/util' import { RewindPlayer } from './RewindPlayer' import { BufferedMoqtObject } from '@/composables/rewindBuffer' -import { TestMetricsManager } from '@/util/testMetrics' +import { telemetryDB } from '@/util/telemetryDB' function SessionPage() { // initialize the MOQTail client @@ -96,7 +96,7 @@ function SessionPage() { const [hasLocalAudio, setHasLocalAudio] = useState(false) const [isCamOn, setisCamOn] = useState(false) const [isScreenSharing, setIsScreenSharing] = useState(false) - const [isChatOpen, setIsChatOpen] = useState(true) + const [isChatOpen, setIsChatOpen] = useState(true) // TODO: implement MoQ chat const [chatMessage, setChatMessage] = useState('') const { socket: contextSocket } = useSocket() const [users, setUsers] = useState<{ [K: string]: RoomUser }>({}) @@ -106,20 +106,11 @@ function SessionPage() { }>({}) const [chatMessages, setChatMessages] = useState([]) const [telemetryData, setTelemetryData] = useState<{ - [userId: string]: { - videoLatency: number - audioLatency: number - screenshareLatency: number - videoBitrate: number - audioBitrate: number - } - }>({}) - const telemetryInstances = useRef<{ - [userId: string]: { video: NetworkTelemetry; audio: NetworkTelemetry; screenshare: NetworkTelemetry } + [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } }>({}) - const [videoLatencyHistory, setVideoLatencyHistory] = useState<{ [userId: string]: number[] }>({}) - const [audioLatencyHistory, setAudioLatencyHistory] = useState<{ [userId: string]: number[] }>({}) - const [screenshareLatencyHistory, setScreenshareLatencyHistory] = useState<{ [userId: string]: number[] }>({}) + const telemetryInstances = useRef<{ [userId: string]: { video: NetworkTelemetry; audio: NetworkTelemetry } }>({}) + const sessionId = useRef(crypto.randomUUID()) + const [latencyHistory, setLatencyHistory] = useState<{ [userId: string]: number[] }>({}) const [videoBitrateHistory, setVideoBitrateHistory] = useState<{ [userId: string]: number[] }>({}) const [audioBitrateHistory, setAudioBitrateHistory] = useState<{ [userId: string]: number[] }>({}) const [timeRemaining, setTimeRemaining] = useState('--:--') @@ -219,11 +210,6 @@ function SessionPage() { null, ) const [pendingHDRequests, setPendingHDRequests] = useState>(new Set()) - - // Test Metrics Manager - const testMetricsManagerRef = useRef(new TestMetricsManager()) - const startupLatencyTracked = useRef(false) - useEffect(() => { userVideoQualitiesRef.current = userVideoQualities }, [userVideoQualities]) @@ -344,16 +330,11 @@ function SessionPage() { // Rewind functionality const handleOpenRewindPlayer = async (userId: string) => { if (isFetching) { - console.warn('Fetch in progress') + console.log('Already fetching rewind data, please wait...') return } - // Track rewind button pressed - const user = users[userId] - if (user) { - testMetricsManagerRef.current.trackRewindButtonPressed(userId) - } - + console.log('Fetching rewind data for user:', userId) setIsFetching(true) try { @@ -365,6 +346,10 @@ function SessionPage() { const videoObjects: BufferedMoqtObject[] = [] const audioObjects: BufferedMoqtObject[] = [] + // Fetch video track + const videoTrackName = getTrackname(roomState.name, userId, 'video') + console.log('Fetching video track:', videoTrackName.toString()) + /* const videoResult = await moqClient.fetch({ priority: 0, @@ -381,12 +366,13 @@ function SessionPage() { */ // get request id from the video track subscription. If the viewer is HD-subscribed // and no SD request id exists, create a temporary SD subscription (no canvas/worker) + console.log('userSubscriptions', userSubscriptions) let videoRequestId = userSubscriptions[userId]?.videoRequestId const isHDSubscribed = !!userSubscriptions[userId]?.videoHDSubscribed if (videoRequestId === undefined && isHDSubscribed) { - console.warn('No SD request id while HD subscribed - creating temporary SD subscription for rewind') + console.log('No SD request id while HD subscribed - creating temporary SD subscription for rewind') // create a silent SD subscription using subscribeOnlyVideo const canvasRef = remoteCanvasRefs[userId] if (!roomState) { @@ -427,6 +413,12 @@ function SessionPage() { return } + console.log('SessionPage: About to fetch video with joiningRequestId:', videoRequestId) + console.log('SessionPage: Using rewind fetch group size:', rewindFetchGroupSize) + console.log( + 'SessionPage: All moqClient requestIds before video fetch:', + moqClient ? Array.from(moqClient.requests.keys()) : 'no client', + ) const videoResult = await moqClient.fetch({ priority: 0, groupOrder: GroupOrder.Original, @@ -441,6 +433,7 @@ function SessionPage() { if (!(videoResult instanceof FetchError)) { const reader = videoResult.stream.getReader() + console.log('Reading video objects from fetch stream...') try { while (true) { @@ -453,6 +446,11 @@ function SessionPage() { timestamp: Date.now(), type: 'video', }) + console.log('Fetched video object:', { + group: value.location.group.toString(), + object: value.location.object.toString(), + payloadSize: value.payload.length, + }) } } } finally { @@ -462,7 +460,9 @@ function SessionPage() { console.warn('Video fetch failed or returned error:', videoResult) } - console.warn('Skipping audio fetch to avoid AV sync issues during demo') + console.log('Skipping audio fetch to avoid AV sync issues during demo') + + console.log(`Fetch complete for user ${userId}: ${videoObjects.length} video objects (audio skipped for demo)`) setFetchedRewindData((prev) => ({ ...prev, @@ -472,11 +472,6 @@ function SessionPage() { if (videoObjects.length > 0) { setSelectedRewindUserId(userId) setIsRewindPlayerOpen(true) - - // Auto-start playback after a short delay to ensure player is initialized - setTimeout(() => { - // The RewindPlayer will handle auto-start via a new prop - }, 250) } else { console.warn('No video data available for user:', userId) } @@ -488,6 +483,7 @@ function SessionPage() { } const handleCloseRewindPlayer = () => { + console.log('SessionPage: Closing rewind player') isRewindCleaningUp.current = true // Clear the fetched rewind data for the selected user @@ -495,6 +491,7 @@ function SessionPage() { setFetchedRewindData((prev) => { const updated = { ...prev } delete updated[selectedRewindUserId] + console.log(`Cleared fetched rewind data for user: ${selectedRewindUserId}`) return updated }) } @@ -505,6 +502,7 @@ function SessionPage() { // If we created a temporary SD subscription for this user, clean it up now const tempSub = tempSDSubscriptionsRef.current[selectedRewindUserId] if (tempSub) { + console.log('Cleaning up temporary SD subscription for', selectedRewindUserId) tempSub.cleanup() delete tempSDSubscriptionsRef.current[selectedRewindUserId] // Clear subscription state if it was only temporary @@ -523,6 +521,7 @@ function SessionPage() { // Add a delay to ensure rewind player cleanup is complete setTimeout(() => { + console.log('SessionPage: Rewind player cleanup complete') isRewindCleaningUp.current = false }, 500) } @@ -601,7 +600,7 @@ function SessionPage() { const handleToggle = (kind: 'mic' | 'cam') => { // Don't allow toggles while rewind player is cleaning up if (isRewindCleaningUp.current) { - console.warn('Rewind player is cleaning up, ignoring toggle request') + console.log('Rewind player is cleaning up, ignoring toggle request') return } @@ -642,6 +641,7 @@ function SessionPage() { videoEncoderObjRef.current.offset = offsetRef.current videoEncoderObjRef.current.start(selfMediaStream.current) } + //TODO: HOW TO CONTINUE HD VIDEO ENCODER if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.offset = offsetRef.current videoHDEncoderObjRef.current.start(selfMediaStream.current) @@ -665,6 +665,7 @@ function SessionPage() { if (videoEncoderObjRef.current) { videoEncoderObjRef.current.stop() } + //TODO: HOW TO STOP HD VIDEO ENCODER if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.stop() } @@ -747,19 +748,17 @@ function SessionPage() { return } - console.debug(`Transitioning ${targetUserId} from ${currentQuality} to ${newQuality}`) + console.log(`Transitioning ${targetUserId} from ${currentQuality} to ${newQuality}`) manualQualityTransitionsRef.current.add(targetUserId) - // Track quality toggle start - const direction = currentQuality === 'HD' && newQuality === 'SD' ? 'HD->SD' : 'SD->HD' - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, direction) - try { if (currentQuality === 'HD' && newQuality === 'SD') { + console.log('Unsubscribing from HD video') hdSubscriptionAttemptsRef.current.delete(targetUserId) await unsubscribeFromHDVideo(targetUserId) await new Promise((resolve) => setTimeout(resolve, 500)) } else if (currentQuality === 'SD' && newQuality === 'HD') { + console.log('Unsubscribing from SD video') hdSubscriptionAttemptsRef.current.delete(targetUserId) await unsubscribeFromUser(targetUserId, 'video') await new Promise((resolve) => setTimeout(resolve, 500)) @@ -770,14 +769,17 @@ function SessionPage() { targetUserId, requesterId: userId, }) + console.log(`Sent HD video request to user ${targetUserId}`) } else { contextSocket?.emit('request-sd-video', { targetUserId, requesterId: userId, }) + console.log(`Sent SD video request to user ${targetUserId}`) const success = await subscribeToSDVideo(targetUserId) if (success) { + console.log(`Successfully switched to SD video for ${targetUserId}`) } else { console.error(`Failed to switch to SD video for ${targetUserId}`) } @@ -808,9 +810,12 @@ function SessionPage() { if (currentQuality === 'HD') { // HD to SD: Direct transition, no permission needed - console.debug(`Switching from HD to SD for ${targetUserId}`) + console.log(`Switching from HD to SD for ${targetUserId}`) await handleQualityTransition(targetUserId, 'HD', 'SD') } else { + // SD to HD: Request permission first + console.log(`Requesting HD permission from ${targetUserId}`) + // Add to pending requests setPendingHDRequests((prev) => new Set(prev).add(targetUserId)) @@ -818,6 +823,7 @@ function SessionPage() { targetUserId, requesterId: userId, }) + console.log(`Sent HD permission request to user ${targetUserId}`) // Wait for permission response - handleQualityTransition will be called based on response } } @@ -825,6 +831,7 @@ function SessionPage() { // HD Permission Response Functions const handleHDPermissionAccept = () => { if (hdPermissionRequest && contextSocket) { + console.log(`Accepting HD permission request from ${hdPermissionRequest.requesterId}`) contextSocket.emit('hd-permission-response', { requesterId: hdPermissionRequest.requesterId, allowed: true, @@ -835,7 +842,7 @@ function SessionPage() { const handleHDPermissionReject = () => { if (hdPermissionRequest && contextSocket) { - console.warn(`Rejecting HD permission request from ${hdPermissionRequest.requesterId}`) + console.log(`Rejecting HD permission request from ${hdPermissionRequest.requesterId}`) contextSocket.emit('hd-permission-response', { requesterId: hdPermissionRequest.requesterId, allowed: false, @@ -849,6 +856,8 @@ function SessionPage() { return } + console.log('Starting HD encoding process...') + setUsers((users) => { const u = users[userId] users[userId] = { ...u, hasVideoHD: true } @@ -860,6 +869,9 @@ function SessionPage() { const videoHDTrack = roomState.users[userId]?.publishedTracks['video-hd'] if (videoHDTrack && moqClientRef.current) { + // TODO: HD Track should be announced only when the user accepts + // TODO: Otherwise, SD should continue (the unsub should not happen) + console.log('Announcing HD track to server') contextSocket?.emit('update-track', { trackType: 'video-hd', event: 'announce' }) if (selfMediaStream.current && tracksRef.current) { @@ -876,6 +888,7 @@ function SessionPage() { if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.offset = offsetRef.current videoHDEncoderObjRef.current.start(selfMediaStream.current) + console.log('HD encoder started successfully') } contextSocket?.emit('update-track', { trackType: 'video-hd', event: 'publish' }) @@ -893,6 +906,8 @@ function SessionPage() { } contextSocket?.emit('toggle-button', { kind: 'cam-hd', value: true }) + + console.log('Started HD encoding due to remote request') } const handleStopHDEncoding = () => { @@ -913,7 +928,7 @@ function SessionPage() { contextSocket?.emit('toggle-button', { kind: 'cam-hd', value: false }) - console.warn('Stopped HD encoding due to remote request') + console.log('Stopped HD encoding due to remote request') } const handleToggleScreenShare = async () => { @@ -930,6 +945,8 @@ function SessionPage() { selfScreenshareStream.current = screenStream + console.log('Screenshare stream tracks:', screenStream.getTracks()) + try { if (!tracksRef.current) { console.error('tracksRef.current is null - cannot start screenshare encoder') @@ -937,6 +954,7 @@ function SessionPage() { } if (screenshareEncoderObjRef.current) { + console.log('Cleaning up previous screenshare encoder') try { screenshareEncoderObjRef.current.stop() } catch (error) { @@ -946,6 +964,7 @@ function SessionPage() { } const screenshareFullTrackName = getTrackname(roomState!.name, userId, 'screenshare') + console.log('screenshareFullTrackName:', screenshareFullTrackName) const screenshareStreamController = tracksRef.current.getScreenshareStreamController() @@ -956,6 +975,7 @@ function SessionPage() { publisherPriority: 1, objectForwardingPreference: ObjectForwardingPreference.Subgroup, }) + console.log('screenshareResult:', screenshareResult) screenshareEncoderObjRef.current = screenshareResult const updateTrackRequest: UpdateTrackRequest = { @@ -963,6 +983,7 @@ function SessionPage() { event: 'announce', } contextSocket?.emit('update-track', updateTrackRequest) + console.log('Screenshare track announced to other clients') } catch (error) { console.error('Failed to start screenshare encoder:', error) if (selfScreenshareStream.current) { @@ -980,6 +1001,8 @@ function SessionPage() { })) screenTrack.onended = () => { + console.log('Screen track ended - cleaning up screenshare') + setIsScreenSharing(false) contextSocket?.emit('screen-share-toggled', { userId, hasScreenshare: false }) setUsers((users) => ({ @@ -1051,6 +1074,7 @@ function SessionPage() { useEffect(() => { async function startPublisher() { try { + //console.log('Starting publisher for user:', userId); if (!userId) { console.error('User ID is not defined') return @@ -1069,11 +1093,14 @@ function SessionPage() { console.warn('No audio tracks available; running without microphone') } setHasLocalAudio(audioTracks.length > 0) + + //console.log('Got user media:', selfMediaStream.current); setMediaReady(true) if (selfVideoRef.current) { selfVideoRef.current.srcObject = selfMediaStream.current selfVideoRef.current.muted = true // Ensure muted + //console.log('Set video srcObject'); } else { console.error('selfVideoRef.current is null') return @@ -1095,6 +1122,7 @@ function SessionPage() { console.error('Self user not found in room state: %s', userId) return } + //console.log('Self user found:', selfUser); const videoTrack = selfUser?.publishedTracks['video'] const videoTrackAlias = videoTrack?.alias @@ -1222,11 +1250,13 @@ function SessionPage() { console.error('Error in publisher setup:', err) } } + //console.log('before startPublisher', moqClient, userId, selfVideoRef.current, publisherInitialized) if (moqClient && userId && selfVideoRef.current && !publisherInitialized.current) { publisherInitialized.current = true setTimeout(async () => { try { await startPublisher() + //console.log('startPublisher done') } catch (err) { console.error('error in startPublishing', err) } @@ -1250,24 +1280,12 @@ function SessionPage() { client.onDataReceived = (_data) => { // console.warn('Data received:', data) } + //console.log('initClient', client) if (roomState && Object.values(users).length === 0) { const otherUsers = Object.keys(roomState.users).filter((uId) => uId != userId) setUsers(roomState.users) Object.keys(roomState.users).forEach((uId) => initializeTelemetryForUser(uId)) - - const anyUserHasMedia = otherUsers.some((uId) => { - const user = roomState.users[uId] - return user.hasVideo || user.hasAudio - }) - - if (anyUserHasMedia) { - testMetricsManagerRef.current.trackStartupJoinButtonClick('global') - } else { - console.log('Startup Latency: N/A') - startupLatencyTracked.current = true - } - const canvasRefs = Object.fromEntries(otherUsers.map((uId) => [uId, React.createRef()])) const screenshareCanvasRefs = Object.fromEntries( otherUsers.map((uId) => [uId, React.createRef()]), @@ -1286,7 +1304,6 @@ function SessionPage() { console.info(`User joined: ${user.name} (${user.id})`) addUser(user) initializeTelemetryForUser(user.id) - setRemoteCanvasRefs((prev) => ({ ...prev, [user.id]: React.createRef(), @@ -1309,6 +1326,7 @@ function SessionPage() { socket.on('track-updated', (response: TrackUpdateResponse) => { setUsers((prevUsers) => { + //console.log('track-updated', prevUsers, response) const updatedUser = prevUsers[response.userId] if (updatedUser) { const track = response.track @@ -1320,6 +1338,9 @@ function SessionPage() { track.kind === 'video-hd' ) { updatedUser.publishedTracks[track.kind] = track + console.log( + `Track updated for ${response.userId}: ${track.kind} - alias: ${track.alias}, announced: ${track.announced}`, + ) } } return { ...prevUsers } @@ -1327,7 +1348,10 @@ function SessionPage() { // Handle HD video subscription outside of setState if (response.track.kind === 'video-hd' && response.track.announced > 0) { + console.log('HD track announced, checking if we should subscribe...') + const currentQuality = userVideoQualitiesRef.current[response.userId] + console.log(`HD track announced by ${response.userId}, current requested quality: ${currentQuality}`) if (currentQuality === 'HD') { if (hdSubscriptionAttemptsRef.current.has(response.userId)) { @@ -1335,6 +1359,7 @@ function SessionPage() { return } + console.log(`Auto-subscribing to HD video from ${response.userId}`) hdSubscriptionAttemptsRef.current.add(response.userId) setTimeout(() => { @@ -1343,8 +1368,10 @@ function SessionPage() { subscribeToHDVideoWithState(response.userId, currentUsers, currentCanvasRefs) .then((success) => { - if (!success) { - console.error(`HD subscription failed for ${response.userId}`) + if (success) { + console.log(`HD subscription successful for ${response.userId}`) + } else { + console.log(`HD subscription failed for ${response.userId}`) } }) .catch((error) => { @@ -1355,6 +1382,8 @@ function SessionPage() { hdSubscriptionAttemptsRef.current.delete(response.userId) }) }, 1000) // Increased delay to 1 second + } else { + console.log(`Not subscribing - current quality is ${currentQuality}, not HD`) } } }) @@ -1375,14 +1404,18 @@ function SessionPage() { }) }) socket.on('screen-share-toggled', ({ userId: toggledUserId, hasScreenshare }) => { + console.log(`Screen share toggled for ${toggledUserId}: ${hasScreenshare}`) + setUsers((prevUsers) => { if (!prevUsers[toggledUserId]) return prevUsers if (!hasScreenshare && prevUsers[toggledUserId].hasScreenshare) { + console.log(`User ${toggledUserId} stopped screensharing, triggering unsubscription`) unsubscribeFromScreenshare(toggledUserId, false) const screenshareVirtualId = `${toggledUserId}-screenshare` if (maximizedUserId === screenshareVirtualId) { + console.log(`Clearing maximized screenshare user ${screenshareVirtualId}`) setMaximizedUserId(null) } @@ -1405,6 +1438,7 @@ function SessionPage() { // Handle HD permission request from another user socket.on('hd-permission-request', ({ requesterId }) => { + console.log(`User ${requesterId} is requesting HD video permission from me`) const requester = usersRef.current[requesterId] const requesterName = requester ? requester.name : 'Unknown User' setHdPermissionRequest({ requesterId, requesterName }) @@ -1412,6 +1446,7 @@ function SessionPage() { // Handle permission denied response socket.on('hd-permission-denied', ({ targetUserId }) => { + console.log(`HD permission denied by ${targetUserId}`) // User stays in SD mode - no action needed since we didn't change quality yet // Remove from pending requests @@ -1424,6 +1459,8 @@ function SessionPage() { // Handle permission granted response socket.on('hd-permission-granted', ({ targetUserId }) => { + console.log(`HD permission granted by ${targetUserId}`) + // Remove from pending requests setPendingHDRequests((prev) => { const newSet = new Set(prev) @@ -1431,8 +1468,6 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') - // First, set the quality to HD so the track-updated handler will auto-subscribe setUserVideoQualities((prev) => { const newQualities = { @@ -1440,12 +1475,14 @@ function SessionPage() { [targetUserId]: 'HD' as VideoQuality, } userVideoQualitiesRef.current = newQualities + console.log(`Set quality to HD for ${targetUserId} - track-updated will handle the rest`) return newQualities }) // Unsubscribe from SD video after a short delay to ensure HD subscription starts setTimeout(async () => { try { + console.log(`Unsubscribing from SD video for ${targetUserId}`) await unsubscribeFromUser(targetUserId, 'video') } catch (error) { console.error(`Error unsubscribing from SD for ${targetUserId}:`, error) @@ -1455,17 +1492,17 @@ function SessionPage() { // Handle direct HD video request (when permission is granted) socket.on('request-hd-video', ({ requesterId }) => { - console.debug(`Starting HD encoding - permission was granted for ${requesterId}`) + console.log(`Starting HD encoding - permission was granted for ${requesterId}`) handleStartHDEncoding() }) socket.on('request-sd-video', ({ requesterId }) => { - console.debug(`User ${requesterId} requested SD video from me`) + console.log(`User ${requesterId} requested SD video from me`) handleStopHDEncoding() }) socket.on('hd-already-active', ({ targetUserId }) => { - console.warn(`HD encoding already active for ${targetUserId} - subscribing to existing HD stream`) + console.log(`HD encoding already active for ${targetUserId} - subscribing to existing HD stream`) // Remove from pending requests since we're getting direct access to HD setPendingHDRequests((prev) => { @@ -1474,8 +1511,6 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') - manualQualityTransitionsRef.current.add(targetUserId) setUserVideoQualities((prev) => { @@ -1484,11 +1519,13 @@ function SessionPage() { [targetUserId]: 'HD' as VideoQuality, } userVideoQualitiesRef.current = newQualities + console.log(`Updated quality state to HD for ${targetUserId}:`, newQualities) return newQualities }) setTimeout(async () => { try { + console.log(`Unsubscribing from SD video for ${targetUserId} before subscribing to existing HD`) await unsubscribeFromUser(targetUserId, 'video') await new Promise((resolve) => setTimeout(resolve, 500)) @@ -1496,9 +1533,12 @@ function SessionPage() { const currentUsers = usersRef.current const currentCanvasRefs = remoteCanvasRefsRef.current + console.log(`Subscribing to existing HD video for ${targetUserId}`) const success = await subscribeToHDVideoWithState(targetUserId, currentUsers, currentCanvasRefs) - if (!success) { + if (success) { + console.log(`Successfully subscribed to existing HD video for ${targetUserId}`) + } else { console.error(`Failed to subscribe to existing HD video for ${targetUserId}`) } } catch (error) { @@ -1548,17 +1588,7 @@ function SessionPage() { return updated }) - setVideoLatencyHistory((prev) => { - const newHistory = { ...prev } - delete newHistory[msg.userId] - return newHistory - }) - setAudioLatencyHistory((prev) => { - const newHistory = { ...prev } - delete newHistory[msg.userId] - return newHistory - }) - setScreenshareLatencyHistory((prev) => { + setLatencyHistory((prev) => { const newHistory = { ...prev } delete newHistory[msg.userId] return newHistory @@ -1582,6 +1612,7 @@ function SessionPage() { return newColors }) + // TODO: unsubscribe }) socket.on('room-closed', (msg: RoomTimeoutMessage) => { @@ -1644,7 +1675,6 @@ function SessionPage() { telemetryInstances.current[userId] = { video: new NetworkTelemetry(1000), // 1 second window audio: new NetworkTelemetry(1000), // 1 second window - screenshare: new NetworkTelemetry(1000), // 1 second window } setCodecData((prev) => ({ @@ -1707,76 +1737,54 @@ function SessionPage() { } } - const previousValues = useRef<{ - [userId: string]: { - videoLatency: number - audioLatency: number - screenshareLatency: number - videoBitrate: number - audioBitrate: number - } - }>({}) + const previousValues = useRef<{ [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } }>( + {}, + ) // Update every 100ms useEffect(() => { const interval = setInterval(() => { - const newTelemetryData: { - [userId: string]: { - videoLatency: number - audioLatency: number - screenshareLatency: number - videoBitrate: number - audioBitrate: number - } - } = {} + const newTelemetryData: { [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } } = {} Object.keys(telemetryInstances.current).forEach((userId) => { const telemetry = telemetryInstances.current[userId] if (telemetry) { const videoLatency = isSelf(userId) ? 0 : Math.round(telemetry.video.latency) const audioLatency = isSelf(userId) ? 0 : Math.round(telemetry.audio.latency) - const screenshareLatency = isSelf(userId) ? 0 : Math.round(telemetry.screenshare.latency) const videoBitrate = (telemetry.video.throughput * 8) / 1000 // bytes/s to Kbps const audioBitrate = (telemetry.audio.throughput * 8) / 1000 // bytes/s to Kbps + const user = users[userId] + const shouldUseAudioLatency = user?.hasAudio && (!user?.hasVideo || audioLatency > 0) + const displayLatency = shouldUseAudioLatency ? audioLatency : videoLatency + //console.log(`Telemetry for user ${userId}: videoLatency=${videoLatency}, audioLatency=${audioLatency}, displayLatency=${displayLatency}, hasVideo=${user?.hasVideo}, hasAudio=${user?.hasAudio}, shouldUseAudioLatency=${shouldUseAudioLatency}`) + newTelemetryData[userId] = { - videoLatency, - audioLatency, - screenshareLatency, + latency: displayLatency, videoBitrate: Math.max(0, videoBitrate), audioBitrate: Math.max(0, audioBitrate), } - // Video Latency history (last 30 points) + // Latency history (last 30 points) if (!isSelf(userId)) { - setVideoLatencyHistory((prevLatency) => { - const userHistory = prevLatency[userId] || [] - const newHistory = [...userHistory, videoLatency].slice(-30) - return { - ...prevLatency, - [userId]: newHistory, - } - }) - - // Audio Latency history (last 30 points) - setAudioLatencyHistory((prevLatency) => { - const userHistory = prevLatency[userId] || [] - const newHistory = [...userHistory, audioLatency].slice(-30) - return { - ...prevLatency, - [userId]: newHistory, - } - }) - - // Screenshare Latency history (last 30 points) - setScreenshareLatencyHistory((prevLatency) => { + setLatencyHistory((prevLatency) => { const userHistory = prevLatency[userId] || [] - const newHistory = [...userHistory, screenshareLatency].slice(-30) + const newHistory = [...userHistory, displayLatency].slice(-30) return { ...prevLatency, [userId]: newHistory, } }) + // Store latency to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + streamType: 'latency', + timestamp: Date.now(), + value: displayLatency, + }) + .catch((err) => console.error('Failed to store latency:', err)) } // Video bitrate history (last 30 points) @@ -1788,6 +1796,16 @@ function SessionPage() { [userId]: newHistory, } }) + // Store video bitrate to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + streamType: 'video', + timestamp: Date.now(), + value: videoBitrate, + }) + .catch((err) => console.error('Failed to store video bitrate:', err)) // Audio bitrate history (last 30 points) setAudioBitrateHistory((prevAudioBitrate) => { @@ -1798,6 +1816,16 @@ function SessionPage() { [userId]: newHistory, } }) + // Store audio bitrate to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + streamType: 'audio', + timestamp: Date.now(), + value: audioBitrate, + }) + .catch((err) => console.error('Failed to store audio bitrate:', err)) } }) @@ -1928,9 +1956,18 @@ function SessionPage() { }, [roomState?.created, sessionDurationMinutes]) useEffect(() => { + console.log('Screenshare useEffect triggered:', { + hasStream: !!selfScreenshareStream.current, + hasRef: !!selfScreenshareRef.current, + isScreenSharing, + streamTracks: selfScreenshareStream.current?.getTracks()?.length || 0, + }) + if (selfScreenshareStream.current && selfScreenshareRef.current && isScreenSharing) { + console.log('Assigning screenshare stream to ref in useEffect') selfScreenshareRef.current.srcObject = selfScreenshareStream.current selfScreenshareRef.current.muted = true + console.log('Screenshare video element configured successfully:', selfScreenshareRef.current) } }, [isScreenSharing, selfScreenshareStream.current]) @@ -1948,6 +1985,7 @@ function SessionPage() { } function handleRemoteVideo(canvasRef: React.RefObject) { + //console.log('handleRemoteVideo init', canvasRef) if (!canvasRef?.current) return if (!moqClient) return if (canvasRef.current.dataset.status) return @@ -1971,14 +2009,23 @@ function SessionPage() { tracksReady && isCompletelyUnsubscribed && !wasIntentionallyUnsubscribed && !isInManualTransition if (shouldSubscribe) { + console.log(`Starting subscription to ${userId} - video: ${videoTrackAlias}, audio: ${audioTrackAlias}`) setTimeout(async () => { await subscribeToTrack(roomName, userId, videoTrackAlias, audioTrackAlias, chatTrackAlias, canvasRef) }, 500) } else { if (tracksReady && isInManualTransition) { + console.log(`Skipping auto-subscription to ${userId} - user is undergoing manual quality transition`) } else if (tracksReady && wasIntentionallyUnsubscribed) { + console.log(`Skipping auto-subscription to ${userId} - user was intentionally unsubscribed from both tracks`) } else if (tracksReady && !isCompletelyUnsubscribed) { + console.log( + `Skipping subscription to ${userId} - already subscribed (video: ${isVideoSubscribed}, videoHD: ${isVideoHDSubscribed}, audio: ${isAudioSubscribed})`, + ) } else if (!tracksReady) { + console.log( + `Not ready to subscribe to ${userId} yet - announced: ${announced}, video: ${videoTrackAlias}, audio: ${audioTrackAlias}`, + ) } } } @@ -2003,14 +2050,16 @@ function SessionPage() { tracksReady && userHasScreenshare && !isScreenshareSubscribed && !wasScreenshareIntentionallyUnsubscribed if (shouldSubscribe) { + console.log(`Starting screenshare subscription to ${userId} - screenshare: ${screenshareTrackAlias}`) setTimeout(async () => { await subscribeToScreenshareTrack(roomName, userId, screenshareTrackAlias, canvasRef) }, 500) } else { if (!userHasScreenshare && isScreenshareSubscribed) { + console.log(`User ${userId} stopped screensharing, cleaning up subscription`) unsubscribeFromScreenshare(userId, false) } else if (!tracksReady) { - console.warn( + console.log( `Not ready to subscribe to screenshare for ${userId} yet - screenshareTrackAlias: ${screenshareTrackAlias}`, ) } @@ -2028,9 +2077,11 @@ function SessionPage() { ) { try { const the_client = client ? client : moqClient! + //console.log('subscribeToTrack', roomName, userId, videoTrackAlias, audioTrackAlias, canvasRef) // TODO: sub to audio and video separately // for now, we just check the video announced date if (canvasRef.current && !canvasRef.current.dataset.status) { + //console.log("subscribeToTrack - Now will try to subscribe") const videoFullTrackName = getTrackname(roomName, userId, 'video') const audioFullTrackName = getTrackname(roomName, userId, 'audio') const chatFullTrackName = getTrackname(roomName, userId, 'chat') @@ -2039,17 +2090,7 @@ function SessionPage() { initializeTelemetryForUser(userId) const userTelemetry = telemetryInstances.current[userId] - const user = usersRef.current[userId] - const hasVideoAtSubscription = user?.hasVideo || false - const hasAudioAtSubscription = user?.hasAudio || false - - const onFirstFrameCallback = () => { - if (!startupLatencyTracked.current) { - startupLatencyTracked.current = true - testMetricsManagerRef.current.trackStartupFirstFrame('global', 'First user', true) - } - } - + //console.log("subscribeToTrack - Use video subscriber called", videoTrackAlias, audioTrackAlias, videoFullTrackName, audioFullTrackName) // Subscribe to video and audio separately for independent control const videoResult = await onlyUseVideoSubscriber( the_client, @@ -2057,7 +2098,6 @@ function SessionPage() { videoTrackAlias, videoFullTrackName, userTelemetry.video, - hasVideoAtSubscription ? onFirstFrameCallback : undefined, )() const audioResult = await onlyUseAudioSubscriber( @@ -2065,7 +2105,6 @@ function SessionPage() { audioTrackAlias, audioFullTrackName, userTelemetry.audio, - hasAudioAtSubscription ? onFirstFrameCallback : undefined, )() const subscriptionResult = { @@ -2088,6 +2127,7 @@ function SessionPage() { // Subscribe to chat if we have a valid chat track alias if (chatTrackAlias > 0) { + console.log('Subscribing to chat track with alias:', chatTrackAlias) try { await subscribeToChatTrack({ moqClient: the_client, @@ -2105,6 +2145,7 @@ function SessionPage() { ]) }, }) + console.log('Successfully subscribed to chat for user:', userId) } catch (error) { console.error('Failed to subscribe to chat for user:', userId, error) } @@ -2112,6 +2153,7 @@ function SessionPage() { console.warn('Chat track alias is invalid or not set:', chatTrackAlias, 'for user:', userId) // Try to subscribe to chat later with a retry mechanism setTimeout(async () => { + console.log('Retrying chat subscription for user:', userId) const retrychatTrackAlias = parseInt(canvasRef.current?.dataset.chattrackalias || '-1') if (retrychatTrackAlias > 0) { try { @@ -2131,6 +2173,7 @@ function SessionPage() { ]) }, }) + console.log('Successfully subscribed to chat on retry for user:', userId) } catch (error) { console.error('Failed to subscribe to chat on retry for user:', userId, error) } @@ -2139,6 +2182,7 @@ function SessionPage() { } }, 2000) // Wait 2 seconds before retrying chat subscription } + //console.log('subscribeToTrack result', result) // TODO: result comes true all the time, refactor... canvasRef.current!.dataset.status = subscriptionResult ? 'playing' : '' } @@ -2154,6 +2198,8 @@ function SessionPage() { currentUsers: { [K: string]: RoomUser }, currentCanvasRefs: { [id: string]: React.RefObject }, ): Promise { + console.log(`🔍 subscribeToHDVideoWithState called for ${targetUserId}`) + if (!roomState || !moqClientRef.current) { console.error('Room state or moqClient not available') return false @@ -2202,14 +2248,7 @@ function SessionPage() { videoHDRequestId: hdResult.videoRequestId, }, })) - - // Track quality toggle completion - - const user = currentUsers[targetUserId] - if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) - } - + console.log(`Successfully subscribed to HD video for user ${targetUserId}`) return true } } catch (error) { @@ -2219,6 +2258,8 @@ function SessionPage() { } async function subscribeToSDVideo(targetUserId: string): Promise { + console.log(`subscribeToSDVideo called for ${targetUserId}`) + if (!roomState || !moqClientRef.current) { console.error('Room state or moqClient not available for SD subscription') return false @@ -2243,6 +2284,12 @@ function SessionPage() { initializeTelemetryForUser(targetUserId) const userTelemetry = telemetryInstances.current[targetUserId] + console.log(`About to subscribe to SD video:`, { + targetUserId, + trackAlias: videoTrackAlias, + fullTrackName: videoFullTrackName.toString(), + }) + // Reset canvas status to allow subscription canvasRef.current.dataset.status = '' @@ -2263,14 +2310,7 @@ function SessionPage() { videoRequestId: sdResult.videoRequestId, }, })) - - // Track quality toggle completion - - const user = usersRef.current[targetUserId] - if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) - } - + console.log(`Successfully subscribed to SD video for user ${targetUserId}`) return true } } catch (error) { @@ -2293,6 +2333,11 @@ function SessionPage() { const screenshareFullTrackName = getTrackname(roomName, userId, 'screenshare') screenshareCanvasRef.current!.dataset.status = 'pending' + console.log(`Starting screenshare subscription for ${userId}`, { + trackName: screenshareFullTrackName, + trackAlias: screenshareTrackAlias, + }) + initializeTelemetryForUser(userId) const userTelemetry = telemetryInstances.current[userId] @@ -2301,7 +2346,7 @@ function SessionPage() { screenshareCanvasRef, screenshareTrackAlias, screenshareFullTrackName, - userTelemetry.screenshare, + userTelemetry.video, )() if (screenshareResult && screenshareResult.videoRequestId) { @@ -2323,7 +2368,7 @@ function SessionPage() { screenshareCanvasRef.current!.dataset.status = screenshareResult ? 'playing' : '' } else { - console.warn(`Cannot subscribe to screenshare for ${userId}:`, { + console.log(`Cannot subscribe to screenshare for ${userId}:`, { hasCanvasRef: !!screenshareCanvasRef.current, currentStatus: screenshareCanvasRef.current?.dataset.status, }) @@ -2372,6 +2417,7 @@ function SessionPage() { const screenshareCanvasRef = remoteScreenshareCanvasRefs[targetUserId] if (screenshareCanvasRef?.current) { screenshareCanvasRef.current.dataset.status = '' + console.log(`Reset canvas status for ${targetUserId} screenshare (no subscription case)`) } return @@ -2380,7 +2426,9 @@ function SessionPage() { let unsubscriptionSuccess = false try { + console.log(`Attempting to unsubscribe from ${targetUserId} screenshare with requestId:`, requestId) await client.unsubscribe(requestId) + console.log(`Successfully unsubscribed from ${targetUserId} screenshare`) unsubscriptionSuccess = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} screenshare:`, error) @@ -2403,9 +2451,19 @@ function SessionPage() { const canvas = screenshareCanvasRef.current clearScreenshareCanvas(canvas) } + + console.log( + `Screenshare cleanup completed for ${targetUserId} (unsubscription: ${unsubscriptionSuccess ? 'success' : 'failed'})`, + { + refCleared: !screenshareSubscriptionsRef.current[targetUserId], + stateUpdate: 'pending...', + }, + ) } function leaveRoom() { + //console.log('Leaving room...'); + // Clean up any pending room closed messages and restore title setPendingRoomClosedMessage(null) document.title = originalTitle.current @@ -2421,6 +2479,7 @@ function SessionPage() { } if (videoEncoderObjRef.current && videoEncoderObjRef.current.stop) { + //console.log('Stopping video encoder...'); videoEncoderObjRef.current.stop() videoEncoderObjRef.current = null } @@ -2444,6 +2503,8 @@ function SessionPage() { } const unsubscribeFromHDVideo = async (targetUserId: string): Promise => { + console.log(`🔍 unsubscribeFromHDVideo called for ${targetUserId}`) + if (!moqClient || targetUserId === userId) { console.warn( `Cannot unsubscribe from HD: moqClient=${!!moqClient}, targetUserId=${targetUserId}, userId=${userId}`, @@ -2458,7 +2519,12 @@ function SessionPage() { } try { + console.log( + `Attempting to unsubscribe from ${targetUserId} HD video with requestId:`, + subscription.videoHDRequestId, + ) await moqClient.unsubscribe(subscription.videoHDRequestId) + console.log(`Successfully unsubscribed from ${targetUserId} HD video`) setUserSubscriptions((prev) => ({ ...prev, @@ -2499,13 +2565,15 @@ function SessionPage() { subscription.videoRequestId !== undefined ) { try { + console.log(`Attempting to unsubscribe from ${targetUserId} video with requestId:`, subscription.videoRequestId) await _client.unsubscribe(subscription.videoRequestId) + console.log(`Successfully unsubscribed from ${targetUserId} video`) videoUnsubscribed = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} video:`, error) } } else if (type === 'video' || type === 'both') { - console.warn(`Skipping video unsubscribe for ${targetUserId} - not subscribed or missing requestId`) + console.log(`Skipping video unsubscribe for ${targetUserId} - not subscribed or missing requestId`) } const audioTypeCheck = type === 'audio' || type === 'both' @@ -2514,13 +2582,18 @@ function SessionPage() { if (audioTypeCheck && audioSubscribedCheck && audioRequestIdCheck) { try { + console.log( + `Attempting to unsubscribe from ${targetUserId} audio with requestId:`, + subscription.audioRequestId!, + ) await _client.unsubscribe(subscription.audioRequestId!) + console.log(`Successfully unsubscribed from ${targetUserId} audio`) audioUnsubscribed = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} audio:`, error) } } else if (type === 'audio' || type === 'both') { - console.warn(`Skipping audio unsubscribe for ${targetUserId} - condition failed`) + console.log(`Skipping audio unsubscribe for ${targetUserId} - condition failed`) } setUserSubscriptions((prev) => { @@ -2547,6 +2620,7 @@ function SessionPage() { intentionallyUnsubscribed: willBeCompletelyUnsubscribed, } + console.log(`Updated subscription state for ${targetUserId}:`, newSubscription) return { ...prev, [targetUserId]: newSubscription, @@ -2559,7 +2633,7 @@ function SessionPage() { canvasRef.current.dataset.status = '' // Don't cleanup the canvas worker, just reset status // The worker will be reused and reconfigured for HD if needed - console.warn(`Reset canvas status for ${targetUserId}`) + console.log(`Reset canvas status for ${targetUserId}`) } } } @@ -2591,15 +2665,17 @@ function SessionPage() { const needsAudioSub = (type === 'audio' || type === 'both') && !hasAudioSub if (!needsVideoSub && !needsAudioSub) { - console.warn(`Already subscribed to ${targetUserId} ${type}`) + console.log(`Already subscribed to ${targetUserId} ${type}`) return } if (canvasRef.current.dataset.status) { + console.log(`Resetting canvas status for ${targetUserId} to allow resubscription`) canvasRef.current.dataset.status = '' } if (needsVideoSub && needsAudioSub) { + console.log(`Subscribing to both video and audio for ${targetUserId}`) await subscribeToTrack( roomName, targetUserId, @@ -2609,9 +2685,7 @@ function SessionPage() { canvasRef, ) } else if (needsVideoSub) { - // Track video resub start (when user clicks to resub) - testMetricsManagerRef.current.trackVideoResubStart(targetUserId) - + console.log(`Adding video subscription for ${targetUserId}`) const videoFullTrackName = getTrackname(roomName, targetUserId, 'video') initializeTelemetryForUser(targetUserId) const userTelemetry = telemetryInstances.current[targetUserId] @@ -2637,12 +2711,7 @@ function SessionPage() { })) canvasRef.current.dataset.status = 'playing' - - // Track video resub complete - const user = usersRef.current[targetUserId] - if (user) { - testMetricsManagerRef.current.trackVideoResubComplete(targetUserId, user.name) - } + console.log(`Video subscription added for ${targetUserId}, requestId: ${videoResult.videoRequestId}`) } else { console.error(`Video subscription failed for ${targetUserId}`) } @@ -2650,8 +2719,7 @@ function SessionPage() { console.error(`Failed to add video subscription for ${targetUserId}:`, error) } } else if (needsAudioSub) { - // Track audio resub start (when user clicks to resub) - testMetricsManagerRef.current.trackAudioResubStart(targetUserId) + console.log(`Adding audio subscription for ${targetUserId}`) const audioFullTrackName = getTrackname(roomName, targetUserId, 'audio') initializeTelemetryForUser(targetUserId) @@ -2676,11 +2744,7 @@ function SessionPage() { }, })) - // Track audio resub complete - const user = usersRef.current[targetUserId] - if (user) { - testMetricsManagerRef.current.trackAudioResubComplete(targetUserId, user.name) - } + console.log(`Audio subscription added for ${targetUserId}, requestId: ${audioResult.audioRequestId}`) } else { console.error(`Audio subscription failed for ${targetUserId}`) } @@ -2804,6 +2868,7 @@ function SessionPage() { useEffect(() => { if (maximizedUserId && !usersToRender.find((u) => u.id === maximizedUserId)) { + console.log(`Maximized user ${maximizedUserId} no longer exists, clearing maximized state`) setMaximizedUserId(null) } }, [maximizedUserId, usersToRender]) @@ -2866,6 +2931,9 @@ function SessionPage() { {getUserCount()} participant{userCount > 1 ? 's' : ''} + + View Telemetry +
⏱️ Time left: {timeRemaining} @@ -3007,8 +3075,8 @@ function SessionPage() { {telemetryData[user.id] && !isSelf(user.id) && ( // TODO: Calculate throughputs for self user
- {telemetryData[user.id].videoLatency}ms | {telemetryData[user.id].videoBitrate.toFixed(0)} - Kbit/s | {telemetryData[user.id].audioBitrate.toFixed(0)}Kbit/s + {telemetryData[user.id].latency}ms | {telemetryData[user.id].videoBitrate.toFixed(0)}Kbit/s + | {telemetryData[user.id].audioBitrate.toFixed(0)}Kbit/s
)}
@@ -3190,88 +3258,39 @@ function SessionPage() {

Network Stats

- {/* Metrics Grid */} -
- {/* Bitrate Section */} -
-

Bitrate

-
- {/* Video Bitrate */} -
-
-
- VIDEO -
- - {telemetryData[user.id] - ? `${telemetryData[user.id].videoBitrate.toFixed(0)} Kbit/s` - : 'N/A'} - -
- {/* Audio Bitrate */} -
-
-
- AUDIO -
- - {telemetryData[user.id] - ? `${telemetryData[user.id].audioBitrate.toFixed(0)} Kbit/s` - : 'N/A'} - -
-
+ {/* Legend */} +
+
+
+ VIDEO
- - {/* Latency Section */} -
-

Latency

-
- {/* Video Latency */} -
-
-
- VIDEO -
- - {!isSelf(user.id) && - telemetryData[user.id] && - (userSubscriptions[user.id]?.videoSubscribed || - userSubscriptions[user.id]?.videoHDSubscribed) - ? `${telemetryData[user.id].videoLatency}ms` - : 'N/A'} - -
- {/* Audio Latency */} -
-
-
- AUDIO -
- - {!isSelf(user.id) && - telemetryData[user.id] && - userSubscriptions[user.id]?.audioSubscribed - ? `${telemetryData[user.id].audioLatency}ms` - : 'N/A'} - -
- {/* Screenshare Latency */} -
-
-
- SCREEN -
- - {!isSelf(user.id) && - telemetryData[user.id] && - userSubscriptions[user.id]?.screenshareSubscribed - ? `${telemetryData[user.id].screenshareLatency}ms` - : 'N/A'} - -
-
+
+
+ AUDIO
+
+
+ LATENCY +
+
+ + {/* Values with smooth transitions */} +
+ + {telemetryData[user.id] + ? `${telemetryData[user.id].videoBitrate.toFixed(0)} Kbit/s` + : 'N/A'} + + + {telemetryData[user.id] + ? `${telemetryData[user.id].audioBitrate.toFixed(0)} Kbit/s` + : 'N/A'} + + + {!isSelf(user.id) && telemetryData[user.id] + ? `${telemetryData[user.id].latency}ms` + : 'N/A'} +
{/* Network Stats Graph */} @@ -3279,13 +3298,13 @@ function SessionPage() { {/* Graph container */}
{/* Left Y-axis labels (Bitrate) */} -
1500K
-
750K
+
500K
+
250K
0
{/* Right Y-axis labels (Latency) */} -
600ms
-
300ms
+
200ms
+
100ms
0ms
{/* Grid lines */} @@ -3310,7 +3329,7 @@ function SessionPage() { .map((videoBitrate, index) => { const x = (index / Math.max(videoBitrateHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((videoBitrate / 1500) * 100, 100) + const y = 100 - Math.min((videoBitrate / 500) * 100, 100) return `${x},${y}` }) .join(' ') @@ -3335,7 +3354,7 @@ function SessionPage() { .map((audioBitrate, index) => { const x = (index / Math.max(audioBitrateHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((audioBitrate / 1500) * 100, 100) + const y = 100 - Math.min((audioBitrate / 500) * 100, 100) return `${x},${y}` }) .join(' ') @@ -3345,7 +3364,7 @@ function SessionPage() {
- {/* Video Latency line */} + {/* Latency line */}
0 - ? videoLatencyHistory[user.id] + !isSelf(user.id) && latencyHistory[user.id] && latencyHistory[user.id].length > 0 + ? latencyHistory[user.id] .map((latency: number, index: number) => { - const x = - (index / Math.max(videoLatencyHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((latency / 600) * 100, 100) + const x = (index / Math.max(latencyHistory[user.id].length - 1, 1)) * 300 + const y = 100 - Math.min((latency / 200) * 100, 100) return `${x},${y}` }) .join(' ') - : '' - } - /> - -
- - {/* Audio Latency line */} -
- - 0 - ? audioLatencyHistory[user.id] - .map((latency: number, index: number) => { - const x = - (index / Math.max(audioLatencyHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((latency / 600) * 100, 100) - return `${x},${y}` - }) - .join(' ') - : '' - } - /> - -
- - {/* Screenshare Latency line */} -
- - 0 - ? screenshareLatencyHistory[user.id] - .map((latency: number, index: number) => { - const x = - (index / Math.max(screenshareLatencyHistory[user.id].length - 1, 1)) * - 300 - const y = 100 - Math.min((latency / 600) * 100, 100) - return `${x},${y}` - }) - .join(' ') - : '' + : isSelf(user.id) + ? '' // No line for self user + : '' } /> @@ -3744,7 +3707,6 @@ function SessionPage() { )} - {/* End Call Button */} diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx new file mode 100644 index 0000000..f15e92b --- /dev/null +++ b/apps/client/src/pages/Telemetry.tsx @@ -0,0 +1,275 @@ +import React, { useState, useEffect } from 'react' +import { telemetryDB, TelemetryEntry } from '@/util/telemetryDB' + +const Telemetry: React.FC = () => { + const [sessions, setSessions] = useState([]) + const [users, setUsers] = useState([]) + const [selectedSession, setSelectedSession] = useState('') + const [selectedUser, setSelectedUser] = useState('') + const [selectedStreamType, setSelectedStreamType] = useState<'video' | 'audio' | 'latency' | ''>('') + const [data, setData] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + loadSessions() + }, []) + + useEffect(() => { + if (selectedSession) { + loadUsers(selectedSession) + } else { + setUsers([]) + setSelectedUser('') + } + }, [selectedSession]) + + useEffect(() => { + loadData() + }, [selectedSession, selectedUser, selectedStreamType]) + + const loadSessions = async () => { + try { + const sessionList = await telemetryDB.getSessions() + setSessions(sessionList) + } catch (error) { + console.error('Failed to load sessions:', error) + } + } + + const loadUsers = async (sessionId: string) => { + try { + const userList = await telemetryDB.getUsers(sessionId) + setUsers(userList) + } catch (error) { + console.error('Failed to load users:', error) + } + } + + const loadData = async () => { + if (!selectedSession) return + + setLoading(true) + try { + const entries = await telemetryDB.getEntries( + selectedSession, + selectedUser || undefined, + selectedStreamType || undefined, + ) + // Sort by timestamp + entries.sort((a, b) => a.timestamp - b.timestamp) + setData(entries) + } catch (error) { + console.error('Failed to load data:', error) + setData([]) + } finally { + setLoading(false) + } + } + + const getDataForStreamType = (streamType: 'video' | 'audio' | 'latency') => { + return data.filter((entry) => entry.streamType === streamType) + } + + const getMaxValue = (streamType: 'video' | 'audio' | 'latency') => { + const streamData = getDataForStreamType(streamType) + if (streamData.length === 0) return 0 + return Math.max(...streamData.map((d) => d.value)) + } + + const renderChart = (streamType: 'video' | 'audio' | 'latency', color: string, maxValue: number) => { + const streamData = getDataForStreamType(streamType) + if (streamData.length === 0) return null + + const points = streamData + .map((entry, index) => { + const x = (index / Math.max(streamData.length - 1, 1)) * 300 + const y = 100 - Math.min((entry.value / maxValue) * 100, 100) + return `${x},${y}` + }) + .join(' ') + + return ( +
+ + + +
+ ) + } + + const getStreamTypeColor = (type: 'video' | 'audio' | 'latency') => { + switch (type) { + case 'video': + return '#3b82f6' + case 'audio': + return '#6b7280' + case 'latency': + return '#ef4444' + } + } + + const getStreamTypeLabel = (type: 'video' | 'audio' | 'latency') => { + switch (type) { + case 'video': + return 'Video Bitrate (Kbit/s)' + case 'audio': + return 'Audio Bitrate (Kbit/s)' + case 'latency': + return 'Latency (ms)' + } + } + + const streamTypes: ('video' | 'audio' | 'latency')[] = ['video', 'audio', 'latency'] + const activeStreamTypes = selectedStreamType ? [selectedStreamType] : streamTypes + + return ( +
+
+

Telemetry Data

+ + {/* Filters */} +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Charts */} + {selectedSession && ( +
+ {activeStreamTypes.map((streamType) => { + const streamData = getDataForStreamType(streamType) + const maxValue = getMaxValue(streamType) || (streamType === 'latency' ? 200 : 500) + const color = getStreamTypeColor(streamType) + + return ( +
+

{getStreamTypeLabel(streamType)}

+ + {loading ? ( +
+
Loading...
+
+ ) : streamData.length === 0 ? ( +
+
No data available
+
+ ) : ( + <> + {/* Current Value */} +
+ + {streamData[streamData.length - 1]?.value.toFixed(1) || 'N/A'} + + {streamType === 'latency' ? 'ms' : 'Kbit/s'} +
+ + {/* Chart */} +
+ {/* Y-axis labels */} +
+ {maxValue.toFixed(0)} +
+
+ {(maxValue / 2).toFixed(0)} +
+
0
+ + {/* Grid lines */} +
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+ + {renderChart(streamType, color, maxValue)} +
+ + {/* Stats */} +
+
+
Min
+
{Math.min(...streamData.map((d) => d.value)).toFixed(1)}
+
+
+
Avg
+
+ {(streamData.reduce((sum, d) => sum + d.value, 0) / streamData.length).toFixed(1)} +
+
+
+
Max
+
{Math.max(...streamData.map((d) => d.value)).toFixed(1)}
+
+
+ + )} +
+ ) + })} +
+ )} + + {!selectedSession && ( +
+
Select a session to view telemetry data
+
+ )} +
+
+ ) +} + +export default Telemetry diff --git a/apps/client/src/util/telemetryDB.ts b/apps/client/src/util/telemetryDB.ts new file mode 100644 index 0000000..4edd3be --- /dev/null +++ b/apps/client/src/util/telemetryDB.ts @@ -0,0 +1,185 @@ +export interface TelemetryEntry { + id?: number + sessionId: string + userId: string + streamType: 'video' | 'audio' | 'latency' + timestamp: number + value: number +} + +class TelemetryDB { + private db: IDBDatabase | null = null + private dbPromise: Promise | null = null + + private async openDB(): Promise { + if (this.db) return this.db + + if (!this.dbPromise) { + this.dbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open('TelemetryDB', 1) + + request.onerror = () => reject(request.error) + request.onsuccess = () => { + this.db = request.result + resolve(request.result) + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + const store = db.createObjectStore('telemetry', { keyPath: 'id', autoIncrement: true }) + + store.createIndex('sessionId', 'sessionId', { unique: false }) + store.createIndex('userId', 'userId', { unique: false }) + store.createIndex('streamType', 'streamType', { unique: false }) + store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('sessionUserStream', ['sessionId', 'userId', 'streamType'], { unique: false }) + } + }) + } + + return this.dbPromise + } + + async addEntry(entry: Omit): Promise { + const db = await this.openDB() + const transaction = db.transaction(['telemetry'], 'readwrite') + const store = transaction.objectStore('telemetry') + await new Promise((resolve, reject) => { + const request = store.add(entry) + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async getEntries( + sessionId?: string, + userId?: string, + streamType?: 'video' | 'audio' | 'latency', + limit?: number, + ): Promise { + const db = await this.openDB() + const transaction = db.transaction(['telemetry'], 'readonly') + const store = transaction.objectStore('telemetry') + + return new Promise((resolve, reject) => { + let request: IDBRequest + + if (sessionId && userId && streamType) { + const index = store.index('sessionUserStream') + const range = IDBKeyRange.only([sessionId, userId, streamType]) + request = index.openCursor(range) + } else if (sessionId) { + const index = store.index('sessionId') + request = index.openCursor(IDBKeyRange.only(sessionId)) + } else if (userId) { + const index = store.index('userId') + request = index.openCursor(IDBKeyRange.only(userId)) + } else if (streamType) { + const index = store.index('streamType') + request = index.openCursor(IDBKeyRange.only(streamType)) + } else { + request = store.openCursor() + } + + const results: TelemetryEntry[] = [] + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + results.push(cursor.value) + if (!limit || results.length < limit) { + cursor.continue() + } else { + resolve(results) + } + } else { + resolve(results) + } + } + + request.onerror = () => reject(request.error) + }) + } + + async getSessions(): Promise { + const db = await this.openDB() + const transaction = db.transaction(['telemetry'], 'readonly') + const store = transaction.objectStore('telemetry') + const index = store.index('sessionId') + + return new Promise((resolve, reject) => { + const request = index.openKeyCursor() + const sessions = new Set() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + sessions.add(cursor.key as string) + cursor.continue() + } else { + resolve(Array.from(sessions).sort()) + } + } + + request.onerror = () => reject(request.error) + }) + } + + async getUsers(sessionId?: string): Promise { + const db = await this.openDB() + const transaction = db.transaction(['telemetry'], 'readonly') + const store = transaction.objectStore('telemetry') + + return new Promise((resolve, reject) => { + let request: IDBRequest + + if (sessionId) { + const index = store.index('sessionId') + request = index.openCursor(IDBKeyRange.only(sessionId)) + } else { + request = store.openCursor() + } + + const users = new Set() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + users.add(cursor.value.userId) + cursor.continue() + } else { + resolve(Array.from(users).sort()) + } + } + + request.onerror = () => reject(request.error) + }) + } + + async clearOldData(keepDays: number = 30): Promise { + const db = await this.openDB() + const transaction = db.transaction(['telemetry'], 'readwrite') + const store = transaction.objectStore('telemetry') + const cutoff = Date.now() - keepDays * 24 * 60 * 60 * 1000 + + return new Promise((resolve, reject) => { + const request = store.openCursor() + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + if (cursor.value.timestamp < cutoff) { + cursor.delete() + } + cursor.continue() + } else { + resolve() + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +export const telemetryDB = new TelemetryDB() diff --git a/package-lock.json b/package-lock.json index 0c91417..8725c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -106,6 +106,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1737,6 +1738,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -1910,6 +1912,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.13.0" } @@ -1927,6 +1930,7 @@ "integrity": "sha512-oSVZmGtDPmRZtVDqvdKUi/qgCsWp5IDY29wp8na8Bj4B3cc99hfNzvNhlMkVVxctkAOGUA3Km7MMpBHAnWfcIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2011,6 +2015,7 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -2391,6 +2396,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2635,6 +2641,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -3219,6 +3226,7 @@ "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4899,6 +4907,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5139,6 +5148,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5151,6 +5161,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5312,6 +5323,7 @@ "integrity": "sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5964,6 +5976,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6091,6 +6104,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6199,6 +6213,7 @@ "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6350,6 +6365,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, From 4ddc8a6e8afbf67f6b0bcceed4d84e6f67d42a21 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:16:23 +0300 Subject: [PATCH 2/9] fixed some bugs --- apps/client/src/components/InfoTooltip.tsx | 64 ++++++++++++++++++++++ apps/client/src/pages/SessionPage.tsx | 15 ++++- apps/client/src/pages/Telemetry.tsx | 44 +++++++++++++-- apps/client/src/util/telemetryDB.ts | 33 +++++++++-- 4 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 apps/client/src/components/InfoTooltip.tsx diff --git a/apps/client/src/components/InfoTooltip.tsx b/apps/client/src/components/InfoTooltip.tsx new file mode 100644 index 0000000..884cbba --- /dev/null +++ b/apps/client/src/components/InfoTooltip.tsx @@ -0,0 +1,64 @@ +import React, { useState, useRef, useEffect } from 'react' + +interface InfoTooltipProps { + title: string + children: React.ReactNode +} + +const InfoTooltip: React.FC = ({ title, children }) => { + const [showTooltip, setShowTooltip] = useState(false) + const [isClickedOpen, setIsClickedOpen] = useState(false) + const tooltipRef = useRef(null) + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + setIsClickedOpen(!isClickedOpen) + } + + const handleMouseEnter = () => { + if (!isClickedOpen) { + setShowTooltip(true) + } + } + + const handleMouseLeave = () => { + if (!isClickedOpen) { + setShowTooltip(false) + } + } + + useEffect(() => { + const handleOutsideClick = (e: MouseEvent) => { + if (tooltipRef.current && !tooltipRef.current.contains(e.target as Node)) { + setIsClickedOpen(false) + } + } + + if (isClickedOpen) { + document.addEventListener('click', handleOutsideClick) + return () => document.removeEventListener('click', handleOutsideClick) + } + }, [isClickedOpen]) + + return ( +
+ + {(showTooltip || isClickedOpen) && ( +
+

{title}

+ {children} +
+ )} +
+ ) +} + +export default InfoTooltip diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index ffbed71..43d3807 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -109,7 +109,17 @@ function SessionPage() { [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } }>({}) const telemetryInstances = useRef<{ [userId: string]: { video: NetworkTelemetry; audio: NetworkTelemetry } }>({}) - const sessionId = useRef(crypto.randomUUID()) + const generateSessionId = () => { + const now = new Date() + const yyyy = now.getFullYear() + const mm = String(now.getMonth() + 1).padStart(2, '0') + const dd = String(now.getDate()).padStart(2, '0') + const hh = String(now.getHours()).padStart(2, '0') + const min = String(now.getMinutes()).padStart(2, '0') + const ss = String(now.getSeconds()).padStart(2, '0') + return `${roomState?.name}-${username}-${yyyy}${mm}${dd}-${hh}${min}${ss}` + } + const sessionId = useRef(generateSessionId()) const [latencyHistory, setLatencyHistory] = useState<{ [userId: string]: number[] }>({}) const [videoBitrateHistory, setVideoBitrateHistory] = useState<{ [userId: string]: number[] }>({}) const [audioBitrateHistory, setAudioBitrateHistory] = useState<{ [userId: string]: number[] }>({}) @@ -1780,6 +1790,7 @@ function SessionPage() { .addEntry({ sessionId: sessionId.current, userId, + userName: users[userId]?.name || userId, streamType: 'latency', timestamp: Date.now(), value: displayLatency, @@ -1801,6 +1812,7 @@ function SessionPage() { .addEntry({ sessionId: sessionId.current, userId, + userName: users[userId]?.name || userId, streamType: 'video', timestamp: Date.now(), value: videoBitrate, @@ -1821,6 +1833,7 @@ function SessionPage() { .addEntry({ sessionId: sessionId.current, userId, + userName: users[userId]?.name || userId, streamType: 'audio', timestamp: Date.now(), value: audioBitrate, diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index f15e92b..d82725d 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react' -import { telemetryDB, TelemetryEntry } from '@/util/telemetryDB' +import { telemetryDB, TelemetryEntry, UserInfo } from '@/util/telemetryDB' +import InfoTooltip from '@/components/InfoTooltip' const Telemetry: React.FC = () => { const [sessions, setSessions] = useState([]) - const [users, setUsers] = useState([]) + const [users, setUsers] = useState([]) const [selectedSession, setSelectedSession] = useState('') const [selectedUser, setSelectedUser] = useState('') const [selectedStreamType, setSelectedStreamType] = useState<'video' | 'audio' | 'latency' | ''>('') @@ -39,7 +40,14 @@ const Telemetry: React.FC = () => { const loadUsers = async (sessionId: string) => { try { const userList = await telemetryDB.getUsers(sessionId) - setUsers(userList) + // parse the userName from session id + let userIndex = 1 + let userName = sessionId.split('-')[userIndex] + let filteredUserList = userList.filter((user) => { + return user.userName !== userName + }) + + setUsers(filteredUserList) } catch (error) { console.error('Failed to load users:', error) } @@ -66,6 +74,11 @@ const Telemetry: React.FC = () => { } } + const formatUserName = (userName: string): string => { + // Truncate to max 20 characters + return userName.length > 20 ? userName.substring(0, 17) + '...' : userName + } + const getDataForStreamType = (streamType: 'video' | 'audio' | 'latency') => { return data.filter((entry) => entry.streamType === streamType) } @@ -132,7 +145,26 @@ const Telemetry: React.FC = () => { return (
-

Telemetry Data

+
+

Telemetry Data

+ +

+ Storage: Data is stored locally in your browser using{' '} + + IndexedDB + + , not sent to any server. +

+

+ Session ID Format: YYYYmmDD-HHmmss-<4 random chars> (e.g., 20260117-143025-A7K2) +

+
+
{/* Filters */}
@@ -163,8 +195,8 @@ const Telemetry: React.FC = () => { > {users.map((user) => ( - ))} diff --git a/apps/client/src/util/telemetryDB.ts b/apps/client/src/util/telemetryDB.ts index 4edd3be..ee60459 100644 --- a/apps/client/src/util/telemetryDB.ts +++ b/apps/client/src/util/telemetryDB.ts @@ -2,11 +2,17 @@ export interface TelemetryEntry { id?: number sessionId: string userId: string + userName?: string streamType: 'video' | 'audio' | 'latency' timestamp: number value: number } +export interface UserInfo { + userId: string + userName: string +} + class TelemetryDB { private db: IDBDatabase | null = null private dbPromise: Promise | null = null @@ -16,7 +22,7 @@ class TelemetryDB { if (!this.dbPromise) { this.dbPromise = new Promise((resolve, reject) => { - const request = indexedDB.open('TelemetryDB', 1) + const request = indexedDB.open('TelemetryDB', 2) request.onerror = () => reject(request.error) request.onsuccess = () => { @@ -26,12 +32,19 @@ class TelemetryDB { request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result + + // Delete existing store if it exists to ensure clean schema + if (db.objectStoreNames.contains('telemetry')) { + db.deleteObjectStore('telemetry') + } + const store = db.createObjectStore('telemetry', { keyPath: 'id', autoIncrement: true }) store.createIndex('sessionId', 'sessionId', { unique: false }) store.createIndex('userId', 'userId', { unique: false }) store.createIndex('streamType', 'streamType', { unique: false }) store.createIndex('timestamp', 'timestamp', { unique: false }) + store.createIndex('sessionUser', ['sessionId', 'userId'], { unique: false }) store.createIndex('sessionUserStream', ['sessionId', 'userId', 'streamType'], { unique: false }) } }) @@ -61,6 +74,8 @@ class TelemetryDB { const transaction = db.transaction(['telemetry'], 'readonly') const store = transaction.objectStore('telemetry') + console.log('Getting entries with:', { sessionId, userId, streamType, limit }) + return new Promise((resolve, reject) => { let request: IDBRequest @@ -68,6 +83,10 @@ class TelemetryDB { const index = store.index('sessionUserStream') const range = IDBKeyRange.only([sessionId, userId, streamType]) request = index.openCursor(range) + } else if (sessionId && userId) { + const index = store.index('sessionUser') + const range = IDBKeyRange.only([sessionId, userId]) + request = index.openCursor(range) } else if (sessionId) { const index = store.index('sessionId') request = index.openCursor(IDBKeyRange.only(sessionId)) @@ -125,7 +144,7 @@ class TelemetryDB { }) } - async getUsers(sessionId?: string): Promise { + async getUsers(sessionId?: string): Promise { const db = await this.openDB() const transaction = db.transaction(['telemetry'], 'readonly') const store = transaction.objectStore('telemetry') @@ -140,15 +159,19 @@ class TelemetryDB { request = store.openCursor() } - const users = new Set() + const usersMap = new Map() request.onsuccess = (event) => { const cursor = (event.target as IDBRequest).result if (cursor) { - users.add(cursor.value.userId) + const { userId, userName } = cursor.value + usersMap.set(userId, userName || userId) cursor.continue() } else { - resolve(Array.from(users).sort()) + const users = Array.from(usersMap.entries()) + .map(([userId, userName]) => ({ userId, userName })) + .sort((a, b) => a.userName.localeCompare(b.userName)) + resolve(users) } } From fa0b5baf7ac5d5f5b077a62f5590a46d3b1a82bc Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:10:38 +0300 Subject: [PATCH 3/9] Merged testMetrics --- apps/client/src/pages/SessionPage.tsx | 665 +++++++++++++++----------- apps/client/src/pages/Telemetry.tsx | 88 +++- apps/client/src/util/telemetryDB.ts | 17 +- 3 files changed, 476 insertions(+), 294 deletions(-) diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index 43d3807..ac62563 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -74,6 +74,7 @@ import { MOQtailClient } from 'moqtail-ts/client' import { NetworkTelemetry, ClockNormalizer } from 'moqtail-ts/util' import { RewindPlayer } from './RewindPlayer' import { BufferedMoqtObject } from '@/composables/rewindBuffer' +import { TestMetricsManager } from '@/util/testMetrics' import { telemetryDB } from '@/util/telemetryDB' function SessionPage() { @@ -96,7 +97,7 @@ function SessionPage() { const [hasLocalAudio, setHasLocalAudio] = useState(false) const [isCamOn, setisCamOn] = useState(false) const [isScreenSharing, setIsScreenSharing] = useState(false) - const [isChatOpen, setIsChatOpen] = useState(true) // TODO: implement MoQ chat + const [isChatOpen, setIsChatOpen] = useState(true) const [chatMessage, setChatMessage] = useState('') const { socket: contextSocket } = useSocket() const [users, setUsers] = useState<{ [K: string]: RoomUser }>({}) @@ -106,9 +107,17 @@ function SessionPage() { }>({}) const [chatMessages, setChatMessages] = useState([]) const [telemetryData, setTelemetryData] = useState<{ - [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } + [userId: string]: { + videoLatency: number + audioLatency: number + screenshareLatency: number + videoBitrate: number + audioBitrate: number + } + }>({}) + const telemetryInstances = useRef<{ + [userId: string]: { video: NetworkTelemetry; audio: NetworkTelemetry; screenshare: NetworkTelemetry } }>({}) - const telemetryInstances = useRef<{ [userId: string]: { video: NetworkTelemetry; audio: NetworkTelemetry } }>({}) const generateSessionId = () => { const now = new Date() const yyyy = now.getFullYear() @@ -120,9 +129,12 @@ function SessionPage() { return `${roomState?.name}-${username}-${yyyy}${mm}${dd}-${hh}${min}${ss}` } const sessionId = useRef(generateSessionId()) - const [latencyHistory, setLatencyHistory] = useState<{ [userId: string]: number[] }>({}) + const [videoLatencyHistory, setVideoLatencyHistory] = useState<{ [userId: string]: number[] }>({}) + const [audioLatencyHistory, setAudioLatencyHistory] = useState<{ [userId: string]: number[] }>({}) + const [screenshareLatencyHistory, setScreenshareLatencyHistory] = useState<{ [userId: string]: number[] }>({}) const [videoBitrateHistory, setVideoBitrateHistory] = useState<{ [userId: string]: number[] }>({}) const [audioBitrateHistory, setAudioBitrateHistory] = useState<{ [userId: string]: number[] }>({}) + const [screenshareBitrateHistory, setScreenshareBitrateHistory] = useState<{ [userId: string]: number[] }>({}) const [timeRemaining, setTimeRemaining] = useState('--:--') const [timeRemainingColor, setTimeRemainingColor] = useState('text-green-400') const selfVideoRef = useRef(null) @@ -220,6 +232,11 @@ function SessionPage() { null, ) const [pendingHDRequests, setPendingHDRequests] = useState>(new Set()) + + // Test Metrics Manager + const testMetricsManagerRef = useRef(new TestMetricsManager()) + const startupLatencyTracked = useRef(false) + useEffect(() => { userVideoQualitiesRef.current = userVideoQualities }, [userVideoQualities]) @@ -340,11 +357,16 @@ function SessionPage() { // Rewind functionality const handleOpenRewindPlayer = async (userId: string) => { if (isFetching) { - console.log('Already fetching rewind data, please wait...') + console.warn('Fetch in progress') return } - console.log('Fetching rewind data for user:', userId) + // Track rewind button pressed + const user = users[userId] + if (user) { + testMetricsManagerRef.current.trackRewindButtonPressed(userId) + } + setIsFetching(true) try { @@ -356,10 +378,6 @@ function SessionPage() { const videoObjects: BufferedMoqtObject[] = [] const audioObjects: BufferedMoqtObject[] = [] - // Fetch video track - const videoTrackName = getTrackname(roomState.name, userId, 'video') - console.log('Fetching video track:', videoTrackName.toString()) - /* const videoResult = await moqClient.fetch({ priority: 0, @@ -376,13 +394,12 @@ function SessionPage() { */ // get request id from the video track subscription. If the viewer is HD-subscribed // and no SD request id exists, create a temporary SD subscription (no canvas/worker) - console.log('userSubscriptions', userSubscriptions) let videoRequestId = userSubscriptions[userId]?.videoRequestId const isHDSubscribed = !!userSubscriptions[userId]?.videoHDSubscribed if (videoRequestId === undefined && isHDSubscribed) { - console.log('No SD request id while HD subscribed - creating temporary SD subscription for rewind') + console.warn('No SD request id while HD subscribed - creating temporary SD subscription for rewind') // create a silent SD subscription using subscribeOnlyVideo const canvasRef = remoteCanvasRefs[userId] if (!roomState) { @@ -423,12 +440,6 @@ function SessionPage() { return } - console.log('SessionPage: About to fetch video with joiningRequestId:', videoRequestId) - console.log('SessionPage: Using rewind fetch group size:', rewindFetchGroupSize) - console.log( - 'SessionPage: All moqClient requestIds before video fetch:', - moqClient ? Array.from(moqClient.requests.keys()) : 'no client', - ) const videoResult = await moqClient.fetch({ priority: 0, groupOrder: GroupOrder.Original, @@ -443,7 +454,6 @@ function SessionPage() { if (!(videoResult instanceof FetchError)) { const reader = videoResult.stream.getReader() - console.log('Reading video objects from fetch stream...') try { while (true) { @@ -456,11 +466,6 @@ function SessionPage() { timestamp: Date.now(), type: 'video', }) - console.log('Fetched video object:', { - group: value.location.group.toString(), - object: value.location.object.toString(), - payloadSize: value.payload.length, - }) } } } finally { @@ -470,9 +475,7 @@ function SessionPage() { console.warn('Video fetch failed or returned error:', videoResult) } - console.log('Skipping audio fetch to avoid AV sync issues during demo') - - console.log(`Fetch complete for user ${userId}: ${videoObjects.length} video objects (audio skipped for demo)`) + console.warn('Skipping audio fetch to avoid AV sync issues during demo') setFetchedRewindData((prev) => ({ ...prev, @@ -482,6 +485,11 @@ function SessionPage() { if (videoObjects.length > 0) { setSelectedRewindUserId(userId) setIsRewindPlayerOpen(true) + + // Auto-start playback after a short delay to ensure player is initialized + setTimeout(() => { + // The RewindPlayer will handle auto-start via a new prop + }, 250) } else { console.warn('No video data available for user:', userId) } @@ -493,7 +501,6 @@ function SessionPage() { } const handleCloseRewindPlayer = () => { - console.log('SessionPage: Closing rewind player') isRewindCleaningUp.current = true // Clear the fetched rewind data for the selected user @@ -501,7 +508,6 @@ function SessionPage() { setFetchedRewindData((prev) => { const updated = { ...prev } delete updated[selectedRewindUserId] - console.log(`Cleared fetched rewind data for user: ${selectedRewindUserId}`) return updated }) } @@ -512,7 +518,6 @@ function SessionPage() { // If we created a temporary SD subscription for this user, clean it up now const tempSub = tempSDSubscriptionsRef.current[selectedRewindUserId] if (tempSub) { - console.log('Cleaning up temporary SD subscription for', selectedRewindUserId) tempSub.cleanup() delete tempSDSubscriptionsRef.current[selectedRewindUserId] // Clear subscription state if it was only temporary @@ -531,7 +536,6 @@ function SessionPage() { // Add a delay to ensure rewind player cleanup is complete setTimeout(() => { - console.log('SessionPage: Rewind player cleanup complete') isRewindCleaningUp.current = false }, 500) } @@ -547,28 +551,19 @@ function SessionPage() { return words[0].substring(0, 2).toUpperCase() } else { return words - .slice(0, 2) - .map((word) => word.charAt(0)) - .join('') - .toUpperCase() } } const availableColors = [ { bgClass: 'bg-blue-500', hexColor: '#3b82f6' }, - { bgClass: 'bg-green-500', hexColor: '#22c55e' }, - { bgClass: 'bg-purple-500', hexColor: '#a855f7' }, - { bgClass: 'bg-red-500', hexColor: '#ff0000' }, - { bgClass: 'bg-orange-500', hexColor: '#f97316' }, - { bgClass: 'bg-teal-500', hexColor: '#14b8a6' }, ] @@ -610,7 +605,7 @@ function SessionPage() { const handleToggle = (kind: 'mic' | 'cam') => { // Don't allow toggles while rewind player is cleaning up if (isRewindCleaningUp.current) { - console.log('Rewind player is cleaning up, ignoring toggle request') + console.warn('Rewind player is cleaning up, ignoring toggle request') return } @@ -651,7 +646,6 @@ function SessionPage() { videoEncoderObjRef.current.offset = offsetRef.current videoEncoderObjRef.current.start(selfMediaStream.current) } - //TODO: HOW TO CONTINUE HD VIDEO ENCODER if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.offset = offsetRef.current videoHDEncoderObjRef.current.start(selfMediaStream.current) @@ -675,7 +669,6 @@ function SessionPage() { if (videoEncoderObjRef.current) { videoEncoderObjRef.current.stop() } - //TODO: HOW TO STOP HD VIDEO ENCODER if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.stop() } @@ -758,17 +751,19 @@ function SessionPage() { return } - console.log(`Transitioning ${targetUserId} from ${currentQuality} to ${newQuality}`) + console.debug(`Transitioning ${targetUserId} from ${currentQuality} to ${newQuality}`) manualQualityTransitionsRef.current.add(targetUserId) + // Track quality toggle start + const direction = currentQuality === 'HD' && newQuality === 'SD' ? 'HD->SD' : 'SD->HD' + testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, direction) + try { if (currentQuality === 'HD' && newQuality === 'SD') { - console.log('Unsubscribing from HD video') hdSubscriptionAttemptsRef.current.delete(targetUserId) await unsubscribeFromHDVideo(targetUserId) await new Promise((resolve) => setTimeout(resolve, 500)) } else if (currentQuality === 'SD' && newQuality === 'HD') { - console.log('Unsubscribing from SD video') hdSubscriptionAttemptsRef.current.delete(targetUserId) await unsubscribeFromUser(targetUserId, 'video') await new Promise((resolve) => setTimeout(resolve, 500)) @@ -779,17 +774,14 @@ function SessionPage() { targetUserId, requesterId: userId, }) - console.log(`Sent HD video request to user ${targetUserId}`) } else { contextSocket?.emit('request-sd-video', { targetUserId, requesterId: userId, }) - console.log(`Sent SD video request to user ${targetUserId}`) const success = await subscribeToSDVideo(targetUserId) if (success) { - console.log(`Successfully switched to SD video for ${targetUserId}`) } else { console.error(`Failed to switch to SD video for ${targetUserId}`) } @@ -820,12 +812,9 @@ function SessionPage() { if (currentQuality === 'HD') { // HD to SD: Direct transition, no permission needed - console.log(`Switching from HD to SD for ${targetUserId}`) + console.debug(`Switching from HD to SD for ${targetUserId}`) await handleQualityTransition(targetUserId, 'HD', 'SD') } else { - // SD to HD: Request permission first - console.log(`Requesting HD permission from ${targetUserId}`) - // Add to pending requests setPendingHDRequests((prev) => new Set(prev).add(targetUserId)) @@ -833,7 +822,6 @@ function SessionPage() { targetUserId, requesterId: userId, }) - console.log(`Sent HD permission request to user ${targetUserId}`) // Wait for permission response - handleQualityTransition will be called based on response } } @@ -841,7 +829,6 @@ function SessionPage() { // HD Permission Response Functions const handleHDPermissionAccept = () => { if (hdPermissionRequest && contextSocket) { - console.log(`Accepting HD permission request from ${hdPermissionRequest.requesterId}`) contextSocket.emit('hd-permission-response', { requesterId: hdPermissionRequest.requesterId, allowed: true, @@ -852,7 +839,7 @@ function SessionPage() { const handleHDPermissionReject = () => { if (hdPermissionRequest && contextSocket) { - console.log(`Rejecting HD permission request from ${hdPermissionRequest.requesterId}`) + console.warn(`Rejecting HD permission request from ${hdPermissionRequest.requesterId}`) contextSocket.emit('hd-permission-response', { requesterId: hdPermissionRequest.requesterId, allowed: false, @@ -866,8 +853,6 @@ function SessionPage() { return } - console.log('Starting HD encoding process...') - setUsers((users) => { const u = users[userId] users[userId] = { ...u, hasVideoHD: true } @@ -879,9 +864,6 @@ function SessionPage() { const videoHDTrack = roomState.users[userId]?.publishedTracks['video-hd'] if (videoHDTrack && moqClientRef.current) { - // TODO: HD Track should be announced only when the user accepts - // TODO: Otherwise, SD should continue (the unsub should not happen) - console.log('Announcing HD track to server') contextSocket?.emit('update-track', { trackType: 'video-hd', event: 'announce' }) if (selfMediaStream.current && tracksRef.current) { @@ -898,7 +880,6 @@ function SessionPage() { if (videoHDEncoderObjRef.current) { videoHDEncoderObjRef.current.offset = offsetRef.current videoHDEncoderObjRef.current.start(selfMediaStream.current) - console.log('HD encoder started successfully') } contextSocket?.emit('update-track', { trackType: 'video-hd', event: 'publish' }) @@ -916,8 +897,6 @@ function SessionPage() { } contextSocket?.emit('toggle-button', { kind: 'cam-hd', value: true }) - - console.log('Started HD encoding due to remote request') } const handleStopHDEncoding = () => { @@ -938,7 +917,7 @@ function SessionPage() { contextSocket?.emit('toggle-button', { kind: 'cam-hd', value: false }) - console.log('Stopped HD encoding due to remote request') + console.warn('Stopped HD encoding due to remote request') } const handleToggleScreenShare = async () => { @@ -955,8 +934,6 @@ function SessionPage() { selfScreenshareStream.current = screenStream - console.log('Screenshare stream tracks:', screenStream.getTracks()) - try { if (!tracksRef.current) { console.error('tracksRef.current is null - cannot start screenshare encoder') @@ -964,7 +941,6 @@ function SessionPage() { } if (screenshareEncoderObjRef.current) { - console.log('Cleaning up previous screenshare encoder') try { screenshareEncoderObjRef.current.stop() } catch (error) { @@ -974,7 +950,6 @@ function SessionPage() { } const screenshareFullTrackName = getTrackname(roomState!.name, userId, 'screenshare') - console.log('screenshareFullTrackName:', screenshareFullTrackName) const screenshareStreamController = tracksRef.current.getScreenshareStreamController() @@ -985,7 +960,6 @@ function SessionPage() { publisherPriority: 1, objectForwardingPreference: ObjectForwardingPreference.Subgroup, }) - console.log('screenshareResult:', screenshareResult) screenshareEncoderObjRef.current = screenshareResult const updateTrackRequest: UpdateTrackRequest = { @@ -993,7 +967,6 @@ function SessionPage() { event: 'announce', } contextSocket?.emit('update-track', updateTrackRequest) - console.log('Screenshare track announced to other clients') } catch (error) { console.error('Failed to start screenshare encoder:', error) if (selfScreenshareStream.current) { @@ -1011,8 +984,6 @@ function SessionPage() { })) screenTrack.onended = () => { - console.log('Screen track ended - cleaning up screenshare') - setIsScreenSharing(false) contextSocket?.emit('screen-share-toggled', { userId, hasScreenshare: false }) setUsers((users) => ({ @@ -1084,7 +1055,6 @@ function SessionPage() { useEffect(() => { async function startPublisher() { try { - //console.log('Starting publisher for user:', userId); if (!userId) { console.error('User ID is not defined') return @@ -1103,14 +1073,11 @@ function SessionPage() { console.warn('No audio tracks available; running without microphone') } setHasLocalAudio(audioTracks.length > 0) - - //console.log('Got user media:', selfMediaStream.current); setMediaReady(true) if (selfVideoRef.current) { selfVideoRef.current.srcObject = selfMediaStream.current selfVideoRef.current.muted = true // Ensure muted - //console.log('Set video srcObject'); } else { console.error('selfVideoRef.current is null') return @@ -1132,7 +1099,6 @@ function SessionPage() { console.error('Self user not found in room state: %s', userId) return } - //console.log('Self user found:', selfUser); const videoTrack = selfUser?.publishedTracks['video'] const videoTrackAlias = videoTrack?.alias @@ -1260,13 +1226,11 @@ function SessionPage() { console.error('Error in publisher setup:', err) } } - //console.log('before startPublisher', moqClient, userId, selfVideoRef.current, publisherInitialized) if (moqClient && userId && selfVideoRef.current && !publisherInitialized.current) { publisherInitialized.current = true setTimeout(async () => { try { await startPublisher() - //console.log('startPublisher done') } catch (err) { console.error('error in startPublishing', err) } @@ -1290,12 +1254,24 @@ function SessionPage() { client.onDataReceived = (_data) => { // console.warn('Data received:', data) } - //console.log('initClient', client) if (roomState && Object.values(users).length === 0) { const otherUsers = Object.keys(roomState.users).filter((uId) => uId != userId) setUsers(roomState.users) Object.keys(roomState.users).forEach((uId) => initializeTelemetryForUser(uId)) + + const anyUserHasMedia = otherUsers.some((uId) => { + const user = roomState.users[uId] + return user.hasVideo || user.hasAudio + }) + + if (anyUserHasMedia) { + testMetricsManagerRef.current.trackStartupJoinButtonClick('global') + } else { + console.log('Startup Latency: N/A') + startupLatencyTracked.current = true + } + const canvasRefs = Object.fromEntries(otherUsers.map((uId) => [uId, React.createRef()])) const screenshareCanvasRefs = Object.fromEntries( otherUsers.map((uId) => [uId, React.createRef()]), @@ -1314,6 +1290,7 @@ function SessionPage() { console.info(`User joined: ${user.name} (${user.id})`) addUser(user) initializeTelemetryForUser(user.id) + setRemoteCanvasRefs((prev) => ({ ...prev, [user.id]: React.createRef(), @@ -1336,7 +1313,6 @@ function SessionPage() { socket.on('track-updated', (response: TrackUpdateResponse) => { setUsers((prevUsers) => { - //console.log('track-updated', prevUsers, response) const updatedUser = prevUsers[response.userId] if (updatedUser) { const track = response.track @@ -1348,9 +1324,6 @@ function SessionPage() { track.kind === 'video-hd' ) { updatedUser.publishedTracks[track.kind] = track - console.log( - `Track updated for ${response.userId}: ${track.kind} - alias: ${track.alias}, announced: ${track.announced}`, - ) } } return { ...prevUsers } @@ -1358,10 +1331,7 @@ function SessionPage() { // Handle HD video subscription outside of setState if (response.track.kind === 'video-hd' && response.track.announced > 0) { - console.log('HD track announced, checking if we should subscribe...') - const currentQuality = userVideoQualitiesRef.current[response.userId] - console.log(`HD track announced by ${response.userId}, current requested quality: ${currentQuality}`) if (currentQuality === 'HD') { if (hdSubscriptionAttemptsRef.current.has(response.userId)) { @@ -1369,7 +1339,6 @@ function SessionPage() { return } - console.log(`Auto-subscribing to HD video from ${response.userId}`) hdSubscriptionAttemptsRef.current.add(response.userId) setTimeout(() => { @@ -1378,10 +1347,8 @@ function SessionPage() { subscribeToHDVideoWithState(response.userId, currentUsers, currentCanvasRefs) .then((success) => { - if (success) { - console.log(`HD subscription successful for ${response.userId}`) - } else { - console.log(`HD subscription failed for ${response.userId}`) + if (!success) { + console.error(`HD subscription failed for ${response.userId}`) } }) .catch((error) => { @@ -1392,8 +1359,6 @@ function SessionPage() { hdSubscriptionAttemptsRef.current.delete(response.userId) }) }, 1000) // Increased delay to 1 second - } else { - console.log(`Not subscribing - current quality is ${currentQuality}, not HD`) } } }) @@ -1414,18 +1379,14 @@ function SessionPage() { }) }) socket.on('screen-share-toggled', ({ userId: toggledUserId, hasScreenshare }) => { - console.log(`Screen share toggled for ${toggledUserId}: ${hasScreenshare}`) - setUsers((prevUsers) => { if (!prevUsers[toggledUserId]) return prevUsers if (!hasScreenshare && prevUsers[toggledUserId].hasScreenshare) { - console.log(`User ${toggledUserId} stopped screensharing, triggering unsubscription`) unsubscribeFromScreenshare(toggledUserId, false) const screenshareVirtualId = `${toggledUserId}-screenshare` if (maximizedUserId === screenshareVirtualId) { - console.log(`Clearing maximized screenshare user ${screenshareVirtualId}`) setMaximizedUserId(null) } @@ -1448,7 +1409,6 @@ function SessionPage() { // Handle HD permission request from another user socket.on('hd-permission-request', ({ requesterId }) => { - console.log(`User ${requesterId} is requesting HD video permission from me`) const requester = usersRef.current[requesterId] const requesterName = requester ? requester.name : 'Unknown User' setHdPermissionRequest({ requesterId, requesterName }) @@ -1456,7 +1416,6 @@ function SessionPage() { // Handle permission denied response socket.on('hd-permission-denied', ({ targetUserId }) => { - console.log(`HD permission denied by ${targetUserId}`) // User stays in SD mode - no action needed since we didn't change quality yet // Remove from pending requests @@ -1469,8 +1428,6 @@ function SessionPage() { // Handle permission granted response socket.on('hd-permission-granted', ({ targetUserId }) => { - console.log(`HD permission granted by ${targetUserId}`) - // Remove from pending requests setPendingHDRequests((prev) => { const newSet = new Set(prev) @@ -1478,6 +1435,8 @@ function SessionPage() { return newSet }) + testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') + // First, set the quality to HD so the track-updated handler will auto-subscribe setUserVideoQualities((prev) => { const newQualities = { @@ -1485,14 +1444,12 @@ function SessionPage() { [targetUserId]: 'HD' as VideoQuality, } userVideoQualitiesRef.current = newQualities - console.log(`Set quality to HD for ${targetUserId} - track-updated will handle the rest`) return newQualities }) // Unsubscribe from SD video after a short delay to ensure HD subscription starts setTimeout(async () => { try { - console.log(`Unsubscribing from SD video for ${targetUserId}`) await unsubscribeFromUser(targetUserId, 'video') } catch (error) { console.error(`Error unsubscribing from SD for ${targetUserId}:`, error) @@ -1502,17 +1459,17 @@ function SessionPage() { // Handle direct HD video request (when permission is granted) socket.on('request-hd-video', ({ requesterId }) => { - console.log(`Starting HD encoding - permission was granted for ${requesterId}`) + console.debug(`Starting HD encoding - permission was granted for ${requesterId}`) handleStartHDEncoding() }) socket.on('request-sd-video', ({ requesterId }) => { - console.log(`User ${requesterId} requested SD video from me`) + console.debug(`User ${requesterId} requested SD video from me`) handleStopHDEncoding() }) socket.on('hd-already-active', ({ targetUserId }) => { - console.log(`HD encoding already active for ${targetUserId} - subscribing to existing HD stream`) + console.warn(`HD encoding already active for ${targetUserId} - subscribing to existing HD stream`) // Remove from pending requests since we're getting direct access to HD setPendingHDRequests((prev) => { @@ -1521,6 +1478,8 @@ function SessionPage() { return newSet }) + testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') + manualQualityTransitionsRef.current.add(targetUserId) setUserVideoQualities((prev) => { @@ -1529,13 +1488,11 @@ function SessionPage() { [targetUserId]: 'HD' as VideoQuality, } userVideoQualitiesRef.current = newQualities - console.log(`Updated quality state to HD for ${targetUserId}:`, newQualities) return newQualities }) setTimeout(async () => { try { - console.log(`Unsubscribing from SD video for ${targetUserId} before subscribing to existing HD`) await unsubscribeFromUser(targetUserId, 'video') await new Promise((resolve) => setTimeout(resolve, 500)) @@ -1543,12 +1500,9 @@ function SessionPage() { const currentUsers = usersRef.current const currentCanvasRefs = remoteCanvasRefsRef.current - console.log(`Subscribing to existing HD video for ${targetUserId}`) const success = await subscribeToHDVideoWithState(targetUserId, currentUsers, currentCanvasRefs) - if (success) { - console.log(`Successfully subscribed to existing HD video for ${targetUserId}`) - } else { + if (!success) { console.error(`Failed to subscribe to existing HD video for ${targetUserId}`) } } catch (error) { @@ -1598,7 +1552,17 @@ function SessionPage() { return updated }) - setLatencyHistory((prev) => { + setVideoLatencyHistory((prev) => { + const newHistory = { ...prev } + delete newHistory[msg.userId] + return newHistory + }) + setAudioLatencyHistory((prev) => { + const newHistory = { ...prev } + delete newHistory[msg.userId] + return newHistory + }) + setScreenshareLatencyHistory((prev) => { const newHistory = { ...prev } delete newHistory[msg.userId] return newHistory @@ -1622,7 +1586,6 @@ function SessionPage() { return newColors }) - // TODO: unsubscribe }) socket.on('room-closed', (msg: RoomTimeoutMessage) => { @@ -1685,6 +1648,7 @@ function SessionPage() { telemetryInstances.current[userId] = { video: new NetworkTelemetry(1000), // 1 second window audio: new NetworkTelemetry(1000), // 1 second window + screenshare: new NetworkTelemetry(1000), // 1 second window } setCodecData((prev) => ({ @@ -1747,57 +1711,116 @@ function SessionPage() { } } - const previousValues = useRef<{ [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } }>( - {}, - ) + const previousValues = useRef<{ + [userId: string]: { + videoLatency: number + audioLatency: number + screenshareLatency: number + videoBitrate: number + audioBitrate: number + } + }>({}) // Update every 100ms useEffect(() => { const interval = setInterval(() => { - const newTelemetryData: { [userId: string]: { latency: number; videoBitrate: number; audioBitrate: number } } = {} + const newTelemetryData: { + [userId: string]: { + videoLatency: number + audioLatency: number + screenshareLatency: number + videoBitrate: number + audioBitrate: number + screenshareBitrate: number + } + } = {} Object.keys(telemetryInstances.current).forEach((userId) => { const telemetry = telemetryInstances.current[userId] if (telemetry) { const videoLatency = isSelf(userId) ? 0 : Math.round(telemetry.video.latency) const audioLatency = isSelf(userId) ? 0 : Math.round(telemetry.audio.latency) + const screenshareLatency = isSelf(userId) ? 0 : Math.round(telemetry.screenshare.latency) const videoBitrate = (telemetry.video.throughput * 8) / 1000 // bytes/s to Kbps const audioBitrate = (telemetry.audio.throughput * 8) / 1000 // bytes/s to Kbps - - const user = users[userId] - const shouldUseAudioLatency = user?.hasAudio && (!user?.hasVideo || audioLatency > 0) - const displayLatency = shouldUseAudioLatency ? audioLatency : videoLatency - //console.log(`Telemetry for user ${userId}: videoLatency=${videoLatency}, audioLatency=${audioLatency}, displayLatency=${displayLatency}, hasVideo=${user?.hasVideo}, hasAudio=${user?.hasAudio}, shouldUseAudioLatency=${shouldUseAudioLatency}`) + const screenshareBitrate = (telemetry.screenshare.throughput * 8) / 1000 // bytes/s to Kbps newTelemetryData[userId] = { - latency: displayLatency, + videoLatency, + audioLatency, + screenshareLatency, videoBitrate: Math.max(0, videoBitrate), audioBitrate: Math.max(0, audioBitrate), + screenshareBitrate: Math.max(0, screenshareBitrate), } - // Latency history (last 30 points) + // Video Latency history (last 30 points) if (!isSelf(userId)) { - setLatencyHistory((prevLatency) => { + setVideoLatencyHistory((prevLatency) => { const userHistory = prevLatency[userId] || [] - const newHistory = [...userHistory, displayLatency].slice(-30) + const newHistory = [...userHistory, videoLatency].slice(-30) return { ...prevLatency, [userId]: newHistory, } }) - // Store latency to DB + // Store video latency to DB telemetryDB .addEntry({ sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'latency', + streamType: 'videoLatency', timestamp: Date.now(), - value: displayLatency, + value: videoLatency, }) - .catch((err) => console.error('Failed to store latency:', err)) + .catch((err) => console.error('Failed to store video latency:', err)) + + // Audio Latency history (last 30 points) + setAudioLatencyHistory((prevLatency) => { + const userHistory = prevLatency[userId] || [] + const newHistory = [...userHistory, audioLatency].slice(-30) + return { + ...prevLatency, + [userId]: newHistory, + } + }) + + // Store audio latency to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + userName: users[userId]?.name || userId, + streamType: 'audioLatency', + timestamp: Date.now(), + value: audioLatency, + }) + .catch((err) => console.error('Failed to store audio latency:', err)) + + // Screenshare Latency history (last 30 points) + setScreenshareLatencyHistory((prevLatency) => { + const userHistory = prevLatency[userId] || [] + const newHistory = [...userHistory, screenshareLatency].slice(-30) + return { + ...prevLatency, + [userId]: newHistory, + } + }) } + // Store screenshare latency to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + userName: users[userId]?.name || userId, + streamType: 'screenshareLatency', + timestamp: Date.now(), + value: screenshareLatency, + }) + .catch((err) => console.error('Failed to store screenshare latency:', err)) + // Video bitrate history (last 30 points) setVideoBitrateHistory((prevVideoBitrate) => { const userHistory = prevVideoBitrate[userId] || [] @@ -1807,13 +1830,14 @@ function SessionPage() { [userId]: newHistory, } }) + // Store video bitrate to DB telemetryDB .addEntry({ sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'video', + streamType: 'videoBitrate', timestamp: Date.now(), value: videoBitrate, }) @@ -1828,17 +1852,39 @@ function SessionPage() { [userId]: newHistory, } }) + // Store audio bitrate to DB telemetryDB .addEntry({ sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'audio', + streamType: 'audioBitrate', timestamp: Date.now(), value: audioBitrate, }) .catch((err) => console.error('Failed to store audio bitrate:', err)) + + setScreenshareBitrateHistory((prevScreenshareBitrate) => { + const userHistory = prevScreenshareBitrate[userId] || [] + const newHistory = [...userHistory, screenshareBitrate].slice(-30) + return { + ...prevScreenshareBitrate, + [userId]: newHistory, + } + }) + + // Store screenshare bitrate to DB + telemetryDB + .addEntry({ + sessionId: sessionId.current, + userId, + userName: users[userId]?.name || userId, + streamType: 'screenshareBitrate', + timestamp: Date.now(), + value: screenshareBitrate, + }) + .catch((err) => console.error('Failed to store screenshare bitrate:', err)) } }) @@ -1969,18 +2015,9 @@ function SessionPage() { }, [roomState?.created, sessionDurationMinutes]) useEffect(() => { - console.log('Screenshare useEffect triggered:', { - hasStream: !!selfScreenshareStream.current, - hasRef: !!selfScreenshareRef.current, - isScreenSharing, - streamTracks: selfScreenshareStream.current?.getTracks()?.length || 0, - }) - if (selfScreenshareStream.current && selfScreenshareRef.current && isScreenSharing) { - console.log('Assigning screenshare stream to ref in useEffect') selfScreenshareRef.current.srcObject = selfScreenshareStream.current selfScreenshareRef.current.muted = true - console.log('Screenshare video element configured successfully:', selfScreenshareRef.current) } }, [isScreenSharing, selfScreenshareStream.current]) @@ -1998,7 +2035,6 @@ function SessionPage() { } function handleRemoteVideo(canvasRef: React.RefObject) { - //console.log('handleRemoteVideo init', canvasRef) if (!canvasRef?.current) return if (!moqClient) return if (canvasRef.current.dataset.status) return @@ -2022,23 +2058,14 @@ function SessionPage() { tracksReady && isCompletelyUnsubscribed && !wasIntentionallyUnsubscribed && !isInManualTransition if (shouldSubscribe) { - console.log(`Starting subscription to ${userId} - video: ${videoTrackAlias}, audio: ${audioTrackAlias}`) setTimeout(async () => { await subscribeToTrack(roomName, userId, videoTrackAlias, audioTrackAlias, chatTrackAlias, canvasRef) }, 500) } else { if (tracksReady && isInManualTransition) { - console.log(`Skipping auto-subscription to ${userId} - user is undergoing manual quality transition`) } else if (tracksReady && wasIntentionallyUnsubscribed) { - console.log(`Skipping auto-subscription to ${userId} - user was intentionally unsubscribed from both tracks`) } else if (tracksReady && !isCompletelyUnsubscribed) { - console.log( - `Skipping subscription to ${userId} - already subscribed (video: ${isVideoSubscribed}, videoHD: ${isVideoHDSubscribed}, audio: ${isAudioSubscribed})`, - ) } else if (!tracksReady) { - console.log( - `Not ready to subscribe to ${userId} yet - announced: ${announced}, video: ${videoTrackAlias}, audio: ${audioTrackAlias}`, - ) } } } @@ -2063,16 +2090,14 @@ function SessionPage() { tracksReady && userHasScreenshare && !isScreenshareSubscribed && !wasScreenshareIntentionallyUnsubscribed if (shouldSubscribe) { - console.log(`Starting screenshare subscription to ${userId} - screenshare: ${screenshareTrackAlias}`) setTimeout(async () => { await subscribeToScreenshareTrack(roomName, userId, screenshareTrackAlias, canvasRef) }, 500) } else { if (!userHasScreenshare && isScreenshareSubscribed) { - console.log(`User ${userId} stopped screensharing, cleaning up subscription`) unsubscribeFromScreenshare(userId, false) } else if (!tracksReady) { - console.log( + console.warn( `Not ready to subscribe to screenshare for ${userId} yet - screenshareTrackAlias: ${screenshareTrackAlias}`, ) } @@ -2090,11 +2115,9 @@ function SessionPage() { ) { try { const the_client = client ? client : moqClient! - //console.log('subscribeToTrack', roomName, userId, videoTrackAlias, audioTrackAlias, canvasRef) // TODO: sub to audio and video separately // for now, we just check the video announced date if (canvasRef.current && !canvasRef.current.dataset.status) { - //console.log("subscribeToTrack - Now will try to subscribe") const videoFullTrackName = getTrackname(roomName, userId, 'video') const audioFullTrackName = getTrackname(roomName, userId, 'audio') const chatFullTrackName = getTrackname(roomName, userId, 'chat') @@ -2103,7 +2126,17 @@ function SessionPage() { initializeTelemetryForUser(userId) const userTelemetry = telemetryInstances.current[userId] - //console.log("subscribeToTrack - Use video subscriber called", videoTrackAlias, audioTrackAlias, videoFullTrackName, audioFullTrackName) + const user = usersRef.current[userId] + const hasVideoAtSubscription = user?.hasVideo || false + const hasAudioAtSubscription = user?.hasAudio || false + + const onFirstFrameCallback = () => { + if (!startupLatencyTracked.current) { + startupLatencyTracked.current = true + testMetricsManagerRef.current.trackStartupFirstFrame('global', 'First user', true) + } + } + // Subscribe to video and audio separately for independent control const videoResult = await onlyUseVideoSubscriber( the_client, @@ -2111,6 +2144,7 @@ function SessionPage() { videoTrackAlias, videoFullTrackName, userTelemetry.video, + hasVideoAtSubscription ? onFirstFrameCallback : undefined, )() const audioResult = await onlyUseAudioSubscriber( @@ -2118,6 +2152,7 @@ function SessionPage() { audioTrackAlias, audioFullTrackName, userTelemetry.audio, + hasAudioAtSubscription ? onFirstFrameCallback : undefined, )() const subscriptionResult = { @@ -2140,7 +2175,6 @@ function SessionPage() { // Subscribe to chat if we have a valid chat track alias if (chatTrackAlias > 0) { - console.log('Subscribing to chat track with alias:', chatTrackAlias) try { await subscribeToChatTrack({ moqClient: the_client, @@ -2158,7 +2192,6 @@ function SessionPage() { ]) }, }) - console.log('Successfully subscribed to chat for user:', userId) } catch (error) { console.error('Failed to subscribe to chat for user:', userId, error) } @@ -2166,7 +2199,6 @@ function SessionPage() { console.warn('Chat track alias is invalid or not set:', chatTrackAlias, 'for user:', userId) // Try to subscribe to chat later with a retry mechanism setTimeout(async () => { - console.log('Retrying chat subscription for user:', userId) const retrychatTrackAlias = parseInt(canvasRef.current?.dataset.chattrackalias || '-1') if (retrychatTrackAlias > 0) { try { @@ -2186,7 +2218,6 @@ function SessionPage() { ]) }, }) - console.log('Successfully subscribed to chat on retry for user:', userId) } catch (error) { console.error('Failed to subscribe to chat on retry for user:', userId, error) } @@ -2195,7 +2226,6 @@ function SessionPage() { } }, 2000) // Wait 2 seconds before retrying chat subscription } - //console.log('subscribeToTrack result', result) // TODO: result comes true all the time, refactor... canvasRef.current!.dataset.status = subscriptionResult ? 'playing' : '' } @@ -2211,8 +2241,6 @@ function SessionPage() { currentUsers: { [K: string]: RoomUser }, currentCanvasRefs: { [id: string]: React.RefObject }, ): Promise { - console.log(`🔍 subscribeToHDVideoWithState called for ${targetUserId}`) - if (!roomState || !moqClientRef.current) { console.error('Room state or moqClient not available') return false @@ -2261,7 +2289,14 @@ function SessionPage() { videoHDRequestId: hdResult.videoRequestId, }, })) - console.log(`Successfully subscribed to HD video for user ${targetUserId}`) + + // Track quality toggle completion + + const user = currentUsers[targetUserId] + if (user) { + testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) + } + return true } } catch (error) { @@ -2271,8 +2306,6 @@ function SessionPage() { } async function subscribeToSDVideo(targetUserId: string): Promise { - console.log(`subscribeToSDVideo called for ${targetUserId}`) - if (!roomState || !moqClientRef.current) { console.error('Room state or moqClient not available for SD subscription') return false @@ -2297,12 +2330,6 @@ function SessionPage() { initializeTelemetryForUser(targetUserId) const userTelemetry = telemetryInstances.current[targetUserId] - console.log(`About to subscribe to SD video:`, { - targetUserId, - trackAlias: videoTrackAlias, - fullTrackName: videoFullTrackName.toString(), - }) - // Reset canvas status to allow subscription canvasRef.current.dataset.status = '' @@ -2323,7 +2350,14 @@ function SessionPage() { videoRequestId: sdResult.videoRequestId, }, })) - console.log(`Successfully subscribed to SD video for user ${targetUserId}`) + + // Track quality toggle completion + + const user = usersRef.current[targetUserId] + if (user) { + testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) + } + return true } } catch (error) { @@ -2346,11 +2380,6 @@ function SessionPage() { const screenshareFullTrackName = getTrackname(roomName, userId, 'screenshare') screenshareCanvasRef.current!.dataset.status = 'pending' - console.log(`Starting screenshare subscription for ${userId}`, { - trackName: screenshareFullTrackName, - trackAlias: screenshareTrackAlias, - }) - initializeTelemetryForUser(userId) const userTelemetry = telemetryInstances.current[userId] @@ -2359,7 +2388,7 @@ function SessionPage() { screenshareCanvasRef, screenshareTrackAlias, screenshareFullTrackName, - userTelemetry.video, + userTelemetry.screenshare, )() if (screenshareResult && screenshareResult.videoRequestId) { @@ -2381,7 +2410,7 @@ function SessionPage() { screenshareCanvasRef.current!.dataset.status = screenshareResult ? 'playing' : '' } else { - console.log(`Cannot subscribe to screenshare for ${userId}:`, { + console.warn(`Cannot subscribe to screenshare for ${userId}:`, { hasCanvasRef: !!screenshareCanvasRef.current, currentStatus: screenshareCanvasRef.current?.dataset.status, }) @@ -2430,7 +2459,6 @@ function SessionPage() { const screenshareCanvasRef = remoteScreenshareCanvasRefs[targetUserId] if (screenshareCanvasRef?.current) { screenshareCanvasRef.current.dataset.status = '' - console.log(`Reset canvas status for ${targetUserId} screenshare (no subscription case)`) } return @@ -2439,9 +2467,7 @@ function SessionPage() { let unsubscriptionSuccess = false try { - console.log(`Attempting to unsubscribe from ${targetUserId} screenshare with requestId:`, requestId) await client.unsubscribe(requestId) - console.log(`Successfully unsubscribed from ${targetUserId} screenshare`) unsubscriptionSuccess = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} screenshare:`, error) @@ -2464,19 +2490,9 @@ function SessionPage() { const canvas = screenshareCanvasRef.current clearScreenshareCanvas(canvas) } - - console.log( - `Screenshare cleanup completed for ${targetUserId} (unsubscription: ${unsubscriptionSuccess ? 'success' : 'failed'})`, - { - refCleared: !screenshareSubscriptionsRef.current[targetUserId], - stateUpdate: 'pending...', - }, - ) } function leaveRoom() { - //console.log('Leaving room...'); - // Clean up any pending room closed messages and restore title setPendingRoomClosedMessage(null) document.title = originalTitle.current @@ -2492,7 +2508,6 @@ function SessionPage() { } if (videoEncoderObjRef.current && videoEncoderObjRef.current.stop) { - //console.log('Stopping video encoder...'); videoEncoderObjRef.current.stop() videoEncoderObjRef.current = null } @@ -2516,8 +2531,6 @@ function SessionPage() { } const unsubscribeFromHDVideo = async (targetUserId: string): Promise => { - console.log(`🔍 unsubscribeFromHDVideo called for ${targetUserId}`) - if (!moqClient || targetUserId === userId) { console.warn( `Cannot unsubscribe from HD: moqClient=${!!moqClient}, targetUserId=${targetUserId}, userId=${userId}`, @@ -2532,12 +2545,7 @@ function SessionPage() { } try { - console.log( - `Attempting to unsubscribe from ${targetUserId} HD video with requestId:`, - subscription.videoHDRequestId, - ) await moqClient.unsubscribe(subscription.videoHDRequestId) - console.log(`Successfully unsubscribed from ${targetUserId} HD video`) setUserSubscriptions((prev) => ({ ...prev, @@ -2578,15 +2586,13 @@ function SessionPage() { subscription.videoRequestId !== undefined ) { try { - console.log(`Attempting to unsubscribe from ${targetUserId} video with requestId:`, subscription.videoRequestId) await _client.unsubscribe(subscription.videoRequestId) - console.log(`Successfully unsubscribed from ${targetUserId} video`) videoUnsubscribed = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} video:`, error) } } else if (type === 'video' || type === 'both') { - console.log(`Skipping video unsubscribe for ${targetUserId} - not subscribed or missing requestId`) + console.warn(`Skipping video unsubscribe for ${targetUserId} - not subscribed or missing requestId`) } const audioTypeCheck = type === 'audio' || type === 'both' @@ -2595,18 +2601,13 @@ function SessionPage() { if (audioTypeCheck && audioSubscribedCheck && audioRequestIdCheck) { try { - console.log( - `Attempting to unsubscribe from ${targetUserId} audio with requestId:`, - subscription.audioRequestId!, - ) await _client.unsubscribe(subscription.audioRequestId!) - console.log(`Successfully unsubscribed from ${targetUserId} audio`) audioUnsubscribed = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} audio:`, error) } } else if (type === 'audio' || type === 'both') { - console.log(`Skipping audio unsubscribe for ${targetUserId} - condition failed`) + console.warn(`Skipping audio unsubscribe for ${targetUserId} - condition failed`) } setUserSubscriptions((prev) => { @@ -2633,7 +2634,6 @@ function SessionPage() { intentionallyUnsubscribed: willBeCompletelyUnsubscribed, } - console.log(`Updated subscription state for ${targetUserId}:`, newSubscription) return { ...prev, [targetUserId]: newSubscription, @@ -2646,7 +2646,7 @@ function SessionPage() { canvasRef.current.dataset.status = '' // Don't cleanup the canvas worker, just reset status // The worker will be reused and reconfigured for HD if needed - console.log(`Reset canvas status for ${targetUserId}`) + console.warn(`Reset canvas status for ${targetUserId}`) } } } @@ -2678,17 +2678,15 @@ function SessionPage() { const needsAudioSub = (type === 'audio' || type === 'both') && !hasAudioSub if (!needsVideoSub && !needsAudioSub) { - console.log(`Already subscribed to ${targetUserId} ${type}`) + console.warn(`Already subscribed to ${targetUserId} ${type}`) return } if (canvasRef.current.dataset.status) { - console.log(`Resetting canvas status for ${targetUserId} to allow resubscription`) canvasRef.current.dataset.status = '' } if (needsVideoSub && needsAudioSub) { - console.log(`Subscribing to both video and audio for ${targetUserId}`) await subscribeToTrack( roomName, targetUserId, @@ -2698,7 +2696,9 @@ function SessionPage() { canvasRef, ) } else if (needsVideoSub) { - console.log(`Adding video subscription for ${targetUserId}`) + // Track video resub start (when user clicks to resub) + testMetricsManagerRef.current.trackVideoResubStart(targetUserId) + const videoFullTrackName = getTrackname(roomName, targetUserId, 'video') initializeTelemetryForUser(targetUserId) const userTelemetry = telemetryInstances.current[targetUserId] @@ -2724,7 +2724,12 @@ function SessionPage() { })) canvasRef.current.dataset.status = 'playing' - console.log(`Video subscription added for ${targetUserId}, requestId: ${videoResult.videoRequestId}`) + + // Track video resub complete + const user = usersRef.current[targetUserId] + if (user) { + testMetricsManagerRef.current.trackVideoResubComplete(targetUserId, user.name) + } } else { console.error(`Video subscription failed for ${targetUserId}`) } @@ -2732,7 +2737,8 @@ function SessionPage() { console.error(`Failed to add video subscription for ${targetUserId}:`, error) } } else if (needsAudioSub) { - console.log(`Adding audio subscription for ${targetUserId}`) + // Track audio resub start (when user clicks to resub) + testMetricsManagerRef.current.trackAudioResubStart(targetUserId) const audioFullTrackName = getTrackname(roomName, targetUserId, 'audio') initializeTelemetryForUser(targetUserId) @@ -2757,7 +2763,11 @@ function SessionPage() { }, })) - console.log(`Audio subscription added for ${targetUserId}, requestId: ${audioResult.audioRequestId}`) + // Track audio resub complete + const user = usersRef.current[targetUserId] + if (user) { + testMetricsManagerRef.current.trackAudioResubComplete(targetUserId, user.name) + } } else { console.error(`Audio subscription failed for ${targetUserId}`) } @@ -2881,7 +2891,6 @@ function SessionPage() { useEffect(() => { if (maximizedUserId && !usersToRender.find((u) => u.id === maximizedUserId)) { - console.log(`Maximized user ${maximizedUserId} no longer exists, clearing maximized state`) setMaximizedUserId(null) } }, [maximizedUserId, usersToRender]) @@ -3088,8 +3097,8 @@ function SessionPage() { {telemetryData[user.id] && !isSelf(user.id) && ( // TODO: Calculate throughputs for self user
- {telemetryData[user.id].latency}ms | {telemetryData[user.id].videoBitrate.toFixed(0)}Kbit/s - | {telemetryData[user.id].audioBitrate.toFixed(0)}Kbit/s + {telemetryData[user.id].videoLatency}ms | {telemetryData[user.id].videoBitrate.toFixed(0)} + Kbit/s | {telemetryData[user.id].audioBitrate.toFixed(0)}Kbit/s
)}
@@ -3271,39 +3280,88 @@ function SessionPage() {

Network Stats

- {/* Legend */} -
-
-
- VIDEO -
-
-
- AUDIO -
-
-
- LATENCY + {/* Metrics Grid */} +
+ {/* Bitrate Section */} +
+

Bitrate

+
+ {/* Video Bitrate */} +
+
+
+ VIDEO +
+ + {telemetryData[user.id] + ? `${telemetryData[user.id].videoBitrate.toFixed(0)} Kbit/s` + : 'N/A'} + +
+ {/* Audio Bitrate */} +
+
+
+ AUDIO +
+ + {telemetryData[user.id] + ? `${telemetryData[user.id].audioBitrate.toFixed(0)} Kbit/s` + : 'N/A'} + +
+
-
- {/* Values with smooth transitions */} -
- - {telemetryData[user.id] - ? `${telemetryData[user.id].videoBitrate.toFixed(0)} Kbit/s` - : 'N/A'} - - - {telemetryData[user.id] - ? `${telemetryData[user.id].audioBitrate.toFixed(0)} Kbit/s` - : 'N/A'} - - - {!isSelf(user.id) && telemetryData[user.id] - ? `${telemetryData[user.id].latency}ms` - : 'N/A'} - + {/* Latency Section */} +
+

Latency

+
+ {/* Video Latency */} +
+
+
+ VIDEO +
+ + {!isSelf(user.id) && + telemetryData[user.id] && + (userSubscriptions[user.id]?.videoSubscribed || + userSubscriptions[user.id]?.videoHDSubscribed) + ? `${telemetryData[user.id].videoLatency}ms` + : 'N/A'} + +
+ {/* Audio Latency */} +
+
+
+ AUDIO +
+ + {!isSelf(user.id) && + telemetryData[user.id] && + userSubscriptions[user.id]?.audioSubscribed + ? `${telemetryData[user.id].audioLatency}ms` + : 'N/A'} + +
+ {/* Screenshare Latency */} +
+
+
+ SCREEN +
+ + {!isSelf(user.id) && + telemetryData[user.id] && + userSubscriptions[user.id]?.screenshareSubscribed + ? `${telemetryData[user.id].screenshareLatency}ms` + : 'N/A'} + +
+
+
{/* Network Stats Graph */} @@ -3311,13 +3369,13 @@ function SessionPage() { {/* Graph container */}
{/* Left Y-axis labels (Bitrate) */} -
500K
-
250K
+
1500K
+
750K
0
{/* Right Y-axis labels (Latency) */} -
200ms
-
100ms
+
600ms
+
300ms
0ms
{/* Grid lines */} @@ -3342,7 +3400,7 @@ function SessionPage() { .map((videoBitrate, index) => { const x = (index / Math.max(videoBitrateHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((videoBitrate / 500) * 100, 100) + const y = 100 - Math.min((videoBitrate / 1500) * 100, 100) return `${x},${y}` }) .join(' ') @@ -3367,7 +3425,7 @@ function SessionPage() { .map((audioBitrate, index) => { const x = (index / Math.max(audioBitrateHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((audioBitrate / 500) * 100, 100) + const y = 100 - Math.min((audioBitrate / 1500) * 100, 100) return `${x},${y}` }) .join(' ') @@ -3377,7 +3435,7 @@ function SessionPage() {
- {/* Latency line */} + {/* Video Latency line */}
0 - ? latencyHistory[user.id] + !isSelf(user.id) && + videoLatencyHistory[user.id] && + videoLatencyHistory[user.id].length > 0 + ? videoLatencyHistory[user.id] .map((latency: number, index: number) => { - const x = (index / Math.max(latencyHistory[user.id].length - 1, 1)) * 300 - const y = 100 - Math.min((latency / 200) * 100, 100) + const x = + (index / Math.max(videoLatencyHistory[user.id].length - 1, 1)) * 300 + const y = 100 - Math.min((latency / 600) * 100, 100) return `${x},${y}` }) .join(' ') - : isSelf(user.id) - ? '' // No line for self user - : '' + : '' + } + /> + +
+ + {/* Audio Latency line */} +
+ + 0 + ? audioLatencyHistory[user.id] + .map((latency: number, index: number) => { + const x = + (index / Math.max(audioLatencyHistory[user.id].length - 1, 1)) * 300 + const y = 100 - Math.min((latency / 600) * 100, 100) + return `${x},${y}` + }) + .join(' ') + : '' + } + /> + +
+ + {/* Screenshare Latency line */} +
+ + 0 + ? screenshareLatencyHistory[user.id] + .map((latency: number, index: number) => { + const x = + (index / Math.max(screenshareLatencyHistory[user.id].length - 1, 1)) * + 300 + const y = 100 - Math.min((latency / 600) * 100, 100) + return `${x},${y}` + }) + .join(' ') + : '' } /> @@ -3720,6 +3834,7 @@ function SessionPage() { )} + {/* End Call Button */} diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index d82725d..e62a69b 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -1,15 +1,17 @@ import React, { useState, useEffect } from 'react' import { telemetryDB, TelemetryEntry, UserInfo } from '@/util/telemetryDB' import InfoTooltip from '@/components/InfoTooltip' +import { TelemetryStreamType } from '@/util/telemetryDB' const Telemetry: React.FC = () => { const [sessions, setSessions] = useState([]) const [users, setUsers] = useState([]) const [selectedSession, setSelectedSession] = useState('') const [selectedUser, setSelectedUser] = useState('') - const [selectedStreamType, setSelectedStreamType] = useState<'video' | 'audio' | 'latency' | ''>('') + const [selectedStreamType, setSelectedStreamType] = useState('') const [data, setData] = useState([]) const [loading, setLoading] = useState(false) + const [refreshInterval, setRefreshInterval] = useState(1000) useEffect(() => { loadSessions() @@ -28,6 +30,16 @@ const Telemetry: React.FC = () => { loadData() }, [selectedSession, selectedUser, selectedStreamType]) + useEffect(() => { + if (!selectedSession || refreshInterval === null) return + + const interval = setInterval(() => { + loadData() + }, refreshInterval) + + return () => clearInterval(interval) + }, [selectedSession, selectedUser, selectedStreamType, refreshInterval]) + const loadSessions = async () => { try { const sessionList = await telemetryDB.getSessions() @@ -79,17 +91,17 @@ const Telemetry: React.FC = () => { return userName.length > 20 ? userName.substring(0, 17) + '...' : userName } - const getDataForStreamType = (streamType: 'video' | 'audio' | 'latency') => { + const getDataForStreamType = (streamType: TelemetryStreamType) => { return data.filter((entry) => entry.streamType === streamType) } - const getMaxValue = (streamType: 'video' | 'audio' | 'latency') => { + const getMaxValue = (streamType: TelemetryStreamType) => { const streamData = getDataForStreamType(streamType) if (streamData.length === 0) return 0 return Math.max(...streamData.map((d) => d.value)) } - const renderChart = (streamType: 'video' | 'audio' | 'latency', color: string, maxValue: number) => { + const renderChart = (streamType: TelemetryStreamType, color: string, maxValue: number) => { const streamData = getDataForStreamType(streamType) if (streamData.length === 0) return null @@ -117,29 +129,48 @@ const Telemetry: React.FC = () => { ) } - const getStreamTypeColor = (type: 'video' | 'audio' | 'latency') => { + const getStreamTypeColor = (type: TelemetryStreamType) => { switch (type) { - case 'video': + case 'videoBitrate': return '#3b82f6' - case 'audio': + case 'audioBitrate': return '#6b7280' - case 'latency': + case 'screenshareBitrate': + return '#8b5cf6' + case 'videoLatency': return '#ef4444' + case 'audioLatency': + return '#f97316' + case 'screenshareLatency': + return '#ec4892' } } - const getStreamTypeLabel = (type: 'video' | 'audio' | 'latency') => { + const getStreamTypeLabel = (type: TelemetryStreamType) => { switch (type) { - case 'video': + case 'videoBitrate': return 'Video Bitrate (Kbit/s)' - case 'audio': + case 'audioBitrate': return 'Audio Bitrate (Kbit/s)' - case 'latency': - return 'Latency (ms)' + case 'screenshareBitrate': + return 'Screenshare Bitrate (Kbit/s)' + case 'videoLatency': + return 'Video Latency (ms)' + case 'audioLatency': + return 'Audio Latency (ms)' + case 'screenshareLatency': + return 'Screenshare Latency (ms)' } } - const streamTypes: ('video' | 'audio' | 'latency')[] = ['video', 'audio', 'latency'] + const streamTypes: TelemetryStreamType[] = [ + 'videoBitrate', + 'audioBitrate', + 'screenshareBitrate', + 'videoLatency', + 'audioLatency', + 'screenshareLatency', + ] const activeStreamTypes = selectedStreamType ? [selectedStreamType] : streamTypes return ( @@ -168,7 +199,7 @@ const Telemetry: React.FC = () => { {/* Filters */}
-
+
+
+ +
+ +
@@ -223,7 +271,7 @@ const Telemetry: React.FC = () => {
{activeStreamTypes.map((streamType) => { const streamData = getDataForStreamType(streamType) - const maxValue = getMaxValue(streamType) || (streamType === 'latency' ? 200 : 500) + const maxValue = getMaxValue(streamType) || (streamType.includes('latency') ? 200 : 500) const color = getStreamTypeColor(streamType) return ( @@ -245,7 +293,7 @@ const Telemetry: React.FC = () => { {streamData[streamData.length - 1]?.value.toFixed(1) || 'N/A'} - {streamType === 'latency' ? 'ms' : 'Kbit/s'} + {streamType.includes('latency') ? 'ms' : 'Kbit/s'}
{/* Chart */} diff --git a/apps/client/src/util/telemetryDB.ts b/apps/client/src/util/telemetryDB.ts index ee60459..ac7251f 100644 --- a/apps/client/src/util/telemetryDB.ts +++ b/apps/client/src/util/telemetryDB.ts @@ -1,9 +1,22 @@ +export type TelemetryStreamType = + | 'videoBitrate' + | 'audioBitrate' + | 'screenshareBitrate' + | 'videoLatency' + | 'audioLatency' + | 'screenshareLatency' export interface TelemetryEntry { id?: number sessionId: string userId: string userName?: string - streamType: 'video' | 'audio' | 'latency' + streamType: + | 'videoBitrate' + | 'audioBitrate' + | 'screenshareBitrate' + | 'videoLatency' + | 'audioLatency' + | 'screenshareLatency' timestamp: number value: number } @@ -67,7 +80,7 @@ class TelemetryDB { async getEntries( sessionId?: string, userId?: string, - streamType?: 'video' | 'audio' | 'latency', + streamType?: TelemetryStreamType, limit?: number, ): Promise { const db = await this.openDB() From 8cb06947cf107fb826eba2ff617104050bfc5899 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:37:18 +0300 Subject: [PATCH 4/9] improved charts --- apps/client/package.json | 2 + apps/client/src/components/TelemetryChart.tsx | 117 +++ apps/client/src/pages/Telemetry.tsx | 89 ++- package-lock.json | 720 ++++++++++++++++++ 4 files changed, 883 insertions(+), 45 deletions(-) create mode 100644 apps/client/src/components/TelemetryChart.tsx diff --git a/apps/client/package.json b/apps/client/package.json index c2c9577..47a155e 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -12,6 +12,8 @@ "test": "vitest" }, "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0", "lucide-react": "^0.344.0", "moqtail-ts": "^0.3.2", "posthog-js": "^1.255.1", diff --git a/apps/client/src/components/TelemetryChart.tsx b/apps/client/src/components/TelemetryChart.tsx new file mode 100644 index 0000000..b2bc6a3 --- /dev/null +++ b/apps/client/src/components/TelemetryChart.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useRef } from 'react' +import * as d3 from 'd3' +import { TelemetryEntry, TelemetryStreamType } from '@/util/telemetryDB' + +interface TelemetryChartProps { + data: TelemetryEntry[] + streamType: TelemetryStreamType + color: string + maxValue: number + timeWindow: number | null + label: string +} + +const TelemetryChart: React.FC = ({ data, streamType, color, maxValue, timeWindow, label }) => { + const svgRef = useRef(null) + const containerRef = useRef(null) + + useEffect(() => { + if (!svgRef.current || data.length === 0) return + + const now = Date.now() + const margin = { top: 20, right: 20, bottom: 30, left: 50 } + const width = containerRef.current?.clientWidth ?? 800 + const height = 250 + + const innerWidth = width - margin.left - margin.right + const innerHeight = height - margin.top - margin.bottom + + // Determine time domain based on timeWindow + const xDomain = + timeWindow === null + ? [data[0]?.timestamp || now - 60000, data[data.length - 1]?.timestamp || now] + : [now - timeWindow, now] + + // Create scales + const xScale = d3.scaleTime().domain(xDomain).range([0, innerWidth]) + + const yScale = d3.scaleLinear().domain([0, maxValue]).range([innerHeight, 0]) + + // Create line generator + const line = d3 + .line() + .x((d) => xScale(d.timestamp)) + .y((d) => yScale(d.value)) + + // Clear previous content + const svg = d3.select(svgRef.current) + svg.selectAll('*').remove() + + // Create main group + const g = svg + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', `translate(${margin.left},${margin.top})`) + + // Add background grid + g.append('g') + .attr('class', 'grid') + .attr('opacity', 0.1) + .call( + d3 + .axisLeft(yScale) + .tickSize(-innerWidth) + .tickFormat(() => ''), + ) + + // Add path + g.append('path') + .attr('d', line(data)) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', 2.5) + .attr('stroke-linecap', 'round') + .attr('stroke-linejoin', 'round') + + // Add X axis + g.append('g') + .attr('transform', `translate(0,${innerHeight})`) + .call( + d3 + .axisBottom(xScale) + .ticks(4) + .tickFormat((d) => { + const date = d as Date + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) + }), + ) + .attr('class', 'text-xs text-gray-500') + + // Add Y axis + g.append('g').call(d3.axisLeft(yScale).ticks(5)).attr('class', 'text-xs text-gray-500') + + // Add Y axis label + g.append('text') + .attr('transform', 'rotate(-90)') + .attr('y', 0 - margin.left) + .attr('x', 0 - innerHeight / 2) + .attr('dy', '1em') + .attr('class', 'text-xs text-gray-500') + .style('text-anchor', 'middle') + .text(label.split('(')[1]?.replace(')', '').trim() || '') + + // Style axes + svg.selectAll('.tick line').attr('stroke', '#d1d5db').attr('stroke-width', 0.5) + svg.selectAll('.tick text').attr('fill', '#6b7280') + svg.selectAll('.domain').attr('stroke', '#d1d5db').attr('stroke-width', 1) + }, [data, color, maxValue, timeWindow, label]) + + return ( +
+ +
+ ) +} + +export default TelemetryChart diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index e62a69b..564b074 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { telemetryDB, TelemetryEntry, UserInfo } from '@/util/telemetryDB' import InfoTooltip from '@/components/InfoTooltip' +import TelemetryChart from '@/components/TelemetryChart' import { TelemetryStreamType } from '@/util/telemetryDB' const Telemetry: React.FC = () => { @@ -12,6 +13,7 @@ const Telemetry: React.FC = () => { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [refreshInterval, setRefreshInterval] = useState(1000) + const [timeWindow, setTimeWindow] = useState(30000) // in milliseconds, null for full session useEffect(() => { loadSessions() @@ -95,38 +97,26 @@ const Telemetry: React.FC = () => { return data.filter((entry) => entry.streamType === streamType) } - const getMaxValue = (streamType: TelemetryStreamType) => { + const getFilteredDataByTimeWindow = (streamType: TelemetryStreamType) => { const streamData = getDataForStreamType(streamType) + if (streamData.length === 0) return [] + + // If timeWindow is null, return all data (Full Session) + if (timeWindow === null) return streamData + + const now = Date.now() + return streamData.filter((entry) => now - entry.timestamp <= timeWindow) + } + + const getMaxValue = (streamType: TelemetryStreamType) => { + const streamData = getFilteredDataByTimeWindow(streamType) if (streamData.length === 0) return 0 return Math.max(...streamData.map((d) => d.value)) } - const renderChart = (streamType: TelemetryStreamType, color: string, maxValue: number) => { - const streamData = getDataForStreamType(streamType) - if (streamData.length === 0) return null - - const points = streamData - .map((entry, index) => { - const x = (index / Math.max(streamData.length - 1, 1)) * 300 - const y = 100 - Math.min((entry.value / maxValue) * 100, 100) - return `${x},${y}` - }) - .join(' ') - - return ( -
- - - -
- ) + const formatTimeLabel = (timestamp: number) => { + const date = new Date(timestamp) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } const getStreamTypeColor = (type: TelemetryStreamType) => { @@ -264,6 +254,24 @@ const Telemetry: React.FC = () => {
+ +
+
+ + +
+
{/* Charts */} @@ -297,24 +305,15 @@ const Telemetry: React.FC = () => {
{/* Chart */} -
- {/* Y-axis labels */} -
- {maxValue.toFixed(0)} -
-
- {(maxValue / 2).toFixed(0)} -
-
0
- - {/* Grid lines */} -
- {[...Array(3)].map((_, i) => ( -
- ))} -
- - {renderChart(streamType, color, maxValue)} +
+
{/* Stats */} diff --git a/package-lock.json b/package-lock.json index 8725c57..e229912 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "apps/client": { "version": "0.4.0", "dependencies": { + "@types/d3": "^7.4.3", + "d3": "^7.9.0", "lucide-react": "^0.344.0", "moqtail-ts": "^0.3.2", "posthog-js": "^1.255.1", @@ -1879,6 +1881,259 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1893,6 +2148,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -2933,6 +3194,417 @@ "dev": true, "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2968,6 +3640,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3993,6 +4674,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4037,6 +4730,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -5317,6 +6019,12 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.52.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz", @@ -5388,6 +6096,18 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", From 29ba7cb2918aa43caee266411b9e8dacf72dfaa9 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:24:24 +0300 Subject: [PATCH 5/9] added new metrics to telemetry page --- apps/client/src/pages/SessionPage.tsx | 42 +++--- apps/client/src/pages/Telemetry.tsx | 126 ++++++++++++------ apps/client/src/util/telemetryDB.ts | 42 +++--- apps/client/src/util/testMetrics.ts | 178 ++++++++++++++++++-------- 4 files changed, 263 insertions(+), 125 deletions(-) diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index ac62563..ea461b4 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -75,7 +75,7 @@ import { NetworkTelemetry, ClockNormalizer } from 'moqtail-ts/util' import { RewindPlayer } from './RewindPlayer' import { BufferedMoqtObject } from '@/composables/rewindBuffer' import { TestMetricsManager } from '@/util/testMetrics' -import { telemetryDB } from '@/util/telemetryDB' +import { telemetryDB, TelemetryStreamType } from '@/util/telemetryDB' function SessionPage() { // initialize the MOQTail client @@ -364,7 +364,7 @@ function SessionPage() { // Track rewind button pressed const user = users[userId] if (user) { - testMetricsManagerRef.current.trackRewindButtonPressed(userId) + testMetricsManagerRef.current.trackRewindButtonPressed(sessionId.current, userId) } setIsFetching(true) @@ -756,7 +756,7 @@ function SessionPage() { // Track quality toggle start const direction = currentQuality === 'HD' && newQuality === 'SD' ? 'HD->SD' : 'SD->HD' - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, direction) + testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, direction) try { if (currentQuality === 'HD' && newQuality === 'SD') { @@ -1435,7 +1435,7 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') + testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') // First, set the quality to HD so the track-updated handler will auto-subscribe setUserVideoQualities((prev) => { @@ -1478,7 +1478,7 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(targetUserId, 'SD->HD') + testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') manualQualityTransitionsRef.current.add(targetUserId) @@ -1770,7 +1770,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'videoLatency', + streamType: TelemetryStreamType.VideoLatency, timestamp: Date.now(), value: videoLatency, }) @@ -1792,7 +1792,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'audioLatency', + streamType: TelemetryStreamType.AudioLatency, timestamp: Date.now(), value: audioLatency, }) @@ -1815,7 +1815,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'screenshareLatency', + streamType: TelemetryStreamType.ScreenshareLatency, timestamp: Date.now(), value: screenshareLatency, }) @@ -1837,7 +1837,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'videoBitrate', + streamType: TelemetryStreamType.VideoBitrate, timestamp: Date.now(), value: videoBitrate, }) @@ -1859,7 +1859,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'audioBitrate', + streamType: TelemetryStreamType.AudioBitrate, timestamp: Date.now(), value: audioBitrate, }) @@ -1880,7 +1880,7 @@ function SessionPage() { sessionId: sessionId.current, userId, userName: users[userId]?.name || userId, - streamType: 'screenshareBitrate', + streamType: TelemetryStreamType.ScreenshareBitrate, timestamp: Date.now(), value: screenshareBitrate, }) @@ -2133,7 +2133,7 @@ function SessionPage() { const onFirstFrameCallback = () => { if (!startupLatencyTracked.current) { startupLatencyTracked.current = true - testMetricsManagerRef.current.trackStartupFirstFrame('global', 'First user', true) + testMetricsManagerRef.current.trackStartupFirstFrame(sessionId.current, 'global', 'First user', true) } } @@ -2294,7 +2294,7 @@ function SessionPage() { const user = currentUsers[targetUserId] if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) + testMetricsManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) } return true @@ -2355,7 +2355,7 @@ function SessionPage() { const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(targetUserId, user.name) + testMetricsManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) } return true @@ -2697,7 +2697,7 @@ function SessionPage() { ) } else if (needsVideoSub) { // Track video resub start (when user clicks to resub) - testMetricsManagerRef.current.trackVideoResubStart(targetUserId) + testMetricsManagerRef.current.trackVideoResubStart(sessionId.current, targetUserId) const videoFullTrackName = getTrackname(roomName, targetUserId, 'video') initializeTelemetryForUser(targetUserId) @@ -2728,7 +2728,7 @@ function SessionPage() { // Track video resub complete const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackVideoResubComplete(targetUserId, user.name) + testMetricsManagerRef.current.trackVideoResubComplete(sessionId.current, targetUserId, user.name) } } else { console.error(`Video subscription failed for ${targetUserId}`) @@ -2738,7 +2738,7 @@ function SessionPage() { } } else if (needsAudioSub) { // Track audio resub start (when user clicks to resub) - testMetricsManagerRef.current.trackAudioResubStart(targetUserId) + testMetricsManagerRef.current.trackAudioResubStart(sessionId.current, targetUserId) const audioFullTrackName = getTrackname(roomName, targetUserId, 'audio') initializeTelemetryForUser(targetUserId) @@ -2766,7 +2766,7 @@ function SessionPage() { // Track audio resub complete const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackAudioResubComplete(targetUserId, user.name) + testMetricsManagerRef.current.trackAudioResubComplete(sessionId.current, targetUserId, user.name) } } else { console.error(`Audio subscription failed for ${targetUserId}`) @@ -3865,7 +3865,11 @@ function SessionPage() { onPlaybackStarted={() => { const user = users[selectedRewindUserId] if (user) { - testMetricsManagerRef.current.trackRewindPlaybackStarted(selectedRewindUserId, user.name) + testMetricsManagerRef.current.trackRewindPlaybackStarted( + sessionId.current, + selectedRewindUserId, + user.name, + ) } }} /> diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index 564b074..8595201 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -12,8 +12,8 @@ const Telemetry: React.FC = () => { const [selectedStreamType, setSelectedStreamType] = useState('') const [data, setData] = useState([]) const [loading, setLoading] = useState(false) - const [refreshInterval, setRefreshInterval] = useState(1000) - const [timeWindow, setTimeWindow] = useState(30000) // in milliseconds, null for full session + const [refreshInterval, setRefreshInterval] = useState(null) + const [timeWindow, setTimeWindow] = useState(null) // in milliseconds, null for full session useEffect(() => { loadSessions() @@ -114,52 +114,100 @@ const Telemetry: React.FC = () => { return Math.max(...streamData.map((d) => d.value)) } - const formatTimeLabel = (timestamp: number) => { - const date = new Date(timestamp) - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) - } - const getStreamTypeColor = (type: TelemetryStreamType) => { switch (type) { - case 'videoBitrate': + case TelemetryStreamType.VideoBitrate: return '#3b82f6' - case 'audioBitrate': + case TelemetryStreamType.AudioBitrate: return '#6b7280' - case 'screenshareBitrate': + case TelemetryStreamType.ScreenshareBitrate: return '#8b5cf6' - case 'videoLatency': + case TelemetryStreamType.VideoLatency: return '#ef4444' - case 'audioLatency': + case TelemetryStreamType.AudioLatency: return '#f97316' - case 'screenshareLatency': + case TelemetryStreamType.ScreenshareLatency: return '#ec4892' + case TelemetryStreamType.StartupLatency: + return '#10b981' + case TelemetryStreamType.HdToSdToggleLatency: + return '#14b8a6' + case TelemetryStreamType.SdToHdToggleLatency: + return '#0ea5e9' + case TelemetryStreamType.RewindLatency: + return '#f59e0b' + case TelemetryStreamType.AudioResubLatency: + return '#6366f1' + case TelemetryStreamType.VideoResubLatency: + return '#f43f5e' + default: + return '#cccccc' } } const getStreamTypeLabel = (type: TelemetryStreamType) => { switch (type) { - case 'videoBitrate': - return 'Video Bitrate (Kbit/s)' - case 'audioBitrate': - return 'Audio Bitrate (Kbit/s)' - case 'screenshareBitrate': - return 'Screenshare Bitrate (Kbit/s)' - case 'videoLatency': - return 'Video Latency (ms)' - case 'audioLatency': - return 'Audio Latency (ms)' - case 'screenshareLatency': - return 'Screenshare Latency (ms)' + case TelemetryStreamType.VideoBitrate: + return `Video Bitrate (${getStreamTypeUnit(type)})` + case TelemetryStreamType.AudioBitrate: + return `Audio Bitrate (${getStreamTypeUnit(type)})` + case TelemetryStreamType.ScreenshareBitrate: + return `Screenshare Bitrate (${getStreamTypeUnit(type)})` + case TelemetryStreamType.VideoLatency: + return `Video Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.AudioLatency: + return `Audio Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.ScreenshareLatency: + return `Screenshare Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.StartupLatency: + return `Startup Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.HdToSdToggleLatency: + return `HD to SD Toggle Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.SdToHdToggleLatency: + return `SD to HD Toggle Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.RewindLatency: + return `Rewind Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.AudioResubLatency: + return `Audio Resubscription Latency (${getStreamTypeUnit(type)})` + case TelemetryStreamType.VideoResubLatency: + return `Video Resubscription Latency (${getStreamTypeUnit(type)})` + } + } + + const getStreamTypeUnit = (type: TelemetryStreamType): string => { + switch (type) { + case TelemetryStreamType.VideoBitrate: + case TelemetryStreamType.AudioBitrate: + case TelemetryStreamType.ScreenshareBitrate: + return 'Kbit/s' + case TelemetryStreamType.VideoLatency: + case TelemetryStreamType.AudioLatency: + case TelemetryStreamType.ScreenshareLatency: + case TelemetryStreamType.StartupLatency: + case TelemetryStreamType.HdToSdToggleLatency: + case TelemetryStreamType.SdToHdToggleLatency: + case TelemetryStreamType.RewindLatency: + case TelemetryStreamType.AudioResubLatency: + case TelemetryStreamType.VideoResubLatency: + return 'ms' + default: + return '' } } const streamTypes: TelemetryStreamType[] = [ - 'videoBitrate', - 'audioBitrate', - 'screenshareBitrate', - 'videoLatency', - 'audioLatency', - 'screenshareLatency', + TelemetryStreamType.VideoBitrate, + TelemetryStreamType.AudioBitrate, + TelemetryStreamType.ScreenshareBitrate, + TelemetryStreamType.VideoLatency, + TelemetryStreamType.AudioLatency, + TelemetryStreamType.ScreenshareLatency, + TelemetryStreamType.StartupLatency, + TelemetryStreamType.HdToSdToggleLatency, + TelemetryStreamType.SdToHdToggleLatency, + TelemetryStreamType.RewindLatency, + TelemetryStreamType.AudioResubLatency, + TelemetryStreamType.VideoResubLatency, ] const activeStreamTypes = selectedStreamType ? [selectedStreamType] : streamTypes @@ -231,12 +279,11 @@ const Telemetry: React.FC = () => { className="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white" > - - - - - - + {streamTypes.map((type) => ( + + ))}
@@ -279,7 +326,8 @@ const Telemetry: React.FC = () => {
{activeStreamTypes.map((streamType) => { const streamData = getDataForStreamType(streamType) - const maxValue = getMaxValue(streamType) || (streamType.includes('latency') ? 200 : 500) + const unit = getStreamTypeUnit(streamType) + const maxValue = getMaxValue(streamType) || (unit === 'ms' ? 200 : 500) const color = getStreamTypeColor(streamType) return ( @@ -301,7 +349,7 @@ const Telemetry: React.FC = () => { {streamData[streamData.length - 1]?.value.toFixed(1) || 'N/A'} - {streamType.includes('latency') ? 'ms' : 'Kbit/s'} + {unit}
{/* Chart */} diff --git a/apps/client/src/util/telemetryDB.ts b/apps/client/src/util/telemetryDB.ts index ac7251f..e33d842 100644 --- a/apps/client/src/util/telemetryDB.ts +++ b/apps/client/src/util/telemetryDB.ts @@ -1,22 +1,24 @@ -export type TelemetryStreamType = - | 'videoBitrate' - | 'audioBitrate' - | 'screenshareBitrate' - | 'videoLatency' - | 'audioLatency' - | 'screenshareLatency' +export enum TelemetryStreamType { + VideoBitrate = 'videoBitrate', + AudioBitrate = 'audioBitrate', + ScreenshareBitrate = 'screenshareBitrate', + VideoLatency = 'videoLatency', + AudioLatency = 'audioLatency', + ScreenshareLatency = 'screenshareLatency', + StartupLatency = 'startupLatency', + HdToSdToggleLatency = 'hdToSdToggleLatency', + SdToHdToggleLatency = 'sdToHdToggleLatency', + RewindLatency = 'rewindLatency', + AudioResubLatency = 'audioResubLatency', + VideoResubLatency = 'videoResubLatency', +} + export interface TelemetryEntry { id?: number sessionId: string userId: string userName?: string - streamType: - | 'videoBitrate' - | 'audioBitrate' - | 'screenshareBitrate' - | 'videoLatency' - | 'audioLatency' - | 'screenshareLatency' + streamType: TelemetryStreamType timestamp: number value: number } @@ -29,6 +31,16 @@ export interface UserInfo { class TelemetryDB { private db: IDBDatabase | null = null private dbPromise: Promise | null = null + private static instance: TelemetryDB + + public static createInstance(): TelemetryDB { + if (!TelemetryDB.instance) { + TelemetryDB.instance = new TelemetryDB() + } + return TelemetryDB.instance + } + + private constructor() {} private async openDB(): Promise { if (this.db) return this.db @@ -218,4 +230,4 @@ class TelemetryDB { } } -export const telemetryDB = new TelemetryDB() +export const telemetryDB = TelemetryDB.createInstance() diff --git a/apps/client/src/util/testMetrics.ts b/apps/client/src/util/testMetrics.ts index 444530c..1f6a140 100644 --- a/apps/client/src/util/testMetrics.ts +++ b/apps/client/src/util/testMetrics.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import { telemetryDB, TelemetryStreamType } from '@/util/telemetryDB' + export interface StartupLatencyMetric { type: 'startup-latency' - participantId: string - participantName: string + userId: string + userName: string startTime: number endTime: number duration: number @@ -27,8 +29,8 @@ export interface StartupLatencyMetric { export interface QualityToggleMetric { type: 'quality-toggle' - participantId: string - participantName: string + userId: string + userName: string direction: 'HD->SD' | 'SD->HD' startTime: number endTime: number @@ -38,8 +40,8 @@ export interface QualityToggleMetric { export interface PeriodicMonitoringMetric { type: 'periodic-monitoring' - participantId: string - participantName: string + userId: string + userName: string wallClock: number latency: number quality: 'SD' | 'HD' @@ -50,8 +52,8 @@ export interface PeriodicMonitoringMetric { export interface RewindMetric { type: 'rewind' - participantId: string - participantName: string + userId: string + userName: string action: 'rewind-button-pressed' | 'rewind-playback-started' | 'rewind-closed' | 'first-frame-after-rewind' time: number timestamp: string @@ -60,8 +62,8 @@ export interface RewindMetric { export interface SubscriptionMetric { type: 'subscription' - participantId: string - participantName: string + userId: string + userName: string action: 'unsub-video' | 'resub-video' | 'unsub-audio' | 'resub-audio' startTime: number endTime?: number @@ -85,14 +87,14 @@ export class TestMetricsManager { constructor() {} - trackStartupJoinButtonClick(participantId: string) { - this.startupTrackingMap.set(participantId, Date.now()) + trackStartupJoinButtonClick(userId: string) { + this.startupTrackingMap.set(userId, Date.now()) } - trackStartupFirstFrame(participantId: string, participantName: string, hasCam: boolean) { - const startTime = this.startupTrackingMap.get(participantId) + trackStartupFirstFrame(sessionId: string, userId: string, userName: string, hasCam: boolean) { + const startTime = this.startupTrackingMap.get(userId) if (!startTime) { - console.log(`[Startup] No start time found for participant: ${participantId}`) + console.log(`[Startup] No start time found for participant: ${userId}`) return } @@ -101,8 +103,8 @@ export class TestMetricsManager { const metric: StartupLatencyMetric = { type: 'startup-latency', - participantId, - participantName, + userId, + userName, startTime, endTime, duration, @@ -110,18 +112,27 @@ export class TestMetricsManager { notes: hasCam ? `First frame rendered` : 'N/A - Camera off', } + telemetryDB.addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.StartupLatency, + timestamp: Date.now(), + value: duration, + }) + console.log('StartupLatency: ', metric.duration, 'ms') - this.startupTrackingMap.delete(participantId) + this.startupTrackingMap.delete(userId) } // ===== Test 2: Quality Toggle ===== - trackQualityToggleStart(participantId: string, direction: 'HD->SD' | 'SD->HD') { + trackQualityToggleStart(_sessionId: string, userId: string, direction: 'HD->SD' | 'SD->HD') { const startTime = Date.now() - this.qualityToggleTrackingMap.set(participantId, { startTime, direction }) + this.qualityToggleTrackingMap.set(userId, { startTime, direction }) } - trackQualityToggleComplete(participantId: string, participantName: string) { - const tracking = this.qualityToggleTrackingMap.get(participantId) + trackQualityToggleComplete(sessionId: string, userId: string, userName: string) { + const tracking = this.qualityToggleTrackingMap.get(userId) if (!tracking) { return } @@ -131,8 +142,8 @@ export class TestMetricsManager { const metric: QualityToggleMetric = { type: 'quality-toggle', - participantId, - participantName, + userId, + userName, direction: tracking.direction, startTime: tracking.startTime, endTime, @@ -146,25 +157,37 @@ export class TestMetricsManager { if (tracking.direction === 'SD->HD') { console.log('SD->HD Toggle: ', metric.duration, 'ms') } - this.qualityToggleTrackingMap.delete(participantId) + this.qualityToggleTrackingMap.delete(userId) + + telemetryDB.addEntry({ + sessionId, + userId, + userName, + streamType: + tracking.direction === 'HD->SD' + ? TelemetryStreamType.HdToSdToggleLatency + : TelemetryStreamType.SdToHdToggleLatency, + timestamp: Date.now(), + value: duration, + }) } // ===== Periodic Monitoring removed (not needed for the 3 requested metrics) ===== // ===== Test 3: Rewind Timing ===== - trackRewindButtonPressed(participantId: string) { + trackRewindButtonPressed(_sessionId: string, userId: string) { const time = Date.now() - this.rewindTrackingMap.set(participantId, { rewindButtonPressed: time }) + this.rewindTrackingMap.set(userId, { rewindButtonPressed: time }) } - trackRewindPlaybackStarted(participantId: string, participantName: string) { + trackRewindPlaybackStarted(sessionId: string, userId: string, userName: string) { const time = Date.now() - const tracking = this.rewindTrackingMap.get(participantId) + const tracking = this.rewindTrackingMap.get(userId) const metric: RewindMetric = { type: 'rewind', - participantId, - participantName, + userId, + userName, action: 'rewind-playback-started', time, timestamp: new Date().toISOString(), @@ -177,26 +200,43 @@ export class TestMetricsManager { console.log('Rewind Latency: ', metric.time - tracking?.rewindButtonPressed!, 'ms') if (tracking) { - this.rewindTrackingMap.set(participantId, { ...tracking, rewindPlaybackStarted: time }) + this.rewindTrackingMap.set(userId, { ...tracking, rewindPlaybackStarted: time }) + + // add to telemetryDB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.RewindLatency, + timestamp: Date.now(), + value: time - tracking.rewindButtonPressed!, + }) + .then(() => { + console.log('Rewind Latency metric saved to telemetryDB') + }) + .catch((error: any) => { + console.error('Error saving Rewind Latency metric to telemetryDB:', error) + }) } } - trackVideoResubStart(participantId: string) { + trackVideoResubStart(_sessionId: string, userId: string) { const time = Date.now() - const tracking = this.subscriptionTrackingMap.get(participantId) || {} - this.subscriptionTrackingMap.set(participantId, { ...tracking, videoResub: time }) + const tracking = this.subscriptionTrackingMap.get(userId) || {} + this.subscriptionTrackingMap.set(userId, { ...tracking, videoResub: time }) } - trackVideoResubComplete(participantId: string, participantName: string) { - const tracking = this.subscriptionTrackingMap.get(participantId) + trackVideoResubComplete(sessionId: string, userId: string, userName: string) { + const tracking = this.subscriptionTrackingMap.get(userId) const startTime = tracking?.videoResub const endTime = Date.now() if (startTime) { const metric: SubscriptionMetric = { type: 'subscription', - participantId, - participantName, + userId, + userName, action: 'resub-video', startTime, endTime, @@ -207,28 +247,45 @@ export class TestMetricsManager { this.metrics.push(metric) const { videoResub, ...rest } = tracking - this.subscriptionTrackingMap.set(participantId, rest) - - console.log(`Video Resubcription Latency for ${participantName}: ${metric.duration}ms`) + this.subscriptionTrackingMap.set(userId, rest) + + // add to telemetryDB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.VideoResubLatency, + timestamp: Date.now(), + value: metric.duration!, + }) + .then(() => { + console.log('Video Resub Latency metric saved to telemetryDB') + }) + .catch((error: any) => { + console.error('Error saving Video Resub Latency metric to telemetryDB:', error) + }) + + console.log(`Video Resubcription Latency for ${userName}: ${metric.duration}ms`) } } - trackAudioResubStart(participantId: string) { + trackAudioResubStart(_sessionId: string, userId: string) { const time = Date.now() - const tracking = this.subscriptionTrackingMap.get(participantId) || {} - this.subscriptionTrackingMap.set(participantId, { ...tracking, audioResub: time }) + const tracking = this.subscriptionTrackingMap.get(userId) || {} + this.subscriptionTrackingMap.set(userId, { ...tracking, audioResub: time }) } - trackAudioResubComplete(participantId: string, participantName: string) { - const tracking = this.subscriptionTrackingMap.get(participantId) + trackAudioResubComplete(sessionId: string, userId: string, userName: string) { + const tracking = this.subscriptionTrackingMap.get(userId) const startTime = tracking?.audioResub const endTime = Date.now() if (startTime) { const metric: SubscriptionMetric = { type: 'subscription', - participantId, - participantName, + userId, + userName, action: 'resub-audio', startTime, endTime, @@ -240,9 +297,26 @@ export class TestMetricsManager { // Clear audio resub tracking const { audioResub, ...rest } = tracking - this.subscriptionTrackingMap.set(participantId, rest) - - console.log(`Audio Resubcription Latency for ${participantName}: ${metric.duration}ms`) + this.subscriptionTrackingMap.set(userId, rest) + + // add to telemetryDB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.AudioResubLatency, + timestamp: Date.now(), + value: metric.duration!, + }) + .then(() => { + console.log('Audio Resub Latency metric saved to telemetryDB') + }) + .catch((error: any) => { + console.error('Error saving Audio Resub Latency metric to telemetryDB:', error) + }) + + console.log(`Audio Resubcription Latency for ${userName}: ${metric.duration}ms`) } } } From 57a4a8104b4ff3c0a16e5bbbbe11faffe0c14164 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:00:19 +0300 Subject: [PATCH 6/9] added export capability --- apps/client/src/pages/SessionPage.tsx | 51 +++++++++++++++++++++-- apps/client/src/pages/Telemetry.tsx | 60 ++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index ea461b4..8791429 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -113,6 +113,7 @@ function SessionPage() { screenshareLatency: number videoBitrate: number audioBitrate: number + screenshareBitrate: number } }>({}) const telemetryInstances = useRef<{ @@ -126,7 +127,7 @@ function SessionPage() { const hh = String(now.getHours()).padStart(2, '0') const min = String(now.getMinutes()).padStart(2, '0') const ss = String(now.getSeconds()).padStart(2, '0') - return `${roomState?.name}-${username}-${yyyy}${mm}${dd}-${hh}${min}${ss}` + return `${yyyy}${mm}${dd}-${hh}${min}${ss}-${roomState?.name}-${username}` } const sessionId = useRef(generateSessionId()) const [videoLatencyHistory, setVideoLatencyHistory] = useState<{ [userId: string]: number[] }>({}) @@ -1577,6 +1578,11 @@ function SessionPage() { delete newHistory[msg.userId] return newHistory }) + setScreenshareBitrateHistory((prev) => { + const newHistory = { ...prev } + delete newHistory[msg.userId] + return newHistory + }) // Clean up user color setUserColors((prev) => { @@ -1718,6 +1724,7 @@ function SessionPage() { screenshareLatency: number videoBitrate: number audioBitrate: number + screenshareBitrate: number } }>({}) @@ -2464,11 +2471,8 @@ function SessionPage() { return } - let unsubscriptionSuccess = false - try { await client.unsubscribe(requestId) - unsubscriptionSuccess = true } catch (error) { console.error(`Failed to unsubscribe from ${targetUserId} screenshare:`, error) } @@ -3310,6 +3314,18 @@ function SessionPage() { : 'N/A'}
+ {/* Screenshare Bitrate */} +
+
+
+ SCREEN +
+ + {telemetryData[user.id] + ? `${telemetryData[user.id].screenshareBitrate.toFixed(0)} Kbit/s` + : 'N/A'} + +
@@ -3435,6 +3451,33 @@ function SessionPage() {
+ {/* Screenshare bitrate line */} +
+ + 0 + ? screenshareBitrateHistory[user.id] + .map((screenshareBitrate, index) => { + const x = + (index / Math.max(screenshareBitrateHistory[user.id].length - 1, 1)) * + 300 + const y = 100 - Math.min((screenshareBitrate / 1500) * 100, 100) + return `${x},${y}` + }) + .join(' ') + : '' + } + /> + +
+ {/* Video Latency line */}
diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index 8595201..0756f4d 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -93,6 +93,52 @@ const Telemetry: React.FC = () => { return userName.length > 20 ? userName.substring(0, 17) + '...' : userName } + const exportToCSV = async () => { + if (!selectedSession) return + + try { + const allEntries = await telemetryDB.getEntries(selectedSession) + + if (allEntries.length === 0) { + alert('No data to export for this session') + return + } + + // Create CSV header + const headers = ['Session ID', 'User ID', 'User Name', 'Stream Type', 'Unit', 'Timestamp', 'Value'] + const csvContent = [ + headers.join(','), + ...allEntries.map((entry) => + [ + entry.sessionId, + entry.userId, + entry.userName || '', + entry.streamType, + getStreamTypeUnit(entry.streamType as TelemetryStreamType), + new Date(entry.timestamp).toISOString(), + entry.value, + ] + .map((field) => `"${String(field).replace(/"/g, '""')}"`) + .join(','), + ), + ].join('\n') + + // Create blob and download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', `telemetry-${selectedSession}-${new Date().toISOString().slice(0, 10)}.csv`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (error) { + console.error('Failed to export data:', error) + alert('Failed to export data') + } + } + const getDataForStreamType = (streamType: TelemetryStreamType) => { return data.filter((entry) => entry.streamType === streamType) } @@ -302,7 +348,7 @@ const Telemetry: React.FC = () => {
-
+
+ + {/* Export Button */} + {selectedSession && ( +
+ +
+ )}
From 94848f0fe24373c58def4d2daf3a3868ac00dfd8 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:02:48 +0300 Subject: [PATCH 7/9] sort sessions in desc order --- apps/client/src/pages/Telemetry.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index 0756f4d..041db66 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -45,6 +45,8 @@ const Telemetry: React.FC = () => { const loadSessions = async () => { try { const sessionList = await telemetryDB.getSessions() + // Sort sessions by most recent first + sessionList.sort((a, b) => (a < b ? 1 : -1)) setSessions(sessionList) } catch (error) { console.error('Failed to load sessions:', error) From 09fc1ca48e5f8a0853528716d61f05533ebd0457 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:26:07 +0300 Subject: [PATCH 8/9] refactored --- apps/client/src/pages/SessionPage.tsx | 118 +++------ apps/client/src/pages/Telemetry.tsx | 18 +- apps/client/src/util/telemetryDB.ts | 16 ++ .../{testMetrics.ts => telemetryManager.ts} | 234 ++++++++---------- 4 files changed, 162 insertions(+), 224 deletions(-) rename apps/client/src/util/{testMetrics.ts => telemetryManager.ts} (65%) diff --git a/apps/client/src/pages/SessionPage.tsx b/apps/client/src/pages/SessionPage.tsx index 8791429..007f869 100644 --- a/apps/client/src/pages/SessionPage.tsx +++ b/apps/client/src/pages/SessionPage.tsx @@ -74,8 +74,7 @@ import { MOQtailClient } from 'moqtail-ts/client' import { NetworkTelemetry, ClockNormalizer } from 'moqtail-ts/util' import { RewindPlayer } from './RewindPlayer' import { BufferedMoqtObject } from '@/composables/rewindBuffer' -import { TestMetricsManager } from '@/util/testMetrics' -import { telemetryDB, TelemetryStreamType } from '@/util/telemetryDB' +import { TelemetryManager } from '@/util/telemetryManager' function SessionPage() { // initialize the MOQTail client @@ -235,7 +234,7 @@ function SessionPage() { const [pendingHDRequests, setPendingHDRequests] = useState>(new Set()) // Test Metrics Manager - const testMetricsManagerRef = useRef(new TestMetricsManager()) + const telemetryManagerRef = useRef(new TelemetryManager()) const startupLatencyTracked = useRef(false) useEffect(() => { @@ -365,7 +364,7 @@ function SessionPage() { // Track rewind button pressed const user = users[userId] if (user) { - testMetricsManagerRef.current.trackRewindButtonPressed(sessionId.current, userId) + telemetryManagerRef.current.trackRewindButtonPressed(sessionId.current, userId) } setIsFetching(true) @@ -757,7 +756,7 @@ function SessionPage() { // Track quality toggle start const direction = currentQuality === 'HD' && newQuality === 'SD' ? 'HD->SD' : 'SD->HD' - testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, direction) + telemetryManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, direction) try { if (currentQuality === 'HD' && newQuality === 'SD') { @@ -1267,7 +1266,7 @@ function SessionPage() { }) if (anyUserHasMedia) { - testMetricsManagerRef.current.trackStartupJoinButtonClick('global') + telemetryManagerRef.current.trackStartupJoinButtonClick('global') } else { console.log('Startup Latency: N/A') startupLatencyTracked.current = true @@ -1436,7 +1435,7 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') + telemetryManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') // First, set the quality to HD so the track-updated handler will auto-subscribe setUserVideoQualities((prev) => { @@ -1479,7 +1478,7 @@ function SessionPage() { return newSet }) - testMetricsManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') + telemetryManagerRef.current.trackQualityToggleStart(sessionId.current, targetUserId, 'SD->HD') manualQualityTransitionsRef.current.add(targetUserId) @@ -1771,17 +1770,6 @@ function SessionPage() { [userId]: newHistory, } }) - // Store video latency to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.VideoLatency, - timestamp: Date.now(), - value: videoLatency, - }) - .catch((err) => console.error('Failed to store video latency:', err)) // Audio Latency history (last 30 points) setAudioLatencyHistory((prevLatency) => { @@ -1793,18 +1781,6 @@ function SessionPage() { } }) - // Store audio latency to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.AudioLatency, - timestamp: Date.now(), - value: audioLatency, - }) - .catch((err) => console.error('Failed to store audio latency:', err)) - // Screenshare Latency history (last 30 points) setScreenshareLatencyHistory((prevLatency) => { const userHistory = prevLatency[userId] || [] @@ -1816,18 +1792,6 @@ function SessionPage() { }) } - // Store screenshare latency to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.ScreenshareLatency, - timestamp: Date.now(), - value: screenshareLatency, - }) - .catch((err) => console.error('Failed to store screenshare latency:', err)) - // Video bitrate history (last 30 points) setVideoBitrateHistory((prevVideoBitrate) => { const userHistory = prevVideoBitrate[userId] || [] @@ -1838,18 +1802,6 @@ function SessionPage() { } }) - // Store video bitrate to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.VideoBitrate, - timestamp: Date.now(), - value: videoBitrate, - }) - .catch((err) => console.error('Failed to store video bitrate:', err)) - // Audio bitrate history (last 30 points) setAudioBitrateHistory((prevAudioBitrate) => { const userHistory = prevAudioBitrate[userId] || [] @@ -1860,18 +1812,6 @@ function SessionPage() { } }) - // Store audio bitrate to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.AudioBitrate, - timestamp: Date.now(), - value: audioBitrate, - }) - .catch((err) => console.error('Failed to store audio bitrate:', err)) - setScreenshareBitrateHistory((prevScreenshareBitrate) => { const userHistory = prevScreenshareBitrate[userId] || [] const newHistory = [...userHistory, screenshareBitrate].slice(-30) @@ -1881,17 +1821,19 @@ function SessionPage() { } }) - // Store screenshare bitrate to DB - telemetryDB - .addEntry({ - sessionId: sessionId.current, - userId, - userName: users[userId]?.name || userId, - streamType: TelemetryStreamType.ScreenshareBitrate, - timestamp: Date.now(), - value: screenshareBitrate, - }) - .catch((err) => console.error('Failed to store screenshare bitrate:', err)) + // Store all latency and bitrate telemetry to DB via TelemetryManager + telemetryManagerRef.current.trackLatencyAndBitrate( + sessionId.current, + userId, + users[userId]?.name || userId, + videoLatency, + audioLatency, + screenshareLatency, + videoBitrate, + audioBitrate, + screenshareBitrate, + isSelf(userId), + ) } }) @@ -2140,7 +2082,7 @@ function SessionPage() { const onFirstFrameCallback = () => { if (!startupLatencyTracked.current) { startupLatencyTracked.current = true - testMetricsManagerRef.current.trackStartupFirstFrame(sessionId.current, 'global', 'First user', true) + telemetryManagerRef.current.trackStartupFirstFrame(sessionId.current, 'global', 'First user', true) } } @@ -2301,7 +2243,7 @@ function SessionPage() { const user = currentUsers[targetUserId] if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) + telemetryManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) } return true @@ -2362,7 +2304,7 @@ function SessionPage() { const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) + telemetryManagerRef.current.trackQualityToggleComplete(sessionId.current, targetUserId, user.name) } return true @@ -2701,7 +2643,7 @@ function SessionPage() { ) } else if (needsVideoSub) { // Track video resub start (when user clicks to resub) - testMetricsManagerRef.current.trackVideoResubStart(sessionId.current, targetUserId) + telemetryManagerRef.current.trackVideoResubStart(sessionId.current, targetUserId) const videoFullTrackName = getTrackname(roomName, targetUserId, 'video') initializeTelemetryForUser(targetUserId) @@ -2732,7 +2674,7 @@ function SessionPage() { // Track video resub complete const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackVideoResubComplete(sessionId.current, targetUserId, user.name) + telemetryManagerRef.current.trackVideoResubComplete(sessionId.current, targetUserId, user.name) } } else { console.error(`Video subscription failed for ${targetUserId}`) @@ -2742,7 +2684,7 @@ function SessionPage() { } } else if (needsAudioSub) { // Track audio resub start (when user clicks to resub) - testMetricsManagerRef.current.trackAudioResubStart(sessionId.current, targetUserId) + telemetryManagerRef.current.trackAudioResubStart(sessionId.current, targetUserId) const audioFullTrackName = getTrackname(roomName, targetUserId, 'audio') initializeTelemetryForUser(targetUserId) @@ -2770,7 +2712,7 @@ function SessionPage() { // Track audio resub complete const user = usersRef.current[targetUserId] if (user) { - testMetricsManagerRef.current.trackAudioResubComplete(sessionId.current, targetUserId, user.name) + telemetryManagerRef.current.trackAudioResubComplete(sessionId.current, targetUserId, user.name) } } else { console.error(`Audio subscription failed for ${targetUserId}`) @@ -3908,11 +3850,7 @@ function SessionPage() { onPlaybackStarted={() => { const user = users[selectedRewindUserId] if (user) { - testMetricsManagerRef.current.trackRewindPlaybackStarted( - sessionId.current, - selectedRewindUserId, - user.name, - ) + telemetryManagerRef.current.trackRewindPlaybackStarted(sessionId.current, selectedRewindUserId, user.name) } }} /> diff --git a/apps/client/src/pages/Telemetry.tsx b/apps/client/src/pages/Telemetry.tsx index 041db66..92beac8 100644 --- a/apps/client/src/pages/Telemetry.tsx +++ b/apps/client/src/pages/Telemetry.tsx @@ -1,3 +1,19 @@ +/** + * Copyright 2025 The MOQtail Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { useState, useEffect } from 'react' import { telemetryDB, TelemetryEntry, UserInfo } from '@/util/telemetryDB' import InfoTooltip from '@/components/InfoTooltip' @@ -57,7 +73,7 @@ const Telemetry: React.FC = () => { try { const userList = await telemetryDB.getUsers(sessionId) // parse the userName from session id - let userIndex = 1 + let userIndex = 3 let userName = sessionId.split('-')[userIndex] let filteredUserList = userList.filter((user) => { return user.userName !== userName diff --git a/apps/client/src/util/telemetryDB.ts b/apps/client/src/util/telemetryDB.ts index e33d842..7e36830 100644 --- a/apps/client/src/util/telemetryDB.ts +++ b/apps/client/src/util/telemetryDB.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2025 The MOQtail Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export enum TelemetryStreamType { VideoBitrate = 'videoBitrate', AudioBitrate = 'audioBitrate', diff --git a/apps/client/src/util/testMetrics.ts b/apps/client/src/util/telemetryManager.ts similarity index 65% rename from apps/client/src/util/testMetrics.ts rename to apps/client/src/util/telemetryManager.ts index 1f6a140..c83689d 100644 --- a/apps/client/src/util/testMetrics.ts +++ b/apps/client/src/util/telemetryManager.ts @@ -16,70 +16,7 @@ import { telemetryDB, TelemetryStreamType } from '@/util/telemetryDB' -export interface StartupLatencyMetric { - type: 'startup-latency' - userId: string - userName: string - startTime: number - endTime: number - duration: number - timestamp: string - notes: string -} - -export interface QualityToggleMetric { - type: 'quality-toggle' - userId: string - userName: string - direction: 'HD->SD' | 'SD->HD' - startTime: number - endTime: number - duration: number - timestamp: string -} - -export interface PeriodicMonitoringMetric { - type: 'periodic-monitoring' - userId: string - userName: string - wallClock: number - latency: number - quality: 'SD' | 'HD' - videoBitrate: number - audioBitrate: number - timestamp: string -} - -export interface RewindMetric { - type: 'rewind' - userId: string - userName: string - action: 'rewind-button-pressed' | 'rewind-playback-started' | 'rewind-closed' | 'first-frame-after-rewind' - time: number - timestamp: string - notes: string -} - -export interface SubscriptionMetric { - type: 'subscription' - userId: string - userName: string - action: 'unsub-video' | 'resub-video' | 'unsub-audio' | 'resub-audio' - startTime: number - endTime?: number - duration?: number - timestamp: string -} - -export type TestMetric = - | StartupLatencyMetric - | QualityToggleMetric - | PeriodicMonitoringMetric - | RewindMetric - | SubscriptionMetric - -export class TestMetricsManager { - private metrics: TestMetric[] = [] +export class TelemetryManager { private startupTrackingMap: Map = new Map() private qualityToggleTrackingMap: MapSD' | 'SD->HD' }> = new Map() private rewindTrackingMap: Map = new Map() @@ -101,17 +38,6 @@ export class TestMetricsManager { const endTime = Date.now() const duration = endTime - startTime - const metric: StartupLatencyMetric = { - type: 'startup-latency', - userId, - userName, - startTime, - endTime, - duration, - timestamp: new Date().toISOString(), - notes: hasCam ? `First frame rendered` : 'N/A - Camera off', - } - telemetryDB.addEntry({ sessionId, userId, @@ -121,7 +47,7 @@ export class TestMetricsManager { value: duration, }) - console.log('StartupLatency: ', metric.duration, 'ms') + console.log('StartupLatency: ', duration, 'ms') this.startupTrackingMap.delete(userId) } @@ -140,22 +66,11 @@ export class TestMetricsManager { const endTime = Date.now() const duration = endTime - tracking.startTime - const metric: QualityToggleMetric = { - type: 'quality-toggle', - userId, - userName, - direction: tracking.direction, - startTime: tracking.startTime, - endTime, - duration, - timestamp: new Date().toISOString(), - } - if (tracking.direction === 'HD->SD') { - console.log('HD->SD Toggle: ', metric.duration, 'ms') + console.log('HD->SD Toggle: ', duration, 'ms') } if (tracking.direction === 'SD->HD') { - console.log('SD->HD Toggle: ', metric.duration, 'ms') + console.log('SD->HD Toggle: ', duration, 'ms') } this.qualityToggleTrackingMap.delete(userId) @@ -172,8 +87,6 @@ export class TestMetricsManager { }) } - // ===== Periodic Monitoring removed (not needed for the 3 requested metrics) ===== - // ===== Test 3: Rewind Timing ===== trackRewindButtonPressed(_sessionId: string, userId: string) { const time = Date.now() @@ -184,20 +97,8 @@ export class TestMetricsManager { const time = Date.now() const tracking = this.rewindTrackingMap.get(userId) - const metric: RewindMetric = { - type: 'rewind', - userId, - userName, - action: 'rewind-playback-started', - time, - timestamp: new Date().toISOString(), - notes: tracking?.rewindButtonPressed - ? `Duration from button press: ${time - tracking.rewindButtonPressed}ms` - : 'No button press tracked', - } - // Log Rewind Latency - console.log('Rewind Latency: ', metric.time - tracking?.rewindButtonPressed!, 'ms') + console.log('Rewind Latency: ', time - tracking?.rewindButtonPressed!, 'ms') if (tracking) { this.rewindTrackingMap.set(userId, { ...tracking, rewindPlaybackStarted: time }) @@ -233,22 +134,11 @@ export class TestMetricsManager { const endTime = Date.now() if (startTime) { - const metric: SubscriptionMetric = { - type: 'subscription', - userId, - userName, - action: 'resub-video', - startTime, - endTime, - duration: endTime - startTime, - timestamp: new Date().toISOString(), - } - - this.metrics.push(metric) - const { videoResub, ...rest } = tracking this.subscriptionTrackingMap.set(userId, rest) + const duration = endTime - startTime + // add to telemetryDB telemetryDB .addEntry({ @@ -257,7 +147,7 @@ export class TestMetricsManager { userName, streamType: TelemetryStreamType.VideoResubLatency, timestamp: Date.now(), - value: metric.duration!, + value: duration, }) .then(() => { console.log('Video Resub Latency metric saved to telemetryDB') @@ -266,7 +156,7 @@ export class TestMetricsManager { console.error('Error saving Video Resub Latency metric to telemetryDB:', error) }) - console.log(`Video Resubcription Latency for ${userName}: ${metric.duration}ms`) + console.log(`Video Resubcription Latency for ${userName}: ${duration}ms`) } } @@ -282,18 +172,7 @@ export class TestMetricsManager { const endTime = Date.now() if (startTime) { - const metric: SubscriptionMetric = { - type: 'subscription', - userId, - userName, - action: 'resub-audio', - startTime, - endTime, - duration: endTime - startTime, - timestamp: new Date().toISOString(), - } - - this.metrics.push(metric) + const duration = endTime - startTime // Clear audio resub tracking const { audioResub, ...rest } = tracking @@ -307,7 +186,7 @@ export class TestMetricsManager { userName, streamType: TelemetryStreamType.AudioResubLatency, timestamp: Date.now(), - value: metric.duration!, + value: duration!, }) .then(() => { console.log('Audio Resub Latency metric saved to telemetryDB') @@ -316,7 +195,96 @@ export class TestMetricsManager { console.error('Error saving Audio Resub Latency metric to telemetryDB:', error) }) - console.log(`Audio Resubcription Latency for ${userName}: ${metric.duration}ms`) + console.log(`Audio Resubcription Latency for ${userName}: ${duration}ms`) } } + + // ===== Latency and Bitrate Telemetry ===== + trackLatencyAndBitrate( + sessionId: string, + userId: string, + userName: string, + videoLatency: number, + audioLatency: number, + screenshareLatency: number, + videoBitrate: number, + audioBitrate: number, + screenshareBitrate: number, + isSelf: boolean, + ) { + // Only track latency for remote users (not self) + if (!isSelf) { + // Store video latency to DB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.VideoLatency, + timestamp: Date.now(), + value: videoLatency, + }) + .catch((err) => console.error('Failed to store video latency:', err)) + + // Store audio latency to DB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.AudioLatency, + timestamp: Date.now(), + value: audioLatency, + }) + .catch((err) => console.error('Failed to store audio latency:', err)) + } + + // Store screenshare latency to DB (for all users) + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.ScreenshareLatency, + timestamp: Date.now(), + value: screenshareLatency, + }) + .catch((err) => console.error('Failed to store screenshare latency:', err)) + + // Store video bitrate to DB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.VideoBitrate, + timestamp: Date.now(), + value: videoBitrate, + }) + .catch((err) => console.error('Failed to store video bitrate:', err)) + + // Store audio bitrate to DB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.AudioBitrate, + timestamp: Date.now(), + value: audioBitrate, + }) + .catch((err) => console.error('Failed to store audio bitrate:', err)) + + // Store screenshare bitrate to DB + telemetryDB + .addEntry({ + sessionId, + userId, + userName, + streamType: TelemetryStreamType.ScreenshareBitrate, + timestamp: Date.now(), + value: screenshareBitrate, + }) + .catch((err) => console.error('Failed to store screenshare bitrate:', err)) + } } From b7845e80c32556dcbac5627b21989c0dd7c48f33 Mon Sep 17 00:00:00 2001 From: Zafer Gurel <1393083+zafergurel@users.noreply.github.com> Date: Sun, 18 Jan 2026 01:27:44 +0300 Subject: [PATCH 9/9] added license text --- apps/client/src/components/InfoTooltip.tsx | 16 ++++++++++++++++ apps/client/src/components/TelemetryChart.tsx | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/apps/client/src/components/InfoTooltip.tsx b/apps/client/src/components/InfoTooltip.tsx index 884cbba..26122da 100644 --- a/apps/client/src/components/InfoTooltip.tsx +++ b/apps/client/src/components/InfoTooltip.tsx @@ -1,3 +1,19 @@ +/** + * Copyright 2025 The MOQtail Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { useState, useRef, useEffect } from 'react' interface InfoTooltipProps { diff --git a/apps/client/src/components/TelemetryChart.tsx b/apps/client/src/components/TelemetryChart.tsx index b2bc6a3..2380982 100644 --- a/apps/client/src/components/TelemetryChart.tsx +++ b/apps/client/src/components/TelemetryChart.tsx @@ -1,3 +1,19 @@ +/** + * Copyright 2025 The MOQtail Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import React, { useEffect, useRef } from 'react' import * as d3 from 'd3' import { TelemetryEntry, TelemetryStreamType } from '@/util/telemetryDB'