import type { AggregatedStatsReport, AudioAggregatedStats, BaseStats, CameraStats, ParticipantsStatsReport, RTCCodecStats, RTCMediaSourceStats, StatsReport, } from './types'; import { CallState } from '../store'; import { Publisher, Subscriber } from '../rtc'; import { flatten } from './utils'; import { TrackType } from '../gen/video/sfu/models/models'; import { isFirefox } from '../helpers/browsers'; import { videoLoggerSystem } from '../logger'; export type StatsReporterOpts = { subscriber: Subscriber; publisher?: Publisher; state: CallState; datacenter: string; pollingIntervalInMs?: number; }; type PeerConnectionKind = 'subscriber' | 'publisher'; export type StatsReporter = { /** * Will turn on stats reporting for a given sessionId. * * @param sessionId the session id. */ startReportingStatsFor: (sessionId: string) => void; /** * Will turn off stats reporting for a given sessionId. * * @param sessionId the session id. */ stopReportingStatsFor: (sessionId: string) => void; /** * Helper method for retrieving stats for a given peer connection kind * and media stream flowing through it. * * @param kind the peer connection kind (subscriber or publisher). * @param mediaStream the media stream. */ getStatsForStream: ( kind: PeerConnectionKind, tracks: MediaStreamTrack[], ) => Promise; /** * Helper method for retrieving raw stats for a given peer connection kind. * * @param kind the peer connection kind (subscriber or publisher). * @param selector the track selector. If not provided, stats for all tracks will be returned. */ getRawStatsForTrack: ( kind: PeerConnectionKind, selector?: MediaStreamTrack, ) => Promise; /** * Stops the stats reporter and releases all resources. */ stop: () => void; }; /** * Creates a new StatsReporter instance that collects metrics about the ongoing call and reports them to the state store */ export const createStatsReporter = ({ subscriber, publisher, state, datacenter, pollingIntervalInMs = 2000, }: StatsReporterOpts): StatsReporter => { const logger = videoLoggerSystem.getLogger('stats'); const getRawStatsForTrack = async ( kind: PeerConnectionKind, selector?: MediaStreamTrack, ) => { if (kind === 'subscriber' && subscriber) { return subscriber.getStats(selector); } else if (kind === 'publisher' && publisher) { return publisher.getStats(selector); } else { return undefined; } }; const getStatsForStream = async ( kind: PeerConnectionKind, tracks: MediaStreamTrack[], ) => { const pc = kind === 'subscriber' ? subscriber : publisher; if (!pc) return []; const statsForStream: StatsReport[] = []; for (const track of tracks) { const report = await pc.getStats(track); const stats = transform(report, { trackKind: track.kind as 'audio' | 'video', kind, publisher: undefined, }); statsForStream.push(stats); } return statsForStream; }; const startReportingStatsFor = (sessionId: string) => { sessionIdsToTrack.add(sessionId); void run(); }; const stopReportingStatsFor = (sessionId: string) => { sessionIdsToTrack.delete(sessionId); void run(); }; const sessionIdsToTrack = new Set(); /** * The main stats reporting loop. */ const run = async () => { const participantStats: ParticipantsStatsReport = {}; if (sessionIdsToTrack.size > 0) { const sessionIds = new Set(sessionIdsToTrack); for (const participant of state.participants) { if (!sessionIds.has(participant.sessionId)) continue; const { audioStream, isLocalParticipant, sessionId, userId, videoStream, } = participant; const kind = isLocalParticipant ? 'publisher' : 'subscriber'; try { const tracks = isLocalParticipant ? publisher?.getPublishedTracks() || [] : [ ...(videoStream?.getVideoTracks() || []), ...(audioStream?.getAudioTracks() || []), ]; participantStats[sessionId] = await getStatsForStream(kind, tracks); } catch (e) { logger.warn(`Failed to collect ${kind} stats for ${userId}`, e); } } } const [subscriberRawStats, publisherRawStats] = await Promise.all([ getRawStatsForTrack('subscriber'), publisher ? getRawStatsForTrack('publisher') : undefined, ]); const process = (report: RTCStatsReport, kind: PeerConnectionKind) => { const videoStats = aggregate( transform(report, { kind, trackKind: 'video', publisher }), ); const audioStats = aggregateAudio( transform(report, { kind, trackKind: 'audio', publisher }), ); return { videoStats, audioStats, }; }; const subscriberResult = subscriberRawStats ? process(subscriberRawStats, 'subscriber') : { videoStats: getEmptyVideoStats(), audioStats: getEmptyAudioStats() }; const publisherResult = publisherRawStats ? process(publisherRawStats, 'publisher') : { videoStats: getEmptyVideoStats(), audioStats: getEmptyAudioStats() }; state.setCallStatsReport({ datacenter, publisherStats: publisherResult.videoStats, publisherAudioStats: publisherResult.audioStats, subscriberStats: subscriberResult.videoStats, subscriberAudioStats: subscriberResult.audioStats, subscriberRawStats, publisherRawStats, participants: participantStats, timestamp: Date.now(), }); }; let timeoutId: NodeJS.Timeout | undefined; if (pollingIntervalInMs > 0) { const loop = async () => { // bail out of the loop as we don't want to collect stats // (they are expensive) if no one is listening to them if (state.isCallStatsReportObserved) { await run().catch((e) => { logger.debug('Failed to collect stats', e); }); } timeoutId = setTimeout(loop, pollingIntervalInMs); }; void loop(); } const stop = () => { if (timeoutId) { clearTimeout(timeoutId); } }; return { getRawStatsForTrack, getStatsForStream, startReportingStatsFor, stopReportingStatsFor, stop, }; }; export type StatsTransformOpts = { /** * The kind of track we are transforming stats for. */ trackKind: 'audio' | 'video'; /** * The kind of peer connection we are transforming stats for. */ kind: PeerConnectionKind; /** * The publisher instance. */ publisher: Publisher | undefined; }; /** * Extracts camera statistics from a media source. * * @param mediaSource the media source stats to extract camera info from. */ const getCameraStats = (mediaSource: RTCMediaSourceStats): CameraStats => ({ frameRate: mediaSource.framesPerSecond, frameWidth: mediaSource.width, frameHeight: mediaSource.height, }); /** * Transforms raw RTC stats into a slimmer and uniform across browsers format. * * @param report the report to transform. * @param opts the transform options. */ const transform = ( report: RTCStatsReport, opts: StatsTransformOpts, ): StatsReport => { const { trackKind, kind, publisher } = opts; const direction = kind === 'subscriber' ? 'inbound-rtp' : 'outbound-rtp'; const stats = flatten(report); const streams = stats .filter( (stat) => stat.type === direction && (stat as RTCRtpStreamStats).kind === trackKind, ) .map((stat): BaseStats => { const rtcStreamStats = stat as RTCInboundRtpStreamStats & RTCOutboundRtpStreamStats; const codec = stats.find( (s) => s.type === 'codec' && s.id === rtcStreamStats.codecId, ) as RTCCodecStats | undefined; const transport = stats.find( (s) => s.type === 'transport' && s.id === rtcStreamStats.transportId, ) as RTCTransportStats | undefined; let roundTripTime: number | undefined; if (transport && transport.dtlsState === 'connected') { const candidatePair = stats.find( (s) => s.type === 'candidate-pair' && s.id === transport.selectedCandidatePairId, ) as RTCIceCandidatePairStats | undefined; roundTripTime = candidatePair?.currentRoundTripTime; } let trackType: TrackType | undefined; let audioLevel: number | undefined; let camera: CameraStats | undefined; let concealedSamples: number | undefined; let concealmentEvents: number | undefined; let packetsReceived: number | undefined; let packetsLost: number | undefined; if (kind === 'publisher' && publisher) { const firefox = isFirefox(); const mediaSource = stats.find( (s) => s.type === 'media-source' && // Firefox doesn't have mediaSourceId, so we need to guess the media source (firefox ? true : s.id === rtcStreamStats.mediaSourceId), ) as RTCMediaSourceStats | undefined; if (mediaSource) { trackType = publisher.getTrackType(mediaSource.trackIdentifier); if ( trackKind === 'audio' && typeof mediaSource.audioLevel === 'number' ) { audioLevel = mediaSource.audioLevel; } if (trackKind === 'video') { camera = getCameraStats(mediaSource); } } } else if (kind === 'subscriber' && trackKind === 'audio') { const inboundStats = rtcStreamStats as RTCInboundRtpStreamStats; const inboundLevel = inboundStats.audioLevel; if (typeof inboundLevel === 'number') { audioLevel = inboundLevel; } concealedSamples = inboundStats.concealedSamples; concealmentEvents = inboundStats.concealmentEvents; packetsReceived = inboundStats.packetsReceived; packetsLost = inboundStats.packetsLost; } return { bytesSent: rtcStreamStats.bytesSent, bytesReceived: rtcStreamStats.bytesReceived, codec: codec?.mimeType, currentRoundTripTime: roundTripTime, frameHeight: rtcStreamStats.frameHeight, frameWidth: rtcStreamStats.frameWidth, framesPerSecond: rtcStreamStats.framesPerSecond, jitter: rtcStreamStats.jitter, kind: rtcStreamStats.kind, mediaSourceId: rtcStreamStats.mediaSourceId, qualityLimitationReason: rtcStreamStats.qualityLimitationReason, rid: rtcStreamStats.rid, ssrc: rtcStreamStats.ssrc, trackType, audioLevel, concealedSamples, concealmentEvents, packetsReceived, packetsLost, camera, }; }); return { rawStats: report, streams, timestamp: Date.now(), }; }; const getEmptyVideoStats = (stats?: StatsReport): AggregatedStatsReport => { return { rawReport: stats ?? { streams: [], timestamp: Date.now() }, totalBytesSent: 0, totalBytesReceived: 0, averageJitterInMs: 0, averageRoundTripTimeInMs: 0, qualityLimitationReasons: 'none', highestFrameWidth: 0, highestFrameHeight: 0, highestFramesPerSecond: 0, camera: {}, codec: '', codecPerTrackType: {}, timestamp: Date.now(), }; }; const getEmptyAudioStats = (): AudioAggregatedStats => { return { totalBytesSent: 0, totalBytesReceived: 0, averageJitterInMs: 0, averageRoundTripTimeInMs: 0, codec: '', codecPerTrackType: {}, timestamp: Date.now(), totalConcealedSamples: 0, totalConcealmentEvents: 0, totalPacketsReceived: 0, totalPacketsLost: 0, }; }; /** * Aggregates generic stats. * * @param stats the stats to aggregate. */ const aggregate = (stats: StatsReport): AggregatedStatsReport => { const aggregatedStats = getEmptyVideoStats(stats); let maxArea = -1; const area = (w: number, h: number) => w * h; const qualityLimitationReasons = new Set(); const streams = stats.streams; const report = streams.reduce((acc, stream) => { acc.totalBytesSent += stream.bytesSent || 0; acc.totalBytesReceived += stream.bytesReceived || 0; acc.averageJitterInMs += stream.jitter || 0; acc.averageRoundTripTimeInMs += stream.currentRoundTripTime || 0; // naive calculation of the highest resolution const streamArea = area(stream.frameWidth || 0, stream.frameHeight || 0); if (streamArea > maxArea) { acc.highestFrameWidth = stream.frameWidth || 0; acc.highestFrameHeight = stream.frameHeight || 0; acc.highestFramesPerSecond = stream.framesPerSecond || 0; maxArea = streamArea; } if (stream.trackType === TrackType.VIDEO) { acc.camera = stream.camera; } qualityLimitationReasons.add(stream.qualityLimitationReason || ''); return acc; }, aggregatedStats); if (streams.length > 0) { report.averageJitterInMs = Math.round( (report.averageJitterInMs / streams.length) * 1000, ); report.averageRoundTripTimeInMs = Math.round( (report.averageRoundTripTimeInMs / streams.length) * 1000, ); // we take the first codec we find, as it should be the same for all streams report.codec = streams[0].codec || ''; report.codecPerTrackType = streams.reduce( (acc, stream) => { if (stream.trackType) { acc[stream.trackType] = stream.codec || ''; } return acc; }, {} as Record, ); } const qualityLimitationReason = [ qualityLimitationReasons.has('cpu') && 'cpu', qualityLimitationReasons.has('bandwidth') && 'bandwidth', qualityLimitationReasons.has('other') && 'other', ] .filter(Boolean) .join(', '); if (qualityLimitationReason) { report.qualityLimitationReasons = qualityLimitationReason; } return report; }; /** * Aggregates audio stats from a stats report. * * @param stats the stats report containing audio streams. * @returns aggregated audio stats. */ const aggregateAudio = (stats: StatsReport): AudioAggregatedStats => { const streams = stats.streams; const audioStats = getEmptyAudioStats(); const report = streams.reduce((acc, stream) => { acc.totalBytesSent += stream.bytesSent || 0; acc.totalBytesReceived += stream.bytesReceived || 0; acc.averageJitterInMs += stream.jitter || 0; acc.averageRoundTripTimeInMs += stream.currentRoundTripTime || 0; acc.totalConcealedSamples += stream.concealedSamples || 0; acc.totalConcealmentEvents += stream.concealmentEvents || 0; acc.totalPacketsReceived += stream.packetsReceived || 0; acc.totalPacketsLost += stream.packetsLost || 0; return acc; }, audioStats); if (streams.length > 0) { report.averageJitterInMs = Math.round( (report.averageJitterInMs / streams.length) * 1000, ); report.averageRoundTripTimeInMs = Math.round( (report.averageRoundTripTimeInMs / streams.length) * 1000, ); report.codec = streams[0].codec || ''; report.codecPerTrackType = streams.reduce( (acc, stream) => { if (stream.trackType) { acc[stream.trackType] = stream.codec || ''; } return acc; }, {} as Record, ); } return report; };