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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/5522-runtime-telemetry-consumer.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
---
'@object-ui/app-shell': minor
'@object-ui/console': minor
---

Console telemetry can now be hard-disabled on an already-built artifact

`/api/v1/runtime/config` gained `telemetry.allowClientErrorReporting`
(objectstack#11382), and the Console now reads it. The Sentry decision becomes a
conjunction of two independent grants — a DSN injected at **build** time AND a
positive permission from the **runtime** — so the single pre-built SPA that both
the hosted SaaS console and the on-premises / air-gapped EE images embed can be
silenced by the deployment it lands in, with no rebuild and without editing files
inside a published bundle. That was the half objectui#5522 could not close before:
every other input to the gate is a Vite build-time variable frozen into the bundle
as a literal, which is how an air-gapped EE Console came to send 14 Sentry
envelopes per session to `sentry.io` carrying IP + User-Agent PII with no way for
the customer to turn it off (objectstack-ai/cloud#1508).

The permission fails **closed** in every direction: absent key, `telemetry` block
absent, malformed payload, failed fetch, or a runtime predating the key all read as
*do not send* — which is precisely the set of runtimes leaking today. It is a
permission and never a source: the server supplies no DSN and cannot turn telemetry
on for a build that carries none. Only a real boolean `true` grants; `'true'`, `1`
and other truthy lookalikes do not.

Behaviour change for deployments that already inject a DSN: reporting now also
requires the runtime to grant permission, via
`OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED` (or `RuntimeConfigPlugin`'s
`allowClientErrorReporting`). A build that opted in but whose runtime says nothing
will go quiet — deliberately, since that is the same artifact an air-gapped
customer runs.

`@object-ui/app-shell` additionally exports `isClientErrorReportingAllowed()` and
the `RuntimeTelemetry` type, so consumers read the permission through the one
fail-closed accessor instead of writing their own optional-chain against the
payload.
19 changes: 15 additions & 4 deletions apps/console/src/main.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,10 +19,6 @@ import { preflightAuth } from './lib/auth-preflight';

const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;

// Kick off Sentry init in the background (no-op if VITE_SENTRY_DSN is unset).
// Not awaited — observability must never block first paint.
void initSentry();

// ────────────────────────────────────────────────────────────────────────────
// Plugin registration
// ────────────────────────────────────────────────────────────────────────────
Expand DownExpand Up@@ -79,6 +75,21 @@ Promise.all([
preflightAuth(AUTH_URL),
seedTenantLanguage(SERVER_BASE),
]).finally(() => {
// Kick off Sentry init (no-op unless a DSN was injected at build time AND
// this runtime granted `telemetry.allowClientErrorReporting`). Still not
// awaited — observability must never block first paint.
//
// ⛔ Ordering is load-bearing, not stylistic: this call used to run at
// module-eval time, BEFORE `initRuntimeConfig()` was even started. The
// server-pushed telemetry permission fails closed, so from there it would
// read DENIED on every boot and memoize that verdict — turning the switch
// objectui#5522 asked for into a permanent removal, silently, including for
// the hosted console. Reading a server value requires waiting for the
// server. `.finally()` (not `.then()`) keeps the pre-existing guarantee that
// a failed config fetch never blocks boot — and on that path the permission
// is denied, so the failure direction is silence.
void initSentry();

// Apply runtime branding before React mounts — avoids a flash of the
// static defaults for operators who configure OS_PRODUCT_NAME etc.
document.title = getProductName();
Expand Down
6 changes: 5 additions & 1 deletion packages/app-shell/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -146,9 +146,13 @@ export {
getPwaDescription,
getPwaThemeColor,
isRuntimeConfigInitialised,
// The fail-closed reading of the runtime's client-telemetry permission.
// Exported so no consumer has to write its own `?.` chain against the
// payload — one `!== false` dialect is all it takes to re-open objectui#5522.
isClientErrorReportingAllowed,
resetRuntimeConfigForTesting,
} from './runtime-config.js';
export type { AppShellRuntimeConfig, RuntimeFeatures, RuntimeBranding, PlatformStage } from './runtime-config.js';
export type { AppShellRuntimeConfig, RuntimeFeatures, RuntimeBranding, RuntimeTelemetry, PlatformStage } from './runtime-config.js';

// Standard inner-SPA views
export {
Expand Down
149 changes: 118 additions & 31 deletions packages/app-shell/src/observability/sentry.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,13 +26,27 @@
* covered below for the posture that ships by default, and the enabled path
* is covered end-to-end by the production-build counter-probe recorded on the
* pull request.
*
* The gate now takes TWO grants — a build-time DSN and the runtime's
* `telemetry.allowClientErrorReporting` permission — so each half is pinned
* against a GRANTING counterpart, never against a second denial. Testing "no
* DSN and no permission ⇒ silence" would prove nothing about either.
*/

import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { resolveSentryGate } from './sentry';
import { isClientErrorReportingAllowed, resetRuntimeConfigForTesting } from '../runtime-config.js';

const DSN = 'https://examplePublicKey@o0.ingest.sentry.io/0';

/**
* The runtime's answer, named rather than spelled `true`/`false` at 30 call
* sites: `resolveSentryGate(env, false)` reads as "some boolean", while
* `RUNTIME_GRANTS` states which of the two grants a case is holding fixed.
*/
const RUNTIME_GRANTS = true;
const RUNTIME_DENIES = false;

const sentryMock = vi.hoisted(() => ({
init: vi.fn(),
captureException: vi.fn(),
Expand All@@ -42,30 +56,36 @@ const sentryMock = vi.hoisted(() => ({

vi.mock('@sentry/react', () => sentryMock);

describe('resolveSentryGate — fails closed when the opt-in signal is absent', () => {
describe('resolveSentryGate — fails closed when the build-time opt-in is absent', () => {
// Every case here holds the RUNTIME grant fixed at "allowed", so a denial can
// only be coming from the build-time half. Pairing two denials would let
// either one carry the result while the other rotted.

it('withholds reporting when no DSN was injected', () => {
expect(resolveSentryGate({})).toMatchObject({ enabled: false, reason: 'no-dsn' });
expect(resolveSentryGate({}, RUNTIME_GRANTS)).toMatchObject({ enabled: false, reason: 'no-dsn' });
});

it('withholds reporting when the env object itself is missing', () => {
// `(import.meta as any).env` can legitimately be undefined outside Vite.
// "Cannot determine the signal" must land on silence, not on send.
expect(resolveSentryGate(undefined)).toMatchObject({ enabled: false, reason: 'no-dsn' });
expect(resolveSentryGate(null)).toMatchObject({ enabled: false, reason: 'no-dsn' });
expect(resolveSentryGate(undefined, RUNTIME_GRANTS)).toMatchObject({ enabled: false, reason: 'no-dsn' });
expect(resolveSentryGate(null, RUNTIME_GRANTS)).toMatchObject({ enabled: false, reason: 'no-dsn' });
});

it('treats an empty or whitespace-only DSN as absent', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: '' })).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: ' ' })).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: '' }, RUNTIME_GRANTS)).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: ' ' }, RUNTIME_GRANTS)).toMatchObject({ enabled: false });
});

it('treats a non-string DSN as absent rather than coercing it', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: true })).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: 1 })).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: true }, RUNTIME_GRANTS)).toMatchObject({ enabled: false });
expect(resolveSentryGate({ VITE_SENTRY_DSN: 1 }, RUNTIME_GRANTS)).toMatchObject({ enabled: false });
});

