Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
287 changes: 156 additions & 131 deletions apps/console/docs/error-tracking.md
Original file line numberDiff line numberDiff line change
@@ -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 }) => (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground mt-2">{error?.message}</p>
<button onClick={() => window.location.reload()} className="mt-4">
Reload Page
</button>
</div>
</div>
),
});
…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<string, unknown>) {
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
<!-- Add your error tracking domain to connect-src -->
<meta http-equiv="Content-Security-Policy"
content="... connect-src 'self' https://*.sentry.io ...;" />
```
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