owncast/web/components/video/metrics/playback.js

289 lines
7.8 KiB
JavaScript

/* eslint-disable no-plusplus */
const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
const METRICS_SEND_INTERVAL = 10000;
const MAX_VALID_LATENCY_SECONDS = 100; // Anything > this gets thrown out.
function getCurrentlyPlayingSegment(tech) {
const targetMedia = tech.vhs.playlists.media();
const snapshotTime = tech.currentTime();
let segment;
// Iterate trough available segments and get first within which snapshot_time is
// eslint-disable-next-line no-plusplus
for (let i = 0, l = targetMedia.segments.length; i < l; i++) {
// Note: segment.end may be undefined or is not properly set
if (snapshotTime < targetMedia.segments[i].end) {
segment = targetMedia.segments[i];
break;
}
}
if (!segment) {
[segment] = targetMedia.segments;
}
return segment;
}
class PlaybackMetrics {
constructor(player, videojs) {
this.player = player;
this.supportsDetailedMetrics = false;
this.hasPerformedInitialVariantChange = false;
this.clockSkewMs = 0;
this.segmentDownloadTime = [];
this.bandwidthTracking = [];
this.latencyTracking = [];
this.errors = 0;
this.qualityVariantChanges = 0;
this.isBuffering = false;
this.bufferingDurationTimer = 0;
this.collectPlaybackMetricsTimer = 0;
this.videoJSReady = this.videoJSReady.bind(this);
this.handlePlaying = this.handlePlaying.bind(this);
this.handleBuffering = this.handleBuffering.bind(this);
this.handleEnded = this.handleEnded.bind(this);
this.handleError = this.handleError.bind(this);
this.send = this.send.bind(this);
this.collectPlaybackMetrics = this.collectPlaybackMetrics.bind(this);
this.handleNoLongerBuffering = this.handleNoLongerBuffering.bind(this);
this.sendMetricsTimer = 0;
this.player.on('canplaythrough', this.handleNoLongerBuffering);
this.player.on('error', this.handleError);
this.player.on('stalled', this.handleBuffering);
this.player.on('waiting', this.handleBuffering);
this.player.on('playing', this.handlePlaying);
this.player.on('ended', this.handleEnded);
// Keep a reference of the standard vjs xhr function.
const oldVjsXhrCallback = videojs.xhr;
// Override the xhr function to track segment download time.
// eslint-disable-next-line no-param-reassign
videojs.Vhs.xhr = (...args) => {
if (args[0].uri.match('.ts')) {
const start = new Date();
const cb = args[1];
// eslint-disable-next-line no-param-reassign
args[1] = (request, error, response) => {
const end = new Date();
const delta = end.getTime() - start.getTime();
this.trackSegmentDownloadTime(delta);
cb(request, error, response);
};
}
return oldVjsXhrCallback(...args);
};
this.videoJSReady();
this.sendMetricsTimer = setInterval(() => {
this.send();
}, METRICS_SEND_INTERVAL);
}
stop() {
clearInterval(this.sendMetricsTimer);
this.player.off();
}
// Keep our client clock in sync with the server clock to determine
// accurate latency calculations.
setClockSkew(skewMs) {
this.clockSkewMs = skewMs;
}
videoJSReady() {
const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
this.supportsDetailedMetrics = !!tech;
tech.on('usage', e => {
if (e.name === 'vhs-unknown-waiting') {
this.setIsBuffering(true);
}
if (e.name === 'vhs-rendition-change-abr') {
// Quality variant has changed
this.incrementQualityVariantChanges();
}
});
// Variant changed
const trackElements = this.player.textTracks();
trackElements.addEventListener('cuechange', () => {
this.incrementQualityVariantChanges();
});
}
handlePlaying() {
clearInterval(this.collectPlaybackMetricsTimer);
this.collectPlaybackMetricsTimer = setInterval(() => {
this.collectPlaybackMetrics();
}, 5000);
}
handleEnded() {
clearInterval(this.collectPlaybackMetricsTimer);
}
handleBuffering() {
this.incrementErrorCount(1);
this.setIsBuffering(true);
}
handleNoLongerBuffering() {
this.setIsBuffering(false);
}
handleError() {
this.incrementErrorCount(1);
}
incrementErrorCount(count) {
this.errors += count;
}
incrementQualityVariantChanges() {
// We always start the player at the lowest quality, so let's just not
// count the first change.
if (!this.hasPerformedInitialVariantChange) {
this.hasPerformedInitialVariantChange = true;
return;
}
this.qualityVariantChanges++;
}
setIsBuffering(isBuffering) {
this.isBuffering = isBuffering;
if (!isBuffering) {
clearTimeout(this.bufferingDurationTimer);
return;
}
this.bufferingDurationTimer = setTimeout(() => {
this.incrementErrorCount(1);
}, 500);
}
trackSegmentDownloadTime(seconds) {
this.segmentDownloadTime.push(seconds);
}
trackBandwidth(bps) {
this.bandwidthTracking.push(bps);
}
trackLatency(latency) {
this.latencyTracking.push(latency);
}
collectPlaybackMetrics() {
const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
if (!tech || !tech.vhs) {
return;
}
// If we're paused then do nothing.
if (this.player.paused()) {
return;
}
// Network state 2 means we're actively using the network.
// We only care about reporting metrics with network activity stats
// if it's actively being used, so don't report otherwise.
const networkState = this.player.networkState();
if (networkState !== 2) {
return;
}
const bandwidth = tech.vhs.systemBandwidth;
this.trackBandwidth(bandwidth);
try {
const segment = getCurrentlyPlayingSegment(tech);
if (!segment || !segment.dateTimeObject) {
return;
}
const segmentTime = segment.dateTimeObject.getTime();
const now = new Date().getTime() + this.clockSkewMs;
const latency = now - segmentTime;
// Throw away values that seem invalid.
if (latency < 0 || latency / 1000 >= MAX_VALID_LATENCY_SECONDS) {
return;
}
this.trackLatency(latency);
} catch (err) {
console.warn(err);
}
}
async send() {
if (this.segmentDownloadTime.length === 0) {
return;
}
// If we're paused then do nothing.
if (!this.player || this.player.paused()) {
return;
}
const errorCount = this.errors;
let data;
if (this.supportsDetailedMetrics) {
const average = arr => arr.reduce((p, c) => p + c, 0) / arr.length;
const averageDownloadDuration = average(this.segmentDownloadTime) / 1000;
const roundedAverageDownloadDuration = Math.round(averageDownloadDuration * 1000) / 1000;
const averageBandwidth = average(this.bandwidthTracking) / 1000;
const roundedAverageBandwidth = Math.round(averageBandwidth * 1000) / 1000;
const averageLatency = average(this.latencyTracking) / 1000;
const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000;
data = {
bandwidth: roundedAverageBandwidth,
latency: roundedAverageLatency,
downloadDuration: roundedAverageDownloadDuration,
errors: errorCount + (this.isBuffering ? 1 : 0),
qualityVariantChanges: this.qualityVariantChanges,
};
} else {
data = {
errors: errorCount + (this.isBuffering ? 1 : 0),
};
}
this.errors = 0;
this.qualityVariantChanges = 0;
this.segmentDownloadTime = [];
this.bandwidthTracking = [];
this.latencyTracking = [];
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
};
try {
await fetch(URL_PLAYBACK_METRICS, options);
} catch (e) {
console.error(e);
}
}
}
export default PlaybackMetrics;