GenerateSaaS

Error Reporting

Send server, browser, and desktop errors to Sentry from one config flag - off by default, and off costs nothing.

config.observability.sentry wires Sentry error reporting into every surface of your project: the Hono API, the browser, and the desktop app if you ship one. It ships off, and off costs nothing at runtime - every surface checks the flag before it dynamically imports the SDK, so a disabled project sends no Sentry bytes to the browser and never loads the module on the server.

This is error reporting, not tracing. For per-request timing logs written to your own stdout, see performance monitoring - the two are independent and can run together.

Turn it on

  1. Create a project in Sentry and copy its DSN (Settings -> Projects -> Client Keys).
  2. Edit packages/config/src/observability.mjs:
packages/config/src/observability.mjs
export const observabilityConfig = {
  sentry: {
    enabled: true,
    dsn: "https://examplePublicKey@o0.ingest.sentry.io/0"
  }
};
  1. Rebuild and restart. There is no env var to set, no build flag to pass, and no CLI option to re-run.

Both fields are required, and a half-configured project is completely silent. Every surface reports only when enabled is true and dsn is non-empty. enabled: true with an empty DSN prints no warning anywhere - it simply reports nothing, because a client initialized without a DSN discards every event. If you turned it on and see no issues in Sentry, check the DSN first.

The DSN is public by design, not a leaked secret

A Sentry DSN authorizes sending events into your project. It grants no read access to your issues, your org, or anything else, and Sentry's own browser and desktop SDKs are built to ship it inside the client - it is visible in the page source of every site that uses them.

That is why it lives in committed config instead of an env var. observability.mjs is plain JavaScript with no env reads, which is what lets the desktop main process import it directly: a packaged desktop app has no env to read at all.

The CLI writes this file fresh into every generated project with reporting off, so it never arrives carrying a value from somewhere else.

What gets captured

SurfaceWhere it livesWhat it reports
APIpackages/api/src/observability.ts, called from app.onError in packages/api/src/index.tsEvery unhandled error thrown while serving a request, on both the fullstack and separate-backend architectures
Browserapps/web-nuxt/app/plugins/sentry.client.tswindow.onerror, unhandled promise rejections, and the breadcrumbs leading up to them
Component errorsthe vue:error hook in the same pluginRender, lifecycle, and watcher errors, which Vue routes into its own handler where window.onerror never sees them
Desktop mainapps/electron/src/main/observability.tsMain-process exceptions and unhandled rejections; init() also arms Electron's native crash reporter
Desktop rendererapps/electron/src/renderer/lib/observability.tsUncaught renderer errors and unhandled promise rejections, routed to the main process over Sentry's own IPC channel - the renderer needs no DSN of its own
Desktop React errorsapps/electron/src/renderer/lib/observability.ts, used by src/renderer/components/error-boundary.tsx and the router's defaultOnCatchRender errors the provider boundary and the router catch, which React reports to the console only - never as a global error event

On the browser and in the desktop app, Sentry.init() runs at startup, not on the first caught error. That is deliberate: init() is what installs the window.onerror and unhandledrejection handlers and starts recording breadcrumbs, so deferring it would capture only the errors the framework hands you, with no breadcrumb trail and no coverage of plain async failures.

404s and 403s are never reported. The same error surface renders your not-found and forbidden pages, so an ungated capture would file a Sentry issue for ordinary missing-page traffic and burn your quota on day one. Only genuine server failures are sent, plus every status-less error - those are the unexpected ones.

Every event carries an environment tag (development or production), so local errors are filterable in Sentry rather than indistinguishable from live ones.

Pointing the backend at a different project

The server, and only the server, reads an optional SENTRY_DSN env var that overrides the committed DSN:

.env
SENTRY_DSN="https://examplePublicKey@o0.ingest.sentry.io/1"

The browser and the packaged desktop app have no env to read, so they always use the committed value.

SENTRY_DSN can redirect reporting; it cannot disable it. The resolution is env.SENTRY_DSN || sentry.dsn - || rather than ??, deliberately: a .env line of SENTRY_DSN= parses as an empty string, and under ?? that empty string would beat a perfectly good committed DSN and silently kill reporting. The trade-off is real: if you run staging and production from one committed config, blanking the env var will not silence staging. Point that deployment at a throwaway Sentry project instead, or ship it a config with enabled: false.

What this does not include

Reporting here is deliberately capture-only. Each gap below is real, and each has a concrete upgrade path.

Automatic tracing and performance instrumentation

Not enabled. Sentry's auto-instrumentation requires Sentry.init() to run before every other module loads, which is exactly what the lazy, config-gated import rules out - and that lazy import is what keeps a disabled project free. The SDK is started with registerEsmLoaderHooks: false to match.

You still get stack traces, breadcrumbs, tags, user context, release info, and manual spans. What you do not get is automatic HTTP, database, and framework spans.

To add it: move Sentry.init() into its own instrument.mjs and load it first from your start script - node --import ./instrument.mjs ..., or NODE_OPTIONS="--import ./instrument.mjs". That is a start-script change per deployment target, which is why it is a deliberate opt-in rather than a config toggle.

