diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..6e2801ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +**/node_modules +**/types +**/dist + diff --git a/examples/typescript/.env.example b/examples/typescript/.env.example index f018a2a5..95a0d661 100644 --- a/examples/typescript/.env.example +++ b/examples/typescript/.env.example @@ -1 +1 @@ -WEBSOCKET_URL=ws://example.com/your/ws \ No newline at end of file +WEBSOCKET_URL=ws://example.com/your/ws diff --git a/examples/typescript/webpack.common.js b/examples/typescript/webpack.common.js index 454ec742..8e5730d4 100644 --- a/examples/typescript/webpack.common.js +++ b/examples/typescript/webpack.common.js @@ -61,8 +61,10 @@ module.exports = { minimize: false }, devServer: { + allowedHosts: "all", static: { directory: path.join(__dirname, 'dist'), }, }, -}; \ No newline at end of file +}; + diff --git a/examples/typescript/webpack.dev.js b/examples/typescript/webpack.dev.js index 6ac383ba..66950080 100644 --- a/examples/typescript/webpack.dev.js +++ b/examples/typescript/webpack.dev.js @@ -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) }), ] }); diff --git a/library/package-lock.json b/library/package-lock.json index 4c27c691..f728cb3f 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -10,9 +10,11 @@ "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", @@ -301,6 +303,12 @@ "@types/node": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "node_modules/@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", @@ -3489,6 +3497,15 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/sockjs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3881,10 +3898,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -4571,6 +4591,12 @@ "@types/node": "*" } }, + "@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true + }, "@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", @@ -6987,6 +7013,14 @@ "faye-websocket": "^0.11.3", "uuid": "^8.3.2", "websocket-driver": "^0.7.4" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } } }, "source-map": { @@ -7269,10 +7303,9 @@ "dev": true }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "vary": { "version": "1.1.2", diff --git a/library/package.json b/library/package.json index b0678aac..6e2d3151 100644 --- a/library/package.json +++ b/library/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/library/src/MetricsReporter.ts b/library/src/MetricsReporter.ts new file mode 100644 index 00000000..3a445696 --- /dev/null +++ b/library/src/MetricsReporter.ts @@ -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 = { + '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}`); + // } + } +} + diff --git a/library/src/SPSApplication.ts b/library/src/SPSApplication.ts index d225fa12..e59d25a6 100644 --- a/library/src/SPSApplication.ts +++ b/library/src/SPSApplication.ts @@ -3,15 +3,17 @@ import { AggregatedStats, SettingFlag, TextParameters } from '@epicgames-ps/lib- import { LoadingOverlay } from './LoadingOverlay'; import { SPSSignalling } from './SignallingExtension'; import { MessageStats } from './Messages'; +import { MetricsReporter } from './MetricsReporter'; // For local testing. Declare a websocket URL that can be imported via a .env file that will override // the signalling server URL builder. declare var WEBSOCKET_URL: string; - +declare var ENABLE_METRICS: boolean; export class SPSApplication extends Application { private loadingOverlay: LoadingOverlay; private signallingExtension: SPSSignalling; + private metrics_reporter: MetricsReporter; static Flags = class { static sendToServer = "sendStatsToServer" @@ -38,9 +40,20 @@ export class SPSApplication extends Application { spsSettingsSection.appendChild(new SettingUIFlag(sendStatsToServerSetting).rootElement); this.loadingOverlay = new LoadingOverlay(this.stream.videoElementParent); + if (ENABLE_METRICS) { + this.metrics_reporter = new MetricsReporter(); + // register the event when the stream starts. + this.stream.addEventListener('webRtcConnected', () => this.metrics_reporter.startSession() ); + // register the event when the browser closes or navigates away. + window.addEventListener('beforeunload', () => this.metrics_reporter.endSession("Navigated away", undefined)); + // register the event when the remote session ends. + this.stream.addEventListener('webRtcDisconnected', (e) => this.metrics_reporter.endSession(e.data.eventString, undefined)); + } + this.stream.addEventListener( 'statsReceived', ({ data: { aggregatedStats } }) => { + this.metrics_reporter?.onSessionStats(aggregatedStats); if (sendStatsToServerSetting.flag) { this.sendStatsToSignallingServer(aggregatedStats); } @@ -48,6 +61,7 @@ export class SPSApplication extends Application { ); } + handleSignallingResponse(signallingResp: string, isError: boolean) { if (isError) { this.showErrorOverlay(signallingResp); @@ -84,6 +98,8 @@ export class SPSApplication extends Application { // this.loadingOverlay.animate(); this.currentOverlay = this.loadingOverlay; + + this.metrics_reporter?.startLoading(); } /** @@ -95,3 +111,4 @@ export class SPSApplication extends Application { this.stream.webSocketController.webSocket.send(data.payload()); } } + diff --git a/library/webpack.common.js b/library/webpack.common.js index 59df1393..c1e47238 100644 --- a/library/webpack.common.js +++ b/library/webpack.common.js @@ -23,4 +23,4 @@ module.exports = { path: path.resolve(__dirname, 'dist'), globalObject: 'this' } -}; \ No newline at end of file +}; diff --git a/metrics/.gitignore b/metrics/.gitignore new file mode 100644 index 00000000..ab39cbce --- /dev/null +++ b/metrics/.gitignore @@ -0,0 +1 @@ +server/ diff --git a/metrics/Dashboards/Unreal Engine Metrics Dashboard.json b/metrics/Dashboards/Unreal Engine Metrics Dashboard.json new file mode 100644 index 00000000..4d231318 --- /dev/null +++ b/metrics/Dashboards/Unreal Engine Metrics Dashboard.json @@ -0,0 +1,872 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "enable": true, + "expr": "{stream=~\"Error|error\"}", + "iconColor": "red", + "instant": false, + "name": "Error", + "target": {}, + "textFormat": "", + "titleFormat": "" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "enable": true, + "expr": "{stream=~\"Log|log\"}", + "iconColor": "blue", + "name": "Info", + "target": {} + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "enable": true, + "expr": "{stream=~\"Warning|warning\"}", + "iconColor": "orange", + "name": "Warning", + "target": {} + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 39, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "sum(count_over_time({job=\"logs\"} | json [$__range]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Total Sessions", + "transformations": [ + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "number", + "targetField": "Line" + } + ], + "fields": {} + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 38, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "sum by() (sum_over_time({job=\"logs\"} | json | unwrap stat_values_session_duration [$__range]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Total Session Time", + "transformations": [ + { + "id": "convertFieldType", + "options": { + "conversions": [ + { + "destinationType": "number", + "targetField": "Line" + } + ], + "fields": {} + } + } + ], + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 36, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.loading_duration\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Average", + "queryType": "range", + "refId": "A", + "step": "" + } + ], + "title": "Loading Times", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 37, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.session_duration\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Average", + "queryType": "range", + "refId": "A", + "step": "" + } + ], + "title": "Session Times", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 31, + "options": { + "displayMode": "gradient", + "maxVizHeight": 300, + "minVizHeight": 16, + "minVizWidth": 8, + "namePlacement": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showUnfilled": true, + "sizing": "auto", + "valueMode": "color" + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "sum by(disconnect_reason) (count_over_time({filename=\"/EventsServer/event.log\"} | json | line_format `{{.disconnect_reason}}` | __error__=`` [$__range]))", + "legendFormat": "{{disconnect_reason}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Disconnect Reasons", + "type": "bargauge" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 33, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_bitrate\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Average", + "queryType": "range", + "refId": "A", + "step": "" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_bitrate_min\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Min", + "queryType": "range", + "refId": "B" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_bitrate_max\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Max", + "queryType": "range", + "refId": "C" + } + ], + "title": "Video Bitrate", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 35, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_fps\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Average", + "queryType": "range", + "refId": "A", + "step": "" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_fps_min\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Min", + "queryType": "range", + "refId": "B" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "code", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.video_fps_max\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Max", + "queryType": "range", + "refId": "C" + } + ], + "title": "Frames Per Second", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 34, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "10.4.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.audio_bitrate\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Average", + "queryType": "range", + "refId": "A", + "step": "" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.audio_bitrate_min\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Min", + "queryType": "range", + "refId": "B" + }, + { + "datasource": { + "type": "loki", + "uid": "P8E80F9AEF21F6940" + }, + "editorMode": "builder", + "expr": "avg(avg_over_time({job=\"logs\"} | json value=\"stat_values.audio_bitrate_max\" | line_format `{{.value}}` | unwrap value | __error__=`` [10m]))", + "hide": false, + "legendFormat": "Max", + "queryType": "range", + "refId": "C" + } + ], + "title": "Audio Bitrate", + "type": "timeseries" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-30m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Unreal Engine Metrics", + "uid": "LZLaM4tnk", + "version": 1, + "weekStart": "" +} + diff --git a/metrics/Dockerfile b/metrics/Dockerfile new file mode 100644 index 00000000..6d0533e9 --- /dev/null +++ b/metrics/Dockerfile @@ -0,0 +1,18 @@ +FROM node:lts-slim + +COPY /library /library +COPY /examples/typescript /www + +WORKDIR /library +RUN npm ci +RUN npm run build-dev + +WORKDIR /www +RUN npm ci +RUN npm link /library + +EXPOSE 8080 + +WORKDIR /www +CMD npm run serve-dev + diff --git a/metrics/docker-compose.yml b/metrics/docker-compose.yml new file mode 100644 index 00000000..fec237ee --- /dev/null +++ b/metrics/docker-compose.yml @@ -0,0 +1,50 @@ + +services: + frontend: + # sps docker image wth metrics changes from metrics/Dockerfile + image: "mcottontensor/sps-frontend-metrics" + container_name: frontend + network_mode: "host" + + buccaneerserver: + # https://github.com/mcottontensor/Buccaneer/tree/sps_metrics + image: "mcottontensor/sps-buccaneerserver" + container_name: buccaneerserver + network_mode: "host" + volumes: + - "./server:/app" + + prometheus: + image: "prom/prometheus" + container_name: prometheus + network_mode: "host" + volumes: + - "./prometheus.yml:/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana + container_name: grafana + network_mode: "host" + volumes: + - "./grafana-dashboard-config.yaml:/etc/grafana/provisioning/dashboards/grafana-dashboard-config.yaml" + - "./grafana-datasource-config.yaml:/etc/grafana/provisioning/datasources/grafana-datasource-config.yaml" + - "./Dashboards/:/etc/dashboards" + + loki: + image: grafana/loki + container_name: loki + network_mode: "host" + volumes: + - "./loki-local-config.yaml:/etc/loki/loki-local-config.yaml" + + promtail: + image: grafana/promtail + container_name: promtail + command: -config.file=/etc/promtail/promtail-local-config.yaml + network_mode: "host" + volumes: + - "./promtail-local-config.yaml:/etc/promtail/promtail-local-config.yaml" + - "./server:/EventsServer" + +volumes: + eventslogs: diff --git a/metrics/grafana-dashboard-config.yaml b/metrics/grafana-dashboard-config.yaml new file mode 100644 index 00000000..9fc8aa71 --- /dev/null +++ b/metrics/grafana-dashboard-config.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: dashboards + type: file + updateIntervalSeconds: 30 + options: + path: /etc/dashboards + foldersFromFilesStructure: true diff --git a/metrics/grafana-datasource-config.yaml b/metrics/grafana-datasource-config.yaml new file mode 100644 index 00000000..7ecdd739 --- /dev/null +++ b/metrics/grafana-datasource-config.yaml @@ -0,0 +1,14 @@ +apiVersion: 1 + +datasources: +- name: Loki + type: loki + access: proxy + url: http://127.0.0.1:3100 + jsonData: + maxLines: 1000 +- name: Prometheus + type: prometheus + access: proxy + httpMethod: POST + url: http://127.0.0.1:9090 diff --git a/metrics/loki-local-config.yaml b/metrics/loki-local-config.yaml new file mode 100644 index 00000000..f396eef5 --- /dev/null +++ b/metrics/loki-local-config.yaml @@ -0,0 +1,30 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://127.0.0.1:9093 diff --git a/metrics/prometheus.yml b/metrics/prometheus.yml new file mode 100644 index 00000000..0d836099 --- /dev/null +++ b/metrics/prometheus.yml @@ -0,0 +1,9 @@ +# my global config +global: + scrape_interval: 2s # Set the scrape interval to every 15 seconds. Default is every 1 minute. + evaluation_interval: 2s # Evaluate rules every 15 seconds. The default is every 1 minute. + +scrape_configs: + - job_name: "realtime_stats" + static_configs: + - targets: ["localhost:8000"] diff --git a/metrics/promtail-local-config.yaml b/metrics/promtail-local-config.yaml new file mode 100644 index 00000000..f88456c8 --- /dev/null +++ b/metrics/promtail-local-config.yaml @@ -0,0 +1,31 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://127.0.0.1:3100/loki/api/v1/push + +scrape_configs: +- job_name: system + + static_configs: + - targets: + - localhost + labels: + job: logs + __path__: "/EventsServer/*.log" + + pipeline_stages: + - json: + expressions: + timestamp: time + instance: id + - labels: + instance: + - timestamp: + source: timestamp + format: RFC3339Nano + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..9727862d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "SPSFrontend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}