What changed, and why

Everwhen runs in production with no error or performance monitoring. When a turn fails, a checkout 500s, or a server route throws inside the AI seam, the only trace is the one-line [ai] / [mod] / [stripe] telemetry — nothing durable, nothing searchable. This PR wires Sentry into both halves of the app so those errors, and a sample of performance traces, get captured.

The change is deliberately small and self-contained. It adds @sentry/nuxt, two SDK init files (client and server), a shared constants file, and four small edits to existing config (nuxt.config.ts, Dockerfile, playwright.config.ts, .env.example). It changes no game logic — no store, no route, no mechanics. Read the eight files below and you can vouch for the whole thing.

Three design calls carry the weight, each with its own section: the DSN is a public identifier committed as a default; reporting is gated to production builds so local dev stays silent; and the server SDK loads via node --import for full instrumentation.

One source of truth: the DSN and sample rate

Both init files and nuxt.config need the same two values, so they're named once here. The whole file is 21 lines:

sentry.shared.ts · 22 lines
sentry.shared.ts22 lines · TypeScript
1/**
2 * Shared Sentry settings, named once here and imported by the SDK init files
3 * (sentry.client.config.ts, sentry.server.config.ts) and nuxt.config.ts so the DSN
4 * and sample rate never drift between the client and server halves.
5 *
6 * The DSN is a PUBLIC client identifier: it only permits SENDING events to the
7 * project (never reading them) and already ships inside the browser bundle, so it is
8 * safe to commit — the same posture as the Supabase publishable key. Override it per
9 * environment with EVERWHEN_SENTRY_DSN; set that to an empty string to turn Sentry off.
10 * (Sentry only reports from production builds anyway — see the init files — so local
11 * `npm run dev` never phones home regardless of the DSN.)
12 */
13export const DEFAULT_SENTRY_DSN =
14 "https://a768a64ce6ba0bef9a67ec398ee072d0@o4511722087776256.ingest.us.sentry.io/4511722098065408";
15 
16/**
17 * Fraction of transactions sampled for performance tracing (0–1). Errors are always
18 * captured regardless; this governs only the heavier transaction/perf data. Set to 1.0
19 * (every transaction) for full performance visibility — watch Sentry transaction volume
20 * on a public game and lower this if quota or cost becomes a concern.
21 */
22export const SENTRY_TRACES_SAMPLE_RATE = 1.0;

Client init: production-only, silent in dev

The client config runs before the Nuxt app mounts. It reads the DSN from public runtime config (so a deploy can override it at runtime without a rebuild) and inits the browser SDK — errors plus a sample of performance traces.

sentry.client.config.ts · 20 lines
sentry.client.config.ts20 lines · TypeScript
1import * as Sentry from "@sentry/nuxt";
2import { SENTRY_TRACES_SAMPLE_RATE } from "./sentry.shared";
3 
4// Runs before the Nuxt app mounts. useRuntimeConfig() is available here and carries the
5// public Sentry settings resolved in nuxt.config, so a deploy can override the DSN or the
6// environment tag at runtime (NUXT_PUBLIC_SENTRY_*) without a rebuild.
7const { public: pub } = useRuntimeConfig();
8const dsn = pub.sentry.dsn;
9 
10Sentry.init({
11 // Report only from a production build that has a DSN wired. Local `npm run dev` (and the
12 // mocked e2e preview, which blanks the DSN) stay inert, so development noise never
13 // reaches the project.
14 enabled: Boolean(dsn) && process.env.NODE_ENV === "production",
15 dsn,
16 environment: pub.sentry.environment,
17 // Browser performance tracing (page loads, navigations, web vitals). Errors are captured
18 // regardless of this sample rate.
19 tracesSampleRate: SENTRY_TRACES_SAMPLE_RATE,
20});

Server init: read env before Nitro boots

The server config is the twin of the client one, with one difference that the comment calls out: it loads via node --import before the Nitro app starts, so useRuntimeConfig() doesn't exist yet — it can only read process.env. The DSN falls back to the committed default; the same production gate applies.

