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

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

//
// This file contains helper methods to draw the stats timeline graphs.
// Each graph represents a series of stats report for a PeerConnection,
// e.g. 1234-0-ssrc-abcd123-bytesSent is the graph for the series of bytesSent
// for ssrc-abcd123 of PeerConnection 0 in process 1234.
// The graphs are drawn as CANVAS, grouped per report type per PeerConnection.
// Each group has an expand/collapse button and is collapsed initially.
//

import {$} from 'chrome://resources/js/util.js';

import {TimelineDataSeries} from './data_series.js';
import {peerConnectionDataStore} from './dump_creator.js';
import {generateStatsLabel} from './stats_helper.js';
import {TimelineGraphView} from './timeline_graph_view.js';

const STATS_GRAPH_CONTAINER_HEADING_CLASS = 'stats-graph-container-heading';

function isReportBlocklisted(report) {
  // Codec stats reflect what has been negotiated. They don't contain
  // information that is useful in graphs.
  if (report.type === 'codec') {
    return true;
  }
  // Unused data channels can stay in "connecting" indefinitely and their
  // counters stay zero.
  if (report.type === 'data-channel' &&
      readReportStat(report, 'state') === 'connecting') {
    return true;
  }
  // The same is true for transports and "new".
  if (report.type === 'transport' &&
      readReportStat(report, 'dtlsState') === 'new') {
    return true;
  }
  // Local and remote candidates don't change over time and there are several of
  // them.
  if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
    return true;
  }
  return false;
}

function readReportStat(report, stat) {
  const values = report.stats.values;
  for (let i = 0; i < values.length; i += 2) {
    if (values[i] === stat) {
      return values[i + 1];
    }
  }
  return undefined;
}

function isStatBlocklisted(report, statName) {
  // The priority does not change over time on its own; plotting uninteresting.
  if (report.type === 'candidate-pair' && statName === 'priority') {
    return true;
  }
  // The mid/rid and ssrcs associated with a sender/receiver do not change
  // over time; plotting uninteresting.
  if (['inbound-rtp', 'outbound-rtp',
        'remote-inbound-rtp', 'remote-outbound-rtp'].includes(report.type) &&
      ['mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc'].includes(statName)) {
    return true;
  }
  // Last packet sent/received timestamps on candidate-pair and inbound-rtp
  // do not plot nicely.
  if (['candidate-pair', 'inbound-rtp'].includes(report.type) &&
      ['lastPacketSentTimestamp',
        'lastPacketReceivedTimestamp'].includes(statName)) {
    return true;
  }
  return false;
}

const graphViews = {};
// Export on |window| since tests access this directly from C++.
window.graphViews = graphViews;
const graphElementsByPeerConnectionId = new Map();

// Returns number parsed from |value|, or NaN.
function getNumberFromValue(name, value) {
  if (isNaN(value)) {
    return NaN;
  }
  return parseFloat(value);
}

// Adds the stats report |report| to the timeline graph for the given
// |peerConnectionElement|.
export function drawSingleReport(
    peerConnectionElement, report) {
  const reportType = report.type;
  const reportId = report.id;
  const stats = report.stats;
  if (!stats || !stats.values) {
    return;
  }

  const childrenBefore = peerConnectionElement.hasChildNodes() ?
      Array.from(peerConnectionElement.childNodes) :
      [];

  for (let i = 0; i < stats.values.length - 1; i = i + 2) {
    const rawLabel = stats.values[i];
    const rawDataSeriesId = reportId + '-' + rawLabel;
    const rawValue = getNumberFromValue(rawLabel, stats.values[i + 1]);
    if (isNaN(rawValue)) {
      // We do not draw non-numerical values, but still want to record it in the
      // data series.
      addDataSeriesPoints(
          peerConnectionElement, reportType, rawDataSeriesId, rawLabel,
          [stats.timestamp], [stats.values[i + 1]]);
      continue;
    }
    let finalDataSeriesId = rawDataSeriesId;
    let finalLabel = rawLabel;
    let finalValue = rawValue;

    // Updates the final dataSeries to draw.
    addDataSeriesPoints(
        peerConnectionElement, reportType, finalDataSeriesId, finalLabel,
        [stats.timestamp], [finalValue]);

    if (isReportBlocklisted(report) || isStatBlocklisted(report, rawLabel)) {
      // We do not want to draw certain reports but still want to
      // record them in the data series.
      continue;
    }

    // Updates the graph.
    const graphType = finalLabel;
    const graphViewId =
        peerConnectionElement.id + '-' + reportId + '-' + graphType;

    if (!graphViews[graphViewId]) {
      graphViews[graphViewId] =
          createStatsGraphView(peerConnectionElement, report, graphType);
      const searchParameters = new URLSearchParams(window.location.search);
      if (searchParameters.has('statsInterval')) {
        const statsInterval = Math.max(
            parseInt(searchParameters.get('statsInterval'), 10),
            100);
        if (isFinite(statsInterval)) {
          graphViews[graphViewId].setScale(statsInterval);
        }
      }
      const date = new Date(stats.timestamp);
      graphViews[graphViewId].setDateRange(date, date);
    }
    // Ensures the stats graph title is up-to-date.
    ensureStatsGraphContainer(peerConnectionElement, report);
    // Adds the new dataSeries to the graphView. We have to do it here to cover
    // both the simple and compound graph cases.
    const dataSeries =
        peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
            finalDataSeriesId);
    if (!graphViews[graphViewId].hasDataSeries(dataSeries)) {
      graphViews[graphViewId].addDataSeries(dataSeries);
    }
    graphViews[graphViewId].updateEndDate();
  }
  // Add a synthetic data series for the timestamp.
  addDataSeriesPoints(
    peerConnectionElement, reportType, reportId + '-timestamp',
    reportId + '-timestamp', [stats.timestamp], [stats.timestamp]);

  const childrenAfter = peerConnectionElement.hasChildNodes() ?
      Array.from(peerConnectionElement.childNodes) :
      [];
  for (let i = 0; i < childrenAfter.length; ++i) {
    if (!childrenBefore.includes(childrenAfter[i])) {
      let graphElements =
          graphElementsByPeerConnectionId.get(peerConnectionElement.id);
      if (!graphElements) {
        graphElements = [];
        graphElementsByPeerConnectionId.set(
            peerConnectionElement.id, graphElements);
      }
      graphElements.push(childrenAfter[i]);
    }
  }
}

