blob: eca482d7cf0ea8371f7e5b95922d86abc7768ec2 [file] [log] [blame]
// Copyright (c) 2013 The Chromium Authors. All rights reserved.
// 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.m.js';
import {TimelineDataSeries} from './data_series.js';
import {peerConnectionDataStore} from './dump_creator.js';
import {GetSsrcFromReport} from './ssrc_info_manager.js';
import {TimelineGraphView} from './timeline_graph_view.js';
const STATS_GRAPH_CONTAINER_HEADING_CLASS = 'stats-graph-container-heading';
const RECEIVED_PROPAGATION_DELTA_LABEL =
'googReceivedPacketGroupPropagationDeltaDebug';
const RECEIVED_PACKET_GROUP_ARRIVAL_TIME_LABEL =
'googReceivedPacketGroupArrivalTimeDebug';
// Specifies which stats should be drawn on the 'bweCompound' graph and how.
const bweCompoundGraphConfig = {
googAvailableSendBandwidth: {color: 'red'},
googTargetEncBitrateCorrected: {color: 'purple'},
googActualEncBitrate: {color: 'orange'},
googRetransmitBitrate: {color: 'blue'},
googTransmitBitrate: {color: 'green'},
};
// Converts the last entry of |srcDataSeries| from the total amount to the
// amount per second.
const totalToPerSecond = function(srcDataSeries) {
const length = srcDataSeries.dataPoints_.length;
if (length >= 2) {
const lastDataPoint = srcDataSeries.dataPoints_[length - 1];
const secondLastDataPoint = srcDataSeries.dataPoints_[length - 2];
return Math.floor(
(lastDataPoint.value - secondLastDataPoint.value) * 1000 /
(lastDataPoint.time - secondLastDataPoint.time));
}
return 0;
};
// Converts the value of total bytes to bits per second.
const totalBytesToBitsPerSecond = function(srcDataSeries) {
return totalToPerSecond(srcDataSeries) * 8;
};
// Specifies which stats should be converted before drawn and how.
// |convertedName| is the name of the converted value, |convertFunction|
// is the function used to calculate the new converted value based on the
// original dataSeries.
const dataConversionConfig = {
packetsSent: {
convertedName: 'packetsSentPerSecond',
convertFunction: totalToPerSecond,
},
bytesSent: {
convertedName: 'bitsSentPerSecond',
convertFunction: totalBytesToBitsPerSecond,
},
packetsReceived: {
convertedName: 'packetsReceivedPerSecond',
convertFunction: totalToPerSecond,
},
bytesReceived: {
convertedName: 'bitsReceivedPerSecond',
convertFunction: totalBytesToBitsPerSecond,
},
// This is due to a bug of wrong units reported for googTargetEncBitrate.
// TODO (jiayl): remove this when the unit bug is fixed.
googTargetEncBitrate: {
convertedName: 'googTargetEncBitrateCorrected',
convertFunction(srcDataSeries) {
const length = srcDataSeries.dataPoints_.length;
const lastDataPoint = srcDataSeries.dataPoints_[length - 1];
if (lastDataPoint.value < 5000) {
return lastDataPoint.value * 1000;
}
return lastDataPoint.value;
}
}
};
// The object contains the stats names that should not be added to the graph,
// even if they are numbers.
const statsNameBlackList = {
'ssrc': true,
'googTrackId': true,
'googComponent': true,
'googLocalAddress': true,
'googRemoteAddress': true,
'googFingerprint': true,
};
function isStandardReportBlocklisted(report) {
// Codec stats reflect what has been negotiated. There are LOTS of them and
// they don't change over time on their own.
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 isStandardStatBlocklisted(report, statName) {
// The datachannelid is an identifier, but because it is a number it shows up
// as a graph if we don't blacklist it.
if (report.type === 'data-channel' && statName === 'datachannelid') {
return true;
}
// The priority does not change over time on its own; plotting uninteresting.
if (report.type === 'candidate-pair' && statName === 'priority') {
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 if the stats name is black-listed.
function getNumberFromValue(name, value) {
if (statsNameBlackList[name]) {
return NaN;
}
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, isLegacyReport) {
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];
// Propagation deltas are handled separately.
if (rawLabel === RECEIVED_PROPAGATION_DELTA_LABEL) {
drawReceivedPropagationDelta(
peerConnectionElement, report, stats.values[i + 1]);
continue;
}
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, rawDataSeriesId, rawLabel, [stats.timestamp],
[stats.values[i + 1]]);
continue;
}
let finalDataSeriesId = rawDataSeriesId;
let finalLabel = rawLabel;
let finalValue = rawValue;
// We need to convert the value if dataConversionConfig[rawLabel] exists.
if (isLegacyReport && dataConversionConfig[rawLabel]) {
// Updates the original dataSeries before the conversion.
addDataSeriesPoints(
peerConnectionElement, rawDataSeriesId, rawLabel, [stats.timestamp],
[rawValue]);
// Convert to another value to draw on graph, using the original
// dataSeries as input.
finalValue = dataConversionConfig[rawLabel].convertFunction(
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
rawDataSeriesId));
finalLabel = dataConversionConfig[rawLabel].convertedName;
finalDataSeriesId = reportId + '-' + finalLabel;
}
// Updates the final dataSeries to draw.
addDataSeriesPoints(
peerConnectionElement, finalDataSeriesId, finalLabel, [stats.timestamp],
[finalValue]);
if (!isLegacyReport &&
(isStandardReportBlocklisted(report) ||
isStandardStatBlocklisted(report, rawLabel))) {
// We do not want to draw certain standard reports but still want to
// record them in the data series.
continue;
}
// Updates the graph.
const graphType =
bweCompoundGraphConfig[finalLabel] ? 'bweCompound' : finalLabel;
const graphViewId =
peerConnectionElement.id + '-' + reportId + '-' + graphType;
if (!graphViews[graphViewId]) {
graphViews[graphViewId] =
createStatsGraphView(peerConnectionElement, report, graphType);
const date = new Date(stats.timestamp);
graphViews[graphViewId].setDateRange(date, date);
}
// 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();
}
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, dataSeriesId, label, times, values) {
let dataSeries =
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
dataSeriesId);
if (!dataSeries) {
dataSeries = new TimelineDataSeries();
peerConnectionDataStore[peerConnectionElement.id].setDataSeries(
dataSeriesId, dataSeries);
if (bweCompoundGraphConfig[label]) {
dataSeries.setColor(bweCompoundGraphConfig[label].color);
}
}
for (let i = 0; i < times.length; ++i) {
dataSeries.addPoint(times[i], values[i]);
}
}
// Draws the received propagation deltas using the packet group arrival time as
// the x-axis. For example, |report.stats.values| should be like
// ['googReceivedPacketGroupArrivalTimeDebug', '[123456, 234455, 344566]',
// 'googReceivedPacketGroupPropagationDeltaDebug', '[23, 45, 56]', ...].
function drawReceivedPropagationDelta(peerConnectionElement, report, deltas) {
const reportId = report.id;
const stats = report.stats;
let times = null;
// Find the packet group arrival times.
for (let i = 0; i < stats.values.length - 1; i = i + 2) {
if (stats.values[i] === RECEIVED_PACKET_GROUP_ARRIVAL_TIME_LABEL) {
times = stats.values[i + 1];
break;
}
}
// Unexpected.
if (times == null) {
return;
}
// Convert |deltas| and |times| from strings to arrays of numbers.
try {
deltas = JSON.parse(deltas);
times = JSON.parse(times);
} catch (e) {
console.log(e);
return;
}
// Update the data series.
const dataSeriesId = reportId + '-' + RECEIVED_PROPAGATION_DELTA_LABEL;
addDataSeriesPoints(
peerConnectionElement, dataSeriesId, RECEIVED_PROPAGATION_DELTA_LABEL,
times, deltas);
// Update the graph.
const graphViewId = peerConnectionElement.id + '-' + reportId + '-' +
RECEIVED_PROPAGATION_DELTA_LABEL;
const date = new Date(times[times.length - 1]);
if (!graphViews[graphViewId]) {
graphViews[graphViewId] = createStatsGraphView(
peerConnectionElement, report, RECEIVED_PROPAGATION_DELTA_LABEL);
graphViews[graphViewId].setScale(10);
graphViews[graphViewId].setDateRange(date, date);
const dataSeries =
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
dataSeriesId);
graphViews[graphViewId].addDataSeries(dataSeries);
}
graphViews[graphViewId].updateEndDate(date);
}
// Get report types for SSRC reports. Returns 'audio' or 'video' where this type
// can be deduced from existing stats labels. Otherwise empty string for
// non-SSRC reports or where type (audio/video) can't be deduced.
function getSsrcReportType(report) {
if (report.type !== 'ssrc') {
return '';
}
if (report.stats && report.stats.values) {
// Known stats keys for audio send/receive streams.
if (report.stats.values.indexOf('audioOutputLevel') !== -1 ||
report.stats.values.indexOf('audioInputLevel') !== -1) {
return 'audio';
}
// Known stats keys for video send/receive streams.
// TODO(pbos): Change to use some non-goog-prefixed stats when available for
// video.
if (report.stats.values.indexOf('googFrameRateReceived') !== -1 ||
report.stats.values.indexOf('googFrameRateSent') !== -1) {
return 'video';
}
}
return '';
}
// Ensures a div container to hold all stats graphs for one track is created as
// a child of |peerConnectionElement|.
function ensureStatsGraphTopContainer(peerConnectionElement, report) {
const containerId = peerConnectionElement.id + '-' + report.type + '-' +
report.id + '-graph-container';
let container = $(containerId);
if (!container) {
container = document.createElement('details');
container.id = containerId;
container.className = 'stats-graph-container';
peerConnectionElement.appendChild(container);
container.appendChild($('summary-span-template').content.cloneNode(true));
container.firstChild.firstChild.className =
STATS_GRAPH_CONTAINER_HEADING_CLASS;
container.firstChild.firstChild.textContent =
'Stats graphs for ' + report.id + ' (' + report.type + ')';
const statsType = getSsrcReportType(report);
if (statsType !== '') {
container.firstChild.firstChild.textContent += ' (' + statsType + ')';
}
if (report.type === 'ssrc') {
const ssrcInfoElement = document.createElement('div');
container.firstChild.appendChild(ssrcInfoElement);
ssrcInfoManager.populateSsrcInfo(
ssrcInfoElement, GetSsrcFromReport(report));
}
}
return container;
}
// Creates the container elements holding a timeline graph
// and the TimelineGraphView object.
function createStatsGraphView(peerConnectionElement, report, statsName) {
const topContainer =
ensureStatsGraphTopContainer(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);
if (statsName === 'bweCompound') {
container.insertBefore(
createBweCompoundLegend(peerConnectionElement, report.id), $(divId));
}
return new TimelineGraphView(divId, canvasId);
}
// Creates the legend section for the bweCompound graph.
// Returns the legend element.
function createBweCompoundLegend(peerConnectionElement, reportId) {
const legend = document.createElement('div');
for (const prop in bweCompoundGraphConfig) {
const div = document.createElement('div');
legend.appendChild(div);
div.appendChild($('checkbox-template').content.cloneNode(true));
div.appendChild(document.createTextNode(prop));
div.style.color = bweCompoundGraphConfig[prop].color;
div.dataSeriesId = reportId + '-' + prop;
div.graphViewId =
peerConnectionElement.id + '-' + reportId + '-bweCompound';
div.firstChild.addEventListener('click', event => {
const target =
peerConnectionDataStore[peerConnectionElement.id].getDataSeries(
event.target.parentNode.dataSeriesId);
target.show(event.target.checked);
graphViews[event.target.parentNode.graphViewId].repaint();
});
}
return legend;
}