Skip to content

Commit 25aae06

Browse files
committed
Save webrtc stats to csv and auto download
1 parent bb883db commit 25aae06

File tree

3 files changed

+212
-0
lines changed

3 files changed

+212
-0
lines changed

Frontend/library/src/PeerConnectionController/AggregatedStats.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export class AggregatedStats {
3434
constructor() {
3535
this.inboundVideoStats = new InboundVideoStats();
3636
this.inboundAudioStats = new InboundAudioStats();
37+
this.candidatePairs = new Array<CandidatePairStats>();
3738
this.datachannelStats = new DataChannelStats();
39+
this.localCandidates = new Array<CandidateStat>();
40+
this.remoteCandidates = new Array<CandidateStat>();
3841
this.outboundVideoStats = new OutboundRTPStats();
3942
this.outboundAudioStats = new OutboundRTPStats();
4043
this.remoteOutboundAudioStats = new RemoteOutboundRTPStats();
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// Copyright Epic Games, Inc. All Rights Reserved.
2+
3+
import {
4+
AggregatedStats,
5+
InboundVideoStats,
6+
InboundAudioStats,
7+
Logger,
8+
SettingNumber,
9+
CandidatePairStats,
10+
DataChannelStats,
11+
OutboundRTPStats,
12+
CandidateStat,
13+
RemoteOutboundRTPStats
14+
} from '@epicgames-ps/lib-pixelstreamingfrontend-ue5.5';
15+
import { StatsSections } from './UIConfigurationTypes';
16+
import { SettingUINumber } from '../Config/SettingUINumber';
17+
import { InboundRTPStats } from '@epicgames-ps/lib-pixelstreamingfrontend-ue5.5/dist/types/PeerConnectionController/InboundRTPStats';
18+
19+
type InboundRTPStatsKeys = Exclude<keyof typeof InboundRTPStats, 'prototype'>;
20+
type InboundRTPStatsIds = (typeof InboundRTPStats)[InboundRTPStatsKeys];
21+
22+
type InboundVideoStatsKeys = Exclude<keyof typeof InboundVideoStats, 'prototype'>;
23+
type InboundVideoStatsIds = (typeof InboundVideoStats)[InboundVideoStatsKeys];
24+
25+
type InboundAudioStatsKeys = Exclude<keyof typeof InboundAudioStats, 'prototype'>;
26+
type InboundAudioStatsIds = (typeof InboundAudioStats)[InboundAudioStatsKeys];
27+
28+
type CandidatePairStatsKeys = Exclude<keyof typeof CandidatePairStats, 'prototype'>;
29+
type CandidatePairStatsIds = (typeof CandidatePairStats)[CandidatePairStatsKeys];
30+
31+
type DataChannelStatsKeys = Exclude<keyof typeof DataChannelStats, 'prototype'>;
32+
type DataChannelStatsIds = (typeof DataChannelStats)[DataChannelStatsKeys];
33+
34+
type CandidateStatKeys = Exclude<keyof typeof CandidateStat, 'prototype'>;
35+
type CandidateStatKeysIds = (typeof CandidateStat)[CandidateStatKeys];
36+
37+
type OutboundRTPStatsKeys = Exclude<keyof typeof OutboundRTPStats, 'prototype'>;
38+
type OutboundRTPStatsIds = (typeof OutboundRTPStats)[OutboundRTPStatsKeys];
39+
40+
type RemoteOutboundRTPStatsKeys = Exclude<keyof typeof RemoteOutboundRTPStats, 'prototype'>;
41+
type RemoteOutboundRTPStatsIds = (typeof RemoteOutboundRTPStats)[RemoteOutboundRTPStatsKeys];
42+
43+
type StatsIds =
44+
| InboundRTPStatsIds
45+
| InboundVideoStatsIds
46+
| InboundAudioStatsIds
47+
| CandidatePairStatsIds
48+
| DataChannelStatsIds
49+
| OutboundRTPStatsIds
50+
| CandidateStatKeysIds
51+
| RemoteOutboundRTPStatsIds;
52+
53+
type AggregatedStatsKeys = Exclude<keyof typeof AggregatedStats, 'prototype'>;
54+
type AggregatedStatsIds = (typeof AggregatedStats)[AggregatedStatsKeys];
55+
56+
type TempIds = AggregatedStatsIds | StatsIds;
57+
58+
type ElementType<T extends Iterable<any>> = T extends Iterable<infer E> ? E : never;
59+
/**
60+
* Session test UI elements and results handling.
61+
*/
62+
export class SessionTest {
63+
_rootElement: HTMLElement;
64+
_latencyTestButton: HTMLInputElement;
65+
_testTimeFrameSetting: SettingNumber<'TestTimeFrame'>;
66+
67+
isCollectingStats: boolean;
68+
69+
records: AggregatedStats[];
70+
71+
constructor() {
72+
this.isCollectingStats = false;
73+
}
74+
75+
/**
76+
* Get the the button containing the stats icon.
77+
*/
78+
public get rootElement(): HTMLElement {
79+
if (!this._rootElement) {
80+
this._rootElement = document.createElement('section');
81+
this._rootElement.classList.add('settingsContainer');
82+
83+
// make heading
84+
const heading = document.createElement('div');
85+
heading.id = 'latencyTestHeader';
86+
heading.classList.add('settings-text');
87+
heading.classList.add('settingsHeader');
88+
this._rootElement.appendChild(heading);
89+
90+
const headingText = document.createElement('div');
91+
headingText.innerHTML = 'Session Test';
92+
heading.appendChild(headingText);
93+
94+
// make test results element
95+
const resultsParentElem = document.createElement('div');
96+
resultsParentElem.id = 'latencyTestContainer';
97+
resultsParentElem.classList.add('d-none');
98+
this._rootElement.appendChild(resultsParentElem);
99+
100+
this._testTimeFrameSetting = new SettingNumber(
101+
'TestTimeFrame',
102+
'Test Time Frame',
103+
'How long the test runs for (seconds)',
104+
0 /*min*/,
105+
3600 /*max*/,
106+
1 /*default*/,
107+
false
108+
);
109+
const testTimeFrameSetting = new SettingUINumber(this._testTimeFrameSetting);
110+
resultsParentElem.appendChild(testTimeFrameSetting.rootElement);
111+
resultsParentElem.appendChild(this.latencyTestButton);
112+
}
113+
return this._rootElement;
114+
}
115+
116+
public get latencyTestButton(): HTMLInputElement {
117+
if (!this._latencyTestButton) {
118+
this._latencyTestButton = document.createElement('input');
119+
this._latencyTestButton.type = 'button';
120+
this._latencyTestButton.value = 'Run Test';
121+
this._latencyTestButton.id = 'btn-start-latency-test';
122+
this._latencyTestButton.classList.add('streamTools-button');
123+
this._latencyTestButton.classList.add('btn-flat');
124+
125+
this._latencyTestButton.onclick = () => {
126+
this.records = [];
127+
this.isCollectingStats = true;
128+
Logger.Warning(`Starting session test. Duration: [${this._testTimeFrameSetting.number}]`);
129+
setTimeout(() => {
130+
this.onCollectingFinished();
131+
}, this._testTimeFrameSetting.number * 1000);
132+
};
133+
}
134+
return this._latencyTestButton;
135+
}
136+
137+
public handleStats(stats: AggregatedStats) {
138+
if (!this.isCollectingStats) {
139+
return;
140+
}
141+
142+
this.records.push(stats);
143+
}
144+
145+
private onCollectingFinished() {
146+
this.isCollectingStats = false;
147+
Logger.Warning(`Finished session test`);
148+
149+
const csvHeader: string[] = [];
150+
let csvBody = '';
151+
152+
this.records.forEach((record) => {
153+
for (const i in record) {
154+
const obj: {} = record[i as AggregatedStatsIds];
155+
156+
if (Array.isArray(obj)) {
157+
for (const j in obj) {
158+
const arrayVal = obj[j];
159+
for (const k in arrayVal) {
160+
if (csvHeader.indexOf(`${i}.${j}.${k}`) === -1) {
161+
csvHeader.push(`${i}.${j}.${k}`);
162+
}
163+
164+
csvBody += `"${arrayVal[k]}",`;
165+
}
166+
}
167+
} else if (obj instanceof Map) {
168+
for (const j in obj.keys()) {
169+
const mapVal = obj.get(j);
170+
for (const k in mapVal) {
171+
if (csvHeader.indexOf(`${i}.${j}.${k}`) === -1) {
172+
csvHeader.push(`${i}.${j}.${k}`);
173+
}
174+
175+
csvBody += `"${mapVal[k]}",`;
176+
}
177+
}
178+
} else {
179+
for (const j in obj) {
180+
if (csvHeader.indexOf(`${i}.${j}`) === -1) {
181+
csvHeader.push(`${i}.${j}`);
182+
}
183+
184+
csvBody += `"${obj[j as StatsIds]}",`;
185+
}
186+
}
187+
}
188+
csvBody += '\n';
189+
});
190+
191+
const file = new Blob([`${csvHeader.join(',')}\n${csvBody}`], { type: 'text/plain' });
192+
const a = document.createElement('a');
193+
const url = URL.createObjectURL(file);
194+
a.href = url;
195+
a.download = 'test_results.csv';
196+
document.body.appendChild(a);
197+
a.click();
198+
setTimeout(function () {
199+
document.body.removeChild(a);
200+
window.URL.revokeObjectURL(url);
201+
}, 0);
202+
}
203+
}

Frontend/ui-library/src/UI/StatsPanel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { AggregatedStats } from '@epicgames-ps/lib-pixelstreamingfrontend-ue5.5';
1212
import { MathUtils } from '../Util/MathUtils';
1313
import { DataChannelLatencyTest } from './DataChannelLatencyTest';
14+
import { SessionTest } from './SessionTest';
1415
import {
1516
isSectionEnabled,
1617
StatsSections,
@@ -41,6 +42,7 @@ export class StatsPanel {
4142
_latencyResult: HTMLElement;
4243
_config: StatsPanelConfiguration;
4344

45+
sessionTest: SessionTest;
4446
latencyTest: LatencyTest;
4547
dataChannelLatencyTest: DataChannelLatencyTest;
4648

@@ -50,6 +52,7 @@ export class StatsPanel {
5052
constructor(config: StatsPanelConfiguration) {
5153
this._config = config;
5254

55+
this.sessionTest = new SessionTest();
5356
this.latencyTest = new LatencyTest();
5457
this.dataChannelLatencyTest = new DataChannelLatencyTest();
5558
}
@@ -111,6 +114,7 @@ export class StatsPanel {
111114

112115
this._statsContentElement.appendChild(streamToolStats);
113116
streamToolStats.appendChild(controlStats);
117+
controlStats.appendChild(this.sessionTest.rootElement);
114118
controlStats.appendChild(statistics);
115119
controlStats.appendChild(latencyStats);
116120

@@ -267,6 +271,8 @@ export class StatsPanel {
267271
maximumFractionDigits: 0
268272
});
269273

274+
this.sessionTest.handleStats(stats);
275+
270276
// Inbound data
271277
const inboundData = MathUtils.formatBytes(stats.inboundVideoStats.bytesReceived, 2);
272278
this.addOrUpdateSessionStat('InboundDataStat', 'Received', inboundData);

0 commit comments

Comments
 (0)