MM-57878 Add PerformanceReporter for clientside performance metrics (#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
Этот коммит содержится в:
Harrison Healey
2024-05-09 12:50:52 -04:00
коммит произвёл GitHub
родитель d6a8ad0d55
Коммит de3c7ad544
22 изменённых файлов: 1188 добавлений и 67 удалений

Просмотреть файл

@@ -261,6 +261,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m
props["IosMinVersion"] = c.ClientRequirements.IosMinVersion
props["EnableDiagnostics"] = strconv.FormatBool(*c.LogSettings.EnableDiagnostics)
props["EnableClientMetrics"] = strconv.FormatBool(*c.MetricsSettings.EnableClientMetrics)
props["EnableComplianceExport"] = strconv.FormatBool(*c.MessageExportSettings.EnableExport)

Просмотреть файл

@@ -758,8 +758,9 @@ func (ts *TelemetryService) trackConfig() {
})
ts.SendTelemetry(TrackConfigMetrics, map[string]any{
"enable": *cfg.MetricsSettings.Enable,
"block_profile_rate": *cfg.MetricsSettings.BlockProfileRate,
"enable": *cfg.MetricsSettings.Enable,
"block_profile_rate": *cfg.MetricsSettings.BlockProfileRate,
"enable_client_metrics": *cfg.MetricsSettings.EnableClientMetrics,
})
ts.SendTelemetry(TrackConfigNativeApp, map[string]any{

Просмотреть файл

@@ -98,6 +98,7 @@
"tinycolor2": "1.4.2",
"turndown": "7.1.1",
"typescript": "5.3.3",
"web-vitals": "3.5.2",
"zen-observable": "0.9.0"
},
"devDependencies": {

Просмотреть файл

@@ -10,15 +10,6 @@ import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {isDevModeEnabled} from 'selectors/general';
import store from 'stores/redux_store';
const SUPPORTS_CLEAR_MARKS = isSupported([performance.clearMarks]);
const SUPPORTS_MARK = isSupported([performance.mark]);
const SUPPORTS_MEASURE_METHODS = isSupported([
performance.measure,
performance.getEntries,
performance.getEntriesByName,
performance.clearMeasures,
]);
const HEADER_X_PAGE_LOAD_CONTEXT = 'X-Page-Load-Context';
export function isTelemetryEnabled(state) {
@@ -58,59 +49,37 @@ export function pageVisited(category, name) {
*
*/
export function clearMarks(names) {
if (!shouldTrackPerformance() || !SUPPORTS_CLEAR_MARKS) {
return;
}
names.forEach((name) => performance.clearMarks(name));
}
export function mark(name) {
if (!shouldTrackPerformance() || !SUPPORTS_MARK) {
performance.mark(name);
if (!shouldTrackPerformance()) {
return;
}
performance.mark(name);
initRequestCountingIfNecessary();
updateRequestCountAtMark(name);
}
/**
* Takes the names of two markers and invokes performance.measure on
* them. The measured duration (ms) and the string name of the measure is
* are returned.
* Takes the names of two markers and returns the number of requests sent between them.
*
* @param {string} name1 the first marker
* @param {string} name2 the second marker
*
* @returns {{duration: number; requestCount: number; measurementName: string}}
* An object containing the measured duration (in ms) between two marks, the
* number of API requests made during that period, and the name of the measurement.
* Returns a duration and request count of -1 if performance isn't being tracked
* or one of the markers can't be found.
* @returns {number} Returns a request count of -1 if performance isn't being tracked
*
*/
export function measure(name1, name2) {
if (!shouldTrackPerformance() || !SUPPORTS_MEASURE_METHODS) {
return {duration: -1, requestCount: -1, measurementName: ''};
export function countRequestsBetween(name1, name2) {
if (!shouldTrackPerformance()) {
return -1;
}
// Check for existence of entry name to avoid DOMException
const performanceEntries = performance.getEntries();
if (![name1, name2].every((name) => performanceEntries.find((item) => item.name === name))) {
return {duration: -1, requestCount: -1, measurementName: ''};
}
const displayPrefix = '🐐 Mattermost: ';
const measurementName = `${displayPrefix}${name1} - ${name2}`;
performance.measure(measurementName, name1, name2);
const duration = mostRecentDurationByEntryName(measurementName);
const requestCount = getRequestCountAtMark(name2) - getRequestCountAtMark(name1);
// Clean up the measures we created
performance.clearMeasures(measurementName);
return {duration, requestCount, measurementName};
return requestCount;
}
/**
@@ -154,11 +123,6 @@ export function measurePageLoadTelemetry() {
}, tenSeconds);
}
function mostRecentDurationByEntryName(entryName) {
const entriesWithName = performance.getEntriesByName(entryName);
return entriesWithName.map((item) => item.duration)[entriesWithName.length - 1];
}
function isSupported(checks) {
for (let i = 0, len = checks.length; i < len; i++) {
const item = checks[i];

Просмотреть файл

@@ -6,13 +6,14 @@ import React from 'react';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {updateNewMessagesAtInChannel} from 'actions/global_actions';
import {clearMarks, mark, measure, trackEvent} from 'actions/telemetry_actions.jsx';
import {clearMarks, countRequestsBetween, mark, shouldTrackPerformance, trackEvent} from 'actions/telemetry_actions.jsx';
import type {LoadPostsParameters, LoadPostsReturnValue, CanLoadMorePosts} from 'actions/views/channel';
import LoadingScreen from 'components/loading_screen';
import VirtPostList from 'components/post_view/post_list_virtualized/post_list_virtualized';
import {PostRequestTypes} from 'utils/constants';
import {measureAndReport} from 'utils/performance_telemetry';
import {getOldestPostId, getLatestPostId} from 'utils/post_utils';
const MAX_NUMBER_OF_AUTO_RETRIES = 3;
@@ -23,29 +24,39 @@ export const MAX_EXTRA_PAGES_LOADED = 10;
function markAndMeasureChannelSwitchEnd(fresh = false) {
mark('PostList#component');
const {duration: dur1, requestCount: requestCount1} = measure('SidebarChannelLink#click', 'PostList#component');
const {duration: dur2, requestCount: requestCount2} = measure('TeamLink#click', 'PostList#component');
// Send new performance metrics to server
const channelSwitch = measureAndReport('channel_switch', 'SidebarChannelLink#click', 'PostList#component', true);
const teamSwitch = measureAndReport('team_switch', 'TeamLink#click', 'PostList#component', true);
// Send old performance metrics to Rudder
if (shouldTrackPerformance()) {
if (channelSwitch) {
const requestCount1 = countRequestsBetween('SidebarChannelLink#click', 'PostList#component');
trackEvent('performance', 'channel_switch', {
duration: Math.round(channelSwitch.duration),
fresh,
requestCount: requestCount1,
});
}
if (teamSwitch) {
const requestCount2 = countRequestsBetween('TeamLink#click', 'PostList#component');
trackEvent('performance', 'team_switch', {
duration: Math.round(teamSwitch.duration),
fresh,
requestCount: requestCount2,
});
}
}
// Clear all the metrics so that we can differentiate between a channel and team switch next time this is called
clearMarks([
'SidebarChannelLink#click',
'TeamLink#click',
'PostList#component',
]);
if (dur1 !== -1) {
trackEvent('performance', 'channel_switch', {
duration: Math.round(dur1),
fresh,
requestCount: requestCount1,
});
}
if (dur2 !== -1) {
trackEvent('performance', 'team_switch', {
duration: Math.round(dur2),
fresh,
requestCount: requestCount2,
});
}
}
export interface Props {

Просмотреть файл

@@ -4,6 +4,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = `
<RootProvider>
<Connect(MobileViewWatcher) />
<LuxonController />
<PerformanceReporterController />
<Switch>
<Route
component={[Function]}

Просмотреть файл

@@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useRef} from 'react';
import {useStore} from 'react-redux';
import {Client4} from 'mattermost-redux/client';
import PerformanceReporter from 'utils/performance_telemetry/reporter';
export default function PerformanceReporterController() {
const store = useStore();
const reporter = useRef<PerformanceReporter>();
useEffect(() => {
reporter.current = new PerformanceReporter(Client4, store);
reporter.current.observe();
// There's no way to clean up web-vitals, so continue to assume that this component won't ever be unmounted
return () => {
// eslint-disable-next-line no-console
console.error('PerformanceReporterController - Component unmounted or store changed');
};
}, [store]);
return null;
}

Просмотреть файл

@@ -55,6 +55,7 @@ import * as Utils from 'utils/utils';
import type {ProductComponent, PluginComponent} from 'types/store/plugins';
import LuxonController from './luxon_controller';
import PerformanceReporterController from './performance_reporter_controller';
import RootProvider from './root_provider';
import RootRedirect from './root_redirect';
@@ -447,6 +448,7 @@ export default class Root extends React.PureComponent<Props, State> {
<RootProvider>
<MobileViewWatcher/>
<LuxonController/>
<PerformanceReporterController/>
<Switch>
<Route
path={'/error'}

Просмотреть файл

@@ -8,21 +8,36 @@
let currentUA = '';
let initialUA = '';
let currentPlatform = '';
let initialPlatform = '';
window.navigator = window.navigator || {};
initialUA = window.navigator.userAgent;
initialPlatform = window.navigator.platform;
Object.defineProperty(window.navigator, 'userAgent', {
get() {
return currentUA;
},
});
Object.defineProperty(window.navigator, 'platform', {
get() {
return currentPlatform;
},
});
export function reset() {
set(initialUA);
setPlatform(initialPlatform);
}
export function set(ua: string) {
currentUA = ua;
}
export function setPlatform(platform: string) {
currentPlatform = platform;
}
export function mockSafari() {
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');
}

Просмотреть файл

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {waitForObservations} from './performance_mock';
describe('PerformanceObserver', () => {
test('should be able to observe a mark', async () => {
const callback = jest.fn();
const observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['mark']});
const testMark = performance.mark('testMark');
await waitForObservations();
expect(callback).toHaveBeenCalledTimes(1);
const observedEntries = callback.mock.calls[0][0].getEntries();
expect(observedEntries).toHaveLength(1);
expect(observedEntries[0]).toBe(testMark);
expect(observedEntries[0]).toMatchObject({
entryType: 'mark',
name: 'testMark',
});
});
test('should be able to observe multiple marks', async () => {
const callback = jest.fn();
const observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['mark']});
const testMarkA = performance.mark('testMarkA');
const testMarkB = performance.mark('testMarkB');
await waitForObservations();
expect(callback).toHaveBeenCalledTimes(1);
// Both marks were batched into a single call
const observedEntries = callback.mock.calls[0][0].getEntries();
expect(observedEntries).toHaveLength(2);
expect(observedEntries[0]).toBe(testMarkA);
expect(observedEntries[0]).toMatchObject({
entryType: 'mark',
name: 'testMarkA',
});
expect(observedEntries[1]).toBe(testMarkB);
expect(observedEntries[1]).toMatchObject({
entryType: 'mark',
name: 'testMarkB',
});
});
test('should be able to observe a measure', async () => {
const callback = jest.fn();
const observer = new PerformanceObserver(callback);
observer.observe({entryTypes: ['measure']});
const testMarkA = performance.mark('testMarkA');
const testMarkB = performance.mark('testMarkB');
const testMeasure = performance.measure('testMeasure', 'testMarkA', 'testMarkB');
await waitForObservations();
expect(callback).toHaveBeenCalledTimes(1);
const observedEntries = callback.mock.calls[0][0].getEntries();
expect(observedEntries).toHaveLength(1);
expect(observedEntries[0]).toBe(testMeasure);
expect(observedEntries[0]).toMatchObject({
entryType: 'measure',
name: 'testMeasure',
duration: testMarkB.startTime - testMarkA.startTime,
});
});
});

Просмотреть файл

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PerformanceObserver as NodePerformanceObserver, performance as nodePerformance} from 'node:perf_hooks';
// These aren't a perfect match for window.performance and PerformanceObserver, but they're close enough. They don't
// work with `jest.useFakeTimers` because that overwrites window.performance in a way that breaks the Node.js version.
//
// To use PerformanceObserver, you need to use a `setTimeout` or `await observations()` to have a PerformanceObserver's
// callback get called. See the accompanying tests for examples.
Object.defineProperty(window, 'performance', {
writable: true,
value: nodePerformance,
});
Object.defineProperty(global, 'PerformanceObserver', {
value: NodePerformanceObserver,
});
// Only Chrome-based browsers support long task timings currently, so make Node pretend it does too
Object.defineProperty(PerformanceObserver, 'supportedEntryTypes', {
value: [...PerformanceObserver.supportedEntryTypes, 'longtask'],
});
export function waitForObservations() {
// Performance observations are processed after any timeout
return new Promise((resolve) => setTimeout(resolve));
}

Просмотреть файл

@@ -9,13 +9,12 @@ import Adapter from 'enzyme-adapter-react-17-updated';
import '@testing-library/jest-dom';
import 'isomorphic-fetch';
import './performance_mock';
import './redux-persist_mock';
import './react-intl_mock';
import './react-router-dom_mock';
import './react-tippy_mock';
global.performance = {} as any;
configure({adapter: new (Adapter as any)()});
global.window = Object.create(window);

Просмотреть файл

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function markAndReport(name: string): PerformanceMark {
return performance.mark(name, {
detail: {
report: true,
},
});
}
/**
* Measures the duration between two performance marks, schedules it to be reported to the server, and returns the
* PerformanceMeasure created by doing this.
*
* If either the start or end mark does not exist, undefined will be returned and, if canFail is false, an error
* will be logged.
*/
export function measureAndReport(measureName: string, startMark: string, endMark: string, canFail = false): PerformanceMeasure | undefined {
const options: PerformanceMeasureOptions = {
start: startMark,
end: endMark,
detail: {
report: true,
},
};
try {
return performance.measure(measureName, options);
} catch (e) {
if (!canFail) {
// eslint-disable-next-line no-console
console.error('Unable to measure ' + measureName, e);
}
return undefined;
}
}

Просмотреть файл

@@ -0,0 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* See https://developer.mozilla.org/en-US/docs/Web/API/PerformanceLongTaskTiming
*/
export interface PerformanceLongTaskTiming extends PerformanceEntry {
readonly entryType: 'longtask';
}

Просмотреть файл

@@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {reset as resetNavigator, setPlatform, set as setUserAgent} from 'tests/helpers/user_agent_mocks';
import {getPlatformLabel, getUserAgentLabel} from './platform_detection';
describe('getUserAgentLabel and getPlatformLabel', () => {
afterEach(() => {
resetNavigator();
});
const testCases = [
{
description: 'Desktop app on Windows',
input: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.156 Electron/29.3.0 Safari/537.36 Mattermost/5.9.0-develop.1',
expectedAgent: 'desktop',
expectedPlatform: 'windows',
},
{
description: 'Desktop app on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.276 Electron/28.2.2 Safari/537.36 Mattermost/5.7.0',
expectedAgent: 'desktop',
expectedPlatform: 'macos',
},
{
description: 'Desktop app on Linux',
input: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.156 Electron/29.3.0 Safari/537.36 Mattermost/5.9.0-develop.1',
expectedAgent: 'desktop',
expectedPlatform: 'linux',
},
{
description: 'Chrome on Windows',
input: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
expectedAgent: 'chrome',
expectedPlatform: 'windows',
},
{
description: 'Chrome on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
expectedAgent: 'chrome',
expectedPlatform: 'macos',
},
{
description: 'Chrome on Linux',
input: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
expectedAgent: 'chrome',
expectedPlatform: 'linux',
},
{
description: 'Chrome on iPhone',
input: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1',
expectedAgent: 'chrome',
expectedPlatform: 'ios',
},
{
description: 'Chrome on iPad',
input: 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/124.0.6367.111 Mobile/15E148 Safari/604.1',
expectedAgent: 'chrome',
expectedPlatform: 'ios',
},
{
description: 'Chrome on Android',
input: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.113 Mobile Safari/537.36',
expectedAgent: 'chrome',
expectedPlatform: 'android',
},
{
description: 'Firefox on Windows',
input: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'windows',
},
{
description: 'Firefox on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:125.0) Gecko/20100101 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'macos',
},
{
description: 'Firefox on Linux 1',
input: 'Mozilla/5.0 (X11; Linux i686; rv:125.0) Gecko/20100101 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Firefox on Linux 2',
input: 'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:125.0) Gecko/20100101 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Firefox on Linux 3',
input: 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Firefox on iPhone',
input: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148 Safari/605.1.15',
expectedAgent: 'firefox',
expectedPlatform: 'ios',
},
{
description: 'Firefox on iPad',
input: 'Mozilla/5.0 (iPad; CPU OS 14_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/125.0 Mobile/15E148 Safari/605.1.15',
expectedAgent: 'firefox',
expectedPlatform: 'ios',
},
{
description: 'Firefox on Android 1',
input: 'Mozilla/5.0 (Android 14; Mobile; rv:125.0) Gecko/125.0 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'android',
},
{
description: 'Firefox on Android 2',
input: 'Mozilla/5.0 (Android 14; Mobile; LG-M255; rv:125.0) Gecko/125.0 Firefox/125.0',
expectedAgent: 'firefox',
expectedPlatform: 'android',
},
{
description: 'Firefox ESR on Windows',
input: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:115.0) Gecko/20100101 Firefox/115.0',
expectedAgent: 'firefox',
expectedPlatform: 'windows',
},
{
description: 'Firefox ESR on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.4; rv:115.0) Gecko/20100101 Firefox/115.0',
expectedAgent: 'firefox',
expectedPlatform: 'macos',
},
{
description: 'Firefox ESR on Linux 1',
input: 'Mozilla/5.0 (Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Firefox ESR on Linux 2',
input: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Firefox ESR on Linux 3',
input: 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:115.0) Gecko/20100101 Firefox/115.0',
expectedAgent: 'firefox',
expectedPlatform: 'linux',
},
{
description: 'Safari on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15',
expectedAgent: 'safari',
expectedPlatform: 'macos',
},
{
description: 'Safari on iPhone',
input: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1',
expectedAgent: 'safari',
expectedPlatform: 'ios',
},
{
description: 'Safari on iPad',
input: 'Mozilla/5.0 (iPad; CPU OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1',
expectedAgent: 'safari',
expectedPlatform: 'ios',
},
{
description: 'Edge on Windows',
input: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.80',
expectedAgent: 'edge',
expectedPlatform: 'windows',
},
{
description: 'Edge on Mac',
input: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.80',
expectedAgent: 'edge',
expectedPlatform: 'macos',
},
{
description: 'Edge on iPhone',
input: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/124.2478.71 Mobile/15E148 Safari/605.1.15',
expectedAgent: 'edge',
expectedPlatform: 'ios',
},
{
description: 'Edge on Android 1',
input: 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.113 Mobile Safari/537.36 EdgA/124.0.2478.62',
expectedAgent: 'edge',
expectedPlatform: 'android',
},
{
description: 'Edge on Android 2',
input: 'Mozilla/5.0 (Linux; Android 10; Pixel 3 XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.113 Mobile Safari/537.36 EdgA/124.0.2478.62',
expectedAgent: 'edge',
expectedPlatform: 'android',
},
{
description: 'Edge on Android 3',
input: 'Mozilla/5.0 (Linux; Android 10; ONEPLUS A6003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.113 Mobile Safari/537.36 EdgA/124.0.2478.62',
expectedAgent: 'edge',
expectedPlatform: 'android',
},
];
for (const testCase of testCases) {
test('should detect user agent and platform for ' + testCase.description, () => {
setUserAgent(testCase.input);
if (testCase.expectedPlatform === 'linux') {
setPlatform('Linux x86_64');
}
expect(getUserAgentLabel()).toEqual(testCase.expectedAgent);
expect(getPlatformLabel()).toEqual(testCase.expectedPlatform);
});
}
});

Просмотреть файл

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as UserAgent from 'utils/user_agent';
export type PlatformLabel = ReturnType<typeof getPlatformLabel>;
export type UserAgentLabel = ReturnType<typeof getUserAgentLabel>;
export function getPlatformLabel() {
if (UserAgent.isIos()) {
return 'ios';
} else if (UserAgent.isAndroid()) {
return 'android';
} else if (UserAgent.isLinux()) {
return 'linux';
} else if (UserAgent.isMac()) {
return 'macos';
} else if (UserAgent.isWindows()) {
return 'windows';
}
return 'other';
}
export function getUserAgentLabel() {
if (UserAgent.isDesktopApp()) {
return 'desktop';
} else if (UserAgent.isFirefox() || UserAgent.isIosFirefox()) {
return 'firefox';
} else if (UserAgent.isChromiumEdge()) {
return 'edge';
} else if (UserAgent.isChrome() || UserAgent.isIosChrome()) {
return 'chrome';
} else if (UserAgent.isSafari()) {
return 'safari';
}
return 'other';
}

Просмотреть файл

@@ -0,0 +1,380 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import nock from 'nock';
import {onCLS, onFCP, onINP, onLCP, onTTFB} from 'web-vitals';
import {Client4} from '@mattermost/client';
import configureStore from 'store';
import {reset as resetUserAgent, setPlatform, set as setUserAgent} from 'tests/helpers/user_agent_mocks';
import {waitForObservations} from 'tests/performance_mock';
import PerformanceReporter from './reporter';
import {markAndReport, measureAndReport} from '.';
jest.mock('web-vitals');
const sendBeacon = jest.fn().mockReturnValue(true);
navigator.sendBeacon = sendBeacon;
const siteUrl = 'http://localhost:8065';
describe('PerformanceReporter', () => {
afterEach(() => {
performance.clearMarks();
performance.clearMeasures();
});
test('should report measurements to the server as histograms', async () => {
const {reporter} = newTestReporter();
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
const testMarkA = performance.mark('testMarkA');
const testMarkB = performance.mark('testMarkB');
measureAndReport('testMeasureA', 'testMarkA', 'testMarkB');
const testMarkC = performance.mark('testMarkC');
measureAndReport('testMeasureB', 'testMarkA', 'testMarkC');
measureAndReport('testMeasureC', 'testMarkB', 'testMarkC');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
const report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
start: performance.timeOrigin + testMarkA.startTime,
end: performance.timeOrigin + testMarkB.startTime,
histograms: [
{
metric: 'testMeasureA',
value: testMarkB.startTime - testMarkA.startTime,
timestamp: performance.timeOrigin + testMarkA.startTime,
},
{
metric: 'testMeasureB',
value: testMarkC.startTime - testMarkA.startTime,
timestamp: performance.timeOrigin + testMarkA.startTime,
},
{
metric: 'testMeasureC',
value: testMarkC.startTime - testMarkB.startTime,
timestamp: performance.timeOrigin + testMarkB.startTime,
},
],
});
reporter.disconnect();
});
test('should report some marks to the server as counters', async () => {
const {reporter} = newTestReporter();
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
performance.mark('notReportedA');
performance.mark('notReportedB');
markAndReport('reportedA');
markAndReport('reportedB');
markAndReport('reportedA');
markAndReport('reportedA');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
const timestamp = performance.timeOrigin + performance.now();
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
const report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
counters: [
{
metric: 'reportedA',
value: 3,
},
{
metric: 'reportedB',
value: 1,
},
],
});
expect(report.start).toBeGreaterThan(timestamp);
expect(report.end).toBeGreaterThan(timestamp);
expect(report.start).toEqual(report.end);
reporter.disconnect();
});
test('should report longtasks to the server as counters', async () => {
const {reporter} = newTestReporter();
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
// Node doesn't support longtask entries, and I can't find a way to inject them directly, so we have to fake some
const entries = {
getEntries: () => [
{
entryType: 'longtask',
duration: 140,
},
{
entryType: 'longtask',
duration: 68,
},
{
entryType: 'longtask',
duration: 86,
},
],
getEntriesByName: jest.fn(),
getEntriesByType: jest.fn(),
} as unknown as PerformanceObserverEntryList;
reporter.handleObservations(entries);
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
const report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
counters: [
{
metric: 'long_tasks',
value: 3,
},
],
});
reporter.disconnect();
});
test('should report web vitals to the server as histograms', async () => {
const {reporter} = newTestReporter();
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
const onCLSCallback = (onCLS as jest.Mock).mock.calls[0][0];
onCLSCallback({name: 'CLS', value: 100});
const onFCPCallback = (onFCP as jest.Mock).mock.calls[0][0];
onFCPCallback({name: 'FCP', value: 1800});
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
let report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
histograms: [
{
metric: 'CLS',
value: 100,
},
{
metric: 'FCP',
value: 1800,
},
],
});
sendBeacon.mockClear();
const onINPCallback = (onINP as jest.Mock).mock.calls[0][0];
onINPCallback({name: 'INP', value: 200});
const onLCPCallback = (onLCP as jest.Mock).mock.calls[0][0];
onLCPCallback({name: 'LCP', value: 2500});
const onTTFBCallback = (onTTFB as jest.Mock).mock.calls[0][0];
onTTFBCallback({name: 'TTFB', value: 800});
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
histograms: [
{
metric: 'INP',
value: 200,
},
{
metric: 'LCP',
value: 2500,
},
{
metric: 'TTFB',
value: 800,
},
],
});
reporter.disconnect();
});
test('should not report anything there is no data to report', async () => {
const {reporter} = newTestReporter();
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
await waitForObservations();
expect(reporter.handleObservations).not.toHaveBeenCalled();
await waitForReport();
expect(reporter.maybeSendReport).toHaveBeenCalled();
expect(sendBeacon).not.toHaveBeenCalled();
reporter.disconnect();
});
test('should not report anything if EnableClientMetrics is false', async () => {
const {reporter} = newTestReporter(false);
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
markAndReport('reportedA');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
await waitForReport();
expect(reporter.maybeSendReport).toHaveBeenCalled();
expect(sendBeacon).not.toHaveBeenCalled();
reporter.disconnect();
});
test('should not report anything if the user is not logged in', async () => {
const {reporter} = newTestReporter(true, false);
reporter.observe();
expect(sendBeacon).not.toHaveBeenCalled();
markAndReport('reportedA');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
await waitForReport();
expect(reporter.maybeSendReport).toHaveBeenCalled();
expect(sendBeacon).not.toHaveBeenCalled();
reporter.disconnect();
});
test('should report user agent and platform', async () => {
setPlatform('MacIntel');
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0');
const {reporter} = newTestReporter();
reporter.observe();
markAndReport('reportedA');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(sendBeacon.mock.calls[0][0]).toEqual(siteUrl + '/api/v4/perf');
const report = JSON.parse(sendBeacon.mock.calls[0][1]);
expect(report).toMatchObject({
labels: {
agent: 'firefox',
platform: 'macos',
},
});
reporter.disconnect();
resetUserAgent();
});
test('should fall back to making a fetch request if a beacon cannot be sent', async () => {
const {client, reporter} = newTestReporter();
reporter.observe();
sendBeacon.mockReturnValue(false);
const mock = nock(client.getBaseRoute()).
post('/perf').
reply(200);
expect(sendBeacon).not.toHaveBeenCalled();
expect(mock.isDone()).toBe(false);
markAndReport('reportedA');
await waitForObservations();
expect(reporter.handleObservations).toHaveBeenCalled();
await waitForReport();
expect(sendBeacon).toHaveBeenCalled();
expect(mock.isDone()).toBe(true);
reporter.disconnect();
});
});
class TestPerformanceReporter extends PerformanceReporter {
public reportPeriodBase = 10;
public reportPeriodJitter = 0;
public disconnect = super.disconnect;
public handleObservations = jest.fn(super.handleObservations);
public maybeSendReport = jest.fn(super.maybeSendReport);
}
function newTestReporter(telemetryEnabled = true, loggedIn = true) {
const client = new Client4();
client.setUrl(siteUrl);
const reporter = new TestPerformanceReporter(client, configureStore({
entities: {
general: {
config: {
EnableClientMetrics: String(telemetryEnabled),
},
},
users: {
currentUserId: loggedIn ? 'currentUserId' : '',
},
},
}));
return {client, reporter};
}
function waitForReport() {
// Reports are set every 10ms by default
return new Promise((resolve) => setTimeout(resolve, 10));
}

