Add playback performance metrics. Closes #1930

This commit is contained in:
Gabe Kangas 2022-06-02 14:23:51 -07:00
parent 04597908a5
commit 221b9c8f0f
No known key found for this signature in database
GPG Key ID: 9A56337728BC81EA
5 changed files with 68 additions and 39 deletions

View File

@ -82,6 +82,11 @@ export const fatalErrorStateAtom = atom<DisplayableError>({
default: null, default: null,
}); });
export const clockSkewAtom = atom<Number>({
key: 'clockSkewAtom',
default: 0.0,
});
// Chat is visible if the user wishes it to be visible AND the required // Chat is visible if the user wishes it to be visible AND the required
// chat state is set. // chat state is set.
export const isChatVisibleSelector = selector({ export const isChatVisibleSelector = selector({
@ -132,6 +137,7 @@ export function ClientConfigStore() {
const setChatDisplayName = useSetRecoilState<string>(chatDisplayNameAtom); const setChatDisplayName = useSetRecoilState<string>(chatDisplayNameAtom);
const setClientConfig = useSetRecoilState<ClientConfig>(clientConfigStateAtom); const setClientConfig = useSetRecoilState<ClientConfig>(clientConfigStateAtom);
const setServerStatus = useSetRecoilState<ServerStatus>(serverStatusState); const setServerStatus = useSetRecoilState<ServerStatus>(serverStatusState);
const setClockSkew = useSetRecoilState<Number>(clockSkewAtom);
const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom); const [chatMessages, setChatMessages] = useRecoilState<ChatMessage[]>(chatMessagesAtom);
const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom); const [accessToken, setAccessToken] = useRecoilState<string>(accessTokenAtom);
const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom); const setAppState = useSetRecoilState<AppStateOptions>(appStateAtom);
@ -170,6 +176,10 @@ export function ClientConfigStore() {
try { try {
const status = await ServerStatusService.getStatus(); const status = await ServerStatusService.getStatus();
setServerStatus(status); setServerStatus(status);
const { serverTime } = status;
const clockSkew = new Date(serverTime).getTime() - Date.now();
setClockSkew(clockSkew);
if (status.online) { if (status.online) {
sendEvent(AppStateEvent.Online); sendEvent(AppStateEvent.Online);

View File

@ -1,15 +1,17 @@
import React from 'react'; import React, { useEffect } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import VideoJS from './player'; import VideoJS from './player';
import ViewerPing from './viewer-ping'; import ViewerPing from './viewer-ping';
import VideoPoster from './VideoPoster'; import VideoPoster from './VideoPoster';
import { getLocalStorage, setLocalStorage } from '../../utils/localStorage'; import { getLocalStorage, setLocalStorage } from '../../utils/localStorage';
import { isVideoPlayingAtom } from '../stores/ClientConfigStore'; import { isVideoPlayingAtom, clockSkewAtom } from '../stores/ClientConfigStore';
import PlaybackMetrics from './metrics/playback';
const PLAYER_VOLUME = 'owncast_volume'; const PLAYER_VOLUME = 'owncast_volume';
const ping = new ViewerPing(); const ping = new ViewerPing();
let playbackMetrics = null;
interface Props { interface Props {
source: string; source: string;
@ -20,6 +22,7 @@ export default function OwncastPlayer(props: Props) {
const playerRef = React.useRef(null); const playerRef = React.useRef(null);
const { source, online } = props; const { source, online } = props;
const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom); const [videoPlaying, setVideoPlaying] = useRecoilState<boolean>(isVideoPlayingAtom);
const clockSkew = useRecoilValue<Number>(clockSkewAtom);
const setSavedVolume = () => { const setSavedVolume = () => {
try { try {
@ -113,7 +116,7 @@ export default function OwncastPlayer(props: Props) {
], ],
}; };
const handlePlayerReady = player => { const handlePlayerReady = (player, videojs) => {
playerRef.current = player; playerRef.current = player;
setSavedVolume(); setSavedVolume();
@ -147,8 +150,17 @@ export default function OwncastPlayer(props: Props) {
}); });
player.on('volumechange', handleVolume); player.on('volumechange', handleVolume);
playbackMetrics = new PlaybackMetrics(player, videojs);
playbackMetrics.setClockSkew(clockSkew);
}; };
useEffect(() => {
if (playbackMetrics) {
playbackMetrics.setClockSkew(clockSkew);
}
}, [clockSkew]);
return ( return (
<div style={{ display: 'grid' }}> <div style={{ display: 'grid' }}>
{online && ( {online && (

View File

@ -1,7 +1,30 @@
import { URL_PLAYBACK_METRICS } from '../utils/constants.js'; /* eslint-disable no-plusplus */
const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
const METRICS_SEND_INTERVAL = 10000; const METRICS_SEND_INTERVAL = 10000;
const MAX_VALID_LATENCY_SECONDS = 40; // Anything > this gets thrown out. const MAX_VALID_LATENCY_SECONDS = 40; // 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 { class PlaybackMetrics {
constructor(player, videojs) { constructor(player, videojs) {
this.player = player; this.player = player;
@ -37,11 +60,13 @@ class PlaybackMetrics {
const oldVjsXhrCallback = videojs.xhr; const oldVjsXhrCallback = videojs.xhr;
// Override the xhr function to track segment download time. // Override the xhr function to track segment download time.
// eslint-disable-next-line no-param-reassign
videojs.Vhs.xhr = (...args) => { videojs.Vhs.xhr = (...args) => {
if (args[0].uri.match('.ts')) { if (args[0].uri.match('.ts')) {
const start = new Date(); const start = new Date();
const cb = args[1]; const cb = args[1];
// eslint-disable-next-line no-param-reassign
args[1] = (request, error, response) => { args[1] = (request, error, response) => {
const end = new Date(); const end = new Date();
const delta = end.getTime() - start.getTime(); const delta = end.getTime() - start.getTime();
@ -70,7 +95,7 @@ class PlaybackMetrics {
const tech = this.player.tech({ IWillNotUseThisInPlugins: true }); const tech = this.player.tech({ IWillNotUseThisInPlugins: true });
this.supportsDetailedMetrics = !!tech; this.supportsDetailedMetrics = !!tech;
tech.on('usage', (e) => { tech.on('usage', e => {
if (e.name === 'vhs-unknown-waiting') { if (e.name === 'vhs-unknown-waiting') {
this.setIsBuffering(true); this.setIsBuffering(true);
} }
@ -83,7 +108,7 @@ class PlaybackMetrics {
// Variant changed // Variant changed
const trackElements = this.player.textTracks(); const trackElements = this.player.textTracks();
trackElements.addEventListener('cuechange', (c) => { trackElements.addEventListener('cuechange', () => {
this.incrementQualityVariantChanges(); this.incrementQualityVariantChanges();
}); });
} }
@ -99,7 +124,7 @@ class PlaybackMetrics {
clearInterval(this.collectPlaybackMetricsTimer); clearInterval(this.collectPlaybackMetricsTimer);
} }
handleBuffering(e) { handleBuffering() {
this.incrementErrorCount(1); this.incrementErrorCount(1);
this.setIsBuffering(true); this.setIsBuffering(true);
} }
@ -199,19 +224,22 @@ class PlaybackMetrics {
return; return;
} }
// If we're paused then do nothing.
if (this.player.paused()) {
return;
}
const errorCount = this.errors; const errorCount = this.errors;
var data; let data;
if (this.supportsDetailedMetrics) { if (this.supportsDetailedMetrics) {
const average = (arr) => arr.reduce((p, c) => p + c, 0) / arr.length; const average = arr => arr.reduce((p, c) => p + c, 0) / arr.length;
const averageDownloadDuration = average(this.segmentDownloadTime) / 1000; const averageDownloadDuration = average(this.segmentDownloadTime) / 1000;
const roundedAverageDownloadDuration = const roundedAverageDownloadDuration = Math.round(averageDownloadDuration * 1000) / 1000;
Math.round(averageDownloadDuration * 1000) / 1000;
const averageBandwidth = average(this.bandwidthTracking) / 1000; const averageBandwidth = average(this.bandwidthTracking) / 1000;
const roundedAverageBandwidth = const roundedAverageBandwidth = Math.round(averageBandwidth * 1000) / 1000;
Math.round(averageBandwidth * 1000) / 1000;
const averageLatency = average(this.latencyTracking) / 1000; const averageLatency = average(this.latencyTracking) / 1000;
const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000; const roundedAverageLatency = Math.round(averageLatency * 1000) / 1000;
@ -252,26 +280,3 @@ class PlaybackMetrics {
} }
export default PlaybackMetrics; export default PlaybackMetrics;
function getCurrentlyPlayingSegment(tech, old_segment = null) {
var target_media = tech.vhs.playlists.media();
var snapshot_time = tech.currentTime();
var segment;
// Iterate trough available segments and get first within which snapshot_time is
for (var i = 0, l = target_media.segments.length; i < l; i++) {
// Note: segment.end may be undefined or is not properly set
if (snapshot_time < target_media.segments[i].end) {
segment = target_media.segments[i];
break;
}
}
// Null segment_time in case it's lower then 0.
if (!segment) {
segment = target_media.segments[0];
segment_time = 0;
}
return segment;
}

View File

@ -9,7 +9,7 @@ require('video.js/dist/video-js.css');
// import { PLAYER_VOLUME, URL_STREAM } from '../../utils/constants.js'; // import { PLAYER_VOLUME, URL_STREAM } from '../../utils/constants.js';
interface Props { interface Props {
options: any; options: any;
onReady: (player: videojs.Player) => void; onReady: (player: videojs.Player, vjsInstance: videojs) => void;
} }
export function VideoJS(props: Props) { export function VideoJS(props: Props) {
@ -25,7 +25,7 @@ export function VideoJS(props: Props) {
// eslint-disable-next-line no-multi-assign // eslint-disable-next-line no-multi-assign
const player = (playerRef.current = videojs(videoElement, options, () => { const player = (playerRef.current = videojs(videoElement, options, () => {
player.log('player is ready'); player.log('player is ready');
return onReady && onReady(player); return onReady && onReady(player, videojs);
})); }));
// TODO: Add airplay support, video settings menu, latency compensator, etc. // TODO: Add airplay support, video settings menu, latency compensator, etc.

View File

@ -5,11 +5,13 @@ export interface ServerStatus {
lastDisconnectTime?: Date; lastDisconnectTime?: Date;
versionNumber?: string; versionNumber?: string;
streamTitle?: string; streamTitle?: string;
serverTime: Date;
} }
export function makeEmptyServerStatus(): ServerStatus { export function makeEmptyServerStatus(): ServerStatus {
return { return {
online: false, online: false,
viewerCount: 0, viewerCount: 0,
serverTime: new Date(),
}; };
} }