From 765d224a2b65e97b7e6c88dc86929bcbe0d7b070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ogle?= Date: Fri, 19 Jun 2026 13:23:44 -0500 Subject: [PATCH 1/2] Add Sentry error monitoring (backend, frontend, SSR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors-only Sentry integration, opt-in via env vars — inert without a DSN, so dev/test/local report nothing. - Backend: sentry + hackney deps, Sentry.PlugCapture/PlugContext, and the Sentry.LoggerHandler (captures Bandit request crashes + Oban job failures). ElixirReactStarter.Sentry enforces the no-PII rule via before_send and a PlugContext body scrubber (unit-tested). - Frontend: @sentry/react, init from a nonce-safe meta tag (works under the strict CSP); ErrorBoundary reports caught render errors. - SSR: @sentry/node in the Inertia SSR workers, reporting render failures before re-raising so Inertia's fallback is unchanged. - Source maps: external maps in assets.deploy, uploaded via @sentry/cli (build/upload-sourcemaps.js, best-effort) then deleted before phx.digest; backend source context packaged in the Dockerfile. - SENTRY_RELEASE defaults to RENDER_GIT_COMMIT; environment from DEPLOY_ENV. Also document two TS style conventions in CLAUDE.md (extract multi-line go()/goSync() callbacks; multi-line bodies use braces + return) and apply them across assets/js, and exclude the routes.gen mix task from coverage (pure tooling, exercised by precommit) like the other custom tasks. --- CLAUDE.md | 2 + Dockerfile | 26 +- assets/build/upload-sourcemaps.js | 77 +++ assets/js/app.tsx | 3 + assets/js/components/ErrorBoundary.tsx | 6 +- assets/js/realtime/provider.tsx | 6 +- assets/js/sentry.ts | 31 + assets/js/ssr.tsx | 80 ++- assets/js/types/env.d.ts | 15 +- assets/package-lock.json | 589 ++++++++++++++++++ assets/package.json | 3 + config/config.exs | 27 + config/runtime.exs | 29 + lib/elixir_react_starter/application.ex | 6 + lib/elixir_react_starter/sentry.ex | 43 ++ .../components/layouts/root.html.heex | 8 + lib/elixir_react_starter_web/endpoint.ex | 10 + .../plugs/shared_data.ex | 7 + mix.exs | 9 +- mix.lock | 2 + test/elixir_react_starter/sentry_test.exs | 58 ++ 21 files changed, 1003 insertions(+), 34 deletions(-) create mode 100644 assets/build/upload-sourcemaps.js create mode 100644 assets/js/sentry.ts create mode 100644 lib/elixir_react_starter/sentry.ex create mode 100644 test/elixir_react_starter/sentry_test.exs 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..829c257 100644 --- a/lib/elixir_react_starter/application.ex +++ b/lib/elixir_react_starter/application.ex @@ -7,6 +7,12 @@ 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. + 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 From be358738bc80b94aebad79a8bb64695511643aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Ogle?= Date: Fri, 19 Jun 2026 13:36:00 -0500 Subject: [PATCH 2/2] Match Logger.add_handlers return to satisfy dialyzer unmatched_return: Logger.add_handlers/1 returns :ok | {:error, _}. The :logger handler config is static (config/config.exs) so it always returns :ok; match it explicitly to document that and fail loudly on a genuine misconfig. --- lib/elixir_react_starter/application.ex | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/elixir_react_starter/application.ex b/lib/elixir_react_starter/application.ex index 829c257..7336342 100644 --- a/lib/elixir_react_starter/application.ex +++ b/lib/elixir_react_starter/application.ex @@ -10,8 +10,10 @@ defmodule ElixirReactStarter.Application 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. - Logger.add_handlers(:elixir_react_starter) + # 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,