From 08c1605fd56297fbe8e4354c265d4dc777546a0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Mon, 27 Jul 2026 15:36:11 +0200 Subject: [PATCH 1/6] feat(runner): report API worker errors to Sentry (DEV-2134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner had no error reporting: DEV-2129, DEV-2130 and the Tier-2 slot exhaustion were all found by hand or by the link-sweep script, days after they started failing. Workers Logs gives raw lines with no grouping or alerting. Wraps the fetch handler with withSentry() and both Durable Objects with instrumentDurableObjectWithSentry() — Tier-2 session orchestration and the share-build snapshotter run inside the DOs, so their failures never reach the handler's error path. Release comes from the Cloudflare version_metadata binding, so no deploy-workflow change is needed. The outer catch converts every unexpected throw into a 500 body, so withSentry() would never observe it; captureException is called there and at the two npm registry probes, which silently degrade version pinning. InvalidFilePathError stays unreported — it is a 400, not a fault. Errors only: tracesSampleRate 0, no replay, no profiling. Two things worth knowing for the follow-up frontend change: - The DSN var is named ERROR_REPORTING_DSN, not SENTRY_DSN. The SDK's getFinalOptions falls back to env.SENTRY_DSN whenever the options object omits a dsn, which initialises the client straight from env and defeats any gate. - Reporting is gated on PREVIEW_HOST matching the production host, the prod-vs-local switch Tier-2 previews already use. Verified locally: with the gate closed the SDK logs "No DSN provided, client will not send events"; with it open the client initialises and captures. A Tier-2 session was booted and its preview URL proxied through the wrapped Sandbox DO to confirm the Proxy does not break the Sandbox SDK or proxyToSandbox() name resolution. Co-Authored-By: Claude Opus 5 --- runner/pnpm-lock.yaml | 150 ++++++++++++++++++++++++++++++ runner/workers/api/package.json | 3 +- runner/workers/api/src/env.ts | 6 ++ runner/workers/api/src/index.ts | 70 +++++++++++++- runner/workers/api/wrangler.jsonc | 16 ++++ 5 files changed, 240 insertions(+), 5 deletions(-) diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index cce430a1..03be0ffb 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@handsontable/demo-runtime': specifier: workspace:* version: link:../../packages/runtime + '@sentry/cloudflare': + specifier: ^10.68.0 + version: 10.68.0(@cloudflare/workers-types@4.20260702.1)(wrangler@4.108.0(@cloudflare/workers-types@4.20260702.1)) devDependencies: '@cloudflare/workers-types': specifier: ^4.20250101.0 @@ -132,6 +135,17 @@ importers: packages: + '@apm-js-collab/code-transformer-bundler-plugins@0.7.1': + resolution: {integrity: sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA==} + engines: {node: '>=18.0.0'} + + '@apm-js-collab/code-transformer@0.18.1': + resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==} + hasBin: true + + '@apm-js-collab/tracing-hooks@0.13.0': + resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -848,6 +862,10 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + '@playwright/test@1.61.1': resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} @@ -1003,6 +1021,30 @@ packages: cpu: [x64] os: [win32] + '@sentry/cloudflare@10.68.0': + resolution: {integrity: sha512-IcSjfncUSkisoZy66SbLMb4gtH+Jr9mS02DIqjuz3JsrKnkxFaQIBiosoDTgHlCMWFIEU5M4MpErYZ1dzJpo6Q==} + engines: {node: '>=18'} + peerDependencies: + '@cloudflare/workers-types': ^4.x || ^5.x + wrangler: ^4.x + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrangler: + optional: true + + '@sentry/conventions@0.16.0': + resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==} + engines: {node: '>=14'} + + '@sentry/core@10.68.0': + resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==} + engines: {node: '>=18'} + + '@sentry/server-utils@10.68.0': + resolution: {integrity: sha512-lp1ZSs1auw7HrCESSYt/n4dOUaKPVUIAKyVYRk6xVr4bMIN3RPub/H5Wm7QPj9CpXVC5bQFvB6+dHZXz809oMg==} + engines: {node: '>=18'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1070,6 +1112,10 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} @@ -1141,6 +1187,9 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1155,6 +1204,14 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1208,6 +1265,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + meriyah@6.1.4: + resolution: {integrity: sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==} + engines: {node: '>=18.0.0'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -1217,6 +1281,9 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1280,6 +1347,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semifies@1.0.0: + resolution: {integrity: sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -1297,6 +1367,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + static-browser-server@1.0.3: resolution: {integrity: sha512-ZUyfgGDdFRbZGGJQ1YhiM930Yczz5VlbJObrQLlk24+qNHVQx4OlLcYswEUo3bIyNAbQUIUR9Yr5/Hqjzqb4zA==} @@ -1419,6 +1493,30 @@ packages: snapshots: + '@apm-js-collab/code-transformer-bundler-plugins@0.7.1': + dependencies: + '@apm-js-collab/code-transformer': 0.18.1 + es-module-lexer: 2.3.1 + magic-string: 0.30.21 + module-details-from-path: 1.0.4 + + '@apm-js-collab/code-transformer@0.18.1': + dependencies: + '@types/estree': 1.0.9 + astring: 1.9.0 + esquery: 1.7.0 + meriyah: 6.1.4 + semifies: 1.0.0 + source-map: 0.6.1 + + '@apm-js-collab/tracing-hooks@0.13.0': + dependencies: + '@apm-js-collab/code-transformer': 0.18.1 + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2003,6 +2101,8 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@opentelemetry/api@1.9.1': {} + '@playwright/test@1.61.1': dependencies: playwright: 1.61.1 @@ -2096,6 +2196,34 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sentry/cloudflare@10.68.0(@cloudflare/workers-types@4.20260702.1)(wrangler@4.108.0(@cloudflare/workers-types@4.20260702.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@sentry/core': 10.68.0 + '@sentry/server-utils': 10.68.0 + magic-string: 0.30.21 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + wrangler: 4.108.0(@cloudflare/workers-types@4.20260702.1) + transitivePeerDependencies: + - supports-color + + '@sentry/conventions@0.16.0': {} + + '@sentry/core@10.68.0': + dependencies: + '@sentry/conventions': 0.16.0 + + '@sentry/server-utils@10.68.0': + dependencies: + '@apm-js-collab/code-transformer-bundler-plugins': 0.7.1 + '@apm-js-collab/tracing-hooks': 0.13.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.68.0 + meriyah: 6.1.4 + transitivePeerDependencies: + - supports-color + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.17': {} @@ -2185,6 +2313,8 @@ snapshots: transitivePeerDependencies: - supports-color + astring@1.9.0: {} + aws4fetch@1.0.20: {} base64-js@1.5.1: {} @@ -2242,6 +2372,8 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-module-lexer@2.3.1: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2302,6 +2434,12 @@ snapshots: escalade@3.2.0: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -2332,6 +2470,12 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + meriyah@6.1.4: {} + mime-db@1.54.0: {} miniflare@4.20260706.0: @@ -2346,6 +2490,8 @@ snapshots: - bufferutil - utf-8-validate + module-details-from-path@1.0.4: {} + ms@2.1.3: {} nanoid@3.3.15: {} @@ -2418,6 +2564,8 @@ snapshots: scheduler@0.27.0: {} + semifies@1.0.0: {} + semver@6.3.1: {} semver@7.8.5: {} @@ -2455,6 +2603,8 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.6.1: {} + static-browser-server@1.0.3: dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/runner/workers/api/package.json b/runner/workers/api/package.json index ad91233b..bb98f5bd 100644 --- a/runner/workers/api/package.json +++ b/runner/workers/api/package.json @@ -10,7 +10,8 @@ }, "dependencies": { "@cloudflare/sandbox": "^0.12.3", - "@handsontable/demo-runtime": "workspace:*" + "@handsontable/demo-runtime": "workspace:*", + "@sentry/cloudflare": "^10.68.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250101.0", diff --git a/runner/workers/api/src/env.ts b/runner/workers/api/src/env.ts index 38436570..2e1ec975 100644 --- a/runner/workers/api/src/env.ts +++ b/runner/workers/api/src/env.ts @@ -16,6 +16,12 @@ export interface Env { // Public, non-secret config. LOGIN_BROKER_URL: string; EMBED_ALLOWED_ANCESTORS: string; + // Sentry ingest endpoint (write-only, committed in wrangler.jsonc `vars`). + // NOT named SENTRY_DSN on purpose — the SDK auto-reads that exact key from env + // and would bypass the local-dev gate in index.ts. See the note there. + ERROR_REPORTING_DSN: string; + // Cloudflare-managed per-deploy version id, used as the Sentry release. + CF_VERSION_METADATA: { id: string; tag: string }; // Wildcard base host for Tier-2 container preview URLs (e.g. // "demos.handsontable.com"). Empty -> use the request host (local dev). PREVIEW_HOST?: string; diff --git a/runner/workers/api/src/index.ts b/runner/workers/api/src/index.ts index ecb5f2e7..a3c269f6 100644 --- a/runner/workers/api/src/index.ts +++ b/runner/workers/api/src/index.ts @@ -7,6 +7,7 @@ // Sharing endpoints (POST/GET/PATCH/DELETE /api/demos) land in Deliverable 5. import { getSandbox, proxyToSandbox, Sandbox as SandboxBase } from "@cloudflare/sandbox"; +import * as Sentry from "@sentry/cloudflare"; import { mintSessionId, pickLatestNextVersion } from "@handsontable/demo-runtime"; import type { Env } from "./env.js"; import { FRAMEWORK_DEV, BUILD_CONFIG } from "./frameworks.generated.js"; @@ -27,10 +28,62 @@ import { createDemo, getDemo, getDemoSource, invalidateDemo, serveDemoAsset, upd // abandoned sessions exhaust the pool. Disk is ephemeral, so a slept container // cold-boots on return — the point is to avoid that mid-session, not to make // wake cheap. -export class Sandbox extends SandboxBase { +/** The one production host. Doubles as the "am I deployed?" signal below. */ +const PRODUCTION_HOST = "demos.handsontable.com"; + +/** + * Sentry init options, shared by the fetch handler and both Durable Objects so + * every event carries the same release and environment. Errors only — no + * tracing, no profiling. See docs/run-and-deploy.md. + * + * Reporting is enabled only on the deployed Worker. `PREVIEW_HOST` is the + * discriminator: it is `demos.handsontable.com` in the committed + * `wrangler.jsonc` vars and is overridden to `localhost:8787` by + * `workers/api/.dev.vars` for local runs — the same prod-vs-local switch the + * Tier-2 preview URLs already depend on. Without the gate, `wrangler dev` would + * file local experiments as `api-production` issues. The browser SDK gates on + * `window.location.hostname` for the same reason. + * + * The DSN var is deliberately NOT named `SENTRY_DSN`: the SDK's own + * `getFinalOptions` falls back to `env.SENTRY_DSN` whenever the options object + * omits a dsn, which would initialise the client straight from env and silently + * defeat the gate. Under a different key that fallback finds nothing, so the only + * path a DSN can take is the explicit line below. For the same reason the gate is + * expressed as `dsn: undefined` (an inert client that never sends) rather than by + * returning `undefined` — the SDK reads that as an empty options object and then + * env-falls-back anyway. + * + * `CF_VERSION_METADATA` is optional-chained: local `wrangler dev` supplies a + * throwaway id, and a missing release must never throw at init. + */ +const sentryOptions = (env: Env): Sentry.CloudflareOptions => ({ + dsn: env.PREVIEW_HOST === PRODUCTION_HOST ? env.ERROR_REPORTING_DSN : undefined, + environment: "api-production", + release: env.CF_VERSION_METADATA?.id, + tracesSampleRate: 0, +}); + +class SandboxBaseWithSleep extends SandboxBase { sleepAfter = "5m"; } -export class BuilderSandbox extends SandboxBase {} + +// Both DO classes are Sentry-instrumented: Tier-2 session orchestration and the +// share-build snapshotter run inside them, so a failure there never passes +// through the fetch handler's own error path. The export name `Sandbox` is +// load-bearing — proxyToSandbox() resolves the live-preview namespace by that +// literal name — so the wrapper is assigned to it rather than aliased. +// The `as unknown as typeof X` casts mirror the proxyToSandbox() cast below: +// the Sandbox SDK's recursive RPC generic hits TS2589 (deep instantiation) when +// a wrapper re-infers it. +export const Sandbox = Sentry.instrumentDurableObjectWithSentry( + sentryOptions, + SandboxBaseWithSleep as unknown as new (state: DurableObjectState, env: Env) => never, +) as unknown as typeof SandboxBaseWithSleep; + +export const BuilderSandbox = Sentry.instrumentDurableObjectWithSentry( + sentryOptions, + SandboxBase as unknown as new (state: DurableObjectState, env: Env) => never, +) as unknown as typeof SandboxBase; const CONTAINER_ROOT = "/app"; const BOOT_LOG = "/tmp/boot.log"; @@ -176,7 +229,7 @@ async function writeFiles(sandbox: SandboxLike, files: Record) { } } -export default { +export default Sentry.withSentry(sentryOptions, { async fetch(request: Request, env: Env): Promise { // 1) Preview-URL traffic (and its HMR WebSocket) is proxied to the container. // Cast to keep TS from instantiating the SDK's deep generic over Env (TS2589). @@ -521,6 +574,9 @@ export default { await env.CACHE.put(cacheKey, JSON.stringify(payload), { expirationTtl: payload.exists ? 3600 : 300 }); return cors(cacheableJson(payload)); } catch (e) { + // The registry being unreachable silently re-pins docs examples onto a + // version that may not exist — actionable, so report it. + Sentry.captureException(e, { tags: { upstream: "npm-registry", probe: "version-exists" } }); return json({ error: e instanceof Error ? e.message : String(e) }, 502); } } @@ -556,6 +612,8 @@ export default { await env.CACHE.put("versions", JSON.stringify(payload), { expirationTtl: 3600 }); return cors(cacheableJson(payload)); } catch (e) { + // Version picker falls back to a stale list when this fails. + Sentry.captureException(e, { tags: { upstream: "npm-registry", probe: "versions" } }); return json({ error: e instanceof Error ? e.message : String(e) }, 502); } } @@ -569,9 +627,13 @@ export default { return cors(new Response("Not found", { status: 404 })); } catch (err) { + // Client input validation (a 400) is not a fault — never reported. if (err instanceof InvalidFilePathError) return json({ error: err.message }, 400); + // This catch turns every unexpected throw into a 500 body, so withSentry() + // never sees it. Report here or the error is invisible. + Sentry.captureException(err); return json({ error: err instanceof Error ? err.message : String(err) }, 500); } }, -}; +}); diff --git a/runner/workers/api/wrangler.jsonc b/runner/workers/api/wrangler.jsonc index 063dabaa..dfd5339a 100644 --- a/runner/workers/api/wrangler.jsonc +++ b/runner/workers/api/wrangler.jsonc @@ -12,9 +12,25 @@ // (attached at deploy time — see the `deploy` script and the routes note below). "workers_dev": true, + // Per-deploy version id (Cloudflare-managed), used as the Sentry release so + // an issue points at the exact deployment that introduced it. Preferred over + // injecting a git SHA with `--var`: the `deploy` script already passes four + // `--routes` flags, and `--var` would have to merge cleanly with the `vars` + // block below. + "version_metadata": { "binding": "CF_VERSION_METADATA" }, + // Public, non-secret config. "vars": { "LOGIN_BROKER_URL": "https://mcp-auth-proxy-j0tb.onrender.com", + // Sentry ingest endpoint. A DSN is write-only and is public by construction + // (the browser SDK ships it in the bundle), so it lives here rather than in + // `wrangler secret` — no Cloudflare secret access needed to change it. + // Abuse is bounded Sentry-side (allowed domains, inbound filters, spike + // protection), not by hiding this string. + // The key is NOT `SENTRY_DSN`: @sentry/cloudflare auto-reads that exact name + // off env, which would initialise the SDK in `wrangler dev` too and file + // local runs as api-production issues. See src/index.ts. + "ERROR_REPORTING_DSN": "https://da4d7c549a5ac69c61bfe44be68873bd@o95873.ingest.us.sentry.io/4511806997135360", "EMBED_ALLOWED_ANCESTORS": "https://handsontable.com https://*.handsontable.com https://*.handsontable-docs-staging.pages.dev http://localhost:*", // Wildcard base host for Tier-2 container preview URLs // (--.demos.handsontable.com). From b070024bcdeef7c9953c7191cc0fee1ce37d1c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Mon, 27 Jul 2026 15:47:15 +0200 Subject: [PATCH 2/6] feat(runner): report authoring-app errors to Sentry (DEV-2134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of DEV-2134, after the API worker. Adds @sentry/react behind a single init module (src/sentry.ts) plus an error boundary — an editor-shell render crash used to leave a blank page and no record of why. Reports at the callsites that previously swallowed a failure whole: saved-demo load, docs-example load and switch (the DEV-2130 class, tagged by which step failed), versions fetch, broker userinfo, and the demo save/fork/embed/share/ revoke calls. The writeFile/deleteFile "not mounted" catches are left alone — that is expected control flow. reportRuntimeError classifies preview failures instead of reporting all or nothing. DemoRuntime.onError is a mixed channel: on the Sandpack engine it carries compile errors from the code being edited, which are product output and would bury the signal under one issue per keystroke. On the container engine it carries SessionStartError (the Tier-2 pool refusing a session, the failure class behind PR #87) and ContainerBootFailure, which are exactly what we want. 410 is excluded as normal teardown, and both are fingerprinted by class so per-example boot logs don't shard one problem into hundreds of issues. Nothing is reported outside production. .env.production is committed and is therefore loaded by ci.yml's authoring build, whose output Playwright serves at localhost:4173 — so without a gate every PR run would file events. Init is conditional on window.location.hostname, mirroring the Worker's PREVIEW_HOST gate. Source maps upload via @sentry/vite-plugin on the deploy workflow's build step only; build.sourcemap follows the token's presence, so a build without one neither emits nor publishes .map files. Needs repo secret SENTRY_AUTH_TOKEN and repo variables SENTRY_ORG / SENTRY_PROJECT (slugs, not the numeric DSN ids). Verified in a real browser against the production bundle: on localhost no client is created and an unhandled error produces zero ingest requests; with the gate temporarily pointed at localhost the envelope POST returns 200, confirming the DSN and project. Typing broken syntax into the editor produced no event, but the hosted Sandpack bundler was unreachable in that environment, so the preview never raised a compile error — repeat that negative check on prod. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-runner-authoring.yml | 8 + runner/apps/authoring/.env.production | 6 + runner/apps/authoring/package.json | 2 + runner/apps/authoring/src/App.tsx | 73 +++- runner/apps/authoring/src/MyDemos.tsx | 13 +- runner/apps/authoring/src/ShareDialog.tsx | 4 + runner/apps/authoring/src/auth.ts | 7 +- runner/apps/authoring/src/main.tsx | 18 +- runner/apps/authoring/src/sentry.ts | 67 +++ runner/apps/authoring/vite.config.ts | 32 +- runner/docs/run-and-deploy.md | 52 +++ runner/pnpm-lock.yaml | 384 ++++++++++++++++++ 12 files changed, 657 insertions(+), 9 deletions(-) create mode 100644 runner/apps/authoring/src/sentry.ts diff --git a/.github/workflows/deploy-runner-authoring.yml b/.github/workflows/deploy-runner-authoring.yml index 589c9ef6..3ca277b4 100644 --- a/.github/workflows/deploy-runner-authoring.yml +++ b/.github/workflows/deploy-runner-authoring.yml @@ -58,7 +58,15 @@ jobs: # Build workspace packages, then the authoring app. VITE_API_BASE comes # from apps/authoring/.env.production (committed) so it targets prod. - run: pnpm build + # SENTRY_* are only set here, on the deploying build. The `test` job reuses + # ci.yml, which gets no token, so PR builds neither emit source maps nor + # upload a release — see apps/authoring/vite.config.ts. - run: pnpm --filter @handsontable/demo-authoring build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + GITHUB_SHA: ${{ github.sha }} - name: Deploy authoring worker working-directory: runner/apps/authoring diff --git a/runner/apps/authoring/.env.production b/runner/apps/authoring/.env.production index f2bb98a7..1fc6ddae 100644 --- a/runner/apps/authoring/.env.production +++ b/runner/apps/authoring/.env.production @@ -1,6 +1,12 @@ # Public prod config for the authoring app (safe to commit — no secrets). +# The Sentry DSN below is one of these: a DSN is a write-only ingest endpoint and +# ships inside the JS bundle by construction, so hiding it buys nothing. Abuse is +# bounded Sentry-side (allowed domains, inbound filters, spike protection). +# src/sentry.ts only initialises on demos.handsontable.com, so this file being +# loaded by PR-CI builds and local `vite preview` sends nothing. # The API worker and this app share the demos.handsontable.com origin, so the # app talks to /api on the same host. This MUST be set for prod builds; without # it the code falls back to http://localhost:8787 and the browser can't reach # the API ("Failed to fetch"). Dev bypasses (VITE_DEV_USER) stay in .env.local. VITE_API_BASE=https://demos.handsontable.com +VITE_SENTRY_DSN=https://da4d7c549a5ac69c61bfe44be68873bd@o95873.ingest.us.sentry.io/4511806997135360 diff --git a/runner/apps/authoring/package.json b/runner/apps/authoring/package.json index 64688ee2..dab1b2cf 100644 --- a/runner/apps/authoring/package.json +++ b/runner/apps/authoring/package.json @@ -12,11 +12,13 @@ "dependencies": { "@handsontable/demo-editor-shell": "workspace:*", "@handsontable/demo-runtime": "workspace:*", + "@sentry/react": "^10.68.0", "fflate": "^0.8.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { + "@sentry/vite-plugin": "^5.4.0", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/runner/apps/authoring/src/App.tsx b/runner/apps/authoring/src/App.tsx index 1ad26df0..22ba0690 100644 --- a/runner/apps/authoring/src/App.tsx +++ b/runner/apps/authoring/src/App.tsx @@ -11,7 +11,7 @@ import { type FilesMap, } from "@handsontable/demo-runtime"; import { SandpackRuntime } from "@handsontable/demo-runtime/sandpack"; -import { ContainerRuntime, ContainerBootFailure } from "@handsontable/demo-runtime/container"; +import { ContainerRuntime, ContainerBootFailure, SessionStartError } from "@handsontable/demo-runtime/container"; import { zipSync, strToU8 } from "fflate"; import { catalog, getEntry, fetchVersions, checkVersionExists, VERSION_OPTIONS, DEFAULT_VERSION } from "./catalog.js"; import { @@ -24,6 +24,7 @@ import { DocsCascader, type CascaderLeaf } from "./DocsCascader.js"; import { currentUser, login, logout, getToken, type User } from "./auth.js"; import { MyDemos } from "./MyDemos.js"; import { ShareLinks } from "./ShareLinks.js"; +import { reportError, reportingEnabled, Sentry } from "./sentry.js"; const SANDPACK_BUNDLER_URL = import.meta.env.VITE_SANDPACK_BUNDLER_URL || undefined; const API_BASE = import.meta.env.VITE_API_BASE || "http://localhost:8787"; @@ -93,6 +94,48 @@ function describeRuntimeError(e: unknown, engine: string, version: string): stri return msg; } +/** + * Decide whether a preview failure is ours to fix, and report it if so. + * + * `DemoRuntime.onError` is a mixed channel. On the Sandpack engine it carries + * compile and runtime errors from the example code being edited — a typo + * mid-keystroke, an example that was imported broken. That is product output, not + * an application fault, and reporting it would bury the signal (and the quota) + * under one issue per syntax error. + * + * The container engine is the opposite: `SessionStartError` is how the Tier-2 + * instance pool refuses a session (the exhaustion class of failure behind PR #87) + * and `ContainerBootFailure` is a dev server that could not install or start. + * Those are exactly what we want to hear about. 410 is excluded — it means the + * client had already navigated away and the server tore the session down, which + * is the designed outcome, not a failure. + * + * Fingerprinted by error class: `ContainerBootFailure` carries the raw boot log, + * which differs per example and would otherwise shard one problem into hundreds + * of issues. + */ +function reportRuntimeError(e: unknown, engine: string): void { + if (!reportingEnabled || engine !== "container") return; + if (e instanceof SessionStartError) { + if (e.status === 410) return; + Sentry.captureException(e, { + tags: { context: "tier2-session-start", session_status: String(e.status) }, + fingerprint: ["tier2-session-start", String(e.status)], + }); + return; + } + if (e instanceof ContainerBootFailure) { + Sentry.captureException(e, { + tags: { context: "tier2-container-boot" }, + fingerprint: ["tier2-container-boot"], + }); + return; + } + // Normal teardown: dispose() races a pending message, or the tab is closing. + if (e instanceof Error && e.message === "The session was closed.") return; + Sentry.captureException(e, { tags: { context: "tier2-runtime" } }); +} + function pinHandsontableFiles(files: FilesMap, version: string): FilesMap { const validated = validateHandsontableVersion(version); if (!validated.ok || files["/package.json"] === undefined) return files; @@ -313,7 +356,10 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { } loadWorkspace(getEntry(src.framework), src.files, savedId); setSourceLoaded(true); - } catch { + } catch (error) { + // A share/edit link that no longer resolves: missing D1 row, unreadable R2 + // snapshot, or a framework the catalog no longer has. + reportError(error, "saved-demo-load"); if (!cancelled) { setErrorMessage("This demo is unavailable."); setSourceLoaded(true); } } })(); @@ -335,7 +381,10 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { } setVersionsResolved(true); }) - .catch(() => { + .catch((error) => { + // Fails open onto the hardcoded VERSION_OPTIONS, so the picker silently + // goes stale rather than breaking — worth knowing about. + reportError(error, "versions-fetch"); if (!cancelled) setVersionsResolved(true); // release buckets can still resolve without dist-tags.next }); return () => { cancelled = true; }; @@ -461,10 +510,16 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { setSourceLoaded(true); sourceLoadedRef.current = true; } catch (error) { + // The DEV-2130 class: a docs page links an example the runner can't + // open. Tagged by which step failed so a missing artifact (docs linking + // an example that was never imported) is distinguishable from a + // transient fetch. + reportError(error, `docs-example-load:${isMissingDocsResource(error) ? "path" : "fetch"}`); failOpenDocs(isMissingDocsResource(error) ? "path" : "fetch"); } }) .catch((error) => { + reportError(error, `docs-bucket-resolve:${isMissingDocsResource(error) ? "bucket" : "fetch"}`); failOpenDocs(isMissingDocsResource(error) ? "bucket" : "fetch"); }); return () => { cancelled = true; }; @@ -534,7 +589,8 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { loadWorkspace(e, nextFiles, `docs:${bucket}:${dp}`); setVersionWarning(null); setDocsRuntimeBlocked(false); - } catch { + } catch (error) { + reportError(error, "docs-example-switch"); // Unlike the deep-link (`?docs=`) load path, a working workspace is already // open here, so a toolbar note is enough — no full-screen not-found takeover. if (docsRequestSeqRef.current === requestSeq) { @@ -625,6 +681,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { if (cancelled) return; setStatus("error"); setErrorMessage(describeRuntimeError(e, entry.engine, v.value.ref)); + reportRuntimeError(e, entry.engine); }); runtimeRef.current = runtime; runtime.mount(filesRef.current).catch((e: unknown) => { @@ -632,6 +689,9 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { setStatus("error"); setErrorMessage(describeRuntimeError(e, entry.engine, v.value.ref)); } + // Reported even when cancelled: a session the pool refused still failed, + // and the unmount that set `cancelled` is often the user giving up on it. + reportRuntimeError(e, entry.engine); }); return () => { cancelled = true; @@ -741,6 +801,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { setLinksId(id); setShareLinksOpen(true); } catch (e) { + reportError(e, "demo-embed"); setErrorMessage(e instanceof Error ? e.message : String(e)); } finally { setEmbedding(false); @@ -772,6 +833,7 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { const { id } = (await res.json()) as { id: string }; location.href = `/edit/${id}`; // boot into the edit page for the new demo } catch (e) { + reportError(e, "demo-fork"); setErrorMessage(e instanceof Error ? e.message : String(e)); setForking(false); } @@ -801,6 +863,9 @@ function Authoring({ user, route }: { user: User | null; route: EditorRoute }) { setDirty(false); dirtyRef.current = false; } catch (e) { + // Losing a save is the worst outcome in the app — the user's edits are only + // in this tab's memory until the PATCH lands. + reportError(e, "demo-save"); setErrorMessage(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); diff --git a/runner/apps/authoring/src/MyDemos.tsx b/runner/apps/authoring/src/MyDemos.tsx index 6d290510..f8deb0e9 100644 --- a/runner/apps/authoring/src/MyDemos.tsx +++ b/runner/apps/authoring/src/MyDemos.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { theme } from "@handsontable/demo-editor-shell"; +import { reportError } from "./sentry.js"; interface DemoListItem { id: string; @@ -32,14 +33,22 @@ export function MyDemos({ apiBase, token, onClose }: MyDemosProps) { const data = (await r.json()) as { demos: DemoListItem[] }; setDemos(data.demos); }) - .catch((e) => setError(String(e))); + .catch((e) => { + reportError(e, "my-demos-list"); + setError(String(e)); + }); }, [apiBase, token]); async function remove(id: string) { const res = await fetch(`${apiBase}/api/demos/${id}`, { method: "DELETE", headers: token ? { Authorization: `Bearer ${token}` } : {}, - }).catch(() => null); + }).catch((e: unknown) => { + // Nothing in the UI changes on failure, so without this the revoke just + // appears not to have happened. + reportError(e, "demo-revoke"); + return null; + }); if (res && (res.status === 204 || res.ok)) { setDemos((cur) => (cur ? cur.map((d) => (d.id === id ? { ...d, revoked: 1 } : d)) : cur)); } diff --git a/runner/apps/authoring/src/ShareDialog.tsx b/runner/apps/authoring/src/ShareDialog.tsx index bf5a019e..507b8c70 100644 --- a/runner/apps/authoring/src/ShareDialog.tsx +++ b/runner/apps/authoring/src/ShareDialog.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { theme } from "@handsontable/demo-editor-shell"; import type { FilesMap } from "@handsontable/demo-runtime"; +import { reportError } from "./sentry.js"; export interface ShareResult { id: string; @@ -64,6 +65,9 @@ export function ShareDialog(props: ShareDialogProps) { setResult(r); props.onResult(r); } catch (e) { + // POST /api/demos also runs the real framework build in the builder + // container, so this covers share-build failures, not just network errors. + reportError(e, "demo-share"); setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); diff --git a/runner/apps/authoring/src/auth.ts b/runner/apps/authoring/src/auth.ts index c1352600..4c5deb47 100644 --- a/runner/apps/authoring/src/auth.ts +++ b/runner/apps/authoring/src/auth.ts @@ -2,6 +2,8 @@ // the broker rejects non-@handsontable.com accounts. The token is per-user, // per-session; kept in sessionStorage, never persisted or logged. +import { reportError } from "./sentry.js"; + const BROKER = import.meta.env.VITE_LOGIN_BROKER_URL || "https://mcp-auth-proxy-j0tb.onrender.com"; const TOKEN_KEY = "hot_token"; @@ -44,7 +46,10 @@ export async function currentUser(): Promise { return null; } return (await res.json()) as User; - } catch { + } catch (error) { + // The broker being unreachable presents as "signed out" with no explanation, + // and every write endpoint then rejects. + reportError(error, "broker-userinfo"); return null; } } diff --git a/runner/apps/authoring/src/main.tsx b/runner/apps/authoring/src/main.tsx index c3e2da2a..5581bcbd 100644 --- a/runner/apps/authoring/src/main.tsx +++ b/runner/apps/authoring/src/main.tsx @@ -1,9 +1,25 @@ +// Must stay first: initialises error reporting before any other module runs, so a +// throw during module evaluation is still captured. +import { Sentry } from "./sentry.js"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App.js"; +// A render crash in the editor shell used to leave a blank page and no record of +// why. The boundary keeps the failure visible to the user and reports it. createRoot(document.getElementById("root")!).render( - + +

Something went wrong

+

+ Reload the page. If it keeps happening, the error has been reported. +

+ + } + > + +
, ); diff --git a/runner/apps/authoring/src/sentry.ts b/runner/apps/authoring/src/sentry.ts new file mode 100644 index 00000000..15ca1549 --- /dev/null +++ b/runner/apps/authoring/src/sentry.ts @@ -0,0 +1,67 @@ +// Error reporting for the authoring app. Errors only — no tracing, no session +// replay, no profiling. Imported for its side effect (init) as the very first +// import in main.tsx, so a crash while the module graph is still evaluating is +// still recorded. +// +// Every callsite reports through `reportError` below rather than importing the +// SDK directly, which keeps the production gate authoritative in one place. +import * as Sentry from "@sentry/react"; + +/** + * The deployed host. Reporting is enabled ONLY here. + * + * `.env.production` is committed and is therefore loaded by every production-mode + * build, including `ci.yml`'s "Build authoring" step — whose output Playwright + * then serves at http://localhost:4173. Without this gate every PR run, and every + * local `vite preview`, would file events against the production project. Gating + * on the runtime hostname (rather than on a second env var or a CI secret) makes + * that structurally impossible. + * + * The API worker gates on `PREVIEW_HOST` for the same reason — see + * workers/api/src/index.ts. + */ +const PRODUCTION_HOST = "demos.handsontable.com"; + +const DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined; + +export const reportingEnabled = + Boolean(DSN) && typeof window !== "undefined" && window.location.hostname === PRODUCTION_HOST; + +/** + * Browser noise that is never actionable: a benign layout-loop warning browsers + * surface as an error, and the two shapes an in-flight request takes when the + * user navigates away mid-fetch. + */ +const IGNORED = [/^ResizeObserver loop/i, /^AbortError/i, /Failed to fetch/i, /Load failed/i]; + +if (reportingEnabled) { + Sentry.init({ + dsn: DSN, + environment: "authoring-production", + release: import.meta.env.VITE_SENTRY_RELEASE as string | undefined, + // Errors only. Spans would triple the event volume for signal we don't act on. + tracesSampleRate: 0, + ignoreErrors: IGNORED, + beforeSend(event) { + // The preview iframe runs arbitrary authored and imported example code, so + // a compile error or a typo there is product output, not an application + // fault — see reportRuntimeError in App.tsx. Being cross-origin, the iframe + // cannot reach this window's error handlers anyway; this is the backstop for + // anything that arrives with frames pointing outside the app's own origin + // (the Sandpack bundler, or a container preview host). + const frames = event.exception?.values?.flatMap((v) => v.stacktrace?.frames ?? []) ?? []; + const foreign = frames.some( + (f) => f.filename?.startsWith("http") && !f.filename.startsWith(window.location.origin), + ); + return foreign ? null : event; + }, + }); +} + +/** Report a caught error that would otherwise be swallowed. No-op when gated off. */ +export function reportError(error: unknown, context: string): void { + if (!reportingEnabled) return; + Sentry.captureException(error, { tags: { context } }); +} + +export { Sentry }; diff --git a/runner/apps/authoring/vite.config.ts b/runner/apps/authoring/vite.config.ts index c051aa8b..d04e0a89 100644 --- a/runner/apps/authoring/vite.config.ts +++ b/runner/apps/authoring/vite.config.ts @@ -1,13 +1,43 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; import { fileURLToPath } from "node:url"; import path from "node:path"; const dir = path.dirname(fileURLToPath(import.meta.url)); const runnerRoot = path.resolve(dir, "../.."); +// Source maps are uploaded to Sentry and then deleted from dist/, so a production +// stack trace resolves to the original .tsx without shipping the maps publicly. +// The token is only present on the deploy workflow's build step: without it the +// plugin no-ops, which is what keeps PR CI and local builds unchanged. +const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN; +// The Cloudflare per-deploy id is not known at build time, so the frontend +// release is the commit — matched by the VITE_SENTRY_RELEASE define below. +const RELEASE = process.env.GITHUB_SHA; + export default defineConfig({ - plugins: [react()], + define: { + "import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(RELEASE ?? ""), + }, + build: { + // Only emitted when there is a token to upload them with. A build without one + // (local, PR CI) would otherwise leave ~12 MB of .map files in dist/ that the + // plugin's post-upload cleanup never runs to remove — and a manual + // `wrangler deploy` would publish them. + sourcemap: Boolean(SENTRY_AUTH_TOKEN), + }, + plugins: [ + react(), + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: SENTRY_AUTH_TOKEN, + disable: !SENTRY_AUTH_TOKEN, + release: RELEASE ? { name: RELEASE } : undefined, + sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, + }), + ], resolve: { alias: { // Compile the editor shell as first-party TSX (it ships TS source). diff --git a/runner/docs/run-and-deploy.md b/runner/docs/run-and-deploy.md index 28399b4f..df8044bf 100644 --- a/runner/docs/run-and-deploy.md +++ b/runner/docs/run-and-deploy.md @@ -146,6 +146,58 @@ Auth: repo secret **`CLOUDFLARE_API_TOKEN`** (account id is read from If routes move out of `wrangler.jsonc` into the deploy command (ADR-0020), add the corresponding `--route` flags to the API workflow's `wrangler deploy` step. +## Error monitoring (Sentry) + +Errors only — no tracing, no session replay, no profiling. One Sentry project +serves both surfaces, separated by `environment`: `authoring-production` (browser) +and `api-production` (Worker). + +**The DSN is committed, in two places**, because a DSN is a write-only ingest +endpoint that ships inside the JS bundle by construction — hiding it buys nothing, +and keeping it out of `wrangler secret` means changing it needs no Cloudflare +access. Abuse is bounded Sentry-side (allowed domains, inbound filters, spike +protection). + +- Browser: `VITE_SENTRY_DSN` in `apps/authoring/.env.production`. +- Worker: `ERROR_REPORTING_DSN` in the `vars` block of + `workers/api/wrangler.jsonc`. + +> The Worker var is **not** called `SENTRY_DSN` on purpose. `@sentry/cloudflare` +> falls back to reading `env.SENTRY_DSN` whenever the options object omits a dsn, +> which initialises the client straight from env and defeats the local-dev gate +> below. Under any other key that fallback finds nothing. + +**Nothing is reported outside production.** `.env.production` is committed and so +is loaded by every production-mode build — including CI's authoring build, whose +output Playwright then serves at `localhost:4173`. Both surfaces therefore gate on +a host: + +- browser: `window.location.hostname === "demos.handsontable.com"` + (`apps/authoring/src/sentry.ts`); +- Worker: `PREVIEW_HOST` matching the production host — the same prod-vs-local + switch Tier-2 preview URLs use, overridden in `workers/api/.dev.vars` + (`workers/api/src/index.ts`). + +**Preview-iframe errors are deliberately not reported.** The iframe runs arbitrary +authored and imported example code, so a compile error or a mid-keystroke typo is +product output, not an application fault. `reportRuntimeError` in +`apps/authoring/src/App.tsx` reports only container-engine faults — +`SessionStartError` (Tier-2 pool refusing a session; 410 excluded, that is normal +teardown) and `ContainerBootFailure` — and never anything from the Sandpack engine. + +**Releases.** The frontend release is the commit (`GITHUB_SHA`, injected as +`VITE_SENTRY_RELEASE`). The Worker release is Cloudflare's per-deploy version id +via the `version_metadata` binding, so the API deploy workflow needs no change. + +**`SENTRY_AUTH_TOKEN`** is the one real credential: a GitHub Actions repo secret, +used only at build time by `@sentry/vite-plugin` to upload browser source maps. +Never committed, not needed at runtime. Also set repo **variables** `SENTRY_ORG` +and `SENTRY_PROJECT` (slugs — not the numeric ids in the DSN). All three are +attached to the authoring build step of `deploy-runner-authoring.yml` only; the +`test` job reuses `ci.yml` and gets none of them, so PR builds neither emit source +maps nor create a release. Without the token the plugin disables itself and +`build.sourcemap` stays off, so no `.map` files are produced or published. + ## Login broker Authoring uses the Handsontable Google login broker (see diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index 03be0ffb..cfcb6c00 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@handsontable/demo-runtime': specifier: workspace:* version: link:../../packages/runtime + '@sentry/react': + specifier: ^10.68.0 + version: 10.68.0(react@19.2.7) fflate: specifier: ^0.8.2 version: 0.8.3 @@ -33,6 +36,9 @@ importers: specifier: ^19.0.0 version: 19.2.7(react@19.2.7) devDependencies: + '@sentry/vite-plugin': + specifier: ^5.4.0 + version: 5.4.0(rollup@4.62.2) '@types/node': specifier: ^22.0.0 version: 22.20.1 @@ -1021,6 +1027,78 @@ packages: cpu: [x64] os: [win32] + '@sentry/browser-utils@10.68.0': + resolution: {integrity: sha512-be8VtdjCngKc77cstJeV+gO15iH+blyXpBDk8yOehmtX4BkFO33mfTMNCWVR2LA0oOxjIHWRAhf77fIUEhzxPg==} + engines: {node: '>=18'} + + '@sentry/browser@10.68.0': + resolution: {integrity: sha512-8xVgk7oG2lajXnbXF6a7H1xMZ/U6icqSldHGzQu1+bajfrK8Gan9ULG/Xsj1VM1LlNeK6/7znDJ3u1jgvIwznw==} + engines: {node: '>=18'} + + '@sentry/bundler-plugins@10.68.0': + resolution: {integrity: sha512-XWv7asJuTTUSlacROvqcIFKuNxAf3PYL/mdjMBhBwp3rJ4vMkA73jqNPjqCTm726cHs/Y4yadbbFA/OZLPWzeg==} + engines: {node: '>= 18'} + peerDependencies: + rollup: '>=3.2.0' + webpack: '>=5.0.0' + peerDependenciesMeta: + rollup: + optional: true + webpack: + optional: true + + '@sentry/cli-darwin@2.58.6': + resolution: {integrity: sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.58.6': + resolution: {integrity: sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd, android] + + '@sentry/cli-linux-arm@2.58.6': + resolution: {integrity: sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd, android] + + '@sentry/cli-linux-i686@2.58.6': + resolution: {integrity: sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd, android] + + '@sentry/cli-linux-x64@2.58.6': + resolution: {integrity: sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd, android] + + '@sentry/cli-win32-arm64@2.58.6': + resolution: {integrity: sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@sentry/cli-win32-i686@2.58.6': + resolution: {integrity: sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.58.6': + resolution: {integrity: sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.58.6': + resolution: {integrity: sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==} + engines: {node: '>= 10'} + hasBin: true + '@sentry/cloudflare@10.68.0': resolution: {integrity: sha512-IcSjfncUSkisoZy66SbLMb4gtH+Jr9mS02DIqjuz3JsrKnkxFaQIBiosoDTgHlCMWFIEU5M4MpErYZ1dzJpo6Q==} engines: {node: '>=18'} @@ -1041,10 +1119,32 @@ packages: resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==} engines: {node: '>=18'} + '@sentry/feedback@10.68.0': + resolution: {integrity: sha512-XbdcXiBnpC3vgw46eHOPeD/ZQ+XzluP75ubdUcaPDW02hCh2nsdXiwjZ2DBImbpvIpTbJgjHf/sIlHWvcZJ2Mg==} + engines: {node: '>=18'} + + '@sentry/react@10.68.0': + resolution: {integrity: sha512-rIq4QR4ScMHHx9JJZv7Jgw31bMdUVJMx+ykHIJb7htjY6mj78sjKs+KpCsMDnvJxDhSmvftGM1KfKD4BggL7OQ==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + + '@sentry/replay-canvas@10.68.0': + resolution: {integrity: sha512-HusYcr+He+ohnUDHunYrc5St6vdDnBXpUAndnT5ReyUMVSCiWKfY3paXowU/0787HwYfxdcpZgwC5u79+XbEIg==} + engines: {node: '>=18'} + + '@sentry/replay@10.68.0': + resolution: {integrity: sha512-ZoG2n16vbkx4GWSCnLIqUUN9xlUmccQFbQ2US2rhruQeHTUnHl/ukr8NHOQXZaEbwKyMkX9bEMwfmZHJm+wSTQ==} + engines: {node: '>=18'} + '@sentry/server-utils@10.68.0': resolution: {integrity: sha512-lp1ZSs1auw7HrCESSYt/n4dOUaKPVUIAKyVYRk6xVr4bMIN3RPub/H5Wm7QPj9CpXVC5bQFvB6+dHZXz809oMg==} engines: {node: '>=18'} + '@sentry/vite-plugin@5.4.0': + resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==} + engines: {node: '>= 18'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -1112,6 +1212,10 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -1119,6 +1223,10 @@ packages: aws4fetch@1.0.20: resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1130,6 +1238,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + browserslist@4.28.5: resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1181,6 +1293,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + electron-to-chromium@1.5.389: resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} @@ -1224,6 +1340,10 @@ packages: fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1238,13 +1358,24 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + hono@4.12.28: resolution: {integrity: sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==} engines: {node: '>=16.9.0'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1262,6 +1393,14 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1281,6 +1420,14 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -1292,6 +1439,15 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -1299,6 +1455,22 @@ packages: outvariant@1.4.0: resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -1326,6 +1498,13 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -1388,6 +1567,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1455,6 +1637,17 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + workerd@1.20260706.1: resolution: {integrity: sha512-0DOmgysJiPZRwtlg9Bh70aDnHuVech70fKVM4721RbdfewL29ODxn4TpgkusybLYrQAkgHkW53SUt2AdkBa+Og==} engines: {node: '>=16'} @@ -1485,6 +1678,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -2196,6 +2393,79 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sentry/browser-utils@10.68.0': + dependencies: + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.68.0 + + '@sentry/browser@10.68.0': + dependencies: + '@sentry/browser-utils': 10.68.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.68.0 + '@sentry/feedback': 10.68.0 + '@sentry/replay': 10.68.0 + '@sentry/replay-canvas': 10.68.0 + + '@sentry/bundler-plugins@10.68.0(rollup@4.62.2)': + dependencies: + '@babel/core': 7.29.7 + '@sentry/cli': 2.58.6 + '@sentry/core': 10.68.0 + dotenv: 17.4.2 + find-up: 5.0.0 + glob: 13.0.6 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.62.2 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.58.6': + optional: true + + '@sentry/cli-linux-arm64@2.58.6': + optional: true + + '@sentry/cli-linux-arm@2.58.6': + optional: true + + '@sentry/cli-linux-i686@2.58.6': + optional: true + + '@sentry/cli-linux-x64@2.58.6': + optional: true + + '@sentry/cli-win32-arm64@2.58.6': + optional: true + + '@sentry/cli-win32-i686@2.58.6': + optional: true + + '@sentry/cli-win32-x64@2.58.6': + optional: true + + '@sentry/cli@2.58.6': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.58.6 + '@sentry/cli-linux-arm': 2.58.6 + '@sentry/cli-linux-arm64': 2.58.6 + '@sentry/cli-linux-i686': 2.58.6 + '@sentry/cli-linux-x64': 2.58.6 + '@sentry/cli-win32-arm64': 2.58.6 + '@sentry/cli-win32-i686': 2.58.6 + '@sentry/cli-win32-x64': 2.58.6 + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/cloudflare@10.68.0(@cloudflare/workers-types@4.20260702.1)(wrangler@4.108.0(@cloudflare/workers-types@4.20260702.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -2214,6 +2484,27 @@ snapshots: dependencies: '@sentry/conventions': 0.16.0 + '@sentry/feedback@10.68.0': + dependencies: + '@sentry/core': 10.68.0 + + '@sentry/react@10.68.0(react@19.2.7)': + dependencies: + '@sentry/browser': 10.68.0 + '@sentry/conventions': 0.16.0 + '@sentry/core': 10.68.0 + react: 19.2.7 + + '@sentry/replay-canvas@10.68.0': + dependencies: + '@sentry/core': 10.68.0 + '@sentry/replay': 10.68.0 + + '@sentry/replay@10.68.0': + dependencies: + '@sentry/browser-utils': 10.68.0 + '@sentry/core': 10.68.0 + '@sentry/server-utils@10.68.0': dependencies: '@apm-js-collab/code-transformer-bundler-plugins': 0.7.1 @@ -2224,6 +2515,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry/vite-plugin@5.4.0(rollup@4.62.2)': + dependencies: + '@sentry/bundler-plugins': 10.68.0(rollup@4.62.2) + transitivePeerDependencies: + - encoding + - rollup + - supports-color + - webpack + '@sindresorhus/is@7.2.0': {} '@speed-highlight/core@1.2.17': {} @@ -2313,16 +2613,28 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + astring@1.9.0: {} aws4fetch@1.0.20: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.10.42: {} blake3-wasm@2.1.5: {} + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.5: dependencies: baseline-browser-mapping: 2.10.42 @@ -2368,6 +2680,8 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.4.2: {} + electron-to-chromium@1.5.389: {} error-stack-parser-es@1.0.5: {} @@ -2446,6 +2760,11 @@ snapshots: fflate@0.8.3: {} + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + fsevents@2.3.2: optional: true @@ -2454,10 +2773,25 @@ snapshots: gensync@1.0.0-beta.2: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + hono@4.12.28: {} + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} + isexe@2.0.0: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -2466,6 +2800,12 @@ snapshots: kleur@4.1.5: {} + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2490,16 +2830,41 @@ snapshots: - bufferutil - utf-8-validate + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + minipass@7.1.3: {} + module-details-from-path@1.0.4: {} ms@2.1.3: {} nanoid@3.3.15: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.50: {} outvariant@1.4.0: {} + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -2522,6 +2887,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + progress@2.0.3: {} + + proxy-from-env@1.1.0: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -2623,6 +2992,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tr46@0.0.3: {} + tslib@2.8.1: optional: true @@ -2656,6 +3027,17 @@ snapshots: w3c-keyname@2.2.8: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + workerd@1.20260706.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260706.1 @@ -2685,6 +3067,8 @@ snapshots: yallist@3.1.1: {} + yocto-queue@0.1.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 From 0e2e7699d86fc55238c7aa7a51ed09ae0835673b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Mon, 27 Jul 2026 15:54:33 +0200 Subject: [PATCH 3/6] fix(runner): require all three SENTRY_* vars before enabling source-map upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the authoring instrumentation, from review. A token with no SENTRY_ORG/SENTRY_PROJECT left the plugin enabled with no upload target. Verified with fake credentials: sentry-cli fails with 401, and vite still exits 0 — so a partial setup degrades silently to unreadable minified traces rather than failing the deploy. The enable condition now requires all three, so a partial setup is a clean no-op; documented the failure mode and the fact that post-upload cleanup still runs (a failed upload never publishes .map files). Also normalises the release: the `define` substitutes "" when GITHUB_SHA is absent, and a release of "" would not match the SHA-named artifact bundle the plugin uploads, silently breaking map resolution. Separately confirmed the cancelled-mount reporting decision. reportRuntimeError fires even when the mount effect was cancelled, on the grounds that a refused session still failed; the risk was that rapid example/version switching would make this the highest-volume issue source. Raced POST /api/session against an immediate DELETE three times against the local worker: all three returned 410, which is the status the classifier drops. Co-Authored-By: Claude Opus 5 --- runner/apps/authoring/src/sentry.ts | 5 ++++- runner/apps/authoring/vite.config.ts | 21 ++++++++++++++------- runner/docs/run-and-deploy.md | 19 ++++++++++++++----- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/runner/apps/authoring/src/sentry.ts b/runner/apps/authoring/src/sentry.ts index 15ca1549..793304af 100644 --- a/runner/apps/authoring/src/sentry.ts +++ b/runner/apps/authoring/src/sentry.ts @@ -38,7 +38,10 @@ if (reportingEnabled) { Sentry.init({ dsn: DSN, environment: "authoring-production", - release: import.meta.env.VITE_SENTRY_RELEASE as string | undefined, + // `|| undefined` matters: the define below substitutes "" when GITHUB_SHA is + // absent, and a release of "" would not match the SHA-named artifact bundle + // the plugin uploads — source maps would silently stop resolving. + release: (import.meta.env.VITE_SENTRY_RELEASE as string | undefined) || undefined, // Errors only. Spans would triple the event volume for signal we don't act on. tracesSampleRate: 0, ignoreErrors: IGNORED, diff --git a/runner/apps/authoring/vite.config.ts b/runner/apps/authoring/vite.config.ts index d04e0a89..2295c3b7 100644 --- a/runner/apps/authoring/vite.config.ts +++ b/runner/apps/authoring/vite.config.ts @@ -9,9 +9,16 @@ const runnerRoot = path.resolve(dir, "../.."); // Source maps are uploaded to Sentry and then deleted from dist/, so a production // stack trace resolves to the original .tsx without shipping the maps publicly. -// The token is only present on the deploy workflow's build step: without it the -// plugin no-ops, which is what keeps PR CI and local builds unchanged. -const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN; +// These are only set on the deploy workflow's build step; without them the plugin +// no-ops, which is what keeps PR CI and local builds unchanged. +// +// All three are required together. With a token but no org/project the plugin is +// enabled and sentry-cli has no upload target — and an upload error fails the +// build, which in the deploy workflow means prod silently stops receiving +// frontend deploys. Treat a partial setup as "off" instead. +const uploadEnabled = Boolean( + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT, +); // The Cloudflare per-deploy id is not known at build time, so the frontend // release is the commit — matched by the VITE_SENTRY_RELEASE define below. const RELEASE = process.env.GITHUB_SHA; @@ -21,19 +28,19 @@ export default defineConfig({ "import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(RELEASE ?? ""), }, build: { - // Only emitted when there is a token to upload them with. A build without one + // Only emitted when there is somewhere to upload them. A build without upload // (local, PR CI) would otherwise leave ~12 MB of .map files in dist/ that the // plugin's post-upload cleanup never runs to remove — and a manual // `wrangler deploy` would publish them. - sourcemap: Boolean(SENTRY_AUTH_TOKEN), + sourcemap: uploadEnabled, }, plugins: [ react(), sentryVitePlugin({ org: process.env.SENTRY_ORG, project: process.env.SENTRY_PROJECT, - authToken: SENTRY_AUTH_TOKEN, - disable: !SENTRY_AUTH_TOKEN, + authToken: process.env.SENTRY_AUTH_TOKEN, + disable: !uploadEnabled, release: RELEASE ? { name: RELEASE } : undefined, sourcemaps: { filesToDeleteAfterUpload: ["dist/**/*.map"] }, }), diff --git a/runner/docs/run-and-deploy.md b/runner/docs/run-and-deploy.md index df8044bf..77e6d11b 100644 --- a/runner/docs/run-and-deploy.md +++ b/runner/docs/run-and-deploy.md @@ -192,11 +192,20 @@ via the `version_metadata` binding, so the API deploy workflow needs no change. **`SENTRY_AUTH_TOKEN`** is the one real credential: a GitHub Actions repo secret, used only at build time by `@sentry/vite-plugin` to upload browser source maps. Never committed, not needed at runtime. Also set repo **variables** `SENTRY_ORG` -and `SENTRY_PROJECT` (slugs — not the numeric ids in the DSN). All three are -attached to the authoring build step of `deploy-runner-authoring.yml` only; the -`test` job reuses `ci.yml` and gets none of them, so PR builds neither emit source -maps nor create a release. Without the token the plugin disables itself and -`build.sourcemap` stays off, so no `.map` files are produced or published. +and `SENTRY_PROJECT` (slugs — not the numeric ids in the DSN). + +**Create all three together, or none.** `vite.config.ts` enables the plugin only +when all three are present, because a token with no org/project has no upload +target. All three are attached to the authoring build step of +`deploy-runner-authoring.yml` only; the `test` job reuses `ci.yml` and gets none of +them, so PR builds neither emit source maps nor create a release. With upload off, +`build.sourcemap` is off too, so no `.map` files are produced or published. + +Note that a *failed* upload (bad token, wrong slug) does **not** fail the build — +`sentry-cli` logs the error and vite still exits 0. The symptom is unreadable +minified stack traces in Sentry, not a red deploy. If prod traces stop resolving, +check the deploy log for `[sentry-vite-plugin] Error`. The post-upload cleanup +still runs on failure, so a failed upload never publishes `.map` files. ## Login broker From 9b65ad2da16ee513f678606f48a0f397ba0343d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 28 Jul 2026 09:28:32 +0200 Subject: [PATCH 4/6] docs(runner): correct the Sentry auth-token scope and record the slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token scope was documented as `project:releases` + `org:read`, which is the older user-auth-token wording. An Organization Auth Token — what the bundler plugin wants — has a fixed `org:ci` scope covering source-map upload and release creation, with nothing to select. Also names the org/project slugs (handsoncode / demos) instead of telling the reader to go find them, and calls out the Deploy Token on the release-tracking page as the wrong credential: it drives the release webhook and cannot upload source maps. Co-Authored-By: Claude Opus 5 --- runner/docs/run-and-deploy.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/runner/docs/run-and-deploy.md b/runner/docs/run-and-deploy.md index 77e6d11b..aba7ee2a 100644 --- a/runner/docs/run-and-deploy.md +++ b/runner/docs/run-and-deploy.md @@ -191,8 +191,24 @@ via the `version_metadata` binding, so the API deploy workflow needs no change. **`SENTRY_AUTH_TOKEN`** is the one real credential: a GitHub Actions repo secret, used only at build time by `@sentry/vite-plugin` to upload browser source maps. -Never committed, not needed at runtime. Also set repo **variables** `SENTRY_ORG` -and `SENTRY_PROJECT` (slugs — not the numeric ids in the DSN). +Never committed, not needed at runtime. + +Create it as an **Organization Auth Token** — Sentry → Settings → Auth Tokens +(`https://sentry.io/settings/handsoncode/auth-tokens/`), value prefixed `sntrys_`, +shown once. Its scope is fixed at `org:ci` (Source Map Upload, Release Creation, +Code Mappings), which is exactly what the plugin needs and nothing more; there is +no scope checklist to get wrong. Not to be confused with the **Deploy Token** on a +project's release-tracking settings page — that one only drives the release webhook +and cannot upload source maps. + +Alongside it, repo **variables** (not secrets — neither is sensitive): + +| Variable | Value | +|---|---| +| `SENTRY_ORG` | `handsoncode` | +| `SENTRY_PROJECT` | `demos` | + +Slugs, not the numeric ids in the DSN (`o95873` / `4511806997135360`). **Create all three together, or none.** `vite.config.ts` enables the plugin only when all three are present, because a token with no org/project has no upload From 94e54f8814e9831d0690f4551c358cec2cbcdf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 28 Jul 2026 10:40:16 +0200 Subject: [PATCH 5/6] fix(runner): stop ignoreErrors from discarding deliberate error reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by Bugbot on #88, and it reproduces. `ignoreErrors` is applied by the event-filters integration, which processes every event — including explicit captureException calls — so the `/Failed to fetch/i` and `/Load failed/i` patterns were discarding exactly the failures reportError was added to surface. An offline broker /broker/userinfo call or a dead /api/versions raises `TypeError: Failed to fetch`; verified in a browser that with those patterns in ignoreErrors the reported event never reaches the transport. The patterns are only meant to suppress global onerror/onunhandledrejection noise, so they now live in beforeSend behind a `mechanism.handled === false` check. captureException sets `handled: true`, and mechanism is populated before beforeSend runs, so intentional reports are never matched. Verified in a browser: reportError with `TypeError: Failed to fetch` and `TypeError: Load failed` both reach the transport, while an unhandled ResizeObserver error and an unhandled `Failed to fetch` rejection are both dropped. Co-Authored-By: Claude Opus 5 --- runner/apps/authoring/src/sentry.ts | 34 +++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/runner/apps/authoring/src/sentry.ts b/runner/apps/authoring/src/sentry.ts index 793304af..16bedb99 100644 --- a/runner/apps/authoring/src/sentry.ts +++ b/runner/apps/authoring/src/sentry.ts @@ -29,10 +29,36 @@ export const reportingEnabled = /** * Browser noise that is never actionable: a benign layout-loop warning browsers - * surface as an error, and the two shapes an in-flight request takes when the - * user navigates away mid-fetch. + * surface as an error, plus the shapes an in-flight request takes when the user + * navigates away mid-fetch (`Failed to fetch` in Chrome, `Load failed` in Safari). + * + * These are matched ONLY against unhandled errors — see `isUnhandledNoise`. They + * must not go in `ignoreErrors`: that runs in the event-filters integration, which + * processes every event including explicit `captureException` calls, so + * `/Failed to fetch/` there would silently discard the offline broker and + * `/api/versions` failures that `reportError` exists to surface. */ -const IGNORED = [/^ResizeObserver loop/i, /^AbortError/i, /Failed to fetch/i, /Load failed/i]; +const UNHANDLED_NOISE = [ + /^ResizeObserver loop/i, + /^AbortError/i, + /Failed to fetch/i, + /Load failed/i, +]; + +/** + * True for a global `onerror` / `onunhandledrejection` event whose message is + * known noise. `mechanism.handled === false` is what distinguishes those from + * anything we reported on purpose (`captureException` sets `handled: true`), and + * it is populated before `beforeSend` runs. + */ +function isUnhandledNoise(event: Sentry.ErrorEvent): boolean { + const values = event.exception?.values ?? []; + return values.some( + (v) => + v.mechanism?.handled === false && + UNHANDLED_NOISE.some((re) => re.test(v.value ?? "") || re.test(v.type ?? "")), + ); +} if (reportingEnabled) { Sentry.init({ @@ -44,8 +70,8 @@ if (reportingEnabled) { release: (import.meta.env.VITE_SENTRY_RELEASE as string | undefined) || undefined, // Errors only. Spans would triple the event volume for signal we don't act on. tracesSampleRate: 0, - ignoreErrors: IGNORED, beforeSend(event) { + if (isUnhandledNoise(event)) return null; // The preview iframe runs arbitrary authored and imported example code, so // a compile error or a typo there is product output, not an application // fault — see reportRuntimeError in App.tsx. Being cross-origin, the iframe From 797cb9b247cbe078e4a30d2830e39ec4aca2fbfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Artur=20M=C4=99dryga=C5=82?= Date: Tue, 28 Jul 2026 10:47:38 +0200 Subject: [PATCH 6/6] fix(runner): report non-OK revoke responses, not just network errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by Bugbot on #88. demo-revoke only reported from the fetch .catch, so a 403/404/500 DELETE left `res` non-null, skipped the success branch, and reported nothing — while the list stayed exactly as it was with no error shown. That is the silent-failure case the reporting was added for, and the one path where the comment claiming to cover it was wrong. save/fork/embed/share already report after !res.ok; revoke now matches. Verified in a browser with the DELETE stubbed to 403: the row stays unrevoked, no error surfaces in the UI, and `revoke failed (403)` is reported with context demo-revoke. Before the change that produced nothing at all. Co-Authored-By: Claude Opus 5 --- runner/apps/authoring/src/MyDemos.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/runner/apps/authoring/src/MyDemos.tsx b/runner/apps/authoring/src/MyDemos.tsx index f8deb0e9..2d83e159 100644 --- a/runner/apps/authoring/src/MyDemos.tsx +++ b/runner/apps/authoring/src/MyDemos.tsx @@ -44,14 +44,18 @@ export function MyDemos({ apiBase, token, onClose }: MyDemosProps) { method: "DELETE", headers: token ? { Authorization: `Bearer ${token}` } : {}, }).catch((e: unknown) => { - // Nothing in the UI changes on failure, so without this the revoke just - // appears not to have happened. reportError(e, "demo-revoke"); return null; }); if (res && (res.status === 204 || res.ok)) { setDemos((cur) => (cur ? cur.map((d) => (d.id === id ? { ...d, revoked: 1 } : d)) : cur)); + return; } + // Every failure path leaves the list exactly as it was, with nothing in the UI + // to say why, so the revoke simply looks like it didn't happen. A non-OK + // response is as silent as a network error and needs reporting just the same — + // `res === null` was already reported in the catch above. + if (res) reportError(new Error(`revoke failed (${res.status})`), "demo-revoke"); } return (