From fe19730c2c38ef8c8bf131c13453c08989062e83 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:14:19 +0000 Subject: [PATCH] Observability for launch: build metadata, request ids, JSON logs, /api/health Build metadata. next.config.ts computes the version (package.json), the short commit (VERCEL_GIT_COMMIT_SHA, else git) and the build time once, and inlines them through Next's `env` option, so the server, proxy and browser read the same values via lib/build.ts. Every response carries X-App-Version and X-Commit-SHA; window.appVersion holds the same in the browser. Request ids. proxy.ts gives every request an X-Request-ID, keeping a caller's X-Request-ID or X-Correlation-ID when it is a plain token of up to 128 characters. It forwards the id to route handlers and echoes it on the response. Structured logs. pino replaces console.error/info in every route handler and in the proxy. Each line is JSON with the build, and with requestId when it is written during a request. Messages are unchanged, so existing log searches still match. LOG_LEVEL sets the level, defaulting to info in production and debug elsewhere; it is read directly rather than through env(), so the log works when the environment does not. Errors. Both Sentry inits now carry a release and environment, plus app_version and commit tags. Unhandled server errors are logged with their request id and route template, and that id is tagged on the Sentry event. Browser source maps ship with the deployment; the source is public AGPL, so they reveal nothing new. Health. GET /api/health returns status, build and per-service checks. The database check is a head-only read of wedding_documents. It answers 503 when a configured database does not respond; a backend-less deployment is healthy. Verified against `next start`: headers on pages and API routes, a log line's requestId matching the X-Request-ID returned, a 503 with a database that does not answer, one build timestamp in both server and client output, and every loaded chunk's source map served. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VLGpqEpvfAjaAmtfcKdS9b --- docs/SELF-HOSTING.md | 20 +++ package-lock.json | 150 ++++++++++++++++++ suite/.env.example | 4 + suite/app/api/accounts/delete/route.test.ts | 5 +- suite/app/api/accounts/delete/route.ts | 5 +- .../app/api/accounts/invite/[token]/route.ts | 5 +- suite/app/api/accounts/invite/route.ts | 7 +- suite/app/api/accounts/members/route.ts | 9 +- suite/app/api/accounts/weddings/route.ts | 9 +- suite/app/api/cron/sweep/route.ts | 5 +- suite/app/api/documents/export/route.ts | 3 +- suite/app/api/documents/history/[id]/route.ts | 3 +- suite/app/api/documents/history/route.ts | 3 +- suite/app/api/documents/route.ts | 9 +- suite/app/api/health/route.test.ts | 71 +++++++++ suite/app/api/health/route.ts | 55 +++++++ suite/app/api/library/[id]/route.ts | 13 +- suite/app/api/library/route.ts | 11 +- suite/app/api/share/[token]/route.ts | 5 +- suite/app/api/share/route.ts | 11 +- suite/app/api/suppliers/[token]/route.ts | 13 +- suite/app/api/suppliers/route.ts | 11 +- suite/app/auth/callback/route.ts | 5 +- suite/instrumentation-client.ts | 7 + suite/instrumentation.ts | 21 ++- suite/lib/build.ts | 25 +++ suite/lib/headers.test.ts | 14 +- suite/lib/sentry/build.ts | 14 ++ suite/lib/server/log.test.ts | 8 + suite/lib/server/log.ts | 33 ++++ suite/next.config.ts | 45 +++++- suite/package.json | 1 + suite/proxy.test.ts | 48 ++++++ suite/proxy.ts | 44 ++++- 34 files changed, 625 insertions(+), 67 deletions(-) create mode 100644 suite/app/api/health/route.test.ts create mode 100644 suite/app/api/health/route.ts create mode 100644 suite/lib/build.ts create mode 100644 suite/lib/sentry/build.ts create mode 100644 suite/lib/server/log.test.ts create mode 100644 suite/lib/server/log.ts create mode 100644 suite/proxy.test.ts diff --git a/docs/SELF-HOSTING.md b/docs/SELF-HOSTING.md index 11658090..d54461c8 100644 --- a/docs/SELF-HOSTING.md +++ b/docs/SELF-HOSTING.md @@ -89,6 +89,7 @@ cp .env.example .env.local | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | accounts | The anon/publishable key. Safe in the browser — row-level security is what protects the data. | | `CRON_SECRET` | the retention sweep | At least 16 characters. Unset means the sweep endpoint refuses everything, including your scheduler. | | `NEXT_PUBLIC_SENTRY_DSN` | error reporting | Optional. Unset means no Sentry, browser or server. | +| `LOG_LEVEL` | server logs | Optional. `fatal`, `error`, `warn`, `info`, `debug`, `trace` or `silent`. Unset means `info` in production and `debug` elsewhere. | Two things that will catch you out: @@ -175,6 +176,25 @@ Then, in the browser: If step 2 says accounts are not set up, go back to section 3 — it is almost always `NEXT_PUBLIC_SUPABASE_URL` or `NEXT_PUBLIC_SUPABASE_ANON_KEY` missing. +### Health, versions and logs + +`GET /api/health` answers 200 when everything the instance is configured to +use responds, and 503 when the database does not. Point an uptime monitor at +it. It also names the build: + +```sh +curl -s https://your-host/api/health +# {"status":"ok","version":"0.1.0","commit":"ce429f1","builtAt":"…","environment":"production", +# "checks":{"database":"ok","accounts":"configured","errorReporting":"configured"}} +``` + +Every response carries `X-App-Version`, `X-Commit-SHA` and `X-Request-ID`. In +a browser console, `window.appVersion` says the same. The server logs one JSON +object per line, and every line written while handling a request carries +that request's `requestId`. To find what the server did for a request, search +the logs for the `X-Request-ID` it returned. A caller's own `X-Request-ID` or +`X-Correlation-ID` is kept when it is a plain token of up to 128 characters. + ## 7. Deploy it somewhere The hosted instance runs on Vercel with **Root Directory** set to `suite`, and diff --git a/package-lock.json b/package-lock.json index 9534caba..aa160687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2339,6 +2339,12 @@ "pako": "^1.0.10" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5193,6 +5199,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axe-core": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", @@ -6444,6 +6459,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6465,6 +6481,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6486,6 +6503,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6507,6 +6525,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6528,6 +6547,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6549,6 +6569,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6570,6 +6591,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6591,6 +6613,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6612,6 +6635,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6633,6 +6657,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6654,6 +6679,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7036,6 +7062,15 @@ "node": ">=18" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -7189,6 +7224,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.56.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", @@ -7225,6 +7297,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -7280,6 +7353,22 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -7305,6 +7394,12 @@ "node": ">=6" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -7344,6 +7439,15 @@ "license": "MIT", "peer": true }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7483,6 +7587,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -7643,6 +7756,15 @@ "node": ">= 10" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7682,6 +7804,15 @@ "specificity": "bin/specificity" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8015,6 +8146,24 @@ "utrie": "^1.0.2" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -8793,6 +8942,7 @@ "lucide-react": "^1.47.0", "next": "16.3.5", "pdf-lib": "^1.17.1", + "pino": "^10.3.1", "react": "19.3.0", "react-dom": "19.3.0", "svg2pdf.js": "^2.8.1", diff --git a/suite/.env.example b/suite/.env.example index c16ecd5a..1e3f9ab7 100644 --- a/suite/.env.example +++ b/suite/.env.example @@ -40,3 +40,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # unset means the sweep endpoint refuses everything, including Vercel. An # endpoint whose job is deleting fails closed. # CRON_SECRET= + +# Server log verbosity: fatal, error, warn, info, debug, trace or silent. +# Unset means info in production and debug everywhere else. +# LOG_LEVEL= diff --git a/suite/app/api/accounts/delete/route.test.ts b/suite/app/api/accounts/delete/route.test.ts index 6b21dd9e..ed260a87 100644 --- a/suite/app/api/accounts/delete/route.test.ts +++ b/suite/app/api/accounts/delete/route.test.ts @@ -20,6 +20,7 @@ vi.mock("@/lib/accounts/serverClient", () => ({ vi.mock("@/lib/accounts/supabaseStore", () => ({ accountsStore: () => store })); const { POST } = await import("./route"); +const request = () => new Request("http://localhost/api/accounts/delete", { method: "POST" }); beforeEach(() => { currentUserResult = { id: `user-${Math.random()}`, email: "a@example.com" }; @@ -30,9 +31,9 @@ test("account deletion past the limit is throttled, per account", async () => { // then stops at the missing service-role key (mocked env has neither), so // calls 1-5 land on that same 500, distinctly from the 429 call 6 must get. for (let i = 0; i < 5; i += 1) { - const response = await POST(); + const response = await POST(request()); expect(response.status).toBe(500); } - const sixth = await POST(); + const sixth = await POST(request()); expect(sixth.status).toBe(429); }); diff --git a/suite/app/api/accounts/delete/route.ts b/suite/app/api/accounts/delete/route.ts index c593d89b..6d4f921e 100644 --- a/suite/app/api/accounts/delete/route.ts +++ b/suite/app/api/accounts/delete/route.ts @@ -5,16 +5,17 @@ import { deleteAccountHandler } from "@/lib/accounts/handlers"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function POST() { +export async function POST(request: Request) { try { return await deleteAccount(); } catch (error) { // See the note in `../wedding/route.ts`. - console.error("[accounts] POST /api/accounts/delete", error); + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/delete"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/accounts/invite/[token]/route.ts b/suite/app/api/accounts/invite/[token]/route.ts index f6304617..863b3757 100644 --- a/suite/app/api/accounts/invite/[token]/route.ts +++ b/suite/app/api/accounts/invite/[token]/route.ts @@ -5,11 +5,12 @@ import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, tokenSchema } from "@/lib/accounts/schemas"; import { allow, AUTH_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function POST(_request: Request, context: { params: Promise<{ token: string }> }) { +export async function POST(request: Request, context: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) { return NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); @@ -32,7 +33,7 @@ export async function POST(_request: Request, context: { params: Promise<{ token return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { // See the note in `../../wedding/route.ts`. - console.error("[accounts] POST /api/accounts/invite/[token]", error); + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/invite/[token]"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/accounts/invite/route.ts b/suite/app/api/accounts/invite/route.ts index 47f2abf2..27239c73 100644 --- a/suite/app/api/accounts/invite/route.ts +++ b/suite/app/api/accounts/invite/route.ts @@ -5,6 +5,7 @@ import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, inviteSchema } from "@/lib/accounts/schemas"; import { allow, INVITE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,8 +17,8 @@ const throttled = () => NextResponse.json({ error: "Too many requests. Wait a while and try again." }, { status: 429 }); /** See the note in `../wedding/route.ts`: an uncaught throw here becomes an HTML 500 the UI can't parse. */ -const failed = (error: unknown) => { - console.error("[accounts] POST /api/accounts/invite", error); +const failed = (request: Request, error: unknown) => { + requestLog(request).error({ err: error }, "[accounts] POST /api/accounts/invite"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -66,6 +67,6 @@ export async function POST(request: Request) { return NextResponse.json(reply.body, { status: 200 }); } catch (error) { - return failed(error); + return failed(request, error); } } diff --git a/suite/app/api/accounts/members/route.ts b/suite/app/api/accounts/members/route.ts index e2cc0a17..6b743c77 100644 --- a/suite/app/api/accounts/members/route.ts +++ b/suite/app/api/accounts/members/route.ts @@ -4,6 +4,7 @@ import { peopleHandler, removeMemberHandler } from "@/lib/accounts/handlers"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, removeMemberSchema } from "@/lib/accounts/schemas"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -12,8 +13,8 @@ const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); /** See `../weddings/route.ts`: an uncaught throw becomes an HTML 500 the UI can't parse. */ -const failed = (where: string, error: unknown) => { - console.error(`[accounts] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[accounts] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -30,7 +31,7 @@ export async function GET(request: Request) { const reply = await peopleHandler(accountsStore(client), weddingId, user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/accounts/members", error); + return failed(request, "GET /api/accounts/members", error); } } @@ -50,6 +51,6 @@ export async function DELETE(request: Request) { const reply = await removeMemberHandler(accountsStore(client), input.value.weddingId, user.id, input.value.userId); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("DELETE /api/accounts/members", error); + return failed(request, "DELETE /api/accounts/members", error); } } diff --git a/suite/app/api/accounts/weddings/route.ts b/suite/app/api/accounts/weddings/route.ts index 786af78b..bda9822c 100644 --- a/suite/app/api/accounts/weddings/route.ts +++ b/suite/app/api/accounts/weddings/route.ts @@ -6,6 +6,7 @@ import { documentStore } from "@/lib/documents/supabaseStore"; import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { check, newWeddingSchema } from "@/lib/accounts/schemas"; import { allow, CREATE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -22,8 +23,8 @@ const throttled = () => * into Next's default 500, whose body is HTML: the browser's `response.json()` * then rejects and the calling page hangs on its loading state forever. */ -const failed = (where: string, error: unknown) => { - console.error(`[accounts] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[accounts] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -44,7 +45,7 @@ export async function GET(request: Request) { const reply = await listWeddingsHandler(accountsStore(client), documentStore(client), user.id, today); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/accounts/weddings", error); + return failed(request, "GET /api/accounts/weddings", error); } } @@ -66,6 +67,6 @@ export async function POST(request: Request) { const reply = await createWeddingHandler(accountsStore(client), user.id, input.value.role); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("POST /api/accounts/weddings", error); + return failed(request, "POST /api/accounts/weddings", error); } } diff --git a/suite/app/api/cron/sweep/route.ts b/suite/app/api/cron/sweep/route.ts index 680c2833..531a21b0 100644 --- a/suite/app/api/cron/sweep/route.ts +++ b/suite/app/api/cron/sweep/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { env } from "@/lib/env"; import { sweepAbandonedDocuments } from "@/lib/documents/handlers"; import { adminDocumentsClient, documentStore } from "@/lib/documents/supabaseStore"; +import { requestLog } from "@/lib/server/log"; /** * Retention, once a day: account weddings nobody has written to inside the @@ -33,12 +34,12 @@ export async function GET(request: Request) { try { const { deleted } = await sweepAbandonedDocuments(documentStore(adminClient)); // A count only: ids identify rows, and a log is no place for them. - console.info(`[Trousseau] retention sweep removed ${deleted.length} wedding(s)`); + requestLog(request).info({ deleted: deleted.length }, "[Trousseau] retention sweep"); return NextResponse.json({ deleted: deleted.length }); } catch (cause) { // A failed sweep must be loud: it deletes, it runs unattended, and silence // here means data kept past the period the Privacy Policy states. - console.error("[Trousseau] retention sweep failed:", cause); + requestLog(request).error({ err: cause }, "[Trousseau] retention sweep failed"); return NextResponse.json({ error: "The sweep failed." }, { status: 503 }); } } diff --git a/suite/app/api/documents/export/route.ts b/suite/app/api/documents/export/route.ts index 2d9a431d..884e724c 100644 --- a/suite/app/api/documents/export/route.ts +++ b/suite/app/api/documents/export/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { exportDocumentHandler } from "@/lib/documents/handlers"; import { allow, EXPORT_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; /** * "Download my wedding" — the honest answer to "can I get my data out". @@ -60,7 +61,7 @@ export async function GET(request: Request) { }, }); } catch (error) { - console.error("[documents] GET /api/documents/export", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/export"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/history/[id]/route.ts b/suite/app/api/documents/history/[id]/route.ts index 0ec17efa..a88fd28c 100644 --- a/suite/app/api/documents/history/[id]/route.ts +++ b/suite/app/api/documents/history/[id]/route.ts @@ -6,6 +6,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { historyDocumentHandler } from "@/lib/documents/handlers"; import { check } from "@/lib/server/check"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const reply = await historyDocumentHandler(documentStore(client), weddingId, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - console.error("[documents] GET /api/documents/history/[id]", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/history/[id]"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/history/route.ts b/suite/app/api/documents/history/route.ts index 7c206f7c..f8f59645 100644 --- a/suite/app/api/documents/history/route.ts +++ b/suite/app/api/documents/history/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { documentStore } from "@/lib/documents/supabaseStore"; import { historyHandler } from "@/lib/documents/handlers"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -24,7 +25,7 @@ export async function GET(request: Request) { const reply = await historyHandler(documentStore(client), people, weddingId, user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - console.error("[documents] GET /api/documents/history", error); + requestLog(request).error({ err: error }, "[documents] GET /api/documents/history"); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); } } diff --git a/suite/app/api/documents/route.ts b/suite/app/api/documents/route.ts index 6ca34340..d90445a4 100644 --- a/suite/app/api/documents/route.ts +++ b/suite/app/api/documents/route.ts @@ -5,6 +5,7 @@ import { requestedWedding } from "@/lib/accounts/requestedWedding"; import { documentStore } from "@/lib/documents/supabaseStore"; import { getDocumentHandler, saveDocumentHandler } from "@/lib/documents/handlers"; import { allow, WRITE_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -20,8 +21,8 @@ const noWedding = () => const throttled = () => NextResponse.json({ error: "Too many requests. Wait a minute and try again." }, { status: 429 }); -const failed = (where: string, error: unknown) => { - console.error(`[documents] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[documents] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -40,7 +41,7 @@ export async function GET(request: Request) { const reply = await getDocumentHandler(documentStore(client), weddingId); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/documents", error); + return failed(request, "GET /api/documents", error); } } @@ -82,6 +83,6 @@ export async function PUT(request: Request) { const reply = await saveDocumentHandler(documentStore(client), weddingId, body.document, body.expectedVersion); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("PUT /api/documents", error); + return failed(request, "PUT /api/documents", error); } } diff --git a/suite/app/api/health/route.test.ts b/suite/app/api/health/route.test.ts new file mode 100644 index 00000000..52092858 --- /dev/null +++ b/suite/app/api/health/route.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { beforeEach, expect, test, vi } from "vitest"; + +let client: unknown = null; + +vi.mock("@/lib/documents/supabaseStore", () => ({ adminDocumentsClient: () => client })); +vi.mock("@/lib/env", () => ({ + accountsConfigured: () => client !== null, + env: () => ({ NEXT_PUBLIC_SENTRY_DSN: undefined }), +})); + +// What Next's `env` option inlines at build time; vitest has no build to do it. +vi.stubEnv("APP_VERSION", "0.1.0"); +vi.stubEnv("GIT_COMMIT_SHA", "abc1234"); +vi.stubEnv("BUILD_TIMESTAMP", "2026-10-01T00:00:00.000Z"); +vi.stubEnv("APP_ENV", "production"); + +const { GET } = await import("./route"); + +const request = () => new Request("http://localhost/api/health", { headers: { "x-request-id": "health-1" } }); + +/** A Supabase client whose one query answers with `error`. */ +const answering = (error: unknown) => { + const query = { select: () => query, limit: async () => ({ error }) }; + return { from: () => query }; +}; + +beforeEach(() => { + client = null; +}); + +test("a deployment with no backend is healthy, not degraded", async () => { + const response = await GET(request()); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = await response.json(); + expect(body.status).toBe("ok"); + expect(body.checks).toEqual({ + database: "not_configured", + accounts: "not_configured", + errorReporting: "not_configured", + }); +}); + +test("a database that answers is ok", async () => { + client = answering(null); + const response = await GET(request()); + expect(response.status).toBe(200); + expect((await response.json()).checks.database).toBe("ok"); +}); + +test("a database that does not answer is a 503 an uptime monitor will see", async () => { + client = answering({ message: "TypeError: fetch failed" }); + const response = await GET(request()); + expect(response.status).toBe(503); + const body = await response.json(); + expect(body.status).toBe("degraded"); + expect(body.checks.database).toBe("down"); +}); + +test("the build is named, and nothing about the configuration but whether it exists", async () => { + client = answering(null); + const body = await (await GET(request())).json(); + expect(body).toMatchObject({ + version: "0.1.0", + commit: "abc1234", + builtAt: "2026-10-01T00:00:00.000Z", + environment: "production", + }); + expect(JSON.stringify(body)).not.toContain("supabase"); +}); diff --git a/suite/app/api/health/route.ts b/suite/app/api/health/route.ts new file mode 100644 index 00000000..60172c44 --- /dev/null +++ b/suite/app/api/health/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { build } from "@/lib/build"; +import { accountsConfigured, env } from "@/lib/env"; +import { adminDocumentsClient } from "@/lib/documents/supabaseStore"; +import { requestLog } from "@/lib/server/log"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Database = "ok" | "down" | "not_configured"; + +/** + * For an uptime monitor, and for a person asking which build is live. + * + * 200 when everything this deployment is configured to use answers, 503 when + * something does not. A deployment with no backend is healthy: local-only is a + * supported way to run this, not a degraded one. + * + * Says what is configured, never with what: no URL, key or row leaves here. + */ +export async function GET(request: Request) { + const database = await databaseStatus(request); + const status = database === "down" ? "degraded" : "ok"; + + return NextResponse.json( + { + status, + version: build.version, + commit: build.commit, + builtAt: build.builtAt, + environment: build.environment, + checks: { + database, + accounts: accountsConfigured() ? "configured" : "not_configured", + errorReporting: env().NEXT_PUBLIC_SENTRY_DSN ? "configured" : "not_configured", + }, + }, + { status: status === "ok" ? 200 : 503, headers: { "Cache-Control": "no-store" } }, + ); +} + +/** + * A head-only read of the table every account wedding lives in: proves the + * database answers and the service key is accepted, and returns no rows. + */ +async function databaseStatus(request: Request): Promise { + const client = adminDocumentsClient(); + if (!client) return "not_configured"; + + const { error } = await client.from("wedding_documents").select("wedding_id", { head: true }).limit(1); + if (!error) return "ok"; + + requestLog(request).warn({ err: error }, "[health] database check failed"); + return "down"; +} diff --git a/suite/app/api/library/[id]/route.ts b/suite/app/api/library/[id]/route.ts index cc5bd545..956afabe 100644 --- a/suite/app/api/library/[id]/route.ts +++ b/suite/app/api/library/[id]/route.ts @@ -5,14 +5,15 @@ import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { libraryStore } from "@/lib/library/supabaseStore"; import { getHandler, removeHandler } from "@/lib/library/handlers"; import { check } from "@/lib/server/check"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const missing = () => NextResponse.json({ error: "That is not in your library." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[library] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[library] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -26,7 +27,7 @@ async function owner(): Promise<{ id: string; client: NonNullable }) { +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const who = await owner(); if (who instanceof NextResponse) return who; @@ -35,11 +36,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: const reply = await getHandler(libraryStore(who.client), who.id, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/library/[id]", error); + return failed(request, "GET /api/library/[id]", error); } } -export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { try { const who = await owner(); if (who instanceof NextResponse) return who; @@ -48,6 +49,6 @@ export async function DELETE(_request: Request, { params }: { params: Promise<{ const reply = await removeHandler(libraryStore(who.client), who.id, id.value); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("DELETE /api/library/[id]", error); + return failed(request, "DELETE /api/library/[id]", error); } } diff --git a/suite/app/api/library/route.ts b/suite/app/api/library/route.ts index ba6fe302..9ce70b8f 100644 --- a/suite/app/api/library/route.ts +++ b/suite/app/api/library/route.ts @@ -4,19 +4,20 @@ import { currentUser, serverClient } from "@/lib/accounts/serverClient"; import { libraryStore } from "@/lib/library/supabaseStore"; import { listHandler, saveHandler } from "@/lib/library/handlers"; import { allow, LIBRARY_LIMIT } from "@/lib/server/rateLimit"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); -const failed = (where: string, error: unknown) => { - console.error(`[library] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[library] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; /** What this account has kept, newest first. */ -export async function GET() { +export async function GET(request: Request) { try { if (!accountsConfigured()) return unconfigured(); const user = await currentUser(); @@ -26,7 +27,7 @@ export async function GET() { const reply = await listHandler(libraryStore(client), user.id); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("GET /api/library", error); + return failed(request, "GET /api/library", error); } } @@ -44,6 +45,6 @@ export async function POST(request: Request) { const reply = await saveHandler(libraryStore(client), user.id, await request.json().catch(() => null)); return NextResponse.json(reply.body, { status: reply.status }); } catch (error) { - return failed("POST /api/library", error); + return failed(request, "POST /api/library", error); } } diff --git a/suite/app/api/share/[token]/route.ts b/suite/app/api/share/[token]/route.ts index 6b407ef2..308d06f0 100644 --- a/suite/app/api/share/[token]/route.ts +++ b/suite/app/api/share/[token]/route.ts @@ -4,6 +4,7 @@ import { serverClient } from "@/lib/accounts/serverClient"; import { check } from "@/lib/server/check"; import { tokenSchema } from "@/lib/share/schemas"; import { shareStore } from "@/lib/share/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -13,7 +14,7 @@ export const dynamic = "force-dynamic"; * whose wedding it is. Anyone with the token may ask; only the key after the * `#` opens it. */ -export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) { return NextResponse.json({ error: "Guest links are not set up on this deployment." }, { status: 501 }); @@ -27,7 +28,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ tok if (!sealed) return NextResponse.json({ error: "This link is not live." }, { status: 404 }); return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } }); } catch (error) { - console.error("[share] GET /api/share/[token]", error); + requestLog(request).error({ err: error }, "[share] GET /api/share/[token]"); return NextResponse.json({ error: "Something went wrong." }, { status: 500 }); } } diff --git a/suite/app/api/share/route.ts b/suite/app/api/share/route.ts index 2a2dffe4..fdb14c3f 100644 --- a/suite/app/api/share/route.ts +++ b/suite/app/api/share/route.ts @@ -6,6 +6,7 @@ import { check } from "@/lib/server/check"; import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit"; import { publishSchema, takeDownSchema } from "@/lib/share/schemas"; import { shareStore } from "@/lib/share/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -15,8 +16,8 @@ const unconfigured = () => const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 }); /** An uncaught throw becomes an HTML 500 the page cannot read. */ -const failed = (where: string, error: unknown) => { - console.error(`[share] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[share] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -33,7 +34,7 @@ export async function GET(request: Request) { if (!weddingId) return notYours(); return NextResponse.json({ link: await shareStore(client).linkOf(weddingId) }); } catch (error) { - return failed("GET /api/share", error); + return failed(request, "GET /api/share", error); } } @@ -60,7 +61,7 @@ export async function PUT(request: Request) { if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 }); return NextResponse.json(published); } catch (error) { - return failed("PUT /api/share", error); + return failed(request, "PUT /api/share", error); } } @@ -80,6 +81,6 @@ export async function DELETE(request: Request) { await shareStore(client).takeDown(input.value.weddingId); return NextResponse.json({}); } catch (error) { - return failed("DELETE /api/share", error); + return failed(request, "DELETE /api/share", error); } } diff --git a/suite/app/api/suppliers/[token]/route.ts b/suite/app/api/suppliers/[token]/route.ts index 16e5454f..fc86d8a4 100644 --- a/suite/app/api/suppliers/[token]/route.ts +++ b/suite/app/api/suppliers/[token]/route.ts @@ -5,19 +5,20 @@ import { check } from "@/lib/server/check"; import { allow, CONFIRM_LIMIT } from "@/lib/server/rateLimit"; import { tokenSchema } from "@/lib/suppliers/schemas"; import { supplierStore } from "@/lib/suppliers/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Supplier links are not set up on this deployment." }, { status: 501 }); const gone = () => NextResponse.json({ error: "This link is not live." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[suppliers] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[suppliers] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; /** What a supplier's link fetches: their sheet, sealed, and when they confirmed it. */ -export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) return unconfigured(); const token = check(tokenSchema, (await params).token); @@ -28,12 +29,12 @@ export async function GET(_request: Request, { params }: { params: Promise<{ tok if (!sealed) return gone(); return NextResponse.json(sealed, { headers: { "cache-control": "no-store" } }); } catch (error) { - return failed("GET /api/suppliers/[token]", error); + return failed(request, "GET /api/suppliers/[token]", error); } } /** The supplier says they have it: when, recorded against their link and nothing else. */ -export async function POST(_request: Request, { params }: { params: Promise<{ token: string }> }) { +export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { try { if (!accountsConfigured()) return unconfigured(); const token = check(tokenSchema, (await params).token); @@ -47,6 +48,6 @@ export async function POST(_request: Request, { params }: { params: Promise<{ to if (!confirmedAt) return gone(); return NextResponse.json({ confirmedAt }); } catch (error) { - return failed("POST /api/suppliers/[token]", error); + return failed(request, "POST /api/suppliers/[token]", error); } } diff --git a/suite/app/api/suppliers/route.ts b/suite/app/api/suppliers/route.ts index 39bb9211..741921f2 100644 --- a/suite/app/api/suppliers/route.ts +++ b/suite/app/api/suppliers/route.ts @@ -6,6 +6,7 @@ import { check } from "@/lib/server/check"; import { allow, SHARE_LIMIT } from "@/lib/server/rateLimit"; import { publishSchema, takeDownSchema } from "@/lib/suppliers/schemas"; import { supplierStore } from "@/lib/suppliers/supabaseStore"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -13,8 +14,8 @@ export const dynamic = "force-dynamic"; const unconfigured = () => NextResponse.json({ error: "Accounts are not set up on this deployment." }, { status: 501 }); const unauthenticated = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); const notYours = () => NextResponse.json({ error: "That is not a wedding you are on." }, { status: 404 }); -const failed = (where: string, error: unknown) => { - console.error(`[suppliers] ${where}`, error); +const failed = (request: Request, where: string, error: unknown) => { + requestLog(request).error({ err: error }, `[suppliers] ${where}`); return NextResponse.json({ error: "Something went wrong. Please try again." }, { status: 500 }); }; @@ -30,7 +31,7 @@ export async function GET(request: Request) { if (!weddingId) return notYours(); return NextResponse.json({ links: await supplierStore(client).linksOf(weddingId) }); } catch (error) { - return failed("GET /api/suppliers", error); + return failed(request, "GET /api/suppliers", error); } } @@ -53,7 +54,7 @@ export async function PUT(request: Request) { if (!published) return NextResponse.json({ error: "The link was published from elsewhere." }, { status: 409 }); return NextResponse.json(published); } catch (error) { - return failed("PUT /api/suppliers", error); + return failed(request, "PUT /api/suppliers", error); } } @@ -71,6 +72,6 @@ export async function DELETE(request: Request) { await supplierStore(client).takeDown(input.value.weddingId, input.value.teamId); return NextResponse.json({}); } catch (error) { - return failed("DELETE /api/suppliers", error); + return failed(request, "DELETE /api/suppliers", error); } } diff --git a/suite/app/auth/callback/route.ts b/suite/app/auth/callback/route.ts index 154e143d..72f90a36 100644 --- a/suite/app/auth/callback/route.ts +++ b/suite/app/auth/callback/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { serverClient } from "@/lib/accounts/serverClient"; import { accountsStore } from "@/lib/accounts/supabaseStore"; import { firstSignInHandler } from "@/lib/accounts/handlers"; +import { requestLog } from "@/lib/server/log"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -82,7 +83,7 @@ export async function GET(request: Request) { } } catch (error) { - console.error("[accounts] GET /auth/callback", error); + requestLog(request).error({ err: error }, "[accounts] GET /auth/callback"); failed = true; } @@ -93,7 +94,7 @@ export async function GET(request: Request) { try { await firstSignInHandler(accountsStore(client), userId); } catch (error) { - console.error("[accounts] GET /auth/callback: starting a wedding", error); + requestLog(request).error({ err: error }, "[accounts] GET /auth/callback: starting a wedding"); } } diff --git a/suite/instrumentation-client.ts b/suite/instrumentation-client.ts index 61a7068b..494df6da 100644 --- a/suite/instrumentation-client.ts +++ b/suite/instrumentation-client.ts @@ -1,5 +1,11 @@ +import { build } from "@/lib/build"; +import { sentryBuild } from "@/lib/sentry/build"; import { scrubEvent } from "@/lib/sentry/scrub"; +// Which build this tab is running, for anyone with the console open — a +// support conversation's first question, answerable without a deploy log. +window.appVersion = build; + /** * Error reporting in the browser, off unless a DSN is configured. * @@ -21,6 +27,7 @@ if (dsn) { void import("@sentry/nextjs").then((Sentry) => Sentry.init({ dsn, + ...sentryBuild, sendDefaultPii: false, // A report is sent only when something breaks. tracesSampleRate: 0, diff --git a/suite/instrumentation.ts b/suite/instrumentation.ts index 425576c7..56cdd6f0 100644 --- a/suite/instrumentation.ts +++ b/suite/instrumentation.ts @@ -1,4 +1,7 @@ import * as Sentry from "@sentry/nextjs"; +import type { Instrumentation } from "next"; +import { log, REQUEST_ID_HEADER } from "@/lib/server/log"; +import { sentryBuild } from "@/lib/sentry/build"; import { scrubEvent } from "@/lib/sentry/scrub"; /** @@ -27,10 +30,26 @@ export async function register() { Sentry.init({ dsn, + ...sentryBuild, sendDefaultPii: false, tracesSampleRate: 0, beforeSend: (event) => scrubEvent(event), }); } -export const onRequestError = Sentry.captureRequestError; +/** + * A throw nothing caught. Logged with its request id and reported with it as a + * tag, so the log line, the Sentry event and the `X-Request-ID` a person saw + * are one search apart. + * + * The route template (`/api/share/[token]`) rather than the path, which can + * carry a token. + */ +export const onRequestError: Instrumentation.onRequestError = (error, request, context) => { + const requestId = request.headers[REQUEST_ID_HEADER]; + log.error({ err: error, requestId, route: context.routePath }, "unhandled request error"); + Sentry.withScope((scope) => { + scope.setTag("request_id", String(requestId)); + Sentry.captureRequestError(error, request, context); + }); +}; diff --git a/suite/lib/build.ts b/suite/lib/build.ts new file mode 100644 index 00000000..112acfef --- /dev/null +++ b/suite/lib/build.ts @@ -0,0 +1,25 @@ +/** + * Which build this is: what a bug report needs first, and what nothing in the + * running app could otherwise tell you. + * + * Computed once by `buildInfo()` in `next.config.ts` and inlined by Next's + * `env` option, so the server, the proxy and the browser all read the same + * strings. Safe to import anywhere, the browser included. + */ +export const build = { + /** `version` from this workspace's package.json. */ + version: process.env.APP_VERSION as string, + /** Short commit hash. */ + commit: process.env.GIT_COMMIT_SHA as string, + /** ISO time the build started. */ + builtAt: process.env.BUILD_TIMESTAMP as string, + /** `production`, `preview` or `development` on Vercel; NODE_ENV elsewhere. */ + environment: process.env.APP_ENV as string, +}; + +declare global { + interface Window { + /** Set by `instrumentation-client.ts` on every page. */ + appVersion: typeof build; + } +} diff --git a/suite/lib/headers.test.ts b/suite/lib/headers.test.ts index 6542b85a..053699dc 100644 --- a/suite/lib/headers.test.ts +++ b/suite/lib/headers.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { contentSecurityPolicy, securityHeaders, sentryOrigin } from "../next.config"; +import { buildHeaders, buildInfo, contentSecurityPolicy, securityHeaders, sentryOrigin } from "../next.config"; const value = (key: string) => securityHeaders.find((header) => header.key === key)?.value; @@ -73,3 +73,15 @@ test("HSTS is stated by the app rather than left to the host", () => { expect(value("Strict-Transport-Security")).toContain("max-age=63072000"); expect(value("Strict-Transport-Security")).toContain("includeSubDomains"); }); + +test("every response names the build that served it", () => { + const version = buildHeaders.find((header) => header.key === "X-App-Version")?.value; + const commit = buildHeaders.find((header) => header.key === "X-Commit-SHA")?.value; + expect(version).toMatch(/^\d+\.\d+\.\d+/); + expect(commit).toMatch(/^[0-9a-f]{7}$/); +}); + +test("the build time is fixed once, so every process of one build agrees", () => { + expect(buildInfo().BUILD_TIMESTAMP).toBe(buildInfo().BUILD_TIMESTAMP); + expect(new Date(buildInfo().BUILD_TIMESTAMP).toISOString()).toBe(buildInfo().BUILD_TIMESTAMP); +}); diff --git a/suite/lib/sentry/build.ts b/suite/lib/sentry/build.ts new file mode 100644 index 00000000..36eeafc2 --- /dev/null +++ b/suite/lib/sentry/build.ts @@ -0,0 +1,14 @@ +import { build } from "@/lib/build"; + +/** + * Which build a report came from, spread into both `Sentry.init` calls so the + * browser and the server cannot describe the same deploy differently. + * + * The release is what Sentry groups regressions by ("first seen in"); the tags + * make the version and commit filterable on every event. + */ +export const sentryBuild = { + release: `trousseau-suite@${build.version}+${build.commit}`, + environment: build.environment, + initialScope: { tags: { app_version: build.version, commit: build.commit } }, +}; diff --git a/suite/lib/server/log.test.ts b/suite/lib/server/log.test.ts new file mode 100644 index 00000000..b72ad4ad --- /dev/null +++ b/suite/lib/server/log.test.ts @@ -0,0 +1,8 @@ +// @vitest-environment node +import { expect, test } from "vitest"; +import { requestLog } from "./log"; + +test("every line a request logs carries that request's id", () => { + const request = new Request("http://localhost/api/x", { headers: { "x-request-id": "req-42" } }); + expect(requestLog(request).bindings()).toEqual({ requestId: "req-42" }); +}); diff --git a/suite/lib/server/log.ts b/suite/lib/server/log.ts new file mode 100644 index 00000000..f297f833 --- /dev/null +++ b/suite/lib/server/log.ts @@ -0,0 +1,33 @@ +import pino from "pino"; +import { build } from "@/lib/build"; + +/** + * The server's log: one JSON object per line on stdout, which is what Vercel's + * log view and every log drain parse into searchable fields. + * + * Every line names the build that wrote it, so a log line and a Sentry report + * can be matched to a commit without guessing which deploy was live. + * + * Log what happened, never what it happened to: a guest list is the one thing + * this app exists to keep on the device, and an error object is the only thing + * logged here that was not written by hand. + * + * `LOG_LEVEL` is read here rather than through `env()`, so that the log works + * when the environment does not — which is when it is needed most. Pino throws + * on a level it does not know, so a typo still fails at boot. + */ +export const log = pino({ + level: process.env.LOG_LEVEL || (process.env.NODE_ENV === "production" ? "info" : "debug"), + // Labels rather than pino's numbers, which no log viewer shows as levels. + formatters: { level: (label) => ({ level: label }) }, + timestamp: pino.stdTimeFunctions.isoTime, + base: { version: build.version, commit: build.commit, env: build.environment }, +}); + +/** Set on every request by `proxy.ts`, and echoed on every response. */ +export const REQUEST_ID_HEADER = "x-request-id"; + +/** The log, for one request: every line it writes carries that request's id. */ +export function requestLog(request: Request): pino.Logger { + return log.child({ requestId: request.headers.get(REQUEST_ID_HEADER) }); +} diff --git a/suite/next.config.ts b/suite/next.config.ts index 090d7f8d..cd5377c1 100644 --- a/suite/next.config.ts +++ b/suite/next.config.ts @@ -1,14 +1,42 @@ +import { execSync } from "node:child_process"; import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { env } from "./lib/env"; +import { version } from "./package.json"; // Checked here so a misconfigured deploy fails the build rather than answering // 501 at runtime and looking like a deliberate local-only one. Called for the -// throw, not the value. +// throw, not the value. `next start` evaluates this file too, so a self-hosted +// server started in a different environment from its build refuses to boot. env(); const development = process.env.NODE_ENV === "development"; +/** + * Which build this is, inlined into every bundle as `process.env.*` and read + * through `lib/build.ts`. + */ +export function buildInfo() { + return { + APP_VERSION: version, + // Vercel hands the build its commit as `VERCEL_GIT_COMMIT_SHA`. Everywhere + // else — CI, a laptop, a self-hosted box, `vercel build` — git is asked, + // and a build that cannot say which commit it is fails here. + GIT_COMMIT_SHA: ( + process.env.VERCEL_GIT_COMMIT_SHA ?? execSync("git rev-parse HEAD", { encoding: "utf8" }) + ) + .trim() + .slice(0, 7), + // Next evaluates this file in more than one process during a build. The + // first evaluation fixes the time in the environment and the later ones + // inherit it, so the server and the browser cannot disagree about it. + BUILD_TIMESTAMP: (process.env.BUILD_TIMESTAMP ??= new Date().toISOString()), + APP_ENV: process.env.VERCEL_ENV ?? process.env.NODE_ENV, + }; +} + +const info = buildInfo(); + /** * Sentry's ingest origin, when there is one. * @@ -127,13 +155,24 @@ const securityHeaders = [ }, ]; +/** Which build answered, on every response — the first question about any bug. */ +const buildHeaders = [ + { key: "X-App-Version", value: info.APP_VERSION }, + { key: "X-Commit-SHA", value: info.GIT_COMMIT_SHA }, +]; + const nextConfig: NextConfig = { + env: info, + // Browser source maps, served alongside the bundles. This is AGPL software + // whose source is already public, so they reveal nothing new, and Sentry + // fetches them from the deployment to turn minified frames back into lines. + productionBrowserSourceMaps: true, async headers() { - return [{ source: "/:path*", headers: securityHeaders }]; + return [{ source: "/:path*", headers: [...securityHeaders, ...buildHeaders] }]; }, }; const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" }); export default withBundleAnalyzer(nextConfig); -export { contentSecurityPolicy, securityHeaders }; +export { buildHeaders, contentSecurityPolicy, securityHeaders }; diff --git a/suite/package.json b/suite/package.json index edc315fa..66728fa8 100644 --- a/suite/package.json +++ b/suite/package.json @@ -30,6 +30,7 @@ "lucide-react": "^1.47.0", "next": "16.3.5", "pdf-lib": "^1.17.1", + "pino": "^10.3.1", "react": "19.3.0", "react-dom": "19.3.0", "svg2pdf.js": "^2.8.1", diff --git a/suite/proxy.test.ts b/suite/proxy.test.ts new file mode 100644 index 00000000..ad4e17ac --- /dev/null +++ b/suite/proxy.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { NextRequest } from "next/server"; +import { expect, test } from "vitest"; +import { proxy } from "./proxy"; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const at = (headers: Record = {}, path = "/api/health") => + new NextRequest(`http://localhost${path}`, { headers }); + +/** What a route handler behind the proxy will read as `x-request-id`. */ +const forwarded = (response: Response) => response.headers.get("x-middleware-request-x-request-id"); + +test("a request without an id is given one, echoed and forwarded", async () => { + const response = await proxy(at()); + const id = response.headers.get("x-request-id"); + expect(id).toMatch(UUID); + expect(forwarded(response)).toBe(id); +}); + +test("a caller's id is kept, so a trace crosses from their system into ours", async () => { + const response = await proxy(at({ "x-request-id": "edge-abc.123:9" })); + expect(response.headers.get("x-request-id")).toBe("edge-abc.123:9"); + expect(forwarded(response)).toBe("edge-abc.123:9"); +}); + +test("X-Correlation-ID is accepted when X-Request-ID is absent", async () => { + const response = await proxy(at({ "x-correlation-id": "corr-1" })); + expect(response.headers.get("x-request-id")).toBe("corr-1"); +}); + +test("an id that could write arbitrary text into the log is replaced", async () => { + const response = await proxy(at({ "x-request-id": 'x" injected=1' })); + expect(response.headers.get("x-request-id")).toMatch(UUID); +}); + +test("an over-long id is replaced", async () => { + const response = await proxy(at({ "x-request-id": "a".repeat(129) })); + expect(response.headers.get("x-request-id")).toMatch(UUID); +}); + +test("a sign-in redirect carries the id too", async () => { + const response = await proxy(at({ "x-request-id": "signin-1" }, "/?code=abc")); + expect(response.status).toBe(307); + expect(response.headers.get("x-request-id")).toBe("signin-1"); + // And the code is still stripped, as before. + expect(response.headers.get("location")).not.toContain("code="); +}); diff --git a/suite/proxy.ts b/suite/proxy.ts index c16c3953..286bda37 100644 --- a/suite/proxy.ts +++ b/suite/proxy.ts @@ -1,5 +1,6 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; +import { log, REQUEST_ID_HEADER } from "@/lib/server/log"; /** * Finish a sign-in wherever its link happens to land. @@ -19,14 +20,47 @@ import { NextResponse, type NextRequest } from "next/server"; const CLEAN = ["code", "token_hash", "type"]; +/** + * What an incoming request id may look like. A caller's id is kept so a trace + * can cross from their system into this one; anything else is replaced, so a + * header cannot write arbitrary text into the log. + */ +const REQUEST_ID = /^[\w.:-]{1,128}$/; + +function requestId(request: NextRequest): string { + const presented = request.headers.get(REQUEST_ID_HEADER) ?? request.headers.get("x-correlation-id"); + return presented && REQUEST_ID.test(presented) ? presented : crypto.randomUUID(); +} + +/** + * Every request gets an id, which the route handlers read back to tag their + * log lines (`requestLog`) and which goes back to the caller on the response, + * so a report from a browser can be found in the log. + */ export async function proxy(request: NextRequest) { - const code = request.nextUrl.searchParams.get("code"); - const tokenHash = request.nextUrl.searchParams.get("token_hash"); + const id = requestId(request); + const response = signsIn(request) ? await finishSignIn(request, id) : forward(request, id); + response.headers.set(REQUEST_ID_HEADER, id); + return response; +} + +function forward(request: NextRequest, id: string): NextResponse { + const headers = new Headers(request.headers); + headers.set(REQUEST_ID_HEADER, id); + return NextResponse.next({ request: { headers } }); +} +function signsIn(request: NextRequest): boolean { + const { searchParams, pathname } = request.nextUrl; // The overwhelmingly common case: no auth material, nothing to do. - if (!code && !tokenHash) return NextResponse.next(); + if (!searchParams.get("code") && !searchParams.get("token_hash")) return false; // The callback route does its own exchange, and knows about `next`. - if (request.nextUrl.pathname === "/auth/callback") return NextResponse.next(); + return pathname !== "/auth/callback"; +} + +async function finishSignIn(request: NextRequest, id: string): Promise { + const code = request.nextUrl.searchParams.get("code"); + const tokenHash = request.nextUrl.searchParams.get("token_hash"); const destination = new URL(request.nextUrl); for (const key of CLEAN) destination.searchParams.delete(key); @@ -59,7 +93,7 @@ export async function proxy(request: NextRequest) { response.headers.set("location", destination.toString()); } } catch (error) { - console.error("[accounts] proxy sign-in", error); + log.child({ requestId: id }).error({ err: error }, "[accounts] proxy sign-in"); destination.searchParams.set("signin", "failed"); response.headers.set("location", destination.toString()); }