sentry.server.config.ts · 18 lines
sentry.server.config.ts18 lines · TypeScript
1import * as Sentry from "@sentry/nuxt";
2import { DEFAULT_SENTRY_DSN, SENTRY_TRACES_SAMPLE_RATE } from "./sentry.shared";
3 
4// Loaded via `node --import` (see the Dockerfile) BEFORE the Nitro app, so it can only
5// read process.env — useRuntimeConfig() isn't available this early. EVERWHEN_SENTRY_DSN
6// overrides the committed default; set it to "" to disable Sentry.
7const dsn = process.env.EVERWHEN_SENTRY_DSN ?? DEFAULT_SENTRY_DSN;
8 
9Sentry.init({
10 // Report only in production (the built image runs with NODE_ENV=production). Local and
11 // test runs stay inert.
12 enabled: Boolean(dsn) && process.env.NODE_ENV === "production",
13 dsn,
14 environment: process.env.EVERWHEN_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
15 // Server performance tracing (Nitro routes + outbound AI/Supabase/Stripe calls). Errors
16 // are captured regardless of this sample rate.
17 tracesSampleRate: SENTRY_TRACES_SAMPLE_RATE,
18});

Wiring it into Nuxt

Three edits to nuxt.config.ts. First, register the module so it picks up the two config files and instruments the build:

The Sentry module joins the existing four.

nuxt.config.ts · 137 lines
nuxt.config.ts137 lines · TypeScript
⋯ 22 lines hidden (lines 1–22)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5import { DEFAULT_SENTRY_DSN } from "./sentry.shared";
6 
7export default defineNuxtConfig({
8 // Ensure compatibility with modern Nuxt features
9 compatibilityDate: "2025-05-15",
10 
11 // Enable devtools for development
12 devtools: { enabled: true },
13 
14 // Declare the document language app-wide (issue #118). Set here rather than in app.vue so
15 // it also reaches routes that bypass the app component — Nuxt's built-in error/404 page —
16 // keeping <html lang="en"> consistent everywhere (accessibility; pairs with og:locale).
17 app: {
18 head: {
19 htmlAttrs: { lang: "en" },
20 },
21 },
22 
23 // Register required modules
24 modules: [
25 "@nuxt/ui",
26 "@pinia/nuxt",
27 "@nuxtjs/supabase",
28 "@nuxt/eslint",
29 "@sentry/nuxt/module",
30 ],
⋯ 107 lines hidden (lines 31–137)
31 
32 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
33 // user so the balance keys on a real user id from the first second; an OAuth
34 // sign-in lands on a fresh or returning account (see utils/sign-in.ts).
35 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
36 // free-trial hook); buying is what requires an account. It also means the module
37 // registers no /confirm callback route, so the sign-in return is finalized in
38 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
39 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
40 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
41 // back to the Site URL and the link never reaches the handler. The client key is
42 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
43 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
44 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
45 supabase: {
46 url: process.env.SUPABASE_URL || "https://placeholder.supabase.co",
47 key: process.env.SUPABASE_PUBLISHABLE_KEY || "sb_publishable_placeholder",
48 redirect: false,
49 // We don't use generated DB types (the balance store talks to Supabase with the
50 // service client directly); disable the lookup so the module doesn't warn.
51 types: false,
52 },
53 
54 // Global CSS imports
55 css: ["~/assets/css/main.css"],
56 
57 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
58 // class on <html> is exactly `dark` / `light` — what the .dark token block and
59 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
60 // the module's inline no-flash script + localStorage persistence replace the old
61 // manual classList toggle (which didn't persist and could FOUC).
62 colorMode: {
63 classSuffix: "",
64 preference: "system",
65 fallback: "light",
66 },
67 
68 // Runtime configuration
69 runtimeConfig: {
70 // Private keys (only available on server-side)
71 openaiApiKey: process.env.OPENAI_API_KEY,
72 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
73 // Supabase — the run store (server-side only). Absent → in-memory dev store.
74 supabaseUrl: process.env.SUPABASE_URL,
75 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
76 // Stripe — run packs (server-side only). Absent → checkout returns 503.
77 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
78 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
79 // Canonical site origin for Stripe redirect URLs (never the client Host header).
80 siteUrl: process.env.EVERWHEN_BASE_URL,
81 // Referral credits (issue #96) — server-side only; sane defaults, env-overridable.
82 // reward: runs credited to a sharer per qualified email signup. cap: soft per-sharer
83 // lifetime limit on credited referrals (<=0 disables). digest: the batched-notice
84 // throttle (a kill-switch + threshold + window). See server/utils/referral.ts.
85 referralReward: process.env.EVERWHEN_REFERRAL_REWARD,
86 referralCap: process.env.EVERWHEN_REFERRAL_CAP,
87 referralDigestEnabled: process.env.EVERWHEN_REFERRAL_DIGEST,
88 referralDigestThreshold: process.env.EVERWHEN_REFERRAL_DIGEST_THRESHOLD,
89 referralDigestWindowHours:
90 process.env.EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS,
91 // Operator allowlist for the Steward's Ledger ops dashboard (/steward) and its
92 // /api/steward/* endpoints — comma-separated emails. Server-side only; empty →
93 // no operators (deny-all). See server/utils/operator-auth.ts.
94 operatorEmails: process.env.EVERWHEN_OPERATOR_EMAILS,
95 // Public keys (exposed to client-side)
96 public: {
97 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
98 // this flag additionally opts a DEPLOYED staging preview in. Never set in
99 // production. The server gates the fixture lane on the same env var, so the
100 // client panel and the server seam agree on one source of truth.
101 devMode: process.env.EVERWHEN_DEV_MODE === "true",
102 // The brand's X/Twitter @handle for twitter:site / twitter:creator attribution
103 // (issue #118). Unset → those two tags are simply omitted (a guessed handle would
104 // mis-attribute the card to a stranger). Set EVERWHEN_TWITTER_HANDLE (e.g.
105 // "@everwhen") in production to activate them.
106 twitterHandle: process.env.EVERWHEN_TWITTER_HANDLE || "",
107 // Sentry monitoring (client init reads this in sentry.client.config.ts). The DSN
108 // is a public client identifier — safe to ship (see sentry.shared.ts). Override
109 // with EVERWHEN_SENTRY_DSN per environment, or "" to disable; `environment` tags
110 // events (production vs a preview) and defaults to NODE_ENV. Sentry reports only
111 // from production builds, so local dev stays silent regardless.
112 sentry: {
113 dsn: process.env.EVERWHEN_SENTRY_DSN ?? DEFAULT_SENTRY_DSN,
114 environment:
115 process.env.EVERWHEN_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
116 },
117 },
118 },
119 
120 // @sentry/nuxt build-time options. Source-map upload is OFF: it needs a Sentry auth
121 // token (a secret) plus the org/project slugs wired into CI, none of which exist yet,
122 // so production stack traces stay minified for now — a clean follow-up once a
123 // SENTRY_AUTH_TOKEN is added to the image build. Server tracing loads via the
124 // Dockerfile's `node --import …/sentry.server.config.mjs`, so no autoInjectServerSentry
125 // (that pairing would double-init Sentry).
126 sentry: {
127 sourceMapsUploadOptions: { enabled: false },
128 },
129 
130 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
131 // out of the app's type program so `nuxt typecheck` never compiles the loops.
132 typescript: {
133 tsConfig: {
134 exclude: ["../agents"],
135 },
136 },
137});

