1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 14:24:58 +02:00
This commit is contained in:
syuilo
2026-06-27 17:17:43 +09:00
parent e9715aa45a
commit 96a454ee3a
4 changed files with 1630 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { readFile, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as util from './utility.mts';
import * as heapSnapshotUtil from './heap-snapshot-util.mts';
import type { HeapSnapshotData } from './heap-snapshot-util.mts';
export type BrowserMeasurement = {
label: string;
timestamp: string;
url: string;
scenario: string;
durationMs: number;
network: {
requestCount: number;
finishedRequestCount: number;
failedRequestCount: number;
cachedRequestCount: number;
serviceWorkerRequestCount: number;
totalEncodedBytes: number;
totalDecodedBodyBytes: number;
sameOriginEncodedBytes: number;
thirdPartyEncodedBytes: number;
byResourceType: Record<string, {
requests: number;
encodedBytes: number;
decodedBodyBytes: number;
}>;
largestRequests: {
url: string;
method: string;
resourceType: string;
status?: number;
encodedBytes: number;
decodedBodyBytes: number;
}[];
failedRequests: {
url: string;
method: string;
resourceType: string;
errorText?: string;
status?: number;
}[];
};
performance: {
cdpMetrics: Record<string, number>;
runtimeHeap?: {
usedSize: number;
totalSize: number;
};
webVitals: {
firstPaintMs?: number;
firstContentfulPaintMs?: number;
domContentLoadedEventEndMs?: number;
loadEventEndMs?: number;
longTaskCount: number;
longTaskDurationMs: number;
maxLongTaskDurationMs: number;
resourceEntryCount: number;
domElements: number;
};
};
heapSnapshot: HeapSnapshotData;
};
export type BrowserMeasurementSample = BrowserMeasurement & {
round: number;
};
export type BrowserMetricsReport = {
label: string;
timestamp: string;
url: string;
scenario: string;
sampleCount: number;
aggregation: 'median';
summary: BrowserMeasurement;
samples: BrowserMeasurementSample[];
};
function escapeCell(value: string) {
return String(value).replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
function truncate(value: string, maxLength = 140) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function formatMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
if (value >= 1_000) return `${util.formatNumber(value / 1_000)} s`;
return `${util.formatNumber(value)} ms`;
}
function formatSecondsAsMs(value: number | null | undefined) {
if (value == null || !Number.isFinite(value)) return '-';
return formatMs(value * 1_000);
}
function formatDelta(value: number, formatter: (value: number) => string) {
if (value === 0) return formatter(0);
return util.formatColoredDelta(formatter(Math.abs(value)), value);
}
function finiteValues(values: (number | null | undefined)[]) {
return values.filter(value => Number.isFinite(value)) as number[];
}
function sampleSpread(report: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) {
const values = finiteValues(report.samples.map(sample => getValue(sample)));
if (values.length < 2) return null;
const center = util.median(values);
return util.median(values.map(value => Math.abs(value - center)));
}
function formatValueWithSpread(report: BrowserMetricsReport, value: number, getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined, formatter: (value: number) => string) {
const spread = sampleSpread(report, getSampleValue);
if (spread == null) return formatter(value);
return `${formatter(value)}<br>± ${formatter(spread)}`;
}
function pairedDelta(reportBase: BrowserMetricsReport, reportHead: BrowserMetricsReport, getValue: (sample: BrowserMeasurementSample) => number | null | undefined) {
try {
return util.pairedDeltaSummary(reportBase.samples, reportHead.samples, sample => getValue(sample) ?? null);
} catch {
return null;
}
}
function metricRow(
label: string,
base: BrowserMetricsReport,
head: BrowserMetricsReport,
getSummaryValue: (summary: BrowserMeasurement) => number | null | undefined,
getSampleValue: (sample: BrowserMeasurementSample) => number | null | undefined,
formatter: (value: number) => string,
) {
const baseValue = getSummaryValue(base.summary);
const headValue = getSummaryValue(head.summary);
if (baseValue == null || headValue == null || !Number.isFinite(baseValue) || !Number.isFinite(headValue)) return null;
const summary = pairedDelta(base, head, getSampleValue);
const medianDelta = summary?.median ?? (headValue - baseValue);
const deltaMedian = `${formatDelta(medianDelta, formatter)}<br>${util.calcAndFormatDeltaPercent(baseValue, headValue).replaceAll('\\%', '\\\\%')}`;
return `| **${label}** | ${formatValueWithSpread(base, baseValue, getSampleValue, formatter)} | ${formatValueWithSpread(head, headValue, getSampleValue, formatter)} | ${deltaMedian} | ${summary == null ? '-' : formatter(summary.mad)} | ${summary == null ? '-' : formatDelta(summary.min, formatter)} | ${summary == null ? '-' : formatDelta(summary.max, formatter)} |`;
}
function resourceTypeBytes(report: BrowserMeasurement, resourceTypes: string[]) {
return resourceTypes.reduce((sum, resourceType) => sum + (report.network.byResourceType[resourceType]?.encodedBytes ?? 0), 0);
}
function resourceTypeSampleBytes(sample: BrowserMeasurementSample, resourceTypes: string[]) {
return resourceTypeBytes(sample, resourceTypes);
}
function getMetric(report: BrowserMeasurement, key: string) {
return report.performance.cdpMetrics[key];
}
function renderSummaryTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const rows = [
metricRow('Scenario duration', base, head, summary => summary.durationMs, sample => sample.durationMs, formatMs),
metricRow('Requests', base, head, summary => summary.network.requestCount, sample => sample.network.requestCount, util.formatNumber),
metricRow('Failed requests', base, head, summary => summary.network.failedRequestCount, sample => sample.network.failedRequestCount, util.formatNumber),
metricRow('Encoded network', base, head, summary => summary.network.totalEncodedBytes, sample => sample.network.totalEncodedBytes, util.formatBytes),
metricRow('Decoded body', base, head, summary => summary.network.totalDecodedBodyBytes, sample => sample.network.totalDecodedBodyBytes, util.formatBytes),
metricRow('Same-origin encoded', base, head, summary => summary.network.sameOriginEncodedBytes, sample => sample.network.sameOriginEncodedBytes, util.formatBytes),
metricRow('Third-party encoded', base, head, summary => summary.network.thirdPartyEncodedBytes, sample => sample.network.thirdPartyEncodedBytes, util.formatBytes),
metricRow('Script encoded', base, head, summary => resourceTypeBytes(summary, ['Script']), sample => resourceTypeSampleBytes(sample, ['Script']), util.formatBytes),
metricRow('Stylesheet encoded', base, head, summary => resourceTypeBytes(summary, ['Stylesheet']), sample => resourceTypeSampleBytes(sample, ['Stylesheet']), util.formatBytes),
metricRow('Fetch/XHR encoded', base, head, summary => resourceTypeBytes(summary, ['Fetch', 'XHR']), sample => resourceTypeSampleBytes(sample, ['Fetch', 'XHR']), util.formatBytes),
metricRow('Image encoded', base, head, summary => resourceTypeBytes(summary, ['Image']), sample => resourceTypeSampleBytes(sample, ['Image']), util.formatBytes),
metricRow('Font encoded', base, head, summary => resourceTypeBytes(summary, ['Font']), sample => resourceTypeSampleBytes(sample, ['Font']), util.formatBytes),
metricRow('First contentful paint', base, head, summary => summary.performance.webVitals.firstContentfulPaintMs, sample => sample.performance.webVitals.firstContentfulPaintMs, formatMs),
metricRow('Load event end', base, head, summary => summary.performance.webVitals.loadEventEndMs, sample => sample.performance.webVitals.loadEventEndMs, formatMs),
metricRow('Long tasks', base, head, summary => summary.performance.webVitals.longTaskCount, sample => sample.performance.webVitals.longTaskCount, util.formatNumber),
metricRow('Long task duration', base, head, summary => summary.performance.webVitals.longTaskDurationMs, sample => sample.performance.webVitals.longTaskDurationMs, formatMs),
metricRow('Max long task', base, head, summary => summary.performance.webVitals.maxLongTaskDurationMs, sample => sample.performance.webVitals.maxLongTaskDurationMs, formatMs),
metricRow('JS heap used', base, head, summary => summary.performance.runtimeHeap?.usedSize ?? getMetric(summary, 'JSHeapUsedSize'), sample => sample.performance.runtimeHeap?.usedSize ?? getMetric(sample, 'JSHeapUsedSize'), util.formatBytes),
metricRow('JS heap total', base, head, summary => summary.performance.runtimeHeap?.totalSize ?? getMetric(summary, 'JSHeapTotalSize'), sample => sample.performance.runtimeHeap?.totalSize ?? getMetric(sample, 'JSHeapTotalSize'), util.formatBytes),
metricRow('V8 heap snapshot total', base, head, summary => summary.heapSnapshot.categories.total, sample => sample.heapSnapshot.categories.total, util.formatBytes),
metricRow('DOM elements', base, head, summary => summary.performance.webVitals.domElements, sample => sample.performance.webVitals.domElements, util.formatNumber),
metricRow('CDP nodes', base, head, summary => getMetric(summary, 'Nodes'), sample => getMetric(sample, 'Nodes'), util.formatNumber),
metricRow('JS event listeners', base, head, summary => getMetric(summary, 'JSEventListeners'), sample => getMetric(sample, 'JSEventListeners'), util.formatNumber),
metricRow('Layout count', base, head, summary => getMetric(summary, 'LayoutCount'), sample => getMetric(sample, 'LayoutCount'), util.formatNumber),
metricRow('Recalc style count', base, head, summary => getMetric(summary, 'RecalcStyleCount'), sample => getMetric(sample, 'RecalcStyleCount'), util.formatNumber),
metricRow('Script duration', base, head, summary => getMetric(summary, 'ScriptDuration'), sample => getMetric(sample, 'ScriptDuration'), formatSecondsAsMs),
metricRow('Task duration', base, head, summary => getMetric(summary, 'TaskDuration'), sample => getMetric(sample, 'TaskDuration'), formatSecondsAsMs),
].filter(row => row != null);
return [
'| Metric | Base median | Head median | Δ median | Δ MAD | Δ min | Δ max |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
...rows,
].join('\n');
}
function renderResourceTypeTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const preferredOrder = ['Document', 'Script', 'Stylesheet', 'Fetch', 'XHR', 'Image', 'Font', 'Media', 'WebSocket', 'EventSource', 'Other'];
const keys = [...new Set([
...preferredOrder,
...Object.keys(base.summary.network.byResourceType),
...Object.keys(head.summary.network.byResourceType),
])].filter(key => base.summary.network.byResourceType[key] != null || head.summary.network.byResourceType[key] != null);
const lines = [
'| Type | Base reqs | Head reqs | Δ reqs | Base encoded | Head encoded | Δ encoded |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
for (const key of keys) {
const baseRow = base.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
const headRow = head.summary.network.byResourceType[key] ?? { requests: 0, encodedBytes: 0 };
lines.push(`| **${key}** | ${util.formatNumber(baseRow.requests)} | ${util.formatNumber(headRow.requests)} | ${formatDelta(headRow.requests - baseRow.requests, util.formatNumber)} | ${util.formatBytes(baseRow.encodedBytes)} | ${util.formatBytes(headRow.encodedBytes)} | ${formatDelta(headRow.encodedBytes - baseRow.encodedBytes, util.formatBytes)} |`);
}
return lines.join('\n');
}
function renderHeapSnapshotTable(base: BrowserMetricsReport, head: BrowserMetricsReport) {
const lines = [
'| Category | Base median | Head median | Δ median | Δ MAD | Base nodes | Head nodes |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
for (const category of Object.keys(heapSnapshotUtil.heapSnapshotCategory) as (keyof typeof heapSnapshotUtil.heapSnapshotCategory)[]) {
const baseValue = base.summary.heapSnapshot.categories[category];
const headValue = head.summary.heapSnapshot.categories[category];
const baseNodeCount = base.summary.heapSnapshot.nodeCounts[category];
const headNodeCount = head.summary.heapSnapshot.nodeCounts[category];
const categoryInfo = heapSnapshotUtil.heapSnapshotCategory[category];
const summary = pairedDelta(base, head, sample => sample.heapSnapshot.categories[category]);
const metricText = `$\\color{${categoryInfo.color}}{\\rule{8pt}{8pt}}$ **${categoryInfo.label}**`;
lines.push(`| ${metricText} | ${util.formatBytes(baseValue)} | ${util.formatBytes(headValue)} | ${formatDelta(summary?.median ?? (headValue - baseValue), util.formatBytes)} | ${summary == null ? '-' : util.formatBytes(summary.mad)} | ${util.formatNumber(baseNodeCount)} | ${util.formatNumber(headNodeCount)} |`);
}
return lines.join('\n');
}
function renderLargestRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.largestRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Encoded | Decoded |',
'| --- | --- | ---: | ---: | ---: |',
];
for (const request of report.summary.network.largestRequests.slice(0, 10)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${util.formatBytes(request.encodedBytes)} | ${util.formatBytes(request.decodedBodyBytes)} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function renderFailedRequests(report: BrowserMetricsReport, title: string) {
if (report.summary.network.failedRequests.length === 0) return null;
const lines = [
`<details><summary>${title}</summary>`,
'',
'| Resource | Type | Status | Error |',
'| --- | --- | ---: | --- |',
];
for (const request of report.summary.network.failedRequests.slice(0, 20)) {
lines.push(`| \`${escapeCell(truncate(request.url))}\` | ${escapeCell(request.resourceType)} | ${request.status ?? '-'} | ${escapeCell(request.errorText ?? '')} |`);
}
lines.push('', '</details>');
return lines.join('\n');
}
function renderHeadHeapSankey(head: BrowserMetricsReport) {
return heapSnapshotUtil.renderHeapSnapshotSankey({
summary: head.summary.heapSnapshot,
samples: head.samples.map(sample => ({
round: sample.round,
data: sample.heapSnapshot,
})),
}, 'Head browser');
}
export function renderFrontendBrowserReport(base: BrowserMetricsReport, head: BrowserMetricsReport, options: {
headHeapSnapshotUrl?: string;
} = {}) {
const headHeapSnapshotUrl = options.headHeapSnapshotUrl;
const sampleSummary = base.sampleCount === head.sampleCount
? `${base.sampleCount} samples per side`
: `${base.sampleCount} base sample(s), ${head.sampleCount} head sample(s)`;
const lines = [
'## Frontend Browser Metrics',
'',
renderSummaryTable(base, head),
'',
`_Measured ${sampleSummary} with fresh headless Chrome profiles, browser cache disabled, service workers bypassed, and forced V8 GC before each heap snapshot. Values are medians; ± is median absolute deviation. Scenario: sign up, dismiss the initial account setup dialog, create the first timeline note, then wait until that note is visible._`,
'',
'<details>',
'<summary>Requests by resource type</summary>',
'',
renderResourceTypeTable(base, head),
'',
'</details>',
'',
'<details>',
'<summary>V8 heap snapshot statistics</summary>',
'',
renderHeapSnapshotTable(base, head),
'',
...(headHeapSnapshotUrl != null && headHeapSnapshotUrl !== '' ? [`[Download representative head heap snapshot](${headHeapSnapshotUrl})`, ''] : []),
'</details>',
'',
];
for (const section of [
renderHeadHeapSankey(head),
renderLargestRequests(head, 'Largest representative head requests'),
renderFailedRequests(base, 'Failed representative base requests'),
renderFailedRequests(head, 'Failed representative head requests'),
]) {
if (section == null) continue;
lines.push(section, '');
}
return lines.join('\n').trimEnd() + '\n';
}
async function main() {
const [baseFile, headFile, outputFile] = process.argv.slice(2);
if (baseFile == null || headFile == null || outputFile == null) {
throw new Error('Usage: node frontend-browser-report.mts <base-browser.json> <head-browser.json> <output.md>');
}
const base = JSON.parse(await readFile(baseFile, 'utf8')) as BrowserMetricsReport;
const head = JSON.parse(await readFile(headFile, 'utf8')) as BrowserMetricsReport;
await writeFile(outputFile, renderFrontendBrowserReport(base, head, {
headHeapSnapshotUrl: process.env.FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL,
}));
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
name: frontend-browser-metrics-report-comment
on:
workflow_run:
workflows:
- frontend-browser-metrics-report
types:
- completed
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
name: Comment frontend browser metrics report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
concurrency:
group: frontend-browser-metrics-report-comment-${{ github.event.workflow_run.id }}
cancel-in-progress: true
steps:
- name: Download browser metrics report
uses: actions/download-artifact@v8
with:
name: frontend-browser-metrics-report
path: ${{ runner.temp }}/frontend-browser-metrics-report
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ github.event.workflow_run.id }}
- name: Load PR number
id: load-pr-number
shell: bash
run: echo "pr-number=$(cat "$RUNNER_TEMP/frontend-browser-metrics-report/pr-number.txt")" >> "$GITHUB_OUTPUT"
- name: Comment on pull request
uses: thollander/actions-comment-pull-request@v3
with:
pr-number: ${{ steps.load-pr-number.outputs.pr-number }}
comment-tag: frontend_browser_metrics_report
file-path: ${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md

View File

@@ -0,0 +1,173 @@
name: frontend-browser-metrics-report
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/backend/**
- packages/i18n/**
- packages/icons-subsetter/**
- packages/misskey-js/**
- packages/misskey-reversi/**
- packages/misskey-bubble-game/**
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .node-version
- .github/misskey/test.yml
- .github/scripts/utility.mts
- .github/scripts/frontend-browser-report.mts
- .github/scripts/heap-snapshot-util.mts
- .github/scripts/measure-frontend-browser-comparison.mts
- .github/workflows/frontend-browser-metrics-report.yml
- .github/workflows/frontend-browser-metrics-report-comment.yml
permissions:
contents: read
pull-requests: read
concurrency:
group: frontend-browser-metrics-report-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
name: Measure frontend browser metrics
runs-on: ubuntu-latest
timeout-minutes: 90
services:
postgres:
image: postgres:18
ports:
- 54312:5432
env:
POSTGRES_DB: test-misskey
POSTGRES_HOST_AUTH_METHOD: trust
redis:
image: redis:8
ports:
- 56312:6379
steps:
- name: Checkout base
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: before
submodules: true
- name: Checkout pull request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
path: after
submodules: true
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.3
with:
package_json_file: after/package.json
- name: Setup Node.js
uses: actions/setup-node@v6.4.0
with:
node-version-file: after/.node-version
cache: pnpm
cache-dependency-path: |
before/pnpm-lock.yaml
after/pnpm-lock.yaml
- name: Install dependencies for base
working-directory: before
run: pnpm i --frozen-lockfile
- name: Configure base
working-directory: before
run: cp .github/misskey/test.yml .config
- name: Build base
working-directory: before
run: pnpm build
- name: Install dependencies for pull request
working-directory: after
run: pnpm i --frozen-lockfile
- name: Configure pull request
working-directory: after
run: cp .github/misskey/test.yml .config
- name: Build pull request
working-directory: after
run: pnpm build
- name: Measure frontend browser metrics
shell: bash
env:
FRONTEND_BROWSER_METRICS_SAMPLE_COUNT: 5
FRONTEND_BROWSER_METRICS_SCENARIO_TIMEOUT_MS: 120000
FRONTEND_BROWSER_METRICS_SETTLE_MS: 1000
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
mkdir -p "$REPORT_DIR"
node after/.github/scripts/measure-frontend-browser-comparison.mts before after "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/head-heap-snapshot.heapsnapshot"
- name: Upload browser head heap snapshot
id: upload-browser-head-heap-snapshot
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-head-heap-snapshot
path: ${{ runner.temp }}/frontend-browser-metrics-report/head-heap-snapshot.heapsnapshot
if-no-files-found: error
retention-days: 7
- name: Generate browser metrics report
shell: bash
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
FRONTEND_BROWSER_HEAD_HEAP_SNAPSHOT_ARTIFACT_URL: ${{ steps.upload-browser-head-heap-snapshot.outputs.artifact-url }}
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
test -s "$REPORT_DIR/before-browser.json"
test -s "$REPORT_DIR/after-browser.json"
node after/.github/scripts/frontend-browser-report.mts "$REPORT_DIR/before-browser.json" "$REPORT_DIR/after-browser.json" "$REPORT_DIR/frontend-browser-metrics-report.md"
printf '%s\n' "$PR_NUMBER" > "$REPORT_DIR/pr-number.txt"
printf '%s\n' "$BASE_SHA" > "$REPORT_DIR/base-sha.txt"
printf '%s\n' "$HEAD_SHA" > "$REPORT_DIR/head-sha.txt"
printf '%s\n' "${{ github.event.pull_request.html_url }}" > "$REPORT_DIR/pr-url.txt"
- name: Check browser metrics report
shell: bash
run: |
REPORT_DIR="$RUNNER_TEMP/frontend-browser-metrics-report"
test -s "$REPORT_DIR/frontend-browser-metrics-report.md"
test -s "$REPORT_DIR/pr-number.txt"
test -s "$REPORT_DIR/head-sha.txt"
cat "$REPORT_DIR/frontend-browser-metrics-report.md" >> "$GITHUB_STEP_SUMMARY"
- name: Upload browser metrics report
uses: actions/upload-artifact@v7
with:
name: frontend-browser-metrics-report
path: |
${{ runner.temp }}/frontend-browser-metrics-report/before-browser.json
${{ runner.temp }}/frontend-browser-metrics-report/after-browser.json
${{ runner.temp }}/frontend-browser-metrics-report/frontend-browser-metrics-report.md
${{ runner.temp }}/frontend-browser-metrics-report/pr-number.txt
${{ runner.temp }}/frontend-browser-metrics-report/base-sha.txt
${{ runner.temp }}/frontend-browser-metrics-report/head-sha.txt
${{ runner.temp }}/frontend-browser-metrics-report/pr-url.txt
if-no-files-found: error
retention-days: 7