feat(perf): wire bundle analyzer and web-vitals beacon

This commit is contained in:
Marc-André Bombeck committed 2026-05-16 02:04:02 +02:00
1 parent b0ef80dcd4
commit ebf83b1ee5
6 files changed
+274 -1

No files matched your search

+14 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
const nextConfig: NextConfig = {
output: "standalone",
@@ -24,4 +25,16 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
/**
* v1.4.28 R3d — opt-in bundle analyzer behind `ANALYZE=1`.
*
* `pnpm analyze` (defined in `package.json`) sets the env var and
* runs `next build`; the analyzer writes static HTML reports to
* `.next/analyze/*.html`. The wrapper is a no-op when the env var is
* unset so the regular `pnpm build` pipeline pays nothing.
*/
const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "1",
});
export default withBundleAnalyzer(nextConfig);
+2
View File
@@ -26,6 +26,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"analyze": "ANALYZE=1 next build",
"start": "next start",
"lint": "eslint",
"db:generate": "prisma generate",
@@ -80,6 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@next/bundle-analyzer": "^16.2.6",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^11.14.0",
+128
View File
@@ -108,6 +108,9 @@ importers:
'@axe-core/playwright':
specifier: ^4.11.3
version: 4.11.3(playwright-core@1.59.1)
'@next/bundle-analyzer':
specifier: ^16.2.6
version: 16.2.6
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
@@ -497,6 +500,10 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
'@dotenvx/dotenvx@1.65.0':
resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==}
hasBin: true
@@ -1098,6 +1105,9 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@next/bundle-analyzer@16.2.6':
resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==}
'@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
@@ -1340,6 +1350,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@prisma/adapter-pg@7.8.0':
resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==}
@@ -2951,6 +2964,10 @@ packages:
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn-walk@8.3.5:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -3331,6 +3348,10 @@ packages:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
compress-commons@6.0.2:
resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==}
engines: {node: '>= 14'}
@@ -3481,6 +3502,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@@ -3592,6 +3616,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
duplexer@0.1.2:
resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -4128,6 +4155,10 @@ packages:
resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
gzip-size@6.0.0:
resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
engines: {node: '>=10'}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -4356,6 +4387,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -4784,6 +4819,10 @@ packages:
resolution: {integrity: sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==}
engines: {node: '>=10', npm: '>=6'}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4953,6 +4992,10 @@ packages:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
opener@1.5.2:
resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
hasBin: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -5605,6 +5648,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -5847,6 +5894,10 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
@@ -6113,6 +6164,11 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webpack-bundle-analyzer@4.10.1:
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
engines: {node: '>= 10.13.0'}
hasBin: true
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -6159,6 +6215,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@7.5.10:
resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
@@ -6890,6 +6958,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@discoveryjs/json-ext@0.5.7': {}
'@dotenvx/dotenvx@1.65.0':
dependencies:
commander: 11.1.0
@@ -7369,6 +7439,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@next/bundle-analyzer@16.2.6':
dependencies:
webpack-bundle-analyzer: 4.10.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@next/env@16.2.6': {}
'@next/eslint-plugin-next@16.2.6':
@@ -7607,6 +7684,8 @@ snapshots:
dependencies:
playwright: 1.59.1
'@polka/url@1.0.0-next.29': {}
'@prisma/adapter-pg@7.8.0':
dependencies:
'@prisma/driver-adapter-utils': 7.8.0
@@ -9378,6 +9457,10 @@ snapshots:
dependencies:
acorn: 8.16.0
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
agent-base@7.1.4: {}
@@ -9791,6 +9874,8 @@ snapshots:
commander@14.0.3: {}
commander@7.2.0: {}
compress-commons@6.0.2:
dependencies:
crc-32: 1.2.2
@@ -9928,6 +10013,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
debounce@1.2.1: {}
debug@3.2.7:
dependencies:
ms: 2.1.3
@@ -10023,6 +10110,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
duplexer@0.1.2: {}
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
@@ -10760,6 +10849,10 @@ snapshots:
graphql@16.14.0: {}
gzip-size@6.0.0:
dependencies:
duplexer: 0.1.2
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -10958,6 +11051,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-promise@4.0.0: {}
is-property@1.0.2: {}
@@ -11329,6 +11424,8 @@ snapshots:
mmdb-lib@3.0.2: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3):
@@ -11520,6 +11617,8 @@ snapshots:
powershell-utils: 0.1.0
wsl-utils: 0.3.1
opener@1.5.2: {}
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -12319,6 +12418,12 @@ snapshots:
signal-exit@4.1.0: {}
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
@@ -12610,6 +12715,8 @@ snapshots:
toidentifier@1.0.1: {}
totalist@3.0.1: {}
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.30
@@ -12877,6 +12984,25 @@ snapshots:
web-streams-polyfill@3.3.3: {}
webpack-bundle-analyzer@4.10.1:
dependencies:
'@discoveryjs/json-ext': 0.5.7
acorn: 8.16.0
acorn-walk: 8.3.5
commander: 7.2.0
debounce: 1.2.1
escape-string-regexp: 4.0.0
gzip-size: 6.0.0
html-escaper: 2.0.2
is-plain-object: 5.0.0
opener: 1.5.2
picocolors: 1.1.1
sirv: 2.0.4
ws: 7.5.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -12947,6 +13073,8 @@ snapshots:
wrappy@1.0.2: {}
ws@7.5.10: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
+76
View File
@@ -0,0 +1,76 @@
/**
* v1.4.28 R3d — Web Vitals beacon.
*
* Next 16 exposes `reportWebVitals` from the root layout; we wire the
* client to POST every measurement to this internal route, which logs
* to the shared wide-event pipeline so ops can see CLS / FCP / FID /
* LCP / TTFB / INP histograms in the same place the rest of the
* request flow surfaces.
*
* Why a route + not the front-end alone:
*
* - `console.log` from the client is invisible to the server-side
* observability stack (HealthLog ships logs through the
* `WideEventBuilder` in `src/lib/logging/context.ts`). Funnelling
* metrics through a thin route reuses the existing pipeline.
* - Persisting elsewhere (Postgres, S3) would be a wasted spend —
* this is a beacon. We log and forget.
*
* Authentication: none (the route is internal-only by name; CSP +
* same-origin policy keep cross-site reporting out). The dispatch is
* idempotent — a duplicate beacon is at worst one extra log line.
*
* Rate-limit: deliberately none. Web Vitals emit one report per metric
* per page load (max 6 per session). A bot trying to flood the route
* is cheaper to drop at the edge.
*
* Payload shape: matches `web-vitals`' `Metric` interface — `id`,
* `name`, `value`, `delta`, `rating`. We log the lot; if a future
* client adds fields we'll see them in the event meta.
*/
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
interface WebVitalsBody {
id?: unknown;
name?: unknown;
value?: unknown;
delta?: unknown;
rating?: unknown;
navigationType?: unknown;
}
export const POST = apiHandler(async (request: NextRequest) => {
let body: WebVitalsBody | null = null;
try {
body = (await request.json()) as WebVitalsBody;
} catch {
// Malformed payload — accept silently so the client doesn't retry.
return NextResponse.json({ ok: true });
}
if (!body || typeof body !== "object") {
return NextResponse.json({ ok: true });
}
const name = typeof body.name === "string" ? body.name : "unknown";
const value =
typeof body.value === "number" && Number.isFinite(body.value)
? body.value
: null;
const rating = typeof body.rating === "string" ? body.rating : null;
annotate({
meta: {
"web_vitals.name": name,
"web_vitals.value": value,
"web_vitals.rating": rating,
"web_vitals.navigation_type":
typeof body.navigationType === "string" ? body.navigationType : null,
},
});
return NextResponse.json({ ok: true });
});
+2
View File
@@ -5,6 +5,7 @@ import "./globals.css";
import { Providers } from "@/components/providers";
import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
@@ -96,6 +97,7 @@ export default async function RootLayout({
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
<AuthShell>{children}</AuthShell>
</Providers>
</body>
@@ -0,0 +1,52 @@
"use client";
import { useReportWebVitals } from "next/web-vitals";
/**
* v1.4.28 R3d — client-side mount that pipes every Web Vitals
* measurement to `/api/internal/web-vitals`.
*
* Next 16 ships `useReportWebVitals` as the supported hook for
* capturing CLS / FCP / FID / LCP / TTFB / INP from the live
* navigation. The hook fires once per metric per page load, so even a
* heavy navigation produces at most six beacons.
*
* Delivery transport: `navigator.sendBeacon` when available (queues
* the POST without blocking the page lifecycle, survives the
* `pagehide` event) with a `fetch({ keepalive: true })` fallback.
* The route accepts the payload best-effort and returns 200 even on
* malformed bodies — beacons must never delay or fail the navigation.
*/
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
delta: metric.delta,
rating: metric.rating,
navigationType: metric.navigationType,
});
const url = "/api/internal/web-vitals";
if (
typeof navigator !== "undefined" &&
typeof navigator.sendBeacon === "function"
) {
navigator.sendBeacon(url, new Blob([body], { type: "application/json" }));
return;
}
// Fallback for non-`sendBeacon` browsers — `keepalive` keeps the
// request alive past navigation. We deliberately ignore the
// response (it's a beacon, not a transaction).
void fetch(url, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
keepalive: true,
}).catch(() => undefined);
});
return null;
}