You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
289 lines
7.8 KiB
289 lines
7.8 KiB
/* 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;
|
|
|