diff --git a/server/channels/app/metrics.go b/server/channels/app/metrics.go index 95a3cc573762..4b47757487a2 100644 --- a/server/channels/app/metrics.go +++ b/server/channels/app/metrics.go @@ -31,9 +31,19 @@ func (a *App) RegisterPerformanceReport(rctx request.CTX, report *model.Performa case model.ClientFirstContentfulPaint: a.Metrics().ObserveClientFirstContentfulPaint(commonLabels["platform"], commonLabels["agent"], h.Value/1000) case model.ClientLargestContentfulPaint: - a.Metrics().ObserveClientLargestContentfulPaint(commonLabels["platform"], commonLabels["agent"], h.Value/1000) + a.Metrics().ObserveClientLargestContentfulPaint( + commonLabels["platform"], + commonLabels["agent"], + h.GetLabelValue("region", model.AcceptedLCPRegions, "other"), + h.Value/1000, + ) case model.ClientInteractionToNextPaint: - a.Metrics().ObserveClientInteractionToNextPaint(commonLabels["platform"], commonLabels["agent"], h.Value/1000) + a.Metrics().ObserveClientInteractionToNextPaint( + commonLabels["platform"], + commonLabels["agent"], + h.GetLabelValue("interaction", model.AcceptedInteractions, "other"), + h.Value/1000, + ) case model.ClientCumulativeLayoutShift: a.Metrics().ObserveClientCumulativeLayoutShift(commonLabels["platform"], commonLabels["agent"], h.Value) case model.ClientPageLoadDuration: diff --git a/server/einterfaces/metrics.go b/server/einterfaces/metrics.go index 42b545c45531..01cf0cc5f428 100644 --- a/server/einterfaces/metrics.go +++ b/server/einterfaces/metrics.go @@ -105,8 +105,8 @@ type MetricsInterface interface { ObserveClientTimeToFirstByte(platform, agent string, elapsed float64) ObserveClientFirstContentfulPaint(platform, agent string, elapsed float64) - ObserveClientLargestContentfulPaint(platform, agent string, elapsed float64) - ObserveClientInteractionToNextPaint(platform, agent string, elapsed float64) + ObserveClientLargestContentfulPaint(platform, agent, region string, elapsed float64) + ObserveClientInteractionToNextPaint(platform, agent, interaction string, elapsed float64) ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64) IncrementClientLongTasks(platform, agent string, inc float64) ObserveClientPageLoadDuration(platform, agent string, elapsed float64) diff --git a/server/einterfaces/mocks/MetricsInterface.go b/server/einterfaces/mocks/MetricsInterface.go index 59fa8387dab1..7cc841e5cf84 100644 --- a/server/einterfaces/mocks/MetricsInterface.go +++ b/server/einterfaces/mocks/MetricsInterface.go @@ -313,14 +313,14 @@ func (_m *MetricsInterface) ObserveClientFirstContentfulPaint(platform string, a _m.Called(platform, agent, elapsed) } -// ObserveClientInteractionToNextPaint provides a mock function with given fields: platform, agent, elapsed -func (_m *MetricsInterface) ObserveClientInteractionToNextPaint(platform string, agent string, elapsed float64) { - _m.Called(platform, agent, elapsed) +// ObserveClientInteractionToNextPaint provides a mock function with given fields: platform, agent, interaction, elapsed +func (_m *MetricsInterface) ObserveClientInteractionToNextPaint(platform string, agent string, interaction string, elapsed float64) { + _m.Called(platform, agent, interaction, elapsed) } -// ObserveClientLargestContentfulPaint provides a mock function with given fields: platform, agent, elapsed -func (_m *MetricsInterface) ObserveClientLargestContentfulPaint(platform string, agent string, elapsed float64) { - _m.Called(platform, agent, elapsed) +// ObserveClientLargestContentfulPaint provides a mock function with given fields: platform, agent, region, elapsed +func (_m *MetricsInterface) ObserveClientLargestContentfulPaint(platform string, agent string, region string, elapsed float64) { + _m.Called(platform, agent, region, elapsed) } // ObserveClientPageLoadDuration provides a mock function with given fields: platform, agent, elapsed diff --git a/server/enterprise/metrics/metrics.go b/server/enterprise/metrics/metrics.go index 7fab048b9c51..a393b141ad3a 100644 --- a/server/enterprise/metrics/metrics.go +++ b/server/enterprise/metrics/metrics.go @@ -1175,7 +1175,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf // Extend the range of buckets for this while we get a better idea of the expected range of this metric is Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 15, 20}, }, - []string{"platform", "agent"}, + []string{"platform", "agent", "region"}, ) m.Registry.MustRegister(m.ClientLargestContentfulPaint) @@ -1186,7 +1186,7 @@ func New(ps *platform.PlatformService, driver, dataSource string) *MetricsInterf Name: "interaction_to_next_paint", Help: "Measure of how long it takes for a user to see the effects of clicking with a mouse, tapping with a touchscreen, or pressing a key on the keyboard (seconds)", }, - []string{"platform", "agent"}, + []string{"platform", "agent", "interaction"}, ) m.Registry.MustRegister(m.ClientInteractionToNextPaint) @@ -1783,12 +1783,12 @@ func (mi *MetricsInterfaceImpl) ObserveClientFirstContentfulPaint(platform, agen mi.ClientFirstContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) } -func (mi *MetricsInterfaceImpl) ObserveClientLargestContentfulPaint(platform, agent string, elapsed float64) { - mi.ClientLargestContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +func (mi *MetricsInterfaceImpl) ObserveClientLargestContentfulPaint(platform, agent, region string, elapsed float64) { + mi.ClientLargestContentfulPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "region": region}).Observe(elapsed) } -func (mi *MetricsInterfaceImpl) ObserveClientInteractionToNextPaint(platform, agent string, elapsed float64) { - mi.ClientInteractionToNextPaint.With(prometheus.Labels{"platform": platform, "agent": agent}).Observe(elapsed) +func (mi *MetricsInterfaceImpl) ObserveClientInteractionToNextPaint(platform, agent, interaction string, elapsed float64) { + mi.ClientInteractionToNextPaint.With(prometheus.Labels{"platform": platform, "agent": agent, "interaction": interaction}).Observe(elapsed) } func (mi *MetricsInterfaceImpl) ObserveClientCumulativeLayoutShift(platform, agent string, elapsed float64) { diff --git a/server/public/model/metrics.go b/server/public/model/metrics.go index a7f566923d3a..3daae496c39b 100644 --- a/server/public/model/metrics.go +++ b/server/public/model/metrics.go @@ -37,6 +37,20 @@ var ( performanceReportVersion = semver.MustParse("0.1.0") acceptedPlatforms = sliceToMapKey("linux", "macos", "ios", "android", "windows", "other") acceptedAgents = sliceToMapKey("desktop", "firefox", "chrome", "safari", "edge", "other") + + AcceptedInteractions = sliceToMapKey("keyboard", "pointer", "other") + AcceptedLCPRegions = sliceToMapKey( + "post", + "post_textbox", + "channel_sidebar", + "team_sidebar", + "channel_header", + "global_header", + "announcement_bar", + "center_channel", + "modal_content", + "other", + ) ) type MetricSample struct { @@ -46,6 +60,10 @@ type MetricSample struct { Labels map[string]string `json:"labels,omitempty"` } +func (s *MetricSample) GetLabelValue(name string, acceptedValues map[string]any, defaultValue string) string { + return processLabel(s.Labels, name, acceptedValues, defaultValue) +} + // PerformanceReport is a set of samples collected from a client type PerformanceReport struct { Version string `json:"version"` @@ -84,37 +102,25 @@ func (r *PerformanceReport) IsValid() error { } func (r *PerformanceReport) ProcessLabels() map[string]string { - var platform, agent string - var ok bool - - // check if the platform is specified - platform, ok = r.Labels["platform"] - if !ok { - platform = "other" - } - platform = strings.ToLower(platform) - - // check if platform is one of the accepted platforms - _, ok = acceptedPlatforms[platform] - if !ok { - platform = "other" + return map[string]string{ + "platform": processLabel(r.Labels, "platform", acceptedPlatforms, "other"), + "agent": processLabel(r.Labels, "agent", acceptedAgents, "other"), } +} - // check if the agent is specified - agent, ok = r.Labels["agent"] +func processLabel(labels map[string]string, name string, acceptedValues map[string]any, defaultValue string) string { + // check if the label is specified + value, ok := labels[name] if !ok { - agent = "other" + return defaultValue } - agent = strings.ToLower(agent) + value = strings.ToLower(value) - // check if agent is one of the accepted agents - _, ok = acceptedAgents[agent] + // check if the value is one that we accept + _, ok = acceptedValues[value] if !ok { - agent = "other" + return defaultValue } - return map[string]string{ - "platform": platform, - "agent": agent, - } + return value } diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js index a6bcd7ab0000..128431fa037c 100644 --- a/webapp/channels/jest.config.js +++ b/webapp/channels/jest.config.js @@ -30,7 +30,7 @@ const config = { '/src/packages/mattermost-redux/test/$1', '^mattermost-redux/(.*)$': '/src/packages/mattermost-redux/src/$1', '^.+\\.(jpg|jpeg|png|apng|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': - 'identity-obj-proxy', + '/src/tests/image_url_mock.json', '^.+\\.(css|less|scss)$': 'identity-obj-proxy', '^.*i18n.*\\.(json)$': '/src/tests/i18n_mock.json', }, diff --git a/webapp/channels/package.json b/webapp/channels/package.json index bc1884450071..f3c10c4c3777 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -97,7 +97,7 @@ "tinycolor2": "1.4.2", "turndown": "7.1.1", "typescript": "5.3.3", - "web-vitals": "3.5.2", + "web-vitals": "4.2.0", "zen-observable": "0.9.0" }, "devDependencies": { diff --git a/webapp/channels/src/components/__snapshots__/file_upload_overlay.test.tsx.snap b/webapp/channels/src/components/__snapshots__/file_upload_overlay.test.tsx.snap index fd682990b6a6..3f340387c4a2 100644 --- a/webapp/channels/src/components/__snapshots__/file_upload_overlay.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/file_upload_overlay.test.tsx.snap @@ -13,7 +13,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is Files @@ -48,7 +48,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is Files @@ -83,7 +83,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is Files diff --git a/webapp/channels/src/components/add_groups_to_channel_modal/__snapshots__/add_groups_to_channel_modal.test.tsx.snap b/webapp/channels/src/components/add_groups_to_channel_modal/__snapshots__/add_groups_to_channel_modal.test.tsx.snap index 36b7c687aa63..2ec52a9cf4b9 100644 --- a/webapp/channels/src/components/add_groups_to_channel_modal/__snapshots__/add_groups_to_channel_modal.test.tsx.snap +++ b/webapp/channels/src/components/add_groups_to_channel_modal/__snapshots__/add_groups_to_channel_modal.test.tsx.snap @@ -114,7 +114,7 @@ exports[`components/AddGroupsToChannelModal should match when renderOption is ca alt="group picture" className="more-modal__image" height="32" - src={null} + src="" width="32" />
This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features. @@ -164,7 +164,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features. @@ -183,7 +183,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice This is the last day of your free trial. @@ -202,7 +202,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice This is the last day of your free trial. diff --git a/webapp/channels/src/components/file_info_preview/__snapshots__/file_info_preview.test.tsx.snap b/webapp/channels/src/components/file_info_preview/__snapshots__/file_info_preview.test.tsx.snap index bb21cce82c4b..701159374aa4 100644 --- a/webapp/channels/src/components/file_info_preview/__snapshots__/file_info_preview.test.tsx.snap +++ b/webapp/channels/src/components/file_info_preview/__snapshots__/file_info_preview.test.tsx.snap @@ -13,7 +13,7 @@ exports[`components/FileInfoPreview should match snapshot, can download files 1` /> file preview
file preview
`; diff --git a/webapp/channels/src/components/onboarding_tasklist/__snapshots__/onboarding_tasklist_completed.test.tsx.snap b/webapp/channels/src/components/onboarding_tasklist/__snapshots__/onboarding_tasklist_completed.test.tsx.snap index 61ecb72b2fe1..66d19fdc9641 100644 --- a/webapp/channels/src/components/onboarding_tasklist/__snapshots__/onboarding_tasklist_completed.test.tsx.snap +++ b/webapp/channels/src/components/onboarding_tasklist/__snapshots__/onboarding_tasklist_completed.test.tsx.snap @@ -10,7 +10,7 @@ exports[`components/onboarding_tasklist/onboarding_tasklist_completed.tsx should completed tasks image

; function getBasePropsAndState(): [Props, DeepPartial] { const channel = TestHelper.getChannelMock(); diff --git a/webapp/channels/src/components/user_settings/display/user_settings_theme/custom_theme_chooser/__snapshots__/custom_theme_chooser.test.tsx.snap b/webapp/channels/src/components/user_settings/display/user_settings_theme/custom_theme_chooser/__snapshots__/custom_theme_chooser.test.tsx.snap index 440148386c0f..ae33c7832f13 100644 --- a/webapp/channels/src/components/user_settings/display/user_settings_theme/custom_theme_chooser/__snapshots__/custom_theme_chooser.test.tsx.snap +++ b/webapp/channels/src/components/user_settings/display/user_settings_theme/custom_theme_chooser/__snapshots__/custom_theme_chooser.test.tsx.snap @@ -436,7 +436,7 @@ exports[`components/user_settings/display/CustomThemeChooser should match, init > code theme image @@ -454,7 +454,7 @@ exports[`components/user_settings/display/CustomThemeChooser should match, init > code theme image diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts index 160c56a8209b..ea0a709e063f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/roles.ts @@ -217,8 +217,8 @@ export function haveIChannelPermission(state: GlobalState, teamId: string | unde return true; } - if (channelId) { - return getMyPermissionsByChannel(state)[channelId]?.has(permission); + if (channelId && getMyPermissionsByChannel(state)[channelId]?.has(permission)) { + return true; } return false; diff --git a/webapp/channels/src/tests/image_url_mock.json b/webapp/channels/src/tests/image_url_mock.json new file mode 100644 index 000000000000..e16c76dff888 --- /dev/null +++ b/webapp/channels/src/tests/image_url_mock.json @@ -0,0 +1 @@ +"" diff --git a/webapp/channels/src/tests/react_testing_utils.tsx b/webapp/channels/src/tests/react_testing_utils.tsx index de0455a32012..ecd682f6ef60 100644 --- a/webapp/channels/src/tests/react_testing_utils.tsx +++ b/webapp/channels/src/tests/react_testing_utils.tsx @@ -14,6 +14,7 @@ import type {Reducer} from 'redux'; import type {DeepPartial} from '@mattermost/types/utilities'; import configureStore from 'store'; +import globalStore from 'stores/redux_store'; import WebSocketClient from 'client/web_websocket_client'; import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; @@ -72,6 +73,8 @@ export const renderWithContext = ( ); } + replaceGlobalStore(() => renderState.store); + const results = render(component, {wrapper: WrapComponent}); return { @@ -120,3 +123,13 @@ function configureOrMockStore(initialState: DeepPartial, useMockedStore: b } return testStore; } + +function replaceGlobalStore(getStore: () => any) { + jest.spyOn(globalStore, 'dispatch').mockImplementation((...args) => getStore().dispatch(...args)); + jest.spyOn(globalStore, 'getState').mockImplementation(() => getStore().getState()); + jest.spyOn(globalStore, 'replaceReducer').mockImplementation((...args) => getStore().replaceReducer(...args)); + jest.spyOn(globalStore, '@@observable').mockImplementation((...args) => getStore()['@@observable'](...args)); + + // This may stop working if getStore starts to return new results + jest.spyOn(globalStore, 'subscribe').mockImplementation((...args) => getStore().subscribe(...args)); +} diff --git a/webapp/channels/src/tests/setup_jest.ts b/webapp/channels/src/tests/setup_jest.ts index b7eb5754a439..c1e135efe8ab 100644 --- a/webapp/channels/src/tests/setup_jest.ts +++ b/webapp/channels/src/tests/setup_jest.ts @@ -46,6 +46,8 @@ jest.mock('@mui/styled-engine', () => { return styledEngineSc; }); +global.ResizeObserver = require('resize-observer-polyfill'); + // isDependencyWarning returns true when the given console.warn message is coming from a dependency using deprecated // React lifecycle methods. function isDependencyWarning(params: string[]) { diff --git a/webapp/channels/src/utils/notifications.test.ts b/webapp/channels/src/utils/notifications.test.ts index e53980c12d61..3b10c7113f4c 100644 --- a/webapp/channels/src/utils/notifications.test.ts +++ b/webapp/channels/src/utils/notifications.test.ts @@ -82,7 +82,7 @@ describe('Notifications.showNotification', () => { expect(call[1]).toEqual({ body: 'body', tag: 'body', - icon: {}, + icon: '', requireInteraction: true, silent: false, }); @@ -111,7 +111,7 @@ describe('Notifications.showNotification', () => { expect(call[1]).toEqual({ body: 'body', tag: 'body', - icon: {}, + icon: '', requireInteraction: true, silent: false, }); diff --git a/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx new file mode 100644 index 000000000000..9407d7cdf980 --- /dev/null +++ b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createMemoryHistory} from 'history'; +import React from 'react'; +import type {AutoSizerProps} from 'react-virtualized-auto-sizer'; + +import {Permissions} from 'mattermost-redux/constants'; + +import ChannelController from 'components/channel_layout/channel_controller'; + +import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import {identifyElementRegion} from './element_identification'; + +jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100})); + +describe('identifyElementRegion', () => { + test('should be able to identify various elements in the app', async () => { + const team = TestHelper.getTeamMock({ + id: 'test-team-id', + display_name: 'Test Team', + name: 'test-team', + }); + const channel = TestHelper.getChannelMock({ + id: 'test-channel-id', + team_id: team.id, + display_name: 'Test Channel', + header: 'This is the channel header', + name: 'test-channel', + }); + const channelsCategory = TestHelper.getCategoryMock({ + team_id: team.id, + channel_ids: [channel.id], + }); + const user = TestHelper.getUserMock({ + id: 'test-user-id', + roles: 'system_admin system_user', + }); + const post = TestHelper.getPostMock({ + id: 'test-post-id', + channel_id: channel.id, + user_id: user.id, + message: 'This is a test post', + type: '', + }); + + const history = createMemoryHistory({ + initialEntries: [ + {pathname: `/${team.name}/channels/${channel.name}`}, + ], + }); + + renderWithContext( + , + { + entities: { + channelCategories: { + byId: { + [channelsCategory.id]: channelsCategory, + }, + orderByTeam: { + [team.id]: [channelsCategory.id], + }, + }, + channels: { + currentChannelId: channel.id, + channels: { + [channel.id]: channel, + }, + channelsInTeam: { + [team.id]: new Set([channel.id]), + }, + messageCounts: { + [channel.id]: {}, + }, + myMembers: { + [channel.id]: TestHelper.getChannelMembershipMock({ + channel_id: channel.id, + user_id: user.id, + }), + }, + }, + posts: { + posts: { + [post.id]: post, + }, + postsInChannel: { + [channel.id]: [ + {oldest: true, order: [post.id], recent: true}, + ], + }, + }, + roles: { + roles: { + system_admin: TestHelper.getRoleMock({ + permissions: [Permissions.CREATE_POST], + }), + }, + }, + teams: { + currentTeamId: team.id, + myMembers: { + [team.id]: TestHelper.getTeamMembershipMock({ + team_id: team.id, + user_id: user.id, + }), + }, + teams: { + [team.id]: team, + }, + }, + users: { + currentUserId: user.id, + profiles: { + [user.id]: user, + }, + }, + }, + views: { + channel: { + lastChannelViewTime: { + [channel.id]: 0, + }, + }, + }, + }, + { + history, + }, + ); + + expect(identifyElementRegion(screen.getAllByText(channel.display_name)[0])).toEqual('channel_sidebar'); + + expect(identifyElementRegion(screen.getAllByText(channel.display_name)[1])).toEqual('channel_header'); + expect(identifyElementRegion(screen.getAllByText(channel.header)[0])).toEqual('channel_header'); + + await waitFor(() => { + expect(identifyElementRegion(screen.getByText(post.message))).toEqual('post'); + }); + + expect(identifyElementRegion(screen.getByText('Write to ' + channel.display_name))).toEqual('post_textbox'); + }); +}); diff --git a/webapp/channels/src/utils/performance_telemetry/element_identification.ts b/webapp/channels/src/utils/performance_telemetry/element_identification.ts new file mode 100644 index 000000000000..462f2157bf6f --- /dev/null +++ b/webapp/channels/src/utils/performance_telemetry/element_identification.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * A list mapping IDs or CSS classes to regions of the app. In case of nested regions, these are sorted deepest-first. + * + * The region names map to values of model.AcceptedLCPRegions on the server. + */ +const elementIdentifiers = [ + + // Post list + ['post__content', 'post'], + ['create_post', 'post_textbox'], + + // LHS + ['SidebarContainer', 'channel_sidebar'], + ['team-sidebar', 'team_sidebar'], + + // Header + ['channel-header', 'channel_header'], + ['global-header', 'global_header'], + ['announcement-bar', 'announcement_bar'], + + // Areas of the app + ['channel_view', 'center_channel'], + ['modal-content', 'modal_content'], +] as const satisfies Array<[string, string]>; + +export type ElementIdentifier = 'other' | typeof elementIdentifiers[number][1]; + +export function identifyElementRegion(element: Element): ElementIdentifier { + let currentElement: Element | null = element; + + while (currentElement) { + for (const identifier of elementIdentifiers) { + if (currentElement.id === identifier[0] || currentElement.classList.contains(identifier[0])) { + return identifier[1]; + } + } + + currentElement = currentElement.parentElement; + } + + return 'other'; +} diff --git a/webapp/channels/src/utils/performance_telemetry/reporter.test.ts b/webapp/channels/src/utils/performance_telemetry/reporter.test.ts index bdf09a1710ad..456f467c260f 100644 --- a/webapp/channels/src/utils/performance_telemetry/reporter.test.ts +++ b/webapp/channels/src/utils/performance_telemetry/reporter.test.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import nock from 'nock'; -import {onCLS, onFCP, onINP, onLCP, onTTFB} from 'web-vitals'; +import {onCLS, onFCP, onINP, onLCP, onTTFB} from 'web-vitals/attribution'; import {Client4} from '@mattermost/client'; @@ -15,7 +15,7 @@ import PerformanceReporter from './reporter'; import {markAndReport, measureAndReport} from '.'; -jest.mock('web-vitals'); +jest.mock('web-vitals/attribution'); const siteUrl = 'http://localhost:8065'; @@ -197,7 +197,7 @@ describe('PerformanceReporter', () => { 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}); + onLCPCallback({name: 'LCP', value: 2500, entries: []}); const onTTFBCallback = (onTTFB as jest.Mock).mock.calls[0][0]; onTTFBCallback({name: 'TTFB', value: 800}); diff --git a/webapp/channels/src/utils/performance_telemetry/reporter.ts b/webapp/channels/src/utils/performance_telemetry/reporter.ts index 811ff944a512..a75ba4f4e638 100644 --- a/webapp/channels/src/utils/performance_telemetry/reporter.ts +++ b/webapp/channels/src/utils/performance_telemetry/reporter.ts @@ -2,8 +2,8 @@ // 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 {onCLS, onFCP, onINP, onLCP, onTTFB} from 'web-vitals/attribution'; +import type {INPMetricWithAttribution, LCPMetricWithAttribution, Metric} from 'web-vitals/attribution'; import type {Client4} from '@mattermost/client'; @@ -12,6 +12,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {GlobalState} from 'types/store'; +import {identifyElementRegion} from './element_identification'; import type {PerformanceLongTaskTiming} from './long_task'; import type {PlatformLabel, UserAgentLabel} from './platform_detection'; import {getPlatformLabel, getUserAgentLabel} from './platform_detection'; @@ -37,6 +38,12 @@ type PerformanceReportMeasure = { * use floating point numbers for performance timestamps, so we need to make sure to round this. */ timestamp: number; + + /** + * labels is an optional map of extra labels to attach to the measure. They must be supported constants as defined + * in model/metrics.go on the server. + */ + labels?: Record; } type PerformanceReport = { @@ -184,10 +191,28 @@ export default class PerformanceReporter { } private handleWebVital(metric: Metric) { + let labels: Record | undefined; + + if (isLCPMetric(metric)) { + const selector = metric.attribution?.element; + const element = selector ? document.querySelector(selector) : null; + + if (element) { + labels = { + region: identifyElementRegion(element), + }; + } + } else if (isINPMetric(metric)) { + labels = { + interaction: metric.attribution?.interactionType, + }; + } + this.histogramMeasures.push({ metric: metric.name, value: metric.value, timestamp: Date.now(), + labels, }); } @@ -328,3 +353,11 @@ function isPerformanceMark(entry: PerformanceEntry): entry is PerformanceMark { function isPerformanceMeasure(entry: PerformanceEntry): entry is PerformanceMeasure { return entry.entryType === 'measure'; } + +function isLCPMetric(entry: Metric): entry is LCPMetricWithAttribution { + return entry.name === 'LCP'; +} + +function isINPMetric(entry: Metric): entry is INPMetricWithAttribution { + return entry.name === 'INP'; +} diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 45c52a584dac..be610ea7358e 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -146,7 +146,7 @@ "tinycolor2": "1.4.2", "turndown": "7.1.1", "typescript": "5.3.3", - "web-vitals": "3.5.2", + "web-vitals": "4.2.0", "zen-observable": "0.9.0" }, "devDependencies": { @@ -23015,9 +23015,9 @@ } }, "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==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.0.tgz", + "integrity": "sha512-ohj72kbtVWCpKYMxcbJ+xaOBV3En76hW47j52dG+tEGG36LZQgfFw5yHl9xyjmosy3XUMn8d/GBUAy4YPM839w==" }, "node_modules/webidl-conversions": { "version": "7.0.0",