Second, expose the DSN and an environment tag to the client through public runtime config — the value the client init reads back:

Public runtime config: env-overridable, committed default.

nuxt.config.ts · 137 lines
nuxt.config.ts137 lines · TypeScript
⋯ 106 lines hidden (lines 1–106)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5import { DEFAULT_SENTRY_DSN } from "./sentry.shared";
6 
7export default defineNuxtConfig({
8 // Ensure compatibility with modern Nuxt features
9 compatibilityDate: "2025-05-15",
10 
11 // Enable devtools for development
12 devtools: { enabled: true },
13 
14 // Declare the document language app-wide (issue #118). Set here rather than in app.vue so
15 // it also reaches routes that bypass the app component — Nuxt's built-in error/404 page —
16 // keeping <html lang="en"> consistent everywhere (accessibility; pairs with og:locale).
17 app: {
18 head: {
19 htmlAttrs: { lang: "en" },
20 },
21 },
22 
23 // Register required modules
24 modules: [
25 "@nuxt/ui",
26 "@pinia/nuxt",
27 "@nuxtjs/supabase",
28 "@nuxt/eslint",
29 "@sentry/nuxt/module",
30 ],
31 
32 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
33 // user so the balance keys on a real user id from the first second; an OAuth
34 // sign-in lands on a fresh or returning account (see utils/sign-in.ts).
35 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
36 // free-trial hook); buying is what requires an account. It also means the module
37 // registers no /confirm callback route, so the sign-in return is finalized in
38 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
39 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
40 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
41 // back to the Site URL and the link never reaches the handler. The client key is
42 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
43 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
44 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
45 supabase: {
46 url: process.env.SUPABASE_URL || "https://placeholder.supabase.co",
47 key: process.env.SUPABASE_PUBLISHABLE_KEY || "sb_publishable_placeholder",
48 redirect: false,
49 // We don't use generated DB types (the balance store talks to Supabase with the
50 // service client directly); disable the lookup so the module doesn't warn.
51 types: false,
52 },
53 
54 // Global CSS imports
55 css: ["~/assets/css/main.css"],
56 
57 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
58 // class on <html> is exactly `dark` / `light` — what the .dark token block and
59 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
60 // the module's inline no-flash script + localStorage persistence replace the old
61 // manual classList toggle (which didn't persist and could FOUC).
62 colorMode: {
63 classSuffix: "",
64 preference: "system",
65 fallback: "light",
66 },
67 
68 // Runtime configuration
69 runtimeConfig: {
70 // Private keys (only available on server-side)
71 openaiApiKey: process.env.OPENAI_API_KEY,
72 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
73 // Supabase — the run store (server-side only). Absent → in-memory dev store.
74 supabaseUrl: process.env.SUPABASE_URL,
75 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
76 // Stripe — run packs (server-side only). Absent → checkout returns 503.
77 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
78 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
79 // Canonical site origin for Stripe redirect URLs (never the client Host header).
80 siteUrl: process.env.EVERWHEN_BASE_URL,
81 // Referral credits (issue #96) — server-side only; sane defaults, env-overridable.
82 // reward: runs credited to a sharer per qualified email signup. cap: soft per-sharer
83 // lifetime limit on credited referrals (<=0 disables). digest: the batched-notice
84 // throttle (a kill-switch + threshold + window). See server/utils/referral.ts.
85 referralReward: process.env.EVERWHEN_REFERRAL_REWARD,
86 referralCap: process.env.EVERWHEN_REFERRAL_CAP,
87 referralDigestEnabled: process.env.EVERWHEN_REFERRAL_DIGEST,
88 referralDigestThreshold: process.env.EVERWHEN_REFERRAL_DIGEST_THRESHOLD,
89 referralDigestWindowHours:
90 process.env.EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS,
91 // Operator allowlist for the Steward's Ledger ops dashboard (/steward) and its
92 // /api/steward/* endpoints — comma-separated emails. Server-side only; empty →
93 // no operators (deny-all). See server/utils/operator-auth.ts.
94 operatorEmails: process.env.EVERWHEN_OPERATOR_EMAILS,
95 // Public keys (exposed to client-side)
96 public: {
97 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
98 // this flag additionally opts a DEPLOYED staging preview in. Never set in
99 // production. The server gates the fixture lane on the same env var, so the
100 // client panel and the server seam agree on one source of truth.
101 devMode: process.env.EVERWHEN_DEV_MODE === "true",
102 // The brand's X/Twitter @handle for twitter:site / twitter:creator attribution
103 // (issue #118). Unset → those two tags are simply omitted (a guessed handle would
104 // mis-attribute the card to a stranger). Set EVERWHEN_TWITTER_HANDLE (e.g.
105 // "@everwhen") in production to activate them.
106 twitterHandle: process.env.EVERWHEN_TWITTER_HANDLE || "",
107 // Sentry monitoring (client init reads this in sentry.client.config.ts). The DSN
108 // is a public client identifier — safe to ship (see sentry.shared.ts). Override
109 // with EVERWHEN_SENTRY_DSN per environment, or "" to disable; `environment` tags
110 // events (production vs a preview) and defaults to NODE_ENV. Sentry reports only
111 // from production builds, so local dev stays silent regardless.
112 sentry: {
113 dsn: process.env.EVERWHEN_SENTRY_DSN ?? DEFAULT_SENTRY_DSN,
114 environment:
115 process.env.EVERWHEN_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
116 },
117 },
⋯ 20 lines hidden (lines 118–137)
118 },
119 
120 // @sentry/nuxt build-time options. Source-map upload is OFF: it needs a Sentry auth
121 // token (a secret) plus the org/project slugs wired into CI, none of which exist yet,
122 // so production stack traces stay minified for now — a clean follow-up once a
123 // SENTRY_AUTH_TOKEN is added to the image build. Server tracing loads via the
124 // Dockerfile's `node --import …/sentry.server.config.mjs`, so no autoInjectServerSentry
125 // (that pairing would double-init Sentry).
126 sentry: {
127 sourceMapsUploadOptions: { enabled: false },
128 },
129 
130 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
131 // out of the app's type program so `nuxt typecheck` never compiles the loops.
132 typescript: {
133 tsConfig: {
134 exclude: ["../agents"],
135 },
136 },
137});

