Skip to content

Adding frontend metrics #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
38f216f
Storing the stats we wish to track. No actual post to any endpoint yet.
mcottontensor Mar 7, 2024
501b89b
Adding back example .env file
mcottontensor Mar 7, 2024
1bfe514
Stats being pushed to buc
mcottontensor Mar 12, 2024
ed0dca6
Trying to add things to a compose file for ease of deployment
mcottontensor Mar 14, 2024
f6fb57c
Working metrics reporting. Updated webpack serve to allow cross host …
mcottontensor Mar 14, 2024
30e781d
Adding enable/disable option for metrics reporting. ENABLE_METRICS in…
mcottontensor Mar 15, 2024
36ba1f0
Trying to fix line endings
mcottontensor Mar 15, 2024
2cf3108
Fixing up tabs. Fixing some other line endings.
mcottontensor Mar 15, 2024
333624b
Adding METRICS_URL.
mcottontensor Mar 15, 2024
df09f42
Moved out metrics reporting into its own class
mcottontensor Mar 15, 2024
5798b16
Cleaning up the reporting code. Better handling of session end.
mcottontensor Mar 15, 2024
ef72287
Fixing tab
mcottontensor Mar 15, 2024
7e5fb37
Stats are now collected and reported only on session end.
mcottontensor Mar 20, 2024
f0b89dc
Adding small comment on where the frontend image comes from
mcottontensor Mar 20, 2024
ba8c798
Fixing up the promtail config
mcottontensor Mar 20, 2024
340d82d
Fixing EMA calculations. Updating grafana dash.
mcottontensor Mar 21, 2024
97bf8ff
Reporting stats to prometheus (buccaneers stats endpoint) as well as …
mcottontensor Apr 3, 2024
4810c35
Updated grafana dashboard. Disabled prometheus reporting.
mcottontensor Apr 3, 2024
6a263ac
Session counting on dashboard. Disconnect reporting changes.
mcottontensor Apr 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**/node_modules
**/types
**/dist

2 changes: 1 addition & 1 deletion examples/typescript/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1 @@
WEBSOCKET_URL=ws://example.com/your/ws
WEBSOCKET_URL=ws://example.com/your/ws
4 changes: 3 additions & 1 deletion examples/typescript/webpack.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ module.exports = {
minimize: false
},
devServer: {
allowedHosts: "all",
static: {
directory: path.join(__dirname, 'dist'),
},
},
};
};

4 changes: 3 additions & 1 deletion examples/typescript/webpack.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ module.exports = merge(common, {
devtool: 'source-map',
plugins: [
new webpack.DefinePlugin({
WEBSOCKET_URL: JSON.stringify((process.env.WEBSOCKET_URL !== undefined) ? process.env.WEBSOCKET_URL : undefined)
WEBSOCKET_URL: JSON.stringify((process.env.WEBSOCKET_URL !== undefined) ? process.env.WEBSOCKET_URL : undefined),
ENABLE_METRICS: (process.env.ENABLE_METRICS !== undefined) ? process.env.ENABLE_METRICS : false,
BUCCANEER_URL: JSON.stringify((process.env.BUCCANEER_URL !== undefined) ? process.env.BUCCANEER_URL : undefined)
}),
]
});
51 changes: 42 additions & 9 deletions library/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@
"license": "MIT",
"dependencies": {
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.4": "^0.0.4",
"@epicgames-ps/lib-pixelstreamingfrontend-ui-ue5.4": "^0.0.4"
"@epicgames-ps/lib-pixelstreamingfrontend-ui-ue5.4": "^0.0.4",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/uuid": "^9.0.8",
"css-loader": "^6.7.3",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"path": "^0.12.7",
"ts-loader": "^9.4.2",
"typescript": "^4.9.4",
"webpack": "^5.76.1",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.11.1",
"typescript": "^4.9.4"
"webpack-dev-server": "^4.11.1"
}
}
}
235 changes: 235 additions & 0 deletions library/src/MetricsReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { AggregatedStats } from '@epicgames-ps/lib-pixelstreamingfrontend-ue5.4';
import { v4 as uuidv4 } from 'uuid';

