Skip to content

Commit da2ee35

Browse files
perf: add Core Web Vitals measurement (INP, LCP, CLS)
Add web-vitals instrumentation for Core Web Vitals measurement: - `initINPObserver()`: Measures Interaction to Next Paint with attribution - `initLCPObserver()`: Measures Largest Contentful Paint with attribution - `initCLSObserver()`: Measures Cumulative Layout Shift with attribution - `initWebVitals()`: Convenience function to initialize all observers Uses web-vitals/attribution for detailed context on what caused each metric. Metrics are automatically reported to Sentry when available. Requires: yarn install && yarn lavamoat:auto Resolves: MetaMask/MetaMask-planning#6735 Resolves: MetaMask/MetaMask-planning#6736 Resolves: MetaMask/MetaMask-planning#6739 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 407e069 commit da2ee35

File tree

4 files changed

+461
-0
lines changed

4 files changed

+461
-0
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@
491491
"unicode-confusables": "^0.1.1",
492492
"uri-js": "^4.4.1",
493493
"uuid": "^8.3.2",
494+
"web-vitals": "^4.2.4",
494495
"xml2js": "^0.6.2"
495496
},
496497
"devDependencies": {
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { initINPObserver, initLCPObserver, initCLSObserver, initWebVitals } from './web-vitals';
2+
3+
// Mock web-vitals
4+
jest.mock('web-vitals/attribution', () => ({
5+
onINP: jest.fn(),
6+
onLCP: jest.fn(),
7+
onCLS: jest.fn(),
8+
}));
9+
10+
import { onINP, onLCP, onCLS } from 'web-vitals/attribution';
11+
12+
const mockOnINP = onINP as jest.Mock;
13+
const mockOnLCP = onLCP as jest.Mock;
14+
const mockOnCLS = onCLS as jest.Mock;
15+
16+
describe('web-vitals', () => {
17+
const originalSentry = globalThis.sentry;
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
22+
delete (globalThis as any).sentry;
23+
});
24+
25+
afterEach(() => {
26+
globalThis.sentry = originalSentry;
27+
});
28+
29+
describe('initINPObserver', () => {
30+
it('registers INP callback', () => {
31+
initINPObserver();
32+
expect(mockOnINP).toHaveBeenCalledTimes(1);
33+
expect(typeof mockOnINP.mock.calls[0][0]).toBe('function');
34+
});
35+
36+
it('reports good INP to Sentry', () => {
37+
const mockSentry = {
38+
setMeasurement: jest.fn(),
39+
setTag: jest.fn(),
40+
setContext: jest.fn(),
41+
addBreadcrumb: jest.fn(),
42+
};
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
(globalThis as any).sentry = mockSentry;
45+
46+
initINPObserver();
47+
const callback = mockOnINP.mock.calls[0][0];
48+
49+
// Simulate good INP (< 200ms)
50+
callback({
51+
value: 150,
52+
attribution: {
53+
eventTarget: 'button',
54+
eventType: 'click',
55+
loadState: 'complete',
56+
interactionTarget: 'button.submit',
57+
},
58+
});
59+
60+
expect(mockSentry.setMeasurement).toHaveBeenCalledWith(
61+
'benchmark.inp',
62+
150,
63+
'millisecond',
64+
);
65+
expect(mockSentry.setTag).toHaveBeenCalledWith('inp.rating', 'good');
66+
expect(mockSentry.setContext).toHaveBeenCalledWith(
67+
'inp_attribution',
68+
expect.objectContaining({
69+
eventTarget: 'button',
70+
eventType: 'click',
71+
}),
72+
);
73+
// Good metrics should not add breadcrumb
74+
expect(mockSentry.addBreadcrumb).not.toHaveBeenCalled();
75+
});
76+
77+
it('reports poor INP with warning breadcrumb', () => {
78+
const mockSentry = {
79+
setMeasurement: jest.fn(),
80+
setTag: jest.fn(),
81+
setContext: jest.fn(),
82+
addBreadcrumb: jest.fn(),
83+
};
84+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85+
(globalThis as any).sentry = mockSentry;
86+
87+
initINPObserver();
88+
const callback = mockOnINP.mock.calls[0][0];
89+
90+
// Simulate poor INP (> 500ms)
91+
callback({
92+
value: 600,
93+
attribution: { eventTarget: 'div', eventType: 'click' },
94+
});
95+
96+
expect(mockSentry.setTag).toHaveBeenCalledWith('inp.rating', 'poor');
97+
expect(mockSentry.addBreadcrumb).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
category: 'performance.inp',
100+
level: 'warning',
101+
}),
102+
);
103+
});
104+
105+
it('handles missing Sentry gracefully', () => {
106+
// Sentry not available
107+
initINPObserver();
108+
const callback = mockOnINP.mock.calls[0][0];
109+
110+
// Should not throw
111+
expect(() => callback({ value: 100, attribution: {} })).not.toThrow();
112+
});
113+
});
114+
115+
describe('initLCPObserver', () => {
116+
it('registers LCP callback', () => {
117+
initLCPObserver();
118+
expect(mockOnLCP).toHaveBeenCalledTimes(1);
119+
});
120+
121+
it('reports LCP to Sentry with attribution', () => {
122+
const mockSentry = {
123+
setMeasurement: jest.fn(),
124+
setTag: jest.fn(),
125+
setContext: jest.fn(),
126+
addBreadcrumb: jest.fn(),
127+
};
128+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
129+
(globalThis as any).sentry = mockSentry;
130+
131+
initLCPObserver();
132+
const callback = mockOnLCP.mock.calls[0][0];
133+
134+
// Simulate good LCP (< 2500ms)
135+
callback({
136+
value: 2000,
137+
attribution: {
138+
element: 'div.account-list',
139+
url: 'https://metamask.io',
140+
},
141+
});
142+
143+
expect(mockSentry.setMeasurement).toHaveBeenCalledWith(
144+
'benchmark.lcp',
145+
2000,
146+
'millisecond',
147+
);
148+
expect(mockSentry.setTag).toHaveBeenCalledWith('lcp.rating', 'good');
149+
expect(mockSentry.setContext).toHaveBeenCalledWith(
150+
'lcp_attribution',
151+
expect.objectContaining({
152+
element: 'div.account-list',
153+
}),
154+
);
155+
});
156+
157+
it('reports poor LCP correctly', () => {
158+
const mockSentry = {
159+
setMeasurement: jest.fn(),
160+
setTag: jest.fn(),
161+
setContext: jest.fn(),
162+
addBreadcrumb: jest.fn(),
163+
};
164+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
165+
(globalThis as any).sentry = mockSentry;
166+
167+
initLCPObserver();
168+
const callback = mockOnLCP.mock.calls[0][0];
169+
170+
// Simulate poor LCP (> 4000ms)
171+
callback({ value: 5000, attribution: {} });
172+
173+
expect(mockSentry.setTag).toHaveBeenCalledWith('lcp.rating', 'poor');
174+
});
175+
});
176+
177+
describe('initCLSObserver', () => {
178+
it('registers CLS callback', () => {
179+
initCLSObserver();
180+
expect(mockOnCLS).toHaveBeenCalledTimes(1);
181+
});
182+
183+
it('reports CLS to Sentry with unitless measurement', () => {
184+
const mockSentry = {
185+
setMeasurement: jest.fn(),
186+
setTag: jest.fn(),
187+
setContext: jest.fn(),
188+
addBreadcrumb: jest.fn(),
189+
};
190+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
191+
(globalThis as any).sentry = mockSentry;
192+
193+
initCLSObserver();
194+
const callback = mockOnCLS.mock.calls[0][0];
195+
196+
// Simulate good CLS (< 0.1)
197+
callback({
198+
value: 0.05,
199+
attribution: {
200+
largestShiftTarget: 'div.token-list',
201+
largestShiftTime: 1000,
202+
largestShiftValue: 0.05,
203+
},
204+
});
205+
206+
expect(mockSentry.setMeasurement).toHaveBeenCalledWith(
207+
'benchmark.cls',
208+
0.05,
209+
'none', // CLS is unitless
210+
);
211+
expect(mockSentry.setTag).toHaveBeenCalledWith('cls.rating', 'good');
212+
});
213+
214+
it('reports poor CLS correctly', () => {
215+
const mockSentry = {
216+
setMeasurement: jest.fn(),
217+
setTag: jest.fn(),
218+
setContext: jest.fn(),
219+
addBreadcrumb: jest.fn(),
220+
};
221+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
222+
(globalThis as any).sentry = mockSentry;
223+
224+
initCLSObserver();
225+
const callback = mockOnCLS.mock.calls[0][0];
226+
227+
// Simulate poor CLS (> 0.25)
228+
callback({ value: 0.3, attribution: {} });
229+
230+
expect(mockSentry.setTag).toHaveBeenCalledWith('cls.rating', 'poor');
231+
});
232+
});
233+
234+
describe('initWebVitals', () => {
235+
it('initializes all observers', () => {
236+
initWebVitals();
237+
238+
expect(mockOnINP).toHaveBeenCalledTimes(1);
239+
expect(mockOnLCP).toHaveBeenCalledTimes(1);
240+
expect(mockOnCLS).toHaveBeenCalledTimes(1);
241+
});
242+
});
243+
});

0 commit comments

Comments
 (0)