chromium/content/browser/webrtc/resources/stats_rates_calculator.js

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

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: <milliseconds>,
  //     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', 'jitterBufferEmittedCount',
              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));
              });
            });
      });
    });
  }
}