Files
clean-architecture-frontend…/scripts/collect-web-vitals-evidence.mjs

107 lines
3.4 KiB
JavaScript

import { mkdir, readFile, writeFile } from "node:fs/promises";
import {
evaluateFieldBudget,
percentile75,
} from "../src/application/policies/performance-budgets.js";
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
const inputPath =
process.env.FIELD_WEB_VITALS_INPUT ||
"config/performance/field-input.example.json";
const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
const now = new Date();
const validation = validateFieldEvidenceInput(
rawInput,
process.env.MIN_ELIGIBLE_SAMPLES,
now,
);
const input = validation.data;
const configured =
/** @type {{
* p75LcpMs: number,
* p75Cls: number,
* p75InpMs: number,
* minimumEligibleSamples: number | null
* }} */ (
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
);
const minimumEligibleSamples = validation.minimumEligibleSamples;
const fallbackEnd = now;
const fallbackStart = new Date(fallbackEnd);
fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28);
const start = input ? new Date(input.window.start) : fallbackStart;
const end = input ? new Date(input.window.end) : fallbackEnd;
const eligible = (input?.samples ?? []).filter((sample) => {
const timestamp = new Date(sample.timestamp);
return (
sample.consent === true &&
sample.releaseId === input?.releaseId &&
timestamp >= start &&
timestamp <= end
);
});
const metrics = {
p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)),
p75Cls: percentile75(eligible.map((sample) => sample.cls)),
p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)),
};
const thresholds = { ...configured, minimumEligibleSamples };
const result = evaluateFieldBudget(
{ metrics, eligibleSamples: eligible.length },
thresholds,
);
const passed = validation.passed && result.passed;
const status = validation.passed ? result.status : "FAIL_UNVERIFIED";
const routeSamples = Object.fromEntries(
Object.entries(
eligible.reduce(
(counts, sample) => {
counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1;
return counts;
},
/** @type {Record<string, number>} */ ({}),
),
).sort(([left], [right]) => left.localeCompare(right)),
);
const report = {
schemaVersion: 1,
generatedAt: now.toISOString(),
window: { days: 28, start: start.toISOString(), end: end.toISOString() },
context: {
source: inputPath,
sourceSystem: input?.source.system ?? null,
exportId: input?.source.exportId ?? null,
network: "production-real-user",
routeAggregation: "route-id-only",
releaseId: input?.releaseId ?? null,
privacyApprovalRef: input?.privacy.approvalRef ?? null,
thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null,
validationFailures: validation.failures,
},
metrics,
thresholds,
eligibility: {
consentRequired: true,
totalSamples: input?.samples.length ?? 0,
eligibleSamples: eligible.length,
minimumEligibleSamples,
routeSamples,
},
status,
passed,
};
await mkdir("artifacts/performance", { recursive: true });
await writeFile(
"artifacts/performance/field-web-vitals.json",
`${JSON.stringify(report, null, 2)}\n`,
);
if (!passed) {
process.stderr.write(
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
);
process.exit(1);
}
process.stdout.write("Field Web Vitals: PASS\n");