Skip to content

Commit de3c7ad

Browse files
authored
MM-57878 Add PerformanceReporter for clientside performance metrics (mattermost#26800)
* Define rough code for PerformanceReporter * Create a component to manage the PerformanceReporter * Start adding tests for PerformanceReporter * Add test for web vitals reporting * Update schema to more closely match the API spec * Collect marks as counters and further update structure of API payload * Add some outstanding TODOs about the API structure * Add counter for long tasks * Add EnableClientMetrics without any System Console UI * Have PerformanceReporter use EnableClientMetrics * Have the PerformanceReporter only report results when logged in * Add test for having PerformanceReporter fall back to fetch * Stop logging errors for measurements failing * Remove buffered from observer * Remove the Mystery Ampersand * Still record marks with telemetry actions even if telemetry is disabled * Add timestamps to performance reports * Reuse the new telemetry code for the old telemetry * The second half of the last commit * Use Node performance libraries in all tests * Set version of PerformanceReport * Switch to the proper version of EnableClientMetrics * Remove TODO for unneeded field * Add user agent and platform detection * Updated metrics API route
1 parent d6a8ad0 commit de3c7ad

File tree

22 files changed

+1188
-67
lines changed

22 files changed

+1188
-67
lines changed

server/config/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m
261261
props["IosMinVersion"] = c.ClientRequirements.IosMinVersion
262262

263263
props["EnableDiagnostics"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
264+
props["EnableClientMetrics"] = strconv.FormatBool(*c.MetricsSettings.EnableClientMetrics)
264265

265266
props["EnableComplianceExport"] = strconv.FormatBool(*c.MessageExportSettings.EnableExport)
266267

server/platform/services/telemetry/telemetry.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -758,8 +758,9 @@ func (ts *TelemetryService) trackConfig() {
758758
})
759759

760760
ts.SendTelemetry(TrackConfigMetrics, map[string]any{
761-
"enable": *cfg.MetricsSettings.Enable,
762-
"block_profile_rate": *cfg.MetricsSettings.BlockProfileRate,
761+
"enable": *cfg.MetricsSettings.Enable,
762+
"block_profile_rate": *cfg.MetricsSettings.BlockProfileRate,
763+
"enable_client_metrics": *cfg.MetricsSettings.EnableClientMetrics,
763764
})
764765

765766
ts.SendTelemetry(TrackConfigNativeApp, map[string]any{

webapp/channels/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"tinycolor2": "1.4.2",
9999
"turndown": "7.1.1",
100100
"typescript": "5.3.3",
101+
"web-vitals": "3.5.2",
101102
"zen-observable": "0.9.0"
102103
},
103104
"devDependencies": {

webapp/channels/src/actions/telemetry_actions.jsx

Lines changed: 9 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,6 @@ import {getBool} from 'mattermost-redux/selectors/entities/preferences';
1010
import {isDevModeEnabled} from 'selectors/general';
1111
import store from 'stores/redux_store';
1212

13-
const SUPPORTS_CLEAR_MARKS = isSupported([performance.clearMarks]);
14-
const SUPPORTS_MARK = isSupported([performance.mark]);
15-
const SUPPORTS_MEASURE_METHODS = isSupported([
16-
performance.measure,
17-
performance.getEntries,
18-
performance.getEntriesByName,
19-
performance.clearMeasures,
20-
]);
21-
2213
const HEADER_X_PAGE_LOAD_CONTEXT = 'X-Page-Load-Context';
2314

2415
export function isTelemetryEnabled(state) {
@@ -58,59 +49,37 @@ export function pageVisited(category, name) {
5849
*
5950
*/
6051
export function clearMarks(names) {
61-
if (!shouldTrackPerformance() || !SUPPORTS_CLEAR_MARKS) {
62-
return;
63-
}
6452
names.forEach((name) => performance.clearMarks(name));
6553
}
6654

6755
export function mark(name) {
68-
if (!shouldTrackPerformance() || !SUPPORTS_MARK) {
56+
performance.mark(name);
57+
58+
if (!shouldTrackPerformance()) {
6959
return;
7060
}
71-
performance.mark(name);
7261

7362
initRequestCountingIfNecessary();
7463
updateRequestCountAtMark(name);
7564
}
7665

7766
/**
78-
* Takes the names of two markers and invokes performance.measure on
79-
* them. The measured duration (ms) and the string name of the measure is
80-
* are returned.
67+
* Takes the names of two markers and returns the number of requests sent between them.
8168
*
8269
* @param {string} name1 the first marker
8370
* @param {string} name2 the second marker
8471
*
85-
* @returns {{duration: number; requestCount: number; measurementName: string}}
86-
* An object containing the measured duration (in ms) between two marks, the
87-
* number of API requests made during that period, and the name of the measurement.
88-
* Returns a duration and request count of -1 if performance isn't being tracked
89-
* or one of the markers can't be found.
72+
* @returns {number} Returns a request count of -1 if performance isn't being tracked
9073
*
9174
*/
92-
export function measure(name1, name2) {
93-
if (!shouldTrackPerformance() || !SUPPORTS_MEASURE_METHODS) {
94-
return {duration: -1, requestCount: -1, measurementName: ''};
95-
}
96-
97-
// Check for existence of entry name to avoid DOMException
98-
const performanceEntries = performance.getEntries();
99-
if (![name1, name2].every((name) => performanceEntries.find((item) => item.name === name))) {
100-
return {duration: -1, requestCount: -1, measurementName: ''};
75+
export function countRequestsBetween(name1, name2) {
76+
if (!shouldTrackPerformance()) {
77+
return -1;
10178
}
10279

103-
const displayPrefix = '🐐 Mattermost: ';
104-
const measurementName = `${displayPrefix}${name1} - ${name2}`;
105-
performance.measure(measurementName, name1, name2);
106-
const duration = mostRecentDurationByEntryName(measurementName);
107-
10880
const requestCount = getRequestCountAtMark(name2) - getRequestCountAtMark(name1);
10981

110-
// Clean up the measures we created
111-
performance.clearMeasures(measurementName);
112-
113-
return {duration, requestCount, measurementName};
82+
return requestCount;
11483
}
11584

11685
/**
@@ -154,11 +123,6 @@ export function measurePageLoadTelemetry() {
154123
}, tenSeconds);
155124
}
156125

157-
function mostRecentDurationByEntryName(entryName) {
158-
const entriesWithName = performance.getEntriesByName(entryName);
159-
return entriesWithName.map((item) => item.duration)[entriesWithName.length - 1];
160-
}
161-
162126
function isSupported(checks) {
163127
for (let i = 0, len = checks.length; i < len; i++) {
164128
const item = checks[i];

webapp/channels/src/components/post_view/post_list/post_list.tsx

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import React from 'react';
66
import type {ActionResult} from 'mattermost-redux/types/actions';
77

88
import type {updateNewMessagesAtInChannel} from 'actions/global_actions';
9-
import {clearMarks, mark, measure, trackEvent} from 'actions/telemetry_actions.jsx';
9+
import {clearMarks, countRequestsBetween, mark, shouldTrackPerformance, trackEvent} from 'actions/telemetry_actions.jsx';
1010
import type {LoadPostsParameters, LoadPostsReturnValue, CanLoadMorePosts} from 'actions/views/channel';
1111

1212
import LoadingScreen from 'components/loading_screen';
1313
import VirtPostList from 'components/post_view/post_list_virtualized/post_list_virtualized';
1414

1515
import {PostRequestTypes} from 'utils/constants';
16+
import {measureAndReport} from 'utils/performance_telemetry';
1617
import {getOldestPostId, getLatestPostId} from 'utils/post_utils';
1718

1819
const MAX_NUMBER_OF_AUTO_RETRIES = 3;
@@ -23,29 +24,39 @@ export const MAX_EXTRA_PAGES_LOADED = 10;
2324
function markAndMeasureChannelSwitchEnd(fresh = false) {
2425
mark('PostList#component');
2526

26-
const {duration: dur1, requestCount: requestCount1} = measure('SidebarChannelLink#click', 'PostList#component');
27-
const {duration: dur2, requestCount: requestCount2} = measure('TeamLink#click', 'PostList#component');
27+
// Send new performance metrics to server
28+
const channelSwitch = measureAndReport('channel_switch', 'SidebarChannelLink#click', 'PostList#component', true);
29+
const teamSwitch = measureAndReport('team_switch', 'TeamLink#click', 'PostList#component', true);
2830

31+
// Send old performance metrics to Rudder
32+
if (shouldTrackPerformance()) {
33+
if (channelSwitch) {
34+
const requestCount1 = countRequestsBetween('SidebarChannelLink#click', 'PostList#component');
35+
36+
trackEvent('performance', 'channel_switch', {
37+
duration: Math.round(channelSwitch.duration),
38+
fresh,
39+
requestCount: requestCount1,
40+
});
41+
}
42+
43+
if (teamSwitch) {
44+
const requestCount2 = countRequestsBetween('TeamLink#click', 'PostList#component');
45+
46+
trackEvent('performance', 'team_switch', {
47+
duration: Math.round(teamSwitch.duration),
48+
fresh,
49+
requestCount: requestCount2,
50+
});
51+
}
52+
}
53+
54+
// Clear all the metrics so that we can differentiate between a channel and team switch next time this is called
2955
clearMarks([
3056
'SidebarChannelLink#click',
3157
'TeamLink#click',
3258
'PostList#component',
3359
]);
34-
35-
if (dur1 !== -1) {
36-
trackEvent('performance', 'channel_switch', {
37-
duration: Math.round(dur1),
38-
fresh,
39-
requestCount: requestCount1,
40-
});
41-
}
42-
if (dur2 !== -1) {
43-
trackEvent('performance', 'team_switch', {
44-
duration: Math.round(dur2),
45-
fresh,
46-
requestCount: requestCount2,
47-
});
48-
}
4960
}
5061

5162
export interface Props {

webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = `
44
<RootProvider>
55
<Connect(MobileViewWatcher) />
66
<LuxonController />
7+
<PerformanceReporterController />
78
<Switch>
89
<Route
910
component={[Function]}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import {useEffect, useRef} from 'react';
5+
import {useStore} from 'react-redux';
6+
7+
import {Client4} from 'mattermost-redux/client';
8+
9+
import PerformanceReporter from 'utils/performance_telemetry/reporter';
10+
11+
export default function PerformanceReporterController() {
12+
const store = useStore();
13+
14+
const reporter = useRef<PerformanceReporter>();
15+
16+
useEffect(() => {
17+
reporter.current = new PerformanceReporter(Client4, store);
18+
reporter.current.observe();
19+
20+
// There's no way to clean up web-vitals, so continue to assume that this component won't ever be unmounted
21+
return () => {
22+
// eslint-disable-next-line no-console
23+
console.error('PerformanceReporterController - Component unmounted or store changed');
24+
};
25+
}, [store]);
26+
27+
return null;
28+
}

webapp/channels/src/components/root/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import * as Utils from 'utils/utils';
5555
import type {ProductComponent, PluginComponent} from 'types/store/plugins';
5656

5757
import LuxonController from './luxon_controller';
58+
import PerformanceReporterController from './performance_reporter_controller';
5859
import RootProvider from './root_provider';
5960
import RootRedirect from './root_redirect';
6061

@@ -447,6 +448,7 @@ export default class Root extends React.PureComponent<Props, State> {
447448
<RootProvider>
448449
<MobileViewWatcher/>
449450
<LuxonController/>
451+
<PerformanceReporterController/>
450452
<Switch>
451453
<Route
452454
path={'/error'}

webapp/channels/src/tests/helpers/user_agent_mocks.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,36 @@
88

99
let currentUA = '';
1010
let initialUA = '';
11+
let currentPlatform = '';
12+
let initialPlatform = '';
1113

1214
window.navigator = window.navigator || {};
15+
1316
initialUA = window.navigator.userAgent;
17+
initialPlatform = window.navigator.platform;
18+
1419
Object.defineProperty(window.navigator, 'userAgent', {
1520
get() {
1621
return currentUA;
1722
},
1823
});
24+
Object.defineProperty(window.navigator, 'platform', {
25+
get() {
26+
return currentPlatform;
27+
},
28+
});
1929

2030
export function reset() {
2131
set(initialUA);
32+
setPlatform(initialPlatform);
2233
}
2334
export function set(ua: string) {
2435
currentUA = ua;
2536
}
37+
export function setPlatform(platform: string) {
38+
currentPlatform = platform;
39+
}
40+
2641
export function mockSafari() {
2742
set('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1 Safari/605.1.15');
2843
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2+
// See LICENSE.txt for license information.
3+
4+
import {waitForObservations} from './performance_mock';
5+
6+
describe('PerformanceObserver', () => {
7+
test('should be able to observe a mark', async () => {
8+
const callback = jest.fn();
9+
10+
const observer = new PerformanceObserver(callback);
11+
observer.observe({entryTypes: ['mark']});
12+
13+
const testMark = performance.mark('testMark');
14+
15+
await waitForObservations();
16+
17+
expect(callback).toHaveBeenCalledTimes(1);
18+
19+
const observedEntries = callback.mock.calls[0][0].getEntries();
20+
expect(observedEntries).toHaveLength(1);
21+
expect(observedEntries[0]).toBe(testMark);
22+
expect(observedEntries[0]).toMatchObject({
23+
entryType: 'mark',
24+
name: 'testMark',
25+
});
26+
});
27+
28+
test('should be able to observe multiple marks', async () => {
29+
const callback = jest.fn();
30+
31+
const observer = new PerformanceObserver(callback);
32+
observer.observe({entryTypes: ['mark']});
33+
34+
const testMarkA = performance.mark('testMarkA');
35+
const testMarkB = performance.mark('testMarkB');
36+
37+
await waitForObservations();
38+
39+
expect(callback).toHaveBeenCalledTimes(1);
40+
41+
// Both marks were batched into a single call
42+
const observedEntries = callback.mock.calls[0][0].getEntries();
43+
expect(observedEntries).toHaveLength(2);
44+
expect(observedEntries[0]).toBe(testMarkA);
45+
expect(observedEntries[0]).toMatchObject({
46+
entryType: 'mark',
47+
name: 'testMarkA',
48+
});
49+
expect(observedEntries[1]).toBe(testMarkB);
50+
expect(observedEntries[1]).toMatchObject({
51+
entryType: 'mark',
52+
name: 'testMarkB',
53+
});
54+
});
55+
56+
test('should be able to observe a measure', async () => {
57+
const callback = jest.fn();
58+
59+
const observer = new PerformanceObserver(callback);
60+
observer.observe({entryTypes: ['measure']});
61+
62+
const testMarkA = performance.mark('testMarkA');
63+
const testMarkB = performance.mark('testMarkB');
64+
const testMeasure = performance.measure('testMeasure', 'testMarkA', 'testMarkB');
65+
66+
await waitForObservations();
67+
68+
expect(callback).toHaveBeenCalledTimes(1);
69+
70+
const observedEntries = callback.mock.calls[0][0].getEntries();
71+
expect(observedEntries).toHaveLength(1);
72+
expect(observedEntries[0]).toBe(testMeasure);
73+
expect(observedEntries[0]).toMatchObject({
74+
entryType: 'measure',
75+
name: 'testMeasure',
76+
duration: testMarkB.startTime - testMarkA.startTime,
77+
});
78+
});
79+
});

0 commit comments

Comments
 (0)