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.
This commit is contained in:
Harrison Healey 2024-07-09 15:06:08 -04:00 committed by GitHub
parent 99881b819a
commit e3b2b13292
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 352 additions and 104 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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
}

View File

@ -30,7 +30,7 @@ const config = {
'<rootDir>/src/packages/mattermost-redux/test/$1',
'^mattermost-redux/(.*)$': '<rootDir>/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',
'<rootDir>/src/tests/image_url_mock.json',
'^.+\\.(css|less|scss)$': 'identity-obj-proxy',
'^.*i18n.*\\.(json)$': '<rootDir>/src/tests/i18n_mock.json',
},

View File

@ -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": {

View File

@ -13,7 +13,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Files"
className="overlay__files"
src={null}
src=""
/>
<span>
<i
@ -28,7 +28,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Logo"
className="overlay__logo"
src={null}
src=""
/>
</div>
</div>
@ -48,7 +48,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Files"
className="overlay__files"
src={null}
src=""
/>
<span>
<i
@ -63,7 +63,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Logo"
className="overlay__logo"
src={null}
src=""
/>
</div>
</div>
@ -83,7 +83,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Files"
className="overlay__files"
src={null}
src=""
/>
<span>
<i
@ -98,7 +98,7 @@ exports[`components/FileUploadOverlay should match snapshot when file upload is
<img
alt="Logo"
className="overlay__logo"
src={null}
src=""
/>
</div>
</div>

View File

@ -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"
/>
<div
@ -163,7 +163,7 @@ exports[`components/AddGroupsToChannelModal should match when renderOption is ca
alt="group picture"
className="more-modal__image"
height="32"
src={null}
src=""
width="32"
/>
<div
@ -212,7 +212,7 @@ exports[`components/AddGroupsToChannelModal should match when renderOption is ca
alt="group picture"
className="more-modal__image"
height="32"
src={null}
src=""
width="32"
/>
<div

View File

@ -151,7 +151,7 @@ exports[`components/AddGroupsToTeamModal should match when renderOption is calle
alt="group picture"
className="more-modal__image"
height="32"
src={null}
src=""
width="32"
/>
<div
@ -202,7 +202,7 @@ exports[`components/AddGroupsToTeamModal should match when renderOption is calle
alt="group picture"
className="more-modal__image"
height="32"
src={null}
src=""
width="32"
/>
<div

View File

@ -10,7 +10,7 @@ exports[`components/OpenIdConvert should match snapshot 1`] = `
<img
alt="OpenId Convert Image"
className="OpenIdConvert_image"
src={null}
src=""
/>
</div>
<div

View File

@ -19,8 +19,6 @@ import type {PostDraft} from 'types/store/draft';
import AdavancedTextEditor from './advanced_text_editor';
global.ResizeObserver = require('resize-observer-polyfill');
const currentUserId = 'current_user_id';
const channelId = 'current_channel_id';

View File

@ -10,8 +10,6 @@ import {Locations} from 'utils/constants';
import FormattingBar from './formatting_bar';
import * as Hooks from './hooks';
global.ResizeObserver = require('resize-observer-polyfill');
jest.mock('./hooks');
const {splitFormattingBarControls} = jest.requireActual('./hooks');

View File

@ -10,7 +10,7 @@ exports[`components/ConfigurationBar should match snapshot, expired 1`] = `
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="{licenseSku} license is expired and some features may be disabled."
@ -37,7 +37,7 @@ exports[`components/ConfigurationBar should match snapshot, expired 1`] = `
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="{licenseSku} license is expired and some features may be disabled."
@ -66,7 +66,7 @@ exports[`components/ConfigurationBar should match snapshot, expired, in grace pe
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="{licenseSku} license is expired and some features may be disabled."
@ -93,7 +93,7 @@ exports[`components/ConfigurationBar should match snapshot, expired, in grace pe
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="{licenseSku} license is expired and some features may be disabled."
@ -116,7 +116,7 @@ exports[`components/ConfigurationBar should match snapshot, expired, regular use
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
<Memo(MemoizedFormattedMessage)
defaultMessage="{licenseSku} license is expired and some features may be disabled. Please contact your System Administrator for details."
@ -145,7 +145,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features.
</React.Fragment>
@ -164,7 +164,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
This is the last day of your free trial. Purchase a license now to continue using Mattermost Professional and Enterprise features.
</React.Fragment>
@ -183,7 +183,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
This is the last day of your free trial.
</React.Fragment>
@ -202,7 +202,7 @@ exports[`components/ConfigurationBar should match snapshot, expiring, trial lice
<React.Fragment>
<img
className="advisor-icon"
src={null}
src=""
/>
This is the last day of your free trial.
</React.Fragment>

View File

@ -13,7 +13,7 @@ exports[`components/FileInfoPreview should match snapshot, can download files 1`
/>
<img
alt="file preview"
src={null}
src=""
/>
</a>
<div
@ -45,7 +45,7 @@ exports[`components/FileInfoPreview should match snapshot, cannot download files
/>
<img
alt="file preview"
src={null}
src=""
/>
</span>
<div

View File

@ -79,7 +79,7 @@ exports[`components/integrations/bots/AddBot blank 1`] = `
<img
alt="bot image"
className="bot-img"
src={null}
src=""
style={
Object {
"transform": "",

View File

@ -90,7 +90,7 @@ exports[`components/MarkdownImage should render a link if the source is unsafe 1
<img
alt="test image"
className="markdown-inline-img broken-image"
src={null}
src=""
/>
</div>
`;

View File

@ -10,7 +10,7 @@ exports[`components/onboarding_tasklist/onboarding_tasklist_completed.tsx should
<CompletedWrapper>
<img
alt="completed tasks image"
src={null}
src=""
/>
<h2>
<MemoizedFormattedMessage

View File

@ -15,7 +15,7 @@ exports[`components/select_team/SelectTeam should match snapshot 1`] = `
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -132,7 +132,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on create team
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -222,7 +222,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on error 1`] =
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -310,7 +310,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on loading 1`]
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -386,7 +386,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on no joinable
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -524,7 +524,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on no joinable
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"
@ -615,7 +615,7 @@ exports[`components/select_team/SelectTeam should match snapshot, on no joinable
<img
alt="signup team logo"
className="signup-team-logo"
src={null}
src=""
/>
<Memo(SiteNameAndDescription)
siteName="Mattermost"

View File

@ -15,9 +15,6 @@ import {TestHelper} from 'utils/test_helper';
import VirtualizedThreadViewer from './virtualized_thread_viewer';
// Needed for apply markdown to properly work down the line
global.ResizeObserver = require('resize-observer-polyfill');
type Props = ComponentProps<typeof VirtualizedThreadViewer>;
function getBasePropsAndState(): [Props, DeepPartial<GlobalState>] {
const channel = TestHelper.getChannelMock();

View File

@ -436,7 +436,7 @@ exports[`components/user_settings/display/CustomThemeChooser should match, init
>
<img
alt="code theme image"
src={null}
src=""
width="200"
/>
</Memo(Popover)>
@ -454,7 +454,7 @@ exports[`components/user_settings/display/CustomThemeChooser should match, init
>
<img
alt="code theme image"
src={null}
src=""
/>
</span>
</OverlayTrigger>

View File

@ -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;

View File

@ -0,0 +1 @@
""

View File

@ -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<T>(initialState: DeepPartial<T>, 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));
}

View File

@ -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[]) {

View File

@ -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,
});

View File

@ -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(
<ChannelController shouldRenderCenterChannel={true}/>,
{
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');
});
});

View File

@ -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';
}

View File

@ -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});

View File

@ -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<string, string>;
}
type PerformanceReport = {
@ -184,10 +191,28 @@ export default class PerformanceReporter {
}
private handleWebVital(metric: Metric) {
let labels: Record<string, string> | 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';
}

View File

@ -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",