From 82cacca4b3a58e82d67fc65a35459c252e3b9a10 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 07:38:07 -0700 Subject: [PATCH 01/10] feat(local): Add daemon-managed telemetry sessions and viewer Port getsentry/cli#1578 to Toolkit with scoped capabilities, portable daemon lifecycle, and native/npm E2E coverage. Co-Authored-By: GPT-6 Codex --- .github/workflows/local.yml | 59 + AGENTS.md | 3 +- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/local.md | 26 +- apps/local/.gitignore | 24 + apps/local/.oxlintrc.json | 8 + apps/local/components.json | 21 + apps/local/index.html | 14 + apps/local/package.json | 55 + apps/local/public/favicon.svg | 53 + apps/local/public/sentry-cli-light.svg | 8 + apps/local/public/sentry-cli.svg | 8 + apps/local/src/App.integration.test.tsx | 1447 +++++++++++++++++ apps/local/src/App.tsx | 1311 +++++++++++++++ .../src/components/connection-landing.tsx | 139 ++ .../components/event-command-dialog.test.tsx | 149 ++ .../src/components/event-command-dialog.tsx | 157 ++ apps/local/src/components/json-view.test.tsx | 23 + apps/local/src/components/json-view.tsx | 117 ++ .../src/components/receiver-controls.tsx | 160 ++ apps/local/src/components/theme-provider.tsx | 6 + apps/local/src/components/theme-toggle.tsx | 21 + apps/local/src/components/trace-waterfall.tsx | 278 ++++ apps/local/src/components/trace-workspace.tsx | 118 ++ apps/local/src/components/ui/badge.tsx | 34 + apps/local/src/components/ui/button.tsx | 48 + apps/local/src/components/ui/card.tsx | 38 + apps/local/src/components/ui/command.tsx | 184 +++ apps/local/src/components/ui/dialog.tsx | 156 ++ apps/local/src/index.css | 97 ++ apps/local/src/lib/presentation.test.ts | 25 + apps/local/src/lib/presentation.ts | 31 + apps/local/src/lib/spotlight.test.ts | 243 +++ apps/local/src/lib/spotlight.ts | 289 ++++ apps/local/src/lib/telemetry-store.test.ts | 79 + apps/local/src/lib/telemetry-store.ts | 118 ++ apps/local/src/lib/trace-model.test.ts | 210 +++ apps/local/src/lib/trace-model.ts | 263 +++ apps/local/src/lib/utils.ts | 6 + apps/local/src/lib/workspace-navigation.ts | 143 ++ apps/local/src/lib/workspace-query.test.tsx | 55 + apps/local/src/lib/workspace-query.ts | 27 + apps/local/src/lib/workspace.test.ts | 36 + apps/local/src/lib/workspace.ts | 117 ++ apps/local/src/main.tsx | 20 + apps/local/src/router.test.tsx | 37 + apps/local/src/router.tsx | 34 + .../src/routes/local-workspace-context.ts | 24 + .../src/routes/local-workspace-route.tsx | 56 + apps/local/src/runtime-dependencies.test.ts | 13 + apps/local/src/test-setup.ts | 19 + apps/local/src/vercel-config.test.ts | 25 + apps/local/tsconfig.app.json | 31 + apps/local/tsconfig.json | 14 + apps/local/tsconfig.node.json | 23 + apps/local/vercel.json | 10 + apps/local/vite.config.ts | 22 + docs/cli/README.md | 2 + docs/cli/local-control-plane.md | 52 + .../sentry-cli/skills/sentry-cli/SKILL.md | 13 + .../skills/sentry-cli/references/local.md | 75 + .../cli/src/commands/local/control-plane.ts | 405 +++++ packages/cli/src/commands/local/daemon.ts | 470 ++++++ packages/cli/src/commands/local/index.ts | 16 +- packages/cli/src/commands/local/management.ts | 422 +++++ packages/cli/src/commands/local/run.ts | 97 +- packages/cli/src/commands/local/server.ts | 64 +- .../cli/src/commands/local/session-store.ts | 200 +++ packages/cli/src/commands/local/ui.ts | 11 +- .../test/commands/local/control-plane.test.ts | 377 +++++ .../commands/local/daemon-invocation.test.ts | 52 + .../commands/local/daemon-lifecycle.test.ts | 90 + .../test/commands/local/daemon-state.test.ts | 39 + .../test/commands/local/session-store.test.ts | 73 + packages/cli/test/e2e/local.test.ts | 340 ++++ packages/cli/test/fixture.ts | 3 + pnpm-lock.yaml | 1282 +++++++++++++-- 77 files changed, 10673 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/local.yml create mode 100644 apps/local/.gitignore create mode 100644 apps/local/.oxlintrc.json create mode 100644 apps/local/components.json create mode 100644 apps/local/index.html create mode 100644 apps/local/package.json create mode 100644 apps/local/public/favicon.svg create mode 100644 apps/local/public/sentry-cli-light.svg create mode 100644 apps/local/public/sentry-cli.svg create mode 100644 apps/local/src/App.integration.test.tsx create mode 100644 apps/local/src/App.tsx create mode 100644 apps/local/src/components/connection-landing.tsx create mode 100644 apps/local/src/components/event-command-dialog.test.tsx create mode 100644 apps/local/src/components/event-command-dialog.tsx create mode 100644 apps/local/src/components/json-view.test.tsx create mode 100644 apps/local/src/components/json-view.tsx create mode 100644 apps/local/src/components/receiver-controls.tsx create mode 100644 apps/local/src/components/theme-provider.tsx create mode 100644 apps/local/src/components/theme-toggle.tsx create mode 100644 apps/local/src/components/trace-waterfall.tsx create mode 100644 apps/local/src/components/trace-workspace.tsx create mode 100644 apps/local/src/components/ui/badge.tsx create mode 100644 apps/local/src/components/ui/button.tsx create mode 100644 apps/local/src/components/ui/card.tsx create mode 100644 apps/local/src/components/ui/command.tsx create mode 100644 apps/local/src/components/ui/dialog.tsx create mode 100644 apps/local/src/index.css create mode 100644 apps/local/src/lib/presentation.test.ts create mode 100644 apps/local/src/lib/presentation.ts create mode 100644 apps/local/src/lib/spotlight.test.ts create mode 100644 apps/local/src/lib/spotlight.ts create mode 100644 apps/local/src/lib/telemetry-store.test.ts create mode 100644 apps/local/src/lib/telemetry-store.ts create mode 100644 apps/local/src/lib/trace-model.test.ts create mode 100644 apps/local/src/lib/trace-model.ts create mode 100644 apps/local/src/lib/utils.ts create mode 100644 apps/local/src/lib/workspace-navigation.ts create mode 100644 apps/local/src/lib/workspace-query.test.tsx create mode 100644 apps/local/src/lib/workspace-query.ts create mode 100644 apps/local/src/lib/workspace.test.ts create mode 100644 apps/local/src/lib/workspace.ts create mode 100644 apps/local/src/main.tsx create mode 100644 apps/local/src/router.test.tsx create mode 100644 apps/local/src/router.tsx create mode 100644 apps/local/src/routes/local-workspace-context.ts create mode 100644 apps/local/src/routes/local-workspace-route.tsx create mode 100644 apps/local/src/runtime-dependencies.test.ts create mode 100644 apps/local/src/test-setup.ts create mode 100644 apps/local/src/vercel-config.test.ts create mode 100644 apps/local/tsconfig.app.json create mode 100644 apps/local/tsconfig.json create mode 100644 apps/local/tsconfig.node.json create mode 100644 apps/local/vercel.json create mode 100644 apps/local/vite.config.ts create mode 100644 docs/cli/local-control-plane.md create mode 100644 packages/cli/src/commands/local/control-plane.ts create mode 100644 packages/cli/src/commands/local/daemon.ts create mode 100644 packages/cli/src/commands/local/management.ts create mode 100644 packages/cli/src/commands/local/session-store.ts create mode 100644 packages/cli/test/commands/local/control-plane.test.ts create mode 100644 packages/cli/test/commands/local/daemon-invocation.test.ts create mode 100644 packages/cli/test/commands/local/daemon-lifecycle.test.ts create mode 100644 packages/cli/test/commands/local/daemon-state.test.ts create mode 100644 packages/cli/test/commands/local/session-store.test.ts create mode 100644 packages/cli/test/e2e/local.test.ts diff --git a/.github/workflows/local.yml b/.github/workflows/local.yml new file mode 100644 index 000000000..2a90a09ac --- /dev/null +++ b/.github/workflows/local.yml @@ -0,0 +1,59 @@ +name: Local E2E + +on: + pull_request: + paths: + - "apps/local/**" + - "packages/cli/src/commands/local/**" + - "packages/cli/test/commands/local/**" + - "packages/cli/test/e2e/local.test.ts" + - "pnpm-lock.yaml" + - ".github/workflows/local.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + local: + name: Local (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + binary: sentry-linux-x64 + - os: macos-latest + binary: sentry-darwin-arm64 + - os: windows-latest + binary: sentry-windows-x64.exe + env: + SENTRY_CLIENT_ID: test-client-id + SENTRY_CLI_NO_TELEMETRY: "1" + FOSSILIZE_SIGN: "n" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22.23.1" + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Viewer integration tests + run: pnpm --filter local test + - name: Bundle the CLI + run: pnpm --filter sentry bundle + - name: NPM daemon E2E + env: + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist/bin.cjs + run: pnpm --filter sentry exec vitest run test/e2e/local.test.ts + - name: Build native CLI + run: pnpm --filter sentry build + - name: Native daemon E2E + env: + SENTRY_CLI_BINARY: ${{ github.workspace }}/packages/cli/dist-bin/${{ matrix.binary }} + run: pnpm --filter sentry exec vitest run test/e2e/local.test.ts diff --git a/AGENTS.md b/AGENTS.md index 62e319c6a..73ded3708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,8 @@ Toolkit contains the Sentry CLI, the Sentry MCP server, and shared code. Product ``` toolkit/ ├── apps/ -│ └── cli-docs/ # CLI documentation site and generated command pages +│ ├── cli-docs/ # CLI documentation site and generated command pages +│ └── local/ # Sentry Local browser viewer ├── packages/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) │ ├── toolkit-core/ # Shared CLI/MCP primitives diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 21665597c..df3224892 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -69,7 +69,7 @@ toolkit/ │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view -│ │ │ ├── local/ # run, serve +│ │ │ ├── local/ # close, create, list, reset, restart, run, serve, start, status, stop, view │ │ │ ├── log/ # list, view │ │ │ ├── monitor/ # list, run │ │ │ ├── org/ # list, view diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 92a571f8f..e1097e51f 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -69,7 +69,7 @@ sentry local --open sentry local run --open -- npm run dev ``` -The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The UI is read-only — it reads the SSE stream but cannot ingest or clear data. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. +The `--open` flag requires a loopback `--host` (localhost, 127.0.0.1, or ::1). The hosted UI reads the SSE stream without changing retained receiver data. A loopback-served viewer can clear a receiver and reset or close a scoped daemon session. Session data stays in memory for the duration of the server; nothing is sent to sentry.io unless a DSN is configured. ## Endpoints @@ -185,3 +185,27 @@ curl -i http://127.0.0.1:3030/api/broken The final request intentionally returns HTTP 500. The fixture is for local experimentation only; do not run it with production credentials. + +## Daemon-owned sessions + +Use `--session ` to capture into the shared loopback daemon and retain data +after your foreground command exits. Sessions keep up to 500 envelopes for three +hours after closing and subsequent ingestion. Stopping or restarting the daemon +clears all sessions. + +```bash +sentry local run --session web -- npm run dev +sentry local serve --session worker +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop +``` + +`local session create --json` returns separate SDK ingest and viewer read +capability URLs. Keep these URLs private. `--clientId ` makes retried session +creation idempotent. Use the session ID when multiple sessions share a label. diff --git a/apps/local/.gitignore b/apps/local/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/local/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/local/.oxlintrc.json b/apps/local/.oxlintrc.json new file mode 100644 index 000000000..6fa991dad --- /dev/null +++ b/apps/local/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/local/components.json b/apps/local/components.json new file mode 100644 index 000000000..8bfc737f9 --- /dev/null +++ b/apps/local/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/local/index.html b/apps/local/index.html new file mode 100644 index 000000000..cbe71556e --- /dev/null +++ b/apps/local/index.html @@ -0,0 +1,14 @@ + + + + + + + + Sentry CLI + + +
+ + + diff --git a/apps/local/package.json b/apps/local/package.json new file mode 100644 index 000000000..adc9a7e19 --- /dev/null +++ b/apps/local/package.json @@ -0,0 +1,55 @@ +{ + "name": "local", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:integration": "vitest run src/App.integration.test.tsx", + "typecheck": "tsc -b" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-slot": "^1.3.3", + "@shikijs/core": "^3.23.0", + "@shikijs/engine-javascript": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@tailwindcss/vite": "^4.3.3", + "@uidotdev/usehooks": "^2.4.1", + "@vercel/analytics": "^2.0.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.43.0", + "next-themes": "^0.4.6", + "nuqs": "^2.10.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^7.18.3", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@spotlightjs/spotlight": "^4.11.7", + "@testing-library/react": "^16.3.2", + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "eventsource": "^4.1.0", + "jsdom": "^28.1.0", + "oxlint": "^1.79.0", + "typescript": "catalog:", + "vite": "^8.2.2", + "vitest": "^4.1.9" + } +} diff --git a/apps/local/public/favicon.svg b/apps/local/public/favicon.svg new file mode 100644 index 000000000..7ad47f504 --- /dev/null +++ b/apps/local/public/favicon.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/local/public/sentry-cli-light.svg b/apps/local/public/sentry-cli-light.svg new file mode 100644 index 000000000..e124651b6 --- /dev/null +++ b/apps/local/public/sentry-cli-light.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/public/sentry-cli.svg b/apps/local/public/sentry-cli.svg new file mode 100644 index 000000000..74e2b4731 --- /dev/null +++ b/apps/local/public/sentry-cli.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx new file mode 100644 index 000000000..b372c476d --- /dev/null +++ b/apps/local/src/App.integration.test.tsx @@ -0,0 +1,1447 @@ +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { EventSource as NodeEventSource } from "eventsource"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { NuqsAdapter } from "nuqs/adapters/react"; +import { BrowserRouter } from "react-router"; +import { ThemeProvider } from "@/components/theme-provider.tsx"; +import App from "./App.tsx"; +import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; +import { + REMOTE_STREAM_STORAGE_KEY, + STREAM_STORAGE_KEY, +} from "./lib/spotlight.ts"; +import { + buildApp, + tryListen, +} from "../../../packages/cli/src/commands/local/server.ts"; + +const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope"; + +type EnvelopeOptions = { + level?: string; + operation?: string; + spanId?: string; + spans?: unknown[]; + statusCode?: number; + startTimestamp?: number; + traceId?: string; + type?: string; +}; + +function envelope(transaction: string, options: EnvelopeOptions = {}): string { + return [ + JSON.stringify({ event_id: crypto.randomUUID() }), + JSON.stringify({ type: options.type ?? "transaction" }), + JSON.stringify({ + transaction, + timestamp: 1_700_000_000, + ...(options.startTimestamp + ? { start_timestamp: options.startTimestamp } + : {}), + ...(options.level ? { level: options.level } : {}), + ...(options.traceId || options.operation + ? { + contexts: { + trace: { + ...(options.traceId ? { trace_id: options.traceId } : {}), + ...(options.spanId ? { span_id: options.spanId } : {}), + ...(options.operation ? { op: options.operation } : {}), + }, + }, + } + : {}), + ...(options.statusCode + ? { request: { status_code: options.statusCode } } + : {}), + ...(options.spans ? { spans: options.spans } : {}), + }), + "", + ].join("\n"); +} + +async function startReceiver() { + return tryListen(buildApp(createSpotlightBuffer(10)), 0, "127.0.0.1"); +} + +async function stopReceiver( + server: Awaited>["server"], +) { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function sendEnvelope( + port: number, + transaction: string, + options?: EnvelopeOptions, +) { + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: envelope(transaction, options), + }); + expect(response.status).toBe(204); +} + +function renderViewer(port: number, path = "/") { + window.history.replaceState( + null, + "", + `${path}#stream=${encodeURIComponent(`http://127.0.0.1:${port}/stream`)}`, + ); + return render( + + + + + + + + + , + ); +} + +function renderBareViewer() { + window.history.replaceState(null, "", "/"); + return render( + + + + + + + + + , + ); +} + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(_url: string) { + super(); + queueMicrotask(() => this.onopen?.(new Event("open"))); + } + + close() {} +} + +class ControllableEventSource extends EventTarget { + static instances: ControllableEventSource[] = []; + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + closed = false; + + constructor(_url: string) { + super(); + ControllableEventSource.instances.push(this); + } + + close() { + this.closed = true; + } +} + +describe("local receiver to viewer integration", () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: vi.fn(() => true), + }); + vi.stubGlobal("EventSource", NodeEventSource); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + media: "", + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); + }); + + test("keeps the selected live transaction open in the detail pane", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + + const shell = screen.getByTestId("app-shell"); + expect(shell.className).not.toContain("px-"); + expect(shell.className).not.toContain("py-"); + expect(screen.queryByRole("banner")).toBeNull(); + + await sendEnvelope(port, "GET /live"); + await screen.findByLabelText("View transaction event"); + await sendEnvelope(port, "GET /live-2"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + const eventList = screen.getByTestId("event-list"); + expect(eventList.className).toContain("flex-1"); + expect(eventList.className).not.toContain("max-h-[42rem]"); + expect( + screen.getByRole("complementary", { name: "Live Activity" }), + ).not.toBeNull(); + const detail = screen.getByTestId("event-detail"); + expect(detail.textContent).toContain("GET /live"); + expect(detail.textContent).not.toContain("GET /live-2"); + expect(detail.textContent).not.toContain("GETGET /live"); + + const events = screen.getAllByLabelText("View transaction event"); + fireEvent.click(events[1]!); + + await waitFor(() => { + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /live-2", + ); + }); + expect(screen.getByRole("tab", { name: "Overview" })).not.toBeNull(); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + const code = screen.getByTestId("highlighted-json"); + expect(code.textContent).toContain("GET /live-2"); + fireEvent.click(screen.getByRole("button", { name: "Copy JSON" })); + expect( + await screen.findByRole("status", { name: "JSON copied" }), + ).not.toBeNull(); + await waitFor(() => { + expect( + screen.getByTestId("highlighted-json").querySelector(".shiki"), + ).not.toBeNull(); + }); + fireEvent.click(events[0]!); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + expect( + screen.getByRole("button", { name: "Copy JSON" }).textContent, + ).toContain("Copy JSON"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps direct receiver actions in a compact sidebar utility bar", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /dock"); + await screen.findByLabelText("View transaction event"); + + const sidebar = screen.getByLabelText("Workspace navigation"); + const utilityBar = sidebar.querySelector( + '[data-testid="sidebar-utility-bar"]', + ); + expect(utilityBar).not.toBeNull(); + expect(utilityBar?.textContent).toContain("Connected"); + expect( + utilityBar?.querySelector('button[aria-label="Search events"]'), + ).not.toBeNull(); + expect( + utilityBar + ?.querySelector('[role="status"]') + ?.getAttribute("aria-label"), + ).toBe("Connected to local receiver"); + expect( + sidebar.querySelector( + 'button[aria-label="Change receiver connection"]', + ), + ).not.toBeNull(); + expect( + sidebar.querySelector('button[aria-label="Clear events"]'), + ).not.toBeNull(); + expect(screen.queryByRole("banner")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect( + screen.getByRole("button", { name: "Open live activity view" }) + .className, + ).toContain("justify-center"); + expect( + utilityBar?.querySelector('button[aria-label="Search events"]') + ?.className, + ).toContain("mx-auto"); + + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + expect( + await screen.findByRole("heading", { name: "Connect a receiver" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers a useful connection landing for a bare viewer visit", () => { + renderBareViewer(); + + expect(screen.getByAltText("Sentry CLI")).not.toBeNull(); + expect(screen.getByRole("banner").textContent).not.toContain( + "Receiver setup", + ); + expect( + screen.queryByRole("searchbox", { name: "Search events" }), + ).toBeNull(); + expect(screen.queryByLabelText("Workspace navigation")).toBeNull(); + expect(screen.getByText("Looking for Sentry Local")).not.toBeNull(); + expect( + screen.getByRole("status", { name: "Checking receiver" }), + ).not.toBeNull(); + expect(screen.queryByText("Advanced connection")).toBeNull(); + const endpoint = screen.getByLabelText( + "Receiver endpoint", + ) as HTMLInputElement; + expect(endpoint.value).toBe("http://localhost:8969/stream"); + const connectButton = screen.getByRole("button", { + name: "Connect", + }) as HTMLButtonElement; + expect(connectButton.disabled).toBe(false); + expect(connectButton.getAttribute("aria-busy")).toBe("false"); + expect( + screen.getByText( + "Defaults to your local receiver. You can paste another local or HTTPS stream above.", + ), + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Copy local serve command" }), + ).not.toBeNull(); + }); + + test("renders every Explorer view from a receiver-backed telemetry session", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace"); + await sendEnvelope(port, "GET /error", { type: "event", level: "error" }); + await sendEnvelope(port, "GET /logs", { type: "log" }); + await sendEnvelope(port, "GET /feedback", { type: "user_report" }); + await sendEnvelope(port, "GET /profile", { type: "profile" }); + await sendEnvelope(port, "GET /sdk", { type: "client_report" }); + await sendEnvelope(port, "GET /ai", { operation: "ai.generate" }); + + const explorerViews = [ + ["Open live activity view", "View transaction event"], + ["Open errors view", "View event event"], + ["Open logs view", "View log event"], + ["Open feedback view", "View user_report event"], + ["Open envelopes view", "View envelope event"], + ["Open profiles view", "View profile event"], + ["Open sessions & sdks view", "View client_report event"], + ["Open ai view", "View transaction event"], + ] as const; + + for (const [view, event] of explorerViews) { + fireEvent.click(screen.getByRole("button", { name: view })); + expect((await screen.findAllByLabelText(event)).length).toBeGreaterThan( + 0, + ); + } + + fireEvent.click(screen.getByRole("button", { name: "Open traces view" })); + expect( + await screen.findByRole("region", { name: "Trace waterfall" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("connects a bare viewer to a custom loopback receiver and saves it after opening", async () => { + const { server, port } = await startReceiver(); + + try { + renderBareViewer(); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: `http://127.0.0.1:${port}/stream` }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await screen.findByText("Connected to local receiver"); + expect(window.localStorage.getItem("sentry.local.stream-url")).toBe( + `http://127.0.0.1:${port}/stream`, + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps a successfully opened remote receiver only for this browser session", async () => { + vi.stubGlobal("EventSource", OpeningEventSource); + renderBareViewer(); + await screen.findByText("Connected to local receiver"); + window.localStorage.clear(); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "https://receiver.example/stream?token=abc" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => { + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + "https://receiver.example/stream?token=abc", + ); + }); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull(); + }); + + test("keeps endpoint validation visible after a failed receiver has been stopped", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onerror?.(new Event("error"))); + await screen.findByRole("alert", { name: "Receiver connection error" }); + + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://receiver.example/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + expect(screen.getByRole("alert").textContent).toContain( + "Enter a loopback stream or an HTTPS remote stream", + ); + + await act(async () => source?.onerror?.(new Event("error"))); + expect(screen.getByRole("alert").textContent).toContain( + "Enter a loopback stream or an HTTPS remote stream", + ); + }); + + test("stops an unanswered receiver attempt after ten seconds", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.useFakeTimers(); + + try { + renderBareViewer(); + + const initialSource = ControllableEventSource.instances[0]; + expect(initialSource).toBeDefined(); + await act(async () => initialSource?.onerror?.(new Event("error"))); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + const source = ControllableEventSource.instances[1]; + expect(source).toBeDefined(); + const connectingButton = screen.getByRole("button", { + name: "Connecting to receiver", + }) as HTMLButtonElement; + expect(connectingButton.disabled).toBe(true); + expect(connectingButton.getAttribute("aria-busy")).toBe("true"); + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + expect(source?.closed).toBe(true); + expect( + screen.getByRole("alert", { name: "Receiver connection error" }) + .textContent, + ).toContain("Connection timed out after 10 seconds"); + expect( + (screen.getByRole("button", { name: "Connect" }) as HTMLButtonElement) + .disabled, + ).toBe(false); + } finally { + cleanup(); + vi.useRealTimers(); + } + }); + + test("keeps receiver selection open when an event arrives during editing", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [ + [ + { type: "transaction" }, + { transaction: "GET /arrived-while-editing" }, + ], + ], + ]), + }), + ); + }); + + expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); + }); + + test("keeps workspace navigation visible while reconnecting", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + fireEvent.click( + screen.getByRole("button", { name: "Change receiver connection" }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + expect(screen.getByLabelText("Workspace navigation")).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Connecting to receiver" }), + ).not.toBeNull(); + }); + + test("keeps a healthy receiver open after an invalid replacement endpoint", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://receiver.example/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + expect(source?.closed).toBe(false); + expect( + screen.getByRole("status", { name: "Connected to local receiver" }), + ).not.toBeNull(); + }); + + test("replays an envelope buffered before the viewer opens", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /buffered"); + renderViewer(port); + + await screen.findByText("Connected to local receiver"); + expect((await screen.findByTestId("event-detail")).textContent).toContain( + "GET /buffered", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("does not mark the receiver replay as new events", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /buffered-first"); + await sendEnvelope(port, "GET /buffered-second"); + renderViewer(port); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + expect( + screen.queryByRole("button", { name: /View .* new event/ }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps Live Activity unfiltered and uses the sidebar for event classes", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View transaction event"); + await screen.findByLabelText("View event event"); + + expect(screen.queryByLabelText("Filter events")).toBeNull(); + expect( + screen.getByLabelText("View transaction event").textContent, + ).toContain("/healthy"); + expect(screen.getByLabelText("View event event").textContent).toContain( + "/broken", + ); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect(screen.queryByLabelText("View transaction event")).toBeNull(); + expect(screen.getByLabelText("View event event").textContent).toContain( + "/broken", + ); + + await sendEnvelope(port, "GET /another-healthy"); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Open live activity view" }) + .textContent, + ).toContain("3"); + }); + expect( + screen.queryByRole("button", { name: /View 1 new event/ }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("uses the collapsible workspace sidebar to focus an observability view", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View event event"); + expect( + screen.getByRole("button", { name: "Open live activity view" }), + ).not.toBeNull(); + expect( + screen.getByRole("button", { name: "Open errors view" }).textContent, + ).toContain("1"); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + screen + .getByRole("button", { name: "Open errors view" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect(screen.getAllByLabelText(/View .* event/)).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + expect( + screen.getByRole("button", { name: "Expand sidebar" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("restores the collapsed sidebar after a refresh", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + + expect( + window.localStorage.getItem("sentry.local.sidebar-collapsed"), + ).toBe("true"); + + cleanup(); + renderViewer(port); + + expect( + await screen.findByRole("button", { name: "Expand sidebar" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps raw received envelopes available outside the live event feed", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /raw-envelope"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + + expect( + screen.getByRole("button", { name: "Open envelopes view" }).textContent, + ).toContain("1"); + fireEvent.click(screen.getByLabelText("View envelope event")); + expect( + screen.getByRole("heading", { name: "Raw envelope" }), + ).not.toBeNull(); + expect(screen.getByTestId("event-detail").textContent).toContain( + "Envelope", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers new raw envelopes after the Envelopes view has been read", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /first-envelope"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + fireEvent.click(await screen.findByLabelText("View envelope event")); + await sendEnvelope(port, "GET /second-envelope"); + + expect( + await screen.findByRole("button", { name: "View 1 new envelope" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("enables clearing when only raw envelopes are retained", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "raw-only-envelope", + }), + ); + }); + + const clearButton = screen.getByRole("button", { + name: "Clear events", + }) as HTMLButtonElement; + await waitFor(() => expect(clearButton.disabled).toBe(false)); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + expect(await screen.findByLabelText("View envelope event")).not.toBeNull(); + fireEvent.click(clearButton); + + await waitFor(() => expect(clearButton.disabled).toBe(true)); + }); + + test.each([204, 403])( + "handles scoped reset HTTP %s and closes a telemetry session", + async (resetStatus) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: resetStatus })) + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + + const initialSource = ControllableEventSource.instances[0]; + expect(initialSource).toBeDefined(); + await act(async () => initialSource?.onopen?.(new Event("open"))); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + + const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/session-1/stream?cap=read-capability"; + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: streamUrl }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + const scopedSource = ControllableEventSource.instances[1]; + expect(scopedSource).toBeDefined(); + await act(async () => scopedSource?.onopen?.(new Event("open"))); + await act(async () => { + scopedSource?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "scoped-envelope", + }), + ); + }); + + const clearButton = screen.getByRole("button", { name: "Clear events" }); + await waitFor(() => + expect((clearButton as HTMLButtonElement).disabled).toBe(false), + ); + fireEvent.click(clearButton); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:8969/_local/v1/sessions/session-1/ui/reset?cap=read-capability", + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual({ method: "POST" }); + if (resetStatus !== 204) { + await screen.findByText( + "Cleared this viewer, but the receiver could not clear its retained session.", + ); + } + + fireEvent.click( + screen.getByRole("button", { name: "Close telemetry session" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:8969/_local/v1/sessions/session-1/ui/close?cap=read-capability", + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual({ method: "POST" }); + await waitFor(() => expect(scopedSource?.closed).toBe(true)); + await waitFor(() => + expect( + screen.queryAllByLabelText("Connected to local receiver"), + ).toHaveLength(0), + ); + }, + ); + + test("keeps retained raw envelopes accessible after the receiver fails", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + + const source = ControllableEventSource.instances[0]; + expect(source).toBeDefined(); + await act(async () => source?.onopen?.(new Event("open"))); + await screen.findByText("Connected to local receiver"); + + await act(async () => { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, []]), + lastEventId: "raw-envelope-before-failure", + }), + ); + }); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + fireEvent.click(await screen.findByLabelText("View envelope event")); + + await act(async () => source?.onerror?.(new Event("error"))); + + expect(screen.getByLabelText("Workspace navigation")).not.toBeNull(); + expect( + screen.getByRole("heading", { name: "Raw envelope" }), + ).not.toBeNull(); + expect(screen.queryByLabelText("Receiver endpoint")).toBeNull(); + }); + + test("omits redundant type pills from dedicated Errors and Envelopes lists", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + await screen.findByLabelText("View event event"); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + screen.getByLabelText("View event event").textContent, + ).not.toContain("Error"); + + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + expect( + screen.getByLabelText("View envelope event").textContent, + ).not.toContain("envelope"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("marks an error entry in the mixed event feed", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + expect( + (await screen.findByLabelText("View event event")).textContent, + ).toContain("Error"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("selects an event from the active workspace instead of retaining a live event", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + await screen.findByLabelText("View event event"); + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "/broken", + ); + expect(screen.getByTestId("event-detail").textContent).not.toContain( + "/healthy", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("filters retained events from the global command search", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /customers/42"); + await sendEnvelope(port, "POST /orders"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "customers" }, + }); + + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, + ); + }); + expect(screen.getByTestId("event-list").textContent).toContain( + "/customers/42", + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "/orders", + ); + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "customers", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("hydrates a shareable Explorer route and selected event from the URL", async () => { + const { server, port } = await startReceiver(); + + try { + await sendEnvelope(port, "GET /other-error", { + level: "error", + type: "event", + }); + await sendEnvelope(port, "GET /shared-error", { + level: "error", + type: "event", + }); + + renderViewer(port, "/errors"); + await screen.findByText("Connected to local receiver"); + await waitFor(() => { + expect(screen.getAllByLabelText("View event event")).toHaveLength(2); + }); + fireEvent.click(screen.getAllByLabelText("View event event")[1]!); + const sharedEventId = await waitFor(() => { + const eventId = new URLSearchParams(window.location.search).get( + "event", + ); + expect(eventId).not.toBeNull(); + return eventId!; + }); + + cleanup(); + renderViewer(port, `/errors?event=${encodeURIComponent(sharedEventId)}`); + await screen.findByText("Connected to local receiver"); + await waitFor(() => { + expect(screen.getAllByLabelText("View event event")).toHaveLength(2); + }); + expect( + screen + .getByRole("button", { name: "Open errors view" }) + .getAttribute("aria-current"), + ).toBe("page"); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/shared-error", + ); + expect(screen.getByTestId("event-detail").textContent).not.toContain( + "/other-error", + ); + expect(new URLSearchParams(window.location.search).get("event")).toBe( + sharedEventId, + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("restores the prior selected event when browser history goes back", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /first"); + await sendEnvelope(port, "GET /second"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + const events = screen.getAllByLabelText("View transaction event"); + fireEvent.click(events[1]!); + const selectedEventId = await waitFor(() => { + const eventId = new URLSearchParams(window.location.search).get( + "event", + ); + expect(eventId).not.toBeNull(); + return eventId!; + }); + expect(new URLSearchParams(window.location.search).get("event")).toBe( + selectedEventId, + ); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/second", + ); + + await act(async () => { + window.history.back(); + }); + await waitFor(() => { + expect(screen.getByTestId("event-detail").textContent).toContain( + "/first", + ); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("opens a global command result in its shareable Explorer view", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /healthy"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + await screen.findByLabelText("View event event"); + + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "broken" }, + }); + fireEvent.click(screen.getByRole("option", { name: /GET \/broken/i })); + + await waitFor(() => { + expect(window.location.pathname).toBe("/errors"); + expect( + new URLSearchParams(window.location.search).get("event"), + ).not.toBeNull(); + }); + expect(screen.getByTestId("event-detail").textContent).toContain( + "/broken", + ); + + await act(async () => { + window.history.back(); + }); + await waitFor(() => { + expect( + screen + .getByRole("button", { name: "Open live activity view" }) + .getAttribute("aria-current"), + ).toBe("page"); + }); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("clears the local viewer feed from the sidebar", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /clear-me"); + await screen.findByLabelText("View transaction event"); + + fireEvent.click(screen.getByRole("button", { name: "Clear events" })); + + expect(screen.queryByLabelText("View transaction event")).toBeNull(); + expect(screen.getByText("Waiting for events")).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("summarizes related retained events in the trace waterfall", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace/first", { traceId: "shared-trace" }); + await sendEnvelope(port, "GET /trace/second", { + traceId: "shared-trace", + }); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + + const traceRelationship = + await screen.findByLabelText("Trace relationship"); + expect(traceRelationship.textContent).toContain("2 captured items"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("offers new events without interrupting the selected detail", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /selected"); + fireEvent.click(await screen.findByLabelText("View transaction event")); + await sendEnvelope(port, "GET /new"); + + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + }); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /selected", + ); + const newEvents = screen.getByRole("button", { + name: "View 1 new event", + }); + fireEvent.click(newEvents); + + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /new", + ); + expect( + screen.queryByRole("button", { name: "View 1 new event" }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("keeps the event overview to non-repeated diagnostic details", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /summary", { + operation: "http.server", + startTimestamp: 1_699_999_999.99383, + statusCode: 201, + traceId: "summary-trace", + }); + + const details = await screen.findByRole("table", { + name: "Event details", + }); + expect(details.textContent).toContain("201"); + expect(details.textContent).toContain("6.17ms"); + expect(details.textContent).not.toContain("Method"); + expect(details.textContent).not.toContain("Route"); + expect(details.textContent).not.toContain("Trace ID"); + expect(details.textContent).not.toContain("Operation"); + expect(screen.getByTestId("event-detail").textContent).toContain( + "GET /summary", + ); + expect(screen.getByTestId("event-detail").textContent).toContain( + "http.server", + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy Status" })); + expect( + screen.getByRole("status", { name: "Status copied" }), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("shows a trace waterfall for a transaction with child spans", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /waterfall", { + operation: "http.server", + spanId: "root-span", + traceId: "waterfall-trace", + startTimestamp: 1_699_999_999.988, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_699_999_999.99, + timestamp: 1_699_999_999.994, + }, + { + span_id: "http-span", + parent_span_id: "root-span", + op: "http.client", + description: "GET https://inventory.test/items/42", + start_timestamp: 1_699_999_999.995, + timestamp: 1_699_999_999.998, + }, + ], + }); + + expect( + await screen.findByRole("region", { name: "Trace waterfall" }), + ).not.toBeNull(); + expect(screen.getByRole("columnheader", { name: "Span" })).not.toBeNull(); + expect( + screen.getByRole("columnheader", { name: "Timeline from 0ms to 12ms" }), + ).not.toBeNull(); + expect( + screen.getByRole("columnheader", { name: "Duration" }), + ).not.toBeNull(); + expect(screen.getByLabelText("Trace relationship").textContent).toContain( + "1 captured item", + ); + expect(screen.queryByLabelText("Trace summary")).toBeNull(); + expect(screen.getByText("db.query")).not.toBeNull(); + expect(screen.getByText("http.client")).not.toBeNull(); + expect( + screen.getByRole("cell", { name: "Duration 4.00ms" }), + ).not.toBeNull(); + expect(screen.getByTestId("waterfall-bar-db-span")).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("groups trace records into one trace investigation", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /trace-parent", { + operation: "http.server", + spanId: "parent-span", + traceId: "grouped-trace", + }); + await sendEnvelope(port, "GET /trace-child", { + operation: "db.query", + spanId: "child-span", + traceId: "grouped-trace", + }); + + fireEvent.click(screen.getByRole("button", { name: "Open traces view" })); + + expect( + (await screen.findAllByRole("heading", { name: "Traces" })).length, + ).toBeGreaterThan(0); + expect(screen.getByText("1 trace")).not.toBeNull(); + const traceList = screen.getByLabelText("Trace list"); + expect(traceList.className).toContain("w-80"); + expect(traceList.className).toContain("bg-muted/30"); + + const traceRow = screen.getByRole("button", { + name: "View trace GET /trace-parent", + }); + expect(traceRow.className).toContain("px-2"); + expect(traceRow.className).toContain("py-2"); + expect(traceRow.className).not.toContain("rounded-md"); + + const waterfall = screen.getByRole("region", { name: "Trace waterfall" }); + expect(waterfall.parentElement?.className).not.toContain("p-4"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("uses the active Explorer view label and gives empty views a useful explanation", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /broken", { + type: "event", + level: "error", + }); + + fireEvent.click(screen.getByRole("button", { name: "Open errors view" })); + expect( + await screen.findByRole("heading", { name: "Errors" }), + ).not.toBeNull(); + expect(screen.getByText("1 error")).not.toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Open feedback view" }), + ); + expect( + await screen.findByRole("heading", { name: "No feedback received" }), + ).not.toBeNull(); + expect( + screen.getByText( + "User feedback submitted through supported SDKs will appear here.", + ), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("moves between detail tabs with arrow keys", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(port, "GET /tabs"); + + const overview = await screen.findByRole("tab", { name: "Overview" }); + fireEvent.keyDown(overview, { key: "ArrowRight" }); + + expect( + screen.getByRole("tab", { name: "JSON" }).getAttribute("aria-selected"), + ).toBe("true"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + + test("reserves space for the CLI logo", async () => { + const { server, port } = await startReceiver(); + + try { + renderViewer(port); + + const logo = await screen.findByAltText("Sentry CLI"); + expect(logo.getAttribute("width")).toBe("117"); + expect(logo.getAttribute("height")).toBe("20"); + } finally { + cleanup(); + await stopReceiver(server); + } + }); +}); diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx new file mode 100644 index 000000000..4140f5490 --- /dev/null +++ b/apps/local/src/App.tsx @@ -0,0 +1,1311 @@ +import { + Check, + ChevronLeft, + ChevronRight, + Copy, + Menu, + Radio, + Search, + Terminal, + Trash2, + X, +} from "lucide-react"; +import { + Fragment, + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + useCopyToClipboard, + useDebounce, + useLocalStorage, +} from "@uidotdev/usehooks"; +import { JsonView } from "@/components/json-view.tsx"; +import { ConnectionLanding } from "@/components/connection-landing.tsx"; +import { + EventCommandDialog, + type CommandNavigationItem, +} from "@/components/event-command-dialog.tsx"; +import { ReceiverControls } from "@/components/receiver-controls.tsx"; +import { TraceWorkspace } from "@/components/trace-workspace.tsx"; +import { TraceWaterfall } from "@/components/trace-waterfall.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + getConnectionPresentation, + type ConnectionPresentation, + type ConnectionState, +} from "@/lib/presentation.ts"; +import { + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + isLoopbackHost, + REMOTE_STREAM_STORAGE_KEY, + resolveInitialStreamUrl, + SENTRY_ENVELOPE_EVENT, + STREAM_STORAGE_KEY, + type EventMetadata, + type LocalFeedItem, +} from "@/lib/spotlight.ts"; +import { + createLocalTelemetryStore, + type LocalTelemetrySnapshot, +} from "@/lib/telemetry-store.ts"; +import { + buildTraceGroups, + traceIdForItem, + type TraceGroup, +} from "@/lib/trace-model.ts"; +import { workspaceNavigation } from "@/lib/workspace-navigation.ts"; +import { + isErrorEvent, + matchesSearch, + type WorkspaceView, + workspaceForItem, +} from "@/lib/workspace.ts"; +import { useLocalWorkspaceRoute } from "@/routes/local-workspace-context.ts"; + +type EventEntryProps = { + item: LocalFeedItem; + isSelected: boolean; + showTypeBadge: boolean; + onSelect: (id: string) => void; +}; + +const CONNECTION_TIMEOUT_MS = 10_000; +const SEARCH_DEBOUNCE_MS = 150; +const SIDEBAR_COLLAPSED_STORAGE_KEY = "sentry.local.sidebar-collapsed"; +const SCOPED_SESSION_STREAM_PATH_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; + +function isScopedSessionStream(pathname: string): boolean { + return SCOPED_SESSION_STREAM_PATH_RE.test(pathname); +} + +function formatTimestamp(timestamp: LocalFeedItem["timestamp"]): string { + if (timestamp === undefined) { + return "unknown time"; + } + const milliseconds = + typeof timestamp === "number" && timestamp < 1_000_000_000_000 + ? timestamp * 1_000 + : timestamp; + const date = new Date(milliseconds); + return Number.isNaN(date.getTime()) + ? String(timestamp) + : date.toLocaleTimeString(); +} + +function formatDuration(durationMs: number | undefined): string | undefined { + if (durationMs === undefined) { + return undefined; + } + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms`; +} + +function getMetadata(item: LocalFeedItem): EventMetadata { + return item.metadata ?? { title: item.type }; +} + +function getStatusClass(statusCode: number | undefined): string { + if (statusCode === undefined) { + return "text-muted-foreground"; + } + if (statusCode >= 500) { + return "text-red-500 dark:text-red-400"; + } + if (statusCode >= 400) { + return "text-amber-600 dark:text-amber-400"; + } + return "text-emerald-600 dark:text-emerald-400"; +} + +function getSavedStream(): string | null { + try { + return window.localStorage.getItem(STREAM_STORAGE_KEY); + } catch { + return null; + } +} + +function getSavedRemoteStream(): string | null { + try { + return window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY); + } catch { + return null; + } +} + +function saveStreamUrl(streamUrl: string): void { + try { + window.localStorage.setItem(STREAM_STORAGE_KEY, streamUrl); + } catch { + // Private browsing or browser policy can disable storage; the current tab still works. + } +} + +function saveRemoteStreamUrl(streamUrl: string): void { + try { + window.sessionStorage.setItem(REMOTE_STREAM_STORAGE_KEY, streamUrl); + } catch { + // Browser policies can disable storage; the active tab still works. + } +} + +function EventEntry({ + item, + isSelected, + showTypeBadge, + onSelect, +}: EventEntryProps) { + const metadata = getMetadata(item); + const duration = formatDuration(metadata.durationMs); + const isError = isErrorEvent(item); + + return ( +
  • + +
  • + ); +} + +type EventDetailProps = { + item: LocalFeedItem; + trace?: TraceGroup; +}; + +type DetailTab = "overview" | "json"; + +type DetailField = { + label: string; + value: string; +}; + +type DetailGroup = { + label: string; + fields: DetailField[]; +}; + +function RawEnvelopeDetail({ item }: { item: LocalFeedItem }) { + return ( +
    +
    +
    +

    Raw envelope

    +

    + Received {formatTimestamp(item.timestamp)} +

    +
    + Envelope +
    +
    + +
    +
    + ); +} + +function WorkspaceEmptyState({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( +
    +
    +

    {title}

    +

    + {description} +

    +
    +
    + ); +} + +function EventDetail({ item, trace }: EventDetailProps) { + const [tab, setTab] = useState("overview"); + const [copiedField, setCopiedField] = useState(); + const [, copyToClipboard] = useCopyToClipboard(); + const detailTabs: DetailTab[] = ["overview", "json"]; + const metadata = getMetadata(item); + const duration = formatDuration(metadata.durationMs); + const detailGroups: DetailGroup[] = [ + { + label: "Details", + fields: [ + ...(metadata.statusCode !== undefined + ? [{ label: "Status", value: String(metadata.statusCode) }] + : []), + ...(duration ? [{ label: "Duration", value: duration }] : []), + ], + }, + { + label: "Telemetry", + fields: [ + ...(metadata.origin + ? [{ label: "Origin", value: metadata.origin }] + : []), + ], + }, + ]; + const hasDetailFields = detailGroups.some((group) => group.fields.length > 0); + + const handleTabKeyDown = (event: KeyboardEvent) => { + const currentIndex = detailTabs.indexOf(tab); + const nextIndex = + event.key === "ArrowRight" + ? (currentIndex + 1) % detailTabs.length + : event.key === "ArrowLeft" + ? (currentIndex - 1 + detailTabs.length) % detailTabs.length + : event.key === "Home" + ? 0 + : event.key === "End" + ? detailTabs.length - 1 + : undefined; + + if (nextIndex === undefined) { + return; + } + + event.preventDefault(); + const nextTab = detailTabs[nextIndex]!; + setTab(nextTab); + requestAnimationFrame(() => { + document.getElementById(`event-detail-tab-${nextTab}`)?.focus(); + }); + }; + + return ( +
    +
    +
    + {metadata.operation ?? item.type} + {metadata.title} +
    +
    +
    + + +
    + +
    +
    + {tab === "overview" ? ( +
    +
    + {hasDetailFields ? ( + + + + {detailGroups.map((group) => + group.fields.length > 0 ? ( + + + + + {group.fields.map((field) => { + const copied = copiedField === field.label; + return ( + + + + + ); + })} + + ) : null, + )} + +
    Captured event details
    + {group.label} +
    + {field.label} + +
    + + {field.value} + + +
    +
    + ) : ( +

    + No additional event details. +

    + )} + {copiedField ? ( + + {copiedField} copied + + ) : null} + {trace ? ( +
    + +
    + ) : null} +
    +
    + ) : ( +
    + +
    + )} +
    + ); +} + +type WorkspaceSidebarProps = { + activeView: WorkspaceView; + canSearch: boolean; + collapsed: boolean; + connection: ConnectionPresentation; + retainedItemCount: number; + snapshot: LocalTelemetrySnapshot; + traceCount: number; + onClear: () => void; + onCloseSession?: () => void; + onChangeReceiver: () => void; + onOpenCommand: (trigger: HTMLButtonElement) => void; + onSelect: (view: WorkspaceView) => void; + onToggle: () => void; +}; + +function WorkspaceSidebar({ + activeView, + canSearch, + collapsed, + connection, + retainedItemCount, + snapshot, + traceCount, + onClear, + onCloseSession, + onChangeReceiver, + onOpenCommand, + onSelect, + onToggle, +}: WorkspaceSidebarProps) { + const statusLabel = connection.label.replace(" to local receiver", ""); + const statusDotClass = + connection.tone === "success" + ? "bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]" + : connection.tone === "warning" + ? "bg-amber-500" + : "bg-muted-foreground"; + + return ( + + ); +} + +export default function App() { + const { + navigateToCommandEvent, + navigateToCommandTrace, + navigateToWorkspace, + workspaceQuery, + workspaceView, + } = useLocalWorkspaceRoute(); + const [commandSearchQuery, setCommandSearchQuery] = useState( + workspaceQuery.query, + ); + const debouncedCommandSearchQuery = useDebounce( + commandSearchQuery, + SEARCH_DEBOUNCE_MS, + ); + const isClearingCommandSearch = useRef(false); + const [streamUrl, setStreamUrl] = useState(() => + resolveInitialStreamUrl( + window.location.hash, + getSavedStream(), + getSavedRemoteStream(), + ), + ); + const [connection, setConnection] = useState("connecting"); + const [draftEndpoint, setDraftEndpoint] = useState(streamUrl); + const [isEditingReceiver, setIsEditingReceiver] = useState(false); + const [isConnectionEnabled, setIsConnectionEnabled] = useState(true); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(); + const [telemetryStore] = useState(() => createLocalTelemetryStore()); + const telemetry = useSyncExternalStore( + telemetryStore.subscribe, + telemetryStore.getSnapshot, + telemetryStore.getSnapshot, + ); + const items = telemetry.items; + const [lastViewedItemIds, setLastViewedItemIds] = useState< + Partial> + >({}); + const [isSidebarCollapsed, setIsSidebarCollapsed] = useLocalStorage( + SIDEBAR_COLLAPSED_STORAGE_KEY, + false, + ); + const [isMobileNavigationOpen, setIsMobileNavigationOpen] = useState(false); + const [isCommandOpen, setIsCommandOpen] = useState(false); + const [message, setMessage] = useState(); + const fallbackEventId = useRef(0); + const commandTriggerRef = useRef(null); + const presentation = getConnectionPresentation(connection); + const traces = useMemo(() => buildTraceGroups(items), [items]); + const searchQuery = workspaceQuery.query; + const activeWorkspace = workspaceNavigation.find( + (entry) => entry.id === workspaceView, + )!; + const workspaceItems = activeWorkspace.getItems(telemetry); + const hasRetainedItems = items.length > 0 || telemetry.envelopes.length > 0; + const retainedItemCount = items.length + telemetry.envelopes.length; + const searchedItems = workspaceItems.filter((item) => + matchesSearch(item, searchQuery), + ); + const workspaceEmptyState = searchQuery.trim() + ? { + title: `No matching ${activeWorkspace.singularLabel}s`, + description: + "Try a different search term or clear the search to see everything in this view.", + } + : activeWorkspace.emptyState; + const selectedItem = + workspaceItems.find((item) => item.id === workspaceQuery.eventId) ?? + workspaceItems[0]; + const selectedEventTrace = selectedItem?.metadata?.traceId + ? traces.find((trace) => trace.id === selectedItem.metadata?.traceId) + : undefined; + const lastViewedItemId = lastViewedItemIds[workspaceView]; + const lastViewedIndex = lastViewedItemId + ? workspaceItems.findIndex((item) => item.id === lastViewedItemId) + : -1; + const unseenItems = + lastViewedItemId === undefined + ? [] + : lastViewedIndex === -1 + ? workspaceItems + : workspaceItems.slice(lastViewedIndex + 1); + const newItems = unseenItems.filter((item) => + matchesSearch(item, searchQuery), + ); + const newItemCount = newItems.length; + + useEffect(() => { + if (isClearingCommandSearch.current) { + if (debouncedCommandSearchQuery !== commandSearchQuery) { + return; + } + isClearingCommandSearch.current = false; + } + if (debouncedCommandSearchQuery !== searchQuery) { + void workspaceQuery.setSearchQuery(debouncedCommandSearchQuery); + } + }, [ + commandSearchQuery, + debouncedCommandSearchQuery, + searchQuery, + workspaceQuery, + ]); + + const markItemsSeen = (view = workspaceView) => { + const viewedItems = workspaceNavigation + .find((entry) => entry.id === view)! + .getItems(telemetry); + setLastViewedItemIds((itemIds) => ({ + ...itemIds, + [view]: viewedItems.at(-1)?.id, + })); + }; + + const selectItem = (id: string) => { + void workspaceQuery.selectEvent(id); + markItemsSeen(); + }; + + const selectWorkspace = (nextWorkspace: WorkspaceView) => { + navigateToWorkspace(nextWorkspace); + void workspaceQuery.resetWorkspace(); + setIsMobileNavigationOpen(false); + markItemsSeen(nextWorkspace); + }; + + const selectCommandItem = (item: LocalFeedItem) => { + isClearingCommandSearch.current = true; + setCommandSearchQuery(""); + const nextWorkspace = workspaceForItem(item); + const traceId = traceIdForItem(item); + if (nextWorkspace === "traces" && traceId) { + navigateToCommandTrace(nextWorkspace, traceId); + } else { + navigateToCommandEvent(nextWorkspace, item.id); + } + markItemsSeen(nextWorkspace); + }; + + const clearItems = () => { + telemetryStore.clear(); + setLastViewedItemIds({}); + void workspaceQuery.clearWorkspace(); + + const endpoint = parseStreamEndpoint(streamUrl); + if ( + endpoint?.kind !== "loopback" || + !isLoopbackHost(window.location.hostname) + ) { + return; + } + const clearUrl = new URL(endpoint.url); + const scopedSession = isScopedSessionStream(clearUrl.pathname); + if (scopedSession) { + clearUrl.pathname = clearUrl.pathname.replace(/\/stream$/, "/ui/reset"); + } else { + clearUrl.pathname = "/clear"; + clearUrl.search = ""; + } + void fetch(clearUrl, { method: scopedSession ? "POST" : "DELETE" }) + .then((response) => { + if (!response.ok) + throw new Error(`Session reset failed with HTTP ${response.status}`); + }) + .catch(() => { + setMessage( + "Cleared this viewer, but the receiver could not clear its retained session.", + ); + }); + }; + + const closeScopedSession = () => { + const endpoint = parseStreamEndpoint(streamUrl); + if ( + endpoint?.kind !== "loopback" || + !isLoopbackHost(window.location.hostname) + ) { + return; + } + const closeUrl = new URL(endpoint.url); + if (!isScopedSessionStream(closeUrl.pathname)) { + return; + } + closeUrl.pathname = closeUrl.pathname.replace(/\/stream$/, "/ui/close"); + void fetch(closeUrl, { method: "POST" }) + .then((response) => { + if (!response.ok) { + throw new Error(`Session close failed with HTTP ${response.status}`); + } + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("closed"); + setMessage( + "Telemetry session closed. Retained events remain available for three hours.", + ); + }) + .catch(() => setMessage("Could not close this telemetry session.")); + }; + + const currentEndpoint = parseStreamEndpoint(streamUrl); + const hasScopedSession = + currentEndpoint?.kind === "loopback" && + isLoopbackHost(window.location.hostname) && + isScopedSessionStream(new URL(currentEndpoint.url).pathname); + + const connectToDraft = () => { + const endpoint = parseStreamEndpoint(draftEndpoint); + if (!endpoint) { + setConnectionError( + "Enter a loopback stream or an HTTPS remote stream ending in /stream.", + ); + if (connection !== "connected") { + setIsConnectionEnabled(false); + setConnection("failed"); + } + return; + } + setDraftEndpoint(endpoint.url); + setStreamUrl(endpoint.url); + setIsConnectionEnabled(true); + setIsConnecting(true); + setConnection("connecting"); + setConnectionError(undefined); + setIsEditingReceiver(false); + }; + + useEffect(() => { + if (window.location.hash) { + window.history.replaceState( + null, + "", + `${window.location.pathname}${window.location.search}`, + ); + } + }, []); + + useEffect(() => { + if (!isConnectionEnabled) { + return; + } + + let isCurrent = true; + const source = new EventSource(streamUrl); + const timeoutId = window.setTimeout(() => { + if (!isCurrent) { + return; + } + isCurrent = false; + source.close(); + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("failed"); + setConnectionError( + "Connection timed out after 10 seconds. Check the endpoint and try again.", + ); + }, CONNECTION_TIMEOUT_MS); + const onEnvelope = (event: Event) => { + if (!isCurrent) { + return; + } + try { + const messageEvent = event as MessageEvent; + const eventId = + messageEvent.lastEventId || `event-${fallbackEventId.current++}`; + telemetryStore.recordEnvelope({ + id: eventId, + type: "envelope", + timestamp: Date.now(), + text: messageEvent.data, + payload: messageEvent.data, + metadata: { title: `Envelope ${eventId.slice(-8)}` }, + }); + const decoded = decodeEnvelope(messageEvent.data, eventId); + telemetryStore.append(decoded); + setMessage(undefined); + } catch { + setMessage("Received an event that could not be decoded."); + } + }; + + source.addEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.onopen = () => { + if (!isCurrent) { + return; + } + window.clearTimeout(timeoutId); + setIsConnecting(false); + const endpoint = parseStreamEndpoint(streamUrl); + if (endpoint?.kind === "loopback") { + saveStreamUrl(endpoint.url); + } else if (endpoint?.kind === "remote") { + saveRemoteStreamUrl(endpoint.url); + } + setConnection("connected"); + setConnectionError(undefined); + setMessage(undefined); + }; + source.onerror = () => { + if (!isCurrent) { + return; + } + window.clearTimeout(timeoutId); + isCurrent = false; + source.close(); + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("failed"); + setConnectionError( + streamUrl === DEFAULT_STREAM_URL + ? "Could not connect to the default receiver. Start it with the command below or enter another endpoint." + : "Could not connect to this receiver. Check the endpoint and try again.", + ); + }; + + return () => { + isCurrent = false; + window.clearTimeout(timeoutId); + source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.close(); + }; + }, [isConnectionEnabled, streamUrl, telemetryStore]); + + const showConnectionLanding = + isEditingReceiver || + (!hasRetainedItems && + (connection === "connecting" || connection === "failed")); + const isReceiverUnavailable = + connection !== "connected" && !isConnecting && !hasRetainedItems; + const canSearch = + connection === "connected" && items.length > 0 && !isEditingReceiver; + const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map( + ({ icon, id, label }) => ({ icon, id, label }), + ); + const setCommandOpen = (open: boolean) => { + if (open) { + setCommandSearchQuery(searchQuery); + } + setIsCommandOpen(open); + }; + const openCommand = (trigger: HTMLButtonElement) => { + commandTriggerRef.current = trigger; + setCommandOpen(true); + }; + + return ( +
    +
    + {!isReceiverUnavailable ? ( + setIsEditingReceiver(true)} + onOpenCommand={openCommand} + onSelect={selectWorkspace} + onToggle={() => setIsSidebarCollapsed((collapsed) => !collapsed)} + /> + ) : null} +
    + {isReceiverUnavailable ? ( +
    +
    + Sentry CLI + +
    +
    + ) : ( +
    + + {canSearch ? ( + + ) : ( + + )} + +
    + )} + + {!isReceiverUnavailable && isMobileNavigationOpen ? ( +
    + +
    + ) : null} + +
    +
    + {connectionError && + connection === "failed" && + hasRetainedItems ? ( +
    + {connectionError} +
    + ) : null} + {message ? ( +
    + {message} +
    + ) : null} + + {showConnectionLanding ? ( + + ) : !hasRetainedItems ? ( +
    +
    + ) : workspaceView === "traces" ? ( + { + void workspaceQuery.selectTrace(traceId); + }} + /> + ) : ( +
    + + {searchedItems.length === 0 ? ( + + ) : selectedItem?.type === "envelope" ? ( + + ) : selectedItem ? ( + + ) : null} +
    + )} +
    +
    +
    +
    + +
    + ); +} diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx new file mode 100644 index 000000000..56edd164c --- /dev/null +++ b/apps/local/src/components/connection-landing.tsx @@ -0,0 +1,139 @@ +import { Check, Copy, LoaderCircle, Radio } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; + +export type ConnectionLandingProps = { + endpoint: string; + error?: string; + isConnecting: boolean; + phase: "probing" | "failed" | "editing"; + onConnect: () => void; + onEndpointChange: (value: string) => void; +}; + +/** A compact, explicit receiver picker for bare Local viewer visits. */ +export function ConnectionLanding({ + endpoint, + error, + isConnecting, + phase, + onConnect, + onEndpointChange, +}: ConnectionLandingProps) { + const [copied, setCopied] = useState(false); + const [, copyToClipboard] = useCopyToClipboard(); + const isProbing = phase === "probing"; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (isConnecting) { + return; + } + onConnect(); + }; + + return ( +
    +
    +
    +
    + {isProbing ? ( + + ) : null} +
    +
    +

    + Sentry Local +

    +

    + {isProbing ? "Looking for Sentry Local" : "Connect a receiver"} +

    +

    + {isProbing + ? "Checking the default receiver on localhost:8969." + : "Connect this read-only viewer to an event stream."} +

    +
    +
    + + {error ? ( +

    + {error} +

    + ) : null} + +
    + +
    + onEndpointChange(event.target.value)} + className="h-9 min-w-0 flex-1 border border-border bg-background px-3 font-mono text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> + +
    +
    + +

    + Defaults to your local receiver. You can paste another local or HTTPS + stream above. +

    + +
    + + sentry local serve --open + + +
    +
    +
    + ); +} diff --git a/apps/local/src/components/event-command-dialog.test.tsx b/apps/local/src/components/event-command-dialog.test.tsx new file mode 100644 index 000000000..23d592f87 --- /dev/null +++ b/apps/local/src/components/event-command-dialog.test.tsx @@ -0,0 +1,149 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { ListTree } from "lucide-react"; +import { useRef, useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + EventCommandDialog, + type CommandNavigationItem, +} from "./event-command-dialog.tsx"; + +const items = [ + { + id: "transaction-1", + type: "transaction", + text: "{}", + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + }, + }, +]; + +const navigation: CommandNavigationItem[] = [ + { id: "traces", label: "Traces", icon: ListTree }, +]; + +afterEach(cleanup); + +describe("EventCommandDialog", () => { + test("opens with Meta+K and selects a fuzzy-matched retained event", () => { + const onSelectItem = vi.fn(); + + function Harness() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + return ( + + ); + } + + render(); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "usr 42" }, + }); + fireEvent.click( + screen.getByRole("option", { name: /GET \/api\/users\/42/i }), + ); + + expect(onSelectItem).toHaveBeenCalledWith( + expect.objectContaining({ id: "transaction-1" }), + ); + }); + + test("offers Explorer navigation alongside events", () => { + render( + , + ); + + expect( + screen.getByRole("dialog", { name: "Search Local events" }), + ).not.toBeNull(); + expect(screen.getByRole("option", { name: "Traces" })).not.toBeNull(); + }); + + test("closes on Escape and returns focus to the search trigger", async () => { + function Harness() { + const [open, setOpen] = useState(true); + const triggerRef = useRef(null); + return ( + <> + + + + ); + } + + render(); + fireEvent.keyDown(screen.getByPlaceholderText("Search events and views"), { + key: "Escape", + }); + + await waitFor(() => { + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Search events" }), + ); + }); + }); + + test("offers the current-view filter when a search query is present", () => { + render( + , + ); + + expect(screen.getByText("Filter current view")).not.toBeNull(); + expect( + screen.getByRole("option", { name: /Filter current view for “users”/ }), + ).not.toBeNull(); + }); +}); diff --git a/apps/local/src/components/event-command-dialog.tsx b/apps/local/src/components/event-command-dialog.tsx new file mode 100644 index 000000000..d2b70cc57 --- /dev/null +++ b/apps/local/src/components/event-command-dialog.tsx @@ -0,0 +1,157 @@ +import type { LucideIcon } from "lucide-react"; +import { Search } from "lucide-react"; +import { type RefObject, useEffect } from "react"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command.tsx"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { eventSearchValue, type WorkspaceView } from "@/lib/workspace.ts"; + +export type CommandNavigationItem = { + icon: LucideIcon; + id: WorkspaceView; + label: string; +}; + +type EventCommandDialogProps = { + enabled: boolean; + items: LocalFeedItem[]; + navigation: CommandNavigationItem[]; + open: boolean; + query: string; + triggerRef?: RefObject; + onNavigate: (view: WorkspaceView) => void; + onOpenChange: (open: boolean) => void; + onQueryChange: (query: string) => void; + onSelectItem: (item: LocalFeedItem) => void; +}; + +export function EventCommandDialog({ + enabled, + items, + navigation, + open, + query, + triggerRef, + onNavigate, + onOpenChange, + onQueryChange, + onSelectItem, +}: EventCommandDialogProps) { + useEffect(() => { + if (!enabled) { + return; + } + + const openWithShortcut = (event: KeyboardEvent) => { + if ( + (!event.metaKey && !event.ctrlKey) || + event.key.toLowerCase() !== "k" + ) { + return; + } + + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return; + } + + event.preventDefault(); + onOpenChange(true); + }; + + window.addEventListener("keydown", openWithShortcut); + return () => window.removeEventListener("keydown", openWithShortcut); + }, [enabled, onOpenChange]); + + const close = () => { + onOpenChange(false); + queueMicrotask(() => triggerRef?.current?.focus()); + }; + + return ( + { + if (nextOpen) { + onOpenChange(true); + return; + } + close(); + }} + > + + + No matching events or views. + + {navigation.map((entry) => { + const Icon = entry.icon; + return ( + { + onNavigate(entry.id); + close(); + }} + > + + ); + })} + + + {items.map((item) => ( + { + onSelectItem(item); + close(); + }} + > + + ))} + + {query.trim() ? ( + + + + + ) : null} + + + ); +} diff --git a/apps/local/src/components/json-view.test.tsx b/apps/local/src/components/json-view.test.tsx new file mode 100644 index 000000000..34e8a5804 --- /dev/null +++ b/apps/local/src/components/json-view.test.tsx @@ -0,0 +1,23 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { JsonView } from "./json-view.tsx"; + +describe("JsonView", () => { + test("formats valid JSON for a wrapped inspector", async () => { + render( + , + ); + + const inspector = screen.getByTestId("highlighted-json"); + + await waitFor(() => { + expect(inspector.textContent).toBe( + '{\n "event_id": "event-1",\n "context": {\n "route": "/api/users/42"\n }\n}', + ); + }); + expect(inspector.className).toContain("whitespace-pre-wrap"); + expect(inspector.className).toContain("break-words"); + }); +}); diff --git a/apps/local/src/components/json-view.tsx b/apps/local/src/components/json-view.tsx new file mode 100644 index 000000000..b5f374d17 --- /dev/null +++ b/apps/local/src/components/json-view.tsx @@ -0,0 +1,117 @@ +import { createHighlighterCore } from "@shikijs/core"; +import { createJavaScriptRegexEngine } from "@shikijs/engine-javascript"; +import json from "@shikijs/langs/json"; +import githubDark from "@shikijs/themes/github-dark"; +import githubLight from "@shikijs/themes/github-light"; +import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; + +type JsonViewProps = { + code: string; +}; + +const highlighter = createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + langs: [json], + themes: [githubLight, githubDark], +}); + +function formatJson(code: string) { + try { + return JSON.stringify(JSON.parse(code), null, 2); + } catch { + return code; + } +} + +/** Render JSON with Shiki only after its containing event has been expanded. */ +export function JsonView({ code }: JsonViewProps) { + const [html, setHtml] = useState(); + const [copiedText, copyToClipboard] = useCopyToClipboard(); + const formattedCode = formatJson(code); + const copied = copiedText === code; + + useEffect(() => { + let disposed = false; + + void highlighter + .then((instance) => + instance.codeToHtml(formattedCode, { + lang: "json", + themes: { + light: "github-light", + dark: "github-dark", + }, + defaultColor: false, + }), + ) + .then((result) => { + if (!disposed) { + setHtml(result); + } + }) + .catch(() => { + // Keep the readable, escaped source fallback if syntax highlighting cannot load. + }); + + return () => { + disposed = true; + }; + }, [formattedCode]); + + const copyJson = () => { + void copyToClipboard(code); + }; + + const copyButton = ( + + ); + + if (!html) { + return ( +
    +
    {copyButton}
    + {copied ? ( + + JSON copied + + ) : null} +
    +          {formattedCode}
    +        
    +
    + ); + } + + return ( +
    +
    {copyButton}
    + {copied ? ( + + JSON copied + + ) : null} +
    +
    + ); +} diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx new file mode 100644 index 000000000..744209db5 --- /dev/null +++ b/apps/local/src/components/receiver-controls.tsx @@ -0,0 +1,160 @@ +import { ChevronDown, Moon, Sun, Trash2, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import type { ConnectionPresentation } from "@/lib/presentation.ts"; + +type ReceiverControlsProps = { + compact?: boolean; + connection: ConnectionPresentation; + retainedItemCount: number; + onClear: () => void; + onCloseSession?: () => void; + showStatusRole?: boolean; +}; + +/** Status and viewer-only actions kept together without exposing receiver mutations. */ +export function ReceiverControls({ + compact = false, + connection, + retainedItemCount, + onClear, + onCloseSession, + showStatusRole = true, +}: ReceiverControlsProps) { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const rootRef = useRef(null); + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + useEffect(() => { + const closeOnOutsidePointer = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("mousedown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, []); + + const dotClassName = + connection.tone === "success" + ? "bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]" + : connection.tone === "warning" + ? "bg-amber-500" + : "bg-muted-foreground"; + + return ( +
    + {compact ? ( + + ) : ( +
    + + + + {connection.label.replace(" to local receiver", "")} + + {showStatusRole ? ( + {connection.label} + ) : null} + + +
    + )} + {isOpen ? ( +
    + + + {onCloseSession ? ( + + ) : null} +
    + ) : null} +
    + ); +} diff --git a/apps/local/src/components/theme-provider.tsx b/apps/local/src/components/theme-provider.tsx new file mode 100644 index 000000000..e96e3bd58 --- /dev/null +++ b/apps/local/src/components/theme-provider.tsx @@ -0,0 +1,6 @@ +import type { ThemeProviderProps } from "next-themes"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/apps/local/src/components/theme-toggle.tsx b/apps/local/src/components/theme-toggle.tsx new file mode 100644 index 000000000..91d9a1317 --- /dev/null +++ b/apps/local/src/components/theme-toggle.tsx @@ -0,0 +1,21 @@ +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { Button } from "@/components/ui/button.tsx"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx new file mode 100644 index 000000000..9add84fd4 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.tsx @@ -0,0 +1,278 @@ +import type { TraceGroup, TraceSpan } from "@/lib/trace-model.ts"; + +type TraceWaterfallProps = { + compact?: boolean; + trace: TraceGroup; +}; + +type PositionedSpan = { + span: TraceSpan; + depth: number; + isLastChild: boolean; +}; + +function formatDuration(durationMs: number | undefined): string { + if (durationMs === undefined) return "—"; + return `${durationMs.toFixed(durationMs < 10 ? 2 : 0)}ms`; +} + +function getSpanPosition( + span: TraceSpan, + trace: TraceGroup, +): { left: string; width: string } | undefined { + if ( + trace.startTimestamp === undefined || + trace.durationMs === undefined || + trace.durationMs <= 0 || + span.startTimestamp === undefined + ) { + return undefined; + } + + const elapsedMs = (span.startTimestamp - trace.startTimestamp) * 1_000; + const left = Math.max(0, Math.min(100, (elapsedMs / trace.durationMs) * 100)); + const width = Math.max( + 1, + Math.min(100 - left, ((span.durationMs ?? 0) / trace.durationMs) * 100), + ); + + return { left: `${left}%`, width: `${width}%` }; +} + +function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { + return spans.flatMap((span, index) => [ + { span, depth, isLastChild: index === spans.length - 1 }, + ...flattenSpans(span.children, depth + 1), + ]); +} + +function isFailingSpan(span: TraceSpan): boolean { + const { level, statusCode } = span.item.metadata ?? {}; + return ( + level === "error" || + level === "fatal" || + (statusCode !== undefined && statusCode >= 500) + ); +} + +function SpanRow({ + positionedSpan, + trace, + index, +}: { + positionedSpan: PositionedSpan; + trace: TraceGroup; + index: number; +}) { + const { span, depth, isLastChild } = positionedSpan; + const position = getSpanPosition(span, trace); + const failed = isFailingSpan(span); + + return ( +
    +
    + {depth > 0 ? ( + <> +
    +
    + + ); +} + +/** A compact, read-only timeline for the spans contained in one local trace. */ +export function TraceWaterfall({ + compact = false, + trace, +}: TraceWaterfallProps) { + const capturedItemLabel = `${trace.itemCount} captured item${trace.itemCount === 1 ? "" : "s"} in this trace`; + + if (trace.spans.length === 0) { + return ( +
    + {compact ? null : ( +
    +

    + Trace +

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + {capturedItemLabel} + Trace ID {trace.id.slice(0, 8)} +
    +
    + )} + {compact ? ( +

    + {capturedItemLabel} +

    + ) : null} +

    + This trace has no timestamped spans to display yet. +

    +
    + ); + } + + const spans = flattenSpans(trace.roots); + + return ( +
    + {compact ? ( +

    + {capturedItemLabel} +

    + ) : ( +
    +

    + Trace +

    +
    +

    {trace.title}

    + + {formatDuration(trace.durationMs)} + +
    +
    + + {trace.spans.length} span{trace.spans.length === 1 ? "" : "s"} + + {trace.errorCount > 0 ? ( + + {trace.errorCount} error{trace.errorCount === 1 ? "" : "s"} + + ) : null} + {trace.logCount > 0 ? ( + + {trace.logCount} log{trace.logCount === 1 ? "" : "s"} + + ) : null} + Trace ID {trace.id.slice(0, 8)} +
    +
    + )} +
    +
    +
    + Span +
    +
    + + Timeline + + +
    +
    + Duration +
    +
    + {spans.map((positionedSpan, index) => ( + + ))} +
    +
    + ); +} diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx new file mode 100644 index 000000000..b98f529e3 --- /dev/null +++ b/apps/local/src/components/trace-workspace.tsx @@ -0,0 +1,118 @@ +import type { TraceGroup } from "../lib/trace-model"; +import { TraceWaterfall } from "./trace-waterfall"; + +type TraceWorkspaceProps = { + onSelect: (traceId: string) => void; + selectedTraceId?: string; + traces: TraceGroup[]; +}; + +function formatDuration(durationMs: number | undefined) { + if (durationMs === undefined) { + return "—"; + } + + if (durationMs < 1_000) { + return `${Math.round(durationMs)} ms`; + } + + return `${(durationMs / 1_000).toFixed(2)} s`; +} + +export function TraceWorkspace({ + onSelect, + selectedTraceId, + traces, +}: TraceWorkspaceProps) { + const selectedTrace = + traces.find((trace) => trace.id === selectedTraceId) ?? traces[0]; + + if (!selectedTrace) { + return ( +
    +
    +

    + No traces captured +

    +

    + Transactions and spans from your receiver will appear here as a + single trace. +

    +
    +
    + ); + } + + return ( +
    + + +
    + +
    +
    + ); +} diff --git a/apps/local/src/components/ui/badge.tsx b/apps/local/src/components/ui/badge.tsx new file mode 100644 index 000000000..f99f43d98 --- /dev/null +++ b/apps/local/src/components/ui/badge.tsx @@ -0,0 +1,34 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +const badgeVariants = cva( + "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap", + { + variants: { + variant: { + default: "border-transparent bg-secondary text-secondary-foreground", + success: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + warning: + "border-amber-500/25 bg-amber-500/10 text-amber-800 dark:text-amber-300", + destructive: "border-destructive/20 bg-destructive/10 text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( + + ); +} + +export { Badge }; diff --git a/apps/local/src/components/ui/button.tsx b/apps/local/src/components/ui/button.tsx new file mode 100644 index 000000000..4ac043a49 --- /dev/null +++ b/apps/local/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + ghost: "hover:bg-accent hover:text-accent-foreground", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-4 py-2", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button }; diff --git a/apps/local/src/components/ui/card.tsx b/apps/local/src/components/ui/card.tsx new file mode 100644 index 000000000..fa28ccea1 --- /dev/null +++ b/apps/local/src/components/ui/card.tsx @@ -0,0 +1,38 @@ +import type * as React from "react"; +import { cn } from "@/lib/utils.ts"; + +function Card({ className, ...props }: React.ComponentProps<"section">) { + return ( +
    + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +

    + ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

    + ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return

    ; +} + +export { Card, CardContent, CardDescription, CardHeader, CardTitle }; diff --git a/apps/local/src/components/ui/command.tsx b/apps/local/src/components/ui/command.tsx new file mode 100644 index 000000000..888ebee4c --- /dev/null +++ b/apps/local/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/utils.ts"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + + {title} + {description} + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
    + + +
    + ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/apps/local/src/components/ui/dialog.tsx b/apps/local/src/components/ui/dialog.tsx new file mode 100644 index 000000000..e480ddd77 --- /dev/null +++ b/apps/local/src/components/ui/dialog.tsx @@ -0,0 +1,156 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; +import { cn } from "@/lib/utils.ts"; + +import { Button } from "@/components/ui/button.tsx"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
    + {children} + {showCloseButton && ( + + + + )} +
    + ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/apps/local/src/index.css b/apps/local/src/index.css new file mode 100644 index 000000000..2dd5dfc1f --- /dev/null +++ b/apps/local/src/index.css @@ -0,0 +1,97 @@ +@import "tailwindcss"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.985 0.002 285); + --foreground: oklch(0.18 0.02 285); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.02 285); + --primary: oklch(0.56 0.25 292); + --primary-foreground: oklch(0.99 0.005 285); + --secondary: oklch(0.95 0.015 285); + --secondary-foreground: oklch(0.3 0.025 285); + --muted: oklch(0.95 0.012 285); + --muted-foreground: oklch(0.48 0.02 285); + --accent: oklch(0.94 0.02 285); + --accent-foreground: oklch(0.25 0.025 285); + --destructive: oklch(0.59 0.22 25); + --border: oklch(0.9 0.012 285); + --input: oklch(0.9 0.012 285); + --ring: oklch(0.56 0.25 292); + --radius: 0.5rem; +} + +.dark { + --background: oklch(0.16 0.018 285); + --foreground: oklch(0.96 0.008 285); + --card: oklch(0.2 0.018 285); + --card-foreground: oklch(0.96 0.008 285); + --primary: oklch(0.72 0.22 292); + --primary-foreground: oklch(0.2 0.018 285); + --secondary: oklch(0.27 0.02 285); + --secondary-foreground: oklch(0.95 0.008 285); + --muted: oklch(0.25 0.018 285); + --muted-foreground: oklch(0.7 0.018 285); + --accent: oklch(0.3 0.022 285); + --accent-foreground: oklch(0.96 0.008 285); + --destructive: oklch(0.7 0.2 25); + --border: oklch(0.31 0.018 285); + --input: oklch(0.31 0.018 285); + --ring: oklch(0.72 0.22 292); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); +} + +@layer base { + * { + @apply border-border; + } + + html { + @apply bg-background; + } + + body { + @apply min-w-80 bg-background text-foreground antialiased; + } +} + +@layer components { + .json-view pre { + @apply m-0 break-words bg-transparent p-0 font-mono text-inherit whitespace-pre-wrap; + } + + .json-view .shiki { + background-color: transparent !important; + padding: 0 !important; + } + + .json-view .shiki span { + color: var(--shiki-light); + } + + .dark .json-view .shiki span { + color: var(--shiki-dark); + } +} diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts new file mode 100644 index 000000000..a1b9617ba --- /dev/null +++ b/apps/local/src/lib/presentation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { getConnectionPresentation } from "./presentation.ts"; + +describe("getConnectionPresentation", () => { + test("describes an active local receiver as connected", () => { + expect(getConnectionPresentation("connected")).toEqual({ + label: "Connected to local receiver", + tone: "success", + }); + }); + + test("explains when no local stream has been provided", () => { + expect(getConnectionPresentation("missing")).toEqual({ + label: "Waiting for a local stream", + tone: "neutral", + }); + }); + + test("makes a failed receiver visible in the status control", () => { + expect(getConnectionPresentation("failed")).toEqual({ + label: "Receiver unavailable", + tone: "warning", + }); + }); +}); diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts new file mode 100644 index 000000000..ccadd87ce --- /dev/null +++ b/apps/local/src/lib/presentation.ts @@ -0,0 +1,31 @@ +export type ConnectionState = + | "connecting" + | "connected" + | "closed" + | "reconnecting" + | "missing" + | "failed"; + +export type ConnectionPresentation = { + label: string; + tone: "neutral" | "success" | "warning"; +}; + +export function getConnectionPresentation( + connection: ConnectionState, +): ConnectionPresentation { + switch (connection) { + case "connected": + return { label: "Connected to local receiver", tone: "success" }; + case "closed": + return { label: "Telemetry session closed", tone: "neutral" }; + case "connecting": + return { label: "Connecting to local receiver", tone: "neutral" }; + case "reconnecting": + return { label: "Reconnecting to local receiver", tone: "warning" }; + case "missing": + return { label: "Waiting for a local stream", tone: "neutral" }; + case "failed": + return { label: "Receiver unavailable", tone: "warning" }; + } +} diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts new file mode 100644 index 000000000..a6eb99e20 --- /dev/null +++ b/apps/local/src/lib/spotlight.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "vitest"; +import { + appendBounded, + DEFAULT_STREAM_URL, + decodeEnvelope, + parseStreamEndpoint, + getPreferredStreamUrl, + resolveInitialStreamUrl, + getStreamUrlFromHash, + getStreamUrlFromStorage, +} from "./spotlight.js"; + +describe("getStreamUrlFromHash", () => { + test("classifies the default stream as loopback", () => { + expect(DEFAULT_STREAM_URL).toBe("http://localhost:8969/stream"); + expect(parseStreamEndpoint(DEFAULT_STREAM_URL)).toEqual({ + url: DEFAULT_STREAM_URL, + kind: "loopback", + }); + }); + + test("accepts an HTTPS remote stream with a query token", () => { + expect( + parseStreamEndpoint("https://receiver.example/stream?token=abc"), + ).toEqual({ + url: "https://receiver.example/stream?token=abc", + kind: "remote", + }); + }); + + test.each([ + "http://receiver.example/stream", + "https://user:pass@receiver.example/stream", + "https://receiver.example/other", + "https://receiver.example/stream#fragment", + ])("rejects unsafe custom endpoint %s", (url) => { + expect(parseStreamEndpoint(url)).toBeUndefined(); + }); + + test("accepts a loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Flocalhost%3A8969%2Fstream"), + ).toBe("http://localhost:8969/stream"); + }); + + test("accepts a loopback daemon session stream with a read capability", () => { + const url = + "http://127.0.0.1:8969/_local/v1/sessions/session/stream?cap=read"; + expect(parseStreamEndpoint(url)).toEqual({ url, kind: "loopback" }); + }); + + test("rejects a non-loopback stream URL", () => { + expect( + getStreamUrlFromHash("#stream=http%3A%2F%2Fexample.com%2Fstream"), + ).toBeUndefined(); + }); + + test("restores a valid saved loopback stream URL", () => { + expect(getStreamUrlFromStorage("http://127.0.0.1:8969/stream")).toBe( + "http://127.0.0.1:8969/stream", + ); + }); + + test("rejects a saved non-loopback stream URL", () => { + expect( + getStreamUrlFromStorage("http://example.com/stream"), + ).toBeUndefined(); + }); + + test("prefers a fresh CLI fragment over a saved stream URL", () => { + expect( + getPreferredStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + "http://127.0.0.1:8969/stream", + ), + ).toBe("http://localhost:9000/stream"); + }); + + test("uses CLI pairing, saved endpoints, then the default in priority order", () => { + expect( + resolveInitialStreamUrl( + "#stream=http%3A%2F%2Flocalhost%3A9000%2Fstream", + null, + null, + ), + ).toBe("http://localhost:9000/stream"); + expect( + resolveInitialStreamUrl("", "http://127.0.0.1:8970/stream", null), + ).toBe("http://127.0.0.1:8970/stream"); + expect(resolveInitialStreamUrl("", null, null)).toBe(DEFAULT_STREAM_URL); + }); +}); + +describe("decodeEnvelope", () => { + test("unpacks batched span and log items from the Local receiver", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"version":2,"items":[{"trace_id":"trace-123","span_id":"span-456","parent_span_id":"root-span","name":"agent.run","start_timestamp":1700000000,"end_timestamp":1700000000.004,"attributes":{"sentry.op":{"value":"gen_ai.invoke_agent","type":"string"}}}]}],[{"type":"log"},{"version":2,"items":[{"trace_id":"trace-123","timestamp":1700000000.005,"level":"error","body":"Fixture request failed"}]}]]]', + "event-1", + ), + ).toMatchObject([ + { + id: "event-1:0", + type: "span", + timestamp: 1700000000.004, + metadata: { + title: "agent.run", + traceId: "trace-123", + spanId: "span-456", + operation: "gen_ai.invoke_agent", + durationMs: 4, + }, + }, + { + id: "event-1:1", + type: "log", + timestamp: 1700000000.005, + metadata: { + title: "Fixture request failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("describes standalone spans and logs with their own payload fields", () => { + expect( + decodeEnvelope( + '[{},[[{"type":"span"},{"trace_id":"trace-123","span_id":"span-456","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000,"timestamp":1700000000.004}],[{"type":"log"},{"trace_id":"trace-123","level":"error","body":"Inventory lookup failed","timestamp":1700000000.005}]]]', + "event-1", + ), + ).toMatchObject([ + { + type: "span", + metadata: { + title: "SELECT * FROM orders", + traceId: "trace-123", + spanId: "span-456", + operation: "db.query", + durationMs: 4, + }, + }, + { + type: "log", + metadata: { + title: "Inventory lookup failed", + traceId: "trace-123", + level: "error", + }, + }, + ]); + }); + + test("keeps the decoded payload available for trace views", () => { + const [item] = decodeEnvelope( + '[{},[[{"type":"transaction"},{"transaction":"GET /orders/42","contexts":{"trace":{"trace_id":"trace-123","span_id":"root-span"}},"spans":[{"span_id":"db-span","parent_span_id":"root-span","op":"db.query","description":"SELECT * FROM orders","start_timestamp":1700000000.001,"timestamp":1700000000.003}]}]]]', + "event-1", + ); + + expect((item as { payload?: unknown }).payload).toEqual({ + transaction: "GET /orders/42", + contexts: { trace: { trace_id: "trace-123", span_id: "root-span" } }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1700000000.001, + timestamp: 1700000000.003, + }, + ], + }); + }); + + test("turns envelope items into displayable feed entries", () => { + expect( + decodeEnvelope( + '[{"sdk":{"name":"sentry.node"}},[[{"type":"transaction"},{"transaction":"GET /api/users/42","start_timestamp":1700000000,"timestamp":1700000000.00668,"contexts":{"trace":{"trace_id":"trace-123","span_id":"span-456","op":"http.server","data":{"http.method":"GET","http.url":"http://127.0.0.1:3030/api/users/42","http.response.status_code":200,"sentry.origin":"auto.http.otel.http"}}}}]]]', + "event-1", + ), + ).toEqual([ + { + id: "event-1:0", + type: "transaction", + timestamp: 1700000000.00668, + metadata: { + title: "GET /api/users/42", + method: "GET", + route: "/api/users/42", + statusCode: 200, + durationMs: 6.68, + traceId: "trace-123", + spanId: "span-456", + operation: "http.server", + origin: "auto.http.otel.http", + }, + text: '{\n "transaction": "GET /api/users/42",\n "start_timestamp": 1700000000,\n "timestamp": 1700000000.00668,\n "contexts": {\n "trace": {\n "trace_id": "trace-123",\n "span_id": "span-456",\n "op": "http.server",\n "data": {\n "http.method": "GET",\n "http.url": "http://127.0.0.1:3030/api/users/42",\n "http.response.status_code": 200,\n "sentry.origin": "auto.http.otel.http"\n }\n }\n }\n}', + payload: { + transaction: "GET /api/users/42", + start_timestamp: 1700000000, + timestamp: 1700000000.00668, + contexts: { + trace: { + trace_id: "trace-123", + span_id: "span-456", + op: "http.server", + data: { + "http.method": "GET", + "http.url": "http://127.0.0.1:3030/api/users/42", + "http.response.status_code": 200, + "sentry.origin": "auto.http.otel.http", + }, + }, + }, + }, + }, + ]); + }); + + test("rejects malformed envelopes", () => { + expect(() => decodeEnvelope("not json", "event-1")).toThrow( + "Invalid Sentry envelope", + ); + }); +}); + +describe("appendBounded", () => { + test("keeps the newest 500 items", () => { + const entries = Array.from({ length: 501 }, (_, index) => ({ + id: String(index), + type: "transaction", + text: String(index), + })); + + const result = appendBounded([], entries); + + expect(result).toHaveLength(500); + expect(result[0]?.id).toBe("1"); + expect(result.at(-1)?.id).toBe("500"); + }); +}); diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts new file mode 100644 index 000000000..3c76cda82 --- /dev/null +++ b/apps/local/src/lib/spotlight.ts @@ -0,0 +1,289 @@ +export const SENTRY_ENVELOPE_EVENT = "application/x-sentry-envelope"; +export const STREAM_STORAGE_KEY = "sentry.local.stream-url"; +export const REMOTE_STREAM_STORAGE_KEY = "sentry.local.remote-stream-url"; +export const DEFAULT_STREAM_URL = "http://localhost:8969/stream"; +const MAX_FEED_ITEMS = 500; + +export type StreamEndpointKind = "loopback" | "remote"; + +export type StreamEndpoint = { + url: string; + kind: StreamEndpointKind; +}; + +export type LocalFeedItem = { + id: string; + type: string; + timestamp?: number | string; + text: string; + payload: unknown; + metadata?: EventMetadata; +}; + +export type EventMetadata = { + title: string; + level?: string; + method?: string; + route?: string; + statusCode?: number; + durationMs?: number; + traceId?: string; + spanId?: string; + operation?: string; + origin?: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function getNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function getAttributeString( + attributes: Record, + key: string, +): string | undefined { + const attribute = attributes[key]; + return isRecord(attribute) + ? getString(attribute.value) + : getString(attribute); +} + +function getRoute(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } catch { + return url.startsWith("/") ? url : undefined; + } +} + +function getEventMetadata(payload: unknown, type: string): EventMetadata { + const event = isRecord(payload) ? payload : {}; + const transaction = getString(event.transaction); + const description = getString(event.description); + const message = + getString(event.message) ?? getString(event.body) ?? getString(event.name); + const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/); + const contexts = isRecord(event.contexts) ? event.contexts : {}; + const trace = isRecord(contexts.trace) ? contexts.trace : event; + const data = isRecord(trace.data) + ? trace.data + : isRecord(event.data) + ? event.data + : {}; + const attributes = isRecord(event.attributes) ? event.attributes : {}; + const request = isRecord(event.request) ? event.request : {}; + const method = + getString(request.method) ?? + getString(data["http.method"]) ?? + transactionMatch?.[1]; + const url = getString(request.url) ?? getString(data["http.url"]); + const route = getRoute(url) ?? transactionMatch?.[2]; + const startTimestamp = getNumber(event.start_timestamp); + const timestamp = + getNumber(event.timestamp) ?? getNumber(event.end_timestamp); + const durationMs = + startTimestamp !== undefined && timestamp !== undefined + ? Math.round((timestamp - startTimestamp) * 100_000) / 100 + : undefined; + + return { + title: transaction ?? description ?? message ?? route ?? type, + level: getString(event.level), + method, + route, + statusCode: + getNumber(request.status_code) ?? + getNumber(data["http.response.status_code"]), + durationMs, + traceId: getString(trace.trace_id) ?? getString(event.trace_id), + spanId: getString(trace.span_id) ?? getString(event.span_id), + operation: + getString(trace.op) ?? + getString(event.op) ?? + getString(event.transaction_op) ?? + getAttributeString(attributes, "sentry.op"), + origin: + getString(data["sentry.origin"]) ?? + getString(event.origin) ?? + getAttributeString(attributes, "sentry.origin"), + }; +} + +function getBatchedPayloadItems(payload: unknown, type: string): unknown[] { + if ( + (type !== "span" && type !== "log") || + !isRecord(payload) || + !Array.isArray(payload.items) + ) { + return [payload]; + } + return payload.items.filter(isRecord); +} + +function getItemTimestamp(payload: unknown): number | string | undefined { + if (!isRecord(payload)) { + return undefined; + } + const timestamp = payload.timestamp ?? payload.end_timestamp; + return typeof timestamp === "number" || typeof timestamp === "string" + ? timestamp + : undefined; +} + +/** Whether a host permits loopback receiver controls. */ +export function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return ( + normalized === "localhost" || + normalized === "127.0.0.1" || + normalized === "::1" + ); +} + +/** Normalize an explicit connection target and classify its persistence policy. */ +export function parseStreamEndpoint( + stream: string | null, +): StreamEndpoint | undefined { + if (!stream) { + return undefined; + } + try { + const url = new URL(stream); + const daemonStream = /^\/_local\/v1\/sessions\/[^/]+\/stream$/.test( + url.pathname, + ); + if ( + (!daemonStream && url.pathname !== "/stream") || + url.username || + url.password || + url.hash + ) { + return undefined; + } + if (isLoopbackHost(url.hostname)) { + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + (!daemonStream && url.search) || + (daemonStream && !url.searchParams.get("cap")) + ) { + return undefined; + } + return { url: url.toString(), kind: "loopback" }; + } + if (url.protocol === "https:") { + return { url: url.toString(), kind: "remote" }; + } + return undefined; + } catch { + return undefined; + } +} + +function normalizeLoopbackStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream); + return endpoint?.kind === "loopback" ? endpoint.url : undefined; +} + +function normalizeRemoteStreamUrl(stream: string | null): string | undefined { + const endpoint = parseStreamEndpoint(stream); + return endpoint?.kind === "remote" ? endpoint.url : undefined; +} + +/** Read the CLI-provided stream endpoint without allowing arbitrary targets. */ +export function getStreamUrlFromHash(hash: string): string | undefined { + return normalizeLoopbackStreamUrl( + new URLSearchParams(hash.replace(/^#/, "")).get("stream"), + ); +} + +/** Validate a stream endpoint saved by an earlier CLI-launched tab. */ +export function getStreamUrlFromStorage( + stream: string | null, +): string | undefined { + return normalizeLoopbackStreamUrl(stream); +} + +/** Validate an explicit remote stream saved for the current browser session. */ +export function getRemoteStreamUrlFromStorage( + stream: string | null, +): string | undefined { + return normalizeRemoteStreamUrl(stream); +} + +/** Prefer a newly supplied CLI endpoint over a previously saved one. */ +export function getPreferredStreamUrl( + hash: string, + savedStream: string | null, +): string | undefined { + return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream); +} + +/** Resolve the bare viewer's receiver in the same safe order as the UI. */ +export function resolveInitialStreamUrl( + hash: string, + savedLoopbackStream: string | null, + savedRemoteStream: string | null, +): string { + return ( + getStreamUrlFromHash(hash) ?? + getStreamUrlFromStorage(savedLoopbackStream) ?? + getRemoteStreamUrlFromStorage(savedRemoteStream) ?? + DEFAULT_STREAM_URL + ); +} + +/** Decode the event payload emitted by the local receiver's SSE endpoint. */ +export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] { + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + throw new Error("Invalid Sentry envelope"); + } + + if (!Array.isArray(parsed) || !Array.isArray(parsed[1])) { + throw new Error("Invalid Sentry envelope"); + } + + return parsed[1].flatMap((item, index) => { + if (!Array.isArray(item) || item.length < 2 || !isRecord(item[0])) { + return []; + } + const header = item[0]; + const type = typeof header.type === "string" ? header.type : "unknown"; + const payloads = getBatchedPayloadItems(item[1], type); + return payloads.map((payload, payloadIndex) => ({ + id: + payloads.length === 1 + ? `${eventId}:${index}` + : `${eventId}:${index}:${payloadIndex}`, + type, + timestamp: getItemTimestamp(payload), + text: JSON.stringify(payload, null, 2) ?? String(payload), + payload, + metadata: getEventMetadata(payload, type), + })); + }); +} + +/** Append received items while keeping the in-memory UI feed bounded. */ +export function appendBounded( + existing: readonly LocalFeedItem[], + incoming: readonly LocalFeedItem[], + limit = MAX_FEED_ITEMS, +): LocalFeedItem[] { + return [...existing, ...incoming].slice(-limit); +} diff --git a/apps/local/src/lib/telemetry-store.test.ts b/apps/local/src/lib/telemetry-store.test.ts new file mode 100644 index 000000000..4dc4bd00b --- /dev/null +++ b/apps/local/src/lib/telemetry-store.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "vitest"; +import type { LocalFeedItem } from "./spotlight.ts"; +import { createLocalTelemetryStore } from "./telemetry-store.ts"; + +function item(overrides: Partial): LocalFeedItem { + return { + id: "event-1:0", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /checkout" }, + ...overrides, + }; +} + +describe("LocalTelemetryStore", () => { + test("indexes a bounded session by its observability view", () => { + const store = createLocalTelemetryStore({ limit: 3 }); + + store.append([ + item({ id: "transaction:0", type: "transaction" }), + item({ + id: "error:0", + type: "event", + metadata: { title: "Checkout failed", level: "error" }, + }), + item({ id: "log:0", type: "log" }), + item({ id: "feedback:0", type: "user_report" }), + ]); + + const snapshot = store.getSnapshot(); + expect(snapshot.items.map((entry) => entry.id)).toEqual([ + "error:0", + "log:0", + "feedback:0", + ]); + expect(snapshot.errors.map((entry) => entry.id)).toEqual(["error:0"]); + expect(snapshot.logs.map((entry) => entry.id)).toEqual(["log:0"]); + expect(snapshot.feedback.map((entry) => entry.id)).toEqual(["feedback:0"]); + expect(snapshot.traces).toEqual([]); + }); + + test("deduplicates replayed envelope items and clears derived indexes", () => { + const store = createLocalTelemetryStore(); + const transaction = item({ id: "transaction:0", type: "transaction" }); + + store.append([transaction, transaction]); + expect(store.getSnapshot().items).toHaveLength(1); + expect(store.getSnapshot().traces).toEqual([transaction]); + + store.clear(); + expect(store.getSnapshot()).toMatchObject({ + items: [], + errors: [], + traces: [], + logs: [], + feedback: [], + envelopes: [], + attachments: [], + profiles: [], + sdks: [], + ai: [], + }); + }); + + test("retains raw envelopes separately from decoded feed items", () => { + const store = createLocalTelemetryStore(); + const envelope = item({ + id: "envelope-1", + type: "envelope", + text: "[{}, []]", + }); + + store.recordEnvelope(envelope); + + expect(store.getSnapshot().items).toEqual([]); + expect(store.getSnapshot().envelopes).toEqual([envelope]); + }); +}); diff --git a/apps/local/src/lib/telemetry-store.ts b/apps/local/src/lib/telemetry-store.ts new file mode 100644 index 000000000..0150b27df --- /dev/null +++ b/apps/local/src/lib/telemetry-store.ts @@ -0,0 +1,118 @@ +import type { LocalFeedItem } from "./spotlight.ts"; +import { isAiEvent, isErrorEvent } from "./workspace.ts"; + +export type LocalTelemetrySnapshot = { + items: LocalFeedItem[]; + errors: LocalFeedItem[]; + traces: LocalFeedItem[]; + logs: LocalFeedItem[]; + feedback: LocalFeedItem[]; + envelopes: LocalFeedItem[]; + attachments: LocalFeedItem[]; + profiles: LocalFeedItem[]; + sdks: LocalFeedItem[]; + ai: LocalFeedItem[]; +}; + +export type LocalTelemetryStore = { + append: (items: readonly LocalFeedItem[]) => void; + recordEnvelope: (envelope: LocalFeedItem) => void; + clear: () => void; + getSnapshot: () => LocalTelemetrySnapshot; + subscribe: (listener: () => void) => () => void; +}; + +const EMPTY_SNAPSHOT: LocalTelemetrySnapshot = { + items: [], + errors: [], + traces: [], + logs: [], + feedback: [], + envelopes: [], + attachments: [], + profiles: [], + sdks: [], + ai: [], +}; + +function buildSnapshot( + items: LocalFeedItem[], + envelopes: LocalFeedItem[], +): LocalTelemetrySnapshot { + return { + items, + errors: items.filter(isErrorEvent), + traces: items.filter( + (item) => item.type === "transaction" || item.type === "span", + ), + logs: items.filter((item) => item.type === "log"), + feedback: items.filter( + (item) => item.type === "user_report" || item.type === "feedback", + ), + envelopes, + attachments: items.filter((item) => item.type === "attachment"), + profiles: items.filter( + (item) => item.type === "profile" || item.type === "profile_chunk", + ), + sdks: items.filter( + (item) => item.type === "client_report" || item.type === "session", + ), + ai: items.filter(isAiEvent), + }; +} + +/** + * Session-only, bounded telemetry state shared by the Local workspace views. + * It intentionally has no browser persistence: restarting a receiver starts a new session. + */ +export function createLocalTelemetryStore({ + limit = 500, +}: { limit?: number } = {}): LocalTelemetryStore { + let snapshot = EMPTY_SNAPSHOT; + const listeners = new Set<() => void>(); + + function publish(items: LocalFeedItem[], envelopes = snapshot.envelopes) { + snapshot = buildSnapshot(items, envelopes); + for (const listener of listeners) { + listener(); + } + } + + return { + append(incoming) { + if (incoming.length === 0) { + return; + } + + const knownIds = new Set(snapshot.items.map((item) => item.id)); + const newItems = incoming.filter((item) => { + if (knownIds.has(item.id)) { + return false; + } + knownIds.add(item.id); + return true; + }); + if (newItems.length === 0) { + return; + } + + publish([...snapshot.items, ...newItems].slice(-limit)); + }, + recordEnvelope(envelope) { + if (snapshot.envelopes.some((item) => item.id === envelope.id)) { + return; + } + publish(snapshot.items, [...snapshot.envelopes, envelope].slice(-limit)); + }, + clear() { + if (snapshot.items.length > 0 || snapshot.envelopes.length > 0) { + publish([], []); + } + }, + getSnapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts new file mode 100644 index 000000000..1e14985fd --- /dev/null +++ b/apps/local/src/lib/trace-model.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, test } from "vitest"; +import type { LocalFeedItem } from "./spotlight.js"; +import { buildTraceGroups, traceIdForItem } from "./trace-model.js"; + +function item(overrides: Partial): LocalFeedItem { + return { + id: "item-1", + type: "transaction", + text: "{}", + payload: {}, + metadata: { title: "GET /orders/42" }, + ...overrides, + }; +} + +describe("buildTraceGroups", () => { + test("uses a transaction ID as the trace fallback used by routing", () => { + expect( + traceIdForItem( + item({ + id: "transaction-without-trace", + metadata: { title: "GET /standalone" }, + }), + ), + ).toBe("transaction-without-trace"); + }); + + test("keeps a standalone transaction discoverable when it has no trace context", () => { + const traces = buildTraceGroups([ + { + id: "transaction-without-trace", + type: "transaction", + timestamp: 1_700_000_000_000, + text: "{}", + payload: {}, + metadata: { title: "GET /standalone" }, + }, + ]); + + expect(traces).toHaveLength(1); + expect(traces[0]?.id).toBe("transaction-without-trace"); + expect(traces[0]?.title).toBe("GET /standalone"); + }); + + test("builds a timestamped span tree and correlates trace errors and logs", () => { + const traces = buildTraceGroups([ + item({ + id: "transaction", + timestamp: 1_700_000_000.012, + metadata: { + title: "GET /orders/42", + traceId: "trace-42", + spanId: "root-span", + operation: "http.server", + durationMs: 12, + }, + payload: { + transaction: "GET /orders/42", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.012, + contexts: { + trace: { + trace_id: "trace-42", + span_id: "root-span", + op: "http.server", + }, + }, + spans: [ + { + span_id: "db-span", + parent_span_id: "root-span", + op: "db.query", + description: "SELECT * FROM orders", + start_timestamp: 1_700_000_000.002, + timestamp: 1_700_000_000.005, + }, + { + span_id: "client-span", + parent_span_id: "root-span", + op: "http.client", + description: "GET https://inventory.test/items/42", + start_timestamp: 1_700_000_000.006, + timestamp: 1_700_000_000.01, + }, + ], + }, + }), + item({ + id: "log", + type: "log", + metadata: { + title: "Inventory lookup finished", + traceId: "trace-42", + level: "info", + }, + }), + item({ + id: "error", + type: "event", + metadata: { + title: "Order failed", + traceId: "trace-42", + level: "error", + }, + }), + ]); + + expect(traces).toHaveLength(1); + expect(traces[0]).toMatchObject({ + id: "trace-42", + title: "GET /orders/42", + itemCount: 3, + logCount: 1, + errorCount: 1, + startTimestamp: 1_700_000_000, + endTimestamp: 1_700_000_000.012, + durationMs: 12, + }); + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + operation: "http.server", + children: [ + { id: "db-span", operation: "db.query", durationMs: 3 }, + { id: "client-span", operation: "http.client", durationMs: 4 }, + ], + }, + ]); + }); + + test("keeps a span visible when its declared parent is absent", () => { + const traces = buildTraceGroups([ + item({ + metadata: { + title: "orphaned work", + traceId: "trace-orphan", + spanId: "orphan-span", + }, + payload: { + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.001, + contexts: { + trace: { + trace_id: "trace-orphan", + span_id: "orphan-span", + parent_span_id: "missing-parent", + op: "db.query", + }, + }, + }, + }), + ]); + + expect(traces[0]?.roots).toMatchObject([ + { id: "orphan-span", parentId: "missing-parent", operation: "db.query" }, + ]); + }); + + test("nests a standalone span using its top-level parent span id", () => { + const traces = buildTraceGroups([ + item({ + id: "root", + metadata: { + title: "POST /agent/run", + traceId: "trace-agent", + spanId: "root-span", + operation: "http.server", + }, + payload: { + transaction: "POST /agent/run", + start_timestamp: 1_700_000_000, + timestamp: 1_700_000_000.01, + contexts: { + trace: { + trace_id: "trace-agent", + span_id: "root-span", + op: "http.server", + }, + }, + }, + }), + item({ + id: "child", + type: "span", + metadata: { + title: "agent.run", + traceId: "trace-agent", + spanId: "child-span", + operation: "gen_ai.invoke_agent", + }, + payload: { + trace_id: "trace-agent", + span_id: "child-span", + parent_span_id: "root-span", + start_timestamp: 1_700_000_000.002, + end_timestamp: 1_700_000_000.006, + }, + }), + ]); + + expect(traces[0]?.roots).toMatchObject([ + { + id: "root-span", + children: [ + { id: "child-span", operation: "gen_ai.invoke_agent", durationMs: 4 }, + ], + }, + ]); + }); +}); diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts new file mode 100644 index 000000000..579552202 --- /dev/null +++ b/apps/local/src/lib/trace-model.ts @@ -0,0 +1,263 @@ +import type { LocalFeedItem } from "./spotlight.js"; +import { isErrorEvent } from "./workspace.js"; + +export type TraceSpan = { + id: string; + parentId?: string; + operation?: string; + description: string; + startTimestamp?: number; + endTimestamp?: number; + durationMs?: number; + item: LocalFeedItem; + children: TraceSpan[]; +}; + +export type TraceGroup = { + id: string; + title: string; + items: LocalFeedItem[]; + roots: TraceSpan[]; + spans: TraceSpan[]; + logCount: number; + errorCount: number; + itemCount: number; + startTimestamp?: number; + endTimestamp?: number; + durationMs?: number; +}; + +type MutableTraceGroup = Omit & { + spanMap: Map; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} + +function getTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function getTraceContext(payload: unknown): Record { + if (!isRecord(payload)) { + return {}; + } + const contexts = isRecord(payload.contexts) ? payload.contexts : {}; + return isRecord(contexts.trace) ? contexts.trace : {}; +} + +function getPayloadSpan( + payload: unknown, + item: LocalFeedItem, +): TraceSpan | undefined { + const event = isRecord(payload) ? payload : {}; + const trace = getTraceContext(payload); + const id = getString(trace.span_id) ?? item.metadata?.spanId; + if (!id) { + return undefined; + } + const startTimestamp = getTimestamp(event.start_timestamp); + const endTimestamp = + getTimestamp(event.timestamp) ?? + getTimestamp(event.end_timestamp) ?? + getTimestamp(item.timestamp); + const operation = getString(trace.op) ?? item.metadata?.operation; + const description = + getString(event.transaction) ?? + item.metadata?.route ?? + item.metadata?.title ?? + operation ?? + item.type; + + return { + id, + parentId: + getString(trace.parent_span_id) ?? getString(event.parent_span_id), + operation, + description, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + }; +} + +function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] { + const event = isRecord(payload) ? payload : {}; + if (!Array.isArray(event.spans)) { + return []; + } + + return event.spans.flatMap((value) => { + if (!isRecord(value)) { + return []; + } + const id = getString(value.span_id); + if (!id) { + return []; + } + const startTimestamp = getTimestamp(value.start_timestamp); + const endTimestamp = getTimestamp(value.timestamp); + const operation = getString(value.op); + return [ + { + id, + parentId: getString(value.parent_span_id), + operation, + description: getString(value.description) ?? operation ?? id, + startTimestamp, + endTimestamp, + durationMs: getDurationMs(startTimestamp, endTimestamp), + item, + children: [], + }, + ]; + }); +} + +function getDurationMs( + startTimestamp: number | undefined, + endTimestamp: number | undefined, +): number | undefined { + if ( + startTimestamp === undefined || + endTimestamp === undefined || + endTimestamp < startTimestamp + ) { + return undefined; + } + return Math.round((endTimestamp - startTimestamp) * 100_000) / 100; +} + +export function traceIdForItem(item: LocalFeedItem): string | undefined { + return ( + item.metadata?.traceId ?? + (item.type === "transaction" || item.type === "span" ? item.id : undefined) + ); +} + +function sortByTimestamp( + items: T[], +): T[] { + return items.sort( + (left, right) => + (left.startTimestamp ?? Infinity) - (right.startTimestamp ?? Infinity), + ); +} + +function updateTraceTiming(trace: MutableTraceGroup): void { + const timestamps = trace.items + .flatMap((item) => [getTimestamp(item.timestamp)]) + .concat( + [...trace.spanMap.values()].flatMap((span) => [ + span.startTimestamp, + span.endTimestamp, + ]), + ) + .filter((timestamp): timestamp is number => timestamp !== undefined); + + if (timestamps.length === 0) { + return; + } + + trace.startTimestamp = Math.min(...timestamps); + trace.endTimestamp = Math.max(...timestamps); + trace.durationMs = getDurationMs(trace.startTimestamp, trace.endTimestamp); +} + +/** Build a small, read-only trace model once for every view in Local. */ +export function buildTraceGroups( + items: readonly LocalFeedItem[], +): TraceGroup[] { + const traces = new Map(); + + for (const item of items) { + const traceId = traceIdForItem(item); + if (!traceId) { + continue; + } + + const trace: MutableTraceGroup = traces.get(traceId) ?? { + id: traceId, + title: item.metadata?.title ?? item.type, + items: [], + logCount: 0, + errorCount: 0, + spanMap: new Map(), + }; + if (!traces.has(traceId)) { + traces.set(traceId, trace); + } + + trace.items.push(item); + if (item.type === "log") { + trace.logCount += 1; + } + if (isErrorEvent(item)) { + trace.errorCount += 1; + } + + if (item.type === "transaction" || item.type === "span") { + const rootSpan = getPayloadSpan(item.payload, item); + if (rootSpan) { + trace.spanMap.set(rootSpan.id, rootSpan); + } + } + for (const childSpan of getChildSpans(item.payload, item)) { + trace.spanMap.set(childSpan.id, childSpan); + } + updateTraceTiming(trace); + } + + return [...traces.values()] + .map((trace) => { + const spans = sortByTimestamp([...trace.spanMap.values()]); + const roots: TraceSpan[] = []; + for (const span of spans) { + span.children = []; + } + for (const span of spans) { + const parent = span.parentId + ? trace.spanMap.get(span.parentId) + : undefined; + if (parent && parent !== span) { + parent.children.push(span); + } else { + roots.push(span); + } + } + for (const span of spans) { + sortByTimestamp(span.children); + } + + return { + id: trace.id, + title: trace.title, + items: trace.items, + roots: sortByTimestamp(roots), + spans, + logCount: trace.logCount, + errorCount: trace.errorCount, + itemCount: trace.items.length, + startTimestamp: trace.startTimestamp, + endTimestamp: trace.endTimestamp, + durationMs: trace.durationMs, + }; + }) + .sort( + (left, right) => (right.endTimestamp ?? 0) - (left.endTimestamp ?? 0), + ); +} diff --git a/apps/local/src/lib/utils.ts b/apps/local/src/lib/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/apps/local/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/local/src/lib/workspace-navigation.ts b/apps/local/src/lib/workspace-navigation.ts new file mode 100644 index 000000000..f57e48d15 --- /dev/null +++ b/apps/local/src/lib/workspace-navigation.ts @@ -0,0 +1,143 @@ +import { + Activity, + Bot, + Boxes, + Bug, + FileArchive, + Gauge, + ListTree, + MessageSquareText, + Terminal, + type LucideIcon, +} from "lucide-react"; +import type { LocalFeedItem } from "./spotlight.ts"; +import type { LocalTelemetrySnapshot } from "./telemetry-store.ts"; +import type { WorkspaceView } from "./workspace.ts"; + +export type WorkspaceNavigationItem = { + id: WorkspaceView; + label: string; + singularLabel: string; + section: "Explore" | "Inspect"; + emptyState: { + title: string; + description: string; + }; + icon: LucideIcon; + getItems: (snapshot: LocalTelemetrySnapshot) => LocalFeedItem[]; +}; + +export const workspaceNavigation: WorkspaceNavigationItem[] = [ + { + id: "live", + label: "Live Activity", + singularLabel: "event", + section: "Explore", + emptyState: { + title: "Waiting for events", + description: "Incoming local telemetry will appear here.", + }, + icon: Activity, + getItems: (snapshot) => snapshot.items, + }, + { + id: "traces", + label: "Traces", + singularLabel: "trace", + section: "Explore", + emptyState: { + title: "No traces captured", + description: + "Transactions and spans from your receiver will appear here as a single trace.", + }, + icon: ListTree, + getItems: (snapshot) => snapshot.traces, + }, + { + id: "errors", + label: "Errors", + singularLabel: "error", + section: "Explore", + emptyState: { + title: "No errors captured", + description: + "Errors and failed requests from this receiver will appear here.", + }, + icon: Bug, + getItems: (snapshot) => snapshot.errors, + }, + { + id: "logs", + label: "Logs", + singularLabel: "log", + section: "Explore", + emptyState: { + title: "No logs captured", + description: + "Structured logs sent through the receiver will appear here.", + }, + icon: Terminal, + getItems: (snapshot) => snapshot.logs, + }, + { + id: "ai", + label: "AI", + singularLabel: "AI event", + section: "Explore", + emptyState: { + title: "No AI activity captured", + description: "AI spans and related telemetry will appear here.", + }, + icon: Bot, + getItems: (snapshot) => snapshot.ai, + }, + { + id: "envelopes", + label: "Envelopes", + singularLabel: "envelope", + section: "Inspect", + emptyState: { + title: "No envelopes received", + description: "Raw envelopes received by this viewer will appear here.", + }, + icon: FileArchive, + getItems: (snapshot) => snapshot.envelopes, + }, + { + id: "sdks", + label: "Sessions & SDKs", + singularLabel: "session or client report", + section: "Inspect", + emptyState: { + title: "No sessions or client reports", + description: "SDK sessions and client reports will appear here.", + }, + icon: Boxes, + getItems: (snapshot) => snapshot.sdks, + }, + { + id: "feedback", + label: "Feedback", + singularLabel: "feedback item", + section: "Inspect", + emptyState: { + title: "No feedback received", + description: + "User feedback submitted through supported SDKs will appear here.", + }, + icon: MessageSquareText, + getItems: (snapshot) => snapshot.feedback, + }, + { + id: "profiles", + label: "Profiles", + singularLabel: "profile", + section: "Inspect", + emptyState: { + title: "No profiles captured", + description: "Profiling data from supported SDKs will appear here.", + }, + icon: Gauge, + getItems: (snapshot) => snapshot.profiles, + }, +]; diff --git a/apps/local/src/lib/workspace-query.test.tsx b/apps/local/src/lib/workspace-query.test.tsx new file mode 100644 index 000000000..6cff806c8 --- /dev/null +++ b/apps/local/src/lib/workspace-query.test.tsx @@ -0,0 +1,55 @@ +import { act, renderHook } from "@testing-library/react"; +import { withNuqsTestingAdapter } from "nuqs/adapters/testing"; +import { describe, expect, test, vi } from "vitest"; +import { useWorkspaceQueryState } from "./workspace-query.ts"; + +describe("useWorkspaceQueryState", () => { + test("hydrates typed state and uses replace history for list controls", async () => { + const onUrlUpdate = vi.fn(); + const { result } = renderHook(() => useWorkspaceQueryState(), { + wrapper: withNuqsTestingAdapter({ + hasMemory: true, + onUrlUpdate, + searchParams: "?event=event-1&trace=trace-1&q=checkout", + }), + }); + + expect(result.current).toMatchObject({ + eventId: "event-1", + query: "checkout", + traceId: "trace-1", + }); + + await act(() => result.current.setSearchQuery("orders")); + + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "replace" }), + queryString: "?event=event-1&trace=trace-1&q=orders", + }), + ); + }); + + test("uses push history for selected events and traces", async () => { + const onUrlUpdate = vi.fn(); + const { result } = renderHook(() => useWorkspaceQueryState(), { + wrapper: withNuqsTestingAdapter({ hasMemory: true, onUrlUpdate }), + }); + + await act(() => result.current.selectEvent("event-1")); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "push" }), + queryString: "?event=event-1", + }), + ); + + await act(() => result.current.selectTrace("trace-1")); + expect(onUrlUpdate).toHaveBeenLastCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ history: "push" }), + queryString: "?trace=trace-1", + }), + ); + }); +}); diff --git a/apps/local/src/lib/workspace-query.ts b/apps/local/src/lib/workspace-query.ts new file mode 100644 index 000000000..ae062f17d --- /dev/null +++ b/apps/local/src/lib/workspace-query.ts @@ -0,0 +1,27 @@ +import { parseAsString, useQueryStates } from "nuqs"; + +const workspaceQueryParsers = { + event: parseAsString, + trace: parseAsString, + q: parseAsString.withDefault(""), +}; + +export function useWorkspaceQueryState() { + const [state, setState] = useQueryStates(workspaceQueryParsers); + + return { + eventId: state.event, + query: state.q, + traceId: state.trace, + clearWorkspace: () => + setState({ event: null, q: null, trace: null }, { history: "replace" }), + resetWorkspace: () => + setState({ event: null, trace: null }, { history: "replace" }), + selectEvent: (eventId: string) => + setState({ event: eventId, trace: null }, { history: "push" }), + selectTrace: (traceId: string) => + setState({ event: null, trace: traceId }, { history: "push" }), + setSearchQuery: (query: string) => + setState({ q: query || null }, { history: "replace" }), + }; +} diff --git a/apps/local/src/lib/workspace.test.ts b/apps/local/src/lib/workspace.test.ts new file mode 100644 index 000000000..15ba55d7b --- /dev/null +++ b/apps/local/src/lib/workspace.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { + workspaceForItem, + workspaceFromPath, + workspacePath, +} from "./workspace.ts"; + +describe("workspace routes", () => { + test("maps retained events to their canonical workspaces", () => { + expect(workspaceForItem({ id: "raw", type: "envelope", text: "[]" })).toBe( + "envelopes", + ); + expect( + workspaceForItem({ + id: "error", + type: "event", + text: "{}", + metadata: { title: "Broken", level: "error" }, + }), + ).toBe("errors"); + expect( + workspaceForItem({ + id: "ai", + type: "span", + text: "{}", + metadata: { title: "Prompt", operation: "gen_ai.invoke_agent" }, + }), + ).toBe("ai"); + }); + + test("uses Live Activity for the compatible root and unknown paths", () => { + expect(workspaceFromPath("/")).toBe("live"); + expect(workspaceFromPath("/not-a-workspace")).toBe("live"); + expect(workspacePath("traces")).toBe("/traces"); + }); +}); diff --git a/apps/local/src/lib/workspace.ts b/apps/local/src/lib/workspace.ts new file mode 100644 index 000000000..61ae8c5ac --- /dev/null +++ b/apps/local/src/lib/workspace.ts @@ -0,0 +1,117 @@ +import type { EventMetadata, LocalFeedItem } from "./spotlight.ts"; + +function getMetadata(item: LocalFeedItem): EventMetadata { + return item.metadata ?? { title: item.type }; +} + +export const workspaceViews = [ + "live", + "traces", + "errors", + "logs", + "ai", + "envelopes", + "sdks", + "feedback", + "profiles", +] as const; + +export type WorkspaceView = (typeof workspaceViews)[number]; + +export function workspacePath(view: WorkspaceView) { + return view === "live" ? "/live" : `/${view}`; +} + +export function workspaceFromPath(pathname: string): WorkspaceView { + if (pathname === "/") { + return "live"; + } + + const view = pathname.slice(1); + return workspaceViews.includes(view as WorkspaceView) + ? (view as WorkspaceView) + : "live"; +} + +export function isErrorEvent(item: LocalFeedItem) { + const { level, statusCode } = getMetadata(item); + return ( + level === "error" || + level === "fatal" || + (statusCode !== undefined && statusCode >= 500) + ); +} + +export function isAiEvent(item: LocalFeedItem) { + const metadata = getMetadata(item); + return ( + `${item.type} ${metadata.operation ?? ""} ${metadata.origin ?? ""}` + .toLowerCase() + .match(/ai|gen_ai|llm/) !== null + ); +} + +export function workspaceForItem(item: LocalFeedItem): WorkspaceView { + if (item.type === "envelope") { + return "envelopes"; + } + if (isAiEvent(item)) { + return "ai"; + } + if (isErrorEvent(item)) { + return "errors"; + } + if (item.type === "transaction" || item.type === "span") { + return "traces"; + } + if (item.type === "log") { + return "logs"; + } + if (item.type === "user_report" || item.type === "feedback") { + return "feedback"; + } + if (item.type === "profile" || item.type === "profile_chunk") { + return "profiles"; + } + if (item.type === "client_report" || item.type === "session") { + return "sdks"; + } + return "live"; +} + +export function matchesSearch(item: LocalFeedItem, query: string) { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + const metadata = getMetadata(item); + return [ + item.type, + item.text, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.spanId, + metadata.operation, + metadata.origin, + ].some((value) => value?.toLowerCase().includes(normalizedQuery)); +} + +export function eventSearchValue(item: LocalFeedItem) { + const metadata = getMetadata(item); + return [ + item.type, + metadata.title, + metadata.level, + metadata.method, + metadata.route, + metadata.traceId, + metadata.operation, + metadata.origin, + ] + .filter(Boolean) + .join(" "); +} diff --git a/apps/local/src/main.tsx b/apps/local/src/main.tsx new file mode 100644 index 000000000..4e906e5f7 --- /dev/null +++ b/apps/local/src/main.tsx @@ -0,0 +1,20 @@ +import { Analytics } from "@vercel/analytics/react"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import { ThemeProvider } from "./components/theme-provider.tsx"; +import { LocalRouter } from "./router.tsx"; + +createRoot(document.getElementById("root")!).render( + + + + + + , +); diff --git a/apps/local/src/router.test.tsx b/apps/local/src/router.test.tsx new file mode 100644 index 000000000..315b1201b --- /dev/null +++ b/apps/local/src/router.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { LocalRouter } from "./router.tsx"; + +class OpeningEventSource extends EventTarget { + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(_url: string) { + super(); + queueMicrotask(() => this.onopen?.(new Event("open"))); + } + + close() {} +} + +describe("LocalRouter", () => { + beforeEach(() => { + window.history.replaceState(null, "", "/"); + vi.stubGlobal("EventSource", OpeningEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("renders the compatible root route", async () => { + render(); + + expect( + ( + await screen.findByRole("button", { name: "Open live activity view" }) + ).getAttribute("aria-current"), + ).toBe("page"); + expect(screen.queryByRole("banner")).toBeNull(); + }); +}); diff --git a/apps/local/src/router.tsx b/apps/local/src/router.tsx new file mode 100644 index 000000000..ec070ec6a --- /dev/null +++ b/apps/local/src/router.tsx @@ -0,0 +1,34 @@ +import { createBrowserRouter, Navigate, RouterProvider } from "react-router"; +import { NuqsAdapter } from "nuqs/adapters/react"; +import App from "./App.tsx"; +import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; + +function WorkspaceRoute() { + return ( + + + + ); +} + +const localRouter = createBrowserRouter([ + { path: "/", Component: WorkspaceRoute }, + { path: "/live", Component: WorkspaceRoute }, + { path: "/traces", Component: WorkspaceRoute }, + { path: "/errors", Component: WorkspaceRoute }, + { path: "/logs", Component: WorkspaceRoute }, + { path: "/ai", Component: WorkspaceRoute }, + { path: "/envelopes", Component: WorkspaceRoute }, + { path: "/sdks", Component: WorkspaceRoute }, + { path: "/feedback", Component: WorkspaceRoute }, + { path: "/profiles", Component: WorkspaceRoute }, + { path: "*", element: }, +]); + +export function LocalRouter() { + return ( + + + + ); +} diff --git a/apps/local/src/routes/local-workspace-context.ts b/apps/local/src/routes/local-workspace-context.ts new file mode 100644 index 000000000..565414d0b --- /dev/null +++ b/apps/local/src/routes/local-workspace-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import { type useWorkspaceQueryState } from "@/lib/workspace-query.ts"; +import type { WorkspaceView } from "@/lib/workspace.ts"; + +export type LocalWorkspaceRouteState = { + workspaceQuery: ReturnType; + workspaceView: WorkspaceView; + navigateToWorkspace: (view: WorkspaceView) => void; + navigateToCommandEvent: (view: WorkspaceView, eventId: string) => void; + navigateToCommandTrace: (view: WorkspaceView, traceId: string) => void; +}; + +export const LocalWorkspaceRouteContext = + createContext(null); + +export function useLocalWorkspaceRoute() { + const context = useContext(LocalWorkspaceRouteContext); + if (!context) { + throw new Error( + "useLocalWorkspaceRoute must be used within LocalWorkspaceRoute", + ); + } + return context; +} diff --git a/apps/local/src/routes/local-workspace-route.tsx b/apps/local/src/routes/local-workspace-route.tsx new file mode 100644 index 000000000..c0395984f --- /dev/null +++ b/apps/local/src/routes/local-workspace-route.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { useWorkspaceQueryState } from "@/lib/workspace-query.ts"; +import { + workspaceFromPath, + workspacePath, + type WorkspaceView, +} from "@/lib/workspace.ts"; +import { LocalWorkspaceRouteContext } from "./local-workspace-context.ts"; + +export function LocalWorkspaceRoute({ children }: { children: ReactNode }) { + const location = useLocation(); + const navigate = useNavigate(); + const workspaceQuery = useWorkspaceQueryState(); + + const navigateToWorkspace = (view: WorkspaceView) => { + navigate({ pathname: workspacePath(view), search: location.search }); + }; + + const navigateToCommandSelection = ( + view: WorkspaceView, + selection: { event?: string; trace?: string }, + ) => { + const search = new URLSearchParams(location.search); + search.delete("event"); + search.delete("q"); + search.delete("trace"); + if (selection.event) { + search.set("event", selection.event); + } + if (selection.trace) { + search.set("trace", selection.trace); + } + const query = search.toString(); + navigate({ + pathname: workspacePath(view), + search: query ? `?${query}` : "", + }); + }; + + return ( + + navigateToCommandSelection(view, { event: eventId }), + navigateToCommandTrace: (view, traceId) => + navigateToCommandSelection(view, { trace: traceId }), + }} + > + {children} + + ); +} diff --git a/apps/local/src/runtime-dependencies.test.ts b/apps/local/src/runtime-dependencies.test.ts new file mode 100644 index 000000000..8011010d9 --- /dev/null +++ b/apps/local/src/runtime-dependencies.test.ts @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +const packageJson = JSON.parse( + readFileSync(resolve(process.cwd(), "package.json"), "utf8"), +) as { dependencies: Record }; + +describe("Local runtime dependencies", () => { + test("declares tslib for dialog dependencies that import its ESM helpers", () => { + expect(packageJson.dependencies.tslib).toBeDefined(); + }); +}); diff --git a/apps/local/src/test-setup.ts b/apps/local/src/test-setup.ts new file mode 100644 index 000000000..58eb88616 --- /dev/null +++ b/apps/local/src/test-setup.ts @@ -0,0 +1,19 @@ +class TestResizeObserver implements ResizeObserver { + disconnect() {} + + observe(_target: Element, _options?: ResizeObserverOptions) {} + + unobserve(_target: Element) {} +} + +if (!globalThis.ResizeObserver) { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, + writable: true, + }); +} + +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; +} diff --git a/apps/local/src/vercel-config.test.ts b/apps/local/src/vercel-config.test.ts new file mode 100644 index 000000000..2c9b4eaad --- /dev/null +++ b/apps/local/src/vercel-config.test.ts @@ -0,0 +1,25 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +describe("Local Vercel configuration", () => { + test("installs dependencies cleanly and serves SPA deep links", () => { + const configPath = resolve(process.cwd(), "vercel.json"); + expect(existsSync(configPath)).toBe(true); + if (!existsSync(configPath)) { + return; + } + + const config = JSON.parse(readFileSync(configPath, "utf8")) as { + installCommand?: string; + rewrites?: Array<{ destination?: string; source?: string }>; + }; + expect(config.installCommand).toBe( + "pnpm install --force --frozen-lockfile", + ); + expect(config.rewrites ?? []).toContainEqual({ + source: "/(.*)", + destination: "/index.html", + }); + }); +}); diff --git a/apps/local/tsconfig.app.json b/apps/local/tsconfig.app.json new file mode 100644 index 000000000..a76bcf6ce --- /dev/null +++ b/apps/local/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/apps/local/tsconfig.json b/apps/local/tsconfig.json new file mode 100644 index 000000000..d0b2bca50 --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/apps/local/tsconfig.node.json b/apps/local/tsconfig.node.json new file mode 100644 index 000000000..8455dcbc2 --- /dev/null +++ b/apps/local/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/local/vercel.json b/apps/local/vercel.json new file mode 100644 index 000000000..2736ab335 --- /dev/null +++ b/apps/local/vercel.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "pnpm install --force --frozen-lockfile", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts new file mode 100644 index 000000000..6a2374b60 --- /dev/null +++ b/apps/local/vite.config.ts @@ -0,0 +1,22 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"), + }, + }, + server: { + port: 5173, + strictPort: true, + }, + test: { + environment: "jsdom", + setupFiles: "./src/test-setup.ts", + }, +}); diff --git a/docs/cli/README.md b/docs/cli/README.md index f4d786229..13f6fefc5 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -31,3 +31,5 @@ and depends on generated CLI documentation. The root [coding guidelines](../contributing/coding-guidelines.md) and [quality checks](../contributing/quality-checks.md) apply alongside the CLI's own rules. + +- [Local daemon and browser viewer](local-control-plane.md) — scoped telemetry sessions and viewer development. diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md new file mode 100644 index 000000000..25c8ed43e --- /dev/null +++ b/docs/cli/local-control-plane.md @@ -0,0 +1,52 @@ +# Local daemon and browser viewer + +`local run --session ` and `local serve --session ` capture into an +isolated session owned by a background, loopback-only daemon. The foreground +command retains its session when it exits. Closed sessions remain queryable for +three hours after the last activity; each retains at most 500 envelopes. All +telemetry disappears when the daemon stops. + +```sh +sentry local run --session web -- npm run dev +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop +``` + +`local session create --json` returns distinct `ingestUrl` and `streamUrl` +capabilities for SDK ingestion and viewer reads. Treat those URLs as credentials. +Use `--clientId ` to make session creation idempotent for retries. Names must +be unique for name-based queries; use the session ID when a name is ambiguous. + +The daemon stores its discovery token under `SENTRY_CONFIG_DIR` in an owner-only +state file. It binds only to localhost, 127.0.0.1, or ::1 and uses a nonzero port. +A second daemon cannot replace the running daemon for that config directory. +Restart preserves its configured host and port and clears all in-memory data. + +## Browser viewer + +The independently served viewer lives in `apps/local`. Run +`pnpm --filter local dev` to serve it on localhost:5173. With that viewer running, +`local run --session web --open -- npm run dev` opens its scoped stream. Production +CLI builds use ; publishing the migrated app requires +that hosting project to use Toolkit and `apps/local` as its root directory. + +A loopback-served viewer can reset and close its scoped session. The hosted viewer +can read the stream and clear its own displayed feed; it cannot mutate retained +receiver data. Remote HTTPS streams stay read-only. + +## Verification + +`pnpm --filter sentry test:e2e` builds the npm bundle and runs the CLI process +suite. `test/e2e/local.test.ts` covers concurrent startup, session isolation, +capability checks, retained queries, scoped browser controls, wrapped applications, +restart, and singleton ownership. The Local workflow repeats these tests against +npm and native binaries on Linux, macOS, and Windows. + +`pnpm --filter local test` includes viewer tests with a real receiver and SSE +connection, as well as scoped reset/close integration coverage. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 4a98046b5..037490a7a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -743,7 +743,20 @@ Print configuration and verify authentication Sentry for local development +- `sentry local daemon start` — Start the Local control-plane daemon +- `sentry local daemon status` — Show Local control-plane status +- `sentry local daemon stop` — Stop the Local control-plane daemon +- `sentry local daemon restart` — Restart the Local control-plane daemon +- `sentry local envelope list ` — List retained envelopes for a Local session +- `sentry local envelope view ` — View a raw Local envelope +- `sentry local event list ` — List decoded events for a Local session +- `sentry local event view ` — View a decoded Local event - `sentry local serve` — Start the local dev server and tail events +- `sentry local session create ` — Create a Local telemetry session +- `sentry local session list` — List Local telemetry sessions +- `sentry local session view ` — View a Local telemetry session +- `sentry local session close ` — Close a Local telemetry session +- `sentry local session reset ` — Clear a Local telemetry session - `sentry local run ` — Run a command with the local dev server enabled → Full flags and examples: `references/local.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index df92efaab..587983f10 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -11,6 +11,43 @@ requires: Sentry for local development +### `sentry local daemon start` + +Start the Local control-plane daemon + +**Flags:** +- `--foreground - Run in the foreground` +- `--host - Loopback host - (default: "localhost")` +- `--port - Control-plane port - (default: "8969")` + +### `sentry local daemon status` + +Show Local control-plane status + +### `sentry local daemon stop` + +Stop the Local control-plane daemon + +### `sentry local daemon restart` + +Restart the Local control-plane daemon + +### `sentry local envelope list ` + +List retained envelopes for a Local session + +### `sentry local envelope view ` + +View a raw Local envelope + +### `sentry local event list ` + +List decoded events for a Local session + +### `sentry local event view ` + +View a decoded Local event + ### `sentry local serve` Start the local dev server and tail events @@ -23,6 +60,32 @@ Start the local dev server and tail events - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` - `--open - Open Sentry Local UI in the browser` +- `--session - Create a daemon-owned telemetry session with this label` + +### `sentry local session create ` + +Create a Local telemetry session + +**Flags:** +- `--clientId - Idempotency key for retried agent session creation` +- `--host - Loopback host - (default: "localhost")` +- `--port - Control-plane port - (default: "8969")` + +### `sentry local session list` + +List Local telemetry sessions + +### `sentry local session view ` + +View a Local telemetry session + +### `sentry local session close ` + +Close a Local telemetry session + +### `sentry local session reset ` + +Clear a Local telemetry session ### `sentry local run ` @@ -37,6 +100,7 @@ Run a command with the local dev server enabled - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Include selected event attributes in output` - `--open - Open Sentry Local UI in the browser` +- `--session - Create a daemon-owned telemetry session with this label` **Examples:** @@ -84,6 +148,17 @@ curl -X POST http://127.0.0.1:3030/api/agent/run \ -H 'content-type: application/json' \ -d '{"prompt":"Where is the rate limit configured?"}' curl -i http://127.0.0.1:3030/api/broken + +sentry local run --session web -- npm run dev +sentry local serve --session worker +sentry local session list --json +sentry local event list web --json +sentry local event view web --json +sentry local envelope list web --json +sentry local session reset web +sentry local session close web +sentry local daemon status +sentry local daemon stop ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts new file mode 100644 index 000000000..0e52d0484 --- /dev/null +++ b/packages/cli/src/commands/local/control-plane.ts @@ -0,0 +1,405 @@ +/** + * HTTP control surface for the singleton Sentry Local daemon. + * + * The daemon owns all session metadata and telemetry buffers. CLI processes use + * its bearer-protected API while SDKs receive only scoped ingestion URLs. + */ + +import { Buffer } from "node:buffer"; +import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { streamSSE } from "hono/streaming"; +import { SENTRY_CONTENT_TYPE } from "../../lib/formatters/local.js"; +import { formatLocalServerUrl } from "./ui.js"; +import { buildSSEHandler, isHostedUiOrigin, MAX_BODY_BYTES } from "./server.js"; +import { logger } from "../../lib/logger.js"; +import { + createLocalSessionStore, + type LocalSessionStore, +} from "./session-store.js"; + +export const CONTROL_PLANE_PREFIX = "/_local/v1"; +const LOOPBACK_ORIGIN_RE = + /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; +const SESSION_UI_ACTION_RE = + /^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close)$/; +const SESSION_STREAM_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; + +/** Authenticated HTTP application and its in-memory session registry. */ +export type LocalControlPlane = { + readonly app: Hono; + readonly sessionStore: LocalSessionStore; +}; + +/** Listener identity, control capability, and optional lifecycle hooks. */ +export type CreateLocalControlPlaneOptions = { + readonly controlToken: string; + readonly host?: string; + readonly onStop?: () => void; + readonly port?: number; + readonly sessionStore?: LocalSessionStore; +}; + +function hasControlCapability(request: Request, controlToken: string): boolean { + return request.headers.get("authorization") === `Bearer ${controlToken}`; +} + +function isSessionUiAction(path: string): boolean { + return SESSION_UI_ACTION_RE.test(path); +} + +function isLoopbackBrowserRequest(request: Request): boolean { + const origin = request.headers.get("origin"); + return origin === null || LOOPBACK_ORIGIN_RE.test(origin); +} + +function eventSummary(parsed: { + envelope: [Record, unknown[]]; +}): { envelopeId: string; eventId: string; itemTypes: string[] } { + const [header, items] = parsed.envelope; + return { + envelopeId: String(header.__spotlight_envelope_id ?? ""), + eventId: String(header.event_id ?? header.__spotlight_envelope_id ?? ""), + itemTypes: items.map((item) => + Array.isArray(item) && typeof item[0] === "object" && item[0] + ? String((item[0] as { type?: unknown }).type ?? "unknown") + : "unknown", + ), + }; +} + +/** Create the in-process HTTP application hosted by the detached Local daemon. */ +export function createLocalControlPlane({ + controlToken, + host = "localhost", + onStop, + port = 8969, + sessionStore = createLocalSessionStore(), +}: CreateLocalControlPlaneOptions): LocalControlPlane { + const app = new Hono(); + const baseUrl = formatLocalServerUrl(host, port); + const sessionExists = (reference: string): boolean => { + sessionStore.pruneExpired(); + try { + sessionStore.resolve(reference); + return true; + } catch (error) { + logger.debug("Local session was not found", error); + return false; + } + }; + + app.get("/health", (c) => c.text("OK")); + + app.use(`${CONTROL_PLANE_PREFIX}/*`, async (c, next) => { + if (SESSION_STREAM_RE.test(c.req.path) || isSessionUiAction(c.req.path)) { + await next(); + return; + } + if (!hasControlCapability(c.req.raw, controlToken)) { + return c.body(null, 401); + } + await next(); + }); + const loopbackCors = cors({ + allowHeaders: [ + "Content-Type", + "Content-Encoding", + "User-Agent", + "Last-Event-ID", + ], + allowMethods: ["GET", "POST", "OPTIONS"], + origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), + }); + const hostedCors = cors({ + allowHeaders: ["Last-Event-ID"], + allowMethods: ["GET", "OPTIONS"], + origin: (origin) => (isHostedUiOrigin(origin) ? origin : null), + }); + app.use(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c, next) => { + const origin = c.req.header("origin") ?? ""; + if (!isHostedUiOrigin(origin)) { + return loopbackCors(c, next); + } + if (c.req.method === "OPTIONS") { + if (c.req.header("access-control-request-method") !== "GET") { + return c.body(null, 403); + } + c.header("Access-Control-Allow-Origin", origin); + c.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Last-Event-ID"); + if (c.req.header("access-control-request-private-network") === "true") { + c.header("Access-Control-Allow-Private-Network", "true"); + } + c.header( + "Vary", + "Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network", + ); + return c.body(null, 204); + } + if (c.req.method !== "GET") { + return c.body(null, 403); + } + return hostedCors(c, next); + }); + app.use( + `${CONTROL_PLANE_PREFIX}/sessions/:id/ui/*`, + cors({ + allowMethods: ["POST", "OPTIONS"], + origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), + }), + ); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions`, (c) => { + sessionStore.pruneExpired(); + return c.json({ sessions: sessionStore.list() }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const session = sessionStore.resolve(c.req.param("id")); + const capabilities = sessionStore.getCapabilities(session.id); + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}/stream`; + return c.json({ + ...session, + ingestUrl: `${baseUrl}${path}?cap=${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}?cap=${capabilities.stream}`, + }); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions`, async (c) => { + const input = await c.req.json().catch((error: unknown) => { + logger.debug("Invalid Local session request body", error); + return; + }); + if ( + typeof input !== "object" || + input === null || + Array.isArray(input) || + typeof (input as { label?: unknown }).label !== "string" || + typeof (input as { cwd?: unknown }).cwd !== "string" || + ("clientId" in input && + typeof (input as { clientId?: unknown }).clientId !== "string") + ) { + return c.json({ error: "label and cwd are required." }, 400); + } + const { clientId, label, cwd } = input as { + clientId?: string; + label: string; + cwd: string; + }; + const session = sessionStore.create({ clientId, label, cwd }); + const capabilities = sessionStore.getCapabilities(session.id); + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}/stream`; + return c.json( + { + ...session, + ingestUrl: `${baseUrl}${path}?cap=${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}?cap=${capabilities.stream}`, + }, + 201, + ); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const capabilities = sessionStore.getCapabilities(c.req.param("id")); + if (c.req.query("cap") !== capabilities.ingest) { + return c.body(null, 401); + } + + if (Number(c.req.header("content-length") ?? 0) > MAX_BODY_BYTES) { + return c.body(null, 413); + } + const arrayBuffer = await c.req.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_BODY_BYTES) { + return c.body(null, 413); + } + const body = Buffer.from(arrayBuffer); + const rawEncoding = c.req.header("content-encoding"); + const encoding = + rawEncoding === "gzip" || + rawEncoding === "deflate" || + rawEncoding === "br" + ? rawEncoding + : undefined; + const browserBeacon = + c.req.query("sentry_client")?.startsWith("sentry.javascript.browser") && + c.req.header("origin"); + pushToSpotlightBuffer({ + spotlightBuffer: sessionStore.getBuffer(c.req.param("id")), + body, + contentType: browserBeacon + ? SENTRY_CONTENT_TYPE + : (c.req.header("content-type") ?? ""), + encoding, + userAgent: c.req.header("user-agent"), + }); + sessionStore.recordActivity(c.req.param("id")); + return c.body(null, 204); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const capabilities = sessionStore.getCapabilities(c.req.param("id")); + const buffer = sessionStore.getBuffer(c.req.param("id")); + if (c.req.query("cap") !== capabilities.stream) { + return c.body(null, 401); + } + return streamSSE(c, async (stream) => { + const readerId = buffer.subscribe( + buildSSEHandler(stream, c.req.query("base64") !== undefined), + c.req.header("Last-Event-ID"), + ); + await new Promise((resolve) => { + stream.onAbort(() => { + buffer.unsubscribe(readerId); + resolve(); + }); + }); + }); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/reset`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + sessionStore.getBuffer(c.req.param("id")).clear(); + return c.body(null, 204); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/close`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + return c.json(sessionStore.close(c.req.param("id"))); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/:action`, (c) => { + if (!(isSessionUiAction(c.req.path) && sessionExists(c.req.param("id")))) { + return c.body(null, 404); + } + if (!isLoopbackBrowserRequest(c.req.raw)) { + return c.body(null, 403); + } + if ( + c.req.query("cap") !== + sessionStore.getCapabilities(c.req.param("id")).stream + ) { + return c.body(null, 401); + } + if (c.req.param("action") === "reset") { + sessionStore.getBuffer(c.req.param("id")).clear(); + return c.body(null, 204); + } + return c.json(sessionStore.close(c.req.param("id"))); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/envelopes`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const envelopes = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .map((container) => { + const parsed = container.getParsedEnvelope(); + const header = (parsed?.envelope[0] ?? {}) as { + __spotlight_envelope_id?: unknown; + }; + return { + id: String(header.__spotlight_envelope_id ?? ""), + contentType: container.getContentType(), + itemTypes: + parsed?.envelope[1] + .map((item) => + Array.isArray(item) && typeof item[0] === "object" && item[0] + ? String((item[0] as { type?: unknown }).type ?? "unknown") + : "unknown", + ) + .filter(Boolean) ?? [], + }; + }) + .reverse(); + return c.json({ envelopes }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/events`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const events = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .flatMap((container) => { + const parsed = container.getParsedEnvelope(); + if (!parsed) { + return []; + } + return [ + { + ...eventSummary(parsed), + }, + ]; + }) + .reverse(); + return c.json({ events }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/events/:eventId`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const container = sessionStore + .getBuffer(c.req.param("id")) + .read({ all: true }) + .find((candidate) => { + const parsed = candidate.getParsedEnvelope(); + return ( + parsed && eventSummary(parsed).eventId === c.req.param("eventId") + ); + }); + const parsed = container?.getParsedEnvelope(); + if (!parsed) { + return c.body(null, 404); + } + return c.json({ + ...eventSummary(parsed), + envelope: parsed.envelope, + }); + }); + + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/envelopes/:envelopeId`, (c) => { + if (!sessionExists(c.req.param("id"))) { + return c.body(null, 404); + } + const container = sessionStore + .getBuffer(c.req.param("id")) + .read({ envelopeId: c.req.param("envelopeId") })[0]; + if (!container) { + return c.body(null, 404); + } + return c.json({ + contentType: container.getContentType(), + data: Buffer.from(container.getData()).toString("base64"), + id: c.req.param("envelopeId"), + }); + }); + + app.post(`${CONTROL_PLANE_PREFIX}/daemon/stop`, (c) => { + if (!onStop) { + return c.body(null, 409); + } + setTimeout(onStop, 0); + return c.body(null, 202); + }); + + return { app, sessionStore }; +} diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts new file mode 100644 index 000000000..361ed9cf8 --- /dev/null +++ b/packages/cli/src/commands/local/daemon.ts @@ -0,0 +1,470 @@ +/** + * Durable discovery state for the singleton Local control-plane daemon. + * + * Telemetry never lives here: this file only lets independently invoked CLI + * processes authenticate to the currently running loopback daemon. + */ + +import { type ChildProcess, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import type { Server } from "node:http"; +import { join } from "node:path"; +import { isSea } from "../../lib/react-native/wrap-call.js"; +import { setTimeout as sleep } from "node:timers/promises"; +import { getConfigDir } from "../../lib/db/index.js"; +import { formatLocalServerUrl } from "./ui.js"; +import { logger } from "../../lib/logger.js"; +import { + CONTROL_PLANE_PREFIX, + createLocalControlPlane, +} from "./control-plane.js"; +import { DEFAULT_PORT, isLoopbackHost, tryListen } from "./server.js"; + +export const LOCAL_DAEMON_STATE_FILENAME = "local-control-plane.json"; +export const LOCAL_DAEMON_PROTOCOL_VERSION = 1; +export const LOCAL_DAEMON_IDLE_GRACE_MS = 30_000; + +/** Private discovery metadata for the authenticated loopback daemon. */ +export type LocalDaemonState = { + /** Loopback listener host. */ + readonly host: string; + /** Owning process ID, used for lifecycle checks. */ + readonly pid: number; + /** Bound TCP port, from 1 through 65535. */ + readonly port: number; + /** Secret control capability; never include in diagnostics. */ + readonly token: string; + /** Discovery protocol version. */ + readonly version: number; +}; + +/** Session identifiers and explicitly returned SDK/viewer capabilities. */ +export type LocalDaemonSession = { + readonly cwd: string; + readonly id: string; + readonly ingestUrl: string; + readonly label: string; + readonly streamUrl: string; +}; + +function statePath(directory: string): string { + return join(directory, LOCAL_DAEMON_STATE_FILENAME); +} + +function startupLockPath(directory: string): string { + return join(directory, "local-control-plane.lock"); +} + +function shutdownServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections?.(); + }); +} + +/** Read daemon discovery metadata, treating absent or malformed files as stale. */ +export async function readLocalDaemonState( + directory: string, +): Promise { + try { + const parsed = JSON.parse( + await readFile(statePath(directory), "utf8"), + ) as unknown; + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as LocalDaemonState).host !== "string" || + !isLoopbackHost((parsed as LocalDaemonState).host) || + !Number.isInteger((parsed as LocalDaemonState).pid) || + (parsed as LocalDaemonState).pid <= 0 || + !Number.isInteger((parsed as LocalDaemonState).port) || + (parsed as LocalDaemonState).port <= 0 || + (parsed as LocalDaemonState).port > 65535 || + typeof (parsed as LocalDaemonState).token !== "string" || + !(parsed as LocalDaemonState).token || + typeof (parsed as LocalDaemonState).version !== "number" + ) { + return; + } + return parsed as LocalDaemonState; + } catch (error) { + logger.debug("Could not read Local daemon state", error); + return; + } +} + +/** Atomically persist the current daemon's discovery state with owner-only access. */ +export async function writeLocalDaemonState( + directory: string, + state: LocalDaemonState, +): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const path = statePath(directory); + const temporaryPath = `${path}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(temporaryPath, 0o600).catch((error: unknown) => { + logger.debug("Could not protect Local daemon state", error); + }); + await rename(temporaryPath, path); + await chmod(path, 0o600).catch((error: unknown) => { + logger.debug("Could not protect Local daemon state", error); + }); +} + +/** Remove discovery state after a clean shutdown or stale-daemon recovery. */ +export async function removeLocalDaemonState(directory: string): Promise { + await rm(statePath(directory), { force: true }); +} + +function daemonBaseUrl(state: LocalDaemonState): string { + return formatLocalServerUrl(state.host, state.port); +} + +async function daemonResponds(state: LocalDaemonState): Promise { + try { + const response = await requestLocalDaemon(state, "/sessions", { + signal: AbortSignal.timeout(500), + }); + if (!response.ok) return false; + const data = (await response.json()) as { sessions?: unknown }; + return Array.isArray(data.sessions); + } catch (error) { + logger.debug("Local daemon identity check failed", error); + return false; + } +} + +/** Arguments for re-executing this CLI as a native binary or Node entrypoint. */ +export function buildLocalDaemonArgs(host: string, port: number): string[] { + const prefix: string[] = []; + if (!isSea()) { + const entrypoint = process.argv[1]; + if (!entrypoint) { + throw new Error( + "Could not determine the Sentry CLI entrypoint for Local daemon startup.", + ); + } + prefix.push(...process.execArgv, entrypoint); + } + return [ + ...prefix, + "local", + "daemon", + "start", + "--foreground", + "--host", + host, + "--port", + String(port), + ]; +} + +/** Hold exclusive daemon ownership until shutdown; recover locks from dead processes. */ +async function claimDaemonOwnership( + directory: string, +): Promise<() => Promise> { + const path = join(directory, "local-control-plane.owner"); + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const lock = await open(path, "wx", 0o600); + try { + await lock.writeFile(String(process.pid), "utf8"); + } finally { + await lock.close(); + } + return async () => { + const owner = await readFile(path, "utf8").catch((error: unknown) => { + logger.debug("Could not read Local daemon ownership", error); + return ""; + }); + if (owner === String(process.pid)) await rm(path, { force: true }); + }; + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "EEXIST") + ) + throw error; + } + const owner = Number(await readFile(path, "utf8")); + if (Number.isInteger(owner) && owner > 0) { + try { + process.kill(owner, 0); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + error.code === "ESRCH" + ) { + await rm(path, { force: true }); + continue; + } + throw error; + } + throw new Error( + "The Sentry Local control-plane daemon is already running.", + ); + } + if (Date.now() - (await stat(path)).mtimeMs > 5000) { + await rm(path, { force: true }); + continue; + } + await sleep(125); + } + throw new Error("Timed out waiting for Sentry Local daemon ownership."); +} + +/** Return a usable daemon state, removing stale discovery information first. */ +export async function getRunningLocalDaemon(): Promise< + LocalDaemonState | undefined +> { + const directory = getConfigDir(); + const state = await readLocalDaemonState(directory); + if (!state) { + return; + } + if ( + state.version !== LOCAL_DAEMON_PROTOCOL_VERSION || + !(await daemonResponds(state)) + ) { + await removeLocalDaemonState(directory); + return; + } + return state; +} + +/** Start the daemon child and wait until its authenticated discovery state is available. */ +export async function ensureLocalDaemon({ + host = "localhost", + port = DEFAULT_PORT, +}: { + host?: string; + port?: number; +} = {}): Promise { + if (!isLoopbackHost(host)) { + throw new Error("The Local control-plane host must be loopback-only."); + } + if (port === 0) { + throw new Error("The Local control-plane port must be non-zero."); + } + const running = await getRunningLocalDaemon(); + if (running) { + return running; + } + const directory = getConfigDir(); + await mkdir(directory, { recursive: true, mode: 0o700 }); + let lock: Awaited> | undefined; + try { + lock = await open(startupLockPath(directory), "wx", 0o600); + } catch (error) { + if ( + !(error instanceof Error && "code" in error) || + error.code !== "EEXIST" + ) { + throw error; + } + for (let attempt = 0; attempt < 40; attempt += 1) { + await sleep(125); + const state = await getRunningLocalDaemon(); + if (state) { + return state; + } + } + await removeLocalDaemonState(directory); + await rm(startupLockPath(directory), { force: true }); + return ensureLocalDaemon({ host, port }); + } + try { + const runningAfterLock = await getRunningLocalDaemon(); + if (runningAfterLock) return runningAfterLock; + let startupError: Error | undefined; + const child: ChildProcess = spawn( + process.execPath, + buildLocalDaemonArgs(host, port), + { + detached: true, + env: process.env, + stdio: "ignore", + }, + ); + child.once("error", (error) => { + startupError = error; + }); + child.unref(); + for (let attempt = 0; attempt < 40; attempt += 1) { + await sleep(125); + if (startupError) throw startupError; + const state = await getRunningLocalDaemon(); + if (state) { + return state; + } + } + } finally { + await lock.close(); + await rm(startupLockPath(directory), { force: true }); + } + throw new Error("Timed out while starting the Sentry Local control plane."); +} + +/** Run the daemon until an explicit control-plane stop request or process signal arrives. */ +export async function runLocalDaemonForeground({ + host = "localhost", + port = DEFAULT_PORT, +}: { + host?: string; + port?: number; +} = {}): Promise { + if (!isLoopbackHost(host)) { + throw new Error("The Local control-plane host must be loopback-only."); + } + if (port === 0) { + throw new Error("The Local control-plane port must be non-zero."); + } + const directory = getConfigDir(); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const releaseOwnership = await claimDaemonOwnership(directory); + let listening: Awaited> | undefined; + let ownedState: LocalDaemonState | undefined; + try { + const token = randomBytes(32).toString("base64url"); + let stop: (() => void) | undefined; + const stopRequested = new Promise((resolve) => { + stop = resolve; + }); + const controlPlane = createLocalControlPlane({ + controlToken: token, + host, + port, + onStop: () => stop?.(), + }); + listening = await tryListen(controlPlane.app, port, host); + const state: LocalDaemonState = { + host, + pid: process.pid, + port: listening.port, + token, + version: LOCAL_DAEMON_PROTOCOL_VERSION, + }; + ownedState = state; + await writeLocalDaemonState(directory, state); + + let idleTimer: ReturnType | undefined; + const pruneSessions = () => { + controlPlane.sessionStore.pruneExpired(); + if (controlPlane.sessionStore.list().length > 0) { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + return; + } + idleTimer ??= setTimeout(() => { + if (controlPlane.sessionStore.list().length === 0) { + stop?.(); + } + }, LOCAL_DAEMON_IDLE_GRACE_MS); + }; + const pruneTimer = setInterval(pruneSessions, 60_000); + pruneSessions(); + + const onSignal = () => stop?.(); + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + try { + await stopRequested; + } finally { + clearInterval(pruneTimer); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + process.removeListener("SIGINT", onSignal); + process.removeListener("SIGTERM", onSignal); + } + } finally { + try { + if (listening) await shutdownServer(listening.server); + const current = await readLocalDaemonState(directory); + if ( + current && + ownedState && + current.pid === ownedState.pid && + current.token === ownedState.token + ) { + await removeLocalDaemonState(directory); + } + } finally { + await releaseOwnership(); + } + } +} + +/** Call an authenticated Local daemon endpoint. */ +export function requestLocalDaemon( + state: LocalDaemonState, + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + headers.set("Authorization", `Bearer ${state.token}`); + return fetch(`${daemonBaseUrl(state)}${CONTROL_PLANE_PREFIX}${path}`, { + ...init, + headers, + }); +} + +/** Create a session and return its separate ingestion and read capabilities. */ +export async function createLocalDaemonSession({ + clientId, + cwd, + host, + label, + port, +}: { + readonly clientId?: string; + readonly cwd: string; + readonly host?: string; + readonly label: string; + readonly port?: number; +}): Promise { + const state = await ensureLocalDaemon({ host, port }); + const response = await requestLocalDaemon(state, "/sessions", { + body: JSON.stringify({ clientId, cwd, label }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + throw new Error( + `Could not create Local session (HTTP ${response.status}).`, + ); + } + return (await response.json()) as LocalDaemonSession; +} + +/** Release a foreground session lease while retaining telemetry for queries. */ +export async function retainLocalDaemonSession( + sessionId: string, +): Promise { + const state = await getRunningLocalDaemon(); + if (!state) { + return; + } + await requestLocalDaemon( + state, + `/sessions/${encodeURIComponent(sessionId)}/close`, + { + method: "POST", + }, + ); +} diff --git a/packages/cli/src/commands/local/index.ts b/packages/cli/src/commands/local/index.ts index 3255cc597..1593b58aa 100644 --- a/packages/cli/src/commands/local/index.ts +++ b/packages/cli/src/commands/local/index.ts @@ -1,10 +1,20 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { + daemonRoute, + envelopeRoute, + eventRoute, + sessionRoute, +} from "./management.js"; import { runCommand } from "./run.js"; import { serverCommand } from "./server.js"; export const localRoute = buildRouteMap({ routes: { + daemon: daemonRoute, + envelope: envelopeRoute, + event: eventRoute, serve: serverCommand, + session: sessionRoute, run: runCommand, }, defaultCommand: "serve", @@ -15,6 +25,10 @@ export const localRoute = buildRouteMap({ "from your dev stack.\n\n" + "Commands:\n" + " serve Start the server and tail events (default)\n" + - " run Run a command with SENTRY_SPOTLIGHT auto-injected", + " run Run a command with SENTRY_SPOTLIGHT auto-injected\n" + + " daemon Manage the shared telemetry daemon\n" + + " session Create, inspect, reset, and close sessions\n" + + " event Query decoded session events\n" + + " envelope Query raw session envelopes", }, }); diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts new file mode 100644 index 000000000..d32f0b3dc --- /dev/null +++ b/packages/cli/src/commands/local/management.ts @@ -0,0 +1,422 @@ +/** Local control-plane management and agent query commands. */ + +import { basename } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { CliError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { buildRouteMap } from "../../lib/route-map.js"; +import { + ensureLocalDaemon, + getRunningLocalDaemon, + requestLocalDaemon, + runLocalDaemonForeground, +} from "./daemon.js"; +import { parsePort } from "./server.js"; + +type DaemonFlags = { + readonly foreground: boolean; + readonly host: string; + readonly port: number; +}; +type SessionFlags = { + readonly clientId?: string; + readonly host: string; + readonly port: number; +}; +type ControlResult = Record; + +function publicDaemonState(state: { + host: string; + pid: number; + port: number; + version: number; +}): ControlResult { + return { + host: state.host, + pid: state.pid, + port: state.port, + version: state.version, + }; +} + +function formatHuman(data: ControlResult): string { + return JSON.stringify(data, null, 2); +} + +async function controlJson( + path: string, + init: RequestInit = {}, + options: { host?: string; port?: number } = {}, +): Promise { + const state = await ensureLocalDaemon(options); + const response = await requestLocalDaemon(state, path, init); + if (!response.ok) { + throw new CliError( + `Sentry Local control plane request failed with HTTP ${response.status}.`, + 1, + ); + } + if (response.status === 204 || response.status === 202) { + return { ok: true }; + } + return (await response.json()) as ControlResult; +} + +function singleReference(parts: string[]): string { + const reference = parts.join(" ").trim(); + if (!reference) { + throw new ValidationError("Provide a Local session or envelope ID.", "id"); + } + return reference; +} + +const daemonStartCommand = buildCommand({ + docs: { brief: "Start the Local control-plane daemon" }, + output: { human: formatHuman }, + parameters: { + flags: { + foreground: { + kind: "boolean", + brief: "Run in the foreground", + default: false, + }, + host: { + kind: "parsed", + parse: String, + brief: "Loopback host", + default: "localhost", + }, + port: { + kind: "parsed", + parse: parsePort, + brief: "Control-plane port", + default: "8969", + }, + }, + }, + auth: false, + async *func(flags: DaemonFlags) { + if (flags.foreground) { + await runLocalDaemonForeground(flags); + return; + } + yield new CommandOutput(publicDaemonState(await ensureLocalDaemon(flags))); + }, +}); + +const daemonStatusCommand = buildCommand({ + docs: { brief: "Show Local control-plane status" }, + output: { human: formatHuman }, + parameters: { flags: {} }, + auth: false, + async *func() { + const daemon = await getRunningLocalDaemon(); + yield new CommandOutput({ + daemon: daemon ? publicDaemonState(daemon) : null, + }); + }, +}); + +const daemonStopCommand = buildCommand({ + docs: { brief: "Stop the Local control-plane daemon" }, + output: { human: formatHuman }, + parameters: { flags: {} }, + auth: false, + async *func() { + const state = await getRunningLocalDaemon(); + if (!state) { + yield new CommandOutput({ stopped: false, reason: "not running" }); + return; + } + const response = await requestLocalDaemon(state, "/daemon/stop", { + method: "POST", + }); + yield new CommandOutput({ stopped: response.ok }); + }, +}); + +const daemonRestartCommand = buildCommand({ + docs: { brief: "Restart the Local control-plane daemon" }, + output: { human: formatHuman }, + parameters: { flags: {} }, + auth: false, + async *func() { + const state = await getRunningLocalDaemon(); + if (state) { + await requestLocalDaemon(state, "/daemon/stop", { method: "POST" }); + for (let attempt = 0; attempt < 40; attempt += 1) { + if (!(await getRunningLocalDaemon())) break; + await sleep(125); + } + } + yield new CommandOutput( + publicDaemonState( + await ensureLocalDaemon( + state ? { host: state.host, port: state.port } : {}, + ), + ), + ); + }, +}); + +const sessionCreateCommand = buildCommand({ + docs: { brief: "Create a Local telemetry session" }, + output: { human: formatHuman }, + parameters: { + flags: { + clientId: { + kind: "parsed", + parse: String, + brief: "Idempotency key for retried agent session creation", + optional: true, + }, + host: { + kind: "parsed", + parse: String, + brief: "Loopback host", + default: "localhost", + }, + port: { + kind: "parsed", + parse: parsePort, + brief: "Control-plane port", + default: "8969", + }, + }, + positional: { + kind: "array", + parameter: { + brief: "Optional session label", + parse: String, + placeholder: "name", + }, + }, + }, + auth: false, + async *func(this: SentryContext, flags: SessionFlags, ...parts: string[]) { + const label = parts.join(" ").trim() || basename(this.cwd); + yield new CommandOutput( + await controlJson( + "/sessions", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: flags.clientId, + label, + cwd: this.cwd, + }), + }, + flags, + ), + ); + }, +}); + +const sessionListCommand = buildCommand({ + docs: { brief: "List Local telemetry sessions" }, + output: { human: formatHuman }, + parameters: { flags: {} }, + auth: false, + async *func() { + yield new CommandOutput(await controlJson("/sessions")); + }, +}); + +const sessionViewCommand = buildCommand({ + docs: { brief: "View a Local telemetry session" }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}`, + ), + ); + }, +}); + +function buildSessionMutationCommand(action: "close" | "reset", brief: string) { + return buildCommand({ + docs: { brief }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}/${action}`, + { method: "POST" }, + ), + ); + }, + }); +} + +const envelopeListCommand = buildCommand({ + docs: { brief: "List retained envelopes for a Local session" }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}/envelopes`, + ), + ); + }, +}); + +const envelopeViewCommand = buildCommand({ + docs: { brief: "View a raw Local envelope" }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session and envelope ID", + parse: String, + placeholder: "session envelope", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + const [session, envelope] = parts; + if (!(session && envelope) || parts.length !== 2) { + throw new ValidationError( + "Provide a session and envelope ID.", + "envelope", + ); + } + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(session)}/envelopes/${encodeURIComponent(envelope)}`, + ), + ); + }, +}); + +const eventListCommand = buildCommand({ + docs: { brief: "List decoded events for a Local session" }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}/events`, + ), + ); + }, +}); + +const eventViewCommand = buildCommand({ + docs: { brief: "View a decoded Local event" }, + output: { human: formatHuman }, + parameters: { + flags: {}, + positional: { + kind: "array", + parameter: { + brief: "Session and event ID", + parse: String, + placeholder: "session event", + }, + }, + }, + auth: false, + async *func(_flags, ...parts: string[]) { + const [session, event] = parts; + if (!(session && event) || parts.length !== 2) { + throw new ValidationError("Provide a session and event ID.", "event"); + } + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(session)}/events/${encodeURIComponent(event)}`, + ), + ); + }, +}); + +export const daemonRoute = buildRouteMap({ + routes: { + start: daemonStartCommand, + status: daemonStatusCommand, + stop: daemonStopCommand, + restart: daemonRestartCommand, + }, + defaultCommand: "status", + docs: { brief: "Manage the Local control plane" }, +}); +export const sessionRoute = buildRouteMap({ + routes: { + create: sessionCreateCommand, + list: sessionListCommand, + view: sessionViewCommand, + close: buildSessionMutationCommand( + "close", + "Close a Local telemetry session", + ), + reset: buildSessionMutationCommand( + "reset", + "Clear a Local telemetry session", + ), + }, + defaultCommand: "list", + docs: { brief: "Manage Local telemetry sessions" }, +}); +export const envelopeRoute = buildRouteMap({ + routes: { list: envelopeListCommand, view: envelopeViewCommand }, + defaultCommand: "list", + docs: { brief: "Query Local envelopes" }, +}); +export const eventRoute = buildRouteMap({ + routes: { list: eventListCommand, view: eventViewCommand }, + defaultCommand: "list", + docs: { brief: "Query Local decoded events" }, +}); diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 6f37ca41f..e279fe608 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -19,7 +19,7 @@ import { spawn, } from "node:child_process"; import type { Server } from "node:http"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; @@ -34,6 +34,10 @@ import { } from "../../lib/formatters/local.js"; import { logger, printJsonLine, printLine } from "../../lib/logger.js"; import { injectWranglerSpotlightBinding } from "../../lib/wrangler.js"; +import { + createLocalDaemonSession, + retainLocalDaemonSession, +} from "./daemon.js"; import { buildApp, consumeSSE, @@ -60,6 +64,7 @@ type RunFlags = { readonly format: FormatValue; readonly attributes: boolean; readonly open: boolean; + readonly session?: string; }; /** Buffer size for the auto-started background server. */ @@ -160,15 +165,23 @@ function forwardChildOutput(child: ChildProcess, useJson: boolean): void { * reached the other server, but nothing was ever printed here. Attaching as * an SSE consumer is what `sentry local serve` does in the same situation. */ -function attachToExistingServer( - url: string, - activeFilters: ReadonlySet, - useJson: boolean, - showAttributes: boolean, -): EventTail { +function attachToExistingServer({ + activeFilters, + showAttributes, + streamUrl, + url, + useJson, +}: { + activeFilters: ReadonlySet; + showAttributes: boolean; + streamUrl?: string; + url: string; + useJson: boolean; +}): EventTail { const ac = new AbortController(); const tail = consumeSSE({ url, + streamUrl, activeFilters, signal: ac.signal, useJson, @@ -253,7 +266,12 @@ async function openEventTail({ if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); - return attachToExistingServer(url, activeFilters, useJson, showAttributes); + return attachToExistingServer({ + activeFilters, + showAttributes, + url, + useJson, + }); } logger.info("No server detected, starting one in the background..."); @@ -273,7 +291,12 @@ async function openEventTail({ } // Something grabbed the port between the probe and the bind. logger.warn(`${err.message}; attaching to it instead`); - return attachToExistingServer(url, activeFilters, useJson, showAttributes); + return attachToExistingServer({ + activeFilters, + showAttributes, + url, + useJson, + }); } } @@ -420,6 +443,12 @@ export const runCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + session: { + kind: "parsed", + parse: String, + brief: "Create a daemon-owned telemetry session with this label", + optional: true, + }, }, aliases: { p: "port", @@ -446,21 +475,43 @@ export const runCommand = buildCommand({ } let url = formatLocalServerUrl(flags.host, flags.port); + let daemonSessionId: string | undefined; + let daemonIngestUrl: string | undefined; + let daemonStreamUrl: string | undefined; const useJson = flags.format === "json"; const activeFilters = new Set(flags.filter); - const tail = await openEventTail({ - port: flags.port, - host: flags.host, - activeFilters, - useJson, - showAttributes: flags.attributes, - }); + const tail = flags.session + ? await (async () => { + const session = await createLocalDaemonSession({ + cwd: this.cwd, + host: flags.host, + label: flags.session || basename(this.cwd), + port: flags.port, + }); + daemonSessionId = session.id; + daemonIngestUrl = session.ingestUrl; + daemonStreamUrl = session.streamUrl; + return attachToExistingServer({ + activeFilters, + showAttributes: flags.attributes, + streamUrl: session.streamUrl, + url: new URL(session.streamUrl).origin, + useJson, + }); + })() + : await openEventTail({ + port: flags.port, + host: flags.host, + activeFilters, + useJson, + showAttributes: flags.attributes, + }); url = tail.url; - await openLocalUiIfRequested(flags.open, url); + await openLocalUiIfRequested(flags.open, daemonStreamUrl ?? url); - const spotlightUrl = `${url}/stream`; + const spotlightUrl = daemonIngestUrl ?? `${url}/stream`; const wrangler = await injectWranglerSpotlightBinding( args, spotlightUrl, @@ -470,7 +521,9 @@ export const runCommand = buildCommand({ logger.info("Injected SENTRY_SPOTLIGHT as a Wrangler Worker binding"); } logger.info(`Starting: ${bold(wrangler.args.join(" "))}`); - logger.info(`SENTRY_SPOTLIGHT=${spotlightUrl}`); + logger.info( + `SENTRY_SPOTLIGHT=${daemonSessionId ? `${new URL(spotlightUrl).origin}/_local/v1/sessions/${daemonSessionId}/stream` : spotlightUrl}`, + ); const childEnv = buildChildEnv(spotlightUrl, commandSource, this.cwd); @@ -485,6 +538,9 @@ export const runCommand = buildCommand({ forwardChildOutput(child, useJson); } catch (err) { await tail.cleanup(); + if (daemonSessionId) { + await retainLocalDaemonSession(daemonSessionId); + } throw new CliError( `Failed to start "${wrangler.args[0]}": ${err instanceof Error ? err.message : String(err)}`, EXIT.GENERAL, @@ -529,6 +585,9 @@ export const runCommand = buildCommand({ process.removeListener("SIGINT", onSigint); process.removeListener("SIGTERM", onSigterm); await tail.cleanup(); + if (daemonSessionId) { + await retainLocalDaemonSession(daemonSessionId); + } } if (exitCode !== 0) { diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 32f9bd7dd..6b3fa15ad 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -12,6 +12,7 @@ */ import type { Server } from "node:http"; +import { basename } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { serve } from "@hono/node-server"; import { @@ -65,7 +66,7 @@ const BUFFER_SIZE = 500; const CR_RE = /\r$/; /** Maximum ingest body size (10 MB). Rejects oversized payloads early. */ -const MAX_BODY_BYTES = 10 * 1024 * 1024; +export const MAX_BODY_BYTES = 10 * 1024 * 1024; /** * Parse and validate a `--format` value. @@ -105,6 +106,7 @@ type LocalFlags = { readonly format: FormatValue; readonly attributes: boolean; readonly open: boolean; + readonly session?: string; }; /** @@ -144,7 +146,8 @@ type LocalReceiverOptions = { uiActions?: boolean; }; -function isHostedUiOrigin(origin: string | undefined): origin is string { +/** Whether an origin is the production or preview Local viewer. */ +export function isHostedUiOrigin(origin: string | undefined): origin is string { if (!origin) { return false; } @@ -197,7 +200,7 @@ function isHostedUiStreamRequest(request: { * suffix (via `?base64` on `/stream`). Spotlight UI clients use the suffix * to pick their decode path, so it must be echoed back or they drop events. */ -function buildSSEHandler( +export function buildSSEHandler( stream: { writeSSE: (event: { id?: string; @@ -600,6 +603,8 @@ const SSE_MAX_RETRY_MS = 30_000; /** Options for consuming an SSE stream. */ export type ConsumeSSEOptions = { url: string; + /** A full scoped stream URL, when consuming a daemon-owned session. */ + streamUrl?: string; activeFilters: ReadonlySet; signal: AbortSignal; quiet?: boolean; @@ -638,6 +643,7 @@ async function sleepUnlessAborted( export async function consumeSSE(opts: ConsumeSSEOptions): Promise { const { url, + streamUrl, activeFilters, signal, quiet = false, @@ -652,6 +658,7 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise { while (!signal.aborted) { const result = await attemptSSEConnection({ url, + streamUrl, activeFilters, signal, quiet, @@ -734,6 +741,7 @@ async function attemptSSEConnection( /** Options for a single SSE connection attempt. */ type ConsumeSSEOnceOptions = { url: string; + streamUrl?: string; activeFilters: ReadonlySet; signal: AbortSignal; quiet: boolean; @@ -766,7 +774,10 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { if (lastEventId) { headers["Last-Event-ID"] = lastEventId; } - const res = await fetch(`${url}/stream`, { headers, signal }); + const res = await fetch(opts.streamUrl ?? `${url}/stream`, { + headers, + signal, + }); if (!res.ok) { logger.warn(`SSE stream returned HTTP ${res.status}`); return false; @@ -917,6 +928,12 @@ export const serverCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + session: { + kind: "parsed", + parse: String, + brief: "Create a daemon-owned telemetry session with this label", + optional: true, + }, }, aliases: { p: "port", @@ -934,6 +951,45 @@ export const serverCommand = buildCommand({ const activeFilters = new Set(flags.filter); const url = formatLocalServerUrl(flags.host, flags.port); + if (flags.session) { + const { createLocalDaemonSession, retainLocalDaemonSession } = + await import("./daemon.js"); + const session = await createLocalDaemonSession({ + cwd: this.cwd, + host: flags.host, + label: flags.session || basename(this.cwd), + port: flags.port, + }); + logger.info("Sentry Local telemetry session"); + logger.info(` Session: ${bold(session.id)} (${session.label})`); + logger.info( + ` Capabilities: sentry local session view ${session.id} --json`, + ); + logger.info("Press Ctrl-C to stop."); + await openLocalUiIfRequested(flags.open, session.streamUrl); + + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + try { + await consumeSSE({ + activeFilters, + quiet: flags.quiet, + showAttributes: flags.attributes, + signal: controller.signal, + streamUrl: session.streamUrl, + url: new URL(session.streamUrl).origin, + useJson: flags.format === "json", + }); + } finally { + process.removeListener("SIGINT", stop); + process.removeListener("SIGTERM", stop); + await retainLocalDaemonSession(session.id); + } + return; + } + if (await isServerRunning(url)) { logger.info(`Connected to existing server at ${bold(url)}`); await openLocalUiIfRequested(flags.open, url); diff --git a/packages/cli/src/commands/local/session-store.ts b/packages/cli/src/commands/local/session-store.ts new file mode 100644 index 000000000..a8c5f61da --- /dev/null +++ b/packages/cli/src/commands/local/session-store.ts @@ -0,0 +1,200 @@ +/** + * In-memory lifecycle and isolation for Local control-plane sessions. + * + * Session metadata and envelopes intentionally disappear when the daemon exits. + */ + +import { randomBytes } from "node:crypto"; +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { uuidv7 } from "uuidv7"; + +/** Maximum number of envelopes retained by an individual Local session. */ +export const SESSION_BUFFER_SIZE = 500; + +/** Retained sessions remain queryable for three hours after closing. */ +export const SESSION_RETENTION_MS = 3 * 60 * 60 * 1000; + +/** Open lease or a closed session retained for later queries. */ +export type LocalSessionState = "open" | "retained"; + +/** Public session metadata, excluding telemetry buffers and secret capabilities. */ +export type LocalSession = { + readonly clientId?: string; + readonly id: string; + readonly label: string; + readonly cwd: string; + readonly createdAt: number; + readonly lastActivityAt: number; + readonly state: LocalSessionState; + readonly expiresAt?: number; +}; + +type StoredSession = LocalSession & { + buffer: ReturnType; + ingestCapability: string; + streamCapability: string; +}; + +/** Session label, working directory, and optional retry idempotency key. */ +export type CreateLocalSessionInput = { + readonly clientId?: string; + readonly label: string; + readonly cwd: string; +}; + +/** In-memory session lifecycle and access to isolated telemetry buffers. */ +export type LocalSessionStore = { + create: (input: CreateLocalSessionInput) => LocalSession; + close: (reference: string) => LocalSession; + getCapabilities: (reference: string) => { + ingest: string; + stream: string; + }; + getBuffer: (reference: string) => ReturnType; + list: () => LocalSession[]; + pruneExpired: () => string[]; + recordActivity: (reference: string) => LocalSession; + resolve: (reference: string) => LocalSession; +}; + +function toPublicSession(session: StoredSession): LocalSession { + const { + buffer: _buffer, + ingestCapability: _ingestCapability, + streamCapability: _streamCapability, + ...publicSession + } = session; + return publicSession; +} + +/** Build a session store with injectable time for deterministic lifecycle tests. */ +export function createLocalSessionStore({ + now = Date.now, +}: { + now?: () => number; +} = {}): LocalSessionStore { + const sessions = new Map(); + const clientSessions = new Map(); + + const resolveStored = (reference: string): StoredSession => { + const byId = sessions.get(reference); + if (byId) { + return byId; + } + const matches = [...sessions.values()].filter( + (session) => session.label === reference, + ); + if (matches.length === 0) { + throw new Error(`Local session "${reference}" was not found.`); + } + if (matches.length > 1) { + throw new Error( + `Local session name "${reference}" is ambiguous; use its session ID.`, + ); + } + const [match] = matches; + if (!match) { + throw new Error(`Local session "${reference}" was not found.`); + } + return match; + }; + + const update = ( + session: StoredSession, + changes: Partial< + Omit + >, + ): StoredSession => { + const next = { ...session, ...changes }; + sessions.set(next.id, next); + return next; + }; + + return { + create({ clientId, label, cwd }) { + const existingId = clientId ? clientSessions.get(clientId) : undefined; + if (existingId) { + return toPublicSession(resolveStored(existingId)); + } + const timestamp = now(); + const session: StoredSession = { + id: uuidv7(), + clientId, + label, + cwd, + createdAt: timestamp, + lastActivityAt: timestamp, + state: "open", + buffer: createSpotlightBuffer(SESSION_BUFFER_SIZE), + ingestCapability: randomBytes(24).toString("base64url"), + streamCapability: randomBytes(24).toString("base64url"), + }; + sessions.set(session.id, session); + if (clientId) { + clientSessions.set(clientId, session.id); + } + return toPublicSession(session); + }, + close(reference) { + const timestamp = now(); + const session = resolveStored(reference); + return toPublicSession( + update(session, { + state: "retained", + lastActivityAt: timestamp, + expiresAt: timestamp + SESSION_RETENTION_MS, + }), + ); + }, + getBuffer(reference) { + return resolveStored(reference).buffer; + }, + getCapabilities(reference) { + const session = resolveStored(reference); + return { + ingest: session.ingestCapability, + stream: session.streamCapability, + }; + }, + list() { + return [...sessions.values()] + .map(toPublicSession) + .sort((left, right) => right.lastActivityAt - left.lastActivityAt); + }, + pruneExpired() { + const timestamp = now(); + const expired = [...sessions.values()] + .filter( + (session) => + session.state === "retained" && + session.expiresAt !== undefined && + session.expiresAt <= timestamp, + ) + .map((session) => session.id); + for (const id of expired) { + const session = sessions.get(id); + if (session?.clientId && clientSessions.get(session.clientId) === id) { + clientSessions.delete(session.clientId); + } + sessions.delete(id); + } + return expired; + }, + recordActivity(reference) { + const timestamp = now(); + const session = resolveStored(reference); + return toPublicSession( + update(session, { + lastActivityAt: timestamp, + expiresAt: + session.state === "retained" + ? timestamp + SESSION_RETENTION_MS + : undefined, + }), + ); + }, + resolve(reference) { + return toPublicSession(resolveStored(reference)); + }, + }; +} diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts index 80bc7f4a6..12751501f 100644 --- a/packages/cli/src/commands/local/ui.ts +++ b/packages/cli/src/commands/local/ui.ts @@ -51,7 +51,9 @@ export function buildLocalUiUrl(receiverUrl: string): string { const receiver = new URL(receiverUrl); assertLoopbackHostForUi(receiver.hostname); - const streamUrl = new URL("/stream", receiver).toString(); + const streamUrl = receiver.pathname.endsWith("/stream") + ? receiver.toString() + : new URL("/stream", receiver).toString(); const uiUrl = new URL(getLocalUiBaseUrl()); uiUrl.hash = new URLSearchParams({ stream: streamUrl }).toString(); return uiUrl.toString(); @@ -70,6 +72,13 @@ export async function openLocalUi(receiverUrl: string): Promise { logger.info("Opening Sentry Local UI..."); return; } + const streamUrl = new URL(receiverUrl); + if (streamUrl.searchParams.has("cap")) { + logger.warn( + "Could not open browser. Use sentry local session view --json to retrieve its stream URL.", + ); + return; + } logger.warn(`Could not open browser. Visit ${uiUrl}`); } diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts new file mode 100644 index 000000000..9d4e8e351 --- /dev/null +++ b/packages/cli/test/commands/local/control-plane.test.ts @@ -0,0 +1,377 @@ +import { request } from "node:http"; +import { describe, expect, test } from "vitest"; +import { createLocalControlPlane } from "../../../src/commands/local/control-plane.js"; +import { tryListen } from "../../../src/commands/local/server.js"; + +const CONTROL_TOKEN = "control-token"; + +function controlHeaders() { + return { Authorization: `Bearer ${CONTROL_TOKEN}` }; +} + +describe("Local control plane", () => { + test("creates isolated sessions through its authenticated control API", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + + expect(created.status).toBe(201); + const session = (await created.json()) as { + id: string; + label: string; + ingestUrl: string; + streamUrl: string; + }; + expect(session.label).toBe("web"); + expect(session.ingestUrl).toContain(`/${session.id}/stream?cap=`); + expect(session.streamUrl).toContain(`/${session.id}/stream?cap=`); + expect(session.ingestUrl).not.toBe(session.streamUrl); + + const sessions = await controlPlane.app.request("/_local/v1/sessions", { + headers: controlHeaders(), + }); + await expect(sessions.json()).resolves.toMatchObject({ + sessions: [{ id: session.id, label: "web", state: "open" }], + }); + }); + + test("does not expose the session registry without the control capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + + const response = await controlPlane.app.request("/_local/v1/sessions"); + + expect(response.status).toBe(401); + }); + + test("accepts envelopes only through the matching session ingestion capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { id: string; ingestUrl: string }; + const envelope = + '{"sdk":{"name":"sentry.node"}}\n{"type":"event"}\n{"message":"isolated"}'; + + const ingested = await controlPlane.app.request(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: envelope, + }); + expect(ingested.status).toBe(204); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(1); + + const rejected = await controlPlane.app.request( + session.ingestUrl.replace(/cap=[^&]+/, "cap=wrong"), + { method: "POST", body: envelope }, + ); + expect(rejected.status).toBe(401); + }); + + test("allows subscribers only through the session stream capability", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { streamUrl: string }; + + const rejected = await controlPlane.app.request( + session.streamUrl.replace(/cap=[^&]+/, "cap=wrong"), + ); + expect(rejected.status).toBe(401); + + const stream = await controlPlane.app.request(session.streamUrl); + expect(stream.status).toBe(200); + expect(stream.headers.get("content-type")).toContain("text/event-stream"); + await stream.body?.cancel(); + }); + + test("indexes decoded events by their Sentry event ID", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { id: string; ingestUrl: string }; + await controlPlane.app.request(session.ingestUrl, { + method: "POST", + body: '{"event_id":"event-123"}\n{"type":"event"}\n{"message":"isolated"}', + }); + + const listed = await controlPlane.app.request( + `/_local/v1/sessions/${session.id}/events`, + { headers: controlHeaders() }, + ); + await expect(listed.json()).resolves.toMatchObject({ + events: [{ eventId: "event-123" }], + }); + const viewed = await controlPlane.app.request( + `/_local/v1/sessions/${session.id}/events/event-123`, + { headers: controlHeaders() }, + ); + expect(viewed.status).toBe(200); + }); + + test("permits browser preflight only from a loopback development origin", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { ingestUrl: string }; + const response = await controlPlane.app.request(session.ingestUrl, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + }); + + test("acknowledges a daemon stop request before closing its listener", async () => { + let server: import("node:http").Server | undefined; + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + onStop: () => { + server?.closeAllConnections(); + server?.close(); + }, + }); + const listening = await tryListen(controlPlane.app, 0, "127.0.0.1"); + server = listening.server; + + const statusCode = await new Promise((resolve, reject) => { + const stopRequest = request( + `http://127.0.0.1:${listening.port}/_local/v1/daemon/stop`, + { method: "POST", headers: controlHeaders() }, + (response) => { + response.resume(); + response.on("end", () => resolve(response.statusCode ?? 0)); + }, + ); + stopRequest.on("error", reject); + stopRequest.end(); + }); + expect(statusCode).toBe(202); + }); + + test("lets a loopback UI reset and close only its scoped session", async () => { + const controlPlane = createLocalControlPlane({ + controlToken: CONTROL_TOKEN, + }); + const created = await controlPlane.app.request("/_local/v1/sessions", { + method: "POST", + headers: { ...controlHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify({ label: "web", cwd: "/work/web" }), + }); + const session = (await created.json()) as { + id: string; + ingestUrl: string; + streamUrl: string; + }; + const resetUrl = session.streamUrl.replace(/\/stream\?/, "/ui/reset?"); + const closeUrl = session.streamUrl.replace(/\/stream\?/, "/ui/close?"); + await controlPlane.app.request(session.ingestUrl, { + method: "POST", + body: '{"event_id":"before-reset"}\n{"type":"event"}\n{"message":"kept private"}', + }); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(1); + + const preflight = await controlPlane.app.request(resetUrl, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + + const wrongCapability = await controlPlane.app.request( + resetUrl.replace(/cap=[^&]+/, "cap=wrong"), + { method: "POST", headers: { Origin: "http://localhost:5173" } }, + ); + expect(wrongCapability.status).toBe(401); + + expect( + ( + await controlPlane.app.request(resetUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }) + ).status, + ).toBe(204); + expect( + controlPlane.sessionStore.getBuffer(session.id).read({ all: true }), + ).toHaveLength(0); + + const rejected = await controlPlane.app.request(closeUrl, { + method: "POST", + headers: { Origin: "https://receiver.example" }, + }); + expect(rejected.status).toBe(403); + + const closed = await controlPlane.app.request(closeUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }); + expect(closed.status).toBe(200); + expect(controlPlane.sessionStore.resolve(session.id).state).toBe( + "retained", + ); + }); +}); + +describe("Local daemon receiver compatibility", () => { + test.each(["gzip", "deflate", "br"] as const)( + "decodes %s SDK envelopes", + async (encoding) => { + const { gzipSync, deflateSync, brotliCompressSync } = + await import("node:zlib"); + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "compressed", + cwd: "/work", + }); + const capability = plane.sessionStore.getCapabilities(session.id).ingest; + const compress = { + gzip: gzipSync, + deflate: deflateSync, + br: brotliCompressSync, + }[encoding]; + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/stream?cap=${capability}`, + { + method: "POST", + headers: { + "Content-Type": "application/x-sentry-envelope", + "Content-Encoding": encoding, + }, + body: new Uint8Array( + compress( + '{"event_id":"compressed"}\n{"type":"event"}\n{"message":"compressed SDK"}', + ), + ), + }, + ); + expect(response.status).toBe(204); + const events = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/compressed`, + { headers: controlHeaders() }, + ); + expect(events.status).toBe(200); + expect(await events.text()).toContain("compressed SDK"); + }, + ); + + test("supports sendBeacon browser SDK envelopes", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "browser", + cwd: "/work", + }); + const cap = plane.sessionStore.getCapabilities(session.id).ingest; + const ingested = await plane.app.request( + `/_local/v1/sessions/${session.id}/stream?cap=${cap}&sentry_client=sentry.javascript.browser/10.0`, + { + method: "POST", + headers: { + "Content-Type": "text/plain", + Origin: "http://localhost:3000", + }, + body: '{"event_id":"browser"}\n{"type":"event"}\n{"message":"browser SDK"}', + }, + ); + expect(ingested.status).toBe(204); + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/browser`, + { headers: controlHeaders() }, + ); + expect(response.status).toBe(200); + }); + + test("allows hosted viewer reads and private-network preflight while rejecting hosted mutations", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "hosted", + cwd: "/work", + }); + const cap = plane.sessionStore.getCapabilities(session.id).stream; + const path = `/_local/v1/sessions/${session.id}/stream?cap=${cap}`; + const preflight = await plane.app.request(path, { + method: "OPTIONS", + headers: { + Origin: "https://local.sentry.dev", + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "Last-Event-ID", + "Access-Control-Request-Private-Network": "true", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe( + "https://local.sentry.dev", + ); + expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe( + "true", + ); + const response = await plane.app.request(path, { + headers: { Origin: "https://local.sentry.dev" }, + }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://local.sentry.dev", + ); + await response.body?.cancel(); + const mutation = await plane.app.request(path, { + method: "POST", + headers: { Origin: "https://local.sentry.dev" }, + body: "invalid", + }); + expect(mutation.status).toBe(403); + }); +}); + +test("requires control authorization for a session named stream", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "stream", + cwd: "/work/stream", + }); + const response = await plane.app.request("/_local/v1/sessions/stream"); + expect(response.status).toBe(401); + const authorized = await plane.app.request( + `/_local/v1/sessions/${session.id}`, + { headers: controlHeaders() }, + ); + expect(authorized.status).toBe(200); +}); diff --git a/packages/cli/test/commands/local/daemon-invocation.test.ts b/packages/cli/test/commands/local/daemon-invocation.test.ts new file mode 100644 index 000000000..09ec58446 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-invocation.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { buildLocalDaemonArgs } from "../../../src/commands/local/daemon.js"; + +const runtime = vi.hoisted(() => ({ sea: false })); +vi.mock("../../../src/lib/react-native/wrap-call.js", () => ({ + isSea: () => runtime.sea, +})); +afterEach(() => { + runtime.sea = false; + vi.unstubAllGlobals(); +}); + +describe("Local daemon invocation", () => { + test("re-executes a native CLI without adding a script or Node flags", () => { + runtime.sea = true; + vi.stubGlobal("process", { + ...process, + argv: ["/sentry", "local", "session", "create"], + execArgv: ["--experimental-sea-config"], + }); + expect(buildLocalDaemonArgs("127.0.0.1", 9000)).toEqual([ + "local", + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + "9000", + ]); + }); + test("preserves the Node script and loader flags for source and npm builds", () => { + vi.stubGlobal("process", { + ...process, + argv: ["/node", "/cli/bin.cjs", "local"], + execArgv: ["--import", "tsx"], + }); + expect(buildLocalDaemonArgs("localhost", 9000)).toEqual([ + "--import", + "tsx", + "/cli/bin.cjs", + "local", + "daemon", + "start", + "--foreground", + "--host", + "localhost", + "--port", + "9000", + ]); + }); +}); diff --git a/packages/cli/test/commands/local/daemon-lifecycle.test.ts b/packages/cli/test/commands/local/daemon-lifecycle.test.ts new file mode 100644 index 000000000..97297ac02 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-lifecycle.test.ts @@ -0,0 +1,90 @@ +/** Daemon identity and process invocation regression coverage. */ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { createServer } from "node:http"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../../helpers.js"; +import { + getRunningLocalDaemon, + writeLocalDaemonState, + runLocalDaemonForeground, +} from "../../../src/commands/local/daemon.js"; + +const getConfigDir = useTestConfigDir("local-daemon-lifecycle-"); +afterEach(() => vi.unstubAllGlobals()); + +describe("Local daemon discovery", () => { + test("rejects stale state when another service answers the public health endpoint", async () => { + vi.stubGlobal( + "fetch", + (globalThis as { __originalFetch: typeof fetch }).__originalFetch, + ); + const server = createServer((request, response) => { + response.statusCode = request.url === "/health" ? 200 : 404; + response.end("OK"); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + try { + await writeLocalDaemonState(getConfigDir(), { + host: "127.0.0.1", + port: address.port, + pid: process.pid, + token: "stale", + version: 1, + }); + await expect(getRunningLocalDaemon()).resolves.toBeUndefined(); + } finally { + await new Promise((resolve) => { + server.close(resolve); + server.closeAllConnections(); + }); + } + }); + + test("rejects non-loopback discovery without making a request", async () => { + const mock = vi.fn(); + vi.stubGlobal("fetch", mock); + await writeLocalDaemonState(getConfigDir(), { + host: "example.com", + port: 8969, + pid: process.pid, + token: "secret", + version: 1, + }); + await expect(getRunningLocalDaemon()).resolves.toBeUndefined(); + expect(mock).not.toHaveBeenCalled(); + }); + + test("rejects port zero in foreground mode", async () => { + await expect( + runLocalDaemonForeground({ host: "127.0.0.1", port: 0 }), + ).rejects.toThrow("non-zero"); + }); +}); + +test("closes the listener when discovery persistence fails", async () => { + const reserve = createServer(); + await new Promise((resolve) => reserve.listen(0, "127.0.0.1", resolve)); + const address = reserve.address(); + if (!address || typeof address === "string") + throw new Error("Expected a TCP address"); + await new Promise((resolve) => reserve.close(() => resolve())); + await mkdir(join(getConfigDir(), "local-control-plane.json")); + await expect( + runLocalDaemonForeground({ host: "127.0.0.1", port: address.port }), + ).rejects.toThrow(); + const replacement = createServer(); + try { + await new Promise((resolve, reject) => { + replacement.once("error", reject); + replacement.listen(address.port, "127.0.0.1", resolve); + }); + } finally { + await new Promise((resolve) => replacement.close(() => resolve())); + } +}); diff --git a/packages/cli/test/commands/local/daemon-state.test.ts b/packages/cli/test/commands/local/daemon-state.test.ts new file mode 100644 index 000000000..b856091e8 --- /dev/null +++ b/packages/cli/test/commands/local/daemon-state.test.ts @@ -0,0 +1,39 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + readLocalDaemonState, + removeLocalDaemonState, + writeLocalDaemonState, +} from "../../../src/commands/local/daemon.js"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("Local daemon state", () => { + test("stores daemon discovery and the control capability in a private state file", async () => { + const directory = await mkdtemp(join(tmpdir(), "sentry-local-state-")); + directories.push(directory); + const state = { + host: "127.0.0.1", + port: 8969, + pid: 42, + token: "secret", + version: 1, + }; + + await writeLocalDaemonState(directory, state); + + await expect(readLocalDaemonState(directory)).resolves.toEqual(state); + await removeLocalDaemonState(directory); + await expect(readLocalDaemonState(directory)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/test/commands/local/session-store.test.ts b/packages/cli/test/commands/local/session-store.test.ts new file mode 100644 index 000000000..0b8f8d343 --- /dev/null +++ b/packages/cli/test/commands/local/session-store.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "vitest"; +import { createLocalSessionStore } from "../../../src/commands/local/session-store.js"; + +describe("LocalSessionStore", () => { + test("creates isolated sessions with stable IDs and readable labels", () => { + const store = createLocalSessionStore({ now: () => 1000 }); + + const web = store.create({ label: "web", cwd: "/work/web" }); + const api = store.create({ label: "api", cwd: "/work/api" }); + + expect(web.id).not.toBe(api.id); + expect(web.label).toBe("web"); + expect(store.resolve(web.id)).toMatchObject({ id: web.id, label: "web" }); + expect(store.resolve("api")).toMatchObject({ + id: api.id, + cwd: "/work/api", + }); + }); + + test("rejects ambiguous labels instead of selecting an arbitrary session", () => { + const store = createLocalSessionStore({ now: () => 1000 }); + store.create({ label: "web", cwd: "/work/one" }); + store.create({ label: "web", cwd: "/work/two" }); + + expect(() => store.resolve("web")).toThrow(/ambiguous/i); + }); + + test("reuses a session when an agent retries with the same client ID", () => { + const store = createLocalSessionStore(); + const first = store.create({ + clientId: "agent-turn-7", + label: "web", + cwd: "/work/web", + }); + const retried = store.create({ + clientId: "agent-turn-7", + label: "web", + cwd: "/work/web", + }); + + expect(retried.id).toBe(first.id); + expect(store.list()).toHaveLength(1); + }); + + test("retains a closed session for three hours and then expires it", () => { + let now = 1000; + const store = createLocalSessionStore({ now: () => now }); + const session = store.create({ label: "web", cwd: "/work/web" }); + + store.close(session.id); + now += 3 * 60 * 60 * 1000 - 1; + expect(store.pruneExpired()).toEqual([]); + expect(store.resolve(session.id).state).toBe("retained"); + + now += 1; + expect(store.pruneExpired()).toEqual([session.id]); + expect(() => store.resolve(session.id)).toThrow(/not found/i); + }); + + test("refreshes the retention deadline when a retained session receives activity", () => { + let now = 1000; + const store = createLocalSessionStore({ now: () => now }); + const session = store.create({ label: "web", cwd: "/work/web" }); + store.close(session.id); + + now += 2 * 60 * 60 * 1000; + store.recordActivity(session.id); + now += 2 * 60 * 60 * 1000; + + expect(store.pruneExpired()).toEqual([]); + expect(store.resolve(session.id).state).toBe("retained"); + }); +}); diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts new file mode 100644 index 000000000..b6f6d45f1 --- /dev/null +++ b/packages/cli/test/e2e/local.test.ts @@ -0,0 +1,340 @@ +/** Real CLI process coverage for daemon discovery, isolated telemetry and leases. */ +import { createServer } from "node:net"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, describe, expect, test } from "vitest"; +import { runCli } from "../fixture.js"; +import { useTestConfigDir } from "../helpers.js"; + +/** Capability URLs returned explicitly by session creation. */ +type Session = { + id: string; + ingestUrl: string; + streamUrl: string; +}; + +const fetch = (globalThis as { __originalFetch: typeof globalThis.fetch }) + .__originalFetch; +const getConfigDir = useTestConfigDir("local-e2e-"); + +async function cli(...args: string[]) { + return runCli(["local", ...args, "--json"], { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }); +} + +async function availablePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected a TCP address"); + } + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +async function createSession(label: string, port: number): Promise { + const result = await cli( + "session", + "create", + label, + "--host", + "127.0.0.1", + "--port", + String(port), + ); + expect(result.exitCode, result.stderr).toBe(0); + return JSON.parse(result.stdout) as Session; +} + +async function ingest(session: Session, eventId: string, message: string) { + const response = await fetch(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: `${JSON.stringify({ event_id: eventId })}\n{"type":"event"}\n${JSON.stringify({ event_id: eventId, message })}`, + }); + expect(response.status).toBe(204); +} + +afterEach(async () => { + await cli("daemon", "stop"); + await expect + .poll( + async () => { + const status = await cli("daemon", "status"); + return JSON.parse(status.stdout) as unknown; + }, + { timeout: 10_000 }, + ) + .toEqual({ daemon: null }); +}); + +describe("Local daemon E2E", () => { + test("isolates sessions and capabilities, queries retained telemetry and clears only one session", async () => { + const port = await availablePort(); + const [web, worker] = await Promise.all([ + createSession("web", port), + createSession("worker", port), + ]); + expect(web.id).not.toBe(worker.id); + const statePath = join(getConfigDir(), "local-control-plane.json"); + const state = JSON.parse(await readFile(statePath, "utf8")) as { + token: string; + host: string; + port: number; + }; + if (process.platform !== "win32") { + expect((await stat(statePath)).mode % 0o1000).toBe(0o600); + } + const status = await cli("daemon", "status"); + expect(status.stdout).not.toContain(state.token); + const base = `http://${state.host}:${state.port}/_local/v1`; + expect((await fetch(`${base}/sessions`)).status).toBe(401); + expect( + (await fetch(web.streamUrl, { method: "POST", body: "invalid" })).status, + ).toBe(401); + const wrongSession = new URL(web.ingestUrl); + wrongSession.pathname = wrongSession.pathname.replace(web.id, worker.id); + expect( + (await fetch(wrongSession, { method: "POST", body: "invalid" })).status, + ).toBe(401); + + await ingest(web, "a".repeat(32), "web event"); + await ingest(worker, "b".repeat(32), "worker event"); + const listed = await cli("event", "list", "web"); + expect(listed.exitCode, listed.stderr).toBe(0); + const events = JSON.parse(listed.stdout) as { + events: { eventId: string; envelopeId: string }[]; + }; + expect(events.events).toHaveLength(1); + expect(events.events[0]?.eventId).toBe("a".repeat(32)); + const viewed = await cli("event", "view", web.id, "a".repeat(32)); + expect(viewed.exitCode, viewed.stderr).toBe(0); + expect(viewed.stdout).toContain("web event"); + expect(viewed.stdout).not.toContain("worker event"); + const raw = await cli( + "envelope", + "view", + web.id, + events.events[0]?.envelopeId ?? "", + ); + expect(raw.exitCode, raw.stderr).toBe(0); + const envelope = JSON.parse(raw.stdout) as { data: string }; + expect(Buffer.from(envelope.data, "base64").toString()).toContain( + "web event", + ); + + const stream = await fetch(web.streamUrl, { + signal: AbortSignal.timeout(5000), + }); + const reader = stream.body?.getReader(); + expect(reader).toBeDefined(); + const first = await reader?.read(); + expect(new TextDecoder().decode(first?.value)).toContain("web event"); + await reader?.cancel(); + const resetUrl = new URL(web.streamUrl); + resetUrl.pathname = resetUrl.pathname.replace(/\/stream$/, "/ui/reset"); + expect( + ( + await fetch(resetUrl, { + method: "POST", + headers: { Origin: "https://example.com" }, + }) + ).status, + ).toBe(403); + expect( + ( + await fetch(resetUrl, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + }) + ).status, + ).toBe(204); + expect(JSON.parse((await cli("event", "list", "web")).stdout)).toEqual({ + events: [], + }); + expect((await cli("event", "list", "worker")).stdout).toContain( + "b".repeat(32), + ); + const closed = await cli("session", "close", worker.id); + expect(closed.exitCode, closed.stderr).toBe(0); + expect(JSON.parse(closed.stdout)).toMatchObject({ + id: worker.id, + state: "retained", + }); + expect( + (await cli("event", "view", worker.id, "b".repeat(32))).stdout, + ).toContain("worker event"); + }, 30_000); + + test("retains a wrapped application's session and injects scoped SDK URLs", async () => { + const port = await availablePort(); + const script = join(getConfigDir(), "app.cjs"); + const { writeFile } = await import("node:fs/promises"); + await writeFile( + script, + `const url = process.env.SENTRY_SPOTLIGHT; + if (url !== process.env.VITE_SENTRY_SPOTLIGHT) process.exit(2); + fetch(url, {method:'POST', headers:{'Content-Type':'application/x-sentry-envelope'}, + body:'{"event_id":"${"c".repeat(32)}"}\\n{"type":"event"}\\n{"message":"wrapped app"}'}) + .then(r => {if (r.status !== 204) process.exitCode=3; console.error('app completed');}) + .catch(() => {process.exitCode=4;});`, + ); + const result = await runCli( + [ + "local", + "run", + "--session", + "wrapped", + "--host", + "127.0.0.1", + "--port", + String(port), + "--format", + "json", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain("app completed"); + expect(result.stdout).toContain("wrapped app"); + const listed = await cli("session", "list"); + expect(JSON.parse(listed.stdout)).toMatchObject({ + sessions: [{ label: "wrapped", state: "retained" }], + }); + expect( + (await cli("event", "view", "wrapped", "c".repeat(32))).stdout, + ).toContain("wrapped app"); + }, 30_000); + + test("restarts on the configured port and preserves singleton discovery", async () => { + const port = await availablePort(); + await createSession("restart", port); + const result = await cli("daemon", "restart"); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + host: "127.0.0.1", + port, + }); + await sleep(100); + expect((await cli("daemon", "status")).exitCode).toBe(0); + }, 30_000); +}); + +describe("Local foreground commands", () => { + test("serve consumes the scoped stream and a second foreground daemon cannot replace discovery", async () => { + const { spawn } = await import("node:child_process"); + const { getCliCommand } = await import("../fixture.js"); + const port = await availablePort(); + const session = await createSession("existing", port); + const before = await readFile( + join(getConfigDir(), "local-control-plane.json"), + "utf8", + ); + const rejected = await cli( + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + String(await availablePort()), + ); + expect(rejected.exitCode).not.toBe(0); + expect( + await readFile(join(getConfigDir(), "local-control-plane.json"), "utf8"), + ).toBe(before); + const [executable, ...prefix] = getCliCommand(); + if (!executable) throw new Error("Missing CLI executable"); + const child = spawn( + executable, + [ + ...prefix, + "local", + "serve", + "--session", + "serve-e2e", + "--format", + "json", + ], + { + cwd: join(import.meta.dirname, "../.."), + env: { + ...process.env, + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed = new Promise((resolve, reject) => { + child.once("close", () => resolve()); + child.once("error", reject); + }); + try { + await expect + .poll( + async () => { + const listed = JSON.parse( + (await cli("session", "list")).stdout, + ) as { sessions: { label: string; id: string }[] }; + return listed.sessions.find((item) => item.label === "serve-e2e") + ?.id; + }, + { timeout: 10000 }, + ) + .toBeDefined(); + const listed = JSON.parse((await cli("session", "list")).stdout) as { + sessions: { label: string; id: string }[]; + }; + const id = listed.sessions.find((item) => item.label === "serve-e2e")?.id; + if (!id) throw new Error("Missing serve session"); + const scoped = JSON.parse( + (await cli("session", "view", id)).stdout, + ) as Session; + await ingest(scoped, "e".repeat(32), "serve session"); + await expect + .poll(() => stdout, { timeout: 5000 }) + .toContain("serve session"); + await ingest(session, "d".repeat(32), "other session"); + expect(stdout).not.toContain("other session"); + expect(child.exitCode, stderr).toBeNull(); + await cli("session", "close", id); + } finally { + child.kill("SIGTERM"); + await closed; + } + }, 30000); +}); diff --git a/packages/cli/test/fixture.ts b/packages/cli/test/fixture.ts index 669fa5720..1614963e3 100644 --- a/packages/cli/test/fixture.ts +++ b/packages/cli/test/fixture.ts @@ -58,6 +58,9 @@ export type CliResult = { export function getCliCommand(): string[] { const binaryPath = process.env.SENTRY_CLI_BINARY; if (binaryPath) { + if (/\.[cm]?js$/i.test(binaryPath)) { + return [process.execPath, binaryPath]; + } return [binaryPath]; } return [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a54503ee..ac190f8c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,7 +232,7 @@ importers: version: 1.4.2(typescript@5.8.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -272,6 +272,109 @@ importers: specifier: ^4.118.0 version: 4.133.0(@types/node@24.13.5) + apps/local: + dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.23 + version: 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': + specifier: ^1.3.3 + version: 1.3.3(@types/react@19.3.0)(react@19.2.8) + '@shikijs/core': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/engine-javascript': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/langs': + specifier: ^3.23.0 + version: 3.23.0 + '@shikijs/themes': + specifier: ^3.23.0 + version: 3.23.0 + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@vercel/analytics': + specifier: ^2.0.1 + version: 2.0.1(react@19.2.8) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + lucide-react: + specifier: ^1.43.0 + version: 1.55.0(@types/react@19.3.0)(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + nuqs: + specifier: ^2.10.1 + version: 2.10.2(react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^7.18.3 + version: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.7.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@spotlightjs/spotlight': + specifier: ^4.11.7 + version: 4.11.8(@cfworker/json-schema@4.1.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.13.8)) + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@types/node': + specifier: ^24.13.3 + version: 24.13.5 + '@types/react': + specifier: ^19.2.18 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.0 + version: 6.1.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + eventsource: + specifier: ^4.1.0 + version: 4.1.1 + jsdom: + specifier: ^28.1.0 + version: 28.1.0 + oxlint: + specifier: ^1.79.0 + version: 1.87.0 + typescript: + specifier: 'catalog:' + version: 5.8.3 + vite: + specifier: ^8.2.2 + version: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + vitest: + specifier: ^4.1.9 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + packages/cli: devDependencies: '@anthropic-ai/sdk': @@ -441,7 +544,7 @@ importers: version: 1.4.2(typescript@5.9.3) vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) wrap-ansi: specifier: ^10.0.0 version: 10.0.1 @@ -474,10 +577,10 @@ importers: version: 11.1.0(@cloudflare/workers-types@4.20260405.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@sentry/react': specifier: 'catalog:' - version: 11.1.0(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 11.1.0(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) agents: specifier: 'catalog:' - version: 0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3) + version: 0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3) ai: specifier: 'catalog:' version: 6.0.64(zod@4.4.3) @@ -532,7 +635,7 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.13.15 - version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) + version: 1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)) '@cloudflare/vitest-pool-workers': specifier: 'catalog:' version: 0.14.1(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11) @@ -556,7 +659,7 @@ importers: version: 0.5.16(tailwindcss@4.3.3) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) '@types/react': specifier: 'catalog:' version: 19.1.8 @@ -568,7 +671,7 @@ importers: version: 4.2.5 '@vitejs/plugin-react': specifier: 'catalog:' - version: 4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) miniflare: specifier: 'catalog:' version: 4.20260401.0(@types/node@24.13.5) @@ -580,10 +683,10 @@ importers: version: 10.1.0 vite: specifier: 'catalog:' - version: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + version: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) wrangler: specifier: 4.80.0 version: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) @@ -702,7 +805,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/mcp-server-evals: dependencies: @@ -741,7 +844,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -814,7 +917,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/smoke-tests: devDependencies: @@ -823,7 +926,7 @@ importers: version: 22.16.0 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages/toolkit-core: devDependencies: @@ -832,7 +935,7 @@ importers: version: 5.8.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -866,6 +969,9 @@ packages: express: optional: true + '@acemir/cssom@0.9.32': + resolution: {integrity: sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==} + '@ai-sdk/anthropic@3.0.33': resolution: {integrity: sha512-MvQzipOAK99Ct3nX6d8oz2AJ9c8kbiU2uSpGYK3F5oBBa8Gy04+LbYBoB9oH8bbZJxFafkgwBq1+k8xpU7vVxQ==} engines: {node: '>=18'} @@ -974,6 +1080,20 @@ packages: '@apm-js-collab/tracing-hooks@0.10.1': resolution: {integrity: sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@ast-grep/cli-darwin-arm64@0.43.0': resolution: {integrity: sha512-0i63gSBbgriPaRpFsbP3yETxolHPK2JAZbpcGbFOytB7QTnKAguwhlKmIOkUGKfsCzYiEq1awY0EBmvjMONXOg==} engines: {node: '>= 10'} @@ -1338,6 +1458,10 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + '@bruits/satteri-darwin-arm64@0.10.5': resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} cpu: [arm64] @@ -1515,6 +1639,7 @@ packages: '@cloudflare/vitest-pool-workers@0.14.1': resolution: {integrity: sha512-AuIvoGXi9bMjgD1636JtP84yGsjhdtsk23rYMVFLO9KB84rvwhVPkvwgBv74RbhbURXYDKWV5koPwPucFSFgUg==} + deprecated: '@cloudflare/vitest-pool-workers has been renamed to @cloudflare/vitest-plugin. This package will not receive future updates.' peerDependencies: '@vitest/runner': ^4.1.0 '@vitest/snapshot': ^4.1.0 @@ -1620,6 +1745,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.6': + resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15': + resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.2.1': resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} engines: {node: '>=14'} @@ -2295,6 +2456,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@expressive-code/core@0.44.2': resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} @@ -2560,9 +2730,6 @@ packages: '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -3170,6 +3337,9 @@ packages: '@radix-ui/primitive@1.1.7': resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + '@radix-ui/react-accordion@1.2.20': resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} peerDependencies: @@ -3236,6 +3406,28 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.0': + resolution: {integrity: sha512-bQUKIf2+/vGpJFCmX2pkxnQVT2Od3gkGGURs+bWCIdQ5JbzyTsItcDL25Y5nlwn2T+m/6utZJL+sEs9g7NfR/Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.1.4': resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} peerDependencies: @@ -3245,6 +3437,41 @@ packages: '@types/react': optional: true + '@radix-ui/react-dismissable-layer@1.2.0': + resolution: {integrity: sha512-oO7RhkUS9cwOjK4DGOWU19g9mk1h9h0ErEcNlVg832rgpBq8PGtQn7xyUJnuIO6EEhw6AbXx6/CGfqNEG0LW0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.0': + resolution: {integrity: sha512-kWTz59QlCFoE5Q05juX/HljYia4IaKAj5Hdx+WWgEznSVUzw4qZrpRFmV6xvqd9xhqWAFVJ4zQI/Gr/QDblc7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.4': resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: @@ -3254,6 +3481,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.10': resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: @@ -3267,6 +3507,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.10': resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: @@ -3280,6 +3533,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -3298,6 +3564,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.6': resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: @@ -3307,6 +3591,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.5': resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} peerDependencies: @@ -4259,9 +4552,31 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4346,9 +4661,6 @@ packages: '@types/node@22.20.3': resolution: {integrity: sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==} - '@types/node@24.0.10': - resolution: {integrity: sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==} - '@types/node@24.13.5': resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==} @@ -4369,6 +4681,11 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + '@types/react-scroll-to-bottom@4.2.5': resolution: {integrity: sha512-gYMMxxhphzTNfKc4NIkEgu4XRiQjfj/6R7QK10Igz8jOUPNXBLSnK3RS7ofsNWnJDEgpLkNOwSLuASASiGsfHQ==} @@ -4396,6 +4713,13 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@uidotdev/usehooks@2.4.1': + resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==} + engines: {node: '>=16'} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -4469,6 +4793,22 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + '@vitest/coverage-v8@4.1.11': resolution: {integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==} peerDependencies: @@ -4549,6 +4889,10 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} @@ -4644,6 +4988,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -4672,6 +5020,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4794,6 +5149,9 @@ packages: better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -5016,6 +5374,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5168,6 +5532,10 @@ packages: resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -5178,6 +5546,10 @@ packages: resolution: {integrity: sha512-H4s4arcLx/ugbu1XkkgSvcUZax0L6tXUqnppGniQb8l5VjUKGHoayXE5RiriiPhYDd+kjZnaok1Uig13PKtKYQ==} engines: {node: '>=12.17'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -5196,6 +5568,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -5244,6 +5619,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devalue@5.9.2: resolution: {integrity: sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==} @@ -5262,6 +5640,9 @@ packages: resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} hasBin: true + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -5349,6 +5730,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -5708,6 +6093,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} @@ -5872,6 +6261,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5894,10 +6287,18 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -6069,6 +6470,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -6157,6 +6561,15 @@ packages: engines: {node: '>=12.0.0'} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -6426,6 +6839,19 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@1.55.0: + resolution: {integrity: sha512-bSozQHiI3/8q7JvgoqVRGOYUi7fYT3siAZwmZsljjsOH2Husp/2ODdFf/leD+ZC/2yKfn7r8oh6yeoR3ITR5vA==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + macho-unsign@2.0.6: resolution: {integrity: sha512-YkIVGFnpVHJMMwfy4bHo79Vy05ddVk/PZGSCmmiCT4zepx+FMP/JAt9hOoXuc31s2bbcOtnzznOGca5fRhgZOg==} engines: {node: '>=18.12.0'} @@ -6804,6 +7230,12 @@ packages: resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} engines: {node: '>= 10'} + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -6862,6 +7294,27 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.10.2: + resolution: {integrity: sha512-HaLMCr9H2wywEnTu3j+QW98EteDT3Kfmx3SJJ6JfcvCvkFi/g/fKd/oCXa5DF0x1s1l4zcFtzsq9bflj6RcM6A==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 || ^8 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -7020,6 +7473,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7170,6 +7626,10 @@ packages: deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -7259,6 +7719,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@9.1.0: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: @@ -7275,12 +7738,32 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@7.18.1: - resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} - engines: {node: '>=20.0.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} peerDependencies: - react: '>=18' - react-dom: '>=18' + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' react-router@7.18.1: resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} @@ -7292,11 +7775,31 @@ packages: react-dom: optional: true + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-scroll-to-bottom@4.2.0: resolution: {integrity: sha512-1WweuumQc5JLzeAR81ykRdK/cEv9NlCPEm4vSwOGN1qS2qlpGVTyMgdI8Y7ZmaqRmzYBGV5/xPuJQtekYzQFGg==} peerDependencies: react: '>= 16.8.6' + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -7500,6 +8003,10 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -7802,6 +8309,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -7809,6 +8319,9 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -7887,6 +8400,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.18: + resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==} + + tldts@7.4.18: + resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==} + hasBin: true + tmp-promise@3.0.3: resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} @@ -7909,9 +8429,17 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8020,9 +8548,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@7.8.0: - resolution: {integrity: sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==} - undici@7.24.4: resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} engines: {node: '>=20.18.1'} @@ -8175,6 +8700,26 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.5.0: resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} peerDependencies: @@ -8354,6 +8899,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-back@5.1.1: resolution: {integrity: sha512-e/FRLDVdZQWFrAzU6Hdvpm7D7m2ina833gIKLptQykRK49mmCYHLHq7UqjPDbxbKLZkTkW1rFqbengdE3sLfdw==} engines: {node: '>=12.17'} @@ -8371,6 +8920,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -8378,6 +8931,14 @@ packages: webpack-virtual-modules@0.5.0: resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -8510,6 +9071,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlcreate@2.0.4: resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} @@ -8632,6 +9200,8 @@ snapshots: optionalDependencies: express: 5.2.1 + '@acemir/cssom@0.9.32': {} + '@ai-sdk/anthropic@3.0.33(zod@4.4.3)': dependencies: '@ai-sdk/provider': 3.0.6 @@ -8792,6 +9362,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@ast-grep/cli-darwin-arm64@0.43.0': optional: true @@ -8885,7 +9475,7 @@ snapshots: '@astrojs/internal-helpers@0.10.4': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 js-yaml: 4.3.2 picomatch: 4.0.7 @@ -8896,7 +9486,7 @@ snapshots: '@astrojs/internal-helpers@0.11.0': dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 js-yaml: 4.3.2 picomatch: 4.0.7 @@ -9249,6 +9839,10 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + '@bruits/satteri-darwin-arm64@0.10.5': optional: true @@ -9386,7 +9980,7 @@ snapshots: optionalDependencies: workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': + '@cloudflare/vite-plugin@1.13.15(@types/node@24.13.5)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5))': dependencies: '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20260401.1) '@remix-run/node-fetch-server': 0.8.1 @@ -9395,7 +9989,7 @@ snapshots: picocolors: 1.1.1 tinyglobby: 0.2.17 unenv: 2.0.0-rc.21 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) wrangler: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) ws: 8.18.0 transitivePeerDependencies: @@ -9411,7 +10005,7 @@ snapshots: cjs-module-lexer: 1.4.3 esbuild: 0.27.3 miniflare: 4.20260401.0(@types/node@24.13.5) - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) wrangler: 4.80.0(@cloudflare/workers-types@4.20260405.1)(@types/node@24.13.5) zod: 3.25.76 transitivePeerDependencies: @@ -9473,6 +10067,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + '@ctrl/tinycolor@4.2.1': {} '@emnapi/core@1.11.1': @@ -9553,7 +10171,7 @@ snapshots: '@emotion/memoize': 0.9.0 '@emotion/unitless': 0.10.0 '@emotion/utils': 1.4.2 - csstype: 3.1.3 + csstype: 3.2.3 '@emotion/sheet@1.4.0': {} @@ -9872,6 +10490,8 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@exodus/bytes@1.16.0': {} + '@expressive-code/core@0.44.2': dependencies: '@ctrl/tinycolor': 4.2.1 @@ -9879,8 +10499,8 @@ snapshots: hast-util-to-html: 9.0.5 hast-util-to-text: 4.0.2 hastscript: 9.0.1 - postcss: 8.5.26 - postcss-nested: 6.2.0(postcss@8.5.26) + postcss: 8.5.28 + postcss-nested: 6.2.0(postcss@8.5.28) unist-util-visit: 5.0.0 unist-util-visit-parents: 6.0.1 @@ -9901,13 +10521,13 @@ snapshots: '@fontsource/ibm-plex-mono@5.3.0': {} - '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.4.3)': + '@hono/mcp@0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.6.5)': dependencies: '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) hono: 4.13.8 hono-rate-limiter: 0.4.2(hono@4.13.8) pkce-challenge: 5.0.1 - zod: 4.4.3 + zod: 4.6.5 '@hono/node-server@1.19.17(hono@4.13.8)': dependencies: @@ -10128,7 +10748,7 @@ snapshots: '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@jridgewell/resolve-uri@3.1.2': {} @@ -10136,11 +10756,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.6.0': {} - '@jridgewell/trace-mapping@0.3.29': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -10244,7 +10859,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdx': 2.0.14 acorn: 8.15.0 collapse-white-space: 2.1.0 @@ -10326,7 +10941,7 @@ snapshots: pkce-challenge: 5.0.1 raw-body: 3.0.1 zod: 4.6.5 - zod-to-json-schema: 3.25.1(zod@4.4.3) + zod-to-json-schema: 3.25.1(zod@4.6.5) optionalDependencies: '@cfworker/json-schema': 4.1.1 transitivePeerDependencies: @@ -10671,6 +11286,8 @@ snapshots: '@radix-ui/primitive@1.1.7': {} + '@radix-ui/primitive@1.2.0': {} + '@radix-ui/react-accordion@1.2.20(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -10728,18 +11345,83 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-context@1.2.2(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-direction@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-dismissable-layer@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-id@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10747,6 +11429,23 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10756,6 +11455,15 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/react-slot': 1.3.3(@types/react@19.1.8)(react@19.2.8) @@ -10765,6 +11473,24 @@ snapshots: '@types/react': 19.1.8 '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.2.8) @@ -10779,6 +11505,27 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-slot@1.3.3(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 @@ -10788,6 +11535,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.1.8)(react@19.2.8)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8) @@ -10795,12 +11551,25 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.1.8)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + '@remix-run/node-fetch-server@0.8.1': {} '@rolldown/binding-android-arm-eabi@1.2.9': @@ -10886,14 +11655,24 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.2.9': optional: true - '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@babel/core': 7.28.0 picomatch: 4.0.7 rolldown: 1.0.0-beta.23 optionalDependencies: '@babel/runtime': 7.28.4 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) + + '@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.28.0 + picomatch: 4.0.7 + rolldown: 1.2.9 + optionalDependencies: + '@babel/runtime': 7.28.4 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + optional: true '@rolldown/pluginutils@1.0.0-beta.19': {} @@ -11321,14 +12100,14 @@ snapshots: '@sentry/conventions': 0.24.0 '@sentry/core': 11.1.0 - '@sentry/react@11.1.0(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': + '@sentry/react@11.1.0(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': dependencies: '@sentry/browser': 11.1.0 '@sentry/conventions': 0.24.0 '@sentry/core': 11.1.0 react: 19.2.8 optionalDependencies: - react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@sentry/replay-canvas@10.75.0': dependencies: @@ -11412,7 +12191,7 @@ snapshots: dependencies: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/core@4.4.3': @@ -11511,9 +12290,9 @@ snapshots: '@spotlightjs/spotlight@4.11.8(@cfworker/json-schema@4.1.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))(hono-rate-limiter@0.4.2(hono@4.13.8))': dependencies: - '@hono/mcp': 0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.4.3) + '@hono/mcp': 0.2.5(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(hono-rate-limiter@0.4.2(hono@4.13.8))(hono@4.13.8)(zod@4.6.5) '@hono/node-server': 1.19.17(hono@4.13.8) - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.31 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) '@sentry/core': 10.63.0(patch_hash=e663994979ff877a26ab6d4dea5968fbaee4ccdfdeb85623983535a572678940) '@sentry/node': 10.65.0(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) @@ -11528,7 +12307,7 @@ snapshots: semver: 7.8.5 uuidv7: 1.2.1 yaml: 2.9.0 - zod: 4.4.3 + zod: 4.6.5 transitivePeerDependencies: - '@cfworker/json-schema' - '@opentelemetry/core' @@ -11615,18 +12394,48 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.3 - '@tailwindcss/vite@4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@tailwindcss/vite@4.3.3(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) + + '@tailwindcss/vite@4.3.3(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.0 @@ -11707,7 +12516,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 form-data: 4.0.6 '@types/node@18.19.130': @@ -11722,10 +12531,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.0.10': - dependencies: - undici-types: 7.8.0 - '@types/node@24.13.5': dependencies: undici-types: 7.18.2 @@ -11736,7 +12541,7 @@ snapshots: '@types/pngjs@6.0.5': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 '@types/qrcode-terminal@0.12.2': {} @@ -11744,9 +12549,13 @@ snapshots: dependencies: '@types/react': 19.1.8 + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@types/react-scroll-to-bottom@4.2.5': dependencies: - '@types/react': 19.1.8 + '@types/react': 19.3.0 '@types/react@19.1.8': dependencies: @@ -11758,7 +12567,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.0.10 + '@types/node': 24.13.5 '@types/semver@7.8.0': {} @@ -11770,6 +12579,11 @@ snapshots: '@types/unist@3.0.3': {} + '@uidotdev/usehooks@2.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@ungap/structured-clone@1.3.0': {} '@vercel/analytics@2.0.1(react@19.2.8)': @@ -11784,7 +12598,7 @@ snapshots: optionalDependencies: react: 19.2.8 - '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@vitejs/plugin-react@4.6.0(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) @@ -11792,10 +12606,17 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.19 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.2.9)(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/coverage-v8@4.1.11(vitest@4.1.11)': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -11808,7 +12629,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -11864,14 +12685,14 @@ snapshots: msw: 2.10.2(@types/node@24.13.5)(typescript@5.8.3) vite: 8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))': + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) '@vitest/pretty-format@4.1.11': dependencies: @@ -11928,18 +12749,20 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 - agents@0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3): + agents@0.20.1(@ai-sdk/react@3.0.232(react@19.2.8)(zod@4.4.3))(@babel/core@7.28.0)(@babel/runtime@7.28.4)(@cloudflare/workers-types@4.20260405.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(ai@6.0.64(zod@4.4.3))(chat@4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3))(react@19.2.8)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3))(zod@4.4.3): dependencies: '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@7.28.0) '@cfworker/json-schema': 4.1.1 '@modelcontextprotocol/client': 2.0.0 '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3) '@modelcontextprotocol/server': 2.0.0 - '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.28.0)(@babel/runtime@7.28.4)(rolldown@1.0.0-beta.23)(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) cron-schedule: 6.0.0 esbuild: 0.28.1 mimetext: 3.0.28 @@ -11954,7 +12777,7 @@ snapshots: '@ai-sdk/react': 3.0.232(react@19.2.8)(zod@4.4.3) ai: 6.0.64(zod@4.4.3) chat: 4.40.0(ai@6.0.64(zod@4.4.3))(zod@4.4.3) - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) transitivePeerDependencies: - '@babel/core' - '@babel/plugin-transform-runtime' @@ -12027,6 +12850,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.1.0: {} @@ -12048,6 +12873,14 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-back@6.2.2: {} @@ -12234,6 +13067,10 @@ snapshots: bindings: 1.5.0 prebuild-install: 7.1.3 + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -12467,6 +13304,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-dialog': 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -12586,12 +13435,26 @@ snapshots: dependencies: css-tree: 2.2.1 + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.2 + csstype@3.1.3: {} csstype@3.2.3: {} current-module-paths@1.1.2: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.1: dependencies: ms: 2.1.3 @@ -12600,6 +13463,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -12633,6 +13498,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + devalue@5.9.2: {} devlop@1.1.0: @@ -12645,6 +13512,8 @@ snapshots: direction@2.0.1: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -12715,6 +13584,8 @@ snapshots: entities@6.0.1: {} + entities@8.1.0: {} + environment@1.1.0: {} error-ex@1.3.2: @@ -13218,6 +14089,8 @@ snapshots: hasown: 2.0.2 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-port@7.1.0: {} get-proto@1.0.1: @@ -13313,12 +14186,12 @@ snapshots: hast-util-embedded@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element: 3.0.0 hast-util-format@1.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-minify-whitespace: 1.0.1 hast-util-phrasing: 3.0.1 @@ -13337,7 +14210,7 @@ snapshots: hast-util-from-parse5@8.0.3: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 devlop: 1.1.0 hastscript: 9.0.1 @@ -13348,19 +14221,19 @@ snapshots: hast-util-has-property@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-body-ok-link@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-is-element@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-minify-whitespace@1.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-is-element: 3.0.0 hast-util-whitespace: 3.0.0 @@ -13368,11 +14241,11 @@ snapshots: hast-util-parse-selector@4.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-phrasing@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-embedded: 3.0.0 hast-util-has-property: 3.0.0 hast-util-is-body-ok-link: 3.0.1 @@ -13380,7 +14253,7 @@ snapshots: hast-util-raw@9.1.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 '@ungap/structured-clone': 1.3.0 hast-util-from-parse5: 8.0.3 @@ -13416,7 +14289,7 @@ snapshots: dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -13435,7 +14308,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -13450,7 +14323,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -13469,7 +14342,7 @@ snapshots: hast-util-to-parse5@8.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 property-information: 7.1.0 @@ -13483,14 +14356,14 @@ snapshots: hast-util-to-text@4.0.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 hast-util-is-element: 3.0.0 unist-util-find-after: 5.0.0 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hastscript@9.0.1: dependencies: @@ -13512,6 +14385,12 @@ snapshots: hookable@5.5.3: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} html-escaper@3.0.3: {} @@ -13532,6 +14411,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -13539,6 +14425,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-signals@5.0.0: {} human-signals@8.0.1: {} @@ -13686,6 +14579,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-stream@3.0.0: {} @@ -13776,6 +14671,33 @@ snapshots: strip-json-comments: 3.1.1 underscore: 1.13.7 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.32 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.3 + '@exodus/bytes': 1.16.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.24.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -13990,6 +14912,14 @@ snapshots: dependencies: react: 19.2.8 + lucide-react@1.55.0(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + macho-unsign@2.0.6: {} magic-string@0.30.21: @@ -14148,7 +15078,7 @@ snapshots: mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.2 @@ -14159,7 +15089,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -14186,7 +15116,7 @@ snapshots: mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.2 @@ -14201,7 +15131,7 @@ snapshots: mdast-util-to-hast@13.2.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -14762,6 +15692,11 @@ snapshots: neotraverse@1.0.1: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -14809,6 +15744,14 @@ snapshots: dependencies: boolbase: 1.0.0 + nuqs@2.10.2(react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + dependencies: + '@standard-schema/spec': 1.1.0 + react: 19.2.8 + optionalDependencies: + react-router: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router-dom: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -15021,6 +15964,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseurl@1.3.3: {} partyserver@0.5.8(@cloudflare/workers-types@4.20260405.1): @@ -15094,9 +16041,9 @@ snapshots: portable-executable-signature@2.0.6: {} - postcss-nested@6.2.0(postcss@8.5.26): + postcss-nested@6.2.0(postcss@8.5.28): dependencies: - postcss: 8.5.26 + postcss: 8.5.28 postcss-selector-parser: 6.1.4 postcss-selector-parser@6.0.10: @@ -15144,6 +16091,12 @@ snapshots: tar-fs: 2.1.5 tunnel-agent: 0.6.0 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -15229,6 +16182,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-markdown@9.1.0(@types/react@19.1.8)(react@19.2.8): dependencies: '@types/hast': 3.0.4 @@ -15254,6 +16209,25 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.2.8) + optionalDependencies: + '@types/react': 19.3.0 + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -15268,6 +16242,14 @@ snapshots: optionalDependencies: react-dom: 19.2.8(react@19.2.8) + react-router@7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.0.2 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + react-scroll-to-bottom@4.2.0(@babel/core@7.28.0)(react@19.2.8): dependencies: '@babel/runtime-corejs3': 7.28.0 @@ -15282,6 +16264,14 @@ snapshots: - '@babel/core' - supports-color + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + react@19.2.8: {} readable-stream@3.6.2: @@ -15348,27 +16338,27 @@ snapshots: rehype-parse@9.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-from-html: 2.0.3 unified: 11.0.5 rehype-raw@7.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-raw: 9.1.0 vfile: 6.0.3 rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.9 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color rehype-stringify@10.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 unified: 11.0.5 @@ -15417,7 +16407,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.0 unified: 11.0.5 @@ -15667,6 +16657,10 @@ snapshots: sax@1.6.1: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} section-matter@1.0.0: @@ -15858,7 +16852,7 @@ snapshots: solid-js@1.9.9: dependencies: - csstype: 3.1.3 + csstype: 3.2.3 seroval: 1.5.6 seroval-plugins: 1.3.3(seroval@1.5.6) @@ -16001,10 +16995,14 @@ snapshots: react: 19.2.8 use-sync-external-store: 1.5.0(react@19.2.8) + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwind-merge@3.3.1: {} + tailwind-merge@3.7.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -16088,6 +17086,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.18: {} + + tldts@7.4.18: + dependencies: + tldts-core: 7.4.18 + tmp-promise@3.0.3: dependencies: tmp: 0.2.3 @@ -16109,8 +17113,16 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.18 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -16165,8 +17177,7 @@ snapshots: - supports-color - vue-tsc - tslib@2.8.1: - optional: true + tslib@2.8.1: {} tsx@4.20.3: dependencies: @@ -16230,8 +17241,6 @@ snapshots: undici-types@7.18.2: {} - undici-types@7.8.0: {} - undici@7.24.4: {} unenv@2.0.0-rc.21: @@ -16365,6 +17374,21 @@ snapshots: urlpattern-polyfill@10.1.0: {} + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + use-sync-external-store@1.5.0(react@19.2.8): dependencies: react: 19.2.8 @@ -16402,7 +17426,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 - vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3): + vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -16414,7 +17438,7 @@ snapshots: '@types/node': 24.13.5 fsevents: 2.3.3 jiti: 2.4.2 - lightningcss: 1.32.0 + lightningcss: 1.33.0 tsx: 4.20.3 yaml: 2.8.3 @@ -16500,9 +17524,9 @@ snapshots: vitest-evals@0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11): dependencies: tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -16528,10 +17552,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.16.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16557,10 +17582,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.16.0 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.3)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.20.3)(typescript@5.9.3))(vite@8.3.0(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16586,10 +17612,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 22.20.3 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) @@ -16615,10 +17642,11 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) @@ -16644,13 +17672,14 @@ snapshots: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3)) + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -16667,15 +17696,20 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.3) + vite: 6.3.5(@types/node@24.13.5)(jiti@2.4.2)(lightningcss@1.33.0)(tsx@4.20.3)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/node': 24.13.5 '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 transitivePeerDependencies: - msw + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-back@5.1.1: {} web-namespaces@2.0.1: {} @@ -16686,10 +17720,22 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -16831,6 +17877,10 @@ snapshots: ws@8.21.3: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xmlcreate@2.0.4: {} xxhash-wasm@1.1.0: {} @@ -16911,7 +17961,7 @@ snapshots: zod-from-json-schema@0.5.6: dependencies: - zod: 4.4.3 + zod: 4.6.5 zod-to-json-schema@3.25.1(zod@4.4.3): dependencies: From 24bf8863b31d4e87bf76662740ef0acadb8d7251 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 08:13:06 -0700 Subject: [PATCH 02/10] fix(local): Preserve SDK ingestion capabilities and redact telemetry URLs Carry the ingest token in the pathname for Spotlight SDK transports that omit URL query parameters. Preserve scoped read URLs and legacy ingestion, cover real SDK capture, and scrub Local capabilities at telemetry delivery. Co-Authored-By: GPT-6 Codex --- docs/cli/local-control-plane.md | 6 +- .../cli/src/commands/local/control-plane.ts | 50 ++++++++++----- packages/cli/src/lib/credential-redaction.ts | 11 ++++ .../test/commands/local/control-plane.test.ts | 61 ++++++++++++++++++- packages/cli/test/e2e/local.test.ts | 46 ++++++++++++++ .../cli/test/lib/credential-redaction.test.ts | 37 +++++++++++ .../test/lib/telemetry-credentials.test.ts | 32 ++++++++++ 7 files changed, 224 insertions(+), 19 deletions(-) diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index 25c8ed43e..74d15c7b6 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -19,7 +19,9 @@ sentry local daemon stop ``` `local session create --json` returns distinct `ingestUrl` and `streamUrl` -capabilities for SDK ingestion and viewer reads. Treat those URLs as credentials. +capabilities for SDK ingestion and viewer reads. The ingest capability lives in +the URL path so SDK transports that omit query parameters still deliver events. +Treat those URLs as credentials. Use `--clientId ` to make session creation idempotent for retries. Names must be unique for name-based queries; use the session ID when a name is ambiguous. @@ -45,7 +47,7 @@ receiver data. Remote HTTPS streams stay read-only. `pnpm --filter sentry test:e2e` builds the npm bundle and runs the CLI process suite. `test/e2e/local.test.ts` covers concurrent startup, session isolation, capability checks, retained queries, scoped browser controls, wrapped applications, -restart, and singleton ownership. The Local workflow repeats these tests against +real Sentry Node SDK ingestion, restart, and singleton ownership. The Local workflow repeats these tests against npm and native binaries on Linux, macOS, and Windows. `pnpm --filter local test` includes viewer tests with a real receiver and SSE diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts index 0e52d0484..5c620db95 100644 --- a/packages/cli/src/commands/local/control-plane.ts +++ b/packages/cli/src/commands/local/control-plane.ts @@ -7,7 +7,7 @@ import { Buffer } from "node:buffer"; import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; -import { Hono } from "hono"; +import { type Context, Hono } from "hono"; import { cors } from "hono/cors"; import { streamSSE } from "hono/streaming"; import { SENTRY_CONTENT_TYPE } from "../../lib/formatters/local.js"; @@ -24,6 +24,7 @@ const LOOPBACK_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; const SESSION_UI_ACTION_RE = /^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close)$/; +const SESSION_INGEST_RE = /^\/_local\/v1\/sessions\/[^/]+\/ingest\/[^/]+$/; const SESSION_STREAM_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; /** Authenticated HTTP application and its in-memory session registry. */ @@ -93,7 +94,12 @@ export function createLocalControlPlane({ app.get("/health", (c) => c.text("OK")); app.use(`${CONTROL_PLANE_PREFIX}/*`, async (c, next) => { - if (SESSION_STREAM_RE.test(c.req.path) || isSessionUiAction(c.req.path)) { + if ( + SESSION_STREAM_RE.test(c.req.path) || + ((c.req.method === "POST" || c.req.method === "OPTIONS") && + SESSION_INGEST_RE.test(c.req.path)) || + isSessionUiAction(c.req.path) + ) { await next(); return; } @@ -112,6 +118,15 @@ export function createLocalControlPlane({ allowMethods: ["GET", "POST", "OPTIONS"], origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), }); + app.use( + `${CONTROL_PLANE_PREFIX}/sessions/:id/ingest/:cap`, + async (c, next) => { + if (!isLoopbackBrowserRequest(c.req.raw)) { + return c.body(null, 403); + } + return loopbackCors(c, next); + }, + ); const hostedCors = cors({ allowHeaders: ["Last-Event-ID"], allowMethods: ["GET", "OPTIONS"], @@ -162,11 +177,11 @@ export function createLocalControlPlane({ } const session = sessionStore.resolve(c.req.param("id")); const capabilities = sessionStore.getCapabilities(session.id); - const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}/stream`; + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`; return c.json({ ...session, - ingestUrl: `${baseUrl}${path}?cap=${capabilities.ingest}`, - streamUrl: `${baseUrl}${path}?cap=${capabilities.stream}`, + ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`, }); }); @@ -193,23 +208,26 @@ export function createLocalControlPlane({ }; const session = sessionStore.create({ clientId, label, cwd }); const capabilities = sessionStore.getCapabilities(session.id); - const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}/stream`; + const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`; return c.json( { ...session, - ingestUrl: `${baseUrl}${path}?cap=${capabilities.ingest}`, - streamUrl: `${baseUrl}${path}?cap=${capabilities.stream}`, + ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`, + streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`, }, 201, ); }); - app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c) => { - if (!sessionExists(c.req.param("id"))) { + // Node SDK Spotlight transports send pathname only, so the ingest capability + // must live in the path. Keep query-based ingestion for existing raw clients. + const ingest = async (c: Context) => { + const reference = c.req.param("id") ?? ""; + if (!sessionExists(reference)) { return c.body(null, 404); } - const capabilities = sessionStore.getCapabilities(c.req.param("id")); - if (c.req.query("cap") !== capabilities.ingest) { + const capabilities = sessionStore.getCapabilities(reference); + if ((c.req.param("cap") ?? c.req.query("cap")) !== capabilities.ingest) { return c.body(null, 401); } @@ -232,7 +250,7 @@ export function createLocalControlPlane({ c.req.query("sentry_client")?.startsWith("sentry.javascript.browser") && c.req.header("origin"); pushToSpotlightBuffer({ - spotlightBuffer: sessionStore.getBuffer(c.req.param("id")), + spotlightBuffer: sessionStore.getBuffer(reference), body, contentType: browserBeacon ? SENTRY_CONTENT_TYPE @@ -240,9 +258,11 @@ export function createLocalControlPlane({ encoding, userAgent: c.req.header("user-agent"), }); - sessionStore.recordActivity(c.req.param("id")); + sessionStore.recordActivity(reference); return c.body(null, 204); - }); + }; + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ingest/:cap`, ingest); + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, ingest); app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, (c) => { if (!sessionExists(c.req.param("id"))) { diff --git a/packages/cli/src/lib/credential-redaction.ts b/packages/cli/src/lib/credential-redaction.ts index 1765df0c5..bb2dceb5b 100644 --- a/packages/cli/src/lib/credential-redaction.ts +++ b/packages/cli/src/lib/credential-redaction.ts @@ -4,6 +4,13 @@ * Successful API responses are not passed through this redactor. */ +// Session URLs are credentials too. SDK HTTP instrumentation records both full +// URLs and relative request targets, including query-based viewer/UI routes. +const LOCAL_INGEST_CAPABILITY = + /(\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/ingest\/)[A-Za-z0-9_-]+/g; +const LOCAL_SCOPED_QUERY = + /\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/(?:stream|ui\/(?:reset|close))\?[^#\s"'\\]*/g; + const INVALID_BEARER_HEADER_START = /(\bHeaders\.(?:set|append):[ \t]*)(\\*["'])Bearer[ \t]+/gi; const INVALID_HEADER_END = /(? + url.replace(/([?&]cap=)[^&#\s"'\\]+/g, "$1[REDACTED]"), + ) .replace(QUOTED_CREDENTIAL, (_match, quote: string, prefix: string) => prefix.toLowerCase().startsWith("bearer") ? `${quote}Bearer [REDACTED]${quote}` diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts index 9d4e8e351..fb1344b27 100644 --- a/packages/cli/test/commands/local/control-plane.test.ts +++ b/packages/cli/test/commands/local/control-plane.test.ts @@ -29,7 +29,8 @@ describe("Local control plane", () => { streamUrl: string; }; expect(session.label).toBe("web"); - expect(session.ingestUrl).toContain(`/${session.id}/stream?cap=`); + expect(session.ingestUrl).toContain(`/${session.id}/ingest/`); + expect(new URL(session.ingestUrl).search).toBe(""); expect(session.streamUrl).toContain(`/${session.id}/stream?cap=`); expect(session.ingestUrl).not.toBe(session.streamUrl); @@ -75,12 +76,68 @@ describe("Local control plane", () => { ).toHaveLength(1); const rejected = await controlPlane.app.request( - session.ingestUrl.replace(/cap=[^&]+/, "cap=wrong"), + session.ingestUrl.replace(/\/ingest\/[^/]+$/, "/ingest/wrong"), { method: "POST", body: envelope }, ); expect(rejected.status).toBe(401); }); + test("protects pathname ingestion from read capabilities and remote browser writes", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ + label: "sdk", + cwd: "/work/sdk", + }); + const caps = plane.sessionStore.getCapabilities(session.id); + const path = `/_local/v1/sessions/${session.id}/ingest`; + expect( + (await plane.app.request(`${path}/${caps.stream}`, { method: "POST" })) + .status, + ).toBe(401); + expect( + ( + await plane.app.request(`${path}/wrong?cap=${caps.ingest}`, { + method: "POST", + }) + ).status, + ).toBe(401); + expect((await plane.app.request(`${path}/${caps.ingest}`)).status).toBe( + 401, + ); + for (const origin of ["https://local.sentry.dev", "https://example.com"]) { + expect( + ( + await plane.app.request(`${path}/${caps.ingest}`, { + method: "POST", + headers: { Origin: origin }, + }) + ).status, + ).toBe(403); + expect( + ( + await plane.app.request(`${path}/${caps.ingest}`, { + method: "OPTIONS", + headers: { + Origin: origin, + "Access-Control-Request-Method": "POST", + }, + }) + ).status, + ).toBe(403); + } + const preflight = await plane.app.request(`${path}/${caps.ingest}`, { + method: "OPTIONS", + headers: { + Origin: "http://localhost:5173", + "Access-Control-Request-Method": "POST", + }, + }); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe( + "http://localhost:5173", + ); + }); + test("allows subscribers only through the session stream capability", async () => { const controlPlane = createLocalControlPlane({ controlToken: CONTROL_TOKEN, diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index b6f6d45f1..8bedd499f 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -229,6 +229,52 @@ describe("Local daemon E2E", () => { ).toContain("wrapped app"); }, 30_000); + test("captures telemetry from the real Sentry Node SDK through the injected URL", async () => { + const port = await availablePort(); + const result = await runCli( + [ + "local", + "run", + "--session", + "sdk", + "--host", + "127.0.0.1", + "--port", + String(port), + "--format", + "json", + "--", + process.execPath, + "--input-type=module", + "-e", + `import * as Sentry from '@sentry/node'; + Sentry.init({enableLogs: true, tracesSampleRate: 1}); + const id = Sentry.captureException(new Error('Local real SDK event')); + console.error('SDK_EVENT:' + id); + await Sentry.flush(2000); + await new Promise(resolve => setTimeout(resolve, 500)); + await Sentry.close(1000);`, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + SENTRY_DSN: "", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + const eventId = result.stderr.match(/SDK_EVENT:([a-f0-9]{32})/)?.[1]; + expect(eventId, result.stderr).toBeDefined(); + expect(result.stdout).toContain("Local real SDK event"); + expect((await cli("event", "list", "sdk")).stdout).toContain(eventId); + expect((await cli("event", "view", "sdk", eventId ?? "")).stdout).toContain( + "Local real SDK event", + ); + }, 30_000); + test("restarts on the configured port and preserves singleton discovery", async () => { const port = await availablePort(); await createSession("restart", port); diff --git a/packages/cli/test/lib/credential-redaction.test.ts b/packages/cli/test/lib/credential-redaction.test.ts index 5730646e3..a51783cda 100644 --- a/packages/cli/test/lib/credential-redaction.test.ts +++ b/packages/cli/test/lib/credential-redaction.test.ts @@ -4,6 +4,43 @@ import { ApiError, formatError, getExitCode } from "../../src/lib/errors.js"; import { SentryError } from "../../src/lib/sdk-types.js"; describe("credential redaction", () => { + test.each([ + ["/ingest/SYNTHETIC_ingest-cap", "/ingest/[REDACTED]"], + [ + "/stream?cap=SYNTHETIC_read-cap&base64=true", + "/stream?cap=[REDACTED]&base64=true", + ], + [ + "/stream?base64=true&cap=SYNTHETIC_read-cap", + "/stream?base64=true&cap=[REDACTED]", + ], + [ + "/stream?cap=SYNTHETIC_one&cap=SYNTHETIC_two", + "/stream?cap=[REDACTED]&cap=[REDACTED]", + ], + ["/ui/reset?cap=SYNTHETIC_read-cap", "/ui/reset?cap=[REDACTED]"], + ["/ui/close?cap=SYNTHETIC_read-cap", "/ui/close?cap=[REDACTED]"], + ])( + "redacts Local URL capabilities in diagnostics: %s", + (suffix, safeSuffix) => { + const path = "/_local/v1/sessions/session-id"; + for (const origin of ["", "http://127.0.0.1:8969", "http://[::1]:8969"]) { + const message = `POST ${origin}${path}${suffix}`; + const expected = `POST ${origin}${path}${safeSuffix}`; + expect(redactCredentialText(message)).toBe(expected); + expect( + JSON.parse(redactCredentialText(JSON.stringify({ message }))), + ).toEqual({ message: expected }); + } + }, + ); + + test("preserves URLs outside the Local capability routes", () => { + const message = + "https://example.com/stream?cap=public /_local/v1/sessions/web /_local/v1/sessions/web/ingest/:cap"; + expect(redactCredentialText(message)).toBe(message); + }); + test.each([ "sntrys_SYNTHETIC_PAYLOAD\n_SYNTHETIC_SECRET", "sntryu_SYNTHETIC_PAYLOAD\r\n_SYNTHETIC_SECRET", diff --git a/packages/cli/test/lib/telemetry-credentials.test.ts b/packages/cli/test/lib/telemetry-credentials.test.ts index 27f3d9423..1c69dc872 100644 --- a/packages/cli/test/lib/telemetry-credentials.test.ts +++ b/packages/cli/test/lib/telemetry-credentials.test.ts @@ -159,6 +159,38 @@ describe("telemetry credential boundaries", () => { expect(serialized).not.toContain("SYNTHETIC"); }); + test("removes Local capabilities from HTTP telemetry without changing caller URLs", async () => { + const capture = captureTelemetry(); + const ingestUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/ingest/LOCAL_INGEST_SECRET"; + const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/stream?base64=true&cap=LOCAL_READ_SECRET"; + const resetUrl = + "http://127.0.0.1:8969/_local/v1/sessions/sdk/ui/reset?cap=LOCAL_READ_SECRET"; + const event: ErrorEvent = { + message: `Rejected POST ${ingestUrl}`, + request: { url: ingestUrl }, + breadcrumbs: [{ type: "http", data: { url: streamUrl } }], + extra: { resetUrl }, + }; + captureEvent(event); + startSpan({ name: `POST ${ingestUrl}`, forceTransaction: true }, (span) => { + span.setAttribute("http.url", ingestUrl); + span.setAttribute("http.target", new URL(ingestUrl).pathname); + span.setAttribute("stream.url", streamUrl); + }); + await capture.client.flush(1000); + for (const type of ["event", "transaction"]) { + const serialized = capture.serializedEnvelope(type); + expect(serialized).not.toContain("LOCAL_INGEST_SECRET"); + expect(serialized).not.toContain("LOCAL_READ_SECRET"); + expect(serialized).toContain("[REDACTED]"); + expect(serialized).toContain("/_local/v1/sessions/sdk/"); + } + expect(event.request?.url).toBe(ingestUrl); + expect(event.extra?.resetUrl).toBe(resetUrl); + }); + test("redacts before deriving a grouping key from the first line", () => { expect(extractMessagePrefix(headerError, 4)).toBe( "Headers.set: is an invalid", From 167fa712a718216eef8986f6cbca293eacbec93b Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 10:59:54 -0700 Subject: [PATCH 03/10] test(local): Isolate viewer query updates across tests Reset shared query queues between browser integration tests and wait for the asynchronous search URL update before asserting it. Co-Authored-By: GPT-6 Codex --- apps/local/src/App.integration.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 1349917a6..e7d203db8 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -10,6 +10,7 @@ import { waitFor, } from "@testing-library/react"; import { NuqsAdapter } from "nuqs/adapters/react"; +import { NuqsTestingAdapter } from "nuqs/adapters/testing"; import { BrowserRouter } from "react-router"; import { ThemeProvider } from "@/components/theme-provider.tsx"; import App from "./App.tsx"; @@ -156,6 +157,9 @@ class ControllableEventSource extends EventTarget { describe("local receiver to viewer integration", () => { beforeEach(() => { + // Nuqs shares pending URL updates across mounts. Reset them between tests + // while keeping the real browser adapter for history integration coverage. + render({null}).unmount(); window.localStorage.clear(); window.sessionStorage.clear(); Object.defineProperty(document, "execCommand", { @@ -1004,9 +1008,11 @@ describe("local receiver to viewer integration", () => { expect(screen.getByTestId("event-list").textContent).not.toContain( "/orders", ); - expect(new URLSearchParams(window.location.search).get("q")).toBe( - "customers", - ); + await waitFor(() => { + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "customers", + ); + }); } finally { cleanup(); await stopReceiver(server); From 3bc9e8b66d9df5466b7e3d89c015a6b04057b16f Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 11:26:32 -0700 Subject: [PATCH 04/10] fix(local): Preserve retained search and session name lookup Keep search available for retained telemetry after disconnect or session close and show search-specific empty trace results. Accept multi-word session names in telemetry view commands consistently with the sibling management commands. Co-Authored-By: GPT-6 Codex --- apps/local/src/App.integration.test.tsx | 112 ++++++++++++++++++ apps/local/src/App.tsx | 4 +- apps/local/src/components/trace-workspace.tsx | 11 +- docs/cli/local-control-plane.md | 7 ++ packages/cli/src/commands/local/management.ts | 10 +- packages/cli/test/e2e/local.test.ts | 29 +++++ 6 files changed, 164 insertions(+), 9 deletions(-) diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index e7d203db8..11afc25e2 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -893,6 +893,118 @@ describe("local receiver to viewer integration", () => { screen.getByRole("heading", { name: "Raw envelope" }), ).not.toBeNull(); expect(screen.queryByLabelText("Receiver endpoint")).toBeNull(); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "unmatched-envelope" }, + }); + await waitFor(() => + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "unmatched-envelope", + ), + ); + expect(screen.getByTestId("event-list").textContent).toContain( + "No matching envelopes", + ); + }); + + test.each(["failed", "closed"])( + "keeps retained search usable after the receiver is %s", + async (state) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + renderBareViewer(); + await act(async () => + ControllableEventSource.instances[0]?.onopen?.(new Event("open")), + ); + fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { + value: + "http://127.0.0.1:8969/_local/v1/sessions/retained/stream?cap=read", + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + const source = ControllableEventSource.instances[1]; + await act(async () => { + source?.onopen?.(new Event("open")); + for (const transaction of ["GET /alpha", "GET /beta"]) { + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "transaction" }, { transaction }]], + ]), + lastEventId: transaction, + }), + ); + } + }); + await waitFor(() => + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ), + ); + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "alpha" }, + }); + await waitFor(() => + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "alpha", + ), + ); + fireEvent.keyDown( + screen.getByPlaceholderText("Search events and views"), + { key: "Escape" }, + ); + if (state === "closed") { + fireEvent.click( + screen.getByRole("button", { name: "Close telemetry session" }), + ); + await waitFor(() => expect(source?.closed).toBe(true)); + } else { + await act(async () => source?.onerror?.(new Event("error"))); + } + fireEvent.click( + screen.getAllByRole("button", { name: "Search events" })[0]!, + ); + fireEvent.change(screen.getByPlaceholderText("Search events and views"), { + target: { value: "" }, + }); + await waitFor(() => { + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ); + expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); + }); + }, + ); + + test("distinguishes empty trace searches from a receiver with no traces", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /alpha", { traceId: "trace-alpha" }); + renderViewer(port, "/traces?q=missing"); + await screen.findByText("Connected to local receiver"); + await screen.findByRole("heading", { name: "No matching traces" }); + expect(screen.queryByText("No traces captured")).toBeNull(); + expect( + screen.getByText( + "Try a different search term or clear the search to see everything in this view.", + ), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } }); test("omits redundant type pills from dedicated Errors and Envelopes lists", async () => { diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 254d76927..d04b0ca26 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1042,8 +1042,7 @@ export default function App() { (connection === "connecting" || connection === "failed")); const isReceiverUnavailable = connection !== "connected" && !isConnecting && !hasRetainedItems; - const canSearch = - connection === "connected" && items.length > 0 && !isEditingReceiver; + const canSearch = hasRetainedItems && !isEditingReceiver; const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map( ({ icon, id, label }) => ({ icon, id, label }), ); @@ -1229,6 +1228,7 @@ export default function App() {
    ) : workspaceView === "traces" ? ( { diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx index b98f529e3..b484f4dc4 100644 --- a/apps/local/src/components/trace-workspace.tsx +++ b/apps/local/src/components/trace-workspace.tsx @@ -2,6 +2,7 @@ import type { TraceGroup } from "../lib/trace-model"; import { TraceWaterfall } from "./trace-waterfall"; type TraceWorkspaceProps = { + emptyState?: { title: string; description: string }; onSelect: (traceId: string) => void; selectedTraceId?: string; traces: TraceGroup[]; @@ -20,6 +21,11 @@ function formatDuration(durationMs: number | undefined) { } export function TraceWorkspace({ + emptyState = { + title: "No traces captured", + description: + "Transactions and spans from your receiver will appear here as a single trace.", + }, onSelect, selectedTraceId, traces, @@ -35,11 +41,10 @@ export function TraceWorkspace({ >

    - No traces captured + {emptyState.title}

    - Transactions and spans from your receiver will appear here as a - single trace. + {emptyState.description}

    diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index 2dcdc1273..cbd9bbbda 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -24,6 +24,9 @@ the URL path so SDK transports that omit query parameters still deliver events. Treat those URLs as credentials. Use `--clientId ` to make session creation idempotent for retries. Names must be unique for name-based queries; use the session ID when a name is ambiguous. +Session lookup commands accept names quoted or as separate words. For `event view` and +`envelope view`, the final argument is the telemetry ID; preceding arguments +form the session name. The daemon stores its discovery token under `SENTRY_CONFIG_DIR` in an owner-only state file. It binds only to localhost, 127.0.0.1, or ::1 and uses a nonzero port. @@ -48,6 +51,10 @@ A loopback-served viewer can reset and close its scoped session. The hosted view can read the stream and clear its own displayed feed; it cannot mutate retained receiver data. Remote HTTPS streams stay read-only. +Search and view navigation remain available for retained telemetry after a +receiver disconnects or a session closes, including raw-only envelopes. A trace +search with no matches explains how to change or clear the filter. + ## Verification `pnpm --filter sentry test:e2e` builds the npm bundle and runs the CLI process diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index d32f0b3dc..c4deb379a 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -316,8 +316,9 @@ const envelopeViewCommand = buildCommand({ }, auth: false, async *func(_flags, ...parts: string[]) { - const [session, envelope] = parts; - if (!(session && envelope) || parts.length !== 2) { + const session = parts.slice(0, -1).join(" ").trim(); + const envelope = parts.at(-1); + if (!(session && envelope)) { throw new ValidationError( "Provide a session and envelope ID.", "envelope", @@ -371,8 +372,9 @@ const eventViewCommand = buildCommand({ }, auth: false, async *func(_flags, ...parts: string[]) { - const [session, event] = parts; - if (!(session && event) || parts.length !== 2) { + const session = parts.slice(0, -1).join(" ").trim(); + const event = parts.at(-1); + if (!(session && event)) { throw new ValidationError("Provide a session and event ID.", "event"); } yield new CommandOutput( diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index 0032de1ce..b9d65e936 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -179,6 +179,35 @@ describe("Local daemon E2E", () => { ).toContain("worker event"); }, 30_000); + test("views telemetry using quoted and split multi-word session names", async () => { + const session = await createSession("My App", await availablePort()); + const eventId = "d".repeat(32); + await ingest(session, eventId, "multi-word session event"); + const listed = await cli("event", "list", "My", "App"); + expect(listed.exitCode, listed.stderr).toBe(0); + const { events } = JSON.parse(listed.stdout) as { + events: { envelopeId: string }[]; + }; + const envelopeId = events[0]?.envelopeId; + expect(envelopeId).toBeDefined(); + for (const parts of [["My App"], ["My", "App"]]) { + const event = await cli("event", "view", ...parts, eventId); + expect(event.exitCode, event.stderr).toBe(0); + expect(event.stdout).toContain("multi-word session event"); + const raw = await cli("envelope", "view", ...parts, envelopeId!); + expect(raw.exitCode, raw.stderr).toBe(0); + const envelope = JSON.parse(raw.stdout) as { data: string }; + expect(Buffer.from(envelope.data, "base64").toString()).toContain( + "multi-word session event", + ); + } + for (const command of ["event", "envelope"]) { + const missingId = await cli(command, "view", session.id); + expect(missingId.exitCode).not.toBe(0); + expect(missingId.stderr).toContain(`Provide a session and ${command} ID`); + } + }, 30_000); + test("retains a wrapped application's session and injects scoped SDK URLs", async () => { const port = await availablePort(); const script = join(getConfigDir(), "app.cjs"); From 4890efff829406a2accf40b8c4ce9ab1e51b0d99 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 13:54:32 -0700 Subject: [PATCH 05/10] fix(local): Correct span styling and SDK event lookup Use each span's own metadata and SDK status for waterfall failures. Match AI namespace components, resolve item-level event IDs, and show the receiver picker for empty closed sessions. Co-Authored-By: GPT-6 Codex --- apps/local/src/App.integration.test.tsx | 18 +++++- apps/local/src/App.tsx | 4 +- .../src/components/trace-waterfall.test.tsx | 63 +++++++++++++++++++ apps/local/src/components/trace-waterfall.tsx | 5 +- apps/local/src/lib/spotlight.ts | 8 ++- apps/local/src/lib/trace-model.ts | 9 ++- apps/local/src/lib/workspace.test.ts | 32 ++++++++++ apps/local/src/lib/workspace.ts | 8 +-- docs/cli/local-control-plane.md | 10 ++- .../cli/src/commands/local/control-plane.ts | 29 ++++++++- .../test/commands/local/control-plane.test.ts | 52 +++++++++++++++ packages/cli/test/e2e/local.test.ts | 32 ++++++++++ 12 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 apps/local/src/components/trace-waterfall.test.tsx diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 11afc25e2..5f331bbfe 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -855,6 +855,9 @@ describe("local receiver to viewer integration", () => { ); expect(fetchMock.mock.calls[1]?.[1]).toEqual({ method: "POST" }); await waitFor(() => expect(scopedSource?.closed).toBe(true)); + await screen.findByRole("heading", { name: "Connect a receiver" }); + expect(screen.queryByText("Waiting for events")).toBeNull(); + expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); await waitFor(() => expect( screen.queryAllByLabelText("Connected to local receiver"), @@ -1515,13 +1518,15 @@ describe("local receiver to viewer integration", () => { } }); - test("shows a trace waterfall for a transaction with child spans", async () => { + test("styles failing spans using their own telemetry", async () => { const { server, port } = await startReceiver(); try { renderViewer(port); await screen.findByText("Connected to local receiver"); await sendEnvelope(port, "GET /waterfall", { + level: "error", + statusCode: 500, operation: "http.server", spanId: "root-span", traceId: "waterfall-trace", @@ -1539,6 +1544,7 @@ describe("local receiver to viewer integration", () => { span_id: "http-span", parent_span_id: "root-span", op: "http.client", + data: { "http.response.status_code": 503 }, description: "GET https://inventory.test/items/42", start_timestamp: 1_699_999_999.995, timestamp: 1_699_999_999.998, @@ -1565,7 +1571,15 @@ describe("local receiver to viewer integration", () => { expect( screen.getByRole("cell", { name: "Duration 4.00ms" }), ).not.toBeNull(); - expect(screen.getByTestId("waterfall-bar-db-span")).not.toBeNull(); + expect(screen.getByTestId("waterfall-bar-root-span").className).toContain( + "bg-red-500", + ); + expect( + screen.getByTestId("waterfall-bar-db-span").className, + ).not.toContain("bg-red-500"); + expect(screen.getByTestId("waterfall-bar-http-span").className).toContain( + "bg-red-500", + ); } finally { cleanup(); await stopReceiver(server); diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index d04b0ca26..4e311f4af 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1039,7 +1039,9 @@ export default function App() { const showConnectionLanding = isEditingReceiver || (!hasRetainedItems && - (connection === "connecting" || connection === "failed")); + (connection === "connecting" || + connection === "failed" || + connection === "closed")); const isReceiverUnavailable = connection !== "connected" && !isConnecting && !hasRetainedItems; const canSearch = hasRetainedItems && !isEditingReceiver; diff --git a/apps/local/src/components/trace-waterfall.test.tsx b/apps/local/src/components/trace-waterfall.test.tsx new file mode 100644 index 000000000..038fe9aa1 --- /dev/null +++ b/apps/local/src/components/trace-waterfall.test.tsx @@ -0,0 +1,63 @@ +import { afterEach, expect, test } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { decodeEnvelope } from "@/lib/spotlight.ts"; +import { buildTraceGroups } from "@/lib/trace-model.ts"; +import { TraceWaterfall } from "./trace-waterfall.tsx"; + +afterEach(cleanup); + +test.each(["internal_error", "error"])( + "styles status-only %s failures without affecting healthy children", + (status) => { + const items = decodeEnvelope( + JSON.stringify([ + {}, + [ + [ + { type: "transaction" }, + { + transaction: "GET /failed", + start_timestamp: 1, + timestamp: 2, + contexts: { + trace: { trace_id: "trace", span_id: "root", status }, + }, + spans: [ + { + span_id: "healthy", + parent_span_id: "root", + op: "db.query", + status: "ok", + start_timestamp: 1.1, + timestamp: 1.2, + }, + { + span_id: "failed", + parent_span_id: "root", + op: "db.query", + status, + start_timestamp: 1.3, + timestamp: 1.4, + }, + ], + }, + ], + ], + ]), + "envelope", + ); + const trace = buildTraceGroups(items)[0]; + expect(trace).toBeDefined(); + if (!trace) throw new Error("Expected trace"); + render(); + expect(screen.getByTestId("waterfall-bar-root").className).toContain( + "bg-red-500", + ); + expect(screen.getByTestId("waterfall-bar-failed").className).toContain( + "bg-red-500", + ); + expect(screen.getByTestId("waterfall-bar-healthy").className).not.toContain( + "bg-red-500", + ); + }, +); diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx index 9add84fd4..992c6dbe6 100644 --- a/apps/local/src/components/trace-waterfall.tsx +++ b/apps/local/src/components/trace-waterfall.tsx @@ -47,11 +47,12 @@ function flattenSpans(spans: TraceSpan[], depth = 0): PositionedSpan[] { } function isFailingSpan(span: TraceSpan): boolean { - const { level, statusCode } = span.item.metadata ?? {}; + const { level, statusCode, spanStatus } = span.metadata; return ( level === "error" || level === "fatal" || - (statusCode !== undefined && statusCode >= 500) + (statusCode !== undefined && statusCode >= 500) || + (spanStatus !== undefined && spanStatus !== "ok") ); } diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts index 3c76cda82..72dd3747d 100644 --- a/apps/local/src/lib/spotlight.ts +++ b/apps/local/src/lib/spotlight.ts @@ -26,6 +26,7 @@ export type EventMetadata = { method?: string; route?: string; statusCode?: number; + spanStatus?: string; durationMs?: number; traceId?: string; spanId?: string; @@ -69,7 +70,11 @@ function getRoute(url: string | undefined): string | undefined { } } -function getEventMetadata(payload: unknown, type: string): EventMetadata { +/** Derive display metadata from this payload without inheriting its parent event. */ +export function getEventMetadata( + payload: unknown, + type: string, +): EventMetadata { const event = isRecord(payload) ? payload : {}; const transaction = getString(event.transaction); const description = getString(event.description); @@ -107,6 +112,7 @@ function getEventMetadata(payload: unknown, type: string): EventMetadata { statusCode: getNumber(request.status_code) ?? getNumber(data["http.response.status_code"]), + spanStatus: getString(trace.status) ?? getString(event.status), durationMs, traceId: getString(trace.trace_id) ?? getString(event.trace_id), spanId: getString(trace.span_id) ?? getString(event.span_id), diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts index 579552202..0518800f2 100644 --- a/apps/local/src/lib/trace-model.ts +++ b/apps/local/src/lib/trace-model.ts @@ -1,4 +1,8 @@ -import type { LocalFeedItem } from "./spotlight.js"; +import { + getEventMetadata, + type EventMetadata, + type LocalFeedItem, +} from "./spotlight.js"; import { isErrorEvent } from "./workspace.js"; export type TraceSpan = { @@ -10,6 +14,7 @@ export type TraceSpan = { endTimestamp?: number; durationMs?: number; item: LocalFeedItem; + metadata: EventMetadata; children: TraceSpan[]; }; @@ -91,6 +96,7 @@ function getPayloadSpan( endTimestamp, durationMs: getDurationMs(startTimestamp, endTimestamp), item, + metadata: item.metadata ?? getEventMetadata(payload, item.type), children: [], }; } @@ -122,6 +128,7 @@ function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] { endTimestamp, durationMs: getDurationMs(startTimestamp, endTimestamp), item, + metadata: getEventMetadata(value, "span"), children: [], }, ]; diff --git a/apps/local/src/lib/workspace.test.ts b/apps/local/src/lib/workspace.test.ts index 15ba55d7b..6ee09dbf9 100644 --- a/apps/local/src/lib/workspace.test.ts +++ b/apps/local/src/lib/workspace.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + isAiEvent, workspaceForItem, workspaceFromPath, workspacePath, @@ -34,3 +35,34 @@ describe("workspace routes", () => { expect(workspacePath("traces")).toBe("/traces"); }); }); + +describe("AI classification", () => { + test.each(["queue.wait", "auto.http.aiohttp", "email.send"])( + "keeps ordinary %s telemetry in traces", + (value) => { + const item = { + id: "span", + type: "span", + text: "{}", + metadata: { title: "work", operation: value, origin: value }, + }; + expect(isAiEvent(item)).toBe(false); + expect(workspaceForItem(item)).toBe("traces"); + }, + ); + test.each([ + "ai.chat", + "gen_ai.invoke_agent", + "llm.generate", + "auto.ai.openai", + ])("recognizes the AI namespace in %s", (value) => + expect( + isAiEvent({ + id: "span", + type: "span", + text: "{}", + metadata: { title: "Prompt", operation: value }, + }), + ).toBe(true), + ); +}); diff --git a/apps/local/src/lib/workspace.ts b/apps/local/src/lib/workspace.ts index 61ae8c5ac..89a38a01d 100644 --- a/apps/local/src/lib/workspace.ts +++ b/apps/local/src/lib/workspace.ts @@ -44,10 +44,10 @@ export function isErrorEvent(item: LocalFeedItem) { export function isAiEvent(item: LocalFeedItem) { const metadata = getMetadata(item); - return ( - `${item.type} ${metadata.operation ?? ""} ${metadata.origin ?? ""}` - .toLowerCase() - .match(/ai|gen_ai|llm/) !== null + return [item.type, metadata.operation, metadata.origin].some( + (value) => + value !== undefined && + /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value), ); } diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index ca8f00592..eab411d04 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -27,7 +27,9 @@ be unique for name-based queries; ambiguous names return HTTP 409 with guidance to use the session ID. Session lookup commands accept names quoted or as separate words. For `event view` and `envelope view`, the final argument is the telemetry ID; preceding arguments -form the session name. +form the session name. Event lookup uses the Sentry event ID from the envelope +header, event/transaction item header, or payload, in that order; envelopes +without an event ID retain their generated envelope ID as the lookup key. The daemon stores its discovery token under `SENTRY_CONFIG_DIR` in an owner-only state file. It binds only to localhost, 127.0.0.1, or ::1 and uses a nonzero port. @@ -60,7 +62,11 @@ receiver data. Remote HTTPS streams stay read-only. Search and view navigation remain available for retained telemetry after a receiver disconnects or a session closes, including raw-only envelopes. A trace -search with no matches explains how to change or clear the filter. +search with no matches explains how to change or clear the filter. Closing an +empty session returns to the receiver picker. Waterfall failure styling uses +each span's own level, HTTP status, and SDK span status; a failed transaction does not mark healthy child +spans as failures. The AI workspace matches AI namespaces (`ai`, `gen_ai`, +`llm`), including dotted origins, rather than substrings of ordinary operations. ## Verification diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts index 0060c0e7c..7bc8880a8 100644 --- a/packages/cli/src/commands/local/control-plane.ts +++ b/packages/cli/src/commands/local/control-plane.ts @@ -57,13 +57,40 @@ function isLoopbackBrowserRequest(request: Request): boolean { return origin === null || LOOPBACK_ORIGIN_RE.test(origin); } +/** Only nonempty string IDs from event-bearing envelope fields are lookup keys. */ +function eventIdFrom(value: unknown): string | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const id = (value as Record).event_id; + return typeof id === "string" && id.length > 0 ? id : undefined; +} + function eventSummary(parsed: { envelope: [Record, unknown[]]; }): { envelopeId: string; eventId: string; itemTypes: string[] } { const [header, items] = parsed.envelope; + const itemEventId = items.flatMap((item) => { + if ( + !Array.isArray(item) || + typeof item[0] !== "object" || + item[0] === null + ) { + return []; + } + const type = (item[0] as Record).type; + if (type !== "event" && type !== "transaction") { + return []; + } + const id = eventIdFrom(item[0]) ?? eventIdFrom(item[1]); + return id ? [id] : []; + })[0]; return { envelopeId: String(header.__spotlight_envelope_id ?? ""), - eventId: String(header.event_id ?? header.__spotlight_envelope_id ?? ""), + eventId: + eventIdFrom(header) ?? + itemEventId ?? + String(header.__spotlight_envelope_id ?? ""), itemTypes: items.map((item) => Array.isArray(item) && typeof item[0] === "object" && item[0] ? String((item[0] as { type?: unknown }).type ?? "unknown") diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts index 8e81fb2fb..6feeae290 100644 --- a/packages/cli/test/commands/local/control-plane.test.ts +++ b/packages/cli/test/commands/local/control-plane.test.ts @@ -256,6 +256,58 @@ describe("Local control plane", () => { expect(viewed.status).toBe(200); }); + test.each([ + [ + { sdk: { name: "sentry.node" } }, + { type: "event", event_id: "item-id" }, + { event_id: "payload-id" }, + "item-id", + ], + [{}, { type: "transaction" }, { event_id: "payload-id" }, "payload-id"], + [ + { event_id: "header-id" }, + { type: "event", event_id: "item-id" }, + { event_id: "payload-id" }, + "header-id", + ], + ])( + "indexes SDK item IDs with envelope-header precedence", + async (header, item, payload, eventId) => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const session = plane.sessionStore.create({ label: "sdk", cwd: "/sdk" }); + const { ingest } = plane.sessionStore.getCapabilities(session.id); + const response = await plane.app.request( + `/_local/v1/sessions/${session.id}/ingest/${ingest}`, + { + method: "POST", + body: [header, item, payload] + .map((value) => JSON.stringify(value)) + .join("\n"), + }, + ); + expect(response.status).toBe(204); + const listed = await plane.app.request( + `/_local/v1/sessions/${session.id}/events`, + { headers: controlHeaders() }, + ); + await expect(listed.json()).resolves.toMatchObject({ + events: [{ eventId }], + }); + const viewed = await plane.app.request( + `/_local/v1/sessions/${session.id}/events/${eventId}`, + { headers: controlHeaders() }, + ); + expect(viewed.status).toBe(200); + const data = await viewed.json(); + expect(data.envelopeId).toBeTruthy(); + const raw = await plane.app.request( + `/_local/v1/sessions/${session.id}/envelopes/${data.envelopeId}`, + { headers: controlHeaders() }, + ); + expect(raw.status).toBe(200); + }, + ); + test("permits browser preflight only from a loopback development origin", async () => { const controlPlane = createLocalControlPlane({ controlToken: CONTROL_TOKEN, diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index 8726b9b99..f062d2fa4 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -280,6 +280,38 @@ describe("Local daemon E2E", () => { ).toContain("worker event"); }, 30_000); + test("queries SDK event IDs from item headers and payloads", async () => { + const session = await createSession("sdk IDs", await availablePort()); + for (const [itemHeader, payload] of [ + [{ type: "event", event_id: "item-event" }, { message: "item ID event" }], + [ + { type: "transaction" }, + { event_id: "payload-event", transaction: "payload ID transaction" }, + ], + ]) { + const response = await fetch(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: [{ sdk: { name: "sentry.node" } }, itemHeader, payload] + .map((value) => JSON.stringify(value)) + .join("\n"), + }); + expect(response.status).toBe(204); + } + const listed = await cli("event", "list", session.id); + expect(listed.exitCode, listed.stderr).toBe(0); + expect( + JSON.parse(listed.stdout) + .events.map((event: { eventId: string }) => event.eventId) + .sort(), + ).toEqual(["item-event", "payload-event"]); + for (const eventId of ["item-event", "payload-event"]) { + const viewed = await cli("event", "view", session.id, eventId); + expect(viewed.exitCode, viewed.stderr).toBe(0); + expect(JSON.parse(viewed.stdout).eventId).toBe(eventId); + } + }, 30_000); + test("views telemetry using quoted and split multi-word session names", async () => { const session = await createSession("My App", await availablePort()); const eventId = "d".repeat(32); From a71fbfaf23dcf0e997c5164e8baa8e56c18e8784 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 14:18:43 -0700 Subject: [PATCH 06/10] refactor(local): Share management command definitions Share connection flags, session references and control output configuration. Use focused builders for session queries and event/envelope lookup while preserving help, argument parsing, errors and daemon lifecycle behavior. Co-Authored-By: GPT-6 Codex --- packages/cli/src/commands/local/management.ts | 292 ++++++------------ 1 file changed, 99 insertions(+), 193 deletions(-) diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index cf1ec465d..2d57deff3 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -8,6 +8,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { buildRouteMap } from "../../lib/route-map.js"; import { ensureLocalDaemon, + type LocalDaemonState, getRunningLocalDaemon, requestLocalDaemon, runLocalDaemonForeground, @@ -29,24 +30,48 @@ type SessionFlags = { }; type ControlResult = Record; -function publicDaemonState(state: { - host: string; - pid: number; - port: number; - version: number; -}): ControlResult { - return { - host: state.host, - pid: state.pid, - port: state.port, - version: state.version, - }; +function publicDaemonState({ + host, + pid, + port, + version, +}: LocalDaemonState): ControlResult { + return { host, pid, port, version }; } function formatHuman(data: ControlResult): string { return JSON.stringify(data, null, 2); } +const CONTROL_COMMAND_DEFAULTS = { + auth: false, + output: { human: formatHuman }, +} as const; + +const DAEMON_CONNECTION_FLAGS = { + host: { + kind: "parsed", + parse: String, + brief: "Loopback host", + default: "localhost", + }, + port: { + kind: "parsed", + parse: parsePort, + brief: "Control-plane port", + default: "8969", + }, +} as const; + +const SESSION_REFERENCE_POSITIONAL = { + kind: "array", + parameter: { + brief: "Session ID or unique name", + parse: String, + placeholder: "session", + }, +} as const; + async function controlJson( path: string, init: RequestInit = {}, @@ -87,7 +112,7 @@ function singleReference(parts: string[]): string { const daemonStartCommand = buildCommand({ docs: { brief: "Start the Local control-plane daemon" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: { foreground: { @@ -95,21 +120,9 @@ const daemonStartCommand = buildCommand({ brief: "Run in the foreground", default: false, }, - host: { - kind: "parsed", - parse: String, - brief: "Loopback host", - default: "localhost", - }, - port: { - kind: "parsed", - parse: parsePort, - brief: "Control-plane port", - default: "8969", - }, + ...DAEMON_CONNECTION_FLAGS, }, }, - auth: false, async *func(flags: DaemonFlags) { if (flags.foreground) { await runLocalDaemonForeground(flags); @@ -121,9 +134,8 @@ const daemonStartCommand = buildCommand({ const daemonStatusCommand = buildCommand({ docs: { brief: "Show Local control-plane status" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, - auth: false, async *func() { const daemon = await getRunningLocalDaemon(); yield new CommandOutput({ @@ -134,9 +146,8 @@ const daemonStatusCommand = buildCommand({ const daemonStopCommand = buildCommand({ docs: { brief: "Stop the Local control-plane daemon" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, - auth: false, async *func() { const state = await getRunningLocalDaemon(); if (!state) { @@ -152,9 +163,8 @@ const daemonStopCommand = buildCommand({ const daemonRestartCommand = buildCommand({ docs: { brief: "Restart the Local control-plane daemon" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, - auth: false, async *func() { const state = await getRunningLocalDaemon(); if (state) { @@ -193,7 +203,7 @@ const daemonRestartCommand = buildCommand({ const sessionCreateCommand = buildCommand({ docs: { brief: "Create a Local telemetry session" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: { clientId: { @@ -202,18 +212,7 @@ const sessionCreateCommand = buildCommand({ brief: "Idempotency key for retried agent session creation", optional: true, }, - host: { - kind: "parsed", - parse: String, - brief: "Loopback host", - default: "localhost", - }, - port: { - kind: "parsed", - parse: parsePort, - brief: "Control-plane port", - default: "8969", - }, + ...DAEMON_CONNECTION_FLAGS, }, positional: { kind: "array", @@ -224,7 +223,6 @@ const sessionCreateCommand = buildCommand({ }, }, }, - auth: false, async *func(this: SentryContext, flags: SessionFlags, ...parts: string[]) { const label = parts.join(" ").trim() || basename(this.cwd); yield new CommandOutput( @@ -247,174 +245,68 @@ const sessionCreateCommand = buildCommand({ const sessionListCommand = buildCommand({ docs: { brief: "List Local telemetry sessions" }, - output: { human: formatHuman }, + ...CONTROL_COMMAND_DEFAULTS, parameters: { flags: {} }, - auth: false, async *func() { yield new CommandOutput(await controlJson("/sessions")); }, }); -const sessionViewCommand = buildCommand({ - docs: { brief: "View a Local telemetry session" }, - output: { human: formatHuman }, - parameters: { - flags: {}, - positional: { - kind: "array", - parameter: { - brief: "Session ID or unique name", - parse: String, - placeholder: "session", - }, +/** Commands that target one session share argument parsing and URL construction. */ +function buildSessionCommand( + brief: string, + suffix = "", + method: "GET" | "POST" = "GET", +): ReturnType { + return buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, + docs: { brief }, + parameters: { flags: {}, positional: SESSION_REFERENCE_POSITIONAL }, + async *func(_flags, ...parts: string[]) { + yield new CommandOutput( + await controlJson( + `/sessions/${encodeURIComponent(singleReference(parts))}${suffix}`, + { method }, + ), + ); }, - }, - auth: false, - async *func(_flags, ...parts: string[]) { - yield new CommandOutput( - await controlJson( - `/sessions/${encodeURIComponent(singleReference(parts))}`, - ), - ); - }, -}); + }); +} -function buildSessionMutationCommand(action: "close" | "reset", brief: string) { +/** Event and envelope lookup both use the final argument as the telemetry ID. */ +function buildTelemetryViewCommand( + kind: "event" | "envelope", + brief: string, +): ReturnType { return buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, docs: { brief }, - output: { human: formatHuman }, parameters: { flags: {}, positional: { kind: "array", parameter: { - brief: "Session ID or unique name", + brief: `Session and ${kind} ID`, parse: String, - placeholder: "session", + placeholder: `session ${kind}`, }, }, }, - auth: false, async *func(_flags, ...parts: string[]) { + const session = parts.slice(0, -1).join(" ").trim(); + const id = parts.at(-1); + if (!(session && id)) { + throw new ValidationError(`Provide a session and ${kind} ID.`, kind); + } yield new CommandOutput( await controlJson( - `/sessions/${encodeURIComponent(singleReference(parts))}/${action}`, - { method: "POST" }, + `/sessions/${encodeURIComponent(session)}/${kind}s/${encodeURIComponent(id)}`, ), ); }, }); } -const envelopeListCommand = buildCommand({ - docs: { brief: "List retained envelopes for a Local session" }, - output: { human: formatHuman }, - parameters: { - flags: {}, - positional: { - kind: "array", - parameter: { - brief: "Session ID or unique name", - parse: String, - placeholder: "session", - }, - }, - }, - auth: false, - async *func(_flags, ...parts: string[]) { - yield new CommandOutput( - await controlJson( - `/sessions/${encodeURIComponent(singleReference(parts))}/envelopes`, - ), - ); - }, -}); - -const envelopeViewCommand = buildCommand({ - docs: { brief: "View a raw Local envelope" }, - output: { human: formatHuman }, - parameters: { - flags: {}, - positional: { - kind: "array", - parameter: { - brief: "Session and envelope ID", - parse: String, - placeholder: "session envelope", - }, - }, - }, - auth: false, - async *func(_flags, ...parts: string[]) { - const session = parts.slice(0, -1).join(" ").trim(); - const envelope = parts.at(-1); - if (!(session && envelope)) { - throw new ValidationError( - "Provide a session and envelope ID.", - "envelope", - ); - } - yield new CommandOutput( - await controlJson( - `/sessions/${encodeURIComponent(session)}/envelopes/${encodeURIComponent(envelope)}`, - ), - ); - }, -}); - -const eventListCommand = buildCommand({ - docs: { brief: "List decoded events for a Local session" }, - output: { human: formatHuman }, - parameters: { - flags: {}, - positional: { - kind: "array", - parameter: { - brief: "Session ID or unique name", - parse: String, - placeholder: "session", - }, - }, - }, - auth: false, - async *func(_flags, ...parts: string[]) { - yield new CommandOutput( - await controlJson( - `/sessions/${encodeURIComponent(singleReference(parts))}/events`, - ), - ); - }, -}); - -const eventViewCommand = buildCommand({ - docs: { brief: "View a decoded Local event" }, - output: { human: formatHuman }, - parameters: { - flags: {}, - positional: { - kind: "array", - parameter: { - brief: "Session and event ID", - parse: String, - placeholder: "session event", - }, - }, - }, - auth: false, - async *func(_flags, ...parts: string[]) { - const session = parts.slice(0, -1).join(" ").trim(); - const event = parts.at(-1); - if (!(session && event)) { - throw new ValidationError("Provide a session and event ID.", "event"); - } - yield new CommandOutput( - await controlJson( - `/sessions/${encodeURIComponent(session)}/events/${encodeURIComponent(event)}`, - ), - ); - }, -}); - export const daemonRoute = buildRouteMap({ routes: { start: daemonStartCommand, @@ -429,26 +321,40 @@ export const sessionRoute = buildRouteMap({ routes: { create: sessionCreateCommand, list: sessionListCommand, - view: sessionViewCommand, - close: buildSessionMutationCommand( - "close", + view: buildSessionCommand("View a Local telemetry session"), + close: buildSessionCommand( "Close a Local telemetry session", + "/close", + "POST", ), - reset: buildSessionMutationCommand( - "reset", + reset: buildSessionCommand( "Clear a Local telemetry session", + "/reset", + "POST", ), }, defaultCommand: "list", docs: { brief: "Manage Local telemetry sessions" }, }); export const envelopeRoute = buildRouteMap({ - routes: { list: envelopeListCommand, view: envelopeViewCommand }, + routes: { + list: buildSessionCommand( + "List retained envelopes for a Local session", + "/envelopes", + ), + view: buildTelemetryViewCommand("envelope", "View a raw Local envelope"), + }, defaultCommand: "list", docs: { brief: "Query Local envelopes" }, }); export const eventRoute = buildRouteMap({ - routes: { list: eventListCommand, view: eventViewCommand }, + routes: { + list: buildSessionCommand( + "List decoded events for a Local session", + "/events", + ), + view: buildTelemetryViewCommand("event", "View a decoded Local event"), + }, defaultCommand: "list", docs: { brief: "Query Local decoded events" }, }); From cff501cac4aff89675a29a39c7ecf077d25a200d Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 15:58:33 -0700 Subject: [PATCH 07/10] feat(local): Configure envelope retention without losing history Add receiver/session limits and runtime controls that preserve retained envelopes and active streams. Reject unsafe decreases, follow ordered settings in the viewer, and clear stale decode alerts with the feed. Co-Authored-By: GPT-6 Codex --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/local/src/App.integration.test.tsx | 248 ++++++++++++++++++ apps/local/src/App.tsx | 85 +++++- .../src/components/retention-settings.tsx | 128 +++++++++ apps/local/src/lib/telemetry-store.test.ts | 22 ++ apps/local/src/lib/telemetry-store.ts | 35 ++- docs/cli/local-control-plane.md | 26 ++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/local.md | 10 + .../cli/src/commands/local/control-plane.ts | 73 +++++- packages/cli/src/commands/local/daemon.ts | 4 +- packages/cli/src/commands/local/index.ts | 2 +- packages/cli/src/commands/local/management.ts | 37 +++ .../src/commands/local/retention-buffer.ts | 148 +++++++++++ .../src/commands/local/retention-limits.ts | 13 + packages/cli/src/commands/local/run.ts | 30 ++- packages/cli/src/commands/local/server.ts | 116 +++++++- .../cli/src/commands/local/session-store.ts | 30 ++- packages/cli/src/lib/credential-redaction.ts | 2 +- .../test/commands/local/control-plane.test.ts | 33 +++ .../cli/test/commands/local/server.test.ts | 52 ++++ .../test/commands/local/session-store.test.ts | 73 +++++- packages/cli/test/e2e/local.test.ts | 194 +++++++++++++- .../cli/test/lib/credential-redaction.test.ts | 1 + 24 files changed, 1325 insertions(+), 40 deletions(-) create mode 100644 apps/local/src/components/retention-settings.tsx create mode 100644 packages/cli/src/commands/local/retention-buffer.ts create mode 100644 packages/cli/src/commands/local/retention-limits.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index df3224892..12f0031d4 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -69,7 +69,7 @@ toolkit/ │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view -│ │ │ ├── local/ # close, create, list, reset, restart, run, serve, start, status, stop, view +│ │ │ ├── local/ # close, create, list, reset, restart, retention, run, serve, start, status, stop, view │ │ │ ├── log/ # list, view │ │ │ ├── monitor/ # list, run │ │ │ ├── org/ # list, view diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index 5f331bbfe..1a1ec5c01 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -13,6 +13,7 @@ import { NuqsAdapter } from "nuqs/adapters/react"; import { NuqsTestingAdapter } from "nuqs/adapters/testing"; import { BrowserRouter } from "react-router"; import { ThemeProvider } from "@/components/theme-provider.tsx"; +import { createLocalRetentionBuffer } from "../../../packages/cli/src/commands/local/retention-buffer.ts"; import App from "./App.tsx"; import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; import { @@ -794,6 +795,253 @@ describe("local receiver to viewer integration", () => { await waitFor(() => expect(clearButton.disabled).toBe(true)); }); + test("preserves undecodable raw envelopes and clears their stale alert with the feed", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: "undecodable", + lastEventId: "raw-broken", + }), + ); + }); + await screen.findByText("Received an event that could not be decoded."); + const clear = screen.getByRole("button", { + name: "Clear events", + }) as HTMLButtonElement; + expect(clear.disabled).toBe(false); + fireEvent.click( + screen.getByRole("button", { name: "Open envelopes view" }), + ); + await screen.findByLabelText("View envelope event"); + fireEvent.click(clear); + await waitFor(() => + expect( + screen.queryByText("Received an event that could not be decoded."), + ).toBeNull(), + ); + expect(clear.disabled).toBe(true); + }); + + test("replays more than 500 retained envelopes and applies a real runtime update", async () => { + const retention = createLocalRetentionBuffer(1000); + const receiver = await tryListen( + buildApp(retention.buffer, { uiActions: true, retention }), + 0, + "127.0.0.1", + ); + try { + // Initial metadata must precede replay, otherwise the viewer's default clips history. + for (let index = 0; index < 501; index++) + await sendEnvelope(receiver.port, `retained-${index}`); + renderViewer(receiver.port, "/explorer/events"); + await screen.findByText( + /Retention limit: 1,000 envelopes · 501 displayed/, + {}, + { timeout: 10000 }, + ); + fireEvent.click(screen.getByText(/Retention limit: 1,000 envelopes/)); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "2000" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await screen.findByText( + /Retention limit: 2,000 envelopes · 501 displayed/, + ); + expect(retention.getSettings()).toEqual({ + maxEnvelopes: 2000, + retainedEnvelopes: 501, + }); + await sendEnvelope(receiver.port, "after-growth"); + await screen.findByText( + /Retention limit: 2,000 envelopes · 502 displayed/, + ); + } finally { + cleanup(); + await stopReceiver(receiver.server); + } + }, 20000); + + test("updates live retention without clearing events or replacing the stream", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ maxEnvelopes: 1000, retainedEnvelopes: 2 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => source?.onopen?.(new Event("open"))); + await act(async () => { + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: JSON.stringify({ maxEnvelopes: 500 }), + }), + ); + for (const id of ["one", "two"]) + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: `retained ${id}` }]], + ]), + lastEventId: id, + }), + ); + }); + fireEvent.click(await screen.findByText(/Retention limit: 500 envelopes/)); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await screen.findByText( + /Cannot lower the limit below 2 displayed envelopes/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1000" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await act(async () => + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: JSON.stringify({ maxEnvelopes: 1000 }), + }), + ), + ); + await screen.findByText(/Retention limit: 1,000 envelopes/); + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(2); + expect(source?.closed).toBe(false); + expect(ControllableEventSource.instances).toHaveLength(1); + }); + + test("requires reconnection before changing a disconnected receiver's retention", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + const source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":1000}', + }), + ); + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "kept" }]], + ]), + lastEventId: "kept", + }), + ); + source?.onerror?.(new Event("error")); + }); + fireEvent.click( + await screen.findByText(/Retention limit: 1,000 envelopes/), + ); + await screen.findByText("Reconnect to this receiver to change retention."); + expect( + screen.queryByRole("button", { name: "Apply retention limit" }), + ).toBeNull(); + expect( + screen.getByRole("button", { name: "View event event" }), + ).not.toBeNull(); + }); + + test.each([false, true])( + "ignores delayed retention responses after newer metadata (switch receiver: %s)", + async (switchReceiver) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + let respond!: (response: Response) => void; + const pending = new Promise((resolve) => { + respond = resolve; + }); + const fetchMock = vi.fn().mockReturnValue(pending); + vi.stubGlobal("fetch", fetchMock); + renderBareViewer(); + let source = ControllableEventSource.instances[0]; + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":500}', + }), + ); + }); + fireEvent.click( + await screen.findByText(/Retention limit: 500 envelopes/), + ); + fireEvent.change(screen.getByLabelText("Retention limit"), { + target: { value: "1" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Apply retention limit" }), + ); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + if (switchReceiver) { + fireEvent.click( + screen.getByRole("button", { name: "Change receiver" }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + source = ControllableEventSource.instances[1]; + } + await act(async () => { + source?.onopen?.(new Event("open")); + source?.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":1000}', + }), + ); + respond(new Response('{"maxEnvelopes":1}', { status: 200 })); + await pending; + }); + await screen.findByText(/Retention limit: 1,000 envelopes/); + await act(async () => { + for (const id of ["one", "two"]) + source?.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: id }]], + ]), + lastEventId: id, + }), + ); + }); + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(2); + }, + ); + test.each([204, 403])( "handles scoped reset HTTP %s and closes a telemetry session", async (resetStatus) => { diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 4e311f4af..766b2109d 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,3 +1,9 @@ +import { RetentionSettings } from "./components/retention-settings.tsx"; +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, + LOCAL_RETENTION_EVENT, +} from "../../../packages/cli/src/commands/local/retention-limits.ts"; import { Check, ChevronLeft, @@ -727,6 +733,8 @@ export default function App() { const [isConnectionEnabled, setIsConnectionEnabled] = useState(true); const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(); + const [retentionLimit, setRetentionLimit] = useState(DEFAULT_MAX_ENVELOPES); + const [hasRetentionSettings, setHasRetentionSettings] = useState(false); const [telemetryStore] = useState(() => createLocalTelemetryStore()); const telemetry = useSyncExternalStore( telemetryStore.subscribe, @@ -845,6 +853,7 @@ export default function App() { isClearingCommandSearch.current = true; setCommandSearchQuery(""); telemetryStore.clear(); + setMessage(undefined); setLastViewedItemIds({}); void workspaceQuery.clearWorkspace(); @@ -904,6 +913,9 @@ export default function App() { }; const currentEndpoint = parseStreamEndpoint(streamUrl); + const canEditRetention = + currentEndpoint?.kind === "loopback" && + isLoopbackHost(window.location.hostname); const hasScopedSession = currentEndpoint?.kind === "loopback" && isLoopbackHost(window.location.hostname) && @@ -924,6 +936,9 @@ export default function App() { if (endpoint.url !== streamUrl) { activeStreamUrl.current = endpoint.url; telemetryStore.clear(); + telemetryStore.setLimit(DEFAULT_MAX_ENVELOPES); + setRetentionLimit(DEFAULT_MAX_ENVELOPES); + setHasRetentionSettings(false); setLastViewedItemIds({}); fallbackEventId.current = 0; isClearingCommandSearch.current = true; @@ -956,7 +971,31 @@ export default function App() { } let isCurrent = true; - const source = new EventSource(streamUrl); + const requestUrl = new URL(streamUrl); + if (parseStreamEndpoint(streamUrl)?.kind === "loopback") + requestUrl.searchParams.set("retention", "1"); + const source = new EventSource(requestUrl.toString()); + const onRetention = (event: Event) => { + if (!isCurrent || activeStreamUrl.current !== streamUrl) return; + try { + const settings: unknown = JSON.parse( + (event as MessageEvent).data, + ); + if ( + typeof settings === "object" && + settings !== null && + "maxEnvelopes" in settings && + isValidMaxEnvelopes(settings.maxEnvelopes) + ) { + telemetryStore.setLimit(settings.maxEnvelopes); + setRetentionLimit(settings.maxEnvelopes); + setHasRetentionSettings(true); + } + } catch { + /* Ignore malformed optional metadata from external receivers. */ + } + }; + source.addEventListener(LOCAL_RETENTION_EVENT, onRetention); const timeoutId = window.setTimeout(() => { if (!isCurrent || activeStreamUrl.current !== streamUrl) { return; @@ -974,22 +1013,25 @@ export default function App() { if (!isCurrent || activeStreamUrl.current !== streamUrl) { return; } + const messageEvent = event as MessageEvent; + const eventId = + messageEvent.lastEventId || `event-${fallbackEventId.current++}`; + const rawEnvelope: LocalFeedItem = { + id: eventId, + type: "envelope", + timestamp: Date.now(), + text: messageEvent.data, + payload: messageEvent.data, + metadata: { title: `Envelope ${eventId.slice(-8)}` }, + }; try { - const messageEvent = event as MessageEvent; - const eventId = - messageEvent.lastEventId || `event-${fallbackEventId.current++}`; - telemetryStore.recordEnvelope({ - id: eventId, - type: "envelope", - timestamp: Date.now(), - text: messageEvent.data, - payload: messageEvent.data, - metadata: { title: `Envelope ${eventId.slice(-8)}` }, - }); - const decoded = decodeEnvelope(messageEvent.data, eventId); - telemetryStore.append(decoded); + telemetryStore.recordEnvelope( + rawEnvelope, + decodeEnvelope(messageEvent.data, eventId), + ); setMessage(undefined); } catch { + telemetryStore.recordEnvelope(rawEnvelope, []); setMessage("Received an event that could not be decoded."); } }; @@ -1032,6 +1074,7 @@ export default function App() { isCurrent = false; window.clearTimeout(timeoutId); source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.removeEventListener(LOCAL_RETENTION_EVENT, onRetention); source.close(); }; }, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]); @@ -1179,6 +1222,20 @@ export default function App() { className="flex min-h-0 flex-1 flex-col" aria-label="Local Sentry events" > + {hasRetentionSettings ? ( + + ) : null} {connectionError && connection === "failed" && hasRetainedItems ? ( diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx new file mode 100644 index 000000000..1f966fc23 --- /dev/null +++ b/apps/local/src/components/retention-settings.tsx @@ -0,0 +1,128 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { + isValidMaxEnvelopes, + MAX_ENVELOPES, +} from "../../../../packages/cli/src/commands/local/retention-limits.ts"; + +type Props = { + limit: number; + count: number; + streamUrl: string; + editable: boolean; + unavailableReason?: string; +}; +/** Envelope-count controls keep existing viewer state and the EventSource intact. */ +export function RetentionSettings({ + limit, + count, + streamUrl, + editable, + unavailableReason, +}: Props) { + const [draft, setDraft] = useState(String(limit)); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + useEffect(() => setDraft(String(limit)), [limit]); + async function save(event: FormEvent) { + event.preventDefault(); + const value = /^\d+$/.test(draft) ? Number(draft) : NaN; + if (!isValidMaxEnvelopes(value)) { + setError(`Enter an integer between 1 and ${MAX_ENVELOPES}.`); + return; + } + if (value < count) { + setError( + `Cannot lower the limit below ${count} displayed envelopes. Clear envelopes first.`, + ); + return; + } + const url = new URL(streamUrl); + url.pathname = /^\/_local\/v1\/sessions\/[^/]+\/stream$/.test(url.pathname) + ? url.pathname.replace(/\/stream$/, "/ui/retention") + : "/retention"; + setSaving(true); + setError(undefined); + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes: value }), + signal: AbortSignal.timeout(5000), + }); + const body: unknown = await response.json(); + if (!response.ok) { + const detail = + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ? body.error + : "Could not update retention. Existing envelopes were preserved."; + setError(detail); + return; + } + if ( + typeof body !== "object" || + body === null || + !("maxEnvelopes" in body) || + !isValidMaxEnvelopes(body.maxEnvelopes) + ) + throw new Error("Invalid retention response"); + // Ordered stream metadata is authoritative. A delayed HTTP response must + // not overwrite a newer CLI change or a different receiver's settings. + } catch { + setError( + "Could not update retention. Existing envelopes were preserved.", + ); + } finally { + setSaving(false); + } + } + return ( +
    + + Retention limit: {limit.toLocaleString()} envelopes ·{" "} + {count.toLocaleString()} displayed + + {editable ? ( +
    + + setDraft(event.target.value)} + className="h-8 w-28 border border-border bg-background px-2" + /> + +

    + Existing envelopes stay available. A lower limit requires clearing + enough envelopes first. +

    + {error ? ( +

    + {error} +

    + ) : null} +
    + ) : ( +

    + {unavailableReason ?? + "Change retention from a locally served viewer or the CLI."} +

    + )} +
    + ); +} diff --git a/apps/local/src/lib/telemetry-store.test.ts b/apps/local/src/lib/telemetry-store.test.ts index 4dc4bd00b..ba22e9dfd 100644 --- a/apps/local/src/lib/telemetry-store.test.ts +++ b/apps/local/src/lib/telemetry-store.test.ts @@ -14,6 +14,28 @@ function item(overrides: Partial): LocalFeedItem { } describe("LocalTelemetryStore", () => { + test("retains every decoded item in the retained envelopes and grows without clearing", () => { + const store = createLocalTelemetryStore({ limit: 2 }); + const raw = (id: string) => item({ id, type: "envelope" }); + store.recordEnvelope(raw("one"), [ + item({ id: "one:0" }), + item({ id: "one:1" }), + item({ id: "one:2" }), + ]); + store.recordEnvelope(raw("two"), [item({ id: "two:0" })]); + expect(store.getSnapshot().items).toHaveLength(4); + store.setLimit(3); + expect(store.getSnapshot().items).toHaveLength(4); + store.recordEnvelope(raw("three"), [item({ id: "three:0" })]); + expect(store.getSnapshot().envelopes).toHaveLength(3); + store.recordEnvelope(raw("four"), [item({ id: "four:0" })]); + expect(store.getSnapshot().items.map((entry) => entry.id)).toEqual([ + "two:0", + "three:0", + "four:0", + ]); + }); + test("indexes a bounded session by its observability view", () => { const store = createLocalTelemetryStore({ limit: 3 }); diff --git a/apps/local/src/lib/telemetry-store.ts b/apps/local/src/lib/telemetry-store.ts index 0150b27df..dae83dfc0 100644 --- a/apps/local/src/lib/telemetry-store.ts +++ b/apps/local/src/lib/telemetry-store.ts @@ -1,3 +1,7 @@ +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, +} from "../../../../packages/cli/src/commands/local/retention-limits.ts"; import type { LocalFeedItem } from "./spotlight.ts"; import { isAiEvent, isErrorEvent } from "./workspace.ts"; @@ -16,7 +20,11 @@ export type LocalTelemetrySnapshot = { export type LocalTelemetryStore = { append: (items: readonly LocalFeedItem[]) => void; - recordEnvelope: (envelope: LocalFeedItem) => void; + recordEnvelope: ( + envelope: LocalFeedItem, + items?: readonly LocalFeedItem[], + ) => void; + setLimit: (limit: number) => void; clear: () => void; getSnapshot: () => LocalTelemetrySnapshot; subscribe: (listener: () => void) => () => void; @@ -66,9 +74,12 @@ function buildSnapshot( * It intentionally has no browser persistence: restarting a receiver starts a new session. */ export function createLocalTelemetryStore({ - limit = 500, + limit = DEFAULT_MAX_ENVELOPES, }: { limit?: number } = {}): LocalTelemetryStore { + if (!isValidMaxEnvelopes(limit)) + throw new Error("Invalid envelope retention limit."); let snapshot = EMPTY_SNAPSHOT; + const envelopeItems = new Map(); const listeners = new Set<() => void>(); function publish(items: LocalFeedItem[], envelopes = snapshot.envelopes) { @@ -98,13 +109,29 @@ export function createLocalTelemetryStore({ publish([...snapshot.items, ...newItems].slice(-limit)); }, - recordEnvelope(envelope) { + setLimit(value) { + if (!isValidMaxEnvelopes(value)) + throw new Error("Invalid envelope retention limit."); + limit = value; + }, + recordEnvelope(envelope, decoded) { if (snapshot.envelopes.some((item) => item.id === envelope.id)) { return; } - publish(snapshot.items, [...snapshot.envelopes, envelope].slice(-limit)); + if (decoded) envelopeItems.set(envelope.id, decoded); + const envelopes = [...snapshot.envelopes, envelope].slice(-limit); + const retainedIds = new Set(envelopes.map((item) => item.id)); + for (const id of envelopeItems.keys()) + if (!retainedIds.has(id)) envelopeItems.delete(id); + publish( + decoded + ? envelopes.flatMap((item) => [...(envelopeItems.get(item.id) ?? [])]) + : snapshot.items, + envelopes, + ); }, clear() { + envelopeItems.clear(); if (snapshot.items.length > 0 || snapshot.envelopes.length > 0) { publish([], []); } diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index eab411d04..f51a3ce38 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -48,6 +48,32 @@ When a managed daemon owns the requested port, plain `local run` and `local serv require `--session ` or a different `--port`; they cannot use its protected receiver as a legacy `/stream` endpoint. +## Envelope retention + +Each session or plain receiver retains 500 envelopes by default. Set a limit from +1 to 50,000 with `--max-envelopes` on `local session create`, `local run`, or +`local serve`, for example: + +```sh +sentry local run --session web --max-envelopes 1000 -- npm run dev +sentry local session retention web --max-envelopes 2000 +sentry local session retention web --json +``` + +Increasing the limit preserves existing envelopes, their IDs, and active streams; +previously evicted envelopes cannot be recovered. Lowering below the retained count +is rejected without changing any data. Clear envelopes first to reduce that limit. +Changing capacity does not reset the session's three-hour lifecycle retention. +An explicit limit also configures an existing plain receiver; ordinary attachment +preserves its current setting. Older receivers that cannot apply the flag report an +error. Retrying session creation with the same client ID preserves its original settings. + +The viewer follows the receiver's envelope limit and retains every decoded item +inside each retained envelope. Its Retention limit control can update a locally +served receiver or scoped session at runtime without replacing the connection. +Hosted viewers display the setting but cannot change it. CLI changes also update +connected viewers. + ## Browser viewer The independently served viewer lives in `apps/local`. Run diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 037490a7a..53ef88550 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -753,6 +753,7 @@ Sentry for local development - `sentry local event view ` — View a decoded Local event - `sentry local serve` — Start the local dev server and tail events - `sentry local session create ` — Create a Local telemetry session +- `sentry local session retention ` — View or change session envelope retention - `sentry local session list` — List Local telemetry sessions - `sentry local session view ` — View a Local telemetry session - `sentry local session close ` — Close a Local telemetry session diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 587983f10..83a431d62 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -60,6 +60,7 @@ Start the local dev server and tail events - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` - `--open - Open Sentry Local UI in the browser` +- `--max-envelopes - Maximum envelopes retained by this receiver or session` - `--session - Create a daemon-owned telemetry session with this label` ### `sentry local session create ` @@ -70,6 +71,14 @@ Create a Local telemetry session - `--clientId - Idempotency key for retried agent session creation` - `--host - Loopback host - (default: "localhost")` - `--port - Control-plane port - (default: "8969")` +- `--max-envelopes - Maximum envelopes retained by this receiver or session - (default: "500")` + +### `sentry local session retention ` + +View or change session envelope retention + +**Flags:** +- `--max-envelopes - Maximum envelopes retained by this receiver or session` ### `sentry local session list` @@ -100,6 +109,7 @@ Run a command with the local dev server enabled - `-F, --format - Output format: human (default) or json (NDJSON on stdout) - (default: "human")` - `-a, --attributes - Include selected event attributes in output` - `--open - Open Sentry Local UI in the browser` +- `--max-envelopes - Maximum envelopes retained by this receiver or session` - `--session - Create a daemon-owned telemetry session with this label` **Examples:** diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts index 7bc8880a8..0c6a58c78 100644 --- a/packages/cli/src/commands/local/control-plane.ts +++ b/packages/cli/src/commands/local/control-plane.ts @@ -21,11 +21,17 @@ import { type LocalSessionStore, } from "./session-store.js"; +import { LOCAL_RETENTION_EVENT } from "./retention-limits.js"; +import { + RetentionLimitError, + validateMaxEnvelopes, +} from "./retention-buffer.js"; + export const CONTROL_PLANE_PREFIX = "/_local/v1"; const LOOPBACK_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/; const SESSION_UI_ACTION_RE = - /^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close)$/; + /^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close|retention)$/; const SESSION_INGEST_RE = /^\/_local\/v1\/sessions\/[^/]+\/ingest\/[^/]+$/; const SESSION_STREAM_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/; @@ -206,6 +212,7 @@ export function createLocalControlPlane({ `${CONTROL_PLANE_PREFIX}/sessions/:id/ui/*`, cors({ allowMethods: ["POST", "OPTIONS"], + allowHeaders: ["Content-Type"], origin: (origin) => (LOOPBACK_ORIGIN_RE.test(origin) ? origin : null), }), ); @@ -245,12 +252,22 @@ export function createLocalControlPlane({ ) { return c.json({ error: "label and cwd are required." }, 400); } - const { clientId, label, cwd } = input as { + const { clientId, label, cwd, maxEnvelopes } = input as { clientId?: string; label: string; cwd: string; + maxEnvelopes?: number; }; - const session = sessionStore.create({ clientId, label, cwd }); + if (maxEnvelopes !== undefined) { + try { + validateMaxEnvelopes(maxEnvelopes); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + } + const session = sessionStore.create({ clientId, label, cwd, maxEnvelopes }); const capabilities = sessionStore.getCapabilities(session.id); const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`; return c.json( @@ -318,6 +335,25 @@ export function createLocalControlPlane({ return c.body(null, 401); } return streamSSE(c, async (stream) => { + const retention = sessionStore.getRetention(c.req.param("id")); + const sendSettings = ( + settings: ReturnType, + ) => { + void stream + .writeSSE({ + event: LOCAL_RETENTION_EVENT, + data: JSON.stringify(settings), + }) + .catch((error) => + logger.debug("Local retention stream closed", error), + ); + }; + const unsubscribeSettings = + c.req.query("retention") === "1" + ? retention.subscribeSettings(sendSettings) + : () => {}; + if (c.req.query("retention") === "1") + sendSettings(retention.getSettings()); const readerId = buffer.subscribe( buildSSEHandler(stream, c.req.query("base64") !== undefined), c.req.header("Last-Event-ID"), @@ -325,12 +361,40 @@ export function createLocalControlPlane({ await new Promise((resolve) => { stream.onAbort(() => { buffer.unsubscribe(readerId); + unsubscribeSettings(); resolve(); }); }); }); }); + const updateRetention = async (c: Context) => { + const input: unknown = await c.req.json().catch(() => undefined); + const value = + typeof input === "object" && input !== null && "maxEnvelopes" in input + ? input.maxEnvelopes + : undefined; + try { + validateMaxEnvelopes(value); + sessionStore.setMaxEnvelopes(c.req.param("id") ?? "", value); + return c.json( + sessionStore.getRetention(c.req.param("id") ?? "").getSettings(), + ); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + }; + app.get(`${CONTROL_PLANE_PREFIX}/sessions/:id/retention`, (c) => + sessionExists(c.req.param("id")) + ? c.json(sessionStore.getRetention(c.req.param("id")).getSettings()) + : c.body(null, 404), + ); + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/retention`, (c) => + sessionExists(c.req.param("id")) ? updateRetention(c) : c.body(null, 404), + ); + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/reset`, (c) => { if (!sessionExists(c.req.param("id"))) { return c.body(null, 404); @@ -346,7 +410,7 @@ export function createLocalControlPlane({ return c.json(sessionStore.close(c.req.param("id"))); }); - app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/:action`, (c) => { + app.post(`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/:action`, async (c) => { if (!(isSessionUiAction(c.req.path) && sessionExists(c.req.param("id")))) { return c.body(null, 404); } @@ -359,6 +423,7 @@ export function createLocalControlPlane({ ) { return c.body(null, 401); } + if (c.req.param("action") === "retention") return updateRetention(c); if (c.req.param("action") === "reset") { sessionStore.getBuffer(c.req.param("id")).clear(); return c.body(null, 204); diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts index dd3926a9b..ad2d1a21c 100644 --- a/packages/cli/src/commands/local/daemon.ts +++ b/packages/cli/src/commands/local/daemon.ts @@ -583,8 +583,10 @@ export async function createLocalDaemonSession({ host, label, port, + maxEnvelopes, }: { readonly clientId?: string; + readonly maxEnvelopes?: number; readonly cwd: string; readonly host?: string; readonly label: string; @@ -592,7 +594,7 @@ export async function createLocalDaemonSession({ }): Promise { const state = await ensureLocalDaemon({ host, port }); const response = await requestLocalDaemon(state, "/sessions", { - body: JSON.stringify({ clientId, cwd, label }), + body: JSON.stringify({ clientId, cwd, label, maxEnvelopes }), headers: { "Content-Type": "application/json" }, method: "POST", }); diff --git a/packages/cli/src/commands/local/index.ts b/packages/cli/src/commands/local/index.ts index 1593b58aa..21f55c310 100644 --- a/packages/cli/src/commands/local/index.ts +++ b/packages/cli/src/commands/local/index.ts @@ -27,7 +27,7 @@ export const localRoute = buildRouteMap({ " serve Start the server and tail events (default)\n" + " run Run a command with SENTRY_SPOTLIGHT auto-injected\n" + " daemon Manage the shared telemetry daemon\n" + - " session Create, inspect, reset, and close sessions\n" + + " session Create, inspect, configure, reset, and close sessions\n" + " event Query decoded session events\n" + " envelope Query raw session envelopes", }, diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts index 2d57deff3..a60b1bcd0 100644 --- a/packages/cli/src/commands/local/management.ts +++ b/packages/cli/src/commands/local/management.ts @@ -18,12 +18,18 @@ import { parsePort } from "./server.js"; import { logger } from "../../lib/logger.js"; import { redactCredentialText } from "../../lib/credential-redaction.js"; +import { + MAX_ENVELOPES_FLAG, + OPTIONAL_MAX_ENVELOPES_FLAG, +} from "./retention-buffer.js"; + type DaemonFlags = { readonly foreground: boolean; readonly host: string; readonly port: number; }; type SessionFlags = { + readonly "max-envelopes": number; readonly clientId?: string; readonly host: string; readonly port: number; @@ -213,6 +219,7 @@ const sessionCreateCommand = buildCommand({ optional: true, }, ...DAEMON_CONNECTION_FLAGS, + "max-envelopes": MAX_ENVELOPES_FLAG, }, positional: { kind: "array", @@ -233,6 +240,7 @@ const sessionCreateCommand = buildCommand({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: flags.clientId, + maxEnvelopes: flags["max-envelopes"], label, cwd: this.cwd, }), @@ -307,6 +315,34 @@ function buildTelemetryViewCommand( }); } +const sessionRetentionCommand = buildCommand({ + ...CONTROL_COMMAND_DEFAULTS, + docs: { brief: "View or change session envelope retention" }, + parameters: { + flags: { "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG }, + positional: SESSION_REFERENCE_POSITIONAL, + }, + async *func( + flags: { readonly "max-envelopes"?: number }, + ...parts: string[] + ) { + const path = `/sessions/${encodeURIComponent(singleReference(parts))}/retention`; + const value = flags["max-envelopes"]; + yield new CommandOutput( + await controlJson( + path, + value === undefined + ? {} + : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes: value }), + }, + ), + ); + }, +}); + export const daemonRoute = buildRouteMap({ routes: { start: daemonStartCommand, @@ -320,6 +356,7 @@ export const daemonRoute = buildRouteMap({ export const sessionRoute = buildRouteMap({ routes: { create: sessionCreateCommand, + retention: sessionRetentionCommand, list: sessionListCommand, view: buildSessionCommand("View a Local telemetry session"), close: buildSessionCommand( diff --git a/packages/cli/src/commands/local/retention-buffer.ts b/packages/cli/src/commands/local/retention-buffer.ts new file mode 100644 index 000000000..9f4f13fe9 --- /dev/null +++ b/packages/cli/src/commands/local/retention-buffer.ts @@ -0,0 +1,148 @@ +/** Resize through the Spotlight SDK's public API while keeping a stable buffer facade. */ +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { ValidationError } from "../../lib/errors.js"; +import { + DEFAULT_MAX_ENVELOPES, + isValidMaxEnvelopes, + MAX_ENVELOPES, +} from "./retention-limits.js"; + +type SpotlightBuffer = ReturnType; +type Subscriber = { + callback: Parameters[0]; + id: string; +}; +export type RetentionSettings = { + maxEnvelopes: number; + retainedEnvelopes: number; +}; +export type LocalRetentionBuffer = { + buffer: SpotlightBuffer; + getSettings: () => RetentionSettings; + resize: (maxEnvelopes: number) => void; + subscribeSettings: ( + listener: (settings: RetentionSettings) => void, + ) => () => void; +}; +export class RetentionLimitError extends Error { + constructor( + message: string, + readonly status: 400 | 409 = 400, + ) { + super(message); + this.name = "RetentionLimitError"; + } +} +export function validateMaxEnvelopes(value: unknown): asserts value is number { + if (!isValidMaxEnvelopes(value)) + throw new RetentionLimitError( + `Retention limit must be an integer between 1 and ${MAX_ENVELOPES}.`, + ); +} +const limitFlag = { + kind: "parsed", + brief: "Maximum envelopes retained by this receiver or session", + parse(value: string): number { + const parsed = /^\d+$/.test(value) ? Number(value) : NaN; + if (!isValidMaxEnvelopes(parsed)) + throw new ValidationError( + `Retention limit must be an integer between 1 and ${MAX_ENVELOPES}.`, + "max-envelopes", + ); + return parsed; + }, +} as const; +export const MAX_ENVELOPES_FLAG = { + ...limitFlag, + default: String(DEFAULT_MAX_ENVELOPES), +} as const; +export const OPTIONAL_MAX_ENVELOPES_FLAG = { + ...limitFlag, + optional: true, +} as const; + +export function createLocalRetentionBuffer( + maxEnvelopes = DEFAULT_MAX_ENVELOPES, +): LocalRetentionBuffer { + validateMaxEnvelopes(maxEnvelopes); + let delegate = createSpotlightBuffer(maxEnvelopes); + const buffer = createSpotlightBuffer(1); + const subscribers = new Map(); + const arrivalTimes = new WeakMap(); + const listeners = new Set<(settings: RetentionSettings) => void>(); + const getSettings = (): RetentionSettings => ({ + maxEnvelopes, + retainedEnvelopes: delegate.read({ all: true }).length, + }); + buffer.put = (item) => { + arrivalTimes.set(item, Date.now()); + delegate.put(item); + }; + buffer.read = (filters = { timeWindow: 60 }) => { + if ("timeWindow" in filters && filters.timeWindow !== undefined) { + const { timeWindow, ...rest } = filters; + return delegate + .read({ ...rest, all: true }) + .filter( + (item) => + (arrivalTimes.get(item) ?? 0) > Date.now() - timeWindow * 1000, + ); + } + return delegate.read(filters); + }; + buffer.subscribe = (callback, lastEventId) => { + const id = delegate.subscribe(callback, lastEventId); + subscribers.set(id, { callback, id }); + return id; + }; + buffer.unsubscribe = (id) => { + const subscriber = subscribers.get(id); + if (subscriber) delegate.unsubscribe(subscriber.id); + subscribers.delete(id); + }; + buffer.stream = (id) => { + const subscriber = subscribers.get(id); + if (subscriber) delegate.stream(subscriber.id); + }; + buffer.clear = () => delegate.clear(); + buffer.reset = () => delegate.reset(); + return { + buffer, + getSettings, + resize(nextLimit) { + validateMaxEnvelopes(nextLimit); + const retained = delegate.read({ all: true }); + if (nextLimit < retained.length) + throw new RetentionLimitError( + `Cannot lower the limit below ${retained.length} retained envelopes. Clear envelopes first.`, + 409, + ); + if (nextLimit === maxEnvelopes) return; + // Flush queued deliveries before moving readers, then advance each new reader + // past the copied history so an active stream neither misses nor repeats it. + for (const subscriber of subscribers.values()) + delegate.stream(subscriber.id); + const next = createSpotlightBuffer(nextLimit); + for (const item of retained.reverse()) next.put(item); + const previous = delegate; + delegate = next; + for (const subscriber of subscribers.values()) { + previous.unsubscribe(subscriber.id); + let replaying = true; + subscriber.id = next.subscribe((item) => { + if (!replaying) subscriber.callback(item); + }); + next.stream(subscriber.id); + replaying = false; + } + maxEnvelopes = nextLimit; + for (const listener of listeners) listener(getSettings()); + }, + subscribeSettings(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/cli/src/commands/local/retention-limits.ts b/packages/cli/src/commands/local/retention-limits.ts new file mode 100644 index 000000000..e704b6a7b --- /dev/null +++ b/packages/cli/src/commands/local/retention-limits.ts @@ -0,0 +1,13 @@ +/** Shared envelope-count limits for the Local receiver and browser viewer. */ +export const DEFAULT_MAX_ENVELOPES = 500; +export const MAX_ENVELOPES = 50_000; +export const LOCAL_RETENTION_EVENT = "sentry.local.retention"; + +export function isValidMaxEnvelopes(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 && + value <= MAX_ENVELOPES + ); +} diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 205af4b52..770bd8ac9 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -20,7 +20,6 @@ import { } from "node:child_process"; import type { Server } from "node:http"; import { basename, resolve } from "node:path"; -import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { detectDevCommand } from "../../lib/dev-script.js"; @@ -44,6 +43,7 @@ import { DEFAULT_PORT, isLoopbackHost, isServerRunning, + configureExistingRetention, parseFilter, parseFormat, parsePort, @@ -55,6 +55,11 @@ import { validateOpenHost, } from "./ui.js"; +import { + createLocalRetentionBuffer, + OPTIONAL_MAX_ENVELOPES_FLAG, +} from "./retention-buffer.js"; + type RunFlags = { readonly port: number; readonly host: string; @@ -65,6 +70,7 @@ type RunFlags = { readonly attributes: boolean; readonly open: boolean; readonly session?: string; + readonly "max-envelopes"?: number; }; /** Buffer size for the auto-started background server. */ @@ -140,6 +146,7 @@ type EventTailOptions = { activeFilters: ReadonlySet; useJson: boolean; showAttributes: boolean; + maxEnvelopes?: number; }; /** Keep stdout exclusively for NDJSON when an agent requests JSON output. */ @@ -213,9 +220,11 @@ async function startBackgroundServer({ activeFilters, useJson, showAttributes, + maxEnvelopes, }: EventTailOptions): Promise { - const buffer = createSpotlightBuffer(BUFFER_SIZE); - const app = buildApp(buffer, { uiActions: isLoopbackHost(host) }); + const retention = createLocalRetentionBuffer(maxEnvelopes); + const buffer = retention.buffer; + const app = buildApp(buffer, { uiActions: isLoopbackHost(host), retention }); const { server, port: boundPort } = await tryListen(app, port, host); const url = formatLocalServerUrl(host, boundPort); @@ -261,10 +270,12 @@ async function openEventTail({ activeFilters, useJson, showAttributes, + maxEnvelopes, }: EventTailOptions): Promise { const url = formatLocalServerUrl(host, port); if (await isServerRunning(url)) { + await configureExistingRetention(url, maxEnvelopes); logger.info(`Connected to existing server at ${bold(url)}`); return attachToExistingServer({ activeFilters, @@ -277,6 +288,7 @@ async function openEventTail({ logger.info("No server detected, starting one in the background..."); try { const bg = await startBackgroundServer({ + maxEnvelopes, port, host, activeFilters, @@ -289,6 +301,7 @@ async function openEventTail({ if (!(err instanceof ValidationError && err.field === "port")) { throw err; } + await configureExistingRetention(url, maxEnvelopes); // Something grabbed the port between the probe and the bind. logger.warn(`${err.message}; attaching to it instead`); return attachToExistingServer({ @@ -443,6 +456,7 @@ export const runCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, session: { kind: "parsed", parse: String, @@ -484,6 +498,7 @@ export const runCommand = buildCommand({ const tail = flags.session ? await (async () => { const session = await createLocalDaemonSession({ + maxEnvelopes: flags["max-envelopes"], cwd: this.cwd, host: flags.host, label: flags.session || basename(this.cwd), @@ -501,6 +516,7 @@ export const runCommand = buildCommand({ }); })() : await openEventTail({ + maxEnvelopes: flags["max-envelopes"], port: flags.port, host: flags.host, activeFilters, @@ -650,8 +666,12 @@ async function* runWithVerify( cwd: string, commandSource: string, ): AsyncGenerator { - const buffer = createSpotlightBuffer(BUFFER_SIZE); - const app = buildApp(buffer, { uiActions: isLoopbackHost(flags.host) }); + const retention = createLocalRetentionBuffer(flags["max-envelopes"]); + const buffer = retention.buffer; + const app = buildApp(buffer, { + uiActions: isLoopbackHost(flags.host), + retention, + }); const { server, port: boundPort } = await tryListen( app, flags.port, diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index ed632023b..0b975b222 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -45,6 +45,15 @@ import { validateOpenHost, } from "./ui.js"; +import { + createLocalRetentionBuffer, + type LocalRetentionBuffer, + OPTIONAL_MAX_ENVELOPES_FLAG, + RetentionLimitError, + validateMaxEnvelopes, +} from "./retention-buffer.js"; +import { LOCAL_RETENTION_EVENT } from "./retention-limits.js"; + /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; @@ -108,6 +117,7 @@ type LocalFlags = { readonly attributes: boolean; readonly open: boolean; readonly session?: string; + readonly "max-envelopes"?: number; }; /** @@ -145,6 +155,7 @@ export function isLoopbackHost(host: string): boolean { type LocalReceiverOptions = { /** Enables stateful UI routes only when the receiver itself is loopback-bound. */ uiActions?: boolean; + retention?: LocalRetentionBuffer; }; /** Whether an origin is the production or preview Local viewer. */ @@ -248,7 +259,7 @@ export function buildSSEHandler( export function buildApp( spotlightBuffer: ReturnType, - { uiActions = false }: LocalReceiverOptions = {}, + { uiActions = false, retention }: LocalReceiverOptions = {}, ): Hono { const app = new Hono(); @@ -323,10 +334,35 @@ export function buildApp( ? c.json({ actions: { clear: true, envelope: true }, retention: "session", + ...retention?.getSettings(), }) : c.body(null, 403), ); + app.post("/retention", async (c) => { + const origin = c.req.header("origin"); + if ( + !uiActions || + !retention || + (origin && !LOCALHOST_ORIGIN_RE.test(origin)) + ) + return c.body(null, 403); + const input: unknown = await c.req.json().catch(() => undefined); + const value = + typeof input === "object" && input !== null && "maxEnvelopes" in input + ? input.maxEnvelopes + : undefined; + try { + validateMaxEnvelopes(value); + retention.resize(value); + return c.json(retention.getSettings()); + } catch (error) { + if (error instanceof RetentionLimitError) + return c.json({ error: error.message }, error.status); + throw error; + } + }); + app.delete("/clear", (c) => { if (!uiActions) { return c.body(null, 403); @@ -424,11 +460,31 @@ export function buildApp( return streamSSE(c, async (stream) => { const lastEventId = c.req.header("Last-Event-ID"); const onEnvelope = buildSSEHandler(stream, useBase64); + const sendSettings = (settings: { + maxEnvelopes: number; + retainedEnvelopes: number; + }) => { + void stream + .writeSSE({ + event: LOCAL_RETENTION_EVENT, + data: JSON.stringify(settings), + }) + .catch((error) => + logger.debug("Local retention stream closed", error), + ); + }; + const unsubscribeSettings = + retention && c.req.query("retention") === "1" + ? retention.subscribeSettings(sendSettings) + : () => {}; + if (retention && c.req.query("retention") === "1") + sendSettings(retention.getSettings()); const readerId = spotlightBuffer.subscribe(onEnvelope, lastEventId); await new Promise((resolve) => { stream.onAbort(() => { spotlightBuffer.unsubscribe(readerId); + unsubscribeSettings(); resolve(); }); }); @@ -570,6 +626,51 @@ export async function isServerRunning(url: string): Promise { return true; } +/** Apply only an explicitly requested limit when attaching to an existing receiver. */ +export async function configureExistingRetention( + url: string, + maxEnvelopes?: number, +): Promise { + if (maxEnvelopes === undefined) return; + validateMaxEnvelopes(maxEnvelopes); + let response: Response; + try { + response = await fetch(`${url}/retention`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ maxEnvelopes }), + signal: AbortSignal.timeout(5000), + }); + } catch { + throw new ValidationError( + "Could not configure retention on the existing receiver. Start a receiver with --max-envelopes on another port.", + "max-envelopes", + ); + } + const body: unknown = await response.json().catch(() => undefined); + if (!response.ok) { + const detail = + typeof body === "object" && + body !== null && + "error" in body && + typeof body.error === "string" + ? body.error + : "The existing receiver does not support retention changes; choose another port."; + throw new ValidationError(redactCredentialText(detail), "max-envelopes"); + } + if ( + typeof body !== "object" || + body === null || + !("maxEnvelopes" in body) || + body.maxEnvelopes !== maxEnvelopes + ) { + throw new ValidationError( + "The existing receiver did not confirm the requested retention limit; choose another port.", + "max-envelopes", + ); + } +} + /** Mutable state for the SSE line parser. */ type SSEParserState = { eventType: string; @@ -939,6 +1040,7 @@ export const serverCommand = buildCommand({ brief: "Open Sentry Local UI in the browser", default: false, }, + "max-envelopes": OPTIONAL_MAX_ENVELOPES_FLAG, session: { kind: "parsed", parse: String, @@ -966,6 +1068,7 @@ export const serverCommand = buildCommand({ const { createLocalDaemonSession, retainLocalDaemonSession } = await import("./daemon.js"); const session = await createLocalDaemonSession({ + maxEnvelopes: flags["max-envelopes"], cwd: this.cwd, host: flags.host, label: flags.session || basename(this.cwd), @@ -1002,6 +1105,7 @@ export const serverCommand = buildCommand({ } if (await isServerRunning(url)) { + await configureExistingRetention(url, flags["max-envelopes"]); logger.info(`Connected to existing server at ${bold(url)}`); await openLocalUiIfRequested(flags.open, url); if (activeFilters.size > 0) { @@ -1035,7 +1139,10 @@ export const serverCommand = buildCommand({ return; } - const buffer = createSpotlightBuffer(BUFFER_SIZE); + const retention = createLocalRetentionBuffer( + flags["max-envelopes"] ?? BUFFER_SIZE, + ); + const buffer = retention.buffer; const useJson = flags.format === "json"; if (!flags.quiet) { @@ -1057,7 +1164,10 @@ export const serverCommand = buildCommand({ }); } - const app = buildApp(buffer, { uiActions: isLoopbackHost(flags.host) }); + const app = buildApp(buffer, { + uiActions: isLoopbackHost(flags.host), + retention, + }); const { server, port: boundPort } = await tryListen( app, diff --git a/packages/cli/src/commands/local/session-store.ts b/packages/cli/src/commands/local/session-store.ts index 693cf5034..2fac52445 100644 --- a/packages/cli/src/commands/local/session-store.ts +++ b/packages/cli/src/commands/local/session-store.ts @@ -6,10 +6,16 @@ import { randomBytes } from "node:crypto"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { + createLocalRetentionBuffer, + type LocalRetentionBuffer, + validateMaxEnvelopes, +} from "./retention-buffer.js"; +import { DEFAULT_MAX_ENVELOPES } from "./retention-limits.js"; import { uuidv7 } from "uuidv7"; /** Maximum number of envelopes retained by an individual Local session. */ -export const SESSION_BUFFER_SIZE = 500; +export const SESSION_BUFFER_SIZE = DEFAULT_MAX_ENVELOPES; /** Retained sessions remain queryable for three hours after closing. */ export const SESSION_RETENTION_MS = 3 * 60 * 60 * 1000; @@ -27,10 +33,12 @@ export type LocalSession = { readonly lastActivityAt: number; readonly state: LocalSessionState; readonly expiresAt?: number; + readonly maxEnvelopes: number; }; type StoredSession = LocalSession & { buffer: ReturnType; + retention: LocalRetentionBuffer; ingestCapability: string; streamCapability: string; }; @@ -40,6 +48,7 @@ export type CreateLocalSessionInput = { readonly clientId?: string; readonly label: string; readonly cwd: string; + readonly maxEnvelopes?: number; }; /** A label matches multiple sessions; callers must select an explicit ID. */ @@ -55,6 +64,8 @@ export class AmbiguousLocalSessionError extends Error { /** In-memory session lifecycle and access to isolated telemetry buffers. */ export type LocalSessionStore = { create: (input: CreateLocalSessionInput) => LocalSession; + setMaxEnvelopes: (reference: string, value: number) => LocalSession; + getRetention: (reference: string) => LocalRetentionBuffer; close: (reference: string) => LocalSession; getCapabilities: (reference: string) => { ingest: string; @@ -70,6 +81,7 @@ export type LocalSessionStore = { function toPublicSession(session: StoredSession): LocalSession { const { buffer: _buffer, + retention: _retention, ingestCapability: _ingestCapability, streamCapability: _streamCapability, ...publicSession @@ -119,12 +131,14 @@ export function createLocalSessionStore({ }; return { - create({ clientId, label, cwd }) { + create({ clientId, label, cwd, maxEnvelopes = DEFAULT_MAX_ENVELOPES }) { + validateMaxEnvelopes(maxEnvelopes); const existingId = clientId ? clientSessions.get(clientId) : undefined; if (existingId) { return toPublicSession(resolveStored(existingId)); } const timestamp = now(); + const retention = createLocalRetentionBuffer(maxEnvelopes); const session: StoredSession = { id: uuidv7(), clientId, @@ -133,7 +147,9 @@ export function createLocalSessionStore({ createdAt: timestamp, lastActivityAt: timestamp, state: "open", - buffer: createSpotlightBuffer(SESSION_BUFFER_SIZE), + buffer: retention.buffer, + retention, + maxEnvelopes, ingestCapability: randomBytes(24).toString("base64url"), streamCapability: randomBytes(24).toString("base64url"), }; @@ -143,6 +159,14 @@ export function createLocalSessionStore({ } return toPublicSession(session); }, + getRetention(reference) { + return resolveStored(reference).retention; + }, + setMaxEnvelopes(reference, value) { + const session = resolveStored(reference); + session.retention.resize(value); + return toPublicSession(update(session, { maxEnvelopes: value })); + }, close(reference) { const timestamp = now(); const session = resolveStored(reference); diff --git a/packages/cli/src/lib/credential-redaction.ts b/packages/cli/src/lib/credential-redaction.ts index bb2dceb5b..2b35010d6 100644 --- a/packages/cli/src/lib/credential-redaction.ts +++ b/packages/cli/src/lib/credential-redaction.ts @@ -9,7 +9,7 @@ const LOCAL_INGEST_CAPABILITY = /(\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/ingest\/)[A-Za-z0-9_-]+/g; const LOCAL_SCOPED_QUERY = - /\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/(?:stream|ui\/(?:reset|close))\?[^#\s"'\\]*/g; + /\/_local\/v1\/sessions\/[^/?#\s"'\\]+\/(?:stream|ui\/(?:reset|close|retention))\?[^#\s"'\\]*/g; const INVALID_BEARER_HEADER_START = /(\bHeaders\.(?:set|append):[ \t]*)(\\*["'])Bearer[ \t]+/gi; diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts index 6feeae290..f58dd5e85 100644 --- a/packages/cli/test/commands/local/control-plane.test.ts +++ b/packages/cli/test/commands/local/control-plane.test.ts @@ -10,6 +10,39 @@ function controlHeaders() { } describe("Local control plane", () => { + test("validates retention settings and keeps scoped mutation loopback-only", async () => { + const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); + const created = await plane.app.request("/_local/v1/sessions", { + method: "POST", + headers: controlHeaders(), + body: JSON.stringify({ label: "web", cwd: "/web", maxEnvelopes: 1000 }), + }); + const session = await created.json(); + expect(session.maxEnvelopes).toBe(1000); + const path = `/_local/v1/sessions/${session.id}/ui/retention?cap=${plane.sessionStore.getCapabilities(session.id).stream}`; + for (const origin of ["https://local.sentry.dev", "https://example.com"]) { + const result = await plane.app.request(path, { + method: "POST", + headers: { Origin: origin }, + body: JSON.stringify({ maxEnvelopes: 2000 }), + }); + expect(result.status).toBe(403); + } + const result = await plane.app.request(path, { + method: "POST", + headers: { Origin: "http://localhost:5173" }, + body: JSON.stringify({ maxEnvelopes: 2000 }), + }); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ maxEnvelopes: 2000 }); + const invalid = await plane.app.request(path, { + method: "POST", + body: JSON.stringify({ maxEnvelopes: 0 }), + }); + expect(invalid.status).toBe(400); + expect(plane.sessionStore.resolve(session.id).maxEnvelopes).toBe(2000); + }); + test("reports ambiguous session names with ID guidance across control routes", async () => { const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN }); const one = plane.sessionStore.create({ label: "My App", cwd: "/one" }); diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts index 03cf6036e..3519c03e4 100644 --- a/packages/cli/test/commands/local/server.test.ts +++ b/packages/cli/test/commands/local/server.test.ts @@ -6,11 +6,13 @@ */ import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { createLocalRetentionBuffer } from "../../../src/commands/local/retention-buffer.js"; import { Hono } from "hono"; import { describe, expect, test, vi } from "vitest"; import { buildApp, consumeSSE, + configureExistingRetention, feedSSELine, isServerRunning, parsePort, @@ -347,6 +349,56 @@ async function readFirstSSEEvent(streamPath: string): Promise { } describe("buildApp", () => { + test("changes legacy retention only when explicit and preserves history on rejected decreases", async () => { + const retention = createLocalRetentionBuffer(2); + const app = buildApp(retention.buffer, { uiActions: true, retention }); + await app.request("/stream", { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: TEST_ENVELOPE, + }); + const retained = retention.buffer.read({ all: true }); + for (const origin of ["https://example.com", "https://local.sentry.dev"]) { + expect( + ( + await app.request("/retention", { + method: "POST", + headers: { Origin: origin }, + body: '{"maxEnvelopes":1000}', + }) + ).status, + ).toBe(403); + } + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => + app.request(new URL(url).pathname, init), + ); + vi.stubGlobal("fetch", fetchMock); + try { + await configureExistingRetention("http://127.0.0.1:8969"); + expect(fetchMock).not.toHaveBeenCalled(); + await configureExistingRetention("http://127.0.0.1:8969", 1000); + expect(retention.getSettings().maxEnvelopes).toBe(1000); + expect(retention.buffer.read({ all: true })).toEqual(retained); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 0), + ).rejects.toThrow(/integer/); + fetchMock.mockImplementation( + async () => new Response("{}", { status: 200 }), + ); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 2000), + ).rejects.toThrow(/did not confirm/); + fetchMock.mockImplementation( + async () => new Response(null, { status: 404 }), + ); + await expect( + configureExistingRetention("http://127.0.0.1:8969", 2000), + ).rejects.toThrow(/does not support/); + } finally { + vi.unstubAllGlobals(); + } + }); + test("health endpoint returns OK", async () => { const buffer = createSpotlightBuffer(10); const app = buildApp(buffer); diff --git a/packages/cli/test/commands/local/session-store.test.ts b/packages/cli/test/commands/local/session-store.test.ts index 0b8f8d343..91f51239a 100644 --- a/packages/cli/test/commands/local/session-store.test.ts +++ b/packages/cli/test/commands/local/session-store.test.ts @@ -1,7 +1,78 @@ -import { describe, expect, test } from "vitest"; +import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { describe, expect, test, vi } from "vitest"; import { createLocalSessionStore } from "../../../src/commands/local/session-store.js"; describe("LocalSessionStore", () => { + test("grows retention without losing envelopes, IDs or live subscribers", () => { + const store = createLocalSessionStore(); + const session = store.create({ + label: "web", + cwd: "/web", + maxEnvelopes: 2, + }); + const buffer = store.getBuffer(session.id); + const messages: string[] = []; + const reader = buffer.subscribe((container) => + messages.push(container.getData().toString()), + ); + function ingest(id: string) { + pushToSpotlightBuffer({ + spotlightBuffer: buffer, + contentType: "application/x-sentry-envelope", + body: Buffer.from( + `{"event_id":"${id}"}\n{"type":"event"}\n{"message":"${id}"}`, + ), + }); + buffer.stream(reader); + } + ingest("one"); + ingest("two"); + const retained = buffer.read({ all: true }); + expect(store.resolve(session.id).maxEnvelopes).toBe(2); + store.setMaxEnvelopes(session.id, 4); + expect(store.getBuffer(session.id)).toBe(buffer); + expect(buffer.read({ all: true })).toEqual(retained); + ingest("three"); + ingest("four"); + expect(buffer.read({ all: true })).toHaveLength(4); + expect(messages).toHaveLength(4); + expect(() => store.setMaxEnvelopes(session.id, 3)).toThrow( + /retained envelopes/, + ); + expect(buffer.read({ all: true })).toHaveLength(4); + buffer.clear(); + store.setMaxEnvelopes(session.id, 1); + ingest("five"); + ingest("six"); + expect(buffer.read({ all: true })).toHaveLength(1); + expect(messages).toHaveLength(6); + buffer.unsubscribe(reader); + }); + + test("keeps original arrival times when resizing", () => { + const store = createLocalSessionStore(); + const session = store.create({ + label: "timed", + cwd: "/timed", + maxEnvelopes: 2, + }); + const buffer = store.getBuffer(session.id); + const time = vi.spyOn(Date, "now").mockReturnValue(1000); + try { + pushToSpotlightBuffer({ + spotlightBuffer: buffer, + contentType: "application/x-sentry-envelope", + body: Buffer.from('{"event_id":"old"}\n{"type":"event"}\n{}'), + }); + time.mockReturnValue(61001); + store.setMaxEnvelopes(session.id, 4); + expect(buffer.read({ timeWindow: 60 })).toHaveLength(0); + expect(buffer.read({ all: true })).toHaveLength(1); + } finally { + time.mockRestore(); + } + }); + test("creates isolated sessions with stable IDs and readable labels", () => { const store = createLocalSessionStore({ now: () => 1000 }); diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index f062d2fa4..8b6c1bc81 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -47,7 +47,11 @@ async function availablePort(): Promise { return address.port; } -async function createSession(label: string, port: number): Promise { +async function createSession( + label: string, + port: number, + maxEnvelopes?: number, +): Promise { const result = await cli( "session", "create", @@ -56,6 +60,9 @@ async function createSession(label: string, port: number): Promise { "127.0.0.1", "--port", String(port), + ...(maxEnvelopes === undefined + ? [] + : ["--max-envelopes", String(maxEnvelopes)]), ); expect(result.exitCode, result.stderr).toBe(0); return JSON.parse(result.stdout) as Session; @@ -280,6 +287,103 @@ describe("Local daemon E2E", () => { ).toContain("worker event"); }, 30_000); + test("configures retention and grows a live session without losing history or replaying duplicates", async () => { + const session = await createSession("limits", await availablePort(), 2); + await ingest(session, "one", "one"); + await ingest(session, "two", "two"); + const before = JSON.parse( + (await cli("event", "list", session.id)).stdout, + ).events; + const streamUrl = new URL(session.streamUrl); + streamUrl.searchParams.set("retention", "1"); + const response = await fetch(streamUrl, { + signal: AbortSignal.timeout(30000), + }); + const reader = response.body!.getReader(); + let text = ""; + async function until(marker: string) { + while (!text.includes(marker)) { + const chunk = await reader.read(); + if (chunk.done) throw new Error("Stream closed"); + text += new TextDecoder().decode(chunk.value); + } + } + try { + await until('"message":"two"'); + const grown = await cli( + "session", + "retention", + session.id, + "--max-envelopes", + "4", + ); + expect(grown.exitCode, grown.stderr).toBe(0); + expect(JSON.parse(grown.stdout)).toMatchObject({ + maxEnvelopes: 4, + retainedEnvelopes: 2, + }); + await until('"maxEnvelopes":4'); + await ingest(session, "three", "three"); + await ingest(session, "four", "four"); + await until('"message":"four"'); + expect(text.match(/"message":"one"/g)).toHaveLength(1); + const after = JSON.parse( + (await cli("event", "list", session.id)).stdout, + ).events; + expect(after).toHaveLength(4); + for (const event of before) expect(after).toContainEqual(event); + const rejected = await cli( + "session", + "retention", + session.id, + "--max-envelopes", + "3", + ); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain("4 retained envelopes"); + const uiUrl = new URL(session.streamUrl); + uiUrl.pathname = uiUrl.pathname.replace(/stream$/, "ui/retention"); + const updated = await fetch(uiUrl, { + method: "POST", + headers: { + Origin: "http://localhost:5173", + "Content-Type": "application/json", + }, + body: JSON.stringify({ maxEnvelopes: 6 }), + }); + expect(updated.status).toBe(200); + await until('"maxEnvelopes":6'); + expect( + JSON.parse((await cli("event", "list", session.id)).stdout).events, + ).toHaveLength(4); + const reset = new URL(session.streamUrl); + reset.pathname = reset.pathname.replace(/stream$/, "ui/reset"); + expect((await fetch(reset, { method: "POST" })).status).toBe(204); + expect( + (await cli("session", "retention", session.id, "--max-envelopes", "1")) + .exitCode, + ).toBe(0); + await ingest(session, "five", "five"); + await ingest(session, "six", "six"); + expect( + JSON.parse((await cli("event", "list", session.id)).stdout).events, + ).toMatchObject([{ eventId: "six" }]); + } finally { + await reader.cancel(); + } + for (const value of ["0", "1.5", "50001"]) { + const invalid = await cli( + "session", + "create", + "invalid", + "--max-envelopes", + value, + ); + expect(invalid.exitCode).not.toBe(0); + expect(invalid.stderr).toContain("Retention limit must be an integer"); + } + }, 30_000); + test("queries SDK event IDs from item headers and payloads", async () => { const session = await createSession("sdk IDs", await availablePort()); for (const [itemHeader, payload] of [ @@ -360,6 +464,8 @@ describe("Local daemon E2E", () => { "run", "--session", "wrapped", + "--max-envelopes", + "1000", "--host", "127.0.0.1", "--port", @@ -384,7 +490,7 @@ describe("Local daemon E2E", () => { expect(result.stdout).toContain("wrapped app"); const listed = await cli("session", "list"); expect(JSON.parse(listed.stdout)).toMatchObject({ - sessions: [{ label: "wrapped", state: "retained" }], + sessions: [{ label: "wrapped", state: "retained", maxEnvelopes: 1000 }], }); expect( (await cli("event", "view", "wrapped", "c".repeat(32))).stdout, @@ -568,6 +674,85 @@ describe("Local daemon E2E", () => { }); describe("Local foreground commands", () => { + test("run configures a plain receiver and explicit attachment preserves retained envelopes", async () => { + const { createLocalRetentionBuffer } = + await import("../../src/commands/local/retention-buffer.js"); + const { buildApp, tryListen } = + await import("../../src/commands/local/server.js"); + const retention = createLocalRetentionBuffer(2); + const receiver = await tryListen( + buildApp(retention.buffer, { uiActions: true, retention }), + 0, + "127.0.0.1", + ); + const script = join(getConfigDir(), "legacy-app.cjs"); + await writeFile( + script, + `fetch(new URL('/capabilities',process.env.SENTRY_SPOTLIGHT)).then(r=>r.json()).then(settings=>{console.log('limit='+settings.maxEnvelopes);if(settings.maxEnvelopes!==1000)process.exitCode=2;});`, + ); + try { + await fetch(`http://127.0.0.1:${receiver.port}/stream`, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: '{}\n{"type":"event"}\n{"message":"preserved"}', + }); + const before = retention.buffer.read({ all: true }); + for (const explicit of [true, false]) { + const result = await runCli( + [ + "local", + "run", + "--host", + "127.0.0.1", + "--port", + String(receiver.port), + ...(explicit ? ["--max-envelopes", "1000"] : []), + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("limit=1000"); + expect(retention.buffer.read({ all: true })).toEqual(before); + } + const created = await runCli( + [ + "local", + "run", + "--host", + "127.0.0.1", + "--port", + String(await availablePort()), + "--max-envelopes", + "1000", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + }, + }, + ); + expect(created.exitCode, created.stderr).toBe(0); + expect(created.stdout).toContain("limit=1000"); + } finally { + receiver.server.closeAllConnections(); + await new Promise((resolve) => + receiver.server.close(() => resolve()), + ); + } + }, 30000); + test("serve consumes the scoped stream and a second foreground daemon cannot replace discovery", async () => { const { spawn } = await import("node:child_process"); const { getCliCommand } = await import("../fixture.js"); @@ -600,6 +785,8 @@ describe("Local foreground commands", () => { "serve", "--session", "serve-e2e", + "--max-envelopes", + "1500", "--format", "json", ], @@ -648,6 +835,9 @@ describe("Local foreground commands", () => { const scoped = JSON.parse( (await cli("session", "view", id)).stdout, ) as Session; + expect( + JSON.parse((await cli("session", "retention", id)).stdout).maxEnvelopes, + ).toBe(1500); await ingest(scoped, "e".repeat(32), "serve session"); await expect .poll(() => stdout, { timeout: 5000 }) diff --git a/packages/cli/test/lib/credential-redaction.test.ts b/packages/cli/test/lib/credential-redaction.test.ts index a51783cda..23d992986 100644 --- a/packages/cli/test/lib/credential-redaction.test.ts +++ b/packages/cli/test/lib/credential-redaction.test.ts @@ -19,6 +19,7 @@ describe("credential redaction", () => { "/stream?cap=[REDACTED]&cap=[REDACTED]", ], ["/ui/reset?cap=SYNTHETIC_read-cap", "/ui/reset?cap=[REDACTED]"], + ["/ui/retention?cap=SYNTHETIC_read-cap", "/ui/retention?cap=[REDACTED]"], ["/ui/close?cap=SYNTHETIC_read-cap", "/ui/close?cap=[REDACTED]"], ])( "redacts Local URL capabilities in diagnostics: %s", From 9fe85bd06a84e4a241531427145276747e90f4ab Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 13 Oct 2026 16:54:40 -0700 Subject: [PATCH 08/10] fix(local): Reclaim daemon ownership within the guarded retry Claim ownership immediately after verified stale-file removal or disappearance, including on the final recovery attempt. Preserve the incomplete-owner grace period, replacement/live ownership checks and bounded retries. Co-Authored-By: GPT-6 Codex --- docs/cli/local-control-plane.md | 2 + packages/cli/src/commands/local/daemon.ts | 47 ++++++++++--------- .../commands/local/daemon-ownership.test.ts | 38 +++++++++++++++ packages/cli/test/e2e/local.test.ts | 27 ++++++++++- 4 files changed, 91 insertions(+), 23 deletions(-) diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index f51a3ce38..6ff70f6ee 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -43,6 +43,8 @@ process dies during that critical section, startup preserves the guard and repor its path; confirm the owning process has stopped before removing the guard and retrying. Startup waits through the listener retry window and preserves active startup locks. +Incomplete owner files receive a five-second grace period. Once safely reclaimed, +ownership is claimed under the same guard, including on the final retry. When a managed daemon owns the requested port, plain `local run` and `local serve` require `--session ` or a different `--port`; they cannot use its protected diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts index ad2d1a21c..ba684b0bf 100644 --- a/packages/cli/src/commands/local/daemon.ts +++ b/packages/cli/src/commands/local/daemon.ts @@ -223,30 +223,30 @@ export async function claimDaemonOwnership( directory: string, ): Promise<() => Promise> { const path = join(directory, "local-control-plane.owner"); + const tryClaim = async () => { + let lock: Awaited>; + try { + lock = await open(path, "wx", 0o600); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "EEXIST") + return undefined; + throw error; + } + try { + await lock.writeFile(String(process.pid), "utf8"); + } finally { + await lock.close(); + } + return () => + withDaemonOwnershipGuard(directory, async () => { + if ((await readOwnerPid(path)) === process.pid) + await rm(path, { force: true }); + }); + }; for (let attempt = 0; attempt < 40; attempt += 1) { const release = await withDaemonOwnershipGuard(directory, async () => { - try { - const lock = await open(path, "wx", 0o600); - try { - await lock.writeFile(String(process.pid), "utf8"); - } finally { - await lock.close(); - } - return () => - withDaemonOwnershipGuard(directory, async () => { - if ((await readOwnerPid(path)) === process.pid) - await rm(path, { force: true }); - }); - } catch (error) { - if ( - !( - error instanceof Error && - "code" in error && - error.code === "EEXIST" - ) - ) - throw error; - } + const claimed = await tryClaim(); + if (claimed) return claimed; try { const ownerText = await readFile(path, "utf8"); const owner = Number(ownerText); @@ -268,6 +268,8 @@ export async function claimDaemonOwnership( (!validPid || !isProcessRunning(owner)) ) { await rm(path, { force: true }); + // Claim while the guard is still held, even on the final recovery attempt. + return tryClaim(); } } } catch (error) { @@ -279,6 +281,7 @@ export async function claimDaemonOwnership( ) ) throw error; + return tryClaim(); } return undefined; }); diff --git a/packages/cli/test/commands/local/daemon-ownership.test.ts b/packages/cli/test/commands/local/daemon-ownership.test.ts index 8f516be83..682ed2ded 100644 --- a/packages/cli/test/commands/local/daemon-ownership.test.ts +++ b/packages/cli/test/commands/local/daemon-ownership.test.ts @@ -32,6 +32,44 @@ describe("Local daemon ownership", () => { }, ); + test("claims an invalid owner that expires on the last recovery inspection", async () => { + const fs = + await vi.importActual( + "node:fs/promises", + ); + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, ""); + const snapshot = await fs.stat(owner); + let inspection = 0; + // Hold the invalid file inside its grace period until the retry boundary. + vi.spyOn(Date, "now").mockImplementation( + () => snapshot.mtimeMs + (++inspection < 40 ? 5000 : 5001), + ); + const release = await claimDaemonOwnership(directory); + try { + expect(await fs.readFile(owner, "utf8")).toBe(String(process.pid)); + } finally { + await release(); + } + }); + + test("preserves a recent invalid owner while its grace period has not elapsed", async () => { + const fs = + await vi.importActual( + "node:fs/promises", + ); + const directory = getConfigDir(); + await mkdir(directory, { recursive: true }); + const owner = join(directory, "local-control-plane.owner"); + await writeFile(owner, ""); + const snapshot = await fs.stat(owner); + vi.spyOn(Date, "now").mockReturnValue(snapshot.mtimeMs + 5000); + await expect(claimDaemonOwnership(directory)).rejects.toThrow(/Timed out/); + expect(await fs.readFile(owner, "utf8")).toBe(""); + }); + test("preserves a replacement live owner when its old snapshot is dead", async () => { const directory = getConfigDir(); await mkdir(directory, { recursive: true }); diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index 8b6c1bc81..e195ef3f4 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -2,7 +2,7 @@ import { createServer } from "node:net"; import { createServer as createHttpServer } from "node:http"; import { rmSync } from "node:fs"; -import { readFile, stat, rm, writeFile } from "node:fs/promises"; +import { readFile, stat, rm, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { afterEach, describe, expect, test } from "vitest"; @@ -551,6 +551,31 @@ describe("Local daemon E2E", () => { ); }, 30_000); + test("recovers an expired incomplete owner and preserves live singleton ownership", async () => { + const owner = join(getConfigDir(), "local-control-plane.owner"); + await writeFile(owner, ""); + const expired = new Date(Date.now() - 6000); + await utimes(owner, expired, expired); + const first = await createSession("reclaimed-owner", await availablePort()); + const pid = await readFile(owner, "utf8"); + expect(Number(pid)).toBeGreaterThan(0); + await createSession("same-owner", await availablePort()); + expect(await readFile(owner, "utf8")).toBe(pid); + await ingest(first, "owner-recovery", "recovered receiver"); + expect( + JSON.parse((await cli("event", "list", first.id)).stdout), + ).toMatchObject({ + events: [{ eventId: "owner-recovery" }], + }); + expect( + (await cli("event", "view", first.id, "owner-recovery")).stdout, + ).toContain("recovered receiver"); + await cli("daemon", "stop"); + await expect(readFile(owner, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, 30000); + test("waits for the port retry window during concurrent daemon startup", async () => { const server = createServer(); await new Promise((resolve) => From 621e0f63700f7b68e537746dcba06fab62a82107 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 14 Oct 2026 05:48:35 -0700 Subject: [PATCH 09/10] fix(local): Join foreground daemon startup safely Wait for authenticated discovery when a foreground owner is still binding. A competing child exiting must not abort attachment to the winning daemon. Cover pre-existing and late ownership with regressions and real CLI E2E. Co-Authored-By: GPT-6 Codex --- docs/cli/local-control-plane.md | 3 + packages/cli/src/commands/local/daemon.ts | 44 +++++---- .../commands/local/daemon-startup.test.ts | 91 ++++++++++++++++++ packages/cli/test/e2e/local.test.ts | 92 +++++++++++++++++++ 4 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 packages/cli/test/commands/local/daemon-startup.test.ts diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index 6ff70f6ee..442bca691 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -43,6 +43,9 @@ process dies during that critical section, startup preserves the guard and repor its path; confirm the owning process has stopped before removing the guard and retrying. Startup waits through the listener retry window and preserves active startup locks. +Concurrent foreground and managed starts join the same daemon: session creation +waits for a live foreground owner to publish authenticated discovery, even if a +competing child exits before that owner finishes binding. Incomplete owner files receive a five-second grace period. Once safely reclaimed, ownership is claimed under the same guard, including on the final retry. diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts index ba684b0bf..d30237e54 100644 --- a/packages/cli/src/commands/local/daemon.ts +++ b/packages/cli/src/commands/local/daemon.ts @@ -389,10 +389,16 @@ async function waitForLocalDaemon( ): Promise { while (Date.now() < deadline) { await sleep(125); - const error = startupError?.(); - if (error) throw error; const state = await discoverLocalDaemon(true); if (state) return state; + const error = startupError?.(); + if (error) { + const owner = await readOwnerPid( + join(getConfigDir(), "local-control-plane.owner"), + ); + // A foreground owner can win after our spawn decision but still be binding. + if (!isProcessRunning(owner)) throw error; + } } } @@ -441,20 +447,26 @@ export async function ensureLocalDaemon({ return runningAfterLock; } let startupError: Error | undefined; - child = spawn(process.execPath, buildLocalDaemonArgs(host, port), { - detached: true, - env: process.env, - stdio: "ignore", - }); - child.once("error", (error) => { - startupError = error; - }); - child.once("exit", (code, signal) => { - startupError = new Error( - `Local daemon exited before startup (status ${code ?? signal}). Check that the requested port is available.`, - ); - }); - child.unref(); + const owner = await readOwnerPid( + join(directory, "local-control-plane.owner"), + ); + if (!isProcessRunning(owner)) { + child = spawn(process.execPath, buildLocalDaemonArgs(host, port), { + detached: true, + env: process.env, + stdio: "ignore", + }); + child.once("error", (error) => { + startupError = error; + }); + child.once("exit", (code, signal) => { + startupError = new Error( + `Local daemon exited before startup (status ${code ?? signal}). Check that the requested port is available.`, + ); + }); + child.unref(); + } + const state = await waitForLocalDaemon( Date.now() + LOCAL_DAEMON_START_TIMEOUT_MS, () => startupError, diff --git a/packages/cli/test/commands/local/daemon-startup.test.ts b/packages/cli/test/commands/local/daemon-startup.test.ts new file mode 100644 index 000000000..2e76ac7ae --- /dev/null +++ b/packages/cli/test/commands/local/daemon-startup.test.ts @@ -0,0 +1,91 @@ +/** Startup joins a live foreground owner while authenticated discovery is pending. */ +import { type ChildProcess, spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { readFile } from "node:fs/promises"; +import { writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { afterEach, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../../helpers.js"; +import { + ensureLocalDaemon, + writeLocalDaemonState, +} from "../../../src/commands/local/daemon.js"; + +vi.mock("node:child_process", async (importOriginal) => { + const cp = await importOriginal(); + return { ...cp, spawn: vi.fn() }; +}); +const getConfigDir = useTestConfigDir("local-foreground-startup-"); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.mocked(spawn).mockReset(); +}); + +test.each(["before startup", "after spawn"])( + "joins live foreground ownership appearing %s without surfacing the losing child's exit", + async (stage) => { + vi.stubGlobal( + "fetch", + (globalThis as { __originalFetch: typeof fetch }).__originalFetch, + ); + const server = createServer((request, response) => { + response.statusCode = + request.url === "/_local/v1/sessions" && + request.headers.authorization === "Bearer foreground-token" + ? 200 + : 401; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ sessions: [] })); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const directory = getConfigDir(); + const owner = join(directory, "local-control-plane.owner"); + const state = { + host: "127.0.0.1", + port: address.port, + pid: process.pid, + token: "foreground-token", + version: 1, + }; + let publishing: Promise | undefined; + const beginForeground = () => { + writeFileSync(owner, String(process.pid)); + publishing = sleep(300).then(() => + writeLocalDaemonState(directory, state), + ); + }; + if (stage === "before startup") beginForeground(); + vi.mocked(spawn).mockImplementation(() => { + if (stage === "after spawn") beginForeground(); + const child = Object.assign(new EventEmitter(), { + kill: vi.fn(() => true), + unref: vi.fn(), + }) as unknown as ChildProcess; + queueMicrotask(() => child.emit("exit", 1, null)); + return child; + }); + try { + await expect( + ensureLocalDaemon({ host: "127.0.0.1", port: address.port }), + ).resolves.toEqual(state); + expect(await readFile(owner, "utf8")).toBe(String(process.pid)); + await expect( + readFile(join(directory, "local-control-plane.lock"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await publishing; + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } + }, +); diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index e195ef3f4..895697ff0 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -576,6 +576,98 @@ describe("Local daemon E2E", () => { }); }, 30000); + test("attaches to a foreground daemon that owns startup before discovery is published", async () => { + const { spawn } = await import("node:child_process"); + const { getCliCommand } = await import("../fixture.js"); + const reserve = createServer(); + await new Promise((resolve) => + reserve.listen(0, "127.0.0.1", resolve), + ); + const address = reserve.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const [executable, ...prefix] = getCliCommand(); + if (!executable) throw new Error("Missing CLI executable"); + const foreground = spawn( + executable, + [ + ...prefix, + "local", + "daemon", + "start", + "--foreground", + "--host", + "127.0.0.1", + "--port", + String(address.port), + ], + { + cwd: join(import.meta.dirname, "../.."), + env: { + ...process.env, + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + stdio: ["ignore", "ignore", "pipe"], + }, + ); + let stderr = ""; + foreground.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const closed = new Promise((resolve, reject) => { + foreground.once("close", () => resolve()); + foreground.once("error", reject); + }); + let pending: ReturnType | undefined; + try { + const owner = join(getConfigDir(), "local-control-plane.owner"); + await expect + .poll( + async () => Number(await readFile(owner, "utf8").catch(() => "0")), + { timeout: 10000 }, + ) + .toBe(foreground.pid); + await expect( + readFile(join(getConfigDir(), "local-control-plane.json"), "utf8"), + ).rejects.toMatchObject({ code: "ENOENT" }); + pending = cli( + "session", + "create", + "foreground-race", + "--host", + "127.0.0.1", + "--port", + String(address.port), + ); + // Keep foreground startup inside the real listener retry window while the client joins it. + await sleep(6500); + await new Promise((resolve) => reserve.close(() => resolve())); + const result = await pending; + expect(result.exitCode, result.stderr).toBe(0); + const session = JSON.parse(result.stdout) as Session; + const state = JSON.parse( + await readFile( + join(getConfigDir(), "local-control-plane.json"), + "utf8", + ), + ) as { pid: number }; + expect(state.pid, stderr).toBe(foreground.pid); + await ingest(session, "foreground-owner", "foreground capture"); + expect( + (await cli("event", "view", session.id, "foreground-owner")).stdout, + ).toContain("foreground capture"); + } finally { + reserve.close(); + await cli("daemon", "stop"); + foreground.kill("SIGTERM"); + await closed; + await pending; + } + }, 30000); + test("waits for the port retry window during concurrent daemon startup", async () => { const server = createServer(); await new Promise((resolve) => From 3c92a50b6462c813f5931bf1527835c1df9ba1fb Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 14 Oct 2026 07:24:15 -0700 Subject: [PATCH 10/10] fix(local): Reject sessions in verification mode Validate --session with --verify before resolving or starting a command. Co-Authored-By: GPT-6 Codex --- docs/cli/local-control-plane.md | 4 +++ packages/cli/src/commands/local/run.ts | 6 ++++ packages/cli/test/commands/local/run.test.ts | 22 ++++++++++++ packages/cli/test/e2e/local.test.ts | 37 ++++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md index 442bca691..8f68d2458 100644 --- a/docs/cli/local-control-plane.md +++ b/docs/cli/local-control-plane.md @@ -18,6 +18,10 @@ sentry local daemon status sentry local daemon stop ``` +`local run --verify` uses an ephemeral receiver and cannot be combined with +`--session` or `--open`. Invalid combinations are rejected before a receiver or +application is started. Omit `--verify` to capture into a retained daemon session. + `local session create --json` returns distinct `ingestUrl` and `streamUrl` capabilities for SDK ingestion and viewer reads. The ingest capability lives in the URL path so SDK transports that omit query parameters still deliver events. diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 770bd8ac9..c5c59e868 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -478,6 +478,12 @@ export const runCommand = buildCommand({ if (flags.open && flags.verify) { throw new ValidationError("--open cannot be used with --verify.", "open"); } + if (flags.session !== undefined && flags.verify) { + throw new ValidationError( + "--session cannot be used with --verify.", + "session", + ); + } validateOpenHost(flags.open, flags.host); const stripped = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs; diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index 9f432ba2f..4aae718c2 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -67,6 +67,7 @@ type RunFunc = ( attributes?: boolean; filter?: ("error" | "transaction" | "log" | "ai")[]; open?: boolean; + session?: string; }, ...args: string[] ) => Promise; @@ -212,6 +213,27 @@ describe("sentry local run", () => { }); }); + test("rejects --session with --verify before starting a receiver", async () => { + const func = (await runCommand.loader()) as unknown as RunFunc; + + await expect( + func.call( + makeContext(), + { + port: 0, + host: "localhost", + verify: true, + timeout: 0, + session: "verification", + }, + "true", + ), + ).rejects.toMatchObject({ + field: "session", + message: "--session cannot be used with --verify.", + }); + }); + test("rejects a non-loopback --open host before starting a receiver", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts index 895697ff0..5919f65ac 100644 --- a/packages/cli/test/e2e/local.test.ts +++ b/packages/cli/test/e2e/local.test.ts @@ -791,6 +791,43 @@ describe("Local daemon E2E", () => { }); describe("Local foreground commands", () => { + test("rejects verification with a retained session before running the application", async () => { + const marker = join(getConfigDir(), "verify-session-child-ran"); + const script = join(getConfigDir(), "verify-session-child.cjs"); + await writeFile( + script, + `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran");`, + ); + const result = await runCli( + [ + "local", + "run", + "--session", + "verification", + "--verify", + "--", + process.execPath, + script, + ], + { + env: { + SENTRY_CONFIG_DIR: getConfigDir(), + SENTRY_CLI_NO_TELEMETRY: "1", + SENTRY_AUTH_TOKEN: "", + SENTRY_TOKEN: "", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--session cannot be used with --verify."); + await expect(readFile(marker, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(JSON.parse((await cli("daemon", "status")).stdout)).toEqual({ + daemon: null, + }); + }); + test("run configures a plain receiver and explicit attachment preserves retained envelopes", async () => { const { createLocalRetentionBuffer } = await import("../../src/commands/local/retention-buffer.js");