Third, the module's own build-time options. Source-map upload is turned off:

Build-time module options.

nuxt.config.ts · 137 lines
nuxt.config.ts137 lines · TypeScript
⋯ 119 lines hidden (lines 1–119)
1/**
2 * Nuxt 3 Configuration
3 * https://nuxt.com/docs/api/configuration/nuxt-config
4 */
5import { DEFAULT_SENTRY_DSN } from "./sentry.shared";
6 
7export default defineNuxtConfig({
8 // Ensure compatibility with modern Nuxt features
9 compatibilityDate: "2025-05-15",
10 
11 // Enable devtools for development
12 devtools: { enabled: true },
13 
14 // Declare the document language app-wide (issue #118). Set here rather than in app.vue so
15 // it also reaches routes that bypass the app component — Nuxt's built-in error/404 page —
16 // keeping <html lang="en"> consistent everywhere (accessibility; pairs with og:locale).
17 app: {
18 head: {
19 htmlAttrs: { lang: "en" },
20 },
21 },
22 
23 // Register required modules
24 modules: [
25 "@nuxt/ui",
26 "@pinia/nuxt",
27 "@nuxtjs/supabase",
28 "@nuxt/eslint",
29 "@sentry/nuxt/module",
30 ],
31 
32 // Supabase Auth (accounts). Anonymous-first: every visitor gets an anonymous
33 // user so the balance keys on a real user id from the first second; an OAuth
34 // sign-in lands on a fresh or returning account (see utils/sign-in.ts).
35 // redirect:false — we DON'T gate play behind sign-in (anonymous play is the
36 // free-trial hook); buying is what requires an account. It also means the module
37 // registers no /confirm callback route, so the sign-in return is finalized in
38 // pages/play.vue (see utils/auth-return.ts). For that return to arrive, the
39 // Supabase project's Auth → URL Configuration → Redirect URLs must allowlist the
40 // deployed origin's /play (e.g. https://<origin>/play); otherwise Supabase falls
41 // back to the Site URL and the link never reaches the handler. The client key is
42 // the PUBLISHABLE key (safe to ship); the service key stays server-only in the
43 // balance store. Placeholders keep `nuxt build` working in CI without secrets;
44 // the real values come from NUXT_PUBLIC_SUPABASE_URL / _KEY at runtime.
45 supabase: {
46 url: process.env.SUPABASE_URL || "https://placeholder.supabase.co",
47 key: process.env.SUPABASE_PUBLISHABLE_KEY || "sb_publishable_placeholder",
48 redirect: false,
49 // We don't use generated DB types (the balance store talks to Supabase with the
50 // service client directly); disable the lookup so the module doesn't warn.
51 types: false,
52 },
53 
54 // Global CSS imports
55 css: ["~/assets/css/main.css"],
56 
57 // Color mode (via @nuxtjs/color-mode, bundled with @nuxt/ui). classSuffix '' so the
58 // class on <html> is exactly `dark` / `light` — what the .dark token block and
59 // Tailwind's dark: variant key off. Defaulting to `system` respects the OS pref, and
60 // the module's inline no-flash script + localStorage persistence replace the old
61 // manual classList toggle (which didn't persist and could FOUC).
62 colorMode: {
63 classSuffix: "",
64 preference: "system",
65 fallback: "light",
66 },
67 
68 // Runtime configuration
69 runtimeConfig: {
70 // Private keys (only available on server-side)
71 openaiApiKey: process.env.OPENAI_API_KEY,
72 anthropicApiKey: process.env.ANTHROPIC_API_KEY,
73 // Supabase — the run store (server-side only). Absent → in-memory dev store.
74 supabaseUrl: process.env.SUPABASE_URL,
75 supabaseSecretKey: process.env.SUPABASE_SECRET_KEY,
76 // Stripe — run packs (server-side only). Absent → checkout returns 503.
77 stripeSecretKey: process.env.STRIPE_SECRET_KEY,
78 stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
79 // Canonical site origin for Stripe redirect URLs (never the client Host header).
80 siteUrl: process.env.EVERWHEN_BASE_URL,
81 // Referral credits (issue #96) — server-side only; sane defaults, env-overridable.
82 // reward: runs credited to a sharer per qualified email signup. cap: soft per-sharer
83 // lifetime limit on credited referrals (<=0 disables). digest: the batched-notice
84 // throttle (a kill-switch + threshold + window). See server/utils/referral.ts.
85 referralReward: process.env.EVERWHEN_REFERRAL_REWARD,
86 referralCap: process.env.EVERWHEN_REFERRAL_CAP,
87 referralDigestEnabled: process.env.EVERWHEN_REFERRAL_DIGEST,
88 referralDigestThreshold: process.env.EVERWHEN_REFERRAL_DIGEST_THRESHOLD,
89 referralDigestWindowHours:
90 process.env.EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS,
91 // Operator allowlist for the Steward's Ledger ops dashboard (/steward) and its
92 // /api/steward/* endpoints — comma-separated emails. Server-side only; empty →
93 // no operators (deny-all). See server/utils/operator-auth.ts.
94 operatorEmails: process.env.EVERWHEN_OPERATOR_EMAILS,
95 // Public keys (exposed to client-side)
96 public: {
97 // Developer mode (issue #105). A dev build always has it (`import.meta.dev`);
98 // this flag additionally opts a DEPLOYED staging preview in. Never set in
99 // production. The server gates the fixture lane on the same env var, so the
100 // client panel and the server seam agree on one source of truth.
101 devMode: process.env.EVERWHEN_DEV_MODE === "true",
102 // The brand's X/Twitter @handle for twitter:site / twitter:creator attribution
103 // (issue #118). Unset → those two tags are simply omitted (a guessed handle would
104 // mis-attribute the card to a stranger). Set EVERWHEN_TWITTER_HANDLE (e.g.
105 // "@everwhen") in production to activate them.
106 twitterHandle: process.env.EVERWHEN_TWITTER_HANDLE || "",
107 // Sentry monitoring (client init reads this in sentry.client.config.ts). The DSN
108 // is a public client identifier — safe to ship (see sentry.shared.ts). Override
109 // with EVERWHEN_SENTRY_DSN per environment, or "" to disable; `environment` tags
110 // events (production vs a preview) and defaults to NODE_ENV. Sentry reports only
111 // from production builds, so local dev stays silent regardless.
112 sentry: {
113 dsn: process.env.EVERWHEN_SENTRY_DSN ?? DEFAULT_SENTRY_DSN,
114 environment:
115 process.env.EVERWHEN_SENTRY_ENVIRONMENT ?? process.env.NODE_ENV,
116 },
117 },
118 },
119 
120 // @sentry/nuxt build-time options. Source-map upload is OFF: it needs a Sentry auth
121 // token (a secret) plus the org/project slugs wired into CI, none of which exist yet,
122 // so production stack traces stay minified for now — a clean follow-up once a
123 // SENTRY_AUTH_TOKEN is added to the image build. Server tracing loads via the
124 // Dockerfile's `node --import …/sentry.server.config.mjs`, so no autoInjectServerSentry
125 // (that pairing would double-init Sentry).
126 sentry: {
127 sourceMapsUploadOptions: { enabled: false },
128 },
⋯ 9 lines hidden (lines 129–137)
129 
130 // The standalone agents/ tooling package has its own deps + tsconfig; keep it
131 // out of the app's type program so `nuxt typecheck` never compiles the loops.
132 typescript: {
133 tsConfig: {
134 exclude: ["../agents"],
135 },
136 },
137});

