From fce3815cb5e910ab9316affbef1e593d0a01fa35 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:24:12 -0700 Subject: [PATCH] feat(ui): add browser Sentry with private source maps Route errors, unhandled browser exceptions, and failed app-level resource loads in the operator UI were previously invisible outside the Lovable dev sandbox (reportLovableError's window.__lovableEvents bridge is a production no-op). Adds apps/gittensory-ui/src/lib/browser-sentry.ts: a DSN-gated (VITE_SENTRY_DSN) browser Sentry integration. Off by default -- a complete no-op, @sentry/react is never even fetched when unset (a lazy dynamic import, the browser-bundle equivalent of the self-host backend's dynamic @sentry/node import). No Session Replay in this pass -- only init's default error-capture integrations are used. Every event is scrubbed before send: request cookies/headers/body data are stripped outright, secret-shaped keys/values (tokens, bearer headers, JWTs) are redacted recursively, local filesystem paths are replaced, and user is always dropped. Tags stay a small allowlist (route, release, environment, app_surface). Wired into client.ts (init before hydration) and __root.tsx's existing ErrorComponent (captureBrowserError alongside reportLovableError). Source maps are opt-in at the vite.config.ts level (SENTRY_BUILD_SOURCEMAPS=1, "hidden" mode) so the regular Cloudflare Workers Build pipeline -- which is external to this repo's GitHub Actions and already owns apps/gittensory-ui's production deploy -- never produces or serves a .map file. A new, independent ui-sentry-release.yml workflow (behind the same protected `release` environment as the Orb image release) does its own never-deployed build with source maps enabled and uploads them to Sentry as a release artifact whenever apps/gittensory-ui changes on main. Documented in the self-hosting operations docs, including the operator-side wiring needed (VITE_SENTRY_RELEASE must match this workflow's release id in the Cloudflare deploy's own build vars, an external system this repo doesn't control) for source-map symbolication to resolve. Closes #1737 --- .github/workflows/ui-sentry-release.yml | 114 ++++++++++ apps/gittensory-ui/package.json | 1 + apps/gittensory-ui/src/client.ts | 5 + .../src/lib/browser-sentry.test.ts | 201 +++++++++++++++++ apps/gittensory-ui/src/lib/browser-sentry.ts | 154 +++++++++++++ apps/gittensory-ui/src/routes/__root.tsx | 2 + .../routes/docs.self-hosting-operations.tsx | 36 +++ apps/gittensory-ui/vite.config.ts | 9 + package-lock.json | 212 ++++++++++++++++++ 9 files changed, 734 insertions(+) create mode 100644 .github/workflows/ui-sentry-release.yml create mode 100644 apps/gittensory-ui/src/lib/browser-sentry.test.ts create mode 100644 apps/gittensory-ui/src/lib/browser-sentry.ts diff --git a/.github/workflows/ui-sentry-release.yml b/.github/workflows/ui-sentry-release.yml new file mode 100644 index 0000000000..2e3b2a94f7 --- /dev/null +++ b/.github/workflows/ui-sentry-release.yml @@ -0,0 +1,114 @@ +# Browser Sentry source-map upload for the operator UI (#1737). apps/gittensory-ui's PRODUCTION build/deploy +# is NOT driven by GitHub Actions -- Cloudflare's own Workers Build git integration owns that (see +# apps/gittensory-ui/vite.config.ts's header comment). That regular build never enables source maps (vite.config.ts's +# SENTRY_BUILD_SOURCEMAPS gate), so `dist/client` never ships a `.map` file publicly. +# +# This workflow does its own INDEPENDENT build with source maps enabled, purely to upload them to Sentry as a +# release artifact -- it never deploys anything. Because Cloudflare Workers Build is external to this repo, the +# operator must set the SAME VITE_SENTRY_RELEASE value (gittensory-ui@, matching this workflow's own +# `release` output) in their Cloudflare deploy's build environment variables for a given commit, or Sentry +# events won't symbolicate against these maps -- see the "Enabling browser Sentry" self-host doc. +name: ui-sentry-release + +on: + push: + branches: [main] + paths: + - "apps/gittensory-ui/**" + - "src/signals/redaction.ts" + - ".github/workflows/ui-sentry-release.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ui-sentry-release + cancel-in-progress: true + +jobs: + upload-sourcemaps: + runs-on: ubuntu-latest + timeout-minutes: 15 + # Same protected-approval boundary as the MCP/engine release-please workflow and the Orb image release + # (Settings > Environments > release) -- a source-map upload is low-risk (nothing deploys), but it still + # writes into the shared Sentry org, so it stays behind the same gate as every other release-adjacent job. + environment: release + env: + SENTRY_ORG: jsonbored + SENTRY_UI_PROJECT: loopover-ui + SENTRY_CLI_PACKAGE: "@sentry/cli@3.6.0" + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build engine + ui-kit (workspace dependency order) + run: | + npm run build --workspace @loopover/engine + npm run ui:kit:build + + - name: Resolve release id + id: version + run: echo "release=gittensory-ui@${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + + - name: Build UI with source maps + working-directory: apps/gittensory-ui + env: + SENTRY_BUILD_SOURCEMAPS: "1" + VITE_SENTRY_RELEASE: ${{ steps.version.outputs.release }} + run: npm run build + + - name: Detect Sentry release token + id: sentry + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + if [ -n "$SENTRY_AUTH_TOKEN" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + # Fails advisory-soft (skip, not error) on the official repo: unlike the Orb image release, browser + # Sentry is a self-host-operator-facing OPT-IN feature (#1737's own "off by default"), not a required + # release artifact -- an unconfigured SENTRY_AUTH_TOKEN should not block every merge to main. + - name: Skip when Sentry isn't configured + if: steps.sentry.outputs.enabled != 'true' + run: echo "::notice::SENTRY_AUTH_TOKEN not configured in the release environment -- skipping source-map upload." + + - name: Upload Sentry source maps + if: steps.sentry.outputs.enabled == 'true' + working-directory: apps/gittensory-ui + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG || 'jsonbored' }} + SENTRY_PROJECT: ${{ vars.SENTRY_UI_PROJECT || 'loopover-ui' }} + SENTRY_URL: ${{ vars.SENTRY_URL }} + SENTRY_RELEASE: ${{ steps.version.outputs.release }} + SENTRY_REPOSITORY: ${{ github.repository }} + SENTRY_COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + test -n "$SENTRY_AUTH_TOKEN" + if [ -z "${SENTRY_URL:-}" ]; then unset SENTRY_URL; fi + npx -y "$SENTRY_CLI_PACKAGE" releases new "$SENTRY_RELEASE" + # Direct PUT (not `sentry-cli releases set-commits`) -- see release-selfhost.yml's identical + # comment: set-commits can silently leave zero associated commits against the GitHub App + # integration; a direct PUT to the release resource resolves the same repo/commit correctly. + curl -sf -X PUT \ + -H "Authorization: Bearer ${SENTRY_AUTH_TOKEN}" \ + -H "Content-Type: application/json" \ + "${SENTRY_URL:-https://sentry.io}/api/0/organizations/${SENTRY_ORG}/releases/$(node -e "process.stdout.write(encodeURIComponent(process.env.SENTRY_RELEASE))")/" \ + -d "$(node -e "process.stdout.write(JSON.stringify({commits:[{repository: process.env.SENTRY_REPOSITORY, id: process.env.SENTRY_COMMIT_SHA}]}))")" \ + >/dev/null + npx -y "$SENTRY_CLI_PACKAGE" sourcemaps inject dist/client + npx -y "$SENTRY_CLI_PACKAGE" sourcemaps upload --release="$SENTRY_RELEASE" --validate --wait --strict dist/client diff --git a/apps/gittensory-ui/package.json b/apps/gittensory-ui/package.json index 527f956b3b..8d950af418 100644 --- a/apps/gittensory-ui/package.json +++ b/apps/gittensory-ui/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-toggle": "^1.1.13", "@radix-ui/react-toggle-group": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.11", + "@sentry/react": "^10.63.0", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", diff --git a/apps/gittensory-ui/src/client.ts b/apps/gittensory-ui/src/client.ts index c69deace4a..1dea73f2ac 100644 --- a/apps/gittensory-ui/src/client.ts +++ b/apps/gittensory-ui/src/client.ts @@ -1,6 +1,11 @@ import * as React from "react"; import { StartClient } from "@tanstack/react-start/client"; import { hydrateRoot } from "react-dom/client"; +import { initBrowserSentry } from "./lib/browser-sentry"; + +// A no-op when VITE_SENTRY_DSN is unset (#1737) -- called before hydration so the earliest possible +// client-side errors are still covered once the (dynamically imported) SDK chunk resolves. +initBrowserSentry(); React.startTransition(() => { hydrateRoot( diff --git a/apps/gittensory-ui/src/lib/browser-sentry.test.ts b/apps/gittensory-ui/src/lib/browser-sentry.test.ts new file mode 100644 index 0000000000..f6dcb510d1 --- /dev/null +++ b/apps/gittensory-ui/src/lib/browser-sentry.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Event as SentryEvent } from "@sentry/react"; + +const mocks = vi.hoisted(() => { + const scope = { setTag: vi.fn() }; + return { + scope, + init: vi.fn(), + withScope: vi.fn((cb: (s: typeof scope) => void) => cb(scope)), + captureException: vi.fn(), + }; +}); +vi.mock("@sentry/react", () => ({ + init: mocks.init, + withScope: mocks.withScope, + captureException: mocks.captureException, +})); + +import { + captureBrowserError, + initBrowserSentry, + isBrowserSentryConfigured, + resetBrowserSentryForTest, + scrubBrowserEvent, +} from "./browser-sentry"; + +beforeEach(() => { + vi.clearAllMocks(); + resetBrowserSentryForTest(); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("isBrowserSentryConfigured", () => { + it("false when VITE_SENTRY_DSN is unset or blank", () => { + vi.stubEnv("VITE_SENTRY_DSN", ""); + expect(isBrowserSentryConfigured()).toBe(false); + vi.stubEnv("VITE_SENTRY_DSN", " "); + expect(isBrowserSentryConfigured()).toBe(false); + }); + + it("true when VITE_SENTRY_DSN is set", () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + expect(isBrowserSentryConfigured()).toBe(true); + }); +}); + +describe("initBrowserSentry", () => { + it("is a no-op (never calls Sentry.init) when VITE_SENTRY_DSN is unset", async () => { + vi.stubEnv("VITE_SENTRY_DSN", ""); + initBrowserSentry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mocks.init).not.toHaveBeenCalled(); + }); + + it("calls Sentry.init with the DSN/release/environment when configured", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + vi.stubEnv("VITE_SENTRY_RELEASE", "gittensory-ui@abc123"); + vi.stubEnv("VITE_SENTRY_ENVIRONMENT", "staging"); + initBrowserSentry(); + await vi.waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); + const options = mocks.init.mock.calls[0]![0] as Record; + expect(options.dsn).toBe("https://key@o0.ingest.sentry.io/0"); + expect(options.release).toBe("gittensory-ui@abc123"); + expect(options.environment).toBe("staging"); + expect(typeof options.beforeSend).toBe("function"); + expect(typeof options.beforeSendTransaction).toBe("function"); + }); + + it("defaults environment to production/development from import.meta.env.PROD when VITE_SENTRY_ENVIRONMENT is unset", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + vi.stubEnv("VITE_SENTRY_ENVIRONMENT", ""); + initBrowserSentry(); + await vi.waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); + const options = mocks.init.mock.calls[0]![0] as Record; + expect(["production", "development"]).toContain(options.environment); + }); + + it("#1737: never configures Session Replay or performance tracing -- error tracking only", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + initBrowserSentry(); + await vi.waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); + const options = mocks.init.mock.calls[0]![0] as Record; + expect(options.integrations).toBeUndefined(); + expect(options.tracesSampleRate).toBeUndefined(); + expect(options.replaysSessionSampleRate).toBeUndefined(); + expect(options.replaysOnErrorSampleRate).toBeUndefined(); + }); + + it("beforeSend runs the event through scrubbing + tagging before Sentry would send it", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + initBrowserSentry(); + await vi.waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); + const options = mocks.init.mock.calls[0]![0] as { + beforeSend: (e: SentryEvent) => SentryEvent | null; + }; + const result = options.beforeSend({ + user: { id: "1" }, + extra: { token: "shh" }, + } as SentryEvent); + expect(result?.user).toBeUndefined(); + expect(result?.extra?.token).toBe("[redacted]"); + expect(result?.tags?.app_surface).toBe("operator_ui"); + }); +}); + +describe("captureBrowserError", () => { + it("is a no-op before Sentry has initialized", () => { + captureBrowserError(new Error("boom"), { boundary: "test" }); + expect(mocks.captureException).not.toHaveBeenCalled(); + }); + + it("captures with a boundary tag once initialized", async () => { + vi.stubEnv("VITE_SENTRY_DSN", "https://key@o0.ingest.sentry.io/0"); + initBrowserSentry(); + await vi.waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); + const error = new Error("boom"); + captureBrowserError(error, { boundary: "tanstack_root_error_component" }); + expect(mocks.scope.setTag).toHaveBeenCalledWith("boundary", "tanstack_root_error_component"); + expect(mocks.captureException).toHaveBeenCalledWith(error); + }); +}); + +describe("scrubBrowserEvent", () => { + it("drops user unconditionally -- no PII ever leaves the browser", () => { + const event = { user: { id: "123", email: "a@b.com" } } as SentryEvent; + expect(scrubBrowserEvent(event)?.user).toBeUndefined(); + }); + + it("strips cookies, headers, and body data from request", () => { + const event = { + request: { + url: "https://x", + cookies: { session: "abc" }, + headers: { Authorization: "Bearer x" }, + data: { password: "hunter2" }, + }, + } as SentryEvent; + const scrubbed = scrubBrowserEvent(event); + expect(scrubbed?.request).toEqual({ url: "https://x" }); + }); + + it("redacts secret-shaped keys in contexts/extra/tags, including nested", () => { + const event = { extra: { apiToken: "shh", nested: { authorization: "shh2" } } } as SentryEvent; + const scrubbed = scrubBrowserEvent(event); + expect((scrubbed?.extra as Record)?.apiToken).toBe("[redacted]"); + const nested = (scrubbed?.extra as Record)?.nested as Record; + expect(nested?.authorization).toBe("[redacted]"); + }); + + it("redacts a secret-shaped VALUE even under an innocuous key", () => { + const event = { message: "call failed with token gts_abcdefghijklmnopqrstuvwx" } as SentryEvent; + expect(scrubBrowserEvent(event)?.message).not.toContain("gts_abcdefghijklmnopqrstuvwx"); + }); + + it("redacts a JWT-shaped value", () => { + const jwt = + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"; + const event = { message: `auth failed: ${jwt}` } as SentryEvent; + expect(scrubBrowserEvent(event)?.message).not.toContain(jwt); + }); + + it("redacts a local filesystem path (a dev-mode stack trace concern, not just secrets)", () => { + const event = { + message: "failed to load /Users/dev/secret-project/config.json", + } as SentryEvent; + expect(scrubBrowserEvent(event)?.message).not.toContain("/Users/dev/secret-project"); + }); + + it("caps recursion depth instead of infinitely descending a deeply nested value", () => { + let deep: unknown = "leaf"; + for (let i = 0; i < 10; i += 1) deep = { child: deep }; + const event = { extra: { deep } } as unknown as SentryEvent; + const scrubbed = scrubBrowserEvent(event); + const serialized = JSON.stringify(scrubbed); + expect(serialized).not.toContain("leaf"); + expect(serialized).toContain("[redacted]"); + }); + + it("fails closed: returns null instead of throwing when scrubbing itself errors", () => { + const poison = {}; + Object.defineProperty(poison, "user", { + enumerable: true, + get() { + throw new Error("boom"); + }, + }); + expect(scrubBrowserEvent(poison as SentryEvent)).toBeNull(); + }); + + it("preserves breadcrumbs array shape while scrubbing each entry", () => { + const event = { + breadcrumbs: [{ message: "click", data: { password: "x" } }], + } as unknown as SentryEvent; + const scrubbed = scrubBrowserEvent(event); + const crumb = scrubbed?.breadcrumbs?.[0] as { message: string; data: Record }; + expect(crumb.data.password).toBe("[redacted]"); + expect(crumb.message).toBe("click"); + }); +}); diff --git a/apps/gittensory-ui/src/lib/browser-sentry.ts b/apps/gittensory-ui/src/lib/browser-sentry.ts new file mode 100644 index 0000000000..711a24bc24 --- /dev/null +++ b/apps/gittensory-ui/src/lib/browser-sentry.ts @@ -0,0 +1,154 @@ +// Browser Sentry (issue #1737): the operator UI's own client-side error tracking -- a separate integration +// from the self-host backend's Node Sentry (src/selfhost/sentry.ts) and the review-enrichment service's +// (review-enrichment/src/sentry.ts). Three independent deploy surfaces, three independent DSN-gated +// integrations. Opt-in: a complete no-op when VITE_SENTRY_DSN is unset -- no SDK init, no event traffic. +// `@sentry/react` is dynamically imported inside the DSN gate so a DSN-less build never fetches its chunk at +// all (Vite code-splits a dynamic import into its own lazily-loaded chunk), the browser-bundle equivalent of +// sentry.ts's "never enters a bundle that doesn't need it." NO Session Replay in this pass: only `init`'s +// default error-capture integrations are used -- `replayIntegration`/`@sentry/replay` are never imported or +// referenced anywhere in this module. +import { PUBLIC_LOCAL_PATH_SCRUB_PATTERN } from "../../../../src/signals/redaction"; +import type { Event as SentryEvent } from "@sentry/react"; + +type SentryReactNs = typeof import("@sentry/react"); + +let Sentry: SentryReactNs | undefined; +let active = false; + +const SECRET_KEY = + /(token|secret|key|password|passwd|authorization|auth|dsn|cookie|bearer|credential|private|session)/i; +const SECRET_VALUE = new RegExp( + [ + String.raw`gh[opsru]_[A-Za-z0-9_]{20,}`, + String.raw`sk-[A-Za-z0-9_-]{20,}`, + String.raw`(?:gts|orbenr|orbsec)_[A-Za-z0-9_]{20,}`, + String.raw`Bearer\s+[A-Za-z0-9._~+/=-]{12,}`, + String.raw`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`, + ].join("|"), + "gi", +); +const REDACTED = "[redacted]"; +const MAX_SCRUB_DEPTH = 6; + +function scrubString(value: string): string { + return value + .replace(SECRET_VALUE, REDACTED) + .replace(PUBLIC_LOCAL_PATH_SCRUB_PATTERN, "[local-path]"); +} + +function scrubValue(value: unknown, depth: number): unknown { + if (typeof value === "string") return scrubString(value); + if (!value || typeof value !== "object") return value; + if (depth >= MAX_SCRUB_DEPTH) return REDACTED; + if (Array.isArray(value)) return value.map((item) => scrubValue(item, depth + 1)); + return Object.fromEntries( + Object.entries(value as Record).map(([key, nested]) => [ + key, + SECRET_KEY.test(key) ? REDACTED : scrubValue(nested, depth + 1), + ]), + ); +} + +/** Strip everything the issue explicitly calls out (headers, cookies, auth/session, request bodies), then + * recursively redact secret-shaped keys/values everywhere else, and unconditionally drop `user` -- no PII + * ever leaves the browser. Returns `null` (drops the whole event) on any scrubbing failure, matching + * sentry.ts's fail-closed discipline: better to lose one event than risk shipping unscrubbed data. */ +export function scrubBrowserEvent(event: T): T | null { + try { + const safe = { ...event } as Record; + delete safe.user; + if (safe.request && typeof safe.request === "object") { + const request = { ...(safe.request as Record) }; + delete request.cookies; + delete request.headers; + delete request.data; + safe.request = request; + } + for (const key of ["contexts", "extra", "tags"] as const) { + if (safe[key]) safe[key] = scrubValue(safe[key], 0); + } + if (Array.isArray(safe.breadcrumbs)) { + safe.breadcrumbs = safe.breadcrumbs.map((crumb) => scrubValue(crumb, 0)); + } + if (safe.message && typeof safe.message === "string") safe.message = scrubString(safe.message); + if (safe.exception && typeof safe.exception === "object") + safe.exception = scrubValue(safe.exception, 0); + return safe as T; + } catch { + return null; + } +} + +/** Low-cardinality tag allowlist (#1737's "safe tags... avoid high-cardinality or sensitive tags"): route + * (pathname only, never query/fragment), release, environment, and a fixed app-surface identifier. Mutates + * `event.tags` directly (an event-processor's job), not scope -- Sentry already applies `release`/ + * `environment` from `init()`'s own options, but setting them as explicit tags too keeps them queryable + * alongside the others without relying on Sentry's separate release/environment filter UI. */ +function applyBrowserTags( + event: T, + release: string | undefined, + environment: string, +): T { + const tags: Record = { ...event.tags, app_surface: "operator_ui", environment }; + if (release) tags.release = release; + if (typeof window !== "undefined") tags.route = window.location.pathname; + return { ...event, tags }; +} + +/** True when VITE_SENTRY_DSN is configured -- the same gate {@link initBrowserSentry} uses, exposed so + * callers (e.g. a settings/about page) can show whether browser error tracking is active without importing + * the SDK. */ +export function isBrowserSentryConfigured(): boolean { + return Boolean(import.meta.env.VITE_SENTRY_DSN?.trim()); +} + +/** Initialize browser Sentry. No-op (never imports `@sentry/react`) when VITE_SENTRY_DSN is unset. Call once, + * before hydration, from the client entry point. */ +export function initBrowserSentry(): void { + const dsn = import.meta.env.VITE_SENTRY_DSN?.trim(); + if (!dsn) return; + const release = import.meta.env.VITE_SENTRY_RELEASE?.trim() || undefined; + const environment = + import.meta.env.VITE_SENTRY_ENVIRONMENT?.trim() || + (import.meta.env.PROD ? "production" : "development"); + void import("@sentry/react").then((mod) => { + Sentry = mod; + Sentry.init({ + dsn, + release, + environment, + // Session Replay is explicitly out of scope for this pass (#1737) -- default integrations only, no + // replayIntegration, no performance tracing (tracesSampleRate omitted -- this is error tracking only). + // Inline (not a shared named function) so each hook's event parameter infers its own type + // (ErrorEvent vs. the internal TransactionEvent) from Sentry.init's own call signature. + beforeSend: (event) => { + const scrubbed = scrubBrowserEvent(event); + return scrubbed ? applyBrowserTags(scrubbed, release, environment) : null; + }, + beforeSendTransaction: (event) => { + const scrubbed = scrubBrowserEvent(event); + return scrubbed ? applyBrowserTags(scrubbed, release, environment) : null; + }, + }); + active = true; + }); +} + +/** Capture a route/render error. No-op when Sentry is off or not yet initialized (the dynamic import in + * {@link initBrowserSentry} may still be in flight for the very first paint's error, which is an acceptable + * gap -- see this file's tests). `boundary` becomes a low-cardinality tag identifying which error boundary + * caught it, mirroring sentry.ts's `eventName`-as-fingerprint discipline for grouping. */ +export function captureBrowserError(error: unknown, context: { boundary: string }): void { + if (!active || !Sentry) return; + Sentry.withScope((scope) => { + scope.setTag("boundary", context.boundary); + Sentry!.captureException(error); + }); +} + +/** Reset module-level init state between tests -- `active`/`Sentry` otherwise persist across every test in a + * file, since {@link initBrowserSentry} is designed to run exactly once per real page load. */ +export function resetBrowserSentryForTest(): void { + Sentry = undefined; + active = false; +} diff --git a/apps/gittensory-ui/src/routes/__root.tsx b/apps/gittensory-ui/src/routes/__root.tsx index c7a92681ea..24e9bd24d4 100644 --- a/apps/gittensory-ui/src/routes/__root.tsx +++ b/apps/gittensory-ui/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, type ReactNode } from "react"; import appCss from "../styles.css?url"; import { reportLovableError } from "../lib/lovable-error-reporting"; +import { captureBrowserError } from "../lib/browser-sentry"; import { SiteHeader } from "@/components/site/site-header"; import { SiteFooter } from "@/components/site/site-footer"; import { THEME_NOFLASH_SCRIPT } from "@/components/site/theme-toggle"; @@ -48,6 +49,7 @@ function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) { const router = useRouter(); useEffect(() => { reportLovableError(error, { boundary: "tanstack_root_error_component" }); + captureBrowserError(error, { boundary: "tanstack_root_error_component" }); }, [error]); return ( diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx index 0a272b7e07..fa79197d5b 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx @@ -590,6 +590,42 @@ SENTRY_RELEASE=gittensory-selfhost@2026.07.05 phones home to a maintainer-owned project unless you configure one. +

Browser Sentry (operator UI)

+

+ The operator UI (apps/gittensory-ui) has its own, separate client-side Sentry + integration for route errors, unhandled browser exceptions, and failed app-level resource + loads — independent of the backend's SENTRY_DSN above.{" "} + Opt-in and off by default: leave VITE_SENTRY_DSN unset for a + complete no-op — the SDK is never even fetched by the browser. Session Replay is not + enabled. +

+ `} + /> +

+ Every browser event is scrubbed before it leaves the box: request cookies, headers, and body + data are stripped outright; secret-shaped keys and values (tokens, bearer headers, JWTs) are + redacted recursively; local filesystem paths are replaced with a placeholder; and{" "} + user is always dropped — no PII is ever sent. Tags stay a small, + low-cardinality set: route (pathname only), release,{" "} + environment, and app_surface. +

+ + The UI's production build/deploy runs through Cloudflare's own Workers Build git + integration, not GitHub Actions, so VITE_SENTRY_DSN/ + VITE_SENTRY_RELEASE are configured as Cloudflare build environment variables, + not repo secrets. Source maps are never produced by that regular build or served publicly — + the .github/workflows/ui-sentry-release.yml workflow (behind the same + maintainer-only release environment gate as the Orb image release) does an + independent, never-deployed build with source maps enabled and uploads them to Sentry as a + release artifact whenever apps/gittensory-ui changes on main. + +

Sentry context taxonomy

Self-host Sentry events carry a small, scrubbed taxonomy so operators can filter by diff --git a/apps/gittensory-ui/vite.config.ts b/apps/gittensory-ui/vite.config.ts index b4614e7f8e..e6aa27e863 100644 --- a/apps/gittensory-ui/vite.config.ts +++ b/apps/gittensory-ui/vite.config.ts @@ -7,6 +7,14 @@ import { defineConfig } from "@lovable.dev/vite-tanstack-config"; const shouldBuildNitro = process.env.npm_lifecycle_event?.startsWith("build") ?? false; +// Source maps (#1737) are OFF by default -- the regular `ui:build`/Cloudflare Workers Build pipeline that +// serves `dist/client` publicly must never produce `.map` files, or a static-asset deploy would serve them. +// Only the dedicated Sentry source-map-upload workflow (.github/workflows/ui-sentry-release.yml) sets +// SENTRY_BUILD_SOURCEMAPS=1 for its own separate, never-deployed build. "hidden" emits `.map` files on disk +// (for that workflow to read and upload) without embedding a `//# sourceMappingURL` comment in the shipped +// JS, so even if this var were ever set by mistake on a real deploy build, the maps would not be +// auto-discoverable from the public bundle. +const sentryBuildSourcemaps = process.env.SENTRY_BUILD_SOURCEMAPS === "1"; const vendorChunks = [ ["react-vendor", ["/node_modules/react", "/node_modules/react-dom"]], ["tanstack-vendor", ["/node_modules/@tanstack"]], @@ -50,6 +58,7 @@ export default defineConfig({ : false, vite: { build: { + ...(sentryBuildSourcemaps ? { sourcemap: "hidden" as const } : {}), rollupOptions: { output: { manualChunks }, }, diff --git a/package-lock.json b/package-lock.json index b56d93f00f..ba2ab1f07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -513,6 +513,7 @@ "@radix-ui/react-toggle": "^1.1.13", "@radix-ui/react-toggle-group": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.11", + "@sentry/react": "^10.63.0", "@tailwindcss/vite": "^4.3.2", "@tanstack/react-query": "^5.101.2", "@tanstack/react-router": "^1.170.17", @@ -5481,6 +5482,78 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry/browser": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.65.0.tgz", + "integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.65.0", + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0", + "@sentry/feedback": "10.65.0", + "@sentry/replay": "10.65.0", + "@sentry/replay-canvas": "10.65.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.65.0.tgz", + "integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/browser-utils/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/conventions": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", @@ -5499,6 +5572,39 @@ "node": ">=18" } }, + "node_modules/@sentry/feedback": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.65.0.tgz", + "integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.65.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/feedback/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/node": { "version": "10.63.0", "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.63.0.tgz", @@ -5577,6 +5683,112 @@ "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, + "node_modules/@sentry/react": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.65.0.tgz", + "integrity": "sha512-fvHxpuvid0wt9/1N3itcKDyKOjqmYHw3MBSt5Pki3Iz4CL2CmgQp9ZFv/CA7UhMnEvn2Gd+Qc2UKxujZWd8FLg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.65.0", + "@sentry/conventions": "^0.15.1", + "@sentry/core": "10.65.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.65.0.tgz", + "integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.65.0", + "@sentry/core": "10.65.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.65.0.tgz", + "integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.65.0", + "@sentry/replay": "10.65.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/replay-canvas/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/core": { + "version": "10.65.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.65.0.tgz", + "integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/server-utils": { "version": "10.63.0", "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.63.0.tgz",