Просмотреть файл

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Store} from 'redux';
import {onCLS, onFCP, onINP, onLCP, onTTFB} from 'web-vitals';
import type {Metric} from 'web-vitals';
import type {Client4} from '@mattermost/client';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import type {PerformanceLongTaskTiming} from './long_task';
import type {PlatformLabel, UserAgentLabel} from './platform_detection';
import {getPlatformLabel, getUserAgentLabel} from './platform_detection';
type PerformanceReportMeasure = {
metric: string;
value: number;
timestamp: number;
}
type PerformanceReport = {
version: '1.0';
labels: {
platform: PlatformLabel;
agent: UserAgentLabel;
};
start: number;
end: number;
counters: PerformanceReportMeasure[];
histograms: PerformanceReportMeasure[];
}
export default class PerformanceReporter {
private client: Client4;
private store: Store<GlobalState>;
private platformLabel: PlatformLabel;
private userAgentLabel: UserAgentLabel;
private counters: Map<string, number>;
private histogramMeasures: PerformanceReportMeasure[];
private observer: PerformanceObserver;
private reportTimeout: number | undefined;
protected reportPeriodBase = 60 * 1000;
protected reportPeriodJitter = 15 * 1000;
constructor(client: Client4, store: Store<GlobalState>) {
this.client = client;
this.store = store;
this.platformLabel = getPlatformLabel();
this.userAgentLabel = getUserAgentLabel();
this.counters = new Map();
this.histogramMeasures = [];
// This uses a PerformanceObserver to listen for calls to Performance.measure made by frontend code. It's
// recommended to use an observer rather than to call Performance.getEntriesByName directly
this.observer = new PerformanceObserver((entries) => this.handleObservations(entries));
}
public observe() {
const observedEntryTypes = ['mark', 'measure'];
if (PerformanceObserver.supportedEntryTypes.includes('longtask')) {
observedEntryTypes.push('longtask');
}
this.observer.observe({
entryTypes: observedEntryTypes,
});
// Register handlers for standard metrics and Web Vitals
onCLS((metric) => this.handleWebVital(metric));
onFCP((metric) => this.handleWebVital(metric));
onINP((metric) => this.handleWebVital(metric));
onLCP((metric) => this.handleWebVital(metric));
onTTFB((metric) => this.handleWebVital(metric));
// Periodically send performance telemetry to the server, roughly every minute but with some randomness to
// avoid overloading the server every minute.
this.reportTimeout = window.setTimeout(() => this.handleReportTimeout(), this.nextTimeout());
// Send any remaining metrics when the page becomes hidden rather than when it's unloaded because that's
// what's recommended by various sites due to unload handlers being unreliable, particularly on mobile.
addEventListener('visibilitychange', this.handleVisibilityChange);
}
/**
* This method is for testing only because we can't clean up the callbacks registered with web-vitals.
*/
protected disconnect() {
removeEventListener('visibilitychange', this.handleVisibilityChange);
clearTimeout(this.reportTimeout);
this.reportTimeout = undefined;
this.observer.disconnect();
}
protected handleObservations(list: PerformanceObserverEntryList) {
for (const entry of list.getEntries()) {
if (isPerformanceMeasure(entry)) {
this.handleMeasure(entry);
} else if (isPerformanceMark(entry)) {
this.handleMark(entry);
} else if (isPerformanceLongTask(entry)) {
this.handleLongTask();
}
}
}
private handleMeasure(entry: PerformanceMeasure) {
if (!entry.detail?.report) {
return;
}
this.histogramMeasures.push({
metric: entry.name,
value: entry.duration,
timestamp: performance.timeOrigin + entry.startTime,
});
}
private handleMark(entry: PerformanceMeasure) {
if (!entry.detail?.report) {
return;
}
this.incrementCounter(entry.name);
}
private handleLongTask() {
this.incrementCounter('long_tasks');
}
private incrementCounter(name: string) {
const current = this.counters.get(name) ?? 0;
this.counters.set(name, current + 1);
}
private handleWebVital(metric: Metric) {
this.histogramMeasures.push({
metric: metric.name,
value: metric.value,
timestamp: performance.timeOrigin + performance.now(),
});
}
private handleReportTimeout() {
this.maybeSendReport();
this.reportTimeout = window.setTimeout(() => this.handleReportTimeout(), this.nextTimeout());
}
private handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this.maybeSendReport();
}
};
/** Returns a random timeout for the next report, ranging between 45 seconds and 1 minute 15 seconds. */
private nextTimeout() {
// Returns a random value between base-jitter and base+jitter
const jitter = ((2 * Math.random()) - 1) * this.reportPeriodJitter;
return this.reportPeriodBase + jitter;
}
private canReportMetrics() {
const state = this.store.getState();
if (getConfig(state).EnableClientMetrics === 'false') {
return false;
}
if (getCurrentUserId(state) === '') {
return false;
}
return true;
}
protected maybeSendReport() {
const histogramMeasures = this.histogramMeasures;
this.histogramMeasures = [];
const counters = this.counters;
this.counters = new Map();
if (histogramMeasures.length === 0 && counters.size === 0) {
return;
}
if (!this.canReportMetrics()) {
return;
}
this.sendReport(this.generateReport(histogramMeasures, counters));
}
private generateReport(histogramMeasures: PerformanceReportMeasure[], counters: Map<string, number>): PerformanceReport {
const now = performance.timeOrigin + performance.now();
const counterMeasures = this.countersToMeasures(now, counters);
return {
version: '1.0',
labels: {
platform: this.platformLabel,
agent: this.userAgentLabel,
},
...this.getReportStartEnd(now, histogramMeasures, counterMeasures),
counters: this.countersToMeasures(now, counters),
histograms: histogramMeasures,
};
}
private getReportStartEnd(now: number, histogramMeasures: PerformanceReportMeasure[], counterMeasures: PerformanceReportMeasure[]): {start: number; end: number} {
let start = now;
let end = performance.timeOrigin;
for (const measure of histogramMeasures) {
start = Math.min(start, measure.timestamp);
end = Math.max(end, measure.timestamp);
}
for (const measure of counterMeasures) {
start = Math.min(start, measure.timestamp);
end = Math.max(end, measure.timestamp);
}
return {
start,
end,
};
}
private countersToMeasures(now: number, counters: Map<string, number>): PerformanceReportMeasure[] {
const counterMeasures = [];
for (const [name, value] of counters.entries()) {
counterMeasures.push({
metric: name,
value,
timestamp: now,
});
}
return counterMeasures;
}
private sendReport(report: PerformanceReport) {
const url = this.client.getClientMetricsRoute();
const data = JSON.stringify(report);
const beaconSent = navigator.sendBeacon(url, data);
if (!beaconSent) {
// The data couldn't be queued as a beacon for some reason, so fall back to sending an immediate fetch
fetch(url, {method: 'POST', body: data});
}
}
}
function isPerformanceLongTask(entry: PerformanceEntry): entry is PerformanceLongTaskTiming {
return entry.entryType === 'longtask';
}
function isPerformanceMark(entry: PerformanceEntry): entry is PerformanceMark {
return entry.entryType === 'mark';
}
function isPerformanceMeasure(entry: PerformanceEntry): entry is PerformanceMeasure {
return entry.entryType === 'measure';
}

