Error Reporting
Send server, browser, and desktop errors to Sentry or PostHog from committed config - off by default, and off loads nothing.
config.observability wires error reporting into the Hono API, the browser, and the desktop app if you ship one. It ships off, and off loads nothing: each surface checks its provider's credential before dynamically importing an SDK. This is capture, not tracing - for per-request timing logs see performance monitoring.
Turn it on
phc_…) plus your instance host.config.observability in packages/config/src/index.ts.observability: {
sentry: { dsn: "https://examplePublicKey@o0.ingest.sentry.io/0" },
posthog: { publicKey: "phc_yourkey", host: "https://us.i.posthog.com", llmContent: false }
},Presence is the switch - no enabled flag, no provider selector. A provider reports when its credential is non-empty, and both may run at once. Keys are committed rather than env because they authorize sending only; SENTRY_DSN and POSTHOG_API_KEY override them server-side, but posthog.host does not, so an env key alone never arms PostHog.
No key, no reporting - silently. Keep the values literal: the bundler drops the SDK branch only when it sees the value inline, so a computed credential ships the SDK to every visitor.
The SDK has to be in your project. The observability manifest option decides at generation time which SDKs you get, stripping the rest with their adapters. To add one, put it in the observability array in .generatesaas/manifest.json and run generatesaas update.
Choose a provider
| Sentry | PostHog | |
|---|---|---|
| API / browser SDK | @sentry/node, @sentry/browser | posthog-node, posthog-js |
| Desktop app | @sentry/electron | No Electron SDK exists |
| Crashes outside a request | Reported | Not reported - no process handlers |
| Environment tag | development / production | None - use a separate PostHog project locally |
| LLM analytics | - | $ai_generation events |
A PostHog desktop build reports nothing - it falls back to a no-op reporter plus one startup warning. Pick Sentry if desktop or boot-time crashes matter.
What gets captured
| Surface | Where it lives | Reports |
|---|---|---|
| API | packages/api/src/observability.ts, from app.onError | Unhandled request errors, with the session user |
| Browser startup | apps/web/instrumentation-client.ts | Arms window.onerror, rejections, breadcrumbs at boot |
| Error boundaries | apps/web/lib/observability.ts | Render errors React catches |
| Desktop main | apps/desktop/src/main/observability.ts | Main-process exceptions and Electron's crash reporter |
| Desktop renderer | apps/desktop/src/renderer/lib/observability.ts | Uncaught and React boundary errors, over Sentry's IPC channel |
From your own code: await captureServerError(err, { userId }) (@repo/api/observability) on the server, captureClientError(err) (apps/web/lib/observability.ts) in the browser. Both no-op when off, and neither needs a flush.
404s and 403s are never reported. isReportableError (@repo/observability) drops anything with a status below 500 and always reports status-less errors. It sits inside captureClientError, so your own calls inherit it.
A visitor who declines cookies still has their errors reported, with their user id when signed in - capture never consults the consent store. Analytics is untouched. Gate it yourself by returning early from the browser wrapper.
LLM analytics (PostHog only)
With a PostHog key and AI enabled, every model call is recorded as a $ai_generation. Built-in and BYOK runs both report; BYOK sends tokens and latency but no cost, since that is the user's own provider bill.
| Property | Value |
|---|---|
$ai_trace_id | the run id credit settlements are keyed by |
$ai_model, $ai_provider | the model that ran, after any fallback |
$ai_input_tokens, $ai_output_tokens | the usage the provider reported |
$ai_input_cost_usd, $ai_output_cost_usd, $ai_total_cost_usd | LIVE rates credits are debited from, not estimates |
$ai_latency | wall-clock seconds around the call |
$ai_is_error, $ai_error | set when the call failed |
$ai_input, $ai_output_choices | prompt and completion, only when llmContent: true - files, tool calls and tool results become placeholders, and reasoning is dropped |
Prompts and completions stay put unless you opt in. llmContent is false until you set it to true, so nothing your users type reaches PostHog by default. Opting in sends their message text verbatim to a third party - check it against your privacy policy first. Every number is captured either way.
What this does not include
Capture only. Each gap is a deliberate opt-in:
- Automatic tracing - needs
init()before every other module loads. Move it toinstrument.mjs:node --import ./instrument.mjs .... - Source-map upload - traces arrive minified. Add the Sentry bundler plugin (or
posthog-cli) plusSENTRY_AUTH_TOKENin CI. - The framework SDK - this is vanilla
@sentry/browser+@sentry/node. Run the@sentry/nextjswizard, then dropinitBrowserReportingfrominstrumentation-client.ts. Replace the body oflib/observability.tsrather than deleting it (the boundaries import from it), keeping itsisReportableErrorline. - Boot crashes on
fullstack- the frontend loads@repo/apion the first request, so addinitServerReporting()to the existingregister()inapps/web/instrumentation.ts; never replace that file.separatearms at process start already.
Sentry adds up to 2 seconds to a 500 response - flush(2000) is awaited before the response goes out, because serverless freezes the function the instant it returns.
Performance Monitoring
Log every API request's method, path, status, duration, and cache outcome via a Hono timing middleware, gated by config.performanceMonitor.
Internationalization
Translate the app from one shared @repo/i18n message store consumed by next-intl, with locale-prefixed routing and an auto-generated translation pipeline.