@samitouri / QOS-React-1 / commits / 7a2c7045ae

[mcp] Add proper web-vitals metric collection (#33109)

Multiple things here: - Improve the mean calculation for metrics so we don't report 0 when web-vitals fail to be retrieved - improve ui chaos monkey to use puppeteer APIs since only those trigger INP/CLS metrics since we need emulated mouse clicks - Add logic to navigate to a temp page after render since some web-vitals metrics are only calculated when the page is backgrounded - Some readability improvements

Jorge Cabiedes committed May 6, 2025 at 08:50 UTC 7a2c7045aed222b1ece44a18db6326f2f10c89e3
2 files changed +130 -64
compiler/packages/react-mcp-server/src/index.ts
+12 -7
@@ -22,6 +22,12 @@ import assertExhaustive from './utils/assertExhaustive';
22 import {convert} from 'html-to-text';
23 import {measurePerformance} from './tools/runtimePerf';
24
25 +function calculateMean(values: number[]): string {
26 + return values.length > 0
27 + ? values.reduce((acc, curr) => acc + curr, 0) / values.length + 'ms'
28 + : 'could not collect';
29 +}
30 +
31 const server = new McpServer({
32 name: 'React',
33 version: '0.0.0',
@@ -326,17 +332,16 @@ server.tool(
332 # React Component Performance Results
333
334 ## Mean Render Time
329 -${results.renderTime / iterations}ms
335 +${calculateMean(results.renderTime)}
336
337 ## Mean Web Vitals
332 -- Cumulative Layout Shift (CLS): ${results.webVitals.cls / iterations}ms
333 -- Largest Contentful Paint (LCP): ${results.webVitals.lcp / iterations}ms
334 -- Interaction to Next Paint (INP): ${results.webVitals.inp / iterations}ms
335 -- First Input Delay (FID): ${results.webVitals.fid / iterations}ms
338 +- Cumulative Layout Shift (CLS): ${calculateMean(results.webVitals.cls)}
339 +- Largest Contentful Paint (LCP): ${calculateMean(results.webVitals.lcp)}
340 +- Interaction to Next Paint (INP): ${calculateMean(results.webVitals.inp)}
341
342 ## Mean React Profiler
338 -- Actual Duration: ${results.reactProfiler.actualDuration / iterations}ms
339 -- Base Duration: ${results.reactProfiler.baseDuration / iterations}ms
343 +- Actual Duration: ${calculateMean(results.reactProfiler.actualDuration)}
344 +- Base Duration: ${calculateMean(results.reactProfiler.baseDuration)}
345 `;
346
347 return {
compiler/packages/react-mcp-server/src/tools/runtimePerf.ts
+118 -57
@@ -8,25 +8,51 @@ import * as babelPresetEnv from '@babel/preset-env';
8 import * as babelPresetReact from '@babel/preset-react';
9
10 type PerformanceResults = {
11 - renderTime: number;
11 + renderTime: number[];
12 webVitals: {
13 - cls: number;
14 - lcp: number;
15 - inp: number;
16 - fid: number;
17 - ttfb: number;
13 + cls: number[];
14 + lcp: number[];
15 + inp: number[];
16 + fid: number[];
17 + ttfb: number[];
18 };
19 reactProfiler: {
20 - id: number;
21 - phase: number;
22 - actualDuration: number;
23 - baseDuration: number;
24 - startTime: number;
25 - commitTime: number;
20 + id: number[];
21 + phase: number[];
22 + actualDuration: number[];
23 + baseDuration: number[];
24 + startTime: number[];
25 + commitTime: number[];
26 };
27 error: Error | null;
28 };
29
30 +type EvaluationResults = {
31 + renderTime: number | null;
32 + webVitals: {
33 + cls: number | null;
34 + lcp: number | null;
35 + inp: number | null;
36 + fid: number | null;
37 + ttfb: number | null;
38 + };
39 + reactProfiler: {
40 + id: number | null;
41 + phase: number | null;
42 + actualDuration: number | null;
43 + baseDuration: number | null;
44 + startTime: number | null;
45 + commitTime: number | null;
46 + };
47 + error: Error | null;
48 +};
49 +
50 +function delay(time: number) {
51 + return new Promise(function (resolve) {
52 + setTimeout(resolve, time);
53 + });
54 +}
55 +
56 export async function measurePerformance(
57 code: string,
58 iterations: number,
@@ -72,21 +98,21 @@ export async function measurePerformance(
98 const html = buildHtml(transpiled);
99
100 let performanceResults: PerformanceResults = {
75 - renderTime: 0,
101 + renderTime: [],
102 webVitals: {
77 - cls: 0,
78 - lcp: 0,
79 - inp: 0,
80 - fid: 0,
81 - ttfb: 0,
103 + cls: [],
104 + lcp: [],
105 + inp: [],
106 + fid: [],
107 + ttfb: [],
108 },
109 reactProfiler: {
84 - id: 0,
85 - phase: 0,
86 - actualDuration: 0,
87 - baseDuration: 0,
88 - startTime: 0,
89 - commitTime: 0,
110 + id: [],
111 + phase: [],
112 + actualDuration: [],
113 + baseDuration: [],
114 + startTime: [],
115 + commitTime: [],
116 },
117 error: null,
118 };
@@ -96,38 +122,73 @@ export async function measurePerformance(
122 await page.waitForFunction(
123 'window.__RESULT__ !== undefined && (window.__RESULT__.renderTime !== null || window.__RESULT__.error !== null)',
124 );
125 +
126 // ui chaos monkey
100 - await page.waitForFunction(`window.__RESULT__ !== undefined && (function() {
101 - for (const el of [...document.querySelectorAll('a'), ...document.querySelectorAll('button')]) {
102 - console.log(el);
103 - el.click();
127 + const selectors = await page.evaluate(() => {
128 + window.__INTERACTABLE_SELECTORS__ = [];
129 + const elements = Array.from(document.querySelectorAll('a')).concat(
130 + Array.from(document.querySelectorAll('button')),
131 + );
132 + for (const el of elements) {
133 + window.__INTERACTABLE_SELECTORS__.push(el.tagName.toLowerCase());
134 }
105 - return true;
106 - })() `);
107 - const evaluationResult: PerformanceResults = await page.evaluate(() => {
135 + return window.__INTERACTABLE_SELECTORS__;
136 + });
137 +
138 + await Promise.all(
139 + selectors.map(async (selector: string) => {
140 + try {
141 + await page.click(selector);
142 + } catch (e) {
143 + console.log(`warning: Could not click ${selector}: ${e.message}`);
144 + }
145 + }),
146 + );
147 + await delay(500);
148 +
149 + // Visit a new page for 1s to background the current page so that WebVitals can finish being calculated
150 + const tempPage = await browser.newPage();
151 + await tempPage.evaluate(() => {
152 + return new Promise(resolve => {
153 + setTimeout(() => {
154 + resolve(true);
155 + }, 1000);
156 + });
157 + });
158 + await tempPage.close();
159 +
160 + const evaluationResult: EvaluationResults = await page.evaluate(() => {
161 return (window as any).__RESULT__;
162 });
163
111 - // TODO: investigate why webvital metrics are not populating correctly
112 - performanceResults.renderTime += evaluationResult.renderTime;
113 - performanceResults.webVitals.cls += evaluationResult.webVitals.cls || 0;
114 - performanceResults.webVitals.lcp += evaluationResult.webVitals.lcp || 0;
115 - performanceResults.webVitals.inp += evaluationResult.webVitals.inp || 0;
116 - performanceResults.webVitals.fid += evaluationResult.webVitals.fid || 0;
117 - performanceResults.webVitals.ttfb += evaluationResult.webVitals.ttfb || 0;
118 -
119 - performanceResults.reactProfiler.id +=
120 - evaluationResult.reactProfiler.actualDuration || 0;
121 - performanceResults.reactProfiler.phase +=
122 - evaluationResult.reactProfiler.phase || 0;
123 - performanceResults.reactProfiler.actualDuration +=
124 - evaluationResult.reactProfiler.actualDuration || 0;
125 - performanceResults.reactProfiler.baseDuration +=
126 - evaluationResult.reactProfiler.baseDuration || 0;
127 - performanceResults.reactProfiler.startTime +=
128 - evaluationResult.reactProfiler.startTime || 0;
129 - performanceResults.reactProfiler.commitTime +=
130 - evaluationResult.reactProfiler.commitTime || 0;
164 + if (evaluationResult.renderTime !== null) {
165 + performanceResults.renderTime.push(evaluationResult.renderTime);
166 + }
167 +
168 + const webVitalMetrics = ['cls', 'lcp', 'inp', 'fid', 'ttfb'] as const;
169 + for (const metric of webVitalMetrics) {
170 + if (evaluationResult.webVitals[metric] !== null) {
171 + performanceResults.webVitals[metric].push(
172 + evaluationResult.webVitals[metric],
173 + );
174 + }
175 + }
176 +
177 + const profilerMetrics = [
178 + 'id',
179 + 'phase',
180 + 'actualDuration',
181 + 'baseDuration',
182 + 'startTime',
183 + 'commitTime',
184 + ] as const;
185 + for (const metric of profilerMetrics) {
186 + if (evaluationResult.reactProfiler[metric] !== null) {
187 + performanceResults.reactProfiler[metric].push(
188 + evaluationResult.reactProfiler[metric],
189 + );
190 + }
191 + }
192
193 performanceResults.error = evaluationResult.error;
194 }
@@ -159,14 +220,14 @@ function buildHtml(transpiled: string) {
220 renderTime: null,
221 webVitals: {},
222 reactProfiler: {},
162 - error: null
223 + error: null,
224 };
225
165 - webVitals.onCLS((metric) => { window.__RESULT__.webVitals.cls = metric; });
166 - webVitals.onLCP((metric) => { window.__RESULT__.webVitals.lcp = metric; });
167 - webVitals.onINP((metric) => { window.__RESULT__.webVitals.inp = metric; });
168 - webVitals.onFID((metric) => { window.__RESULT__.webVitals.fid = metric; });
169 - webVitals.onTTFB((metric) => { window.__RESULT__.webVitals.ttfb = metric; });
226 + webVitals.onCLS(({value}) => { window.__RESULT__.webVitals.cls = value; });
227 + webVitals.onLCP(({value}) => { window.__RESULT__.webVitals.lcp = value; });
228 + webVitals.onINP(({value}) => { window.__RESULT__.webVitals.inp = value; });
229 + webVitals.onFID(({value}) => { window.__RESULT__.webVitals.fid = value; });
230 + webVitals.onTTFB(({value}) => { window.__RESULT__.webVitals.ttfb = value; });
231
232 try {
233 ${transpiled}