// 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'; } }); }