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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
d6a8ad0d55
Коммит
de3c7ad544
@@ -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');
|
||||
}
|
||||
|
||||
79
webapp/channels/src/tests/performance_mock.test.ts
Обычный файл
79
webapp/channels/src/tests/performance_mock.test.ts
Обычный файл
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
29
webapp/channels/src/tests/performance_mock.ts
Обычный файл
29
webapp/channels/src/tests/performance_mock.ts
Обычный файл
@@ -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);
|
||||
|
||||
38
webapp/channels/src/utils/performance_telemetry/index.ts
Обычный файл
38
webapp/channels/src/utils/performance_telemetry/index.ts
Обычный файл
@@ -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;
|
||||
}
|
||||
}
|
||||
9
webapp/channels/src/utils/performance_telemetry/long_task.ts
Обычный файл
9
webapp/channels/src/utils/performance_telemetry/long_task.ts
Обычный файл
@@ -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';
|
||||
}
|
||||
380
webapp/channels/src/utils/performance_telemetry/reporter.test.ts
Обычный файл
380
webapp/channels/src/utils/performance_telemetry/reporter.test.ts
Обычный файл
@@ -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));
|
||||
}
|
||||
285
webapp/channels/src/utils/performance_telemetry/reporter.ts
Обычный файл
285
webapp/channels/src/utils/performance_telemetry/reporter.ts
Обычный файл
@@ -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
сгенерированный
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;
|
||||
|
||||
Ссылка в новой задаче
Block a user