/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const CHECK_RTC_STATS_COLLECTION = [ "inboundRtpStreamStats", "outboundRtpStreamStats", "remoteInboundRtpStreamStats", "remoteOutboundRtpStreamStats", ]; const DEFAULT_PROPS = { avgPoints: 10, histSecs: 15, toRate: false, noAvg: false, fixedPointDecimals: 2, toHuman: false, }; const REMOTE_RTP_PROPS = "avgPoints=2;histSecs=90"; const GRAPH_KEYS = [ "inbound-rtp.framesPerSecond;noAvg", "inbound-rtp.packetsReceived;toRate", "inbound-rtp.packetsLost;toRate", "inbound-rtp.jitter;fixedPointDecimals=4", `remote-inbound-rtp.roundTripTime;${REMOTE_RTP_PROPS}`, `remote-inbound-rtp.packetsReceived;toRate;${REMOTE_RTP_PROPS}`, "outbound-rtp.packetsSent;toRate", "outbound-rtp.nackCount", "outbound-rtp.pliCount", "outbound-rtp.firCount", `remote-outbound-rtp.bytesSent;toHuman;toRate;${REMOTE_RTP_PROPS}`, `remote-outbound-rtp.packetsSent;toRate;${REMOTE_RTP_PROPS}`, ] .map(k => k.split(".", 2)) .reduce((mapOfArr, [k, rest]) => { mapOfArr[k] ??= []; const [subKey, ...conf] = rest.split(";"); let config = conf.reduce((c, v) => { let [configName, ...configVal] = v.split("=", 2); c[configName] = !configVal.length ? true : configVal[0]; return c; }, {}); mapOfArr[k].push({ subKey, config }); return mapOfArr; }, {}); // Sliding window iterator of size n (where: n >= 1) over the array. // Only returns full windows. // Returns [] if n > array.length. // eachN(['a','b','c','d','e'], 3) will yield the following values: // ['a','b','c'], ['b','c','d'], and ['c','d','e'] const eachN = (array, n) => { return { // Index state index: 0, // Iteration function next() { let slice = array.slice(this.index, this.index + n); this.index++; // Done is true _AFTER_ the last value has returned. // When done is true, value is ignored. return { value: slice, done: slice.length < n }; }, [Symbol.iterator]() { return this; }, }; }; const msToSec = ms => 1000 * ms; // // A subset of the graph data // class GraphDataSet { constructor(dataPoints) { this.dataPoints = dataPoints; } // The latest latest = () => (this.dataPoints ? this.dataPoints.slice(-1)[0] : undefined); earliest = () => (this.dataPoints ? this.dataPoints[0] : undefined); // The returns the earliest time to graph startTime = () => (this.earliest() || { time: 0 }).time; // Returns the latest time to graph stopTime = () => (this.latest() || { time: 0 }).time; // Elapsed time within the display window elapsed = () => this.dataPoints ? this.latest().time - this.earliest().time : 0; // Return a new data set that has been filtered filter = fn => new GraphDataSet([...this.dataPoints].filter(fn)); // The range of values in the set or or undefined if the set is empty dataRange = () => this.dataPoints.reduce( ({ min, max }, { value }) => ({ min: Math.min(min, value), max: Math.max(max, value), }), this.dataPoints.length ? { min: this.dataPoints[0].value, max: this.dataPoints[0].value } : undefined ); // Get the rates between points. By definition the rates will have // one fewer data points. toRateDataSet = () => new GraphDataSet( [...eachN(this.dataPoints, 2)].map(([a, b]) => ({ // Time mid point time: (b.time + a.time) / 2, value: msToSec(b.value - a.value) / (b.time - a.time), })) ); average = samples => samples.reduce( ({ time, value }, { time: t, value: v }) => ({ time: time + t / samples.length, value: value + v / samples.length, }), { time: 0, value: 0 } ); toRollingAverageDataSet = sampleSize => new GraphDataSet([...eachN(this.dataPoints, sampleSize)].map(this.average)); } class GraphData { constructor(id, key, subKey, config) { this.id = id; this.key = key; this.subKey = subKey; this.data = []; this.config = Object.assign({}, DEFAULT_PROPS, config); } setValueForTime(dataPoint) { this.data = this.data.filter(({ time: t }) => t != dataPoint.time); this.data.push(dataPoint); } getValuesSince = time => this.data.filter(dp => dp.time > time); getDataSetSince = time => new GraphDataSet(this.data.filter(dp => dp.time > time)); getConfig = () => this.config; // Cull old data, but keep twice the window size for average computation cullData = timeNow => (this.data = this.data.filter( ({ time }) => time + msToSec(this.config.histSecs * 2) > timeNow )); } class GraphDb { constructor(report) { this.graphDatas = new Map(); this.insertReportData(report); } mkStoreKey = ({ id, key, subKey }) => `${key}.${id}.${subKey}`; insertDataPoint(id, key, subKey, config, time, value) { let storeKey = this.mkStoreKey({ id, key, subKey }); let data = this.graphDatas.get(storeKey) || new GraphData(id, key, subKey, config); data.setValueForTime({ time, value }); data.cullData(time); this.graphDatas.set(storeKey, data); } insertReportData(report) { if (report.timestamp == this.lastReportTimestamp) { return; } this.lastReportTimestamp = report.timestamp; CHECK_RTC_STATS_COLLECTION.forEach(listName => { (report[listName] || []).forEach(stats => { (GRAPH_KEYS[stats.type] || []).forEach(({ subKey, config }) => { if (stats[subKey] !== undefined) { this.insertDataPoint( stats.id, stats.type, subKey, config, stats.timestamp, stats[subKey] ); } }); }); }); } getGraphDataById = id => [...this.graphDatas.values()].filter(gd => gd.id == id); } export { GraphDb };