it('honours the explicit force-off even when a DSN was injected', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: 'false' })).toMatchObject({
expect(
resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: 'false' }, RUNTIME_GRANTS),
).toMatchObject({
enabled: false,
reason: 'forced-off',
});
Expand All@@ -79,49 +99,103 @@ describe('resolveSentryGate — fails closed when the opt-in signal is absent',
{ VITE_SENTRY_SEND_DEFAULT_PII: 'true' },
{ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: 'false', VITE_SENTRY_SEND_DEFAULT_PII: 'true' },
]) {
const decision = resolveSentryGate(env);
const decision = resolveSentryGate(env, RUNTIME_GRANTS);
expect(decision.enabled).toBe(false);
expect(decision.sendDefaultPii).toBe(false);
}
});
});

/**
* The post-build off switch (objectui#5522 / objectstack#10805, cloud#1508).
*
* The half that could not be built before: every other input to this gate is a
* Vite build-time variable frozen into the bundle, so an air-gapped EE Console
* running the SAME artifact as the hosted console had no way to be silenced.
* These cases hold the BUILD-time grant fixed at "fully opted in" — a real DSN,
* no force-off — so a denial can only be coming from the runtime.
*/
describe('resolveSentryGate — the runtime permission can silence a build that opted in', () => {
it('withholds reporting when the runtime declines, DSN notwithstanding', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN }, RUNTIME_DENIES)).toMatchObject({
enabled: false,
reason: 'runtime-denied',
});
});

