diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3d28b9de0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# SDK envelope item lengths count raw bytes. Preserve fixture bytes on every OS. +apps/local/fixtures/*.txt -text diff --git a/apps/local/README.md b/apps/local/README.md new file mode 100644 index 000000000..2a362420f --- /dev/null +++ b/apps/local/README.md @@ -0,0 +1,95 @@ +# Sentry Local viewer + +The viewer connects to a Local receiver and inspects captured SDK envelopes. +Start the receiver and UI in separate terminals from the repository root: + +```sh +pnpm --filter sentry run cli local serve +pnpm --filter local dev +``` + +Open the URL printed by Vite and connect to `http://localhost:8969`. +The receiver can also be a named Local session or an explicitly selected remote +receiver. Capture lifecycle and retention are managed through the existing +Local controls. + +## Replay example captures + +With a receiver running, load all bundled examples: + +```sh +pnpm --filter local sample +``` + +The examples cover correlated errors/logs/traces, repeated database queries, +resource loading, Web Vitals, an AI conversation and tool execution, feedback, +SDK reports, legacy and continuous profiles, standalone span-v2 telemetry, +and text/JSON/HTML/image attachments. All names and content are synthetic. +They follow the SDK envelope shapes used in Spotlight's fixture corpus. + +Replay any directory of raw SDK envelopes, including Spotlight's `_fixtures` +directory, recursively: + +```sh +pnpm --filter local sample /path/to/spotlight/packages/spotlight/_fixtures \ + --endpoint http://localhost:8969/stream --compression gzip + +pnpm --filter local sample --interval 200 --repeat 3 +``` + +`--fixtures` is an alternative to the positional path. `--endpoint` takes +precedence over `SENTRY_SPOTLIGHT`; otherwise the default is +`http://localhost:8969/stream`. A named session requires its ingestion endpoint, +not its capability-protected viewer stream URL. + +The loader sends `.txt`, `.bin`, and `.envelope` files in sorted order, preserving +raw bytes and captured timestamps. Bundled byte-counted envelopes disable Git +line-ending conversion; preserve the original bytes when checking out external +captures as well. `--compression` supports `none`, `gzip`, and +`br`; `--interval` is milliseconds between envelopes and `--repeat` is a finite +number of passes. It stops with a nonzero exit status when a request is rejected +or times out. The command does not clear existing capture history. Raise the +receiver's envelope retention limit when replaying a large corpus. + +## Investigation views + +- Errors include exception causes, captured source lines and local variables, + breadcrumbs, request/context data, tags, modules, VS Code links for absolute + local paths, and a copyable Markdown summary for AI assistants. +- AI inspection reads standard `gen_ai.*` and Vercel AI fields from both embedded + transaction spans and standalone spans. Prompts, responses, token usage and + tool arguments/results appear when the SDK captured them. Child AI steps stay + linked to their parent interaction. +- Queries and resources group exact operation/description pairs. Sort by count, + duration or captured resource size and open an execution in its trace. +- Web Vitals groups visits by page and shows individual captured measurements. + LCP, INP, CLS, FCP and TTFB use the [Web Vitals thresholds](https://web.dev/articles/vitals); + FID is labelled as a legacy metric. Local samples are not field percentiles. +- Profiles support Sentry sample/stack/frame payloads in legacy `profile` items + and continuous `profile_chunk` items. Select a thread, compare inclusive/self + time and samples, and explore flame, treemap or sunburst views. Observed time + uses adjacent samples on each thread; no duration is invented for the last + sample. Charts limit displayed depth/nodes for responsiveness; JSON preserves + the original payload. +- SDKs lists observed names, versions, platforms and last-seen metadata, followed + by session/client reports. Feedback links to associated errors in the capture. +- Attachments show safe text/JSON/image/video previews and downloads. Captured + HTML, SVG and code remain inert; the viewer never executes them or fetches + captured resource URLs. + +Use severity/logger filters and sorting in event/log views. The Columns control +chooses which metadata appears beside each message. In a trace, search spans, +show only matches, collapse branches, or adjust the tree width. + +## Tests + +```sh +pnpm --filter local test +pnpm --filter local typecheck +pnpm --filter local build +``` + +Integration tests start an actual receiver, replay the bundled capture through +HTTP/SSE, then navigate the analysis and inspector views. Replay tests cover +binary bytes, gzip/Brotli, recursive directories, repeated passes and rejected +requests. Unknown payloads remain inspectable as JSON. diff --git a/apps/local/fixtures/01-checkout.txt b/apps/local/fixtures/01-checkout.txt new file mode 100644 index 000000000..c8ed7a050 --- /dev/null +++ b/apps/local/fixtures/01-checkout.txt @@ -0,0 +1,3 @@ +{"event_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "transaction", "length": 2554} +{"event_id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","type":"transaction","transaction":"GET /checkout","platform":"javascript","start_timestamp":1730000000,"timestamp":1730000002,"contexts":{"trace":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","span_id":"1111111111111111","op":"pageload"},"profile":{"profile_id":"ffffffffffffffffffffffffffffffff","profiler_id":"demo-profiler"}},"measurements":{"lcp":{"value":3200,"unit":"millisecond"},"inp":{"value":125,"unit":"millisecond"},"cls":{"value":0.04,"unit":"none"},"fcp":{"value":900,"unit":"millisecond"},"ttfb":{"value":180,"unit":"millisecond"}},"spans":[{"span_id":"2222222222222222","parent_span_id":"1111111111111111","op":"db.sql.query","description":"SELECT id, total FROM orders WHERE user_id = ?","start_timestamp":1730000000.1,"timestamp":1730000000.3,"data":{"db.system":"postgresql"}},{"span_id":"3333333333333333","parent_span_id":"1111111111111111","op":"db.sql.query","description":"SELECT id, total FROM orders WHERE user_id = ?","start_timestamp":1730000000.4,"timestamp":1730000000.8,"data":{"db.system":"postgresql"}},{"span_id":"4444444444444444","parent_span_id":"1111111111111111","op":"resource.script","description":"http://localhost:3000/checkout.js","start_timestamp":1730000000.01,"timestamp":1730000000.2,"data":{"http.response_content_length":20480}},{"span_id":"5555555555555555","parent_span_id":"1111111111111111","op":"resource.img","description":"http://localhost:3000/cart.png","start_timestamp":1730000000.02,"timestamp":1730000000.4,"data":{"http.response_content_length":8192}},{"span_id":"6666666666666666","parent_span_id":"1111111111111111","op":"gen_ai.chat","description":"Order assistant","start_timestamp":1730000000.8,"timestamp":1730000001.6,"data":{"gen_ai.request.model":"demo-model","gen_ai.system":"demo-provider","gen_ai.usage.input_tokens":84,"gen_ai.usage.output_tokens":26,"gen_ai.system_instructions":"Help the user find their order.","gen_ai.request.messages":"[{\"role\": \"user\", \"content\": \"Where is order 42?\"}]","gen_ai.response.text":"Order 42 ships tomorrow.","gen_ai.response.tool_calls":"[{\"id\": \"call-demo\", \"name\": \"lookup_order\", \"arguments\": {\"order_id\": 42}}]"}},{"span_id":"7777777777777777","parent_span_id":"6666666666666666","op":"gen_ai.execute_tool","description":"lookup_order","start_timestamp":1730000001,"timestamp":1730000001.2,"data":{"gen_ai.tool.name":"lookup_order","gen_ai.tool.call.id":"call-demo","gen_ai.tool.input":"{\"order_id\": 42}","gen_ai.tool.output":"{\"status\": \"shipping tomorrow\"}"}}]} diff --git a/apps/local/fixtures/02-error.txt b/apps/local/fixtures/02-error.txt new file mode 100644 index 000000000..53d35a1f1 --- /dev/null +++ b/apps/local/fixtures/02-error.txt @@ -0,0 +1,3 @@ +{"event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "event", "length": 1131} +{"event_id":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee","level":"error","platform":"javascript","timestamp":1730000001.8,"contexts":{"trace":{"trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","span_id":"2222222222222222"},"browser":{"name":"Firefox","version":"130"},"runtime":{"name":"node","version":"22"}},"request":{"method":"POST","url":"http://localhost:3000/checkout","headers":{"content-type":"application/json"}},"tags":{"environment":"local","feature":"checkout"},"extra":{"cart_items":2},"modules":{"checkout-demo":"1.0.0"},"breadcrumbs":[{"timestamp":1730000000.1,"category":"navigation","message":"Opened checkout"},{"timestamp":1730000001.6,"category":"http","message":"POST /checkout returned 500","level":"error","data":{"status_code":500}}],"exception":{"values":[{"type":"TypeError","value":"Payment method is missing","stacktrace":{"frames":[{"function":"submitOrder","abs_path":"/app/checkout.ts","filename":"checkout.ts","lineno":42,"colno":7,"context_line":"throw new TypeError(\"Payment method is missing\");","pre_context":["if (!paymentMethod) {"],"post_context":["}"],"vars":{"cartItems":2,"paymentMethod":null}}]}}]}} diff --git a/apps/local/fixtures/03-logs.txt b/apps/local/fixtures/03-logs.txt new file mode 100644 index 000000000..60129b427 --- /dev/null +++ b/apps/local/fixtures/03-logs.txt @@ -0,0 +1,3 @@ +{"event_id": "b738e253681bd7b552a2beec92fc38bf", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "log", "length": 495} +{"items":[{"timestamp":1730000000.5,"body":"Loaded two orders","level":"info","trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","span_id":"2222222222222222","attributes":{"sentry.logger.name":{"type":"string","value":"orders"},"order.count":{"type":"integer","value":2}}},{"timestamp":1730000001.8,"body":"Payment validation failed","level":"error","trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","span_id":"2222222222222222","attributes":{"sentry.logger.name":{"type":"string","value":"payments"}}}]} diff --git a/apps/local/fixtures/04-feedback.txt b/apps/local/fixtures/04-feedback.txt new file mode 100644 index 000000000..895c020de --- /dev/null +++ b/apps/local/fixtures/04-feedback.txt @@ -0,0 +1,3 @@ +{"event_id": "9dd5c245c456bb1da2b82673d5b3a9d4", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "feedback", "length": 291} +{"timestamp":1730000002.1,"contexts":{"feedback":{"message":"The checkout button did not complete my order.","name":"Example User","contact_email":"user@example.com","url":"http://localhost:3000/checkout","source":"feedback_widget","associated_event_id":"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"}}} diff --git a/apps/local/fixtures/05-profile.txt b/apps/local/fixtures/05-profile.txt new file mode 100644 index 000000000..46ad851fc --- /dev/null +++ b/apps/local/fixtures/05-profile.txt @@ -0,0 +1,3 @@ +{"event_id": "ffffffffffffffffffffffffffffffff", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "profile", "length": 1118} +{"event_id":"ffffffffffffffffffffffffffffffff","platform":"javascript","transactions":[{"id":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"profile":{"frames":[{"function":"main","filename":"checkout.ts","in_app":true},{"function":"submitOrder","filename":"checkout.ts","lineno":40,"in_app":true},{"function":"queryDatabase","filename":"db.ts","lineno":12,"in_app":true},{"function":"read","module":"node:fs","in_app":false}],"stacks":[[0],[1,0],[2,1,0],[3,2,1,0]],"samples":[{"elapsed_since_start_ns":"0","thread_id":"main","stack_id":0},{"elapsed_since_start_ns":"10000000","thread_id":"main","stack_id":1},{"elapsed_since_start_ns":"20000000","thread_id":"main","stack_id":2},{"elapsed_since_start_ns":"30000000","thread_id":"main","stack_id":3},{"elapsed_since_start_ns":"40000000","thread_id":"main","stack_id":2},{"elapsed_since_start_ns":"50000000","thread_id":"main","stack_id":2},{"elapsed_since_start_ns":"60000000","thread_id":"main","stack_id":1},{"elapsed_since_start_ns":"70000000","thread_id":"main","stack_id":0}],"thread_metadata":{"main":{"name":"Main thread"}}}} diff --git a/apps/local/fixtures/06-profile-chunk.txt b/apps/local/fixtures/06-profile-chunk.txt new file mode 100644 index 000000000..652c8fae0 --- /dev/null +++ b/apps/local/fixtures/06-profile-chunk.txt @@ -0,0 +1,3 @@ +{"event_id": "57c18c15911867b6a10701947b1a9a6c", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "profile_chunk", "length": 1208} +{"chunk_id":"cccccccccccccccccccccccccccccccc","profiler_id":"demo-profiler","platform":"javascript","profile":{"frames":[{"function":"main","filename":"checkout.ts","in_app":true},{"function":"submitOrder","filename":"checkout.ts","lineno":40,"in_app":true},{"function":"queryDatabase","filename":"db.ts","lineno":12,"in_app":true},{"function":"read","module":"node:fs","in_app":false}],"stacks":[[0],[1,0],[2,1,0],[3,2,1,0]],"samples":[{"timestamp":1730000000.0,"thread_id":"main","stack_id":0},{"timestamp":1730000000.01,"thread_id":"main","stack_id":1},{"timestamp":1730000000.02,"thread_id":"main","stack_id":2},{"timestamp":1730000000.03,"thread_id":"main","stack_id":3},{"timestamp":1730000000.04,"thread_id":"main","stack_id":0},{"timestamp":1730000000.05,"thread_id":"main","stack_id":1},{"timestamp":1730000000.06,"thread_id":"main","stack_id":2},{"timestamp":1730000000.07,"thread_id":"main","stack_id":3},{"timestamp":1730000000.08,"thread_id":"main","stack_id":0},{"timestamp":1730000000.09,"thread_id":"main","stack_id":1},{"timestamp":1730000000.1,"thread_id":"main","stack_id":2},{"timestamp":1730000000.11,"thread_id":"main","stack_id":3}],"thread_metadata":{"main":{"name":"Main thread"}}}} diff --git a/apps/local/fixtures/07-sdk-reports.txt b/apps/local/fixtures/07-sdk-reports.txt new file mode 100644 index 000000000..f56bd5178 --- /dev/null +++ b/apps/local/fixtures/07-sdk-reports.txt @@ -0,0 +1,5 @@ +{"event_id": "199741d5b434c4a604635de1884cec77", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "session", "length": 100} +{"sid":"dddddddddddddddddddddddddddddddd","started":"2024-10-27T03:33:20Z","status":"ok","errors":0} +{"type": "client_report", "length": 108} +{"timestamp":1730000000,"discarded_events":[{"reason":"sample_rate","category":"transaction","quantity":3}]} diff --git a/apps/local/fixtures/08-attachments.bin b/apps/local/fixtures/08-attachments.bin new file mode 100644 index 000000000..88f92a2d5 Binary files /dev/null and b/apps/local/fixtures/08-attachments.bin differ diff --git a/apps/local/fixtures/09-span-v2.txt b/apps/local/fixtures/09-span-v2.txt new file mode 100644 index 000000000..a1c707b47 --- /dev/null +++ b/apps/local/fixtures/09-span-v2.txt @@ -0,0 +1,3 @@ +{"event_id": "15e02eb45f3d4a0d7f566cf8a189f0ea", "sent_at": "2024-10-27T03:33:20Z", "sdk": {"name": "sentry.javascript.node", "version": "10.0.0"}} +{"type": "span", "length": 352} +{"version":2,"items":[{"span_id":"8888888888888888","trace_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","parent_span_id":"1111111111111111","name":"SELECT count(*) FROM orders","start_timestamp":1730000001.5,"end_timestamp":1730000001.7,"attributes":{"sentry.op":{"type":"string","value":"db.sql.query"},"db.system":{"type":"string","value":"postgresql"}}}]} diff --git a/apps/local/package.json b/apps/local/package.json index b17767ac1..eab1c58ea 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -10,14 +10,18 @@ "preview": "vite preview", "test": "vitest run", "test:integration": "vitest run src/App.integration.test.tsx", - "typecheck": "tsc -b" + "typecheck": "tsc -b", + "sample": "tsx scripts/replay.ts" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "dependencies": { + "@hookform/resolvers": "^5.9.1", "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-popover": "^1.3.1", "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tooltip": "^1.4.1", "@shikijs/core": "^3.23.0", "@shikijs/engine-javascript": "^3.23.0", "@shikijs/langs": "^3.23.0", @@ -33,10 +37,12 @@ "nuqs": "^2.10.1", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-hook-form": "^7.90.0", "react-router": "^7.18.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "valibot": "catalog:" }, "devDependencies": { "@spotlightjs/spotlight": "^4.11.7", diff --git a/apps/local/public/sentry-cli-light.svg b/apps/local/public/sentry-cli-light.svg deleted file mode 100644 index e124651b6..000000000 --- a/apps/local/public/sentry-cli-light.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/local/public/sentry-cli.svg b/apps/local/public/sentry-cli.svg deleted file mode 100644 index 74e2b4731..000000000 --- a/apps/local/public/sentry-cli.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/local/scripts/fixture-checkout.test.ts b/apps/local/scripts/fixture-checkout.test.ts new file mode 100644 index 000000000..7f77f4c78 --- /dev/null +++ b/apps/local/scripts/fixture-checkout.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment node +import { execFileSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +test("Windows Git checkout preserves byte-counted replay envelopes", async () => { + const sandbox = await mkdtemp(join(tmpdir(), "local-fixture-checkout-")); + try { + const fixturePath = "apps/local/fixtures/01-checkout.txt"; + await mkdir(join(sandbox, "apps/local/fixtures"), { recursive: true }); + const expected = await readFile(join(repository, fixturePath)); + await writeFile(join(sandbox, fixturePath), expected); + try { + await copyFile( + join(repository, ".gitattributes"), + join(sandbox, ".gitattributes"), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + execFileSync("git", ["init", "--quiet"], { cwd: sandbox }); + execFileSync("git", ["-c", "core.autocrlf=false", "add", "."], { + cwd: sandbox, + }); + const checkout = join(sandbox, "checkout"); + execFileSync( + "git", + [ + "-c", + "core.autocrlf=true", + "checkout-index", + "--all", + `--prefix=${checkout.replaceAll("\\", "/")}/`, + ], + { cwd: sandbox }, + ); + const actual = await readFile(join(checkout, fixturePath)); + expect(actual.byteLength).toBe(expected.byteLength); + expect(Buffer.compare(actual, expected)).toBe(0); + } finally { + await rm(sandbox, { recursive: true, force: true }); + } +}); diff --git a/apps/local/scripts/fixtures.test.ts b/apps/local/scripts/fixtures.test.ts new file mode 100644 index 000000000..67eb13325 --- /dev/null +++ b/apps/local/scripts/fixtures.test.ts @@ -0,0 +1,40 @@ +// @vitest-environment node +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { defaultFixtures } from "./replay.ts"; + +test("bundled envelope item lengths match their raw payload boundaries", async () => { + const files = (await readdir(defaultFixtures)).filter((name) => + /\.(txt|bin)$/.test(name), + ); + expect(files.length).toBeGreaterThanOrEqual(9); + for (const file of files) { + const data = await readFile(join(defaultFixtures, file)); + let cursor = data.indexOf(10) + 1; + expect(cursor, file).toBeGreaterThan(0); + while (cursor < data.length) { + const headerEnd = data.indexOf(10, cursor); + expect(headerEnd, file).toBeGreaterThanOrEqual(cursor); + const header = JSON.parse( + data.subarray(cursor, headerEnd).toString("utf8"), + ) as { type: string; length: number }; + expect( + Number.isSafeInteger(header.length) && header.length >= 0, + file, + ).toBe(true); + const payloadStart = headerEnd + 1; + const payload = data.subarray(payloadStart, payloadStart + header.length); + expect(payload.byteLength, file).toBe(header.length); + if (header.type !== "attachment") + expect(() => JSON.parse(payload.toString("utf8")), file).not.toThrow(); + cursor = payloadStart + header.length; + expect( + data[cursor], + `${file}: ${header.type} payload must end at its LF delimiter`, + ).toBe(10); + cursor++; + } + expect(cursor, file).toBe(data.length); + } +}); diff --git a/apps/local/scripts/replay.test.ts b/apps/local/scripts/replay.test.ts new file mode 100644 index 000000000..dee35dba4 --- /dev/null +++ b/apps/local/scripts/replay.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment node +import { createServer, type Server, type RequestListener } from "node:http"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { brotliDecompressSync, gunzipSync } from "node:zlib"; +import { afterEach, expect, test } from "vitest"; +import { replayEnvelopes } from "./replay.ts"; +const servers: Server[] = [], + dirs: string[] = []; +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ), + ); + await Promise.all( + dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); +async function endpoint(handler: RequestListener) { + const server = createServer(handler); + servers.push(server); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("No port"); + return `http://127.0.0.1:${address.port}/stream`; +} +async function fixtures() { + const dir = await mkdtemp(join(tmpdir(), "local-replay-")); + dirs.push(dir); + return dir; +} +test.each(["none", "gzip", "br"] as const)( + "recursively replays binary fixtures with %s compression and repeat", + async (compression) => { + const dir = await fixtures(); + await mkdir(join(dir, "nested")); + const bytes = Buffer.from([0, 255, 10, 42]); + await writeFile(join(dir, "nested", "test.bin"), bytes); + await writeFile(join(dir, "README.md"), "not an envelope"); + const received: Buffer[] = []; + const target = await endpoint((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("end", () => { + const body = Buffer.concat(chunks); + received.push( + compression === "gzip" + ? gunzipSync(body) + : compression === "br" + ? brotliDecompressSync(body) + : body, + ); + response.writeHead(204).end(); + }); + }); + expect( + await replayEnvelopes({ + endpoint: target, + fixtures: dir, + repeat: 2, + compression, + }), + ).toBe(2); + expect(received).toEqual([bytes, bytes]); + }, +); +test("reports rejected envelopes and empty fixture directories", async () => { + const dir = await fixtures(); + await expect( + replayEnvelopes({ endpoint: "http://localhost:1/stream", fixtures: dir }), + ).rejects.toThrow("No envelope files"); + await writeFile(join(dir, "test.txt"), "{}"); + const target = await endpoint((_request, response) => + response.writeHead(422).end(), + ); + await expect( + replayEnvelopes({ endpoint: target, fixtures: dir }), + ).rejects.toThrow("HTTP 422"); +}); diff --git a/apps/local/scripts/replay.ts b/apps/local/scripts/replay.ts new file mode 100644 index 000000000..95fb198d9 --- /dev/null +++ b/apps/local/scripts/replay.ts @@ -0,0 +1,152 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { dirname, extname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { brotliCompressSync, gzipSync } from "node:zlib"; +import { setTimeout as delay } from "node:timers/promises"; + +export const defaultFixtures = resolve( + dirname(fileURLToPath(import.meta.url)), + "../fixtures", +); +export type ReplayOptions = { + endpoint: string; + fixtures?: string; + compression?: "none" | "gzip" | "br"; + interval?: number; + repeat?: number; + onSent?: (file: string, index: number) => void; +}; +async function envelopeFiles(path: string): Promise { + const info = await stat(path); + if (info.isFile()) return [path]; + const entries = await readdir(path, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const child = join(path, entry.name); + if (entry.isDirectory()) files.push(...(await envelopeFiles(child))); + else if ( + entry.isFile() && + [".txt", ".bin", ".envelope"].includes(extname(entry.name)) + ) + files.push(child); + } + return files; +} +/** Replay raw bytes through the public receiver endpoint; never bypass ingestion. */ +export async function replayEnvelopes({ + endpoint, + fixtures = defaultFixtures, + compression = "none", + interval = 0, + repeat = 1, + onSent, +}: ReplayOptions): Promise { + const target = new URL(endpoint); + if ( + !["http:", "https:"].includes(target.protocol) || + target.username || + target.password || + target.hash + ) + throw new Error( + "Endpoint must be an HTTP(S) receiver URL without user information or a fragment.", + ); + if ( + !Number.isSafeInteger(repeat) || + repeat < 1 || + !Number.isSafeInteger(interval) || + interval < 0 + ) + throw new Error( + "Repeat must be a positive integer and interval a non-negative integer in milliseconds.", + ); + if (!["none", "gzip", "br"].includes(compression)) + throw new Error("Compression must be none, gzip, or br."); + const files = await envelopeFiles(resolve(fixtures)); + if (!files.length) + throw new Error("No envelope files found (.txt, .bin, .envelope)."); + let sent = 0; + for (let cycle = 0; cycle < repeat; cycle++) + for (const file of files) { + if (sent && interval) await delay(interval); + const raw = await readFile(file); + const body = + compression === "gzip" + ? gzipSync(raw) + : compression === "br" + ? brotliCompressSync(raw) + : raw; + const response = await fetch(target, { + method: "POST", + headers: { + "Content-Type": "application/x-sentry-envelope", + ...(compression === "none" + ? {} + : { "Content-Encoding": compression }), + }, + body: new Uint8Array(body), + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + await response.body?.cancel(); + if (!response.ok) + throw new Error(`Receiver rejected ${file}: HTTP ${response.status}`); + sent++; + onSent?.(file, sent); + } + return sent; +} +async function main() { + const { values, positionals } = parseArgs({ + options: { + endpoint: { type: "string" }, + fixtures: { type: "string" }, + compression: { type: "string", default: "none" }, + interval: { type: "string", default: "0" }, + repeat: { type: "string", default: "1" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + if (values.help) { + console.log( + "Replay envelopes: pnpm --filter local sample [directory-or-file] [--endpoint http://localhost:8969/stream] [--compression none|gzip|br] [--interval 200] [--repeat 3]\nDefaults to bundled examples. SENTRY_SPOTLIGHT may also select the endpoint.", + ); + return; + } + const compression = values.compression; + if (compression !== "none" && compression !== "gzip" && compression !== "br") + throw new Error("Compression must be none, gzip, or br."); + if (positionals.length > 1) + throw new Error("Supply at most one fixture directory or file."); + const spotlight = process.env.SENTRY_SPOTLIGHT; + const endpoint = + values.endpoint ?? + (spotlight && !/^(1|true|yes|on|enabled)$/i.test(spotlight) + ? spotlight + : "http://localhost:8969/stream"); + const target = new URL(endpoint); + console.log(`Replaying envelopes to ${target.origin}${target.pathname}`); + const count = await replayEnvelopes({ + endpoint, + fixtures: values.fixtures ?? positionals[0], + compression, + interval: Number(values.interval), + repeat: Number(values.repeat), + onSent: (file, index) => + console.log( + `${index}. ${file.slice(dirname(resolve(values.fixtures ?? positionals[0] ?? defaultFixtures)).length + 1)}`, + ), + }); + console.log(`Sent ${count} envelope${count === 1 ? "" : "s"}.`); +} +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "Replay failed"); + process.exitCode = 1; + }); +} diff --git a/apps/local/src/App.capture-lifecycle.test.tsx b/apps/local/src/App.capture-lifecycle.test.tsx new file mode 100644 index 000000000..f4eec10a6 --- /dev/null +++ b/apps/local/src/App.capture-lifecycle.test.tsx @@ -0,0 +1,627 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} 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 { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; +import App from "./App.tsx"; +import { + REMOTE_STREAM_STORAGE_KEY, + STREAM_STORAGE_KEY, +} from "./lib/spotlight.ts"; + +const STREAM = + "http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=web-read"; +const WORKER_STREAM = + "http://127.0.0.1:8969/_local/v1/sessions/worker/stream?cap=worker-read"; +const DIRECTORY = + "http://127.0.0.1:8969/_local/v1/ui/sessions?cap=directory-read"; +const EXPIRES_AT = 1_795_000_000_000; +const REMOTE_STREAM = "https://receiver.example/stream?token=remote-read"; + +class ControllableEventSource extends EventTarget { + static instances: ControllableEventSource[] = []; + onopen: ((event: Event) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + closed = false; + constructor(readonly url: string) { + super(); + ControllableEventSource.instances.push(this); + } + close() { + this.closed = true; + } +} + +function renderCapture({ path = "/live", directory = false } = {}) { + window.history.replaceState( + null, + "", + `${path}#${new URLSearchParams({ stream: STREAM, ...(directory ? { directory: DIRECTORY } : {}) })}`, + ); + return renderViewer(); +} + +function renderViewer() { + return render( + + + + + + + + + , + ); +} + +async function openLatestReceiver() { + const probe = await openSource(); + const committed = ControllableEventSource.instances.at(-1)!; + return committed === probe ? committed : await openSource(committed); +} + +async function switchReceiver(streamUrl: string) { + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: streamUrl }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect", exact: true })); + return await openLatestReceiver(); +} + +async function openSource(source = ControllableEventSource.instances.at(-1)!) { + await act(async () => source.onopen?.(new Event("open"))); + return source; +} + +async function sessionMetadata( + source: ControllableEventSource, + metadata: unknown, +) { + await act(async () => + source.dispatchEvent( + new MessageEvent("sentry.local.session", { + data: JSON.stringify(metadata), + }), + ), + ); +} + +async function sendEvent( + source: ControllableEventSource, + message: string, + id: string, +) { + await act(async () => + source.dispatchEvent( + new MessageEvent("application/x-sentry-envelope", { + data: JSON.stringify([{}, [[{ type: "event" }, { message }]]]), + lastEventId: id, + }), + ), + ); +} + +function captureAction(name: string): HTMLButtonElement { + let action = screen.queryByRole("button", { name, exact: true }); + if (!action) { + fireEvent.click( + screen.getAllByRole("button", { + name: "Capture actions", + exact: true, + })[0]!, + ); + action = screen.getByRole("button", { name, exact: true }); + } + return action as HTMLButtonElement; +} + +function openSettings() { + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + return screen.getByRole("dialog", { name: "Settings", exact: true }); +} + +beforeEach(() => { + render({null}).unmount(); + window.localStorage.clear(); + window.sessionStorage.clear(); + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + media: "", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + })); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); +}); + +test("retained SSE metadata shows the actual saved deadline and stops cleanly", async () => { + const request = vi.fn(); + vi.stubGlobal("fetch", request); + renderCapture(); + const source = await openSource(); + await sendEvent(source, "saved checkout failure", "saved-event"); + await sessionMetadata(source, { state: "retained", expiresAt: EXPIRES_AT }); + expect(source.closed).toBe(true); + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Saved capture"); + const header = screen.getByRole("banner", { name: "Capture context" }); + expect(header.textContent).toContain("Saved until"); + expect( + header.querySelector( + `time[datetime="${new Date(EXPIRES_AT).toISOString()}"]`, + ), + ).not.toBeNull(); + expect(screen.getByTestId("event-list").textContent).toContain( + "saved checkout failure", + ); + const settings = openSettings(); + expect(within(settings).getByText("Saved until")).not.toBeNull(); + expect(settings.querySelector("time")?.dateTime).toBe( + new Date(EXPIRES_AT).toISOString(), + ); + expect(request).not.toHaveBeenCalled(); +}); + +test.each([ + undefined, + 0, + -1, + 1.5, + "1795000000000", + Number.MAX_SAFE_INTEGER + 1, +])( + "invalid or omitted optional expiry %s never invents a saved deadline", + async (expiresAt) => { + renderCapture(); + const source = await openSource(); + await sessionMetadata(source, { state: "retained", expiresAt }); + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Saved capture"); + expect(screen.queryByText(/Saved until/)).toBeNull(); + const settings = openSettings(); + expect(within(settings).queryByText("Saved until")).toBeNull(); + }, +); + +test("switching captures clears the old label and deadline before new metadata", async () => { + const sessions = [ + { + id: "web", + label: "Web checkout", + cwd: "/web", + state: "open", + streamUrl: STREAM, + }, + { + id: "worker", + label: "Worker jobs", + cwd: "/worker", + state: "open", + streamUrl: WORKER_STREAM, + }, + ]; + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + async () => + new Response(JSON.stringify({ sessions }), { + headers: { "Content-Type": "application/json" }, + }), + ), + ); + renderCapture({ directory: true }); + const first = await openSource(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("Web checkout"), + ); + await sessionMetadata(first, { state: "retained", expiresAt: EXPIRES_AT }); + expect( + screen.getByRole("banner", { name: "Capture context" }).textContent, + ).toContain("Saved until"); + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + fireEvent.click( + await screen.findByRole("option", { name: /^Worker jobs ·/ }), + ); + const second = await openSource(); + expect(second).not.toBe(first); + const header = screen.getByRole("banner", { name: "Capture context" }); + expect(header.textContent).toContain("Worker jobs"); + expect(header.textContent).not.toContain("Web checkout"); + expect(header.textContent).not.toContain("Saved until"); + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Capturing"); + const settings = openSettings(); + expect(settings.textContent).toContain("For capture: Worker jobs"); + expect(settings.textContent).not.toContain("Web checkout"); + expect(settings.textContent).not.toContain("Saved until"); +}); + +test("an empty saved traces view describes stopped history without promising future traces", async () => { + renderCapture({ path: "/traces" }); + const source = await openSource(); + await sendEvent(source, "unrelated error", "non-trace"); + await sessionMetadata(source, { state: "retained", expiresAt: EXPIRES_AT }); + expect( + screen.getByRole("heading", { name: "No traces saved" }), + ).not.toBeNull(); + expect( + screen.getByText( + "This capture has stopped and its saved history contains no traces.", + ), + ).not.toBeNull(); + expect(screen.queryByText(/Transactions and spans.*will appear/)).toBeNull(); +}); + +test("deletion cancellation sends no HTTP and confirmation mutates only the scoped capture", async () => { + const request = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", request); + renderCapture(); + const source = await openSource(); + await sendEvent(source, "keep until confirmed", "confirm-event"); + fireEvent.click(captureAction("Delete captured events")); + const confirmation = screen.getByRole("dialog", { + name: "Delete captured events?", + }); + expect(confirmation.textContent).toContain( + "Other viewers lose this history too", + ); + fireEvent.keyDown(window, { key: "k", ctrlKey: true }); + expect( + screen.queryByRole("dialog", { name: "Search this session" }), + ).toBeNull(); + expect(screen.getByRole("dialog", { name: "Delete captured events?" })).toBe( + confirmation, + ); + expect(request).not.toHaveBeenCalled(); + fireEvent.click( + within(confirmation).getByRole("button", { name: "Cancel", exact: true }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(request).not.toHaveBeenCalled(); + expect(source.closed).toBe(false); + expect(screen.getByTestId("event-list").textContent).toContain( + "keep until confirmed", + ); + fireEvent.click(captureAction("Delete captured events")); + fireEvent.click( + within( + screen.getByRole("dialog", { name: "Delete captured events?" }), + ).getByRole("button", { name: "Delete captured events", exact: true }), + ); + await waitFor(() => expect(request).toHaveBeenCalledTimes(1)); + const [url, options] = request.mock.calls[0]!; + expect(String(url)).toBe( + "http://127.0.0.1:8969/_local/v1/sessions/web/ui/reset?cap=web-read", + ); + expect(options).toMatchObject({ method: "POST" }); + expect(screen.queryByText("keep until confirmed")).toBeNull(); + expect( + screen.getByRole("heading", { name: "No events captured" }), + ).not.toBeNull(); + expect(screen.queryByText(/Clear view only affects this tab/)).toBeNull(); +}); + +test("a receiver reset describes empty capture history without tab-only recovery guidance", async () => { + renderCapture(); + const source = await openSource(); + await sendEvent(source, "deleted at receiver", "reset-event"); + await sessionMetadata(source, { state: "open", action: "reset" }); + expect(screen.queryByText("deleted at receiver")).toBeNull(); + expect( + screen.getByRole("heading", { name: "No events captured" }), + ).not.toBeNull(); + expect(screen.queryByText(/Clear view only affects this tab/)).toBeNull(); + expect(source.closed).toBe(false); +}); + +test("pending deletion disables Reload capture and ignores queued events until clean replay", async () => { + let finishReset!: (response: Response) => void; + const pendingReset = new Promise((resolve) => { + finishReset = resolve; + }); + const request = vi.fn().mockReturnValue(pendingReset); + vi.stubGlobal("fetch", request); + renderCapture(); + const beforeReset = await openSource(); + await sendEvent(beforeReset, "before pending reset", "before-pending"); + fireEvent.click(captureAction("Delete captured events")); + fireEvent.click( + within( + screen.getByRole("dialog", { name: "Delete captured events?" }), + ).getByRole("button", { name: "Delete captured events", exact: true }), + ); + expect(request).toHaveBeenCalledTimes(1); + const reload = screen.getByRole("button", { + name: "Reload capture", + exact: true, + }) as HTMLButtonElement; + expect(reload.disabled).toBe(true); + fireEvent.click(reload); + expect(ControllableEventSource.instances).toHaveLength(1); + expect(beforeReset.closed).toBe(true); + await sendEvent(beforeReset, "queued before deletion", "queued-before"); + expect(screen.queryByText("queued before deletion")).toBeNull(); + await act(async () => finishReset(new Response(null, { status: 204 }))); + await waitFor(() => + expect(ControllableEventSource.instances).toHaveLength(2), + ); + const replay = await openSource(); + expect(replay).not.toBe(beforeReset); + await sendEvent(replay, "captured after deletion", "after-pending"); + expect(screen.getByTestId("event-list").textContent).toContain( + "captured after deletion", + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "before pending reset", + ); + expect( + ( + screen.getByRole("button", { + name: "Reload capture", + exact: true, + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect(request).toHaveBeenCalledTimes(1); +}); + +test("a successful remote connection survives page reload despite an older saved loopback", async () => { + window.localStorage.setItem(STREAM_STORAGE_KEY, STREAM); + renderCapture(); + await openSource(); + const remote = await switchReceiver(REMOTE_STREAM); + expect(remote.url).toBe(REMOTE_STREAM); + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + REMOTE_STREAM, + ); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBe(STREAM); + expect(window.location.hash).toBe(""); + cleanup(); + renderViewer(); + const restored = await openSource(); + expect(restored.url).toBe(REMOTE_STREAM); + expect( + screen.getByRole("banner", { name: "Capture context" }).textContent, + ).toContain("Remote capture"); +}); + +test("switching from remote to loopback clears the remote choice before page reload", async () => { + renderCapture(); + await openSource(); + await switchReceiver(REMOTE_STREAM); + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBe( + REMOTE_STREAM, + ); + const local = await switchReceiver(STREAM); + expect(new URL(local.url).pathname).toBe(new URL(STREAM).pathname); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBe(STREAM); + expect(window.sessionStorage.getItem(REMOTE_STREAM_STORAGE_KEY)).toBeNull(); + cleanup(); + renderViewer(); + const restored = await openSource(); + expect(new URL(restored.url).origin).toBe(new URL(STREAM).origin); + expect(new URL(restored.url).pathname).toBe(new URL(STREAM).pathname); + expect(restored.url).not.toBe(REMOTE_STREAM); +}); + +test.each(["capturing", "saved"] as const)( + "reloading the same %s capture clears tab-only empty guidance in other views", + async (state) => { + const request = vi.fn(); + vi.stubGlobal("fetch", request); + renderCapture({ path: "/traces" }); + const initial = await openSource(); + await sendEvent(initial, "saved event without traces", "reload-event"); + if (state === "saved") + await sessionMetadata(initial, { + state: "retained", + expiresAt: EXPIRES_AT, + }); + fireEvent.click(captureAction("Clear view")); + expect( + screen.getByRole("heading", { name: "No traces displayed" }), + ).not.toBeNull(); + expect(screen.getByText(/Clear view only affects this tab/)).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Reload capture", exact: true }), + ); + const replay = await openSource(); + expect(replay).not.toBe(initial); + await sendEvent(replay, "saved event without traces", "reload-event"); + if (state === "saved") + await sessionMetadata(replay, { + state: "retained", + expiresAt: EXPIRES_AT, + }); + expect( + screen.getByRole("heading", { + name: state === "saved" ? "No traces saved" : "No traces captured", + }), + ).not.toBeNull(); + expect(screen.queryByText(/Clear view only affects this tab/)).toBeNull(); + expect(request).not.toHaveBeenCalled(); + }, +); + +test("session command search leaves the active view filter unchanged until Filter is chosen", async () => { + renderCapture(); + const source = await openSource(); + await sendEvent(source, "alpha checkout", "alpha-event"); + await sendEvent(source, "beta worker", "beta-event"); + fireEvent.change(screen.getByRole("searchbox", { name: "Filter Events" }), { + target: { value: "alpha" }, + }); + await waitFor(() => + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(1), + ); + expect(screen.getByTestId("event-list").textContent).toContain( + "alpha checkout", + ); + fireEvent.click( + screen.getAllByRole("button", { + name: "Search this session", + exact: true, + })[0]!, + ); + fireEvent.change( + screen.getByRole("combobox", { name: "Search this session", exact: true }), + { + target: { value: "beta" }, + }, + ); + expect( + await screen.findByRole("option", { name: /beta worker/i }), + ).not.toBeNull(); + expect(new URLSearchParams(window.location.search).get("q")).toBe("alpha"); + expect(screen.getByTestId("event-list").textContent).toContain( + "alpha checkout", + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "beta worker", + ); + fireEvent.click( + screen.getByRole("option", { name: "Filter Events for “beta”" }), + ); + await waitFor(() => + expect(screen.getByTestId("event-list").textContent).toContain( + "beta worker", + ), + ); + expect(new URLSearchParams(window.location.search).get("q")).toBe("beta"); + fireEvent.click( + screen.getByRole("button", { name: "Clear filter", exact: true }), + ); + await waitFor(() => + expect( + screen.getAllByRole("button", { name: "View event event" }), + ).toHaveLength(2), + ); +}); + +test.each(["connecting", "failed"] as const)( + "landing edits retain ownership when session discovery completes late (%s)", + async (phase) => { + let resolveDirectory!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + resolveDirectory = resolve; + }), + ), + ); + window.history.replaceState( + null, + "", + `/#${new URLSearchParams({ stream: "http://127.0.0.1:8969/stream", directory: DIRECTORY })}`, + ); + renderViewer(); + await waitFor(() => expect(resolveDirectory).toBeDefined()); + if (phase === "failed") + await act(async () => + ControllableEventSource.instances.at(-1)?.onerror?.(new Event("error")), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + const attempts = ControllableEventSource.instances.length; + await act(async () => + resolveDirectory( + new Response( + JSON.stringify({ + sessions: [ + { + id: "web", + label: "Web", + cwd: "/web", + state: "open", + streamUrl: STREAM, + }, + ], + }), + { headers: { "Content-Type": "application/json" } }, + ), + ), + ); + expect( + (screen.getByLabelText("Receiver endpoint") as HTMLInputElement).value, + ).toBe("http://127.0.0.1:8970/stream"); + expect(ControllableEventSource.instances).toHaveLength(attempts); + }, +); + +test.each(["filtered", "saved", "offline"] as const)( + "SDK report empty state preserves %s recovery guidance", + async (state) => { + renderCapture({ path: state === "filtered" ? "/sdks?q=missing" : "/sdks" }); + const source = await openSource(); + await sendEvent(source, "unrelated event", "sdk-empty"); + if (state === "saved") + await sessionMetadata(source, { + state: "retained", + expiresAt: EXPIRES_AT, + }); + if (state === "offline") + await act(async () => source.onerror?.(new Event("error"))); + if (state === "filtered") + expect( + screen.getByRole("heading", { + name: /No matching session or client reports/, + }), + ).toBeDefined(); + else if (state === "saved") + expect( + screen.getByText(/capture has stopped.*session or client reports/), + ).toBeDefined(); + else + expect( + screen.getByText( + /Reconnect to the receiver to load available capture history/, + ), + ).toBeDefined(); + expect( + screen.queryByText(/Session and client reports will appear/), + ).toBeNull(); + }, +); diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx index df770a5f9..1bc432db5 100644 --- a/apps/local/src/App.integration.test.tsx +++ b/apps/local/src/App.integration.test.tsx @@ -1,3 +1,4 @@ +import { replayEnvelopes, defaultFixtures } from "../scripts/replay.ts"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import { EventSource as NodeEventSource } from "eventsource"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -8,6 +9,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import { NuqsAdapter } from "nuqs/adapters/react"; import { NuqsTestingAdapter } from "nuqs/adapters/testing"; @@ -15,6 +17,7 @@ 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 { saveDisconnected } from "./lib/connection-history.ts"; import { sdkException } from "./test-fixtures/sdk-exception.ts"; import { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; import { @@ -71,7 +74,11 @@ function envelope(transaction: string, options: EnvelopeOptions = {}): string { } async function startReceiver() { - return await tryListen(buildApp(createSpotlightBuffer(10)), 0, "127.0.0.1"); + return await tryListen( + buildApp(createSpotlightBuffer(10), { uiActions: true }), + 0, + "127.0.0.1", + ); } async function stopReceiver( @@ -79,6 +86,8 @@ async function stopReceiver( ) { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); + // Node 22 can otherwise wait for old SSE/probe keep-alive sockets during teardown. + server.closeAllConnections(); }); } @@ -95,6 +104,30 @@ async function sendEnvelope( expect(response.status).toBe(204); } +async function sendLog( + port: number, + body: string, + logger: string, + level = "info", +) { + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: [ + JSON.stringify({}), + JSON.stringify({ type: "log" }), + JSON.stringify({ + timestamp: 1_700_000_000, + body, + level, + attributes: { "sentry.logger.name": { type: "string", value: logger } }, + }), + "", + ].join("\n"), + }); + expect(response.status).toBe(204); +} + function renderViewer(port: number, path = "/") { window.history.replaceState( null, @@ -114,8 +147,8 @@ function renderViewer(port: number, path = "/") { ); } -function renderBareViewer() { - window.history.replaceState(null, "", "/"); +function renderBareViewer(path = "/") { + window.history.replaceState(null, "", path); return render( @@ -157,6 +190,46 @@ class ControllableEventSource extends EventTarget { } } +/** Open the replacement probe, then the committed replay stream when one is created. */ +async function openLatestReceiver() { + const probe = ControllableEventSource.instances.at(-1)!; + await act(async () => probe.onopen?.(new Event("open"))); + const current = ControllableEventSource.instances.at(-1)!; + if (current !== probe) + await act(async () => current.onopen?.(new Event("open"))); + return current; +} + +function viewButton(name: string): HTMLButtonElement { + if (!screen.queryByRole("button", { name, exact: true })) { + fireEvent.click( + screen.getByRole("button", { name: "Diagnostics", exact: true }), + ); + } + return screen.getByRole("button", { name, exact: true }) as HTMLButtonElement; +} + +function captureAction(name: string): HTMLButtonElement { + let button = screen.queryByRole("button", { name, exact: true }); + if (!button) { + fireEvent.click( + screen.getAllByRole("button", { + name: "Capture actions", + exact: true, + })[0]!, + ); + button = screen.getByRole("button", { name, exact: true }); + } + return button as HTMLButtonElement; +} + +function confirmDeleteCapturedEvents() { + fireEvent.click(captureAction("Delete captured events")); + fireEvent.click( + screen.getByRole("button", { name: "Delete captured events", exact: true }), + ); +} + describe("local receiver to viewer integration", () => { beforeEach(() => { // Nuqs shares pending URL updates across mounts. Reset them between tests @@ -187,6 +260,253 @@ describe("local receiver to viewer integration", () => { window.history.replaceState(null, "", "/"); }); + test("consumes the connection link without restoring it during view navigation", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer("/?url=http%3A%2F%2F127.0.0.1%3A8969%2Fstream"); + await act(async () => + ControllableEventSource.instances[0]!.onopen?.(new Event("open")), + ); + fireEvent.click(screen.getByRole("button", { name: "Open traces view" })); + await waitFor(() => expect(window.location.pathname).toBe("/traces")); + expect(new URLSearchParams(window.location.search).has("url")).toBe(false); + }); + + test("Clear view only clears this tab and keeps the capture connection open", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const request = vi.fn(); + vi.stubGlobal("fetch", request); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + await act(async () => { + source.onopen?.(new Event("open")); + source.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "before local clear" }]], + ]), + lastEventId: "before-local-clear", + }), + ); + }); + fireEvent.click(captureAction("Clear view")); + expect(request).not.toHaveBeenCalled(); + expect(source.closed).toBe(false); + expect(screen.queryByLabelText("View event event")).toBeNull(); + await act(async () => + source.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "after local clear" }]], + ]), + lastEventId: "after-local-clear", + }), + ), + ); + expect(screen.getByTestId("event-list").textContent).toContain( + "after local clear", + ); + }); + + test("Switch receiver is a dismissible modal that keeps the live feed and restores focus", async () => { + const receiver = await startReceiver(); + try { + renderViewer(receiver.port); + await screen.findByText("Connected to local receiver"); + await sendEnvelope(receiver.port, "GET /keep-live"); + await screen.findByLabelText("View transaction event"); + const trigger = screen.getByRole("button", { + name: "Switch receiver", + exact: true, + }); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "Switch receiver" }); + expect(document.body.style.pointerEvents).toBe("none"); + expect(screen.queryByRole("button", { name: "Clear view" })).toBeNull(); + fireEvent.keyDown(dialog, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(trigger); + expect(screen.getByTestId("event-list").textContent).toContain( + "/keep-live", + ); + fireEvent.click(trigger); + fireEvent.click( + screen.getByRole("button", { name: "Close", exact: true }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + fireEvent.click(trigger); + const overlay = document.querySelector('[data-slot="dialog-overlay"]')!; + // Radix registers outside-pointer listeners on the next task. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 }); + fireEvent.click(overlay); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + } finally { + cleanup(); + await stopReceiver(receiver.server); + } + }); + + test("a failed or cancelled Switch receiver attempt keeps the current stream and events", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + const first = ControllableEventSource.instances[0]!; + await act(async () => { + first.onopen?.(new Event("open")); + first.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "keep this event" }]], + ]), + }), + ); + }); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + const candidate = ControllableEventSource.instances.at(-1)!; + await act(async () => candidate.onerror?.(new Event("error"))); + expect(first.closed).toBe(false); + expect( + screen.getByRole("dialog", { name: "Switch receiver" }), + ).not.toBeNull(); + expect( + screen.getByRole("alert", { name: "Receiver connection error" }) + .textContent, + ).toContain("Could not connect"); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + const cancelled = ControllableEventSource.instances.at(-1)!; + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); + expect(cancelled.closed).toBe(true); + await act(async () => cancelled.onopen?.(new Event("open"))); + expect(first.closed).toBe(false); + expect(screen.getByTestId("event-list").textContent).toContain( + "keep this event", + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + test("Settings blocks the background, closes on outside click and restores its trigger", async () => { + vi.stubGlobal("EventSource", OpeningEventSource); + renderBareViewer(); + await screen.findByText("Connected to local receiver"); + const trigger = screen.getByRole("button", { + name: "Settings", + exact: true, + }); + fireEvent.click(trigger); + expect(document.body.style.pointerEvents).toBe("none"); + expect(screen.queryByRole("button", { name: "Clear view" })).toBeNull(); + const overlay = document.querySelector('[data-slot="dialog-overlay"]')!; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 }); + fireEvent.click(overlay); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(trigger); + }); + + test("editing the connection landing prevents a late automatic connection from dismissing it", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + const automatic = ControllableEventSource.instances[0]!; + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + expect(automatic.closed).toBe(true); + await act(async () => automatic.onopen?.(new Event("open"))); + expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await openLatestReceiver(); + await screen.findByText("Connected to local receiver"); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBe( + "http://127.0.0.1:8970/stream", + ); + }); + + test("appearance Settings remain available without a connected receiver", () => { + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + expect(screen.getByRole("dialog", { name: "Settings" })).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Dark", exact: true })); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect( + screen.getByText("This receiver does not provide retention settings."), + ).not.toBeNull(); + }); + + test("Settings changes and persists the viewer theme without reconnecting the receiver", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderViewer(8969); + await act(async () => + ControllableEventSource.instances.at(-1)?.onopen?.(new Event("open")), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + fireEvent.click(screen.getByRole("button", { name: "Dark", exact: true })); + await waitFor(() => + expect(document.documentElement.classList.contains("dark")).toBe(true), + ); + expect(window.localStorage.getItem("theme")).toBe("dark"); + expect(ControllableEventSource.instances).toHaveLength(1); + expect(ControllableEventSource.instances[0]?.closed).toBe(false); + + cleanup(); + renderViewer(8969); + await act(async () => + ControllableEventSource.instances.at(-1)?.onopen?.(new Event("open")), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + expect( + screen + .getByRole("button", { name: "Dark", exact: true }) + .getAttribute("aria-pressed"), + ).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Light", exact: true })); + await waitFor(() => + expect(document.documentElement.classList.contains("light")).toBe(true), + ); + expect(window.localStorage.getItem("theme")).toBe("light"); + fireEvent.click( + screen.getByRole("button", { name: "System", exact: true }), + ); + expect(window.localStorage.getItem("theme")).toBe("system"); + expect( + screen + .getByRole("button", { name: "System", exact: true }) + .getAttribute("aria-pressed"), + ).toBe("true"); + expect(document.documentElement.classList.contains("light")).toBe(true); + expect(ControllableEventSource.instances).toHaveLength(2); + expect(ControllableEventSource.instances[1]?.closed).toBe(false); + }); + test("shows SDK exception titles, causes and stacks in Overview without losing JSON", async () => { const { server, port } = await startReceiver(); try { @@ -271,6 +591,59 @@ describe("local receiver to viewer integration", () => { } }); + test("System appearance follows live device changes while explicit choices stay fixed", async () => { + let prefersDark = false; + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + vi.stubGlobal("matchMedia", (query: string) => ({ + get matches() { + return prefersDark; + }, + media: query, + addListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.add(listener), + removeListener: (listener: (event: MediaQueryListEvent) => void) => + listeners.delete(listener), + })); + const changeSystemTheme = async (dark: boolean) => { + prefersDark = dark; + await act(async () => { + const event = Object.assign(new Event("change"), { + matches: dark, + media: "(prefers-color-scheme: dark)", + }) as MediaQueryListEvent; + for (const listener of listeners) listener(event); + }); + }; + window.localStorage.setItem("theme", "system"); + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderViewer(8969); + await act(async () => + ControllableEventSource.instances[0]?.onopen?.(new Event("open")), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + expect(document.documentElement.classList.contains("light")).toBe(true); + expect(screen.getByText("System appearance: Light")).not.toBeNull(); + await changeSystemTheme(true); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(screen.getByText("System appearance: Dark")).not.toBeNull(); + await changeSystemTheme(false); + expect(document.documentElement.classList.contains("light")).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Dark", exact: true })); + await changeSystemTheme(true); + await changeSystemTheme(false); + expect(document.documentElement.classList.contains("dark")).toBe(true); + fireEvent.click( + screen.getByRole("button", { name: "System", exact: true }), + ); + expect(document.documentElement.classList.contains("light")).toBe(true); + expect(ControllableEventSource.instances).toHaveLength(1); + expect(ControllableEventSource.instances[0]?.closed).toBe(false); + expect(window.localStorage.getItem("theme")).toBe("system"); + }); + test("keeps the selected live transaction open in the detail pane", async () => { const { server, port } = await startReceiver(); @@ -281,7 +654,7 @@ describe("local receiver to viewer integration", () => { const shell = screen.getByTestId("app-shell"); expect(shell.className).not.toContain("px-"); expect(shell.className).not.toContain("py-"); - expect(screen.queryByRole("banner")).toBeNull(); + expect(screen.queryByLabelText("Capture context")).not.toBeNull(); await sendEnvelope(port, "GET /live"); await screen.findByLabelText("View transaction event"); @@ -296,7 +669,7 @@ describe("local receiver to viewer integration", () => { expect(eventList.className).toContain("flex-1"); expect(eventList.className).not.toContain("max-h-[42rem]"); expect( - screen.getByRole("complementary", { name: "Live Activity" }), + screen.getByRole("complementary", { name: "Events list" }), ).not.toBeNull(); const detail = screen.getByTestId("event-detail"); expect(detail.textContent).toContain("GET /live"); @@ -335,54 +708,28 @@ describe("local receiver to viewer integration", () => { } }); - test("keeps direct receiver actions in a compact sidebar utility bar", async () => { + test("keeps capture context separate from labelled sidebar actions", 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(sidebar.querySelector('[role="status"]')).toBeNull(); + expect(screen.getByLabelText("Capture context").textContent).toContain( + "Capturing", ); - 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(); - + screen.getAllByRole("button", { name: "Search this session" }), + ).not.toHaveLength(0); fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); expect( - screen.getByRole("button", { name: "Open live activity view" }) - .className, + screen.getByRole("button", { name: "Open events view" }).className, ).toContain("justify-center"); + fireEvent.click(captureAction("Switch receiver")); 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" }), + await screen.findByRole("dialog", { name: "Switch receiver" }), ).not.toBeNull(); } finally { cleanup(); @@ -398,10 +745,10 @@ describe("local receiver to viewer integration", () => { "Receiver setup", ); expect( - screen.queryByRole("searchbox", { name: "Search events" }), + screen.queryByRole("searchbox", { name: "Search this session" }), ).toBeNull(); expect(screen.queryByLabelText("Workspace navigation")).toBeNull(); - expect(screen.getByText("Looking for Sentry Local")).not.toBeNull(); + expect(screen.getByText("Connecting to receiver")).not.toBeNull(); expect( screen.getByRole("status", { name: "Checking receiver" }), ).not.toBeNull(); @@ -416,15 +763,135 @@ describe("local receiver to viewer integration", () => { 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.", - ), + screen.getByText("Paste a local /stream address or an HTTPS stream URL."), ).not.toBeNull(); expect( - screen.getByRole("button", { name: "Copy local serve command" }), + screen.getByRole("button", { name: "Copy local run command" }), ).not.toBeNull(); }); + test("replays bundled envelopes through the receiver into every investigation view", async () => { + const { server, port } = await startReceiver(); + const originalCreate = URL.createObjectURL?.bind(URL); + const originalRevoke = URL.revokeObjectURL?.bind(URL); + URL.createObjectURL = () => "blob:fixture"; + URL.revokeObjectURL = () => {}; + try { + renderViewer(port); + await screen.findByText("Connected to local receiver"); + expect( + await replayEnvelopes({ + endpoint: `http://127.0.0.1:${port}/stream`, + fixtures: defaultFixtures, + compression: "gzip", + }), + ).toBe(9); + await waitFor(() => + expect(screen.getByTestId("event-list").textContent).toContain( + "/checkout", + ), + ); + fireEvent.click(viewButton("Open queries view")); + const query = await screen.findByRole("button", { + name: "SELECT id, total FROM orders WHERE user_id = ?", + }); + fireEvent.click(query); + expect( + screen.getByRole("region", { name: "Database queries executions" }) + .textContent, + ).toContain("Captured executions · 2"); + fireEvent.click( + screen.getAllByRole("button", { name: /GET \/checkout.*ms/ })[0]!, + ); + expect( + await screen.findByRole("region", { name: "Span detail" }), + ).toBeDefined(); + fireEvent.click(viewButton("Open resources view")); + expect( + await screen.findByRole("button", { + name: "http://localhost:3000/checkout.js", + }), + ).toBeDefined(); + fireEvent.click(viewButton("Open web vitals view")); + expect( + await screen.findByRole("img", { + name: /LCP: 3200 ms, Needs improvement/, + }), + ).toBeDefined(); + fireEvent.click(viewButton("Open ai view")); + expect(await screen.findByText("Order 42 ships tomorrow.")).toBeDefined(); + fireEvent.click(viewButton("Open profiles view")); + expect( + await screen.findByRole("table", { name: "Profile functions" }), + ).toBeDefined(); + fireEvent.change(screen.getByLabelText("Visualization"), { + target: { value: "sunburst" }, + }); + expect( + screen.getByRole("group", { name: "sunburst profile visualization" }), + ).toBeDefined(); + fireEvent.click(viewButton("Open attachments view")); + expect( + await screen.findByRole("heading", { name: "notes.txt", level: 3 }), + ).toBeDefined(); + fireEvent.click( + screen.getAllByRole("button", { name: "View attachment event" })[3]!, + ); + const image = await screen.findByRole("img", { name: "pixel.png" }); + expect(image.getAttribute("src")).toMatch(/^blob:/); + fireEvent.click(viewButton("Open sdks view")); + expect(await screen.findByText("sentry.javascript.node")).toBeDefined(); + } finally { + cleanup(); + URL.createObjectURL = originalCreate; + URL.revokeObjectURL = originalRevoke; + await stopReceiver(server); + } + }, 15_000); + + test("parameterized text attachments follow the real receiver's base64 wire encoding", async () => { + const { server, port } = await startReceiver(); + const observer = new NodeEventSource(`http://127.0.0.1:${port}/stream`); + try { + const received = new Promise((resolve) => + observer.addEventListener( + SENTRY_CONTENT_TYPE, + (event) => resolve(event.data), + { once: true }, + ), + ); + await new Promise((resolve, reject) => { + observer.onopen = () => resolve(); + observer.onerror = reject; + }); + renderViewer(port, "/attachments"); + await screen.findByText("Connected to local receiver"); + const content = "héllo"; + const header = { + type: "attachment", + filename: "charset.txt", + content_type: "text/plain; charset=utf-8", + length: new TextEncoder().encode(content).byteLength, + }; + const response = await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: `{}\n${JSON.stringify(header)}\n${content}\n`, + }); + expect(response.status).toBe(204); + const wire = JSON.parse(await received) as [ + unknown, + [[unknown, { data: string }]], + ]; + expect(wire[1][0][1].data).toBe(Buffer.from(content).toString("base64")); + expect(await screen.findByText(content)).toBeDefined(); + } finally { + observer.close(); + cleanup(); + await stopReceiver(server); + } + }); + test("renders every Explorer view from a receiver-backed telemetry session", async () => { const { server, port } = await startReceiver(); @@ -440,18 +907,18 @@ describe("local receiver to viewer integration", () => { await sendEnvelope(port, "GET /ai", { operation: "ai.generate" }); const explorerViews = [ - ["Open live activity view", "View transaction event"], + ["Open events 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 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 })); + fireEvent.click(viewButton(view)); expect((await screen.findAllByLabelText(event)).length).toBeGreaterThan( 0, ); @@ -487,12 +954,90 @@ describe("local receiver to viewer integration", () => { } }); + test("past connections survive reload and reconnect isolated feeds without pasting endpoints", async () => { + const first = await startReceiver(); + const second = await startReceiver(); + try { + await sendEnvelope(first.port, "GET /past-first"); + await sendEnvelope(second.port, "GET /past-second"); + renderViewer(first.port); + await screen.findByLabelText("View transaction event"); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: `http://127.0.0.1:${second.port}/stream` }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await waitFor(() => + expect(screen.getByTestId("event-list").textContent).toContain( + "/past-second", + ), + ); + cleanup(); + renderBareViewer(); + await screen.findByLabelText("View transaction event"); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + const firstLabel = `http://127.0.0.1:${first.port}/stream`; + const secondLabel = `http://127.0.0.1:${second.port}/stream`; + expect( + screen.getByRole("button", { + name: `Reconnect to ${secondLabel}`, + exact: true, + }), + ).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: `Reconnect to ${firstLabel}`, + exact: true, + }), + ); + await waitFor(() => + expect(screen.getByTestId("event-list").textContent).toContain( + "/past-first", + ), + ); + expect(screen.getByTestId("event-list").textContent).not.toContain( + "/past-second", + ); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + fireEvent.click( + screen.getByRole("button", { + name: `Forget ${secondLabel}`, + exact: true, + }), + ); + expect( + screen.queryByRole("button", { + name: `Reconnect to ${secondLabel}`, + exact: true, + }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Close", exact: true }), + ); + expect(screen.getByTestId("event-list").textContent).toContain( + "/past-first", + ); + } finally { + cleanup(); + await stopReceiver(first.server); + await stopReceiver(second.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.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: "https://receiver.example/stream?token=abc" }, @@ -504,7 +1049,315 @@ describe("local receiver to viewer integration", () => { "https://receiver.example/stream?token=abc", ); }); - expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull(); + expect(window.localStorage.getItem(STREAM_STORAGE_KEY)).toBeNull(); + }); + + test("editing a failed landing clears its stale connection error without reconnecting", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + 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://127.0.0.1:8970/stream" }, + }); + expect( + screen.queryByRole("alert", { name: "Receiver connection error" }), + ).toBeNull(); + expect(ControllableEventSource.instances).toHaveLength(1); + await act(async () => source.onerror?.(new Event("error"))); + expect( + screen.queryByRole("alert", { name: "Receiver connection error" }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await openLatestReceiver(); + await screen.findByText("Connected to local receiver"); + }); + + test("an ignored remote query never reconnects a disconnected tab to its saved receiver", () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + window.localStorage.setItem( + STREAM_STORAGE_KEY, + "http://127.0.0.1:8969/_local/v1/sessions/saved/stream?cap=read", + ); + saveDisconnected(true); + renderBareViewer( + "/?url=" + encodeURIComponent("https://remote.example/stream"), + ); + expect(ControllableEventSource.instances).toHaveLength(0); + expect( + screen.getByRole("heading", { name: "Capture a local session" }), + ).not.toBeNull(); + }); + + test.each([true, false])( + "an explicit scoped link persists reconnection only after it opens (success: %s)", + async (success) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + saveDisconnected(true); + const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/new-run/stream?cap=read"; + renderBareViewer("/#stream=" + encodeURIComponent(streamUrl)); + const source = ControllableEventSource.instances[0]!; + await act(async () => { + if (success) source.onopen?.(new Event("open")); + else source.onerror?.(new Event("error")); + }); + expect(window.location.hash).toBe(""); + cleanup(); + renderBareViewer(); + expect(ControllableEventSource.instances).toHaveLength(success ? 2 : 1); + if (success) { + await act(async () => + ControllableEventSource.instances[1]!.onopen?.(new Event("open")), + ); + expect( + screen.getByRole("heading", { name: "No events captured" }), + ).not.toBeNull(); + } else { + expect( + screen.getByRole("heading", { name: "Capture a local session" }), + ).not.toBeNull(); + } + }, + ); + + test("clear ignores pre-reset deliveries and reconnects without replacing the workspace shell", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + window.localStorage.setItem( + STREAM_STORAGE_KEY, + "http://127.0.0.1:8969/_local/v1/sessions/reset-run/stream?cap=reset-test", + ); + let completeReset: ((response: Response) => void) | undefined; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + completeReset = resolve; + }), + ), + ); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + const deliver = (message: string, id: string) => + source.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([{}, [[{ type: "event" }, { message }]]]), + lastEventId: id, + }), + ); + await act(async () => { + source.onopen?.(new Event("open")); + deliver("before reset", "before"); + }); + const shell = screen.getByRole("complementary", { + name: "Workspace navigation", + }); + confirmDeleteCapturedEvents(); + await act(async () => deliver("stale queued event", "stale")); + expect(screen.queryByText("stale queued event")).toBeNull(); + expect( + screen.getByRole("complementary", { name: "Workspace navigation" }), + ).toBe(shell); + await act(async () => completeReset?.(new Response(null, { status: 204 }))); + expect(ControllableEventSource.instances).toHaveLength(2); + expect( + screen.getByRole("complementary", { name: "Workspace navigation" }), + ).toBe(shell); + }); + + test.each(["http", "network", "reconnecting"])( + "failed %s clear keeps an empty workspace recoverable", + async (failure) => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const request = vi.fn(); + if (failure === "network") + request.mockRejectedValue(new TypeError("Network unavailable")); + else request.mockResolvedValue(new Response(null, { status: 403 })); + vi.stubGlobal("fetch", request); + window.localStorage.setItem( + STREAM_STORAGE_KEY, + "http://127.0.0.1:8969/_local/v1/sessions/clear-failed/stream?cap=read", + ); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + await act(async () => { + source.onopen?.(new Event("open")); + source.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "before clear" }]], + ]), + lastEventId: "before-clear", + }), + ); + }); + if (failure === "reconnecting") { + await act(async () => source.onerror?.(new Event("error"))); + fireEvent.click( + screen.getByRole("button", { name: "Reconnect", exact: true }), + ); + } + const sidebar = screen.getByRole("complementary", { + name: "Workspace navigation", + }); + confirmDeleteCapturedEvents(); + await screen.findByText( + "This view is empty, but the receiver could not delete its captured events.", + ); + expect( + screen.getByRole("complementary", { name: "Workspace navigation" }), + ).toBe(sidebar); + expect(source.closed).toBe(true); + expect(screen.queryByText("Waiting for events")).toBeNull(); + expect( + screen.getByRole("heading", { name: "No events displayed" }), + ).not.toBeNull(); + expect( + ( + screen.getByRole("button", { + name: "Reconnect", + exact: true, + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + fireEvent.click( + screen.getByRole("button", { name: "Reconnect", exact: true }), + ); + const reconnected = await openLatestReceiver(); + expect(reconnected).not.toBe(source); + await act(async () => + reconnected.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "after reconnect" }]], + ]), + lastEventId: "after-reconnect", + }), + ), + ); + await screen.findByRole("button", { name: "View event event" }); + expect(screen.getByTestId("event-list").textContent).toContain( + "after reconnect", + ); + expect( + screen.queryByRole("heading", { name: "Receiver disconnected" }), + ).toBeNull(); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + test("unchanged Switch receiver retries a disconnected scoped session without losing its events or selection", async () => { + const stream = + "http://127.0.0.1:8969/_local/v1/sessions/selected-run/stream?cap=session-test"; + const requestedStreams: string[] = []; + ControllableEventSource.instances = []; + vi.stubGlobal( + "EventSource", + class extends ControllableEventSource { + constructor(url: string) { + super(url); + requestedStreams.push(url); + } + }, + ); + window.localStorage.setItem(STREAM_STORAGE_KEY, stream); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + await act(async () => { + source.onopen?.(new Event("open")); + source.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [ + [ + { type: "event" }, + { message: "kept scoped event", level: "error" }, + ], + ], + ]), + lastEventId: "kept-scoped", + }), + ); + }); + fireEvent.click( + screen.getByRole("button", { name: "Open errors view", exact: true }), + ); + await screen.findByRole("heading", { name: "Errors", exact: true }); + fireEvent.click(screen.getByRole("tab", { name: "JSON", exact: true })); + await act(async () => source.onerror?.(new Event("error"))); + await screen.findByText("Connection lost", { exact: true }); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + expect( + (screen.getByLabelText("Receiver endpoint") as HTMLInputElement).value, + ).toBe("http://127.0.0.1:8969/stream"); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await openLatestReceiver(); + await screen.findByText("Connected to local receiver"); + expect( + screen.getByRole("heading", { name: "Errors", exact: true }), + ).not.toBeNull(); + expect(screen.getByTestId("event-detail").textContent).toContain( + "kept scoped event", + ); + expect( + screen + .getByRole("tab", { name: "JSON", exact: true }) + .getAttribute("aria-selected"), + ).toBe("true"); + const reconnected = new URL(requestedStreams.at(-1)!); + expect(reconnected.pathname).toBe(new URL(stream).pathname); + expect(reconnected.searchParams.get("cap")).toBe("session-test"); + }); + + test("editing a replacement keeps the disconnected feed's error after closing the modal", 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_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "kept event" }]], + ]), + lastEventId: "kept", + }), + ); + source.onerror?.(new Event("error")); + }); + await screen.findByText("Connection lost", { exact: true }); + const error = + "Could not connect to the default receiver. Start it with the command below or enter another endpoint."; + expect(screen.getByText(error)).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "http://127.0.0.1:8970/stream" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); + expect(screen.getByText(error)).not.toBeNull(); + expect(screen.getByTestId("event-detail").textContent).toContain( + "kept event", + ); }); test("keeps endpoint validation visible after a failed receiver has been stopped", async () => { @@ -583,7 +1436,7 @@ describe("local receiver to viewer integration", () => { 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.click(screen.getByRole("button", { name: "Switch receiver" })); await act(async () => { source?.dispatchEvent( @@ -614,9 +1467,7 @@ describe("local receiver to viewer integration", () => { 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.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: "http://127.0.0.1:8970/stream" }, }); @@ -637,7 +1488,7 @@ describe("local receiver to viewer integration", () => { 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.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: "http://receiver.example/stream" }, }); @@ -645,7 +1496,7 @@ describe("local receiver to viewer integration", () => { expect(source?.closed).toBe(false); expect( - screen.getByRole("status", { name: "Connected to local receiver" }), + screen.getAllByText("Connected to local receiver", { exact: true })[0], ).not.toBeNull(); }); @@ -720,8 +1571,7 @@ describe("local receiver to viewer integration", () => { await sendEnvelope(port, "GET /another-healthy"); await waitFor(() => { expect( - screen.getByRole("button", { name: "Open live activity view" }) - .textContent, + screen.getByRole("button", { name: "Open events view" }).textContent, ).toContain("3"); }); expect( @@ -747,7 +1597,7 @@ describe("local receiver to viewer integration", () => { await screen.findByLabelText("View event event"); expect( - screen.getByRole("button", { name: "Open live activity view" }), + screen.getByRole("button", { name: "Open events view" }), ).not.toBeNull(); expect( screen.getByRole("button", { name: "Open errors view" }).textContent, @@ -804,13 +1654,9 @@ describe("local receiver to viewer integration", () => { await sendEnvelope(port, "GET /raw-envelope"); await screen.findByLabelText("View transaction event"); - fireEvent.click( - screen.getByRole("button", { name: "Open envelopes view" }), - ); + fireEvent.click(viewButton("Open envelopes view")); - expect( - screen.getByRole("button", { name: "Open envelopes view" }).textContent, - ).toContain("1"); + expect(viewButton("Open envelopes view").textContent).toContain("1"); fireEvent.click(screen.getByLabelText("View envelope event")); expect( screen.getByRole("heading", { name: "Raw envelope" }), @@ -833,9 +1679,7 @@ describe("local receiver to viewer integration", () => { await sendEnvelope(port, "GET /first-envelope"); await screen.findByLabelText("View transaction event"); - fireEvent.click( - screen.getByRole("button", { name: "Open envelopes view" }), - ); + fireEvent.click(viewButton("Open envelopes view")); fireEvent.click(await screen.findByLabelText("View envelope event")); await sendEnvelope(port, "GET /second-envelope"); @@ -867,17 +1711,10 @@ describe("local receiver to viewer integration", () => { ); }); - 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" }), - ); + fireEvent.click(viewButton("Open envelopes view")); expect(await screen.findByLabelText("View envelope event")).not.toBeNull(); - fireEvent.click(clearButton); - - await waitFor(() => expect(clearButton.disabled).toBe(true)); + fireEvent.click(captureAction("Clear view")); + expect(captureAction("Clear view").disabled).toBe(true); }); test("preserves undecodable raw envelopes and clears their stale alert with the feed", async () => { @@ -899,24 +1736,29 @@ describe("local receiver to viewer integration", () => { ); }); 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" }), - ); + fireEvent.click(viewButton("Open envelopes view")); await screen.findByLabelText("View envelope event"); - fireEvent.click(clear); + fireEvent.click(captureAction("Clear view")); await waitFor(() => expect( screen.queryByText("Received an event that could not be decoded."), ).toBeNull(), ); - expect(clear.disabled).toBe(true); + expect(captureAction("Clear view").disabled).toBe(true); }); test("replays more than 500 retained envelopes and applies a real runtime update", async () => { + const realFetch = globalThis.fetch; + vi.stubGlobal("fetch", async (...args: Parameters) => { + const target = args[0]; + const url = new URL(target instanceof Request ? target.url : target); + if (url.pathname === "/retention" && args[1]?.method === "POST") { + // Valid request latency can exceed Testing Library's one-second default. + // Keep the real HTTP request and receiver mutation behind this delay. + await new Promise((resolve) => setTimeout(resolve, 1250)); + } + return await realFetch(...args); + }); const retention = createLocalRetentionBuffer(1000); const receiver = await tryListen( buildApp(retention.buffer, { uiActions: true, retention }), @@ -925,37 +1767,71 @@ describe("local receiver to viewer integration", () => { ); 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}`); + // Keep real HTTP ingestion without serializing 501 network round trips. + for (let offset = 0; offset < 501; offset += 16) { + await Promise.all( + Array.from({ length: Math.min(16, 501 - offset) }, (_, index) => + sendEnvelope(receiver.port, `retained-${offset + index}`), + ), + ); + } renderViewer(receiver.port, "/explorer/events"); - await screen.findByText( - /Retention limit: 1,000 envelopes · 501 displayed/, + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + // Role queries over hundreds of aria-hidden background rows are costly in + // jsdom and can starve this real receiver's request timeout on CI workers. + const settings = within(screen.getByRole("dialog", { name: "Settings" })); + await settings.findByText( + /501 envelopes currently retained/, {}, { timeout: 10000 }, ); - fireEvent.click(screen.getByText(/Retention limit: 1,000 envelopes/)); - fireEvent.change(screen.getByLabelText("Retention limit"), { + fireEvent.change(settings.getByLabelText("Envelope limit"), { target: { value: "2000" }, }); fireEvent.click( - screen.getByRole("button", { name: "Apply retention limit" }), - ); - await screen.findByText( - /Retention limit: 2,000 envelopes · 501 displayed/, + settings.getByRole("button", { name: "Apply envelope limit" }), ); + expect( + ( + (await settings.findByRole("button", { + name: "Applying…", + })) as HTMLButtonElement + ).disabled, + ).toBe(true); + // The edited input is already 2000 while the HTTP mutation is pending. + expect( + (settings.getByLabelText("Envelope limit") as HTMLInputElement).value, + ).toBe("2000"); + expect(retention.getSettings().maxEnvelopes).toBe(1000); + // Await completion, allowing the receiver's five-second request timeout + // plus scheduling overhead, before checking its committed configuration. + const apply = await settings.findByRole( + "button", + { name: "Apply envelope limit" }, + { timeout: 6000 }, + ); + expect((apply as HTMLButtonElement).disabled).toBe(false); + expect(settings.queryByRole("alert")).toBeNull(); + expect( + settings.getByText(/501 envelopes currently retained/), + ).not.toBeNull(); expect(retention.getSettings()).toEqual({ maxEnvelopes: 2000, retainedEnvelopes: 501, }); await sendEnvelope(receiver.port, "after-growth"); - await screen.findByText( - /Retention limit: 2,000 envelopes · 502 displayed/, + await settings.findByText( + /502 envelopes currently retained/, + {}, + { timeout: 10000 }, ); } finally { cleanup(); await stopReceiver(receiver.server); } - }, 20000); + }, 60000); test("updates live retention without clearing events or replacing the stream", async () => { ControllableEventSource.instances = []; @@ -989,22 +1865,24 @@ describe("local receiver to viewer integration", () => { }), ); }); - fireEvent.click(await screen.findByText(/Retention limit: 500 envelopes/)); - fireEvent.change(screen.getByLabelText("Retention limit"), { + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + fireEvent.change(screen.getByLabelText("Envelope limit"), { target: { value: "1" }, }); fireEvent.click( - screen.getByRole("button", { name: "Apply retention limit" }), + screen.getByRole("button", { name: "Apply envelope limit" }), ); await screen.findByText( - /Cannot lower the limit below 2 displayed envelopes/, + /Cannot lower the limit below 2 retained envelopes/, ); expect(fetchMock).not.toHaveBeenCalled(); - fireEvent.change(screen.getByLabelText("Retention limit"), { + fireEvent.change(screen.getByLabelText("Envelope limit"), { target: { value: "1000" }, }); fireEvent.click( - screen.getByRole("button", { name: "Apply retention limit" }), + screen.getByRole("button", { name: "Apply envelope limit" }), ); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); await act(async () => @@ -1014,7 +1892,21 @@ describe("local receiver to viewer integration", () => { }), ), ); - await screen.findByText(/Retention limit: 1,000 envelopes/); + await waitFor(() => + expect( + (screen.getByLabelText("Envelope limit") as HTMLInputElement).value, + ).toBe("1000"), + ); + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Settings", exact: true }), + ), + ); + const clearButton = captureAction("Clear view"); + fireEvent.focus(clearButton); + expect(document.activeElement).toBe(clearButton); + fireEvent.blur(clearButton); expect( screen.getAllByRole("button", { name: "View event event" }), ).toHaveLength(2); @@ -1046,17 +1938,68 @@ describe("local receiver to viewer integration", () => { source?.onerror?.(new Event("error")); }); fireEvent.click( - await screen.findByText(/Retention limit: 1,000 envelopes/), + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + await screen.findByText( + "Reconnect to this receiver to change history capacity.", ); - await screen.findByText("Reconnect to this receiver to change retention."); expect( - screen.queryByRole("button", { name: "Apply retention limit" }), + screen.queryByRole("button", { name: "Apply envelope limit" }), ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); expect( screen.getByRole("button", { name: "View event event" }), ).not.toBeNull(); }); + test("keeps closed retention read-only while its archive replay connects and opens", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + window.localStorage.setItem( + STREAM_STORAGE_KEY, + "http://127.0.0.1:8969/_local/v1/sessions/closed-run/stream?cap=read", + ); + renderBareViewer(); + const source = ControllableEventSource.instances[0]!; + const retained = () => + new MessageEvent("sentry.local.session", { + data: '{"state":"retained"}', + }); + await act(async () => { + source.onopen?.(new Event("open")); + source.dispatchEvent( + new MessageEvent("sentry.local.retention", { + data: '{"maxEnvelopes":1000}', + }), + ); + source.dispatchEvent(retained()); + }); + await screen.findByText("Saved capture", { exact: true }); + fireEvent.click(screen.getByRole("button", { name: "Reload capture" })); + const replay = ControllableEventSource.instances.at(-1)!; + expect(replay).not.toBe(source); + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + const assertReadOnly = () => { + expect( + screen.getByText("History capacity is read-only for saved captures."), + ).not.toBeNull(); + expect(screen.queryByLabelText("Envelope limit")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Apply envelope limit" }), + ).toBeNull(); + }; + assertReadOnly(); + await act(async () => replay.onopen?.(new Event("open"))); + assertReadOnly(); + await act(async () => replay.dispatchEvent(retained())); + assertReadOnly(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + test.each([false, true])( "ignores delayed retention responses after newer metadata (switch receiver: %s)", async (switchReceiver) => { @@ -1079,24 +2022,27 @@ describe("local receiver to viewer integration", () => { ); }); fireEvent.click( - await screen.findByText(/Retention limit: 500 envelopes/), + await screen.findByRole("button", { name: "Settings", exact: true }), ); - fireEvent.change(screen.getByLabelText("Retention limit"), { + fireEvent.change(screen.getByLabelText("Envelope limit"), { target: { value: "1" }, }); fireEvent.click( - screen.getByRole("button", { name: "Apply retention limit" }), + screen.getByRole("button", { name: "Apply envelope limit" }), ); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); if (switchReceiver) { fireEvent.click( - screen.getByRole("button", { name: "Change receiver" }), + screen.getByRole("button", { name: "Close", exact: true }), + ); + fireEvent.click( + screen.getByRole("button", { name: "Switch 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]; + source = await openLatestReceiver(); } await act(async () => { source?.onopen?.(new Event("open")); @@ -1108,7 +2054,18 @@ describe("local receiver to viewer integration", () => { respond(new Response('{"maxEnvelopes":1}', { status: 200 })); await pending; }); - await screen.findByText(/Retention limit: 1,000 envelopes/); + if (switchReceiver) + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + await waitFor(() => + expect( + (screen.getByLabelText("Envelope limit") as HTMLInputElement).value, + ).toBe("1000"), + ); + fireEvent.click( + screen.getByRole("button", { name: "Close", exact: true }), + ); await act(async () => { for (const id of ["one", "two"]) source?.dispatchEvent( @@ -1144,15 +2101,13 @@ describe("local receiver to viewer integration", () => { ); async function connect(url: string) { fireEvent.click( - screen.getAllByRole("button", { name: /^Change receiver/ })[0]!, + screen.getAllByRole("button", { name: /^Switch receiver/ })[0]!, ); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: url }, }); fireEvent.click(screen.getByRole("button", { name: "Connect" })); - const source = ControllableEventSource.instances.at(-1)!; - await act(async () => source.onopen?.(new Event("open"))); - return source; + return await openLatestReceiver(); } const firstUrl = "http://127.0.0.1:8969/_local/v1/sessions/first/stream?cap=read"; @@ -1168,14 +2123,8 @@ describe("local receiver to viewer integration", () => { }), ), ); - fireEvent.click( - screen.getByRole("button", { - name: - action === "clear error" - ? "Clear events" - : "Close telemetry session", - }), - ); + if (action === "clear error") confirmDeleteCapturedEvents(); + else fireEvent.click(captureAction("Stop capture")); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); await connect("http://127.0.0.1:8970/stream"); const current = await connect(firstUrl); @@ -1189,11 +2138,11 @@ describe("local receiver to viewer integration", () => { }); expect(current.closed).toBe(false); expect( - screen.queryByText("Could not close this telemetry session."), + screen.queryByText("Could not stop this capture. Try again."), ).toBeNull(); expect( screen.queryByText( - "Cleared this viewer, but the receiver could not clear its retained session.", + "This view is empty, but the receiver could not delete its captured events.", ), ).toBeNull(); await act(async () => @@ -1211,11 +2160,55 @@ describe("local receiver to viewer integration", () => { await screen.findByRole("button", { name: "View event event" }), ).not.toBeNull(); expect( - screen.queryByRole("heading", { name: "Connect a receiver" }), + screen.queryByRole("heading", { name: "Capture a local session" }), ).toBeNull(); }, ); + test("closing a session preserves final envelopes when HTTP completes before the stream", async () => { + ControllableEventSource.instances = []; + vi.stubGlobal("EventSource", ControllableEventSource); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + window.localStorage.setItem( + STREAM_STORAGE_KEY, + "http://127.0.0.1:8969/_local/v1/sessions/closing-run/stream?cap=read", + ); + renderBareViewer(); + await act(async () => + ControllableEventSource.instances[0]!.onopen?.(new Event("open")), + ); + fireEvent.click(captureAction("Stop capture")); + await screen.findByText("Saved capture", { exact: true }); + // The close response can overtake SSE delivery. The current feed must still + // consume the final archive before its terminal lifecycle notification. + const finalSource = ControllableEventSource.instances.at(-1)!; + await act(async () => { + finalSource.onopen?.(new Event("open")); + finalSource.dispatchEvent( + new MessageEvent(SENTRY_CONTENT_TYPE, { + data: JSON.stringify([ + {}, + [[{ type: "event" }, { message: "final accepted event" }]], + ]), + lastEventId: "final-envelope", + }), + ); + finalSource.dispatchEvent( + new MessageEvent("sentry.local.session", { + data: '{"state":"retained"}', + }), + ); + }); + expect(await screen.findAllByText("final accepted event")).not.toHaveLength( + 0, + ); + expect(finalSource.closed).toBe(true); + expect(screen.queryByRole("alert")).toBeNull(); + }); + test.each([204, 403])( "handles scoped reset HTTP %s and closes a telemetry session", async (resetStatus) => { @@ -1231,7 +2224,7 @@ describe("local receiver to viewer integration", () => { const initialSource = ControllableEventSource.instances[0]; expect(initialSource).toBeDefined(); await act(async () => initialSource?.onopen?.(new Event("open"))); - fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.click(screen.getByRole("button", { name: "Switch receiver" })); const streamUrl = "http://127.0.0.1:8969/_local/v1/sessions/session-1/stream?cap=read-capability"; @@ -1240,9 +2233,7 @@ describe("local receiver to viewer integration", () => { }); fireEvent.click(screen.getByRole("button", { name: "Connect" })); - const scopedSource = ControllableEventSource.instances[1]; - expect(scopedSource).toBeDefined(); - await act(async () => scopedSource?.onopen?.(new Event("open"))); + const scopedSource = await openLatestReceiver(); await act(async () => { scopedSource?.dispatchEvent( new MessageEvent(SENTRY_CONTENT_TYPE, { @@ -1252,33 +2243,61 @@ describe("local receiver to viewer integration", () => { ); }); - const clearButton = screen.getByRole("button", { name: "Clear events" }); + const clearButton = captureAction("Delete captured events"); await waitFor(() => expect((clearButton as HTMLButtonElement).disabled).toBe(false), ); fireEvent.click(clearButton); + fireEvent.click( + screen.getByRole("button", { + name: "Delete captured events", + exact: true, + }), + ); 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" }); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: "POST", + signal: expect.any(AbortSignal), + }), + ); if (resetStatus !== 204) { await screen.findByText( - "Cleared this viewer, but the receiver could not clear its retained session.", + "This view is empty, but the receiver could not delete its captured events.", ); } - fireEvent.click( - screen.getByRole("button", { name: "Close telemetry session" }), - ); + fireEvent.click(captureAction("Stop capture")); 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" }); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: "POST", + signal: expect.any(AbortSignal), + }), + ); await waitFor(() => expect(scopedSource?.closed).toBe(true)); - await screen.findByRole("heading", { name: "Connect a receiver" }); - expect(screen.queryByText("Waiting for events")).toBeNull(); + await screen.findByText("Saved capture", { exact: true }); + expect( + screen.queryByText( + "Telemetry session closed. Retained events remain available for three hours.", + ), + ).toBeNull(); + expect(screen.getByLabelText("Workspace navigation")).not.toBeNull(); + expect(screen.queryByLabelText("Receiver endpoint")).toBeNull(); + expect( + screen.getByRole("button", { name: "Reload capture" }), + ).not.toBeNull(); + fireEvent.click(captureAction("Disconnect viewer")); + await screen.findByRole("heading", { name: "Capture a local session" }); + expect( + screen.getByRole("region", { name: "Past connections" }), + ).not.toBeNull(); expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull(); await waitFor(() => expect( @@ -1306,9 +2325,7 @@ describe("local receiver to viewer integration", () => { }), ); }); - fireEvent.click( - screen.getByRole("button", { name: "Open envelopes view" }), - ); + fireEvent.click(viewButton("Open envelopes view")); fireEvent.click(await screen.findByLabelText("View envelope event")); await act(async () => source?.onerror?.(new Event("error"))); @@ -1318,12 +2335,12 @@ 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.getByRole("searchbox", { name: "Filter Envelopes" }), + { + target: { value: "unmatched-envelope" }, + }, ); - 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", @@ -1347,7 +2364,7 @@ describe("local receiver to viewer integration", () => { await act(async () => ControllableEventSource.instances[0]?.onopen?.(new Event("open")), ); - fireEvent.click(screen.getByRole("button", { name: "Change receiver" })); + fireEvent.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: @@ -1355,9 +2372,8 @@ describe("local receiver to viewer integration", () => { }, }); fireEvent.click(screen.getByRole("button", { name: "Connect" })); - const source = ControllableEventSource.instances[1]; + const source = await openLatestReceiver(); await act(async () => { - source?.onopen?.(new Event("open")); for (const transaction of ["GET /alpha", "GET /beta"]) { source?.dispatchEvent( new MessageEvent(SENTRY_CONTENT_TYPE, { @@ -1375,35 +2391,50 @@ describe("local receiver to viewer integration", () => { 2, ), ); - fireEvent.click( - screen.getAllByRole("button", { name: "Search events" })[0]!, + fireEvent.change( + screen.getByRole("searchbox", { name: "Filter Events" }), + { + target: { value: "alpha" }, + }, ); - 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(captureAction("Stop capture")); + await waitFor(() => expect(source?.closed).toBe(true)); + await screen.findByText("Saved capture", { exact: true }); + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, + ); + expect( + screen.queryByRole("button", { name: "Stop capture" }), + ).toBeNull(); fireEvent.click( - screen.getByRole("button", { name: "Close telemetry session" }), + screen.getByRole("button", { + name: "Reload capture", + exact: true, + }), + ); + const replay = await openLatestReceiver(); + await act(async () => replay.onerror?.(new Event("error"))); + expect(screen.getByRole("alert").textContent).toContain( + "Could not connect to this receiver", + ); + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 1, ); - 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.getByRole("searchbox", { name: "Filter Events" }), + { + target: { value: "" }, + }, ); - fireEvent.change(screen.getByPlaceholderText("Search events and views"), { - target: { value: "" }, - }); await waitFor(() => { expect(screen.getAllByLabelText("View transaction event")).toHaveLength( 2, @@ -1423,7 +2454,7 @@ describe("local receiver to viewer integration", () => { 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.", + "Adjust the filter or clear it to see all traces in this capture.", ), ).not.toBeNull(); } finally { @@ -1432,7 +2463,7 @@ describe("local receiver to viewer integration", () => { } }); - test("omits redundant type pills from dedicated Errors and Envelopes lists", async () => { + test("distinguishes request types in Errors and keeps envelopes quiet", async () => { const { server, port } = await startReceiver(); try { @@ -1445,13 +2476,11 @@ describe("local receiver to viewer integration", () => { 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 event event").textContent).toContain( + "Exception", ); + + fireEvent.click(viewButton("Open envelopes view")); expect( screen.getByLabelText("View envelope event").textContent, ).not.toContain("envelope"); @@ -1474,7 +2503,7 @@ describe("local receiver to viewer integration", () => { expect( (await screen.findByLabelText("View event event")).textContent, - ).toContain("Error"); + ).toContain("Exception"); } finally { cleanup(); await stopReceiver(server); @@ -1508,48 +2537,43 @@ describe("local receiver to viewer integration", () => { } }); - test("filters retained events from the global command search", async () => { + test("session search does not filter the workspace until explicitly requested", 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(() => { + await waitFor(() => expect(screen.getAllByLabelText("View transaction event")).toHaveLength( 2, - ); - }); + ), + ); fireEvent.click( - screen.getAllByRole("button", { name: "Search events" })[0]!, + screen.getAllByRole("button", { name: "Search this session" })[0]!, ); fireEvent.change(screen.getByPlaceholderText("Search events and views"), { target: { value: "customers" }, }); - - expect(screen.getAllByLabelText("View transaction event")).toHaveLength( - 2, - ); + expect(screen.getByTestId("event-list").textContent).toContain("/orders"); expect(new URLSearchParams(window.location.search).get("q")).toBeNull(); - - await waitFor(() => { + fireEvent.click( + screen.getByRole("option", { name: /Filter Events for “customers”/ }), + ); + 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", + ); + fireEvent.click(screen.getByRole("button", { name: "Clear filter" })); + await waitFor(() => + expect(screen.getAllByLabelText("View transaction event")).toHaveLength( + 2, + ), ); - await waitFor(() => { - expect(new URLSearchParams(window.location.search).get("q")).toBe( - "customers", - ); - }); } finally { cleanup(); await stopReceiver(server); @@ -1585,7 +2609,7 @@ describe("local receiver to viewer integration", () => { renderViewer(port, "/?q=old-search"); await screen.findByText("Connected to local receiver"); await screen.findByLabelText("View transaction event"); - fireEvent.click(screen.getByRole("button", { name: "Clear events" })); + fireEvent.click(captureAction("Clear view")); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 500)); }); @@ -1601,6 +2625,51 @@ describe("local receiver to viewer integration", () => { } }); + test("span drill-down within Traces preserves its active view filter", async () => { + const { server, port } = await startReceiver(); + try { + await sendEnvelope(port, "GET /alpha", { + traceId: "trace-alpha", + spanId: "root-alpha", + startTimestamp: 1_699_999_999, + spans: [ + { + span_id: "child-alpha", + parent_span_id: "root-alpha", + op: "db.query", + description: "SELECT alpha", + start_timestamp: 1_699_999_999.1, + timestamp: 1_699_999_999.2, + }, + ], + }); + await sendEnvelope(port, "GET /beta", { traceId: "trace-beta" }); + renderViewer(port, "/traces?q=alpha"); + const span = await screen.findByRole("button", { + name: "Inspect span SELECT alpha", + }); + fireEvent.click(span); + await screen.findByRole("button", { name: "Back to trace" }); + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "alpha", + ); + expect(new URLSearchParams(window.location.search).get("span")).toBe( + "child-alpha", + ); + expect( + screen.queryByRole("button", { name: "View trace GET /beta" }), + ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Back to trace" })); + await screen.findByRole("button", { name: "Inspect span SELECT alpha" }); + expect(new URLSearchParams(window.location.search).get("q")).toBe( + "alpha", + ); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + test("filters trace groups with global search while retaining their full waterfall", async () => { const { server, port } = await startReceiver(); try { @@ -1637,9 +2706,7 @@ describe("local receiver to viewer integration", () => { renderViewer(first.port); await screen.findByText("Connected to local receiver"); await screen.findByLabelText("View transaction event"); - fireEvent.click( - screen.getByRole("button", { name: "Change receiver connection" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.click(screen.getByRole("button", { name: "Connect" })); await screen.findByText("Connected to local receiver"); expect(screen.getByTestId("event-list").textContent).toContain( @@ -1648,9 +2715,7 @@ describe("local receiver to viewer integration", () => { expect(screen.getAllByLabelText("View transaction event")).toHaveLength( 1, ); - fireEvent.click( - screen.getByRole("button", { name: "Change receiver connection" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Switch receiver" })); fireEvent.change(screen.getByLabelText("Receiver endpoint"), { target: { value: `http://127.0.0.1:${second.port}/stream` }, }); @@ -1787,7 +2852,7 @@ describe("local receiver to viewer integration", () => { await screen.findByLabelText("View event event"); fireEvent.click( - screen.getAllByRole("button", { name: "Search events" })[0]!, + screen.getAllByRole("button", { name: "Search this session" })[0]!, ); fireEvent.change(screen.getByPlaceholderText("Search events and views"), { target: { value: "broken" }, @@ -1810,7 +2875,7 @@ describe("local receiver to viewer integration", () => { await waitFor(() => { expect( screen - .getByRole("button", { name: "Open live activity view" }) + .getByRole("button", { name: "Open events view" }) .getAttribute("aria-current"), ).toBe("page"); }); @@ -1820,6 +2885,88 @@ describe("local receiver to viewer integration", () => { } }); + test.each(["logger", "severity"])( + "command navigation reveals an explicit log excluded by its %s facet", + async (facet) => { + const { server, port } = await startReceiver(); + try { + await sendLog(port, "Selected log A", "logger-a"); + await sendLog(port, "Destination log B", "logger-b", "warning"); + renderViewer(port, "/logs"); + await screen.findByText("Connected to local receiver"); + await waitFor(() => + expect(screen.getAllByLabelText("View log event")).toHaveLength(2), + ); + fireEvent.click(screen.getByRole("button", { name: "Filters" })); + fireEvent.click( + screen.getByRole("checkbox", { + name: facet === "logger" ? "logger-a" : "info", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Filters (1)" })); + expect(screen.getAllByLabelText("View log event")).toHaveLength(1); + fireEvent.click( + screen.getAllByRole("button", { name: "Search this session" })[0]!, + ); + fireEvent.change( + screen.getByPlaceholderText("Search events and views"), + { target: { value: "Destination log B" } }, + ); + fireEvent.click( + screen.getByRole("option", { name: /^Destination log B/ }), + ); + await waitFor(() => + expect(screen.getByTestId("event-detail").textContent).toContain( + "Destination log B", + ), + ); + expect( + screen.queryByRole("button", { name: "Clear 1 filter" }), + ).toBeNull(); + expect( + new URLSearchParams(window.location.search).get("event"), + ).not.toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }, + ); + + test("new log badges and actions honor active logger and severity facets", async () => { + const { server, port } = await startReceiver(); + try { + await sendLog(port, "Selected log", "logger-a"); + renderViewer(port, "/logs"); + await screen.findByText("Connected to local receiver"); + fireEvent.click(await screen.findByLabelText("View log event")); + fireEvent.click(screen.getByRole("button", { name: "Filters" })); + fireEvent.click(screen.getByRole("checkbox", { name: "logger-a" })); + fireEvent.click(screen.getByRole("checkbox", { name: "info" })); + fireEvent.click(screen.getByRole("button", { name: "Filters (2)" })); + await sendLog(port, "Visible new log", "logger-a"); + await sendLog(port, "Hidden logger log", "logger-b"); + await sendLog(port, "Hidden severity log", "logger-a", "warning"); + await screen.findByRole("button", { name: "View 1 new log" }); + // Wait for the final arrival too: its severity becomes a filter option. + fireEvent.click(screen.getByRole("button", { name: "Filters (2)" })); + await screen.findByRole("checkbox", { name: "warning" }); + fireEvent.click(screen.getByRole("button", { name: "Filters (2)" })); + fireEvent.click(screen.getByRole("button", { name: "View 1 new log" })); + await waitFor(() => + expect(screen.getByTestId("event-detail").textContent).toContain( + "Visible new log", + ), + ); + expect( + screen.queryByRole("button", { name: /View \d+ new log/ }), + ).toBeNull(); + } finally { + cleanup(); + await stopReceiver(server); + } + }); + test("clears the local viewer feed from the sidebar", async () => { const { server, port } = await startReceiver(); @@ -1829,10 +2976,10 @@ describe("local receiver to viewer integration", () => { await sendEnvelope(port, "GET /clear-me"); await screen.findByLabelText("View transaction event"); - fireEvent.click(screen.getByRole("button", { name: "Clear events" })); + fireEvent.click(captureAction("Clear view")); expect(screen.queryByLabelText("View transaction event")).toBeNull(); - expect(screen.getByText("Waiting for events")).not.toBeNull(); + expect(screen.getByText("No events displayed")).not.toBeNull(); } finally { cleanup(); await stopReceiver(server); @@ -1914,12 +3061,10 @@ describe("local receiver to viewer integration", () => { traceId: "summary-trace", }); - const details = await screen.findByRole("table", { - name: "Event details", - }); + const details = await screen.findByLabelText("Request summary"); expect(details.textContent).toContain("201"); expect(details.textContent).toContain("6.17ms"); - expect(details.textContent).not.toContain("Method"); + expect(details.textContent).toContain("Method"); expect(details.textContent).not.toContain("Route"); expect(details.textContent).not.toContain("Trace ID"); expect(details.textContent).not.toContain("Operation"); @@ -2032,7 +3177,9 @@ describe("local receiver to viewer integration", () => { ).toBeGreaterThan(0); expect(screen.getByText("1 trace")).not.toBeNull(); const traceList = screen.getByLabelText("Trace list"); - expect(traceList.className).toContain("w-80"); + expect( + screen.getByRole("separator", { name: "Resize Trace list" }), + ).not.toBeNull(); expect(traceList.className).toContain("bg-muted/30"); const traceRow = screen.getByRole("button", { @@ -2067,15 +3214,13 @@ describe("local receiver to viewer integration", () => { ).not.toBeNull(); expect(screen.getByText("1 error")).not.toBeNull(); - fireEvent.click( - screen.getByRole("button", { name: "Open feedback view" }), - ); + fireEvent.click(viewButton("Open feedback view")); expect( - await screen.findByRole("heading", { name: "No feedback received" }), + await screen.findByRole("heading", { name: "No feedback captured" }), ).not.toBeNull(); expect( screen.getByText( - "User feedback submitted through supported SDKs will appear here.", + "Submit user feedback through a supported SDK to this receiver.", ), ).not.toBeNull(); } finally { diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx new file mode 100644 index 000000000..389fa8d6d --- /dev/null +++ b/apps/local/src/App.sessions.integration.test.tsx @@ -0,0 +1,748 @@ +import { SessionSwitcher } from "./components/session-switcher.tsx"; +// @vitest-environment-options {"url":"https://local.sentry.dev"} +import { createServer } from "node:net"; +import { EventSource as NodeEventSource } from "eventsource"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + 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 { LocalWorkspaceRoute } from "./routes/local-workspace-route.tsx"; +import { createLocalControlPlane } from "../../../packages/cli/src/commands/local/control-plane.ts"; +import { createLocalRetentionBuffer } from "../../../packages/cli/src/commands/local/retention-buffer.ts"; +import { + buildApp, + tryListen, +} from "../../../packages/cli/src/commands/local/server.ts"; +import { + createLocalSessionStore, + SESSION_RETENTION_MS, +} from "../../../packages/cli/src/commands/local/session-store.ts"; +import App from "./App.tsx"; + +const headers = { Authorization: "Bearer session-switcher-test" }; +async function startDaemon(now = Date.now, preferredPort = 0) { + const reservation = createServer(); + await new Promise((r) => + reservation.listen(preferredPort, "127.0.0.1", r), + ); + const address = reservation.address(); + if (!address || typeof address === "string") + throw new Error("Expected TCP address"); + const port = address.port; + await new Promise((r) => reservation.close(() => r())); + const plane = createLocalControlPlane({ + controlToken: "session-switcher-test", + host: "127.0.0.1", + port, + sessionStore: createLocalSessionStore({ now }), + }); + const { server } = await tryListen(plane.app, port, "127.0.0.1"); + const create = async (label: string, maxEnvelopes = 500) => { + const response = await fetch( + `http://127.0.0.1:${port}/_local/v1/sessions`, + { + method: "POST", + headers, + body: JSON.stringify({ + label, + cwd: `/projects/${label}`, + maxEnvelopes, + }), + }, + ); + return await (response.json() as Promise<{ + id: string; + streamUrl: string; + ingestUrl: string; + directoryUrl: string; + }>); + }; + const ingest = async ( + session: { ingestUrl: string }, + transaction: string, + ) => { + expect( + ( + await fetch(session.ingestUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: `${JSON.stringify({ event_id: crypto.randomUUID() })}\n{"type":"transaction"}\n${JSON.stringify({ transaction })}\n`, + }) + ).status, + ).toBe(204); + }; + return { + port, + plane, + create, + ingest, + stop: () => + new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + }; +} +function renderSession( + session: { streamUrl: string; directoryUrl?: string }, + fromQuery = false, +) { + window.history.replaceState( + null, + "", + fromQuery + ? `/?${new URLSearchParams({ url: session.streamUrl })}` + : `/#${new URLSearchParams({ stream: session.streamUrl, ...(session.directoryUrl ? { directory: session.directoryUrl } : {}) })}`, + ); + return renderViewer(); +} +function renderViewer() { + return render( + + + + + + + + + , + ); +} +async function selectSession(label: string) { + if (!screen.queryByRole("listbox")) + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + fireEvent.click( + await screen.findByRole("option", { name: new RegExp(`^${label} ·`) }), + ); +} + +function captureAction(name: string): HTMLButtonElement { + let action = screen.queryByRole("button", { name, exact: true }); + if (!action) { + fireEvent.click( + screen.getAllByRole("button", { + name: "Capture actions", + exact: true, + })[0]!, + ); + action = screen.getByRole("button", { name, exact: true }); + } + return action as HTMLButtonElement; +} + +beforeEach(() => { + render({null}).unmount(); + window.localStorage.clear(); + window.sessionStorage.clear(); + vi.stubGlobal("EventSource", NodeEventSource); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + media: "", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + })); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); +}); + +test("hosted viewer switches live sessions without mixing events and replays retained data", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + const worker = await daemon.create("worker", 1000); + await daemon.ingest(web, "/web-request"); + await daemon.ingest(worker, "/worker-job"); + expect(window.location.origin).toBe("https://local.sentry.dev"); + renderSession(web); + await screen.findAllByText("/web-request"); + const picker = await screen.findByRole("combobox", { name: "Session" }); + expect(window.location.hash).toBe(""); + expect(screen.queryByText("/worker-job")).toBeNull(); + await selectSession("worker"); + await screen.findAllByText("/worker-job"); + expect(screen.queryByText("/web-request")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + const limit = (await screen.findByLabelText( + "Envelope limit", + )) as HTMLInputElement; + expect(limit.value).toBe("1000"); + fireEvent.change(limit, { target: { value: "2000" } }); + fireEvent.click( + screen.getByRole("button", { name: "Apply envelope limit" }), + ); + await waitFor(() => + expect(daemon.plane.sessionStore.resolve(worker.id).maxEnvelopes).toBe( + 2000, + ), + ); + await waitFor(() => expect(limit.value).toBe("2000")); + expect( + daemon.plane.sessionStore.getBuffer(worker.id).read({ all: true }), + ).toHaveLength(1); + expect(screen.queryByRole("button", { name: "Stop capture" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); + await daemon.ingest(web, "/web-after-switch"); + await daemon.ingest(worker, "/worker-after-switch"); + await screen.findAllByText("/worker-after-switch"); + expect(screen.queryByText("/web-after-switch")).toBeNull(); + await selectSession("web"); + await screen.findAllByText("/web-after-switch"); + await screen.findAllByText("/web-request"); + expect(screen.queryByText("/worker-job")).toBeNull(); + const api = await daemon.create("api"); + fireEvent.click(picker); + await screen.findByRole( + "option", + { name: /api.*Capturing/ }, + { timeout: 7000 }, + ); + const search = screen.getByRole("combobox", { + name: "Search sessions", + exact: true, + }); + fireEvent.change(search, { target: { value: "api" } }); + await waitFor(() => + expect(screen.queryByRole("option", { name: /^worker ·/ })).toBeNull(), + ); + fireEvent.keyDown(search, { key: "Enter" }); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(picker)); + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("api"); + daemon.plane.sessionStore.close(worker.id); + fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" })); + fireEvent.click(picker); + await screen.findByRole("option", { name: /worker.*Saved/ }); + fireEvent.keyDown( + screen.getByRole("combobox", { name: "Search sessions", exact: true }), + { key: "Escape" }, + ); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(picker)); + await selectSession("worker"); + await screen.findAllByText("/worker-job"); + await screen.findAllByText("/worker-after-switch"); + expect( + daemon.plane.sessionStore.getBuffer(web.id).read({ all: true }), + ).toHaveLength(2); + expect( + daemon.plane.sessionStore.getBuffer(worker.id).read({ all: true }), + ).toHaveLength(2); + expect(daemon.plane.sessionStore.resolve(api.id).state).toBe("open"); + cleanup(); + renderSession({ streamUrl: worker.streamUrl }); + await screen.findByRole("combobox", { name: "Session" }); + await screen.findAllByText("/worker-job"); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("a discovery failure keeps the selected feed readable and can be retried", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + await daemon.ingest(web, "/still-readable"); + const directory = new URL(web.directoryUrl); + directory.searchParams.set("cap", "expired"); + renderSession({ ...web, directoryUrl: directory.toString() }); + await screen.findAllByText("/still-readable"); + await screen.findByText(/Could not refresh sessions/); + fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" })); + await waitFor(() => + expect(screen.getAllByText("/still-readable").length).toBeGreaterThan(0), + ); + expect( + daemon.plane.sessionStore.getBuffer(web.id).read({ all: true }), + ).toHaveLength(1); + } finally { + cleanup(); + await daemon.stop(); + } +}); + +test("a scoped stream alone never enables sibling discovery", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + await daemon.ingest(web, "/scoped-only"); + renderSession({ streamUrl: web.streamUrl }); + await screen.findAllByText("/scoped-only"); + expect(screen.queryByRole("combobox", { name: "Session" })).toBeNull(); + } finally { + cleanup(); + await daemon.stop(); + } +}); + +test("hosted viewer offers tab-local Clear view while delete and stop stay unavailable", async () => { + const daemon = await startDaemon(); + let restoreFetch: (() => void) | undefined; + try { + const web = await daemon.create("web"); + await daemon.ingest(web, "/hosted-private-history"); + renderSession({ streamUrl: web.streamUrl }); + await screen.findAllByText("/hosted-private-history"); + expect(window.location.origin).toBe("https://local.sentry.dev"); + const requests = vi.spyOn(globalThis, "fetch"); + restoreFetch = () => requests.mockRestore(); + fireEvent.click( + screen.getAllByRole("button", { + name: "Capture actions", + exact: true, + })[0]!, + ); + expect( + screen.queryByRole("button", { + name: "Delete captured events", + exact: true, + }), + ).toBeNull(); + expect( + screen.queryByRole("button", { name: "Stop capture", exact: true }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Clear view", exact: true }), + ); + await screen.findByRole("heading", { name: "No events displayed" }); + expect(requests).not.toHaveBeenCalled(); + expect( + daemon.plane.sessionStore.getBuffer(web.id).read({ all: true }), + ).toHaveLength(1); + expect(daemon.plane.sessionStore.resolve(web.id).state).toBe("open"); + } finally { + restoreFetch?.(); + cleanup(); + await daemon.stop(); + } +}); + +test("expired sessions disappear without clearing already displayed telemetry", async () => { + let now = 1000; + const daemon = await startDaemon(() => now); + try { + const web = await daemon.create("web"); + await daemon.ingest(web, "/retained-in-viewer"); + renderSession(web); + await screen.findAllByText("/retained-in-viewer"); + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + await screen.findByRole("option", { name: /web.*Capturing/ }); + await selectSession("web"); + daemon.plane.sessionStore.close(web.id); + now += SESSION_RETENTION_MS; + fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" })); + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + await screen.findByText("No sessions available."); + expect(screen.getAllByText("/retained-in-viewer").length).toBeGreaterThan( + 0, + ); + } finally { + cleanup(); + await daemon.stop(); + } +}); + +test("pasting a plain receiver discovers active sessions and waits for newly started runs", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + const worker = await daemon.create("worker"); + await daemon.ingest(web, "/plain-web"); + await daemon.ingest(worker, "/plain-worker"); + const stream = new URL("/stream", web.streamUrl).toString(); + renderSession({ streamUrl: web.streamUrl }); + await screen.findAllByText("/plain-web"); + expect( + screen.queryByRole("combobox", { name: "Session", exact: true }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Switch receiver", + exact: true, + }), + ); + // The prefilled display address is identical; clear it to make this an explicit edit. + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: stream }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await screen.findByRole("combobox", { name: "Session", exact: true }); + await selectSession("worker"); + await screen.findAllByText("/plain-worker"); + expect(screen.queryByText("/plain-web")).toBeNull(); + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("worker"); + expect(screen.queryByText("Envelope limit")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + await screen.findByRole("dialog", { name: "Settings", exact: true }); + expect( + ((await screen.findByLabelText("Envelope limit")) as HTMLInputElement) + .value, + ).toBe("500"); + expect( + screen.getByRole("button", { name: "Apply envelope limit" }), + ).not.toBeNull(); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("an empty receiver discovers the first session when it starts without reconnecting manually", async () => { + let now = Date.now(); + const daemon = await startDaemon(() => now); + try { + const reservation = await daemon.create("expired"); + daemon.plane.sessionStore.close(reservation.id); + now += SESSION_RETENTION_MS + 1; + daemon.plane.sessionStore.pruneExpired(); + renderSession({ + streamUrl: new URL("/stream", reservation.streamUrl).toString(), + }); + await screen.findByRole("combobox", { name: "Session", exact: true }); + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + await screen.findByText("No sessions available."); + const web = await daemon.create("new-run"); + await daemon.ingest(web, "/late-start"); + fireEvent.keyDown( + screen.getByRole("combobox", { name: "Search sessions", exact: true }), + { key: "Escape" }, + ); + fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" })); + await screen.findAllByText("/late-start"); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("reconnecting a plain receiver after daemon restart never selects a stale directory entry", async () => { + const first = await startDaemon(); + let second: Awaited> | undefined; + try { + const old = await first.create("old-run"); + await first.ingest(old, "/before-restart"); + renderSession(old); + await screen.findAllByText("/before-restart"); + await screen.findByRole("combobox", { name: "Session", exact: true }); + fireEvent.click( + screen.getByRole("combobox", { name: "Session", exact: true }), + ); + await screen.findByRole("option", { name: /^old-run ·/ }); + fireEvent.keyDown( + screen.getByRole("combobox", { name: "Search sessions", exact: true }), + { key: "Escape" }, + ); + await first.stop(); + await screen.findByText("Receiver unavailable", { exact: true }); + second = await startDaemon(Date.now, first.port); + const current = await second.create("current-run"); + await second.ingest(current, "/after-restart"); + fireEvent.click( + screen.getByRole("button", { + name: "Switch receiver", + exact: true, + }), + ); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: "" }, + }); + fireEvent.change(screen.getByLabelText("Receiver endpoint"), { + target: { value: `http://127.0.0.1:${first.port}/stream` }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await screen.findAllByText("/after-restart"); + expect(screen.queryByText("/before-restart")).toBeNull(); + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("current-run"); + } finally { + cleanup(); + if (second) await second.stop(); + else await first.stop(); + } +}, 15_000); + +test("retrying a failed CLI-selected stream keeps that session instead of discovering another run", async () => { + const daemon = await startDaemon(() => 1_700_000_000_000); + try { + const web = await daemon.create("web"); + const worker = await daemon.create("worker"); + await daemon.ingest(web, "/retry-web"); + await daemon.ingest(worker, "/retry-worker"); + let failInitialStream = true; + vi.stubGlobal( + "EventSource", + class extends NodeEventSource { + constructor(url: string) { + super(url, { + fetch: (input, init) => { + if ( + failInitialStream && + new URL(url).pathname === new URL(worker.streamUrl).pathname + ) { + failInitialStream = false; + return Promise.resolve(new Response(null, { status: 503 })); + } + return fetch(input, init); + }, + }); + } + }, + ); + renderSession(worker); + await screen.findByRole("alert", { name: "Receiver connection error" }); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await screen.findAllByText("/retry-worker"); + expect(screen.queryByText("/retry-web")).toBeNull(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("worker"), + ); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("a CLI viewer query link discovers both runs and does not override the selected session after reload", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + const worker = await daemon.create("worker"); + await daemon.ingest(web, "/link-web"); + await daemon.ingest(worker, "/link-worker"); + window.localStorage.setItem( + "sentry.local.stream-url", + "http://127.0.0.1:1/stream", + ); + renderSession( + { streamUrl: new URL("/stream", web.streamUrl).toString() }, + true, + ); + await screen.findAllByText("/link-web"); + expect(window.location.search).toBe(""); + await selectSession("worker"); + await screen.findAllByText("/link-worker"); + expect(window.location.search).toBe(""); + cleanup(); + renderViewer(); + await screen.findAllByText("/link-worker"); + expect(screen.queryByText("/link-web")).toBeNull(); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("worker"), + ); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("a session lifecycle refresh immediately replaces a stale Capturing badge with Saved", async () => { + const daemon = await startDaemon(); + try { + const session = await daemon.create("close-preview"); + const view = render( + {}} + />, + ); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("Capturing"), + ); + const response = await fetch( + `http://127.0.0.1:${daemon.port}/_local/v1/sessions/${session.id}/close`, + { method: "POST", headers }, + ); + expect(response.ok).toBe(true); + view.rerender( + {}} + refreshKey="closed" + />, + ); + await waitFor(() => + expect( + screen.getByRole("combobox", { name: "Session", exact: true }) + .textContent, + ).toContain("Saved"), + ); + } finally { + cleanup(); + await daemon.stop(); + } +}); + +test("close, archive replay, clear and disconnect keep the viewer lifecycle stable", async () => { + const daemon = await startDaemon(); + try { + const web = await daemon.create("web"); + const worker = await daemon.create("worker"); + await daemon.ingest(web, "/archived-web"); + await daemon.ingest(worker, "/running-worker"); + renderSession(web); + await screen.findAllByText("/archived-web"); + daemon.plane.sessionStore.close(web.id); + await waitFor(() => + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Saved capture"), + ); + expect(screen.queryByRole("alert")).toBeNull(); + await selectSession("worker"); + await screen.findAllByText("/running-worker"); + await selectSession("web"); + await screen.findAllByText("/archived-web"); + await waitFor(() => + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Saved capture"), + ); + fireEvent.click(captureAction("Clear view")); + await screen.findByRole("heading", { + name: "No events displayed", + exact: true, + }); + expect(screen.queryByLabelText("Receiver endpoint")).toBeNull(); + expect( + screen.getByRole("complementary", { name: "Workspace navigation" }), + ).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Reload capture", exact: true }), + ); + await screen.findAllByText("/archived-web"); + fireEvent.click(captureAction("Disconnect viewer")); + await screen.findByRole("heading", { + name: "Capture a local session", + exact: true, + }); + expect( + screen.queryByRole("combobox", { name: "Session", exact: true }), + ).toBeNull(); + expect( + daemon.plane.sessionStore.getRetention(web.id).getSettings() + .retainedEnvelopes, + ).toBe(1); + cleanup(); + renderViewer(); + await screen.findByRole("heading", { + name: "Capture a local session", + exact: true, + }); + expect(screen.queryByText("/archived-web")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Connect", exact: true }), + ); + await screen.findAllByText("/archived-web"); + await waitFor(() => + expect( + screen.getByRole("status", { name: "Capture state" }).textContent, + ).toBe("Saved capture"), + ); + expect(screen.queryByRole("alert")).toBeNull(); + } finally { + cleanup(); + await daemon.stop(); + } +}, 15_000); + +test("hosted standalone receiver edits retention without losing saved events", async () => { + const retention = createLocalRetentionBuffer(500); + const { server, port } = await tryListen( + buildApp(retention.buffer, { uiActions: true, retention }), + 0, + "127.0.0.1", + ); + try { + const streamUrl = `http://127.0.0.1:${port}/stream`; + await fetch(streamUrl, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body: '{}\n{"type":"event"}\n{"message":"saved standalone event"}\n', + }); + renderSession({ streamUrl }); + await screen.findByRole("button", { name: "View event event" }); + fireEvent.click( + screen.getByRole("button", { name: "Settings", exact: true }), + ); + const input = (await screen.findByLabelText( + "Envelope limit", + )) as HTMLInputElement; + fireEvent.change(input, { target: { value: "1000" } }); + fireEvent.click( + screen.getByRole("button", { name: "Apply envelope limit" }), + ); + await waitFor(() => + expect(retention.getSettings().maxEnvelopes).toBe(1000), + ); + await waitFor(() => expect(input.value).toBe("1000")); + expect(retention.buffer.read({ all: true })).toHaveLength(1); + } finally { + cleanup(); + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } +}); diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx index 55bf3086c..196e30e9d 100644 --- a/apps/local/src/App.tsx +++ b/apps/local/src/App.tsx @@ -1,38 +1,56 @@ -import { ExceptionOverview } from "./components/exception-overview.tsx"; -import { getEventExceptions } from "./lib/exceptions.ts"; -import { RetentionSettings } from "./components/retention-settings.tsx"; +import { EventFilterControls } from "./components/event-filter-controls.tsx"; +import { + filterEvents, + defaultEventFilters, + loggerForItem, + type EventFilters, + type EventColumn, +} from "./lib/event-filters.ts"; +import { + SpanAnalysis, + WebVitalsWorkspace, +} from "./components/performance-workspace.tsx"; +import { ProfileWorkspace } from "./components/profile-workspace.tsx"; +import { SdkInventory } from "./components/sdk-inventory.tsx"; +import { CliLogo } from "./components/cli-logo.tsx"; +import { useReceiverProbe } from "./lib/use-receiver-probe.ts"; +import { ReceiverDialog } from "./components/receiver-dialog.tsx"; +import { + readConnectionHistory, + rememberConnection, + saveConnectionHistory, + receiverAddress, + getInitialDisconnected, + saveDisconnected, +} from "./lib/connection-history.ts"; +import { SessionSwitcher } from "./components/session-switcher.tsx"; +import { + type ViewerSession, + getInitialSessionDirectory, + parseSessionDirectoryUrl, + saveSessionDirectory, +} from "./lib/session-directory.ts"; +import { LocalSettings } from "./components/local-settings.tsx"; +import { + isHostedUiOrigin, + LOCAL_DIRECTORY_EVENT, + LOCAL_SESSION_EVENT, +} from "../../../packages/cli/src/commands/local/viewer-protocol.ts"; import { DEFAULT_MAX_ENVELOPES, isValidMaxEnvelopes, LOCAL_RETENTION_EVENT, } from "../../../packages/cli/src/commands/local/retention-limits.ts"; +import { Menu, Settings, Search } from "lucide-react"; import { - Check, - ChevronLeft, - ChevronRight, - Copy, - Menu, - Radio, - Search, - Terminal, - Trash2, - X, -} from "lucide-react"; -import { - Fragment, - type KeyboardEvent, + useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react"; -import { - useCopyToClipboard, - useDebounce, - useLocalStorage, -} from "@uidotdev/usehooks"; -import { JsonView } from "@/components/json-view.tsx"; +import { useLocalStorage } from "@uidotdev/usehooks"; import { ConnectionLanding } from "@/components/connection-landing.tsx"; import { EventCommandDialog, @@ -40,11 +58,28 @@ import { } 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 { EventDetail } from "@/components/event-detail.tsx"; +import { ResizableWorkspace } from "@/components/resizable-workspace.tsx"; +import { WorkspaceSidebar } from "@/components/workspace-sidebar.tsx"; +import { CaptureHeader } from "@/components/capture-header.tsx"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog.tsx"; import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip.tsx"; import { getConnectionPresentation, - type ConnectionPresentation, + getWorkspaceEmptyState, type ConnectionState, } from "@/lib/presentation.ts"; import { @@ -59,15 +94,8 @@ import { 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 { createLocalTelemetryStore } from "@/lib/telemetry-store.ts"; +import { buildTraceGroups, traceIdForItem } from "@/lib/trace-model.ts"; import { workspaceNavigation } from "@/lib/workspace-navigation.ts"; import { isErrorEvent, @@ -81,11 +109,11 @@ type EventEntryProps = { item: LocalFeedItem; isSelected: boolean; showTypeBadge: boolean; + columns?: EventColumn[]; 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$/; @@ -153,6 +181,11 @@ function saveStreamUrl(streamUrl: string): void { } catch { // Private browsing or browser policy can disable storage; the current tab still works. } + try { + window.sessionStorage.removeItem(REMOTE_STREAM_STORAGE_KEY); + } catch { + // A successful local choice must supersede this tab's older remote preference when possible. + } } function saveRemoteStreamUrl(streamUrl: string): void { @@ -167,11 +200,23 @@ function EventEntry({ item, isSelected, showTypeBadge, + columns = defaultEventFilters.columns, onSelect, }: EventEntryProps) { const metadata = getMetadata(item); const duration = formatDuration(metadata.durationMs); const isError = isErrorEvent(item); + const typeLabel = + item.type === "event" + ? isError + ? "Exception" + : "Event" + : (metadata.method ?? + (item.type === "transaction" + ? "Request" + : item.type === "log" + ? "Log" + : (metadata.operation ?? item.type))); return (
  • @@ -186,79 +231,53 @@ function EventEntry({ >
    - {showTypeBadge ? ( + {showTypeBadge && columns.includes("type") ? ( - {isError - ? "Error" - : (metadata.method ?? metadata.operation ?? item.type)} + {typeLabel} ) : null} - + {item.type === "event" ? metadata.title : (metadata.route ?? metadata.title)}
    -
    +
    {metadata.statusCode !== undefined ? ( {metadata.statusCode} ) : null} - {duration ? {duration} : null} - {metadata.traceId ? ( - {metadata.traceId.slice(0, 8)} + {duration && columns.includes("duration") ? ( + {duration} + ) : null} + {isError && item.type === "transaction" ? ( + Failed request + ) : null} + {item.type === "log" && + metadata.level && + columns.includes("level") ? ( + {metadata.level} ) : null}
    - + {item.type === "log" && + columns.includes("logger") && + loggerForItem(item) ? ( + + {loggerForItem(item)} + + ) : null} + {columns.includes("time") ? ( + + ) : null}
  • ); } -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, @@ -278,442 +297,6 @@ function WorkspaceEmptyState({ ); } -function EventDetail({ item, trace }: EventDetailProps) { - const exceptions = - item.type === "event" ? getEventExceptions(item.payload) : []; - 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.message - ? [{ label: "Message", value: metadata.message }] - : []), - ...(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" ? ( -
    -
    - {exceptions.length > 0 ? ( - - ) : null} - {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} - - -
    -
    - ) : exceptions.length === 0 ? ( -

    - No additional event details. -

    - ) : null} - {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, @@ -722,27 +305,56 @@ export default function App() { workspaceQuery, workspaceView, } = useLocalWorkspaceRoute(); - const [commandSearchQuery, setCommandSearchQuery] = useState( - workspaceQuery.query, - ); - const debouncedCommandSearchQuery = useDebounce( - commandSearchQuery, - SEARCH_DEBOUNCE_MS, - ); - const isClearingCommandSearch = useRef(false); + const [commandSearchQuery, setCommandSearchQuery] = useState(""); const [streamUrl, setStreamUrl] = useState(() => resolveInitialStreamUrl( window.location.hash, getSavedStream(), getSavedRemoteStream(), + window.location.search, ), ); + const [isDisconnected, setIsDisconnected] = useState(getInitialDisconnected); + const [hasEstablishedConnection, setHasEstablishedConnection] = + useState(false); + const [isClearing, setIsClearing] = useState(false); + const [isDeleteConfirmationOpen, setIsDeleteConfirmationOpen] = + useState(false); + const deleteTriggerRef = useRef(null); + const [currentSession, setCurrentSession] = useState(); + const [sessionExpiresAt, setSessionExpiresAt] = useState(); + const [latestEventTime, setLatestEventTime] = useState(); + const [isViewCleared, setIsViewCleared] = useState(false); + const clearingGeneration = useRef(undefined); + const [directoryUrl, setDirectoryUrl] = useState(() => + getInitialSessionDirectory(streamUrl), + ); + const activeDirectoryUrl = parseSessionDirectoryUrl(directoryUrl, streamUrl); const connectionGeneration = useRef(0); const [connectionAttempt, setConnectionAttempt] = useState(0); - const [connection, setConnection] = useState("connecting"); - const [draftEndpoint, setDraftEndpoint] = useState(streamUrl); + const [connection, setConnection] = useState( + isDisconnected ? "missing" : "connecting", + ); + const [draftEndpoint, setDraftEndpoint] = useState(() => + receiverAddress(streamUrl), + ); + const receiverEndpointEdited = useRef(false); + const [recentConnections, setRecentConnections] = useState( + readConnectionHistory, + ); + const [receiverError, setReceiverError] = useState(); + const { + isPending: isAddingReceiver, + cancel: cancelReceiverAttempt, + probe: probeReceiver, + } = useReceiverProbe(CONNECTION_TIMEOUT_MS); + const receiverTriggerRef = useRef(null); + const [closedSessionUrl, setClosedSessionUrl] = useState(); + const [isClosingSession, setIsClosingSession] = useState(false); + const closingGeneration = useRef(undefined); const [isEditingReceiver, setIsEditingReceiver] = useState(false); - const [isConnectionEnabled, setIsConnectionEnabled] = useState(true); + const [isConnectionEnabled, setIsConnectionEnabled] = + useState(!isDisconnected); const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(); const [retentionLimit, setRetentionLimit] = useState(DEFAULT_MAX_ENVELOPES); @@ -763,31 +375,83 @@ export default function App() { ); const [isMobileNavigationOpen, setIsMobileNavigationOpen] = useState(false); const [isCommandOpen, setIsCommandOpen] = useState(false); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsTriggerRef = useRef(null); + const openSettings = (trigger: HTMLButtonElement) => { + settingsTriggerRef.current = trigger; + setIsSettingsOpen(true); + }; const [message, setMessage] = useState(); const fallbackEventId = useRef(0); const commandTriggerRef = useRef(null); - const presentation = getConnectionPresentation(connection); + const isClosedSession = closedSessionUrl === streamUrl; + const presentation = getConnectionPresentation( + isClosedSession ? "closed" : connection, + ); + const setReceiverDialogOpen = (open: boolean) => { + if (!open) cancelReceiverAttempt(); + setReceiverError(undefined); + setIsEditingReceiver(open); + }; + const openReceiverDialog = (trigger: HTMLButtonElement) => { + receiverTriggerRef.current = trigger; + setDraftEndpoint(receiverAddress(streamUrl)); + receiverEndpointEdited.current = false; + setReceiverDialogOpen(true); + }; + useEffect( + () => saveConnectionHistory(recentConnections), + [recentConnections], + ); const traces = useMemo(() => buildTraceGroups(items), [items]); const searchQuery = workspaceQuery.query; + const filterKey = `${workspaceView}:${streamUrl}`; + const [eventFilterState, setEventFilterState] = useState<{ + key: string; + filters: EventFilters; + }>({ key: "", filters: defaultEventFilters }); + const eventFilters = + eventFilterState.key === filterKey + ? eventFilterState.filters + : defaultEventFilters; + const supportsFacets = ["live", "logs", "errors", "ai"].includes( + workspaceView, + ); + 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 searchedItems = filterEvents( + workspaceItems.filter((item) => matchesSearch(item, searchQuery)), + supportsFacets ? eventFilters : defaultEventFilters, ); const searchedTraces = traces.filter((trace) => trace.items.some((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 workspaceEmptyState = getWorkspaceEmptyState({ + view: workspaceView, + label: + workspaceView === "sdks" + ? "session or client reports" + : activeWorkspace.label, + singularLabel: activeWorkspace.singularLabel, + filtered: Boolean( + searchQuery.trim() || + (supportsFacets && + (eventFilters.levels.length || eventFilters.loggers.length)), + ), + state: + connection === "failed" + ? "offline" + : isViewCleared + ? "cleared" + : isClosedSession + ? "saved" + : "capturing", + }); const selectedItem = searchedItems.find((item) => item.id === workspaceQuery.eventId) ?? searchedItems[0]; @@ -804,28 +468,10 @@ export default function App() { : lastViewedIndex === -1 ? workspaceItems : workspaceItems.slice(lastViewedIndex + 1); - const newItems = unseenItems.filter((item) => - matchesSearch(item, searchQuery), - ); + const visibleItemIds = new Set(searchedItems.map((item) => item.id)); + const newItems = unseenItems.filter((item) => visibleItemIds.has(item.id)); 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)! @@ -849,8 +495,13 @@ export default function App() { }; const selectCommandItem = (item: LocalFeedItem) => { - isClearingCommandSearch.current = true; setCommandSearchQuery(""); + // Explicit event links must reveal their destination, including within the + // current workspace. Keep presentation choices while removing facet gates. + setEventFilterState((state) => ({ + ...state, + filters: { ...state.filters, levels: [], loggers: [] }, + })); const nextWorkspace = workspaceForItem(item); const traceId = traceIdForItem(item); if (nextWorkspace === "traces" && traceId) { @@ -861,21 +512,62 @@ export default function App() { markItemsSeen(nextWorkspace); }; - const clearItems = () => { - isClearingCommandSearch.current = true; + const clearDisplayedItems = (markViewCleared = false) => { setCommandSearchQuery(""); telemetryStore.clear(); + setIsViewCleared(markViewCleared); setMessage(undefined); setLastViewedItemIds({}); void workspaceQuery.clearWorkspace(); - + }; + const clearDisplayedItemsRef = useRef(clearDisplayedItems); + useEffect(() => { + clearDisplayedItemsRef.current = clearDisplayedItems; + }); + const disconnectReceiver = () => { + connectionGeneration.current += 1; + cancelReceiverAttempt(); + setIsConnectionEnabled(false); + setIsConnecting(false); + setHasEstablishedConnection(false); + setIsDisconnected(true); + saveDisconnected(true); + setConnection("missing"); + setClosedSessionUrl(undefined); + setConnectionError(undefined); + setReceiverError(undefined); + setIsEditingReceiver(false); + setIsSettingsOpen(false); + setIsDeleteConfirmationOpen(false); + setCurrentSession(undefined); + setSessionExpiresAt(undefined); + setLatestEventTime(undefined); + setIsClearing(false); + clearingGeneration.current = undefined; + closingGeneration.current = undefined; + setIsClosingSession(false); + setDraftEndpoint(receiverAddress(streamUrl)); + receiverEndpointEdited.current = false; + clearDisplayedItems(); + }; + const clearItems = () => clearDisplayedItems(true); + const deleteCapturedEvents = () => { + if (isClearing || isClosedSession) return; const endpoint = parseStreamEndpoint(streamUrl); if ( endpoint?.kind !== "loopback" || !isLoopbackHost(window.location.hostname) - ) { + ) return; - } + setIsDeleteConfirmationOpen(false); + clearDisplayedItems(); + // Pause this generation before resetting. Replaying afterward captures all + // post-reset arrivals without allowing queued pre-reset events back in. + const generation = ++connectionGeneration.current; + clearingGeneration.current = generation; + setIsClearing(true); + setIsConnectionEnabled(false); + setIsConnecting(false); const clearUrl = new URL(endpoint.url); const scopedSession = isScopedSessionStream(clearUrl.pathname); if (scopedSession) { @@ -884,17 +576,31 @@ export default function App() { clearUrl.pathname = "/clear"; clearUrl.search = ""; } - const generation = connectionGeneration.current; - void fetch(clearUrl, { method: scopedSession ? "POST" : "DELETE" }) + void fetch(clearUrl, { + method: scopedSession ? "POST" : "DELETE", + signal: AbortSignal.timeout(5000), + }) .then((response) => { if (!response.ok) throw new Error(`Session reset failed with HTTP ${response.status}`); + if (connectionGeneration.current === generation) + connectToEndpoint(streamUrl); }) .catch(() => { if (connectionGeneration.current !== generation) return; + setConnection("failed"); + setConnectionError( + "Could not delete captured events. Reconnect to try again.", + ); setMessage( - "Cleared this viewer, but the receiver could not clear its retained session.", + "This view is empty, but the receiver could not delete its captured events.", ); + }) + .finally(() => { + if (clearingGeneration.current === generation) { + clearingGeneration.current = undefined; + setIsClearing(false); + } }); }; @@ -912,38 +618,47 @@ export default function App() { } closeUrl.pathname = closeUrl.pathname.replace(/\/stream$/, "/ui/close"); const generation = connectionGeneration.current; - void fetch(closeUrl, { method: "POST" }) + if (closingGeneration.current === generation) return; + closingGeneration.current = generation; + setIsClosingSession(true); + void fetch(closeUrl, { method: "POST", signal: AbortSignal.timeout(5000) }) .then((response) => { if (connectionGeneration.current !== generation) return; 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.", - ); + // The HTTP response can overtake the final SSE envelopes. Replay the + // closed archive so every accepted event is visible before stopping. + // Existing envelope IDs keep already displayed telemetry deduplicated. + connectToEndpoint(streamUrl); + setClosedSessionUrl(streamUrl); }) .catch(() => { if (connectionGeneration.current !== generation) return; - setMessage("Could not close this telemetry session."); + setMessage("Could not stop this capture. Try again."); + }) + .finally(() => { + if (closingGeneration.current === generation) { + closingGeneration.current = undefined; + setIsClosingSession(false); + } }); }; const currentEndpoint = parseStreamEndpoint(streamUrl); const canEditRetention = currentEndpoint?.kind === "loopback" && - isLoopbackHost(window.location.hostname); + (isLoopbackHost(window.location.hostname) || + isHostedUiOrigin(window.location.origin)); const hasScopedSession = currentEndpoint?.kind === "loopback" && isLoopbackHost(window.location.hostname) && isScopedSessionStream(new URL(currentEndpoint.url).pathname); - const connectToDraft = () => { - const endpoint = parseStreamEndpoint(draftEndpoint); + const connectToEndpoint = (nextStreamUrl: string) => { + const endpoint = parseStreamEndpoint(nextStreamUrl); if (!endpoint) { - setConnectionError( + setReceiverError( "Enter a loopback stream or an HTTPS remote stream ending in /stream.", ); if (connection !== "connected") { @@ -952,19 +667,44 @@ export default function App() { } return; } + cancelReceiverAttempt(); + setIsViewCleared(false); + setIsDisconnected(false); + saveDisconnected(false); + setIsClearing(false); + clearingGeneration.current = undefined; connectionGeneration.current += 1; + closingGeneration.current = undefined; + setIsClosingSession(false); + setIsSettingsOpen(false); + setIsDeleteConfirmationOpen(false); + setReceiverError(undefined); + setMessage(undefined); + if ( + endpoint.kind === "loopback" && + new URL(endpoint.url).pathname === "/stream" + ) { + // A reconnect can target a new daemon on the same port. Wait for its + // metadata rather than auto-selecting an entry from the old directory. + setDirectoryUrl(undefined); + } if (endpoint.url !== streamUrl) { + setClosedSessionUrl(undefined); + setCurrentSession(undefined); + setSessionExpiresAt(undefined); + setLatestEventTime(undefined); + setIsViewCleared(false); telemetryStore.clear(); telemetryStore.setLimit(DEFAULT_MAX_ENVELOPES); setRetentionLimit(DEFAULT_MAX_ENVELOPES); setHasRetentionSettings(false); setLastViewedItemIds({}); fallbackEventId.current = 0; - isClearingCommandSearch.current = true; setCommandSearchQuery(""); void workspaceQuery.clearWorkspace(); } - setDraftEndpoint(endpoint.url); + setDraftEndpoint(receiverAddress(endpoint.url)); + receiverEndpointEdited.current = false; setStreamUrl(endpoint.url); setConnectionAttempt((attempt) => attempt + 1); setIsConnectionEnabled(true); @@ -974,15 +714,66 @@ export default function App() { setIsEditingReceiver(false); }; - useEffect(() => { - if (window.location.hash) { - window.history.replaceState( - null, - "", - `${window.location.pathname}${window.location.search}`, - ); + const connectReceiver = (url: string) => { + const endpoint = parseStreamEndpoint(url); + if (!endpoint || (connection !== "connected" && !hasRetainedItems)) { + connectToEndpoint(url); + return; + } + if ( + connection === "connected" && + (endpoint.url === streamUrl || + (activeDirectoryUrl && endpoint.url === receiverAddress(streamUrl))) + ) { + setReceiverDialogOpen(false); + return; } - }, []); + setReceiverError(undefined); + probeReceiver( + endpoint.url, + () => connectToEndpoint(endpoint.url), + setReceiverError, + ); + }; + const connectionForm = { + endpoint: draftEndpoint, + error: receiverError ?? (isEditingReceiver ? undefined : connectionError), + isConnecting: isConnecting || isAddingReceiver, + onEndpointChange: (value: string) => { + setDraftEndpoint(value); + receiverEndpointEdited.current = true; + setReceiverError(undefined); + if (!isEditingReceiver) setConnectionError(undefined); + cancelReceiverAttempt(); + if ( + !isEditingReceiver && + !hasEstablishedConnection && + !hasRetainedItems + ) { + // Editing the landing owns the next attempt; a late automatic probe must not dismiss it. + connectionGeneration.current += 1; + setIsConnectionEnabled(false); + setIsConnecting(false); + setConnection("missing"); + } + }, + onConnect: () => + connectReceiver( + // The form hides local session capabilities. Retrying its unchanged + // address must keep the selected run rather than rediscovering one. + // An explicitly edited address can re-pair after a daemon restart. + !receiverEndpointEdited.current && + draftEndpoint === receiverAddress(streamUrl) + ? streamUrl + : draftEndpoint, + ), + recentConnections, + onReconnect: connectReceiver, + onForget: (url: string) => + setRecentConnections((history) => + history.filter((entry) => entry !== url), + ), + }; useEffect(() => { if (!isConnectionEnabled) { @@ -1018,6 +809,36 @@ export default function App() { /* Ignore malformed optional metadata from external receivers. */ } }; + const onDirectory = (event: Event) => { + if (!isActiveConnection()) return; + const endpoint = parseStreamEndpoint(streamUrl); + if ( + endpoint?.kind !== "loopback" || + new URL(endpoint.url).pathname !== "/stream" + ) + return; + try { + const value: unknown = JSON.parse((event as MessageEvent).data); + if ( + value && + typeof value === "object" && + "directoryUrl" in value && + typeof value.directoryUrl === "string" + ) { + const directory = parseSessionDirectoryUrl( + value.directoryUrl, + streamUrl, + ); + if (directory) { + saveSessionDirectory(directory); + setDirectoryUrl(directory); + } + } + } catch { + /* Optional metadata from legacy/external receivers may be malformed. */ + } + }; + source.addEventListener(LOCAL_DIRECTORY_EVENT, onDirectory); source.addEventListener(LOCAL_RETENTION_EVENT, onRetention); const timeoutId = window.setTimeout(() => { if (!isActiveConnection()) { @@ -1032,10 +853,56 @@ export default function App() { "Connection timed out after 10 seconds. Check the endpoint and try again.", ); }, CONNECTION_TIMEOUT_MS); + const onSession = (event: Event) => { + if (!isActiveConnection()) return; + let metadata: unknown; + try { + metadata = JSON.parse((event as MessageEvent).data); + } catch { + return; + } + if ( + !metadata || + typeof metadata !== "object" || + !("state" in metadata) || + (metadata.state !== "open" && metadata.state !== "retained") + ) + return; + if ("action" in metadata && metadata.action === "reset") { + clearDisplayedItemsRef.current(); + return; + } + if ( + "expiresAt" in metadata && + typeof metadata.expiresAt === "number" && + Number.isSafeInteger(metadata.expiresAt) && + metadata.expiresAt > 0 + ) { + setSessionExpiresAt(metadata.expiresAt); + } + if (metadata.state === "retained") { + // This notification follows the complete archive replay. EOF is normal, + // and must not be mistaken for a failed receiver or trigger live retry. + isCurrent = false; + source.close(); + window.clearTimeout(timeoutId); + connectionGeneration.current += 1; + setIsConnectionEnabled(false); + setIsConnecting(false); + setHasEstablishedConnection(true); + setConnection("closed"); + setClosedSessionUrl(streamUrl); + setConnectionError(undefined); + setMessage(undefined); + } + }; + source.addEventListener(LOCAL_SESSION_EVENT, onSession); const onEnvelope = (event: Event) => { if (!isActiveConnection()) { return; } + setIsViewCleared(false); + setLatestEventTime(Date.now()); const messageEvent = event as MessageEvent; const eventId = messageEvent.lastEventId || `event-${fallbackEventId.current++}`; @@ -1066,12 +933,17 @@ export default function App() { } window.clearTimeout(timeoutId); setIsConnecting(false); + setHasEstablishedConnection(true); + // A successfully opened explicit CLI link also replaces the tab's prior + // Disconnect choice after its URL fragment has been removed. + saveDisconnected(false); const endpoint = parseStreamEndpoint(streamUrl); if (endpoint?.kind === "loopback") { saveStreamUrl(endpoint.url); } else if (endpoint?.kind === "remote") { saveRemoteStreamUrl(endpoint.url); } + setRecentConnections((history) => rememberConnection(history, streamUrl)); setConnection("connected"); setConnectionError(undefined); setMessage(undefined); @@ -1097,26 +969,32 @@ export default function App() { isCurrent = false; window.clearTimeout(timeoutId); source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope); + source.removeEventListener(LOCAL_SESSION_EVENT, onSession); source.removeEventListener(LOCAL_RETENTION_EVENT, onRetention); + source.removeEventListener(LOCAL_DIRECTORY_EVENT, onDirectory); source.close(); }; }, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]); const showConnectionLanding = - isEditingReceiver || - (!hasRetainedItems && - (connection === "connecting" || - connection === "failed" || - connection === "closed")); - const isReceiverUnavailable = - connection !== "connected" && !isConnecting && !hasRetainedItems; - const canSearch = hasRetainedItems && !isEditingReceiver; + isDisconnected || (!hasEstablishedConnection && !hasRetainedItems); + const canAutoSelectSession = + isConnectionEnabled && + !isEditingReceiver && + currentEndpoint?.kind === "loopback" && + new URL(currentEndpoint.url).pathname === "/stream"; + const isReceiverUnavailable = showConnectionLanding && !isConnecting; + const canSearch = + hasRetainedItems && + !isEditingReceiver && + !isSettingsOpen && + !isDeleteConfirmationOpen; const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map( ({ icon, id, label }) => ({ icon, id, label }), ); const setCommandOpen = (open: boolean) => { if (open) { - setCommandSearchQuery(searchQuery); + setCommandSearchQuery(""); } setIsCommandOpen(open); }; @@ -1125,8 +1003,98 @@ export default function App() { setCommandOpen(true); }; + const selectSpan = (traceId: string, spanId: string) => { + if (workspaceView === "traces") { + void workspaceQuery.selectSpan(traceId, spanId); + } else { + navigateToCommandTrace("traces", traceId, spanId); + } + markItemsSeen("traces"); + }; + const requestDeleteCapturedEvents = (trigger: HTMLButtonElement) => { + deleteTriggerRef.current = trigger; + setIsDeleteConfirmationOpen(true); + }; + const canDeleteCapturedEvents = + currentEndpoint?.kind === "loopback" && + isLoopbackHost(window.location.hostname) && + !isClosedSession; + const captureLabel = + currentSession?.streamUrl === streamUrl ? currentSession.label : undefined; + const updateCurrentSession = useCallback( + (session: ViewerSession | undefined) => { + setCurrentSession( + (previous) => + session ?? (previous?.streamUrl === streamUrl ? previous : undefined), + ); + }, + [streamUrl], + ); + return (
    + + { + event.preventDefault(); + deleteTriggerRef.current?.focus(); + }} + > + Delete captured events? + + Delete the events stored by the receiver for{" "} + {captureLabel ? `“${captureLabel}”` : "this capture"}. Other viewers + lose this history too. Capture will continue receiving new events. + + + + + + + + +
    setIsEditingReceiver(true)} + onDisconnect={disconnectReceiver} + isClearing={isClearing} + onCloseSession={ + hasScopedSession && !isClosedSession + ? closeScopedSession + : undefined + } + isClosingSession={isClosingSession} + onChangeReceiver={openReceiverDialog} + onOpenSettings={openSettings} onOpenCommand={openCommand} onSelect={selectWorkspace} onToggle={() => setIsSidebarCollapsed((collapsed) => !collapsed)} @@ -1155,21 +1134,25 @@ export default function App() { className="flex min-w-0 flex-1 items-center" aria-label="Sentry CLI" > - Sentry CLI - +
    + + + + + + Settings + + ) : (
    @@ -1185,13 +1168,13 @@ export default function App() { {canSearch ? ( ) : ( @@ -1200,10 +1183,22 @@ export default function App() {
    @@ -1215,60 +1210,179 @@ export default function App() { aria-label="Workspace navigation" className="grid grid-cols-2 gap-1" > - {workspaceNavigation.map((entry) => { - const Icon = entry.icon; - const active = workspaceView === entry.id; - return ( - - ); - })} + {workspaceNavigation + .filter((entry) => entry.section !== "Diagnostics") + .map((entry) => { + const Icon = entry.icon; + const active = workspaceView === entry.id; + return ( + + ); + })} +
    + + Diagnostics + +
    + {" "} + {workspaceNavigation + .filter((entry) => entry.section === "Diagnostics") + .map((entry) => { + const Icon = entry.icon; + const active = workspaceView === entry.id; + return ( + + ); + })} +
    +
    ) : null} -
    +
    - {hasRetentionSettings ? ( - { + if (!isClearing) connectToEndpoint(streamUrl); + }} + isReloading={isConnecting || isClearing} + isAwaitingSession={Boolean( + activeDirectoryUrl && + currentEndpoint?.kind === "loopback" && + new URL(currentEndpoint.url).pathname === "/stream", + )} + sessionPicker={ + activeDirectoryUrl && !isDisconnected ? ( + + ) : undefined + } /> + ) : activeDirectoryUrl && !isDisconnected ? ( +
    + +
    ) : null} - {connectionError && + {!showConnectionLanding && connection === "failed" && - hasRetainedItems ? ( -
    {connectionError} +

    + ) : null} + {!showConnectionLanding ? ( +
    + + {searchQuery.trim() ? ( + + ) : null}
    ) : null} + {!showConnectionLanding && supportsFacets && hasRetainedItems ? ( + + setEventFilterState({ key: filterKey, filters }) + } + /> + ) : null} {message ? (
    ) : !hasRetainedItems ? ( -
    -
    + + ) : workspaceView === "queries" || + workspaceView === "resources" ? ( + + ) : workspaceView === "web-vitals" ? ( + + ) : workspaceView === "profiles" ? ( + navigateToCommandTrace("traces", id)} + /> ) : workspaceView === "traces" ? ( + navigateToCommandEvent("profiles", id) + } selectedTraceId={workspaceQuery.traceId ?? undefined} + selectedSpanId={workspaceQuery.spanId ?? undefined} + onSelectSpan={selectSpan} + onClearSpan={() => { + void workspaceQuery.clearSpan(); + }} + onSelectRelatedItem={selectCommandItem} onSelect={(traceId) => { void workspaceQuery.selectTrace(traceId); }} /> ) : ( -
    - - {searchedItems.length === 0 ? ( - - ) : selectedItem?.type === "envelope" ? ( - - ) : selectedItem ? ( - + <> + {workspaceView === "sdks" ? ( + ) : null} -
    + +
    +
    +

    + {activeWorkspace.label} +

    + + {searchedItems.length}{" "} + {activeWorkspace.singularLabel} + {searchedItems.length === 1 ? "" : "s"} + +
    + {newItemCount > 0 ? ( + + ) : null} +
    +
      + {searchedItems.map((item) => ( + + ))} + {searchedItems.length === 0 ? ( +
    1. + {workspaceEmptyState.title} +
    2. + ) : null} +
    + + } + > + {searchedItems.length === 0 ? ( + + ) : selectedItem ? ( + + ) : null} +
    + )}
    @@ -1404,6 +1542,10 @@ export default function App() { onOpenChange={setCommandOpen} onQueryChange={setCommandSearchQuery} onSelectItem={selectCommandItem} + currentViewLabel={activeWorkspace.label} + onFilterView={(query) => { + void workspaceQuery.setSearchQuery(query); + }} />
    ); diff --git a/apps/local/src/components/ai-inspector.tsx b/apps/local/src/components/ai-inspector.tsx new file mode 100644 index 000000000..241fd97c6 --- /dev/null +++ b/apps/local/src/components/ai-inspector.tsx @@ -0,0 +1,141 @@ +import type { TraceSpan } from "@/lib/trace-model.ts"; +import { + inspectAi, + parsed, + record, + display, + text, + isAiPayload, +} from "@/lib/inspection.ts"; +import { DetailFields } from "./span-detail.tsx"; + +function CapturedContent({ value }: { value: unknown }) { + const rawContent = parsed(value); + const content = record(rawContent).messages ?? rawContent; + if (Array.isArray(content)) + return ( +
    + {content.map((message, index) => { + const entry = record(message); + return ( +
    +

    + {text(entry.role) ?? text(entry.type) ?? `Part ${index + 1}`} +

    +
    +                {display(entry.content ?? entry.text ?? message)}
    +              
    +
    + ); + })} +
    + ); + return ( +
    +      {display(content)}
    +    
    + ); +} +export function AiInspector({ + payload, + steps = [], +}: { + payload: unknown; + steps?: readonly TraceSpan[]; +}) { + const ai = inspectAi(payload); + if (!ai) return null; + const children: TraceSpan[] = []; + const pending = [...steps]; + const seen = new Set(); + while (pending.length) { + const step = pending.shift()!; + if (seen.has(step.id)) continue; + seen.add(step.id); + if (isAiPayload(step.payload)) children.push(step); + else pending.push(...step.children); + } + return ( +
    +

    AI interaction

    + + {ai.system !== undefined ? ( +
    +

    System instructions

    + +
    + ) : null} + {ai.messages !== undefined ? ( +
    +

    Prompt

    + +
    + ) : null} + {ai.response !== undefined ? ( +
    +

    Response

    + +
    + ) : null} + {ai.tools.length ? ( +
    +

    Tool calls

    + {ai.tools.map((value, index) => { + const tool = record(value), + fn = record(tool.function); + return ( +
    +
    + {text(tool.name ?? tool.toolName ?? fn.name) ?? + `Tool ${index + 1}`} +
    + + content === undefined + ? [] + : [{ label, value: display(content), code: true }], + )} + /> +
    + ); + })} +
    + ) : null} + {children.length ? ( +
    +

    AI steps

    + {children.map((step) => ( +
    + + {step.description} + + +
    + ))} +
    + ) : null} + {ai.messages === undefined && + ai.response === undefined && + !ai.tools.length ? ( +

    + Prompt and response content were not captured. Check your SDK’s AI + content recording settings. +

    + ) : null} +
    + ); +} diff --git a/apps/local/src/components/attachment-detail.tsx b/apps/local/src/components/attachment-detail.tsx new file mode 100644 index 000000000..138777db1 --- /dev/null +++ b/apps/local/src/components/attachment-detail.tsx @@ -0,0 +1,103 @@ +import { attachmentBytes } from "@/lib/attachments.ts"; +import { useEffect, useState } from "react"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { text } from "@/lib/inspection.ts"; +import { JsonView } from "./json-view.tsx"; + +export function AttachmentDetail({ item }: { item: LocalFeedItem }) { + const [url, setUrl] = useState(); + const name = text(item.itemHeader?.filename) ?? "Attachment"; + const attachment = attachmentBytes(item); + useEffect(() => { + const decoded = attachmentBytes(item); + if (!decoded || typeof URL.createObjectURL !== "function") return; + // Downloads stay inert even when the captured MIME type is HTML or SVG. + const objectUrl = URL.createObjectURL( + new Blob([decoded.bytes], { + type: decoded.downloadType, + }), + ); + // Object URLs are browser resources: create after commit and revoke on cleanup. + // eslint-disable-next-line react-hooks/set-state-in-effect + setUrl(objectUrl); + return () => { + URL.revokeObjectURL(objectUrl); + setUrl(undefined); + }; + }, [item]); + const content = attachment?.isText + ? new TextDecoder().decode(attachment.bytes) + : undefined; + return ( +
    +
    +

    {name}

    + {url ? ( + + Download attachment + + ) : null} +
    +

    + {attachment?.contentType ?? + text(item.itemHeader?.content_type) ?? + "Unknown type"} + {attachment + ? ` · ${attachment.bytes.byteLength.toLocaleString()} bytes` + : ""} +

    + {!attachment ? ( +

    + This attachment could not be decoded. Its original payload is + available in JSON. +

    + ) : content !== undefined ? ( + attachment.contentType.includes("json") ? ( + + ) : ( +
    +            {content}
    +          
    + ) + ) : url && attachment.preview === "image" ? ( + {name} + ) : url && attachment.preview === "video" ? ( + + ) : ( +

    + No preview available for this file type. Download it to inspect its + contents. +

    + )} +
    + ); +} +export function EnvelopeAttachments({ + attachments, +}: { + attachments: readonly LocalFeedItem[]; +}) { + if (!attachments.length) return null; + return ( +
    + {attachments.map((attachment) => ( + + ))} +
    + ); +} diff --git a/apps/local/src/components/capture-header.test.tsx b/apps/local/src/components/capture-header.test.tsx new file mode 100644 index 000000000..fb45e37e7 --- /dev/null +++ b/apps/local/src/components/capture-header.test.tsx @@ -0,0 +1,222 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { CaptureHeader } from "./capture-header.tsx"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-26T10:00:00.000Z")); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); +const streamUrl = + "http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=SECRET"; +const session = { + id: "web", + label: "Web app", + cwd: "/projects/web", + state: "open" as const, + streamUrl, +}; +const props = { + streamUrl, + connection: "connected" as const, + isClosed: false, + session, + onReload: vi.fn(), + isReloading: false, +}; + +test("unifies capture identity with receiver connectivity and the last received time", () => { + render(); + expect(screen.getByRole("heading", { name: "Web app" })).not.toBeNull(); + expect(screen.getByText("/projects/web")).not.toBeNull(); + expect(screen.getByText("Capturing")).not.toBeNull(); + expect(screen.getByText("Connected to local receiver")).not.toBeNull(); + expect(screen.getByText(/Last received/)).not.toBeNull(); + expect(document.querySelector("time")?.getAttribute("dateTime")).toBe( + "2026-10-26T10:30:00.000Z", + ); + expect(document.body.innerHTML).not.toContain("SECRET"); + const address = screen.getByText("http://127.0.0.1:8969"); + expect(address.getAttribute("title")).toBe("http://127.0.0.1:8969"); + expect(document.body.innerHTML).not.toContain("/_local/v1/sessions/"); + fireEvent.click(screen.getByRole("button", { name: "Reload capture" })); + expect(props.onReload).toHaveBeenCalledOnce(); +}); + +test("shows a saved capture independently of a failed receiver and preserves the actual deadline", () => { + render( + , + ); + expect(screen.getByText("Saved capture")).not.toBeNull(); + expect(screen.getByText("Receiver unavailable")).not.toBeNull(); + expect(screen.getByText(/Saved until/)).not.toBeNull(); + expect(screen.getByRole("button", { name: "Reconnect" })).not.toBeNull(); +}); + +test("does not invent expiry or repeat the picker identity", () => { + render( + Web app picker} + />, + ); + expect(screen.getByRole("button", { name: "Web app picker" })).not.toBeNull(); + expect(screen.queryByRole("heading", { name: "Web app" })).toBeNull(); + expect(screen.queryByText(/Saved until/)).toBeNull(); +}); + +test("awaiting a daemon session asks for capture selection without implying capture is active", () => { + render( + , + ); + expect(screen.getByText("Choose a capture")).not.toBeNull(); + expect(screen.queryByText("Capturing")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reload capture" })).toBeNull(); +}); + +test("never displays stale capture identity or URL credentials after changing receiver", () => { + render( + , + ); + expect(screen.queryByText("Web app")).toBeNull(); + expect(screen.queryByText("/projects/web")).toBeNull(); + expect(document.body.textContent).not.toMatch(/SECRET|HIDDEN|token=|cap=/); + expect(screen.getByText("Connection lost")).not.toBeNull(); + expect(screen.getByText("https://receiver.example")).not.toBeNull(); +}); + +test("shows reconnecting state and prevents duplicate reload requests", () => { + const onReload = vi.fn(); + render( + , + ); + expect(screen.getByText("Connecting")).not.toBeNull(); + const reload = screen.getByRole("button", { name: "Reload capture" }); + expect(reload.hasAttribute("disabled")).toBe(true); + expect(reload.getAttribute("aria-busy")).toBe("true"); + fireEvent.click(reload); + expect(onReload).not.toHaveBeenCalled(); +}); + +test("expires at the frozen save deadline despite newer events and preserves capture identity", async () => { + const onReload = vi.fn(); + const expiresAt = Date.now() + 2000; + const { rerender } = render( + , + ); + expect(screen.getByText("Saved capture")).not.toBeNull(); + expect(vi.getTimerCount()).toBe(1); + await act(() => vi.advanceTimersByTime(1000)); + rerender( + , + ); + expect(vi.getTimerCount()).toBe(1); + await act(() => vi.advanceTimersByTime(1000)); + expect(screen.getByText("Saved capture expired")).not.toBeNull(); + expect(screen.getByRole("heading", { name: "Web app" })).not.toBeNull(); + expect( + screen.getByText("Events already loaded in this tab remain available."), + ).not.toBeNull(); + const reload = screen.getByRole("button", { name: "Reload capture" }); + expect(reload.hasAttribute("disabled")).toBe(true); + fireEvent.click(reload); + expect(onReload).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); +}); + +test("never infers or carries forward expiry for an older receiver omitting metadata", async () => { + const { rerender } = render( + , + ); + expect(screen.getByText("Saved capture expired")).not.toBeNull(); + rerender( + , + ); + expect(screen.getByText("Saved capture")).not.toBeNull(); + expect(screen.queryByText(/Saved until/)).toBeNull(); + expect( + screen + .getByRole("button", { name: "Reload capture" }) + .hasAttribute("disabled"), + ).toBe(false); + expect(vi.getTimerCount()).toBe(0); + await act(() => vi.advanceTimersByTime(24 * 60 * 60 * 1000)); + expect(screen.getByText("Saved capture")).not.toBeNull(); +}); + +test("caps expiry timer delays and cleans up the single pending timer on unmount", async () => { + const maxTimeout = 2_147_483_647; + const expiresAt = Date.now() + maxTimeout + 1000; + const { rerender, unmount } = render( + , + ); + expect(vi.getTimerCount()).toBe(1); + await act(() => vi.advanceTimersByTime(maxTimeout)); + expect(screen.getByText("Saved capture")).not.toBeNull(); + expect(vi.getTimerCount()).toBe(1); + await act(() => vi.advanceTimersByTime(1000)); + expect(screen.getByText("Saved capture expired")).not.toBeNull(); + expect(vi.getTimerCount()).toBe(0); + rerender(); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); +}); + +test("keeps the single reload action with connectivity metadata so capture identity has full width", () => { + render(); + const metadataRow = screen.getByText( + "Connected to local receiver", + ).parentElement; + const reload = screen.getByRole("button", { name: "Reload capture" }); + expect(metadataRow?.contains(reload)).toBe(true); + expect(metadataRow?.textContent).toContain("Last received"); + expect( + screen.getAllByRole("button", { name: "Reload capture" }), + ).toHaveLength(1); +}); diff --git a/apps/local/src/components/capture-header.tsx b/apps/local/src/components/capture-header.tsx new file mode 100644 index 000000000..400baf9a0 --- /dev/null +++ b/apps/local/src/components/capture-header.tsx @@ -0,0 +1,209 @@ +import { LoaderCircle, RefreshCw } from "lucide-react"; +import { type ReactNode, useCallback, useSyncExternalStore } from "react"; +import type { ConnectionState } from "../lib/presentation.ts"; +import type { ViewerSession } from "../lib/session-directory.ts"; +import { parseStreamEndpoint } from "../lib/spotlight.ts"; +import { Badge } from "./ui/badge.tsx"; +import { Button } from "./ui/button.tsx"; + +export type CaptureHeaderProps = { + streamUrl: string | null; + connection: ConnectionState; + isClosed: boolean; + expiresAt?: number; + session?: ViewerSession; + latestEventTime?: number; + sessionPicker?: ReactNode; + onReload: () => void; + isReloading: boolean; + isAwaitingSession?: boolean; +}; + +function validDate(timestamp: number | undefined): Date | undefined { + return typeof timestamp === "number" && + Number.isSafeInteger(timestamp) && + timestamp > 0 && + timestamp <= 8.64e15 + ? new Date(timestamp) + : undefined; +} + +const MAX_TIMEOUT = 2_147_483_647; + +/** A frozen receiver deadline needs one wake-up, with long delays bounded safely. */ +function useCaptureExpired(deadline: number | undefined): boolean { + const subscribe = useCallback( + (notify: () => void) => { + if (deadline === undefined) return () => {}; + let timer: ReturnType | undefined; + const check = () => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + notify(); + return; + } + timer = setTimeout(check, Math.min(remaining, MAX_TIMEOUT)); + }; + check(); + return () => clearTimeout(timer); + }, + [deadline], + ); + const getSnapshot = useCallback( + () => deadline !== undefined && Date.now() >= deadline, + [deadline], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +const RECEIVER_STATES: Record = { + connected: "Receiver connected", + connecting: "Connecting to receiver", + reconnecting: "Reconnecting to receiver", + failed: "Receiver unavailable", + missing: "Viewer disconnected", + closed: "Receiver replay complete", +}; + +/** Capture lifecycle and viewer transport stay separate, including finite replay. */ +export function CaptureHeader({ + streamUrl, + connection, + isClosed, + expiresAt, + session, + latestEventTime, + sessionPicker, + onReload, + isReloading, + isAwaitingSession = false, +}: CaptureHeaderProps) { + const endpoint = parseStreamEndpoint(streamUrl); + const currentSession = session?.streamUrl === streamUrl ? session : undefined; + const awaiting = isAwaitingSession || !endpoint; + const saved = !awaiting && (isClosed || currentSession?.state === "retained"); + const received = validDate(latestEventTime); + const savedUntil = saved + ? validDate(expiresAt ?? currentSession?.expiresAt) + : undefined; + const expired = useCaptureExpired(savedUntil?.getTime()); + const state = awaiting + ? "Choose a capture" + : saved + ? expired + ? "Saved capture expired" + : "Saved capture" + : connection === "connected" + ? "Capturing" + : connection === "connecting" || connection === "reconnecting" + ? "Connecting" + : "Connection lost"; + const receiverLabel = endpoint ? new URL(endpoint.url).origin : undefined; + const canReload = + !awaiting && + (saved || + connection === "connected" || + connection === "failed" || + connection === "closed" || + isReloading); + const tone = + saved || awaiting + ? "default" + : connection === "connected" + ? "success" + : connection === "failed" + ? "warning" + : "default"; + return ( +
    +
    +
    +
    + {sessionPicker ?? ( +

    + {currentSession?.label ?? + (endpoint?.kind === "remote" + ? "Remote capture" + : "Local capture")} +

    + )} +
    + + + {state} + + +
    + {currentSession?.cwd ? ( +

    + {currentSession.cwd} +

    + ) : null} +
    + + {connection === "connected" + ? endpoint?.kind === "loopback" + ? "Connected to local receiver" + : "Connected to receiver" + : RECEIVER_STATES[connection]} + + {receiverLabel ? ( + + {receiverLabel} + + ) : null} + {received ? ( + + Last received{" "} + + + ) : null} + {savedUntil ? ( + + Saved until{" "} + + + ) : null} + {canReload ? ( + + ) : null} +
    + {expired ? ( +

    + Events already loaded in this tab remain available. +

    + ) : null} +
    +
    + ); +} diff --git a/apps/local/src/components/cli-logo.tsx b/apps/local/src/components/cli-logo.tsx new file mode 100644 index 000000000..3e1bc1a21 --- /dev/null +++ b/apps/local/src/components/cli-logo.tsx @@ -0,0 +1,14 @@ +import logo from "../../../cli-docs/src/assets/logo.svg"; + +/** The CLI brand asset shared with its documentation site. */ +export function CliLogo() { + return ( + Sentry CLI + ); +} diff --git a/apps/local/src/components/connection-landing.test.tsx b/apps/local/src/components/connection-landing.test.tsx new file mode 100644 index 000000000..4bc414a5c --- /dev/null +++ b/apps/local/src/components/connection-landing.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { ConnectionLanding } from "./connection-landing.tsx"; +import { ReceiverDialog } from "./receiver-dialog.tsx"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); +const form = { + endpoint: "http://localhost:8969/stream", + isConnecting: false, + onConnect: vi.fn(), + onEndpointChange: vi.fn(), + recentConnections: [], + onReconnect: vi.fn(), + onForget: vi.fn(), +}; +test("first run copies a capture command and keeps existing receiver entry secondary", () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render(); + expect( + screen.getByRole("heading", { name: "Capture a local session" }), + ).not.toBeNull(); + expect(screen.getByText(/Replace /)).not.toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Copy local run command" }), + ); + expect(writeText).toHaveBeenCalledWith( + "sentry local run --session web --open -- ", + ); + const existing = screen.getByText("Connect an existing receiver"); + fireEvent.click(existing); + fireEvent.submit( + screen + .getByRole("button", { name: "Connect", exact: true }) + .closest("form")!, + ); + expect(form.onConnect).toHaveBeenCalledOnce(); +}); + +test("switching receivers preserves the current feed and does not show onboarding", () => { + render( + , + ); + expect( + screen.getByRole("dialog", { name: "Switch receiver" }), + ).not.toBeNull(); + expect(screen.getByText(/current feed stays open/)).not.toBeNull(); + expect( + screen.queryByRole("button", { name: "Copy local run command" }), + ).toBeNull(); +}); diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx index badf85391..7eb51a31b 100644 --- a/apps/local/src/components/connection-landing.tsx +++ b/apps/local/src/components/connection-landing.tsx @@ -1,43 +1,164 @@ -import { Check, Copy, LoaderCircle, Radio } from "lucide-react"; -import { type FormEvent, useState } from "react"; +import { Check, Copy, History, LoaderCircle, Radio, X } from "lucide-react"; +import { type FormEvent, useId } from "react"; import { useCopyToClipboard } from "@uidotdev/usehooks"; +import { connectionLabel } from "../lib/connection-history.ts"; +import { Button } from "./ui/button.tsx"; +import { Input } from "./ui/input.tsx"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip.tsx"; -export type ConnectionLandingProps = { +export type ConnectionFormProps = { endpoint: string; error?: string; isConnecting: boolean; - phase: "probing" | "failed" | "editing"; onConnect: () => void; onEndpointChange: (value: string) => void; + recentConnections: string[]; + onReconnect: (url: string) => void; + onForget: (url: string) => void; }; -/** A compact, explicit receiver picker for bare Local viewer visits. */ -export function ConnectionLanding({ +/** Shared receiver entry and recent connections for the landing and modal. */ +export function ConnectionForm({ endpoint, error, isConnecting, - phase, onConnect, onEndpointChange, -}: ConnectionLandingProps) { - const [copied, setCopied] = useState(false); - const [, copyToClipboard] = useCopyToClipboard(); - const isProbing = phase === "probing"; - + recentConnections, + onReconnect, + onForget, +}: ConnectionFormProps) { + const inputId = useId(); + const errorId = useId(); const submit = (event: FormEvent) => { event.preventDefault(); - if (isConnecting) { - return; - } - onConnect(); + if (!isConnecting) onConnect(); }; + return ( +
    +
    + +
    + onEndpointChange(event.target.value)} + className="min-w-0 flex-1 font-mono" + placeholder="http://localhost:8969/stream" + /> + +
    + {error ? ( + + ) : null} +

    + Paste a local /stream address or an HTTPS stream URL. +

    +
    + {recentConnections.length ? ( +
    +

    +

    + +
      + {recentConnections.map((url) => ( +
    • + + + + + + Forget connection + +
    • + ))} +
    +
    +
    + ) : null} +
    + ); +} +export type ConnectionLandingProps = ConnectionFormProps & { + phase: "probing" | "failed" | "editing"; +}; + +/** A focused landing when no captured events are available to inspect. */ +export function ConnectionLanding({ phase, ...form }: ConnectionLandingProps) { + const titleId = useId(); + const isProbing = phase === "probing"; + const [copied, copyToClipboard] = useCopyToClipboard(); + const command = "sentry local run --session web --open -- "; return (
    -
    +
    -

    - Sentry Local -

    -

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

    + {isProbing ? "Connecting to receiver" : "Capture a local session"}

    {isProbing - ? "Checking the selected receiver." - : "Connect this read-only viewer to an event stream."} + ? "Establishing a connection…" + : "Inspect events from your local development sessions."}

    - - {error ? ( -

    - {error} +

    +

    + Run this in your project to start capturing and open the viewer. + Replace {""} with your app’s development command.

    - ) : 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 + + {command} - + {copied === command ? "Copied" : "Copy command"} +
    +
    +

    Connect an existing receiver

    + +
    ); diff --git a/apps/local/src/components/event-command-dialog.test.tsx b/apps/local/src/components/event-command-dialog.test.tsx index 23d592f87..ee977daea 100644 --- a/apps/local/src/components/event-command-dialog.test.tsx +++ b/apps/local/src/components/event-command-dialog.test.tsx @@ -84,7 +84,7 @@ describe("EventCommandDialog", () => { ); expect( - screen.getByRole("dialog", { name: "Search Local events" }), + screen.getByRole("dialog", { name: "Search this session" }), ).not.toBeNull(); expect(screen.getByRole("option", { name: "Traces" })).not.toBeNull(); }); @@ -127,6 +127,7 @@ describe("EventCommandDialog", () => { }); test("offers the current-view filter when a search query is present", () => { + const onFilterView = vi.fn(); render( { navigation={navigation} open query="users" + onFilterView={onFilterView} + currentViewLabel="Logs" onNavigate={vi.fn()} onOpenChange={vi.fn()} onQueryChange={vi.fn()} @@ -141,9 +144,14 @@ describe("EventCommandDialog", () => { />, ); - expect(screen.getByText("Filter current view")).not.toBeNull(); + expect(screen.getByText("Filter Logs")).not.toBeNull(); expect( - screen.getByRole("option", { name: /Filter current view for “users”/ }), + screen.getByRole("option", { name: /Filter Logs for “users”/ }), ).not.toBeNull(); + expect(onFilterView).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("option", { name: /Filter Logs for “users”/ }), + ); + expect(onFilterView).toHaveBeenCalledWith("users"); }); }); diff --git a/apps/local/src/components/event-command-dialog.tsx b/apps/local/src/components/event-command-dialog.tsx index d2b70cc57..3d6e2bc9e 100644 --- a/apps/local/src/components/event-command-dialog.tsx +++ b/apps/local/src/components/event-command-dialog.tsx @@ -29,6 +29,8 @@ type EventCommandDialogProps = { onOpenChange: (open: boolean) => void; onQueryChange: (query: string) => void; onSelectItem: (item: LocalFeedItem) => void; + onFilterView?: (query: string) => void; + currentViewLabel?: string; }; export function EventCommandDialog({ @@ -42,6 +44,8 @@ export function EventCommandDialog({ onOpenChange, onQueryChange, onSelectItem, + onFilterView, + currentViewLabel = "current view", }: EventCommandDialogProps) { useEffect(() => { if (!enabled) { @@ -82,8 +86,8 @@ export function EventCommandDialog({ return ( { if (nextOpen) { onOpenChange(true); @@ -94,6 +98,7 @@ export function EventCommandDialog({ > ))} - {query.trim() ? ( - + {query.trim() && onFilterView ? ( + { + onFilterView(query.trim()); + close(); + }} > diff --git a/apps/local/src/components/event-context.tsx b/apps/local/src/components/event-context.tsx new file mode 100644 index 000000000..52221f010 --- /dev/null +++ b/apps/local/src/components/event-context.tsx @@ -0,0 +1,138 @@ +import { + record, + display, + text, + feedbackData, + EVENT_CONTEXT_SECTIONS, +} from "@/lib/inspection.ts"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { DetailFields } from "./span-detail.tsx"; +import { attributeFields } from "./inspector-data.ts"; + +export function EventContext({ payload }: { payload: unknown }) { + const p = record(payload); + const breadcrumbs = Array.isArray(p.breadcrumbs) + ? p.breadcrumbs + : record(p.breadcrumbs).values; + + return ( +
    + {Array.isArray(breadcrumbs) && breadcrumbs.length ? ( +
    +

    Breadcrumbs

    +
      + {breadcrumbs.map((value, index) => { + const crumb = record(value); + return ( +
    1. +
      + + {text(crumb.category) ?? text(crumb.type) ?? "Breadcrumb"} + + {text(crumb.level)} + +
      + {crumb.message !== undefined ? ( +

      + {display(crumb.message)} +

      + ) : null} + {Object.keys(record(crumb.data)).length ? ( + + ) : null} +
    2. + ); + })} +
    +
    + ) : null} + {EVENT_CONTEXT_SECTIONS.map((key) => + Object.keys(record(p[key])).length ? ( +
    + + {key === "extra" ? "Additional data" : key} + + {key === "contexts" ? ( + Object.entries(record(p.contexts)).map(([name, context]) => ( +
    +

    {name}

    + +
    + )) + ) : ( + + )} +
    + ) : null, + )} +
    + ); +} +export function FeedbackInspector({ + item, + items = [], + onSelect, +}: { + item: LocalFeedItem; + items?: readonly LocalFeedItem[]; + onSelect?: (item: LocalFeedItem) => void; +}) { + const feedback = feedbackData(item.payload); + const associatedId = + text(feedback.associated_event_id) ?? + (item.type === "user_report" ? text(feedback.event_id) : undefined); + const associated = associatedId + ? items.find( + (candidate) => + candidate.type === "event" && + record(candidate.payload).event_id === associatedId, + ) + : undefined; + return ( +
    +

    User feedback

    + + value === undefined ? [] : [{ label, value: display(value) }], + )} + /> + {associated && onSelect ? ( + + ) : associatedId ? ( +

    + The associated event is not in this capture. +

    + ) : null} +
    + ); +} diff --git a/apps/local/src/components/event-detail.test.tsx b/apps/local/src/components/event-detail.test.tsx new file mode 100644 index 000000000..2cb389db4 --- /dev/null +++ b/apps/local/src/components/event-detail.test.tsx @@ -0,0 +1,231 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { decodeEnvelope } from "@/lib/spotlight.ts"; +import { buildTraceGroups } from "@/lib/trace-model.ts"; +import { EventDetail } from "./event-detail.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function item(type: string, payload: unknown) { + const entry = decodeEnvelope( + JSON.stringify([{}, [[{ type }, payload]]]), + "fixture", + )[0]; + if (!entry) throw new Error("Expected fixture"); + return entry; +} + +test("logs display their message, severity, and structured attributes", () => { + render( + , + ); + expect(screen.getByRole("tabpanel").textContent).toContain("Cache refreshed"); + expect(screen.getByRole("tabpanel").textContent).toContain("info"); + expect( + screen.getByRole("table", { name: "Log attributes" }).textContent, + ).toContain("orders"); + expect(screen.getByRole("button", { name: "Copy Message" })).toBeDefined(); +}); + +test("raw-only SDK payloads default to JSON and reset on a different item", () => { + const { rerender } = render( + , + ); + fireEvent.click(screen.getByRole("tab", { name: "JSON" })); + rerender( + , + ); + expect( + screen.getByRole("tab", { name: "JSON" }).getAttribute("aria-selected"), + ).toBe("true"); + expect(screen.getByTestId("highlighted-json").textContent).toContain( + "sample_rate", + ); + rerender( + , + ); + expect( + screen.getByRole("tab", { name: "Overview" }).getAttribute("aria-selected"), + ).toBe("true"); +}); + +test("arrow navigation wraps between detail tabs and moves keyboard focus", () => { + render(); + const overview = screen.getByRole("tab", { name: "Overview" }); + fireEvent.keyDown(overview, { key: "ArrowLeft" }); + const json = screen.getByRole("tab", { name: "JSON" }); + expect(json.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(json); + fireEvent.keyDown(json, { key: "ArrowRight" }); + expect(overview.getAttribute("aria-selected")).toBe("true"); + expect(document.activeElement).toBe(overview); +}); + +test("raw envelopes retain their recognizable heading and open directly in JSON", () => { + const envelope = { + id: "envelope:1", + type: "envelope", + text: "undecodable source", + payload: "undecodable source", + metadata: { title: "Envelope 1" }, + }; + render(); + expect(screen.getByRole("heading", { name: "Raw envelope" })).toBeDefined(); + expect( + screen.getByRole("tab", { name: "JSON" }).getAttribute("aria-selected"), + ).toBe("true"); + expect(screen.getByTestId("highlighted-json").textContent).toContain( + "undecodable source", + ); +}); + +test("malformed and nested log attributes remain readable inert text", () => { + render( + unsafe()" }, + }, + }, + })} + />, + ); + const attributes = screen.getByRole("table", { name: "Log attributes" }); + expect(attributes.textContent).toContain('"type":"string"'); + expect(attributes.textContent).toContain("null"); + expect(attributes.textContent).toContain("[1,null]"); + expect(attributes.textContent).toContain(""); + expect(attributes.querySelector("script")).toBeNull(); +}); + +test("requests show a compact copyable summary with captured method and URL", () => { + const copied: string[] = []; + vi.stubGlobal("navigator", { + clipboard: { + writeText: async (value: string) => { + copied.push(value); + }, + }, + }); + render( + , + ); + const summary = screen.getByLabelText("Request summary"); + expect(summary.tagName).toBe("DL"); + expect(summary.textContent).toContain("GET"); + expect(summary.textContent).toContain("https://example.test/orders?limit=10"); + expect(summary.textContent).toContain("201"); + expect(summary.textContent).toContain("250ms"); + expect(summary.textContent).toContain("auto.http"); + expect(screen.queryByRole("table", { name: "Event details" })).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Copy Status", description: "201" }), + ); + expect(screen.getByRole("status", { name: "Status copied" })).toBeDefined(); + expect(copied).toEqual(["201"]); +}); + +test("span summaries use captured typed URL attributes without inventing request fields", () => { + render( + , + ); + const summary = screen.getByLabelText("Span summary"); + expect(summary.textContent).toContain("https://example.test/items"); + expect(summary.textContent).toContain("POST"); + expect(summary.textContent).not.toContain("Status"); +}); + +test("repeated parent updates keep one request summary and its waterfall", () => { + const transaction = item("transaction", { + transaction: "GET /orders", + start_timestamp: 1, + timestamp: 2, + request: { status_code: 200 }, + contexts: { trace: { trace_id: "trace", span_id: "root" } }, + }); + const [trace] = buildTraceGroups([transaction]); + const { rerender } = render(); + for (let index = 0; index < 10; index += 1) { + rerender( + undefined} + />, + ); + } + expect(screen.getAllByLabelText("Request summary")).toHaveLength(1); + expect( + screen.getAllByRole("region", { name: "Trace waterfall" }), + ).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Inspect span GET /orders" }), + ).toBeDefined(); +}); + +test("captured context suppresses the empty-details message", () => { + render( + , + ); + expect(screen.getByText("Firefox")).toBeDefined(); + expect(screen.queryByText("No additional event details.")).toBeNull(); +}); + +test("a related trace suppresses the empty-details message", () => { + const transaction = item("transaction", { + transaction: "GET /trace", + contexts: { trace: { trace_id: "trace", span_id: "root" } }, + start_timestamp: 1, + timestamp: 2, + }); + const [trace] = buildTraceGroups([transaction]); + render(); + expect(screen.getByRole("region", { name: "Trace waterfall" })).toBeDefined(); + expect(screen.queryByText("No additional event details.")).toBeNull(); +}); diff --git a/apps/local/src/components/event-detail.tsx b/apps/local/src/components/event-detail.tsx new file mode 100644 index 000000000..6c0566551 --- /dev/null +++ b/apps/local/src/components/event-detail.tsx @@ -0,0 +1,313 @@ +import { envelopeAttachments } from "@/lib/attachments.ts"; +import { EventContext, FeedbackInspector } from "./event-context.tsx"; +import { AiInspector } from "./ai-inspector.tsx"; +import { AttachmentDetail, EnvelopeAttachments } from "./attachment-detail.tsx"; +import { errorMarkdown, inspectAi, hasEventContext } from "@/lib/inspection.ts"; +import { Button } from "./ui/button.tsx"; +import { useCopyToClipboard } from "@uidotdev/usehooks"; +import { useId, useState, useMemo } from "react"; +import { + attributeFields, + detailRecord, + type DetailField, +} from "./inspector-data.ts"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { getEventMetadata } from "@/lib/spotlight.ts"; +import { getEventExceptions } from "@/lib/exceptions.ts"; +import type { TraceGroup } from "@/lib/trace-model.ts"; +import { Badge } from "./ui/badge.tsx"; +import { ExceptionOverview } from "./exception-overview.tsx"; +import { TraceWaterfall } from "./trace-waterfall.tsx"; +import { + DetailFields, + InspectorTabs, + RelatedTelemetry, +} from "./span-detail.tsx"; + +type EventDetailProps = { + item: LocalFeedItem; + relatedItems?: readonly LocalFeedItem[]; + trace?: TraceGroup; + onSelectSpan?: (traceId: string, spanId: string) => void; + onSelectRelatedItem?: (item: LocalFeedItem) => void; +}; + +function formatTimestamp(timestamp: LocalFeedItem["timestamp"]) { + if (timestamp === undefined) return "unknown time"; + const date = new Date( + typeof timestamp === "number" && timestamp < 1_000_000_000_000 + ? timestamp * 1_000 + : timestamp, + ); + return Number.isNaN(date.getTime()) + ? String(timestamp) + : date.toLocaleTimeString(); +} + +function CompactSummary({ + label, + fields, +}: { + label: string; + fields: DetailField[]; +}) { + const [copiedField, setCopiedField] = useState(); + const [, copy] = useCopyToClipboard(); + const id = useId(); + if (!fields.length) return null; + return ( + <> +
    + {fields.map((field, index) => ( +
    +
    {field.label}
    +
    + +
    +
    + ))} +
    + {copiedField ? ( + + {copiedField} copied + + ) : null} + + ); +} + +/** Prefer the useful view for each telemetry type; keep its full original JSON one tab away. */ +export function EventDetail({ + item, + relatedItems, + trace, + onSelectSpan, + onSelectRelatedItem, +}: EventDetailProps) { + const [copiedForAi, setCopiedForAi] = useState(false); + const [, copyForAi] = useCopyToClipboard(); + const ai = inspectAi(item.payload); + const attachments = useMemo( + () => (item.type === "envelope" ? envelopeAttachments(item) : []), + [item], + ); + const hasContext = + (item.type === "event" || item.type === "transaction") && + hasEventContext(item.payload); + const metadata = item.metadata ?? getEventMetadata(item.payload, item.type); + const payload = detailRecord(item.payload); + const exceptions = + item.type === "event" ? getEventExceptions(item.payload) : []; + const hasOverview = + [ + "event", + "log", + "transaction", + "span", + "feedback", + "user_report", + "attachment", + ].includes(item.type) || + Boolean(ai) || + attachments.length > 0; + const message = + metadata.message ?? + (item.type === "log" + ? typeof payload.body === "string" + ? payload.body + : metadata.title + : undefined); + const fields: DetailField[] = [ + ...(message ? [{ label: "Message", value: message }] : []), + ...(item.type === "log" && metadata.level + ? [{ label: "Level", value: metadata.level }] + : []), + ...(metadata.statusCode === undefined + ? [] + : [{ label: "Status", value: String(metadata.statusCode) }]), + ...(metadata.durationMs === undefined + ? [] + : [ + { + label: "Duration", + value: `${metadata.durationMs.toFixed(metadata.durationMs < 10 ? 2 : 0)}ms`, + }, + ]), + ...(metadata.origin + ? [{ label: "Origin", value: metadata.origin, code: true }] + : []), + ]; + const isRequestOrSpan = item.type === "transaction" || item.type === "span"; + const request = detailRecord(payload.request); + const traceData = detailRecord( + detailRecord(detailRecord(payload.contexts).trace).data, + ); + const data = detailRecord(payload.data); + const urlAttribute = detailRecord(payload.attributes)["url.full"]; + const capturedURL = [ + request.url, + traceData["http.url"], + data["http.url"], + detailRecord(urlAttribute).value, + urlAttribute, + ].find( + (value): value is string => typeof value === "string" && value.length > 0, + ); + const summaryFields: DetailField[] = [ + ...(metadata.method ? [{ label: "Method", value: metadata.method }] : []), + ...fields.filter((field) => field.label !== "Message"), + ...(capturedURL ? [{ label: "URL", value: capturedURL }] : []), + ]; + const detailFields = isRequestOrSpan + ? fields.filter((field) => field.label === "Message") + : fields; + const attributes = attributeFields(payload.attributes); + return ( +
    +
    +
    + + {item.type === "envelope" + ? "Envelope" + : (metadata.operation ?? item.type)} + +

    + {item.type === "envelope" ? "Raw envelope" : metadata.title} +

    +
    + {item.type === "event" ? ( + + ) : null} + +
    + + {item.type === "attachment" ? ( + + ) : null} + {item.type === "envelope" ? ( + + ) : null} + {item.type === "feedback" || item.type === "user_report" ? ( + + ) : null} + {ai ? ( + span.id === metadata.spanId) + ?.children + } + /> + ) : null} + {exceptions.length ? ( + + ) : null} + {isRequestOrSpan ? ( + + ) : null} + + {attributes.length ? ( +
    +

    Attributes

    + +
    + ) : null} + {!fields.length && + !(isRequestOrSpan && summaryFields.length) && + !exceptions.length && + !attributes.length && + !ai && + !hasContext && + !trace && + !["attachment", "envelope", "feedback", "user_report"].includes( + item.type, + ) ? ( +

    + No additional event details. +

    + ) : null} + {hasContext ? : null} + {trace ? ( + <> + onSelectSpan(trace.id, spanId) + : undefined + } + /> + + + ) : null} + + } + /> +
    + ); +} diff --git a/apps/local/src/components/event-filter-controls.tsx b/apps/local/src/components/event-filter-controls.tsx new file mode 100644 index 000000000..ba1afc69c --- /dev/null +++ b/apps/local/src/components/event-filter-controls.tsx @@ -0,0 +1,167 @@ +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import { + defaultEventFilters, + loggerForItem, + type EventFilters, + type EventColumn, +} from "@/lib/event-filters.ts"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx"; +import { Button } from "./ui/button.tsx"; +function toggle(values: string[], value: string) { + return values.includes(value) + ? values.filter((entry) => entry !== value) + : [...values, value]; +} +export function EventFilterControls({ + items, + filters, + onChange, + showLogger, +}: { + items: readonly LocalFeedItem[]; + filters: EventFilters; + onChange: (filters: EventFilters) => void; + showLogger: boolean; +}) { + const levels = [ + ...new Set(items.map((item) => item.metadata?.level ?? "unknown")), + ].sort(); + const loggers = [ + ...new Set(items.map((item) => loggerForItem(item) ?? "unknown")), + ].sort(); + const count = filters.levels.length + filters.loggers.length; + return ( +
    + + + + + +
    + Severity + {levels.map((level) => ( + + ))} +
    + {showLogger ? ( +
    + Logger + {loggers.map((logger) => ( + + ))} +
    + ) : null} +
    +
    + + + + + + +

    + Message is always shown. +

    + {( + [ + "time", + "type", + "level", + ...(showLogger ? ["logger"] : []), + "duration", + ] as EventColumn[] + ).map((column) => ( + + ))} +
    +
    + {count ? ( + + ) : null} + {count ? ( +

    + {[...filters.levels, ...filters.loggers].join(" · ")} +

    + ) : null} +
    + ); +} diff --git a/apps/local/src/components/exception-overview.tsx b/apps/local/src/components/exception-overview.tsx index d7a667291..10ca49383 100644 --- a/apps/local/src/components/exception-overview.tsx +++ b/apps/local/src/components/exception-overview.tsx @@ -1,3 +1,5 @@ +import { attributeFields } from "./inspector-data.ts"; +import { DetailFields } from "./span-detail.tsx"; import { formatExceptionTitle, type EventException } from "@/lib/exceptions.ts"; /** Render SDK text as text, including source lines; never execute or fetch source. */ @@ -46,6 +48,25 @@ export function ExceptionOverview({ ? `:${frame.line}${frame.column !== undefined ? `:${frame.column}` : ""}` : ""}
    + {(frame.editorPath ?? frame.filename).startsWith("/") ? ( + + Open in VS Code + + ) : null} + {frame.variables ? ( +
    + + Local variables + + +
    + ) : null} {frame.source ? (
                             {frame.source}
    diff --git a/apps/local/src/components/inspector-data.ts b/apps/local/src/components/inspector-data.ts
    new file mode 100644
    index 000000000..6896d5f70
    --- /dev/null
    +++ b/apps/local/src/components/inspector-data.ts
    @@ -0,0 +1,22 @@
    +export type DetailField = { label: string; value: string; code?: boolean };
    +
    +export function detailRecord(value: unknown): Record {
    +  return typeof value === "object" && value !== null && !Array.isArray(value)
    +    ? (value as Record)
    +    : {};
    +}
    +
    +export function attributeFields(value: unknown): DetailField[] {
    +  return Object.entries(detailRecord(value)).map(([label, attribute]) => {
    +    const record = detailRecord(attribute);
    +    const content = "value" in record ? record.value : attribute;
    +    return {
    +      label,
    +      value:
    +        typeof content === "string"
    +          ? content
    +          : (JSON.stringify(content) ?? String(content)),
    +      code: true,
    +    };
    +  });
    +}
    diff --git a/apps/local/src/components/investigation-controls.test.tsx b/apps/local/src/components/investigation-controls.test.tsx
    new file mode 100644
    index 000000000..1c27a917b
    --- /dev/null
    +++ b/apps/local/src/components/investigation-controls.test.tsx
    @@ -0,0 +1,97 @@
    +import { cleanup, fireEvent, render, screen } from "@testing-library/react";
    +import { afterEach, expect, test } from "vitest";
    +import { decodeEnvelope } from "@/lib/spotlight.ts";
    +import { buildTraceGroups } from "@/lib/trace-model.ts";
    +import { TraceWaterfall } from "./trace-waterfall.tsx";
    +import { filterEvents, defaultEventFilters } from "@/lib/event-filters.ts";
    +afterEach(cleanup);
    +const events = decodeEnvelope(
    +  JSON.stringify([
    +    {},
    +    [
    +      [
    +        { type: "log" },
    +        {
    +          items: [
    +            {
    +              body: "first",
    +              level: "info",
    +              timestamp: 10,
    +              attributes: { "sentry.logger.name": { value: "orders" } },
    +            },
    +            {
    +              body: "second",
    +              level: "error",
    +              timestamp: 20,
    +              attributes: { "sentry.logger.name": { value: "payments" } },
    +            },
    +          ],
    +        },
    +      ],
    +    ],
    +  ]),
    +  "logs",
    +);
    +test("log filters combine severity and logger and sort timestamps without changing captured data", () => {
    +  expect(
    +    filterEvents(events, {
    +      ...defaultEventFilters,
    +      levels: ["error"],
    +      loggers: ["payments"],
    +      sort: "newest",
    +    }).map((item) => item.metadata?.title),
    +  ).toEqual(["second"]);
    +  expect(
    +    filterEvents(events, { ...defaultEventFilters, sort: "newest" })[0]
    +      ?.timestamp,
    +  ).toBe(20);
    +  expect(events[0]?.timestamp).toBe(10);
    +});
    +test("trace search reveals matching descendants and branch collapse is reversible", () => {
    +  const [trace] = buildTraceGroups(
    +    decodeEnvelope(
    +      JSON.stringify([
    +        {},
    +        [
    +          [
    +            { type: "transaction" },
    +            {
    +              transaction: "Checkout",
    +              contexts: { trace: { trace_id: "t", span_id: "root" } },
    +              spans: [
    +                {
    +                  span_id: "query",
    +                  parent_span_id: "root",
    +                  op: "db.query",
    +                  description: "select orders",
    +                },
    +                {
    +                  span_id: "cache",
    +                  parent_span_id: "root",
    +                  op: "cache",
    +                  description: "cache lookup",
    +                },
    +              ],
    +            },
    +          ],
    +        ],
    +      ]),
    +      "trace",
    +    ),
    +  );
    +  render( {}} />);
    +  fireEvent.click(screen.getByRole("button", { name: "Collapse Checkout" }));
    +  expect(
    +    screen.queryByRole("button", { name: "Inspect span select orders" }),
    +  ).toBeNull();
    +  fireEvent.change(screen.getByRole("searchbox", { name: "Search in trace" }), {
    +    target: { value: "orders" },
    +  });
    +  fireEvent.click(screen.getByRole("checkbox", { name: "Only show matches" }));
    +  expect(
    +    screen.getByRole("button", { name: "Inspect span select orders" }),
    +  ).toBeDefined();
    +  expect(
    +    screen.queryByRole("button", { name: "Inspect span cache lookup" }),
    +  ).toBeNull();
    +});
    diff --git a/apps/local/src/components/local-settings.test.tsx b/apps/local/src/components/local-settings.test.tsx
    new file mode 100644
    index 000000000..e25c893b5
    --- /dev/null
    +++ b/apps/local/src/components/local-settings.test.tsx
    @@ -0,0 +1,43 @@
    +import { cleanup, render, screen } from "@testing-library/react";
    +import { afterEach, expect, test, vi } from "vitest";
    +import { LocalSettings } from "./local-settings.tsx";
    +
    +afterEach(cleanup);
    +const props = {
    +  open: true,
    +  onOpenChange: vi.fn(),
    +  triggerRef: { current: null },
    +  streamUrl: "http://127.0.0.1:8969/stream",
    +  limit: 500,
    +  count: 8,
    +  editable: false,
    +  supported: true,
    +  captureLabel: "Web app",
    +};
    +
    +test("separates global appearance, capture capacity, and the existing save deadline", () => {
    +  const expiresAt = 1793010600000;
    +  render();
    +  expect(screen.getByRole("heading", { name: "Appearance" })).not.toBeNull();
    +  expect(screen.getByText(/Applies to all captures/)).not.toBeNull();
    +  expect(
    +    screen.getByRole("heading", { name: "History capacity" }),
    +  ).not.toBeNull();
    +  expect(screen.getByText("For capture: Web app")).not.toBeNull();
    +  expect(
    +    screen.getByText(/An SDK envelope can contain several events/),
    +  ).not.toBeNull();
    +  expect(screen.getByText("Saved until")).not.toBeNull();
    +  expect(document.querySelector("time")?.getAttribute("dateTime")).toBe(
    +    "2026-10-26T10:30:00.000Z",
    +  );
    +  expect(screen.queryByRole("spinbutton")).toBeNull();
    +});
    +
    +test.each([undefined, Number.NaN, Number.POSITIVE_INFINITY])(
    +  "does not invent a saved deadline from %s",
    +  (expiresAt) => {
    +    render();
    +    expect(screen.queryByText("Saved until")).toBeNull();
    +  },
    +);
    diff --git a/apps/local/src/components/local-settings.tsx b/apps/local/src/components/local-settings.tsx
    new file mode 100644
    index 000000000..e320c05f3
    --- /dev/null
    +++ b/apps/local/src/components/local-settings.tsx
    @@ -0,0 +1,126 @@
    +import type { RefObject } from "react";
    +import { Monitor, Moon, Sun } from "lucide-react";
    +import { useTheme } from "next-themes";
    +import { Button } from "./ui/button.tsx";
    +import { RetentionSettings } from "./retention-settings.tsx";
    +import {
    +  Dialog,
    +  DialogContent,
    +  DialogDescription,
    +  DialogHeader,
    +  DialogTitle,
    +} from "./ui/dialog.tsx";
    +
    +const THEME_OPTIONS = [
    +  { value: "light", label: "Light", Icon: Sun },
    +  { value: "dark", label: "Dark", Icon: Moon },
    +  { value: "system", label: "System", Icon: Monitor },
    +] as const;
    +
    +type Props = {
    +  triggerRef: RefObject;
    +  open: boolean;
    +  onOpenChange: (open: boolean) => void;
    +  streamUrl: string;
    +  limit: number;
    +  count: number;
    +  editable: boolean;
    +  supported: boolean;
    +  unavailableReason?: string;
    +  captureLabel?: string;
    +  expiresAt?: number;
    +};
    +
    +export function LocalSettings({
    +  open,
    +  onOpenChange,
    +  supported,
    +  triggerRef,
    +  captureLabel,
    +  expiresAt,
    +  ...retention
    +}: Props) {
    +  const { theme, resolvedTheme, setTheme } = useTheme();
    +  const savedUntil =
    +    typeof expiresAt === "number" &&
    +    Number.isSafeInteger(expiresAt) &&
    +    expiresAt > 0 &&
    +    expiresAt <= 8.64e15
    +      ? new Date(expiresAt)
    +      : undefined;
    +  return (
    +    
    +       {
    +          if (triggerRef.current?.isConnected) {
    +            event.preventDefault();
    +            triggerRef.current.focus();
    +          }
    +        }}
    +      >
    +        
    +          Settings
    +          
    +            App appearance and capture history.
    +          
    +        
    +        
    +
    +

    Appearance

    + {theme === "system" && resolvedTheme ? ( +

    + System appearance: {resolvedTheme === "dark" ? "Dark" : "Light"} +

    + ) : null} +
    +

    + Applies to all captures in this viewer. +

    +
    + {THEME_OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
    +
    +
    +

    History capacity

    +

    + For capture: {captureLabel ?? "current receiver"} +

    + {supported ? ( + + ) : ( +

    + This receiver does not provide retention settings. +

    + )} +
    + {savedUntil ? ( +
    +
    Saved until
    +
    + +
    +
    + ) : null} +
    +
    + ); +} diff --git a/apps/local/src/components/performance-workspace.test.tsx b/apps/local/src/components/performance-workspace.test.tsx new file mode 100644 index 000000000..150ad788d --- /dev/null +++ b/apps/local/src/components/performance-workspace.test.tsx @@ -0,0 +1,112 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { decodeEnvelope } from "@/lib/spotlight.ts"; +import { buildTraceGroups } from "@/lib/trace-model.ts"; +import { SpanAnalysis, WebVitalsWorkspace } from "./performance-workspace.tsx"; +import { ProfileWorkspace } from "./profile-workspace.tsx"; +afterEach(cleanup); +const item = (type: string, payload: unknown) => + decodeEnvelope(JSON.stringify([{}, [[{ type }, payload]]]), "sample")[0]!; +test("resource groups sort and open the selected execution in its trace", () => { + const traces = buildTraceGroups([ + item("transaction", { + transaction: "Page", + contexts: { trace: { trace_id: "t", span_id: "root" } }, + spans: [ + { + span_id: "one", + op: "resource.script", + description: "app.js", + start_timestamp: 1, + timestamp: 1.2, + data: { "http.response_content_length": 100 }, + }, + { + span_id: "two", + op: "resource.img", + description: "logo.png", + start_timestamp: 1, + timestamp: 1.1, + }, + ], + }), + ]); + const onSelect = vi.fn(); + render( + , + ); + fireEvent.change(screen.getByLabelText("Sort by"), { + target: { value: "name" }, + }); + fireEvent.click(screen.getByRole("button", { name: "logo.png" })); + fireEvent.click(screen.getByRole("button", { name: /Page.*two.*100.0 ms/ })); + expect(onSelect).toHaveBeenCalledWith("t", "two"); +}); +test("Web Vitals metric switching displays captured INP without fabricating missing visits", () => { + render( + {}} + />, + ); + fireEvent.change(screen.getByLabelText("Measurement"), { + target: { value: "inp" }, + }); + expect( + screen.getByRole("img", { name: "INP: 350 ms, Needs improvement" }), + ).toBeDefined(); +}); +test("profile chart supports zoom, visualization switching and independent thread samples", () => { + const profile = item("profile", { + profile: { + frames: [{ function: "mainWork" }, { function: "workerWork" }], + stacks: [[0], [1]], + samples: [ + { thread_id: "main", stack_id: 0, elapsed_since_start_ns: "0" }, + { thread_id: "worker", stack_id: 1, elapsed_since_start_ns: "0" }, + ], + thread_metadata: { main: { name: "Main" }, worker: { name: "Worker" } }, + }, + }); + render( + {}} + onSelectTrace={() => {}} + />, + ); + fireEvent.keyDown( + screen.getByRole("button", { name: "Zoom to mainWork, 1 samples" }), + { key: "Enter" }, + ); + expect(screen.getByRole("button", { name: "Reset zoom" })).toBeDefined(); + fireEvent.change(screen.getByLabelText("Visualization"), { + target: { value: "treemap" }, + }); + expect( + screen.getByRole("group", { name: "treemap profile visualization" }), + ).toBeDefined(); + fireEvent.change(screen.getByLabelText("Thread"), { + target: { value: "worker" }, + }); + expect(screen.queryByRole("button", { name: "Reset zoom" })).toBeNull(); + expect( + screen.getByRole("table", { name: "Profile functions" }).textContent, + ).toContain("workerWork"); + expect( + screen.getByRole("table", { name: "Profile functions" }).textContent, + ).not.toContain("mainWork"); +}); diff --git a/apps/local/src/components/performance-workspace.tsx b/apps/local/src/components/performance-workspace.tsx new file mode 100644 index 000000000..bcd0a4053 --- /dev/null +++ b/apps/local/src/components/performance-workspace.tsx @@ -0,0 +1,333 @@ +import { useMemo, useState } from "react"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import type { TraceGroup } from "@/lib/trace-model.ts"; +import { + spanInsights, + webVitalPages, + VITALS, + type VitalName, +} from "@/lib/performance.ts"; +import { ResizableWorkspace } from "./resizable-workspace.tsx"; +function ms(value?: number) { + return value === undefined ? "—" : `${value.toFixed(1)} ms`; +} +function Empty({ children }: { children: string }) { + return ( +

    + {children} +

    + ); +} +export function SpanAnalysis({ + kind, + traces, + query, + onSelectSpan, +}: { + kind: "queries" | "resources"; + traces: readonly TraceGroup[]; + query: string; + onSelectSpan: (trace: string, span: string) => void; +}) { + const [sort, setSort] = useState< + "name" | "count" | "totalMs" | "averageMs" | "averageBytes" + >("totalMs"); + const [ascending, setAscending] = useState(false); + const [selectedId, setSelectedId] = useState(); + const groups = useMemo( + () => + spanInsights(traces, kind) + .filter((group) => + `${group.name} ${group.operation}` + .toLowerCase() + .includes(query.toLowerCase()), + ) + .sort( + (a, b) => + (sort === "name" + ? a.name.localeCompare(b.name) + : (a[sort] ?? -Infinity) - (b[sort] ?? -Infinity)) * + (ascending ? 1 : -1), + ), + [traces, kind, query, sort, ascending], + ); + const selected = groups.find((group) => group.id === selectedId) ?? groups[0]; + const label = kind === "queries" ? "Database queries" : "Resources"; + if (!selected) + return ( + + {query + ? `No matching ${label.toLowerCase()}. Clear the filter to see all groups.` + : `No ${label.toLowerCase()} captured. Send ${kind === "queries" ? "database" : "resource loading"} spans through your SDK.`} + + ); + const columns = [ + ["name", kind === "queries" ? "Query" : "Resource"], + ["count", "Executions"], + ["totalMs", "Total time"], + ["averageMs", "Average"], + ...(kind === "resources" ? [["averageBytes", "Average size"]] : []), + ] as const; + return ( +
    +
    +

    {label}

    +

    + Grouped by captured operation and exact description. Durations and + sizes use only executions that include those values. +

    +
    + +
    + + + + {groups.length} groups + +
    +
      + {groups.map((group) => ( +
    1. + +
    2. + ))} +
    +
    + } + > +
    +

    {selected.operation}

    +

    + {selected.name} +

    +

    + Captured executions · {selected.count} +

    +
      + {selected.executions + .toSorted( + (a, b) => (b.span.durationMs ?? -1) - (a.span.durationMs ?? -1), + ) + .map((entry) => ( +
    1. + +
    2. + ))} +
    +
    + + + ); +} +export function WebVitalsWorkspace({ + items, + query, + onSelectItem, +}: { + items: readonly LocalFeedItem[]; + query: string; + onSelectItem: (item: LocalFeedItem) => void; +}) { + const pages = useMemo( + () => + webVitalPages(items).filter((page) => + page.name.toLowerCase().includes(query.toLowerCase()), + ), + [items, query], + ); + const [selectedName, setSelectedName] = useState(); + const [chosenMetric, setChosenMetric] = useState("lcp"); + const selected = pages.find((page) => page.name === selectedName) ?? pages[0]; + if (!selected) + return ( + + {query + ? "No pages match this filter." + : "No Web Vitals captured. Send browser pageload transactions with performance measurements."} + + ); + const available = (Object.keys(VITALS) as VitalName[]).filter((name) => + selected.visits.some((visit) => visit.metrics[name]), + ); + const metric = available.includes(chosenMetric) + ? chosenMetric + : available[0]!; + const config = VITALS[metric]; + const max = Math.max( + config.poor, + ...selected.visits.map((visit) => visit.metrics[metric]?.value ?? 0), + ); + const valueLabel = (value: number) => + `${value.toFixed(metric === "cls" ? 3 : 0)}${config.unit ? ` ${config.unit}` : ""}`; + return ( + +

    + Web Vitals +

    +
      + {pages.map((page) => ( +
    1. + +
    2. + ))} +
    + + } + > +
    +

    {selected.name}

    +

    + Local measurements per visit. These are development samples, not a + field percentile or a combined performance score. +

    + +

    + Good ≤ {valueLabel(config.good)} · Poor > {valueLabel(config.poor)} +

    +
      + {selected.visits.map((visit, index) => { + const measurement = visit.metrics[metric]; + return ( +
    1. + + {measurement ? ( + <> +
      + {measurement.rating} + + {valueLabel(measurement.value)} + +
      +
      +
      +
      + + ) : ( +

      + Not captured for this visit +

      + )} +
    2. + ); + })} +
    +
    +
    + ); +} diff --git a/apps/local/src/components/profile-workspace.tsx b/apps/local/src/components/profile-workspace.tsx new file mode 100644 index 000000000..9e7a5ec5c --- /dev/null +++ b/apps/local/src/components/profile-workspace.tsx @@ -0,0 +1,479 @@ +import { + useMemo, + useState, + useRef, + useEffect, + type KeyboardEvent, +} from "react"; +import type { LocalFeedItem } from "@/lib/spotlight.ts"; +import type { TraceGroup } from "@/lib/trace-model.ts"; +import { + parseProfile, + profileMatchesTrace, + type ProfileNode, +} from "@/lib/profiles.ts"; +import { record, text } from "@/lib/inspection.ts"; +import { ResizableWorkspace } from "./resizable-workspace.tsx"; +import { InspectorTabs } from "./span-detail.tsx"; +import { Button } from "./ui/button.tsx"; + +type Box = { + node: ProfileNode; + x: number; + y: number; + width: number; + height: number; + depth: number; +}; +function boxes( + root: ProfileNode, + type: "flame" | "treemap", + width: number, +): Box[] { + const result: Box[] = []; + const pending: Box[] = [ + { + node: root, + x: 0, + y: 0, + width, + height: type === "flame" ? 28 : 400, + depth: 0, + }, + ]; + while (pending.length && result.length < 2500) { + const box = pending.pop()!; + result.push(box); + if (box.depth >= 32 || box.node.samples === 0) continue; + let offset = 0; + for (const child of box.node.children) { + const fraction = child.samples / box.node.samples; + const vertical = type === "treemap" && box.depth % 2 === 0; + pending.push({ + node: child, + x: vertical ? box.x + 2 : box.x + offset, + y: + type === "flame" + ? box.y + 30 + : vertical + ? box.y + 18 + offset + : box.y + 18, + width: vertical ? Math.max(0, box.width - 4) : box.width * fraction, + height: + type === "flame" + ? 28 + : vertical + ? Math.max(0, (box.height - 20) * fraction) + : Math.max(0, box.height - 20), + depth: box.depth + 1, + }); + offset += + (vertical ? Math.max(0, box.height - 20) : box.width) * fraction; + } + } + return result; +} +function color(node: ProfileNode): string { + return node.inApp ? "#9b6b16" : node.id === "root" ? "#6655a7" : "#60657b"; +} +function arc( + inner: number, + outer: number, + start: number, + end: number, + center: number, + centerY: number, +): string { + const point = (r: number, angle: number) => + `${center + r * Math.cos(angle)},${centerY + r * Math.sin(angle)}`; + const last = Math.min(end, start + Math.PI * 2 - 0.00001), + large = last - start > Math.PI ? 1 : 0; + return `M${point(outer, start)}A${outer},${outer} 0 ${large} 1 ${point(outer, last)}L${point(inner, last)}A${inner},${inner} 0 ${large} 0 ${point(inner, start)}Z`; +} +function ProfileChart({ + root, + type, + onSelect, +}: { + root: ProfileNode; + type: "flame" | "treemap" | "sunburst"; + onSelect: (node: ProfileNode) => void; +}) { + const container = useRef(null); + const [width, setWidth] = useState(800); + useEffect(() => { + const element = container.current; + if (!element) return; + const update = () => { + if (element.clientWidth) setWidth(Math.max(288, element.clientWidth)); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + const layouts = boxes(root, type === "sunburst" ? "flame" : type, width); + const radius = Math.min(200, (width - 8) / 2); + const centerY = radius + 8; + const depth = Math.max(1, ...layouts.map((box) => box.depth)); + const height = + type === "flame" + ? (depth + 1) * 30 + : type === "sunburst" + ? 2 * radius + 16 + : 400; + const activate = (event: KeyboardEvent, node: ProfileNode) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(node); + } + }; + return ( +
    + + + Sample distribution. Select a frame to zoom. App frames are gold; + system frames are gray. + + {layouts + .filter((box) => box.width >= 1 && box.height >= 1) + .map((box) => ( + onSelect(box.node)} + onKeyDown={(event) => activate(event, box.node)} + > + {`${box.node.name}\n${box.node.location}\n${box.node.samples} inclusive samples; ${box.node.selfSamples} self samples`} + {type === "sunburst" ? ( + box.depth === 0 ? ( + + ) : ( + + ) + ) : ( + <> + + {box.width > 70 ? ( + + {box.node.name.slice(0, Math.floor(box.width / 7) - 1)} + + ) : null} + + )} + + ))} + +

    + App frames: gold · System frames: gray · Select a frame to zoom. Charts + show up to 32 levels and 2,500 nodes; the function table includes all + parsed frames. +

    +
    + ); +} +function ProfileInspector({ + item, + traces, + onSelectTrace, +}: { + item: LocalFeedItem; + traces: readonly TraceGroup[]; + onSelectTrace: (id: string) => void; +}) { + const [thread, setThread] = useState(); + const [type, setType] = useState<"flame" | "treemap" | "sunburst">("flame"); + const [focus, setFocus] = useState(); + const [sort, setSort] = useState<"selfMs" | "totalMs" | "samples">("totalMs"); + const [filter, setFilter] = useState(""); + const profile = useMemo(() => parseProfile(item, thread), [item, thread]); + const linked = traces.filter((trace) => profileMatchesTrace(item, trace)); + const functions = profile.functions + .filter((fn) => + `${fn.name} ${fn.location}`.toLowerCase().includes(filter.toLowerCase()), + ) + .toSorted((a, b) => b[sort] - a[sort]); + const time = (value: number) => + profile.durationMs === undefined ? "—" : `${value.toFixed(2)} ms`; + return ( + +
    +

    + {item.type === "profile_chunk" + ? "Continuous profile chunk" + : "Profile"} +

    + +
    +

    + {profile.sampleCount.toLocaleString()} samples ·{" "} + {profile.durationMs === undefined + ? "Timing not captured" + : `${profile.durationMs.toFixed(2)} ms observed`} + . Time uses adjacent samples on the selected thread; the final + sample has no inferred duration. Stacks are limited to 256 frames. +

    + {linked.length ? ( +
    + {linked.map((trace) => ( + + ))} +
    + ) : ( +

    + No associated trace is available in this capture. +

    + )} + {profile.sampleCount && profile.functions.length ? ( + <> +
    + + {focus ? ( + + ) : null} +
    + {focus ? ( +

    + {focus.name} · {focus.samples} samples · {focus.location} +

    + ) : null} + + +
    + + + + + {( + [ + ["selfMs", "Self time"], + ["totalMs", "Total time"], + ["samples", "Samples"], + ] as const + ).map(([key, label]) => ( + + ))} + + + + {functions.map((fn) => ( + + + + + + + ))} + +
    + Function + + +
    + {fn.name} + + {fn.location} + + {time(fn.selfMs)} + {time(fn.totalMs)} + {fn.samples}
    +
    + + ) : ( +

    + No usable stack samples were captured. Inspect JSON for the + original profile payload. +

    + )} + + } + /> + ); +} +export function ProfileWorkspace({ + items, + traces, + query, + selectedId, + onSelect, + onSelectTrace, +}: { + items: readonly LocalFeedItem[]; + traces: readonly TraceGroup[]; + query: string; + selectedId?: string; + onSelect: (id: string) => void; + onSelectTrace: (id: string) => void; +}) { + const profiles = items.filter((item) => + item.text.toLowerCase().includes(query.toLowerCase()), + ); + const selected = + profiles.find((item) => item.id === selectedId) ?? profiles[0]; + if (!selected) + return ( +

    + {query + ? "No profiles match this filter." + : "No profiles captured. Enable profiling in your SDK and send its envelopes to this receiver."} +

    + ); + return ( + +

    + Profiles · {profiles.length} +

    +
      + {profiles.map((item, index) => ( +
    1. + +
    2. + ))} +
    + + } + > + +
    + ); +} diff --git a/apps/local/src/components/receiver-controls.test.tsx b/apps/local/src/components/receiver-controls.test.tsx new file mode 100644 index 000000000..dde094fda --- /dev/null +++ b/apps/local/src/components/receiver-controls.test.tsx @@ -0,0 +1,182 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; +import { useRef, useState } from "react"; +import { ReceiverControls } from "./receiver-controls.tsx"; +import { LocalSettings } from "./local-settings.tsx"; +import { ThemeProvider } from "./theme-provider.tsx"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +const connection = { + label: "Connected to local receiver", + tone: "success", +} as const; + +test("mobile receiver actions dismiss with Escape and restore keyboard focus", async () => { + render( + , + ); + const trigger = screen.getByRole("button", { name: "Capture actions" }); + fireEvent.click(trigger); + const dialog = await screen.findByRole("dialog", { + name: "Capture actions", + }); + fireEvent.keyDown(dialog, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(trigger)); +}); + +test("mobile Settings opens the shared modal without its closing popover stealing focus", async () => { + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addListener: () => undefined, + removeListener: () => undefined, + })); + function ViewerActions() { + const [open, setOpen] = useState(false); + const ref = useRef(null); + return ( + + { + ref.current = trigger; + setOpen(true); + }} + /> + + + ); + } + render(); + const trigger = screen.getByRole("button", { name: "Capture actions" }); + fireEvent.click(trigger); + fireEvent.click( + await screen.findByRole("button", { name: "Settings", exact: true }), + ); + await screen.findByRole("dialog", { name: "Settings", exact: true }); + expect(screen.queryByRole("dialog", { name: "Capture actions" })).toBeNull(); + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole("button", { name: "Light", exact: true }), + ), + ); + expect(screen.queryByRole("button", { name: /Use .* mode/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Close", exact: true })); + await waitFor(() => expect(document.activeElement).toBe(trigger)); +}); + +test("mobile actions prevent empty clears and duplicate close requests", async () => { + const clear = vi.fn(); + const close = vi.fn(); + const add = vi.fn(); + render( + , + ); + const trigger = screen.getByRole("button", { name: "Capture actions" }); + fireEvent.click(trigger); + const clearButton = await screen.findByRole("button", { + name: "Clear view", + }); + const closeButton = screen.getByRole("button", { name: "Stop capture" }); + expect((clearButton as HTMLButtonElement).disabled).toBe(true); + expect((closeButton as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(clearButton); + fireEvent.click(closeButton); + expect(clear).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + fireEvent.click( + screen.getByRole("button", { name: "Switch receiver", exact: true }), + ); + expect(add).toHaveBeenCalledWith(trigger); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +}); + +test("mobile disconnect returns to connections without clearing or closing", async () => { + const disconnect = vi.fn(); + const clear = vi.fn(); + const close = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Capture actions" })); + fireEvent.click( + await screen.findByRole("button", { name: "Disconnect viewer" }), + ); + expect(disconnect).toHaveBeenCalledOnce(); + expect(clear).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +}); + +test("capture deletion requires its own capability and receives the stable trigger", async () => { + const clear = vi.fn(); + const remove = vi.fn(); + const { rerender } = render( + , + ); + const trigger = screen.getByRole("button", { name: "Capture actions" }); + fireEvent.click(trigger); + expect( + screen.queryByRole("button", { name: "Delete captured events" }), + ).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Clear view" })); + expect(clear).toHaveBeenCalledOnce(); + expect(remove).not.toHaveBeenCalled(); + rerender( + , + ); + fireEvent.click(trigger); + fireEvent.click( + await screen.findByRole("button", { name: "Delete captured events" }), + ); + expect(remove).toHaveBeenCalledWith(trigger); + expect(trigger.isConnected).toBe(true); + expect(clear).toHaveBeenCalledOnce(); +}); diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx index 744209db5..cd5299ee0 100644 --- a/apps/local/src/components/receiver-controls.tsx +++ b/apps/local/src/components/receiver-controls.tsx @@ -1,160 +1,174 @@ -import { ChevronDown, Moon, Sun, Trash2, X } from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; -import { useTheme } from "next-themes"; +import { + ChevronDown, + Radio, + Settings, + Trash2, + Eraser, + X, + Unplug, + type LucideIcon, +} from "lucide-react"; +import { useRef, useState } from "react"; import type { ConnectionPresentation } from "@/lib/presentation.ts"; +import { Button } from "./ui/button.tsx"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx"; type ReceiverControlsProps = { - compact?: boolean; connection: ConnectionPresentation; retainedItemCount: number; onClear: () => void; + onDeleteCapturedEvents?: (trigger: HTMLButtonElement) => void; + onDisconnect?: () => void; + isClearing?: boolean; + onOpenSettings?: (trigger: HTMLButtonElement) => void; onCloseSession?: () => void; + isClosingSession?: boolean; + onAddReceiver?: (trigger: HTMLButtonElement) => void; showStatusRole?: boolean; }; +type ReceiverAction = { + label: string; + Icon: LucideIcon; + onClick?: () => void; + onOpenDialog?: (trigger: HTMLButtonElement) => void; + disabled?: boolean; + destructive?: boolean; +}; -/** Status and viewer-only actions kept together without exposing receiver mutations. */ +/** Mobile receiver actions share the desktop dialogs and their focus behavior. */ export function ReceiverControls({ - compact = false, connection, retainedItemCount, onClear, + onDeleteCapturedEvents, + onDisconnect, + isClearing = false, + onOpenSettings, onCloseSession, + isClosingSession = false, + onAddReceiver, 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 = + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const openingDialog = useRef(false); + const actions: ReceiverAction[] = [ + ...(onAddReceiver + ? [ + { + label: "Switch receiver", + Icon: Radio, + onOpenDialog: onAddReceiver, + }, + ] + : []), + ...(onOpenSettings + ? [ + { + label: "Settings", + Icon: Settings, + onOpenDialog: onOpenSettings, + }, + ] + : []), + { + label: "Clear view", + Icon: Eraser, + onClick: onClear, + disabled: retainedItemCount === 0 || isClearing, + }, + ...(onDeleteCapturedEvents + ? [ + { + label: "Delete captured events", + Icon: Trash2, + destructive: true, + disabled: isClearing, + onOpenDialog: onDeleteCapturedEvents, + }, + ] + : []), + ...(onCloseSession + ? [ + { + label: "Stop capture", + Icon: X, + onClick: onCloseSession, + disabled: isClosingSession || isClearing, + destructive: true, + }, + ] + : []), + ...(onDisconnect + ? [{ label: "Disconnect viewer", Icon: Unplug, onClick: onDisconnect }] + : []), + ]; + const dotClass = connection.tone === "success" - ? "bg-emerald-500 shadow-[0_0_10px_oklch(0.72_0.19_160)]" + ? "bg-emerald-500" : connection.tone === "warning" ? "bg-amber-500" : "bg-muted-foreground"; - return ( -
    - {compact ? ( - - ) : ( -
    - - - - {connection.label.replace(" to local receiver", "")} - - {showStatusRole ? ( - {connection.label} - ) : null} - - -
    - )} - {isOpen ? ( -
    - - - {onCloseSession ? ( - + + { + // The destination dialog owns focus when an action opens one. + if (openingDialog.current) event.preventDefault(); + openingDialog.current = false; + }} + > + {actions.map( + ({ label, Icon, onClick, disabled, destructive, onOpenDialog }) => ( + - ) : null} -
    - ) : null} -
    +