export function removeStatsReportGraphs(peerConnectionElement) {
  const graphElements =
      graphElementsByPeerConnectionId.get(peerConnectionElement.id);
  if (graphElements) {
    for (let i = 0; i < graphElements.length; ++i) {
      peerConnectionElement.removeChild(graphElements[i]);
    }
    graphElementsByPeerConnectionId.delete(peerConnectionElement.id);
  }
  Object.keys(graphViews).forEach(key => {
    if (key.startsWith(peerConnectionElement.id)) {
      delete graphViews[key];
    }
  });
}

// Makes sure the TimelineDataSeries with id |dataSeriesId| is created,
// and adds the new data points to it. |times| is the list of timestamps for
// each data point, and |values| is the list of the data point values.
function addDataSeriesPoints(
    peerConnectionElement, reportType, dataSeriesId, label, times, values) {
  let dataSeries =
      peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
          dataSeriesId);
  if (!dataSeries) {
    dataSeries = new TimelineDataSeries(reportType);
    peerConnectionDataStore[peerConnectionElement.id].setDataSeries(
        dataSeriesId, dataSeries);
  }
  for (let i = 0; i < times.length; ++i) {
    dataSeries.addPoint(times[i], values[i]);
  }
}

// Ensures a div container to the stats graph for a peerConnectionElement is
// created as a child of the |peerConnectionElement|.
function ensureStatsGraphTopContainer(peerConnectionElement) {
  const containerId = peerConnectionElement.id + '-graph-container';
  let container = document.getElementById(containerId);
  if (!container) {
    container = document.createElement('div');
    container.id = containerId;
    container.className = 'stats-graph-container';
    const label = document.createElement('label');
    label.innerText = 'Filter statistics graphs by type including ';
    container.appendChild(label);
    const input = document.createElement('input');
    input.placeholder = 'separate multiple values by `,`';
    input.size = 25;
    input.oninput = (e) => filterStats(e, container);
    container.appendChild(input);

    peerConnectionElement.appendChild(container);
  }
  return container;
}

// Ensures a div container to the stats graph for a single set of data is
// created as a child of the |peerConnectionElement|'s graph container.
function ensureStatsGraphContainer(peerConnectionElement, report) {
  const topContainer = ensureStatsGraphTopContainer(peerConnectionElement);
  const containerId = peerConnectionElement.id + '-' + report.type + '-' +
      report.id + '-graph-container';
  // Disable getElementById restriction here, since |containerId| is not always
  // a valid selector.
  // eslint-disable-next-line no-restricted-properties
  let container = document.getElementById(containerId);
  if (!container) {
    container = document.createElement('details');
    container.id = containerId;
    container.className = 'stats-graph-container';
    container.attributes['data-statsType'] = report.type;

    peerConnectionElement.appendChild(container);
    container.appendChild($('summary-span-template').content.cloneNode(true));
    container.firstChild.firstChild.className =
        STATS_GRAPH_CONTAINER_HEADING_CLASS;
    topContainer.appendChild(container);
  }
  // Update the label all the time to account for new information.
  container.firstChild.firstChild.textContent = 'Stats graphs for ' +
    generateStatsLabel(report);
  return container;
}

// Creates the container elements holding a timeline graph
// and the TimelineGraphView object.
function createStatsGraphView(peerConnectionElement, report, statsName) {
  const topContainer =
      ensureStatsGraphContainer(peerConnectionElement, report);

  const graphViewId =
      peerConnectionElement.id + '-' + report.id + '-' + statsName;
  const divId = graphViewId + '-div';
  const canvasId = graphViewId + '-canvas';
  const container = document.createElement('div');
  container.className = 'stats-graph-sub-container';

  topContainer.appendChild(container);
  const canvasDiv = $('container-template').content.cloneNode(true);
  canvasDiv.querySelectorAll('div')[0].textContent = statsName;
  canvasDiv.querySelectorAll('div')[1].id = divId;
  canvasDiv.querySelector('canvas').id = canvasId;
  container.appendChild(canvasDiv);
  return new TimelineGraphView(divId, canvasId);
}

/**
 * Apply a filter to the stats graphs
 * @param event InputEvent from the filter input field.
 * @param container stats table container element.
 * @private
 */
function filterStats(event, container) {
  const filter =  event.target.value;
  const filters = filter.split(',');
  container.childNodes.forEach(node => {
    if (node.nodeName !== 'DETAILS') {
      return;
    }
    const statsType = node.attributes['data-statsType'];
    if (!filter || filters.includes(statsType) ||
        filters.find(f => statsType.includes(f))) {
      node.style.display = 'block';
    } else {
      node.style.display = 'none';
    }
  });
}