it('still reports the DSN it refused, so a silent deployment is diagnosable', () => {
// The operator's question is "why is nothing arriving" — `reason` has to
// distinguish "you shipped no DSN" from "your runtime said no", and the
// second is invisible from inside the artifact.
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN }, RUNTIME_DENIES).dsn).toBe(DSN);
});

it('sends no PII on a runtime denial even when the build asked for it', () => {
const decision = resolveSentryGate(
{ VITE_SENTRY_DSN: DSN, VITE_SENTRY_SEND_DEFAULT_PII: 'true' },
RUNTIME_DENIES,
);
expect(decision.enabled).toBe(false);
expect(decision.sendDefaultPii).toBe(false);
});

it('requires a real `true`, so no truthy value can grant by accident', () => {
// `!== true` rather than `!`. The parameter is typed `boolean`, but this
// gate is the last thing standing between an air-gapped network and
// sentry.io, and JS callers exist. A permission must be granted, never
// coerced.
for (const truthy of ['true', 1, 'yes', {}, [], 'granted']) {
expect(
resolveSentryGate({ VITE_SENTRY_DSN: DSN }, truthy as unknown as boolean).enabled,
`runtime permission ${JSON.stringify(truthy)} must not grant`,
).toBe(false);
}
});

it('denies on BOTH halves missing without masking either reason', () => {
// Order matters only for the diagnostic: no DSN is the more actionable
// answer, so it wins the `reason` slot when both are absent.
expect(resolveSentryGate({}, RUNTIME_DENIES)).toMatchObject({ enabled: false, reason: 'no-dsn' });
});
});

describe('counter-probe — a posture that SHOULD report still does', () => {
it('grants reporting when a DSN was deliberately injected at build time', () => {
it('grants reporting when a DSN was injected at build time AND the runtime allows it', () => {
// The whole point: the fix must not silently disable the hosted SaaS
// build, which opts in by injecting a DSN in its own deploy environment.
// If this case ever goes red, the absence assertions above stop meaning
// "the gate is careful" and start meaning "the gate is stuck shut".
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN })).toMatchObject({
// build, which opts in by injecting a DSN in its own deploy environment
// and runs on a runtime that grants the permission. If this case ever goes
// red, the absence assertions above stop meaning "the gate is careful" and
// start meaning "the gate is stuck shut".
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN }, RUNTIME_GRANTS)).toMatchObject({
enabled: true,
reason: 'opted-in',
dsn: DSN,
});
});

