signal-desktop/js/calling-tools/stats_graph_helper.js
2024-05-23 15:19:12 -07:00

307 lines
11 KiB
JavaScript

// Derived from Chromium WebRTC Internals Dashboard - see Acknowledgements for full license details
//
// 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 './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'].includes(report.type) &&
['mid', 'rid', 'ssrc', 'rtxSsrc', 'fecSsrc'].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';
}
});
}