Full server instrumentation, the Sentry way

For the server SDK to instrument Nitro and the outbound AI / Supabase / Stripe calls, it has to load before the app's own modules. Sentry's recommended path for a container you control is Node's --import flag, so the Dockerfile's launch command changes:

The runtime CMD now preloads the emitted server config.

Dockerfile · 57 lines
Dockerfile57 lines · Docker
⋯ 51 lines hidden (lines 1–51)
1# syntax=docker/dockerfile:1
2 
3# Everwhen is a Nuxt 3 (Nitro) SSR app: the OpenAI calls run server-side, so
4# this ships the Node server — not a static bundle. Two stages keep the runtime
5# image small (just Node + the self-contained Nitro output, no source/devDeps).
6 
7# ── Build stage ───────────────────────────────────────────────────────────────
8FROM node:22-alpine AS build
9WORKDIR /app
10 
11# Install deps against the MANIFESTS ALONE first, so the (slow) npm ci layer caches
12# on the lockfile — a source-only change (a doc, a component) no longer reinstalls the
13# whole tree (issue #239). Dev deps are included; Nuxt needs them to build.
14# --ignore-scripts skips the `postinstall` (nuxt prepare): the source isn't here yet,
15# and `npm run build` regenerates .nuxt below regardless. It also skips Playwright's
16# browser download, which the image build has no use for. The native deps that ship
17# via optional packages (@resvg/resvg-js, esbuild) install without a script, so they
18# are unaffected.
19COPY package.json package-lock.json ./
20RUN npm ci --ignore-scripts
21 
22# Now the source, layered on top of the cached install. .dockerignore keeps
23# node_modules/.env/.output/agents and the non-build dirs out.
24COPY . .
25 
26# Produce /app/.output — the Nitro node server, which bundles its runtime deps.
27RUN npm run build
28 
29# ── Runtime stage ─────────────────────────────────────────────────────────────
30# Minimal: Node + the built output only. No source, no node_modules, no secrets —
31# OPENAI_API_KEY is injected at runtime via env (never baked into the image).
32FROM node:22-alpine AS runtime
33WORKDIR /app
34 
35# Fonts for the server-rendered share-card images (issue #88): resvg ships no fonts and
36# the slim alpine base has none, so card text would render blank. DejaVu (libre) covers
37# the Latin + digits + Δ the card uses; fontconfig lets resvg discover it.
38RUN apk add --no-cache fontconfig ttf-dejavu
39 
40ENV NODE_ENV=production \
41 NITRO_HOST=0.0.0.0 \
42 NITRO_PORT=3000 \
43 PORT=3000
44 
45COPY --from=build /app/.output ./.output
46 
47# Drop privileges to the image's built-in non-root user.
48USER node
49 
50EXPOSE 3000
51 
52# Load the Sentry server SDK before the app (via --import) so it can instrument Nitro, its
53# HTTP layer, and the outbound AI/Supabase/Stripe calls. @sentry/nuxt emits this file into
54# the build output; it self-disables when no DSN is set (see sentry.server.config.ts), so
55# the flag is safe even with Sentry off. Deliberately NOT paired with autoInjectServerSentry
56# (nuxt.config) — using both would initialize Sentry twice.
57CMD ["node", "--import", "./.output/server/sentry.server.config.mjs", ".output/server/index.mjs"]

