Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => ( <Foo /> )`) 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 (`<Link href>`, `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
Expand Down
26 changes: 25 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions assets/build/upload-sourcemaps.js
Original file line number Diff line number Diff line change
@@ -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();
3 changes: 3 additions & 0 deletions assets/js/app.tsx
Original file line number Diff line number Diff line change
@@ -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 <head>.
import './sentry';
import './i18n';
import { go } from '@api3/promise-utils';
import { createInertiaApp, router } from '@inertiajs/react';
Expand Down
6 changes: 4 additions & 2 deletions assets/js/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -31,9 +32,10 @@ export default class ErrorBoundary extends Component<Props, State> {
}

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() {
Expand Down
6 changes: 3 additions & 3 deletions assets/js/realtime/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
Expand Down
31 changes: 31 additions & 0 deletions assets/js/sentry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as Sentry from '@sentry/react';

// Read config the server stamped into <head> (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<HTMLMetaElement>(`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<string, unknown>): void {
if (sentryEnabled) {
Sentry.captureException(error, context ? { extra: context } : undefined);
}
}
80 changes: 54 additions & 26 deletions assets/js/ssr.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>{createElement(Component, { key: key ?? undefined, ...pageProps })}</AppProviders>
)}
</App>
),
});
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 }) => (
<App {...props}>
{({ Component, props: pageProps, key }) => (
<AppProviders>{createElement(Component, { key: key ?? undefined, ...pageProps })}</AppProviders>
)}
</App>
),
});
};

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;
}
15 changes: 14 additions & 1 deletion assets/js/types/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
Loading