From de3c7ad5443d7ca89560b9b2007d4629f51b0997 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 9 May 2024 12:50:52 -0400 Subject: [PATCH] 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 --- server/config/client.go | 1 + .../platform/services/telemetry/telemetry.go | 5 +- webapp/channels/package.json | 1 + .../src/actions/telemetry_actions.jsx | 54 +-- .../post_view/post_list/post_list.tsx | 47 ++- .../root/__snapshots__/root.test.tsx.snap | 1 + .../root/performance_reporter_controller.tsx | 28 ++ webapp/channels/src/components/root/root.tsx | 2 + .../src/tests/helpers/user_agent_mocks.ts | 15 + .../src/tests/performance_mock.test.ts | 79 ++++ webapp/channels/src/tests/performance_mock.ts | 29 ++ webapp/channels/src/tests/setup_jest.ts | 3 +- .../src/utils/performance_telemetry/index.ts | 38 ++ .../utils/performance_telemetry/long_task.ts | 9 + .../platform_detection.test.ts | 220 ++++++++++ .../platform_detection.ts | 39 ++ .../performance_telemetry/reporter.test.ts | 380 ++++++++++++++++++ .../utils/performance_telemetry/reporter.ts | 285 +++++++++++++ webapp/channels/src/utils/user_agent.tsx | 8 + webapp/package-lock.json | 6 + webapp/platform/client/src/client4.ts | 4 + webapp/platform/types/src/config.ts | 1 + 22 files changed, 1188 insertions(+), 67 deletions(-) create mode 100644 webapp/channels/src/components/root/performance_reporter_controller.tsx create mode 100644 webapp/channels/src/tests/performance_mock.test.ts create mode 100644 webapp/channels/src/tests/performance_mock.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/index.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/long_task.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/platform_detection.test.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/platform_detection.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/reporter.test.ts create mode 100644 webapp/channels/src/utils/performance_telemetry/reporter.ts diff --git a/server/config/client.go b/server/config/client.go index 70dd750d414d7..8d9372b6dd913 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -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) diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 6ff2756c16f02..7989f56837d49 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -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{ diff --git a/webapp/channels/package.json b/webapp/channels/package.json index 57b6cc248e79e..74d582d530b13 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -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": { diff --git a/webapp/channels/src/actions/telemetry_actions.jsx b/webapp/channels/src/actions/telemetry_actions.jsx index 5b173a41d47db..eca38753abfa1 100644 --- a/webapp/channels/src/actions/telemetry_actions.jsx +++ b/webapp/channels/src/actions/telemetry_actions.jsx @@ -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: ''}; - } - - // 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: ''}; +export function countRequestsBetween(name1, name2) { + if (!shouldTrackPerformance()) { + return -1; } - 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]; diff --git a/webapp/channels/src/components/post_view/post_list/post_list.tsx b/webapp/channels/src/components/post_view/post_list/post_list.tsx index 65392acb75c92..424f91278e5fa 100644 --- a/webapp/channels/src/components/post_view/post_list/post_list.tsx +++ b/webapp/channels/src/components/post_view/post_list/post_list.tsx @@ -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 { diff --git a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap index 9a93ccb00b15e..bb62eab70417c 100644 --- a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap +++ b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap @@ -4,6 +4,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = ` + (); + + 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; +} diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index b7ad84c6ae929..bb421420eaf4c 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -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 { + { + 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, + }); + }); +}); diff --git a/webapp/channels/src/tests/performance_mock.ts b/webapp/channels/src/tests/performance_mock.ts new file mode 100644 index 0000000000000..68fbec1d97443 --- /dev/null +++ b/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)); +} diff --git a/webapp/channels/src/tests/setup_jest.ts b/webapp/channels/src/tests/setup_jest.ts index 78f6711fa0870..c3d6c29780939 100644 --- a/webapp/channels/src/tests/setup_jest.ts +++ b/webapp/channels/src/tests/setup_jest.ts @@ -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); diff --git a/webapp/channels/src/utils/performance_telemetry/index.ts b/webapp/channels/src/utils/performance_telemetry/index.ts new file mode 100644 index 0000000000000..909b7ee0d706b --- /dev/null +++ b/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; + } +} diff --git a/webapp/channels/src/utils/performance_telemetry/long_task.ts b/webapp/channels/src/utils/performance_telemetry/long_task.ts new file mode 100644 index 0000000000000..7a0cbbc7ad83e --- /dev/null +++ b/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'; +} diff --git a/webapp/channels/src/utils/performance_telemetry/platform_detection.test.ts b/webapp/channels/src/utils/performance_telemetry/platform_detection.test.ts new file mode 100644 index 0000000000000..f78452c4e8215 --- /dev/null +++ b/webapp/channels/src/utils/performance_telemetry/platform_detection.test.ts @@ -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); + }); + } +}); diff --git a/webapp/channels/src/utils/performance_telemetry/platform_detection.ts b/webapp/channels/src/utils/performance_telemetry/platform_detection.ts new file mode 100644 index 0000000000000..b6a7f5cd198c2 --- /dev/null +++ b/webapp/channels/src/utils/performance_telemetry/platform_detection.ts @@ -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; +export type UserAgentLabel = ReturnType; + +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'; +} diff --git a/webapp/channels/src/utils/performance_telemetry/reporter.test.ts b/webapp/channels/src/utils/performance_telemetry/reporter.test.ts new file mode 100644 index 0000000000000..134a46dfadba0 --- /dev/null +++ b/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)); +} diff --git a/webapp/channels/src/utils/performance_telemetry/reporter.ts b/webapp/channels/src/utils/performance_telemetry/reporter.ts new file mode 100644 index 0000000000000..134e234fbc1ab --- /dev/null +++ b/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; + + private platformLabel: PlatformLabel; + private userAgentLabel: UserAgentLabel; + + private counters: Map; + private histogramMeasures: PerformanceReportMeasure[]; + + private observer: PerformanceObserver; + private reportTimeout: number | undefined; + + protected reportPeriodBase = 60 * 1000; + protected reportPeriodJitter = 15 * 1000; + + constructor(client: Client4, store: Store) { + 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): 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): 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'; +} diff --git a/webapp/channels/src/utils/user_agent.tsx b/webapp/channels/src/utils/user_agent.tsx index 11f61f767643a..15da2d1a315c9 100644 --- a/webapp/channels/src/utils/user_agent.tsx +++ b/webapp/channels/src/utils/user_agent.tsx @@ -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; } diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 8c40d0699d0a2..3fe7786e1b968 100644 --- a/webapp/package-lock.json +++ b/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, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index dba808fcbc1ce..080a6717de841 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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(';'); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 40a8ea0e799c9..f2d1860e4130f 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -47,6 +47,7 @@ export type ClientConfig = { EnableBanner: string; EnableBotAccountCreation: string; EnableChannelViewedMessages: string; + EnableClientMetrics: string; EnableClientPerformanceDebugging: string; EnableCluster: string; EnableCommands: string;