declare var BUCCANEER_URL: string;

enum StatOperation {
Reset = 1,
Add,
Average,
Min,
Max
}

interface StatOptions {
description: string;
operation: StatOperation;
}

const SupportedStats : Record<string, StatOptions> = {
'video_width': { description: 'Video width', operation: StatOperation.Reset },
'video_height': { description: 'Video height', operation: StatOperation.Reset },
'video_bitrate': { description: 'Video bitrate', operation: StatOperation.Average },
'video_bitrate_min': { description: 'Min video bitrate', operation: StatOperation.Min },
'video_bitrate_max': { description: 'Max video bitrate', operation: StatOperation.Max },
'video_dropped': { description: 'Video frames dropped', operation: StatOperation.Reset },
'video_packets_lost': { description: 'Video packets lost', operation: StatOperation.Reset },
'video_fps': { description: 'Video frames per second', operation: StatOperation.Average },
'video_fps_min': { description: 'Min video frames per second', operation: StatOperation.Min },
'video_fps_max': { description: 'Max video frames per second', operation: StatOperation.Max },
'video_pli_count': { description: 'Video PLI count', operation: StatOperation.Reset },
'video_keyframes': { description: 'Video keyframes', operation: StatOperation.Reset },
'video_nack_count': { description: 'Video NACK count', operation: StatOperation.Reset },
'video_freeze_count': { description: 'Video freeze count', operation: StatOperation.Reset },
'video_jitter': { description: 'Video jitter', operation: StatOperation.Average },
'video_frame_count': { description: 'Video frame count', operation: StatOperation.Reset },
'audio_bitrate': { description: 'Audio bitrate', operation: StatOperation.Average },
'audio_bitrate_min': { description: 'Min audio bitrate', operation: StatOperation.Min },
'audio_bitrate_max': { description: 'Max audio bitrate', operation: StatOperation.Max },
'loading_duration': { description: 'Loading time', operation: StatOperation.Reset },
'session_duration': { description: 'Session time', operation: StatOperation.Reset }
}

