From e3b2b1329218d3af7ba46605621b48d76fa3f089 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 9 Jul 2024 15:06:08 -0400 Subject: [PATCH] MM-58535 Add more information to LCP and INP metrics (#27484) * Improve mocking of imported resources in unit tests We have Webpack configured so that, when code imports an image or other resource, the code gets the URL of that image. Jest now matches that behaviour which is needed because React Testing Library would previously throw an error. * Polyfill ResizeObserver in all unit tests * Ensure haveIChannelPermission always returns a boolean value The previous code could sometimes return undefined. While that should behave the same in practice, it can cause React to print prop type warnings * MM-58535 Add region label to LCP metrics * MM-58535 Upgrade web-vitals and add INP attribution * Change new labels to use snake_case * Remove replaceGlobalStore option from renderWithContext I was going to add this in case any tests failed with this option set to false, but after running those tests, that's not the case. I'm going to remove this as an option since it seems more likely than not that anyone using RTL would prefer to have this on. --- server/channels/app/metrics.go | 14 +- server/einterfaces/metrics.go | 4 +- server/einterfaces/mocks/MetricsInterface.go | 12 +- server/enterprise/metrics/metrics.go | 12 +- server/public/model/metrics.go | 68 ++++---- webapp/channels/jest.config.js | 2 +- webapp/channels/package.json | 2 +- .../file_upload_overlay.test.tsx.snap | 12 +- .../add_groups_to_channel_modal.test.tsx.snap | 6 +- .../add_groups_to_team_modal.test.tsx.snap | 4 +- .../openid_convert.test.tsx.snap | 2 +- .../advanced_text_editor.test.tsx | 2 - .../formatting_bar/formatting_bar.test.tsx | 2 - .../configuration_bar.test.tsx.snap | 18 +-- .../file_info_preview.test.tsx.snap | 4 +- .../__snapshots__/add_bot.test.tsx.snap | 2 +- .../markdown_image.test.tsx.snap | 2 +- ...nboarding_tasklist_completed.test.tsx.snap | 2 +- .../__snapshots__/select_team.test.tsx.snap | 14 +- .../virtualized_thread_viewer.test.tsx | 3 - .../custom_theme_chooser.test.tsx.snap | 4 +- .../src/selectors/entities/roles.ts | 4 +- webapp/channels/src/tests/image_url_mock.json | 1 + .../src/tests/react_testing_utils.tsx | 13 ++ webapp/channels/src/tests/setup_jest.ts | 2 + .../channels/src/utils/notifications.test.ts | 4 +- .../element_identification.test.tsx | 145 ++++++++++++++++++ .../element_identification.ts | 45 ++++++ .../performance_telemetry/reporter.test.ts | 6 +- .../utils/performance_telemetry/reporter.ts | 37 ++++- webapp/package-lock.json | 8 +- 31 files changed, 352 insertions(+), 104 deletions(-) create mode 100644 webapp/channels/src/tests/image_url_mock.json create mode 100644 webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx create mode 100644 webapp/channels/src/utils/performance_telemetry/element_identification.ts diff --git a/server/channels/app/metrics.go b/server/channels/app/metrics.go index 95a3cc5737..4b47757487 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 42b545c455..01cf0cc5f4 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 59fa8387da..7cc841e5cf 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 7fab048b9c..a393b141ad 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 a7f566923d..3daae496c3 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" - } - - // check if the agent is specified - agent, ok = r.Labels["agent"] - if !ok { - agent = "other" - } - agent = strings.ToLower(agent) - - // check if agent is one of the accepted agents - _, ok = acceptedAgents[agent] - if !ok { - agent = "other" - } - return map[string]string{ - "platform": platform, - "agent": agent, + "platform": processLabel(r.Labels, "platform", acceptedPlatforms, "other"), + "agent": processLabel(r.Labels, "agent", acceptedAgents, "other"), } } + +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 { + return defaultValue + } + value = strings.ToLower(value) + + // check if the value is one that we accept + _, ok = acceptedValues[value] + if !ok { + return defaultValue + } + + return value +} diff --git a/webapp/channels/jest.config.js b/webapp/channels/jest.config.js index a6bcd7ab00..128431fa03 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 bc18844500..f3c10c4c37 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 fd682990b6..3f340387c4 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 36b7c687aa..2ec52a9cf4 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 bb21cce82c..701159374a 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 61ecb72b2f..66d19fdc96 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 440148386c..ae33c7832f 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 160c56a820..ea0a709e06 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 0000000000..e16c76dff8 --- /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 de0455a320..ecd682f6ef 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 b7eb5754a4..c1e135efe8 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 e53980c12d..3b10c7113f 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 0000000000..9407d7cdf9 --- /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 0000000000..462f2157bf --- /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 bdf09a1710..456f467c26 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 811ff944a5..a75ba4f4e6 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 45c52a584d..be610ea735 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",