// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details const CalculatorModifier = Object.freeze({ kNone: Object.freeze({postfix: '', multiplier: 1}), kMillisecondsFromSeconds: Object.freeze({postfix: '_in_ms', multiplier: 1000}), kBytesToBits: Object.freeze({bitrate: true, multiplier: 8}), }); class Metric { constructor(name, value) { this.name = name; this.value = value; } toString() { return '{"' + this.name + '":"' + this.value + '"}'; } } // Represents a companion dictionary to an RTCStats object of an RTCStatsReport. // The CalculatedStats object contains additional metrics associated with the // original RTCStats object. Typically, the RTCStats object contains // accumulative counters, but in chrome://webrc-internals/ we also want graphs // for the average rate over the last second, so we have CalculatedStats // containing calculated Metrics. class CalculatedStats { constructor(id) { this.id = id; // A map Original Name -> Array of Metrics, where Original Name refers to // the name of the metric in the original RTCStats object, and the Metrics // are calculated metrics. For example, if the original RTCStats report // contains framesReceived, and from that we've calculated // [framesReceived/s] and [framesReceived-framesDecoded], then there will be // a mapping from "framesReceived" to an array of two Metric objects, // "[framesReceived/s]" and "[framesReceived-framesDecoded]". this.calculatedMetricsByOriginalName = new Map(); } addCalculatedMetric(originalName, metric) { let calculatedMetrics = this.calculatedMetricsByOriginalName.get(originalName); if (!calculatedMetrics) { calculatedMetrics = []; this.calculatedMetricsByOriginalName.set(originalName, calculatedMetrics); } calculatedMetrics.push(metric); } // Gets the calculated metrics associated with |originalName| in the order // that they were added, or an empty list if there are no associated metrics. getCalculatedMetrics(originalName) { const calculatedMetrics = this.calculatedMetricsByOriginalName.get(originalName); if (!calculatedMetrics) { return []; } return calculatedMetrics; } toString() { let str = '{id:"' + this.id + '"'; for (const originalName of this.calculatedMetricsByOriginalName.keys()) { const calculatedMetrics = this.calculatedMetricsByOriginalName.get(originalName); str += ',' + originalName + ':['; for (let i = 0; i < calculatedMetrics.length; i++) { str += calculatedMetrics[i].toString(); if (i + 1 < calculatedMetrics.length) { str += ','; } str += ']'; } } str += '}'; return str; } } // Contains the metrics of an RTCStatsReport, as well as calculated metrics // associated with metrics from the original report. Convertible to and from the // "internal reports" format used by webrtc_internals.js to pass stats from C++ // to JavaScript. export class StatsReport { constructor() { // Represents an RTCStatsReport. It is a Map RTCStats.id -> RTCStats. // https://w3c.github.io/webrtc-pc/#dom-rtcstatsreport this.statsById = new Map(); // RTCStats.id -> CalculatedStats this.calculatedStatsById = new Map(); } // |internalReports| is an array, each element represents an RTCStats object, // but the format is a little different from the spec. This is the format: // { // id: "string", // type: "string", // stats: { // timestamp: , // values: ["member1", value1, "member2", value2...] // } // } static fromInternalsReportList(internalReports) { const result = new StatsReport(); internalReports.forEach(internalReport => { if (!internalReport.stats || !internalReport.stats.values) { return; // continue; } const stats = { id: internalReport.id, type: internalReport.type, timestamp: internalReport.stats.timestamp / 1000.0 // ms -> s }; const values = internalReport.stats.values; for (let i = 0; i < values.length; i += 2) { // Metric "name: value". stats[values[i]] = values[i + 1]; } result.statsById.set(stats.id, stats); }); return result; } toInternalsReportList() { const result = []; for (const stats of this.statsById.values()) { const internalReport = { id: stats.id, type: stats.type, stats: { timestamp: stats.timestamp * 1000.0, // s -> ms values: [] } }; Object.keys(stats).forEach(metricName => { if (metricName === 'id' || metricName === 'type' || metricName === 'timestamp') { return; // continue; } internalReport.stats.values.push(metricName); internalReport.stats.values.push(stats[metricName]); const calculatedMetrics = this.getCalculatedMetrics(stats.id, metricName); calculatedMetrics.forEach(calculatedMetric => { internalReport.stats.values.push(calculatedMetric.name); // Treat calculated metrics that are undefined as 0 to ensure graphs // can be created anyway. internalReport.stats.values.push( calculatedMetric.value ? calculatedMetric.value : 0); }); }); result.push(internalReport); } return result; } toString() { let str = ''; for (const stats of this.statsById.values()) { if (str !== '') { str += ','; } str += JSON.stringify(stats); } let str2 = ''; for (const stats of this.calculatedStatsById.values()) { if (str2 !== '') { str2 += ','; } str2 += stats.toString(); } return '[original:' + str + '],calculated:[' + str2 + ']'; } get(id) { return this.statsById.get(id); } getByType(type) { const result = []; for (const stats of this.statsById.values()) { if (stats.type === type) { result.push(stats); } } return result; } addCalculatedMetric(id, insertAtOriginalMetricName, name, value) { let calculatedStats = this.calculatedStatsById.get(id); if (!calculatedStats) { calculatedStats = new CalculatedStats(id); this.calculatedStatsById.set(id, calculatedStats); } calculatedStats.addCalculatedMetric( insertAtOriginalMetricName, new Metric(name, value)); } getCalculatedMetrics(id, originalMetricName) { const calculatedStats = this.calculatedStatsById.get(id); return calculatedStats ? calculatedStats.getCalculatedMetrics(originalMetricName) : []; } } // Shows a `DOMHighResTimeStamp` as a human readable date time. // The metric must be a time value in milliseconds with Unix epoch as time // origin. class DateCalculator { constructor(metric) { this.metric = metric; } getCalculatedMetricName() { return '[' + this.metric + ']'; } calculate(id, previousReport, currentReport) { const timestamp = currentReport.get(id)[this.metric]; const date = new Date(timestamp); return date.toLocaleString(); } } // Calculates the rate "delta accumulative / delta samples" and returns it. If // a rate cannot be calculated, such as the metric is missing in the current // or previous report, undefined is returned. class RateCalculator { constructor( accumulativeMetric, samplesMetric, modifier = CalculatorModifier.kNone) { this.accumulativeMetric = accumulativeMetric; this.samplesMetric = samplesMetric; this.modifier = modifier; } getCalculatedMetricName() { const accumulativeMetric = this.modifier.bitrate ? this.accumulativeMetric + '_in_bits' : this.accumulativeMetric; if (this.samplesMetric === 'timestamp') { return '[' + accumulativeMetric + '/s]'; } return '[' + accumulativeMetric + '/' + this.samplesMetric + this.modifier.postfix + ']'; } calculate(id, previousReport, currentReport) { return RateCalculator.calculateRate( id, previousReport, currentReport, this.accumulativeMetric, this.samplesMetric) * this.modifier.multiplier; } static calculateRate( id, previousReport, currentReport, accumulativeMetric, samplesMetric) { if (!previousReport || !currentReport) { return undefined; } const previousStats = previousReport.get(id); const currentStats = currentReport.get(id); if (!previousStats || !currentStats) { return undefined; } const deltaTime = currentStats.timestamp - previousStats.timestamp; if (deltaTime <= 0) { return undefined; } // Try to convert whatever the values are to numbers. This gets around the // fact that some types that are not supported by base::Value (e.g. uint32, // int64, uint64 and double) are passed as strings. const previousValue = Number(previousStats[accumulativeMetric]); const currentValue = Number(currentStats[accumulativeMetric]); if (typeof previousValue !== 'number' || typeof currentValue !== 'number') { return undefined; } const previousSamples = Number(previousStats[samplesMetric]); const currentSamples = Number(currentStats[samplesMetric]); if (typeof previousSamples !== 'number' || typeof currentSamples !== 'number') { return undefined; } const deltaValue = currentValue - previousValue; const deltaSamples = currentSamples - previousSamples; return deltaValue / deltaSamples; } } // Looks up codec and payload type from a codecId reference, constructing an // informative string about which codec is used. class CodecCalculator { getCalculatedMetricName() { return '[codec]'; } calculate(id, previousReport, currentReport) { const targetStats = currentReport.get(id); const codecStats = currentReport.get(targetStats.codecId); if (!codecStats) { return undefined; } // If mimeType is 'video/VP8' then codec is 'VP8'. const codec = codecStats.mimeType.substr(codecStats.mimeType.indexOf('/') + 1); let fmtpLine = ''; if (codecStats.sdpFmtpLine) { fmtpLine = ', ' + codecStats.sdpFmtpLine; } return codec + ' (' + codecStats.payloadType + fmtpLine + ')'; } } // Calculates "RMS" audio level, which is the average audio level between the // previous and current report, in the interval [0,1]. Calculated per: // https://w3c.github.io/webrtc-stats/#dom-rtcinboundrtpstreamstats-totalaudioenergy class AudioLevelRmsCalculator { getCalculatedMetricName() { return '[Audio_Level_in_RMS]'; } calculate(id, previousReport, currentReport) { const averageAudioLevelSquared = RateCalculator.calculateRate( id, previousReport, currentReport, 'totalAudioEnergy', 'totalSamplesDuration'); return Math.sqrt(averageAudioLevelSquared); } } // Calculates "metricA - SUM(otherMetrics)", only looking at the current report. class DifferenceCalculator { constructor(metricA, ...otherMetrics) { this.metricA = metricA; this.otherMetrics = otherMetrics; } getCalculatedMetricName() { return '[' + this.metricA + '-' + this.otherMetrics.join('-') + ']'; } calculate(id, previousReport, currentReport) { const currentStats = currentReport.get(id); return parseInt(currentStats[this.metricA], 10) - this.otherMetrics.map(metric => parseInt(currentStats[metric], 10)) .reduce((a, b) => a + b, 0); } } // Calculates the standard deviation from a totalSquaredSum, totalSum, and // totalCount. If the standard deviation cannot be calculated, such as the // metric is missing in the current or previous report, undefined is returned. class StandardDeviationCalculator { constructor(totalSquaredSumMetric, totalSumMetric, totalCount, label) { this.totalSquaredSumMetric = totalSquaredSumMetric; this.totalSumMetric = totalSumMetric; this.totalCount = totalCount; this.label = label; } getCalculatedMetricName() { return '[' + this.label + 'StDev_in_ms]'; } calculate(id, previousReport, currentReport) { return StandardDeviationCalculator.calculateStandardDeviation( id, previousReport, currentReport, this.totalSquaredSumMetric, this.totalSumMetric, this.totalCount); } static calculateStandardDeviation( id, previousReport, currentReport, totalSquaredSumMetric, totalSumMetric, totalCount) { if (!previousReport || !currentReport) { return undefined; } const previousStats = previousReport.get(id); const currentStats = currentReport.get(id); if (!previousStats || !currentStats) { return undefined; } const deltaCount = Number(currentStats[totalCount]) - Number(previousStats[totalCount]); if (deltaCount <= 0) { return undefined; } // Try to convert whatever the values are to numbers. This gets around the // fact that some types that are not supported by base::Value (e.g. uint32, // int64, uint64 and double) are passed as strings. const previousSquaredSumValue = Number(previousStats[totalSquaredSumMetric]); const currentSquaredSumValue = Number(currentStats[totalSquaredSumMetric]); if (typeof previousSquaredSumValue !== 'number' || typeof currentSquaredSumValue !== 'number') { return undefined; } const previousSumValue = Number(previousStats[totalSumMetric]); const currentSumValue = Number(currentStats[totalSumMetric]); if (typeof previousSumValue !== 'number' || typeof currentSumValue !== 'number') { return undefined; } const deltaSquaredSum = currentSquaredSumValue - previousSquaredSumValue; const deltaSum = currentSumValue - previousSumValue; const variance = (deltaSquaredSum - Math.pow(deltaSum, 2) / deltaCount) / deltaCount; if (variance < 0) { return undefined; } return 1000 * Math.sqrt(variance); } } // Keeps track of previous and current stats report and calculates all // calculated metrics. export class StatsRatesCalculator { constructor() { this.previousReport = null; this.currentReport = null; this.statsCalculators = [ { type: 'data-channel', metricCalculators: { messagesSent: new RateCalculator('messagesSent', 'timestamp'), messagesReceived: new RateCalculator('messagesReceived', 'timestamp'), bytesSent: new RateCalculator( 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), bytesReceived: new RateCalculator( 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), }, }, { type: 'media-source', metricCalculators: { totalAudioEnergy: new AudioLevelRmsCalculator(), }, }, { type: 'outbound-rtp', metricCalculators: { bytesSent: new RateCalculator( 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), headerBytesSent: new RateCalculator( 'headerBytesSent', 'timestamp', CalculatorModifier.kBytesToBits), retransmittedBytesSent: new RateCalculator( 'retransmittedBytesSent', 'timestamp', CalculatorModifier.kBytesToBits), packetsSent: new RateCalculator('packetsSent', 'timestamp'), retransmittedPacketsSent: new RateCalculator('retransmittedPacketsSent', 'timestamp'), totalPacketSendDelay: new RateCalculator( 'totalPacketSendDelay', 'packetsSent', CalculatorModifier.kMillisecondsFromSeconds), framesEncoded: new RateCalculator('framesEncoded', 'timestamp'), framesSent: new RateCalculator('framesSent', 'timestamp'), totalEncodedBytesTarget: new RateCalculator( 'totalEncodedBytesTarget', 'timestamp', CalculatorModifier.kBytesToBits), totalEncodeTime: new RateCalculator( 'totalEncodeTime', 'framesEncoded', CalculatorModifier.kMillisecondsFromSeconds), qpSum: new RateCalculator('qpSum', 'framesEncoded'), codecId: new CodecCalculator(), }, }, { type: 'inbound-rtp', metricCalculators: { bytesReceived: new RateCalculator( 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), headerBytesReceived: new RateCalculator( 'headerBytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), retransmittedBytesReceived: new RateCalculator( 'retransmittedBytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), fecBytesReceived: new RateCalculator( 'fecBytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), packetsReceived: new RateCalculator('packetsReceived', 'timestamp'), packetsDiscarded: new RateCalculator('packetsDiscarded', 'timestamp'), retransmittedPacketsReceived: new RateCalculator('retransmittedPacketsReceived', 'timestamp'), fecPacketsReceived: new RateCalculator('fecPacketsReceived', 'timestamp'), fecPacketsDiscarded: new RateCalculator('fecPacketsDiscarded', 'timestamp'), framesReceived: [ new RateCalculator('framesReceived', 'timestamp'), new DifferenceCalculator('framesReceived', 'framesDecoded', 'framesDropped'), ], framesDecoded: new RateCalculator('framesDecoded', 'timestamp'), keyFramesDecoded: new RateCalculator('keyFramesDecoded', 'timestamp'), totalDecodeTime: new RateCalculator( 'totalDecodeTime', 'framesDecoded', CalculatorModifier.kMillisecondsFromSeconds), totalInterFrameDelay: new RateCalculator( 'totalInterFrameDelay', 'framesDecoded', CalculatorModifier.kMillisecondsFromSeconds), totalSquaredInterFrameDelay: new StandardDeviationCalculator( 'totalSquaredInterFrameDelay', 'totalInterFrameDelay', 'framesDecoded', 'interFrameDelay'), totalSamplesReceived: new RateCalculator('totalSamplesReceived', 'timestamp'), concealedSamples: [ new RateCalculator('concealedSamples', 'timestamp'), new RateCalculator('concealedSamples', 'totalSamplesReceived'), ], silentConcealedSamples: new RateCalculator('silentConcealedSamples', 'timestamp'), insertedSamplesForDeceleration: new RateCalculator('insertedSamplesForDeceleration', 'timestamp'), removedSamplesForAcceleration: new RateCalculator('removedSamplesForAcceleration', 'timestamp'), qpSum: new RateCalculator('qpSum', 'framesDecoded'), codecId: new CodecCalculator(), totalAudioEnergy: new AudioLevelRmsCalculator(), jitterBufferDelay: new RateCalculator( 'jitterBufferDelay', 'jitterBufferEmittedCount', CalculatorModifier.kMillisecondsFromSeconds), jitterBufferTargetDelay: new RateCalculator( 'jitterBufferTargetDelay', 'jitterBufferEmittedCount', CalculatorModifier.kMillisecondsFromSeconds), jitterBufferMinimumDelay: new RateCalculator( 'jitterBufferMinimumDelay', 'jitterBufferEmittedCount', CalculatorModifier.kMillisecondsFromSeconds), lastPacketReceivedTimestamp: new DateCalculator( 'lastPacketReceivedTimestamp'), estimatedPlayoutTimestamp: new DateCalculator( 'estimatedPlayoutTimestamp'), totalProcessingDelay: new RateCalculator( 'totalProcessingDelay', 'framesDecoded', CalculatorModifier.kMillisecondsFromSeconds), totalAssemblyTime: new RateCalculator( 'totalAssemblyTime', 'framesAssembledFromMultiplePackets', CalculatorModifier.kMillisecondsFromSeconds), }, }, { type: 'remote-outbound-rtp', metricCalculators: { remoteTimestamp: new DateCalculator('remoteTimestamp'), }, }, { type: 'transport', metricCalculators: { bytesSent: new RateCalculator( 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), bytesReceived: new RateCalculator( 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), packetsSent: new RateCalculator( 'packetsSent', 'timestamp'), packetsReceived: new RateCalculator( 'packetsReceived', 'timestamp'), }, }, { type: 'candidate-pair', metricCalculators: { bytesSent: new RateCalculator( 'bytesSent', 'timestamp', CalculatorModifier.kBytesToBits), bytesReceived: new RateCalculator( 'bytesReceived', 'timestamp', CalculatorModifier.kBytesToBits), packetsSent: new RateCalculator( 'packetsSent', 'timestamp'), packetsReceived: new RateCalculator( 'packetsReceived', 'timestamp'), totalRoundTripTime: new RateCalculator('totalRoundTripTime', 'responsesReceived'), lastPacketReceivedTimestamp: new DateCalculator( 'lastPacketReceivedTimestamp'), lastPacketSentTimestamp: new DateCalculator( 'lastPacketSentTimestamp'), }, }, ]; } addStatsReport(report) { this.previousReport = this.currentReport; this.currentReport = report; this.updateCalculatedMetrics_(); } // Updates all "calculated metrics", which are metrics derived from standard // values, such as converting total counters (e.g. bytesSent) to rates (e.g. // bytesSent/s). updateCalculatedMetrics_() { this.statsCalculators.forEach(statsCalculator => { this.currentReport.getByType(statsCalculator.type).forEach(stats => { Object.keys(statsCalculator.metricCalculators) .forEach(originalMetric => { let metricCalculators = statsCalculator.metricCalculators[originalMetric]; if (!Array.isArray(metricCalculators)) { metricCalculators = [metricCalculators]; } metricCalculators.forEach(metricCalculator => { this.currentReport.addCalculatedMetric( stats.id, originalMetric, metricCalculator.getCalculatedMetricName(), metricCalculator.calculate( stats.id, this.previousReport, this.currentReport)); }); }); }); }); } }