Source maps, so production stack traces are minified

Not uploaded. Your production bundles are minified and Sentry has nothing to unminify them against, so browser stack traces arrive as chunk-a1b2c3.js:1:48210 instead of your file and line number. Server traces are unaffected on non-bundled deployments.

To fix it: add the Sentry bundler plugin to your build (@sentry/vite-plugin, or the upload @sentry/nuxt configures for you), turn on hidden source maps, and set SENTRY_AUTH_TOKEN plus your org and project slugs in CI. sentry-cli sourcemaps upload does the same job as a standalone CI step. It needs build-time credentials and a bundler change, which is why it is not a config flag.

The framework SDK

This is vanilla @sentry/browser and @sentry/node, not @sentry/nuxt. The framework SDK wraps your build config and injects its own instrumentation, which a runtime config flag cannot do reversibly.

To upgrade: install @sentry/nuxt and run its wizard, then delete apps/web-nuxt/app/plugins/sentry.client.ts so the two do not both initialize a client.

Delete apps/web-nuxt/tests/plugins/sentry-client.test.ts in the same change. It imports the plugin, so leaving it behind breaks pnpm test at collection - before a single assertion runs.

Server crashes before the first request error

Server capture is wired into the Hono app.onError, which covers every request-scoped error on both architectures. That is also what starts the SDK: Sentry.init() runs lazily, on the first error onError sees, because the module is imported only once reporting is known to be on.

That leaves a real window. @sentry/node registers uncaughtException and unhandledRejection handlers itself, as default integrations - but only from the moment init() runs. A crash outside a request, in a process that has not yet served a failing one, reaches your host's logs and never reaches Sentry. Boot-time failures are the common case.

Nothing in the boilerplate registers those handlers on its own, deliberately: a handler that suppresses Node's default print-and-exit leaves the process alive in an undefined state, which is a resilience decision an error-reporting flag should not make for you. Sentry's own integration does not suppress the exit.

The browser and the desktop main process have no such window - init() runs at startup on both, so their handlers are armed from the first line of your code.

To close it: initialize eagerly at boot. Call Sentry.init() behind the same config gate from whichever process actually serves your API, and the default integrations cover it from startup. Which file that is depends on your architecture:

  • separate - apps/backend/src/index.ts, which already handles SIGINT/SIGTERM.
  • fullstack (the default) - your frontend serves @repo/api in-process, and apps/backend ships dormant, excluded from pnpm dev as a second copy of the same API. Initializing there would cover a process you never start. Add a Nitro server plugin instead - every module in server/plugins/ runs once when the server starts:
apps/web-nuxt/server/plugins/sentry.ts
import { config } from "@repo/config";

export default defineNitroPlugin(async () => {
  const sentry = config.observability?.sentry;
  if (!sentry?.enabled) return;
  const dsn = process.env.SENTRY_DSN || sentry.dsn;
  if (!dsn) return;
  // Kept dynamic on purpose: a disabled project never loads the SDK.
  const Sentry = await import("@sentry/node");
  Sentry.init({ dsn, environment: process.env.NODE_ENV, registerEsmLoaderHooks: false });
});

It stays free when reporting is off; the only reason it is not the default is that every surface here uses one gate shape, imported on demand.

What it costs

StateCost
OffNothing at runtime. No Sentry bytes in the browser bundle, no server module load, one boolean check per error.
OnUp to 2 seconds added to a 500 response.

That 2 seconds is Sentry.flush(2000), awaited before the 500 goes out, and it is the deliberate trade that makes serverless capture work at all: a serverless platform freezes the function the instant its response returns, so an unflushed event is dropped and the error you most wanted to see never arrives. It only applies to responses that are already failing.

The one honest exception to "off costs nothing": @sentry/node is a plain dependency of @repo/api, and serverless file tracing follows dynamic imports, so a serverless deployment artifact carries the SDK even with reporting off. A packaged desktop app likewise carries the main-process SDK chunk either way (its renderer bundle drops it entirely when off). Nothing loads or executes - it is bytes at rest, not runtime cost.

Frequently asked questions

Can I report an error from my own code? Yes. On the server, import { captureServerError } from "@repo/api/observability" and await captureServerError(err). In the browser the plugin has already initialized the client, so const Sentry = await import("@sentry/browser") then Sentry.captureException(err) reuses it - keep the config.observability?.sentry?.enabled check around that import, or a disabled project pulls the SDK into its bundle.

Do I have to redeploy to turn it off? Yes. The flag is committed config, compiled into the client bundles, so flipping it is a code change plus a deploy - not a dashboard switch.

Does it report errors in development too? Yes, whenever it is enabled. Events are tagged environment: development, so filter or discard them in Sentry, or keep enabled: false in your local checkout.

Does the desktop app need its own Sentry project? No. It uses the same DSN, and the renderer needs no DSN at all - the SDK routes renderer events through the main process. The strict renderer CSP needs no entry either, because that routing uses a privileged internal protocol rather than a network request.

Why is config.observability separate from config.performanceMonitor? They solve different problems. performanceMonitor logs one timing line per request to your own stdout; error reporting ships exceptions to a third party. Neither implies the other.

On this page