diff --git a/next.config.ts b/next.config.ts index baad6357d..1fbfcb7eb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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); diff --git a/package.json b/package.json index 7f0d3c635..9b2122418 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80d0e0827..071f56fef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/api/internal/web-vitals/route.ts b/src/app/api/internal/web-vitals/route.ts new file mode 100644 index 000000000..9b5470f48 --- /dev/null +++ b/src/app/api/internal/web-vitals/route.ts @@ -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 }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 87a62e877..37bdb92df 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({