From cca3b380f0eab468df92f2f2a88c642927729d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 08:35:21 +0000 Subject: [PATCH] docs(console): rewrite error-tracking guide for the two-key telemetry gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `apps/console/docs/error-tracking.md` predated the #5522 fix chain and every load-bearing instruction in it contradicted the shipped design. Followed literally it told a reader to `pnpm add @sentry/react`, hand-roll `src/lib/sentry.ts`, and call it before React renders — bolting a second, ungated Sentry init onto an app that already has a gated one, which is exactly the "decision frozen at build time, no operator switch" shape #5522 removed. Rewritten to describe the system that exists: - Sentry is built in; the install + hand-rolled-init recipe is gone. - Enabling is the CONJUNCTION of two independent opt-ins: build-time `VITE_SENTRY_DSN` (plus the optional knobs mirrored from the authoritative comment block in `apps/console/.env.production`) AND the runtime permission `telemetry.allowClientErrorReporting`, granted server-side with `OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED` or `new RuntimeConfigPlugin({ allowClientErrorReporting: true })`. - The fail-closed contract stated once: either half missing => no reporting, silently, by design; every "cannot determine" state reads denied. - Ordering documented for host embedders: `initSentry()` must run after `initRuntimeConfig()` settles, because it memoizes its verdict. - "Option 2: Custom Error Boundary" removed. It was a second ungated exfiltration path presented as an alternative; replaced by the built-in `captureError()` / `setSentryUser()`, which route through the same gate, plus a pointer to the app-shell `ErrorBoundary` that already calls them. - Verification steps now check the half an operator can inspect from outside: `curl /api/v1/runtime/config | jq .telemetry`, then the vendor-sentry chunk, then a thrown test error. Two corrections beyond the card's table, both verified against the tree: - The CSP section was fabricated. It claimed the console "includes a Content Security Policy meta tag" whose default "already includes https://*.sentry.io in connect-src". `apps/console/index.html` sets no CSP meta tag and the doc was the only file in the repo mentioning CSP at all. Rewritten to say the console ships no CSP, and to give the connect-src a *hosting layer* would need. - `VITE_ENVIRONMENT` and `VITE_ERROR_ENDPOINT` had zero read sites anywhere in the tree; both are gone. Every env var the doc now names greps to a real read site (11/11 verified). The source-maps CI note is kept — `sourcemap: false` in `apps/console/vite.config.ts` is still accurate — with the release pinned to `VITE_SENTRY_RELEASE` so uploaded maps match the events. --- apps/console/docs/error-tracking.md | 287 +++++++++++++++------------- 1 file changed, 156 insertions(+), 131 deletions(-) diff --git a/apps/console/docs/error-tracking.md b/apps/console/docs/error-tracking.md index ffde6f0c6b..b7ee1dedd2 100644 --- a/apps/console/docs/error-tracking.md +++ b/apps/console/docs/error-tracking.md @@ -1,164 +1,189 @@ -# Error Tracking Integration Guide +# Error Tracking (Console) -This guide explains how to integrate error tracking (Sentry or equivalent) into the ObjectUI Console for production deployments. +Sentry error reporting is **already built into the Console**. There is nothing to +install and no init code to write — this guide is about *turning it on*, which takes +two independent opt-ins, one at build time and one at runtime. -## Option 1: Sentry (Recommended) +> ⛔ **Do not add your own `Sentry.init()` / `src/lib/sentry.ts` to the Console.** A +> second init is not gated by anything below, so it would report regardless of what the +> deployment permits — rebuilding the exact "decision frozen at build time, no operator +> switch" shape that objectui#5522 existed to remove. The integration below is the +> supported path; extend it, don't duplicate it. -### Installation +## The gate: reporting needs BOTH halves -```bash -pnpm add @sentry/react --filter @object-ui/console +``` +send ⇔ a DSN was injected at BUILD time ∧ the RUNTIME granted permission ``` -### Configuration - -Create `src/lib/sentry.ts`: - -```typescript -import * as Sentry from '@sentry/react'; - -export function initErrorTracking() { - if (import.meta.env.PROD) { - Sentry.init({ - dsn: import.meta.env.VITE_SENTRY_DSN, - environment: import.meta.env.VITE_ENVIRONMENT || 'production', - release: import.meta.env.VITE_APP_VERSION || '1.0.0', - - // Performance monitoring - tracesSampleRate: 0.1, // 10% of transactions - replaysSessionSampleRate: 0.01, // 1% of sessions - replaysOnErrorSampleRate: 1.0, // 100% of error sessions - - // Filter out noise - ignoreErrors: [ - 'ResizeObserver loop limit exceeded', - 'ResizeObserver loop completed with undelivered notifications', - 'Non-Error promise rejection captured', - ], - - // Scrub sensitive data - beforeSend(event) { - if (event.request?.headers) { - delete event.request.headers['Authorization']; - } - return event; - }, - }); - } -} +Both are opt-in, and **either one alone denies**. This is what lets one artifact serve +every posture: `@object-ui/console` publishes a single pre-built SPA that the hosted SaaS +console and the on-premises / air-gapped EE images all embed, so the bundle cannot tell +those deployments apart — only the server can. + +The decision lives in one place, `resolveSentryGate()` in +`packages/app-shell/src/observability/sentry.ts`, and is pinned case by case in +`sentry.test.ts`. + +### Half 1 — build time (the build environment of the Console) + +Set these in your **deploy environment** (hosting panel / CI), the same way you already +inject `VITE_SERVER_URL`. The authoritative list is the comment block in +`apps/console/.env.production` — mirror it, don't invent knobs. + +| Variable | Effect | +|:--|:--| +| `VITE_SENTRY_DSN` | **Required.** Presence *is* the build-time opt-in — there is no separate "enable" flag. Absent ⇒ `initSentry()` returns `false` and `@sentry/react` is never even imported, so the vendor-sentry chunk is never fetched. | +| `VITE_SENTRY_SEND_DEFAULT_PII` | `=true` opts in to sending **IP address + User-Agent**. Off by default: one artifact serves both SaaS and on-prem, so PII collection must be the deliberate choice of the build that wants it. | +| `VITE_SENTRY_ENABLED` | `=false` force-disables reporting even when a DSN was injected — for a pipeline that keeps the DSN in its environment but wants reporting stopped. | +| `VITE_SENTRY_ENVIRONMENT` | Defaults to Vite's `MODE`. | +| `VITE_SENTRY_RELEASE` | Defaults to `VITE_APP_VERSION`, then `unknown`. CI typically injects the commit SHA. | +| `VITE_SENTRY_TRACES_SAMPLE_RATE` | Defaults to `0.1`. | +| `VITE_SENTRY_REPLAY` | `=true` records 10% of **error** sessions. Session replay is otherwise off. | + +⛔ **Never commit a DSN** — not to `.env.production`, not as an "example". Vite inlines +every `VITE_*` from a committed `.env` file into the published bundle as a frozen object +literal, so a committed DSN is a live third-party endpoint compiled into an artifact that +lands inside customer networks, and it cannot be switched off afterwards (the +`VITE_SENTRY_ENABLED` kill switch is read off that same frozen literal). That is not +hypothetical: an air-gapped EE deployment was measured sending 14 Sentry envelopes per +session carrying IP + User-Agent PII, with no way for the customer to stop it +(objectstack-ai/cloud#1508, objectui#5522). The ratchet +`packages/app-shell/src/observability/committed-telemetry-endpoint.test.ts` fails CI if +any committed `.env*` file carries a telemetry endpoint or turns PII on by default. + +A DSN looks like `https://your-key@your-org.ingest.sentry.io/your-project-id`. + +### Half 2 — runtime (each production server) + +The server grants permission through `telemetry.allowClientErrorReporting` on +`GET /api/v1/runtime/config`. Set it on the **ObjectStack runtime**, not the Console +build: + +```bash +OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true ``` -### Integration in `main.tsx` - -```typescript -import { initErrorTracking } from './lib/sentry'; - -// Initialize error tracking before React renders -initErrorTracking(); - -// Wrap your app with Sentry error boundary -import * as Sentry from '@sentry/react'; - -const SentryErrorBoundary = Sentry.withErrorBoundary(App, { - fallback: ({ error }) => ( -
-
-

