Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 135 additions & 1 deletion src/trace-processing/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,144 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription}

${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`;

type Rating = 'good' | 'needs-improvement' | 'poor';

/**
* Rate a timing-based Web Vitals metric value (in ms) against its thresholds.
* Thresholds are from https://web.dev/articles/vitals
*/
export function rateTimingMetric(
metric: string,
valueMs: number,
): Rating | null {
const thresholds: Record<string, {good: number; poor: number}> = {
LCP: {good: 2500, poor: 4000},
FCP: {good: 1800, poor: 3000},
INP: {good: 200, poor: 500},
TTFB: {good: 800, poor: 1800},
};

const t = thresholds[metric];
if (!t) {
return null;
}
if (valueMs <= t.good) {
return 'good';
}
if (valueMs >= t.poor) {
return 'poor';
}
return 'needs-improvement';
}

export function rateCLS(value: number): Rating {
if (value <= 0.1) {
return 'good';
}
if (value >= 0.25) {
return 'poor';
}
return 'needs-improvement';
}

/**
* Build a CrUX field metrics section with ratings included directly,
* using the structured data from the trace insights rather than
* regex post-processing.
*/
function buildRatedCruxSection(result: TraceResult): string[] | null {
const parsedTrace = result.parsedTrace;
const insights = result.insights;
if (!insights) {
return null;
}

// Find the first insight set with CrUX data.
for (const insightSet of insights.values()) {
try {
const cruxScope =
DevTools.CrUXManager.instance().getSelectedScope();
const fieldMetrics =
DevTools.TraceEngine.Insights.Common.getFieldMetricsForInsightSet(
insightSet,
parsedTrace.metadata,
cruxScope,
);

if (!fieldMetrics) {
continue;
}

const {lcp: fieldLcp, inp: fieldInp, cls: fieldCls} = fieldMetrics;
if (!fieldLcp && !fieldInp && !fieldCls) {
continue;
}

const parts: string[] = [];
parts.push('Metrics (field / real users):');

if (fieldLcp) {
const ms = Math.round(fieldLcp.value / 1000);
const rating = rateTimingMetric('LCP', ms);
const ratingStr = rating ? ` [${rating}]` : '';
parts.push(
` - LCP: ${ms} ms (scope: ${fieldLcp.pageScope})${ratingStr}`,
);
}
if (fieldInp) {
const ms = Math.round(fieldInp.value / 1000);
const rating = rateTimingMetric('INP', ms);
const ratingStr = rating ? ` [${rating}]` : '';
parts.push(
` - INP: ${ms} ms (scope: ${fieldInp.pageScope})${ratingStr}`,
);
}
if (fieldCls) {
const clsValue = fieldCls.value;
const rating = rateCLS(clsValue);
parts.push(
` - CLS: ${clsValue.toFixed(2)} (scope: ${fieldCls.pageScope}) [${rating}]`,
);
}

return parts;
} catch {
continue;
}
}

return null;
}

export function getTraceSummary(result: TraceResult): string {
const focus = DevTools.AgentFocus.fromParsedTrace(result.parsedTrace);
const formatter = new DevTools.PerformanceTraceFormatter(focus);
const summaryText = formatter.formatTraceSummary();
let summaryText = formatter.formatTraceSummary();

// Replace the CrUX section in the formatter output with our rated version.
const ratedCrux = buildRatedCruxSection(result);
if (ratedCrux) {
const lines = summaryText.split('\n');
const cruxHeaderIdx = lines.findIndex(l =>
l.startsWith('Metrics (field / real users):'),
);
if (cruxHeaderIdx !== -1) {
// Find the end of the CrUX section (next non-indented line or section header).
let endIdx = cruxHeaderIdx + 1;
while (
endIdx < lines.length &&
(lines[endIdx].startsWith(' - ') || lines[endIdx].startsWith(' - '))
) {
endIdx++;
}
lines.splice(
cruxHeaderIdx,
endIdx - cruxHeaderIdx,
...ratedCrux,
);
summaryText = lines.join('\n');
}
}
return `## Summary of Performance trace findings:
${summaryText}

Expand Down
46 changes: 46 additions & 0 deletions tests/trace-processing/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {describe, it} from 'node:test';
import {
getTraceSummary,
parseRawTraceBuffer,
rateCLS,
rateTimingMetric,
} from '../../src/trace-processing/parse.js';

import '../../src/DevtoolsUtils.js';
Expand Down Expand Up @@ -40,6 +42,50 @@ describe('Trace parsing', async () => {
t.assert.snapshot?.(output);
});

describe('rateTimingMetric', () => {
it('rates fast LCP as good', () => {
assert.strictEqual(rateTimingMetric('LCP', 1500), 'good');
});

it('rates moderate LCP as needs-improvement', () => {
assert.strictEqual(rateTimingMetric('LCP', 3000), 'needs-improvement');
});

it('rates slow LCP as poor', () => {
assert.strictEqual(rateTimingMetric('LCP', 5000), 'poor');
});

it('rates fast INP as good', () => {
assert.strictEqual(rateTimingMetric('INP', 100), 'good');
});

it('rates FCP at boundary as needs-improvement', () => {
assert.strictEqual(rateTimingMetric('FCP', 2500), 'needs-improvement');
});

it('rates fast TTFB as good', () => {
assert.strictEqual(rateTimingMetric('TTFB', 500), 'good');
});

it('returns null for unknown metrics', () => {
assert.strictEqual(rateTimingMetric('UNKNOWN', 100), null);
});
});

describe('rateCLS', () => {
it('rates low CLS as good', () => {
assert.strictEqual(rateCLS(0.05), 'good');
});

it('rates moderate CLS as needs-improvement', () => {
assert.strictEqual(rateCLS(0.15), 'needs-improvement');
});

it('rates high CLS as poor', () => {
assert.strictEqual(rateCLS(0.30), 'poor');
});
});

it('will return a message if there is an error', async () => {
const result = await parseRawTraceBuffer(undefined);
assert.deepEqual(result, {
Expand Down