From d161ccb631a1c24cf1ec8536cd0c8cdc98ce017e Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 11:36:32 -0700
Subject: [PATCH 01/30] feat(local): Add browser session switching
Discover running and retained sessions through a separate browser capability and switch isolated feeds in local and hosted viewers.
Co-Authored-By: GPT-6 Codex
---
.../src/App.sessions.integration.test.tsx | 240 ++++++++++++++++++
apps/local/src/App.tsx | 21 +-
.../local/src/components/session-switcher.tsx | 114 +++++++++
apps/local/src/lib/session-directory.test.ts | 65 +++++
apps/local/src/lib/session-directory.ts | 113 +++++++++
docs/cli/local-control-plane.md | 31 ++-
.../cli/src/commands/local/control-plane.ts | 104 +++++---
packages/cli/src/commands/local/daemon.ts | 2 +
packages/cli/src/commands/local/run.ts | 8 +-
packages/cli/src/commands/local/server.ts | 6 +-
packages/cli/src/commands/local/ui.ts | 37 ++-
.../test/commands/local/control-plane.test.ts | 95 +++++++
.../cli/test/commands/local/server.test.ts | 51 ++++
packages/cli/test/commands/local/ui.test.ts | 35 +++
packages/cli/test/e2e/local.test.ts | 87 +++++++
15 files changed, 965 insertions(+), 44 deletions(-)
create mode 100644 apps/local/src/App.sessions.integration.test.tsx
create mode 100644 apps/local/src/components/session-switcher.tsx
create mode 100644 apps/local/src/lib/session-directory.test.ts
create mode 100644 apps/local/src/lib/session-directory.ts
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..e9558afb3
--- /dev/null
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -0,0 +1,240 @@
+// @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 { 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) {
+ const reservation = createServer();
+ await new Promise((r) => reservation.listen(0, "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 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 {
+ plane,
+ create,
+ ingest,
+ stop: () =>
+ new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ server.closeAllConnections();
+ }),
+ };
+}
+function renderSession(session: { streamUrl: string; directoryUrl?: string }) {
+ window.history.replaceState(
+ null,
+ "",
+ `/#${new URLSearchParams({ stream: session.streamUrl, ...(session.directoryUrl ? { directory: session.directoryUrl } : {}) })}`,
+ );
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+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();
+ fireEvent.change(picker, { target: { value: worker.id } });
+ await screen.findAllByText("/worker-job");
+ expect(screen.queryByText("/web-request")).toBeNull();
+ await screen.findByText(/Retention limit: 1,000 envelopes/);
+ expect(screen.queryByLabelText("Retention limit")).toBeNull();
+ 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();
+ fireEvent.change(picker, { target: { value: web.id } });
+ await screen.findAllByText("/web-after-switch");
+ await screen.findAllByText("/web-request");
+ expect(screen.queryByText("/worker-job")).toBeNull();
+ const api = await daemon.create("api");
+ await screen.findByRole(
+ "option",
+ { name: /api.*Running/ },
+ { timeout: 7000 },
+ );
+ daemon.plane.sessionStore.close(worker.id);
+ fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" }));
+ await screen.findByRole("option", { name: /worker.*Retained/ });
+ fireEvent.change(picker, { target: { value: worker.id } });
+ 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("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");
+ await screen.findByRole("option", { name: /web.*Running/ });
+ daemon.plane.sessionStore.close(web.id);
+ now += SESSION_RETENTION_MS;
+ fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" }));
+ await screen.findByRole("option", { name: "No sessions available" });
+ expect(screen.getAllByText("/retained-in-viewer").length).toBeGreaterThan(
+ 0,
+ );
+ } finally {
+ cleanup();
+ await daemon.stop();
+ }
+});
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 4d56c3a32..69dc65ff7 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1,3 +1,8 @@
+import { SessionSwitcher } from "./components/session-switcher.tsx";
+import {
+ getInitialSessionDirectory,
+ parseSessionDirectoryUrl,
+} from "./lib/session-directory.ts";
import { RetentionSettings } from "./components/retention-settings.tsx";
import {
DEFAULT_MAX_ENVELOPES,
@@ -725,6 +730,8 @@ export default function App() {
getSavedRemoteStream(),
),
);
+ const [directoryUrl] = useState(() => getInitialSessionDirectory(streamUrl));
+ const activeDirectoryUrl = parseSessionDirectoryUrl(directoryUrl, streamUrl);
const connectionGeneration = useRef(0);
const [connectionAttempt, setConnectionAttempt] = useState(0);
const [connection, setConnection] = useState("connecting");
@@ -928,8 +935,8 @@ export default function App() {
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(
"Enter a loopback stream or an HTTPS remote stream ending in /stream.",
@@ -1233,6 +1240,14 @@ export default function App() {
className="flex min-h-0 flex-1 flex-col"
aria-label="Local Sentry events"
>
+ {activeDirectoryUrl ? (
+
+ ) : null}
{hasRetentionSettings ? (
connectToEndpoint(draftEndpoint)}
/>
) : !hasRetainedItems ? (
diff --git a/apps/local/src/components/session-switcher.tsx b/apps/local/src/components/session-switcher.tsx
new file mode 100644
index 000000000..36ae7dea6
--- /dev/null
+++ b/apps/local/src/components/session-switcher.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from "react";
+import { RefreshCw } from "lucide-react";
+import {
+ parseViewerSessions,
+ saveSessionDirectory,
+ type ViewerSession,
+} from "../lib/session-directory.ts";
+
+/** A read-only directory remains independent of the selected telemetry stream. */
+export function SessionSwitcher({
+ directoryUrl,
+ streamUrl,
+ onSelect,
+}: {
+ directoryUrl: string;
+ streamUrl: string;
+ onSelect: (streamUrl: string) => void;
+}) {
+ const [sessions, setSessions] = useState
([]);
+ const [error, setError] = useState(false);
+ const [loaded, setLoaded] = useState(false);
+ const [refresh, setRefresh] = useState(0);
+ useEffect(() => {
+ let active = true;
+ let timer: ReturnType | undefined;
+ let controller: AbortController | undefined;
+ async function load() {
+ controller = new AbortController();
+ const timeout = setTimeout(() => controller?.abort(), 10_000);
+ try {
+ const response = await fetch(directoryUrl, {
+ signal: controller.signal,
+ credentials: "omit",
+ cache: "no-store",
+ redirect: "error",
+ referrerPolicy: "no-referrer",
+ });
+ if (!response.ok) throw new Error("Session discovery unavailable");
+ const next = parseViewerSessions(await response.json(), directoryUrl);
+ if (!active) return;
+ setSessions(next);
+ setLoaded(true);
+ setError(false);
+ saveSessionDirectory(directoryUrl);
+ } catch {
+ if (active) setError(true);
+ } finally {
+ clearTimeout(timeout);
+ if (active) timer = setTimeout(() => void load(), 5000);
+ }
+ }
+ void load();
+ return () => {
+ active = false;
+ controller?.abort();
+ clearTimeout(timer);
+ };
+ }, [directoryUrl, refresh]);
+ const current = sessions.find((s) => s.streamUrl === streamUrl);
+ return (
+
+
+ Session
+
+ {
+ const selected = sessions.find((s) => s.id === event.target.value);
+ if (selected) onSelect(selected.streamUrl);
+ }}
+ >
+ {!current ? (
+
+ {sessions.length
+ ? "Current session unavailable"
+ : error
+ ? "Sessions unavailable"
+ : loaded
+ ? "No sessions available"
+ : "Loading sessions…"}
+
+ ) : null}
+ {sessions.map((session) => (
+
+ {session.label} ·{" "}
+ {session.state === "open" ? "Running" : "Retained"} · {session.cwd}{" "}
+ · {session.id.slice(-8)}
+
+ ))}
+
+ setRefresh((value) => value + 1)}
+ >
+
+
+ {error ? (
+
+ Could not refresh sessions. The selected feed is unchanged. Retry with
+ Refresh sessions.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/local/src/lib/session-directory.test.ts b/apps/local/src/lib/session-directory.test.ts
new file mode 100644
index 000000000..7aa6e5c0f
--- /dev/null
+++ b/apps/local/src/lib/session-directory.test.ts
@@ -0,0 +1,65 @@
+import { beforeEach, expect, test } from "vitest";
+import {
+ parseSessionDirectoryUrl,
+ parseViewerSessions,
+ getInitialSessionDirectory,
+ saveSessionDirectory,
+} from "./session-directory.ts";
+const stream =
+ "http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=web-secret";
+const directory =
+ "http://127.0.0.1:8969/_local/v1/ui/sessions?cap=directory-secret";
+beforeEach(() => {
+ window.sessionStorage.clear();
+ window.history.replaceState(null, "", "/");
+});
+test("discovery never sends its capability to a different receiver or remote host", () => {
+ expect(parseSessionDirectoryUrl(directory, stream)).toBe(directory);
+ for (const target of [
+ "https://evil.example/_local/v1/ui/sessions?cap=secret",
+ "http://127.0.0.1:9999/_local/v1/ui/sessions?cap=secret",
+ "http://127.0.0.1:8969/_local/v1/sessions?cap=secret",
+ "http://127.0.0.1:8969/_local/v1/ui/sessions",
+ ]) {
+ expect(parseSessionDirectoryUrl(target, stream)).toBeUndefined();
+ }
+});
+test("returned stream targets must belong to the listed session on the connected daemon", () => {
+ const web = {
+ id: "web",
+ label: "same name",
+ cwd: "/web",
+ state: "open",
+ streamUrl: stream,
+ };
+ expect(parseViewerSessions({ sessions: [web] }, directory)).toEqual([web]);
+ for (const streamUrl of [
+ stream.replace(":8969", ":9999"),
+ stream.replace("/web/", "/worker/"),
+ "https://evil.example/stream",
+ ]) {
+ expect(() =>
+ parseViewerSessions({ sessions: [{ ...web, streamUrl }] }, directory),
+ ).toThrow();
+ }
+ expect(() =>
+ parseViewerSessions(
+ { sessions: [{ ...web, state: "invalid" }] },
+ directory,
+ ),
+ ).toThrow();
+});
+test("explicit discovery is tab-scoped and cannot follow a saved directory to another daemon", () => {
+ window.history.replaceState(
+ null,
+ "",
+ `/#${new URLSearchParams({ stream, directory })}`,
+ );
+ expect(getInitialSessionDirectory(stream)).toBe(directory);
+ saveSessionDirectory(directory);
+ window.history.replaceState(null, "", "/");
+ expect(getInitialSessionDirectory(stream)).toBe(directory);
+ expect(
+ getInitialSessionDirectory(stream.replace(":8969", ":9999")),
+ ).toBeUndefined();
+});
diff --git a/apps/local/src/lib/session-directory.ts b/apps/local/src/lib/session-directory.ts
new file mode 100644
index 000000000..ae080158a
--- /dev/null
+++ b/apps/local/src/lib/session-directory.ts
@@ -0,0 +1,113 @@
+import { isLoopbackHost, parseStreamEndpoint } from "./spotlight.ts";
+
+const DIRECTORY_STORAGE_KEY = "sentry.local.session-directory";
+export type ViewerSession = {
+ id: string;
+ label: string;
+ cwd: string;
+ state: "open" | "retained";
+ streamUrl: string;
+};
+
+/** Discovery is opt-in and stays on the already selected loopback daemon. */
+export function parseSessionDirectoryUrl(
+ value: string | null | undefined,
+ streamUrl: string,
+): string | undefined {
+ if (!value) return;
+ try {
+ const directory = new URL(value);
+ const stream = parseStreamEndpoint(streamUrl);
+ if (
+ stream?.kind !== "loopback" ||
+ !isLoopbackHost(directory.hostname) ||
+ directory.origin !== new URL(stream.url).origin ||
+ directory.username ||
+ directory.password ||
+ directory.hash ||
+ directory.pathname !== "/_local/v1/ui/sessions" ||
+ !directory.searchParams.get("cap")
+ )
+ return;
+ return directory.toString();
+ } catch {
+ return;
+ }
+}
+
+export function getInitialSessionDirectory(
+ streamUrl: string,
+): string | undefined {
+ const fragment = new URLSearchParams(window.location.hash.slice(1));
+ let saved: string | null = null;
+ try {
+ saved = window.sessionStorage.getItem(DIRECTORY_STORAGE_KEY);
+ } catch {
+ /* Storage may be disabled. */
+ }
+ return parseSessionDirectoryUrl(
+ fragment.has("directory") ? fragment.get("directory") : saved,
+ streamUrl,
+ );
+}
+
+export function saveSessionDirectory(directoryUrl: string): void {
+ try {
+ window.sessionStorage.setItem(DIRECTORY_STORAGE_KEY, directoryUrl);
+ } catch {
+ /* Current tab still works without storage. */
+ }
+}
+
+/** Treat daemon responses as untrusted; never follow off-daemon stream URLs. */
+export function parseViewerSessions(
+ value: unknown,
+ directoryUrl: string,
+): ViewerSession[] {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ !("sessions" in value) ||
+ !Array.isArray(value.sessions)
+ )
+ throw new Error("Invalid session directory");
+ const origin = new URL(directoryUrl).origin;
+ return value.sessions
+ .map((entry: unknown): ViewerSession => {
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
+ throw new Error("Invalid session");
+ const session = entry as Record;
+ if (
+ typeof session.id !== "string" ||
+ !session.id ||
+ typeof session.label !== "string" ||
+ typeof session.cwd !== "string" ||
+ (session.state !== "open" && session.state !== "retained") ||
+ typeof session.streamUrl !== "string"
+ )
+ throw new Error("Invalid session");
+ const stream = parseStreamEndpoint(session.streamUrl);
+ if (stream?.kind !== "loopback")
+ throw new Error("Invalid session stream");
+ const url = new URL(stream.url);
+ if (
+ url.origin !== origin ||
+ url.pathname !==
+ `/_local/v1/sessions/${encodeURIComponent(session.id)}/stream`
+ )
+ throw new Error("Invalid session stream");
+ return {
+ id: session.id,
+ label: session.label,
+ cwd: session.cwd,
+ state: session.state,
+ streamUrl: stream.url,
+ };
+ })
+ .sort(
+ (a, b) =>
+ Number(a.state !== "open") - Number(b.state !== "open") ||
+ a.label.localeCompare(b.label) ||
+ a.id.localeCompare(b.id),
+ );
+}
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 9fcca1a80..745aae648 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -22,8 +22,8 @@ sentry local daemon stop
`--session` or `--open`. Invalid combinations are rejected before a receiver or
application is started. Omit `--verify` to capture into a retained daemon session.
-`local session create --json` returns distinct `ingestUrl` and `streamUrl`
-capabilities for SDK ingestion and viewer reads. The ingest capability lives in
+`local session create --json` returns distinct `ingestUrl`, `streamUrl`, and `directoryUrl`
+capabilities for SDK ingestion, single-session streaming, and browser session discovery. The ingest capability lives in
the URL path so SDK transports that omit query parameters still deliver events.
Treat those URLs as credentials.
Use `--clientId ` to make session creation idempotent for retries. Once a
@@ -93,6 +93,31 @@ The independently served viewer lives in `apps/local`. Run
CLI builds use ; publishing the migrated app requires
that hosting project to use Toolkit and `apps/local` as its root directory.
+### Switching sessions
+
+Run either command with `--session --open` to open a viewer with a Session
+picker. It works in the local development viewer and on .
+The picker lists running and retained sessions from that daemon, including sessions
+created after the viewer opens. It refreshes every five seconds; Refresh sessions
+updates it immediately. Labels include the working directory and a short session ID
+to distinguish runs with the same name. Picking a session replaces only the viewer's
+feed, searches, and selection; the daemon retains every session's captured data.
+Switching back replays its retained envelopes. Retention follows the selected session.
+A failed refresh leaves the current feed readable. Expired sessions leave the list;
+the viewer keeps already displayed telemetry and does not silently switch runs.
+
+Discovery requires the separate `directoryUrl` capability returned by session create
+and session view. `--open` passes it alongside the initial stream in the URL fragment,
+which is removed after startup and is never sent to the hosting server. The directory
+is kept only in tab session storage, so reloads can retain the picker. The capability
+allows discovery of all sessions and their stream capabilities on this daemon; treat
+it as a credential. It does not grant ingestion or the CLI control API, and expires
+when the daemon stops. By themselves, existing scoped stream links do not grant sibling discovery.
+An older CLI/daemon or a plain receiver can still connect without a picker; reopen
+with an updated CLI and daemon to enable discovery. An already-running older daemon
+needs a restart after upgrading; restarting clears its in-memory telemetry. One picker
+covers one daemon, not receivers on other ports or computers.
+
A loopback-served viewer can reset and close its scoped session. The hosted viewer
can read the stream and clear its own displayed feed; it cannot mutate retained
receiver data. Remote HTTPS streams stay read-only.
@@ -112,7 +137,7 @@ spans as failures. The AI workspace matches AI namespaces (`ai`, `gen_ai`,
`pnpm --filter sentry test:e2e` builds the npm bundle and runs the CLI process
suite. `test/e2e/local.test.ts` covers concurrent startup, session isolation,
-capability checks, retained queries, scoped browser controls, wrapped applications,
+capability checks, hosted-origin session discovery and isolated stream switching, retained queries, scoped browser controls, wrapped applications,
real Sentry Node SDK ingestion, restart, and singleton ownership. The Local workflow repeats these tests against
npm and native binaries on Linux, macOS, and Windows.
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index da8b850c5..bf66e9b6a 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -5,6 +5,7 @@
* its bearer-protected API while SDKs receive only scoped ingestion URLs.
*/
+import { randomBytes } from "node:crypto";
import { Buffer } from "node:buffer";
import { pushToSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import { type Context, Hono } from "hono";
@@ -33,6 +34,7 @@ const LOOPBACK_ORIGIN_RE =
const SESSION_UI_ACTION_RE =
/^\/_local\/v1\/sessions\/[^/]+\/ui\/(reset|close|retention)$/;
const SESSION_INGEST_RE = /^\/_local\/v1\/sessions\/[^/]+\/ingest\/[^/]+$/;
+const VIEWER_SESSIONS_PATH = "/_local/v1/ui/sessions";
const SESSION_STREAM_RE = /^\/_local\/v1\/sessions\/[^/]+\/stream$/;
/** Authenticated HTTP application and its in-memory session registry. */
@@ -115,6 +117,10 @@ export function createLocalControlPlane({
}: CreateLocalControlPlaneOptions): LocalControlPlane {
const app = new Hono();
const baseUrl = formatLocalServerUrl(host, port);
+ // Separate opt-in capability: a scoped stream never grants sibling discovery.
+ // This token dies with the daemon and never authorizes the control API.
+ const viewerCapability = randomBytes(24).toString("base64url");
+ const directoryUrl = `${baseUrl}${VIEWER_SESSIONS_PATH}?cap=${viewerCapability}`;
const sessionExists = (reference: string): boolean => {
sessionStore.pruneExpired();
try {
@@ -138,6 +144,7 @@ export function createLocalControlPlane({
app.use(`${CONTROL_PLANE_PREFIX}/*`, async (c, next) => {
if (
+ c.req.path === VIEWER_SESSIONS_PATH ||
SESSION_STREAM_RE.test(c.req.path) ||
((c.req.method === "POST" || c.req.method === "OPTIONS") &&
SESSION_INGEST_RE.test(c.req.path)) ||
@@ -175,39 +182,56 @@ export function createLocalControlPlane({
allowMethods: ["GET", "OPTIONS"],
origin: (origin) => (isHostedUiOrigin(origin) ? origin : null),
});
- app.use(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c, next) => {
- const origin = c.req.header("origin") ?? "";
- const isWrite =
- c.req.method === "POST" ||
- (c.req.method === "OPTIONS" &&
- c.req.header("access-control-request-method") !== "GET");
- if (isWrite && !isLoopbackBrowserRequest(c.req.raw)) {
- return c.body(null, 403);
- }
- if (!isHostedUiOrigin(origin)) {
- return loopbackCors(c, next);
- }
- if (c.req.method === "OPTIONS") {
- if (c.req.header("access-control-request-method") !== "GET") {
+ for (const path of [
+ `${CONTROL_PLANE_PREFIX}/sessions/:id/stream`,
+ VIEWER_SESSIONS_PATH,
+ ])
+ app.use(path, async (c, next) => {
+ if (c.req.path === VIEWER_SESSIONS_PATH) {
+ if (
+ (c.req.method !== "GET" && c.req.method !== "OPTIONS") ||
+ (c.req.method === "OPTIONS" &&
+ c.req.header("access-control-request-method") !== "GET")
+ )
+ return c.body(null, 403);
+ if (
+ !isLoopbackBrowserRequest(c.req.raw) &&
+ !isHostedUiOrigin(c.req.header("origin") ?? "")
+ )
+ return c.body(null, 403);
+ }
+ const origin = c.req.header("origin") ?? "";
+ const isWrite =
+ c.req.method === "POST" ||
+ (c.req.method === "OPTIONS" &&
+ c.req.header("access-control-request-method") !== "GET");
+ if (isWrite && !isLoopbackBrowserRequest(c.req.raw)) {
return c.body(null, 403);
}
- c.header("Access-Control-Allow-Origin", origin);
- c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
- c.header("Access-Control-Allow-Headers", "Last-Event-ID");
- if (c.req.header("access-control-request-private-network") === "true") {
- c.header("Access-Control-Allow-Private-Network", "true");
+ if (!isHostedUiOrigin(origin)) {
+ return loopbackCors(c, next);
}
- c.header(
- "Vary",
- "Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
- );
- return c.body(null, 204);
- }
- if (c.req.method !== "GET") {
- return c.body(null, 403);
- }
- return hostedCors(c, next);
- });
+ if (c.req.method === "OPTIONS") {
+ if (c.req.header("access-control-request-method") !== "GET") {
+ return c.body(null, 403);
+ }
+ c.header("Access-Control-Allow-Origin", origin);
+ c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
+ c.header("Access-Control-Allow-Headers", "Last-Event-ID");
+ if (c.req.header("access-control-request-private-network") === "true") {
+ c.header("Access-Control-Allow-Private-Network", "true");
+ }
+ c.header(
+ "Vary",
+ "Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
+ );
+ return c.body(null, 204);
+ }
+ if (c.req.method !== "GET") {
+ return c.body(null, 403);
+ }
+ return hostedCors(c, next);
+ });
app.use(
`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/*`,
cors({
@@ -217,6 +241,24 @@ export function createLocalControlPlane({
}),
);
+ app.get(VIEWER_SESSIONS_PATH, (c) => {
+ if (c.req.query("cap") !== viewerCapability) return c.body(null, 401);
+ sessionStore.pruneExpired();
+ c.header("Cache-Control", "no-store");
+ return c.json({
+ sessions: sessionStore
+ .list()
+ .map(({ id, label, cwd, state, createdAt }) => ({
+ id,
+ label,
+ cwd,
+ state,
+ createdAt,
+ streamUrl: `${baseUrl}${CONTROL_PLANE_PREFIX}/sessions/${id}/stream?cap=${sessionStore.getCapabilities(id).stream}`,
+ })),
+ });
+ });
+
app.get(`${CONTROL_PLANE_PREFIX}/sessions`, (c) => {
sessionStore.pruneExpired();
return c.json({ sessions: sessionStore.list() });
@@ -231,6 +273,7 @@ export function createLocalControlPlane({
const path = `${CONTROL_PLANE_PREFIX}/sessions/${session.id}`;
return c.json({
...session,
+ directoryUrl,
ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`,
streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`,
});
@@ -274,6 +317,7 @@ export function createLocalControlPlane({
return c.json(
{
...session,
+ directoryUrl,
ingestUrl: `${baseUrl}${path}/ingest/${capabilities.ingest}`,
streamUrl: `${baseUrl}${path}/stream?cap=${capabilities.stream}`,
},
diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts
index d30237e54..ad5f5350f 100644
--- a/packages/cli/src/commands/local/daemon.ts
+++ b/packages/cli/src/commands/local/daemon.ts
@@ -62,6 +62,8 @@ export type LocalDaemonSession = {
readonly ingestUrl: string;
readonly label: string;
readonly streamUrl: string;
+ /** Older daemon versions do not advertise browser discovery. */
+ readonly directoryUrl?: string;
};
function statePath(directory: string): string {
diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts
index 0ed949819..26e8c93ed 100644
--- a/packages/cli/src/commands/local/run.ts
+++ b/packages/cli/src/commands/local/run.ts
@@ -499,6 +499,7 @@ export const runCommand = buildCommand({
let daemonSessionId: string | undefined;
let daemonIngestUrl: string | undefined;
let daemonStreamUrl: string | undefined;
+ let daemonDirectoryUrl: string | undefined;
const useJson = flags.format === "json";
const activeFilters = new Set(flags.filter);
@@ -514,6 +515,7 @@ export const runCommand = buildCommand({
daemonSessionId = session.id;
daemonIngestUrl = session.ingestUrl;
daemonStreamUrl = session.streamUrl;
+ daemonDirectoryUrl = session.directoryUrl;
return attachToExistingServer({
activeFilters,
showAttributes: flags.attributes,
@@ -532,7 +534,11 @@ export const runCommand = buildCommand({
});
url = tail.url;
- await openLocalUiIfRequested(flags.open, daemonStreamUrl ?? url);
+ await openLocalUiIfRequested(
+ flags.open,
+ daemonStreamUrl ?? url,
+ daemonDirectoryUrl,
+ );
const spotlightUrl = daemonIngestUrl ?? `${url}/stream`;
const wrangler = await injectWranglerSpotlightBinding(
diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts
index 28a1e1a63..a5d4363c3 100644
--- a/packages/cli/src/commands/local/server.ts
+++ b/packages/cli/src/commands/local/server.ts
@@ -1071,7 +1071,11 @@ export const serverCommand = buildCommand({
` Capabilities: sentry local session view ${session.id} --json`,
);
logger.info("Press Ctrl-C to stop.");
- await openLocalUiIfRequested(flags.open, session.streamUrl);
+ await openLocalUiIfRequested(
+ flags.open,
+ session.streamUrl,
+ session.directoryUrl,
+ );
const controller = new AbortController();
const stop = () => controller.abort();
diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts
index b0db5fdc5..b5efb6f4e 100644
--- a/packages/cli/src/commands/local/ui.ts
+++ b/packages/cli/src/commands/local/ui.ts
@@ -41,7 +41,10 @@ export function getLocalUiBaseUrl(
}
/** Build the browser URL without exposing the local stream to the UI host. */
-export function buildLocalUiUrl(receiverUrl: string): string {
+export function buildLocalUiUrl(
+ receiverUrl: string,
+ directoryUrl?: string,
+): string {
const receiver = new URL(receiverUrl);
assertLoopbackHostForUi(receiver.hostname);
@@ -49,13 +52,34 @@ export function buildLocalUiUrl(receiverUrl: string): string {
? receiver.toString()
: new URL("/stream", receiver).toString();
const uiUrl = new URL(getLocalUiBaseUrl());
- uiUrl.hash = new URLSearchParams({ stream: streamUrl }).toString();
+ const fragment = new URLSearchParams({ stream: streamUrl });
+ if (directoryUrl) {
+ const directory = new URL(directoryUrl);
+ if (
+ directory.origin !== receiver.origin ||
+ directory.pathname !== "/_local/v1/ui/sessions" ||
+ !directory.searchParams.get("cap") ||
+ directory.username ||
+ directory.password ||
+ directory.hash
+ ) {
+ throw new ValidationError(
+ "Session discovery must use the same loopback receiver.",
+ "directory",
+ );
+ }
+ fragment.set("directory", directory.toString());
+ }
+ uiUrl.hash = fragment.toString();
return uiUrl.toString();
}
/** Open the independently served local UI without affecting capture on failure. */
-export async function openLocalUi(receiverUrl: string): Promise {
- const uiUrl = buildLocalUiUrl(receiverUrl);
+export async function openLocalUi(
+ receiverUrl: string,
+ directoryUrl?: string,
+): Promise {
+ const uiUrl = buildLocalUiUrl(receiverUrl, directoryUrl);
let opened = false;
try {
opened = await openBrowser(uiUrl);
@@ -67,7 +91,7 @@ export async function openLocalUi(receiverUrl: string): Promise {
return;
}
const streamUrl = new URL(receiverUrl);
- if (streamUrl.searchParams.has("cap")) {
+ if (streamUrl.searchParams.has("cap") || directoryUrl) {
logger.warn(
"Could not open browser. Use sentry local session view --json to retrieve its stream URL.",
);
@@ -80,8 +104,9 @@ export async function openLocalUi(receiverUrl: string): Promise {
export async function openLocalUiIfRequested(
requested: boolean,
receiverUrl: string,
+ directoryUrl?: string,
): Promise {
if (requested) {
- await openLocalUi(receiverUrl);
+ await openLocalUi(receiverUrl, directoryUrl);
}
}
diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts
index a87781093..bbd182db0 100644
--- a/packages/cli/test/commands/local/control-plane.test.ts
+++ b/packages/cli/test/commands/local/control-plane.test.ts
@@ -14,6 +14,101 @@ function controlHeaders() {
}
describe("Local control plane", () => {
+ test("discovers sibling streams only with the explicit browser directory capability", async () => {
+ const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
+ const create = async (label: string) =>
+ (
+ await plane.app.request("/_local/v1/sessions", {
+ method: "POST",
+ headers: controlHeaders(),
+ body: JSON.stringify({ label, cwd: `/${label}` }),
+ })
+ ).json();
+ const web = await create("web");
+ const worker = await create("worker");
+ expect(web.directoryUrl).toEqual(expect.any(String));
+ const directoryCap = new URL(web.directoryUrl).searchParams.get("cap");
+ expect(
+ (
+ await plane.app.request("/_local/v1/sessions", {
+ headers: { Authorization: `Bearer ${directoryCap}` },
+ })
+ ).status,
+ ).toBe(401);
+ for (const cap of [
+ "",
+ CONTROL_TOKEN,
+ new URL(web.streamUrl).searchParams.get("cap")!,
+ new URL(web.ingestUrl).pathname.split("/").at(-1)!,
+ ]) {
+ const url = new URL(web.directoryUrl);
+ url.searchParams.set("cap", cap);
+ expect((await plane.app.request(url.toString())).status).toBe(401);
+ }
+ const response = await plane.app.request(web.directoryUrl, {
+ headers: { Origin: "https://local.sentry.dev" },
+ });
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://local.sentry.dev",
+ );
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ const body = await response.json();
+ expect(body.sessions).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: web.id,
+ label: "web",
+ state: "open",
+ streamUrl: web.streamUrl,
+ }),
+ expect.objectContaining({
+ id: worker.id,
+ label: "worker",
+ state: "open",
+ streamUrl: worker.streamUrl,
+ }),
+ ]),
+ );
+ expect(JSON.stringify(body)).not.toContain("ingestUrl");
+ expect(JSON.stringify(body)).not.toContain(CONTROL_TOKEN);
+ expect(
+ (
+ await plane.app.request(web.directoryUrl, {
+ headers: { Origin: "https://evil.example" },
+ })
+ ).status,
+ ).toBe(403);
+ const preflight = await plane.app.request(web.directoryUrl, {
+ method: "OPTIONS",
+ headers: {
+ Origin: "https://local.sentry.dev",
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe(
+ "true",
+ );
+ expect(
+ (
+ await plane.app.request(web.directoryUrl, {
+ method: "POST",
+ headers: { Origin: "https://local.sentry.dev" },
+ })
+ ).status,
+ ).toBe(403);
+ plane.sessionStore.close(worker.id);
+ expect(
+ (await (await plane.app.request(web.directoryUrl)).json()).sessions,
+ ).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: worker.id, state: "retained" }),
+ ]),
+ );
+ });
+
test("replaces expired clientId sessions with usable fresh capabilities on creation", async () => {
let now = 1000;
const sessionStore = createLocalSessionStore({ now: () => now });
diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts
index 3519c03e4..837f937ed 100644
--- a/packages/cli/test/commands/local/server.test.ts
+++ b/packages/cli/test/commands/local/server.test.ts
@@ -38,6 +38,7 @@ type ServeFunc = (
format: "human" | "json";
attributes: boolean;
open: boolean;
+ session?: string;
},
) => Promise;
@@ -142,6 +143,56 @@ describe("sentry local serve --open", () => {
open: true,
};
+ test("scoped serve opens the viewer with explicit session discovery", async () => {
+ const daemon = await import("../../../src/commands/local/daemon.js");
+ const { server, port } = await tryListen(
+ buildApp(createSpotlightBuffer(5)),
+ 0,
+ "127.0.0.1",
+ );
+ const streamUrl = `http://127.0.0.1:${port}/stream`;
+ const directoryUrl = `http://127.0.0.1:${port}/_local/v1/ui/sessions?cap=directory-secret`;
+ const create = vi
+ .spyOn(daemon, "createLocalDaemonSession")
+ .mockResolvedValue({
+ id: "web",
+ cwd: "/web",
+ label: "web",
+ streamUrl,
+ ingestUrl: streamUrl,
+ directoryUrl,
+ });
+ const retain = vi
+ .spyOn(daemon, "retainLocalDaemonSession")
+ .mockResolvedValue(undefined);
+ const signal = interceptShutdownSignal();
+ const opener = vi.mocked(openBrowser);
+ opener.mockClear();
+ const func = (await serverCommand.loader()) as unknown as ServeFunc;
+ try {
+ const command = func.call(
+ { cwd: "/web" },
+ { ...flags, port, session: "web" },
+ );
+ await vi.waitFor(() => expect(opener).toHaveBeenCalledTimes(1));
+ signal.trigger();
+ await command;
+ const url = new URL(opener.mock.calls[0]![0]);
+ expect(new URLSearchParams(url.hash.slice(1)).get("directory")).toBe(
+ directoryUrl,
+ );
+ expect(url.search).toBe("");
+ } finally {
+ signal.restore();
+ create.mockRestore();
+ retain.mockRestore();
+ await new Promise((r) => {
+ server.close(() => r());
+ server.closeAllConnections();
+ });
+ }
+ });
+
test("opens the UI after starting an owned receiver", async () => {
const signal = interceptShutdownSignal();
const openBrowserMock = vi.mocked(openBrowser);
diff --git a/packages/cli/test/commands/local/ui.test.ts b/packages/cli/test/commands/local/ui.test.ts
index b59575e52..2b0314537 100644
--- a/packages/cli/test/commands/local/ui.test.ts
+++ b/packages/cli/test/commands/local/ui.test.ts
@@ -6,6 +6,7 @@ import {
getLocalUiBaseUrl,
openLocalUi,
} from "../../../src/commands/local/ui.js";
+import { logger } from "../../../src/lib/logger.js";
import { openBrowser } from "../../../src/lib/browser.js";
vi.mock("../../../src/lib/browser.js", () => ({
@@ -13,6 +14,40 @@ vi.mock("../../../src/lib/browser.js", () => ({
}));
describe("local UI URLs", () => {
+ test("never logs a directory capability when a browser launch fails", async () => {
+ vi.mocked(openBrowser).mockResolvedValueOnce(false);
+ const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
+ try {
+ await openLocalUi(
+ "http://localhost:8969/stream",
+ "http://localhost:8969/_local/v1/ui/sessions?cap=directory-secret",
+ );
+ expect(
+ warn.mock.calls.map((call) => String(call[0])).join("\n"),
+ ).not.toContain("directory-secret");
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ test("keeps browser session discovery in the fragment alongside its scoped stream", () => {
+ const stream =
+ "http://localhost:8969/_local/v1/sessions/web/stream?cap=stream-secret";
+ const directory =
+ "http://localhost:8969/_local/v1/ui/sessions?cap=directory-secret";
+ const url = new URL(buildLocalUiUrl(stream, directory));
+ expect(url.search).toBe("");
+ const fragment = new URLSearchParams(url.hash.slice(1));
+ expect(fragment.get("stream")).toBe(stream);
+ expect(fragment.get("directory")).toBe(directory);
+ expect(() =>
+ buildLocalUiUrl(
+ stream,
+ "http://localhost:9999/_local/v1/ui/sessions?cap=other",
+ ),
+ ).toThrow();
+ });
+
test("uses the Vite UI in development", () => {
expect(getLocalUiBaseUrl("development")).toBe("http://localhost:5173");
});
diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts
index 81f76c617..27c47957d 100644
--- a/packages/cli/test/e2e/local.test.ts
+++ b/packages/cli/test/e2e/local.test.ts
@@ -14,6 +14,7 @@ type Session = {
id: string;
ingestUrl: string;
streamUrl: string;
+ directoryUrl: string;
};
const fetch = (globalThis as { __originalFetch: typeof globalThis.fetch })
@@ -91,6 +92,92 @@ afterEach(async () => {
});
describe("Local daemon E2E", () => {
+ test("hosted session discovery switches isolated streams and preserves retained sessions", async () => {
+ const port = await availablePort();
+ const web = await createSession("web", port);
+ const worker = await createSession("worker", port);
+ await ingest(web, "web-id", "web-only");
+ await ingest(worker, "worker-id", "worker-only");
+ const origin = { Origin: "https://local.sentry.dev" };
+ const response = await fetch(web.directoryUrl, { headers: origin });
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://local.sentry.dev",
+ );
+ const directory = (await response.json()) as {
+ sessions: {
+ id: string;
+ label: string;
+ state: string;
+ streamUrl: string;
+ }[];
+ };
+ expect(directory.sessions).toHaveLength(2);
+ expect(JSON.stringify(directory)).not.toContain("ingestUrl");
+ for (const [id, expected, excluded] of [
+ [web.id, "web-only", "worker-only"],
+ [worker.id, "worker-only", "web-only"],
+ [web.id, "web-only", "worker-only"],
+ ]) {
+ const session = directory.sessions.find((s) => s.id === id)!;
+ const stream = await fetch(session.streamUrl, {
+ headers: origin,
+ signal: AbortSignal.timeout(5000),
+ });
+ const reader = stream.body!.getReader();
+ try {
+ let text = "";
+ while (!text.includes(expected!)) {
+ const chunk = await reader.read();
+ if (chunk.done) throw new Error("Stream closed");
+ text += new TextDecoder().decode(chunk.value);
+ }
+ expect(text).not.toContain(excluded!);
+ } finally {
+ await reader.cancel();
+ }
+ }
+ const wrong = new URL(web.directoryUrl);
+ wrong.searchParams.set(
+ "cap",
+ new URL(web.streamUrl).searchParams.get("cap")!,
+ );
+ expect((await fetch(wrong, { headers: origin })).status).toBe(401);
+ const preflight = await fetch(web.directoryUrl, {
+ method: "OPTIONS",
+ headers: {
+ ...origin,
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe(
+ "true",
+ );
+ expect(
+ (
+ await fetch(web.directoryUrl, {
+ headers: { Origin: "https://evil.example" },
+ })
+ ).status,
+ ).toBe(403);
+ expect(
+ (await fetch(web.directoryUrl, { method: "POST", headers: origin }))
+ .status,
+ ).toBe(403);
+ expect((await cli("session", "close", worker.id)).exitCode).toBe(0);
+ const refreshed = (await (
+ await fetch(web.directoryUrl, { headers: origin })
+ ).json()) as typeof directory;
+ expect(refreshed.sessions.find((s) => s.id === worker.id)?.state).toBe(
+ "retained",
+ );
+ expect(
+ JSON.parse((await cli("event", "list", worker.id)).stdout).events,
+ ).toHaveLength(1);
+ }, 30_000);
+
test.each(
["stop", "restart"].flatMap((command) =>
["delayed", "rejected", "never", "hung"].map((mode) => ({
From 8124a15e4933e9637a034eaa5f70ea9310f095da Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 11:56:35 -0700
Subject: [PATCH 02/30] feat(local): Add a searchable session picker
Use the existing shadcn Command and Button components with a Radix popover, keep session paths in the menu, and constrain the workspace width.
Co-Authored-By: GPT-6 Codex
---
apps/local/package.json | 1 +
.../src/App.sessions.integration.test.tsx | 48 +++-
apps/local/src/App.tsx | 4 +-
.../local/src/components/session-switcher.tsx | 165 ++++++++++----
apps/local/src/components/ui/popover.tsx | 34 +++
pnpm-lock.yaml | 214 +++++++++++++++++-
6 files changed, 421 insertions(+), 45 deletions(-)
create mode 100644 apps/local/src/components/ui/popover.tsx
diff --git a/apps/local/package.json b/apps/local/package.json
index adc9a7e19..d37fa3fd9 100644
--- a/apps/local/package.json
+++ b/apps/local/package.json
@@ -17,6 +17,7 @@
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.23",
+ "@radix-ui/react-popover": "^1.3.1",
"@radix-ui/react-slot": "^1.3.3",
"@shikijs/core": "^3.23.0",
"@shikijs/engine-javascript": "^3.23.0",
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index e9558afb3..6fb64d439 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -101,6 +101,16 @@ function renderSession(session: { streamUrl: string; directoryUrl?: string }) {
,
);
}
+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} ·`) }),
+ );
+}
+
beforeEach(() => {
render({null} ).unmount();
window.localStorage.clear();
@@ -138,7 +148,7 @@ test("hosted viewer switches live sessions without mixing events and replays ret
const picker = await screen.findByRole("combobox", { name: "Session" });
expect(window.location.hash).toBe("");
expect(screen.queryByText("/worker-job")).toBeNull();
- fireEvent.change(picker, { target: { value: worker.id } });
+ await selectSession("worker");
await screen.findAllByText("/worker-job");
expect(screen.queryByText("/web-request")).toBeNull();
await screen.findByText(/Retention limit: 1,000 envelopes/);
@@ -147,20 +157,43 @@ test("hosted viewer switches live sessions without mixing events and replays ret
await daemon.ingest(worker, "/worker-after-switch");
await screen.findAllByText("/worker-after-switch");
expect(screen.queryByText("/web-after-switch")).toBeNull();
- fireEvent.change(picker, { target: { value: web.id } });
+ 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.*Running/ },
{ 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.*Retained/ });
- fireEvent.change(picker, { target: { value: worker.id } });
+ 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(
@@ -225,11 +258,18 @@ test("expired sessions disappear without clearing already displayed telemetry",
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.*Running/ });
+ await selectSession("web");
daemon.plane.sessionStore.close(web.id);
now += SESSION_RETENTION_MS;
fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" }));
- await screen.findByRole("option", { name: "No sessions available" });
+ fireEvent.click(
+ screen.getByRole("combobox", { name: "Session", exact: true }),
+ );
+ await screen.findByText("No sessions available.");
expect(screen.getAllByText("/retained-in-viewer").length).toBeGreaterThan(
0,
);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 69dc65ff7..7cae26e4d 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1235,9 +1235,9 @@ export default function App() {
) : null}
-
+
{activeDirectoryUrl ? (
diff --git a/apps/local/src/components/session-switcher.tsx b/apps/local/src/components/session-switcher.tsx
index 36ae7dea6..6d7a2f449 100644
--- a/apps/local/src/components/session-switcher.tsx
+++ b/apps/local/src/components/session-switcher.tsx
@@ -1,5 +1,16 @@
import { useEffect, useState } from "react";
-import { RefreshCw } from "lucide-react";
+import { Check, ChevronsUpDown, Layers3, RefreshCw } from "lucide-react";
+import { Button } from "./ui/button.tsx";
+import { Badge } from "./ui/badge.tsx";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "./ui/command.tsx";
+import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx";
import {
parseViewerSessions,
saveSessionDirectory,
@@ -16,6 +27,7 @@ export function SessionSwitcher({
streamUrl: string;
onSelect: (streamUrl: string) => void;
}) {
+ const [open, setOpen] = useState(false);
const [sessions, setSessions] = useState([]);
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(false);
@@ -60,49 +72,126 @@ export function SessionSwitcher({
return (
-
- Session
-
- {
- const selected = sessions.find((s) => s.id === event.target.value);
- if (selected) onSelect(selected.streamUrl);
- }}
- >
- {!current ? (
-
- {sessions.length
- ? "Current session unavailable"
- : error
- ? "Sessions unavailable"
- : loaded
- ? "No sessions available"
- : "Loading sessions…"}
-
- ) : null}
- {sessions.map((session) => (
-
- {session.label} ·{" "}
- {session.state === "open" ? "Running" : "Retained"} · {session.cwd}{" "}
- · {session.id.slice(-8)}
-
- ))}
-
-
+
+
+
+
+ {current?.label ?? "Choose session"}
+
+ {current ? (
+
+ {current.state === "open" ? "Running" : "Retained"}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {error
+ ? "Sessions unavailable."
+ : !loaded
+ ? "Loading sessions…"
+ : sessions.length
+ ? "No matching sessions."
+ : "No sessions available."}
+
+ {(["open", "retained"] as const).map((state) => {
+ const entries = sessions.filter(
+ (session) => session.state === state,
+ );
+ return entries.length ? (
+
+ {entries.map((session) => (
+ {
+ if (session.streamUrl !== streamUrl)
+ onSelect(session.streamUrl);
+ setOpen(false);
+ }}
+ >
+
+
+
+ {session.label}
+
+
+ {session.cwd}
+
+
+
+ {session.id.slice(-8)}
+
+
+
+ ))}
+
+ ) : null;
+ })}
+
+
+
+ {sessions.length} {sessions.length === 1 ? "session" : "sessions"}{" "}
+ on this receiver
+
+
+
+ setRefresh((value) => value + 1)}
>
-
-
+
+
{error ? (
Could not refresh sessions. The selected feed is unchanged. Retry with
diff --git a/apps/local/src/components/ui/popover.tsx b/apps/local/src/components/ui/popover.tsx
new file mode 100644
index 000000000..bb050e840
--- /dev/null
+++ b/apps/local/src/components/ui/popover.tsx
@@ -0,0 +1,34 @@
+import type * as React from "react";
+import * as PopoverPrimitive from "@radix-ui/react-popover";
+import { cn } from "@/lib/utils.ts";
+
+function Popover(props: React.ComponentProps) {
+ return ;
+}
+function PopoverTrigger(
+ props: React.ComponentProps,
+) {
+ return ;
+}
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 6,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+export { Popover, PopoverTrigger, PopoverContent };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 41c04c816..2d1f9fadf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -277,6 +277,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.1.23
version: 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-popover':
+ specifier: ^1.3.1
+ version: 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot':
specifier: ^1.3.3
version: 1.3.3(@types/react@19.3.0)(react@19.2.8)
@@ -2477,6 +2480,21 @@ packages:
'@expressive-code/plugin-text-markers@0.44.2':
resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==}
+ '@floating-ui/core@1.8.0':
+ resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
+
+ '@floating-ui/dom@1.8.0':
+ resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
+
+ '@floating-ui/react-dom@2.1.9':
+ resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.12':
+ resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
+
'@fontsource-variable/rubik@5.3.0':
resolution: {integrity: sha512-yQUf8qle24MJYA+EuauyF+cqjjN6I715nGjitt3sGmwZA0XfA4fWJcLq1C/diREZl4Qzn1E80nLMc0ipC/a4Lg==}
@@ -3347,6 +3365,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-arrow@1.1.17':
+ resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-collapsible@1.1.20':
resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==}
peerDependencies:
@@ -3444,6 +3475,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-dismissable-layer@1.2.1':
+ resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-focus-guards@1.2.0':
resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==}
peerDependencies:
@@ -3466,6 +3510,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-focus-scope@1.3.1':
+ resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-id@1.1.4':
resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==}
peerDependencies:
@@ -3475,6 +3532,32 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-popover@1.3.1':
+ resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popper@1.4.0':
+ resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-portal@1.2.0':
resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==}
peerDependencies:
@@ -3612,6 +3695,27 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-use-rect@1.1.4':
+ resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-size@1.1.5':
+ resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/rect@1.1.3':
+ resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
+
'@remix-run/node-fetch-server@0.8.1':
resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==}
@@ -10501,6 +10605,23 @@ snapshots:
dependencies:
'@expressive-code/core': 0.44.2
+ '@floating-ui/core@1.8.0':
+ dependencies:
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/dom@1.8.0':
+ dependencies:
+ '@floating-ui/core': 1.8.0
+ '@floating-ui/utils': 0.2.12
+
+ '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@floating-ui/dom': 1.8.0
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@floating-ui/utils@0.2.12': {}
+
'@fontsource-variable/rubik@5.3.0': {}
'@fontsource/ibm-plex-mono@5.3.0': {}
@@ -11285,6 +11406,15 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
+ '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-collapsible@1.1.20(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/primitive': 1.1.7
@@ -11385,6 +11515,19 @@ snapshots:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
+ '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/primitive': 1.2.0
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.2.8)':
dependencies:
react: 19.2.8
@@ -11402,6 +11545,17 @@ snapshots:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
+ '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-id@1.1.4(@types/react@19.1.8)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.1.8)(react@19.2.8)
@@ -11416,6 +11570,48 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
+ '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/primitive': 1.2.0
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ aria-hidden: 1.2.6
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
+ '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/rect': 1.1.3
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -11550,6 +11746,22 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
+ '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.2.8)':
+ dependencies:
+ '@radix-ui/rect': 1.1.3
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.2.8)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.3.0
+
+ '@radix-ui/rect@1.1.3': {}
+
'@remix-run/node-fetch-server@0.8.1': {}
'@rolldown/binding-android-arm-eabi@1.2.9':
@@ -12609,7 +12821,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
- vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.0(@types/node@22.16.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0))
+ vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.0(@types/node@24.13.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))
'@vitest/expect@4.1.11':
dependencies:
From ffbcc0ed61bca3b2829e88c26ebac41f353df27f Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 12:03:09 -0700
Subject: [PATCH 03/30] fix(local): Give receiver actions room in the sidebar
Use full-width labeled buttons and separate closing a session from clearing events.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.tsx | 124 ++++++++++++++++++++++++-----------------
1 file changed, 72 insertions(+), 52 deletions(-)
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 7cae26e4d..f9ebcd577 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -15,11 +15,11 @@ import {
ChevronRight,
Copy,
Menu,
- Radio,
+ Plug,
+ Eraser,
+ CircleStop,
Search,
Terminal,
- Trash2,
- X,
} from "lucide-react";
import {
Fragment,
@@ -45,6 +45,7 @@ import { ReceiverControls } from "@/components/receiver-controls.tsx";
import { TraceWorkspace } from "@/components/trace-workspace.tsx";
import { TraceWaterfall } from "@/components/trace-waterfall.tsx";
import { Badge } from "@/components/ui/badge.tsx";
+import { Button } from "@/components/ui/button.tsx";
import {
getConnectionPresentation,
type ConnectionPresentation,
@@ -515,6 +516,39 @@ function WorkspaceSidebar({
onSelect,
onToggle,
}: WorkspaceSidebarProps) {
+ const receiverActions = [
+ {
+ label: "Change receiver",
+ accessibleLabel: "Change receiver connection",
+ Icon: Plug,
+ onClick: onChangeReceiver,
+ disabled: false,
+ destructive: false,
+ separate: false,
+ },
+ {
+ label: "Clear events",
+ accessibleLabel: "Clear events",
+ Icon: Eraser,
+ onClick: onClear,
+ disabled: retainedItemCount === 0,
+ destructive: true,
+ separate: false,
+ },
+ ...(onCloseSession
+ ? [
+ {
+ label: "Close session",
+ accessibleLabel: "Close telemetry session",
+ Icon: CircleStop,
+ onClick: onCloseSession,
+ disabled: false,
+ destructive: true,
+ separate: true,
+ },
+ ]
+ : []),
+ ];
const statusLabel = connection.label.replace(" to local receiver", "");
const statusDotClass =
connection.tone === "success"
@@ -652,56 +686,42 @@ function WorkspaceSidebar({
))}
-
-
-
-
- {!collapsed ? Change : null}
-
-
-
- {!collapsed ? Clear : null}
-
- {onCloseSession ? (
-
+ {receiverActions.map(
+ ({
+ label,
+ accessibleLabel,
+ Icon,
+ onClick,
+ disabled,
+ destructive,
+ separate,
+ }) => (
+
-
- {!collapsed ? Close : null}
-
- ) : null}
-
+
+
+ {!collapsed ? {label} : null}
+
+
+ ),
+ )}
);
From ae71daa944990434ae2a8091452f8f6a835d61bf Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 12:11:15 -0700
Subject: [PATCH 04/30] fix(local): Keep secondary receiver actions compact
Give Change receiver a full button and retain accessible icon controls for clearing events and closing sessions.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.tsx | 88 +++++++++++++++++-------------------------
1 file changed, 36 insertions(+), 52 deletions(-)
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index f9ebcd577..584089bb1 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -15,9 +15,9 @@ import {
ChevronRight,
Copy,
Menu,
- Plug,
- Eraser,
- CircleStop,
+ Radio,
+ Trash2,
+ X,
Search,
Terminal,
} from "lucide-react";
@@ -516,35 +516,20 @@ function WorkspaceSidebar({
onSelect,
onToggle,
}: WorkspaceSidebarProps) {
- const receiverActions = [
- {
- label: "Change receiver",
- accessibleLabel: "Change receiver connection",
- Icon: Plug,
- onClick: onChangeReceiver,
- disabled: false,
- destructive: false,
- separate: false,
- },
+ const sessionActions = [
{
label: "Clear events",
- accessibleLabel: "Clear events",
- Icon: Eraser,
+ Icon: Trash2,
onClick: onClear,
disabled: retainedItemCount === 0,
- destructive: true,
- separate: false,
},
...(onCloseSession
? [
{
- label: "Close session",
- accessibleLabel: "Close telemetry session",
- Icon: CircleStop,
+ label: "Close telemetry session",
+ Icon: X,
onClick: onCloseSession,
disabled: false,
- destructive: true,
- separate: true,
},
]
: []),
@@ -691,37 +676,36 @@ function WorkspaceSidebar({
aria-label="Receiver actions"
className="shrink-0 space-y-1 border-t border-border p-2"
>
- {receiverActions.map(
- ({
- label,
- accessibleLabel,
- Icon,
- onClick,
- disabled,
- destructive,
- separate,
- }) => (
-
+
+ {!collapsed ?
Change receiver : null}
+
+
+ {sessionActions.map(({ label, Icon, onClick, disabled }) => (
+
-
-
- {!collapsed ? {label} : null}
-
-
- ),
- )}
+
+
+ ))}
+
);
From 71289e1132130f702277271fb39323968313dc38 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 12:18:05 -0700
Subject: [PATCH 05/30] fix(local): Align receiver actions in one flex row
Let Change fill the available space beside fixed-size Clear and Close icon buttons.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.tsx | 42 +++++++++++++++++++-----------------------
1 file changed, 19 insertions(+), 23 deletions(-)
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 584089bb1..37fdb8302 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -674,38 +674,34 @@ function WorkspaceSidebar({
- {!collapsed ? Change receiver : null}
+ {!collapsed ? Change : null}
-
- {sessionActions.map(({ label, Icon, onClick, disabled }) => (
-
-
-
- ))}
-
+ {sessionActions.map(({ label, Icon, onClick, disabled }) => (
+
+
+
+ ))}
);
From 6abe93389c02a6cf2660364f6676ff011f77c4a2 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 12:46:04 -0700
Subject: [PATCH 06/30] feat(local): Discover sessions from receiver
connections
Move retention into validated sidebar settings and add tooltips to receiver actions. Preserve discovery across reloads and same-port daemon restarts.
Co-Authored-By: GPT-6 Codex
---
apps/local/package.json | 6 +-
apps/local/src/App.integration.test.tsx | 36 +++-
.../src/App.sessions.integration.test.tsx | 140 ++++++++++++-
apps/local/src/App.tsx | 198 ++++++++++++++----
apps/local/src/components/local-settings.tsx | 56 +++++
.../src/components/receiver-controls.tsx | 21 +-
.../components/retention-settings.test.tsx | 82 ++++++++
.../src/components/retention-settings.tsx | 150 +++++++------
.../local/src/components/session-switcher.tsx | 9 +-
apps/local/src/components/ui/input.tsx | 17 ++
apps/local/src/components/ui/tooltip.tsx | 27 +++
apps/local/src/lib/session-directory.test.ts | 13 +-
apps/local/src/lib/session-directory.ts | 6 +-
docs/cli/local-control-plane.md | 22 +-
.../cli/src/commands/local/control-plane.ts | 28 ++-
.../cli/src/commands/local/viewer-protocol.ts | 2 +
.../test/commands/local/control-plane.test.ts | 58 +++++
packages/cli/test/e2e/local.test.ts | 47 ++++-
pnpm-lock.yaml | 178 ++++++++++++++++
19 files changed, 965 insertions(+), 131 deletions(-)
create mode 100644 apps/local/src/components/local-settings.tsx
create mode 100644 apps/local/src/components/retention-settings.test.tsx
create mode 100644 apps/local/src/components/ui/input.tsx
create mode 100644 apps/local/src/components/ui/tooltip.tsx
create mode 100644 packages/cli/src/commands/local/viewer-protocol.ts
diff --git a/apps/local/package.json b/apps/local/package.json
index d37fa3fd9..acb574759 100644
--- a/apps/local/package.json
+++ b/apps/local/package.json
@@ -16,9 +16,11 @@
"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",
@@ -34,10 +36,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/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index d7688861c..3474c0d88 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -843,12 +843,14 @@ describe("local receiver to viewer integration", () => {
for (let index = 0; index < 501; index++)
await sendEnvelope(receiver.port, `retained-${index}`);
renderViewer(receiver.port, "/explorer/events");
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Settings", exact: true }),
+ );
await screen.findByText(
/Retention limit: 1,000 envelopes · 501 displayed/,
{},
{ timeout: 10000 },
);
- fireEvent.click(screen.getByText(/Retention limit: 1,000 envelopes/));
fireEvent.change(screen.getByLabelText("Retention limit"), {
target: { value: "2000" },
});
@@ -904,7 +906,9 @@ describe("local receiver to viewer integration", () => {
}),
);
});
- fireEvent.click(await screen.findByText(/Retention limit: 500 envelopes/));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Settings", exact: true }),
+ );
fireEvent.change(screen.getByLabelText("Retention limit"), {
target: { value: "1" },
});
@@ -930,6 +934,19 @@ describe("local receiver to viewer integration", () => {
),
);
await screen.findByText(/Retention limit: 1,000 envelopes/);
+ fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
+ await waitFor(() =>
+ expect(document.activeElement).toBe(
+ screen.getByRole("button", { name: "Settings", exact: true }),
+ ),
+ );
+ const clearButton = screen.getByRole("button", {
+ name: "Clear events",
+ exact: true,
+ });
+ fireEvent.focus(clearButton);
+ await screen.findByRole("tooltip", { name: "Clear events", exact: true });
+ fireEvent.blur(clearButton);
expect(
screen.getAllByRole("button", { name: "View event event" }),
).toHaveLength(2);
@@ -961,12 +978,13 @@ 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 retention.");
expect(
screen.queryByRole("button", { name: "Apply retention limit" }),
).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
expect(
screen.getByRole("button", { name: "View event event" }),
).not.toBeNull();
@@ -994,7 +1012,7 @@ 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"), {
target: { value: "1" },
@@ -1004,6 +1022,9 @@ describe("local receiver to viewer integration", () => {
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
if (switchReceiver) {
+ fireEvent.click(
+ screen.getByRole("button", { name: "Close", exact: true }),
+ );
fireEvent.click(
screen.getByRole("button", { name: "Change receiver" }),
);
@@ -1023,7 +1044,14 @@ describe("local receiver to viewer integration", () => {
respond(new Response('{"maxEnvelopes":1}', { status: 200 }));
await pending;
});
+ if (switchReceiver)
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Settings", exact: true }),
+ );
await screen.findByText(/Retention limit: 1,000 envelopes/);
+ fireEvent.click(
+ screen.getByRole("button", { name: "Close", exact: true }),
+ );
await act(async () => {
for (const id of ["one", "two"])
source?.dispatchEvent(
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 6fb64d439..0186ea5e2 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -23,9 +23,11 @@ import {
import App from "./App.tsx";
const headers = { Authorization: "Bearer session-switcher-test" };
-async function startDaemon(now = Date.now) {
+async function startDaemon(now = Date.now, preferredPort = 0) {
const reservation = createServer();
- await new Promise((r) => reservation.listen(0, "127.0.0.1", r));
+ 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");
@@ -73,6 +75,7 @@ async function startDaemon(now = Date.now) {
).toBe(204);
};
return {
+ port,
plane,
create,
ingest,
@@ -151,8 +154,12 @@ test("hosted viewer switches live sessions without mixing events and replays ret
await selectSession("worker");
await screen.findAllByText("/worker-job");
expect(screen.queryByText("/web-request")).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Settings", exact: true }),
+ );
await screen.findByText(/Retention limit: 1,000 envelopes/);
expect(screen.queryByLabelText("Retention limit")).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");
@@ -278,3 +285,132 @@ test("expired sessions disappear without clearing already displayed telemetry",
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: "Change receiver connection",
+ exact: true,
+ }),
+ );
+ 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(/Retention limit:/)).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Settings", exact: true }),
+ );
+ await screen.findByRole("dialog", { name: "Settings", exact: true });
+ await screen.findByText(/Retention limit: 500 envelopes/);
+ expect(screen.queryByLabelText("Retention limit")).toBeNull();
+ await screen.findByText(
+ "Change retention from a locally served viewer or the CLI.",
+ );
+ } 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.findByRole("status", {
+ name: "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: "Change receiver connection",
+ exact: true,
+ }),
+ );
+ 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);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 37fdb8302..bba13debd 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -2,8 +2,10 @@ import { SessionSwitcher } from "./components/session-switcher.tsx";
import {
getInitialSessionDirectory,
parseSessionDirectoryUrl,
+ saveSessionDirectory,
} from "./lib/session-directory.ts";
-import { RetentionSettings } from "./components/retention-settings.tsx";
+import { LocalSettings } from "./components/local-settings.tsx";
+import { LOCAL_DIRECTORY_EVENT } from "../../../packages/cli/src/commands/local/viewer-protocol.ts";
import {
DEFAULT_MAX_ENVELOPES,
isValidMaxEnvelopes,
@@ -16,6 +18,7 @@ import {
Copy,
Menu,
Radio,
+ Settings,
Trash2,
X,
Search,
@@ -46,6 +49,12 @@ import { TraceWorkspace } from "@/components/trace-workspace.tsx";
import { TraceWaterfall } from "@/components/trace-waterfall.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,
@@ -496,6 +505,7 @@ type WorkspaceSidebarProps = {
onClear: () => void;
onCloseSession?: () => void;
onChangeReceiver: () => void;
+ onOpenSettings: (trigger: HTMLButtonElement) => void;
onOpenCommand: (trigger: HTMLButtonElement) => void;
onSelect: (view: WorkspaceView) => void;
onToggle: () => void;
@@ -512,6 +522,7 @@ function WorkspaceSidebar({
onClear,
onCloseSession,
onChangeReceiver,
+ onOpenSettings,
onOpenCommand,
onSelect,
onToggle,
@@ -671,38 +682,77 @@ function WorkspaceSidebar({
))}
-
-
+
-
- {!collapsed ?
Change : null}
-
- {sessionActions.map(({ label, Icon, onClick, disabled }) => (
-
+
+
+
+ {!collapsed ? Change receiver : null}
+
+
+ {collapsed ? (
+ Change receiver
+ ) : null}
+
+
-
-
- ))}
-
+
+
+ onOpenSettings(event.currentTarget)}
+ className={`text-muted-foreground ${collapsed ? "w-full" : ""}`}
+ >
+
+
+
+
+ Settings
+
+
+ {!collapsed ? : null}
+ {sessionActions.map(({ label, Icon, onClick, disabled }) => (
+
+
+
+
+
+
+
+
+
+ {label === "Close telemetry session"
+ ? "Close session"
+ : label}
+
+
+ ))}
+
+
+
);
}
@@ -730,7 +780,9 @@ export default function App() {
getSavedRemoteStream(),
),
);
- const [directoryUrl] = useState(() => getInitialSessionDirectory(streamUrl));
+ const [directoryUrl, setDirectoryUrl] = useState(() =>
+ getInitialSessionDirectory(streamUrl),
+ );
const activeDirectoryUrl = parseSessionDirectoryUrl(directoryUrl, streamUrl);
const connectionGeneration = useRef(0);
const [connectionAttempt, setConnectionAttempt] = useState(0);
@@ -758,6 +810,12 @@ 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);
@@ -948,6 +1006,15 @@ export default function App() {
return;
}
connectionGeneration.current += 1;
+ setIsSettingsOpen(false);
+ 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) {
telemetryStore.clear();
telemetryStore.setLimit(DEFAULT_MAX_ENVELOPES);
@@ -1013,6 +1080,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()) {
@@ -1093,6 +1190,7 @@ export default function App() {
window.clearTimeout(timeoutId);
source.removeEventListener(SENTRY_ENVELOPE_EVENT, onEnvelope);
source.removeEventListener(LOCAL_RETENTION_EVENT, onRetention);
+ source.removeEventListener(LOCAL_DIRECTORY_EVENT, onDirectory);
source.close();
};
}, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]);
@@ -1122,6 +1220,22 @@ export default function App() {
return (
+
setIsEditingReceiver(true)}
+ onOpenSettings={openSettings}
onOpenCommand={openCommand}
onSelect={selectWorkspace}
onToggle={() => setIsSidebarCollapsed((collapsed) => !collapsed)}
@@ -1199,6 +1314,7 @@ export default function App() {
onCloseSession={
hasScopedSession ? closeScopedSession : undefined
}
+ onOpenSettings={openSettings}
showStatusRole={false}
/>
@@ -1245,21 +1361,11 @@ export default function App() {
key={activeDirectoryUrl}
directoryUrl={activeDirectoryUrl}
streamUrl={streamUrl}
- onSelect={connectToEndpoint}
- />
- ) : null}
- {hasRetentionSettings ? (
-
) : null}
{connectionError &&
diff --git a/apps/local/src/components/local-settings.tsx b/apps/local/src/components/local-settings.tsx
new file mode 100644
index 000000000..aeb437e04
--- /dev/null
+++ b/apps/local/src/components/local-settings.tsx
@@ -0,0 +1,56 @@
+import type { RefObject } from "react";
+import { RetentionSettings } from "./retention-settings.tsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog.tsx";
+
+type Props = {
+ triggerRef: RefObject;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ streamUrl: string;
+ limit: number;
+ count: number;
+ editable: boolean;
+ supported: boolean;
+ unavailableReason?: string;
+};
+
+export function LocalSettings({
+ open,
+ onOpenChange,
+ supported,
+ triggerRef,
+ ...retention
+}: Props) {
+ return (
+
+ {
+ if (triggerRef.current?.isConnected) {
+ event.preventDefault();
+ triggerRef.current.focus();
+ }
+ }}
+ >
+
+ Settings
+
+ Manage retention for the selected session.
+
+
+ {supported ? (
+
+ ) : (
+
+ This receiver does not provide retention settings.
+
+ )}
+
+
+ );
+}
diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx
index 744209db5..788084c68 100644
--- a/apps/local/src/components/receiver-controls.tsx
+++ b/apps/local/src/components/receiver-controls.tsx
@@ -1,4 +1,4 @@
-import { ChevronDown, Moon, Sun, Trash2, X } from "lucide-react";
+import { ChevronDown, Moon, Sun, Settings, Trash2, X } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { useTheme } from "next-themes";
import type { ConnectionPresentation } from "@/lib/presentation.ts";
@@ -8,6 +8,7 @@ type ReceiverControlsProps = {
connection: ConnectionPresentation;
retainedItemCount: number;
onClear: () => void;
+ onOpenSettings?: (trigger: HTMLButtonElement) => void;
onCloseSession?: () => void;
showStatusRole?: boolean;
};
@@ -18,6 +19,7 @@ export function ReceiverControls({
connection,
retainedItemCount,
onClear,
+ onOpenSettings,
onCloseSession,
showStatusRole = true,
}: ReceiverControlsProps) {
@@ -128,6 +130,23 @@ export function ReceiverControls({
{isDark ? : }
Use {isDark ? "light" : "dark"} mode
+ {onOpenSettings ? (
+ {
+ const trigger =
+ rootRef.current?.querySelector(
+ 'button[aria-label="Receiver options"]',
+ );
+ if (trigger) onOpenSettings(trigger);
+ setIsOpen(false);
+ }}
+ >
+
+ Session settings
+
+ ) : null}
{
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+test("validates retention with an accessible field error before sending mutations", async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ render(
+ ,
+ );
+ const input = screen.getByLabelText("Retention limit");
+ fireEvent.change(input, { target: { value: "1.5" } });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention limit" }),
+ );
+ expect((await screen.findByRole("alert")).textContent).toContain(
+ "Enter an integer between 1 and 50,000",
+ );
+ expect(input.getAttribute("aria-invalid")).toBe("true");
+ expect(fetch).not.toHaveBeenCalled();
+});
+
+test("reports a receiver-side retention race without claiming a successful save", async () => {
+ const fetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ error: "Cannot lower the limit below 9 retained envelopes.",
+ }),
+ { status: 409 },
+ ),
+ );
+ vi.stubGlobal("fetch", fetch);
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("Retention limit"), {
+ target: { value: "8" },
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention limit" }),
+ );
+ expect((await screen.findByRole("alert")).textContent).toContain(
+ "9 retained envelopes",
+ );
+ await waitFor(() =>
+ expect(
+ (
+ screen.getByRole("button", {
+ name: "Apply retention limit",
+ }) as HTMLButtonElement
+ ).disabled,
+ ).toBe(false),
+ );
+ expect(fetch).toHaveBeenCalledWith(
+ new URL(
+ "http://127.0.0.1:8969/_local/v1/sessions/web/ui/retention?cap=test",
+ ),
+ expect.objectContaining({ body: '{"maxEnvelopes":8}' }),
+ );
+ expect(screen.getByText(/Retention limit: 500 envelopes/)).not.toBeNull();
+});
diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx
index 1f966fc23..7757a6e29 100644
--- a/apps/local/src/components/retention-settings.tsx
+++ b/apps/local/src/components/retention-settings.tsx
@@ -1,8 +1,12 @@
-import { type FormEvent, useEffect, useState } from "react";
+import { valibotResolver } from "@hookform/resolvers/valibot";
+import { useForm } from "react-hook-form";
+import * as v from "valibot";
import {
isValidMaxEnvelopes,
MAX_ENVELOPES,
} from "../../../../packages/cli/src/commands/local/retention-limits.ts";
+import { Button } from "./ui/button.tsx";
+import { Input } from "./ui/input.tsx";
type Props = {
limit: number;
@@ -11,7 +15,8 @@ type Props = {
editable: boolean;
unavailableReason?: string;
};
-/** Envelope-count controls keep existing viewer state and the EventSource intact. */
+
+/** Changes the selected session's limit without replacing its live stream. */
export function RetentionSettings({
limit,
count,
@@ -19,34 +24,38 @@ export function RetentionSettings({
editable,
unavailableReason,
}: Props) {
- const [draft, setDraft] = useState(String(limit));
- const [error, setError] = useState();
- const [saving, setSaving] = useState(false);
- useEffect(() => setDraft(String(limit)), [limit]);
- async function save(event: FormEvent) {
- event.preventDefault();
- const value = /^\d+$/.test(draft) ? Number(draft) : NaN;
- if (!isValidMaxEnvelopes(value)) {
- setError(`Enter an integer between 1 and ${MAX_ENVELOPES}.`);
- return;
- }
- if (value < count) {
- setError(
+ const invalidLimit = `Enter an integer between 1 and ${MAX_ENVELOPES.toLocaleString()}.`;
+ const schema = v.object({
+ maxEnvelopes: v.pipe(
+ v.string(),
+ v.regex(/^\d+$/, invalidLimit),
+ v.transform((value: string) => Number(value)),
+ v.check((value: number) => isValidMaxEnvelopes(value), invalidLimit),
+ v.check(
+ (value) => value >= count,
`Cannot lower the limit below ${count} displayed envelopes. Clear envelopes first.`,
- );
- return;
- }
+ ),
+ ),
+ });
+ const form = useForm<
+ v.InferInput,
+ unknown,
+ v.InferOutput
+ >({
+ resolver: valibotResolver(schema),
+ values: { maxEnvelopes: String(limit) },
+ });
+ const save = form.handleSubmit(async ({ maxEnvelopes }) => {
+ form.clearErrors("root");
const url = new URL(streamUrl);
url.pathname = /^\/_local\/v1\/sessions\/[^/]+\/stream$/.test(url.pathname)
? url.pathname.replace(/\/stream$/, "/ui/retention")
: "/retention";
- setSaving(true);
- setError(undefined);
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ maxEnvelopes: value }),
+ body: JSON.stringify({ maxEnvelopes }),
signal: AbortSignal.timeout(5000),
});
const body: unknown = await response.json();
@@ -58,7 +67,7 @@ export function RetentionSettings({
typeof body.error === "string"
? body.error
: "Could not update retention. Existing envelopes were preserved.";
- setError(detail);
+ form.setError("root.server", { message: detail });
return;
}
if (
@@ -68,61 +77,72 @@ export function RetentionSettings({
!isValidMaxEnvelopes(body.maxEnvelopes)
)
throw new Error("Invalid retention response");
- // Ordered stream metadata is authoritative. A delayed HTTP response must
- // not overwrite a newer CLI change or a different receiver's settings.
+ // Ordered SSE metadata is authoritative; delayed HTTP responses cannot
+ // overwrite a newer CLI change or a different receiver's settings.
} catch {
- setError(
- "Could not update retention. Existing envelopes were preserved.",
- );
- } finally {
- setSaving(false);
+ form.setError("root.server", {
+ message:
+ "Could not update retention. Existing envelopes were preserved.",
+ });
}
- }
+ });
+ const error =
+ form.formState.errors.maxEnvelopes?.message ??
+ form.formState.errors.root?.server?.message;
return (
-
-
+
+
Retention limit: {limit.toLocaleString()} envelopes ·{" "}
{count.toLocaleString()} displayed
-
+
{editable ? (
-
) : (
-
+
{unavailableReason ??
"Change retention from a locally served viewer or the CLI."}
)}
-
+
);
}
diff --git a/apps/local/src/components/session-switcher.tsx b/apps/local/src/components/session-switcher.tsx
index 6d7a2f449..a162bd932 100644
--- a/apps/local/src/components/session-switcher.tsx
+++ b/apps/local/src/components/session-switcher.tsx
@@ -13,7 +13,6 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover.tsx";
import {
parseViewerSessions,
- saveSessionDirectory,
type ViewerSession,
} from "../lib/session-directory.ts";
@@ -22,7 +21,9 @@ export function SessionSwitcher({
directoryUrl,
streamUrl,
onSelect,
+ selectInitialSession = false,
}: {
+ selectInitialSession?: boolean;
directoryUrl: string;
streamUrl: string;
onSelect: (streamUrl: string) => void;
@@ -53,7 +54,6 @@ export function SessionSwitcher({
setSessions(next);
setLoaded(true);
setError(false);
- saveSessionDirectory(directoryUrl);
} catch {
if (active) setError(true);
} finally {
@@ -68,6 +68,11 @@ export function SessionSwitcher({
clearTimeout(timer);
};
}, [directoryUrl, refresh]);
+ useEffect(() => {
+ // Only the daemon-wide /stream entry point selects an initial run. Expiring
+ // or closing a selected scoped session must never silently change feeds.
+ if (selectInitialSession && sessions[0]) onSelect(sessions[0].streamUrl);
+ }, [selectInitialSession, sessions, onSelect]);
const current = sessions.find((s) => s.streamUrl === streamUrl);
return (
) {
+ return (
+
+ );
+}
+export { Input };
diff --git a/apps/local/src/components/ui/tooltip.tsx b/apps/local/src/components/ui/tooltip.tsx
new file mode 100644
index 000000000..0e446d1d2
--- /dev/null
+++ b/apps/local/src/components/ui/tooltip.tsx
@@ -0,0 +1,27 @@
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import { cn } from "@/lib/utils.ts";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+function TooltipContent({
+ className,
+ sideOffset = 6,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
diff --git a/apps/local/src/lib/session-directory.test.ts b/apps/local/src/lib/session-directory.test.ts
index 7aa6e5c0f..3aeeb9348 100644
--- a/apps/local/src/lib/session-directory.test.ts
+++ b/apps/local/src/lib/session-directory.test.ts
@@ -3,7 +3,6 @@ import {
parseSessionDirectoryUrl,
parseViewerSessions,
getInitialSessionDirectory,
- saveSessionDirectory,
} from "./session-directory.ts";
const stream =
"http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=web-secret";
@@ -56,10 +55,20 @@ test("explicit discovery is tab-scoped and cannot follow a saved directory to an
`/#${new URLSearchParams({ stream, directory })}`,
);
expect(getInitialSessionDirectory(stream)).toBe(directory);
- saveSessionDirectory(directory);
window.history.replaceState(null, "", "/");
expect(getInitialSessionDirectory(stream)).toBe(directory);
expect(
getInitialSessionDirectory(stream.replace(":8969", ":9999")),
).toBeUndefined();
});
+
+test("invalid fragment discovery is never persisted", () => {
+ window.history.replaceState(
+ null,
+ "",
+ `/#${new URLSearchParams({ directory: directory.replace(":8969", ":9999") })}`,
+ );
+ expect(getInitialSessionDirectory(stream)).toBeUndefined();
+ window.history.replaceState(null, "", "/");
+ expect(getInitialSessionDirectory(stream)).toBeUndefined();
+});
diff --git a/apps/local/src/lib/session-directory.ts b/apps/local/src/lib/session-directory.ts
index ae080158a..182018c25 100644
--- a/apps/local/src/lib/session-directory.ts
+++ b/apps/local/src/lib/session-directory.ts
@@ -45,10 +45,14 @@ export function getInitialSessionDirectory(
} catch {
/* Storage may be disabled. */
}
- return parseSessionDirectoryUrl(
+ const directory = parseSessionDirectoryUrl(
fragment.has("directory") ? fragment.get("directory") : saved,
streamUrl,
);
+ // The startup effect removes the fragment before discovery can finish. Save
+ // its validated capability now so an early reload cannot lose the picker.
+ if (directory && fragment.has("directory")) saveSessionDirectory(directory);
+ return directory;
}
export function saveSessionDirectory(directoryUrl: string): void {
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 745aae648..2f67d5eb3 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -95,8 +95,12 @@ that hosting project to use Toolkit and `apps/local` as its root directory.
### Switching sessions
-Run either command with `--session --open` to open a viewer with a Session
-picker. It works in the local development viewer and on .
+Connect to the daemon's plain `http://127.0.0.1:8969/stream` endpoint (using its
+actual host and port) to discover running sessions automatically. The viewer
+selects the first running session and fills the Session picker. If no sessions
+exist yet, it waits and discovers them as they start. This works in the local
+development viewer and on . Alternatively, run either
+command with `--session --open` to start on that specific session.
The picker lists running and retained sessions from that daemon, including sessions
created after the viewer opens. It refreshes every five seconds; Refresh sessions
updates it immediately. Labels include the working directory and a short session ID
@@ -106,8 +110,10 @@ Switching back replays its retained envelopes. Retention follows the selected se
A failed refresh leaves the current feed readable. Expired sessions leave the list;
the viewer keeps already displayed telemetry and does not silently switch runs.
-Discovery requires the separate `directoryUrl` capability returned by session create
-and session view. `--open` passes it alongside the initial stream in the URL fragment,
+The daemon-wide `/stream` emits discovery metadata to loopback viewers and the
+trusted hosted Local origins. Other browser origins cannot read it. Discovery
+uses a separate `directoryUrl` capability, also returned by session create and
+session view. `--open` passes it alongside the initial stream in the URL fragment,
which is removed after startup and is never sent to the hosting server. The directory
is kept only in tab session storage, so reloads can retain the picker. The capability
allows discovery of all sessions and their stream capabilities on this daemon; treat
@@ -118,6 +124,14 @@ with an updated CLI and daemon to enable discovery. An already-running older dae
needs a restart after upgrading; restarting clears its in-memory telemetry. One picker
covers one daemon, not receivers on other ports or computers.
+Open Settings with the sidebar gear (or Session settings in the mobile receiver
+menu) to view retention for the selected session. A locally served viewer can
+change the limit at runtime with validation; increases preserve captured data,
+and lowering it below the retained count is rejected until enough data is cleared.
+Settings are read-only in the hosted viewer; use the CLI to change retention there.
+The sidebar keeps Change receiver on its own row, with Settings at the left and
+Clear/Close icons at the right beneath it. Icon tooltips also work with keyboard focus.
+
A loopback-served viewer can reset and close its scoped session. The hosted viewer
can read the stream and clear its own displayed feed; it cannot mutate retained
receiver data. Remote HTTPS streams stay read-only.
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index bf66e9b6a..c9fefd2c4 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -22,6 +22,7 @@ import {
type LocalSessionStore,
} from "./session-store.js";
+import { LOCAL_DIRECTORY_EVENT } from "./viewer-protocol.js";
import { LOCAL_RETENTION_EVENT } from "./retention-limits.js";
import {
RetentionLimitError,
@@ -185,9 +186,10 @@ export function createLocalControlPlane({
for (const path of [
`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`,
VIEWER_SESSIONS_PATH,
+ "/stream",
])
app.use(path, async (c, next) => {
- if (c.req.path === VIEWER_SESSIONS_PATH) {
+ if (c.req.path === VIEWER_SESSIONS_PATH || c.req.path === "/stream") {
if (
(c.req.method !== "GET" && c.req.method !== "OPTIONS") ||
(c.req.method === "OPTIONS" &&
@@ -241,10 +243,32 @@ export function createLocalControlPlane({
}),
);
+ // Choosing the daemon-wide entry point opts into discovering its sessions.
+ // Browser origins remain restricted; scoped streams never emit this metadata.
+ app.get("/stream", (c) => {
+ const response = streamSSE(c, async (stream) => {
+ await stream.writeSSE({
+ event: LOCAL_DIRECTORY_EVENT,
+ data: JSON.stringify({
+ directoryUrl: new URL(
+ `${VIEWER_SESSIONS_PATH}?cap=${viewerCapability}`,
+ c.req.url,
+ ).toString(),
+ }),
+ });
+ await new Promise((resolve) => stream.onAbort(resolve));
+ });
+ c.header("Cache-Control", "no-store");
+ response.headers.set("Cache-Control", "no-store");
+ return response;
+ });
+
app.get(VIEWER_SESSIONS_PATH, (c) => {
if (c.req.query("cap") !== viewerCapability) return c.body(null, 401);
sessionStore.pruneExpired();
c.header("Cache-Control", "no-store");
+ // Stay on the exact loopback origin the viewer chose (localhost/127.0.0.1/::1).
+ const viewerBaseUrl = new URL(c.req.url).origin;
return c.json({
sessions: sessionStore
.list()
@@ -254,7 +278,7 @@ export function createLocalControlPlane({
cwd,
state,
createdAt,
- streamUrl: `${baseUrl}${CONTROL_PLANE_PREFIX}/sessions/${id}/stream?cap=${sessionStore.getCapabilities(id).stream}`,
+ streamUrl: `${viewerBaseUrl}${CONTROL_PLANE_PREFIX}/sessions/${id}/stream?cap=${sessionStore.getCapabilities(id).stream}`,
})),
});
});
diff --git a/packages/cli/src/commands/local/viewer-protocol.ts b/packages/cli/src/commands/local/viewer-protocol.ts
new file mode 100644
index 000000000..d1b475d8d
--- /dev/null
+++ b/packages/cli/src/commands/local/viewer-protocol.ts
@@ -0,0 +1,2 @@
+/** Optional SSE metadata used to bootstrap the Local session picker. */
+export const LOCAL_DIRECTORY_EVENT = "sentry.local.directory";
diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts
index bbd182db0..ef3aa85c2 100644
--- a/packages/cli/test/commands/local/control-plane.test.ts
+++ b/packages/cli/test/commands/local/control-plane.test.ts
@@ -14,6 +14,64 @@ function controlHeaders() {
}
describe("Local control plane", () => {
+ test("a plain stream bootstraps discovery for trusted viewers without a control token", async () => {
+ const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
+ const session = plane.sessionStore.create({ label: "web", cwd: "/web" });
+ const controller = new AbortController();
+ // The daemon may advertise localhost while the user connects via 127.0.0.1.
+ const response = await plane.app.request("http://127.0.0.1:8969/stream", {
+ headers: { Origin: "https://local.sentry.dev" },
+ signal: controller.signal,
+ });
+ expect(response.status).toBe(200);
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://local.sentry.dev",
+ );
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
+ const reader = response.body!.getReader();
+ try {
+ const chunk = new TextDecoder().decode((await reader.read()).value);
+ expect(chunk).toContain("event: sentry.local.directory");
+ const data = JSON.parse(chunk.match(/data: (.+)/)![1]!);
+ expect(new URL(data.directoryUrl).pathname).toBe(
+ "/_local/v1/ui/sessions",
+ );
+ expect(new URL(data.directoryUrl).origin).toBe("http://127.0.0.1:8969");
+ const listing = await plane.app.request(data.directoryUrl);
+ expect(listing.status).toBe(200);
+ const directory = await listing.json();
+ expect(directory.sessions[0].id).toBe(session.id);
+ expect(new URL(directory.sessions[0].streamUrl).origin).toBe(
+ "http://127.0.0.1:8969",
+ );
+ expect(chunk).not.toContain(CONTROL_TOKEN);
+ } finally {
+ controller.abort();
+ await reader.cancel();
+ }
+ for (const headers of [
+ { Origin: "https://evil.example" },
+ { Origin: "http://local.sentry.dev.evil.example" },
+ ])
+ expect((await plane.app.request("/stream", { headers })).status).toBe(
+ 403,
+ );
+ expect(
+ (await plane.app.request("/stream", { method: "POST" })).status,
+ ).toBe(403);
+ const preflight = await plane.app.request("/stream", {
+ method: "OPTIONS",
+ headers: {
+ Origin: "https://local.sentry.dev",
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe(
+ "true",
+ );
+ });
test("discovers sibling streams only with the explicit browser directory capability", async () => {
const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
const create = async (label: string) =>
diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts
index 27c47957d..c1645f33b 100644
--- a/packages/cli/test/e2e/local.test.ts
+++ b/packages/cli/test/e2e/local.test.ts
@@ -99,7 +99,52 @@ describe("Local daemon E2E", () => {
await ingest(web, "web-id", "web-only");
await ingest(worker, "worker-id", "worker-only");
const origin = { Origin: "https://local.sentry.dev" };
- const response = await fetch(web.directoryUrl, { headers: origin });
+ // A user only needs the daemon address; /stream supplies discovery metadata.
+ const bootstrap = await fetch(`http://127.0.0.1:${port}/stream`, {
+ headers: origin,
+ signal: AbortSignal.timeout(5000),
+ });
+ expect(bootstrap.status).toBe(200);
+ expect(bootstrap.headers.get("Access-Control-Allow-Origin")).toBe(
+ "https://local.sentry.dev",
+ );
+ const bootstrapReader = bootstrap.body!.getReader();
+ let directoryUrl: string;
+ try {
+ let text = "";
+ while (!text.includes("\n\n")) {
+ const chunk = await bootstrapReader.read();
+ if (chunk.done) throw new Error("Bootstrap stream closed");
+ text += new TextDecoder().decode(chunk.value);
+ }
+ expect(text).toContain("event: sentry.local.directory");
+ directoryUrl = (
+ JSON.parse(text.match(/data: (.+)/)![1]!) as { directoryUrl: string }
+ ).directoryUrl;
+ } finally {
+ await bootstrapReader.cancel();
+ }
+ expect(directoryUrl).toBe(web.directoryUrl);
+ expect(
+ (
+ await fetch(`http://127.0.0.1:${port}/stream`, {
+ headers: { Origin: "https://evil.example" },
+ })
+ ).status,
+ ).toBe(403);
+ const bootstrapPreflight = await fetch(`http://127.0.0.1:${port}/stream`, {
+ method: "OPTIONS",
+ headers: {
+ ...origin,
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(bootstrapPreflight.status).toBe(204);
+ expect(
+ bootstrapPreflight.headers.get("Access-Control-Allow-Private-Network"),
+ ).toBe("true");
+ const response = await fetch(directoryUrl, { headers: origin });
expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
"https://local.sentry.dev",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2d1f9fadf..925de8fae 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -274,6 +274,9 @@ importers:
apps/local:
dependencies:
+ '@hookform/resolvers':
+ specifier: ^5.9.1
+ version: 5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.8.3))(zod@4.6.5)
'@radix-ui/react-dialog':
specifier: ^1.1.23
version: 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -283,6 +286,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.3
version: 1.3.3(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-tooltip':
+ specifier: ^1.4.1
+ version: 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@shikijs/core':
specifier: ^3.23.0
version: 3.23.0
@@ -328,6 +334,9 @@ importers:
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
+ react-hook-form:
+ specifier: ^7.90.0
+ version: 7.90.0(@types/react@19.3.0)(react@19.2.8)
react-router:
specifier: ^7.18.3
version: 7.18.4(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -340,6 +349,9 @@ importers:
tslib:
specifier: ^2.8.1
version: 2.8.1
+ valibot:
+ specifier: 'catalog:'
+ version: 1.4.2(typescript@5.8.3)
devDependencies:
'@spotlightjs/spotlight':
specifier: ^4.11.7
@@ -2521,6 +2533,84 @@ packages:
peerDependencies:
hono: ^4
+ '@hookform/resolvers@5.9.1':
+ resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==}
+ peerDependencies:
+ '@sinclair/typebox': '>=0.25.24'
+ '@standard-schema/spec': ^1.0.0
+ '@typeschema/main': '>=0.13.7'
+ '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
+ ajv: ^8.12.0
+ ajv-errors: ^3.0.0
+ ajv-formats: ^2.1.1
+ arktype: ^2.0.0
+ ata-validator: ^1.2.0
+ class-transformer: '>=0.4.0'
+ class-validator: '>=0.12.0'
+ computed-types: ^1.0.0
+ effect: ^3.10.3
+ fluentvalidation-ts: ^3.0.0
+ fp-ts: ^2.7.0
+ io-ts: ^2.0.0
+ joi: ^17.0.0 || ^18.0.0
+ nope-validator: '>=0.12.0'
+ react-hook-form: ^7.55.0
+ superstruct: '>=0.12.0'
+ typanion: ^3.3.2
+ valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
+ vest: '>=6.0.0'
+ yup: ^1.0.0
+ zod: ^3.25.0 || ^4.0.0
+ peerDependenciesMeta:
+ '@sinclair/typebox':
+ optional: true
+ '@standard-schema/spec':
+ optional: true
+ '@typeschema/main':
+ optional: true
+ '@vinejs/vine':
+ optional: true
+ ajv:
+ optional: true
+ ajv-errors:
+ optional: true
+ ajv-formats:
+ optional: true
+ arktype:
+ optional: true
+ ata-validator:
+ optional: true
+ class-transformer:
+ optional: true
+ class-validator:
+ optional: true
+ computed-types:
+ optional: true
+ effect:
+ optional: true
+ fluentvalidation-ts:
+ optional: true
+ fp-ts:
+ optional: true
+ io-ts:
+ optional: true
+ joi:
+ optional: true
+ nope-validator:
+ optional: true
+ superstruct:
+ optional: true
+ typanion:
+ optional: true
+ valibot:
+ optional: true
+ vest:
+ optional: true
+ yup:
+ optional: true
+ zod:
+ optional: true
+
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -3650,6 +3740,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-tooltip@1.4.1':
+ resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-use-callback-ref@1.1.5':
resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==}
peerDependencies:
@@ -3713,6 +3816,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-visually-hidden@1.2.13':
+ resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/rect@1.1.3':
resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==}
@@ -4541,6 +4657,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@stricli/auto-complete@1.3.0':
resolution: {integrity: sha512-egN+fdP2USbP08DkhSH+y9XsiXSrJMgbR1E5SuOD2kqI4AckufhOIKokDzmzmUT6yMa7j0jmWu29phT/jXwzqQ==}
hasBin: true
@@ -7804,6 +7923,16 @@ packages:
peerDependencies:
react: ^19.2.8
+ react-hook-form@7.90.0:
+ resolution: {integrity: sha512-+NdG0FLzCPhFOp2NNxaUYLF9fyZhNjx9FTBBSYWF71/l6BBlmIc/j6xlp3DbaHWLJbIG7Zd8oznA19xJClU97w==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -10642,6 +10771,16 @@ snapshots:
dependencies:
hono: 4.13.8
+ '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.8.3))(zod@4.6.5)':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.90.0(@types/react@19.3.0)(react@19.2.8)
+ optionalDependencies:
+ '@standard-schema/spec': 1.1.0
+ ajv: 8.20.0
+ valibot: 1.4.2(typescript@5.8.3)
+ zod: 4.6.5
+
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.4':
@@ -11695,6 +11834,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
+ '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/primitive': 1.2.0
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
+ '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.2.8)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.2.8)
@@ -11760,6 +11921,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
+ '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/rect@1.1.3': {}
'@remix-run/node-fetch-server@0.8.1': {}
@@ -12509,6 +12679,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@standard-schema/utils@0.3.0': {}
+
'@stricli/auto-complete@1.3.0':
dependencies:
'@stricli/core': 1.3.0
@@ -16361,6 +16533,12 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
+ react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+ optionalDependencies:
+ '@types/react': 19.3.0
+
react-is@16.13.1: {}
react-is@17.0.2: {}
From df0d845886b780280c68b98715f78abdca88e93b Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 13:18:17 -0700
Subject: [PATCH 07/30] feat(local): Print hosted viewer links in command
output
Point human Local output at the hosted viewer with the actual loopback port. Accept plain receiver query links and consume them after pairing so reloads preserve the selected session without exposing capabilities or changing JSON output.
Co-Authored-By: GPT-6 Codex
---
.../src/App.sessions.integration.test.tsx | 46 ++++++++++++++++++-
apps/local/src/App.tsx | 12 ++---
apps/local/src/lib/spotlight.test.ts | 19 ++++++++
apps/local/src/lib/spotlight.ts | 10 ++++
docs/cli/local-control-plane.md | 14 ++++++
packages/cli/src/commands/local/management.ts | 7 +--
packages/cli/src/commands/local/run.ts | 3 ++
packages/cli/src/commands/local/server.ts | 4 ++
packages/cli/src/commands/local/ui.ts | 24 ++++++++++
packages/cli/src/lib/formatters/local.ts | 40 ++++++++++++++++
packages/cli/test/commands/local/run.test.ts | 32 +++++++++++++
.../cli/test/commands/local/server.test.ts | 22 ++++++++-
packages/cli/test/commands/local/ui.test.ts | 17 +++++++
packages/cli/test/e2e/local.test.ts | 41 +++++++++++++++++
14 files changed, 277 insertions(+), 14 deletions(-)
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 0186ea5e2..477f8fd7b 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -86,12 +86,20 @@ async function startDaemon(now = Date.now, preferredPort = 0) {
}),
};
}
-function renderSession(session: { streamUrl: string; directoryUrl?: string }) {
+function renderSession(
+ session: { streamUrl: string; directoryUrl?: string },
+ fromQuery = false,
+) {
window.history.replaceState(
null,
"",
- `/#${new URLSearchParams({ stream: session.streamUrl, ...(session.directoryUrl ? { directory: session.directoryUrl } : {}) })}`,
+ fromQuery
+ ? `/?${new URLSearchParams({ url: session.streamUrl })}`
+ : `/#${new URLSearchParams({ stream: session.streamUrl, ...(session.directoryUrl ? { directory: session.directoryUrl } : {}) })}`,
);
+ return renderViewer();
+}
+function renderViewer() {
return render(
@@ -414,3 +422,37 @@ test("reconnecting a plain receiver after daemon restart never selects a stale d
else await first.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();
+ expect(
+ screen.getByRole("combobox", { name: "Session", exact: true })
+ .textContent,
+ ).toContain("worker");
+ } finally {
+ cleanup();
+ await daemon.stop();
+ }
+}, 15_000);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index bba13debd..621e81b80 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -778,6 +778,7 @@ export default function App() {
window.location.hash,
getSavedStream(),
getSavedRemoteStream(),
+ window.location.search,
),
);
const [directoryUrl, setDirectoryUrl] = useState(() =>
@@ -1037,12 +1038,11 @@ export default function App() {
};
useEffect(() => {
- if (window.location.hash) {
- window.history.replaceState(
- null,
- "",
- `${window.location.pathname}${window.location.search}`,
- );
+ const url = new URL(window.location.href);
+ if (url.hash || url.searchParams.has("url")) {
+ url.hash = "";
+ url.searchParams.delete("url");
+ window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}
}, []);
diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts
index f37a7eb1d..58d67bc5f 100644
--- a/apps/local/src/lib/spotlight.test.ts
+++ b/apps/local/src/lib/spotlight.test.ts
@@ -6,6 +6,7 @@ import {
getPreferredStreamUrl,
resolveInitialStreamUrl,
getStreamUrlFromHash,
+ getStreamUrlFromQuery,
getStreamUrlFromStorage,
} from "./spotlight.js";
@@ -75,6 +76,24 @@ describe("getStreamUrlFromHash", () => {
).toBe("http://localhost:9000/stream");
});
+ test("accepts a plain loopback url query over saved connections without accepting credentials or remote targets", () => {
+ const query = `?${new URLSearchParams({ url: "http://localhost:9012/stream" })}`;
+ expect(getStreamUrlFromQuery(query)).toBe("http://localhost:9012/stream");
+ expect(
+ resolveInitialStreamUrl("", "http://localhost:8969/stream", null, query),
+ ).toBe("http://localhost:9012/stream");
+ for (const target of [
+ "https://evil.example/stream",
+ "http://user:password@localhost:9012/stream",
+ "http://localhost:9012/_local/v1/sessions/web/stream?cap=secret",
+ "http://localhost:9012/stream?token=secret",
+ ]) {
+ expect(
+ getStreamUrlFromQuery(`?${new URLSearchParams({ url: target })}`),
+ ).toBeUndefined();
+ }
+ });
+
test("uses CLI pairing, saved endpoints, then the default in priority order", () => {
expect(
resolveInitialStreamUrl(
diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts
index 40f5e75ca..9525ac0a6 100644
--- a/apps/local/src/lib/spotlight.ts
+++ b/apps/local/src/lib/spotlight.ts
@@ -207,6 +207,14 @@ export function getStreamUrlFromHash(hash: string): string | undefined {
);
}
+/** Public viewer query links may select only a credential-free loopback receiver. */
+export function getStreamUrlFromQuery(search: string): string | undefined {
+ const endpoint = parseStreamEndpoint(new URLSearchParams(search).get("url"));
+ if (endpoint?.kind !== "loopback") return;
+ const url = new URL(endpoint.url);
+ return url.pathname === "/stream" && !url.search ? endpoint.url : undefined;
+}
+
/** Validate a stream endpoint saved by an earlier CLI-launched tab. */
export function getStreamUrlFromStorage(
stream: string | null,
@@ -234,9 +242,11 @@ export function resolveInitialStreamUrl(
hash: string,
savedLoopbackStream: string | null,
savedRemoteStream: string | null,
+ search = "",
): string {
return (
getStreamUrlFromHash(hash) ??
+ getStreamUrlFromQuery(search) ??
getStreamUrlFromStorage(savedLoopbackStream) ??
getRemoteStreamUrlFromStorage(savedRemoteStream) ??
DEFAULT_STREAM_URL
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 2f67d5eb3..28af19c51 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -95,6 +95,20 @@ that hosting project to use Toolkit and `apps/local` as its root directory.
### Switching sessions
+Human-readable `local run` and `local serve` output prints a **View events at**
+link with the receiver's actual port, for example:
+
+```text
+View events at https://local.sentry.dev/?url=http%3A%2F%2Flocalhost%3A8969%2Fstream
+```
+
+Session create/view and daemon start/status summaries include the same link.
+It opens the hosted viewer and connects to the plain receiver address, so named
+sessions appear in the picker automatically. The public `url` query accepts only
+credential-free loopback `/stream` addresses; session capabilities still use the
+existing fragment connection flow. The viewer consumes the query at startup so
+reloading keeps the selected session. JSON and NDJSON output stay unchanged.
+
Connect to the daemon's plain `http://127.0.0.1:8969/stream` endpoint (using its
actual host and port) to discover running sessions automatically. The viewer
selects the first running session and fills the Session picker. If no sessions
diff --git a/packages/cli/src/commands/local/management.ts b/packages/cli/src/commands/local/management.ts
index 5c6e67425..f96359288 100644
--- a/packages/cli/src/commands/local/management.ts
+++ b/packages/cli/src/commands/local/management.ts
@@ -4,6 +4,7 @@ import { basename } from "node:path";
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { CliError, ValidationError } from "../../lib/errors.js";
+import { formatLocalControlResult } from "../../lib/formatters/local.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { buildRouteMap } from "../../lib/route-map.js";
import {
@@ -45,13 +46,9 @@ function publicDaemonState({
return { host, pid, port, version };
}
-function formatHuman(data: ControlResult): string {
- return JSON.stringify(data, null, 2);
-}
-
const CONTROL_COMMAND_DEFAULTS = {
auth: false,
- output: { human: formatHuman },
+ output: { human: formatLocalControlResult },
} as const;
const DAEMON_CONNECTION_FLAGS = {
diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts
index 26e8c93ed..5cbd1169c 100644
--- a/packages/cli/src/commands/local/run.ts
+++ b/packages/cli/src/commands/local/run.ts
@@ -26,6 +26,7 @@ import { detectDevCommand } from "../../lib/dev-script.js";
import { CliError, EXIT, ValidationError } from "../../lib/errors.js";
import { bold } from "../../lib/formatters/colors.js";
import {
+ printLocalViewerHint,
type FilterValue,
type FormatValue,
formatEnvelopeLines,
@@ -533,6 +534,7 @@ export const runCommand = buildCommand({
showAttributes: flags.attributes,
});
url = tail.url;
+ printLocalViewerHint(url, useJson);
await openLocalUiIfRequested(
flags.open,
@@ -692,6 +694,7 @@ async function* runWithVerify(
);
const url = formatLocalServerUrl(flags.host, boundPort);
logger.info(`Verify server listening on ${bold(url)}`);
+ printLocalViewerHint(url, flags.format === "json");
const spotlightUrl = `${url}/stream`;
diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts
index a5d4363c3..d30d052fa 100644
--- a/packages/cli/src/commands/local/server.ts
+++ b/packages/cli/src/commands/local/server.ts
@@ -32,6 +32,7 @@ import type { FilterValue, FormatValue } from "../../lib/formatters/local.js";
import {
FILTER_VALUES,
FORMAT_VALUES,
+ printLocalViewerHint,
formatEnvelopeLines,
formatEnvelopeLinesJson,
formatItem,
@@ -1066,6 +1067,7 @@ export const serverCommand = buildCommand({
port: flags.port,
});
logger.info("Sentry Local telemetry session");
+ printLocalViewerHint(session.streamUrl, flags.format === "json");
logger.info(` Session: ${bold(session.id)} (${session.label})`);
logger.info(
` Capabilities: sentry local session view ${session.id} --json`,
@@ -1102,6 +1104,7 @@ export const serverCommand = buildCommand({
if (await isServerRunning(url)) {
await configureExistingRetention(url, flags["max-envelopes"]);
logger.info(`Connected to existing server at ${bold(url)}`);
+ printLocalViewerHint(url, flags.format === "json");
await openLocalUiIfRequested(flags.open, url);
if (activeFilters.size > 0) {
logger.info(`Filtering: ${[...activeFilters].join(", ")}`);
@@ -1170,6 +1173,7 @@ export const serverCommand = buildCommand({
const listenUrl = formatLocalServerUrl(flags.host, boundPort);
logger.info("Sentry Local Dev Server");
+ printLocalViewerHint(listenUrl, useJson);
logger.info(` Ingest: ${bold(`${listenUrl}/stream`)}`);
logger.info(` Events: ${bold(`${listenUrl}/stream`)} (SSE)`);
logger.info("");
diff --git a/packages/cli/src/commands/local/ui.ts b/packages/cli/src/commands/local/ui.ts
index b5efb6f4e..41d86609b 100644
--- a/packages/cli/src/commands/local/ui.ts
+++ b/packages/cli/src/commands/local/ui.ts
@@ -74,6 +74,30 @@ export function buildLocalUiUrl(
return uiUrl.toString();
}
+/** A shareable hosted-viewer link contains only the daemon's plain address. */
+export function getLocalUiConnectionUrl(
+ receiverUrl: string,
+): string | undefined {
+ try {
+ const receiver = new URL(receiverUrl);
+ if (
+ !isLoopbackHost(receiver.hostname) ||
+ receiver.username ||
+ receiver.password ||
+ !["http:", "https:"].includes(receiver.protocol)
+ )
+ return;
+ const ui = new URL(PRODUCTION_UI_URL);
+ ui.searchParams.set("url", new URL("/stream", receiver).toString());
+ return ui.toString();
+ } catch {
+ logger.debug(
+ "Skipping Local viewer hint because the receiver URL is invalid.",
+ );
+ return;
+ }
+}
+
/** Open the independently served local UI without affecting capture on failure. */
export async function openLocalUi(
receiverUrl: string,
diff --git a/packages/cli/src/lib/formatters/local.ts b/packages/cli/src/lib/formatters/local.ts
index 99d42be60..dea8a318f 100644
--- a/packages/cli/src/lib/formatters/local.ts
+++ b/packages/cli/src/lib/formatters/local.ts
@@ -1,5 +1,9 @@
/** Tail formatters for the local dev server. */
+import {
+ formatLocalServerUrl,
+ getLocalUiConnectionUrl,
+} from "../../commands/local/ui.js";
import { logger } from "../logger.js";
import { blue, bold, cyan, green, muted, red, yellow } from "./colors.js";
import { stripAnsi } from "./plain-detect.js";
@@ -1213,3 +1217,39 @@ export function formatEnvelopeLines(
}
return [formatFallbackLine(resolveUnparseableLabel(container))];
}
+
+/** Human control-plane summaries add a viewer link without altering JSON data. */
+export function formatLocalControlResult(
+ data: Record,
+): string {
+ const daemon =
+ data.daemon &&
+ typeof data.daemon === "object" &&
+ !Array.isArray(data.daemon)
+ ? (data.daemon as Record)
+ : data;
+ const receiverUrl =
+ typeof data.streamUrl === "string"
+ ? data.streamUrl
+ : typeof daemon.host === "string" && typeof daemon.port === "number"
+ ? formatLocalServerUrl(daemon.host, daemon.port)
+ : undefined;
+ const hint = receiverUrl ? formatLocalViewerHint(receiverUrl) : undefined;
+ return JSON.stringify(data, null, 2) + (hint ? `\n\n${hint}` : "");
+}
+
+/** Point human Local command output to a credential-free receiver connection. */
+export function formatLocalViewerHint(receiverUrl: string): string | undefined {
+ const url = getLocalUiConnectionUrl(receiverUrl);
+ return url ? `View events at ${url}` : undefined;
+}
+
+/** Streaming commands cannot defer their hint until the generator returns. */
+export function printLocalViewerHint(
+ receiverUrl: string,
+ useJson: boolean,
+): void {
+ if (useJson) return;
+ const hint = formatLocalViewerHint(receiverUrl);
+ if (hint) logger.info(hint);
+}
diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts
index 4aae718c2..abfc8ed82 100644
--- a/packages/cli/test/commands/local/run.test.ts
+++ b/packages/cli/test/commands/local/run.test.ts
@@ -17,6 +17,7 @@ import {
} from "../../../src/commands/local/run.js";
import { buildApp, tryListen } from "../../../src/commands/local/server.js";
import { openBrowser } from "../../../src/lib/browser.js";
+import { logger } from "../../../src/lib/logger.js";
import { CliError, ValidationError } from "../../../src/lib/errors.js";
import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js";
import { TEST_TMP_DIR } from "../../constants.js";
@@ -101,6 +102,37 @@ describe("sentry local run", () => {
spawnCapture.stdio = undefined;
});
+ test.each(["human", "json"] as const)(
+ "prints a credential-free viewer link only for human tails (%s)",
+ async (format) => {
+ const info = vi.spyOn(logger, "info").mockImplementation(() => {});
+ try {
+ const func = (await runCommand.loader()) as unknown as RunFunc;
+ await func.call(
+ makeContext(),
+ { port: 0, host: "127.0.0.1", verify: false, timeout: 0, format },
+ process.execPath,
+ "-e",
+ "process.exit(0)",
+ );
+ const hints = info.mock.calls
+ .map((c) => String(c[0]))
+ .filter((line) => line.startsWith("View events at "));
+ expect(hints).toHaveLength(format === "human" ? 1 : 0);
+ if (hints[0]) {
+ const url = new URL(hints[0].slice("View events at ".length));
+ const receiver = new URL(url.searchParams.get("url")!);
+ expect(url.origin).toBe("https://local.sentry.dev");
+ expect(receiver.pathname).toBe("/stream");
+ expect(Number(receiver.port)).toBeGreaterThan(0);
+ expect(receiver.search).toBe("");
+ }
+ } finally {
+ info.mockRestore();
+ }
+ },
+ );
+
test("throws ValidationError when no command and no auto-detect", async () => {
const func = (await runCommand.loader()) as unknown as RunFunc;
const ctx = makeContext();
diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts
index 837f937ed..f2f1a4d88 100644
--- a/packages/cli/test/commands/local/server.test.ts
+++ b/packages/cli/test/commands/local/server.test.ts
@@ -7,8 +7,9 @@
import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import { createLocalRetentionBuffer } from "../../../src/commands/local/retention-buffer.js";
+import { logger } from "../../../src/lib/logger.js";
import { Hono } from "hono";
-import { describe, expect, test, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
buildApp,
consumeSSE,
@@ -134,6 +135,22 @@ describe("parsePort", () => {
});
describe("sentry local serve --open", () => {
+ let info: ReturnType;
+ beforeEach(() => {
+ info = vi.spyOn(logger, "info").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ info.mockRestore();
+ });
+ function expectViewerHint() {
+ const hints = info.mock.calls
+ .map((c) => String(c[0]))
+ .filter((line) => line.startsWith("View events at "));
+ expect(hints).toHaveLength(1);
+ const url = new URL(hints[0]!.slice("View events at ".length));
+ expect(url.origin).toBe("https://local.sentry.dev");
+ expect(new URL(url.searchParams.get("url")!).pathname).toBe("/stream");
+ }
const flags = {
host: "127.0.0.1",
quiet: true,
@@ -177,6 +194,7 @@ describe("sentry local serve --open", () => {
await vi.waitFor(() => expect(opener).toHaveBeenCalledTimes(1));
signal.trigger();
await command;
+ expectViewerHint();
const url = new URL(opener.mock.calls[0]![0]);
expect(new URLSearchParams(url.hash.slice(1)).get("directory")).toBe(
directoryUrl,
@@ -204,6 +222,7 @@ describe("sentry local serve --open", () => {
await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1));
signal.trigger();
await command;
+ expectViewerHint();
} finally {
signal.restore();
}
@@ -238,6 +257,7 @@ describe("sentry local serve --open", () => {
await vi.waitFor(() => expect(openBrowserMock).toHaveBeenCalledTimes(1));
signal.trigger();
await command;
+ expectViewerHint();
} finally {
signal.restore();
globalThis.fetch = savedFetch;
diff --git a/packages/cli/test/commands/local/ui.test.ts b/packages/cli/test/commands/local/ui.test.ts
index 2b0314537..289816338 100644
--- a/packages/cli/test/commands/local/ui.test.ts
+++ b/packages/cli/test/commands/local/ui.test.ts
@@ -4,6 +4,7 @@ import {
buildLocalUiUrl,
formatLocalServerUrl,
getLocalUiBaseUrl,
+ getLocalUiConnectionUrl,
openLocalUi,
} from "../../../src/commands/local/ui.js";
import { logger } from "../../../src/lib/logger.js";
@@ -14,6 +15,22 @@ vi.mock("../../../src/lib/browser.js", () => ({
}));
describe("local UI URLs", () => {
+ test("builds a hosted connection link with the actual receiver port and no session capabilities", () => {
+ const url = new URL(
+ getLocalUiConnectionUrl(
+ "http://127.0.0.1:9012/_local/v1/sessions/web/stream?cap=stream-secret",
+ )!,
+ );
+ expect(url.origin).toBe("https://local.sentry.dev");
+ expect(url.searchParams.get("url")).toBe("http://127.0.0.1:9012/stream");
+ expect(url.toString()).not.toContain("stream-secret");
+ expect(
+ new URL(getLocalUiConnectionUrl("http://[::1]:9012")!).searchParams.get(
+ "url",
+ ),
+ ).toBe("http://[::1]:9012/stream");
+ expect(getLocalUiConnectionUrl("http://0.0.0.0:9012")).toBeUndefined();
+ });
test("never logs a directory capability when a browser launch fails", async () => {
vi.mocked(openBrowser).mockResolvedValueOnce(false);
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts
index c1645f33b..716a9250d 100644
--- a/packages/cli/test/e2e/local.test.ts
+++ b/packages/cli/test/e2e/local.test.ts
@@ -92,6 +92,47 @@ afterEach(async () => {
});
describe("Local daemon E2E", () => {
+ test("human session and daemon summaries advertise a plain viewer link without changing JSON", async () => {
+ const port = await availablePort();
+ const env = {
+ SENTRY_CONFIG_DIR: getConfigDir(),
+ SENTRY_CLI_NO_TELEMETRY: "1",
+ SENTRY_AUTH_TOKEN: "",
+ SENTRY_TOKEN: "",
+ };
+ const created = await runCli(
+ [
+ "local",
+ "session",
+ "create",
+ "viewer-link",
+ "--host",
+ "127.0.0.1",
+ "--port",
+ String(port),
+ ],
+ { env },
+ );
+ expect(created.exitCode, created.stderr).toBe(0);
+ for (const result of [
+ created,
+ await runCli(["local", "session", "view", "viewer-link"], { env }),
+ await runCli(["local", "daemon", "status"], { env }),
+ ]) {
+ expect(result.exitCode, result.stderr).toBe(0);
+ const link = result.stdout.match(/View events at (https:\/\/\S+)/)?.[1];
+ expect(link).toBeTruthy();
+ const url = new URL(link!);
+ expect(url.origin).toBe("https://local.sentry.dev");
+ expect(url.searchParams.get("url")).toBe(
+ `http://127.0.0.1:${port}/stream`,
+ );
+ }
+ const json = await cli("session", "view", "viewer-link");
+ expect(json.stdout).not.toContain("View events at");
+ expect(JSON.parse(json.stdout).label).toBe("viewer-link");
+ }, 30_000);
+
test("hosted session discovery switches isolated streams and preserves retained sessions", async () => {
const port = await availablePort();
const web = await createSession("web", port);
From 15701e24411efe75a8f2252299469ce4fce6f514 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 13:30:50 -0700
Subject: [PATCH 08/30] feat(local): Add appearance controls to Settings
Offer Light, Dark, and System themes through the existing viewer theme provider. Persist the preference without reconnecting the receiver and cover the Settings flow with an integration regression.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 50 ++++++++++++++++++++
apps/local/src/components/local-settings.tsx | 50 ++++++++++++++++----
docs/cli/local-control-plane.md | 3 ++
3 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 3474c0d88..38268a235 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -186,6 +186,56 @@ describe("local receiver to viewer integration", () => {
window.history.replaceState(null, "", "/");
});
+ 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("keeps the selected live transaction open in the detail pane", async () => {
const { server, port } = await startReceiver();
diff --git a/apps/local/src/components/local-settings.tsx b/apps/local/src/components/local-settings.tsx
index aeb437e04..43426ac81 100644
--- a/apps/local/src/components/local-settings.tsx
+++ b/apps/local/src/components/local-settings.tsx
@@ -1,4 +1,7 @@
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,
@@ -8,6 +11,12 @@ import {
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;
@@ -27,6 +36,7 @@ export function LocalSettings({
triggerRef,
...retention
}: Props) {
+ const { theme, setTheme } = useTheme();
return (
Settings
- Manage retention for the selected session.
+ Customize viewer appearance and manage session retention.
- {supported ? (
-
- ) : (
-
- This receiver does not provide retention settings.
-
- )}
+
+
Appearance
+
+ {THEME_OPTIONS.map(({ value, label, Icon }) => (
+ setTheme(value)}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {supported ? (
+
+ ) : (
+
+ This receiver does not provide retention settings.
+
+ )}
+
);
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 28af19c51..33800e1a1 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -93,6 +93,9 @@ The independently served viewer lives in `apps/local`. Run
CLI builds use ; publishing the migrated app requires
that hosting project to use Toolkit and `apps/local` as its root directory.
+The Settings dialog also offers Light, Dark, and System appearance options.
+The choice applies immediately and persists in the browser across reloads.
+
### Switching sessions
Human-readable `local run` and `local serve` output prints a **View events at**
From d4f5ddec0d3cdc0f7785e697b211b5f59d2466af Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 13:57:01 -0700
Subject: [PATCH 09/30] fix(local): Complete receiver connection and session
recovery flows
Use a dismissible Add receiver modal with a shared connection form and tab-scoped history.
Probe replacement streams before switching, preserve retained-session context, and guard
cancelled or stale lifecycle actions. Keep Settings accessible on the landing and cover
modal, history, replay, and session-state transitions with regressions.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 333 ++++++++++++++++--
.../src/App.sessions.integration.test.tsx | 47 ++-
apps/local/src/App.tsx | 260 +++++++++++---
.../src/components/connection-landing.tsx | 287 ++++++++++-----
apps/local/src/components/local-settings.tsx | 1 +
.../src/components/receiver-controls.tsx | 34 +-
apps/local/src/components/receiver-dialog.tsx | 46 +++
.../local/src/components/session-switcher.tsx | 9 +-
apps/local/src/components/ui/dialog.tsx | 2 +-
apps/local/src/lib/connection-history.test.ts | 54 +++
apps/local/src/lib/connection-history.ts | 63 ++++
apps/local/src/lib/use-receiver-probe.ts | 57 +++
docs/cli/local-control-plane.md | 27 +-
13 files changed, 1038 insertions(+), 182 deletions(-)
create mode 100644 apps/local/src/components/receiver-dialog.tsx
create mode 100644 apps/local/src/lib/connection-history.test.ts
create mode 100644 apps/local/src/lib/connection-history.ts
create mode 100644 apps/local/src/lib/use-receiver-probe.ts
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 38268a235..fa6d85fbb 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -156,6 +156,16 @@ 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;
+}
+
describe("local receiver to viewer integration", () => {
beforeEach(() => {
// Nuqs shares pending URL updates across mounts. Reset them between tests
@@ -186,6 +196,150 @@ describe("local receiver to viewer integration", () => {
window.history.replaceState(null, "", "/");
});
+ test("Add 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: "Add receiver",
+ exact: true,
+ });
+ fireEvent.click(trigger);
+ const dialog = screen.getByRole("dialog", { name: "Add receiver" });
+ expect(document.body.style.pointerEvents).toBe("none");
+ expect(screen.queryByRole("button", { name: "Clear events" })).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 Add 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: "Add 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: "Add 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 events" })).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);
@@ -324,9 +478,7 @@ describe("local receiver to viewer integration", () => {
?.getAttribute("aria-label"),
).toBe("Connected to local receiver");
expect(
- sidebar.querySelector(
- 'button[aria-label="Change receiver connection"]',
- ),
+ sidebar.querySelector('button[aria-label="Add receiver"]'),
).not.toBeNull();
expect(
sidebar.querySelector('button[aria-label="Clear events"]'),
@@ -343,11 +495,9 @@ describe("local receiver to viewer integration", () => {
?.className,
).toContain("mx-auto");
- fireEvent.click(
- screen.getByRole("button", { name: "Change receiver connection" }),
- );
+ fireEvent.click(screen.getByRole("button", { name: "Add receiver" }));
expect(
- await screen.findByRole("heading", { name: "Connect a receiver" }),
+ await screen.findByRole("dialog", { name: "Add receiver" }),
).not.toBeNull();
} finally {
cleanup();
@@ -382,7 +532,7 @@ describe("local receiver to viewer integration", () => {
expect(connectButton.getAttribute("aria-busy")).toBe("false");
expect(
screen.getByText(
- "Defaults to your local receiver. You can paste another local or HTTPS stream above.",
+ "Use your local receiver's plain /stream address to discover its sessions, or enter an HTTPS stream.",
),
).not.toBeNull();
expect(
@@ -452,12 +602,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: "Add 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: "Add 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: "Add 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: "Add receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "https://receiver.example/stream?token=abc" },
@@ -548,7 +776,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: "Add receiver" }));
await act(async () => {
source?.dispatchEvent(
@@ -579,9 +807,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: "Add receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://127.0.0.1:8970/stream" },
});
@@ -602,7 +828,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: "Add receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://receiver.example/stream" },
});
@@ -610,7 +836,7 @@ describe("local receiver to viewer integration", () => {
expect(source?.closed).toBe(false);
expect(
- screen.getByRole("status", { name: "Connected to local receiver" }),
+ screen.getAllByLabelText("Connected to local receiver")[0],
).not.toBeNull();
});
@@ -1075,14 +1301,12 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
screen.getByRole("button", { name: "Close", exact: true }),
);
- fireEvent.click(
- screen.getByRole("button", { name: "Change receiver" }),
- );
+ fireEvent.click(screen.getByRole("button", { name: "Add 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"));
@@ -1137,15 +1361,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: /^Add 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 openLatestReceiver();
}
const firstUrl =
"http://127.0.0.1:8969/_local/v1/sessions/first/stream?cap=read";
@@ -1224,7 +1446,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: "Add receiver" }));
const streamUrl =
"http://127.0.0.1:8969/_local/v1/sessions/session-1/stream?cap=read-capability";
@@ -1233,9 +1455,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, {
@@ -1268,9 +1488,33 @@ describe("local receiver to viewer integration", () => {
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" });
+ await screen.findByRole("heading", { name: "Session closed" });
+ expect(
+ screen.queryByText(
+ "Telemetry session closed. Retained events remain available for three hours.",
+ ),
+ ).toBeNull();
+ expect(
+ screen.getByRole("region", { name: "Past connections" }),
+ ).not.toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Reconnect to session" }),
+ ).not.toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Reconnect to session" }),
+ );
+ await openLatestReceiver();
+ expect(
+ screen.getByRole("heading", { name: "Session closed" }),
+ ).not.toBeNull();
+ expect(screen.queryByText("Waiting for events")).toBeNull();
expect(screen.queryByText("Waiting for events")).toBeNull();
expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull();
await waitFor(() =>
@@ -1340,7 +1584,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: "Add receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: {
value:
@@ -1348,9 +1592,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, {
@@ -1388,6 +1631,24 @@ describe("local receiver to viewer integration", () => {
screen.getByRole("button", { name: "Close telemetry session" }),
);
await waitFor(() => expect(source?.closed).toBe(true));
+ await screen.findByRole("heading", { name: "Session closed" });
+ expect(screen.getAllByLabelText("View transaction event")).toHaveLength(
+ 1,
+ );
+ expect(
+ screen.queryByRole("button", { name: "Close telemetry session" }),
+ ).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Reconnect", 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,
+ );
} else {
await act(async () => source?.onerror?.(new Event("error")));
}
@@ -1630,9 +1891,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: "Add receiver" }));
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
await screen.findByText("Connected to local receiver");
expect(screen.getByTestId("event-list").textContent).toContain(
@@ -1641,9 +1900,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: "Add receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: `http://127.0.0.1:${second.port}/stream` },
});
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 477f8fd7b..9cdde7825 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -1,3 +1,4 @@
+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";
@@ -309,7 +310,7 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
).toBeNull();
fireEvent.click(
screen.getByRole("button", {
- name: "Change receiver connection",
+ name: "Add receiver",
exact: true,
}),
);
@@ -400,7 +401,7 @@ test("reconnecting a plain receiver after daemon restart never selects a stale d
await second.ingest(current, "/after-restart");
fireEvent.click(
screen.getByRole("button", {
- name: "Change receiver connection",
+ name: "Add receiver",
exact: true,
}),
);
@@ -456,3 +457,45 @@ test("a CLI viewer query link discovers both runs and does not override the sele
await daemon.stop();
}
}, 15_000);
+
+test("a session lifecycle refresh immediately replaces a stale Running badge with Retained", 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("Running"),
+ );
+ 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("Retained"),
+ );
+ } finally {
+ cleanup();
+ await daemon.stop();
+ }
+});
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 621e81b80..641ca60fc 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1,3 +1,11 @@
+import { useReceiverProbe } from "./lib/use-receiver-probe.ts";
+import { ReceiverDialog } from "./components/receiver-dialog.tsx";
+import {
+ readConnectionHistory,
+ rememberConnection,
+ saveConnectionHistory,
+ receiverAddress,
+} from "./lib/connection-history.ts";
import { SessionSwitcher } from "./components/session-switcher.tsx";
import {
getInitialSessionDirectory,
@@ -504,7 +512,8 @@ type WorkspaceSidebarProps = {
traceCount: number;
onClear: () => void;
onCloseSession?: () => void;
- onChangeReceiver: () => void;
+ onChangeReceiver: (trigger: HTMLButtonElement) => void;
+ isClosingSession: boolean;
onOpenSettings: (trigger: HTMLButtonElement) => void;
onOpenCommand: (trigger: HTMLButtonElement) => void;
onSelect: (view: WorkspaceView) => void;
@@ -522,6 +531,7 @@ function WorkspaceSidebar({
onClear,
onCloseSession,
onChangeReceiver,
+ isClosingSession,
onOpenSettings,
onOpenCommand,
onSelect,
@@ -540,7 +550,7 @@ function WorkspaceSidebar({
label: "Close telemetry session",
Icon: X,
onClick: onCloseSession,
- disabled: false,
+ disabled: isClosingSession,
},
]
: []),
@@ -693,16 +703,16 @@ function WorkspaceSidebar({
onChangeReceiver(event.currentTarget)}
className={`h-9 w-full font-normal text-muted-foreground hover:text-foreground ${collapsed ? "px-0" : "gap-2 px-2"}`}
>
- {!collapsed ? Change receiver : null}
+ {!collapsed ? Add receiver : null}
{collapsed ? (
- Change receiver
+ Add receiver
) : null}
("connecting");
- const [draftEndpoint, setDraftEndpoint] = useState(streamUrl);
+ const [draftEndpoint, setDraftEndpoint] = useState(() =>
+ receiverAddress(streamUrl),
+ );
+ 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 [isConnecting, setIsConnecting] = useState(false);
@@ -820,7 +845,24 @@ export default function App() {
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));
+ setReceiverDialogOpen(true);
+ };
+ useEffect(
+ () => saveConnectionHistory(recentConnections),
+ [recentConnections],
+ );
const traces = useMemo(() => buildTraceGroups(items), [items]);
const searchQuery = workspaceQuery.query;
const activeWorkspace = workspaceNavigation.find(
@@ -966,22 +1008,32 @@ 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}`);
}
+ connectionGeneration.current += 1;
setIsConnectionEnabled(false);
setIsConnecting(false);
setConnection("closed");
- setMessage(
- "Telemetry session closed. Retained events remain available for three hours.",
- );
+ setClosedSessionUrl(streamUrl);
+ setConnectionError(undefined);
+ setMessage(undefined);
})
.catch(() => {
if (connectionGeneration.current !== generation) return;
setMessage("Could not close this telemetry session.");
+ })
+ .finally(() => {
+ if (closingGeneration.current === generation) {
+ closingGeneration.current = undefined;
+ setIsClosingSession(false);
+ }
});
};
@@ -997,7 +1049,7 @@ export default function App() {
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") {
@@ -1006,8 +1058,13 @@ export default function App() {
}
return;
}
+ cancelReceiverAttempt();
connectionGeneration.current += 1;
+ closingGeneration.current = undefined;
+ setIsClosingSession(false);
setIsSettingsOpen(false);
+ setReceiverError(undefined);
+ setMessage(undefined);
if (
endpoint.kind === "loopback" &&
new URL(endpoint.url).pathname === "/stream"
@@ -1017,6 +1074,7 @@ export default function App() {
setDirectoryUrl(undefined);
}
if (endpoint.url !== streamUrl) {
+ setClosedSessionUrl(undefined);
telemetryStore.clear();
telemetryStore.setLimit(DEFAULT_MAX_ENVELOPES);
setRetentionLimit(DEFAULT_MAX_ENVELOPES);
@@ -1027,7 +1085,7 @@ export default function App() {
setCommandSearchQuery("");
void workspaceQuery.clearWorkspace();
}
- setDraftEndpoint(endpoint.url);
+ setDraftEndpoint(receiverAddress(endpoint.url));
setStreamUrl(endpoint.url);
setConnectionAttempt((attempt) => attempt + 1);
setIsConnectionEnabled(true);
@@ -1037,6 +1095,56 @@ export default function App() {
setIsEditingReceiver(false);
};
+ 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);
+ setReceiverError(undefined);
+ cancelReceiverAttempt();
+ if (
+ !isEditingReceiver &&
+ connection === "connecting" &&
+ !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(draftEndpoint),
+ recentConnections,
+ onReconnect: connectReceiver,
+ onForget: (url: string) =>
+ setRecentConnections((history) =>
+ history.filter((entry) => entry !== url),
+ ),
+ };
+
useEffect(() => {
const url = new URL(window.location.href);
if (url.hash || url.searchParams.has("url")) {
@@ -1164,6 +1272,7 @@ export default function App() {
} else if (endpoint?.kind === "remote") {
saveRemoteStreamUrl(endpoint.url);
}
+ setRecentConnections((history) => rememberConnection(history, streamUrl));
setConnection("connected");
setConnectionError(undefined);
setMessage(undefined);
@@ -1196,14 +1305,11 @@ export default function App() {
}, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]);
const showConnectionLanding =
- isEditingReceiver ||
- (!hasRetainedItems &&
- (connection === "connecting" ||
- connection === "failed" ||
- connection === "closed"));
+ !hasRetainedItems && (isClosedSession || connection !== "connected");
const isReceiverUnavailable =
- connection !== "connected" && !isConnecting && !hasRetainedItems;
- const canSearch = hasRetainedItems && !isEditingReceiver;
+ !hasRetainedItems &&
+ (isClosedSession || (connection !== "connected" && !isConnecting));
+ const canSearch = hasRetainedItems && !isEditingReceiver && !isSettingsOpen;
const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map(
({ icon, id, label }) => ({ icon, id, label }),
);
@@ -1220,6 +1326,12 @@ export default function App() {
return (
+
setIsEditingReceiver(true)}
+ onCloseSession={
+ hasScopedSession && !isClosedSession
+ ? closeScopedSession
+ : undefined
+ }
+ isClosingSession={isClosingSession}
+ onChangeReceiver={openReceiverDialog}
onOpenSettings={openSettings}
onOpenCommand={openCommand}
onSelect={selectWorkspace}
@@ -1280,6 +1397,23 @@ export default function App() {
height="20"
/>
+
+
+
+ openSettings(event.currentTarget)}
+ >
+
+
+
+ Settings
+
+
) : (
@@ -1312,8 +1446,12 @@ export default function App() {
retainedItemCount={retainedItemCount}
onClear={clearItems}
onCloseSession={
- hasScopedSession ? closeScopedSession : undefined
+ hasScopedSession && !isClosedSession
+ ? closeScopedSession
+ : undefined
}
+ isClosingSession={isClosingSession}
+ onAddReceiver={openReceiverDialog}
onOpenSettings={openSettings}
showStatusRole={false}
/>
@@ -1359,23 +1497,52 @@ export default function App() {
{activeDirectoryUrl ? (
) : null}
- {connectionError &&
- connection === "failed" &&
- hasRetainedItems ? (
+ {hasRetainedItems &&
+ (isClosedSession || connection === "failed") ? (
- {connectionError}
+
+
+ {isClosedSession
+ ? "Session closed"
+ : "Receiver disconnected"}
+
+
+ {isClosedSession
+ ? "Capture has stopped. You can keep inspecting these events or reconnect to retained data for up to three hours."
+ : connectionError}
+
+ {isClosedSession &&
+ connection === "failed" &&
+ connectionError ? (
+
+ {connectionError}
+
+ ) : null}
+
+
connectToEndpoint(streamUrl)}
+ >
+ Reconnect
+
) : null}
{message ? (
@@ -1389,18 +1556,21 @@ export default function App() {
{showConnectionLanding ? (
connectToEndpoint(streamUrl)
+ : undefined
}
- endpoint={draftEndpoint}
- error={connectionError}
- isConnecting={isConnecting}
- onEndpointChange={setDraftEndpoint}
- onConnect={() => connectToEndpoint(draftEndpoint)}
/>
) : !hasRetainedItems ? (
@@ -1409,13 +1579,9 @@ export default function App() {
aria-hidden="true"
/>
Waiting for events
-
setIsEditingReceiver(true)}
- >
- Change receiver
-
+
+ Send events to the selected receiver to see them here.
+
) : workspaceView === "traces" ? (
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) {
+ recentConnections,
+ onReconnect,
+ onForget,
+}: ConnectionFormProps) {
+ const inputId = useId();
+ const errorId = useId();
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
- const isProbing = phase === "probing";
-
const submit = (event: FormEvent) => {
event.preventDefault();
- if (isConnecting) {
- return;
- }
- onConnect();
+ if (!isConnecting) onConnect();
};
+ return (
+
+
+ {recentConnections.length ? (
+
+
+
+ Past connections
+
+
+ Recent receivers in this tab. Reconnect to discover available
+ sessions.
+
+
+
+ {recentConnections.map((url) => (
+
+ onReconnect(url)}
+ >
+
+ {connectionLabel(url)}
+
+
+
+ onForget(url)}
+ >
+
+
+
+ Forget connection
+
+
+ ))}
+
+
+
+ ) : null}
+
+
+ sentry local serve --open
+
+ {
+ void copyToClipboard("sentry local serve --open");
+ setCopied(true);
+ }}
+ >
+ {copied ? (
+
+ ) : (
+
+ )}
+ {copied ? "Copied" : "Copy command"}
+
+
+
+ );
+}
+export type ConnectionLandingProps = ConnectionFormProps & {
+ phase: "probing" | "failed" | "editing" | "closed";
+ onReconnectSession?: () => void;
+};
+
+/** A focused landing when no captured events are available to inspect. */
+export function ConnectionLanding({
+ phase,
+ onReconnectSession,
+ ...form
+}: ConnectionLandingProps) {
+ const titleId = useId();
+ const isProbing = phase === "probing";
+ const closed = phase === "closed";
return (
-
+
) : null}
-
+ {closed ? (
+
+ ) : (
+
+ )}
Sentry Local
-
- {isProbing ? "Looking for Sentry Local" : "Connect a receiver"}
+
+ {isProbing
+ ? "Looking for Sentry Local"
+ : closed
+ ? "Session closed"
+ : "Connect a receiver"}
{isProbing
? "Checking the selected receiver."
- : "Connect this read-only viewer to an event stream."}
+ : closed
+ ? "Capture has stopped. Retained events remain available for up to three hours. Reconnect to inspect them or choose another receiver below."
+ : "Connect this viewer to a local receiver or an HTTPS event stream."}
-
- {error ? (
-
- {error}
-
- ) : null}
-
-
-
-
- Defaults to your local receiver. You can paste another local or HTTPS
- stream above.
-
-
-
-
- sentry local serve --open
-
- {
- void copyToClipboard("sentry local serve --open");
- setCopied(true);
- }}
+ variant="outline"
+ disabled={form.isConnecting}
+ aria-busy={form.isConnecting}
+ onClick={onReconnectSession}
>
- {copied ? (
-
- ) : (
-
- )}
- {copied ? "Copied" : "Copy command"}
-
-
+ Reconnect to session
+
+ ) : null}
+
);
diff --git a/apps/local/src/components/local-settings.tsx b/apps/local/src/components/local-settings.tsx
index 43426ac81..3b41f471d 100644
--- a/apps/local/src/components/local-settings.tsx
+++ b/apps/local/src/components/local-settings.tsx
@@ -40,6 +40,7 @@ export function LocalSettings({
return (
{
if (triggerRef.current?.isConnected) {
event.preventDefault();
diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx
index 788084c68..067d5d118 100644
--- a/apps/local/src/components/receiver-controls.tsx
+++ b/apps/local/src/components/receiver-controls.tsx
@@ -1,4 +1,12 @@
-import { ChevronDown, Moon, Sun, Settings, Trash2, X } from "lucide-react";
+import {
+ ChevronDown,
+ Moon,
+ Sun,
+ Settings,
+ Trash2,
+ X,
+ Radio,
+} from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { useTheme } from "next-themes";
import type { ConnectionPresentation } from "@/lib/presentation.ts";
@@ -10,6 +18,8 @@ type ReceiverControlsProps = {
onClear: () => void;
onOpenSettings?: (trigger: HTMLButtonElement) => void;
onCloseSession?: () => void;
+ isClosingSession?: boolean;
+ onAddReceiver?: (trigger: HTMLButtonElement) => void;
showStatusRole?: boolean;
};
@@ -21,6 +31,8 @@ export function ReceiverControls({
onClear,
onOpenSettings,
onCloseSession,
+ isClosingSession = false,
+ onAddReceiver,
showStatusRole = true,
}: ReceiverControlsProps) {
const [isOpen, setIsOpen] = useState(false);
@@ -130,6 +142,23 @@ export function ReceiverControls({
{isDark ? : }
Use {isDark ? "light" : "dark"} mode
+ {onAddReceiver ? (
+ {
+ const trigger =
+ rootRef.current?.querySelector(
+ 'button[aria-label="Receiver options"]',
+ );
+ if (trigger) onAddReceiver(trigger);
+ setIsOpen(false);
+ }}
+ >
+
+ Add receiver
+
+ ) : null}
{onOpenSettings ? (
{
onCloseSession();
setIsOpen(false);
diff --git a/apps/local/src/components/receiver-dialog.tsx b/apps/local/src/components/receiver-dialog.tsx
new file mode 100644
index 000000000..d905e09d3
--- /dev/null
+++ b/apps/local/src/components/receiver-dialog.tsx
@@ -0,0 +1,46 @@
+import type { RefObject } from "react";
+import {
+ ConnectionForm,
+ type ConnectionFormProps,
+} from "./connection-landing.tsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog.tsx";
+
+export function ReceiverDialog({
+ open,
+ onOpenChange,
+ triggerRef,
+ ...form
+}: ConnectionFormProps & {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ triggerRef: RefObject;
+}) {
+ return (
+
+ {
+ if (triggerRef.current?.isConnected) {
+ event.preventDefault();
+ triggerRef.current.focus();
+ }
+ }}
+ >
+
+ Add receiver
+
+ Connect another receiver. Your current feed stays available until
+ the new connection succeeds.
+
+
+
+
+
+ );
+}
diff --git a/apps/local/src/components/session-switcher.tsx b/apps/local/src/components/session-switcher.tsx
index a162bd932..f28dfffd1 100644
--- a/apps/local/src/components/session-switcher.tsx
+++ b/apps/local/src/components/session-switcher.tsx
@@ -22,8 +22,12 @@ export function SessionSwitcher({
streamUrl,
onSelect,
selectInitialSession = false,
+ refreshKey,
+ onRetainedSession,
}: {
selectInitialSession?: boolean;
+ refreshKey?: string;
+ onRetainedSession?: (streamUrl: string) => void;
directoryUrl: string;
streamUrl: string;
onSelect: (streamUrl: string) => void;
@@ -67,13 +71,16 @@ export function SessionSwitcher({
controller?.abort();
clearTimeout(timer);
};
- }, [directoryUrl, refresh]);
+ }, [directoryUrl, refresh, refreshKey]);
useEffect(() => {
// Only the daemon-wide /stream entry point selects an initial run. Expiring
// or closing a selected scoped session must never silently change feeds.
if (selectInitialSession && sessions[0]) onSelect(sessions[0].streamUrl);
}, [selectInitialSession, sessions, onSelect]);
const current = sessions.find((s) => s.streamUrl === streamUrl);
+ useEffect(() => {
+ if (current?.state === "retained") onRetainedSession?.(streamUrl);
+ }, [current?.state, streamUrl, onRetainedSession]);
return (
{
+ window.sessionStorage.clear();
+ window.localStorage.clear();
+});
+
+test("recent receivers deduplicate daemon sessions without storing their capabilities", () => {
+ let history: string[] = [];
+ for (let port = 9000; port < 9010; port++)
+ history = rememberConnection(history, `http://localhost:${port}/stream`);
+ history = rememberConnection(
+ history,
+ "http://localhost:9009/_local/v1/sessions/web/stream?cap=private-session",
+ );
+ expect(history).toHaveLength(8);
+ expect(history[0]).toBe("http://localhost:9009/stream");
+ saveConnectionHistory(history);
+ expect(window.sessionStorage.getItem(CONNECTION_HISTORY_KEY)).not.toContain(
+ "private-session",
+ );
+ expect(readConnectionHistory()).toEqual(history);
+});
+
+test("remote history survives reload in this tab and hides query credentials in its label", () => {
+ const url = "https://receiver.example/stream?token=private-remote";
+ saveConnectionHistory(rememberConnection([], url));
+ expect(readConnectionHistory()).toEqual([url]);
+ expect(connectionLabel(url)).toBe("https://receiver.example/stream");
+ expect(window.localStorage.getItem(CONNECTION_HISTORY_KEY)).toBeNull();
+});
+
+test("history rejects malformed storage and unsafe endpoints", () => {
+ window.sessionStorage.setItem(CONNECTION_HISTORY_KEY, "invalid JSON");
+ expect(readConnectionHistory()).toEqual([]);
+ window.sessionStorage.setItem(
+ CONNECTION_HISTORY_KEY,
+ JSON.stringify([
+ null,
+ 1,
+ "http://remote.example/stream",
+ "https://user:password@receiver.example/stream",
+ "http://localhost:9000/stream",
+ ]),
+ );
+ expect(readConnectionHistory()).toEqual(["http://localhost:9000/stream"]);
+});
diff --git a/apps/local/src/lib/connection-history.ts b/apps/local/src/lib/connection-history.ts
new file mode 100644
index 000000000..95abe9464
--- /dev/null
+++ b/apps/local/src/lib/connection-history.ts
@@ -0,0 +1,63 @@
+import { parseStreamEndpoint } from "./spotlight.ts";
+
+export const CONNECTION_HISTORY_KEY = "sentry.local.recent-connections";
+const MAX_CONNECTIONS = 8;
+
+/** Re-pair daemon receivers through discovery, without retaining session capabilities. */
+export function receiverAddress(stream: string): string {
+ const endpoint = parseStreamEndpoint(stream);
+ return endpoint?.kind === "loopback"
+ ? new URL("/stream", endpoint.url).toString()
+ : stream;
+}
+
+/** Display endpoints without query credentials. */
+export function connectionLabel(stream: string): string {
+ const url = new URL(stream);
+ return `${url.origin}${url.pathname}`;
+}
+
+export function rememberConnection(
+ history: string[],
+ stream: string,
+): string[] {
+ if (!parseStreamEndpoint(stream)) return history;
+ const url = receiverAddress(stream);
+ const label = connectionLabel(url);
+ return [
+ url,
+ ...history.filter((entry) => connectionLabel(entry) !== label),
+ ].slice(0, MAX_CONNECTIONS);
+}
+
+/** Query-bearing remote connections stay in this tab's session storage only. */
+export function readConnectionHistory(): string[] {
+ try {
+ const value: unknown = JSON.parse(
+ window.sessionStorage.getItem(CONNECTION_HISTORY_KEY) ?? "[]",
+ );
+ if (!Array.isArray(value)) return [];
+ return value
+ .slice(0, MAX_CONNECTIONS)
+ .reduceRight(
+ (history, entry: unknown) =>
+ typeof entry === "string"
+ ? rememberConnection(history, entry)
+ : history,
+ [],
+ );
+ } catch {
+ return [];
+ }
+}
+
+export function saveConnectionHistory(history: string[]): void {
+ try {
+ window.sessionStorage.setItem(
+ CONNECTION_HISTORY_KEY,
+ JSON.stringify(history),
+ );
+ } catch {
+ // The current tab's in-memory history still works when storage is unavailable.
+ }
+}
diff --git a/apps/local/src/lib/use-receiver-probe.ts b/apps/local/src/lib/use-receiver-probe.ts
new file mode 100644
index 000000000..bf8951f52
--- /dev/null
+++ b/apps/local/src/lib/use-receiver-probe.ts
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/** Test a receiver without replacing the active feed or committing a cancelled attempt. */
+export function useReceiverProbe(timeoutMs: number) {
+ const [isPending, setIsPending] = useState(false);
+ const pending = useRef<{ source: EventSource; timer: number } | undefined>(
+ undefined,
+ );
+ const stop = useCallback((notify = true) => {
+ const current = pending.current;
+ pending.current = undefined;
+ if (current) {
+ window.clearTimeout(current.timer);
+ current.source.close();
+ }
+ if (notify) setIsPending(false);
+ }, []);
+ useEffect(() => () => stop(false), [stop]);
+ const probe = (
+ url: string,
+ onReady: () => void,
+ onError: (message: string) => void,
+ ) => {
+ stop();
+ const failure =
+ "Could not connect to this receiver. Your current feed is unchanged.";
+ let source: EventSource;
+ try {
+ source = new EventSource(url);
+ } catch {
+ onError(failure);
+ return;
+ }
+ setIsPending(true);
+ const fail = (message: string) => {
+ if (pending.current?.source !== source) return;
+ stop();
+ onError(message);
+ };
+ const timer = window.setTimeout(
+ () =>
+ fail(
+ "Connection timed out after 10 seconds. Your current feed is unchanged.",
+ ),
+ timeoutMs,
+ );
+ pending.current = { source, timer };
+ source.onerror = () => fail(failure);
+ source.onopen = () => {
+ if (pending.current?.source !== source) return;
+ // The committed stream replays the receiver buffer with its real metadata.
+ stop();
+ onReady();
+ };
+ };
+ return { isPending, cancel: stop, probe };
+}
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 33800e1a1..ec4200c44 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -95,6 +95,29 @@ that hosting project to use Toolkit and `apps/local` as its root directory.
The Settings dialog also offers Light, Dark, and System appearance options.
The choice applies immediately and persists in the browser across reloads.
+Settings also remains accessible from the connection landing without an active receiver.
+
+### Receiver connections
+
+**Add receiver** opens a modal over the current feed. Close it with its close
+button, Escape, or an outside click; focus returns to its trigger. The background
+is blocked while Add receiver or Settings is open. Both dialogs scroll within
+the viewport. Adding a receiver checks its stream before replacing a healthy
+feed. A failed or cancelled attempt keeps the existing connection and events.
+
+**Past connections**, below the connection form, lists successful receivers for
+this browser tab and survives reloads. Choose one to reconnect without pasting
+its address, or use its remove button to forget the entry. Local entries use the
+plain `/stream` address so reconnecting discovers available sessions without
+saving session capabilities in the list. Remote URLs stay in tab session storage;
+query credentials are omitted from their displayed labels. No history entry is
+created for a failed connection.
+
+Closing a session stops capture and keeps already displayed events available
+for inspection and search. A status card offers **Reconnect** to replay retained
+data. When no events are displayed, the landing explains that capture stopped and
+offers **Reconnect to session**, another endpoint, and past connections. Closing
+is guarded against duplicate requests and times out if the receiver does not respond.
### Switching sessions
@@ -145,8 +168,8 @@ Open Settings with the sidebar gear (or Session settings in the mobile receiver
menu) to view retention for the selected session. A locally served viewer can
change the limit at runtime with validation; increases preserve captured data,
and lowering it below the retained count is rejected until enough data is cleared.
-Settings are read-only in the hosted viewer; use the CLI to change retention there.
-The sidebar keeps Change receiver on its own row, with Settings at the left and
+Retention is read-only in the hosted viewer; use the CLI to change it there.
+The sidebar keeps Add receiver on its own row, with Settings at the left and
Clear/Close icons at the right beneath it. Icon tooltips also work with keyboard focus.
A loopback-served viewer can reset and close its scoped session. The hosted viewer
From b7d795229171074334b3bb43a37a79ef6226ae66 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 14:07:18 -0700
Subject: [PATCH 10/30] test(local): Close receiver sockets during fixture
teardown
Close old SSE and probe sockets explicitly after stopping each test server.
This avoids Node 22 keep-alive delays exhausting the history test timeout on CI.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index fa6d85fbb..d51b30f9f 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -78,6 +78,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();
});
}
From 382d7c809e08b0de90fe66fc986956c92110280d Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 14:27:49 -0700
Subject: [PATCH 11/30] fix(local): Clarify retained envelope counts in
Settings
Separate retained envelopes from the editable limit and preserve visible capacity in read-only viewers.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 39 +++++++++-----
.../src/App.sessions.integration.test.tsx | 6 +--
.../components/retention-settings.test.tsx | 54 ++++++++++++++++++-
.../src/components/retention-settings.tsx | 22 +++++---
4 files changed, 97 insertions(+), 24 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index d51b30f9f..ad38f4354 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -1125,7 +1125,7 @@ describe("local receiver to viewer integration", () => {
await screen.findByRole("button", { name: "Settings", exact: true }),
);
await screen.findByText(
- /Retention limit: 1,000 envelopes · 501 displayed/,
+ /501 envelopes currently retained/,
{},
{ timeout: 10000 },
);
@@ -1135,17 +1135,22 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
screen.getByRole("button", { name: "Apply retention limit" }),
);
- await screen.findByText(
- /Retention limit: 2,000 envelopes · 501 displayed/,
+ await waitFor(() =>
+ expect(
+ (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ ).toBe("2000"),
);
- expect(retention.getSettings()).toEqual({
- maxEnvelopes: 2000,
- retainedEnvelopes: 501,
- });
- await sendEnvelope(receiver.port, "after-growth");
- await screen.findByText(
- /Retention limit: 2,000 envelopes · 502 displayed/,
+ expect(
+ screen.getByText(/501 envelopes currently retained/),
+ ).not.toBeNull();
+ await waitFor(() =>
+ expect(retention.getSettings()).toEqual({
+ maxEnvelopes: 2000,
+ retainedEnvelopes: 501,
+ }),
);
+ await sendEnvelope(receiver.port, "after-growth");
+ await screen.findByText(/502 envelopes currently retained/);
} finally {
cleanup();
await stopReceiver(receiver.server);
@@ -1194,7 +1199,7 @@ describe("local receiver to viewer integration", () => {
screen.getByRole("button", { name: "Apply retention 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"), {
@@ -1211,7 +1216,11 @@ describe("local receiver to viewer integration", () => {
}),
),
);
- await screen.findByText(/Retention limit: 1,000 envelopes/);
+ await waitFor(() =>
+ expect(
+ (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ ).toBe("1000"),
+ );
fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
await waitFor(() =>
expect(document.activeElement).toBe(
@@ -1324,7 +1333,11 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
await screen.findByRole("button", { name: "Settings", exact: true }),
);
- await screen.findByText(/Retention limit: 1,000 envelopes/);
+ await waitFor(() =>
+ expect(
+ (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ ).toBe("1000"),
+ );
fireEvent.click(
screen.getByRole("button", { name: "Close", exact: true }),
);
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 9cdde7825..fcb05ee4e 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -166,7 +166,7 @@ test("hosted viewer switches live sessions without mixing events and replays ret
fireEvent.click(
screen.getByRole("button", { name: "Settings", exact: true }),
);
- await screen.findByText(/Retention limit: 1,000 envelopes/);
+ await screen.findByText(/1,000 envelopes/);
expect(screen.queryByLabelText("Retention limit")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
await daemon.ingest(web, "/web-after-switch");
@@ -328,12 +328,12 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
screen.getByRole("combobox", { name: "Session", exact: true })
.textContent,
).toContain("worker");
- expect(screen.queryByText(/Retention limit:/)).toBeNull();
+ expect(screen.queryByText("Retention limit")).toBeNull();
fireEvent.click(
screen.getByRole("button", { name: "Settings", exact: true }),
);
await screen.findByRole("dialog", { name: "Settings", exact: true });
- await screen.findByText(/Retention limit: 500 envelopes/);
+ await screen.findByText(/500 envelopes/);
expect(screen.queryByLabelText("Retention limit")).toBeNull();
await screen.findByText(
"Change retention from a locally served viewer or the CLI.",
diff --git a/apps/local/src/components/retention-settings.test.tsx b/apps/local/src/components/retention-settings.test.tsx
index 14963850e..ecd5e40e8 100644
--- a/apps/local/src/components/retention-settings.test.tsx
+++ b/apps/local/src/components/retention-settings.test.tsx
@@ -78,5 +78,57 @@ test("reports a receiver-side retention race without claiming a successful save"
),
expect.objectContaining({ body: '{"maxEnvelopes":8}' }),
);
- expect(screen.getByText(/Retention limit: 500 envelopes/)).not.toBeNull();
+ expect(
+ (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ ).toBe("8");
+ expect(screen.getByText("8 envelopes currently retained.")).not.toBeNull();
});
+
+test.each([0, 1, 8])(
+ "describes %i retained envelopes separately from the editable limit",
+ (count) => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText(
+ `${count} ${count === 1 ? "envelope" : "envelopes"} currently retained.`,
+ ),
+ ).not.toBeNull();
+ expect(
+ (
+ screen.getByRole("spinbutton", {
+ name: "Retention limit",
+ }) as HTMLInputElement
+ ).value,
+ ).toBe("500");
+ expect(screen.queryByText(/displayed/)).toBeNull();
+ },
+);
+
+test.each([1, 1000])(
+ "keeps capacity %i visible when the viewer cannot edit retention",
+ (limit) => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Retention limit")).not.toBeNull();
+ expect(
+ screen.getByText(
+ `${limit.toLocaleString()} ${limit === 1 ? "envelope" : "envelopes"}`,
+ ),
+ ).not.toBeNull();
+ expect(screen.getByText("8 envelopes currently retained.")).not.toBeNull();
+ expect(screen.queryByRole("spinbutton")).toBeNull();
+ },
+);
diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx
index 7757a6e29..b44b5c186 100644
--- a/apps/local/src/components/retention-settings.tsx
+++ b/apps/local/src/components/retention-settings.tsx
@@ -33,7 +33,7 @@ export function RetentionSettings({
v.check((value: number) => isValidMaxEnvelopes(value), invalidLimit),
v.check(
(value) => value >= count,
- `Cannot lower the limit below ${count} displayed envelopes. Clear envelopes first.`,
+ `Cannot lower the limit below ${count.toLocaleString()} retained ${count === 1 ? "envelope" : "envelopes"}. Clear envelopes first.`,
),
),
});
@@ -92,8 +92,8 @@ export function RetentionSettings({
return (
- Retention limit: {limit.toLocaleString()} envelopes ·{" "}
- {count.toLocaleString()} displayed
+ {count.toLocaleString()} {count === 1 ? "envelope" : "envelopes"}{" "}
+ currently retained.
{editable ? (
) : (
-
- {unavailableReason ??
- "Change retention from a locally served viewer or the CLI."}
-
+
+
+ Retention limit
+
+ {limit.toLocaleString()} {limit === 1 ? "envelope" : "envelopes"}
+
+
+
+ {unavailableReason ??
+ "Change retention from a locally served viewer or the CLI."}
+
+
)}
);
From 04aedafd613f96216f6d7a814c92ecf79d4508e3 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 15:22:25 -0700
Subject: [PATCH 12/30] fix(local): Clear stale receiver errors while editing
Clear a failed landing attempt when its endpoint changes. Preserve the disconnected feed error while a replacement is edited in the modal.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 63 +++++++++++++++++++++++++
apps/local/src/App.tsx | 1 +
2 files changed, 64 insertions(+)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 9143d4606..2959ef6aa 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -838,6 +838,69 @@ describe("local receiver to viewer integration", () => {
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("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.findByRole("heading", {
+ name: "Receiver disconnected",
+ 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: "Add 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 () => {
ControllableEventSource.instances = [];
vi.stubGlobal("EventSource", ControllableEventSource);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 5b4a149fa..c0da3754d 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1123,6 +1123,7 @@ export default function App() {
onEndpointChange: (value: string) => {
setDraftEndpoint(value);
setReceiverError(undefined);
+ if (!isEditingReceiver) setConnectionError(undefined);
cancelReceiverAttempt();
if (
!isEditingReceiver &&
From bf766b3b6a4bd0132265c6d1816b826af48b9664 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 16:30:48 -0700
Subject: [PATCH 13/30] fix(local): Preserve scoped sessions when retrying
connections
Keep the selected run when the landing retries its unchanged receiver address. Cover an initial SSE failure with two real daemon sessions.
Co-Authored-By: GPT-6 Codex
---
.../src/App.sessions.integration.test.tsx | 44 +++++++++++++++++++
apps/local/src/App.tsx | 9 +++-
2 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index fcb05ee4e..6de17e6ce 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -424,6 +424,50 @@ test("reconnecting a plain receiver after daemon restart never selects a stale d
}
}, 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();
+ 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 {
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index c0da3754d..2c34022ad 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1137,7 +1137,14 @@ export default function App() {
setConnection("missing");
}
},
- onConnect: () => connectReceiver(draftEndpoint),
+ onConnect: () =>
+ connectReceiver(
+ // The form hides local session capabilities. Retrying its unchanged
+ // address must keep the selected run rather than rediscovering one.
+ !isEditingReceiver && draftEndpoint === receiverAddress(streamUrl)
+ ? streamUrl
+ : draftEndpoint,
+ ),
recentConnections,
onReconnect: connectReceiver,
onForget: (url: string) =>
From 515ae1e6d10d9a977fcbe536ba5ba5439032df54 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 16:46:08 -0700
Subject: [PATCH 14/30] fix(local): Retry transient daemon ownership reads
Handle brief EPERM failures while a stopping daemon removes its owner file. Keep retries bounded and propagate persistent access errors without treating unreadable ownership as absent.
Co-Authored-By: GPT-6 Codex
---
packages/cli/src/commands/local/daemon.ts | 23 +++++---
.../commands/local/daemon-ownership.test.ts | 59 ++++++++++++++++++-
2 files changed, 74 insertions(+), 8 deletions(-)
diff --git a/packages/cli/src/commands/local/daemon.ts b/packages/cli/src/commands/local/daemon.ts
index ad5f5350f..05ce39d28 100644
--- a/packages/cli/src/commands/local/daemon.ts
+++ b/packages/cli/src/commands/local/daemon.ts
@@ -312,13 +312,22 @@ function isProcessRunning(pid: number): boolean {
/** Read a lock's owning PID without interpreting an absent file as ownership. */
async function readOwnerPid(path: string): Promise {
- try {
- return Number(await readFile(path, "utf8"));
- } catch (error) {
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
- return 0;
- logger.debug("Could not read Local startup ownership", error);
- throw error;
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return Number(await readFile(path, "utf8"));
+ } catch (error) {
+ if (error instanceof Error && "code" in error) {
+ if (error.code === "ENOENT") return 0;
+ // Windows may briefly deny reads while the previous owner removes its
+ // file. Retry without interpreting an unreadable owner as absent.
+ if (error.code === "EPERM" && attempt < 4) {
+ await sleep(125);
+ continue;
+ }
+ }
+ logger.debug("Could not read Local startup ownership", error);
+ throw error;
+ }
}
}
diff --git a/packages/cli/test/commands/local/daemon-ownership.test.ts b/packages/cli/test/commands/local/daemon-ownership.test.ts
index 682ed2ded..1d4b66245 100644
--- a/packages/cli/test/commands/local/daemon-ownership.test.ts
+++ b/packages/cli/test/commands/local/daemon-ownership.test.ts
@@ -2,7 +2,10 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useTestConfigDir } from "../../helpers.js";
-import { claimDaemonOwnership } from "../../../src/commands/local/daemon.js";
+import {
+ claimDaemonOwnership,
+ waitForLocalDaemonShutdown,
+} from "../../../src/commands/local/daemon.js";
vi.mock("node:fs/promises", async (importOriginal) => {
const fs = await importOriginal();
@@ -168,3 +171,57 @@ describe("Local daemon ownership", () => {
expect(await readFile(owner, "utf8")).toBe("42");
});
});
+
+describe("Local daemon shutdown ownership reads", () => {
+ const state = {
+ host: "127.0.0.1",
+ port: 8969,
+ pid: process.pid,
+ token: "stopping",
+ version: 1,
+ };
+
+ test("retries a transient owner read error while the stopped daemon releases its file", async () => {
+ const fs =
+ await vi.importActual(
+ "node:fs/promises",
+ );
+ const directory = getConfigDir();
+ await mkdir(directory, { recursive: true });
+ const owner = join(directory, "local-control-plane.owner");
+ await writeFile(owner, String(process.pid));
+ vi.mocked(readFile)
+ .mockImplementationOnce(fs.readFile)
+ .mockImplementationOnce(async () => {
+ await fs.rm(owner);
+ throw Object.assign(new Error("owner deletion in progress"), {
+ code: "EPERM",
+ });
+ });
+ await expect(waitForLocalDaemonShutdown(state)).resolves.toBeUndefined();
+ });
+
+ test.each(["EPERM", "EACCES"])(
+ "preserves an owner when reads remain denied with %s",
+ async (code) => {
+ const fs =
+ await vi.importActual(
+ "node:fs/promises",
+ );
+ const directory = getConfigDir();
+ await mkdir(directory, { recursive: true });
+ const owner = join(directory, "local-control-plane.owner");
+ await writeFile(owner, String(process.pid));
+ const denied = Object.assign(new Error("owner remains unreadable"), {
+ code,
+ });
+ vi.mocked(readFile).mockRejectedValue(denied);
+ try {
+ await expect(waitForLocalDaemonShutdown(state)).rejects.toBe(denied);
+ expect(await fs.readFile(owner, "utf8")).toBe(String(process.pid));
+ } finally {
+ vi.mocked(readFile).mockImplementation(fs.readFile);
+ }
+ },
+ );
+});
From 5f9feec3499214bb4fd67930a4f411531d757cfc Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 17:33:56 -0700
Subject: [PATCH 15/30] fix(local): Preserve sessions on unchanged receiver
submissions
Retry the selected scoped session when Add receiver is submitted without edits. Preserve retained events and workspace selection while explicit endpoint edits still allow rediscovery after a daemon restart.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 71 +++++++++++++++++++
.../src/App.sessions.integration.test.tsx | 7 ++
apps/local/src/App.tsx | 8 ++-
3 files changed, 85 insertions(+), 1 deletion(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 2959ef6aa..45f1035e8 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -863,6 +863,77 @@ describe("local receiver to viewer integration", () => {
await screen.findByText("Connected to local receiver");
});
+ test("unchanged Add 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.findByRole("heading", {
+ name: "Receiver disconnected",
+ exact: true,
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Add 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);
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 6de17e6ce..1765afd84 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -314,6 +314,10 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
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 },
});
@@ -405,6 +409,9 @@ test("reconnecting a plain receiver after daemon restart never selects a stale d
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` },
});
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 2c34022ad..99e2a787d 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -801,6 +801,7 @@ export default function App() {
const [draftEndpoint, setDraftEndpoint] = useState(() =>
receiverAddress(streamUrl),
);
+ const receiverEndpointEdited = useRef(false);
const [recentConnections, setRecentConnections] = useState(
readConnectionHistory,
);
@@ -857,6 +858,7 @@ export default function App() {
const openReceiverDialog = (trigger: HTMLButtonElement) => {
receiverTriggerRef.current = trigger;
setDraftEndpoint(receiverAddress(streamUrl));
+ receiverEndpointEdited.current = false;
setReceiverDialogOpen(true);
};
useEffect(
@@ -1086,6 +1088,7 @@ export default function App() {
void workspaceQuery.clearWorkspace();
}
setDraftEndpoint(receiverAddress(endpoint.url));
+ receiverEndpointEdited.current = false;
setStreamUrl(endpoint.url);
setConnectionAttempt((attempt) => attempt + 1);
setIsConnectionEnabled(true);
@@ -1122,6 +1125,7 @@ export default function App() {
isConnecting: isConnecting || isAddingReceiver,
onEndpointChange: (value: string) => {
setDraftEndpoint(value);
+ receiverEndpointEdited.current = true;
setReceiverError(undefined);
if (!isEditingReceiver) setConnectionError(undefined);
cancelReceiverAttempt();
@@ -1141,7 +1145,9 @@ export default function App() {
connectReceiver(
// The form hides local session capabilities. Retrying its unchanged
// address must keep the selected run rather than rediscovering one.
- !isEditingReceiver && draftEndpoint === receiverAddress(streamUrl)
+ // An explicitly edited address can re-pair after a daemon restart.
+ !receiverEndpointEdited.current &&
+ draftEndpoint === receiverAddress(streamUrl)
? streamUrl
: draftEndpoint,
),
From 481b9b241bd0fd2c4d14e277d598372f1d17fff7 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 18:54:15 -0700
Subject: [PATCH 16/30] fix(local): Make capture and viewer lifecycle actions
consistent
Freeze closed sessions, replay finite archives, notify every reader on reset and close, and provide explicit disconnection without deleting capture data. Keep the workspace stable during clears and guard stale deliveries.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 81 +++++--
.../src/App.sessions.integration.test.tsx | 65 ++++++
apps/local/src/App.tsx | 200 ++++++++++++++----
.../src/components/connection-landing.tsx | 43 +---
.../src/components/receiver-controls.test.tsx | 23 ++
.../src/components/receiver-controls.tsx | 12 +-
apps/local/src/lib/connection-history.test.ts | 18 ++
apps/local/src/lib/connection-history.ts | 22 ++
docs/cli/local-control-plane.md | 23 +-
.../cli/src/commands/local/control-plane.ts | 82 +++++--
.../cli/src/commands/local/session-store.ts | 54 +++--
.../cli/src/commands/local/viewer-protocol.ts | 7 +
.../commands/local/session-lifecycle.test.ts | 114 ++++++++++
.../test/commands/local/session-store.test.ts | 6 +-
packages/cli/test/e2e/local.test.ts | 45 +++-
15 files changed, 661 insertions(+), 134 deletions(-)
create mode 100644 packages/cli/test/commands/local/session-lifecycle.test.ts
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 45f1035e8..19766377f 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -71,7 +71,11 @@ function envelope(transaction: string, options: EnvelopeOptions = {}): string {
}
async function startReceiver() {
- return tryListen(buildApp(createSpotlightBuffer(10)), 0, "127.0.0.1");
+ return tryListen(
+ buildApp(createSpotlightBuffer(10), { uiActions: true }),
+ 0,
+ "127.0.0.1",
+ );
}
async function stopReceiver(
@@ -863,6 +867,54 @@ describe("local receiver to viewer integration", () => {
await screen.findByText("Connected to local receiver");
});
+ 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",
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Clear events", exact: true }),
+ );
+ 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("unchanged Add 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";
@@ -1759,7 +1811,12 @@ describe("local receiver to viewer integration", () => {
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.",
@@ -1786,21 +1843,18 @@ describe("local receiver to viewer integration", () => {
"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("region", { name: "Past connections" }),
- ).not.toBeNull();
- expect(
- screen.getByRole("button", { name: "Reconnect to session" }),
+ screen.getByRole("button", { name: "Reload saved events" }),
).not.toBeNull();
fireEvent.click(
- screen.getByRole("button", { name: "Reconnect to session" }),
+ screen.getByRole("button", { name: "Disconnect receiver" }),
);
- await openLatestReceiver();
+ await screen.findByRole("heading", { name: "Connect a receiver" });
expect(
- screen.getByRole("heading", { name: "Session closed" }),
+ screen.getByRole("region", { name: "Past connections" }),
).not.toBeNull();
- expect(screen.queryByText("Waiting for events")).toBeNull();
- expect(screen.queryByText("Waiting for events")).toBeNull();
expect(screen.getByLabelText("Receiver endpoint")).not.toBeNull();
await waitFor(() =>
expect(
@@ -1924,7 +1978,10 @@ describe("local receiver to viewer integration", () => {
screen.queryByRole("button", { name: "Close telemetry session" }),
).toBeNull();
fireEvent.click(
- screen.getByRole("button", { name: "Reconnect", exact: true }),
+ screen.getByRole("button", {
+ name: "Reload saved events",
+ exact: true,
+ }),
);
const replay = await openLatestReceiver();
await act(async () => replay.onerror?.(new Event("error")));
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 1765afd84..2abe52133 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -550,3 +550,68 @@ test("a session lifecycle refresh immediately replaces a stale Running badge wit
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 screen.findByRole("heading", { name: "Session closed", exact: true });
+ expect(screen.queryByRole("alert")).toBeNull();
+ await selectSession("worker");
+ await screen.findAllByText("/running-worker");
+ await selectSession("web");
+ await screen.findAllByText("/archived-web");
+ await screen.findByRole("heading", { name: "Session closed", exact: true });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Clear events", exact: true }),
+ );
+ await screen.findByRole("heading", {
+ name: "No saved 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 saved events", exact: true }),
+ );
+ await screen.findAllByText("/archived-web");
+ fireEvent.click(
+ screen.getByRole("button", { name: "Disconnect receiver", exact: true }),
+ );
+ await screen.findByRole("heading", {
+ name: "Connect a receiver",
+ 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: "Connect a receiver",
+ exact: true,
+ });
+ expect(screen.queryByText("/archived-web")).toBeNull();
+ fireEvent.click(
+ screen.getByRole("button", { name: "Connect", exact: true }),
+ );
+ await screen.findAllByText("/archived-web");
+ await screen.findByRole("heading", { name: "Session closed", exact: true });
+ expect(screen.queryByRole("alert")).toBeNull();
+ } finally {
+ cleanup();
+ await daemon.stop();
+ }
+}, 15_000);
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 99e2a787d..09897307d 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -8,6 +8,8 @@ import {
rememberConnection,
saveConnectionHistory,
receiverAddress,
+ getInitialDisconnected,
+ saveDisconnected,
} from "./lib/connection-history.ts";
import { SessionSwitcher } from "./components/session-switcher.tsx";
import {
@@ -16,7 +18,10 @@ import {
saveSessionDirectory,
} from "./lib/session-directory.ts";
import { LocalSettings } from "./components/local-settings.tsx";
-import { LOCAL_DIRECTORY_EVENT } from "../../../packages/cli/src/commands/local/viewer-protocol.ts";
+import {
+ LOCAL_DIRECTORY_EVENT,
+ LOCAL_SESSION_EVENT,
+} from "../../../packages/cli/src/commands/local/viewer-protocol.ts";
import {
DEFAULT_MAX_ENVELOPES,
isValidMaxEnvelopes,
@@ -28,6 +33,7 @@ import {
ChevronRight,
Copy,
Menu,
+ Unplug,
Radio,
Settings,
Trash2,
@@ -524,6 +530,8 @@ type WorkspaceSidebarProps = {
snapshot: LocalTelemetrySnapshot;
traceCount: number;
onClear: () => void;
+ onDisconnect: () => void;
+ isClearing: boolean;
onCloseSession?: () => void;
onChangeReceiver: (trigger: HTMLButtonElement) => void;
isClosingSession: boolean;
@@ -542,6 +550,8 @@ function WorkspaceSidebar({
snapshot,
traceCount,
onClear,
+ onDisconnect,
+ isClearing,
onCloseSession,
onChangeReceiver,
isClosingSession,
@@ -555,7 +565,7 @@ function WorkspaceSidebar({
label: "Clear events",
Icon: Trash2,
onClick: onClear,
- disabled: retainedItemCount === 0,
+ disabled: retainedItemCount === 0 || isClearing,
},
...(onCloseSession
? [
@@ -563,10 +573,16 @@ function WorkspaceSidebar({
label: "Close telemetry session",
Icon: X,
onClick: onCloseSession,
- disabled: isClosingSession,
+ disabled: isClosingSession || isClearing,
},
]
: []),
+ {
+ label: "Disconnect receiver",
+ Icon: Unplug,
+ onClick: onDisconnect,
+ disabled: false,
+ },
];
const statusLabel = connection.label.replace(" to local receiver", "");
const statusDotClass =
@@ -576,6 +592,12 @@ function WorkspaceSidebar({
? "bg-amber-500"
: "bg-muted-foreground";
+ const statusPillClass =
+ connection.tone === "success"
+ ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
+ : connection.tone === "warning"
+ ? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
+ : "bg-muted text-muted-foreground";
return (
@@ -747,7 +769,7 @@ function WorkspaceSidebar({
aria-label={label}
disabled={disabled}
onClick={onClick}
- className={`text-muted-foreground hover:bg-destructive/10 hover:text-destructive ${collapsed ? "w-full" : ""}`}
+ className={`text-muted-foreground ${label === "Disconnect receiver" ? "" : "hover:bg-destructive/10 hover:text-destructive"} ${collapsed ? "w-full" : ""}`}
>
@@ -791,13 +813,20 @@ export default function App() {
window.location.search,
),
);
+ const [isDisconnected, setIsDisconnected] = useState(getInitialDisconnected);
+ const [hasEstablishedConnection, setHasEstablishedConnection] =
+ useState(false);
+ const [isClearing, setIsClearing] = 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 [connection, setConnection] = useState(
+ isDisconnected ? "missing" : "connecting",
+ );
const [draftEndpoint, setDraftEndpoint] = useState(() =>
receiverAddress(streamUrl),
);
@@ -816,7 +845,8 @@ export default function App() {
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);
@@ -959,21 +989,55 @@ export default function App() {
markItemsSeen(nextWorkspace);
};
- const clearItems = () => {
+ const clearDisplayedItems = () => {
isClearingCommandSearch.current = true;
setCommandSearchQuery("");
telemetryStore.clear();
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);
+ setIsClearing(false);
+ clearingGeneration.current = undefined;
+ closingGeneration.current = undefined;
+ setIsClosingSession(false);
+ setDraftEndpoint(receiverAddress(streamUrl));
+ receiverEndpointEdited.current = false;
+ clearDisplayedItems();
+ };
+ const clearItems = () => {
+ if (isClearing) return;
const endpoint = parseStreamEndpoint(streamUrl);
+ clearDisplayedItems();
if (
endpoint?.kind !== "loopback" ||
!isLoopbackHost(window.location.hostname)
- ) {
+ )
return;
- }
+ // 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);
const clearUrl = new URL(endpoint.url);
const scopedSession = isScopedSessionStream(clearUrl.pathname);
if (scopedSession) {
@@ -982,17 +1046,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 clear receiver data. Reconnect to try again.",
+ );
setMessage(
"Cleared this viewer, but the receiver could not clear its retained session.",
);
+ })
+ .finally(() => {
+ if (clearingGeneration.current === generation) {
+ clearingGeneration.current = undefined;
+ setIsClearing(false);
+ }
});
};
@@ -1061,6 +1139,10 @@ export default function App() {
return;
}
cancelReceiverAttempt();
+ setIsDisconnected(false);
+ saveDisconnected(false);
+ setIsClearing(false);
+ clearingGeneration.current = undefined;
connectionGeneration.current += 1;
closingGeneration.current = undefined;
setIsClosingSession(false);
@@ -1246,6 +1328,42 @@ 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 (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;
@@ -1280,6 +1398,7 @@ export default function App() {
}
window.clearTimeout(timeoutId);
setIsConnecting(false);
+ setHasEstablishedConnection(true);
const endpoint = parseStreamEndpoint(streamUrl);
if (endpoint?.kind === "loopback") {
saveStreamUrl(endpoint.url);
@@ -1312,6 +1431,7 @@ 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();
@@ -1319,10 +1439,8 @@ export default function App() {
}, [connectionAttempt, isConnectionEnabled, streamUrl, telemetryStore]);
const showConnectionLanding =
- !hasRetainedItems && (isClosedSession || connection !== "connected");
- const isReceiverUnavailable =
- !hasRetainedItems &&
- (isClosedSession || (connection !== "connected" && !isConnecting));
+ isDisconnected || (!hasEstablishedConnection && !hasRetainedItems);
+ const isReceiverUnavailable = showConnectionLanding && !isConnecting;
const canSearch = hasRetainedItems && !isEditingReceiver && !isSettingsOpen;
const commandNavigation: CommandNavigationItem[] = workspaceNavigation.map(
({ icon, id, label }) => ({ icon, id, label }),
@@ -1357,9 +1475,11 @@ export default function App() {
count={telemetry.envelopes.length}
editable={canEditRetention && connection === "connected"}
unavailableReason={
- canEditRetention && connection !== "connected"
- ? "Reconnect to this receiver to change retention."
- : undefined
+ connection === "closed"
+ ? "Retention is read-only for closed sessions."
+ : canEditRetention && connection !== "connected"
+ ? "Reconnect to this receiver to change retention."
+ : undefined
}
/>
- {activeDirectoryUrl ? (
+ {activeDirectoryUrl && !isDisconnected ? (
) : null}
- {hasRetainedItems &&
- (isClosedSession || connection === "failed") ? (
+ {!isDisconnected &&
+ (isClosedSession ||
+ (hasRetainedItems && connection === "failed")) ? (
connectToEndpoint(streamUrl)}
>
- Reconnect
+ {isClosedSession ? "Reload saved events" : "Reconnect"}
) : null}
@@ -1560,18 +1685,11 @@ export default function App() {
connectToEndpoint(streamUrl)
- : undefined
+ connection === "connecting"
+ ? "probing"
+ : connection === "failed"
+ ? "failed"
+ : "editing"
}
/>
) : !hasRetainedItems ? (
@@ -1580,9 +1698,15 @@ export default function App() {
className="mb-3 size-5 text-primary"
aria-hidden="true"
/>
- Waiting for events
+
+ {isClosedSession
+ ? "No saved events displayed"
+ : "Waiting for events"}
+
- Send events to the selected receiver to see them here.
+ {isClosedSession
+ ? "Capture has stopped. Reload saved events or connect to another session."
+ : "Send events to the selected receiver to see them here."}
) : workspaceView === "traces" ? (
diff --git a/apps/local/src/components/connection-landing.tsx b/apps/local/src/components/connection-landing.tsx
index 172ea1d6c..76f217eb9 100644
--- a/apps/local/src/components/connection-landing.tsx
+++ b/apps/local/src/components/connection-landing.tsx
@@ -168,19 +168,13 @@ export function ConnectionForm({
}
export type ConnectionLandingProps = ConnectionFormProps & {
- phase: "probing" | "failed" | "editing" | "closed";
- onReconnectSession?: () => void;
+ phase: "probing" | "failed" | "editing";
};
/** A focused landing when no captured events are available to inspect. */
-export function ConnectionLanding({
- phase,
- onReconnectSession,
- ...form
-}: ConnectionLandingProps) {
+export function ConnectionLanding({ phase, ...form }: ConnectionLandingProps) {
const titleId = useId();
const isProbing = phase === "probing";
- const closed = phase === "closed";
return (
) : null}
- {closed ? (
-
- ) : (
-
- )}
+
- {isProbing
- ? "Connecting to receiver"
- : closed
- ? "Session closed"
- : "Connect a receiver"}
+ {isProbing ? "Connecting to receiver" : "Connect a receiver"}
{isProbing
? "Establishing a connection…"
- : closed
- ? "Events remain available for up to 3 hours."
- : "Inspect events from your local development sessions."}
+ : "Inspect events from your local development sessions."}
- {onReconnectSession ? (
-
- Reconnect to session
-
- ) : null}
diff --git a/apps/local/src/components/receiver-controls.test.tsx b/apps/local/src/components/receiver-controls.test.tsx
index a1832dc17..720d63b3e 100644
--- a/apps/local/src/components/receiver-controls.test.tsx
+++ b/apps/local/src/components/receiver-controls.test.tsx
@@ -122,3 +122,26 @@ test("mobile actions prevent empty clears and duplicate close requests", async (
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: "Receiver options" }));
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Disconnect receiver" }),
+ );
+ expect(disconnect).toHaveBeenCalledOnce();
+ expect(clear).not.toHaveBeenCalled();
+ expect(close).not.toHaveBeenCalled();
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+});
diff --git a/apps/local/src/components/receiver-controls.tsx b/apps/local/src/components/receiver-controls.tsx
index 7efc714c6..69f3f2681 100644
--- a/apps/local/src/components/receiver-controls.tsx
+++ b/apps/local/src/components/receiver-controls.tsx
@@ -4,6 +4,7 @@ import {
Settings,
Trash2,
X,
+ Unplug,
type LucideIcon,
} from "lucide-react";
import { useRef, useState } from "react";
@@ -15,6 +16,8 @@ type ReceiverControlsProps = {
connection: ConnectionPresentation;
retainedItemCount: number;
onClear: () => void;
+ onDisconnect?: () => void;
+ isClearing?: boolean;
onOpenSettings?: (trigger: HTMLButtonElement) => void;
onCloseSession?: () => void;
isClosingSession?: boolean;
@@ -35,6 +38,8 @@ export function ReceiverControls({
connection,
retainedItemCount,
onClear,
+ onDisconnect,
+ isClearing = false,
onOpenSettings,
onCloseSession,
isClosingSession = false,
@@ -73,7 +78,7 @@ export function ReceiverControls({
label: "Clear all events",
Icon: Trash2,
onClick: onClear,
- disabled: retainedItemCount === 0,
+ disabled: retainedItemCount === 0 || isClearing,
destructive: true,
},
...(onCloseSession
@@ -82,11 +87,14 @@ export function ReceiverControls({
label: "Close session",
Icon: X,
onClick: onCloseSession,
- disabled: isClosingSession,
+ disabled: isClosingSession || isClearing,
destructive: true,
},
]
: []),
+ ...(onDisconnect
+ ? [{ label: "Disconnect receiver", Icon: Unplug, onClick: onDisconnect }]
+ : []),
];
const dotClass =
connection.tone === "success"
diff --git a/apps/local/src/lib/connection-history.test.ts b/apps/local/src/lib/connection-history.test.ts
index 0b4f6bb22..3d514d8c0 100644
--- a/apps/local/src/lib/connection-history.test.ts
+++ b/apps/local/src/lib/connection-history.test.ts
@@ -1,6 +1,8 @@
import { afterEach, expect, test } from "vitest";
import {
CONNECTION_HISTORY_KEY,
+ getInitialDisconnected,
+ saveDisconnected,
connectionLabel,
readConnectionHistory,
rememberConnection,
@@ -10,6 +12,7 @@ import {
afterEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
+ window.history.replaceState(null, "", "/");
});
test("recent receivers deduplicate daemon sessions without storing their capabilities", () => {
@@ -52,3 +55,18 @@ test("history rejects malformed storage and unsafe endpoints", () => {
);
expect(readConnectionHistory()).toEqual(["http://localhost:9000/stream"]);
});
+
+test("disconnect survives reload but an explicit receiver link overrides it", () => {
+ saveDisconnected(true);
+ expect(getInitialDisconnected()).toBe(true);
+ window.history.replaceState(
+ null,
+ "",
+ "/?url=" + encodeURIComponent("http://127.0.0.1:8969/stream"),
+ );
+ expect(getInitialDisconnected()).toBe(false);
+ window.history.replaceState(null, "", "/");
+ expect(getInitialDisconnected()).toBe(true);
+ saveDisconnected(false);
+ expect(getInitialDisconnected()).toBe(false);
+});
diff --git a/apps/local/src/lib/connection-history.ts b/apps/local/src/lib/connection-history.ts
index 95abe9464..bd96aacbd 100644
--- a/apps/local/src/lib/connection-history.ts
+++ b/apps/local/src/lib/connection-history.ts
@@ -61,3 +61,25 @@ export function saveConnectionHistory(history: string[]): void {
// The current tab's in-memory history still works when storage is unavailable.
}
}
+
+const DISCONNECTED_KEY = "sentry.local.disconnected";
+/** An explicit link or user Connect overrides a tab's disconnected landing. */
+export function getInitialDisconnected(): boolean {
+ const fragment = new URLSearchParams(window.location.hash.slice(1));
+ const query = new URLSearchParams(window.location.search);
+ if (parseStreamEndpoint(fragment.get("stream") ?? query.get("url") ?? ""))
+ return false;
+ try {
+ return window.sessionStorage.getItem(DISCONNECTED_KEY) === "1";
+ } catch {
+ return false;
+ }
+}
+export function saveDisconnected(disconnected: boolean): void {
+ try {
+ if (disconnected) window.sessionStorage.setItem(DISCONNECTED_KEY, "1");
+ else window.sessionStorage.removeItem(DISCONNECTED_KEY);
+ } catch {
+ /* The current tab still works when storage is disabled. */
+ }
+}
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 495d23050..db6c06408 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -119,11 +119,20 @@ saving session capabilities in the list. Remote URLs stay in tab session storage
query credentials are omitted from their displayed labels. No history entry is
created for a failed connection.
-Closing a session stops capture and keeps already displayed events available
-for inspection and search. A compact status row offers **Reconnect** to replay retained
-data. When no events are displayed, the landing explains that capture stopped and
-offers **Reconnect to session**, another endpoint, and past connections. Closing
-is guarded against duplicate requests and times out if the receiver does not respond.
+Closing a session stops capture immediately: later ingestion receives HTTP 410,
+including requests whose bodies finish after Close. Saved events remain readable
+for three hours from the first Close; retries and attempted ingestion do not extend
+that deadline. **Reload saved events** replays the archive without resuming capture.
+The viewer keeps the workspace visible even when the archive is empty. Close is
+guarded against duplicate requests and times out if the receiver does not respond.
+
+**Disconnect receiver** returns to the connection screen without closing capture
+or deleting receiver data. This tab stays disconnected after a reload; use Connect,
+past connections, or an explicit connection link to resume. **Clear events** resets
+local receiver data when permitted and resumes only an open session's feed. Closed
+sessions stay closed and empty after a reset. Other connected viewers receive reset
+and close notifications. The hosted viewer clears only its displayed data; reloading
+saved events restores the daemon's archive.
### Switching sessions
@@ -176,7 +185,7 @@ change the limit at runtime with validation; increases preserve captured data,
and lowering it below the retained count is rejected until enough data is cleared.
Retention is read-only in the hosted viewer; use the CLI to change it there.
The sidebar keeps Add receiver on its own row, with Settings at the left and
-Clear/Close icons at the right beneath it. Icon tooltips also work with keyboard focus.
+Clear, Close, and Disconnect icons at the right beneath it. Icon tooltips also work with keyboard focus.
A loopback-served viewer can reset and close its scoped session. The hosted viewer
can read the stream and clear its own displayed feed; it cannot mutate retained
@@ -194,7 +203,7 @@ from earlier connections, including when returning to the same receiver URL.
Search and view navigation remain available for retained telemetry after a
receiver disconnects or a session closes, including raw-only envelopes. A trace
search with no matches explains how to change or clear the filter. Closing an
-empty session returns to the receiver picker. Waterfall failure styling uses
+empty session keeps the workspace visible; Disconnect returns to the receiver picker. Waterfall failure styling uses
each span's own level, HTTP status, and SDK span status; a failed transaction does not mark healthy child
spans as failures. The AI workspace matches AI namespaces (`ai`, `gen_ai`,
`llm`), including dotted origins, rather than substrings of ordinary operations.
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index c9fefd2c4..bd475d9a7 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -22,7 +22,11 @@ import {
type LocalSessionStore,
} from "./session-store.js";
-import { LOCAL_DIRECTORY_EVENT } from "./viewer-protocol.js";
+import {
+ LOCAL_DIRECTORY_EVENT,
+ LOCAL_SESSION_EVENT,
+ type LocalSessionEvent,
+} from "./viewer-protocol.js";
import { LOCAL_RETENTION_EVENT } from "./retention-limits.js";
import {
RetentionLimitError,
@@ -379,6 +383,12 @@ export function createLocalControlPlane({
const browserBeacon =
c.req.query("sentry_client")?.startsWith("sentry.javascript.browser") &&
c.req.header("origin");
+ // Close can occur while the body is being read. Check at the commit boundary.
+ if (sessionStore.resolve(reference).state === "retained")
+ return c.json(
+ { error: "Session is closed; create a new session to capture events." },
+ 410,
+ );
pushToSpotlightBuffer({
spotlightBuffer: sessionStore.getBuffer(reference),
body,
@@ -404,18 +414,32 @@ export function createLocalControlPlane({
return c.body(null, 401);
}
return streamSSE(c, async (stream) => {
- const retention = sessionStore.getRetention(c.req.param("id"));
+ const reference = c.req.param("id");
+ const retention = sessionStore.getRetention(reference);
+ // Serialize metadata and envelopes so a close notification is the final
+ // message, after every envelope accepted before capture stopped.
+ let pending = Promise.resolve();
+ const writer = {
+ writeSSE(message: { id?: string; event?: string; data: string }) {
+ const write = pending.then(() => stream.writeSSE(message));
+ pending = write.catch((error) =>
+ logger.debug("Local session stream closed", error),
+ );
+ return pending;
+ },
+ };
+ const sendSession = (event: LocalSessionEvent) =>
+ writer.writeSSE({
+ event: LOCAL_SESSION_EVENT,
+ data: JSON.stringify(event),
+ });
const sendSettings = (
settings: ReturnType,
) => {
- void stream
- .writeSSE({
- event: LOCAL_RETENTION_EVENT,
- data: JSON.stringify(settings),
- })
- .catch((error) =>
- logger.debug("Local retention stream closed", error),
- );
+ void writer.writeSSE({
+ event: LOCAL_RETENTION_EVENT,
+ data: JSON.stringify(settings),
+ });
};
const unsubscribeSettings =
c.req.query("retention") === "1"
@@ -424,16 +448,38 @@ export function createLocalControlPlane({
if (c.req.query("retention") === "1")
sendSettings(retention.getSettings());
const readerId = buffer.subscribe(
- buildSSEHandler(stream, c.req.query("base64") !== undefined),
+ buildSSEHandler(writer, c.req.query("base64") !== undefined),
c.req.header("Last-Event-ID"),
);
- await new Promise((resolve) => {
- stream.onAbort(() => {
+ let finish: () => void = () => {};
+ const finished = new Promise((resolve) => {
+ finish = resolve;
+ });
+ const unsubscribeSession = sessionStore.subscribe(reference, (event) => {
+ if (event.state === "retained" && event.action !== "reset") {
+ buffer.stream(readerId);
buffer.unsubscribe(readerId);
- unsubscribeSettings();
- resolve();
- });
+ void sendSession(event).finally(finish);
+ } else {
+ void sendSession(event);
+ }
});
+ stream.onAbort(finish);
+ try {
+ const state = sessionStore.resolve(reference).state;
+ if (state === "retained") {
+ buffer.stream(readerId);
+ await sendSession({ state });
+ } else {
+ await sendSession({ state });
+ await finished;
+ }
+ await pending;
+ } finally {
+ buffer.unsubscribe(readerId);
+ unsubscribeSettings();
+ unsubscribeSession();
+ }
});
});
@@ -468,7 +514,7 @@ export function createLocalControlPlane({
if (!sessionExists(c.req.param("id"))) {
return c.body(null, 404);
}
- sessionStore.getBuffer(c.req.param("id")).clear();
+ sessionStore.reset(c.req.param("id"));
return c.body(null, 204);
});
@@ -494,7 +540,7 @@ export function createLocalControlPlane({
}
if (c.req.param("action") === "retention") return updateRetention(c);
if (c.req.param("action") === "reset") {
- sessionStore.getBuffer(c.req.param("id")).clear();
+ sessionStore.reset(c.req.param("id"));
return c.body(null, 204);
}
return c.json(sessionStore.close(c.req.param("id")));
diff --git a/packages/cli/src/commands/local/session-store.ts b/packages/cli/src/commands/local/session-store.ts
index 9d27fabf8..07e4d7877 100644
--- a/packages/cli/src/commands/local/session-store.ts
+++ b/packages/cli/src/commands/local/session-store.ts
@@ -13,6 +13,7 @@ import {
} from "./retention-buffer.js";
import { DEFAULT_MAX_ENVELOPES } from "./retention-limits.js";
import { uuidv7 } from "uuidv7";
+import type { LocalSessionEvent } from "./viewer-protocol.js";
/** Retained sessions remain queryable for three hours after closing. */
export const SESSION_RETENTION_MS = 3 * 60 * 60 * 1000;
@@ -64,6 +65,11 @@ export type LocalSessionStore = {
setMaxEnvelopes: (reference: string, value: number) => LocalSession;
getRetention: (reference: string) => LocalRetentionBuffer;
close: (reference: string) => LocalSession;
+ reset: (reference: string) => void;
+ subscribe: (
+ reference: string,
+ listener: (event: LocalSessionEvent) => void,
+ ) => () => void;
getCapabilities: (reference: string) => {
ingest: string;
stream: string;
@@ -94,6 +100,10 @@ export function createLocalSessionStore({
} = {}): LocalSessionStore {
const sessions = new Map();
const clientSessions = new Map();
+ const listeners = new Map void>>();
+ const notify = (id: string, event: LocalSessionEvent) => {
+ for (const listener of listeners.get(id) ?? []) listener(event);
+ };
const resolveStored = (reference: string): StoredSession => {
const byId = sessions.get(reference);
@@ -165,15 +175,32 @@ export function createLocalSessionStore({
return toPublicSession(update(session, { maxEnvelopes: value }));
},
close(reference) {
+ const session = resolveStored(reference);
+ if (session.state === "retained") return toPublicSession(session);
const timestamp = now();
+ const closed = update(session, {
+ state: "retained",
+ lastActivityAt: timestamp,
+ expiresAt: timestamp + SESSION_RETENTION_MS,
+ });
+ notify(session.id, { state: "retained" });
+ return toPublicSession(closed);
+ },
+ reset(reference) {
const session = resolveStored(reference);
- return toPublicSession(
- update(session, {
- state: "retained",
- lastActivityAt: timestamp,
- expiresAt: timestamp + SESSION_RETENTION_MS,
- }),
- );
+ session.buffer.clear();
+ notify(session.id, { state: session.state, action: "reset" });
+ },
+ subscribe(reference, listener) {
+ const session = resolveStored(reference);
+ let subscriptions = listeners.get(session.id);
+ if (!subscriptions)
+ listeners.set(session.id, (subscriptions = new Set()));
+ subscriptions.add(listener);
+ return () => {
+ subscriptions.delete(listener);
+ if (subscriptions.size === 0) listeners.delete(session.id);
+ };
},
getBuffer(reference) {
return resolveStored(reference).buffer;
@@ -206,21 +233,14 @@ export function createLocalSessionStore({
clientSessions.delete(session.clientId);
}
sessions.delete(id);
+ listeners.delete(id);
}
return expired;
},
recordActivity(reference) {
- const timestamp = now();
const session = resolveStored(reference);
- return toPublicSession(
- update(session, {
- lastActivityAt: timestamp,
- expiresAt:
- session.state === "retained"
- ? timestamp + SESSION_RETENTION_MS
- : undefined,
- }),
- );
+ if (session.state === "retained") return toPublicSession(session);
+ return toPublicSession(update(session, { lastActivityAt: now() }));
},
resolve(reference) {
return toPublicSession(resolveStored(reference));
diff --git a/packages/cli/src/commands/local/viewer-protocol.ts b/packages/cli/src/commands/local/viewer-protocol.ts
index d1b475d8d..e2057e968 100644
--- a/packages/cli/src/commands/local/viewer-protocol.ts
+++ b/packages/cli/src/commands/local/viewer-protocol.ts
@@ -1,2 +1,9 @@
/** Optional SSE metadata used to bootstrap the Local session picker. */
export const LOCAL_DIRECTORY_EVENT = "sentry.local.directory";
+
+/** Ordered lifecycle metadata follows replayed envelopes on a scoped stream. */
+export const LOCAL_SESSION_EVENT = "sentry.local.session";
+export type LocalSessionEvent = {
+ state: "open" | "retained";
+ action?: "reset";
+};
diff --git a/packages/cli/test/commands/local/session-lifecycle.test.ts b/packages/cli/test/commands/local/session-lifecycle.test.ts
new file mode 100644
index 000000000..c360e8e16
--- /dev/null
+++ b/packages/cli/test/commands/local/session-lifecycle.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, test } from "vitest";
+import { createLocalControlPlane } from "../../../src/commands/local/control-plane.js";
+import {
+ createLocalSessionStore,
+ SESSION_RETENTION_MS,
+} from "../../../src/commands/local/session-store.js";
+
+const headers = { Authorization: "Bearer lifecycle-test" };
+const envelope = (message: string) =>
+ `${JSON.stringify({ event_id: crypto.randomUUID() })}\n{"type":"event"}\n${JSON.stringify({ message })}\n`;
+async function fixture() {
+ let now = 1000;
+ const store = createLocalSessionStore({ now: () => now });
+ const plane = createLocalControlPlane({
+ controlToken: "lifecycle-test",
+ sessionStore: store,
+ });
+ const response = await plane.app.request("/_local/v1/sessions", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ label: "web", cwd: "/web" }),
+ });
+ const session = (await response.json()) as {
+ id: string;
+ ingestUrl: string;
+ streamUrl: string;
+ };
+ const ingest = (message: string) =>
+ plane.app.request(session.ingestUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-sentry-envelope" },
+ body: envelope(message),
+ });
+ const action = (name: string) =>
+ plane.app.request(`/_local/v1/sessions/${session.id}/${name}`, {
+ method: "POST",
+ headers,
+ });
+ return {
+ ...plane,
+ store,
+ session,
+ ingest,
+ action,
+ advance: (ms: number) => {
+ now += ms;
+ },
+ };
+}
+
+describe("Local session lifecycle", () => {
+ test("close freezes ingestion and expiry while preserving saved envelopes", async () => {
+ const f = await fixture();
+ expect((await f.ingest("saved")).status).toBe(204);
+ const closed = await (await f.action("close")).json();
+ f.advance(1000);
+ expect((await f.ingest("late")).status).toBe(410);
+ expect(await (await f.action("close")).json()).toEqual(closed);
+ expect(
+ f.store.getRetention(f.session.id).getSettings().retainedEnvelopes,
+ ).toBe(1);
+ f.store.recordActivity(f.session.id);
+ f.advance(SESSION_RETENTION_MS);
+ expect(f.store.pruneExpired()).toEqual([f.session.id]);
+ });
+
+ test("rejects an ingestion body that finishes after close", async () => {
+ const f = await fixture();
+ let controller: ReadableStreamDefaultController | undefined;
+ const pending = f.app.request(f.session.ingestUrl, {
+ method: "POST",
+ body: new ReadableStream({
+ start(value) {
+ controller = value;
+ },
+ }),
+ duplex: "half",
+ } as RequestInit & { duplex: "half" });
+ await f.action("close");
+ controller!.enqueue(new TextEncoder().encode(envelope("late body")));
+ controller!.close();
+ expect((await pending).status).toBe(410);
+ expect(
+ f.store.getRetention(f.session.id).getSettings().retainedEnvelopes,
+ ).toBe(0);
+ });
+
+ test("notifies every reader about reset and close, then replays a finite archive", async () => {
+ const f = await fixture();
+ await f.ingest("before-reset");
+ const first = await f.app.request(f.session.streamUrl);
+ const second = await f.app.request(f.session.streamUrl);
+ const readings = [first.text(), second.text()];
+ await f.action("reset");
+ await f.ingest("after-reset");
+ await f.action("close");
+ for (const text of await Promise.all(readings)) {
+ expect(text).toContain('"action":"reset"');
+ expect(text).toContain("after-reset");
+ expect(text).toContain('"state":"retained"');
+ expect(text.lastIndexOf('"state":"retained"')).toBeGreaterThan(
+ text.indexOf("after-reset"),
+ );
+ }
+ const replay = await (await f.app.request(f.session.streamUrl)).text();
+ expect(replay).toContain("after-reset");
+ expect(replay).not.toContain("before-reset");
+ expect(replay).toContain('"state":"retained"');
+ await f.action("reset");
+ const empty = await (await f.app.request(f.session.streamUrl)).text();
+ expect(empty).not.toContain("after-reset");
+ expect(empty).toContain('"state":"retained"');
+ });
+});
diff --git a/packages/cli/test/commands/local/session-store.test.ts b/packages/cli/test/commands/local/session-store.test.ts
index 91f51239a..895745c15 100644
--- a/packages/cli/test/commands/local/session-store.test.ts
+++ b/packages/cli/test/commands/local/session-store.test.ts
@@ -128,7 +128,7 @@ describe("LocalSessionStore", () => {
expect(() => store.resolve(session.id)).toThrow(/not found/i);
});
- test("refreshes the retention deadline when a retained session receives activity", () => {
+ test("does not extend the expiry of a closed session after activity", () => {
let now = 1000;
const store = createLocalSessionStore({ now: () => now });
const session = store.create({ label: "web", cwd: "/work/web" });
@@ -138,7 +138,7 @@ describe("LocalSessionStore", () => {
store.recordActivity(session.id);
now += 2 * 60 * 60 * 1000;
- expect(store.pruneExpired()).toEqual([]);
- expect(store.resolve(session.id).state).toBe("retained");
+ expect(store.pruneExpired()).toEqual([session.id]);
+ expect(() => store.resolve(session.id)).toThrow(/not found/i);
});
});
diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts
index 716a9250d..a1fc7ad87 100644
--- a/packages/cli/test/e2e/local.test.ts
+++ b/packages/cli/test/e2e/local.test.ts
@@ -264,6 +264,42 @@ describe("Local daemon E2E", () => {
).toHaveLength(1);
}, 30_000);
+ test("close freezes capture and reconnect replays a finite archive until reset", async () => {
+ const session = await createSession("archive", await availablePort());
+ await ingest(session, "saved-id", "saved-before-close");
+ expect((await cli("session", "close", session.id)).exitCode).toBe(0);
+ const rejected = await fetch(session.ingestUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-sentry-envelope" },
+ body: '{}\n{"type":"event"}\n{"message":"must-not-arrive"}',
+ });
+ expect(rejected.status).toBe(410);
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const archive = await fetch(session.streamUrl, {
+ signal: AbortSignal.timeout(5000),
+ });
+ const text = await archive.text();
+ expect(text).toContain("saved-before-close");
+ expect(text).toContain("event: sentry.local.session");
+ expect(text).toContain('"state":"retained"');
+ expect(text).not.toContain("must-not-arrive");
+ }
+ const reset = new URL(session.streamUrl);
+ reset.pathname = reset.pathname.replace(/\/stream$/, "/ui/reset");
+ expect((await fetch(reset, { method: "POST" })).status).toBe(204);
+ const empty = await (
+ await fetch(session.streamUrl, { signal: AbortSignal.timeout(5000) })
+ ).text();
+ expect(empty).toContain('"state":"retained"');
+ expect(empty).not.toContain("saved-before-close");
+ const directory = (await (await fetch(session.directoryUrl)).json()) as {
+ sessions: { id: string; state: string }[];
+ };
+ expect(
+ directory.sessions.find((entry) => entry.id === session.id)?.state,
+ ).toBe("retained");
+ }, 30_000);
+
test.each(
["stop", "restart"].flatMap((command) =>
["delayed", "rejected", "never", "hung"].map((mode) => ({
@@ -439,8 +475,13 @@ describe("Local daemon E2E", () => {
});
const reader = stream.body?.getReader();
expect(reader).toBeDefined();
- const first = await reader?.read();
- expect(new TextDecoder().decode(first?.value)).toContain("web event");
+ let replay = "";
+ while (!replay.includes("web event")) {
+ const chunk = await reader!.read();
+ if (chunk.done) throw new Error("Stream ended before retained event");
+ replay += new TextDecoder().decode(chunk.value);
+ }
+ expect(replay).toContain("web event");
await reader?.cancel();
const resetUrl = new URL(web.streamUrl);
resetUrl.pathname = resetUrl.pathname.replace(/\/stream$/, "/ui/reset");
From 953125166cd2018365f78f41ef7d9762ce22df9b Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 19:20:35 -0700
Subject: [PATCH 17/30] feat(local): Allow validated runtime retention from
hosted viewers
Use the existing React Hook Form and Valibot form on trusted hosted Local origins. Permit only retention updates, preserve session capabilities and origin checks, and reject values that would discard retained envelopes.
Co-Authored-By: GPT-6 Codex
---
.../src/App.sessions.integration.test.tsx | 80 ++++++++++++++--
apps/local/src/App.tsx | 4 +-
.../components/retention-settings.test.tsx | 60 +++++++++++-
.../src/components/retention-settings.tsx | 4 +-
docs/cli/local-control-plane.md | 17 ++--
.../cli/src/commands/local/control-plane.ts | 38 +++++---
packages/cli/src/commands/local/server.ts | 65 +++++++------
.../cli/src/commands/local/viewer-protocol.ts | 22 +++++
.../test/commands/local/control-plane.test.ts | 95 ++++++++++++++++++-
.../cli/test/commands/local/server.test.ts | 81 +++++++++++++++-
packages/cli/test/e2e/local.test.ts | 2 +-
11 files changed, 402 insertions(+), 66 deletions(-)
diff --git a/apps/local/src/App.sessions.integration.test.tsx b/apps/local/src/App.sessions.integration.test.tsx
index 2abe52133..a6dc10c34 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -16,7 +16,11 @@ 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 { tryListen } from "../../../packages/cli/src/commands/local/server.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,
@@ -166,8 +170,26 @@ test("hosted viewer switches live sessions without mixing events and replays ret
fireEvent.click(
screen.getByRole("button", { name: "Settings", exact: true }),
);
- await screen.findByText(/1,000 envelopes/);
- expect(screen.queryByLabelText("Retention limit")).toBeNull();
+ const limit = (await screen.findByLabelText(
+ "Retention limit",
+ )) as HTMLInputElement;
+ expect(limit.value).toBe("1000");
+ fireEvent.change(limit, { target: { value: "2000" } });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention 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: "Close telemetry session" }),
+ ).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
await daemon.ingest(web, "/web-after-switch");
await daemon.ingest(worker, "/worker-after-switch");
@@ -337,11 +359,13 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
screen.getByRole("button", { name: "Settings", exact: true }),
);
await screen.findByRole("dialog", { name: "Settings", exact: true });
- await screen.findByText(/500 envelopes/);
- expect(screen.queryByLabelText("Retention limit")).toBeNull();
- await screen.findByText(
- "Change retention from a locally served viewer or the CLI.",
- );
+ expect(
+ ((await screen.findByLabelText("Retention limit")) as HTMLInputElement)
+ .value,
+ ).toBe("500");
+ expect(
+ screen.getByRole("button", { name: "Apply retention limit" }),
+ ).not.toBeNull();
} finally {
cleanup();
await daemon.stop();
@@ -615,3 +639,43 @@ test("close, archive replay, clear and disconnect keep the viewer lifecycle stab
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(
+ "Retention limit",
+ )) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: "1000" } });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention 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 09897307d..96649d8fc 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -19,6 +19,7 @@ import {
} 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";
@@ -1120,7 +1121,8 @@ export default function App() {
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) &&
diff --git a/apps/local/src/components/retention-settings.test.tsx b/apps/local/src/components/retention-settings.test.tsx
index da757754a..23b857a2e 100644
--- a/apps/local/src/components/retention-settings.test.tsx
+++ b/apps/local/src/components/retention-settings.test.tsx
@@ -13,7 +13,7 @@ afterEach(() => {
vi.unstubAllGlobals();
});
-test.each(["0", "1.5", "50001", ""])(
+test.each(["-1", "0", "1.5", "50001", "9007199254740992", ""])(
"rejects invalid retention %s before sending mutations",
async (value) => {
const fetch = vi.fn();
@@ -135,3 +135,61 @@ test.each([1, 1000])(
expect(screen.queryByRole("spinbutton")).toBeNull();
},
);
+
+test.each([1, 50000])(
+ "submits valid boundary %i as a number",
+ async (value) => {
+ const fetch = vi
+ .fn()
+ .mockResolvedValue(
+ new Response(JSON.stringify({ maxEnvelopes: value }), { status: 200 }),
+ );
+ vi.stubGlobal("fetch", fetch);
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("Retention limit"), {
+ target: { value: String(value) },
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention limit" }),
+ );
+ await waitFor(() =>
+ expect(fetch).toHaveBeenCalledWith(
+ expect.any(URL),
+ expect.objectContaining({
+ body: JSON.stringify({ maxEnvelopes: value }),
+ }),
+ ),
+ );
+ expect(screen.queryByRole("alert")).toBeNull();
+ },
+);
+
+test("rejects lowering capacity below saved data before sending a request", async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByLabelText("Retention limit"), {
+ target: { value: "7" },
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Apply retention limit" }),
+ );
+ expect((await screen.findByRole("alert")).textContent).toContain(
+ "Cannot lower the limit below 8",
+ );
+ expect(fetch).not.toHaveBeenCalled();
+});
diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx
index 9d9d4e0a5..8a93ed54f 100644
--- a/apps/local/src/components/retention-settings.tsx
+++ b/apps/local/src/components/retention-settings.tsx
@@ -116,8 +116,8 @@ export function RetentionSettings({
id="retention-help"
className="text-xs leading-relaxed text-muted-foreground"
>
- Maximum envelopes to keep. To set a limit below the retained
- count, clear events first.
+ Keep 1–{MAX_ENVELOPES.toLocaleString()} envelopes. Existing data
+ is preserved.
{error ? (
(LOOPBACK_ORIGIN_RE.test(origin) ? origin : null),
- }),
- );
+ const sessionUiCors = cors({
+ allowMethods: ["POST", "OPTIONS"],
+ allowHeaders: ["Content-Type"],
+ origin: (origin) =>
+ LOOPBACK_ORIGIN_RE.test(origin) || isHostedUiOrigin(origin)
+ ? origin
+ : null,
+ });
+ app.use(`${CONTROL_PLANE_PREFIX}/sessions/:id/ui/*`, async (c, next) => {
+ const hostedRetention =
+ c.req.path.endsWith("/ui/retention") &&
+ isHostedUiOrigin(c.req.header("origin"));
+ if (!isLoopbackBrowserRequest(c.req.raw) && !hostedRetention)
+ return c.body(null, 403);
+ if (hostedRetention && c.req.method === "OPTIONS") {
+ if (c.req.header("access-control-request-method") !== "POST")
+ return c.body(null, 403);
+ if (c.req.header("access-control-request-private-network") === "true") {
+ c.header("Access-Control-Allow-Private-Network", "true");
+ c.header("Vary", "Access-Control-Request-Private-Network");
+ }
+ }
+ return sessionUiCors(c, next);
+ });
// Choosing the daemon-wide entry point opts into discovering its sessions.
// Browser origins remain restricted; scoped streams never emit this metadata.
@@ -529,9 +546,6 @@ export function createLocalControlPlane({
if (!(isSessionUiAction(c.req.path) && sessionExists(c.req.param("id")))) {
return c.body(null, 404);
}
- if (!isLoopbackBrowserRequest(c.req.raw)) {
- return c.body(null, 403);
- }
if (
c.req.query("cap") !==
sessionStore.getCapabilities(c.req.param("id")).stream
diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts
index d30d052fa..89646c1cc 100644
--- a/packages/cli/src/commands/local/server.ts
+++ b/packages/cli/src/commands/local/server.ts
@@ -1,3 +1,5 @@
+import { isHostedUiOrigin } from "./viewer-protocol.js";
+export { isHostedUiOrigin } from "./viewer-protocol.js";
import { isLoopbackHost } from "./loopback.js";
/**
* sentry local serve
@@ -139,8 +141,6 @@ export function parsePort(value: string): number {
/** Match localhost origins on any port (http or https), including IPv6. */
const LOCALHOST_ORIGIN_RE =
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
-const LOCAL_UI_ORIGIN = "https://local.sentry.dev";
-const LOCAL_UI_PREVIEW_HOST_RE = /^sentry-local-git-[a-z0-9-]+\.sentry\.dev$/;
export { isLoopbackHost } from "./loopback.js";
@@ -150,25 +150,6 @@ type LocalReceiverOptions = {
retention?: LocalRetentionBuffer;
};
-/** Whether an origin is the production or preview Local viewer. */
-export function isHostedUiOrigin(origin: string | undefined): origin is string {
- if (!origin) {
- return false;
- }
-
- if (!URL.canParse(origin)) {
- return false;
- }
-
- const url = new URL(origin);
- return (
- origin === url.origin &&
- url.protocol === "https:" &&
- (url.origin === LOCAL_UI_ORIGIN ||
- LOCAL_UI_PREVIEW_HOST_RE.test(url.hostname))
- );
-}
-
function isHostedUiStreamRequest(request: {
method: string;
path: string;
@@ -255,6 +236,12 @@ export function buildApp(
): Hono {
const app = new Hono();
+ const hostedRetentionCors = cors({
+ origin: (origin) => (isHostedUiOrigin(origin) ? origin : null),
+ allowMethods: ["POST", "OPTIONS"],
+ allowHeaders: ["Content-Type"],
+ });
+
app.use("*", async (c, next) => {
c.header("X-Powered-By", SERVER_IDENTIFIER);
await next();
@@ -281,13 +268,21 @@ export function buildApp(
const hostedUiOrigin = isHostedUiOrigin(requestOrigin);
const hostedStreamRequest = isHostedUiStreamRequest(c.req);
- // The hosted UI may only read the event stream. CORS alone cannot stop a
- // simple cross-origin POST, so reject any other hosted-origin request.
- if (hostedUiOrigin && !hostedStreamRequest) {
+ const hostedRetentionRequest =
+ hostedUiOrigin &&
+ uiActions &&
+ retention &&
+ c.req.path === "/retention" &&
+ (c.req.method === "POST" ||
+ (c.req.method === "OPTIONS" &&
+ c.req.header("access-control-request-method") === "POST"));
+ // CORS alone cannot stop simple POSTs. Hosted viewers may only read the
+ // stream or change its validated retention limit; other controls stay local.
+ if (hostedUiOrigin && !hostedStreamRequest && !hostedRetentionRequest) {
return c.body(null, 403);
}
- if (!hostedStreamRequest) {
+ if (!hostedStreamRequest && !hostedRetentionRequest) {
return localhostCors(c, next);
}
@@ -295,8 +290,14 @@ export function buildApp(
const isPrivateNetworkRequest =
c.req.header("access-control-request-private-network") === "true";
c.header("Access-Control-Allow-Origin", requestOrigin);
- c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
- c.header("Access-Control-Allow-Headers", "Last-Event-ID");
+ c.header(
+ "Access-Control-Allow-Methods",
+ hostedRetentionRequest ? "POST, OPTIONS" : "GET, OPTIONS",
+ );
+ c.header(
+ "Access-Control-Allow-Headers",
+ hostedRetentionRequest ? "Content-Type" : "Last-Event-ID",
+ );
if (isPrivateNetworkRequest) {
c.header("Access-Control-Allow-Private-Network", "true");
}
@@ -313,14 +314,16 @@ export function buildApp(
return c.body(null, 204);
}
- return await hostedStreamCors(c, next);
+ return hostedRetentionRequest
+ ? hostedRetentionCors(c, next)
+ : hostedStreamCors(c, next);
});
app.get("/health", (c) => c.text("OK"));
// These endpoints deliberately describe and manage only the in-memory
- // receiver session. They remain unavailable to the hosted UI origin; the
- // CORS guard above limits them to a loopback-served Local UI.
+ // receiver session. Only validated retention updates are available to the
+ // trusted hosted viewer; other controls require a loopback-served Local UI.
app.get("/capabilities", (c) =>
uiActions
? c.json({
@@ -336,7 +339,7 @@ export function buildApp(
if (
!uiActions ||
!retention ||
- (origin && !LOCALHOST_ORIGIN_RE.test(origin))
+ (origin && !LOCALHOST_ORIGIN_RE.test(origin) && !isHostedUiOrigin(origin))
)
return c.body(null, 403);
const input: unknown = await c.req.json().catch(() => undefined);
diff --git a/packages/cli/src/commands/local/viewer-protocol.ts b/packages/cli/src/commands/local/viewer-protocol.ts
index e2057e968..53b9e7b8b 100644
--- a/packages/cli/src/commands/local/viewer-protocol.ts
+++ b/packages/cli/src/commands/local/viewer-protocol.ts
@@ -7,3 +7,25 @@ export type LocalSessionEvent = {
state: "open" | "retained";
action?: "reset";
};
+
+const LOCAL_UI_ORIGIN = "https://local.sentry.dev";
+const LOCAL_UI_PREVIEW_HOST_RE = /^sentry-local-git-[a-z0-9-]+\.sentry\.dev$/;
+
+/** Whether an origin is the production or preview Local viewer. */
+export function isHostedUiOrigin(origin: string | undefined): origin is string {
+ if (!origin) {
+ return false;
+ }
+
+ if (!URL.canParse(origin)) {
+ return false;
+ }
+
+ const url = new URL(origin);
+ return (
+ origin === url.origin &&
+ url.protocol === "https:" &&
+ (url.origin === LOCAL_UI_ORIGIN ||
+ LOCAL_UI_PREVIEW_HOST_RE.test(url.hostname))
+ );
+}
diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts
index ef3aa85c2..0dc82ffb0 100644
--- a/packages/cli/test/commands/local/control-plane.test.ts
+++ b/packages/cli/test/commands/local/control-plane.test.ts
@@ -225,7 +225,7 @@ describe("Local control plane", () => {
expect(sessionStore.list()).toHaveLength(1);
});
- test("validates retention settings and keeps scoped mutation loopback-only", async () => {
+ test("validates retention settings and rejects untrusted scoped mutations", async () => {
const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
const created = await plane.app.request("/_local/v1/sessions", {
method: "POST",
@@ -235,7 +235,7 @@ describe("Local control plane", () => {
const session = await created.json();
expect(session.maxEnvelopes).toBe(1000);
const path = `/_local/v1/sessions/${session.id}/ui/retention?cap=${plane.sessionStore.getCapabilities(session.id).stream}`;
- for (const origin of ["https://local.sentry.dev", "https://example.com"]) {
+ for (const origin of ["https://example.com"]) {
const result = await plane.app.request(path, {
method: "POST",
headers: { Origin: origin },
@@ -258,6 +258,95 @@ describe("Local control plane", () => {
expect(plane.sessionStore.resolve(session.id).maxEnvelopes).toBe(2000);
});
+ test("trusted hosted viewers can update only scoped retention with a valid capability", async () => {
+ const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
+ const session = plane.sessionStore.create({ label: "web", cwd: "/web" });
+ const cap = plane.sessionStore.getCapabilities(session.id).stream;
+ const base = `/_local/v1/sessions/${session.id}/ui`;
+ for (const origin of [
+ "https://local.sentry.dev",
+ "https://sentry-local-git-codex-local-session-switcher.sentry.dev",
+ ]) {
+ const path = `${base}/retention?cap=${cap}`;
+ const preflight = await plane.app.request(path, {
+ method: "OPTIONS",
+ headers: {
+ Origin: origin,
+ "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Headers": "content-type",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe(origin);
+ expect(
+ preflight.headers.get("Access-Control-Allow-Private-Network"),
+ ).toBe("true");
+ const update = await plane.app.request(path, {
+ method: "POST",
+ headers: { Origin: origin, "Content-Type": "application/json" },
+ body: '{"maxEnvelopes":50000}',
+ });
+ expect(update.status).toBe(200);
+ expect(update.headers.get("Access-Control-Allow-Origin")).toBe(origin);
+ expect(await update.json()).toMatchObject({ maxEnvelopes: 50000 });
+ expect(
+ (
+ await plane.app.request(`${base}/retention?cap=wrong`, {
+ method: "POST",
+ headers: { Origin: origin },
+ body: '{"maxEnvelopes":1000}',
+ })
+ ).status,
+ ).toBe(401);
+ for (const action of ["reset", "close"]) {
+ expect(
+ (
+ await plane.app.request(`${base}/${action}?cap=${cap}`, {
+ method: "POST",
+ headers: { Origin: origin },
+ })
+ ).status,
+ ).toBe(403);
+ expect(
+ (
+ await plane.app.request(`${base}/${action}?cap=${cap}`, {
+ method: "OPTIONS",
+ headers: {
+ Origin: origin,
+ "Access-Control-Request-Method": "POST",
+ },
+ })
+ ).status,
+ ).toBe(403);
+ }
+ }
+ for (const origin of [
+ "https://evil.example",
+ "https://local.sentry.dev.evil.example",
+ "http://local.sentry.dev",
+ ]) {
+ expect(
+ (
+ await plane.app.request(`${base}/retention?cap=${cap}`, {
+ method: "POST",
+ headers: { Origin: origin },
+ body: '{"maxEnvelopes":1000}',
+ })
+ ).status,
+ ).toBe(403);
+ }
+ for (const value of [-1, 0, 1.5, 50001, "1000", null]) {
+ const result = await plane.app.request(`${base}/retention?cap=${cap}`, {
+ method: "POST",
+ headers: { Origin: "https://local.sentry.dev" },
+ body: JSON.stringify({ maxEnvelopes: value }),
+ });
+ expect(result.status).toBe(400);
+ }
+ expect(plane.sessionStore.resolve(session.id).maxEnvelopes).toBe(50000);
+ });
+
test("reports ambiguous session names with ID guidance across control routes", async () => {
const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
const one = plane.sessionStore.create({ label: "My App", cwd: "/one" });
@@ -390,7 +479,7 @@ describe("Local control plane", () => {
expect((await plane.app.request(`${path}/${caps.ingest}`)).status).toBe(
401,
);
- for (const origin of ["https://local.sentry.dev", "https://example.com"]) {
+ for (const origin of ["https://example.com"]) {
expect(
(
await plane.app.request(`${path}/${caps.ingest}`, {
diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts
index f2f1a4d88..8f711cb72 100644
--- a/packages/cli/test/commands/local/server.test.ts
+++ b/packages/cli/test/commands/local/server.test.ts
@@ -420,6 +420,85 @@ async function readFirstSSEEvent(streamPath: string): Promise {
}
describe("buildApp", () => {
+ test("hosted retention updates preserve standalone data and leave other controls blocked", async () => {
+ const retention = createLocalRetentionBuffer(500);
+ const app = buildApp(retention.buffer, { uiActions: true, retention });
+ await app.request("/stream", {
+ method: "POST",
+ headers: { "Content-Type": SENTRY_CONTENT_TYPE },
+ body: TEST_ENVELOPE,
+ });
+ const before = retention.buffer.read({ all: true });
+ const origin = "https://local.sentry.dev";
+ const preflight = await app.request("/retention", {
+ method: "OPTIONS",
+ headers: {
+ Origin: origin,
+ "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Headers": "content-type",
+ "Access-Control-Request-Private-Network": "true",
+ },
+ });
+ expect(preflight.status).toBe(204);
+ expect(preflight.headers.get("Access-Control-Allow-Origin")).toBe(origin);
+ expect(preflight.headers.get("Access-Control-Allow-Private-Network")).toBe(
+ "true",
+ );
+ const updated = await app.request("/retention", {
+ method: "POST",
+ headers: { Origin: origin, "Content-Type": "application/json" },
+ body: '{"maxEnvelopes":50000}',
+ });
+ expect(updated.status).toBe(200);
+ expect(updated.headers.get("Access-Control-Allow-Origin")).toBe(origin);
+ expect(await updated.json()).toMatchObject({
+ maxEnvelopes: 50000,
+ retainedEnvelopes: 1,
+ });
+ for (const value of [-1, 0, 1.5, 50001, "1000", null]) {
+ expect(
+ (
+ await app.request("/retention", {
+ method: "POST",
+ headers: { Origin: origin },
+ body: JSON.stringify({ maxEnvelopes: value }),
+ })
+ ).status,
+ ).toBe(400);
+ }
+ expect(retention.buffer.read({ all: true })).toEqual(before);
+ expect(
+ (
+ await app.request("/clear", {
+ method: "DELETE",
+ headers: { Origin: origin },
+ })
+ ).status,
+ ).toBe(403);
+ expect(
+ (
+ await app.request("/retention", {
+ method: "POST",
+ headers: { Origin: "https://evil.example" },
+ body: '{"maxEnvelopes":1000}',
+ })
+ ).status,
+ ).toBe(403);
+ const disabled = buildApp(retention.buffer, {
+ uiActions: false,
+ retention,
+ });
+ expect(
+ (
+ await disabled.request("/retention", {
+ method: "POST",
+ headers: { Origin: origin },
+ body: '{"maxEnvelopes":1000}',
+ })
+ ).status,
+ ).toBe(403);
+ });
+
test("changes legacy retention only when explicit and preserves history on rejected decreases", async () => {
const retention = createLocalRetentionBuffer(2);
const app = buildApp(retention.buffer, { uiActions: true, retention });
@@ -429,7 +508,7 @@ describe("buildApp", () => {
body: TEST_ENVELOPE,
});
const retained = retention.buffer.read({ all: true });
- for (const origin of ["https://example.com", "https://local.sentry.dev"]) {
+ for (const origin of ["https://example.com"]) {
expect(
(
await app.request("/retention", {
diff --git a/packages/cli/test/e2e/local.test.ts b/packages/cli/test/e2e/local.test.ts
index a1fc7ad87..e85b5f1c8 100644
--- a/packages/cli/test/e2e/local.test.ts
+++ b/packages/cli/test/e2e/local.test.ts
@@ -577,7 +577,7 @@ describe("Local daemon E2E", () => {
const updated = await fetch(uiUrl, {
method: "POST",
headers: {
- Origin: "http://localhost:5173",
+ Origin: "https://local.sentry.dev",
"Content-Type": "application/json",
},
body: JSON.stringify({ maxEnvelopes: 6 }),
From ada1980b6e8795daf41517fa897b99577324fbab Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 20:06:53 -0700
Subject: [PATCH 18/30] fix(local): Keep only five recent receiver connections
Evict the least recently used receiver when adding another and trim existing oversized history on load and save.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/lib/connection-history.test.ts | 31 ++++++++++++++++++-
apps/local/src/lib/connection-history.ts | 4 +--
docs/cli/local-control-plane.md | 4 ++-
3 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/apps/local/src/lib/connection-history.test.ts b/apps/local/src/lib/connection-history.test.ts
index 3d514d8c0..ab01ae02a 100644
--- a/apps/local/src/lib/connection-history.test.ts
+++ b/apps/local/src/lib/connection-history.test.ts
@@ -23,7 +23,11 @@ test("recent receivers deduplicate daemon sessions without storing their capabil
history,
"http://localhost:9009/_local/v1/sessions/web/stream?cap=private-session",
);
- expect(history).toHaveLength(8);
+ expect(history).toEqual(
+ [9009, 9008, 9007, 9006, 9005].map(
+ (port) => `http://localhost:${port}/stream`,
+ ),
+ );
expect(history[0]).toBe("http://localhost:9009/stream");
saveConnectionHistory(history);
expect(window.sessionStorage.getItem(CONNECTION_HISTORY_KEY)).not.toContain(
@@ -70,3 +74,28 @@ test("disconnect survives reload but an explicit receiver link overrides it", ()
saveDisconnected(false);
expect(getInitialDisconnected()).toBe(false);
});
+
+test("reconnecting promotes a receiver and adding a sixth discards the least recent", () => {
+ const url = (port: number) => `http://localhost:${port}/stream`;
+ let history = [9005, 9004, 9003, 9002, 9001].map(url);
+ history = rememberConnection(history, url(9001));
+ expect(history).toEqual([9001, 9005, 9004, 9003, 9002].map(url));
+ history = rememberConnection(history, url(9006));
+ expect(history).toEqual([9006, 9001, 9005, 9004, 9003].map(url));
+});
+
+test("older eight-entry history loads and saves only the newest five", () => {
+ const oldHistory = Array.from(
+ { length: 8 },
+ (_, index) => `http://localhost:${9008 - index}/stream`,
+ );
+ window.sessionStorage.setItem(
+ CONNECTION_HISTORY_KEY,
+ JSON.stringify(oldHistory),
+ );
+ expect(readConnectionHistory()).toEqual(oldHistory.slice(0, 5));
+ saveConnectionHistory(oldHistory);
+ expect(
+ JSON.parse(window.sessionStorage.getItem(CONNECTION_HISTORY_KEY)!),
+ ).toEqual(oldHistory.slice(0, 5));
+});
diff --git a/apps/local/src/lib/connection-history.ts b/apps/local/src/lib/connection-history.ts
index bd96aacbd..b6aded072 100644
--- a/apps/local/src/lib/connection-history.ts
+++ b/apps/local/src/lib/connection-history.ts
@@ -1,7 +1,7 @@
import { parseStreamEndpoint } from "./spotlight.ts";
export const CONNECTION_HISTORY_KEY = "sentry.local.recent-connections";
-const MAX_CONNECTIONS = 8;
+const MAX_CONNECTIONS = 5;
/** Re-pair daemon receivers through discovery, without retaining session capabilities. */
export function receiverAddress(stream: string): string {
@@ -55,7 +55,7 @@ export function saveConnectionHistory(history: string[]): void {
try {
window.sessionStorage.setItem(
CONNECTION_HISTORY_KEY,
- JSON.stringify(history),
+ JSON.stringify(history.slice(0, MAX_CONNECTIONS)),
);
} catch {
// The current tab's in-memory history still works when storage is unavailable.
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 3d9a69f9d..682775d56 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -116,7 +116,9 @@ this browser tab and survives reloads. Choose one to reconnect without pasting
its address, or use its remove button to forget the entry. Local entries use the
plain `/stream` address so reconnecting discovers available sessions without
saving session capabilities in the list. Remote URLs stay in tab session storage;
-query credentials are omitted from their displayed labels. No history entry is
+query credentials are omitted from their displayed labels. Each tab keeps at most
+five receivers, ordered by most recent use; adding another discards the oldest
+entry, and reconnecting moves an existing receiver to the top. No history entry is
created for a failed connection.
Closing a session stops capture immediately: later ingestion receives HTTP 410,
From da78ed8c0a36ae404137d7c1e202dacf8270b3e9 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Thu, 15 Oct 2026 20:17:45 -0700
Subject: [PATCH 19/30] fix(local): Keep archived retention read-only during
replay
Gate Settings on the known closed session instead of transient SSE connection status. Cover replay connection, open and completion in a regression test.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 50 +++++++++++++++++++++++++
apps/local/src/App.tsx | 6 ++-
2 files changed, 54 insertions(+), 2 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 19766377f..f405da143 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -1599,6 +1599,56 @@ describe("local receiver to viewer integration", () => {
).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.findByRole("heading", { name: "Session closed" });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Reload saved events" }),
+ );
+ 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("Retention is read-only for closed sessions."),
+ ).not.toBeNull();
+ expect(screen.queryByLabelText("Retention limit")).toBeNull();
+ expect(
+ screen.queryByRole("button", { name: "Apply retention 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) => {
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 96649d8fc..239b6aedd 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1475,9 +1475,11 @@ export default function App() {
supported={hasRetentionSettings}
limit={retentionLimit}
count={telemetry.envelopes.length}
- editable={canEditRetention && connection === "connected"}
+ editable={
+ canEditRetention && !isClosedSession && connection === "connected"
+ }
unavailableReason={
- connection === "closed"
+ isClosedSession
? "Retention is read-only for closed sessions."
: canEditRetention && connection !== "connected"
? "Reconnect to this receiver to change retention."
From 716b1c0cf9da4a9449acd99a49645565d9629f41 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Fri, 16 Oct 2026 12:21:04 -0700
Subject: [PATCH 20/30] fix(local): Restore clear recovery and preserve
disconnection
Offer an enabled reconnect action after failed clears, including clears during a pending reconnect. Reuse startup URL validation so ignored links cannot reconnect a disconnected tab to a saved receiver.
Co-Authored-By: GPT-6 Codex
---
apps/local/src/App.integration.test.tsx | 108 +++++++++++++++++-
apps/local/src/App.tsx | 14 ++-
apps/local/src/lib/connection-history.test.ts | 51 +++++++++
apps/local/src/lib/connection-history.ts | 13 ++-
docs/cli/local-control-plane.md | 10 +-
5 files changed, 181 insertions(+), 15 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index f405da143..fdf35455b 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -15,6 +15,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 {
@@ -120,8 +121,8 @@ function renderViewer(port: number, path = "/") {
);
}
-function renderBareViewer() {
- window.history.replaceState(null, "", "/");
+function renderBareViewer(path = "/") {
+ window.history.replaceState(null, "", path);
return render(
@@ -867,6 +868,23 @@ describe("local receiver to viewer integration", () => {
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: "Connect a receiver" }),
+ ).not.toBeNull();
+ });
+
test("clear ignores pre-reset deliveries and reconnects without replacing the workspace shell", async () => {
ControllableEventSource.instances = [];
vi.stubGlobal("EventSource", ControllableEventSource);
@@ -915,6 +933,92 @@ describe("local receiver to viewer integration", () => {
).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",
+ });
+ fireEvent.click(
+ screen.getByRole("button", { name: "Clear events", exact: true }),
+ );
+ await screen.findByText(
+ "Cleared this viewer, but the receiver could not clear its retained session.",
+ );
+ 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 Add 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";
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 239b6aedd..074b45d2e 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1039,6 +1039,7 @@ export default function App() {
clearingGeneration.current = generation;
setIsClearing(true);
setIsConnectionEnabled(false);
+ setIsConnecting(false);
const clearUrl = new URL(endpoint.url);
const scopedSession = isScopedSessionStream(clearUrl.pathname);
if (scopedSession) {
@@ -1638,9 +1639,8 @@ export default function App() {
onSelect={connectToEndpoint}
/>
) : null}
- {!isDisconnected &&
- (isClosedSession ||
- (hasRetainedItems && connection === "failed")) ? (
+ {!showConnectionLanding &&
+ (isClosedSession || connection === "failed") ? (
{isClosedSession
? "No saved events displayed"
- : "Waiting for events"}
+ : connection === "failed"
+ ? "No events displayed"
+ : "Waiting for events"}
{isClosedSession
? "Capture has stopped. Reload saved events or connect to another session."
- : "Send events to the selected receiver to see them here."}
+ : connection === "failed"
+ ? "Reconnect to resume this feed."
+ : "Send events to the selected receiver to see them here."}
) : workspaceView === "traces" ? (
diff --git a/apps/local/src/lib/connection-history.test.ts b/apps/local/src/lib/connection-history.test.ts
index ab01ae02a..812900554 100644
--- a/apps/local/src/lib/connection-history.test.ts
+++ b/apps/local/src/lib/connection-history.test.ts
@@ -99,3 +99,54 @@ test("older eight-entry history loads and saves only the newest five", () => {
JSON.parse(window.sessionStorage.getItem(CONNECTION_HISTORY_KEY)!),
).toEqual(oldHistory.slice(0, 5));
});
+
+test.each([
+ {
+ search: "?url=" + encodeURIComponent("https://remote.example/stream"),
+ hash: "",
+ },
+ {
+ search:
+ "?url=" +
+ encodeURIComponent(
+ "http://localhost:8969/_local/v1/sessions/web/stream?cap=read",
+ ),
+ hash: "",
+ },
+ {
+ search: "",
+ hash: "#stream=" + encodeURIComponent("https://remote.example/stream"),
+ },
+])(
+ "ignored connection links preserve a disconnected tab: $search $hash",
+ ({ search, hash }) => {
+ saveDisconnected(true);
+ window.history.replaceState(null, "", "/" + search + hash);
+ expect(getInitialDisconnected()).toBe(true);
+ },
+);
+
+test("startup applies a valid query when an invalid fragment is ignored", () => {
+ saveDisconnected(true);
+ window.history.replaceState(
+ null,
+ "",
+ "/?url=" +
+ encodeURIComponent("http://127.0.0.1:8969/stream") +
+ "#stream=invalid",
+ );
+ expect(getInitialDisconnected()).toBe(false);
+});
+
+test("an applied scoped CLI fragment can reconnect a disconnected tab", () => {
+ saveDisconnected(true);
+ window.history.replaceState(
+ null,
+ "",
+ "/#stream=" +
+ encodeURIComponent(
+ "http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=read",
+ ),
+ );
+ expect(getInitialDisconnected()).toBe(false);
+});
diff --git a/apps/local/src/lib/connection-history.ts b/apps/local/src/lib/connection-history.ts
index b6aded072..9cff0331f 100644
--- a/apps/local/src/lib/connection-history.ts
+++ b/apps/local/src/lib/connection-history.ts
@@ -1,4 +1,8 @@
-import { parseStreamEndpoint } from "./spotlight.ts";
+import {
+ getStreamUrlFromHash,
+ getStreamUrlFromQuery,
+ parseStreamEndpoint,
+} from "./spotlight.ts";
export const CONNECTION_HISTORY_KEY = "sentry.local.recent-connections";
const MAX_CONNECTIONS = 5;
@@ -65,9 +69,10 @@ export function saveConnectionHistory(history: string[]): void {
const DISCONNECTED_KEY = "sentry.local.disconnected";
/** An explicit link or user Connect overrides a tab's disconnected landing. */
export function getInitialDisconnected(): boolean {
- const fragment = new URLSearchParams(window.location.hash.slice(1));
- const query = new URLSearchParams(window.location.search);
- if (parseStreamEndpoint(fragment.get("stream") ?? query.get("url") ?? ""))
+ if (
+ getStreamUrlFromHash(window.location.hash) ??
+ getStreamUrlFromQuery(window.location.search)
+ )
return false;
try {
return window.sessionStorage.getItem(DISCONNECTED_KEY) === "1";
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 682775d56..1e66e4ae1 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -130,10 +130,12 @@ guarded against duplicate requests and times out if the receiver does not respon
**Disconnect receiver** returns to the connection screen without closing capture
or deleting receiver data. This tab stays disconnected after a reload; use Connect,
-past connections, or an explicit connection link to resume. **Clear events** resets
-local receiver data when permitted and resumes only an open session's feed. Closed
-sessions stay closed and empty after a reset. Other connected viewers receive reset
-and close notifications. The hosted viewer clears only its displayed data; reloading
+past connections, or an accepted connection link to resume. Ignored or unsupported
+URL parameters do not reconnect a saved receiver. **Clear events** resets
+local receiver data when permitted and resumes only an open session's feed. If the
+reset fails, the feed stays paused and offers Reconnect even when the view is empty.
+Closed sessions stay closed and empty after a reset. Other connected viewers
+receive reset and close notifications. The hosted viewer clears only its displayed data; reloading
saved events restores the daemon's archive.
### Switching sessions
From 6cac39508db4b79c6285abc2fd51a156ae2bcbe0 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 13:36:06 +0530
Subject: [PATCH 21/30] fix(local): Harden discovery and preserve streamed
telemetry
Reject foreign daemon authorities and finish CLI tails after retained replay.
Preserve final viewer events and explicit reconnections, and validate streamed spans with segment names and typed HTTP metadata.
Refs #1509
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.integration.test.tsx | 79 ++++++++++
apps/local/src/App.tsx | 13 +-
apps/local/src/lib/spotlight.test.ts | 119 ++++++++++++++
apps/local/src/lib/spotlight.ts | 50 ++++--
apps/local/src/lib/trace-model.test.ts | 146 +++++++++++++++++-
apps/local/src/lib/trace-model.ts | 12 ++
docs/cli/local-control-plane.md | 17 +-
.../cli/src/commands/local/control-plane.ts | 14 ++
packages/cli/src/commands/local/server.ts | 46 ++++--
.../test/commands/local/control-plane.test.ts | 57 +++++++
.../cli/test/commands/local/server.test.ts | 100 ++++++++++++
11 files changed, 614 insertions(+), 39 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index bb21c9e04..4865961be 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -885,6 +885,39 @@ describe("local receiver to viewer integration", () => {
).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: "Waiting for events" }),
+ ).not.toBeNull();
+ } else {
+ expect(
+ screen.getByRole("heading", { name: "Connect a receiver" }),
+ ).not.toBeNull();
+ }
+ },
+ );
+
test("clear ignores pre-reset deliveries and reconnects without replacing the workspace shell", async () => {
ControllableEventSource.instances = [];
vi.stubGlobal("EventSource", ControllableEventSource);
@@ -1922,6 +1955,52 @@ describe("local receiver to viewer integration", () => {
},
);
+ 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(
+ screen.getByRole("button", { name: "Close telemetry session" }),
+ );
+ await screen.findByRole("heading", { name: "Session closed" });
+ // 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) => {
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 074b45d2e..e4e29bbea 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1099,13 +1099,11 @@ export default function App() {
if (!response.ok) {
throw new Error(`Session close failed with HTTP ${response.status}`);
}
- connectionGeneration.current += 1;
- setIsConnectionEnabled(false);
- setIsConnecting(false);
- setConnection("closed");
+ // 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);
- setConnectionError(undefined);
- setMessage(undefined);
})
.catch(() => {
if (connectionGeneration.current !== generation) return;
@@ -1402,6 +1400,9 @@ 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);
diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts
index 8cdb33f07..8707fabed 100644
--- a/apps/local/src/lib/spotlight.test.ts
+++ b/apps/local/src/lib/spotlight.test.ts
@@ -38,6 +38,34 @@ describe("getStreamUrlFromHash", () => {
expect(parseStreamEndpoint(url)).toBeUndefined();
});
+ test("keeps span HTTP attribute interpretation out of log metadata", () => {
+ const [log] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "log" },
+ {
+ body: "Request finished",
+ attributes: {
+ "http.request.method": { type: "string", value: "GET" },
+ "url.full": {
+ type: "string",
+ value: "https://example.test/orders",
+ },
+ "http.response.status_code": { type: "integer", value: 200 },
+ },
+ },
+ ],
+ ],
+ ]),
+ "log-http",
+ );
+ expect(log?.metadata?.method).toBeUndefined();
+ expect(log?.metadata?.route).toBeUndefined();
+ expect(log?.metadata?.statusCode).toBeUndefined();
+ });
+
test("accepts a loopback stream URL", () => {
expect(
getStreamUrlFromHash("#stream=http%3A%2F%2Flocalhost%3A8969%2Fstream"),
@@ -111,6 +139,97 @@ describe("getStreamUrlFromHash", () => {
});
describe("decodeEnvelope", () => {
+ test("reads typed HTTP attributes from streamed spans without changing their payload", () => {
+ const span = {
+ trace_id: "trace-123",
+ span_id: "root-span",
+ name: "GET /orders/42",
+ start_timestamp: 1700000000,
+ end_timestamp: 1700000001,
+ is_segment: true,
+ attributes: {
+ "sentry.op": { type: "string", value: "http.server" },
+ "http.request.method": { type: "string", value: "GET" },
+ "url.full": { type: "string", value: "https://example.test/orders/42" },
+ "http.response.status_code": { type: "integer", value: 503 },
+ },
+ };
+ const [item] = decodeEnvelope(
+ JSON.stringify([{}, [[{ type: "span" }, { version: 2, items: [span] }]]]),
+ "streamed-http",
+ );
+ expect(item?.metadata).toMatchObject({
+ title: "GET /orders/42",
+ method: "GET",
+ route: "/orders/42",
+ statusCode: 503,
+ operation: "http.server",
+ durationMs: 1000,
+ });
+ expect(item?.timestamp).toBe(1700000001);
+ expect(item?.payload).toEqual(span);
+ });
+
+ test("filters unsupported span containers and incomplete spans while preserving valid siblings and logs", () => {
+ const span = {
+ trace_id: "trace-123",
+ span_id: "query-span",
+ name: "SELECT 1",
+ start_timestamp: 1700000000,
+ end_timestamp: 1700000000.125,
+ };
+ const decoded = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [{ type: "span" }, { version: 99, items: [span] }],
+ [{ type: "span" }, { version: 2, items: "invalid" }],
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: [
+ null,
+ {},
+ { ...span, trace_id: "" },
+ { ...span, span_id: "" },
+ { ...span, start_timestamp: null },
+ { ...span, end_timestamp: undefined },
+ { ...span, end_timestamp: "1700000000.125" },
+ span,
+ ],
+ },
+ ],
+ [
+ { type: "log" },
+ { version: 99, items: [{ body: "still visible" }] },
+ ],
+ ],
+ ]),
+ "mixed-envelope",
+ );
+ expect(decoded).toMatchObject([
+ { type: "span", payload: span },
+ { type: "log", payload: { body: "still visible" } },
+ ]);
+ expect(decoded).toHaveLength(2);
+ });
+
+ test.each([
+ ["1e400", "1700000001"],
+ ["1700000000", "1e400"],
+ ])(
+ "rejects span timestamps that overflow finite numbers (%s, %s)",
+ (start, end) => {
+ expect(
+ decodeEnvelope(
+ `[{},[[{"type":"span"},{"version":2,"items":[{"trace_id":"trace-123","span_id":"span-123","start_timestamp":${start},"end_timestamp":${end}}]}]]]`,
+ "overflow",
+ ),
+ ).toEqual([]);
+ },
+ );
+
test("uses the current SDK exception as the title ahead of its request transaction", () => {
const [item] = decodeEnvelope(
JSON.stringify([{}, [[{ type: "event" }, sdkException]]]),
diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts
index 13fa306b0..0d7bd7c3a 100644
--- a/apps/local/src/lib/spotlight.ts
+++ b/apps/local/src/lib/spotlight.ts
@@ -52,14 +52,19 @@ function getNumber(value: unknown): number | undefined {
: undefined;
}
+function getAttributeValue(
+ attributes: Record,
+ key: string,
+): unknown {
+ const attribute = attributes[key];
+ return isRecord(attribute) ? attribute.value : attribute;
+}
+
function getAttributeString(
attributes: Record,
key: string,
): string | undefined {
- const attribute = attributes[key];
- return isRecord(attribute)
- ? getString(attribute.value)
- : getString(attribute);
+ return getString(getAttributeValue(attributes, key));
}
function getRoute(url: string | undefined): string | undefined {
@@ -95,12 +100,17 @@ export function getEventMetadata(
? event.data
: {};
const attributes = isRecord(event.attributes) ? event.attributes : {};
+ const spanAttributes = type === "span" ? attributes : {};
const request = isRecord(event.request) ? event.request : {};
const method =
getString(request.method) ??
getString(data["http.method"]) ??
+ getAttributeString(spanAttributes, "http.request.method") ??
transactionMatch?.[1];
- const url = getString(request.url) ?? getString(data["http.url"]);
+ const url =
+ getString(request.url) ??
+ getString(data["http.url"]) ??
+ getAttributeString(spanAttributes, "url.full");
const route = getRoute(url) ?? transactionMatch?.[2];
const startTimestamp = getNumber(event.start_timestamp);
const timestamp =
@@ -128,7 +138,8 @@ export function getEventMetadata(
route,
statusCode:
getNumber(request.status_code) ??
- getNumber(data["http.response.status_code"]),
+ getNumber(data["http.response.status_code"]) ??
+ getNumber(getAttributeValue(spanAttributes, "http.response.status_code")),
spanStatus: getString(trace.status) ?? getString(event.status),
durationMs,
traceId: getString(trace.trace_id) ?? getString(event.trace_id),
@@ -146,11 +157,28 @@ export function getEventMetadata(
}
function getBatchedPayloadItems(payload: unknown, type: string): unknown[] {
- if (
- (type !== "span" && type !== "log") ||
- !isRecord(payload) ||
- !Array.isArray(payload.items)
- ) {
+ if (type === "span") {
+ if (!isRecord(payload)) return [];
+ const isContainer = "items" in payload;
+ if (isContainer && (payload.version !== 2 || !Array.isArray(payload.items)))
+ return [];
+ const spans: unknown[] = isContainer
+ ? (payload.items as unknown[])
+ : [payload];
+ return spans.filter(
+ (span) =>
+ isRecord(span) &&
+ getString(span.trace_id) !== undefined &&
+ getString(span.span_id) !== undefined &&
+ getNumber(span.start_timestamp) !== undefined &&
+ getNumber(
+ isContainer
+ ? span.end_timestamp
+ : (span.timestamp ?? span.end_timestamp),
+ ) !== undefined,
+ );
+ }
+ if (type !== "log" || !isRecord(payload) || !Array.isArray(payload.items)) {
return [payload];
}
return payload.items.filter(isRecord);
diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts
index 4c476138f..70af66ae7 100644
--- a/apps/local/src/lib/trace-model.test.ts
+++ b/apps/local/src/lib/trace-model.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
-import type { LocalFeedItem } from "./spotlight.js";
+import { decodeEnvelope, type LocalFeedItem } from "./spotlight.js";
import { buildTraceGroups, traceIdForItem } from "./trace-model.js";
function item(overrides: Partial): LocalFeedItem {
@@ -14,6 +14,150 @@ function item(overrides: Partial): LocalFeedItem {
}
describe("buildTraceGroups", () => {
+ test.each([undefined, "remote-parent"])(
+ "promotes a late streamed segment title and reparents earlier children (parent: %s)",
+ (parentId) => {
+ const child = {
+ trace_id: "trace-stream",
+ span_id: "query",
+ parent_span_id: "root",
+ name: "SELECT 1",
+ start_timestamp: 1700000000.125,
+ end_timestamp: 1700000000.25,
+ attributes: { "sentry.op": { type: "string", value: "db" } },
+ };
+ const decode = (items: unknown[], id: string) =>
+ decodeEnvelope(
+ JSON.stringify([{}, [[{ type: "span" }, { version: 2, items }]]]),
+ id,
+ );
+ const earlyItems = decode([child], "child");
+ expect(buildTraceGroups(earlyItems)[0]?.roots).toMatchObject([
+ { id: "query" },
+ ]);
+ const items = [
+ ...earlyItems,
+ ...decode(
+ [
+ {
+ trace_id: "trace-stream",
+ span_id: "root",
+ parent_span_id: parentId,
+ name: "GET /orders",
+ start_timestamp: 1700000000,
+ end_timestamp: 1700000001,
+ is_segment: true,
+ attributes: {
+ "sentry.op": { type: "string", value: "http.server" },
+ },
+ },
+ ],
+ "segment",
+ ),
+ ...decode([child], "redelivery"),
+ ];
+ const [trace] = buildTraceGroups(items);
+ expect(trace).toMatchObject({
+ title: "GET /orders",
+ startTimestamp: 1700000000,
+ endTimestamp: 1700000001,
+ durationMs: 1000,
+ itemCount: 3,
+ roots: [{ id: "root", children: [{ id: "query", durationMs: 125 }] }],
+ });
+ expect(trace?.spans).toHaveLength(2);
+ expect(trace?.roots).toHaveLength(1);
+ expect(trace?.roots[0]?.children).toHaveLength(1);
+ },
+ );
+
+ test.each([true, false])(
+ "combines legacy transactions and streamed spans without duplicate spans (transaction first: %s)",
+ (transactionFirst) => {
+ const transaction = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1700000000,
+ timestamp: 1700000001,
+ contexts: {
+ trace: {
+ trace_id: "trace-mixed",
+ span_id: "root",
+ op: "http.server",
+ },
+ },
+ spans: [
+ {
+ span_id: "query",
+ parent_span_id: "root",
+ description: "SELECT 1",
+ op: "db",
+ start_timestamp: 1700000000.125,
+ timestamp: 1700000000.25,
+ },
+ ],
+ },
+ ],
+ ],
+ ]),
+ "legacy",
+ );
+ const streamed = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: [
+ {
+ trace_id: "trace-mixed",
+ span_id: "query",
+ parent_span_id: "root",
+ name: "SELECT 1",
+ start_timestamp: 1700000000.125,
+ end_timestamp: 1700000000.25,
+ attributes: {
+ "sentry.op": { type: "string", value: "db" },
+ },
+ },
+ {
+ trace_id: "other-trace",
+ span_id: "query",
+ name: "other query",
+ start_timestamp: 1700000002,
+ end_timestamp: 1700000003,
+ },
+ ],
+ },
+ ],
+ ],
+ ]),
+ "streamed",
+ );
+ const traces = buildTraceGroups(
+ transactionFirst
+ ? [...transaction, ...streamed]
+ : [...streamed, ...transaction],
+ );
+ expect(traces).toHaveLength(2);
+ const trace = traces.find((value) => value.id === "trace-mixed");
+ expect(trace).toMatchObject({
+ title: "GET /orders",
+ durationMs: 1000,
+ roots: [{ id: "root", children: [{ id: "query", durationMs: 125 }] }],
+ });
+ expect(trace?.spans).toHaveLength(2);
+ expect(trace?.roots[0]?.children).toHaveLength(1);
+ },
+ );
+
test("reads trace timing in linear work for a long single trace", () => {
let timestampReads = 0;
const entries: LocalFeedItem[] = Array.from(
diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts
index 48365a160..5abc4d388 100644
--- a/apps/local/src/lib/trace-model.ts
+++ b/apps/local/src/lib/trace-model.ts
@@ -34,6 +34,7 @@ export type TraceGroup = {
type MutableTraceGroup = Omit & {
spanMap: Map;
+ titlePriority: number;
};
function isRecord(value: unknown): value is Record {
@@ -200,6 +201,7 @@ export function buildTraceGroups(
const trace: MutableTraceGroup = traces.get(traceId) ?? {
id: traceId,
title: item.metadata?.title ?? item.type,
+ titlePriority: 0,
items: [],
logCount: 0,
errorCount: 0,
@@ -221,6 +223,16 @@ export function buildTraceGroups(
const rootSpan = getPayloadSpan(item.payload, item);
if (rootSpan) {
trace.spanMap.set(rootSpan.id, rootSpan);
+ const isSegment =
+ isRecord(item.payload) && item.payload.is_segment === true;
+ if (item.type === "transaction" || isSegment) {
+ // A local segment can have a remote parent absent from this receiver.
+ const priority = rootSpan.parentId ? 1 : 2;
+ if (priority > trace.titlePriority) {
+ trace.title = item.metadata?.title ?? rootSpan.description;
+ trace.titlePriority = priority;
+ }
+ }
}
}
for (const childSpan of getChildSpans(item.payload, item)) {
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 1e66e4ae1..2acb5ce86 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -3,7 +3,7 @@
`local run --session ` and `local serve --session ` capture into an
isolated session owned by a background, loopback-only daemon. The foreground
command retains its session when it exits. Closed sessions remain queryable for
-three hours after the last activity; each retains 500 envelopes by default. All
+three hours from the first close; each retains 500 envelopes by default. All
telemetry disappears when the daemon stops.
```sh
@@ -38,6 +38,8 @@ without an event ID retain their generated envelope ID as the lookup key.
The daemon stores its discovery token under `SENTRY_CONFIG_DIR` in an owner-only
state file. It binds only to localhost, 127.0.0.1, or ::1 and uses a nonzero port.
+Requests must also use a loopback authority, so rebinding a remote hostname
+to the listener cannot expose session discovery.
A second daemon cannot replace the running daemon for that config directory.
Restart preserves its configured host and port and clears all in-memory data.
Both stop and restart wait for the previous daemon to release its state and ownership
@@ -80,9 +82,9 @@ preserves its current setting. Older receivers that cannot apply the flag report
error. Retrying session creation with the same client ID preserves its original settings.
The viewer follows the receiver's envelope limit and retains every decoded item
-inside each retained envelope. Its Retention limit control can update a locally
-served receiver or scoped session at runtime without replacing the connection.
-Hosted viewers display the setting but cannot change it. CLI changes also update
+inside each retained envelope. Its Retention limit control can update a loopback
+receiver or scoped session at runtime without replacing the connection, from a
+locally served viewer or trusted hosted Local origin. CLI changes also update
connected viewers.
## Browser viewer
@@ -200,6 +202,13 @@ reset receiver data or close sessions. Scoped retention requests require the ses
stream capability. Standalone retention updates require UI actions to be enabled on
the loopback receiver. Other browser origins cannot change retention. Remote HTTPS streams stay read-only.
+Standalone spans and version 2 span containers populate trace waterfalls as they
+arrive. Child spans remain visible before their parent or segment arrives; the
+segment supplies the trace name once available. Typed span attributes supply
+operation and HTTP metadata. Unsupported containers and malformed spans are
+skipped by the trace view while their raw envelopes remain inspectable. Legacy
+standalone spans and transaction envelopes remain supported.
+
Exception event titles use the current SDK exception type and message, including
when the event carries a request transaction name. Overview shows the current
exception, its causes, and stack frames with function names, source locations, and
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index fb373b4ec..4026e7ce8 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -15,6 +15,7 @@ import { streamSSE } from "hono/streaming";
import { stringifyUnknown } from "../../lib/errors.js";
import { SENTRY_CONTENT_TYPE } from "../../lib/formatters/local.js";
import { formatLocalServerUrl } from "./ui.js";
+import { isLoopbackHost } from "./loopback.js";
import {
buildSSEHandler,
MAX_BODY_BYTES,
@@ -145,6 +146,19 @@ export function createLocalControlPlane({
}
};
+ app.use("*", async (c, next) => {
+ // Same-origin browser GETs may omit Origin after DNS rebinding. The Node
+ // adapter derives ordinary URLs from Host but accepts absolute targets too.
+ const authority = c.req.header("host");
+ if (
+ !isLoopbackHost(new URL(c.req.url).hostname) ||
+ (authority !== undefined &&
+ !LOOPBACK_ORIGIN_RE.test(`http://${authority.toLowerCase()}`))
+ )
+ return c.body(null, 403);
+ await next();
+ });
+
app.get("/health", (c) => {
c.header("X-Sentry-Local-Mode", "daemon");
return c.text("OK");
diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts
index 2535b4257..831cdf7d3 100644
--- a/packages/cli/src/commands/local/server.ts
+++ b/packages/cli/src/commands/local/server.ts
@@ -1,4 +1,4 @@
-import { isHostedUiOrigin } from "./viewer-protocol.js";
+import { isHostedUiOrigin, LOCAL_SESSION_EVENT } from "./viewer-protocol.js";
export { isHostedUiOrigin } from "./viewer-protocol.js";
import { isLoopbackHost } from "./loopback.js";
/**
@@ -787,7 +787,7 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise {
},
});
- if (signal.aborted) {
+ if (signal.aborted || result === "retained") {
return;
}
// On the first attempt, both "no-connection" (HTTP error/no body) and
@@ -830,10 +830,11 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise {
* - `"no-connection"` if the server couldn't be reached or aborted
* - `"connected-then-lost"` if a connection was established (got HTTP 200)
* but the stream ended or errored — eligible for reconnection
+ * - `"retained"` after a session's final lifecycle message — never reconnect
*/
async function attemptSSEConnection(
opts: ConsumeSSEOnceOptions,
-): Promise<"no-connection" | "connected-then-lost"> {
+): Promise<"no-connection" | "connected-then-lost" | "retained"> {
let wasConnected = false;
const augmented = {
...opts,
@@ -843,6 +844,7 @@ async function attemptSSEConnection(
};
try {
const completed = await consumeSSEOnce(augmented);
+ if (completed === "retained") return "retained";
return completed ? "connected-then-lost" : "no-connection";
} catch (err: unknown) {
if (isAbortError(err) || opts.signal.aborted) {
@@ -874,10 +876,13 @@ type ConsumeSSEOnceOptions = {
/**
* Single SSE connection attempt. Returns `true` if a connection was
- * established (received a 200 response), `false` if it never connected.
- * The caller uses this to decide whether to retry on failure.
+ * established (received a 200 response), `false` if it never connected, and
+ * `"retained"` when the session closes after replay. Quiet tails still parse
+ * lifecycle metadata so closing capture terminates them without reconnecting.
*/
-async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise {
+async function consumeSSEOnce(
+ opts: ConsumeSSEOnceOptions,
+): Promise {
const {
url,
activeFilters,
@@ -909,22 +914,28 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise {
// decide whether mid-stream errors are worth retrying.
onConnected?.();
- // In quiet mode we still consume the stream to detect disconnection,
- // but skip parsing/formatting entirely.
- if (quiet) {
- for await (const _chunk of res.body) {
- // drain
- }
- return true;
- }
-
const decoder = new TextDecoder();
const state: SSEParserState = { eventType: "", dataLines: [], id: "" };
+ let retained = false;
const onEvent = (type: string, data: string, id: string) => {
if (id) {
onId(id);
}
- if (type === SENTRY_CONTENT_TYPE) {
+ if (type === LOCAL_SESSION_EVENT) {
+ try {
+ const event: unknown = JSON.parse(data);
+ if (
+ typeof event === "object" &&
+ event !== null &&
+ "state" in event &&
+ event.state === "retained" &&
+ !("action" in event && event.action === "reset")
+ )
+ retained = true;
+ } catch {
+ logger.debug("Ignoring invalid Local session lifecycle event.");
+ }
+ } else if (!quiet && type === SENTRY_CONTENT_TYPE) {
processSSEEvent(data, activeFilters, useJson, showAttributes);
}
};
@@ -937,12 +948,13 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise {
partial = lines.pop() ?? "";
for (const rawLine of lines) {
feedSSELine(rawLine.replace(CR_RE, ""), state, onEvent);
+ if (retained) return "retained";
}
}
if (partial) {
feedSSELine(partial.replace(CR_RE, ""), state, onEvent);
}
- return true;
+ return retained ? "retained" : true;
}
/** Parse and format a single SSE data payload from upstream. */
diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts
index 66515c03a..1c21f7acd 100644
--- a/packages/cli/test/commands/local/control-plane.test.ts
+++ b/packages/cli/test/commands/local/control-plane.test.ts
@@ -14,6 +14,63 @@ function controlHeaders() {
}
describe("Local control plane", () => {
+ test("rejects foreign request authorities before exposing daemon discovery", async () => {
+ const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
+ plane.sessionStore.create({ label: "private", cwd: "/private" });
+ for (const url of [
+ "http://attacker.example:8969/stream",
+ "http://attacker.example:8969/health",
+ ]) {
+ const response = await plane.app.request(url);
+ await response.body?.cancel();
+ expect(response.status).toBe(403);
+ }
+ for (const hostname of ["localhost", "127.0.0.1", "[::1]"]) {
+ const response = await plane.app.request(
+ `http://${hostname}:8969/stream`,
+ {
+ headers: { Origin: "https://local.sentry.dev" },
+ },
+ );
+ await response.body?.cancel();
+ expect(response.status).toBe(200);
+ }
+ });
+
+ test("rejects a foreign Host even when an absolute request target is loopback", async () => {
+ const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
+ const listening = await tryListen(plane.app, 0, "127.0.0.1");
+ try {
+ for (const path of [
+ "/stream",
+ `http://127.0.0.1:${listening.port}/stream`,
+ ]) {
+ const status = await new Promise((resolve, reject) => {
+ const req = request(
+ {
+ hostname: "127.0.0.1",
+ port: listening.port,
+ path,
+ headers: { Host: `attacker.example:${listening.port}` },
+ },
+ (response) => {
+ resolve(response.statusCode ?? 0);
+ response.destroy();
+ },
+ );
+ req.on("error", reject);
+ req.end();
+ });
+ expect(status).toBe(403);
+ }
+ } finally {
+ listening.server.closeAllConnections();
+ await new Promise((resolve) =>
+ listening.server.close(() => resolve()),
+ );
+ }
+ });
+
test("a plain stream bootstraps discovery for trusted viewers without a control token", async () => {
const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
const session = plane.sessionStore.create({ label: "web", cwd: "/web" });
diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts
index 7696c6ab0..a1fc1c7a2 100644
--- a/packages/cli/test/commands/local/server.test.ts
+++ b/packages/cli/test/commands/local/server.test.ts
@@ -6,6 +6,7 @@
*/
import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
+import { createLocalControlPlane } from "../../../src/commands/local/control-plane.js";
import { createLocalRetentionBuffer } from "../../../src/commands/local/retention-buffer.js";
import { logger } from "../../../src/lib/logger.js";
import { Hono } from "hono";
@@ -110,6 +111,105 @@ test("reports the scoped SSE endpoint without leaking its capability on initial
expect(writes.join("")).not.toContain("fixture-secret");
});
+test.each([
+ { quiet: false, closedBeforeAttach: true },
+ { quiet: true, closedBeforeAttach: true },
+ { quiet: false, closedBeforeAttach: false },
+ { quiet: true, closedBeforeAttach: false },
+])(
+ "stops a scoped tail on Close ($quiet quiet, $closedBeforeAttach already closed)",
+ async ({ quiet, closedBeforeAttach }) => {
+ const plane = createLocalControlPlane({ controlToken: "tail-test" });
+ const session = plane.sessionStore.create({
+ label: "saved",
+ cwd: "/saved",
+ });
+ const { ingest, stream } = plane.sessionStore.getCapabilities(session.id);
+ const base = `http://localhost:8969/_local/v1/sessions/${session.id}`;
+ await plane.app.request(`${base}/ingest/${ingest}`, {
+ method: "POST",
+ headers: { "Content-Type": SENTRY_CONTENT_TYPE },
+ body: '{"event_id":"saved"}\n{"type":"event"}\n{"message":"saved event"}',
+ });
+ if (closedBeforeAttach) plane.sessionStore.close(session.id);
+ const savedFetch = globalThis.fetch;
+ let requests = 0;
+ globalThis.fetch = async (input, init) => {
+ requests += 1;
+ const url = input instanceof Request ? input.url : input.toString();
+ const response = await plane.app.request(url, init);
+ if (!closedBeforeAttach)
+ queueMicrotask(() => plane.sessionStore.close(session.id));
+ return response;
+ };
+ const output: string[] = [];
+ const stdout = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation((chunk) => {
+ output.push(chunk.toString());
+ return true;
+ });
+ const info = vi.spyOn(logger, "info").mockImplementation(() => {});
+ try {
+ await consumeSSE({
+ url: "http://localhost:8969",
+ streamUrl: `${base}/stream?cap=${stream}`,
+ activeFilters: new Set(),
+ signal: AbortSignal.timeout(1200),
+ useJson: true,
+ quiet,
+ });
+ expect(requests).toBe(1);
+ expect(info.mock.calls.flat().join(" ")).not.toContain("reconnecting");
+ if (quiet) expect(output).toHaveLength(0);
+ else {
+ expect(output).toHaveLength(1);
+ expect(output.join("")).toContain("saved event");
+ }
+ } finally {
+ globalThis.fetch = savedFetch;
+ stdout.mockRestore();
+ info.mockRestore();
+ }
+ },
+);
+
+test("keeps reading after a retained reset notification until Close", async () => {
+ const savedFetch = globalThis.fetch;
+ const envelope = [{}, [[{ type: "event" }, { message: "after reset" }]]];
+ globalThis.fetch = async () =>
+ new Response(
+ [
+ 'event: sentry.local.session\ndata: {"state":"retained","action":"reset"}\n\n',
+ `event: application/x-sentry-envelope\ndata: ${JSON.stringify(envelope)}\n\n`,
+ 'event: sentry.local.session\ndata: {"state":"retained"}\n\n',
+ ].join(""),
+ { headers: { "Content-Type": "text/event-stream" } },
+ );
+ const output: string[] = [];
+ const stdout = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation((chunk) => {
+ output.push(chunk.toString());
+ return true;
+ });
+ const signal = AbortSignal.timeout(1000);
+ try {
+ await consumeSSE({
+ url: "http://localhost:8969",
+ activeFilters: new Set(),
+ signal,
+ useJson: true,
+ });
+ expect(signal.aborted).toBe(false);
+ expect(output).toHaveLength(1);
+ expect(output.join("")).toContain("after reset");
+ } finally {
+ globalThis.fetch = savedFetch;
+ stdout.mockRestore();
+ }
+});
+
describe("parsePort", () => {
test("parses valid port numbers", () => {
expect(parsePort("8969")).toBe(8969);
From 484be12a142527efa8ec96de062a05b262c2ea60 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 14:17:16 +0530
Subject: [PATCH 22/30] test(local): Await retention request completion in
viewer test
Exercise a delayed real POST and wait for submission completion.
Assert retained data after the receiver commits the new limit.
Refs #1509
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.integration.test.tsx | 47 +++++++++++++++++++------
1 file changed, 36 insertions(+), 11 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 4865961be..82db77a3b 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -1569,6 +1569,17 @@ describe("local receiver to viewer integration", () => {
});
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 }),
@@ -1594,20 +1605,34 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
screen.getByRole("button", { name: "Apply retention limit" }),
);
- await waitFor(() =>
- expect(
- (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
- ).toBe("2000"),
- );
+ expect(
+ (
+ (await screen.findByRole("button", {
+ name: "Applying…",
+ })) as HTMLButtonElement
+ ).disabled,
+ ).toBe(true);
+ // The edited input is already 2000 while the HTTP mutation is pending.
+ expect(
+ (screen.getByLabelText("Retention 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 screen.findByRole(
+ "button",
+ { name: "Apply retention limit" },
+ { timeout: 6000 },
+ );
+ expect((apply as HTMLButtonElement).disabled).toBe(false);
+ expect(screen.queryByRole("alert")).toBeNull();
expect(
screen.getByText(/501 envelopes currently retained/),
).not.toBeNull();
- await waitFor(() =>
- expect(retention.getSettings()).toEqual({
- maxEnvelopes: 2000,
- retainedEnvelopes: 501,
- }),
- );
+ expect(retention.getSettings()).toEqual({
+ maxEnvelopes: 2000,
+ retainedEnvelopes: 501,
+ });
await sendEnvelope(receiver.port, "after-growth");
await screen.findByText(/502 envelopes currently retained/);
} finally {
From b45530eb301509f9f5146f4ad9faaf0ae58fee2c Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 16:26:42 +0530
Subject: [PATCH 23/30] feat(local): Clarify capture workflows and event
inspection
Separate tab-only clearing from confirmed receiver deletion.
Show capture lifecycle and scope, and provide event and span inspectors with resizable panes.
Add explicit view filters, diagnostics, onboarding, and lifecycle and accessibility regressions.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.capture-lifecycle.test.tsx | 423 ++++++
apps/local/src/App.integration.test.tsx | 487 +++----
.../src/App.sessions.integration.test.tsx | 151 ++-
apps/local/src/App.tsx | 1154 ++++++-----------
.../src/components/capture-header.test.tsx | 222 ++++
apps/local/src/components/capture-header.tsx | 209 +++
.../components/connection-landing.test.tsx | 62 +
.../src/components/connection-landing.tsx | 60 +-
.../components/event-command-dialog.test.tsx | 14 +-
.../src/components/event-command-dialog.tsx | 20 +-
.../src/components/event-detail.test.tsx | 205 +++
apps/local/src/components/event-detail.tsx | 249 ++++
apps/local/src/components/inspector-data.ts | 22 +
.../src/components/local-settings.test.tsx | 43 +
apps/local/src/components/local-settings.tsx | 34 +-
.../src/components/receiver-controls.test.tsx | 55 +-
.../src/components/receiver-controls.tsx | 70 +-
apps/local/src/components/receiver-dialog.tsx | 2 +-
.../components/resizable-workspace.test.tsx | 95 ++
.../src/components/resizable-workspace.tsx | 144 ++
.../components/retention-settings.test.tsx | 28 +-
.../src/components/retention-settings.tsx | 13 +-
.../src/components/session-switcher.test.tsx | 89 ++
.../local/src/components/session-switcher.tsx | 17 +-
.../local/src/components/span-detail.test.tsx | 74 ++
apps/local/src/components/span-detail.tsx | 293 +++++
.../src/components/trace-waterfall.test.tsx | 39 +
apps/local/src/components/trace-waterfall.tsx | 66 +-
.../src/components/trace-workspace.test.tsx | 134 ++
apps/local/src/components/trace-workspace.tsx | 186 ++-
apps/local/src/components/ui/command.tsx | 5 +-
.../src/components/workspace-sidebar.test.tsx | 119 ++
.../src/components/workspace-sidebar.tsx | 241 ++++
apps/local/src/lib/presentation.test.ts | 88 +-
apps/local/src/lib/presentation.ts | 61 +-
apps/local/src/lib/session-directory.test.ts | 31 +
apps/local/src/lib/session-directory.ts | 10 +
apps/local/src/lib/trace-model.test.ts | 34 +
apps/local/src/lib/trace-model.ts | 3 +
apps/local/src/lib/workspace-navigation.ts | 14 +-
apps/local/src/lib/workspace-query.test.tsx | 24 +
apps/local/src/lib/workspace-query.ts | 28 +-
apps/local/src/router.test.tsx | 4 +-
.../src/routes/local-workspace-context.ts | 6 +-
.../src/routes/local-workspace-route.tsx | 27 +-
docs/cli/local-control-plane.md | 104 +-
.../plans/2026-10-26-local-ui-clarity.md | 84 ++
.../2026-10-26-local-ui-clarity-design.md | 37 +
.../cli/src/commands/local/control-plane.ts | 7 +-
.../cli/src/commands/local/session-store.ts | 10 +-
.../cli/src/commands/local/viewer-protocol.ts | 2 +
.../test/commands/local/control-plane.test.ts | 43 +-
.../test/commands/local/session-store.test.ts | 28 +
53 files changed, 4373 insertions(+), 1297 deletions(-)
create mode 100644 apps/local/src/App.capture-lifecycle.test.tsx
create mode 100644 apps/local/src/components/capture-header.test.tsx
create mode 100644 apps/local/src/components/capture-header.tsx
create mode 100644 apps/local/src/components/connection-landing.test.tsx
create mode 100644 apps/local/src/components/event-detail.test.tsx
create mode 100644 apps/local/src/components/event-detail.tsx
create mode 100644 apps/local/src/components/inspector-data.ts
create mode 100644 apps/local/src/components/local-settings.test.tsx
create mode 100644 apps/local/src/components/resizable-workspace.test.tsx
create mode 100644 apps/local/src/components/resizable-workspace.tsx
create mode 100644 apps/local/src/components/session-switcher.test.tsx
create mode 100644 apps/local/src/components/span-detail.test.tsx
create mode 100644 apps/local/src/components/span-detail.tsx
create mode 100644 apps/local/src/components/trace-workspace.test.tsx
create mode 100644 apps/local/src/components/workspace-sidebar.test.tsx
create mode 100644 apps/local/src/components/workspace-sidebar.tsx
create mode 100644 docs/superpowers/plans/2026-10-26-local-ui-clarity.md
create mode 100644 docs/superpowers/specs/2026-10-26-local-ui-clarity-design.md
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..cf1772574
--- /dev/null
+++ b/apps/local/src/App.capture-lifecycle.test.tsx
@@ -0,0 +1,423 @@
+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";
+
+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;
+
+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 render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+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.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),
+ );
+});
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 82db77a3b..cf48a1f62 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -174,6 +174,36 @@ async function openLatestReceiver() {
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
@@ -204,7 +234,58 @@ describe("local receiver to viewer integration", () => {
window.history.replaceState(null, "", "/");
});
- test("Add receiver is a dismissible modal that keeps the live feed and restores focus", async () => {
+ 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);
@@ -212,13 +293,13 @@ describe("local receiver to viewer integration", () => {
await sendEnvelope(receiver.port, "GET /keep-live");
await screen.findByLabelText("View transaction event");
const trigger = screen.getByRole("button", {
- name: "Add receiver",
+ name: "Switch receiver",
exact: true,
});
fireEvent.click(trigger);
- const dialog = screen.getByRole("dialog", { name: "Add receiver" });
+ const dialog = screen.getByRole("dialog", { name: "Switch receiver" });
expect(document.body.style.pointerEvents).toBe("none");
- expect(screen.queryByRole("button", { name: "Clear events" })).toBeNull();
+ 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);
@@ -245,7 +326,7 @@ describe("local receiver to viewer integration", () => {
}
});
- test("a failed or cancelled Add receiver attempt keeps the current stream and events", async () => {
+ test("a failed or cancelled Switch receiver attempt keeps the current stream and events", async () => {
ControllableEventSource.instances = [];
vi.stubGlobal("EventSource", ControllableEventSource);
renderBareViewer();
@@ -262,7 +343,7 @@ describe("local receiver to viewer integration", () => {
);
});
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://127.0.0.1:8970/stream" },
@@ -273,7 +354,9 @@ describe("local receiver to viewer integration", () => {
const candidate = ControllableEventSource.instances.at(-1)!;
await act(async () => candidate.onerror?.(new Event("error")));
expect(first.closed).toBe(false);
- expect(screen.getByRole("dialog", { name: "Add receiver" })).not.toBeNull();
+ expect(
+ screen.getByRole("dialog", { name: "Switch receiver" }),
+ ).not.toBeNull();
expect(
screen.getByRole("alert", { name: "Receiver connection error" })
.textContent,
@@ -302,7 +385,7 @@ describe("local receiver to viewer integration", () => {
});
fireEvent.click(trigger);
expect(document.body.style.pointerEvents).toBe("none");
- expect(screen.queryByRole("button", { name: "Clear events" })).toBeNull();
+ 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));
@@ -545,7 +628,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");
@@ -560,7 +643,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");
@@ -599,50 +682,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="Add receiver"]'),
- ).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: "Add receiver" }));
- expect(
- await screen.findByRole("dialog", { name: "Add receiver" }),
+ await screen.findByRole("dialog", { name: "Switch receiver" }),
).not.toBeNull();
} finally {
cleanup();
@@ -658,7 +719,7 @@ 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("Connecting to receiver")).not.toBeNull();
@@ -679,7 +740,7 @@ describe("local receiver to viewer integration", () => {
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();
});
@@ -698,18 +759,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 sdk reports 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,
);
@@ -754,7 +815,7 @@ describe("local receiver to viewer integration", () => {
renderViewer(first.port);
await screen.findByLabelText("View transaction event");
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: `http://127.0.0.1:${second.port}/stream` },
@@ -771,7 +832,7 @@ describe("local receiver to viewer integration", () => {
renderBareViewer();
await screen.findByLabelText("View transaction event");
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ 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`;
@@ -796,7 +857,7 @@ describe("local receiver to viewer integration", () => {
"/past-second",
);
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
fireEvent.click(
screen.getByRole("button", {
@@ -828,7 +889,7 @@ describe("local receiver to viewer integration", () => {
renderBareViewer();
await screen.findByText("Connected to local receiver");
window.localStorage.clear();
- fireEvent.click(screen.getByRole("button", { name: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "https://receiver.example/stream?token=abc" },
@@ -881,7 +942,7 @@ describe("local receiver to viewer integration", () => {
);
expect(ControllableEventSource.instances).toHaveLength(0);
expect(
- screen.getByRole("heading", { name: "Connect a receiver" }),
+ screen.getByRole("heading", { name: "Capture a local session" }),
).not.toBeNull();
});
@@ -908,11 +969,11 @@ describe("local receiver to viewer integration", () => {
ControllableEventSource.instances[1]!.onopen?.(new Event("open")),
);
expect(
- screen.getByRole("heading", { name: "Waiting for events" }),
+ screen.getByRole("heading", { name: "No events captured" }),
).not.toBeNull();
} else {
expect(
- screen.getByRole("heading", { name: "Connect a receiver" }),
+ screen.getByRole("heading", { name: "Capture a local session" }),
).not.toBeNull();
}
},
@@ -951,9 +1012,7 @@ describe("local receiver to viewer integration", () => {
const shell = screen.getByRole("complementary", {
name: "Workspace navigation",
});
- fireEvent.click(
- screen.getByRole("button", { name: "Clear events", exact: true }),
- );
+ confirmDeleteCapturedEvents();
await act(async () => deliver("stale queued event", "stale"));
expect(screen.queryByText("stale queued event")).toBeNull();
expect(
@@ -1003,11 +1062,9 @@ describe("local receiver to viewer integration", () => {
const sidebar = screen.getByRole("complementary", {
name: "Workspace navigation",
});
- fireEvent.click(
- screen.getByRole("button", { name: "Clear events", exact: true }),
- );
+ confirmDeleteCapturedEvents();
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.",
);
expect(
screen.getByRole("complementary", { name: "Workspace navigation" }),
@@ -1052,7 +1109,7 @@ describe("local receiver to viewer integration", () => {
},
);
- test("unchanged Add receiver retries a disconnected scoped session without losing its events or selection", async () => {
+ 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[] = [];
@@ -1092,12 +1149,9 @@ describe("local receiver to viewer integration", () => {
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.findByRole("heading", {
- name: "Receiver disconnected",
- exact: true,
- });
+ await screen.findByText("Connection lost", { exact: true });
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
expect(
(screen.getByLabelText("Receiver endpoint") as HTMLInputElement).value,
@@ -1141,15 +1195,12 @@ describe("local receiver to viewer integration", () => {
);
source.onerror?.(new Event("error"));
});
- await screen.findByRole("heading", {
- name: "Receiver disconnected",
- exact: true,
- });
+ 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: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://127.0.0.1:8970/stream" },
@@ -1237,7 +1288,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: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
await act(async () => {
source?.dispatchEvent(
@@ -1268,7 +1319,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: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://127.0.0.1:8970/stream" },
});
@@ -1289,7 +1340,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: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://receiver.example/stream" },
});
@@ -1297,7 +1348,7 @@ describe("local receiver to viewer integration", () => {
expect(source?.closed).toBe(false);
expect(
- screen.getAllByLabelText("Connected to local receiver")[0],
+ screen.getAllByText("Connected to local receiver", { exact: true })[0],
).not.toBeNull();
});
@@ -1372,8 +1423,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(
@@ -1399,7 +1449,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,
@@ -1456,13 +1506,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" }),
@@ -1485,9 +1531,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");
@@ -1519,17 +1563,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 () => {
@@ -1551,21 +1588,15 @@ 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 () => {
@@ -1599,11 +1630,11 @@ describe("local receiver to viewer integration", () => {
{},
{ timeout: 10000 },
);
- fireEvent.change(screen.getByLabelText("Retention limit"), {
+ fireEvent.change(screen.getByLabelText("Envelope limit"), {
target: { value: "2000" },
});
fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
);
expect(
(
@@ -1614,14 +1645,14 @@ describe("local receiver to viewer integration", () => {
).toBe(true);
// The edited input is already 2000 while the HTTP mutation is pending.
expect(
- (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ (screen.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 screen.findByRole(
"button",
- { name: "Apply retention limit" },
+ { name: "Apply envelope limit" },
{ timeout: 6000 },
);
expect((apply as HTMLButtonElement).disabled).toBe(false);
@@ -1676,21 +1707,21 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
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 screen.findByText(
/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 () =>
@@ -1702,7 +1733,7 @@ describe("local receiver to viewer integration", () => {
);
await waitFor(() =>
expect(
- (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ (screen.getByLabelText("Envelope limit") as HTMLInputElement).value,
).toBe("1000"),
);
fireEvent.click(screen.getByRole("button", { name: "Close", exact: true }));
@@ -1711,12 +1742,9 @@ describe("local receiver to viewer integration", () => {
screen.getByRole("button", { name: "Settings", exact: true }),
),
);
- const clearButton = screen.getByRole("button", {
- name: "Clear events",
- exact: true,
- });
+ const clearButton = captureAction("Clear view");
fireEvent.focus(clearButton);
- await screen.findByRole("tooltip", { name: "Clear events", exact: true });
+ expect(document.activeElement).toBe(clearButton);
fireEvent.blur(clearButton);
expect(
screen.getAllByRole("button", { name: "View event event" }),
@@ -1751,9 +1779,11 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
await screen.findByRole("button", { name: "Settings", exact: true }),
);
- await screen.findByText("Reconnect to this receiver to change retention.");
+ await screen.findByText(
+ "Reconnect to this receiver to change history capacity.",
+ );
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(
@@ -1785,10 +1815,8 @@ describe("local receiver to viewer integration", () => {
);
source.dispatchEvent(retained());
});
- await screen.findByRole("heading", { name: "Session closed" });
- fireEvent.click(
- screen.getByRole("button", { name: "Reload saved events" }),
- );
+ 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(
@@ -1796,11 +1824,11 @@ describe("local receiver to viewer integration", () => {
);
const assertReadOnly = () => {
expect(
- screen.getByText("Retention is read-only for closed sessions."),
+ screen.getByText("History capacity is read-only for saved captures."),
).not.toBeNull();
- expect(screen.queryByLabelText("Retention limit")).toBeNull();
+ expect(screen.queryByLabelText("Envelope limit")).toBeNull();
expect(
- screen.queryByRole("button", { name: "Apply retention limit" }),
+ screen.queryByRole("button", { name: "Apply envelope limit" }),
).toBeNull();
};
assertReadOnly();
@@ -1835,18 +1863,20 @@ describe("local receiver to viewer integration", () => {
fireEvent.click(
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: "Close", exact: true }),
);
- fireEvent.click(screen.getByRole("button", { name: "Add receiver" }));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Switch receiver" }),
+ );
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: "http://127.0.0.1:8970/stream" },
});
@@ -1869,7 +1899,7 @@ describe("local receiver to viewer integration", () => {
);
await waitFor(() =>
expect(
- (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ (screen.getByLabelText("Envelope limit") as HTMLInputElement).value,
).toBe("1000"),
);
fireEvent.click(
@@ -1910,7 +1940,7 @@ describe("local receiver to viewer integration", () => {
);
async function connect(url: string) {
fireEvent.click(
- screen.getAllByRole("button", { name: /^Add receiver/ })[0]!,
+ screen.getAllByRole("button", { name: /^Switch receiver/ })[0]!,
);
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: url },
@@ -1932,14 +1962,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);
@@ -1953,11 +1977,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 () =>
@@ -1975,7 +1999,7 @@ 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();
},
);
@@ -1995,10 +2019,8 @@ describe("local receiver to viewer integration", () => {
await act(async () =>
ControllableEventSource.instances[0]!.onopen?.(new Event("open")),
);
- fireEvent.click(
- screen.getByRole("button", { name: "Close telemetry session" }),
- );
- await screen.findByRole("heading", { name: "Session closed" });
+ 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)!;
@@ -2041,7 +2063,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: "Add 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";
@@ -2060,11 +2082,17 @@ 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",
@@ -2077,13 +2105,11 @@ describe("local receiver to viewer integration", () => {
);
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",
@@ -2095,7 +2121,7 @@ describe("local receiver to viewer integration", () => {
}),
);
await waitFor(() => expect(scopedSource?.closed).toBe(true));
- await screen.findByRole("heading", { name: "Session closed" });
+ await screen.findByText("Saved capture", { exact: true });
expect(
screen.queryByText(
"Telemetry session closed. Retained events remain available for three hours.",
@@ -2104,12 +2130,10 @@ describe("local receiver to viewer integration", () => {
expect(screen.getByLabelText("Workspace navigation")).not.toBeNull();
expect(screen.queryByLabelText("Receiver endpoint")).toBeNull();
expect(
- screen.getByRole("button", { name: "Reload saved events" }),
+ screen.getByRole("button", { name: "Reload capture" }),
).not.toBeNull();
- fireEvent.click(
- screen.getByRole("button", { name: "Disconnect receiver" }),
- );
- await screen.findByRole("heading", { name: "Connect a receiver" });
+ fireEvent.click(captureAction("Disconnect viewer"));
+ await screen.findByRole("heading", { name: "Capture a local session" });
expect(
screen.getByRole("region", { name: "Past connections" }),
).not.toBeNull();
@@ -2140,9 +2164,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")));
@@ -2152,12 +2174,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",
@@ -2181,7 +2203,7 @@ describe("local receiver to viewer integration", () => {
await act(async () =>
ControllableEventSource.instances[0]?.onopen?.(new Event("open")),
);
- fireEvent.click(screen.getByRole("button", { name: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: {
value:
@@ -2208,36 +2230,30 @@ 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(
- screen.getByRole("button", { name: "Close telemetry session" }),
- );
+ fireEvent.click(captureAction("Stop capture"));
await waitFor(() => expect(source?.closed).toBe(true));
- await screen.findByRole("heading", { name: "Session closed" });
+ await screen.findByText("Saved capture", { exact: true });
expect(screen.getAllByLabelText("View transaction event")).toHaveLength(
1,
);
expect(
- screen.queryByRole("button", { name: "Close telemetry session" }),
+ screen.queryByRole("button", { name: "Stop capture" }),
).toBeNull();
fireEvent.click(
screen.getByRole("button", {
- name: "Reload saved events",
+ name: "Reload capture",
exact: true,
}),
);
@@ -2252,12 +2268,12 @@ describe("local receiver to viewer integration", () => {
} 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,
@@ -2277,7 +2293,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 {
@@ -2286,7 +2302,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 {
@@ -2299,13 +2315,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");
@@ -2328,7 +2342,7 @@ describe("local receiver to viewer integration", () => {
expect(
(await screen.findByLabelText("View event event")).textContent,
- ).toContain("Error");
+ ).toContain("Exception");
} finally {
cleanup();
await stopReceiver(server);
@@ -2362,48 +2376,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);
@@ -2439,7 +2448,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));
});
@@ -2491,7 +2500,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: "Add receiver" }));
+ 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(
@@ -2500,7 +2509,7 @@ describe("local receiver to viewer integration", () => {
expect(screen.getAllByLabelText("View transaction event")).toHaveLength(
1,
);
- fireEvent.click(screen.getByRole("button", { name: "Add receiver" }));
+ fireEvent.click(screen.getByRole("button", { name: "Switch receiver" }));
fireEvent.change(screen.getByLabelText("Receiver endpoint"), {
target: { value: `http://127.0.0.1:${second.port}/stream` },
});
@@ -2637,7 +2646,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" },
@@ -2660,7 +2669,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");
});
@@ -2679,10 +2688,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);
@@ -2764,12 +2773,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");
@@ -2882,7 +2889,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", {
@@ -2917,15 +2926,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
index 21ea41985..389fa8d6d 100644
--- a/apps/local/src/App.sessions.integration.test.tsx
+++ b/apps/local/src/App.sessions.integration.test.tsx
@@ -86,6 +86,10 @@ async function startDaemon(now = Date.now, preferredPort = 0) {
ingest,
stop: () =>
new Promise((resolve, reject) => {
+ if (!server.listening) {
+ resolve();
+ return;
+ }
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
}),
@@ -127,6 +131,20 @@ async function selectSession(label: string) {
);
}
+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();
@@ -171,12 +189,12 @@ test("hosted viewer switches live sessions without mixing events and replays ret
screen.getByRole("button", { name: "Settings", exact: true }),
);
const limit = (await screen.findByLabelText(
- "Retention limit",
+ "Envelope limit",
)) as HTMLInputElement;
expect(limit.value).toBe("1000");
fireEvent.change(limit, { target: { value: "2000" } });
fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
);
await waitFor(() =>
expect(daemon.plane.sessionStore.resolve(worker.id).maxEnvelopes).toBe(
@@ -187,9 +205,7 @@ test("hosted viewer switches live sessions without mixing events and replays ret
expect(
daemon.plane.sessionStore.getBuffer(worker.id).read({ all: true }),
).toHaveLength(1);
- expect(
- screen.queryByRole("button", { name: "Close telemetry session" }),
- ).toBeNull();
+ 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");
@@ -203,7 +219,7 @@ test("hosted viewer switches live sessions without mixing events and replays ret
fireEvent.click(picker);
await screen.findByRole(
"option",
- { name: /api.*Running/ },
+ { name: /api.*Capturing/ },
{ timeout: 7000 },
);
const search = screen.getByRole("combobox", {
@@ -224,7 +240,7 @@ test("hosted viewer switches live sessions without mixing events and replays ret
daemon.plane.sessionStore.close(worker.id);
fireEvent.click(screen.getByRole("button", { name: "Refresh sessions" }));
fireEvent.click(picker);
- await screen.findByRole("option", { name: /worker.*Retained/ });
+ await screen.findByRole("option", { name: /worker.*Saved/ });
fireEvent.keyDown(
screen.getByRole("combobox", { name: "Search sessions", exact: true }),
{ key: "Escape" },
@@ -288,6 +304,48 @@ test("a scoped stream alone never enables sibling discovery", async () => {
}
});
+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);
@@ -299,7 +357,7 @@ test("expired sessions disappear without clearing already displayed telemetry",
fireEvent.click(
screen.getByRole("combobox", { name: "Session", exact: true }),
);
- await screen.findByRole("option", { name: /web.*Running/ });
+ await screen.findByRole("option", { name: /web.*Capturing/ });
await selectSession("web");
daemon.plane.sessionStore.close(web.id);
now += SESSION_RETENTION_MS;
@@ -332,7 +390,7 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
).toBeNull();
fireEvent.click(
screen.getByRole("button", {
- name: "Add receiver",
+ name: "Switch receiver",
exact: true,
}),
);
@@ -354,17 +412,17 @@ test("pasting a plain receiver discovers active sessions and waits for newly sta
screen.getByRole("combobox", { name: "Session", exact: true })
.textContent,
).toContain("worker");
- expect(screen.queryByText("Retention limit")).toBeNull();
+ 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("Retention limit")) as HTMLInputElement)
+ ((await screen.findByLabelText("Envelope limit")) as HTMLInputElement)
.value,
).toBe("500");
expect(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
).not.toBeNull();
} finally {
cleanup();
@@ -420,16 +478,13 @@ test("reconnecting a plain receiver after daemon restart never selects a stale d
{ key: "Escape" },
);
await first.stop();
- await screen.findByRole("status", {
- name: "Receiver unavailable",
- exact: true,
- });
+ 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: "Add receiver",
+ name: "Switch receiver",
exact: true,
}),
);
@@ -489,10 +544,12 @@ test("retrying a failed CLI-selected stream keeps that session instead of discov
);
await screen.findAllByText("/retry-worker");
expect(screen.queryByText("/retry-web")).toBeNull();
- expect(
- screen.getByRole("combobox", { name: "Session", exact: true })
- .textContent,
- ).toContain("worker");
+ await waitFor(() =>
+ expect(
+ screen.getByRole("combobox", { name: "Session", exact: true })
+ .textContent,
+ ).toContain("worker"),
+ );
} finally {
cleanup();
await daemon.stop();
@@ -523,17 +580,19 @@ test("a CLI viewer query link discovers both runs and does not override the sele
renderViewer();
await screen.findAllByText("/link-worker");
expect(screen.queryByText("/link-web")).toBeNull();
- expect(
- screen.getByRole("combobox", { name: "Session", exact: true })
- .textContent,
- ).toContain("worker");
+ 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 Running badge with Retained", async () => {
+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");
@@ -548,7 +607,7 @@ test("a session lifecycle refresh immediately replaces a stale Running badge wit
expect(
screen.getByRole("combobox", { name: "Session", exact: true })
.textContent,
- ).toContain("Running"),
+ ).toContain("Capturing"),
);
const response = await fetch(
`http://127.0.0.1:${daemon.port}/_local/v1/sessions/${session.id}/close`,
@@ -567,7 +626,7 @@ test("a session lifecycle refresh immediately replaces a stale Running badge wit
expect(
screen.getByRole("combobox", { name: "Session", exact: true })
.textContent,
- ).toContain("Retained"),
+ ).toContain("Saved"),
);
} finally {
cleanup();
@@ -585,18 +644,24 @@ test("close, archive replay, clear and disconnect keep the viewer lifecycle stab
renderSession(web);
await screen.findAllByText("/archived-web");
daemon.plane.sessionStore.close(web.id);
- await screen.findByRole("heading", { name: "Session closed", exact: true });
+ 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 screen.findByRole("heading", { name: "Session closed", exact: true });
- fireEvent.click(
- screen.getByRole("button", { name: "Clear events", exact: true }),
+ await waitFor(() =>
+ expect(
+ screen.getByRole("status", { name: "Capture state" }).textContent,
+ ).toBe("Saved capture"),
);
+ fireEvent.click(captureAction("Clear view"));
await screen.findByRole("heading", {
- name: "No saved events displayed",
+ name: "No events displayed",
exact: true,
});
expect(screen.queryByLabelText("Receiver endpoint")).toBeNull();
@@ -604,14 +669,12 @@ test("close, archive replay, clear and disconnect keep the viewer lifecycle stab
screen.getByRole("complementary", { name: "Workspace navigation" }),
).not.toBeNull();
fireEvent.click(
- screen.getByRole("button", { name: "Reload saved events", exact: true }),
+ screen.getByRole("button", { name: "Reload capture", exact: true }),
);
await screen.findAllByText("/archived-web");
- fireEvent.click(
- screen.getByRole("button", { name: "Disconnect receiver", exact: true }),
- );
+ fireEvent.click(captureAction("Disconnect viewer"));
await screen.findByRole("heading", {
- name: "Connect a receiver",
+ name: "Capture a local session",
exact: true,
});
expect(
@@ -624,7 +687,7 @@ test("close, archive replay, clear and disconnect keep the viewer lifecycle stab
cleanup();
renderViewer();
await screen.findByRole("heading", {
- name: "Connect a receiver",
+ name: "Capture a local session",
exact: true,
});
expect(screen.queryByText("/archived-web")).toBeNull();
@@ -632,7 +695,11 @@ test("close, archive replay, clear and disconnect keep the viewer lifecycle stab
screen.getByRole("button", { name: "Connect", exact: true }),
);
await screen.findAllByText("/archived-web");
- await screen.findByRole("heading", { name: "Session closed", exact: true });
+ await waitFor(() =>
+ expect(
+ screen.getByRole("status", { name: "Capture state" }).textContent,
+ ).toBe("Saved capture"),
+ );
expect(screen.queryByRole("alert")).toBeNull();
} finally {
cleanup();
@@ -660,11 +727,11 @@ test("hosted standalone receiver edits retention without losing saved events", a
screen.getByRole("button", { name: "Settings", exact: true }),
);
const input = (await screen.findByLabelText(
- "Retention limit",
+ "Envelope limit",
)) as HTMLInputElement;
fireEvent.change(input, { target: { value: "1000" } });
fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
);
await waitFor(() =>
expect(retention.getSettings().maxEnvelopes).toBe(1000),
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index e4e29bbea..9d9ae81f0 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1,5 +1,3 @@
-import { ExceptionOverview } from "./components/exception-overview.tsx";
-import { getEventExceptions } from "./lib/exceptions.ts";
import { CliLogo } from "./components/cli-logo.tsx";
import { useReceiverProbe } from "./lib/use-receiver-probe.ts";
import { ReceiverDialog } from "./components/receiver-dialog.tsx";
@@ -13,6 +11,7 @@ import {
} from "./lib/connection-history.ts";
import { SessionSwitcher } from "./components/session-switcher.tsx";
import {
+ type ViewerSession,
getInitialSessionDirectory,
parseSessionDirectoryUrl,
saveSessionDirectory,
@@ -28,35 +27,16 @@ import {
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,
- Unplug,
- Radio,
- Settings,
- Trash2,
- X,
- Search,
- Terminal,
-} 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,
@@ -64,7 +44,17 @@ 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 {
@@ -75,7 +65,7 @@ import {
} from "@/components/ui/tooltip.tsx";
import {
getConnectionPresentation,
- type ConnectionPresentation,
+ getWorkspaceEmptyState,
type ConnectionState,
} from "@/lib/presentation.ts";
import {
@@ -90,15 +80,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,
@@ -116,7 +99,6 @@ type EventEntryProps = {
};
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$/;
@@ -203,6 +185,17 @@ function EventEntry({
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 (
@@ -219,30 +212,31 @@ function EventEntry({
{showTypeBadge ? (
- {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)}
+ {isError && item.type === "transaction" ? (
+ Failed request
+ ) : null}
+ {item.type === "log" && metadata.level ? (
+ {metadata.level}
) : null}
-
+
{formatTimestamp(item.timestamp)}
@@ -250,46 +244,6 @@ function EventEntry({
);
}
-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,
@@ -309,487 +263,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}
-
-
-
- setTab("overview")}
- onKeyDown={handleTabKeyDown}
- >
- Overview
-
- setTab("json")}
- onKeyDown={handleTabKeyDown}
- >
- JSON
-
-
-
- {formatTimestamp(item.timestamp)}
-
-
-
- {tab === "overview" ? (
-
-
- {exceptions.length > 0 ? (
-
- ) : null}
- {hasDetailFields ? (
-
- Captured event details
-
- {detailGroups.map((group) =>
- group.fields.length > 0 ? (
-
-
-
- {group.label}
-
-
- {group.fields.map((field) => {
- const copied = copiedField === field.label;
- return (
-
-
- {field.label}
-
-
-
-
- {field.value}
-
- {
- void copyToClipboard(field.value);
- setCopiedField(field.label);
- }}
- >
- {copied ? (
-
- ) : (
-
- )}
-
-
-
-
- );
- })}
-
- ) : null,
- )}
-
-
- ) : 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;
- onDisconnect: () => void;
- isClearing: boolean;
- onCloseSession?: () => void;
- onChangeReceiver: (trigger: HTMLButtonElement) => void;
- isClosingSession: boolean;
- onOpenSettings: (trigger: HTMLButtonElement) => void;
- onOpenCommand: (trigger: HTMLButtonElement) => void;
- onSelect: (view: WorkspaceView) => void;
- onToggle: () => void;
-};
-
-function WorkspaceSidebar({
- activeView,
- canSearch,
- collapsed,
- connection,
- retainedItemCount,
- snapshot,
- traceCount,
- onClear,
- onDisconnect,
- isClearing,
- onCloseSession,
- onChangeReceiver,
- isClosingSession,
- onOpenSettings,
- onOpenCommand,
- onSelect,
- onToggle,
-}: WorkspaceSidebarProps) {
- const sessionActions = [
- {
- label: "Clear events",
- Icon: Trash2,
- onClick: onClear,
- disabled: retainedItemCount === 0 || isClearing,
- },
- ...(onCloseSession
- ? [
- {
- label: "Close telemetry session",
- Icon: X,
- onClick: onCloseSession,
- disabled: isClosingSession || isClearing,
- },
- ]
- : []),
- {
- label: "Disconnect receiver",
- Icon: Unplug,
- onClick: onDisconnect,
- disabled: false,
- },
- ];
- const statusLabel = connection.label.replace(" to local receiver", "");
- const statusDotClass =
- connection.tone === "success"
- ? "bg-emerald-500"
- : connection.tone === "warning"
- ? "bg-amber-500"
- : "bg-muted-foreground";
-
- const statusPillClass =
- connection.tone === "success"
- ? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
- : connection.tone === "warning"
- ? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
- : "bg-muted text-muted-foreground";
- return (
-
-
- {!collapsed ? (
- <>
-
- >
- ) : null}
-
- {collapsed ? (
-
- ) : (
-
- )}
-
-
-
-
-
- {!collapsed ? (
- {statusLabel}
- ) : null}
- {connection.label}
-
- {canSearch ? (
- onOpenCommand(event.currentTarget)}
- >
-
-
- ) : null}
-
-
- {(["Explore", "Inspect"] as const).map((section) => (
-
- {!collapsed ? (
-
- {section}
-
- ) : null}
- {workspaceNavigation
- .filter((entry) => entry.section === section)
- .map((entry) => {
- const Icon = entry.icon;
- const count =
- entry.id === "traces"
- ? traceCount
- : entry.getItems(snapshot).length;
- const active = activeView === entry.id;
- return (
-
onSelect(entry.id)}
- >
-
- {!collapsed ? (
- <>
-
- {entry.label}
-
- {count > 0 ? (
-
- {count}
-
- ) : null}
- >
- ) : null}
-
- );
- })}
-
- ))}
-
-
-
-
-
- onChangeReceiver(event.currentTarget)}
- className={`h-9 w-full font-normal text-muted-foreground hover:text-foreground ${collapsed ? "px-0" : "gap-2 px-2"}`}
- >
-
- {!collapsed ? Add receiver : null}
-
-
- {collapsed ? (
- Add receiver
- ) : null}
-
-
-
-
- onOpenSettings(event.currentTarget)}
- className={`text-muted-foreground ${collapsed ? "w-full" : ""}`}
- >
-
-
-
-
- Settings
-
-
- {!collapsed ? : null}
- {sessionActions.map(({ label, Icon, onClick, disabled }) => (
-
-
-
-
-
-
-
-
-
- {label === "Close telemetry session"
- ? "Close session"
- : label}
-
-
- ))}
-
-
-
-
- );
-}
-
export default function App() {
const {
navigateToCommandEvent,
@@ -798,14 +271,7 @@ 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,
@@ -818,6 +284,13 @@ export default function App() {
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),
@@ -910,13 +383,20 @@ export default function App() {
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: activeWorkspace.label,
+ singularLabel: activeWorkspace.singularLabel,
+ filtered: Boolean(searchQuery.trim()),
+ state:
+ connection === "failed"
+ ? "offline"
+ : isViewCleared
+ ? "cleared"
+ : isClosedSession
+ ? "saved"
+ : "capturing",
+ });
const selectedItem =
searchedItems.find((item) => item.id === workspaceQuery.eventId) ??
searchedItems[0];
@@ -938,23 +418,6 @@ export default function App() {
);
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)!
@@ -978,7 +441,6 @@ export default function App() {
};
const selectCommandItem = (item: LocalFeedItem) => {
- isClearingCommandSearch.current = true;
setCommandSearchQuery("");
const nextWorkspace = workspaceForItem(item);
const traceId = traceIdForItem(item);
@@ -990,10 +452,10 @@ export default function App() {
markItemsSeen(nextWorkspace);
};
- const clearDisplayedItems = () => {
- isClearingCommandSearch.current = true;
+ const clearDisplayedItems = (markViewCleared = false) => {
setCommandSearchQuery("");
telemetryStore.clear();
+ setIsViewCleared(markViewCleared);
setMessage(undefined);
setLastViewedItemIds({});
void workspaceQuery.clearWorkspace();
@@ -1016,6 +478,10 @@ export default function App() {
setReceiverError(undefined);
setIsEditingReceiver(false);
setIsSettingsOpen(false);
+ setIsDeleteConfirmationOpen(false);
+ setCurrentSession(undefined);
+ setSessionExpiresAt(undefined);
+ setLatestEventTime(undefined);
setIsClearing(false);
clearingGeneration.current = undefined;
closingGeneration.current = undefined;
@@ -1024,15 +490,17 @@ export default function App() {
receiverEndpointEdited.current = false;
clearDisplayedItems();
};
- const clearItems = () => {
- if (isClearing) return;
+ const clearItems = () => clearDisplayedItems(true);
+ const deleteCapturedEvents = () => {
+ if (isClearing || isClosedSession) return;
const endpoint = parseStreamEndpoint(streamUrl);
- clearDisplayedItems();
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;
@@ -1062,10 +530,10 @@ export default function App() {
if (connectionGeneration.current !== generation) return;
setConnection("failed");
setConnectionError(
- "Could not clear receiver data. Reconnect to try again.",
+ "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(() => {
@@ -1107,7 +575,7 @@ export default function App() {
})
.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) {
@@ -1140,6 +608,7 @@ export default function App() {
return;
}
cancelReceiverAttempt();
+ setIsViewCleared(false);
setIsDisconnected(false);
saveDisconnected(false);
setIsClearing(false);
@@ -1148,6 +617,7 @@ export default function App() {
closingGeneration.current = undefined;
setIsClosingSession(false);
setIsSettingsOpen(false);
+ setIsDeleteConfirmationOpen(false);
setReceiverError(undefined);
setMessage(undefined);
if (
@@ -1160,13 +630,16 @@ export default function App() {
}
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();
}
@@ -1242,15 +715,6 @@ export default function App() {
),
};
- useEffect(() => {
- const url = new URL(window.location.href);
- if (url.hash || url.searchParams.has("url")) {
- url.hash = "";
- url.searchParams.delete("url");
- window.history.replaceState(null, "", `${url.pathname}${url.search}`);
- }
- }, []);
-
useEffect(() => {
if (!isConnectionEnabled) {
return;
@@ -1348,6 +812,14 @@ export default function App() {
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.
@@ -1369,6 +841,8 @@ export default function App() {
if (!isActiveConnection()) {
return;
}
+ setIsViewCleared(false);
+ setLatestEventTime(Date.now());
const messageEvent = event as MessageEvent;
const eventId =
messageEvent.lastEventId || `event-${fallbackEventId.current++}`;
@@ -1445,13 +919,17 @@ export default function App() {
const showConnectionLanding =
isDisconnected || (!hasEstablishedConnection && !hasRetainedItems);
const isReceiverUnavailable = showConnectionLanding && !isConnecting;
- const canSearch = hasRetainedItems && !isEditingReceiver && !isSettingsOpen;
+ 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);
};
@@ -1460,8 +938,66 @@ export default function App() {
setCommandOpen(true);
};
+ const selectSpan = (traceId: string, spanId: string) => {
+ 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.
+
+
+ setIsDeleteConfirmationOpen(false)}
+ >
+ Cancel
+
+
+ Delete captured events
+
+
+
+
@@ -1501,6 +1039,9 @@ export default function App() {
retainedItemCount={retainedItemCount}
snapshot={telemetry}
traceCount={traces.length}
+ onDeleteCapturedEvents={
+ canDeleteCapturedEvents ? requestDeleteCapturedEvents : undefined
+ }
onClear={clearItems}
onDisconnect={disconnectReceiver}
isClearing={isClearing}
@@ -1558,13 +1099,13 @@ export default function App() {
{canSearch ? (
openCommand(event.currentTarget)}
>
- Search events and views
+ Search this session
) : (
@@ -1573,6 +1114,11 @@ export default function App() {
- {workspaceNavigation.map((entry) => {
- const Icon = entry.icon;
- const active = workspaceView === entry.id;
- return (
- selectWorkspace(entry.id)}
- >
-
- {entry.label}
-
- );
- })}
+ {workspaceNavigation
+ .filter((entry) => entry.section === "Explore")
+ .map((entry) => {
+ const Icon = entry.icon;
+ const active = workspaceView === entry.id;
+ return (
+ selectWorkspace(entry.id)}
+ >
+
+ {entry.label}
+
+ );
+ })}
+
+
+ Diagnostics
+
+
+ {" "}
+ {workspaceNavigation
+ .filter((entry) => entry.section === "Diagnostics")
+ .map((entry) => {
+ const Icon = entry.icon;
+ const active = workspaceView === entry.id;
+ return (
+ selectWorkspace(entry.id)}
+ >
+
+ {entry.label}
+
+ );
+ })}
+
+
) : null}
@@ -1625,56 +1209,103 @@ export default function App() {
className="flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="Local Sentry events"
>
- {activeDirectoryUrl && !isDisconnected ? (
- connectToEndpoint(streamUrl)}
+ isReloading={isConnecting}
+ isAwaitingSession={Boolean(
+ activeDirectoryUrl &&
currentEndpoint?.kind === "loopback" &&
- new URL(currentEndpoint.url).pathname === "/stream"
+ new URL(currentEndpoint.url).pathname === "/stream",
+ )}
+ sessionPicker={
+ activeDirectoryUrl && !isDisconnected ? (
+
+ ) : undefined
}
- onSelect={connectToEndpoint}
/>
+ ) : activeDirectoryUrl && !isDisconnected ? (
+
+
+
) : null}
{!showConnectionLanding &&
- (isClosedSession || connection === "failed") ? (
-
-
-
- {isClosedSession
- ? "Session closed"
- : "Receiver disconnected"}
-
-
- {isClosedSession
- ? "Events remain available for up to 3 hours."
- : connectionError}
-
- {isClosedSession &&
- connection === "failed" &&
- connectionError ? (
-
- {connectionError}
-
- ) : null}
-
-
connectToEndpoint(streamUrl)}
- >
- {isClosedSession ? "Reload saved events" : "Reconnect"}
-
+ {connectionError}
+
+ ) : null}
+ {!showConnectionLanding ? (
+
+
+
+
+ Filter {activeWorkspace.label}
+
+ {
+ void workspaceQuery.setSearchQuery(event.target.value);
+ }}
+ placeholder={`Within ${activeWorkspace.label.toLowerCase()}`}
+ className="h-8 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ />
+
+ {searchQuery.trim() ? (
+ {
+ void workspaceQuery.setSearchQuery("");
+ }}
+ >
+ Clear filter
+
+ ) : null}
) : null}
{message ? (
@@ -1698,106 +1329,93 @@ export default function App() {
}
/>
) : !hasRetainedItems ? (
-
-
-
- {isClosedSession
- ? "No saved events displayed"
- : connection === "failed"
- ? "No events displayed"
- : "Waiting for events"}
-
-
- {isClosedSession
- ? "Capture has stopped. Reload saved events or connect to another session."
- : connection === "failed"
- ? "Reconnect to resume this feed."
- : "Send events to the selected receiver to see them here."}
-
-
+
) : workspaceView === "traces" ? (
{
+ void workspaceQuery.clearSpan();
+ }}
+ onSelectRelatedItem={selectCommandItem}
onSelect={(traceId) => {
void workspaceQuery.selectTrace(traceId);
}}
/>
) : (
-
@@ -1814,6 +1432,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/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 (
+
+ );
+}
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 76f217eb9..7eb51a31b 100644
--- a/apps/local/src/components/connection-landing.tsx
+++ b/apps/local/src/components/connection-landing.tsx
@@ -1,5 +1,5 @@
import { Check, Copy, History, LoaderCircle, Radio, X } from "lucide-react";
-import { type FormEvent, useId, useState } from "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";
@@ -35,8 +35,6 @@ export function ConnectionForm({
}: ConnectionFormProps) {
const inputId = useId();
const errorId = useId();
- const [copied, setCopied] = useState(false);
- const [, copyToClipboard] = useCopyToClipboard();
const submit = (event: FormEvent) => {
event.preventDefault();
if (!isConnecting) onConnect();
@@ -141,28 +139,6 @@ export function ConnectionForm({
) : null}
-
-
- sentry local serve --open
-
- {
- void copyToClipboard("sentry local serve --open");
- setCopied(true);
- }}
- >
- {copied ? (
-
- ) : (
-
- )}
- {copied ? "Copied" : "Copy command"}
-
-
);
}
@@ -175,6 +151,8 @@ export type ConnectionLandingProps = ConnectionFormProps & {
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 (
- {isProbing ? "Connecting to receiver" : "Connect a receiver"}
+ {isProbing ? "Connecting to receiver" : "Capture a local session"}
{isProbing
@@ -206,7 +184,35 @@ export function ConnectionLanding({ phase, ...form }: ConnectionLandingProps) {
-
+
+
+ Run this in your project to start capturing and open the viewer.
+ Replace {""} with your app’s development command.
+
+
+ {command}
+
+
copyToClipboard(command)}
+ >
+ {copied === 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();
+ }}
>
- Filter current view for “{query.trim()}”
+ Filter {currentViewLabel} for “{query.trim()}”
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..13c49802e
--- /dev/null
+++ b/apps/local/src/components/event-detail.test.tsx
@@ -0,0 +1,205 @@
+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();
+});
diff --git a/apps/local/src/components/event-detail.tsx b/apps/local/src/components/event-detail.tsx
new file mode 100644
index 000000000..e5f44f2e0
--- /dev/null
+++ b/apps/local/src/components/event-detail.tsx
@@ -0,0 +1,249 @@
+import { useCopyToClipboard } from "@uidotdev/usehooks";
+import { useId, useState } 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;
+ 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}
+
+ {
+ void copy(field.value);
+ setCopiedField(field.label);
+ }}
+ >
+ {field.value}
+
+
+
+ ))}
+
+ {copiedField ? (
+
+ {copiedField} copied
+
+ ) : null}
+ >
+ );
+}
+
+/** Prefer the useful view for each telemetry type; keep its full original JSON one tab away. */
+export function EventDetail({
+ item,
+ trace,
+ onSelectSpan,
+ onSelectRelatedItem,
+}: EventDetailProps) {
+ 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",
+ ].includes(item.type);
+ 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 (
+
+
+
+ {exceptions.length ? (
+
+ ) : null}
+ {isRequestOrSpan ? (
+
+ ) : null}
+
+ {attributes.length ? (
+
+ ) : null}
+ {!fields.length &&
+ !(isRequestOrSpan && summaryFields.length) &&
+ !exceptions.length &&
+ !attributes.length ? (
+
+ No additional event details.
+
+ ) : null}
+ {trace ? (
+ <>
+ onSelectSpan(trace.id, spanId)
+ : undefined
+ }
+ />
+
+ >
+ ) : null}
+ >
+ }
+ />
+
+ );
+}
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/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
index 16a43d9fa..e320c05f3 100644
--- a/apps/local/src/components/local-settings.tsx
+++ b/apps/local/src/components/local-settings.tsx
@@ -27,6 +27,8 @@ type Props = {
editable: boolean;
supported: boolean;
unavailableReason?: string;
+ captureLabel?: string;
+ expiresAt?: number;
};
export function LocalSettings({
@@ -34,9 +36,18 @@ export function LocalSettings({
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 (
Settings
- Appearance and session retention.
+ App appearance and capture history.
-
Appearance
+
Appearance
{theme === "system" && resolvedTheme ? (
System appearance: {resolvedTheme === "dark" ? "Dark" : "Light"}
) : null}
+
+ Applies to all captures in this viewer.
+
-
+
+
History capacity
+
+ For capture: {captureLabel ?? "current receiver"}
+
{supported ? (
) : (
@@ -92,6 +110,16 @@ export function LocalSettings({
)}
+ {savedUntil ? (
+
+ Saved until
+
+
+ {savedUntil.toLocaleString()}
+
+
+
+ ) : null}
);
diff --git a/apps/local/src/components/receiver-controls.test.tsx b/apps/local/src/components/receiver-controls.test.tsx
index 720d63b3e..dde094fda 100644
--- a/apps/local/src/components/receiver-controls.test.tsx
+++ b/apps/local/src/components/receiver-controls.test.tsx
@@ -28,10 +28,10 @@ test("mobile receiver actions dismiss with Escape and restore keyboard focus", a
onClear={vi.fn()}
/>,
);
- const trigger = screen.getByRole("button", { name: "Receiver options" });
+ const trigger = screen.getByRole("button", { name: "Capture actions" });
fireEvent.click(trigger);
const dialog = await screen.findByRole("dialog", {
- name: "Receiver actions",
+ name: "Capture actions",
});
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
@@ -73,13 +73,13 @@ test("mobile Settings opens the shared modal without its closing popover stealin
);
}
render(
);
- const trigger = screen.getByRole("button", { name: "Receiver options" });
+ 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: "Receiver actions" })).toBeNull();
+ expect(screen.queryByRole("dialog", { name: "Capture actions" })).toBeNull();
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Light", exact: true }),
@@ -104,12 +104,12 @@ test("mobile actions prevent empty clears and duplicate close requests", async (
onAddReceiver={add}
/>,
);
- const trigger = screen.getByRole("button", { name: "Receiver options" });
+ const trigger = screen.getByRole("button", { name: "Capture actions" });
fireEvent.click(trigger);
const clearButton = await screen.findByRole("button", {
- name: "Clear all events",
+ name: "Clear view",
});
- const closeButton = screen.getByRole("button", { name: "Close session" });
+ 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);
@@ -117,7 +117,7 @@ test("mobile actions prevent empty clears and duplicate close requests", async (
expect(clear).not.toHaveBeenCalled();
expect(close).not.toHaveBeenCalled();
fireEvent.click(
- screen.getByRole("button", { name: "Add receiver", exact: true }),
+ screen.getByRole("button", { name: "Switch receiver", exact: true }),
);
expect(add).toHaveBeenCalledWith(trigger);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
@@ -136,12 +136,47 @@ test("mobile disconnect returns to connections without clearing or closing", asy
onDisconnect={disconnect}
/>,
);
- fireEvent.click(screen.getByRole("button", { name: "Receiver options" }));
+ fireEvent.click(screen.getByRole("button", { name: "Capture actions" }));
fireEvent.click(
- await screen.findByRole("button", { name: "Disconnect receiver" }),
+ 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 69f3f2681..cd5299ee0 100644
--- a/apps/local/src/components/receiver-controls.tsx
+++ b/apps/local/src/components/receiver-controls.tsx
@@ -3,6 +3,7 @@ import {
Radio,
Settings,
Trash2,
+ Eraser,
X,
Unplug,
type LucideIcon,
@@ -16,6 +17,7 @@ type ReceiverControlsProps = {
connection: ConnectionPresentation;
retainedItemCount: number;
onClear: () => void;
+ onDeleteCapturedEvents?: (trigger: HTMLButtonElement) => void;
onDisconnect?: () => void;
isClearing?: boolean;
onOpenSettings?: (trigger: HTMLButtonElement) => void;
@@ -27,10 +29,10 @@ type ReceiverControlsProps = {
type ReceiverAction = {
label: string;
Icon: LucideIcon;
- onClick: () => void;
+ onClick?: () => void;
+ onOpenDialog?: (trigger: HTMLButtonElement) => void;
disabled?: boolean;
destructive?: boolean;
- opensDialog?: boolean;
};
/** Mobile receiver actions share the desktop dialogs and their focus behavior. */
@@ -38,6 +40,7 @@ export function ReceiverControls({
connection,
retainedItemCount,
onClear,
+ onDeleteCapturedEvents,
onDisconnect,
isClearing = false,
onOpenSettings,
@@ -53,12 +56,9 @@ export function ReceiverControls({
...(onAddReceiver
? [
{
- label: "Add receiver",
+ label: "Switch receiver",
Icon: Radio,
- opensDialog: true,
- onClick: () => {
- if (triggerRef.current) onAddReceiver(triggerRef.current);
- },
+ onOpenDialog: onAddReceiver,
},
]
: []),
@@ -67,24 +67,31 @@ export function ReceiverControls({
{
label: "Settings",
Icon: Settings,
- opensDialog: true,
- onClick: () => {
- if (triggerRef.current) onOpenSettings(triggerRef.current);
- },
+ onOpenDialog: onOpenSettings,
},
]
: []),
{
- label: "Clear all events",
- Icon: Trash2,
+ label: "Clear view",
+ Icon: Eraser,
onClick: onClear,
disabled: retainedItemCount === 0 || isClearing,
- destructive: true,
},
+ ...(onDeleteCapturedEvents
+ ? [
+ {
+ label: "Delete captured events",
+ Icon: Trash2,
+ destructive: true,
+ disabled: isClearing,
+ onOpenDialog: onDeleteCapturedEvents,
+ },
+ ]
+ : []),
...(onCloseSession
? [
{
- label: "Close session",
+ label: "Stop capture",
Icon: X,
onClick: onCloseSession,
disabled: isClosingSession || isClearing,
@@ -93,7 +100,7 @@ export function ReceiverControls({
]
: []),
...(onDisconnect
- ? [{ label: "Disconnect receiver", Icon: Unplug, onClick: onDisconnect }]
+ ? [{ label: "Disconnect viewer", Icon: Unplug, onClick: onDisconnect }]
: []),
];
const dotClass =
@@ -109,23 +116,18 @@ export function ReceiverControls({
ref={triggerRef}
type="button"
variant="outline"
- aria-label="Receiver options"
+ aria-label="Capture actions"
title={connection.label}
className="h-8 gap-2 px-2.5"
>
-
+ {showStatusRole ? (
-
- {connection.label.replace(" to local receiver", "")}
-
-
+ ) : null}
+
Capture actions
{
// The destination dialog owns focus when an action opens one.
if (openingDialog.current) event.preventDefault();
@@ -144,7 +146,7 @@ export function ReceiverControls({
}}
>
{actions.map(
- ({ label, Icon, onClick, disabled, destructive, opensDialog }) => (
+ ({ label, Icon, onClick, disabled, destructive, onOpenDialog }) => (
{
- openingDialog.current = Boolean(opensDialog);
+ openingDialog.current = Boolean(onOpenDialog);
setOpen(false);
- onClick();
+ if (onOpenDialog && triggerRef.current) {
+ onOpenDialog(triggerRef.current);
+ } else {
+ onClick?.();
+ }
}}
>
diff --git a/apps/local/src/components/receiver-dialog.tsx b/apps/local/src/components/receiver-dialog.tsx
index 4a4efe041..8bfbb6dcf 100644
--- a/apps/local/src/components/receiver-dialog.tsx
+++ b/apps/local/src/components/receiver-dialog.tsx
@@ -33,7 +33,7 @@ export function ReceiverDialog({
}}
>
- Add receiver
+ Switch receiver
Your current feed stays open while you connect.
diff --git a/apps/local/src/components/resizable-workspace.test.tsx b/apps/local/src/components/resizable-workspace.test.tsx
new file mode 100644
index 000000000..ef73a92ce
--- /dev/null
+++ b/apps/local/src/components/resizable-workspace.test.tsx
@@ -0,0 +1,95 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+import { ResizableWorkspace } from "./resizable-workspace.tsx";
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+test("keyboard resizing is bounded, persists, and preserves inspector space", () => {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
+ width: 900,
+ left: 0,
+ } as DOMRect);
+ const { unmount } = render(
+ Events}
+ listLabel="Event list"
+ storageKey="test.width"
+ >
+ Inspector
+ ,
+ );
+ const separator = screen.getByRole("separator", {
+ name: "Resize Event list",
+ });
+ fireEvent.keyDown(separator, { key: "End" });
+ expect(Number(separator.getAttribute("aria-valuenow"))).toBeLessThanOrEqual(
+ 574,
+ );
+ expect(Number(localStorage.getItem("test.width"))).toBeLessThanOrEqual(574);
+ fireEvent.keyDown(separator, { key: "Home" });
+ expect(separator.getAttribute("aria-valuenow")).toBe("256");
+ fireEvent.keyDown(separator, { key: "ArrowRight" });
+ expect(separator.getAttribute("aria-valuenow")).toBe("272");
+ unmount();
+ render(
+ Events}
+ listLabel="Event list"
+ storageKey="test.width"
+ >
+ Inspector
+ ,
+ );
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe(
+ "272",
+ );
+});
+
+test("invalid saved widths and unavailable storage leave a usable workspace", () => {
+ localStorage.setItem("test.width", "NaN");
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("blocked");
+ });
+ render(
+
+ Inspector
+ ,
+ );
+ const separator = screen.getByRole("separator");
+ fireEvent.keyDown(separator, { key: "ArrowRight" });
+ expect(separator.getAttribute("aria-valuenow")).toBe("368");
+ expect(screen.getByText("Inspector")).toBeDefined();
+});
+
+test("pointer drag resizes the list and cancellation ends the drag", () => {
+ const capture = vi.fn();
+ Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
+ configurable: true,
+ value: capture,
+ });
+ render(
+
+ Inspector
+ ,
+ );
+ const separator = screen.getByRole("separator");
+ fireEvent.pointerDown(separator, { button: 0, clientX: 352, pointerId: 1 });
+ fireEvent.pointerMove(separator, { clientX: 480, pointerId: 1 });
+ expect(separator.getAttribute("aria-valuenow")).toBe("480");
+ fireEvent.pointerCancel(separator, { pointerId: 1 });
+ fireEvent.pointerMove(separator, { clientX: 600, pointerId: 1 });
+ expect(separator.getAttribute("aria-valuenow")).toBe("480");
+ expect(localStorage.getItem("pointer.width")).toBe("480");
+});
diff --git a/apps/local/src/components/resizable-workspace.tsx b/apps/local/src/components/resizable-workspace.tsx
new file mode 100644
index 000000000..60eeb8c86
--- /dev/null
+++ b/apps/local/src/components/resizable-workspace.tsx
@@ -0,0 +1,144 @@
+import {
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+type ResizableWorkspaceProps = {
+ list: ReactNode;
+ children: ReactNode;
+ storageKey: string;
+ listLabel: string;
+ initialWidth?: number;
+};
+
+const MIN_LIST_WIDTH = 256;
+const MAX_LIST_WIDTH = 640;
+const MIN_INSPECTOR_WIDTH = 320;
+const SEPARATOR_WIDTH = 6;
+
+/** Keep the inspector readable while allowing each list's width to follow the user. */
+export function ResizableWorkspace({
+ list,
+ children,
+ storageKey,
+ listLabel,
+ initialWidth = 352,
+}: ResizableWorkspaceProps) {
+ const container = useRef(null);
+ const drag = useRef<{ x: number; width: number } | undefined>(undefined);
+ const [containerWidth, setContainerWidth] = useState(0);
+ const [preferredWidth, setPreferredWidth] = useState(() => {
+ try {
+ const saved = localStorage.getItem(storageKey);
+ const parsed = saved === null ? initialWidth : Number(saved);
+ return Number.isFinite(parsed)
+ ? Math.max(MIN_LIST_WIDTH, Math.min(MAX_LIST_WIDTH, parsed))
+ : initialWidth;
+ } catch {
+ return initialWidth;
+ }
+ });
+ const maxWidth = Math.max(
+ MIN_LIST_WIDTH,
+ Math.min(
+ MAX_LIST_WIDTH,
+ containerWidth
+ ? containerWidth - MIN_INSPECTOR_WIDTH - SEPARATOR_WIDTH
+ : MAX_LIST_WIDTH,
+ ),
+ );
+ const width = Math.max(MIN_LIST_WIDTH, Math.min(maxWidth, preferredWidth));
+ const desktop =
+ containerWidth === 0 ||
+ containerWidth >= MIN_LIST_WIDTH + MIN_INSPECTOR_WIDTH + SEPARATOR_WIDTH;
+
+ useEffect(() => {
+ const element = container.current;
+ if (!element) return;
+ const measure = () =>
+ setContainerWidth(element.getBoundingClientRect().width);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, []);
+
+ function resize(nextWidth: number) {
+ const bounded = Math.max(MIN_LIST_WIDTH, Math.min(maxWidth, nextWidth));
+ setPreferredWidth(bounded);
+ try {
+ localStorage.setItem(storageKey, String(bounded));
+ } catch {
+ // A browser storage policy must not prevent resizing the current view.
+ }
+ }
+
+ return (
+
+
+
{
+ const delta = event.shiftKey ? 64 : 16;
+ const next =
+ event.key === "ArrowLeft"
+ ? width - delta
+ : event.key === "ArrowRight"
+ ? width + delta
+ : event.key === "Home"
+ ? MIN_LIST_WIDTH
+ : event.key === "End"
+ ? maxWidth
+ : undefined;
+ if (next === undefined) return;
+ event.preventDefault();
+ resize(next);
+ }}
+ onPointerDown={(event) => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ event.currentTarget.focus();
+ event.currentTarget.setPointerCapture(event.pointerId);
+ drag.current = { x: event.clientX, width };
+ }}
+ onPointerMove={(event) => {
+ if (drag.current)
+ resize(drag.current.width + event.clientX - drag.current.x);
+ }}
+ onPointerUp={(event) => {
+ drag.current = undefined;
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }}
+ onPointerCancel={() => {
+ drag.current = undefined;
+ }}
+ onLostPointerCapture={() => {
+ drag.current = undefined;
+ }}
+ />
+
+ {children}
+
+
+ );
+}
diff --git a/apps/local/src/components/retention-settings.test.tsx b/apps/local/src/components/retention-settings.test.tsx
index 23b857a2e..a98c38f96 100644
--- a/apps/local/src/components/retention-settings.test.tsx
+++ b/apps/local/src/components/retention-settings.test.tsx
@@ -26,10 +26,10 @@ test.each(["-1", "0", "1.5", "50001", "9007199254740992", ""])(
editable
/>,
);
- const input = screen.getByLabelText("Retention limit");
+ const input = screen.getByLabelText("Envelope limit");
fireEvent.change(input, { target: { value } });
fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
);
expect((await screen.findByRole("alert")).textContent).toContain(
"Enter an integer between 1 and 50,000",
@@ -57,12 +57,10 @@ test("reports a receiver-side retention race without claiming a successful save"
editable
/>,
);
- fireEvent.change(screen.getByLabelText("Retention limit"), {
+ fireEvent.change(screen.getByLabelText("Envelope limit"), {
target: { value: "8" },
});
- fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
- );
+ fireEvent.click(screen.getByRole("button", { name: "Apply envelope limit" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"9 retained envelopes",
);
@@ -70,7 +68,7 @@ test("reports a receiver-side retention race without claiming a successful save"
expect(
(
screen.getByRole("button", {
- name: "Apply retention limit",
+ name: "Apply envelope limit",
}) as HTMLButtonElement
).disabled,
).toBe(false),
@@ -82,7 +80,7 @@ test("reports a receiver-side retention race without claiming a successful save"
expect.objectContaining({ body: '{"maxEnvelopes":8}' }),
);
expect(
- (screen.getByLabelText("Retention limit") as HTMLInputElement).value,
+ (screen.getByLabelText("Envelope limit") as HTMLInputElement).value,
).toBe("8");
expect(screen.getByText("8 envelopes currently retained.")).not.toBeNull();
});
@@ -106,7 +104,7 @@ test.each([0, 1, 8])(
expect(
(
screen.getByRole("spinbutton", {
- name: "Retention limit",
+ name: "Envelope limit",
}) as HTMLInputElement
).value,
).toBe("500");
@@ -125,7 +123,7 @@ test.each([1, 1000])(
editable={false}
/>,
);
- expect(screen.getByText("Retention limit")).not.toBeNull();
+ expect(screen.getByText("Envelope limit")).not.toBeNull();
expect(
screen.getByText(
`${limit.toLocaleString()} ${limit === 1 ? "envelope" : "envelopes"}`,
@@ -153,11 +151,11 @@ test.each([1, 50000])(
editable
/>,
);
- fireEvent.change(screen.getByLabelText("Retention limit"), {
+ fireEvent.change(screen.getByLabelText("Envelope limit"), {
target: { value: String(value) },
});
fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
+ screen.getByRole("button", { name: "Apply envelope limit" }),
);
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
@@ -182,12 +180,10 @@ test("rejects lowering capacity below saved data before sending a request", asyn
editable
/>,
);
- fireEvent.change(screen.getByLabelText("Retention limit"), {
+ fireEvent.change(screen.getByLabelText("Envelope limit"), {
target: { value: "7" },
});
- fireEvent.click(
- screen.getByRole("button", { name: "Apply retention limit" }),
- );
+ fireEvent.click(screen.getByRole("button", { name: "Apply envelope limit" }));
expect((await screen.findByRole("alert")).textContent).toContain(
"Cannot lower the limit below 8",
);
diff --git a/apps/local/src/components/retention-settings.tsx b/apps/local/src/components/retention-settings.tsx
index 8a93ed54f..5cbaeeb5a 100644
--- a/apps/local/src/components/retention-settings.tsx
+++ b/apps/local/src/components/retention-settings.tsx
@@ -33,7 +33,7 @@ export function RetentionSettings({
v.check((value: number) => isValidMaxEnvelopes(value), invalidLimit),
v.check(
(value) => value >= count,
- `Cannot lower the limit below ${count.toLocaleString()} retained ${count === 1 ? "envelope" : "envelopes"}. Clear envelopes first.`,
+ `Cannot lower the limit below ${count.toLocaleString()} retained ${count === 1 ? "envelope" : "envelopes"}. Delete captured events first.`,
),
),
});
@@ -91,6 +91,11 @@ export function RetentionSettings({
form.formState.errors.root?.server?.message;
return (
+
+ Capacity is measured in SDK envelopes. An SDK envelope can contain
+ several events, such as logs, errors, or a transaction. This limit does
+ not extend how long a saved capture is available.
+
{count.toLocaleString()} {count === 1 ? "envelope" : "envelopes"}{" "}
currently retained.
@@ -99,7 +104,7 @@ export function RetentionSettings({
) : (
- Retention limit
+ Envelope limit
{limit.toLocaleString()} {limit === 1 ? "envelope" : "envelopes"}
diff --git a/apps/local/src/components/session-switcher.test.tsx b/apps/local/src/components/session-switcher.test.tsx
new file mode 100644
index 000000000..ac74c60bf
--- /dev/null
+++ b/apps/local/src/components/session-switcher.test.tsx
@@ -0,0 +1,89 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+import { SessionSwitcher } from "./session-switcher.tsx";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+const directoryUrl = "http://127.0.0.1:8969/_local/v1/ui/sessions?cap=test";
+const active = {
+ id: "web",
+ label: "Web app",
+ cwd: "/projects/web",
+ state: "open",
+ streamUrl: "http://127.0.0.1:8969/_local/v1/sessions/web/stream?cap=web",
+};
+const saved = {
+ id: "old",
+ label: "Previous run",
+ cwd: "/projects/web",
+ state: "retained",
+ streamUrl: "http://127.0.0.1:8969/_local/v1/sessions/old/stream?cap=old",
+};
+
+test("reports the current capture metadata and clears it for an unknown stream", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockImplementation(
+ async () => new Response(JSON.stringify({ sessions: [active, saved] })),
+ ),
+ );
+ const onCurrentSessionChange = vi.fn();
+ const props = {
+ directoryUrl,
+ streamUrl: active.streamUrl,
+ onSelect: vi.fn(),
+ onCurrentSessionChange,
+ };
+ const { rerender } = render( );
+ await waitFor(() =>
+ expect(onCurrentSessionChange).toHaveBeenLastCalledWith(active),
+ );
+ expect(screen.getByText("Capturing")).not.toBeNull();
+ rerender(
+ ,
+ );
+ await waitFor(() =>
+ expect(onCurrentSessionChange).toHaveBeenLastCalledWith(undefined),
+ );
+});
+
+test("hides duplicate header state while retaining capturing and saved choices", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockImplementation(
+ async () => new Response(JSON.stringify({ sessions: [active, saved] })),
+ ),
+ );
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ await screen.findByText("Web app");
+ expect(screen.queryByText("Capturing")).toBeNull();
+ fireEvent.click(screen.getByRole("combobox", { name: "Session" }));
+ const option = await screen.findByRole("option", {
+ name: /Previous run · Saved/,
+ });
+ expect(
+ screen.getByRole("option", { name: /Web app · Capturing/ }),
+ ).not.toBeNull();
+ fireEvent.click(option);
+ expect(onSelect).toHaveBeenCalledWith(saved.streamUrl);
+});
diff --git a/apps/local/src/components/session-switcher.tsx b/apps/local/src/components/session-switcher.tsx
index 25ba64a39..c53d09574 100644
--- a/apps/local/src/components/session-switcher.tsx
+++ b/apps/local/src/components/session-switcher.tsx
@@ -24,8 +24,12 @@ export function SessionSwitcher({
selectInitialSession = false,
refreshKey,
onRetainedSession,
+ onCurrentSessionChange,
+ showState = true,
}: {
selectInitialSession?: boolean;
+ onCurrentSessionChange?: (session: ViewerSession | undefined) => void;
+ showState?: boolean;
refreshKey?: string;
onRetainedSession?: (streamUrl: string) => void;
directoryUrl: string;
@@ -78,13 +82,16 @@ export function SessionSwitcher({
if (selectInitialSession && sessions[0]) onSelect(sessions[0].streamUrl);
}, [selectInitialSession, sessions, onSelect]);
const current = sessions.find((s) => s.streamUrl === streamUrl);
+ useEffect(() => {
+ onCurrentSessionChange?.(current);
+ }, [current, onCurrentSessionChange]);
useEffect(() => {
if (current?.state === "retained") onRetainedSession?.(streamUrl);
}, [current?.state, streamUrl, onRetainedSession]);
return (
@@ -102,12 +109,12 @@ export function SessionSwitcher({
{current?.label ?? "Choose session"}
- {current ? (
+ {current && showState ? (
- {current.state === "open" ? "Running" : "Retained"}
+ {current.state === "open" ? "Capturing" : "Saved"}
) : null}
{entries.map((session) => (
{
diff --git a/apps/local/src/components/span-detail.test.tsx b/apps/local/src/components/span-detail.test.tsx
new file mode 100644
index 000000000..00ec3cade
--- /dev/null
+++ b/apps/local/src/components/span-detail.test.tsx
@@ -0,0 +1,74 @@
+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 { SpanDetail } from "./span-detail.tsx";
+
+afterEach(cleanup);
+
+test("span inspection shows its own timing, attributes and JSON plus navigable trace telemetry", () => {
+ const [trace] = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ spans: [
+ {
+ span_id: "query",
+ parent_span_id: "root",
+ description: "SELECT orders",
+ start_timestamp: 1.1,
+ timestamp: 1.2,
+ data: { "db.system": "postgresql" },
+ },
+ ],
+ },
+ ],
+ [
+ { type: "log" },
+ { body: "Query completed", trace_id: "trace", span_id: "query" },
+ ],
+ ],
+ ]),
+ "fixture",
+ ),
+ );
+ const span = trace?.spans.find((entry) => entry.id === "query");
+ if (!trace || !span) throw new Error("Expected trace span");
+ const related = vi.fn();
+ const back = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("table", { name: "Span details" }).textContent,
+ ).toContain("100ms");
+ expect(
+ screen.getByRole("table", { name: "Span attributes" }).textContent,
+ ).toContain("postgresql");
+ fireEvent.click(
+ screen.getByRole("button", { name: "View log Query completed" }),
+ );
+ expect(related).toHaveBeenCalledWith(trace.items[1]);
+ fireEvent.click(screen.getByRole("tab", { name: "JSON" }));
+ expect(screen.getByTestId("highlighted-json").textContent).toContain(
+ "SELECT orders",
+ );
+ expect(screen.getByTestId("highlighted-json").textContent).not.toContain(
+ "GET /orders",
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Back to trace" }));
+ expect(back).toHaveBeenCalledOnce();
+});
diff --git a/apps/local/src/components/span-detail.tsx b/apps/local/src/components/span-detail.tsx
new file mode 100644
index 000000000..07e42341e
--- /dev/null
+++ b/apps/local/src/components/span-detail.tsx
@@ -0,0 +1,293 @@
+import { useCopyToClipboard } from "@uidotdev/usehooks";
+import { Check, Copy, ChevronLeft } from "lucide-react";
+import { type KeyboardEvent, type ReactNode, useId, useState } from "react";
+import type { LocalFeedItem } from "@/lib/spotlight.ts";
+import type { TraceGroup, TraceSpan } from "@/lib/trace-model.ts";
+import { isErrorEvent } from "@/lib/workspace.ts";
+import { JsonView } from "./json-view.tsx";
+
+import {
+ attributeFields,
+ detailRecord,
+ type DetailField,
+} from "./inspector-data.ts";
+
+export function DetailFields({
+ label,
+ fields,
+}: {
+ label: string;
+ fields: DetailField[];
+}) {
+ const [copiedField, setCopiedField] = useState();
+ const [, copy] = useCopyToClipboard();
+ if (!fields.length) return null;
+ return (
+ <>
+
+ {label}
+
+ {fields.map((field) => (
+
+
+ {field.label}
+
+
+
+
+ {field.value}
+
+ {
+ void copy(field.value);
+ setCopiedField(field.label);
+ }}
+ >
+ {copiedField === field.label ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+
+ {copiedField ? (
+
+ {copiedField} copied
+
+ ) : null}
+ >
+ );
+}
+
+/** Both inspectors share the same keyboard tab behavior and labelled panels. */
+export function InspectorTabs({
+ initialTab = "overview",
+ overview,
+ json,
+}: {
+ initialTab?: "overview" | "json";
+ overview: ReactNode;
+ json: string;
+}) {
+ const [tab, setTab] = useState(initialTab);
+ const id = useId();
+ const tabs = ["overview", "json"] as const;
+ function handleKeyDown(event: KeyboardEvent) {
+ const next =
+ event.key === "ArrowRight" || event.key === "ArrowLeft"
+ ? tab === "overview"
+ ? "json"
+ : "overview"
+ : event.key === "End"
+ ? "json"
+ : event.key === "Home"
+ ? "overview"
+ : undefined;
+ if (!next) return;
+ event.preventDefault();
+ setTab(next);
+ document.getElementById(`${id}-${next}`)?.focus();
+ }
+ return (
+ <>
+
+ {tabs.map((value) => (
+ setTab(value)}
+ onKeyDown={handleKeyDown}
+ >
+ {value === "json" ? "JSON" : "Overview"}
+
+ ))}
+
+
+ {tab === "json" ? (
+
+ ) : (
+
{overview}
+ )}
+
+ >
+ );
+}
+
+export function RelatedTelemetry({
+ trace,
+ currentItemId,
+ onSelect,
+}: {
+ trace: TraceGroup;
+ currentItemId?: string;
+ onSelect?: (item: LocalFeedItem) => void;
+}) {
+ const items = trace.items.filter(
+ (item) =>
+ item.id !== currentItemId && (item.type === "log" || isErrorEvent(item)),
+ );
+ if (!items.length) return null;
+ return (
+
+ Errors and logs in this trace
+
+
+ );
+}
+
+export function SpanDetail({
+ span,
+ trace,
+ onBack,
+ onSelectRelatedItem,
+}: {
+ span: TraceSpan;
+ trace: TraceGroup;
+ onBack?: () => void;
+ onSelectRelatedItem?: (item: LocalFeedItem) => void;
+}) {
+ const payload = detailRecord(span.payload);
+ const context = detailRecord(detailRecord(payload.contexts).trace);
+ const fields: DetailField[] = [
+ ...(span.durationMs === undefined
+ ? []
+ : [
+ {
+ label: "Duration",
+ value: `${span.durationMs.toFixed(span.durationMs < 10 ? 2 : 0)}ms`,
+ code: true,
+ },
+ ]),
+ ...(span.startTimestamp === undefined
+ ? []
+ : [
+ {
+ label: "Start timestamp",
+ value: String(span.startTimestamp),
+ code: true,
+ },
+ ]),
+ ...(span.endTimestamp === undefined
+ ? []
+ : [
+ {
+ label: "End timestamp",
+ value: String(span.endTimestamp),
+ code: true,
+ },
+ ]),
+ ...(span.operation
+ ? [{ label: "Operation", value: span.operation, code: true }]
+ : []),
+ ...(span.metadata.spanStatus
+ ? [{ label: "Status", value: span.metadata.spanStatus }]
+ : []),
+ { label: "Span ID", value: span.id, code: true },
+ ...(span.parentId
+ ? [{ label: "Parent span ID", value: span.parentId, code: true }]
+ : []),
+ { label: "Trace ID", value: trace.id, code: true },
+ ];
+ const attributes = attributeFields({
+ ...detailRecord(context.data),
+ ...detailRecord(payload.data),
+ ...detailRecord(payload.attributes),
+ });
+ return (
+
+
+
+
+ {attributes.length ? (
+
+ ) : null}
+
+ >
+ }
+ />
+
+ );
+}
diff --git a/apps/local/src/components/trace-waterfall.test.tsx b/apps/local/src/components/trace-waterfall.test.tsx
index 23fcd91be..8aea70b97 100644
--- a/apps/local/src/components/trace-waterfall.test.tsx
+++ b/apps/local/src/components/trace-waterfall.test.tsx
@@ -97,3 +97,42 @@ test("renders a deeply nested valid span chain without overflowing the stack", (
markup.indexOf(`deep-${count - 1}`),
);
});
+
+test("span descriptions are keyboard-selectable and expose selected state", () => {
+ const [trace] = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ },
+ ],
+ ],
+ ]),
+ "select",
+ ),
+ );
+ if (!trace) throw new Error("Expected trace");
+ let selected: string | undefined;
+ render(
+ {
+ selected = spanId;
+ }}
+ />,
+ );
+ const button = screen.getByRole("button", {
+ name: "Inspect span GET /orders",
+ });
+ expect(button.getAttribute("aria-pressed")).toBe("true");
+ button.click();
+ expect(selected).toBe("root");
+});
diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx
index 850262571..015d20b21 100644
--- a/apps/local/src/components/trace-waterfall.tsx
+++ b/apps/local/src/components/trace-waterfall.tsx
@@ -3,6 +3,8 @@ import type { TraceGroup, TraceSpan } from "@/lib/trace-model.ts";
type TraceWaterfallProps = {
compact?: boolean;
trace: TraceGroup;
+ selectedSpanId?: string;
+ onSelectSpan?: (spanId: string) => void;
};
type PositionedSpan = {
@@ -80,19 +82,24 @@ function SpanRow({
positionedSpan,
trace,
index,
+ selectedSpanId,
+ onSelectSpan,
}: {
positionedSpan: PositionedSpan;
trace: TraceGroup;
index: number;
+ selectedSpanId?: string;
+ onSelectSpan?: (spanId: string) => void;
}) {
const { span, depth, isLastChild } = positionedSpan;
const position = getSpanPosition(span, trace);
+ const visualDepth = Math.min(depth, 4);
const failed = isFailingSpan(span);
return (
>
) : null}
-
+
{span.operation ?? "span"}
-
- {span.description}
-
+ {onSelectSpan ? (
+ onSelectSpan(span.id)}
+ >
+ {span.description}
+
+ ) : (
+
+ {span.description}
+
+ )}
{formatDuration(span.durationMs)}
@@ -161,10 +181,12 @@ function SpanRow({
);
}
-/** A compact, read-only timeline for the spans contained in one local trace. */
+/** A compact, selectable timeline for the spans contained in one local trace. */
export function TraceWaterfall({
compact = false,
trace,
+ selectedSpanId,
+ onSelectSpan,
}: TraceWaterfallProps) {
const capturedItemLabel = `${trace.itemCount} captured item${trace.itemCount === 1 ? "" : "s"} in this trace`;
@@ -177,7 +199,9 @@ export function TraceWaterfall({
Trace
-
{trace.title}
+
+ {trace.title}
+
{formatDuration(trace.durationMs)}
@@ -187,7 +211,9 @@ export function TraceWaterfall({
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
{capturedItemLabel}
- Trace ID {trace.id.slice(0, 8)}
+
+ Trace ID {trace.id.slice(0, 8)}
+
)}
@@ -223,7 +249,9 @@ export function TraceWaterfall({
Trace
-
{trace.title}
+
+ {trace.title}
+
{formatDuration(trace.durationMs)}
@@ -245,14 +273,16 @@ export function TraceWaterfall({
{trace.logCount} log{trace.logCount === 1 ? "" : "s"}
) : null}
- Trace ID {trace.id.slice(0, 8)}
+
+ Trace ID {trace.id.slice(0, 8)}
+
)}
-
+
diff --git a/apps/local/src/components/trace-workspace.test.tsx b/apps/local/src/components/trace-workspace.test.tsx
new file mode 100644
index 000000000..db3562a3e
--- /dev/null
+++ b/apps/local/src/components/trace-workspace.test.tsx
@@ -0,0 +1,134 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { afterEach, expect, test, vi } from "vitest";
+import { decodeEnvelope } from "@/lib/spotlight.ts";
+import { buildTraceGroups } from "@/lib/trace-model.ts";
+import { TraceWorkspace } from "./trace-workspace.tsx";
+
+afterEach(cleanup);
+
+test("selects spans within the trace inspector and returns to the waterfall", () => {
+ const traces = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ },
+ ],
+ ],
+ ]),
+ "trace",
+ ),
+ );
+ const selectSpan = vi.fn();
+ const clearSpan = vi.fn();
+ const { rerender } = render(
+
,
+ );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Inspect span GET /orders" }),
+ );
+ expect(selectSpan).toHaveBeenCalledWith("trace", "root");
+ rerender(
+
,
+ );
+ expect(screen.getByRole("region", { name: "Span detail" })).toBeDefined();
+ expect(screen.queryByRole("table", { name: "Trace spans" })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Back to trace" }));
+ expect(clearSpan).toHaveBeenCalledOnce();
+});
+
+test("a stale trace selection cannot drill into a duplicate span ID on a fallback trace", () => {
+ const traces = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ },
+ ],
+ ],
+ ]),
+ "trace",
+ ),
+ );
+ render(
+
,
+ );
+ expect(screen.queryByRole("region", { name: "Span detail" })).toBeNull();
+ expect(screen.getByRole("table", { name: "Trace spans" })).toBeDefined();
+});
+
+test("drilling into a span and returning restores keyboard focus", () => {
+ const traces = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ },
+ ],
+ ],
+ ]),
+ "trace",
+ ),
+ );
+ function Workspace() {
+ const [selectedSpanId, setSelectedSpanId] = useState
();
+ return (
+ undefined}
+ selectedTraceId="trace"
+ selectedSpanId={selectedSpanId}
+ onSelectSpan={(_traceId, spanId) => setSelectedSpanId(spanId)}
+ onClearSpan={() => setSelectedSpanId(undefined)}
+ />
+ );
+ }
+ render( );
+ fireEvent.click(
+ screen.getByRole("button", { name: "Inspect span GET /orders" }),
+ );
+ const back = screen.getByRole("button", { name: "Back to trace" });
+ expect(document.activeElement).toBe(back);
+ fireEvent.click(back);
+ expect(document.activeElement).toBe(
+ screen.getByRole("button", { name: "Inspect span GET /orders" }),
+ );
+});
diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx
index c6ba2d12f..ccccd7f0a 100644
--- a/apps/local/src/components/trace-workspace.tsx
+++ b/apps/local/src/components/trace-workspace.tsx
@@ -1,3 +1,7 @@
+import { useEffect, useRef } from "react";
+import type { LocalFeedItem } from "../lib/spotlight";
+import { ResizableWorkspace } from "./resizable-workspace";
+import { SpanDetail } from "./span-detail";
import type { TraceGroup } from "../lib/trace-model";
import { TraceWaterfall } from "./trace-waterfall";
@@ -5,6 +9,10 @@ type TraceWorkspaceProps = {
emptyState?: { title: string; description: string };
onSelect: (traceId: string) => void;
selectedTraceId?: string;
+ selectedSpanId?: string;
+ onSelectSpan?: (traceId: string, spanId: string) => void;
+ onClearSpan?: () => void;
+ onSelectRelatedItem?: (item: LocalFeedItem) => void;
traces: TraceGroup[];
};
@@ -28,11 +36,45 @@ export function TraceWorkspace({
},
onSelect,
selectedTraceId,
+ selectedSpanId,
+ onSelectSpan,
+ onClearSpan,
+ onSelectRelatedItem,
traces,
}: TraceWorkspaceProps) {
const selectedTrace =
traces.find((trace) => trace.id === selectedTraceId) ?? traces[0];
+ const selectedSpan =
+ selectedTrace?.id === selectedTraceId
+ ? selectedTrace?.spans.find((span) => span.id === selectedSpanId)
+ : undefined;
+ const inspector = useRef(null);
+ const previousSpan = useRef<{ traceId: string; spanId: string } | undefined>(
+ undefined,
+ );
+ const activeSpanId = selectedSpan?.id;
+ const activeTraceId = selectedTrace?.id;
+ useEffect(() => {
+ if (activeSpanId && activeTraceId) {
+ previousSpan.current = { traceId: activeTraceId, spanId: activeSpanId };
+ inspector.current
+ ?.querySelector("[data-span-back]")
+ ?.focus();
+ } else if (previousSpan.current) {
+ const previous = previousSpan.current;
+ previousSpan.current = undefined;
+ if (previous.traceId === activeTraceId) {
+ const button = Array.from(
+ inspector.current?.querySelectorAll(
+ "[data-span-id]",
+ ) ?? [],
+ ).find((entry) => entry.dataset.spanId === previous.spanId);
+ button?.focus();
+ }
+ }
+ }, [activeSpanId, activeTraceId]);
+
if (!selectedTrace) {
return (
-
-
-
- Traces
-
-
- {traces.length} trace{traces.length === 1 ? "" : "s"}
-
-
+
+
+
+ Traces
+
+
+ {traces.length} trace{traces.length === 1 ? "" : "s"}
+
+
-
- {traces.map((trace) => {
- const isSelected = trace.id === selectedTrace.id;
+
+ {traces.map((trace) => {
+ const isSelected = trace.id === selectedTrace.id;
- return (
-
- onSelect(trace.id)}
- >
-
-
- {trace.title}
-
-
-
{trace.spans.length} spans
- {trace.errorCount > 0 ? (
-
- {trace.errorCount} errors
-
- ) : null}
- {trace.logCount > 0 ? (
-
{trace.logCount} logs
- ) : null}
+ return (
+
+ onSelect(trace.id)}
+ >
+
+
+ {trace.title}
+
+
+ {trace.spans.length} spans
+ {trace.errorCount > 0 ? (
+
+ {trace.errorCount} errors
+
+ ) : null}
+ {trace.logCount > 0 ? (
+ {trace.logCount} logs
+ ) : null}
+
-
-
- {formatDuration(trace.durationMs)}
-
-
-
- );
- })}
-
-
-
-
-
+
+ {formatDuration(trace.durationMs)}
+
+
+
+ );
+ })}
+
+ >
+ }
+ >
+
+ {selectedSpan ? (
+
+ ) : (
+ onSelectSpan(selectedTrace.id, spanId)
+ : undefined
+ }
+ />
+ )}
-
+
);
}
diff --git a/apps/local/src/components/ui/command.tsx b/apps/local/src/components/ui/command.tsx
index 888ebee4c..4f1521eaa 100644
--- a/apps/local/src/components/ui/command.tsx
+++ b/apps/local/src/components/ui/command.tsx
@@ -52,7 +52,10 @@ function CommandDialog({
{title}
{description}
-
+
{children}
diff --git a/apps/local/src/components/workspace-sidebar.test.tsx b/apps/local/src/components/workspace-sidebar.test.tsx
new file mode 100644
index 000000000..b0dd621a6
--- /dev/null
+++ b/apps/local/src/components/workspace-sidebar.test.tsx
@@ -0,0 +1,119 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+import { createLocalTelemetryStore } from "../lib/telemetry-store.ts";
+import { workspacePath } from "../lib/workspace.ts";
+import {
+ WorkspaceSidebar,
+ type WorkspaceSidebarProps,
+} from "./workspace-sidebar.tsx";
+
+afterEach(cleanup);
+function sidebarProps(): WorkspaceSidebarProps {
+ return {
+ activeView: "live",
+ canSearch: true,
+ collapsed: false,
+ connection: { label: "Connected to local receiver", tone: "success" },
+ retainedItemCount: 0,
+ snapshot: createLocalTelemetryStore().getSnapshot(),
+ traceCount: 0,
+ onClear: vi.fn(),
+ onDisconnect: vi.fn(),
+ isClearing: false,
+ onChangeReceiver: vi.fn(),
+ isClosingSession: false,
+ onOpenSettings: vi.fn(),
+ onOpenCommand: vi.fn(),
+ onSelect: vi.fn(),
+ onToggle: vi.fn(),
+ };
+}
+
+test("keeps core views prominent and reveals diagnostics without changing compatible routes", () => {
+ const props = sidebarProps();
+ render( );
+ fireEvent.click(screen.getByRole("button", { name: "Open events view" }));
+ expect(props.onSelect).toHaveBeenCalledWith("live");
+ expect(workspacePath("live")).toBe("/live");
+ expect(
+ screen.queryByRole("button", { name: "Open sdk reports view" }),
+ ).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Diagnostics" }));
+ fireEvent.click(
+ screen.getByRole("button", { name: "Open sdk reports view" }),
+ );
+ expect(props.onSelect).toHaveBeenCalledWith("sdks");
+ expect(workspacePath("sdks")).toBe("/sdks");
+});
+
+test("opens diagnostics for the selected route and lets users collapse it", () => {
+ render( );
+ expect(
+ screen
+ .getByRole("button", { name: "Open envelopes view" })
+ .getAttribute("aria-current"),
+ ).toBe("page");
+ const diagnostics = screen.getByRole("button", { name: "Diagnostics" });
+ expect(diagnostics.getAttribute("aria-expanded")).toBe("true");
+ fireEvent.click(diagnostics);
+ expect(
+ screen.queryByRole("button", { name: "Open envelopes view" }),
+ ).toBeNull();
+});
+
+test("labels session-wide search and hands its persistent trigger to the command dialog", () => {
+ const props = sidebarProps();
+ render( );
+ const search = screen.getByRole("button", { name: "Search this session" });
+ expect(search.textContent).toContain("Search this session");
+ fireEvent.click(search);
+ expect(props.onOpenCommand).toHaveBeenCalledWith(search);
+ expect(screen.queryByRole("status")).toBeNull();
+});
+
+test("shares capture actions and capability-gated deletion with the mobile control", async () => {
+ const props = sidebarProps();
+ const remove = vi.fn();
+ const { rerender } = render( );
+ const trigger = screen.getByRole("button", { name: "Capture actions" });
+ const switchReceiver = screen.getByRole("button", {
+ name: "Switch receiver",
+ });
+ fireEvent.click(switchReceiver);
+ expect(props.onChangeReceiver).toHaveBeenCalledWith(switchReceiver);
+ const settings = screen.getByRole("button", {
+ name: "Settings",
+ exact: true,
+ });
+ fireEvent.click(settings);
+ expect(props.onOpenSettings).toHaveBeenCalledWith(settings);
+ fireEvent.click(trigger);
+ expect(
+ screen.queryByRole("button", { name: "Delete captured events" }),
+ ).toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Clear view" }).hasAttribute("disabled"),
+ ).toBe(true);
+ fireEvent.keyDown(screen.getByRole("dialog", { name: "Capture actions" }), {
+ key: "Escape",
+ });
+ rerender( );
+ fireEvent.click(trigger);
+ fireEvent.click(
+ await screen.findByRole("button", { name: "Delete captured events" }),
+ );
+ expect(remove).toHaveBeenCalledWith(trigger);
+});
+
+test("keeps search and actions keyboard named when the sidebar is collapsed", () => {
+ const props = sidebarProps();
+ render( );
+ expect(
+ screen.getByRole("button", { name: "Search this session" }),
+ ).not.toBeNull();
+ expect(
+ screen.getByRole("button", { name: "Capture actions" }),
+ ).not.toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Expand sidebar" }));
+ expect(props.onToggle).toHaveBeenCalledOnce();
+});
diff --git a/apps/local/src/components/workspace-sidebar.tsx b/apps/local/src/components/workspace-sidebar.tsx
new file mode 100644
index 000000000..ccfd3effc
--- /dev/null
+++ b/apps/local/src/components/workspace-sidebar.tsx
@@ -0,0 +1,241 @@
+import {
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ Radio,
+ Search,
+ Settings,
+ SlidersHorizontal,
+} from "lucide-react";
+import { useId, useState } from "react";
+import type { ConnectionPresentation } from "../lib/presentation.ts";
+import type { LocalTelemetrySnapshot } from "../lib/telemetry-store.ts";
+import type { WorkspaceView } from "../lib/workspace.ts";
+import {
+ workspaceNavigation,
+ type WorkspaceNavigationItem,
+} from "../lib/workspace-navigation.ts";
+import { CliLogo } from "./cli-logo.tsx";
+import { ReceiverControls } from "./receiver-controls.tsx";
+import { Button } from "./ui/button.tsx";
+
+export type WorkspaceSidebarProps = {
+ activeView: WorkspaceView;
+ canSearch: boolean;
+ collapsed: boolean;
+ connection: ConnectionPresentation;
+ retainedItemCount: number;
+ snapshot: LocalTelemetrySnapshot;
+ traceCount: number;
+ onClear: () => void;
+ onDisconnect: () => void;
+ isClearing: boolean;
+ onCloseSession?: () => void;
+ onChangeReceiver: (trigger: HTMLButtonElement) => void;
+ isClosingSession: boolean;
+ onOpenSettings: (trigger: HTMLButtonElement) => void;
+ onOpenCommand: (trigger: HTMLButtonElement) => void;
+ onSelect: (view: WorkspaceView) => void;
+ onToggle: () => void;
+ onDeleteCapturedEvents?: (trigger: HTMLButtonElement) => void;
+};
+
+/** The main debugging views stay visible; secondary SDK data is discoverable. */
+export function WorkspaceSidebar({
+ activeView,
+ canSearch,
+ collapsed,
+ connection,
+ retainedItemCount,
+ snapshot,
+ traceCount,
+ onClear,
+ onDisconnect,
+ isClearing,
+ onCloseSession,
+ onChangeReceiver,
+ isClosingSession,
+ onOpenSettings,
+ onOpenCommand,
+ onSelect,
+ onToggle,
+ onDeleteCapturedEvents,
+}: WorkspaceSidebarProps) {
+ const diagnosticsId = useId();
+ const activeDiagnostics = workspaceNavigation.some(
+ (entry) => entry.id === activeView && entry.section === "Diagnostics",
+ );
+ const [diagnosticsState, setDiagnosticsState] = useState({
+ view: activeView,
+ open: activeDiagnostics,
+ });
+ const diagnosticsOpen =
+ diagnosticsState.view === activeView
+ ? diagnosticsState.open
+ : activeDiagnostics || diagnosticsState.open;
+
+ const renderEntry = (entry: WorkspaceNavigationItem) => {
+ const Icon = entry.icon;
+ const count =
+ entry.id === "traces" ? traceCount : entry.getItems(snapshot).length;
+ const active = activeView === entry.id;
+ return (
+ onSelect(entry.id)}
+ >
+
+ {!collapsed ? (
+ <>
+ {entry.label}
+ {count > 0 ? (
+
+ {count}
+
+ ) : null}
+ >
+ ) : null}
+
+ );
+ };
+
+ return (
+
+
+ {!collapsed ? (
+ <>
+
+ onOpenSettings(event.currentTarget)}
+ >
+
+
+ >
+ ) : null}
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {canSearch ? (
+
+ onOpenCommand(event.currentTarget)}
+ >
+
+ {!collapsed ? Search this session : null}
+
+
+ ) : null}
+
+
+
+ Explore
+
+ {workspaceNavigation
+ .filter((entry) => entry.section === "Explore")
+ .map(renderEntry)}
+
+
+
+ setDiagnosticsState({ view: activeView, open: !diagnosticsOpen })
+ }
+ >
+ {collapsed ? (
+
+ ) : (
+ <>
+ Diagnostics
+
+ >
+ )}
+
+
+ {workspaceNavigation
+ .filter((entry) => entry.section === "Diagnostics")
+ .map(renderEntry)}
+
+
+
+ span]:sr-only" : "[&_[data-slot=popover-trigger]]:w-full"}`}
+ >
+ {!collapsed ? (
+ onChangeReceiver(event.currentTarget)}
+ >
+
+ Switch receiver
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/local/src/lib/presentation.test.ts b/apps/local/src/lib/presentation.test.ts
index a1b9617ba..87ab75204 100644
--- a/apps/local/src/lib/presentation.test.ts
+++ b/apps/local/src/lib/presentation.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "vitest";
-import { getConnectionPresentation } from "./presentation.ts";
+import {
+ getConnectionPresentation,
+ getWorkspaceEmptyState,
+} from "./presentation.ts";
+import { workspaceViews } from "./workspace.ts";
describe("getConnectionPresentation", () => {
test("describes an active local receiver as connected", () => {
@@ -22,4 +26,86 @@ describe("getConnectionPresentation", () => {
tone: "warning",
});
});
+
+ test("describes a stopped capture without implying the receiver disconnected", () => {
+ expect(getConnectionPresentation("closed")).toEqual({
+ label: "Capture stopped",
+ tone: "neutral",
+ });
+ });
+});
+
+describe("getWorkspaceEmptyState", () => {
+ const logs = {
+ view: "logs" as const,
+ label: "Logs",
+ singularLabel: "log",
+ filtered: false,
+ };
+
+ test("filtered results take precedence over every lifecycle state", () => {
+ for (const state of ["capturing", "saved", "offline", "cleared"] as const) {
+ expect(
+ getWorkspaceEmptyState({ ...logs, filtered: true, state }),
+ ).toEqual(
+ getWorkspaceEmptyState({ ...logs, filtered: true, state: "capturing" }),
+ );
+ }
+ });
+
+ test("filtered results name their scope and offer filter recovery", () => {
+ const empty = getWorkspaceEmptyState({
+ ...logs,
+ filtered: true,
+ state: "saved",
+ });
+ expect(empty.title).toBe("No matching logs");
+ expect(empty.description).toMatch(/adjust.*filter|clear/i);
+ });
+
+ test("capturing views offer a relevant telemetry trigger", () => {
+ const triggers = {
+ live: /send.*event/i,
+ traces: /instrumented.*request|operation/i,
+ errors: /error.*failed request/i,
+ logs: /structured log/i,
+ ai: /instrumented AI/i,
+ envelopes: /SDK.*envelope/i,
+ sdks: /session.*client report/i,
+ feedback: /submit.*feedback/i,
+ profiles: /profiling/i,
+ };
+ for (const view of workspaceViews) {
+ expect(
+ getWorkspaceEmptyState({
+ ...logs,
+ view,
+ singularLabel: view === "live" ? "event" : logs.singularLabel,
+ state: "capturing",
+ }).description,
+ ).toMatch(triggers[view]);
+ }
+ });
+
+ test("saved captures explain the absence without promising new events", () => {
+ const empty = getWorkspaceEmptyState({ ...logs, state: "saved" });
+ expect(empty.title).toBe("No logs saved");
+ expect(empty.description).toMatch(/capture has stopped/i);
+ expect(empty.description).not.toMatch(/will appear|waiting|send|trigger/i);
+ });
+
+ test("offline views offer reconnection and preserve the tab's history", () => {
+ const empty = getWorkspaceEmptyState({ ...logs, state: "offline" });
+ expect(empty.title).toBe("No logs displayed");
+ expect(empty.description).toMatch(/reconnect/i);
+ expect(empty.description).toMatch(/history.*remain|retain.*history/i);
+ });
+
+ test("a cleared tab distinguishes reloadable history from receiver deletion", () => {
+ const empty = getWorkspaceEmptyState({ ...logs, state: "cleared" });
+ expect(empty.title).toBe("No logs displayed");
+ expect(empty.description).toMatch(/Clear view only affects this tab/);
+ expect(empty.description).toMatch(/reload.*capture/i);
+ expect(empty.description).toMatch(/receiver.*trim/i);
+ });
});
diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts
index ccadd87ce..ab1f1cc4e 100644
--- a/apps/local/src/lib/presentation.ts
+++ b/apps/local/src/lib/presentation.ts
@@ -18,7 +18,7 @@ export function getConnectionPresentation(
case "connected":
return { label: "Connected to local receiver", tone: "success" };
case "closed":
- return { label: "Telemetry session closed", tone: "neutral" };
+ return { label: "Capture stopped", tone: "neutral" };
case "connecting":
return { label: "Connecting to local receiver", tone: "neutral" };
case "reconnecting":
@@ -29,3 +29,62 @@ export function getConnectionPresentation(
return { label: "Receiver unavailable", tone: "warning" };
}
}
+import type { WorkspaceView } from "./workspace.ts";
+
+export function getWorkspaceEmptyState({
+ view,
+ label,
+ singularLabel,
+ filtered,
+ state,
+}: {
+ view: WorkspaceView;
+ label: string;
+ singularLabel: string;
+ filtered: boolean;
+ state: "capturing" | "saved" | "offline" | "cleared";
+}): { title: string; description: string } {
+ const items = label.toLowerCase();
+ if (filtered) {
+ return {
+ title: `No matching ${items}`,
+ description: `Adjust the filter or clear it to see all ${items} in this capture.`,
+ };
+ }
+ if (state === "saved") {
+ return {
+ title: `No ${items} saved`,
+ description: `This capture has stopped and its saved history contains no ${items}.`,
+ };
+ }
+ if (state === "offline") {
+ return {
+ title: `No ${items} displayed`,
+ description:
+ "Reconnect to the receiver to load available capture history. History already displayed remains in this tab.",
+ };
+ }
+ if (state === "cleared") {
+ return {
+ title: `No ${items} displayed`,
+ description:
+ "Clear view only affects this tab. Reload the capture to restore saved history; the receiver may have trimmed older events.",
+ };
+ }
+ const triggers: Record = {
+ live: `Send an SDK ${singularLabel} from your app to this receiver.`,
+ traces:
+ "Trigger an instrumented request or operation in your app to send a trace to this receiver.",
+ errors:
+ "Trigger a test error or failed request in your app and send it to this receiver.",
+ logs: "Send a structured log through your app's Sentry SDK to this receiver.",
+ ai: "Run an instrumented AI operation in your app to send its spans to this receiver.",
+ envelopes:
+ "Send an event through your app's Sentry SDK to inspect its raw envelope here.",
+ sdks: "Run your app with SDK session tracking or client reporting enabled to send session and client reports.",
+ feedback: "Submit user feedback through a supported SDK to this receiver.",
+ profiles:
+ "Enable SDK profiling and run an operation in your app to send profiling data to this receiver.",
+ };
+ return { title: `No ${items} captured`, description: triggers[view] };
+}
diff --git a/apps/local/src/lib/session-directory.test.ts b/apps/local/src/lib/session-directory.test.ts
index 3aeeb9348..635bab042 100644
--- a/apps/local/src/lib/session-directory.test.ts
+++ b/apps/local/src/lib/session-directory.test.ts
@@ -72,3 +72,34 @@ test("invalid fragment discovery is never persisted", () => {
window.history.replaceState(null, "", "/");
expect(getInitialSessionDirectory(stream)).toBeUndefined();
});
+
+test("optional saved-capture deadlines preserve older directories and reject invalid timestamps", () => {
+ const saved = {
+ id: "web",
+ label: "web",
+ cwd: "/web",
+ state: "retained",
+ streamUrl: stream,
+ };
+ expect(parseViewerSessions({ sessions: [saved] }, directory)).toEqual([
+ saved,
+ ]);
+ const expiresAt = 1_795_000_000_000;
+ expect(
+ parseViewerSessions({ sessions: [{ ...saved, expiresAt }] }, directory),
+ ).toEqual([{ ...saved, expiresAt }]);
+ for (const expiresAt of [
+ 0,
+ -1,
+ 1.5,
+ Number.NaN,
+ Infinity,
+ Number.MAX_SAFE_INTEGER + 1,
+ "1795000000000",
+ null,
+ ]) {
+ expect(() =>
+ parseViewerSessions({ sessions: [{ ...saved, expiresAt }] }, directory),
+ ).toThrow("Invalid session");
+ }
+});
diff --git a/apps/local/src/lib/session-directory.ts b/apps/local/src/lib/session-directory.ts
index 182018c25..a741da17f 100644
--- a/apps/local/src/lib/session-directory.ts
+++ b/apps/local/src/lib/session-directory.ts
@@ -7,6 +7,7 @@ export type ViewerSession = {
cwd: string;
state: "open" | "retained";
streamUrl: string;
+ expiresAt?: number;
};
/** Discovery is opt-in and stays on the already selected loopback daemon. */
@@ -81,6 +82,14 @@ export function parseViewerSessions(
if (!entry || typeof entry !== "object" || Array.isArray(entry))
throw new Error("Invalid session");
const session = entry as Record;
+ const expiresAt = session.expiresAt;
+ if (
+ expiresAt !== undefined &&
+ (typeof expiresAt !== "number" ||
+ !Number.isSafeInteger(expiresAt) ||
+ expiresAt <= 0)
+ )
+ throw new Error("Invalid session");
if (
typeof session.id !== "string" ||
!session.id ||
@@ -106,6 +115,7 @@ export function parseViewerSessions(
cwd: session.cwd,
state: session.state,
streamUrl: stream.url,
+ ...(typeof expiresAt === "number" && { expiresAt }),
};
})
.sort(
diff --git a/apps/local/src/lib/trace-model.test.ts b/apps/local/src/lib/trace-model.test.ts
index 70af66ae7..32c6c10bd 100644
--- a/apps/local/src/lib/trace-model.test.ts
+++ b/apps/local/src/lib/trace-model.test.ts
@@ -408,3 +408,37 @@ describe("buildTraceGroups", () => {
]);
});
});
+
+test("child spans retain their own raw payload independently of the transaction", () => {
+ const payload = {
+ span_id: "query",
+ parent_span_id: "root",
+ op: "db.query",
+ data: { "db.system": "postgresql" },
+ start_timestamp: 1.1,
+ timestamp: 1.2,
+ };
+ const [trace] = buildTraceGroups(
+ decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "transaction" },
+ {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ spans: [payload],
+ },
+ ],
+ ],
+ ]),
+ "payload",
+ ),
+ );
+ expect(trace?.spans.find((span) => span.id === "query")?.payload).toEqual(
+ payload,
+ );
+});
diff --git a/apps/local/src/lib/trace-model.ts b/apps/local/src/lib/trace-model.ts
index 5abc4d388..f38be8afe 100644
--- a/apps/local/src/lib/trace-model.ts
+++ b/apps/local/src/lib/trace-model.ts
@@ -14,6 +14,7 @@ export type TraceSpan = {
endTimestamp?: number;
durationMs?: number;
item: LocalFeedItem;
+ payload: unknown;
metadata: EventMetadata;
children: TraceSpan[];
};
@@ -97,6 +98,7 @@ function getPayloadSpan(
endTimestamp,
durationMs: getDurationMs(startTimestamp, endTimestamp),
item,
+ payload,
metadata: item.metadata ?? getEventMetadata(payload, item.type),
children: [],
};
@@ -129,6 +131,7 @@ function getChildSpans(payload: unknown, item: LocalFeedItem): TraceSpan[] {
endTimestamp,
durationMs: getDurationMs(startTimestamp, endTimestamp),
item,
+ payload: value,
metadata: getEventMetadata(value, "span"),
children: [],
},
diff --git a/apps/local/src/lib/workspace-navigation.ts b/apps/local/src/lib/workspace-navigation.ts
index f57e48d15..7ee4b166b 100644
--- a/apps/local/src/lib/workspace-navigation.ts
+++ b/apps/local/src/lib/workspace-navigation.ts
@@ -18,7 +18,7 @@ export type WorkspaceNavigationItem = {
id: WorkspaceView;
label: string;
singularLabel: string;
- section: "Explore" | "Inspect";
+ section: "Explore" | "Diagnostics";
emptyState: {
title: string;
description: string;
@@ -30,7 +30,7 @@ export type WorkspaceNavigationItem = {
export const workspaceNavigation: WorkspaceNavigationItem[] = [
{
id: "live",
- label: "Live Activity",
+ label: "Events",
singularLabel: "event",
section: "Explore",
emptyState: {
@@ -95,7 +95,7 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
id: "envelopes",
label: "Envelopes",
singularLabel: "envelope",
- section: "Inspect",
+ section: "Diagnostics",
emptyState: {
title: "No envelopes received",
description: "Raw envelopes received by this viewer will appear here.",
@@ -105,9 +105,9 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
},
{
id: "sdks",
- label: "Sessions & SDKs",
+ label: "SDK reports",
singularLabel: "session or client report",
- section: "Inspect",
+ section: "Diagnostics",
emptyState: {
title: "No sessions or client reports",
description: "SDK sessions and client reports will appear here.",
@@ -119,7 +119,7 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
id: "feedback",
label: "Feedback",
singularLabel: "feedback item",
- section: "Inspect",
+ section: "Diagnostics",
emptyState: {
title: "No feedback received",
description:
@@ -132,7 +132,7 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
id: "profiles",
label: "Profiles",
singularLabel: "profile",
- section: "Inspect",
+ section: "Diagnostics",
emptyState: {
title: "No profiles captured",
description: "Profiling data from supported SDKs will appear here.",
diff --git a/apps/local/src/lib/workspace-query.test.tsx b/apps/local/src/lib/workspace-query.test.tsx
index 6cff806c8..e6406d416 100644
--- a/apps/local/src/lib/workspace-query.test.tsx
+++ b/apps/local/src/lib/workspace-query.test.tsx
@@ -52,4 +52,28 @@ describe("useWorkspaceQueryState", () => {
}),
);
});
+ test("tracks a selected span in history and clears it on other selections", async () => {
+ const onUrlUpdate = vi.fn();
+ const { result } = renderHook(() => useWorkspaceQueryState(), {
+ wrapper: withNuqsTestingAdapter({ hasMemory: true, onUrlUpdate }),
+ });
+ await act(() => result.current.selectSpan("trace-1", "span-2"));
+ expect(result.current).toMatchObject({
+ traceId: "trace-1",
+ spanId: "span-2",
+ });
+ expect(onUrlUpdate).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ options: expect.objectContaining({ history: "push" }),
+ queryString: "?trace=trace-1&span=span-2",
+ }),
+ );
+ await act(() => result.current.clearSpan());
+ expect(result.current.spanId).toBeNull();
+ expect(result.current.traceId).toBe("trace-1");
+ await act(() => result.current.selectSpan("trace-1", "span-2"));
+ await act(() => result.current.selectEvent("event-1"));
+ expect(result.current.spanId).toBeNull();
+ expect(result.current.traceId).toBeNull();
+ });
});
diff --git a/apps/local/src/lib/workspace-query.ts b/apps/local/src/lib/workspace-query.ts
index ae062f17d..96e304179 100644
--- a/apps/local/src/lib/workspace-query.ts
+++ b/apps/local/src/lib/workspace-query.ts
@@ -3,6 +3,7 @@ import { parseAsString, useQueryStates } from "nuqs";
const workspaceQueryParsers = {
event: parseAsString,
trace: parseAsString,
+ span: parseAsString,
q: parseAsString.withDefault(""),
};
@@ -13,14 +14,33 @@ export function useWorkspaceQueryState() {
eventId: state.event,
query: state.q,
traceId: state.trace,
+ spanId: state.span,
clearWorkspace: () =>
- setState({ event: null, q: null, trace: null }, { history: "replace" }),
+ setState(
+ { event: null, q: null, trace: null, span: null },
+ { history: "replace" },
+ ),
resetWorkspace: () =>
- setState({ event: null, trace: null }, { history: "replace" }),
+ setState(
+ { event: null, trace: null, span: null },
+ { history: "replace" },
+ ),
selectEvent: (eventId: string) =>
- setState({ event: eventId, trace: null }, { history: "push" }),
+ setState(
+ { event: eventId, trace: null, span: null },
+ { history: "push" },
+ ),
selectTrace: (traceId: string) =>
- setState({ event: null, trace: traceId }, { history: "push" }),
+ setState(
+ { event: null, trace: traceId, span: null },
+ { history: "push" },
+ ),
+ selectSpan: (traceId: string, spanId: string) =>
+ setState(
+ { event: null, trace: traceId, span: spanId },
+ { history: "push" },
+ ),
+ clearSpan: () => setState({ span: null }, { history: "replace" }),
setSearchQuery: (query: string) =>
setState({ q: query || null }, { history: "replace" }),
};
diff --git a/apps/local/src/router.test.tsx b/apps/local/src/router.test.tsx
index 315b1201b..b384ac94e 100644
--- a/apps/local/src/router.test.tsx
+++ b/apps/local/src/router.test.tsx
@@ -29,9 +29,9 @@ describe("LocalRouter", () => {
expect(
(
- await screen.findByRole("button", { name: "Open live activity view" })
+ await screen.findByRole("button", { name: "Open events view" })
).getAttribute("aria-current"),
).toBe("page");
- expect(screen.queryByRole("banner")).toBeNull();
+ expect(screen.getByLabelText("Capture context")).not.toBeNull();
});
});
diff --git a/apps/local/src/routes/local-workspace-context.ts b/apps/local/src/routes/local-workspace-context.ts
index 565414d0b..2b5856c69 100644
--- a/apps/local/src/routes/local-workspace-context.ts
+++ b/apps/local/src/routes/local-workspace-context.ts
@@ -7,7 +7,11 @@ export type LocalWorkspaceRouteState = {
workspaceView: WorkspaceView;
navigateToWorkspace: (view: WorkspaceView) => void;
navigateToCommandEvent: (view: WorkspaceView, eventId: string) => void;
- navigateToCommandTrace: (view: WorkspaceView, traceId: string) => void;
+ navigateToCommandTrace: (
+ view: WorkspaceView,
+ traceId: string,
+ spanId?: string,
+ ) => void;
};
export const LocalWorkspaceRouteContext =
diff --git a/apps/local/src/routes/local-workspace-route.tsx b/apps/local/src/routes/local-workspace-route.tsx
index 69264414f..5d593345d 100644
--- a/apps/local/src/routes/local-workspace-route.tsx
+++ b/apps/local/src/routes/local-workspace-route.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { type ReactNode, useEffect, useRef } from "react";
import { useLocation, useNavigate } from "react-router";
import { useWorkspaceQueryState } from "@/lib/workspace-query.ts";
import {
@@ -12,6 +12,23 @@ export function LocalWorkspaceRoute({ children }: { children: ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const workspaceQuery = useWorkspaceQueryState();
+ const consumedConnectionLink = useRef(false);
+ useEffect(() => {
+ if (consumedConnectionLink.current) return;
+ consumedConnectionLink.current = true;
+ const search = new URLSearchParams(location.search);
+ if (!location.hash && !search.has("url")) return;
+ search.delete("url");
+ const query = search.toString();
+ void navigate(
+ {
+ pathname: location.pathname,
+ search: query ? `?${query}` : "",
+ hash: "",
+ },
+ { replace: true },
+ );
+ }, [location.hash, location.pathname, location.search, navigate]);
const navigateToWorkspace = (view: WorkspaceView) => {
// navigate() can return a promise; `void` marks this as deliberate
@@ -21,18 +38,20 @@ export function LocalWorkspaceRoute({ children }: { children: ReactNode }) {
const navigateToCommandSelection = (
view: WorkspaceView,
- selection: { event?: string; trace?: string },
+ selection: { event?: string; trace?: string; span?: string },
) => {
const search = new URLSearchParams(location.search);
search.delete("event");
search.delete("q");
search.delete("trace");
+ search.delete("span");
if (selection.event) {
search.set("event", selection.event);
}
if (selection.trace) {
search.set("trace", selection.trace);
}
+ if (selection.span) search.set("span", selection.span);
const query = search.toString();
// Fire-and-forget, as in navigateToWorkspace.
void navigate({
@@ -49,8 +68,8 @@ export function LocalWorkspaceRoute({ children }: { children: ReactNode }) {
navigateToWorkspace,
navigateToCommandEvent: (view, eventId) =>
navigateToCommandSelection(view, { event: eventId }),
- navigateToCommandTrace: (view, traceId) =>
- navigateToCommandSelection(view, { trace: traceId }),
+ navigateToCommandTrace: (view, traceId, spanId) =>
+ navigateToCommandSelection(view, { trace: traceId, span: spanId }),
}}
>
{children}
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 2acb5ce86..367ce3ff8 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -82,7 +82,7 @@ preserves its current setting. Older receivers that cannot apply the flag report
error. Retrying session creation with the same client ID preserves its original settings.
The viewer follows the receiver's envelope limit and retains every decoded item
-inside each retained envelope. Its Retention limit control can update a loopback
+inside each retained envelope. Its History capacity control can update a loopback
receiver or scoped session at runtime without replacing the connection, from a
locally served viewer or trusted hosted Local origin. CLI changes also update
connected viewers.
@@ -103,14 +103,17 @@ Light or Dark choice overrides it until System is selected again.
Settings also remains accessible from the connection landing without an active receiver.
On narrow screens, the event or trace list stacks above its inspector. Mobile
-receiver actions open the same Settings and Add receiver dialogs as the sidebar.
+capture actions open the same Settings and Switch receiver dialogs as the sidebar.
+Desktop list panes can be resized with a pointer or the separator’s arrow keys
+(Shift changes the step); Home and End choose the bounds. Widths persist in this
+browser. Narrow workspaces keep the stacked layout to preserve readable inspectors.
### Receiver connections
-**Add receiver** opens a modal over the current feed. Close it with its close
+**Switch receiver** opens a modal over the current feed. Close it with its close
button, Escape, or an outside click; focus returns to its trigger. The background
-is blocked while Add receiver or Settings is open. Both dialogs scroll within
-the viewport. Adding a receiver checks its stream before replacing a healthy
+is blocked while Switch receiver or Settings is open. Both dialogs scroll within
+the viewport. Switching receivers checks its stream before replacing a healthy
feed. A failed or cancelled attempt keeps the existing connection and events.
**Past connections**, below the connection form, lists successful receivers for
@@ -123,22 +126,38 @@ five receivers, ordered by most recent use; adding another discards the oldest
entry, and reconnecting moves an existing receiver to the top. No history entry is
created for a failed connection.
-Closing a session stops capture immediately: later ingestion receives HTTP 410,
-including requests whose bodies finish after Close. Saved events remain readable
-for three hours from the first Close; retries and attempted ingestion do not extend
-that deadline. **Reload saved events** replays the archive without resuming capture.
-The viewer keeps the workspace visible even when the archive is empty. Close is
-guarded against duplicate requests and times out if the receiver does not respond.
-
-**Disconnect receiver** returns to the connection screen without closing capture
-or deleting receiver data. This tab stays disconnected after a reload; use Connect,
-past connections, or an accepted connection link to resume. Ignored or unsupported
-URL parameters do not reconnect a saved receiver. **Clear events** resets
-local receiver data when permitted and resumes only an open session's feed. If the
-reset fails, the feed stays paused and offers Reconnect even when the view is empty.
-Closed sessions stay closed and empty after a reset. Other connected viewers
-receive reset and close notifications. The hosted viewer clears only its displayed data; reloading
-saved events restores the daemon's archive.
+The capture header keeps the selected session and project, capture state, receiver
+connectivity, and last received time together. **Capturing** means the receiver
+accepts events; it does not indicate whether a wrapped app process is running.
+**Saved capture** means ingestion has stopped. When the receiver supplies its
+original expiry time, the header shows **Saved until** with that deadline. Older
+receivers do not imply a new three-hour countdown when the viewer opens.
+
+The **Capture actions** menu labels each action by its scope:
+
+- **Clear view** removes displayed events, filters, and selection from this tab.
+ It leaves receiver history and capture running. **Reload capture** restores
+ history still held by the receiver; trimmed or expired events cannot be restored.
+- **Delete captured events** asks for confirmation before deleting receiver
+ history and clearing this view. Other connected viewers receive the reset.
+ This action is available only to a loopback-served viewer with existing receiver
+ permissions. If deletion fails, the view stays paused and offers Reconnect.
+- **Stop capture** stops scoped-session ingestion immediately: later ingestion
+ receives HTTP 410, including requests whose bodies finish after Stop. Saved
+ events remain readable for three hours from the first stop; retries and
+ attempted ingestion do not extend that deadline. Reloading the archive does
+ not resume capture. Duplicate stop requests are guarded and requests time out
+ if the receiver does not respond.
+- **Disconnect viewer** returns to the connection screen without stopping capture
+ or deleting receiver data. This tab stays disconnected after a reload; use
+ Connect, past connections, or an accepted connection link to resume. Ignored
+ or unsupported URL parameters do not reconnect a saved receiver.
+
+The workspace stays readable after capture stops or the receiver disconnects,
+including empty saved captures. Empty states distinguish waiting for an app event,
+filtered results, cleared views, unavailable receivers, and saved captures.
+The hosted viewer can clear its displayed view, but cannot delete receiver data
+or stop captures.
### Switching sessions
@@ -162,7 +181,7 @@ selects the first running session and fills the Session picker. If no sessions
exist yet, it waits and discovers them as they start. This works in the local
development viewer and on . Alternatively, run either
command with `--session --open` to start on that specific session.
-The picker lists running and retained sessions from that daemon, including sessions
+The picker groups sessions as **Capturing** and **Saved**, including sessions
created after the viewer opens. It refreshes every five seconds; Refresh sessions
updates it immediately. Labels include the working directory and a short session ID
to distinguish runs with the same name. Picking a session replaces only the viewer's
@@ -185,18 +204,29 @@ with an updated CLI and daemon to enable discovery. An already-running older dae
needs a restart after upgrading; restarting clears its in-memory telemetry. One picker
covers one daemon, not receivers on other ports or computers.
-Open Settings with the sidebar gear (or Session settings in the mobile receiver
-menu) to change retention for a running session. Both a locally served viewer and
-local.sentry.dev (including trusted PR previews) use the runtime API. The form uses
-React Hook Form and Valibot to require a whole number from 1 to 50,000. Increases preserve
-captured data, and lowering below the retained count is rejected until enough data
-is cleared. The receiver validates the same bounds and rejects concurrent decreases
-that would discard envelopes. Closed sessions and disconnected streams are read-only.
+Open **Settings** to choose app-wide appearance or change **History capacity**
+for the named capture. Capacity is measured in envelopes: SDK batches that may
+contain several events. It is separate from how long a stopped capture is saved.
+Both a locally served viewer and local.sentry.dev (including trusted PR previews)
+use the existing runtime API. The form requires a whole number from 1 to 50,000.
+Increases preserve captured data, and lowering below the retained count is rejected
+until enough data is deleted. The receiver rejects concurrent decreases that would
+discard envelopes. Saved captures and disconnected streams are read-only.
Older daemons/receivers need an upgrade and restart to allow hosted updates.
-The sidebar keeps Add receiver on its own row, with Settings at the left and
-Clear, Close, and Disconnect icons at the right beneath it. Icon tooltips also work with keyboard focus.
-A loopback-served viewer can reset and close its scoped session. The hosted viewer
+**Events**, **Traces**, **Errors**, **Logs**, and **AI** are primary views.
+The **Diagnostics** section contains Envelopes, SDK reports, Feedback, and Profiles;
+SDK reports describe SDK sessions and client reports, separate from named Local
+captures. Existing `/live` and `/sdks` links continue to work.
+
+**Search this session** (Cmd/Ctrl+K) finds events across the capture and offers view
+navigation. Typing there does not change the current view’s filter. Choose an event
+to navigate to it, or explicitly choose **Filter [view]**. Each view also has its own
+labelled filter field and **Clear filter** action. The `q` URL parameter represents
+this visible view filter. Event, trace, and span selection are stored in the URL,
+including `span` for waterfall drill-down, and work with browser history.
+
+A loopback-served viewer can delete captured events and stop its scoped capture. The hosted viewer
can read the stream, update retention, and clear its own displayed feed; it cannot
reset receiver data or close sessions. Scoped retention requests require the session
stream capability. Standalone retention updates require UI actions to be enabled on
@@ -215,13 +245,19 @@ exception, its causes, and stack frames with function names, source locations, a
captured source lines. Frames appear most recent first; the JSON tab preserves the
complete original payload.
+Log inspectors show the message, level, and attributes. Requests summarize useful
+metadata and related telemetry. Waterfall spans are keyboard-selectable and open
+their own timing, attributes, original payload, and trace-related errors/logs in
+the inspector; **Back to trace** returns to the waterfall and its selected span.
+Raw envelopes and other diagnostic types open directly in JSON.
+
Switching or reconnecting a receiver isolates pending actions and stream callbacks
from earlier connections, including when returning to the same receiver URL.
Search and view navigation remain available for retained telemetry after a
receiver disconnects or a session closes, including raw-only envelopes. A trace
-search with no matches explains how to change or clear the filter. Closing an
-empty session keeps the workspace visible; Disconnect returns to the receiver picker. Waterfall failure styling uses
+search with no matches explains how to change or clear the filter. Stopping an
+empty capture keeps the workspace visible; Disconnect viewer returns to the connection screen. Waterfall failure styling uses
each span's own level, HTTP status, and SDK span status; a failed transaction does not mark healthy child
spans as failures. The AI workspace matches AI namespaces (`ai`, `gen_ai`,
`llm`), including dotted origins, rather than substrings of ordinary operations.
diff --git a/docs/superpowers/plans/2026-10-26-local-ui-clarity.md b/docs/superpowers/plans/2026-10-26-local-ui-clarity.md
new file mode 100644
index 000000000..7b8f1aa8f
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-26-local-ui-clarity.md
@@ -0,0 +1,84 @@
+# Local UI Clarity Implementation Plan
+
+> **For agentic workers:** Use scoped ownership/interfaces below, TDD, and independent final review. The primary agent integrates and publishes.
+
+**Goal:** Include all ten approved improvements in PR #1509.
+**Architecture:** Extend optional lifecycle metadata and focused React/Radix components while preserving the receiver/session state machine.
+**Tech Stack:** TypeScript, React, Radix, Tailwind, Vitest, Hono.
+**Spec:** [Local UI clarity](../specs/2026-10-26-local-ui-clarity-design.md)
+
+## Global Constraints
+
+- No new dependencies, deployment, or PR merge.
+- Preserve routes, older receivers, security boundaries, stale-callback isolation, raw data, and finite replay.
+- Run the root quality gate before committing; only the two previously approved macOS failures may remain.
+
+## Review Focus
+
+- Receiver switches during dialogs/requests must not apply stale state.
+- Hosted/remote viewers must not gain reset/close privileges.
+- Missing/invalid/expired metadata must not invent a deadline.
+- Duplicate span IDs, keyboard use, mobile, and browser history must preserve inspector context.
+- Clear view and filtered/saved-empty states must not imply receiver data was deleted.
+
+### Task 1: Lifecycle metadata
+
+**Files:** CLI viewer-protocol/session-store/control-plane and tests; viewer session-directory/parser tests.
+**Interfaces:** LocalSessionEvent and ViewerSession add optional expiresAt?: number in epoch milliseconds. Directory and retained lifecycle/replay/reset metadata carry the existing frozen deadline.
+
+- [x] Write/run failing propagation and parser compatibility tests.
+- [x] Implement optional metadata without changing capabilities or expiry.
+- [x] Verify focused CLI/parser suites, types, and lint.
+
+### Task 2: Connections, actions, and settings
+
+**Files:** connection-landing, receiver-dialog, receiver-controls, local-settings, retention-settings, session-switcher and tests.
+**Interfaces:** SessionSwitcher adds onCurrentSessionChange?: (session: ViewerSession | undefined) => void and showState?: boolean. ReceiverControls preserves callbacks and adds onDeleteCapturedEvents?: (trigger: HTMLButtonElement) => void. LocalSettings adds captureLabel?: string and expiresAt?: number.
+
+- [x] Write/run failing tests for names, onboarding, scope, permissions, and focus.
+- [x] Implement Capturing/Saved, Switch receiver, Clear view, Stop capture, Disconnect viewer, and a confirmed-deletion trigger.
+- [x] Preserve probing/history; distinguish global appearance, capture capacity, and expiry.
+- [x] Verify focused suites, types, and lint.
+
+### Task 3: Inspectors and layout
+
+**Files:** new event-detail, span-detail, resizable-workspace components/tests; trace-model, trace-waterfall, trace-workspace and tests.
+**Interfaces:** EventDetail({item, trace?, onSelectSpan?, onSelectRelatedItem?}). TraceSpan retains its own payload. TraceWaterfall adds selectedSpanId? and onSelectSpan?: (spanId: string) => void. TraceWorkspace adds selectedSpanId?, onSelectSpan?: (traceId: string, spanId: string) => void, onClearSpan?: () => void, onSelectRelatedItem?: (item: LocalFeedItem) => void. ResizableWorkspace({list, children, storageKey, listLabel, initialWidth?}).
+
+- [x] Write/run failing inspector, payload, keyboard, and resize tests.
+- [x] Extract EventDetail from App without editing App; parent removes old implementation.
+- [x] Add useful type-specific detail and span drill-down with trace context/related telemetry.
+- [x] Add bounded accessible desktop split panes and mobile stacking.
+- [x] Verify focused suites, types, and lint.
+
+### Task 4: Workspace integration
+
+**Files:** App, presentation, workspace navigation/query, command dialog, app tests.
+**Interfaces:** Query state adds span, selectSpan(traceId, spanId), clearSpan(). Existing routes stay valid.
+
+- [x] Write/run failing tests for clear/delete scope, lifecycle empties, filters, span navigation, and switching isolation.
+- [x] Integrate context header, action confirmations, purposeful inspectors and resize.
+- [x] Integrate diagnostics, labelled search, clear-filter controls, and mobile parity.
+- [x] Update existing label expectations and verify the complete viewer suite.
+
+### Task 5: Review/publication
+
+- [x] Update Local docs and take desktop/mobile screenshots.
+- [x] Run full viewer/Local CLI/root quality gates and real process E2E.
+- [x] Independent review; reproduce/fix actionable findings.
+- [ ] Commit/push to codex/local-session-switcher; refresh PR description/screenshots.
+- [ ] Verify final-head CI/feedback and report any external advisory failure accurately.
+
+## Verification results
+
+- Root typecheck and lint pass.
+- Full workspace test run: all packages pass except the two previously approved
+ macOS failures (musl installer detection and DerivedData additive scan timeout).
+ CLI unit suite: 11,400 passed, 37 skipped, those two failures.
+- Real Local CLI process E2E: 27 passed.
+- Viewer regressions cover lifecycle deadlines, confirmed deletion, hosted
+ permissions, filters, span URL/history, resizing, and repeated parent refreshes.
+- Visual QA used isolated synthetic captures at desktop and 390×844 mobile sizes;
+ inspected request/log/span views, waterfall, scoped settings, and delete/cancel.
+- Review fixes: stale cleared-view guidance, connection-link URL restoration,
+ duplicate request-summary sibling keys, and accessible summary values.
diff --git a/docs/superpowers/specs/2026-10-26-local-ui-clarity-design.md b/docs/superpowers/specs/2026-10-26-local-ui-clarity-design.md
new file mode 100644
index 000000000..4140e1258
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-26-local-ui-clarity-design.md
@@ -0,0 +1,37 @@
+# Local UI clarity
+
+The user approved all ten UI proposals and asked to include them in PR #1509.
+
+## Intent
+
+Make capture identity, lifecycle, action consequences, and inspection useful and understandable. Preserve the compact developer-tool layout and React/Radix stack.
+
+## Approved behavior
+
+1. A unified context header shows session/project, Capturing or Saved capture, receiver connectivity, and latest event time. Running becomes Capturing; Retained becomes Saved; Live Activity becomes Events.
+2. Clear view affects this tab only. Delete captured events is a separate confirmed receiver mutation, available only under existing permissions. Stop capture retains history but rejects future ingestion. Disconnect viewer leaves capture intact. Desktop/mobile actions share explicit names and focus behavior.
+3. Empty screens distinguish active, saved, unavailable, and filtered-empty states and provide the relevant next action.
+4. First run leads with a copyable local-run command; connecting an existing receiver is secondary. Switch receiver preserves the current feed until its replacement succeeds.
+5. Logs show message/attributes; exceptions show causes/frames; requests have compact summaries/related telemetry; raw-only items open in JSON.
+6. Keyboard-selectable waterfall spans open their own timing, attributes, and raw payload with trace context and related errors/logs. Drill down within the inspector pane, avoiding another permanently narrow column.
+7. Desktop event/trace list widths resize by pointer/keyboard within safe bounds; mobile stays stacked. Reserve monospace for code/IDs, allow descriptions to wrap, and reduce redundant boxed metadata.
+8. Common debugging views stay prominent; secondary inspection is a collapsible Diagnostics group. Sessions & SDKs becomes SDK reports. Preserve /live and /sdks URLs.
+9. Appearance is app-wide. History capacity names the capture and explains SDK-envelope units. Show Saved until separately when optional expiry metadata exists.
+10. Label session-wide search, preserve Cmd/Ctrl+K, expose view-filter scope and active filters, and provide Clear filter.
+
+## Constraints
+
+- No new dependencies, deployment, or PR merge.
+- Keep capability, origin, authority, stale-callback, replay, and finite-retention protections.
+- Add optional expiresAt epoch-millisecond metadata; never invent or extend deadlines. Older receivers remain supported.
+- Receiver/session changes clear stale filter/inspector context.
+- Connected transport does not mean the application process is running.
+- Raw span payloads and independent span error styling remain accurate.
+
+## Approaches
+
+Update the existing workspace and interactions. Cosmetic-only changes leave ambiguous state/action semantics intact. Replacing the UI framework adds migration risk without helping these flows. The user approved the ten proposals; the engineering choices above make them concrete within the existing architecture.
+
+## Verification
+
+Use failing-then-passing regression tests, full viewer/Local CLI tests, root typecheck/lint/tests, real process E2E, desktop/mobile visual checks, and independent final review. The earlier user-approved exception remains limited to the unrelated macOS musl-installer and DerivedData failures.
diff --git a/packages/cli/src/commands/local/control-plane.ts b/packages/cli/src/commands/local/control-plane.ts
index 4026e7ce8..b4fcd2f73 100644
--- a/packages/cli/src/commands/local/control-plane.ts
+++ b/packages/cli/src/commands/local/control-plane.ts
@@ -309,12 +309,13 @@ export function createLocalControlPlane({
return c.json({
sessions: sessionStore
.list()
- .map(({ id, label, cwd, state, createdAt }) => ({
+ .map(({ id, label, cwd, state, createdAt, expiresAt }) => ({
id,
label,
cwd,
state,
createdAt,
+ expiresAt,
streamUrl: `${viewerBaseUrl}${CONTROL_PLANE_PREFIX}/sessions/${id}/stream?cap=${sessionStore.getCapabilities(id).stream}`,
})),
});
@@ -499,10 +500,10 @@ export function createLocalControlPlane({
});
stream.onAbort(finish);
try {
- const state = sessionStore.resolve(reference).state;
+ const { state, expiresAt } = sessionStore.resolve(reference);
if (state === "retained") {
buffer.stream(readerId);
- await sendSession({ state });
+ await sendSession({ state, expiresAt });
} else {
await sendSession({ state });
await finished;
diff --git a/packages/cli/src/commands/local/session-store.ts b/packages/cli/src/commands/local/session-store.ts
index 07e4d7877..e1c393572 100644
--- a/packages/cli/src/commands/local/session-store.ts
+++ b/packages/cli/src/commands/local/session-store.ts
@@ -183,13 +183,19 @@ export function createLocalSessionStore({
lastActivityAt: timestamp,
expiresAt: timestamp + SESSION_RETENTION_MS,
});
- notify(session.id, { state: "retained" });
+ notify(session.id, { state: "retained", expiresAt: closed.expiresAt });
return toPublicSession(closed);
},
reset(reference) {
const session = resolveStored(reference);
session.buffer.clear();
- notify(session.id, { state: session.state, action: "reset" });
+ notify(session.id, {
+ state: session.state,
+ action: "reset",
+ ...(session.expiresAt !== undefined && {
+ expiresAt: session.expiresAt,
+ }),
+ });
},
subscribe(reference, listener) {
const session = resolveStored(reference);
diff --git a/packages/cli/src/commands/local/viewer-protocol.ts b/packages/cli/src/commands/local/viewer-protocol.ts
index 53b9e7b8b..9390cca3d 100644
--- a/packages/cli/src/commands/local/viewer-protocol.ts
+++ b/packages/cli/src/commands/local/viewer-protocol.ts
@@ -6,6 +6,8 @@ export const LOCAL_SESSION_EVENT = "sentry.local.session";
export type LocalSessionEvent = {
state: "open" | "retained";
action?: "reset";
+ /** Existing retained-session deadline, in epoch milliseconds. */
+ expiresAt?: number;
};
const LOCAL_UI_ORIGIN = "https://local.sentry.dev";
diff --git a/packages/cli/test/commands/local/control-plane.test.ts b/packages/cli/test/commands/local/control-plane.test.ts
index 1c21f7acd..f139a49e8 100644
--- a/packages/cli/test/commands/local/control-plane.test.ts
+++ b/packages/cli/test/commands/local/control-plane.test.ts
@@ -214,12 +214,16 @@ describe("Local control plane", () => {
})
).status,
).toBe(403);
- plane.sessionStore.close(worker.id);
+ const closed = plane.sessionStore.close(worker.id);
expect(
(await (await plane.app.request(web.directoryUrl)).json()).sessions,
).toEqual(
expect.arrayContaining([
- expect.objectContaining({ id: worker.id, state: "retained" }),
+ expect.objectContaining({
+ id: worker.id,
+ state: "retained",
+ expiresAt: closed.expiresAt,
+ }),
]),
);
});
@@ -282,6 +286,41 @@ describe("Local control plane", () => {
expect(sessionStore.list()).toHaveLength(1);
});
+ test("close and finite replay advertise the original saved deadline after reset", async () => {
+ let now = 1000;
+ const sessionStore = createLocalSessionStore({ now: () => now });
+ const plane = createLocalControlPlane({
+ controlToken: CONTROL_TOKEN,
+ sessionStore,
+ });
+ const session = await (
+ await plane.app.request("/_local/v1/sessions", {
+ method: "POST",
+ headers: controlHeaders(),
+ body: JSON.stringify({ label: "web", cwd: "/web" }),
+ })
+ ).json();
+ const live = await plane.app.request(session.streamUrl);
+ const reading = live.text();
+ const closed = sessionStore.close(session.id);
+ const metadata = JSON.stringify({
+ state: "retained",
+ expiresAt: closed.expiresAt,
+ });
+ expect(await reading).toContain(metadata);
+ now += 60_000;
+ expect(await (await plane.app.request(session.streamUrl)).text()).toContain(
+ metadata,
+ );
+ sessionStore.reset(session.id);
+ expect(await (await plane.app.request(session.streamUrl)).text()).toContain(
+ metadata,
+ );
+ expect(sessionStore.resolve(session.id).expiresAt).toBe(
+ 1000 + SESSION_RETENTION_MS,
+ );
+ });
+
test("validates retention settings and rejects untrusted scoped mutations", async () => {
const plane = createLocalControlPlane({ controlToken: CONTROL_TOKEN });
const created = await plane.app.request("/_local/v1/sessions", {
diff --git a/packages/cli/test/commands/local/session-store.test.ts b/packages/cli/test/commands/local/session-store.test.ts
index 895745c15..dd54517df 100644
--- a/packages/cli/test/commands/local/session-store.test.ts
+++ b/packages/cli/test/commands/local/session-store.test.ts
@@ -141,4 +141,32 @@ describe("LocalSessionStore", () => {
expect(store.pruneExpired()).toEqual([session.id]);
expect(() => store.resolve(session.id)).toThrow(/not found/i);
});
+
+ test("lifecycle notifications carry the frozen deadline through close and reset", () => {
+ let now = 1000;
+ const store = createLocalSessionStore({ now: () => now });
+ const session = store.create({ label: "web", cwd: "/work/web" });
+ const listener = vi.fn();
+ const unsubscribe = store.subscribe(session.id, listener);
+ store.reset(session.id);
+ expect(listener).toHaveBeenLastCalledWith({
+ state: "open",
+ action: "reset",
+ });
+ const closed = store.close(session.id);
+ expect(listener).toHaveBeenLastCalledWith({
+ state: "retained",
+ expiresAt: closed.expiresAt,
+ });
+ now += 60_000;
+ expect(store.close(session.id).expiresAt).toBe(closed.expiresAt);
+ store.reset(session.id);
+ expect(listener).toHaveBeenLastCalledWith({
+ state: "retained",
+ action: "reset",
+ expiresAt: closed.expiresAt,
+ });
+ expect(store.resolve(session.id).expiresAt).toBe(closed.expiresAt);
+ unsubscribe();
+ });
});
From fe714ccb90398c14ae896a52a3998816539d55b9 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 18:42:40 +0530
Subject: [PATCH 24/30] test(local): Bound replay setup and scope retention
queries
Seed retained envelopes with bounded HTTP concurrency and query only the settings dialog.
Keep real replay and mutation coverage without starving CI request deadlines.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.integration.test.tsx | 38 +++++++++++++++++--------
1 file changed, 26 insertions(+), 12 deletions(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index cf48a1f62..8455c769c 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -8,6 +8,7 @@ import {
render,
screen,
waitFor,
+ within,
} from "@testing-library/react";
import { NuqsAdapter } from "nuqs/adapters/react";
import { NuqsTestingAdapter } from "nuqs/adapters/testing";
@@ -1619,58 +1620,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");
fireEvent.click(
await screen.findByRole("button", { name: "Settings", exact: true }),
);
- await screen.findByText(
+ // 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.change(screen.getByLabelText("Envelope limit"), {
+ fireEvent.change(settings.getByLabelText("Envelope limit"), {
target: { value: "2000" },
});
fireEvent.click(
- screen.getByRole("button", { name: "Apply envelope limit" }),
+ settings.getByRole("button", { name: "Apply envelope limit" }),
);
expect(
(
- (await screen.findByRole("button", {
+ (await settings.findByRole("button", {
name: "Applying…",
})) as HTMLButtonElement
).disabled,
).toBe(true);
// The edited input is already 2000 while the HTTP mutation is pending.
expect(
- (screen.getByLabelText("Envelope limit") as HTMLInputElement).value,
+ (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 screen.findByRole(
+ const apply = await settings.findByRole(
"button",
{ name: "Apply envelope limit" },
{ timeout: 6000 },
);
expect((apply as HTMLButtonElement).disabled).toBe(false);
- expect(screen.queryByRole("alert")).toBeNull();
+ expect(settings.queryByRole("alert")).toBeNull();
expect(
- screen.getByText(/501 envelopes currently retained/),
+ 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(/502 envelopes currently retained/);
+ 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 = [];
From d5e5863ee319412c20ea02892b8479d18d127dde Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 19:07:10 +0530
Subject: [PATCH 25/30] fix(local): Preserve receiver and inspector state
across actions
Restore this tab's receiver choice, block reload during deletion, and preserve trace filters.
Ignore stale span selections on fallback traces and cover each transition with regressions.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.capture-lifecycle.test.tsx | 116 ++++++++++++++++++
apps/local/src/App.integration.test.tsx | 45 +++++++
apps/local/src/App.tsx | 17 ++-
.../src/components/trace-workspace.test.tsx | 6 +
apps/local/src/components/trace-workspace.tsx | 4 +-
apps/local/src/lib/spotlight.test.ts | 33 +++++
apps/local/src/lib/spotlight.ts | 4 +-
docs/cli/local-control-plane.md | 5 +
.../plans/2026-10-26-local-ui-clarity.md | 15 ++-
9 files changed, 234 insertions(+), 11 deletions(-)
diff --git a/apps/local/src/App.capture-lifecycle.test.tsx b/apps/local/src/App.capture-lifecycle.test.tsx
index cf1772574..b8b9a41cf 100644
--- a/apps/local/src/App.capture-lifecycle.test.tsx
+++ b/apps/local/src/App.capture-lifecycle.test.tsx
@@ -14,6 +14,10 @@ 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";
@@ -22,6 +26,7 @@ const WORKER_STREAM =
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[] = [];
@@ -43,6 +48,10 @@ function renderCapture({ path = "/live", directory = false } = {}) {
"",
`${path}#${new URLSearchParams({ stream: STREAM, ...(directory ? { directory: DIRECTORY } : {}) })}`,
);
+ return renderViewer();
+}
+
+function renderViewer() {
return render(
@@ -56,6 +65,23 @@ function renderCapture({ path = "/live", directory = false } = {}) {
);
}
+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;
@@ -326,6 +352,96 @@ test("a receiver reset describes empty capture history without tab-only recovery
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) => {
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 8455c769c..74731739e 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -2478,6 +2478,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 {
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index 9d9ae81f0..b01040084 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -166,6 +166,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 {
@@ -939,7 +944,11 @@ export default function App() {
};
const selectSpan = (traceId: string, spanId: string) => {
- navigateToCommandTrace("traces", traceId, spanId);
+ if (workspaceView === "traces") {
+ void workspaceQuery.selectSpan(traceId, spanId);
+ } else {
+ navigateToCommandTrace("traces", traceId, spanId);
+ }
markItemsSeen("traces");
};
const requestDeleteCapturedEvents = (trigger: HTMLButtonElement) => {
@@ -1221,8 +1230,10 @@ export default function App() {
}
expiresAt={sessionExpiresAt ?? currentSession?.expiresAt}
latestEventTime={latestEventTime}
- onReload={() => connectToEndpoint(streamUrl)}
- isReloading={isConnecting}
+ onReload={() => {
+ if (!isClearing) connectToEndpoint(streamUrl);
+ }}
+ isReloading={isConnecting || isClearing}
isAwaitingSession={Boolean(
activeDirectoryUrl &&
currentEndpoint?.kind === "loopback" &&
diff --git a/apps/local/src/components/trace-workspace.test.tsx b/apps/local/src/components/trace-workspace.test.tsx
index db3562a3e..83d42ff99 100644
--- a/apps/local/src/components/trace-workspace.test.tsx
+++ b/apps/local/src/components/trace-workspace.test.tsx
@@ -82,10 +82,16 @@ test("a stale trace selection cannot drill into a duplicate span ID on a fallbac
onSelect={vi.fn()}
selectedTraceId="expired-trace"
selectedSpanId="root"
+ onSelectSpan={vi.fn()}
/>,
);
expect(screen.queryByRole("region", { name: "Span detail" })).toBeNull();
expect(screen.getByRole("table", { name: "Trace spans" })).toBeDefined();
+ expect(
+ screen
+ .getByRole("button", { name: "Inspect span GET /orders" })
+ .getAttribute("aria-pressed"),
+ ).toBe("false");
});
test("drilling into a span and returning restores keyboard focus", () => {
diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx
index ccccd7f0a..1dd21ae3e 100644
--- a/apps/local/src/components/trace-workspace.tsx
+++ b/apps/local/src/components/trace-workspace.tsx
@@ -169,7 +169,9 @@ export function TraceWorkspace({
) : (
onSelectSpan(selectedTrace.id, spanId)
diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts
index 8707fabed..2d010a8d8 100644
--- a/apps/local/src/lib/spotlight.test.ts
+++ b/apps/local/src/lib/spotlight.test.ts
@@ -123,6 +123,39 @@ describe("getStreamUrlFromHash", () => {
}
});
+ test("restores this tab's remote choice ahead of the browser-wide local receiver", () => {
+ expect(
+ resolveInitialStreamUrl(
+ "",
+ "http://127.0.0.1:8970/stream",
+ "https://receiver.example/stream?token=tab-choice",
+ ),
+ ).toBe("https://receiver.example/stream?token=tab-choice");
+ });
+
+ test("explicit startup links override both stored remote and local choices", () => {
+ const local = "http://127.0.0.1:8970/stream";
+ const remote = "https://receiver.example/stream?token=tab-choice";
+ const fragment = `#${new URLSearchParams({ stream: "http://localhost:9000/stream" })}`;
+ const query = `?${new URLSearchParams({ url: "http://localhost:9012/stream" })}`;
+ expect(resolveInitialStreamUrl(fragment, local, remote, query)).toBe(
+ "http://localhost:9000/stream",
+ );
+ expect(resolveInitialStreamUrl("", local, remote, query)).toBe(
+ "http://localhost:9012/stream",
+ );
+ });
+
+ test("falls back to the saved local receiver when the tab's remote choice is invalid", () => {
+ expect(
+ resolveInitialStreamUrl(
+ "",
+ "http://127.0.0.1:8970/stream",
+ "http://receiver.example/stream?token=unsafe",
+ ),
+ ).toBe("http://127.0.0.1:8970/stream");
+ });
+
test("uses CLI pairing, saved endpoints, then the default in priority order", () => {
expect(
resolveInitialStreamUrl(
diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts
index 0d7bd7c3a..4319542b2 100644
--- a/apps/local/src/lib/spotlight.ts
+++ b/apps/local/src/lib/spotlight.ts
@@ -280,7 +280,7 @@ export function getPreferredStreamUrl(
return getStreamUrlFromHash(hash) ?? getStreamUrlFromStorage(savedStream);
}
-/** Resolve the bare viewer's receiver in the same safe order as the UI. */
+/** Explicit startup links precede this tab's remote choice and the shared local default. */
export function resolveInitialStreamUrl(
hash: string,
savedLoopbackStream: string | null,
@@ -290,8 +290,8 @@ export function resolveInitialStreamUrl(
return (
getStreamUrlFromHash(hash) ??
getStreamUrlFromQuery(search) ??
- getStreamUrlFromStorage(savedLoopbackStream) ??
getRemoteStreamUrlFromStorage(savedRemoteStream) ??
+ getStreamUrlFromStorage(savedLoopbackStream) ??
DEFAULT_STREAM_URL
);
}
diff --git a/docs/cli/local-control-plane.md b/docs/cli/local-control-plane.md
index 367ce3ff8..7eab6eb32 100644
--- a/docs/cli/local-control-plane.md
+++ b/docs/cli/local-control-plane.md
@@ -126,6 +126,11 @@ five receivers, ordered by most recent use; adding another discards the oldest
entry, and reconnecting moves an existing receiver to the top. No history entry is
created for a failed connection.
+A successful remote connection is restored from this tab’s session storage before
+the browser’s shared local receiver preference. A successful local connection
+clears that tab’s older remote preference. Failed replacements preserve the
+previous choice; another tab’s remote choice is unaffected.
+
The capture header keeps the selected session and project, capture state, receiver
connectivity, and last received time together. **Capturing** means the receiver
accepts events; it does not indicate whether a wrapped app process is running.
diff --git a/docs/superpowers/plans/2026-10-26-local-ui-clarity.md b/docs/superpowers/plans/2026-10-26-local-ui-clarity.md
index 7b8f1aa8f..952ffc33b 100644
--- a/docs/superpowers/plans/2026-10-26-local-ui-clarity.md
+++ b/docs/superpowers/plans/2026-10-26-local-ui-clarity.md
@@ -11,7 +11,7 @@
- No new dependencies, deployment, or PR merge.
- Preserve routes, older receivers, security boundaries, stale-callback isolation, raw data, and finite replay.
-- Run the root quality gate before committing; only the two previously approved macOS failures may remain.
+- Run the root quality gate before committing; only the three explicitly approved baseline failures may remain.
## Review Focus
@@ -66,15 +66,17 @@
- [x] Update Local docs and take desktop/mobile screenshots.
- [x] Run full viewer/Local CLI/root quality gates and real process E2E.
- [x] Independent review; reproduce/fix actionable findings.
-- [ ] Commit/push to codex/local-session-switcher; refresh PR description/screenshots.
+- [x] Commit/push to codex/local-session-switcher; refresh PR description/screenshots.
- [ ] Verify final-head CI/feedback and report any external advisory failure accurately.
## Verification results
- Root typecheck and lint pass.
-- Full workspace test run: all packages pass except the two previously approved
- macOS failures (musl installer detection and DerivedData additive scan timeout).
- CLI unit suite: 11,400 passed, 37 skipped, those two failures.
+- Known approved local gate exceptions: macOS musl installer detection,
+ DerivedData additive scan timeout, and the unrelated issue-link response-cache
+ race under coverage. The cache race also reproduces on the pre-UI commit;
+ it remains separate from this PR at the user's request.
+- Latest viewer suite: 278 tests pass.
- Real Local CLI process E2E: 27 passed.
- Viewer regressions cover lifecycle deadlines, confirmed deletion, hosted
permissions, filters, span URL/history, resizing, and repeated parent refreshes.
@@ -82,3 +84,6 @@
inspected request/log/span views, waterfall, scoped settings, and delete/cancel.
- Review fixes: stale cleared-view guidance, connection-link URL restoration,
duplicate request-summary sibling keys, and accessible summary values.
+- Final automated-review fixes preserve the tab's remote receiver on reload,
+ prevent reload during a pending deletion, retain filters during span drill-down,
+ and avoid stale selected-span styling on fallback traces.
From 48be2cbeca9099b5e81c182e3f9ef172bee2a18c Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 21:19:23 +0530
Subject: [PATCH 26/30] feat(local): Add telemetry insights and envelope replay
Add dedicated AI, error, profile, attachment, feedback and SDK inspection
alongside query, resource and Web Vitals analysis. Extend filtering and
trace navigation and replay raw SDK envelopes through the receiver for
repeatable UI verification.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/README.md | 93 ++++
apps/local/fixtures/01-checkout.txt | 3 +
apps/local/fixtures/02-error.txt | 3 +
apps/local/fixtures/03-logs.txt | 3 +
apps/local/fixtures/04-feedback.txt | 3 +
apps/local/fixtures/05-profile.txt | 3 +
apps/local/fixtures/06-profile-chunk.txt | 3 +
apps/local/fixtures/07-sdk-reports.txt | 5 +
apps/local/fixtures/08-attachments.bin | Bin 0 -> 855 bytes
apps/local/fixtures/09-span-v2.txt | 3 +
apps/local/package.json | 3 +-
apps/local/scripts/replay.test.ts | 85 ++++
apps/local/scripts/replay.ts | 152 ++++++
apps/local/src/App.integration.test.tsx | 188 ++++++-
apps/local/src/App.tsx | 271 +++++++---
apps/local/src/components/ai-inspector.tsx | 141 ++++++
.../src/components/attachment-detail.tsx | 122 +++++
apps/local/src/components/event-context.tsx | 132 +++++
apps/local/src/components/event-detail.tsx | 72 ++-
.../src/components/event-filter-controls.tsx | 167 ++++++
.../src/components/exception-overview.tsx | 21 +
.../investigation-controls.test.tsx | 97 ++++
.../components/performance-workspace.test.tsx | 112 ++++
.../src/components/performance-workspace.tsx | 333 ++++++++++++
.../src/components/profile-workspace.tsx | 479 ++++++++++++++++++
apps/local/src/components/sdk-inventory.tsx | 72 +++
.../components/semantic-inspection.test.tsx | 242 +++++++++
apps/local/src/components/span-detail.tsx | 2 +
apps/local/src/components/trace-waterfall.tsx | 122 ++++-
apps/local/src/components/trace-workspace.tsx | 24 +
.../src/components/workspace-sidebar.test.tsx | 8 +-
.../src/components/workspace-sidebar.tsx | 23 +-
apps/local/src/lib/attachments.ts | 48 ++
apps/local/src/lib/event-filters.ts | 46 ++
apps/local/src/lib/exceptions.ts | 24 +-
apps/local/src/lib/inspection.ts | 243 +++++++++
apps/local/src/lib/performance.test.ts | 180 +++++++
apps/local/src/lib/performance.ts | 115 +++++
apps/local/src/lib/presentation.ts | 5 +
apps/local/src/lib/profiles.ts | 228 +++++++++
apps/local/src/lib/spotlight.test.ts | 2 +
apps/local/src/lib/spotlight.ts | 27 +-
apps/local/src/lib/telemetry-store.ts | 5 +-
apps/local/src/lib/workspace-navigation.ts | 57 ++-
apps/local/src/lib/workspace.ts | 17 +-
apps/local/src/router.tsx | 4 +
apps/local/src/test-setup.ts | 2 +-
apps/local/tsconfig.node.json | 2 +-
docs/cli/README.md | 2 +
.../plans/2026-10-26-local-ui-parity.md | 66 +++
50 files changed, 3945 insertions(+), 115 deletions(-)
create mode 100644 apps/local/README.md
create mode 100644 apps/local/fixtures/01-checkout.txt
create mode 100644 apps/local/fixtures/02-error.txt
create mode 100644 apps/local/fixtures/03-logs.txt
create mode 100644 apps/local/fixtures/04-feedback.txt
create mode 100644 apps/local/fixtures/05-profile.txt
create mode 100644 apps/local/fixtures/06-profile-chunk.txt
create mode 100644 apps/local/fixtures/07-sdk-reports.txt
create mode 100644 apps/local/fixtures/08-attachments.bin
create mode 100644 apps/local/fixtures/09-span-v2.txt
create mode 100644 apps/local/scripts/replay.test.ts
create mode 100644 apps/local/scripts/replay.ts
create mode 100644 apps/local/src/components/ai-inspector.tsx
create mode 100644 apps/local/src/components/attachment-detail.tsx
create mode 100644 apps/local/src/components/event-context.tsx
create mode 100644 apps/local/src/components/event-filter-controls.tsx
create mode 100644 apps/local/src/components/investigation-controls.test.tsx
create mode 100644 apps/local/src/components/performance-workspace.test.tsx
create mode 100644 apps/local/src/components/performance-workspace.tsx
create mode 100644 apps/local/src/components/profile-workspace.tsx
create mode 100644 apps/local/src/components/sdk-inventory.tsx
create mode 100644 apps/local/src/components/semantic-inspection.test.tsx
create mode 100644 apps/local/src/lib/attachments.ts
create mode 100644 apps/local/src/lib/event-filters.ts
create mode 100644 apps/local/src/lib/inspection.ts
create mode 100644 apps/local/src/lib/performance.test.ts
create mode 100644 apps/local/src/lib/performance.ts
create mode 100644 apps/local/src/lib/profiles.ts
create mode 100644 docs/superpowers/plans/2026-10-26-local-ui-parity.md
diff --git a/apps/local/README.md b/apps/local/README.md
new file mode 100644
index 000000000..c6291283f
--- /dev/null
+++ b/apps/local/README.md
@@ -0,0 +1,93 @@
+# 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. `--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 0000000000000000000000000000000000000000..88f92a2d5c2b9339f4a86157081f763469ca9117
GIT binary patch
literal 855
zcma))&2G~`5XVi+0U7!N2#M96Q^#Kr^
z&SyO~?M+h=3ECmlSUCeLkHa`UjKagXKM9jzk__YUm6PyG3~#7nm9RRS(yW6Ewqz!6
zq=rD%+!b4j#>iS(Q4|Jv(YEzB6xP}$IyPIe6lx9y%AzQ;f-N-7ZdkNKD`56IdAZPYcDRnj`miNdHTraRfmD
zRk;hodE$bte^f-re+G3ndH#&x`D<%~XyB-e(VI{24X$~h_
zUz|TZ@bCMc=N*jCMlW%{W$!z;@%j0|y*=FSjz^Ctt1tPt?+?BG{XOsN$I}n^*&Cmp
Lk3Kzl^UnVPM_mSX
literal 0
HcmV?d00001
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 8b130e98f..eab1c58ea 100644
--- a/apps/local/package.json
+++ b/apps/local/package.json
@@ -10,7 +10,8 @@
"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"
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.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 74731739e..818b57064 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";
@@ -103,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,
@@ -745,6 +770,85 @@ describe("local receiver to viewer integration", () => {
).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);
+ }
+ });
+
test("renders every Explorer view from a receiver-backed telemetry session", async () => {
const { server, port } = await startReceiver();
@@ -766,7 +870,7 @@ describe("local receiver to viewer integration", () => {
["Open feedback view", "View user_report event"],
["Open envelopes view", "View envelope event"],
["Open profiles view", "View profile event"],
- ["Open sdk reports view", "View client_report event"],
+ ["Open sdks view", "View client_report event"],
["Open ai view", "View transaction event"],
] as const;
@@ -2738,6 +2842,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();
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index b01040084..fd124ef52 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -1,3 +1,17 @@
+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";
@@ -95,6 +109,7 @@ type EventEntryProps = {
item: LocalFeedItem;
isSelected: boolean;
showTypeBadge: boolean;
+ columns?: EventColumn[];
onSelect: (id: string) => void;
};
@@ -185,6 +200,7 @@ function EventEntry({
item,
isSelected,
showTypeBadge,
+ columns = defaultEventFilters.columns,
onSelect,
}: EventEntryProps) {
const metadata = getMetadata(item);
@@ -215,7 +231,7 @@ function EventEntry({
>
- {showTypeBadge ? (
+ {showTypeBadge && columns.includes("type") ? (
{typeLabel}
@@ -232,18 +248,31 @@ function EventEntry({
{metadata.statusCode}
) : null}
- {duration ? {duration} : null}
+ {duration && columns.includes("duration") ? (
+ {duration}
+ ) : null}
{isError && item.type === "transaction" ? (
Failed request
) : null}
- {item.type === "log" && metadata.level ? (
+ {item.type === "log" &&
+ metadata.level &&
+ columns.includes("level") ? (
{metadata.level}
) : null}
-
- {formatTimestamp(item.timestamp)}
-
+ {item.type === "log" &&
+ columns.includes("logger") &&
+ loggerForItem(item) ? (
+
+ {loggerForItem(item)}
+
+ ) : null}
+ {columns.includes("time") ? (
+
+ {formatTimestamp(item.timestamp)}
+
+ ) : null}
);
@@ -376,14 +405,28 @@ export default function App() {
);
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)),
@@ -392,7 +435,11 @@ export default function App() {
view: workspaceView,
label: activeWorkspace.label,
singularLabel: activeWorkspace.singularLabel,
- filtered: Boolean(searchQuery.trim()),
+ filtered: Boolean(
+ searchQuery.trim() ||
+ (supportsFacets &&
+ (eventFilters.levels.length || eventFilters.loggers.length)),
+ ),
state:
connection === "failed"
? "offline"
@@ -418,9 +465,8 @@ 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;
const markItemsSeen = (view = workspaceView) => {
@@ -447,6 +493,12 @@ export default function App() {
const selectCommandItem = (item: LocalFeedItem) => {
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) {
@@ -1151,7 +1203,7 @@ export default function App() {
className="grid grid-cols-2 gap-1"
>
{workspaceNavigation
- .filter((entry) => entry.section === "Explore")
+ .filter((entry) => entry.section !== "Diagnostics")
.map((entry) => {
const Icon = entry.icon;
const active = workspaceView === entry.id;
@@ -1319,6 +1371,18 @@ export default function App() {
) : 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}
@@ -1357,76 +1449,91 @@ export default function App() {
}}
/>
) : (
-
-
-
-
- {activeWorkspace.label}
-
-
- {searchedItems.length}{" "}
- {activeWorkspace.singularLabel}
- {searchedItems.length === 1 ? "" : "s"}
-
-
- {newItemCount > 0 ? (
-
{
- const latestItem = newItems.at(-1);
- if (latestItem) {
- selectItem(latestItem.id);
- }
- }}
- >
- View {newItemCount} new{" "}
- {activeWorkspace.singularLabel}
- {newItemCount === 1 ? "" : "s"}
-
- ) : null}
-
-
- {searchedItems.map((item) => (
-
- ))}
- {searchedItems.length === 0 ? (
-
- {workspaceEmptyState.title}
-
- ) : null}
-
- >
- }
- >
- {searchedItems.length === 0 ? (
-
- ) : selectedItem ? (
-
+ <>
+ {workspaceView === "sdks" ? (
+
) : null}
-
+
+
+
+
+ {activeWorkspace.label}
+
+
+ {searchedItems.length}{" "}
+ {activeWorkspace.singularLabel}
+ {searchedItems.length === 1 ? "" : "s"}
+
+
+ {newItemCount > 0 ? (
+
{
+ const latestItem = newItems.at(-1);
+ if (latestItem) {
+ selectItem(latestItem.id);
+ }
+ }}
+ >
+ View {newItemCount} new{" "}
+ {activeWorkspace.singularLabel}
+ {newItemCount === 1 ? "" : "s"}
+
+ ) : null}
+
+
+ {searchedItems.map((item) => (
+
+ ))}
+ {searchedItems.length === 0 ? (
+
+ {workspaceEmptyState.title}
+
+ ) : null}
+
+ >
+ }
+ >
+ {searchedItems.length === 0 ? (
+
+ ) : selectedItem ? (
+
+ ) : null}
+
+ >
)}
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 ? (
+
+ ) : null}
+ {ai.response !== undefined ? (
+
+ ) : 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..832816d5f
--- /dev/null
+++ b/apps/local/src/components/attachment-detail.tsx
@@ -0,0 +1,122 @@
+import { attachmentBytes } from "@/lib/attachments.ts";
+import { useEffect, useState } from "react";
+import type { LocalFeedItem } from "@/lib/spotlight.ts";
+import { display, record, text } from "@/lib/inspection.ts";
+import { JsonView } from "./json-view.tsx";
+
+const IMAGE_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+]);
+const VIDEO_TYPES = new Set(["video/mp4", "video/webm"]);
+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.isText ? "text/plain" : decoded.contentType,
+ }),
+ );
+ // 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 (
+
+
+
+ {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 && IMAGE_TYPES.has(attachment.contentType) ? (
+
+ ) : url && VIDEO_TYPES.has(attachment.contentType) ? (
+
+
+
+ ) : (
+
+ No preview available for this file type. Download it to inspect its
+ contents.
+
+ )}
+
+ );
+}
+export function EnvelopeAttachments({ item }: { item: LocalFeedItem }) {
+ const envelope = item.payload;
+ if (!Array.isArray(envelope) || !Array.isArray(envelope[1])) return null;
+ const attachments = envelope[1].flatMap((entry, index): LocalFeedItem[] =>
+ Array.isArray(entry) && record(entry[0]).type === "attachment"
+ ? [
+ {
+ id: `${item.id}:${index}`,
+ type: "attachment",
+ itemHeader: record(entry[0]),
+ payload: entry[1],
+ text: display(entry[1]),
+ },
+ ]
+ : [],
+ );
+ if (!attachments.length) return null;
+ return (
+
+ {attachments.map((attachment) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/local/src/components/event-context.tsx b/apps/local/src/components/event-context.tsx
new file mode 100644
index 000000000..8886175d0
--- /dev/null
+++ b/apps/local/src/components/event-context.tsx
@@ -0,0 +1,132 @@
+import { record, display, text, feedbackData } 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;
+ const sections = ["request", "contexts", "tags", "extra", "modules"] as const;
+ return (
+
+ {Array.isArray(breadcrumbs) && breadcrumbs.length ? (
+
+ Breadcrumbs
+
+ {breadcrumbs.map((value, index) => {
+ const crumb = record(value);
+ return (
+
+
+
+ {text(crumb.category) ?? text(crumb.type) ?? "Breadcrumb"}
+
+ {text(crumb.level)}
+
+ {crumb.timestamp === undefined
+ ? ""
+ : display(crumb.timestamp)}
+
+
+ {crumb.message !== undefined ? (
+
+ {display(crumb.message)}
+
+ ) : null}
+ {Object.keys(record(crumb.data)).length ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+ ) : null}
+ {sections.map((key) =>
+ Object.keys(record(p[key])).length ? (
+
+
+ {key === "extra" ? "Additional data" : key}
+
+ {key === "contexts" ? (
+ Object.entries(record(p.contexts)).map(([name, context]) => (
+
+ ))
+ ) : (
+
+ )}
+
+ ) : 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 ? (
+ onSelect(associated)}
+ >
+ View associated error · {associated.metadata?.title ?? associatedId}
+
+ ) : associatedId ? (
+
+ The associated event is not in this capture.
+
+ ) : null}
+
+ );
+}
diff --git a/apps/local/src/components/event-detail.tsx b/apps/local/src/components/event-detail.tsx
index e5f44f2e0..5ec3db560 100644
--- a/apps/local/src/components/event-detail.tsx
+++ b/apps/local/src/components/event-detail.tsx
@@ -1,3 +1,8 @@
+import { EventContext, FeedbackInspector } from "./event-context.tsx";
+import { AiInspector } from "./ai-inspector.tsx";
+import { AttachmentDetail, EnvelopeAttachments } from "./attachment-detail.tsx";
+import { errorMarkdown, inspectAi } from "@/lib/inspection.ts";
+import { Button } from "./ui/button.tsx";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { useId, useState } from "react";
import {
@@ -20,6 +25,7 @@ import {
type EventDetailProps = {
item: LocalFeedItem;
+ relatedItems?: readonly LocalFeedItem[];
trace?: TraceGroup;
onSelectSpan?: (traceId: string, spanId: string) => void;
onSelectRelatedItem?: (item: LocalFeedItem) => void;
@@ -91,21 +97,30 @@ function CompactSummary({
/** 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 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",
- ].includes(item.type);
+ const hasOverview =
+ [
+ "event",
+ "log",
+ "transaction",
+ "span",
+ "feedback",
+ "user_report",
+ "attachment",
+ ].includes(item.type) ||
+ Boolean(ai) ||
+ (item.type === "envelope" && item.text.includes('"attachment"'));
const message =
metadata.message ??
(item.type === "log"
@@ -177,6 +192,18 @@ export function EventDetail({
{item.type === "envelope" ? "Raw envelope" : metadata.title}
+ {item.type === "event" ? (
+
{
+ void copyForAi(errorMarkdown(item));
+ setCopiedForAi(true);
+ }}
+ >
+ {copiedForAi ? "Copied for AI" : "Copy for AI"}
+
+ ) : null}
{formatTimestamp(item.timestamp)}
@@ -187,6 +214,28 @@ export function EventDetail({
json={item.text}
overview={
<>
+ {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}
@@ -218,11 +267,18 @@ export function EventDetail({
{!fields.length &&
!(isRequestOrSpan && summaryFields.length) &&
!exceptions.length &&
- !attributes.length ? (
+ !attributes.length &&
+ !ai &&
+ !["attachment", "envelope", "feedback", "user_report"].includes(
+ item.type,
+ ) ? (
No additional event details.
) : null}
+ {item.type === "event" || item.type === "transaction" ? (
+
+ ) : null}
{trace ? (
<>
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 (
+
+
+
+
+ Filters{count ? ` (${count})` : ""}
+
+
+
+
+ Severity
+ {levels.map((level) => (
+
+
+ onChange({
+ ...filters,
+ levels: toggle(filters.levels, level),
+ })
+ }
+ />
+ {level}
+
+ ))}
+
+ {showLogger ? (
+
+ Logger
+ {loggers.map((logger) => (
+
+
+ onChange({
+ ...filters,
+ loggers: toggle(filters.loggers, logger),
+ })
+ }
+ />
+ {logger}
+
+ ))}
+
+ ) : null}
+
+
+
+ Sort
+
+ onChange({
+ ...filters,
+ sort: event.target.value as EventFilters["sort"],
+ })
+ }
+ >
+ Received order
+ Newest first
+ Oldest first
+ Longest duration
+
+
+
+
+
+ Columns
+
+
+
+
+ Message is always shown.
+
+ {(
+ [
+ "time",
+ "type",
+ "level",
+ ...(showLogger ? ["logger"] : []),
+ "duration",
+ ] as EventColumn[]
+ ).map((column) => (
+
+
+ onChange({
+ ...filters,
+ columns: toggle(filters.columns, column) as EventColumn[],
+ })
+ }
+ />
+ {column}
+
+ ))}
+
+
+ {count ? (
+
+ onChange({
+ ...defaultEventFilters,
+ sort: filters.sort,
+ columns: filters.columns,
+ })
+ }
+ >
+ Clear {count} filter{count === 1 ? "" : "s"}
+
+ ) : 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/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/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..1af30e74f
--- /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.
+
+
+
+
+
+ Sort by{" "}
+ {
+ setSort(event.target.value as typeof sort);
+ setAscending(event.target.value === "name");
+ }}
+ >
+ {columns.map(([key, title]) => (
+
+ {title}
+
+ ))}
+
+
+ setAscending(!ascending)}
+ >
+ {ascending ? "↑" : "↓"}
+
+
+ {groups.length} groups
+
+
+
+ {groups.map((group) => (
+
+ setSelectedId(group.id)}
+ >
+
+ {group.name}
+
+
+ {group.count} executions
+ {ms(group.totalMs)} total
+ {ms(group.averageMs)} avg
+ {kind === "resources" &&
+ group.averageBytes !== undefined ? (
+
+ {Math.round(group.averageBytes).toLocaleString()} B
+ avg
+
+ ) : null}
+
+
+
+ ))}
+
+
+ }
+ >
+
+ {selected.operation}
+
+ {selected.name}
+
+
+ Captured executions · {selected.count}
+
+
+ {selected.executions
+ .toSorted(
+ (a, b) => (b.span.durationMs ?? -1) - (a.span.durationMs ?? -1),
+ )
+ .map((entry) => (
+
+ onSelectSpan(entry.traceId, entry.span.id)}
+ >
+
+ {entry.traceTitle}
+
+ {entry.span.id}
+
+
+
+ {ms(entry.span.durationMs)}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+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) => (
+
+ setSelectedName(page.name)}
+ >
+ {page.name}
+
+ {page.visits.length} captured visit
+ {page.visits.length === 1 ? "" : "s"}
+
+
+
+ ))}
+
+
+ }
+ >
+
+ {selected.name}
+
+ Local measurements per visit. These are development samples, not a
+ field percentile or a combined performance score.
+
+
+ Measurement
+
+ setChosenMetric(event.target.value as VitalName)
+ }
+ >
+ {available.map((name) => (
+
+ {VITALS[name].label}
+
+ ))}
+
+
+
+ Good ≤ {valueLabel(config.good)} · Poor > {valueLabel(config.poor)}
+
+
+ {selected.visits.map((visit, index) => {
+ const measurement = visit.metrics[metric];
+ return (
+
+ onSelectItem(visit.item)}
+ >
+ Inspect visit {index + 1}
+
+ {measurement ? (
+ <>
+
+ {measurement.rating}
+
+ {valueLabel(measurement.value)}
+
+
+
+ >
+ ) : (
+
+ Not captured for this visit
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/local/src/components/profile-workspace.tsx b/apps/local/src/components/profile-workspace.tsx
new file mode 100644
index 000000000..1530d89ee
--- /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"}
+
+
+ Thread{" "}
+ {
+ setThread(event.target.value);
+ setFocus(undefined);
+ }}
+ >
+ {profile.threads.map((entry) => (
+
+ {entry.name} ({entry.id})
+
+ ))}
+
+
+
+
+ {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) => (
+ onSelectTrace(trace.id)}
+ >
+ View trace · {trace.title}
+
+ ))}
+
+ ) : (
+
+ No associated trace is available in this capture.
+
+ )}
+ {profile.sampleCount && profile.functions.length ? (
+ <>
+
+
+ Visualization{" "}
+
+ setType(event.target.value as typeof type)
+ }
+ >
+ Flame
+ Treemap
+ Sunburst
+
+
+ {focus ? (
+ setFocus(undefined)}
+ >
+ Reset zoom
+
+ ) : null}
+
+ {focus ? (
+
+ {focus.name} · {focus.samples} samples · {focus.location}
+
+ ) : null}
+
+
+ Find function
+ setFilter(event.target.value)}
+ />
+
+
+
+
+
+
+ Function
+
+ {(
+ [
+ ["selfMs", "Self time"],
+ ["totalMs", "Total time"],
+ ["samples", "Samples"],
+ ] as const
+ ).map(([key, label]) => (
+
+ setSort(key)}
+ >
+ {label}
+ {sort === key ? " ↓" : ""}
+
+
+ ))}
+
+
+
+ {functions.map((fn) => (
+
+
+ {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) => (
+
+ onSelect(item.id)}
+ >
+
+ {item.type === "profile_chunk" ? "Chunk" : "Profile"}{" "}
+ {index + 1} ·{" "}
+ {text(record(item.payload).platform) ?? "Unknown platform"}
+
+
+ {text(record(item.payload).event_id) ??
+ text(record(item.payload).chunk_id) ??
+ item.id}
+
+
+
+ ))}
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/apps/local/src/components/sdk-inventory.tsx b/apps/local/src/components/sdk-inventory.tsx
new file mode 100644
index 000000000..a34097e60
--- /dev/null
+++ b/apps/local/src/components/sdk-inventory.tsx
@@ -0,0 +1,72 @@
+import { sdkInventory } from "@/lib/inspection.ts";
+import type { LocalFeedItem } from "@/lib/spotlight.ts";
+export function SdkInventory({
+ items,
+ query = "",
+}: {
+ items: readonly LocalFeedItem[];
+ query?: string;
+}) {
+ const entries = sdkInventory(items).filter((entry) =>
+ `${entry.name} ${entry.version} ${entry.platform}`
+ .toLowerCase()
+ .includes(query.toLowerCase()),
+ );
+ return (
+
+ Connected SDKs
+
+ SDK versions observed in this capture. Session and client reports appear
+ below.
+
+ {entries.length ? (
+
+
+
+
+ {["SDK", "Version", "Platform", "Items", "Last seen"].map(
+ (label) => (
+
+ {label}
+
+ ),
+ )}
+
+
+
+ {entries.map((entry) => (
+
+
+ {entry.name}
+
+ {entry.version}
+ {entry.platform}
+ {entry.count}
+
+ {entry.lastSeen === undefined
+ ? "Unknown"
+ : String(entry.lastSeen)}
+
+
+ ))}
+
+
+
+ ) : (
+
+ No matching SDK metadata captured.
+
+ )}
+
+ );
+}
diff --git a/apps/local/src/components/semantic-inspection.test.tsx b/apps/local/src/components/semantic-inspection.test.tsx
new file mode 100644
index 000000000..b361afc69
--- /dev/null
+++ b/apps/local/src/components/semantic-inspection.test.tsx
@@ -0,0 +1,242 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, expect, test, vi } from "vitest";
+import { decodeEnvelope } from "../lib/spotlight.ts";
+import { aiItems, sdkInventory } from "../lib/inspection.ts";
+import { EventDetail } from "./event-detail.tsx";
+import { AttachmentDetail } from "./attachment-detail.tsx";
+import { attachmentBytes } from "../lib/attachments.ts";
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+const decode = (type: string, payload: unknown) =>
+ decodeEnvelope(
+ JSON.stringify([
+ { sdk: { name: "sentry.javascript.node", version: "10.0" } },
+ [[{ type }, payload]],
+ ]),
+ "test",
+ )[0]!;
+
+test("errors expose captured breadcrumbs, contexts, locals and editor actions", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Opened checkout")).toBeDefined();
+ expect(screen.getByText("Firefox")).toBeDefined();
+ expect(screen.getByText("cartSize")).toBeDefined();
+ expect(
+ screen.getByRole("link", { name: /Open in VS Code/ }).getAttribute("href"),
+ ).toContain("checkout.ts:12");
+ expect(screen.getByRole("button", { name: "Copy for AI" })).toBeDefined();
+});
+
+test("AI includes embedded spans with readable conversation and tool execution", () => {
+ const item = decode("transaction", {
+ contexts: { trace: { trace_id: "trace", span_id: "root" } },
+ spans: [
+ {
+ span_id: "chat",
+ op: "gen_ai.chat",
+ description: "Assistant turn",
+ data: {
+ "gen_ai.request.model": "test-model",
+ "gen_ai.usage.input_tokens": 42,
+ "gen_ai.request.messages": JSON.stringify([
+ { role: "user", content: "Find my order" },
+ ]),
+ "gen_ai.response.text": "Order found",
+ "gen_ai.response.tool_calls": JSON.stringify([
+ { name: "find_order", arguments: { id: 12 } },
+ ]),
+ },
+ },
+ ],
+ });
+ const [ai] = aiItems([item]);
+ expect(ai).toBeDefined();
+ render( );
+ expect(screen.getByText("test-model")).toBeDefined();
+ expect(screen.getByText("Find my order")).toBeDefined();
+ expect(screen.getByText("Order found")).toBeDefined();
+ expect(screen.getByText("find_order")).toBeDefined();
+ expect(screen.getByText("42")).toBeDefined();
+});
+
+test("feedback reads modern contexts.feedback and links to associated captured error", () => {
+ const error = decode("event", {
+ event_id: "error-id",
+ level: "error",
+ message: "Oops",
+ });
+ const feedback = decode("feedback", {
+ contexts: {
+ feedback: {
+ message: "Checkout is broken",
+ contact_email: "test@example.com",
+ associated_event_id: "error-id",
+ },
+ },
+ });
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Feedback details" }).textContent,
+ ).toContain("Checkout is broken");
+ fireEvent.click(
+ screen.getByRole("button", { name: /View associated error/ }),
+ );
+ expect(onSelect).toHaveBeenCalledWith(error);
+});
+
+test("attachments preserve filename and display HTML as inert source", () => {
+ const [attachment] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ {
+ type: "attachment",
+ filename: "report.html",
+ content_type: "text/html",
+ },
+ { data: "" },
+ ],
+ ],
+ ]),
+ "attachment",
+ );
+ render( );
+ expect(screen.getByText("report.html")).toBeDefined();
+ expect(screen.getByText("")).toBeDefined();
+ expect(document.querySelector("script")).toBeNull();
+});
+
+test("SDK inventory discovers envelope SDK metadata without client reports", () => {
+ const entries = sdkInventory([
+ decode("event", { platform: "javascript" }),
+ decode("log", { body: "test" }),
+ ]);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ name: "sentry.javascript.node",
+ version: "10.0",
+ count: 2,
+ });
+});
+
+test("binary and charset attachment payloads retain receiver-encoded bytes", () => {
+ const [binary] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ {
+ type: "attachment",
+ filename: "file.bin",
+ content_type: "application/octet-stream",
+ },
+ { data: "AP9B" },
+ ],
+ ],
+ ]),
+ "binary",
+ );
+ expect([...attachmentBytes(binary!)!.bytes]).toEqual([0, 255, 65]);
+ const [text] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "attachment", content_type: "text/plain; charset=utf-8" },
+ { data: "aGVsbG8=" },
+ ],
+ ],
+ ]),
+ "text",
+ );
+ expect(new TextDecoder().decode(attachmentBytes(text!)!.bytes)).toBe("hello");
+});
+
+test("minimal span-v2 AI operations remain visible with honest missing-content details", () => {
+ const item = decode("span", {
+ version: 2,
+ items: [
+ {
+ trace_id: "t",
+ span_id: "s",
+ start_timestamp: 1,
+ end_timestamp: 2,
+ attributes: { "sentry.op": { type: "string", value: "gen_ai.chat" } },
+ },
+ ],
+ });
+ expect(item.metadata?.operation).toBe("gen_ai.chat");
+ expect(aiItems([item])).toEqual([item]);
+ render( );
+ expect(
+ screen.getByRole("region", { name: "AI interaction" }).textContent,
+ ).toContain("Prompt and response content were not captured");
+});
+
+test("legacy user reports associate by event_id even when the report arrives first", () => {
+ const error = decode("event", {
+ event_id: "error-id",
+ level: "error",
+ message: "Oops",
+ });
+ const feedback = decode("user_report", {
+ event_id: "error-id",
+ comments: "Broken checkout",
+ name: "Test",
+ email: "test@example.com",
+ });
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Feedback details" }).textContent,
+ ).toContain("Broken checkout");
+ fireEvent.click(
+ screen.getByRole("button", { name: /View associated error/ }),
+ );
+ expect(onSelect).toHaveBeenCalledWith(error);
+});
diff --git a/apps/local/src/components/span-detail.tsx b/apps/local/src/components/span-detail.tsx
index 07e42341e..d16fa7d42 100644
--- a/apps/local/src/components/span-detail.tsx
+++ b/apps/local/src/components/span-detail.tsx
@@ -1,3 +1,4 @@
+import { AiInspector } from "./ai-inspector.tsx";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { Check, Copy, ChevronLeft } from "lucide-react";
import { type KeyboardEvent, type ReactNode, useId, useState } from "react";
@@ -277,6 +278,7 @@ export function SpanDetail({
json={JSON.stringify(span.payload, null, 2) ?? "null"}
overview={
<>
+
{attributes.length ? (
diff --git a/apps/local/src/components/trace-waterfall.tsx b/apps/local/src/components/trace-waterfall.tsx
index 015d20b21..61237934d 100644
--- a/apps/local/src/components/trace-waterfall.tsx
+++ b/apps/local/src/components/trace-waterfall.tsx
@@ -1,3 +1,5 @@
+import { useMemo, useState } from "react";
+import { ChevronDown, ChevronRight } from "lucide-react";
import type { TraceGroup, TraceSpan } from "@/lib/trace-model.ts";
type TraceWaterfallProps = {
@@ -50,9 +52,12 @@ function flattenSpans(spans: TraceSpan[]): PositionedSpan[] {
}))
.reverse();
const positioned: PositionedSpan[] = [];
+ const visited = new Set();
while (pending.length > 0) {
const current = pending.pop();
if (!current) break;
+ if (visited.has(current.span.id)) continue;
+ visited.add(current.span.id);
positioned.push(current);
const children = current.span.children;
for (let index = children.length - 1; index >= 0; index -= 1) {
@@ -84,7 +89,15 @@ function SpanRow({
index,
selectedSpanId,
onSelectSpan,
+ collapsed,
+ onToggle,
+ treeWidth,
+ matched,
}: {
+ collapsed: boolean;
+ onToggle: () => void;
+ treeWidth: number;
+ matched: boolean;
positionedSpan: PositionedSpan;
trace: TraceGroup;
index: number;
@@ -99,6 +112,10 @@ function SpanRow({
return (
>
) : null}
+ {span.children.length ? (
+
+ {collapsed ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
onSelectSpan(span.id)}
>
{span.description}
@@ -188,6 +221,24 @@ export function TraceWaterfall({
selectedSpanId,
onSelectSpan,
}: TraceWaterfallProps) {
+ const [query, setQuery] = useState("");
+ const [onlyMatches, setOnlyMatches] = useState(false);
+ const [collapsed, setCollapsed] = useState>(new Set());
+ const [treeWidth, setTreeWidth] = useState(40);
+ const allSpans = useMemo(() => flattenSpans(trace.roots), [trace]);
+ const matching = useMemo(
+ () =>
+ new Set(
+ allSpans
+ .filter(({ span }) =>
+ `${span.description} ${span.operation ?? ""} ${span.id}`
+ .toLowerCase()
+ .includes(query.trim().toLowerCase()),
+ )
+ .map(({ span }) => span.id),
+ ),
+ [allSpans, query],
+ );
const capturedItemLabel = `${trace.itemCount} captured item${trace.itemCount === 1 ? "" : "s"} in this trace`;
if (trace.spans.length === 0) {
@@ -232,7 +283,16 @@ export function TraceWaterfall({
);
}
- const spans = flattenSpans(trace.roots);
+ const spans: PositionedSpan[] = [];
+ let hiddenBelow: number | undefined;
+ for (const entry of allSpans) {
+ if (!query.trim()) {
+ if (hiddenBelow !== undefined && entry.depth > hiddenBelow) continue;
+ hiddenBelow = collapsed.has(entry.span.id) ? entry.depth : undefined;
+ }
+ if (!query.trim() || !onlyMatches || matching.has(entry.span.id))
+ spans.push(entry);
+ }
return (
@@ -279,9 +339,48 @@ export function TraceWaterfall({
)}
+ {!compact ? (
+
+ setQuery(event.target.value)}
+ />
+
+ setOnlyMatches(event.target.checked)}
+ />
+ Only show matches
+
+
+ Tree width
+ setTreeWidth(Number(event.target.value))}
+ className="w-20"
+ />
+
+ {query.trim() ? (
+
+ {matching.size} matches · branches expanded while searching
+
+ ) : null}
+
+ ) : null}
+ {spans.length === 0 ? (
+
+ No spans match this search.
+
+ ) : null}
{spans.map((positionedSpan, index) => (
+ setCollapsed((previous) => {
+ const next = new Set(previous);
+ if (next.has(positionedSpan.span.id))
+ next.delete(positionedSpan.span.id);
+ else next.add(positionedSpan.span.id);
+ return next;
+ })
+ }
/>
))}
diff --git a/apps/local/src/components/trace-workspace.tsx b/apps/local/src/components/trace-workspace.tsx
index 1dd21ae3e..46daf5a5e 100644
--- a/apps/local/src/components/trace-workspace.tsx
+++ b/apps/local/src/components/trace-workspace.tsx
@@ -1,3 +1,4 @@
+import { profileMatchesTrace } from "@/lib/profiles.ts";
import { useEffect, useRef } from "react";
import type { LocalFeedItem } from "../lib/spotlight";
import { ResizableWorkspace } from "./resizable-workspace";
@@ -14,6 +15,8 @@ type TraceWorkspaceProps = {
onClearSpan?: () => void;
onSelectRelatedItem?: (item: LocalFeedItem) => void;
traces: TraceGroup[];
+ profiles?: readonly LocalFeedItem[];
+ onSelectProfile?: (id: string) => void;
};
function formatDuration(durationMs: number | undefined) {
@@ -41,6 +44,8 @@ export function TraceWorkspace({
onClearSpan,
onSelectRelatedItem,
traces,
+ profiles = [],
+ onSelectProfile,
}: TraceWorkspaceProps) {
const selectedTrace =
traces.find((trace) => trace.id === selectedTraceId) ?? traces[0];
@@ -159,6 +164,24 @@ export function TraceWorkspace({
}
>
+ {onSelectProfile
+ ? profiles
+ .filter((profile) => profileMatchesTrace(profile, selectedTrace))
+ .map((profile) => (
+ onSelectProfile(profile.id)}
+ >
+ Inspect{" "}
+ {profile.type === "profile_chunk"
+ ? "continuous profile chunk"
+ : "profile"}{" "}
+ for this trace
+
+ ))
+ : null}
{selectedSpan ? (
) : (
{
const Icon = entry.icon;
const count =
- entry.id === "traces" ? traceCount : entry.getItems(snapshot).length;
+ entry.section === "Performance"
+ ? 0
+ : entry.id === "sdks"
+ ? sdkInventory(snapshot.items).length
+ : entry.id === "traces"
+ ? traceCount
+ : entry.getItems(snapshot).length;
const active = activeView === entry.id;
return (
entry.section === "Explore")
.map(renderEntry)}
+
+
+ Performance
+
+ {workspaceNavigation
+ .filter((entry) => entry.section === "Performance")
+ .map(renderEntry)}
+
; contentType: string; isText: boolean }
+ | undefined {
+ const wireType =
+ text(item.itemHeader?.content_type) ?? "application/octet-stream";
+ const contentType = wireType.split(";")[0]!.trim().toLowerCase();
+ const payload = record(item.payload),
+ raw = payload.data ?? item.payload;
+ const isText = contentType.startsWith("text/") || TEXT_TYPES.has(contentType);
+ if (typeof raw !== "string") return;
+ try {
+ return {
+ contentType,
+ isText,
+ bytes: new Set([
+ "text/plain",
+ "text/css",
+ "text/csv",
+ "text/html",
+ "text/javascript",
+ "text/json",
+ "text/x-json",
+ "application/json",
+ "application/ld+json",
+ ]).has(wireType)
+ ? new TextEncoder().encode(raw)
+ : Uint8Array.from(atob(raw), (c) => c.charCodeAt(0)),
+ };
+ } catch {
+ return;
+ }
+}
diff --git a/apps/local/src/lib/event-filters.ts b/apps/local/src/lib/event-filters.ts
new file mode 100644
index 000000000..bc2492bef
--- /dev/null
+++ b/apps/local/src/lib/event-filters.ts
@@ -0,0 +1,46 @@
+import type { LocalFeedItem } from "./spotlight.ts";
+import { spanData, text } from "./inspection.ts";
+export type EventColumn = "time" | "type" | "level" | "logger" | "duration";
+export type EventFilters = {
+ levels: string[];
+ loggers: string[];
+ sort: "received" | "newest" | "oldest" | "duration";
+ columns: EventColumn[];
+};
+export const defaultEventFilters: EventFilters = {
+ levels: [],
+ loggers: [],
+ sort: "received",
+ columns: ["time", "type", "level", "logger", "duration"],
+};
+export function loggerForItem(item: LocalFeedItem): string | undefined {
+ return text(spanData(item.payload)["sentry.logger.name"]);
+}
+function timestamp(item: LocalFeedItem): number {
+ const value = item.timestamp;
+ if (typeof value === "number") return value < 1e12 ? value * 1000 : value;
+ if (typeof value === "string") {
+ const date = Date.parse(value);
+ if (Number.isFinite(date)) return date;
+ }
+ return -Infinity;
+}
+export function filterEvents(
+ items: readonly LocalFeedItem[],
+ filters: EventFilters,
+): LocalFeedItem[] {
+ const filtered = items.filter(
+ (item) =>
+ (!filters.levels.length ||
+ filters.levels.includes(item.metadata?.level ?? "unknown")) &&
+ (!filters.loggers.length ||
+ filters.loggers.includes(loggerForItem(item) ?? "unknown")),
+ );
+ if (filters.sort === "received") return filtered;
+ return filtered.sort((a, b) =>
+ filters.sort === "duration"
+ ? (b.metadata?.durationMs ?? -Infinity) -
+ (a.metadata?.durationMs ?? -Infinity)
+ : (timestamp(b) - timestamp(a)) * (filters.sort === "oldest" ? -1 : 1),
+ );
+}
diff --git a/apps/local/src/lib/exceptions.ts b/apps/local/src/lib/exceptions.ts
index 690ad875d..721e7f7c9 100644
--- a/apps/local/src/lib/exceptions.ts
+++ b/apps/local/src/lib/exceptions.ts
@@ -5,6 +5,8 @@ export type ExceptionFrame = {
line?: number;
column?: number;
source?: string;
+ variables?: Record;
+ editorPath?: string;
};
export type EventException = {
@@ -57,7 +59,21 @@ export function getEventExceptions(payload: unknown): EventException[] {
text(frame.abs_path) ??
text(frame.module);
const name = text(frame.function);
- const source = text(frame.context_line);
+ const line = text(frame.context_line);
+ const before = Array.isArray(frame.pre_context)
+ ? frame.pre_context.filter(
+ (value): value is string => typeof value === "string",
+ )
+ : [];
+ const after = Array.isArray(frame.post_context)
+ ? frame.post_context.filter(
+ (value): value is string => typeof value === "string",
+ )
+ : [];
+ const source =
+ before.length || after.length
+ ? [...before, line ?? "", ...after].join("\n")
+ : line;
if (!filename && !name && !source) return [];
return [
{
@@ -66,6 +82,12 @@ export function getEventExceptions(payload: unknown): EventException[] {
line: coordinate(frame.lineno),
column: coordinate(frame.colno),
source,
+ ...(record(frame.vars)
+ ? { variables: record(frame.vars) }
+ : {}),
+ ...(text(frame.abs_path) && frame.abs_path !== filename
+ ? { editorPath: text(frame.abs_path) }
+ : {}),
},
];
})
diff --git a/apps/local/src/lib/inspection.ts b/apps/local/src/lib/inspection.ts
new file mode 100644
index 000000000..6fcb703bb
--- /dev/null
+++ b/apps/local/src/lib/inspection.ts
@@ -0,0 +1,243 @@
+import { getEventMetadata, type LocalFeedItem } from "./spotlight.ts";
+import { getEventExceptions, formatExceptionTitle } from "./exceptions.ts";
+
+export function record(value: unknown): Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+export function text(value: unknown): string | undefined {
+ return typeof value === "string" && value ? value : undefined;
+}
+export function finite(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value)
+ ? value
+ : undefined;
+}
+export function display(value: unknown): string {
+ return typeof value === "string"
+ ? value
+ : (JSON.stringify(value, null, 2) ?? String(value));
+}
+export function parsed(value: unknown): unknown {
+ if (typeof value !== "string") return value;
+ try {
+ return JSON.parse(value);
+ } catch {
+ return value;
+ }
+}
+/** Merge legacy span data and typed span-v2 attributes without losing unknown fields. */
+export function spanData(payload: unknown): Record {
+ const p = record(payload);
+ const attributes = Object.fromEntries(
+ Object.entries(record(p.attributes)).map(([key, value]) => [
+ key,
+ "value" in record(value) ? record(value).value : value,
+ ]),
+ );
+ return {
+ ...record(record(record(p.contexts).trace).data),
+ ...record(p.data),
+ ...attributes,
+ };
+}
+export function isAiPayload(payload: unknown): boolean {
+ const p = record(payload),
+ data = spanData(p);
+ return (
+ /^(?:gen_ai|ai|llm)(?:[._]|$)/i.test(
+ text(p.op) ??
+ text(record(record(p.contexts).trace).op) ??
+ text(data["sentry.op"]) ??
+ "",
+ ) ||
+ Object.keys(data).some((key) => /^(?:gen_ai|vercel\.ai|ai|llm)\./.test(key))
+ );
+}
+/** Include AI spans embedded in transactions, deduplicated against standalone copies. */
+export function aiItems(items: readonly LocalFeedItem[]): LocalFeedItem[] {
+ const found = new Map();
+ for (const item of items) {
+ const payload = record(item.payload);
+ const candidates = [
+ item,
+ ...(Array.isArray(payload.spans) ? payload.spans : [])
+ .filter(isAiPayload)
+ .map((span, index): LocalFeedItem => {
+ const data = record(span);
+ const traceId = item.metadata?.traceId;
+ const enriched = { ...data, trace_id: data.trace_id ?? traceId };
+ return {
+ ...item,
+ id: `${item.id}:span:${text(data.span_id) ?? index}`,
+ type: "span",
+ payload: enriched,
+ text: display(enriched),
+ metadata: getEventMetadata(enriched, "span"),
+ };
+ }),
+ ];
+ for (const candidate of candidates)
+ if (isAiPayload(candidate.payload)) {
+ const metadata = candidate.metadata;
+ const key = metadata?.spanId
+ ? `${metadata.traceId ?? ""}:${metadata.spanId}`
+ : candidate.id;
+ found.set(key, candidate);
+ }
+ }
+ return [...found.values()];
+}
+export type AiInspection = {
+ fields: { label: string; value: string }[];
+ system?: unknown;
+ messages?: unknown;
+ response?: unknown;
+ tools: unknown[];
+};
+export function inspectAi(payload: unknown): AiInspection | undefined {
+ if (!isAiPayload(payload)) return;
+ const data = spanData(payload);
+ const pick = (...keys: string[]) =>
+ keys
+ .map((key) => data[key])
+ .find((value) => value !== undefined && value !== null);
+ const definitions: [string, string[]][] = [
+ [
+ "Model",
+ ["gen_ai.request.model", "gen_ai.response.model", "vercel.ai.model.id"],
+ ],
+ [
+ "Provider",
+ ["gen_ai.system", "gen_ai.provider.name", "vercel.ai.model.provider"],
+ ],
+ [
+ "Input tokens",
+ [
+ "gen_ai.usage.input_tokens",
+ "vercel.ai.usage.promptTokens",
+ "ai.usage.promptTokens",
+ ],
+ ],
+ [
+ "Output tokens",
+ [
+ "gen_ai.usage.output_tokens",
+ "vercel.ai.usage.completionTokens",
+ "ai.usage.completionTokens",
+ ],
+ ],
+ [
+ "Finish reason",
+ ["gen_ai.response.finish_reasons", "vercel.ai.response.finishReason"],
+ ],
+ ["Function", ["gen_ai.function_id", "vercel.ai.telemetry.functionId"]],
+ ["Max retries", ["vercel.ai.settings.maxRetries"]],
+ ["Max steps", ["vercel.ai.settings.maxSteps"]],
+ ];
+ const calls = parsed(
+ pick(
+ "gen_ai.response.tool_calls",
+ "vercel.ai.response.toolCalls",
+ "ai.response.toolCalls",
+ ),
+ );
+ const tools = Array.isArray(calls)
+ ? [...calls]
+ : calls === undefined
+ ? []
+ : [calls];
+ const toolName = pick("gen_ai.tool.name", "ai.toolCall.name");
+ if (toolName !== undefined)
+ tools.push({
+ name: toolName,
+ id: pick("gen_ai.tool.call.id", "ai.toolCall.id"),
+ arguments: parsed(pick("gen_ai.tool.input", "ai.toolCall.args")),
+ result: parsed(pick("gen_ai.tool.output", "ai.toolCall.result")),
+ });
+ return {
+ fields: definitions.flatMap(([label, keys]) => {
+ const value = pick(...keys);
+ return value === undefined ? [] : [{ label, value: display(value) }];
+ }),
+ system: parsed(
+ pick(
+ "gen_ai.system_instructions",
+ "gen_ai.prompt.system",
+ "ai.prompt.system",
+ ),
+ ),
+ messages: parsed(
+ pick(
+ "gen_ai.request.messages",
+ "gen_ai.input.messages",
+ "vercel.ai.prompt.messages",
+ "vercel.ai.prompt",
+ "gen_ai.prompt",
+ "ai.prompt.messages",
+ "ai.prompt",
+ ),
+ ),
+ response: parsed(
+ pick(
+ "gen_ai.response.text",
+ "gen_ai.output.messages",
+ "vercel.ai.response.text",
+ "ai.response.text",
+ ),
+ ),
+ tools,
+ };
+}
+export function feedbackData(payload: unknown): Record {
+ const p = record(payload);
+ return { ...p, ...record(record(p.contexts).feedback) };
+}
+export type SdkEntry = {
+ name: string;
+ version: string;
+ platform: string;
+ lastSeen?: number | string;
+ count: number;
+};
+export function sdkInventory(items: readonly LocalFeedItem[]): SdkEntry[] {
+ const entries = new Map();
+ for (const item of items) {
+ const p = record(item.payload),
+ sdk = { ...record(item.envelopeHeader?.sdk), ...record(p.sdk) };
+ const name = text(sdk.name);
+ if (!name) continue;
+ const version = text(sdk.version) ?? "Unknown",
+ key = `${name}@${version}`;
+ const previous = entries.get(key);
+ const timestamp = item.timestamp ?? text(item.envelopeHeader?.sent_at);
+ entries.set(key, {
+ name,
+ version,
+ platform: text(p.platform) ?? previous?.platform ?? "Unknown",
+ lastSeen: timestamp ?? previous?.lastSeen,
+ count: (previous?.count ?? 0) + 1,
+ });
+ }
+ return [...entries.values()].sort(
+ (a, b) =>
+ a.name.localeCompare(b.name) || a.version.localeCompare(b.version),
+ );
+}
+export function errorMarkdown(item: LocalFeedItem): string {
+ const p = record(item.payload);
+ const exceptions = getEventExceptions(p)
+ .map(
+ (exception) =>
+ `### ${formatExceptionTitle(exception)}\n${exception.frames.map((frame) => `- ${frame.function} (${frame.filename}${frame.line === undefined ? "" : `:${frame.line}`})${frame.source ? `\n ${frame.source}` : ""}`).join("\n")}`,
+ )
+ .join("\n\n");
+ return [
+ `# ${item.metadata?.title ?? item.type}`,
+ exceptions,
+ `## Captured context\n\n${display({ request: p.request, contexts: p.contexts, tags: p.tags, extra: p.extra, breadcrumbs: p.breadcrumbs })}`,
+ ]
+ .filter(Boolean)
+ .join("\n\n");
+}
diff --git a/apps/local/src/lib/performance.test.ts b/apps/local/src/lib/performance.test.ts
new file mode 100644
index 000000000..2e7628903
--- /dev/null
+++ b/apps/local/src/lib/performance.test.ts
@@ -0,0 +1,180 @@
+import { expect, test } from "vitest";
+import { decodeEnvelope } from "./spotlight.ts";
+import { buildTraceGroups } from "./trace-model.ts";
+import { spanInsights, webVitalPages } from "./performance.ts";
+import { parseProfile, profileMatchesTrace } from "./profiles.ts";
+const decode = (type: string, payload: unknown) =>
+ decodeEnvelope(JSON.stringify([{}, [[{ type }, payload]]]), "fixture")[0]!;
+test("query analysis groups embedded and standalone executions without counting a copied span twice", () => {
+ const transaction = decode("transaction", {
+ transaction: "GET /orders",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: { trace: { trace_id: "t", span_id: "root" } },
+ spans: [
+ {
+ span_id: "q1",
+ op: "db.sql.query",
+ description: "select * from orders",
+ start_timestamp: 1,
+ timestamp: 1.1,
+ },
+ ],
+ });
+ const copy = decode("span", {
+ trace_id: "t",
+ span_id: "q1",
+ op: "db.sql.query",
+ description: "select * from orders",
+ start_timestamp: 1,
+ timestamp: 1.1,
+ });
+ const [group] = spanInsights(
+ buildTraceGroups([transaction, copy]),
+ "queries",
+ );
+ expect(group).toMatchObject({ count: 1, averageMs: 100, totalMs: 100 });
+ expect(group?.executions[0]).toMatchObject({
+ traceId: "t",
+ span: { id: "q1" },
+ });
+});
+test("Web Vitals retain per-visit units and classify thresholds instead of inventing missing measurements", () => {
+ const [page] = webVitalPages([
+ decode("transaction", {
+ transaction: "/orders",
+ contexts: { trace: { op: "pageload", trace_id: "t" } },
+ measurements: {
+ lcp: { value: 3200, unit: "millisecond" },
+ cls: { value: 0.05, unit: "none" },
+ },
+ }),
+ ]);
+ expect(page?.visits[0]?.metrics).toMatchObject({
+ lcp: { value: 3200, rating: "Needs improvement" },
+ cls: { value: 0.05, rating: "Good" },
+ });
+ expect(page?.visits[0]?.metrics.inp).toBeUndefined();
+});
+test("profiles build root-first trees, inclusive/self samples, and interval times per thread", () => {
+ const payload = {
+ profile: {
+ frames: [{ function: "main" }, { function: "query" }],
+ stacks: [[1, 0], [0]],
+ samples: [
+ { thread_id: "main", stack_id: 0, elapsed_since_start_ns: "0" },
+ { thread_id: "main", stack_id: 1, elapsed_since_start_ns: "10000000" },
+ { thread_id: "worker", stack_id: 0, elapsed_since_start_ns: "0" },
+ ],
+ },
+ };
+ const profile = parseProfile(decode("profile", payload), "main");
+ expect(profile.sampleCount).toBe(2);
+ expect(profile.root.children[0]).toMatchObject({
+ name: "main",
+ samples: 2,
+ children: [{ name: "query", samples: 1 }],
+ });
+ expect(profile.functions.find((fn) => fn.name === "query")).toMatchObject({
+ selfMs: 10,
+ totalMs: 10,
+ });
+ expect(profile.threads).toHaveLength(2);
+});
+test("continuous chunks and missing samples remain honest", () => {
+ const chunk = parseProfile(
+ decode("profile_chunk", {
+ profile: {
+ frames: [{ function: "work" }],
+ stacks: [[0]],
+ samples: [
+ { thread_id: "1", stack_id: 0, timestamp: 10 },
+ { thread_id: "1", stack_id: 0, timestamp: 10.02 },
+ ],
+ },
+ }),
+ );
+ expect(chunk.durationMs).toBeCloseTo(20);
+ expect(parseProfile(decode("profile", {})).sampleCount).toBe(0);
+});
+
+test("profiles require captured IDs before associating standalone spans or logs", () => {
+ const chunk = decode("profile_chunk", {
+ profiler_id: "unrelated",
+ profile: { samples: [{ timestamp: 1000 }] },
+ });
+ for (const item of [
+ decode("span", {
+ trace_id: "other",
+ span_id: "span",
+ start_timestamp: 1,
+ timestamp: 2,
+ }),
+ decode("log", { trace_id: "other", timestamp: 1, body: "Unrelated log" }),
+ ]) {
+ expect(profileMatchesTrace(chunk, buildTraceGroups([item])[0]!)).toBe(
+ false,
+ );
+ }
+});
+
+test("legacy and continuous profiles retain real transaction associations", () => {
+ const transaction = decode("transaction", {
+ event_id: "event",
+ start_timestamp: 1,
+ timestamp: 2,
+ contexts: {
+ trace: { trace_id: "t", span_id: "s" },
+ profile: { profiler_id: "profiler", profile_id: "legacy" },
+ },
+ });
+ const trace = buildTraceGroups([transaction])[0]!;
+ for (const payload of [
+ { transaction: { id: "event" } },
+ { transactions: [{ trace_id: "t" }] },
+ { event_id: "legacy" },
+ { profiler_id: "profiler", profile: { samples: [{ timestamp: 1.5 }] } },
+ ])
+ expect(profileMatchesTrace(decode("profile", payload), trace)).toBe(true);
+ expect(
+ profileMatchesTrace(
+ decode("profile_chunk", {
+ profiler_id: "profiler",
+ profile: { samples: [{ timestamp: 3 }] },
+ }),
+ trace,
+ ),
+ ).toBe(false);
+});
+
+test("large continuous profiles can be matched without exceeding argument limits", () => {
+ const transaction = decode("transaction", {
+ event_id: "event",
+ start_timestamp: 1000,
+ timestamp: 1001,
+ contexts: {
+ trace: { trace_id: "t", span_id: "s" },
+ profile: { profiler_id: "profiler" },
+ },
+ });
+ const trace = buildTraceGroups([transaction])[0]!;
+ const profile = {
+ samples: Array.from({ length: 150_000 }, (_, index) => ({
+ timestamp: 1000 + index / 100,
+ thread_id: "1",
+ stack_id: 0,
+ })),
+ };
+ expect(
+ profileMatchesTrace(
+ decode("profile_chunk", { profiler_id: "profiler", profile }),
+ trace,
+ ),
+ ).toBe(true);
+ expect(
+ profileMatchesTrace(
+ decode("profile_chunk", { profiler_id: "unrelated", profile }),
+ trace,
+ ),
+ ).toBe(false);
+});
diff --git a/apps/local/src/lib/performance.ts b/apps/local/src/lib/performance.ts
new file mode 100644
index 000000000..efc6256bb
--- /dev/null
+++ b/apps/local/src/lib/performance.ts
@@ -0,0 +1,115 @@
+import type { LocalFeedItem } from "./spotlight.ts";
+import type { TraceGroup, TraceSpan } from "./trace-model.ts";
+import { finite, record, spanData, text } from "./inspection.ts";
+export type SpanInsight = {
+ id: string;
+ name: string;
+ operation: string;
+ count: number;
+ totalMs?: number;
+ averageMs?: number;
+ averageBytes?: number;
+ executions: { traceId: string; traceTitle: string; span: TraceSpan }[];
+};
+export function spanInsights(
+ traces: readonly TraceGroup[],
+ kind: "queries" | "resources",
+): SpanInsight[] {
+ const groups = new Map();
+ for (const trace of traces)
+ for (const span of trace.spans) {
+ if (
+ !(kind === "queries" ? /^db(?:\.|$)/ : /^resource(?:\.|$)/).test(
+ span.operation ?? "",
+ )
+ )
+ continue;
+ const id = `${span.operation}:${span.description}`;
+ const group = groups.get(id) ?? {
+ id,
+ name: span.description,
+ operation: span.operation ?? "",
+ count: 0,
+ executions: [],
+ };
+ group.count++;
+ group.executions.push({
+ traceId: trace.id,
+ traceTitle: trace.title,
+ span,
+ });
+ groups.set(id, group);
+ }
+ for (const group of groups.values()) {
+ const durations = group.executions.flatMap((entry) =>
+ entry.span.durationMs === undefined ? [] : [entry.span.durationMs],
+ );
+ if (durations.length) {
+ group.totalMs = durations.reduce((a, b) => a + b, 0);
+ group.averageMs = group.totalMs / durations.length;
+ }
+ const sizes = group.executions.flatMap((entry) => {
+ const data = spanData(entry.span.payload);
+ const size =
+ finite(data["http.response_content_length"]) ??
+ finite(data["http.response.body.size"]) ??
+ finite(data["http.decoded_response_content_length"]);
+ return size === undefined || size < 0 ? [] : [size];
+ });
+ if (sizes.length)
+ group.averageBytes = sizes.reduce((a, b) => a + b, 0) / sizes.length;
+ }
+ return [...groups.values()];
+}
+export const VITALS = {
+ lcp: { label: "LCP", unit: "ms", good: 2500, poor: 4000 },
+ inp: { label: "INP", unit: "ms", good: 200, poor: 500 },
+ cls: { label: "CLS", unit: "", good: 0.1, poor: 0.25 },
+ fcp: { label: "FCP", unit: "ms", good: 1800, poor: 3000 },
+ ttfb: { label: "TTFB", unit: "ms", good: 800, poor: 1800 },
+ fid: { label: "FID (legacy)", unit: "ms", good: 100, poor: 300 },
+} as const;
+export type VitalName = keyof typeof VITALS;
+export type VitalMeasurement = {
+ value: number;
+ rating: "Good" | "Needs improvement" | "Poor";
+};
+export type VitalVisit = {
+ item: LocalFeedItem;
+ metrics: Partial>;
+};
+export type VitalPage = { name: string; visits: VitalVisit[] };
+export function webVitalPages(items: readonly LocalFeedItem[]): VitalPage[] {
+ const pages = new Map();
+ for (const item of items) {
+ const p = record(item.payload),
+ measurements = record(p.measurements);
+ if (item.type !== "transaction" || !Object.keys(measurements).length)
+ continue;
+ const metrics: VitalVisit["metrics"] = {};
+ for (const name of Object.keys(VITALS) as VitalName[]) {
+ const metric = record(measurements[name]),
+ value = finite(metric.value);
+ if (value === undefined || value < 0) continue;
+ const config = VITALS[name];
+ // SDK measurements normally use milliseconds; honor seconds when explicitly supplied.
+ const normalized =
+ config.unit === "ms" && metric.unit === "second" ? value * 1000 : value;
+ metrics[name] = {
+ value: normalized,
+ rating:
+ normalized <= config.good
+ ? "Good"
+ : normalized <= config.poor
+ ? "Needs improvement"
+ : "Poor",
+ };
+ }
+ if (!Object.keys(metrics).length) continue;
+ const name = text(p.transaction) ?? item.metadata?.route ?? "Unknown page";
+ const page = pages.get(name) ?? { name, visits: [] };
+ page.visits.push({ item, metrics });
+ pages.set(name, page);
+ }
+ return [...pages.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
diff --git a/apps/local/src/lib/presentation.ts b/apps/local/src/lib/presentation.ts
index ab1f1cc4e..758b158b1 100644
--- a/apps/local/src/lib/presentation.ts
+++ b/apps/local/src/lib/presentation.ts
@@ -72,6 +72,11 @@ export function getWorkspaceEmptyState({
};
}
const triggers: Record = {
+ queries: "Run an instrumented database query in your app.",
+ resources: "Load instrumented browser resources in your app.",
+ "web-vitals":
+ "Load a browser page with SDK performance measurements enabled.",
+ attachments: "Send an SDK event with a file attachment.",
live: `Send an SDK ${singularLabel} from your app to this receiver.`,
traces:
"Trigger an instrumented request or operation in your app to send a trace to this receiver.",
diff --git a/apps/local/src/lib/profiles.ts b/apps/local/src/lib/profiles.ts
new file mode 100644
index 000000000..b73415b79
--- /dev/null
+++ b/apps/local/src/lib/profiles.ts
@@ -0,0 +1,228 @@
+import type { LocalFeedItem } from "./spotlight.ts";
+import type { TraceGroup } from "./trace-model.ts";
+import { record, finite, text } from "./inspection.ts";
+export type ProfileNode = {
+ id: string;
+ name: string;
+ location: string;
+ inApp: boolean;
+ samples: number;
+ selfSamples: number;
+ totalMs: number;
+ selfMs: number;
+ children: ProfileNode[];
+};
+export type ProfileFunction = Omit;
+export type ParsedProfile = {
+ root: ProfileNode;
+ functions: ProfileFunction[];
+ sampleCount: number;
+ durationMs?: number;
+ threads: { id: string; name: string }[];
+ threadId?: string;
+};
+function node(
+ id: string,
+ name: string,
+ location = "",
+ inApp = false,
+): ProfileNode {
+ return {
+ id,
+ name,
+ location,
+ inApp,
+ samples: 0,
+ selfSamples: 0,
+ totalMs: 0,
+ selfMs: 0,
+ children: [],
+ };
+}
+function sampleTime(sample: Record): number | undefined {
+ const ns = sample.elapsed_since_start_ns;
+ if (typeof ns === "string" && ns.trim() && Number.isFinite(Number(ns)))
+ return Number(ns) / 1e6;
+ if (finite(ns) !== undefined) return Number(ns) / 1e6;
+ const seconds = finite(sample.timestamp);
+ return seconds === undefined ? undefined : seconds * 1000;
+}
+function sampleThread(value: unknown): string {
+ const number = finite(value);
+ return text(value) ?? (number === undefined ? "main" : String(number));
+}
+/** SDK stacks are leaf-first; sample intervals are calculated independently per thread. */
+export function parseProfile(
+ item: LocalFeedItem,
+ selectedThread?: string,
+): ParsedProfile {
+ const payload = record(item.payload),
+ profile = Object.keys(record(payload.profile)).length
+ ? record(payload.profile)
+ : payload;
+ const samples = (Array.isArray(profile.samples) ? profile.samples : []).map(
+ record,
+ );
+ const frames = Array.isArray(profile.frames) ? profile.frames : [];
+ const stacks = Array.isArray(profile.stacks) ? profile.stacks : [];
+ const metadata = record(profile.thread_metadata);
+ const threads = [
+ ...new Set(samples.map((sample) => sampleThread(sample.thread_id))),
+ ].map((id) => ({ id, name: text(record(metadata[id]).name) ?? id }));
+ const threadId = threads.some((thread) => thread.id === selectedThread)
+ ? selectedThread
+ : threads[0]?.id;
+ const selected = samples
+ .filter((sample) => sampleThread(sample.thread_id) === threadId)
+ .sort((a, b) => (sampleTime(a) ?? Infinity) - (sampleTime(b) ?? Infinity));
+ const root = node("root", "All samples"),
+ functions = new Map();
+ const children = new Map>();
+ let hasTiming = false;
+ for (let index = 0; index < selected.length; index++) {
+ const sample = selected[index]!,
+ stackId = finite(sample.stack_id),
+ rawStack: unknown = stackId === undefined ? undefined : stacks[stackId];
+ const stack = Array.isArray(rawStack)
+ ? rawStack
+ .filter(
+ (id): id is number =>
+ typeof id === "number" && Number.isInteger(id) && id >= 0,
+ )
+ .toReversed()
+ : [];
+ const time = sampleTime(sample),
+ next = selected[index + 1] ? sampleTime(selected[index + 1]!) : undefined;
+ const weight =
+ time !== undefined && next !== undefined ? Math.max(0, next - time) : 0;
+ if (time !== undefined && next !== undefined) hasTiming = true;
+ root.samples++;
+ root.totalMs += weight;
+ let parent = root;
+ const seenFunctions = new Set();
+ // Bound pathological stack depth to protect the UI; raw data remains inspectable.
+ for (const frameId of stack.slice(0, 256)) {
+ if (!frames[frameId] || typeof frames[frameId] !== "object") continue;
+ const frame = record(frames[frameId]);
+ const name =
+ text(frame.function) ??
+ text(frame.module) ??
+ text(frame.instruction_addr) ??
+ "Anonymous";
+ const location = [
+ text(frame.abs_path) ?? text(frame.filename),
+ finite(frame.lineno),
+ ]
+ .filter((value) => value !== undefined)
+ .join(":");
+ const childMap = children.get(parent) ?? new Map();
+ children.set(parent, childMap);
+ let child = childMap.get(frameId);
+ if (!child) {
+ child = node(
+ `${parent.id}/${frameId}`,
+ name,
+ location,
+ frame.in_app === true,
+ );
+ childMap.set(frameId, child);
+ parent.children.push(child);
+ }
+ child.samples++;
+ child.totalMs += weight;
+ parent = child;
+ const key = `${name}:${location}`;
+ const fn = functions.get(key) ?? {
+ ...node(key, name, location, frame.in_app === true),
+ };
+ if (!seenFunctions.has(key)) {
+ fn.samples++;
+ fn.totalMs += weight;
+ seenFunctions.add(key);
+ }
+ functions.set(key, fn);
+ }
+ parent.selfSamples++;
+ parent.selfMs += weight;
+ if (parent !== root) {
+ const fn = functions.get(`${parent.name}:${parent.location}`);
+ if (fn) {
+ fn.selfSamples++;
+ fn.selfMs += weight;
+ }
+ }
+ }
+ return {
+ root,
+ functions: [...functions.values()].sort(
+ (a, b) => b.totalMs - a.totalMs || b.samples - a.samples,
+ ),
+ sampleCount: selected.length,
+ durationMs: hasTiming ? root.totalMs : undefined,
+ threads,
+ threadId,
+ };
+}
+type ProfileBounds = { start: number; end: number };
+// Captured items are immutable. Reuse bounds across trace rows and inspectors,
+// and let retention release the cache together with its captured item.
+const profileBoundsCache = new WeakMap();
+function profileBounds(item: LocalFeedItem): ProfileBounds | null {
+ const cached = profileBoundsCache.get(item);
+ if (cached !== undefined) return cached;
+ const payload = record(item.payload);
+ const samples = record(payload.profile).samples ?? payload.samples;
+ let start = Infinity,
+ end = -Infinity;
+ if (Array.isArray(samples)) {
+ for (const sample of samples) {
+ const timestamp = finite(record(sample).timestamp);
+ if (timestamp === undefined) continue;
+ start = Math.min(start, timestamp);
+ end = Math.max(end, timestamp);
+ }
+ }
+ const bounds = start === Infinity ? null : { start, end };
+ profileBoundsCache.set(item, bounds);
+ return bounds;
+}
+export function profileMatchesTrace(
+ item: LocalFeedItem,
+ trace: TraceGroup,
+): boolean {
+ const p = record(item.payload);
+ const transactions = Array.isArray(p.transactions)
+ ? p.transactions.map(record)
+ : [record(p.transaction)];
+ if (
+ transactions.some((transaction) => {
+ const traceId = text(transaction.trace_id);
+ const eventId = text(transaction.id);
+ return (
+ (traceId !== undefined && traceId === trace.id) ||
+ (eventId !== undefined &&
+ trace.items.some(
+ (entry) => text(record(entry.payload).event_id) === eventId,
+ ))
+ );
+ })
+ )
+ return true;
+ const profileId = text(p.event_id);
+ const profilerId = text(p.profiler_id);
+ return trace.items.some((entry) => {
+ const captured = record(record(record(entry.payload).contexts).profile);
+ if (profileId !== undefined && captured.profile_id === profileId)
+ return true;
+ // Most traces have no matching profiler. Reject them before touching samples.
+ if (profilerId === undefined || captured.profiler_id !== profilerId)
+ return false;
+ if (trace.startTimestamp === undefined || trace.endTimestamp === undefined)
+ return true;
+ const bounds = profileBounds(item);
+ return (
+ bounds === null ||
+ (bounds.start <= trace.endTimestamp && bounds.end >= trace.startTimestamp)
+ );
+ });
+}
diff --git a/apps/local/src/lib/spotlight.test.ts b/apps/local/src/lib/spotlight.test.ts
index 2d010a8d8..d7175f4ec 100644
--- a/apps/local/src/lib/spotlight.test.ts
+++ b/apps/local/src/lib/spotlight.test.ts
@@ -415,6 +415,8 @@ describe("decodeEnvelope", () => {
).toEqual([
{
id: "event-1:0",
+ envelopeHeader: { sdk: { name: "sentry.node" } },
+ itemHeader: { type: "transaction" },
type: "transaction",
timestamp: 1700000000.00668,
metadata: {
diff --git a/apps/local/src/lib/spotlight.ts b/apps/local/src/lib/spotlight.ts
index 4319542b2..8e7bb1e82 100644
--- a/apps/local/src/lib/spotlight.ts
+++ b/apps/local/src/lib/spotlight.ts
@@ -21,6 +21,8 @@ export type LocalFeedItem = {
text: string;
payload: unknown;
metadata?: EventMetadata;
+ envelopeHeader?: Record;
+ itemHeader?: Record;
};
export type EventMetadata = {
@@ -79,6 +81,13 @@ function getRoute(url: string | undefined): string | undefined {
}
}
+function contextFeedback(
+ event: Record,
+): Record {
+ const contexts = isRecord(event.contexts) ? event.contexts : {};
+ return isRecord(contexts.feedback) ? contexts.feedback : {};
+}
+
/** Derive display metadata from this payload without inheriting its parent event. */
export function getEventMetadata(
payload: unknown,
@@ -90,7 +99,14 @@ export function getEventMetadata(
const transaction = getString(event.transaction);
const description = getString(event.description);
const message =
- getString(event.message) ?? getString(event.body) ?? getString(event.name);
+ getString(event.message) ??
+ getString(event.body) ??
+ getString(event.name) ??
+ getString(
+ isRecord(contextFeedback(event))
+ ? contextFeedback(event).message
+ : undefined,
+ );
const transactionMatch = transaction?.match(/^([A-Z]+)\s+(.+)$/);
const contexts = isRecord(event.contexts) ? event.contexts : {};
const trace = isRecord(contexts.trace) ? contexts.trace : event;
@@ -322,10 +338,17 @@ export function decodeEnvelope(data: string, eventId: string): LocalFeedItem[] {
? `${eventId}:${index}`
: `${eventId}:${index}:${payloadIndex}`,
type,
+ envelopeHeader: isRecord(parsed[0]) ? parsed[0] : {},
+ itemHeader: header,
timestamp: getItemTimestamp(payload),
text: JSON.stringify(payload, null, 2) ?? String(payload),
payload,
- metadata: getEventMetadata(payload, type),
+ metadata: {
+ ...getEventMetadata(payload, type),
+ ...(type === "attachment" && typeof header.filename === "string"
+ ? { title: header.filename }
+ : {}),
+ },
}));
});
}
diff --git a/apps/local/src/lib/telemetry-store.ts b/apps/local/src/lib/telemetry-store.ts
index 76074c107..0038f12ef 100644
--- a/apps/local/src/lib/telemetry-store.ts
+++ b/apps/local/src/lib/telemetry-store.ts
@@ -1,9 +1,10 @@
+import { aiItems } from "./inspection.ts";
import {
DEFAULT_MAX_ENVELOPES,
isValidMaxEnvelopes,
} from "../../../../packages/cli/src/commands/local/retention-limits.ts";
import type { LocalFeedItem } from "./spotlight.ts";
-import { isAiEvent, isErrorEvent } from "./workspace.ts";
+import { isErrorEvent } from "./workspace.ts";
export type LocalTelemetrySnapshot = {
items: LocalFeedItem[];
@@ -64,7 +65,7 @@ function buildSnapshot(
sdks: items.filter(
(item) => item.type === "client_report" || item.type === "session",
),
- ai: items.filter(isAiEvent),
+ ai: aiItems(items),
};
}
diff --git a/apps/local/src/lib/workspace-navigation.ts b/apps/local/src/lib/workspace-navigation.ts
index 7ee4b166b..0341440de 100644
--- a/apps/local/src/lib/workspace-navigation.ts
+++ b/apps/local/src/lib/workspace-navigation.ts
@@ -1,4 +1,7 @@
import {
+ Database,
+ Globe,
+ Paperclip,
Activity,
Bot,
Boxes,
@@ -18,7 +21,7 @@ export type WorkspaceNavigationItem = {
id: WorkspaceView;
label: string;
singularLabel: string;
- section: "Explore" | "Diagnostics";
+ section: "Explore" | "Performance" | "Diagnostics";
emptyState: {
title: string;
description: string;
@@ -91,6 +94,54 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
icon: Bot,
getItems: (snapshot) => snapshot.ai,
},
+ {
+ id: "queries",
+ label: "Queries",
+ singularLabel: "query",
+ section: "Performance",
+ icon: Database,
+ emptyState: {
+ title: "No queries captured",
+ description: "Database spans from your SDK will appear here.",
+ },
+ getItems: (snapshot) => snapshot.traces,
+ },
+ {
+ id: "resources",
+ label: "Resources",
+ singularLabel: "resource",
+ section: "Performance",
+ icon: Globe,
+ emptyState: {
+ title: "No resources captured",
+ description: "Resource loading spans from your SDK will appear here.",
+ },
+ getItems: (snapshot) => snapshot.traces,
+ },
+ {
+ id: "web-vitals",
+ label: "Web Vitals",
+ singularLabel: "visit",
+ section: "Performance",
+ icon: Gauge,
+ emptyState: {
+ title: "No Web Vitals captured",
+ description: "Browser pageload measurements will appear here.",
+ },
+ getItems: (snapshot) => snapshot.traces,
+ },
+ {
+ id: "attachments",
+ label: "Attachments",
+ singularLabel: "attachment",
+ section: "Diagnostics",
+ icon: Paperclip,
+ emptyState: {
+ title: "No attachments captured",
+ description: "Files attached to SDK envelopes will appear here.",
+ },
+ getItems: (snapshot) => snapshot.attachments,
+ },
{
id: "envelopes",
label: "Envelopes",
@@ -105,7 +156,7 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
},
{
id: "sdks",
- label: "SDK reports",
+ label: "SDKs",
singularLabel: "session or client report",
section: "Diagnostics",
emptyState: {
@@ -132,7 +183,7 @@ export const workspaceNavigation: WorkspaceNavigationItem[] = [
id: "profiles",
label: "Profiles",
singularLabel: "profile",
- section: "Diagnostics",
+ section: "Performance",
emptyState: {
title: "No profiles captured",
description: "Profiling data from supported SDKs will appear here.",
diff --git a/apps/local/src/lib/workspace.ts b/apps/local/src/lib/workspace.ts
index 89a38a01d..302be6eac 100644
--- a/apps/local/src/lib/workspace.ts
+++ b/apps/local/src/lib/workspace.ts
@@ -1,3 +1,4 @@
+import { isAiPayload } from "./inspection.ts";
import type { EventMetadata, LocalFeedItem } from "./spotlight.ts";
function getMetadata(item: LocalFeedItem): EventMetadata {
@@ -14,6 +15,10 @@ export const workspaceViews = [
"sdks",
"feedback",
"profiles",
+ "queries",
+ "resources",
+ "web-vitals",
+ "attachments",
] as const;
export type WorkspaceView = (typeof workspaceViews)[number];
@@ -44,10 +49,13 @@ export function isErrorEvent(item: LocalFeedItem) {
export function isAiEvent(item: LocalFeedItem) {
const metadata = getMetadata(item);
- return [item.type, metadata.operation, metadata.origin].some(
- (value) =>
- value !== undefined &&
- /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value),
+ return (
+ isAiPayload(item.payload) ||
+ [item.type, metadata.operation, metadata.origin].some(
+ (value) =>
+ value !== undefined &&
+ /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value),
+ )
);
}
@@ -70,6 +78,7 @@ export function workspaceForItem(item: LocalFeedItem): WorkspaceView {
if (item.type === "user_report" || item.type === "feedback") {
return "feedback";
}
+ if (item.type === "attachment") return "attachments";
if (item.type === "profile" || item.type === "profile_chunk") {
return "profiles";
}
diff --git a/apps/local/src/router.tsx b/apps/local/src/router.tsx
index ec070ec6a..8f22ea737 100644
--- a/apps/local/src/router.tsx
+++ b/apps/local/src/router.tsx
@@ -22,6 +22,10 @@ const localRouter = createBrowserRouter([
{ path: "/sdks", Component: WorkspaceRoute },
{ path: "/feedback", Component: WorkspaceRoute },
{ path: "/profiles", Component: WorkspaceRoute },
+ { path: "/queries", Component: WorkspaceRoute },
+ { path: "/resources", Component: WorkspaceRoute },
+ { path: "/web-vitals", Component: WorkspaceRoute },
+ { path: "/attachments", Component: WorkspaceRoute },
{ path: "*", element: },
]);
diff --git a/apps/local/src/test-setup.ts b/apps/local/src/test-setup.ts
index 58eb88616..f3c3d28b4 100644
--- a/apps/local/src/test-setup.ts
+++ b/apps/local/src/test-setup.ts
@@ -14,6 +14,6 @@ if (!globalThis.ResizeObserver) {
});
}
-if (!Element.prototype.scrollIntoView) {
+if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
diff --git a/apps/local/tsconfig.node.json b/apps/local/tsconfig.node.json
index 8455dcbc2..8d2b2b02f 100644
--- a/apps/local/tsconfig.node.json
+++ b/apps/local/tsconfig.node.json
@@ -19,5 +19,5 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "scripts/**/*.ts"]
}
diff --git a/docs/cli/README.md b/docs/cli/README.md
index 005fe5d4d..2cdffa6f9 100644
--- a/docs/cli/README.md
+++ b/docs/cli/README.md
@@ -38,3 +38,5 @@ and depends on generated CLI documentation. The root
own rules.
- [Local daemon and browser viewer](local-control-plane.md) — scoped telemetry sessions and viewer development.
+
+For viewer development and envelope replay, see the [Local viewer guide](../../apps/local/README.md).
diff --git a/docs/superpowers/plans/2026-10-26-local-ui-parity.md b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
new file mode 100644
index 000000000..d0b8a15a8
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
@@ -0,0 +1,66 @@
+# Local UI investigation and replay implementation plan
+
+> For agentic workers: use superpowers:executing-plans. The user approved the comparison's UI scope and explicitly requested implementation in PR #1509. No additional design approval is needed.
+
+**Goal:** Make captured Local telemetry explorable through dedicated inspectors and performance views, with reproducible envelope replay. MCP is excluded.
+
+**Architecture:** Extend the bounded telemetry store, React components, routes and trace model. Derive analysis from captures, preserve JSON and envelope metadata, and share semantic extraction for transaction and standalone spans. Never fetch captured URLs or execute captured HTML/source. Use editor protocol links rather than a server execution endpoint.
+
+**Tech stack:** Existing React 19, TypeScript, Vite, Vitest, Tailwind. No added runtime dependencies.
+
+## Global constraints
+
+- Preserve capture/session lifecycle and receiver compatibility.
+- No MCP changes; no merge or deployment.
+- Unknown SDK values remain inspectable in JSON. Do not invent measurements or token counts.
+- Quality gate: pnpm run tsc && pnpm run lint && pnpm run test before committing. Existing user-authorized unrelated failures remain separate.
+- Accessible controls, responsive panes, dark/light themes; captured text stays inert.
+
+## Review focus
+
+- Binary attachment bytes and metadata survive SSE.
+- Missing timestamps, empty profiles and legacy payloads degrade honestly.
+- Filters/sorting do not mutate incoming data or silently hide selection.
+- Analysis covers embedded and standalone spans without duplicate executions.
+- Replay uses receiver ingestion, supports custom fixtures, and reports rejection.
+
+### Task 1: Semantic event inspection
+
+- [x] Preserve envelope/item headers and binary encoding; test SDK-shaped envelopes.
+- [x] Add breadcrumbs/context/request/tags/modules, frame locals, editor links and Copy for AI.
+- [x] Add AI prompts/responses/models/tokens/tools, feedback associations, attachments and SDK inventory.
+- [x] Test representative sanitized payloads and unsupported/missing data.
+
+### Task 2: Performance analysis
+
+- [x] Query/resource grouping, sorting and trace/span drill-down.
+- [x] Pageload Web Vitals summaries/page charts with metric units/thresholds.
+- [x] Legacy profiles and continuous chunks: summaries, flame/treemap/sunburst, thread selection and trace links.
+- [x] Cover analysis and interactions with fixtures.
+
+### Task 3: Investigation controls
+
+- [x] Integrate router/sidebar/command navigation.
+- [x] Severity/logger filters, sorting and configurable columns.
+- [x] Trace search, only-matches, branch collapse and adjustable tree width.
+- [x] Integration tests for deep links and empty/filtered states.
+
+### Task 4: Replay and validation
+
+- [x] Local replay command, bundled sanitized scenarios, recursive custom fixtures.
+- [x] Endpoint, interval/repeat, compression and deterministic failures.
+- [x] Real receiver/viewer tests and documentation.
+- [x] Focused tests, full gate, visual QA, fresh review, fixes, commit/push, PR description.
+
+## Execution ledger
+
+- Base d5e5863ee319412c20ea02892b8479d18d127dde; existing worktree clean.
+- Ruling: reuse layout/dependencies; vscode:// links avoid arbitrary server-side editor execution.
+- Ruling: all requested UI work stays in this PR; Electron and MCP excluded.
+
+- Semantic inspection, performance views, navigation controls and replay are implemented.
+- Tests: 306 viewer tests pass; receiver-backed replay exercises all views. Production viewer build passes.
+- Full UTC quality gate: root typecheck/lint pass; all workspace suites pass except the two previously user-approved macOS CLI failures (musl installer detection and DerivedData scan timeout). CLI result: 11,400 pass, 2 fail, 37 skipped. No CLI source changed in this addition.
+- Final review found five issues; all were fixed with red/green regressions. Scoped re-review is clean.
+- Browser QA verified query readability, profile visualization/keyboard controls, AI conversation/tool content and binary image decoding.
+- Decisions: reuse existing React/dependencies/layout; use explicit VS Code protocol links for absolute local source paths; retain original envelope JSON; exclude MCP/Electron as requested.
From eb6ab1732f2aba4d5dfc0838fb188f1fd8efbbfe Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 21:36:41 +0530
Subject: [PATCH 27/30] test(local): Preserve raw envelopes on Windows
checkouts
Disable line-ending conversion for byte-counted replay fixtures.
Exercise a real Git checkout with core.autocrlf=true to prevent corruption.
Co-Authored-By: GPT-6 (Codex)
---
.gitattributes | 2 +
apps/local/README.md | 4 +-
apps/local/scripts/fixture-checkout.test.ts | 54 +++++++++++++++++++++
3 files changed, 59 insertions(+), 1 deletion(-)
create mode 100644 .gitattributes
create mode 100644 apps/local/scripts/fixture-checkout.test.ts
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
index c6291283f..2a362420f 100644
--- a/apps/local/README.md
+++ b/apps/local/README.md
@@ -43,7 +43,9 @@ precedence over `SENTRY_SPOTLIGHT`; otherwise the default is
not its capability-protected viewer stream URL.
The loader sends `.txt`, `.bin`, and `.envelope` files in sorted order, preserving
-raw bytes and captured timestamps. `--compression` supports `none`, `gzip`, and
+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
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 });
+ }
+});
From 0b924ddd2db09802b69ab094541032ad7ed7fe6c Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 21:36:42 +0530
Subject: [PATCH 28/30] fix(local): Retain AI spans identified by origin
Apply the same AI operation and origin classification to semantic
inspection and workspace routing, including spans without captured content.
Co-Authored-By: GPT-6 (Codex)
---
.../components/semantic-inspection.test.tsx | 33 +++++++++++++++++++
apps/local/src/lib/inspection.ts | 15 ++++++---
.../plans/2026-10-26-local-ui-parity.md | 4 +++
3 files changed, 47 insertions(+), 5 deletions(-)
diff --git a/apps/local/src/components/semantic-inspection.test.tsx b/apps/local/src/components/semantic-inspection.test.tsx
index b361afc69..bb54a071b 100644
--- a/apps/local/src/components/semantic-inspection.test.tsx
+++ b/apps/local/src/components/semantic-inspection.test.tsx
@@ -240,3 +240,36 @@ test("legacy user reports associate by event_id even when the report arrives fir
);
expect(onSelect).toHaveBeenCalledWith(error);
});
+
+test("AI origin metadata remains visible when the SDK did not capture content", () => {
+ const [span] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ { type: "span" },
+ {
+ version: 2,
+ items: [
+ {
+ trace_id: "trace",
+ span_id: "origin-only",
+ start_timestamp: 1,
+ end_timestamp: 2,
+ attributes: {
+ "sentry.origin": { type: "string", value: "auto.ai.vercel" },
+ },
+ },
+ ],
+ },
+ ],
+ ],
+ ]),
+ "origin",
+ );
+ expect(aiItems([span!])).toHaveLength(1);
+ render( );
+ expect(
+ screen.getByText(/Prompt and response content were not captured/),
+ ).toBeDefined();
+});
diff --git a/apps/local/src/lib/inspection.ts b/apps/local/src/lib/inspection.ts
index 6fcb703bb..f46d171ba 100644
--- a/apps/local/src/lib/inspection.ts
+++ b/apps/local/src/lib/inspection.ts
@@ -46,11 +46,16 @@ export function isAiPayload(payload: unknown): boolean {
const p = record(payload),
data = spanData(p);
return (
- /^(?:gen_ai|ai|llm)(?:[._]|$)/i.test(
- text(p.op) ??
- text(record(record(p.contexts).trace).op) ??
- text(data["sentry.op"]) ??
- "",
+ [
+ p.type,
+ p.op,
+ record(record(p.contexts).trace).op,
+ data["sentry.op"],
+ data["sentry.origin"],
+ ].some(
+ (value) =>
+ typeof value === "string" &&
+ /(?:^|[.\s:_-])(?:ai|gen_ai|llm)(?:$|[.\s:_-])/i.test(value),
) ||
Object.keys(data).some((key) => /^(?:gen_ai|vercel\.ai|ai|llm)\./.test(key))
);
diff --git a/docs/superpowers/plans/2026-10-26-local-ui-parity.md b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
index d0b8a15a8..061c4671f 100644
--- a/docs/superpowers/plans/2026-10-26-local-ui-parity.md
+++ b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
@@ -64,3 +64,7 @@
- Final review found five issues; all were fixed with red/green regressions. Scoped re-review is clean.
- Browser QA verified query readability, profile visualization/keyboard controls, AI conversation/tool content and binary image decoding.
- Decisions: reuse existing React/dependencies/layout; use explicit VS Code protocol links for absolute local source paths; retain original envelope JSON; exclude MCP/Electron as requested.
+
+- CI follow-up: Windows checkout converted byte-counted fixtures to CRLF. Added scoped Git attributes and a real core.autocrlf checkout regression (red: 2,746 vs 2,743 bytes; green: identical bytes). Linux/macOS feature-commit CI passed.
+- Completed origin-only span-v2 AI classification so route classification and inspection agree when content is absent. Regression observed red before fix.
+- Final viewer validation: 308 tests pass across 37 files; typecheck and root lint pass. Full UTC gate again has only the three previously approved unrelated macOS/cache failures (11,399 CLI tests pass). CLI source/cache remain unchanged.
From 771367e7cc6b6a0e6f6fe9894f5ab25bb5d8ed36 Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 21:44:08 +0530
Subject: [PATCH 29/30] test(local): Budget the full capture replay scenario
Allow the multi-view HTTP/SSE integration scenario 15 seconds on slower
Windows runners, preserving teardown before the next receiver test.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/src/App.integration.test.tsx | 2 +-
docs/superpowers/plans/2026-10-26-local-ui-parity.md | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/apps/local/src/App.integration.test.tsx b/apps/local/src/App.integration.test.tsx
index 818b57064..18bac4731 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -847,7 +847,7 @@ describe("local receiver to viewer integration", () => {
URL.revokeObjectURL = originalRevoke;
await stopReceiver(server);
}
- });
+ }, 15_000);
test("renders every Explorer view from a receiver-backed telemetry session", async () => {
const { server, port } = await startReceiver();
diff --git a/docs/superpowers/plans/2026-10-26-local-ui-parity.md b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
index 061c4671f..b3ba0fe20 100644
--- a/docs/superpowers/plans/2026-10-26-local-ui-parity.md
+++ b/docs/superpowers/plans/2026-10-26-local-ui-parity.md
@@ -68,3 +68,5 @@
- CI follow-up: Windows checkout converted byte-counted fixtures to CRLF. Added scoped Git attributes and a real core.autocrlf checkout regression (red: 2,746 vs 2,743 bytes; green: identical bytes). Linux/macOS feature-commit CI passed.
- Completed origin-only span-v2 AI classification so route classification and inspection agree when content is absent. Regression observed red before fix.
- Final viewer validation: 308 tests pass across 37 files; typecheck and root lint pass. Full UTC gate again has only the three previously approved unrelated macOS/cache failures (11,399 CLI tests pass). CLI source/cache remain unchanged.
+
+- Windows follow-up: byte-preservation regression passes on Windows. The all-views receiver scenario exceeded the five-second default; its dedicated timeout is now 15 seconds so teardown completes before the next case. All 308 viewer tests pass locally.
From fbdbea3ec22b395de43c21910124e3ed68d55b0a Mon Sep 17 00:00:00 2001
From: mathuraditya724
Date: Mon, 26 Oct 2026 23:52:02 +0530
Subject: [PATCH 30/30] fix(local): Keep attachments inert and preserve viewer
state
Restrict download MIME types and render attachments from raw SSE envelopes.
Preserve edited receiver ownership and lifecycle-aware SDK empty states.
Keep long profile and Web Vitals lists scrollable and avoid false empty details.
Cover attachment encoding and raw fixture boundaries with receiver regressions.
Co-Authored-By: GPT-6 (Codex)
---
apps/local/scripts/fixtures.test.ts | 40 +++++++++
apps/local/src/App.capture-lifecycle.test.tsx | 88 +++++++++++++++++++
apps/local/src/App.integration.test.tsx | 43 +++++++++
apps/local/src/App.tsx | 34 +++----
.../src/components/attachment-detail.tsx | 37 ++------
apps/local/src/components/event-context.tsx | 12 ++-
.../src/components/event-detail.test.tsx | 26 ++++++
apps/local/src/components/event-detail.tsx | 22 +++--
.../src/components/performance-workspace.tsx | 6 +-
.../src/components/profile-workspace.tsx | 6 +-
.../components/semantic-inspection.test.tsx | 66 ++++++++++++++
apps/local/src/lib/attachments.ts | 77 ++++++++++++----
apps/local/src/lib/inspection.ts | 20 +++++
13 files changed, 396 insertions(+), 81 deletions(-)
create mode 100644 apps/local/scripts/fixtures.test.ts
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/src/App.capture-lifecycle.test.tsx b/apps/local/src/App.capture-lifecycle.test.tsx
index b8b9a41cf..f4eec10a6 100644
--- a/apps/local/src/App.capture-lifecycle.test.tsx
+++ b/apps/local/src/App.capture-lifecycle.test.tsx
@@ -537,3 +537,91 @@ test("session command search leaves the active view filter unchanged until Filte
).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 18bac4731..1bc432db5 100644
--- a/apps/local/src/App.integration.test.tsx
+++ b/apps/local/src/App.integration.test.tsx
@@ -849,6 +849,49 @@ describe("local receiver to viewer integration", () => {
}
}, 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();
diff --git a/apps/local/src/App.tsx b/apps/local/src/App.tsx
index fd124ef52..196e30e9d 100644
--- a/apps/local/src/App.tsx
+++ b/apps/local/src/App.tsx
@@ -433,7 +433,10 @@ export default function App() {
);
const workspaceEmptyState = getWorkspaceEmptyState({
view: workspaceView,
- label: activeWorkspace.label,
+ label:
+ workspaceView === "sdks"
+ ? "session or client reports"
+ : activeWorkspace.label,
singularLabel: activeWorkspace.singularLabel,
filtered: Boolean(
searchQuery.trim() ||
@@ -744,7 +747,7 @@ export default function App() {
cancelReceiverAttempt();
if (
!isEditingReceiver &&
- connection === "connecting" &&
+ !hasEstablishedConnection &&
!hasRetainedItems
) {
// Editing the landing owns the next attempt; a late automatic probe must not dismiss it.
@@ -975,6 +978,11 @@ export default function App() {
const showConnectionLanding =
isDisconnected || (!hasEstablishedConnection && !hasRetainedItems);
+ const canAutoSelectSession =
+ isConnectionEnabled &&
+ !isEditingReceiver &&
+ currentEndpoint?.kind === "loopback" &&
+ new URL(currentEndpoint.url).pathname === "/stream";
const isReceiverUnavailable = showConnectionLanding && !isConnecting;
const canSearch =
hasRetainedItems &&
@@ -1301,11 +1309,7 @@ export default function App() {
showState={false}
directoryUrl={activeDirectoryUrl}
streamUrl={streamUrl}
- selectInitialSession={
- !isEditingReceiver &&
- currentEndpoint?.kind === "loopback" &&
- new URL(currentEndpoint.url).pathname === "/stream"
- }
+ selectInitialSession={canAutoSelectSession}
onSelect={connectToEndpoint}
/>
) : undefined
@@ -1321,11 +1325,7 @@ export default function App() {
showState={false}
directoryUrl={activeDirectoryUrl}
streamUrl={streamUrl}
- selectInitialSession={
- !isEditingReceiver &&
- currentEndpoint?.kind === "loopback" &&
- new URL(currentEndpoint.url).pathname === "/stream"
- }
+ selectInitialSession={canAutoSelectSession}
onSelect={connectToEndpoint}
/>
@@ -1513,15 +1513,7 @@ export default function App() {
}
>
{searchedItems.length === 0 ? (
-
+
) : selectedItem ? (
();
const name = text(item.itemHeader?.filename) ?? "Attachment";
@@ -22,7 +14,7 @@ export function AttachmentDetail({ item }: { item: LocalFeedItem }) {
// Downloads stay inert even when the captured MIME type is HTML or SVG.
const objectUrl = URL.createObjectURL(
new Blob([decoded.bytes], {
- type: decoded.isText ? "text/plain" : decoded.contentType,
+ type: decoded.downloadType,
}),
);
// Object URLs are browser resources: create after commit and revoke on cleanup.
@@ -71,13 +63,13 @@ export function AttachmentDetail({ item }: { item: LocalFeedItem }) {
{content}
)
- ) : url && IMAGE_TYPES.has(attachment.contentType) ? (
+ ) : url && attachment.preview === "image" ? (
- ) : url && VIDEO_TYPES.has(attachment.contentType) ? (
+ ) : url && attachment.preview === "video" ? (
);
}
-export function EnvelopeAttachments({ item }: { item: LocalFeedItem }) {
- const envelope = item.payload;
- if (!Array.isArray(envelope) || !Array.isArray(envelope[1])) return null;
- const attachments = envelope[1].flatMap((entry, index): LocalFeedItem[] =>
- Array.isArray(entry) && record(entry[0]).type === "attachment"
- ? [
- {
- id: `${item.id}:${index}`,
- type: "attachment",
- itemHeader: record(entry[0]),
- payload: entry[1],
- text: display(entry[1]),
- },
- ]
- : [],
- );
+export function EnvelopeAttachments({
+ attachments,
+}: {
+ attachments: readonly LocalFeedItem[];
+}) {
if (!attachments.length) return null;
return (
diff --git a/apps/local/src/components/event-context.tsx b/apps/local/src/components/event-context.tsx
index 8886175d0..52221f010 100644
--- a/apps/local/src/components/event-context.tsx
+++ b/apps/local/src/components/event-context.tsx
@@ -1,4 +1,10 @@
-import { record, display, text, feedbackData } from "@/lib/inspection.ts";
+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";
@@ -8,7 +14,7 @@ export function EventContext({ payload }: { payload: unknown }) {
const breadcrumbs = Array.isArray(p.breadcrumbs)
? p.breadcrumbs
: record(p.breadcrumbs).values;
- const sections = ["request", "contexts", "tags", "extra", "modules"] as const;
+
return (
{Array.isArray(breadcrumbs) && breadcrumbs.length ? (
@@ -47,7 +53,7 @@ export function EventContext({ payload }: { payload: unknown }) {
) : null}
- {sections.map((key) =>
+ {EVENT_CONTEXT_SECTIONS.map((key) =>
Object.keys(record(p[key])).length ? (
diff --git a/apps/local/src/components/event-detail.test.tsx b/apps/local/src/components/event-detail.test.tsx
index 13c49802e..2cb389db4 100644
--- a/apps/local/src/components/event-detail.test.tsx
+++ b/apps/local/src/components/event-detail.test.tsx
@@ -203,3 +203,29 @@ test("repeated parent updates keep one request summary and its waterfall", () =>
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
index 5ec3db560..6c0566551 100644
--- a/apps/local/src/components/event-detail.tsx
+++ b/apps/local/src/components/event-detail.tsx
@@ -1,10 +1,11 @@
+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 } from "@/lib/inspection.ts";
+import { errorMarkdown, inspectAi, hasEventContext } from "@/lib/inspection.ts";
import { Button } from "./ui/button.tsx";
import { useCopyToClipboard } from "@uidotdev/usehooks";
-import { useId, useState } from "react";
+import { useId, useState, useMemo } from "react";
import {
attributeFields,
detailRecord,
@@ -105,6 +106,13 @@ export function EventDetail({
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 =
@@ -120,7 +128,7 @@ export function EventDetail({
"attachment",
].includes(item.type) ||
Boolean(ai) ||
- (item.type === "envelope" && item.text.includes('"attachment"'));
+ attachments.length > 0;
const message =
metadata.message ??
(item.type === "log"
@@ -218,7 +226,7 @@ export function EventDetail({
) : null}
{item.type === "envelope" ? (
-
+
) : null}
{item.type === "feedback" || item.type === "user_report" ? (
) : null}
- {item.type === "event" || item.type === "transaction" ? (
-
- ) : null}
+ {hasContext ? : null}
{trace ? (
<>
-
+
+
Web Vitals
-
+
{pages.map((page) => (
-
+
+
Profiles · {profiles.length}
-
+
{profiles.map((item, index) => (
{
+ const content =
+ ' ';
+ const [item] = decodeEnvelope(
+ JSON.stringify([
+ {},
+ [
+ [
+ {
+ type: "attachment",
+ filename: "capture.svg",
+ content_type: contentType,
+ },
+ { data: wireText ? content : btoa(content) },
+ ],
+ ],
+ ]),
+ "mime",
+ );
+ const create = vi.fn((_blob: Blob) => "blob:attachment");
+ class TestURL extends URL {
+ static createObjectURL = create;
+ static revokeObjectURL = vi.fn();
+ }
+ vi.stubGlobal("URL", TestURL);
+ render( );
+ expect(create.mock.calls[0]?.[0].type).toBe(expectedType);
+ expect(document.querySelector("script")).toBeNull();
+ },
+);
+
+test("raw SSE envelopes show attachment previews and keep the original JSON", () => {
+ const raw = JSON.stringify([
+ {},
+ [
+ [
+ {
+ type: "attachment",
+ filename: "notes.txt",
+ content_type: "text/plain",
+ },
+ { data: "captured notes" },
+ ],
+ ],
+ ]);
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("region", { name: "Envelope attachments" }).textContent,
+ ).toContain("captured notes");
+ fireEvent.click(screen.getByRole("tab", { name: "JSON" }));
+ expect(screen.getByTestId("highlighted-json").textContent).toContain(
+ "notes.txt",
+ );
+});
diff --git a/apps/local/src/lib/attachments.ts b/apps/local/src/lib/attachments.ts
index 6972b61db..8b25e7738 100644
--- a/apps/local/src/lib/attachments.ts
+++ b/apps/local/src/lib/attachments.ts
@@ -1,5 +1,5 @@
import type { LocalFeedItem } from "./spotlight.ts";
-import { record, text } from "./inspection.ts";
+import { record, text, parsed, display } from "./inspection.ts";
const TEXT_TYPES = new Set([
"application/json",
@@ -11,11 +11,36 @@ const TEXT_TYPES = new Set([
"text/x-json",
]);
+const IMAGE_TYPES = new Set([
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+]);
+const VIDEO_TYPES = new Set(["video/mp4", "video/webm"]);
+// The installed receiver uses an exact MIME match for wire encoding, including parameters.
+const RECEIVER_TEXT_TYPES = new Set([
+ "text/plain",
+ "text/css",
+ "text/csv",
+ "text/html",
+ "text/javascript",
+ "text/json",
+ "text/x-json",
+ "application/json",
+ "application/ld+json",
+]);
+
/** Spotlight's receiver sends text as UTF-8 and all other raw item types as base64. */
-export function attachmentBytes(
- item: LocalFeedItem,
-):
- | { bytes: Uint8Array; contentType: string; isText: boolean }
+export function attachmentBytes(item: LocalFeedItem):
+ | {
+ bytes: Uint8Array;
+ contentType: string;
+ isText: boolean;
+ downloadType: string;
+ preview?: "image" | "video";
+ }
| undefined {
const wireType =
text(item.itemHeader?.content_type) ?? "application/octet-stream";
@@ -24,21 +49,23 @@ export function attachmentBytes(
raw = payload.data ?? item.payload;
const isText = contentType.startsWith("text/") || TEXT_TYPES.has(contentType);
if (typeof raw !== "string") return;
+ const preview = IMAGE_TYPES.has(contentType)
+ ? "image"
+ : VIDEO_TYPES.has(contentType)
+ ? "video"
+ : undefined;
try {
return {
+ preview,
+ // Untrusted document MIME types must stay inert even if a Blob URL is opened in a tab.
+ downloadType: preview
+ ? contentType
+ : isText
+ ? "text/plain"
+ : "application/octet-stream",
contentType,
isText,
- bytes: new Set([
- "text/plain",
- "text/css",
- "text/csv",
- "text/html",
- "text/javascript",
- "text/json",
- "text/x-json",
- "application/json",
- "application/ld+json",
- ]).has(wireType)
+ bytes: RECEIVER_TEXT_TYPES.has(wireType)
? new TextEncoder().encode(raw)
: Uint8Array.from(atob(raw), (c) => c.charCodeAt(0)),
};
@@ -46,3 +73,21 @@ export function attachmentBytes(
return;
}
}
+
+export function envelopeAttachments(item: LocalFeedItem): LocalFeedItem[] {
+ const envelope = parsed(item.payload);
+ if (!Array.isArray(envelope) || !Array.isArray(envelope[1])) return [];
+ return envelope[1].flatMap((entry, index): LocalFeedItem[] =>
+ Array.isArray(entry) && record(entry[0]).type === "attachment"
+ ? [
+ {
+ id: `${item.id}:${index}`,
+ type: "attachment",
+ itemHeader: record(entry[0]),
+ payload: entry[1],
+ text: display(entry[1]),
+ },
+ ]
+ : [],
+ );
+}
diff --git a/apps/local/src/lib/inspection.ts b/apps/local/src/lib/inspection.ts
index f46d171ba..27431074c 100644
--- a/apps/local/src/lib/inspection.ts
+++ b/apps/local/src/lib/inspection.ts
@@ -246,3 +246,23 @@ export function errorMarkdown(item: LocalFeedItem): string {
.filter(Boolean)
.join("\n\n");
}
+
+export const EVENT_CONTEXT_SECTIONS = [
+ "request",
+ "contexts",
+ "tags",
+ "extra",
+ "modules",
+] as const;
+export function hasEventContext(payload: unknown): boolean {
+ const value = record(payload);
+ const breadcrumbs = Array.isArray(value.breadcrumbs)
+ ? value.breadcrumbs
+ : record(value.breadcrumbs).values;
+ return (
+ (Array.isArray(breadcrumbs) && breadcrumbs.length > 0) ||
+ EVENT_CONTEXT_SECTIONS.some(
+ (key) => Object.keys(record(value[key])).length > 0,
+ )
+ );
+}