Never phone home from CI

The e2e suite runs the production build through nuxt preview, which would otherwise satisfy the production gate and start sending real events during CI. The Playwright web server blanks the DSN so it stays inert:

webServer.env merges over process.env; empty DSN disables Sentry.

playwright.config.ts · 67 lines
playwright.config.ts67 lines · TypeScript
⋯ 52 lines hidden (lines 1–52)
1/**
2 * Playwright Configuration for End-to-End Testing
3 * Integrates with Nuxt 3 for full-stack testing
4 */
5import { defineConfig, devices } from "@playwright/test";
6import { fileURLToPath } from "node:url";
7import type { ConfigOptions } from "@nuxt/test-utils/playwright";
8 
9export default defineConfig<ConfigOptions>({
10 // Test directory configuration
11 testDir: "./tests/e2e",
12 
13 // Run tests sequentially to avoid file handle exhaustion
14 fullyParallel: false,
15 
16 // Prevent use of test.only in CI
17 forbidOnly: !!process.env.CI,
18 
19 // Retry configuration for CI stability
20 retries: process.env.CI ? 2 : 0,
21 
22 // Worker configuration - limit workers to prevent file handle exhaustion
23 workers: process.env.CI ? 1 : 2,
24 
25 // HTML report generation
26 reporter: "html",
27 
28 // Global test configuration
29 use: {
30 // Nuxt integration settings
31 nuxt: {
32 rootDir: fileURLToPath(new URL(".", import.meta.url)),
33 },
34 
35 // Base URL for tests
36 baseURL: "http://127.0.0.1:3000",
37 
38 // Enable tracing for debugging failed tests
39 trace: "on-first-retry",
40 },
41 
42 // Browser projects - reduced for stability
43 projects: [
44 {
45 name: "chromium",
46 use: { ...devices["Desktop Chrome"] },
47 },
48 ],
49 
50 // Development server configuration. In CI the workflow runs an explicit `nuxt build`
51 // step first (fast, clear build-breakage signal), so the server only needs to preview
52 // that output — no second build. Locally there's no prior build, so build then preview.
53 webServer: {
54 command: process.env.CI
55 ? "npm run preview"
56 : "npm run build && npm run preview",
57 port: 3000,
58 reuseExistingServer: !process.env.CI,
59 // Blank the Sentry DSN for the mocked e2e run so the preview never initializes Sentry:
60 // no events reach the real project, and the suite carries no network dependency on
61 // sentry.io. NUXT_PUBLIC_* overrides the client runtimeConfig; EVERWHEN_* the server.
62 env: {
63 NUXT_PUBLIC_SENTRY_DSN: "",
64 EVERWHEN_SENTRY_DSN: "",
65 },
⋯ 2 lines hidden (lines 66–67)
66 },
67});

