Skip to content

feat(app-shell,console): gate Sentry on the runtime's client-telemetry permission - #5982

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-5522-runtime-telemetry-consumer
Aug 24, 2026
Merged

feat(app-shell,console): gate Sentry on the runtime's client-telemetry permission#5982
yinlianghui merged 1 commit into
mainfrom
claude/issue-5522-runtime-telemetry-consumer

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Part of #5522

Reads the runtime's client-telemetry permission off /api/v1/runtime/config and gates initSentry on it, fail-closed. This is the consumer half; the objectui half landed in #5559 and the upstream contract half landed as objectstack-ai/objectstack#11382.

Part of rather than a closing keyword — deliberately, see What would close the card below. The invariant is restored in code, but a live credential named by the card is still outstanding and that is a maintainer action. Flipping this to a closing keyword is a one-line change if the reviewer disagrees; a wrongly-closed p0 is not as cheap to undo.

The gate is now a conjunction

send ⇔ a DSN was injected at BUILD time ∧ the RUNTIME granted permission

Both are opt-in and either one denies alone. That is what finally lets one artifact serve every posture: @object-ui/console publishes a single pre-built SPA that the hosted SaaS console and the on-prem / air-gapped EE images all embed, so the bundle cannot tell those deployments apart — only the server can. An air-gapped EE Console was measured sending 14 Sentry envelopes per session to sentry.io carrying IP + User-Agent PII with no way for the customer to stop it (objectstack-ai/cloud#1508).

The server half is a permission, never a source: it supplies no DSN and cannot switch telemetry on for a build that carries none.

PM assumption 1 was falsified — there is no pin, and that is load-bearing

The dispatch asked whether objectui's pin resolves to a version carrying the new key. It cannot, in either direction. Measured, not assumed:

  • No package.json in this repo names @objectstack/cloud-connection — the package that serves the payload and owns RuntimeTelemetryPosture.
  • Neither @objectstack/spec nor @objectstack/client, which we do pin at ^17.0.0, depends on or re-exports it.

The payload shape reaches this repo only by being retyped, exactly as branding and features already are in runtime-config.ts. So no version bump can hand us the key and no pin lag can withhold it. No pin was bumped.

That changes what the risk actually is: not pin lag but mirror drift. grantsClientErrorReporting is therefore a deliberate line-for-line mirror of isClientErrorReportingAllowed from @objectstack/cloud-connection/telemetry-posture, named as such at its definition, and pinned in both directions by test.

Fail-closed, and why each state is pinned separately

Absent key, telemetry block absent, malformed payload, failed fetch, and runtimes predating the key all read do not send — precisely the set of runtimes leaking today. They arrive through four different code paths (early return, absent key, absent block, catch) and share only their answer, so each is pinned on its own rather than represented by one case.

Only a real boolean true grants; 'true', 1, 'yes' do not. The permission is replaced per payload, never merged like features / branding, so a grant cannot outlive the response that carried it.

resolveSentryGate takes the permission as a required parameter. Both spellings fail closed, but only a required one makes the compiler refuse a caller that never considered the question — and that is this card's entire defect class. It is package-internal (not exported from packages/app-shell/src/index.ts), so no published signature changed.

⚠️ Declared fence extension: apps/console/src/main.tsx

The dispatch fenced this to packages/app-shell/src/observability/** + the runtime-config reader + a changeset. One line outside that fence was load-bearing and I took it, declaring it here rather than shipping a vacuous gate.

void initSentry() ran at module-eval time, before initRuntimeConfig() was even started. Reading a server value requires waiting for the server: from there the fail-closed permission would read DENIED on every boot and memoize that verdict — turning the switch this card asks for into a permanent removal of telemetry, silently, including for the hosted console. The call now sits inside the existing .finally() that already awaits the config before first paint. .finally() (not .then()) preserves 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.

initSentry has exactly one call site repo-wide, so this is the whole ordering surface. The static import set of main.tsx is byte-identical before and after (only a call expression moved), which is why the eager-closure budget cannot move — see verification.

Verification

Union re-run after the final commit, at ca91e85ef:

checkverdict line
vitest run (5 files, path-filtered)Test Files 5 passed (5) / Tests 93 passed (93)
type-check app-shell + consolepackages/app-shell type-check: Done · apps/console type-check: Done
check:control-bytes✅ OK (scanned 4950 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
check:self-import✅ No package names itself inside its own src/.
check:spec-symbols✅ spec alignment claims: 2 declared deliberate copies
check-changeset-presence✅ 6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-major / check-changeset-fixed both
eslint (6 changed .ts/.tsx)0 errors, 8 warnings — all no-explicit-any, the idiom already in these files; CI sets no --max-warnings by deliberate policy

Heavy steps ran through the shared verify lock. packages/app-shell's whole-package suite (~784 s) was not run; the run above is path-filtered, as dispatched.

Two ablations, each mutation confirmed on disk by grep counts of the removed and injected text, each with a trap ... EXIT INT TERM restore, each restore confirmed byte-identical by cmp:

ablationpredictedobserved
delete the runtime conjunct from resolveSentryGateREDTests 3 failed | 17 passed (20); marker count 1 → 0 on disk
flip the mirrored reader === true!== false (the classic fail-open dialect)REDTests 2 failed | 24 passed (26); strict-marker 2 → 1, injected-marker 0 → 1

No rebuild was needed for either leg: both suites import the module under test by relative source path, not through a package exports field pointing at dist/. The ablations going red is itself the evidence for that — a suite resolving a stale dist/ would have stayed green.

check:eager-closure was not run: it needs apps/console/dist/eager-closure.json, a console SPA build artifact CI produces. Narrowed with proof rather than skipped — the static import set of the one changed file is byte-identical, so the eager closure has nothing to move.

Every negative assertion is paired with a counter-probe that must stay green (a granting runtime + injected DSN still reports; the payload that denies is proven to have actually parsed). Absence is the shape a broken test reproduces perfectly, so the negatives are only evidence while the counter-probes hold.

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.

What would close the card (maintainer actions, not mine)

  1. Rotate the Sentry DSN that was committed in apps/console/.env.production before console/app-shell: stop shipping an un-disableable Sentry DSN, and make sendDefaultPii opt-in (#5522) #5559 removed it. It is still in git history and presumably still live. Per dispatch I neither invented nor rotated a credential.
  2. Grant the permission on the hosted console if SaaS error reporting should continue, or it goes quiet on deploy. Related: SaaS/demo console deploy env must inject VITE_SENTRY_DSN — otherwise the hosted console ships with error reporting off after #5546 #5550.

Generated by Claude Code

…y permission
`/api/v1/runtime/config` now carries `telemetry.allowClientErrorReporting`
(objectstack#11382), so the Console can finally be silenced by the deployment it
lands in rather than only by the build it came from.
The shipped decision becomes a conjunction of two independent grants — a DSN
injected at build time AND a positive permission from the runtime — and either
one denies alone. That is what lets the single pre-built SPA that both the
hosted SaaS console and the on-premises / air-gapped EE images embed serve every
posture: the identical bundle meets a runtime that grants nothing and stays
quiet, with no rebuild and without editing files inside a published SPA.
- `runtime-config.ts` parses the key through `grantsClientErrorReporting`, a
deliberate line-for-line mirror of `isClientErrorReportingAllowed` from
`@objectstack/cloud-connection`. Nothing here depends on that package, so the
shape reaches us only by being retyped, exactly as `branding` and `features`
already are; the mirror is pinned in both directions.
- The permission is REPLACED per payload, never merged like `features` /
`branding`, so a grant cannot outlive the response that carried it.
- `resolveSentryGate` takes the permission as a REQUIRED parameter. Both
spellings fail closed, but only a required one makes the compiler refuse a
caller that never considered the question, which is this card's defect class.
- The console's boot kick moves after `initRuntimeConfig()` settles. Reading a
server value requires waiting for the server; from module-eval time the
fail-closed permission read DENIED on every boot and memoized it, which would
have turned the switch into a permanent removal.
Fails closed on absent key, absent block, malformed payload, failed fetch and
runtimes predating the key — precisely the set that is leaking today. Only a
real boolean `true` grants.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.5 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-26QEauJw.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)504.75KB114.32KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.00KB61.86KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)52.40KB17.45KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.88KB1.85KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yinlianghui@claude