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..2d83e159 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,17 +33,29 @@ 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) => { + 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 ( 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..16bedb99 --- /dev/null +++ b/runner/apps/authoring/src/sentry.ts @@ -0,0 +1,96 @@ +// 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, 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 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({ + dsn: DSN, + environment: "authoring-production", + // `|| 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, + 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 + // 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..2295c3b7 100644 --- a/runner/apps/authoring/vite.config.ts +++ b/runner/apps/authoring/vite.config.ts @@ -1,13 +1,50 @@ 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. +// 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; + export default defineConfig({ - plugins: [react()], + define: { + "import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(RELEASE ?? ""), + }, + build: { + // 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: uploadEnabled, + }, + plugins: [ + react(), + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + disable: !uploadEnabled, + 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..aba7ee2a 100644 --- a/runner/docs/run-and-deploy.md +++ b/runner/docs/run-and-deploy.md @@ -146,6 +146,83 @@ 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. + +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 +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 Authoring uses the Handsontable Google login broker (see diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index cce430a1..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 @@ -119,6 +125,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 +141,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 +868,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 +1027,124 @@ 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'} + 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/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'} @@ -1070,9 +1212,21 @@ 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 + 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==} @@ -1084,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} @@ -1135,12 +1293,19 @@ 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==} 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 +1320,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'} @@ -1167,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} @@ -1181,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==} @@ -1205,9 +1393,24 @@ 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==} + 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 +1420,17 @@ 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==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1225,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'} @@ -1232,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==} @@ -1259,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: @@ -1280,6 +1526,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 +1546,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==} @@ -1314,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==} @@ -1381,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'} @@ -1411,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==} @@ -1419,6 +1690,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 +2298,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 +2393,137 @@ 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 + '@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/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 + '@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 + + '@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': {} @@ -2185,14 +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 @@ -2238,10 +2680,14 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.4.2: {} + electron-to-chromium@1.5.389: {} 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,12 +2748,23 @@ 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 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 @@ -2316,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: {} @@ -2328,10 +2800,22 @@ 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 + 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,14 +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: {} @@ -2376,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 @@ -2418,6 +2933,8 @@ snapshots: scheduler@0.27.0: {} + semifies@1.0.0: {} + semver@6.3.1: {} semver@7.8.5: {} @@ -2455,6 +2972,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 @@ -2473,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 @@ -2506,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 @@ -2535,6 +3067,8 @@ snapshots: yallist@3.1.1: {} + yocto-queue@0.1.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3 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).