The env knobs, and why v9 not v10

.env.example documents the two optional overrides, and reiterates that production works with no setup because the default DSN is committed:

.env.example · 85 lines
.env.example85 lines · Text only
⋯ 77 lines hidden (lines 1–77)
1# Everwhen — environment variables
2#
3# Copy to `.env` and fill in what you need. Only a model key is required to
4# actually play; everything else has a safe default (in-memory dev store, no
5# paywall, moderation on) and can stay unset locally.
6#
7# In production these also ride in as NUXT_*-prefixed runtimeConfig overrides
8# (see infra/k8s/everwhen); locally the plain names below are what the server
9# reads. Never commit your filled-in `.env`.
10 
11# ── Models (required to play) ────────────────────────────────────────────────
12# Every AI task routes through a per-task seam whose default lane is Anthropic,
13# so ANTHROPIC_API_KEY is required. OPENAI_API_KEY powers the fallback lane
14# (EVERWHEN_AI_LANE=openai) and the dual-grader evals — set it too if you'll use
15# either.
16ANTHROPIC_API_KEY=your_anthropic_key_here
17OPENAI_API_KEY=your_openai_key_here
18 
19# Force the whole AI seam onto one provider (emergency lever / eval matrix).
20# openai | anthropic. Unset → anthropic.
21# EVERWHEN_AI_LANE=anthropic
22 
23# ── Persistence & payments (optional locally) ────────────────────────────────
24# Supabase is the run store + accounts. Absent → an in-memory dev store, fine for
25# `npm run dev` and the tests. SUPABASE_PUBLISHABLE_KEY drives client sign-in;
26# absent → device-cookie mode.
27# SUPABASE_URL=https://your-project.supabase.co
28# SUPABASE_SECRET_KEY=your_supabase_service_role_key
29# SUPABASE_PUBLISHABLE_KEY=your_supabase_publishable_key
30 
31# Stripe sells run packs. Absent → checkout returns 503 (the rest of the game
32# still runs). EVERWHEN_BASE_URL is the canonical origin Stripe redirects back to
33# (never the client Host header) — set it to your public URL in production.
34# STRIPE_SECRET_KEY=sk_test_...
35# STRIPE_WEBHOOK_SECRET=whsec_...
36# EVERWHEN_BASE_URL=http://localhost:3000
37 
38# ── Spend cap (optional) ─────────────────────────────────────────────────────
39# Global, windowed AI-cost ceiling that pauses NEW runs once hit — the backstop
40# behind the paywall. Defaults: $25 over a 24h (86400s) window.
41# EVERWHEN_SPEND_CAP_USD=25
42# EVERWHEN_SPEND_WINDOW_SECONDS=86400
43 
44# ── Content moderation (optional) ────────────────────────────────────────────
45# off | shadow | enforce. Unset → enforce (blocks + refunds disallowed content;
46# shadow logs would-block without blocking; off disables the seam).
47# EVERWHEN_MODERATION=enforce
48 
49# ── AI-objective semantic de-dup (optional; #95 follow-up) ───────────────────
50# off | shadow | enforce. Unset → enforce. A Haiku judge rejects a freshly composed
51# objective that merely rewords the curated pool or a session roll (the near-
52# duplicate a normalized-title match misses); a rejection rerolls, then falls back
53# to curated. shadow logs [objdedup] would-reject without acting; off skips the call.
54# EVERWHEN_OBJECTIVE_DEDUP=enforce
55 
56# ── Referral credits (optional; issue #96) ───────────────────────────────────
57# reward: runs credited to a sharer per qualified email signup (default 3). cap:
58# soft per-sharer lifetime limit on credited referrals, <=0 disables (default 10).
59# The digest is the batched-notice throttle: an on/off kill-switch (default on),
60# a threshold (default 3), and a window in hours (default 24).
61# EVERWHEN_REFERRAL_REWARD=3
62# EVERWHEN_REFERRAL_CAP=10
63# EVERWHEN_REFERRAL_DIGEST=true
64# EVERWHEN_REFERRAL_DIGEST_THRESHOLD=3
65# EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS=24
66 
67# ── Operator dashboard (optional) ────────────────────────────────────────────
68# Comma-separated emails allowed into the Steward's Ledger ops dashboard
69# (/steward + /api/steward/*). Empty → deny-all.
70# EVERWHEN_OPERATOR_EMAILS=you@example.com
71 
72# ── Misc (optional) ──────────────────────────────────────────────────────────
73# Opt a DEPLOYED staging preview into the dev fixtures/panel. Never set in prod.
74# EVERWHEN_DEV_MODE=false
75# Brand X/Twitter @handle for card attribution. Unset → those tags are omitted.
76# EVERWHEN_TWITTER_HANDLE=@everwhen
77 
78# ── Error monitoring (optional) ──────────────────────────────────────────────
79# Sentry error + performance monitoring. A PUBLIC Sentry DSN ships as the committed
80# default (safe — a DSN can only send events, never read them), so production reports
81# with no setup here. Sentry stays silent in local dev regardless (it reports only from
82# production builds). Override the DSN per environment, or set it EMPTY to disable Sentry:
83# EVERWHEN_SENTRY_DSN=
84# Tag events by environment (e.g. production, preview). Unset → NODE_ENV.
85# EVERWHEN_SENTRY_ENVIRONMENT=