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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/ui-sentry-release.yml
Original file line number Diff line number Diff line change
@@ -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@<short-sha>, 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
1 change: 1 addition & 0 deletions apps/gittensory-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/gittensory-ui/src/client.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
201 changes: 201 additions & 0 deletions apps/gittensory-ui/src/lib/browser-sentry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>)?.apiToken).toBe("[redacted]");
const nested = (scrubbed?.extra as Record<string, unknown>)?.nested as Record<string, unknown>;
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<string, unknown> };
expect(crumb.data.password).toBe("[redacted]");
expect(crumb.message).toBe("click");
});
});
Loading
Loading