/* global __filename */ import { getLogger } from 'jitsi-meet-logger'; import * as ConnectionQualityEvents from '../../service/connectivity/ConnectionQualityEvents'; import * as ConferenceEvents from '../../JitsiConferenceEvents'; import * as MediaType from '../../service/RTC/MediaType'; import RTCBrowserType from '../RTC/RTCBrowserType'; import Statistics from './statistics'; import * as VideoType from '../../service/RTC/VideoType'; /** * All avg RTP stats are currently reported under 1 event name, but under * different keys. This constant stores the name of this event. * Example structure of "avg.rtp.stats" analytics event: * * { * "stat_avg_rtt": { * value: 200, * samples: [ 100, 200, 300 ] * }, * "stat_avg_packetloss_total": { * value: 10, * samples: [ 5, 10, 15] * }, * "p2p_stat_avg_packetloss_total": { * value: 15, * samples: [ 10, 15, 20] * } * } * * Note that the samples array is currently emitted for debug purposes only and * can be removed anytime soon from the structure. * * Also not all values are always present in "avg.rtp.stats", some of the values * are obtained and calculated as part of different process/event pipe. For * example {@link ConnectionAvgStats} instances are doing the reports for each * {@link TraceablePeerConnection} and work independently from the main stats * pipe. * * @type {string} */ const AVG_RTP_STATS_EVENT = 'avg.rtp.stats'; const logger = getLogger(__filename); /** * This will calculate an average for one, named stat and submit it to * the analytics module when requested. It automatically counts the samples. */ class AverageStatReport { /** * Creates new AverageStatReport for given name. * @param {string} name that's the name of the event that will be reported * to the analytics module. */ constructor(name) { this.name = name; this.count = 0; this.sum = 0; this.samples = []; } /** * Adds the next value that will be included in the average when * {@link calculate} is called. * @param {number} nextValue */ addNext(nextValue) { if (typeof nextValue !== 'number') { logger.error( `${this.name} - invalid value for idx: ${this.count}`, nextValue); } else if (!isNaN(nextValue)) { this.sum += nextValue; this.samples.push(nextValue); this.count += 1; } } /** * Calculates an average for the samples collected using {@link addNext}. * @return {number|NaN} an average of all collected samples or NaN * if no samples were collected. */ calculate() { return this.sum / this.count; } /** * Appends the report to the analytics "data" object. The object will be * added under {@link this.name} key or "p2p_" + {@link this.name} if * isP2P is true. * @param {Object} report the analytics "data" object * @param {boolean} isP2P true if the stats is being report for * P2P connection or false for the JVB connection. */ appendReport(report, isP2P) { report[`${isP2P ? 'p2p_' : ''}${this.name}`] = { value: this.calculate(), samples: this.samples }; } /** * Clears all memory of any samples collected, so that new average can be * calculated using this instance. */ reset() { this.samples = []; this.sum = 0; this.count = 0; } } /** * Class gathers the stats that are calculated and reported for a * {@link TraceablePeerConnection} even if it's not currently active. For * example we want to monitor RTT for the JVB connection while in P2P mode. */ class ConnectionAvgStats { /** * Creates new ConnectionAvgStats * @param {JitsiConference} conference * @param {boolean} isP2P * @param {number} n the number of samples, before arithmetic mean is to be * calculated and values submitted to the analytics module. */ constructor(conference, isP2P, n) { /** * Is this instance for JVB or P2P connection ? * @type {boolean} */ this.isP2P = isP2P; /** * How many samples are to be included in arithmetic mean calculation. * @type {number} * @private */ this._n = n; /** * The current sample index. Starts from 0 and goes up to {@link _n}) * when analytics report will be submitted. * @type {number} * @private */ this._sampleIdx = 0; /** * Average round trip time reported by the ICE candidate pair. * @type {AverageStatReport} */ this._avgRTT = new AverageStatReport('stat_avg_rtt'); /** * Map stores average RTT to the JVB reported by remote participants. * Mapped per participant id {@link JitsiParticipant.getId}. * * This is used only when {@link ConnectionAvgStats.isP2P} equals to * false. * * @type {Map} * @private */ this._avgRemoteRTTMap = new Map(); /** * The conference for which stats will be collected and reported. * @type {JitsiConference} * @private */ this._conference = conference; this._onConnectionStats = (tpc, stats) => { if (this.isP2P === tpc.isP2P) { this._calculateAvgStats(stats); } }; conference.statistics.addConnectionStatsListener( this._onConnectionStats); if (!this.isP2P) { this._onUserLeft = id => this._avgRemoteRTTMap.delete(id); conference.on(ConferenceEvents.USER_LEFT, this._onUserLeft); this._onRemoteStatsUpdated = (id, data) => this._processRemoteStats(id, data); conference.on( ConnectionQualityEvents.REMOTE_STATS_UPDATED, this._onRemoteStatsUpdated); } } /** * Processes next batch of stats. * @param {go figure} data * @private */ _calculateAvgStats(data) { if (!data) { logger.error('No stats'); return; } if (RTCBrowserType.supportsRTTStatistics()) { if (data.transport && data.transport.length) { this._avgRTT.addNext(data.transport[0].rtt); } } this._sampleIdx += 1; if (this._sampleIdx >= this._n) { if (RTCBrowserType.supportsRTTStatistics()) { const batchReport = { }; this._avgRTT.appendReport(batchReport, this.isP2P); // Report end to end RTT only for JVB if (!this.isP2P) { const avgRemoteRTT = this._calculateAvgRemoteRTT(); const avgLocalRTT = this._avgRTT.calculate(); if (!isNaN(avgLocalRTT) && !isNaN(avgRemoteRTT)) { // eslint-disable-next-line camelcase batchReport.stat_avg_end2endrtt = { value: avgLocalRTT + avgRemoteRTT }; } } Statistics.analytics.sendEvent( AVG_RTP_STATS_EVENT, batchReport); } this._resetAvgStats(); } } /** * Calculates arithmetic mean of all RTTs towards the JVB reported by * participants. * @return {number|NaN} NaN if not available (not enough data) * @private */ _calculateAvgRemoteRTT() { let count = 0, sum = 0; // FIXME should we ignore RTT for participant // who "is having connectivity issues" ? for (const remoteAvg of this._avgRemoteRTTMap.values()) { const avg = remoteAvg.calculate(); if (!isNaN(avg)) { sum += avg; count += 1; remoteAvg.reset(); } } return sum / count; } /** * Processes {@link ConnectionQualityEvents.REMOTE_STATS_UPDATED} to analyse * RTT towards the JVB reported by each participant. * @param {string} id {@link JitsiParticipant.getId} * @param {go figure in ConnectionQuality.js} data * @private */ _processRemoteStats(id, data) { const validData = typeof data.jvbRTT === 'number'; let rttAvg = this._avgRemoteRTTMap.get(id); if (!rttAvg && validData) { rttAvg = new AverageStatReport(`${id}_stat_rtt`); this._avgRemoteRTTMap.set(id, rttAvg); } if (validData) { rttAvg.addNext(data.jvbRTT); } else if (rttAvg) { this._avgRemoteRTTMap.delete(id); } } /** * Reset cache of all averages and {@link _sampleIdx}. * @private */ _resetAvgStats() { this._avgRTT.reset(); if (this._avgRemoteRTTMap) { this._avgRemoteRTTMap.clear(); } this._sampleIdx = 0; } /** * */ dispose() { this._conference.statistics.removeConnectionStatsListener( this._onConnectionStats); if (!this.isP2P) { this._conference.off( ConnectionQualityEvents.REMOTE_STATS_UPDATED, this._onRemoteStatsUpdated); this._conference.off( ConferenceEvents.USER_LEFT, this._onUserLeft); } } } /** * Reports average RTP statistics values (arithmetic mean) to the analytics * module for things like bit rate, bandwidth, packet loss etc. It keeps track * of the P2P vs JVB conference modes and submits the values under different * namespaces (the events for P2P mode have 'p2p.' prefix). Every switch between * P2P mode resets the data collected so far and averages are calculated from * scratch. */ export default class AvgRTPStatsReporter { /** * Creates new instance of AvgRTPStatsReporter * @param {JitsiConference} conference * @param {number} n the number of samples, before arithmetic mean is to be * calculated and values submitted to the analytics module. */ constructor(conference, n) { /** * How many {@link ConnectionQualityEvents.LOCAL_STATS_UPDATED} samples * are to be included in arithmetic mean calculation. * @type {number} * @private */ this._n = n; if (n > 0) { logger.info(`Avg RTP stats will be calculated every ${n} samples`); } else { logger.info('Avg RTP stats reports are disabled.'); // Do not initialize return; } /** * The current sample index. Starts from 0 and goes up to {@link _n}) * when analytics report will be submitted. * @type {number} * @private */ this._sampleIdx = 0; /** * The conference for which stats will be collected and reported. * @type {JitsiConference} * @private */ this._conference = conference; /** * Average audio upload bitrate * @type {AverageStatReport} * @private */ this._avgAudioBitrateUp = new AverageStatReport('stat_avg_bitrate_audio_upload'); /** * Average audio download bitrate * @type {AverageStatReport} * @private */ this._avgAudioBitrateDown = new AverageStatReport('stat_avg_bitrate_audio_download'); /** * Average video upload bitrate * @type {AverageStatReport} * @private */ this._avgVideoBitrateUp = new AverageStatReport('stat_avg_bitrate_video_upload'); /** * Average video download bitrate * @type {AverageStatReport} * @private */ this._avgVideoBitrateDown = new AverageStatReport('stat_avg_bitrate_video_download'); /** * Average upload bandwidth * @type {AverageStatReport} * @private */ this._avgBandwidthUp = new AverageStatReport('stat_avg_bandwidth_upload'); /** * Average download bandwidth * @type {AverageStatReport} * @private */ this._avgBandwidthDown = new AverageStatReport('stat_avg_bandwidth_download'); /** * Average total packet loss * @type {AverageStatReport} * @private */ this._avgPacketLossTotal = new AverageStatReport('stat_avg_packetloss_total'); /** * Average upload packet loss * @type {AverageStatReport} * @private */ this._avgPacketLossUp = new AverageStatReport('stat_avg_packetloss_upload'); /** * Average download packet loss * @type {AverageStatReport} * @private */ this._avgPacketLossDown = new AverageStatReport('stat_avg_packetloss_download'); /** * Average FPS for remote videos * @type {AverageStatReport} * @private */ this._avgRemoteFPS = new AverageStatReport('stat_avg_framerate_remote'); /** * Average FPS for remote screen streaming videos (reported only if not * a NaN). * @type {AverageStatReport} * @private */ this._avgRemoteScreenFPS = new AverageStatReport('stat_avg_framerate_screen_remote'); /** * Average FPS for local video (camera) * @type {AverageStatReport} * @private */ this._avgLocalFPS = new AverageStatReport('stat_avg_framerate_local'); /** * Average FPS for local screen streaming video (reported only if not * a NaN). * @type {AverageStatReport} * @private */ this._avgLocalScreenFPS = new AverageStatReport('stat_avg_framerate_screen_local'); /** * Average connection quality as defined by * the {@link ConnectionQuality} module. * @type {AverageStatReport} * @private */ this._avgCQ = new AverageStatReport('stat_avg_cq'); this._onLocalStatsUpdated = data => this._calculateAvgStats(data); conference.on( ConnectionQualityEvents.LOCAL_STATS_UPDATED, this._onLocalStatsUpdated); this._onP2PStatusChanged = () => { logger.debug('Resetting average stats calculation'); this._resetAvgStats(); this.jvbStatsMonitor._resetAvgStats(); this.p2pStatsMonitor._resetAvgStats(); }; conference.on( ConferenceEvents.P2P_STATUS, this._onP2PStatusChanged); this.jvbStatsMonitor = new ConnectionAvgStats(conference, false /* JVB */, n); this.p2pStatsMonitor = new ConnectionAvgStats(conference, true /* P2P */, n); } /** * Processes next batch of stats reported on * {@link ConnectionQualityEvents.LOCAL_STATS_UPDATED}. * @param {go figure} data * @private */ _calculateAvgStats(data) { const isP2P = this._conference.isP2PActive(); const peerCount = this._conference.getParticipants().length; if (!isP2P && peerCount < 1) { // There's no point in collecting stats for a JVB conference of 1. // That happens for short period of time after everyone leaves // the room, until Jicofo terminates the session. return; } /* Uncomment to figure out stats structure for (const key in data) { if (data.hasOwnProperty(key)) { logger.info(`local stat ${key}: `, data[key]); } } */ if (!data) { logger.error('No stats'); return; } const bitrate = data.bitrate; const bandwidth = data.bandwidth; const packetLoss = data.packetLoss; const frameRate = data.framerate; if (!bitrate) { logger.error('No "bitrate"'); return; } else if (!bandwidth) { logger.error('No "bandwidth"'); return; } else if (!packetLoss) { logger.error('No "packetloss"'); return; } else if (!frameRate) { logger.error('No "framerate"'); return; } this._avgAudioBitrateUp.addNext(bitrate.audio.upload); this._avgAudioBitrateDown.addNext(bitrate.audio.download); this._avgVideoBitrateUp.addNext(bitrate.video.upload); this._avgVideoBitrateDown.addNext(bitrate.video.download); if (RTCBrowserType.supportsBandwidthStatistics()) { this._avgBandwidthUp.addNext(bandwidth.upload); this._avgBandwidthDown.addNext(bandwidth.download); } this._avgPacketLossUp.addNext(packetLoss.upload); this._avgPacketLossDown.addNext(packetLoss.download); this._avgPacketLossTotal.addNext(packetLoss.total); this._avgCQ.addNext(data.connectionQuality); if (frameRate) { this._avgRemoteFPS.addNext( this._calculateAvgVideoFps( frameRate, false /* remote */, VideoType.CAMERA)); this._avgRemoteScreenFPS.addNext( this._calculateAvgVideoFps( frameRate, false /* remote */, VideoType.DESKTOP)); this._avgLocalFPS.addNext( this._calculateAvgVideoFps( frameRate, true /* local */, VideoType.CAMERA)); this._avgLocalScreenFPS.addNext( this._calculateAvgVideoFps( frameRate, true /* local */, VideoType.DESKTOP)); } this._sampleIdx += 1; if (this._sampleIdx >= this._n) { const batchReport = { }; this._avgAudioBitrateUp.appendReport(batchReport, isP2P); this._avgAudioBitrateDown.appendReport(batchReport, isP2P); this._avgVideoBitrateUp.appendReport(batchReport, isP2P); this._avgVideoBitrateDown.appendReport(batchReport, isP2P); if (RTCBrowserType.supportsBandwidthStatistics()) { this._avgBandwidthUp.appendReport(batchReport, isP2P); this._avgBandwidthDown.appendReport(batchReport, isP2P); } this._avgPacketLossUp.appendReport(batchReport, isP2P); this._avgPacketLossDown.appendReport(batchReport, isP2P); this._avgPacketLossTotal.appendReport(batchReport, isP2P); this._avgRemoteFPS.appendReport(batchReport, isP2P); if (!isNaN(this._avgRemoteScreenFPS.calculate())) { this._avgRemoteScreenFPS.appendReport(batchReport, isP2P); } this._avgLocalFPS.appendReport(batchReport, isP2P); if (!isNaN(this._avgLocalScreenFPS.calculate())) { this._avgLocalScreenFPS.appendReport(batchReport, isP2P); } this._avgCQ.appendReport(batchReport, isP2P); Statistics.analytics.sendEvent(AVG_RTP_STATS_EVENT, batchReport); this._resetAvgStats(); } } /** * Calculates average FPS for the report * @param {go figure} frameRate * @param {boolean} isLocal if the average is to be calculated for the local * video or false if for remote videos. * @param {VideoType} videoType * @return {number|NaN} average FPS or NaN if there are no samples. * @private */ _calculateAvgVideoFps(frameRate, isLocal, videoType) { let peerFpsSum = 0; let peerCount = 0; const myID = this._conference.myUserId(); for (const peerID of Object.keys(frameRate)) { if (isLocal ? peerID === myID : peerID !== myID) { const participant = isLocal ? null : this._conference.getParticipantById(peerID); const videosFps = frameRate[peerID]; // Do not continue without participant for non local peerID if ((isLocal || participant) && videosFps) { const peerAvgFPS = this._calculatePeerAvgVideoFps( videosFps, participant, videoType); if (!isNaN(peerAvgFPS)) { peerFpsSum += peerAvgFPS; peerCount += 1; } } } } return peerFpsSum / peerCount; } /** * Calculate average FPS for either remote or local participant * @param {object} videos maps FPS per video SSRC * @param {JitsiParticipant|null} participant remote participant or * null for local FPS calculation. * @param {VideoType} videoType the type of the video for which an average * will be calculated. * @return {number|NaN} average FPS of all participant's videos or * NaN if currently not available * @private */ _calculatePeerAvgVideoFps(videos, participant, videoType) { let ssrcs = Object.keys(videos).map(ssrc => Number(ssrc)); let videoTracks = null; // NOTE that this method is supposed to be called for the stats // received from the current peerconnection. const tpc = this._conference.getActivePeerConnection(); if (participant) { videoTracks = participant.getTracksByMediaType(MediaType.VIDEO); if (videoTracks) { ssrcs = ssrcs.filter( ssrc => videoTracks.find( track => !track.isMuted() && track.getSSRC() === ssrc && track.videoType === videoType)); } } else { videoTracks = this._conference.getLocalTracks(MediaType.VIDEO); ssrcs = ssrcs.filter( ssrc => videoTracks.find( track => !track.isMuted() && tpc.getLocalSSRC(track) === ssrc && track.videoType === videoType)); } let peerFpsSum = 0; let peerSsrcCount = 0; for (const ssrc of ssrcs) { const peerSsrcFps = Number(videos[ssrc]); // FPS is reported as 0 for users with no video if (!isNaN(peerSsrcFps) && peerSsrcFps > 0) { peerFpsSum += peerSsrcFps; peerSsrcCount += 1; } } return peerFpsSum / peerSsrcCount; } /** * Reset cache of all averages and {@link _sampleIdx}. * @private */ _resetAvgStats() { this._avgAudioBitrateUp.reset(); this._avgAudioBitrateDown.reset(); this._avgVideoBitrateUp.reset(); this._avgVideoBitrateDown.reset(); this._avgBandwidthUp.reset(); this._avgBandwidthDown.reset(); this._avgPacketLossUp.reset(); this._avgPacketLossDown.reset(); this._avgPacketLossTotal.reset(); this._avgRemoteFPS.reset(); this._avgRemoteScreenFPS.reset(); this._avgLocalFPS.reset(); this._avgLocalScreenFPS.reset(); this._avgCQ.reset(); this._sampleIdx = 0; } /** * Unregisters all event listeners and stops working. */ dispose() { this._conference.off( ConferenceEvents.P2P_STATUS, this._onP2PStatusChanged); this._conference.off( ConnectionQualityEvents.LOCAL_STATS_UPDATED, this._onLocalStatsUpdated); this.jvbStatsMonitor.dispose(); this.p2pStatsMonitor.dispose(); } }