1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
content / browser / webrtc / resources / stats_graph_helper.js [blame]
// 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';
}
});
}