Просмотреть файл

@@ -67,6 +67,10 @@ export function isIosChrome(): boolean {
return userAgent().indexOf('CriOS') !== -1;
}
export function isIosFirefox(): boolean {
return userAgent().indexOf('FxiOS') !== -1;
}
export function isIosWeb(): boolean {
return isIosSafari() || isIosChrome();
}
@@ -122,6 +126,10 @@ export function isEdge(): boolean {
return userAgent().indexOf('Edge') !== -1;
}
export function isChromiumEdge(): boolean {
return userAgent().indexOf('Edg') !== -1 && userAgent().indexOf('Edge') === -1;
}
export function isDesktopApp(): boolean {
return userAgent().indexOf('Mattermost') !== -1 && userAgent().indexOf('Electron') !== -1;
}

6
webapp/package-lock.json сгенерированный
Просмотреть файл

@@ -147,6 +147,7 @@
"tinycolor2": "1.4.2",
"turndown": "7.1.1",
"typescript": "5.3.3",
"web-vitals": "3.5.2",
"zen-observable": "0.9.0"
},
"devDependencies": {
@@ -23014,6 +23015,11 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/web-vitals": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.2.tgz",
"integrity": "sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg=="
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"dev": true,

Просмотреть файл

@@ -491,6 +491,10 @@ export default class Client4 {
return `${this.getLimitsRoute()}/server`;
}
getClientMetricsRoute() {
return `${this.getBaseRoute()}/perf`;
}
getCSRFFromCookie() {
if (typeof document !== 'undefined' && typeof document.cookie !== 'undefined') {
const cookies = document.cookie.split(';');

Просмотреть файл

@@ -47,6 +47,7 @@ export type ClientConfig = {
EnableBanner: string;
EnableBotAccountCreation: string;
EnableChannelViewedMessages: string;
EnableClientMetrics: string;
EnableClientPerformanceDebugging: string;
EnableCluster: string;
EnableCommands: string;