it('does NOT require a separate enable flag alongside the DSN', () => {
// Presence of the DSN is the opt-in. Were `VITE_SENTRY_ENABLED=true` ever
// made mandatory, the SaaS pipeline would go dark the moment it forgot
// Presence of the DSN is the build-time opt-in. Were `VITE_SENTRY_ENABLED=true`
// ever made mandatory, the SaaS pipeline would go dark the moment it forgot
// the second variable — a quiet failure, which is the direction this card
// exists to avoid.
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: undefined }).enabled).toBe(true);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: '' }).enabled).toBe(true);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: 'true' }).enabled).toBe(true);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: undefined }, RUNTIME_GRANTS).enabled).toBe(true);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: '' }, RUNTIME_GRANTS).enabled).toBe(true);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_ENABLED: 'true' }, RUNTIME_GRANTS).enabled).toBe(true);
});

it('trims a padded DSN rather than rejecting it', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: ` ${DSN} ` })).toMatchObject({ enabled: true, dsn: DSN });
expect(resolveSentryGate({ VITE_SENTRY_DSN: ` ${DSN} ` }, RUNTIME_GRANTS)).toMatchObject({ enabled: true, dsn: DSN });
});
});

describe('sendDefaultPii — opt-in, because one artifact ships to every posture', () => {
it('is OFF when the build said nothing about it', () => {
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN }).sendDefaultPii).toBe(false);
expect(resolveSentryGate({ VITE_SENTRY_DSN: DSN }, RUNTIME_GRANTS).sendDefaultPii).toBe(false);
});

it('is ON only when the build explicitly asked for it', () => {
expect(
resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_SEND_DEFAULT_PII: 'true' }).sendDefaultPii,
resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_SEND_DEFAULT_PII: 'true' }, RUNTIME_GRANTS).sendDefaultPii,
).toBe(true);
});

Expand All@@ -130,14 +204,18 @@ describe('sendDefaultPii — opt-in, because one artifact ships to every posture
// These are the values that flipped meaning; none of them may enable PII.
for (const value of ['false', 'TRUE', 'True', '1', 'yes', 'on', '']) {
expect(
resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_SEND_DEFAULT_PII: value }).sendDefaultPii,
resolveSentryGate({ VITE_SENTRY_DSN: DSN, VITE_SENTRY_SEND_DEFAULT_PII: value }, RUNTIME_GRANTS).sendDefaultPii,
`VITE_SENTRY_SEND_DEFAULT_PII=${JSON.stringify(value)} must not enable PII`,
).toBe(false);
}
});
});

describe('initSentry — the posture that actually ships', () => {
afterEach(() => {
resetRuntimeConfigForTesting();
});

it('does not initialize, and does not load the SDK, on a build with no DSN', async () => {
// `import.meta.env` under Vitest carries no VITE_SENTRY_DSN, which is
// exactly the shape of a console build that never opted in. Not merely
Expand All@@ -150,11 +228,20 @@ describe('initSentry — the posture that actually ships', () => {
expect(getSentry()).toBeNull();
});

it('agrees with the pure gate about the env it actually reads', () => {
// Ties the two halves together: whatever `import.meta.env` holds in this
// run, `initSentry`'s verdict above must be the one `resolveSentryGate`
// derives from it. Without this, the pure tests and the wiring test could
// drift apart and both stay green.
expect(resolveSentryGate((import.meta as any).env).enabled).toBe(false);
it('agrees with the pure gate about the two inputs it actually reads', () => {
// Ties the halves together: whatever `import.meta.env` and the runtime
// config singleton hold in this run, `initSentry`'s verdict above must be
// the one `resolveSentryGate` derives from them. Without this, the pure
// tests and the wiring test could drift apart and both stay green.
expect(resolveSentryGate((import.meta as any).env, isClientErrorReportingAllowed()).enabled).toBe(false);
});

it('reads the runtime permission as DENIED before any config has been fetched', () => {
// The state `initSentry` sees if it is ever called before
// `initRuntimeConfig()` settles. It must be denial: the console's boot
// ordering is what guarantees the permission has arrived, and the cost of
// getting that ordering wrong has to be silence, never a leak.
resetRuntimeConfigForTesting();
expect(isClientErrorReportingAllowed()).toBe(false);
});
});
Loading
Loading