diff --git a/CLAUDE.md b/CLAUDE.md
index 8d2f150..22d719d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -32,6 +32,8 @@ These are the rules that must not be forgotten or looked up — they're the ones
- Reusable UI primitives live flat in `assets/js/components/` (`Button`, `Spinner`, `Select`, `DropdownMenu`, `AlertDialog`, …) — no sub-folders
- Forms use Inertia's `useForm` hook; errors come from `assign_errors(conn, changeset)` on the server
- **No raw `try/catch` for async work — wrap every Promise in `go()` from `@api3/promise-utils`** (sync work uses `goSync`). It returns `{ success, data, error }`, which forces every call site to acknowledge the failure path explicitly and prevents the "swallow the error and move on" pattern that hides real bugs. The same applies to dynamic `import()`, `fetch()`, JSON parsing, and any vendor SDK call. The only acceptable exception is at top-level error boundaries that genuinely *do* need to catch everything synchronously
+- **Don't inline a multi-line function into `go(...)` / `goSync(...)`** — extract it to a named function above the call and pass it by name (`const result = await go(renderApp)`), so the call site stays a scannable one-liner. Small one-line callbacks (`go(() => i18n.changeLanguage(locale))`) are fine to inline. Mirrors the Elixir `with`-clause rule below
+- **Multi-line arrow/function bodies use explicit braces and `return`** — never a multi-line implicit return (it's too easy to lose track of what's returned, or drop the `return` when editing, and get weird behaviour). When you convert an implicit return to a block body, **keep the `return`** so the returned value is preserved — only drop it when the value is genuinely unused. And if the body fits on one line within the 120-col width, prefer collapsing to a single-line implicit return rather than a block. Single-line implicit returns (`(x) => x.id`) and idiomatic multi-line JSX render-props wrapped in parens (`({ Component }) => ( )`) are fine
- Never edit `assets/js/_pages.ts`, `assets/js/_ssr_pages.ts`, or `assets/js/routes.ts` — they're auto-generated
- **Frontend paths come from the `routes` helper, never string literals.** `assets/js/routes.ts` is generated from the Phoenix router by `mix routes.gen` (run in `assets.build`/`assets.deploy` + the dev watcher; `mix routes.gen --check` in `precommit` fails the build on drift). Use `routes.login()`, `routes.settingsEmail()`, etc. for every internal URL on the frontend (``, `useForm().post(...)`, `router.visit/delete(...)`) — the TS path-builder is the frontend counterpart to the server's `~p` sigil and keeps the two in sync. Names are the camelCased path (`/settings/email/apply-change` → `settingsEmailApplyChange`, `/` → `root`); `:param` segments become typed args and every builder takes an optional query object (`routes.confirmEmail({ token })`). Adding/removing a route + rebuilding regenerates the file; never hand-edit it
- **Validation errors must redirect, not re-render.** On `{:error, changeset}` always use `conn |> assign_errors(changeset) |> redirect(to: ~p"/current-page")`. Do **not** use `put_status(:unprocessable_entity) |> render_inertia(...)` — Inertia updates the browser URL to the POST/PUT target when you re-render, which is both wrong UX and a regression risk. The Inertia plug flashes errors through the session across the redirect, so the form still shows them
diff --git a/Dockerfile b/Dockerfile
index 38dd514..0c0345e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -65,9 +65,33 @@ COPY lib lib
COPY assets assets
# Build browser bundle + SSR bundle + digest, then compile the app.
-RUN mix assets.deploy
+#
+# Source maps: assets.deploy runs build/upload-sourcemaps.js, which ships
+# maps to Sentry only when SENTRY_AUTH_TOKEN/SENTRY_ORG/SENTRY_PROJECT are
+# set in the build env, and otherwise skips (so local/CI builds without
+# Sentry creds still succeed). These are declared as ARGs so Render's build
+# environment populates them, and passed only to this RUN so the auth token
+# is never ENV-persisted into an image layer (the builder stage is
+# discarded in the final image regardless).
+#
+# RENDER_GIT_COMMIT is Render's commit SHA; it tags the upload with the
+# same release runtime events report. Nothing to set by hand.
+ARG RENDER_GIT_COMMIT=""
+ARG SENTRY_ORG=""
+ARG SENTRY_PROJECT=""
+ARG SENTRY_AUTH_TOKEN=""
+RUN RENDER_GIT_COMMIT="${RENDER_GIT_COMMIT}" \
+ SENTRY_ORG="${SENTRY_ORG}" \
+ SENTRY_PROJECT="${SENTRY_PROJECT}" \
+ SENTRY_AUTH_TOKEN="${SENTRY_AUTH_TOKEN}" \
+ mix assets.deploy
RUN mix compile
+# Package the app's source so Sentry can show source context on backend
+# stack traces. Writes into the :sentry dep's priv dir, which the release
+# bundles; loaded when the :sentry app starts. Must run before mix release.
+RUN mix sentry.package_source_code
+
# Runtime config + release assembly.
COPY config/runtime.exs config/
COPY rel rel
diff --git a/assets/build/upload-sourcemaps.js b/assets/build/upload-sourcemaps.js
new file mode 100644
index 0000000..d8700a5
--- /dev/null
+++ b/assets/build/upload-sourcemaps.js
@@ -0,0 +1,77 @@
+// Uploads the built browser source maps to Sentry, then deletes every
+// `.map` file so they're never digested by `phx.digest` or served by
+// Plug.Static (publishing them would leak source). `sourcemaps inject`
+// stamps a debug ID into each .js and its .map, so Sentry pairs minified
+// frames with the original source regardless of the later phx.digest rename.
+//
+// Runs inside `mix assets.deploy`, after the minified esbuild build and
+// before `phx.digest`.
+//
+// Resilience: the upload is best-effort. If credentials are missing it's
+// skipped, and if the upload errors it warns but does NOT fail the build —
+// a source-map hiccup shouldn't block a deploy (the app ships fine, you
+// just get minified stack traces). Either way the `.map` files are removed
+// so they never reach the served bundle.
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { SentryCli } = require('@sentry/cli');
+
+const ASSETS_DIR = path.join(__dirname, '..', '..', 'priv', 'static', 'assets');
+
+const authToken = process.env.SENTRY_AUTH_TOKEN;
+const org = process.env.SENTRY_ORG;
+const project = process.env.SENTRY_PROJECT;
+// Tag the upload with the deploy's commit SHA (Render's RENDER_GIT_COMMIT)
+// so it matches the release runtime events report. Source maps still
+// resolve without it — the injected debug IDs are what Sentry matches on.
+const release = process.env.SENTRY_RELEASE || process.env.RENDER_GIT_COMMIT;
+
+async function uploadSourceMaps() {
+ const cli = new SentryCli(null, { authToken });
+
+ // Inject must run before upload: it stamps a debug ID into each .js and
+ // its .map so Sentry can pair minified frames with the original source.
+ await cli.execute(['sourcemaps', 'inject', ASSETS_DIR], true);
+
+ const uploadArgs = ['sourcemaps', 'upload', '--org', org, '--project', project];
+ if (release) uploadArgs.push('--release', release);
+ uploadArgs.push(ASSETS_DIR);
+ await cli.execute(uploadArgs, true);
+}
+
+function deleteSourceMaps(dir) {
+ let deleted = 0;
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ deleted += deleteSourceMaps(full);
+ } else if (entry.name.endsWith('.map')) {
+ fs.rmSync(full);
+ deleted++;
+ }
+ }
+ return deleted;
+}
+
+async function main() {
+ if (authToken && org && project) {
+ // Build scripts run outside the app's `go()` convention; a try/catch at
+ // this top-level boundary keeps an upload failure from failing the build.
+ try {
+ await uploadSourceMaps();
+ process.stdout.write('sentry: uploaded source maps\n');
+ } catch (error) {
+ process.stderr.write(`sentry: source map upload failed (continuing): ${error?.message || error}\n`);
+ }
+ } else {
+ process.stdout.write('sentry: SENTRY_AUTH_TOKEN/ORG/PROJECT not set — skipping source map upload\n');
+ }
+
+ // Always remove the .map files so they're never digested or served,
+ // whether the upload ran, was skipped, or failed.
+ const deleted = deleteSourceMaps(ASSETS_DIR);
+ process.stdout.write(`sentry: removed ${deleted} .map file(s) from the served bundle\n`);
+}
+
+main();
diff --git a/assets/js/app.tsx b/assets/js/app.tsx
index 48d6017..98cc508 100644
--- a/assets/js/app.tsx
+++ b/assets/js/app.tsx
@@ -1,3 +1,6 @@
+// Init Sentry first so its global error handlers are installed before any
+// other module can throw. No-op unless a DSN was stamped into
.
+import './sentry';
import './i18n';
import { go } from '@api3/promise-utils';
import { createInertiaApp, router } from '@inertiajs/react';
diff --git a/assets/js/components/ErrorBoundary.tsx b/assets/js/components/ErrorBoundary.tsx
index 9dadfe1..ef6c49f 100644
--- a/assets/js/components/ErrorBoundary.tsx
+++ b/assets/js/components/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
+import { captureException } from '../sentry';
import Button from './Button';
interface Props {
@@ -31,9 +32,10 @@ export default class ErrorBoundary extends Component {
}
componentDidCatch(error: Error, info: ErrorInfo) {
- // Surface to the console in every environment. Wire your error
- // monitoring SDK (Sentry, AppSignal, …) in here.
+ // Surface to the console in every environment, and report to Sentry
+ // (no-op unless configured) with the component stack for context.
console.error('Unhandled render error:', error, info.componentStack);
+ captureException(error, { componentStack: info.componentStack });
}
render() {
diff --git a/assets/js/realtime/provider.tsx b/assets/js/realtime/provider.tsx
index 46e9257..a78f52d 100644
--- a/assets/js/realtime/provider.tsx
+++ b/assets/js/realtime/provider.tsx
@@ -155,9 +155,9 @@ export function RealtimeProvider({ children }: { children: ReactNode }) {
channel
.join()
.receive('ok', () => updateChannelStatus(topic, 'joined', null))
- .receive('error', (reply: { reason?: string }) =>
- updateChannelStatus(topic, 'errored', { reason: reply.reason ?? 'unknown' })
- );
+ .receive('error', (reply: { reason?: string }) => {
+ return updateChannelStatus(topic, 'errored', { reason: reply.reason ?? 'unknown' });
+ });
},
[socket, updateChannelStatus]
);
diff --git a/assets/js/sentry.ts b/assets/js/sentry.ts
new file mode 100644
index 0000000..539b98b
--- /dev/null
+++ b/assets/js/sentry.ts
@@ -0,0 +1,31 @@
+import * as Sentry from '@sentry/react';
+
+// Read config the server stamped into (see root.html.heex). The
+// tags are absent unless SENTRY_DSN_FRONTEND is set, so dev and any
+// unconfigured deploy skip init and the SDK stays a no-op.
+function metaContent(name: string): string | undefined {
+ return document.querySelector(`meta[name="${name}"]`)?.content || undefined;
+}
+
+const dsn = metaContent('sentry-dsn');
+
+// Errors only: no `tracesSampleRate`, no replay integration. init()
+// auto-registers the global `window.onerror` / `unhandledrejection`
+// handlers, so uncaught errors and rejected promises are captured without
+// further wiring; ErrorBoundary reports the render-tree errors it catches.
+if (dsn) {
+ Sentry.init({
+ dsn,
+ environment: metaContent('sentry-environment'),
+ release: metaContent('sentry-release'),
+ });
+}
+
+export const sentryEnabled = dsn !== undefined;
+
+/** Report a caught exception. No-op when Sentry isn't configured. */
+export function captureException(error: unknown, context?: Record): void {
+ if (sentryEnabled) {
+ Sentry.captureException(error, context ? { extra: context } : undefined);
+ }
+}
diff --git a/assets/js/ssr.tsx b/assets/js/ssr.tsx
index 3f8f5f0..ad842fe 100644
--- a/assets/js/ssr.tsx
+++ b/assets/js/ssr.tsx
@@ -1,44 +1,72 @@
import './i18n';
+import { go } from '@api3/promise-utils';
import { createInertiaApp } from '@inertiajs/react';
+import * as Sentry from '@sentry/node';
import { createElement } from 'react';
import ReactDOMServer from 'react-dom/server';
import pages, { ssrClientOnly } from './_ssr_pages.ts';
import { AppProviders } from './app-providers';
import i18n from './i18n';
+// Sentry for the SSR Node workers (errors only — no tracing). The DSN is
+// inherited from the BEAM's environment; falls back to the frontend DSN
+// since SSR runs the same React code. No-op unless a DSN is set.
+const sentryDsn = process.env.SENTRY_DSN_SSR || process.env.SENTRY_DSN_FRONTEND;
+if (sentryDsn) {
+ Sentry.init({
+ dsn: sentryDsn,
+ environment: process.env.DEPLOY_ENV || 'production',
+ release: process.env.SENTRY_RELEASE || process.env.RENDER_GIT_COMMIT,
+ });
+}
+
// Called by Elixir's Inertia.SSR Node.js worker pool with the page
// protocol payload. The shape varies per page and is supplied by
// Inertia itself, not by our own code, so it's opaque on the Node side.
// biome-ignore lint/suspicious/noExplicitAny: protocol-level payload from Inertia
-export function render(page: any) {
+export async function render(page: any) {
// Sync locale before rendering so SSR output matches
const locale = page.props?.locale as string | undefined;
if (locale && locale !== i18n.language) {
- i18n.changeLanguage(locale);
+ void go(() => i18n.changeLanguage(locale));
}
- return createInertiaApp({
- page,
- render: ReactDOMServer.renderToString,
- resolve: (name) => {
- const component = pages[name];
- if (component) return component;
- // Client-only pages (pages/client/*) aren't in the SSR bundle. Render
- // nothing server-side and let the client take over, rather than failing
- // the render. A genuinely unknown name still throws.
- if (ssrClientOnly.has(name)) return () => null;
- throw new Error(`SSR page not found: ${name}`);
- },
- // Mirror the client wrapping (see app.tsx): page components are
- // rendered inside AppProviders so context-dependent components
- // (Tooltip, etc.) work during SSR. Side-effect providers like
- // RealtimeProvider are safe — their useEffect doesn't run server-side.
- setup: ({ App, props }) => (
-
- {({ Component, props: pageProps, key }) => (
- {createElement(Component, { key: key ?? undefined, ...pageProps })}
- )}
-
- ),
- });
+ const renderApp = () => {
+ return createInertiaApp({
+ page,
+ render: ReactDOMServer.renderToString,
+ resolve: (name) => {
+ const component = pages[name];
+ if (component) return component;
+ // Client-only pages (pages/client/*) aren't in the SSR bundle. Render
+ // nothing server-side and let the client take over, rather than failing
+ // the render. A genuinely unknown name still throws.
+ if (ssrClientOnly.has(name)) return () => null;
+ throw new Error(`SSR page not found: ${name}`);
+ },
+ // Mirror the client wrapping (see app.tsx): page components are
+ // rendered inside AppProviders so context-dependent components
+ // (Tooltip, etc.) work during SSR. Side-effect providers like
+ // RealtimeProvider are safe — their useEffect doesn't run server-side.
+ setup: ({ App, props }) => (
+
+ {({ Component, props: pageProps, key }) => (
+ {createElement(Component, { key: key ?? undefined, ...pageProps })}
+ )}
+
+ ),
+ });
+ };
+
+ const result = await go(renderApp);
+
+ // Report the failure, then re-raise so Inertia's own SSR-failure handling
+ // runs unchanged (graceful client-side fallback in prod, raise in dev per
+ // `raise_on_ssr_failure`).
+ if (!result.success) {
+ if (sentryDsn) Sentry.captureException(result.error);
+ throw result.error;
+ }
+
+ return result.data;
}
diff --git a/assets/js/types/env.d.ts b/assets/js/types/env.d.ts
index a9643f3..1dfc9dd 100644
--- a/assets/js/types/env.d.ts
+++ b/assets/js/types/env.d.ts
@@ -4,5 +4,18 @@
// gives editors/TypeScript the type — `process` itself never reaches the
// bundle.
declare const process: {
- env: { NODE_ENV?: 'development' | 'production' };
+ env: {
+ NODE_ENV?: 'development' | 'production';
+ // Read only by ssr.tsx, which runs in a real Node worker (spawned by
+ // the BEAM) and inherits these from the deploy environment. They never
+ // reach the browser bundle.
+ SENTRY_DSN_SSR?: string;
+ SENTRY_DSN_FRONTEND?: string;
+ SENTRY_RELEASE?: string;
+ // Render sets this (the commit SHA) at runtime; used as the release
+ // when SENTRY_RELEASE is unset.
+ RENDER_GIT_COMMIT?: string;
+ // Deploy tier (staging/qa/production); the Sentry environment.
+ DEPLOY_ENV?: string;
+ };
};
diff --git a/assets/package-lock.json b/assets/package-lock.json
index 94aba1d..5e98961 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -13,6 +13,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@sentry/node": "^10.58.0",
+ "@sentry/react": "^10.58.0",
"@types/phoenix": "^1.6.7",
"i18next": "^26.3.0",
"lucide-react": "^1.18.0",
@@ -24,6 +26,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.0",
"@playwright/test": "^1.61.0",
+ "@sentry/cli": "^3.5.0",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
@@ -320,6 +323,101 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/api-logs": {
+ "version": "0.214.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz",
+ "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@opentelemetry/core": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
+ "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/instrumentation": {
+ "version": "0.214.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz",
+ "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api-logs": "0.214.0",
+ "import-in-the-middle": "^3.0.0",
+ "require-in-the-middle": "^8.0.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.3.0"
+ }
+ },
+ "node_modules/@opentelemetry/resources": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
+ "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.8.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/sdk-trace-base": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
+ "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/core": "2.8.0",
+ "@opentelemetry/resources": "2.8.0",
+ "@opentelemetry/semantic-conventions": "^1.29.0"
+ },
+ "engines": {
+ "node": "^18.19.0 || >=20.6.0"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+ }
+ },
+ "node_modules/@opentelemetry/semantic-conventions": {
+ "version": "1.41.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
+ "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
@@ -1125,6 +1223,363 @@
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
+ "node_modules/@sentry/browser": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.58.0.tgz",
+ "integrity": "sha512-Syf6h6HDwC15fk86+/0ql8ebwwJFw2wp+lvOX9GfLTJVOqrSILCrtLKNI+f4v/3w8mzImsv9ttJGGbUugLNvcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser-utils": "10.58.0",
+ "@sentry/core": "10.58.0",
+ "@sentry/feedback": "10.58.0",
+ "@sentry/replay": "10.58.0",
+ "@sentry/replay-canvas": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/browser-utils": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.58.0.tgz",
+ "integrity": "sha512-TzXrhZq3Llj6qPSv0ZVG5N5c7C6qNN/aRKJXhq2LombJrLwiQrWdgizp7zdHA0FGlZ7F5YpyRA2JIpkhvrFqYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-3.5.1.tgz",
+ "integrity": "sha512-h710aEXT8At4lg7GbXDZkatDDq0uA5QKZmSiF/8Hy6jJZ/9dh6EBDZZpTYnDpNrwRvE8tqhnwEjTZDlHjOhPNQ==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "FSL-1.1-MIT",
+ "dependencies": {
+ "progress": "^2.0.3",
+ "proxy-from-env": "^1.1.0",
+ "undici": "^6.22.0",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "sentry-cli": "bin/sentry-cli"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "optionalDependencies": {
+ "@sentry/cli-darwin": "3.5.1",
+ "@sentry/cli-linux-arm": "3.5.1",
+ "@sentry/cli-linux-arm64": "3.5.1",
+ "@sentry/cli-linux-i686": "3.5.1",
+ "@sentry/cli-linux-x64": "3.5.1",
+ "@sentry/cli-win32-arm64": "3.5.1",
+ "@sentry/cli-win32-i686": "3.5.1",
+ "@sentry/cli-win32-x64": "3.5.1"
+ }
+ },
+ "node_modules/@sentry/cli-darwin": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.5.1.tgz",
+ "integrity": "sha512-GxHAtZaXRA650egcepQXU0pR0dZ8ZNvQS8eq3wBxhCK8os5VQpLyBABIaN6AdrE/b8M7UvqGjsuVm0giI1GZ7w==",
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-3.5.1.tgz",
+ "integrity": "sha512-JOfgXCDZKbxQpw8Z4Kbkt8Hl+ASW5pYVUYw8Hc2msPN3HtfTmsc1z0y6jq3TCUWDWbWpWbI1zfXa0v02vQf3gw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm64": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.5.1.tgz",
+ "integrity": "sha512-Qa0NLXG/FSYWGhKjdm2mxp/GgpluFFkj/J+CpmVzwvezNC/Uy1omquv7J+VficqskNYuptjsoB4dNIPPcXpbxg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-linux-i686": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-3.5.1.tgz",
+ "integrity": "sha512-/Bqcl8EyS6T8RIjBeeMYUPgjMJ8kb4plF0w3QOG6TY+bUEqHEnZyfzKJWZY/OqhmrSgj+ZYynaSHIIR6dWluMA==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-linux-x64": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-3.5.1.tgz",
+ "integrity": "sha512-iUJT2GI/soc0myi1sSnXdu71oBkUER3fBeXn10bcEZX+UK9GomsqL40OLlygDipZZ6FqhODORqLeTxHdHbRuOw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-win32-arm64": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.5.1.tgz",
+ "integrity": "sha512-Hs4jHOsTKrrI2W8c4wEIvQzSuHqvrjsDHT7iwujHjp4oeAOnYjBUm+/BgDH2Eg1/jL5ilEnJbpnAn2AE2KQUXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-win32-i686": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-3.5.1.tgz",
+ "integrity": "sha512-Op+9MYAg0RbVWOQ9/NtFunWcknS8MlqhEa03ENFUrRDQE0m+iHioknGOagKrNXYXj1UbGEf0G9UzIMcRwB/UCQ==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/cli-win32-x64": {
+ "version": "3.5.1",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-3.5.1.tgz",
+ "integrity": "sha512-Vf+CtFPkuGzjddD6dJoAVKszw5QB7P1ffc++yAbU54ditqJdgswo45oyTFOiQFO2isCmhgICzimqe8SkU+p2Mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "FSL-1.1-MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.58.0.tgz",
+ "integrity": "sha512-bkIbh2c6dzwhrWn/FGWu7j8hf6TAat2XxpkGM91LiN09fLYUXIMwcohVsXqze5l2cq35TnvqmSROAbRNr27GVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/feedback": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.58.0.tgz",
+ "integrity": "sha512-VmIlR/0O0GXITbvgjPkQqd6yM0JDEk52WXv6Rs1kTdaIDU5h3Y64VDVN4MAbYVRHqSz7F1arjRRk2FkaKC7ZOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/node": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.58.0.tgz",
+ "integrity": "sha512-KICgacBS+I/eWzFlAembutSwFwy0WVSrGp8UMV9n1XZqqu4EBTlALRsbLNlDSv61UgH85L9L3vk91tgq6nJXAA==",
+ "license": "MIT",
+ "dependencies": {
+ "@opentelemetry/api": "^1.9.1",
+ "@opentelemetry/core": "^2.6.1",
+ "@opentelemetry/instrumentation": "^0.214.0",
+ "@opentelemetry/sdk-trace-base": "^2.6.1",
+ "@opentelemetry/semantic-conventions": "^1.40.0",
+ "@sentry/core": "10.58.0",
+ "@sentry/node-core": "10.58.0",
+ "@sentry/opentelemetry": "10.58.0",
+ "@sentry/server-utils": "10.58.0",
+ "import-in-the-middle": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/node-core": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.58.0.tgz",
+ "integrity": "sha512-7dTbYuoaSwSmF2GWDl7KK+sXQL8iqaZeZ2I/aFm+SvPZLckZF3OGFb2VsluWsSXQLnxtxPX9QP93viyK+VZsuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0",
+ "@sentry/opentelemetry": "10.58.0",
+ "import-in-the-middle": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.1.0",
+ "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1",
+ "@opentelemetry/instrumentation": ">=0.57.1 <1",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0",
+ "@opentelemetry/semantic-conventions": "^1.39.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@opentelemetry/core": {
+ "optional": true
+ },
+ "@opentelemetry/exporter-trace-otlp-http": {
+ "optional": true
+ },
+ "@opentelemetry/instrumentation": {
+ "optional": true
+ },
+ "@opentelemetry/sdk-trace-base": {
+ "optional": true
+ },
+ "@opentelemetry/semantic-conventions": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sentry/opentelemetry": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.58.0.tgz",
+ "integrity": "sha512-qKOGVmt02wDaq7E70VekG8Z9XM641trJPoTHSeVUfGaXVcmGc46ZldTNtfWbxJq/8f/fge2pap60gn066ido2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0",
+ "@opentelemetry/core": "^1.30.1 || ^2.1.0",
+ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0",
+ "@opentelemetry/semantic-conventions": "^1.39.0"
+ }
+ },
+ "node_modules/@sentry/react": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.58.0.tgz",
+ "integrity": "sha512-3FLRtnXrue30UZALrQ9wQZeuvVmZl/pTCA+RyPlaZ5GxcYTapN9CVbm1IvbQpK4w1bt80+JxaKM4HikvII6KpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser": "10.58.0",
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || 17.x || 18.x || 19.x"
+ }
+ },
+ "node_modules/@sentry/replay": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.58.0.tgz",
+ "integrity": "sha512-EVasQNUenpwJksK9DI1TQ78YytpjLAhxn0UTiHqA3sU/s1fHK5XZdZJPr/9uEGedRoInIP0UIWmbOtOEX8HHDg==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser-utils": "10.58.0",
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/replay-canvas": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.58.0.tgz",
+ "integrity": "sha512-ufFmaJ968DXGe6u9W/UqNVjCCTtAqT2bNtSu1jHAjIFFpWIuM80lcR33grK6SuGnFxceu5iJFaIW6JRyfbM0Sw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0",
+ "@sentry/replay": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@sentry/server-utils": {
+ "version": "10.58.0",
+ "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.58.0.tgz",
+ "integrity": "sha512-PywIl2jvl+tO5R4j+n72Lcf3ItanHcaMN/oL1U9ZHE8icaT2zpo2W4uOaslpQeQvqPC24HGZ3BW2etzsCFQbag==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "10.58.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
@@ -1161,6 +1616,27 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-import-attributes": {
+ "version": "1.9.5",
+ "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
+ "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8"
+ }
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -1183,6 +1659,12 @@
"node": ">=4"
}
},
+ "node_modules/cjs-module-lexer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
+ "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1190,6 +1672,23 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -1267,6 +1766,28 @@
}
}
},
+ "node_modules/import-in-the-middle": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz",
+ "integrity": "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-import-attributes": "^1.9.5",
+ "cjs-module-lexer": "^2.2.0",
+ "module-details-from-path": "^1.0.4"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/laravel-precognition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
@@ -1293,6 +1814,18 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/module-details-from-path": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
+ "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/phoenix": {
"version": "1.8.8",
"resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.8.tgz",
@@ -1331,6 +1864,23 @@
"node": ">=18"
}
},
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -1448,6 +1998,19 @@
}
}
},
+ "node_modules/require-in-the-middle": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
+ "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "module-details-from-path": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=9.3.0 || >=8.10.0 <9.0.0"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1460,6 +2023,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/undici": {
+ "version": "6.27.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
+ "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
@@ -1527,6 +2100,22 @@
"engines": {
"node": ">=0.10.0"
}
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
}
}
}
diff --git a/assets/package.json b/assets/package.json
index 220a22c..69a13ef 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -20,6 +20,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@sentry/node": "^10.58.0",
+ "@sentry/react": "^10.58.0",
"@types/phoenix": "^1.6.7",
"i18next": "^26.3.0",
"lucide-react": "^1.18.0",
@@ -31,6 +33,7 @@
"devDependencies": {
"@biomejs/biome": "^2.5.0",
"@playwright/test": "^1.61.0",
+ "@sentry/cli": "^3.5.0",
"@types/node": "^25.9.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
diff --git a/config/config.exs b/config/config.exs
index 58c6b7d..4806d94 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -103,6 +103,33 @@ config :logger, :default_formatter,
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
+# Sentry error monitoring (backend). The DSN is set per-environment in
+# config/runtime.exs — without it the SDK is inert, so dev/test report
+# nothing. Errors only: no tracing/spans are configured. `before_send`
+# enforces the project's no-PII rule on every event (see
+# ElixirReactStarter.Sentry).
+config :sentry,
+ enable_source_code_context: true,
+ root_source_code_paths: [File.cwd!()],
+ before_send: {ElixirReactStarter.Sentry, :before_send},
+ # Report failed Oban jobs (cron check-in monitoring left off — errors only).
+ integrations: [oban: [capture_errors: true]]
+
+# Route OTP/Logger crash reports into Sentry: Bandit request crashes and
+# Oban job failures both surface here. Attached at boot by
+# ElixirReactStarter.Application via `Logger.add_handlers(:elixir_react_starter)`.
+# Keeps the SDK defaults (level: :error, capture_log_messages: false, and
+# the Bandit domain excluded so PlugCapture doesn't double-report) — we only
+# widen the metadata so the correlation IDs we already log ride along.
+config :elixir_react_starter, :logger, [
+ {:handler, :sentry, Sentry.LoggerHandler,
+ %{
+ config: %{
+ metadata: [:request_id, :user_id]
+ }
+ }}
+]
+
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
diff --git a/config/runtime.exs b/config/runtime.exs
index 391582b..6cff776 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -133,4 +133,33 @@ if config_env() == :prod do
adapter: Swoosh.Adapters.Mailjet,
api_key: get_env!.("MAILJET_API_KEY"),
secret: get_env!.("MAILJET_SECRET")
+
+ # Sentry error monitoring. Both sides are opt-in: the SDK stays inert
+ # unless its DSN env var is set, so a deploy without Sentry configured
+ # simply reports nothing. Backend and frontend use separate Sentry
+ # projects (separate DSNs); the frontend DSN is surfaced to the browser
+ # via meta tags in root.html.heex (see ElixirReactStarterWeb.Plugs.SharedData).
+ #
+ # The Sentry environment is the deploy tier (staging/qa/production), taken
+ # from DEPLOY_ENV — distinct from MIX_ENV, which is :prod for every
+ # deployed tier. Defaults to "production" when unset.
+ sentry_env = System.get_env("DEPLOY_ENV", "production")
+ # Release defaults to the deploy's commit SHA. On Render that's
+ # RENDER_GIT_COMMIT (provided at build and runtime), so neither needs to
+ # be set by hand; SENTRY_RELEASE overrides it if you ever want to.
+ sentry_release = System.get_env("SENTRY_RELEASE") || System.get_env("RENDER_GIT_COMMIT")
+
+ if dsn = System.get_env("SENTRY_DSN") do
+ config :sentry,
+ dsn: dsn,
+ environment_name: sentry_env,
+ release: sentry_release
+ end
+
+ if dsn = System.get_env("SENTRY_DSN_FRONTEND") do
+ config :elixir_react_starter, :sentry_client,
+ dsn: dsn,
+ environment: sentry_env,
+ release: sentry_release
+ end
end
diff --git a/lib/elixir_react_starter/application.ex b/lib/elixir_react_starter/application.ex
index b0953f4..7336342 100644
--- a/lib/elixir_react_starter/application.ex
+++ b/lib/elixir_react_starter/application.ex
@@ -7,6 +7,14 @@ defmodule ElixirReactStarter.Application do
@impl true
def start(_type, _args) do
+ # Attach the Sentry :logger handler configured under `config
+ # :elixir_react_starter, :logger`. Routes OTP crash reports (Bandit
+ # request crashes, Oban job failures, GenServer crashes) to Sentry.
+ # Inert until a DSN is set, so this is a no-op in dev/test. The handler
+ # config is static (config/config.exs), so this always returns :ok;
+ # matching it documents that and fails loudly on a genuine misconfig.
+ :ok = Logger.add_handlers(:elixir_react_starter)
+
children = [
ElixirReactStarterWeb.Telemetry,
ElixirReactStarter.Repo,
diff --git a/lib/elixir_react_starter/sentry.ex b/lib/elixir_react_starter/sentry.ex
new file mode 100644
index 0000000..189c758
--- /dev/null
+++ b/lib/elixir_react_starter/sentry.ex
@@ -0,0 +1,43 @@
+defmodule ElixirReactStarter.Sentry do
+ @moduledoc """
+ Sentry glue that enforces the project's no-PII rule on everything that
+ leaves the system.
+
+ Two hooks:
+
+ * `before_send/1` — the last gate every event passes through. It strips
+ the user's email (we keep the user *id* for correlation, never the
+ address) so a misconfigured call site can't leak one into Sentry.
+ * `scrub_params/1` — a `Sentry.PlugContext` body scrubber. It extends
+ the SDK default (which masks `password`/`secret`/…) to also drop the
+ email/token/code params that flow through auth and account routes.
+
+ Wired in `config/config.exs` (`before_send`) and on the endpoint's
+ `Sentry.PlugContext` plug (`scrub_params`).
+ """
+
+ # Request params that can carry PII or secrets and must never reach Sentry.
+ # Kept as strings because `Sentry.PlugContext.default_body_scrubber/1`
+ # returns a string-keyed map of the parsed body.
+ @pii_params ~w(email new_email current_password password password_confirmation token code secret)
+
+ @doc """
+ `before_send` callback. Drops the email from the event's user context;
+ returns the event otherwise unchanged so it still ships.
+ """
+ def before_send(%Sentry.Event{user: user} = event) when is_map(user) do
+ %{event | user: Map.drop(user, [:email, "email"])}
+ end
+
+ def before_send(%Sentry.Event{} = event), do: event
+
+ @doc """
+ `Sentry.PlugContext` body scrubber. Runs the SDK default scrubber, then
+ drops the project's additional PII/secret params.
+ """
+ def scrub_params(conn) do
+ conn
+ |> Sentry.PlugContext.default_body_scrubber()
+ |> Map.drop(@pii_params)
+ end
+end
diff --git a/lib/elixir_react_starter_web/components/layouts/root.html.heex b/lib/elixir_react_starter_web/components/layouts/root.html.heex
index 9b0ea7c..ddcac17 100644
--- a/lib/elixir_react_starter_web/components/layouts/root.html.heex
+++ b/lib/elixir_react_starter_web/components/layouts/root.html.heex
@@ -4,6 +4,14 @@
+ <%!-- Frontend Sentry config. Present only when SENTRY_DSN_FRONTEND is
+ set (see ElixirReactStarterWeb.Plugs.SharedData / config/runtime.exs);
+ read by assets/js/sentry.ts before the app boots. --%>
+ <%= if config = assigns[:sentry_client] do %>
+
+
+
+ <% end %>
<.inertia_title>{assigns[:page_title] || "ElixirReactStarter"}
<.inertia_head content={@inertia_head} />
<%!-- Theme bootstrap. Reads the `theme` cookie before any CSS/JS
diff --git a/lib/elixir_react_starter_web/endpoint.ex b/lib/elixir_react_starter_web/endpoint.ex
index 5c091d9..1d01d41 100644
--- a/lib/elixir_react_starter_web/endpoint.ex
+++ b/lib/elixir_react_starter_web/endpoint.ex
@@ -1,4 +1,8 @@
defmodule ElixirReactStarterWeb.Endpoint do
+ # Capture exceptions raised in the plug pipeline and report them to
+ # Sentry. Must sit above `use Phoenix.Endpoint`. Inert until a DSN is
+ # configured (see config/runtime.exs).
+ use Sentry.PlugCapture
use Phoenix.Endpoint, otp_app: :elixir_react_starter
# The session will be stored in the cookie and signed,
@@ -68,6 +72,12 @@ defmodule ElixirReactStarterWeb.Endpoint do
pass: ["*/*"],
json_decoder: Phoenix.json_library()
+ # Attach request context (method, path, scrubbed params/headers) to any
+ # Sentry event captured for this request. Must sit after Plug.Parsers so
+ # the body is available. `scrub_params` extends the default scrubber to
+ # drop the project's PII/secret params.
+ plug Sentry.PlugContext, body_scrubber: {ElixirReactStarter.Sentry, :scrub_params}
+
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
diff --git a/lib/elixir_react_starter_web/plugs/shared_data.ex b/lib/elixir_react_starter_web/plugs/shared_data.ex
index d7743e6..6c789d3 100644
--- a/lib/elixir_react_starter_web/plugs/shared_data.ex
+++ b/lib/elixir_react_starter_web/plugs/shared_data.ex
@@ -24,6 +24,13 @@ defmodule ElixirReactStarterWeb.Plugs.SharedData do
user = conn.assigns[:current_user]
conn
+ # Layout assign (not an Inertia prop): root.html.heex stamps the
+ # frontend Sentry DSN into meta tags that app.tsx reads before booting.
+ # nil when unconfigured, so the tags (and the SDK) are simply absent.
+ |> Plug.Conn.assign(
+ :sentry_client,
+ Application.get_env(:elixir_react_starter, :sentry_client)
+ )
|> assign_prop(:current_user, serialize_user(user))
|> assign_prop(:locale, conn.assigns[:locale] || "en")
|> assign_prop(:flash, conn.assigns[:flash] || %{})
diff --git a/mix.exs b/mix.exs
index 96cbb48..77086f9 100644
--- a/mix.exs
+++ b/mix.exs
@@ -50,6 +50,7 @@ defmodule ElixirReactStarter.MixProject do
ElixirReactStarterWeb.Telemetry,
Mix.Tasks.Lint,
Mix.Tasks.I18n.Check,
+ Mix.Tasks.Routes.Gen,
~r/^Inspect\./,
~r/Case$/,
~r/Factory$/
@@ -128,6 +129,7 @@ defmodule ElixirReactStarter.MixProject do
{:ecto_sql, "~> 3.13"},
{:exflect, "~> 1.0"},
{:gettext, "~> 1.0"},
+ {:hackney, "~> 1.20"},
{:hammer, "~> 7.0"},
{:jason, "~> 1.2"},
{:oban, "~> 2.19"},
@@ -138,6 +140,7 @@ defmodule ElixirReactStarter.MixProject do
{:phoenix_live_view, "~> 1.2.0"},
{:postgrex, ">= 0.0.0"},
{:req, "~> 0.5"},
+ {:sentry, "~> 13.2"},
{:swoosh, "~> 1.16"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
@@ -189,8 +192,12 @@ defmodule ElixirReactStarter.MixProject do
"cmd node assets/build/generate-ssr-pages.js",
# Generate the typed frontend route table (see assets.build above).
"routes.gen",
- ~s(esbuild elixir_react_starter --minify --define:process.env.NODE_ENV='"production"'),
+ # External source maps so Sentry can de-minify production stack
+ # traces. upload-sourcemaps.js ships them to Sentry and deletes the
+ # .map files before phx.digest runs, so they're never served.
+ ~s(esbuild elixir_react_starter --minify --sourcemap=external --define:process.env.NODE_ENV='"production"'),
"esbuild elixir_react_starter_ssr",
+ "cmd node assets/build/upload-sourcemaps.js",
"phx.digest",
# phx.digest writes `.gz` next to every asset; this step writes the
# brotli sibling so Plug.Static can serve whichever the request
diff --git a/mix.lock b/mix.lock
index 10635c5..ba4560b 100644
--- a/mix.lock
+++ b/mix.lock
@@ -39,6 +39,7 @@
"mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+ "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nodejs": {:hex, :nodejs, "3.1.4", "c165a0e5901966e98d7965a20375a81d84ea89cadcde6c513f7f466e7166b3d3", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5.1", [hex: :poolboy, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.7", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "ce69d966a743bdc002892358f653bf4c2074a6e8a4a46f99299d8fd8174d0195"},
@@ -57,6 +58,7 @@
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"req": {:hex, :req, "0.6.1", "7b904c8b42d0e08136a5c6aba024fd12fc79a1ed8856e7a3522b0917f7e75113", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "aaf11c9c80f2df2364630b3594e1857fe610d8ea7cb994e1ce3dcb55f204ff1c"},
+ "sentry": {:hex, :sentry, "13.2.0", "edef8afdbe3bbdae141c2a1a18661c214d9af57308ad6bd41b2182c6e9506382", [:mix], [{:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, ">= 1.8.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "f3397760ba0f0a2d8abb969c3e9f95e909839f7c45957249ee229c0e9738a3b4"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"swoosh": {:hex, :swoosh, "1.26.1", "2989f1089e3cf1a938bbd3908c11d7dba8c8a4fe78801a7762d26f01bdbb32b4", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, ">= 1.9.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, ">= 6.0.0 and < 8.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ad197c025102bbcbaffcd67c10ff7b390746c32f534ff5158891b149460118e"},
"tailwind": {:hex, :tailwind, "0.5.1", "35435b13158c90d37da11e1cfc808755fca1d7b6c5ab87b1b19c5de87e2f0a10", [:mix], [], "hexpm", "c4e26302a59fec72abc5610ecb6ad2116d9aa31f31aab2d4b8eb6e95d25a689c"},
diff --git a/test/elixir_react_starter/sentry_test.exs b/test/elixir_react_starter/sentry_test.exs
new file mode 100644
index 0000000..2f49da8
--- /dev/null
+++ b/test/elixir_react_starter/sentry_test.exs
@@ -0,0 +1,58 @@
+defmodule ElixirReactStarter.SentryTest do
+ use ExUnit.Case, async: true
+
+ alias ElixirReactStarter.Sentry, as: SentryGlue
+
+ describe "before_send/1" do
+ test "drops the email from the user context but keeps the id" do
+ event = build_event(user: %{id: 42, email: "jane@example.com"})
+
+ assert %Sentry.Event{user: user} = SentryGlue.before_send(event)
+ assert user == %{id: 42}
+ end
+
+ test "drops a string-keyed email too" do
+ event = build_event(user: %{"id" => 42, "email" => "jane@example.com"})
+
+ assert %Sentry.Event{user: user} = SentryGlue.before_send(event)
+ assert user == %{"id" => 42}
+ end
+
+ test "passes through an event with no user context unchanged" do
+ event = build_event(user: nil)
+
+ assert SentryGlue.before_send(event) == event
+ end
+ end
+
+ # Sentry.Event enforces :event_id and :timestamp; the values are
+ # irrelevant to before_send, so any placeholders do.
+ defp build_event(fields) do
+ struct!(Sentry.Event, [event_id: "test-event", timestamp: "1970-01-01T00:00:00Z"] ++ fields)
+ end
+
+ describe "scrub_params/1" do
+ test "drops PII/secret params while keeping benign ones" do
+ conn = %Plug.Conn{
+ params: %{
+ "email" => "jane@example.com",
+ "new_email" => "jane2@example.com",
+ "password" => "hunter2",
+ "current_password" => "hunter1",
+ "token" => "abc",
+ "code" => "123456",
+ "secret" => "shh",
+ "name" => "Jane"
+ }
+ }
+
+ scrubbed = SentryGlue.scrub_params(conn)
+
+ for key <- ~w(email new_email password current_password token code secret) do
+ refute Map.has_key?(scrubbed, key), "expected #{key} to be scrubbed"
+ end
+
+ assert scrubbed["name"] == "Jane"
+ end
+ end
+end