UNPKG

twilio-video

Version:

Twilio Video JavaScript Library

443 lines (384 loc) 15.8 kB
'use strict'; const StatsReport = require('../stats/statsreport'); const MovingAverageDelta = require('../util/movingaveragedelta'); const { filterObject, flatMap, difference } = require('../util'); const telemetry = require('./telemetry'); /** * StatsMonitor analyzes WebRTC statistics and publishes insights events. * @internal */ class StatsMonitor { /** * Create a StatsMonitor * @param {Object} statsSource - An object that provides getStats() method * @param {Log} log - Logger instance * @param {Object} [options] - Configuration options * @param {number} [options.publishIntervalMs=10000] - Interval for publishing stats reports * @param {number} [options.collectionIntervalMs=1000] - Interval for collecting stats */ constructor(statsSource, log, options = {}) { if (!statsSource || typeof statsSource.getStats !== 'function') { throw new Error('StatsMonitor requires a stats source with getStats() method'); } if (!log) { throw new Error('StatsMonitor requires a log instance'); } this._statsSource = statsSource; this._log = log; this._publishIntervalMs = options.publishIntervalMs || 10000; this._collectionIntervalMs = options.collectionIntervalMs || 1000; this._collectionsPerPublish = Math.floor(this._publishIntervalMs / this._collectionIntervalMs); this._stallThreshold = 0.5; this._resumeThreshold = 5; this._initializeState(); this._startStatsCollection(); } /** * Initialize monitoring state * @private */ _initializeState() { this._movingAverageDeltas = new Map(); this._interval = null; this._statsCollectionCount = 0; this._iceCandidatePublishToggle = false; this._hasSeenActivePair = false; this._lastNetworkType = null; this._lastQualityLimitationReasonByTrackSid = new Map(); this._stalledTrackSids = new Set(); } /** * Start periodic stats collection and analysis * @private */ _startStatsCollection() { if (this._interval) { this._log.warn('StatsMonitor already started'); return; } this._interval = setInterval(async () => { await this._collectAndAnalyzeStats(); }, this._collectionIntervalMs); this._log.debug('StatsMonitor started'); } /** * Stop stats collection * @private */ _stopStatsCollection() { if (this._interval) { clearInterval(this._interval); this._interval = null; } this._log.debug('StatsMonitor stopped'); } /** * Collect stats and analyze them * @private */ async _collectAndAnalyzeStats() { try { const stats = await this._statsSource.getStats(); this._statsCollectionCount++; const shouldPublishStatsReport = (this._statsCollectionCount % this._collectionsPerPublish) === 0; if (shouldPublishStatsReport) { this._iceCandidatePublishToggle = !this._iceCandidatePublishToggle; } const shouldPublishIceCandidate = shouldPublishStatsReport && this._iceCandidatePublishToggle; this._analyzeStats(stats, shouldPublishStatsReport, shouldPublishIceCandidate); } catch { // Stats collection failures are expected occasionally } } /** * Analyze WebRTC stats and publish insights * @private * @param {Map|Array} stats - Map or Array of StandardizedStatsResponse objects * @param {boolean} shouldPublishStatsReport - Whether to publish stats-report events this cycle * @param {boolean} shouldPublishIceCandidate - Whether to publish ICE candidate pair this cycle */ _analyzeStats(stats, shouldPublishStatsReport, shouldPublishIceCandidate) { if (!stats || (stats instanceof Map && stats.size === 0) || (Array.isArray(stats) && stats.length === 0)) { return; } stats.forEach((response, id) => { this._checkNetworkTypeChanges(response); this._checkQualityLimitations(response.localVideoTrackStats); this._checkTrackStalls(response.remoteVideoTrackStats); if (shouldPublishStatsReport) { this._publishStatsReport(id, response); if (shouldPublishIceCandidate) { this._publishActiveIceCandidatePair(id, response); } } }); } /** * Check for network type changes from active ICE candidate pair * @private * @param {Object} response - StandardizedStatsResponse */ _checkNetworkTypeChanges(response) { const { activeIceCandidatePair } = response; if (!activeIceCandidatePair || !activeIceCandidatePair.localCandidate) { return; } const networkType = activeIceCandidatePair.localCandidate.networkType; if (!networkType) { return; } if (!this._hasSeenActivePair) { this._hasSeenActivePair = true; this._lastNetworkType = networkType; telemetry.network.typeChanged(networkType); return; } if (this._lastNetworkType !== networkType) { this._log.debug(`Network type changed: ${this._lastNetworkType} -> ${networkType}`); this._lastNetworkType = networkType; telemetry.network.typeChanged(networkType); } } /** * Check for quality limitation reason changes * @private * @param {Array} localVideoTrackStats - Local video track statistics */ _checkQualityLimitations(localVideoTrackStats) { if (!Array.isArray(localVideoTrackStats)) { return; } localVideoTrackStats.forEach(({ trackSid, qualityLimitationReason }) => { if (!trackSid || typeof qualityLimitationReason !== 'string') { return; } const lastReason = this._lastQualityLimitationReasonByTrackSid.get(trackSid); if (lastReason !== qualityLimitationReason) { this._log.debug(`Quality limitation reason changed for track ${trackSid}: ${lastReason || 'none'} -> ${qualityLimitationReason}`); this._lastQualityLimitationReasonByTrackSid.set(trackSid, qualityLimitationReason); telemetry.quality.limitationChanged(trackSid, qualityLimitationReason); } }); } /** * Check for track stalls (low frame rates) * @private * @param {Array} remoteVideoTrackStats - Remote video track statistics */ _checkTrackStalls(remoteVideoTrackStats) { if (!Array.isArray(remoteVideoTrackStats)) { return; } remoteVideoTrackStats.forEach(({ trackSid, frameRateReceived }) => { if (frameRateReceived === undefined) { return; } const frameRate = (typeof frameRateReceived === 'number' && !isNaN(frameRateReceived)) ? frameRateReceived : 0; const isStalled = this._stalledTrackSids.has(trackSid); if (!isStalled && frameRate < this._stallThreshold) { this._stalledTrackSids.add(trackSid); this._log.debug(`Track ${trackSid} stalled: frame rate ${frameRate} below threshold ${this._stallThreshold}`); telemetry.track.stalled(trackSid, frameRate, this._stallThreshold); } else if (isStalled && frameRate >= this._resumeThreshold) { this._stalledTrackSids.delete(trackSid); this._log.debug(`Track ${trackSid} resumed: frame rate ${frameRate} above threshold ${this._resumeThreshold}`); telemetry.track.resumed(trackSid, frameRate, this._resumeThreshold); } }); } /** * Add A/V sync metrics to local track stats * @private * @param {Object} trackStats - The track stats from StatsReport * @param {Object} trackResponse - The original track response * @returns {Object} Augmented track stats with A/V sync metrics */ _addLocalTrackMetrics(trackStats, trackResponse) { const { framesEncoded, packetsSent, totalEncodeTime, totalPacketSendDelay } = trackResponse; const augmentedTrackStats = Object.assign({}, trackStats); const key = `${trackStats.trackSid}+${trackStats.ssrc}`; const trackMovingAverageDeltas = this._movingAverageDeltas.get(key) || new Map(); if (typeof totalEncodeTime === 'number' && typeof framesEncoded === 'number') { const trackAvgEncodeDelayMovingAverageDelta = trackMovingAverageDeltas.get('avgEncodeDelay') || new MovingAverageDelta(); trackAvgEncodeDelayMovingAverageDelta.putSample(totalEncodeTime * 1000, framesEncoded); augmentedTrackStats.avgEncodeDelay = Math.round(trackAvgEncodeDelayMovingAverageDelta.get()); trackMovingAverageDeltas.set('avgEncodeDelay', trackAvgEncodeDelayMovingAverageDelta); } if (typeof totalPacketSendDelay === 'number' && typeof packetsSent === 'number') { const trackAvgPacketSendDelayMovingAverageDelta = trackMovingAverageDeltas.get('avgPacketSendDelay') || new MovingAverageDelta(); trackAvgPacketSendDelayMovingAverageDelta.putSample(totalPacketSendDelay * 1000, packetsSent); augmentedTrackStats.avgPacketSendDelay = Math.round(trackAvgPacketSendDelayMovingAverageDelta.get()); trackMovingAverageDeltas.set('avgPacketSendDelay', trackAvgPacketSendDelayMovingAverageDelta); } this._movingAverageDeltas.set(key, trackMovingAverageDeltas); return augmentedTrackStats; } /** * Add A/V sync metrics to remote track stats * @private * @param {Object} trackStats - The track stats from StatsReport * @param {Object} trackResponse - The original track response * @returns {Object} Augmented track stats with A/V sync metrics */ _addRemoteTrackMetrics(trackStats, trackResponse) { const { estimatedPlayoutTimestamp, framesDecoded, jitterBufferDelay, jitterBufferEmittedCount, totalDecodeTime } = trackResponse; const augmentedTrackStats = Object.assign({}, trackStats); const key = `${trackStats.trackSid}+${trackStats.ssrc}`; const trackMovingAverageDeltas = this._movingAverageDeltas.get(key) || new Map(); if (typeof estimatedPlayoutTimestamp === 'number') { augmentedTrackStats.estimatedPlayoutTimestamp = estimatedPlayoutTimestamp; } if (typeof framesDecoded === 'number' && typeof totalDecodeTime === 'number') { const trackAvgDecodeDelayMovingAverageDelta = trackMovingAverageDeltas.get('avgDecodeDelay') || new MovingAverageDelta(); trackAvgDecodeDelayMovingAverageDelta.putSample(totalDecodeTime * 1000, framesDecoded); augmentedTrackStats.avgDecodeDelay = Math.round(trackAvgDecodeDelayMovingAverageDelta.get()); trackMovingAverageDeltas.set('avgDecodeDelay', trackAvgDecodeDelayMovingAverageDelta); } if (typeof jitterBufferDelay === 'number' && typeof jitterBufferEmittedCount === 'number') { const trackAvgJitterBufferDelayMovingAverageDelta = trackMovingAverageDeltas.get('avgJitterBufferDelay') || new MovingAverageDelta(); trackAvgJitterBufferDelayMovingAverageDelta.putSample(jitterBufferDelay * 1000, jitterBufferEmittedCount); augmentedTrackStats.avgJitterBufferDelay = Math.round(trackAvgJitterBufferDelayMovingAverageDelta.get()); trackMovingAverageDeltas.set('avgJitterBufferDelay', trackAvgJitterBufferDelayMovingAverageDelta); } this._movingAverageDeltas.set(key, trackMovingAverageDeltas); return augmentedTrackStats; } /** * Clean up moving average delta entries for tracks that are no longer active * @private * @param {Object} report - The stats report with track stats arrays */ _cleanupMovingAverageDeltas(report) { const keys = flatMap([ 'localAudioTrackStats', 'localVideoTrackStats', 'remoteAudioTrackStats', 'remoteVideoTrackStats' ], prop => report[prop].map(({ ssrc, trackSid }) => `${trackSid}+${ssrc}`)); const movingAverageDeltaKeysToBeRemoved = difference( Array.from(this._movingAverageDeltas.keys()), keys ); movingAverageDeltaKeysToBeRemoved.forEach(key => this._movingAverageDeltas.delete(key)); } /** * Publish stats-report event * @private * @param {number|string} id - Peer connection ID * @param {Object} response - StandardizedStatsResponse */ _publishStatsReport(id, response) { // NOTE(mmalavalli): A StatsReport is used to publish a "stats-report" // event instead of using StandardizedStatsResponse directly because // StatsReport will add zeros to properties that do not exist. const report = new StatsReport(id, response, true /* prepareForInsights */); // NOTE(mmalavalli): Since A/V sync metrics are not part of the StatsReport class, // we add them to the insights payload here. telemetry.quality.statsReport({ audioTrackStats: report.remoteAudioTrackStats.map((trackStat, i) => this._addRemoteTrackMetrics(trackStat, response.remoteAudioTrackStats[i])), localAudioTrackStats: report.localAudioTrackStats.map((trackStat, i) => this._addLocalTrackMetrics(trackStat, response.localAudioTrackStats[i])), localVideoTrackStats: report.localVideoTrackStats.map((trackStat, i) => this._addLocalTrackMetrics(trackStat, response.localVideoTrackStats[i])), peerConnectionId: report.peerConnectionId, videoTrackStats: report.remoteVideoTrackStats.map((trackStat, i) => this._addRemoteTrackMetrics(trackStat, response.remoteVideoTrackStats[i])) }); this._cleanupMovingAverageDeltas(report); } /** * Publish active-ice-candidate-pair event * @private * @param {string|number} peerConnectionId - Peer connection ID * @param {Object} response - StandardizedStatsResponse */ _publishActiveIceCandidatePair(peerConnectionId, response) { const activeIceCandidatePair = this._replaceNullsWithDefaults( response.activeIceCandidatePair, peerConnectionId ); telemetry.quality.iceCandidatePair(activeIceCandidatePair); } /** * Replace null values in activeIceCandidatePair with defaults. * * NOTE(mmalavalli): null properties of the "active-ice-candidate-pair" * payload are assigned default values until the Insights gateway * accepts null values. * * @private * @param {Object} activeIceCandidatePair - The active ICE candidate pair * @param {string} peerConnectionId - The peer connection ID * @returns {Object} Active ICE candidate pair with null values replaced */ _replaceNullsWithDefaults(activeIceCandidatePair, peerConnectionId) { activeIceCandidatePair = Object.assign({ availableIncomingBitrate: 0, availableOutgoingBitrate: 0, bytesReceived: 0, bytesSent: 0, consentRequestsSent: 0, currentRoundTripTime: 0, lastPacketReceivedTimestamp: 0, lastPacketSentTimestamp: 0, nominated: false, peerConnectionId: peerConnectionId, priority: 0, readable: false, requestsReceived: 0, requestsSent: 0, responsesReceived: 0, responsesSent: 0, retransmissionsReceived: 0, retransmissionsSent: 0, state: 'failed', totalRoundTripTime: 0, transportId: '', writable: false }, filterObject(activeIceCandidatePair || {}, null)); activeIceCandidatePair.localCandidate = Object.assign({ candidateType: 'host', deleted: false, ip: '', port: 0, priority: 0, protocol: 'udp', url: '' }, filterObject(activeIceCandidatePair.localCandidate || {}, null)); activeIceCandidatePair.remoteCandidate = Object.assign({ candidateType: 'host', ip: '', port: 0, priority: 0, protocol: 'udp', url: '' }, filterObject(activeIceCandidatePair.remoteCandidate || {}, null)); return activeIceCandidatePair; } /** * Cleanup all monitoring state */ cleanup() { this._stopStatsCollection(); this._movingAverageDeltas.clear(); this._lastQualityLimitationReasonByTrackSid.clear(); this._stalledTrackSids.clear(); } } module.exports = StatsMonitor;