Something went wrong

-

{error?.message}

- -
-
- ), -}); +…or, from a host that composes the plugin directly: + +```ts +new RuntimeConfigPlugin({ allowClientErrorReporting: true }) ``` -### Environment Variables +The explicit plugin option wins over the environment variable. The switch answers to a +**closed** vocabulary — `1` / `true` / `on` / `yes` grant, `0` / `false` / `off` / `no` +deny — and an **unrecognised spelling is refused, never coerced**: the permission stays +denied and `RuntimeConfigPlugin` names the rejected value in a warning at mount time. So +`=enable` or `=Y` does not quietly half-work; check your server's startup log if +reporting stays silent. + +⚠️ **`OS_CLOUD_URL=off` overrules a grant.** A runtime that declared its control plane +off has declined outbound calls, and this one with it — the permission is lowered to +`false` and the refusal is warned about at mount. This is the copied-env-file shape +cloud#1508 reported: a hosted configuration landing on an air-gapped box. + +Canonical row for this variable: +[Environment Variables → Observability](https://objectstack.ai/docs/deployment/environment-variables) +(`content/docs/deployment/environment-variables.mdx` in the objectstack repo). + +## Fail-closed contract + +**Either half missing ⇒ no reporting, silently, by design.** Every "cannot determine the +answer" state — the config fetch failed, the endpoint 404s, the runtime predates the key, +a third-party host, the config has not arrived yet — reads as **denied**. An unreported +error is recoverable; PII leaving an air-gapped deployment is not. Silence is therefore +the *correct* behaviour, not a bug to work around: if you want reporting, supply both +halves rather than loosening the gate. + +Note the direction differs from `isMarketplaceEnabled()` / `isAiStudioEnabled()`, which +fail **open**. Do not "make it consistent" with them. + +### Ordering, if you embed app-shell in your own host + +`initSentry()` must run **after** `initRuntimeConfig()` has settled — the runtime +permission is a server-pushed value that reads denied until the payload arrives, and +`initSentry()` memoizes its verdict on first call. Calling it at module-eval time freezes +`denied` for the whole session, turning the operator switch into a permanent removal. +`apps/console/src/main.tsx` does this correctly: `initSentry()` is kicked off inside +`.finally()` on the boot `Promise.all`, so a failed config fetch still never blocks first +paint (and on that path the permission is denied, so the failure direction is silence). + +## Reporting errors from your own code -Add to your deployment environment: +Use the built-in helpers from `@object-ui/app-shell` — they route through the same gate +and no-op when it denied, so they cannot become a second ungated path: -```env -VITE_SENTRY_DSN=https://your-key@sentry.io/your-project-id -VITE_ENVIRONMENT=production -VITE_APP_VERSION=1.0.0 +```ts +import { captureError, setSentryUser } from '@object-ui/app-shell'; + +captureError(err, { where: 'record-save' }); // no-op unless the gate passed +setSentryUser({ id: user.id }); // pass null on logout ``` -### Source Maps (Optional) +`packages/app-shell/src/chrome/ErrorBoundary.tsx` already calls `captureError()` with the +React component stack, so uncaught render errors are covered without any wiring. + +## Verifying a deployment + +1. **Check the runtime half** — it is the half you can inspect from outside: + + ```bash + curl -s https://your-deployment.example.com/api/v1/runtime/config | jq .telemetry + # → { "allowClientErrorReporting": true } + ``` + + `false` (or an absent `telemetry` block) means the server is denying; fix that before + looking at the build. + +2. **Check the build half** — in the browser devtools **Network** tab, confirm the + `vendor-sentry` chunk is fetched on load. If it never appears, the bundle carries no + DSN (or `VITE_SENTRY_ENABLED=false`), and no runtime grant can rescue it: the server + supplies a *permission*, never a source. -For readable stack traces in production, upload source maps during CI: +3. **Trigger a test error** — in the browser console, `throw new Error('Test error')`. + +4. **Confirm it lands** in your Sentry project, tagged with the expected `environment` + and `release`. + +## Source maps + +The Console build sets `sourcemap: false` (`apps/console/vite.config.ts`). For readable +stack traces, enable source maps in CI only, upload them, then discard them rather than +publishing them with the bundle: ```yaml -# In your CI/CD pipeline - name: Upload Source Maps run: | npx @sentry/cli sourcemaps upload \ --auth-token $SENTRY_AUTH_TOKEN \ --org your-org \ --project objectui-console \ - --release $APP_VERSION \ + --release $VITE_SENTRY_RELEASE \ apps/console/dist/assets/ ``` -> **Note:** The console build has `sourcemap: false` by default. To generate source maps for Sentry only, temporarily enable them in CI and upload before deleting. - -## Option 2: Custom Error Boundary - -If you prefer a lightweight solution without a third-party service, use React's built-in error boundary with a custom reporter: - -```typescript -// src/lib/error-reporter.ts -export function reportError(error: Error, context?: Record) { - const payload = { - message: error.message, - stack: error.stack, - context, - timestamp: new Date().toISOString(), - url: window.location.href, - userAgent: navigator.userAgent, - }; - - // Send to your error tracking endpoint - if (import.meta.env.VITE_ERROR_ENDPOINT) { - fetch(import.meta.env.VITE_ERROR_ENDPOINT, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).catch(() => { - // Silently fail — don't create error loops - }); - } -} - -// Catch unhandled errors -window.addEventListener('error', (event) => { - reportError(event.error || new Error(event.message)); -}); - -window.addEventListener('unhandledrejection', (event) => { - reportError( - event.reason instanceof Error - ? event.reason - : new Error(String(event.reason)) - ); -}); -``` +Keep `--release` identical to the `VITE_SENTRY_RELEASE` the bundle was built with, or the +uploaded maps will not match the events. + +## Content Security Policy -## CSP Compatibility +The Console ships **no CSP meta tag** today — `apps/console/index.html` sets none, and the +repo defines no default policy. Nothing in the Console needs relaxing for Sentry out of +the box. -The console includes a Content Security Policy (CSP) meta tag. If your error tracking service requires additional domains, update the CSP in `index.html`: +If **your hosting layer** serves CSP headers (many do), Sentry's ingest endpoint has to be +reachable or events are dropped silently by the browser: -```html - - +``` +connect-src 'self' https://*.ingest.sentry.io; ``` -The default CSP already includes `https://*.sentry.io` in the `connect-src` directive. +Match the origin to your own DSN — `*.ingest.sentry.io` for current Sentry SaaS projects, +your own host for a self-hosted Sentry. -## Verifying the Integration +## Related -1. **Build the console:** `pnpm --filter @object-ui/console build` -2. **Preview:** `pnpm --filter @object-ui/console preview` -3. **Trigger a test error:** Open the browser console and run `throw new Error('Test error')` -4. **Check your dashboard:** Verify the error appears in Sentry / your tracking endpoint +- `packages/app-shell/src/observability/sentry.ts` — the gate and its rationale +- `packages/app-shell/src/runtime-config.ts` — `isClientErrorReportingAllowed()` +- `apps/console/.env.production` — authoritative build-time variable list +- objectui#5522 · objectstack#10805 · objectstack-ai/cloud#1508