export class MetricsReporter {
private stat_values: any;
private ema_samples: any;
private session_id: string | undefined;
private user_agent: string | undefined;
private loading_start: number | undefined;
private start_time: number | undefined;
private disconnect_code: string | undefined;
private disconnect_reason: string | undefined;

constructor() {
this.stat_values = {};
this.ema_samples = {};
}

startLoading() {
if (!this.loading_start) {
this.loading_start = Date.now();
}
}

startSession() {
// collect some session data
this.session_id = uuidv4();
this.user_agent = navigator.userAgent;
this.start_time = Date.now();

const loading_duration = this.start_time - (this.loading_start || this.start_time);
this.updateStatValue("loading_duration", loading_duration);
this.loading_start = undefined;
}

// note: code is currently left as undefined since the webrtcdisconnect event does not include
// the code but only the reason.
// a possible solution for it might be to use webSocketControllers close event which contains
// the code and reason from the signalling server but reason is sometimes set by the frontend.
// the real solution for this would be to update the pixel streaming library code to include
// the code also.
endSession(reason: string, code: string) {
if (!this.session_id) {
return;
}

// record end time
const session_duration = Date.now() - this.start_time;
this.updateStatValue("session_duration", session_duration);

// record end reason
this.disconnect_code = code;
this.disconnect_reason = reason;

this.postSessionData();

// clear session id which also indicates no session
this.session_id = undefined;
}

onSessionStats(aggregatedStats: AggregatedStats) {
if (!this.session_id) {
return;
}

// if sessionData is defined we can assume the session is active
if (aggregatedStats.inboundVideoStats) {
const video_stats = aggregatedStats.inboundVideoStats;
this.updateStatValue("video_width", video_stats.frameWidth);
this.updateStatValue("video_height", video_stats.frameHeight);
this.updateStatValue("video_bitrate", video_stats.bitrate);
this.updateStatValue("video_bitrate_min", video_stats.bitrate);
this.updateStatValue("video_bitrate_max", video_stats.bitrate);
this.updateStatValue("video_dropped", video_stats.framesDropped);
this.updateStatValue("video_packets_lost", video_stats.packetsLost);
// rtt?
this.updateStatValue("video_fps", video_stats.framesPerSecond);
this.updateStatValue("video_fps_min", video_stats.framesPerSecond);
this.updateStatValue("video_fps_max", video_stats.framesPerSecond);
this.updateStatValue("video_pli_count", video_stats.pliCount);
this.updateStatValue("video_keyframes", video_stats.keyFramesDecoded);
this.updateStatValue("video_nack_count", video_stats.nackCount);
this.updateStatValue("video_freeze_count", video_stats.freezeCount);
this.updateStatValue("video_jitter", video_stats.jitter);
this.updateStatValue("video_frame_count", video_stats.framesReceived);
}
if (aggregatedStats.inboundAudioStats) {
const audioStats = aggregatedStats.inboundAudioStats;
this.updateStatValue("audio_bitrate", audioStats.bitrate);
this.updateStatValue("audio_bitrate_min", audioStats.bitrate);
this.updateStatValue("audio_bitrate_max", audioStats.bitrate);
}
}

private calcMA(prev_value: number, num_samples: number, new_value: number): number {
const result = num_samples * prev_value + new_value;
return result / (num_samples + 1.0);
}

private calcEMA(prev_value: number, num_samples: number, new_value: number): number {
const K = 2 / (num_samples + 1);
return (new_value - prev_value) * K + prev_value;
}

private updateStatValue(name: string, value: number) {
if (value == null) {
return;
}

const stat_options = SupportedStats[name];
if (!stat_options) {
console.log(`Unknown stat ${name}`);
return;
}

if (stat_options.operation == StatOperation.Average) {
// Calculate EMA
if (this.stat_values[name]) {
const prev_value = this.stat_values[name];
const num_samples = this.ema_samples[name];
if (num_samples < 10) {
this.stat_values[name] = this.calcMA(prev_value, num_samples, value);
} else {
this.stat_values[name] = this.calcEMA(prev_value, num_samples, value);
}
this.ema_samples[name] += 1;
} else {
this.stat_values[name] = value;
this.ema_samples[name] = 1;
}
} else if (stat_options.operation == StatOperation.Add) {
this.stat_values[name] += value;
} else if (stat_options.operation == StatOperation.Min) {
if (!this.stat_values[name]) {
this.stat_values[name] = value;
} else {
this.stat_values[name] = Math.min(this.stat_values[name], value);
}
} else if (stat_options.operation == StatOperation.Max) {
if (!this.stat_values[name]) {
this.stat_values[name] = value;
} else {
this.stat_values[name] = Math.max(this.stat_values[name], value);
}
} else {
this.stat_values[name] = value;
}
}

private postSessionData() {
const session_data = {
id: this.session_id,
user_agent: this.user_agent,
disconnect_code: this.disconnect_code,
disconnect_reason: this.disconnect_reason,
stat_values: this.stat_values
}

// log session for loki

const events_url = `http://${BUCCANEER_URL || window.location.hostname}:8000/event`;
try {
const blob = new Blob([JSON.stringify(session_data)], { type: 'application/json; charset=UTF-8' });
navigator.sendBeacon(events_url, blob);
} catch (error) {
console.error(`Unable to POST session data to ${events_url}: ${error}`);
}

// i thought posting to prom would make grafana queries nicer but it was
// acting even weirder. if we need to post to prom we can reuse this code.

// // post session stats for prometheus

// const stats_package: any = {};
// for (const stat_name in this.stat_values) {
// stats_package[stat_name] = {
// description: SupportedStats[stat_name].description,
// value: this.stat_values[stat_name]
// };
// }

// const post_data = {
// id: this.session_id,
// metrics: stats_package
// };

// const stats_url = `http://${BUCCANEER_URL || window.location.hostname}:8000/stats`;
// try {
// const blob = new Blob([JSON.stringify(post_data)], { type: 'application/json; charset=UTF-8' });
// navigator.sendBeacon(stats_url, blob);
// } catch (error) {
// console.error(`Unable to POST stats data to ${stats_url}: ${error}`);
// }
}
}

Loading