Skip to content

feat(local): Add daemon-managed telemetry sessions and viewer - #1492

Open
MathurAditya724 wants to merge 3 commits into
mainfrom
codex/local-daemon-migration
Open

MathurAditya724 wants to merge 3 commits into
mainfrom
codex/local-daemon-migration

Conversation

@MathurAditya724

@MathurAditya724 MathurAditya724 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Add isolated Sentry Local telemetry sessions owned by a loopback daemon. local run --session <name> and local serve --session <name> retain captured data after their foreground lease ends; session, event, envelope, and daemon commands let developers and agents inspect it later. Each session has separate ingest/read capabilities and a 500-envelope buffer, with three-hour retention after close and subsequent ingestion.

Move the Local browser viewer into apps/local so the complete feature lives in Toolkit. A loopback-served viewer can reset or close its scoped session; the hosted viewer has read-only access. The existing Vercel Local project must be pointed at Toolkit with apps/local as its root to publish the migrated viewer.

The daemon enforces singleton ownership, authenticates discovery, preserves SDK compression and browser stream compatibility, and starts correctly from source, npm bundles, and native binaries. The ingest capability lives in the URL path for SDK transports that omit query parameters; Local capability URLs are scrubbed from diagnostics and outgoing CLI telemetry. Process E2E coverage exercises real Sentry Node SDK capture, concurrent startup, scoped telemetry, retained queries, foreground leases, restart, and shutdown; the Local CI workflow runs npm/native cases on Linux, macOS, and Windows.

Supersedes getsentry/cli#1578.

The full local workspace test gate fails two existing macOS-dependent tests: the musl installer case and the DerivedData scanner case. They remain outside this Local migration. Typecheck, lint, Local regressions, viewer tests, full npm CLI E2E, and native macOS Local E2E pass; hosted platform results will be recorded in the PR.

Port getsentry/cli#1578 to Toolkit with scoped capabilities, portable daemon lifecycle, and native/npm E2E coverage.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
@MathurAditya724

Copy link
Copy Markdown
Member Author

Validation of the migrated feature:

  • Workspace typecheck and lint passed.
  • Local unit/regression suite: 98 passed.
  • Viewer suite: 80 passed.
  • Full npm CLI E2E: 167 passed; 3 existing credential-dependent tests skipped.
  • Actual macOS SEA binary Local E2E: 4 passed.
  • Real browser + isolated daemon: ingestion, scoped reset and session close verified.
  • Independent review found no remaining actionable Local findings after fixes.

The complete workspace test command was run in UTC with isolated temporary storage and four workers. Its CLI result was 10,644 passed, 14 existing skips, and two failures:

  1. test/lib/install-script.test.ts — the musl case supplies a fake ldd but does not simulate Linux, so on macOS the installer takes the Darwin path.
  2. test/commands/debug-files/upload.test.ts — the DerivedData case scans the actual macOS Xcode directory and exceeds its 15-second timeout.

These existing test-isolation failures are documented rather than changed, as requested. The installer and debug-files tests have no changes in this PR. Linux, macOS and Windows npm/native Local coverage is configured in the new Local E2E workflow.

The existing Local hosting project also needs to point to Toolkit with apps/local as its root to deploy the migrated viewer.

@MathurAditya724

Copy link
Copy Markdown
Member Author

Hosted Local E2E passed on Linux, macOS, and Windows at commit 82cacca. Each platform ran viewer integration tests plus the Local process suite against both the npm bundle and a freshly built native CLI. Typecheck, lint, and docs checks also passed after rebasing onto current main. The original getsentry/cli#1578 draft is now closed as superseded.

…URLs

Carry the ingest token in the pathname for Spotlight SDK transports that
omit URL query parameters. Preserve scoped read URLs and legacy ingestion,
cover real SDK capture, and scrub Local capabilities at telemetry delivery.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>
@MathurAditya724
MathurAditya724 marked this pull request as ready for review October 13, 2026 15:13
@MathurAditya724

Copy link
Copy Markdown
Member Author

Validation for 24bf8863b31d4e87bf76662740ef0acadb8d7251:

  • Workspace typecheck and lint pass.
  • Local unit regressions: 99 pass. Credential/telemetry boundary tests: 53 pass. Viewer tests: 80 pass.
  • Full npm CLI process E2E: 168 pass, 3 existing credential-dependent skips.
  • Rebuilt macOS native CLI Local E2E: all 5 pass, including real Sentry Node SDK capture, live output and retained event queries.
  • Independent review found no remaining actionable Local defects. Real browser ingest/reset/close verification was completed.

The full workspace test gate retains exactly two existing macOS-dependent failures, with 10,653 CLI tests passing and 14 skipped: the musl installer case does not simulate Linux, and the DerivedData case scans the host Xcode directory and times out. The user requested that this PR remain scoped to Local, so those unrelated test files are unchanged. Other tested workspace packages pass.

The actual Node SDK regression exposed transports dropping query parameters; the ingest capability now lives in the pathname. Scoped capability URLs are also redacted at final CLI telemetry delivery. Fresh Linux/macOS/Windows npm and native E2E checks are running on this head. BYK is requested for review; the PR is now ready for review.

@MathurAditya724

Copy link
Copy Markdown
Member Author

Hosted readiness update for 24bf8863:

The PR risk (Jev) check failed with provider_error: GatewayInternalServerError, HTTP 400. Its result artifact marks the attempt retryable: false; no risk classification or code finding was produced. The classifier truncated its input to the configured 30,000-token budget. This is a classifier/provider gate failure, not a failed Local test, and remains unresolved.

Warden and the broad CLI test job are still running. BYK has not yet reviewed. The PR should not be merged with these gates unresolved. Unrelated workflow/provider changes remain outside this Local migration.

@MathurAditya724

Copy link
Copy Markdown
Member Author

Final platform validation at 24bf8863b31d4e87bf76662740ef0acadb8d7251: Local E2E passed on Linux, macOS and Windows. Every platform passed viewer integration tests, all five Local E2E cases against the npm bundle, and the same cases against a freshly built native CLI. This includes actual Sentry Node SDK capture and retained event queries.

The independent review has no remaining actionable findings and this PR has no unresolved review threads. Merge readiness is still blocked: PR risk (Jev) failed with a non-retryable provider HTTP 400, Warden and the broad CLI check remain in progress, and BYK review is requested but not yet submitted. The two documented local macOS baseline failures remain unchanged as requested.

Comment on lines +135 to +160
app.use(`${CONTROL_PLANE_PREFIX}/sessions/:id/stream`, async (c, next) => {
const origin = c.req.header("origin") ?? "";
if (!isHostedUiOrigin(origin)) {
return loopbackCors(c, next);
}
if (c.req.method === "OPTIONS") {
if (c.req.header("access-control-request-method") !== "GET") {
return c.body(null, 403);
}
c.header("Access-Control-Allow-Origin", origin);
c.header("Access-Control-Allow-Methods", "GET, OPTIONS");
c.header("Access-Control-Allow-Headers", "Last-Event-ID");
if (c.req.header("access-control-request-private-network") === "true") {
c.header("Access-Control-Allow-Private-Network", "true");
}
c.header(
"Vary",
"Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
);
return c.body(null, 204);
}
if (c.req.method !== "GET") {
return c.body(null, 403);
}
return hostedCors(c, next);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /stream bypasses remote-origin ingest write protection

Non-loopback browsers blocked on /sessions/:id/ingest/:cap can still write envelopes via POST /sessions/:id/stream?cap=<ingest>, so a page that knows the client-injected ingest capability can forge telemetry into the session. Apply the same isLoopbackBrowserRequest (or non-GET) rejection used on the ingest route to stream POSTs.

Evidence
  • Ingest middleware (lines 122–128) returns 403 unless isLoopbackBrowserRequest passes, and tests cover rejecting https://local.sentry.dev / https://example.com POSTs to /ingest/:cap.
  • Stream middleware (lines 135–143) only special-cases hosted UI origins; any other origin, including remote sites, falls through to loopbackCors and reaches the shared ingest handler for POST.
  • ingest accepts the ingest secret from c.req.query("cap") (line 230) and is mounted on POST .../stream, so /stream?cap=<ingestCapability> is an alternate write path.
  • sentry local run --session injects session.ingestUrl into VITE_ / NEXT_PUBLIC_ / related SENTRY_SPOTLIGHT vars, so the ingest capability is present in browser-exposed client env during normal use.
  • A simple text/plain POST (no CORS preflight) from a non-loopback origin therefore performs the write even though the dedicated ingest route would have returned 403.

Identified by Warden · security-review · LEJ-9TY

Comment thread apps/local/src/App.tsx
Comment on lines +839 to +869
const clearItems = () => {
telemetryStore.clear();
setLastViewedItemIds({});
void workspaceQuery.clearWorkspace();

const endpoint = parseStreamEndpoint(streamUrl);
if (
endpoint?.kind !== "loopback" ||
!isLoopbackHost(window.location.hostname)
) {
return;
}
const clearUrl = new URL(endpoint.url);
const scopedSession = isScopedSessionStream(clearUrl.pathname);
if (scopedSession) {
clearUrl.pathname = clearUrl.pathname.replace(/\/stream$/, "/ui/reset");
} else {
clearUrl.pathname = "/clear";
clearUrl.search = "";
}
void fetch(clearUrl, { method: scopedSession ? "POST" : "DELETE" })
.then((response) => {
if (!response.ok)
throw new Error(`Session reset failed with HTTP ${response.status}`);
})
.catch(() => {
setMessage(
"Cleared this viewer, but the receiver could not clear its retained session.",
);
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear events immediately restores the previous search query

After Clear, clearWorkspace() nulls q, but the search-sync effect writes the still-stale commandSearchQuery back into the URL, so later events stay filtered by the old term.

Evidence
  • clearItems() calls workspaceQuery.clearWorkspace(), which sets q to null.
  • It never clears commandSearchQuery or sets isClearingCommandSearch.
  • The effect at lines 787–802 runs whenever searchQuery changes and, when debouncedCommandSearchQuery !== searchQuery, calls setSearchQuery(debouncedCommandSearchQuery).
  • selectCommandItem avoids this by clearing commandSearchQuery under isClearingCommandSearch; clearItems does not.

Identified by Warden · code-review · D6F-KU9

Comment thread apps/local/src/App.tsx Outdated
Comment on lines +766 to +768
const selectedItem =
workspaceItems.find((item) => item.id === workspaceQuery.eventId) ??
workspaceItems[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Detail pane can show an event outside the active search filter

Selected event resolution uses unfiltered workspaceItems, so with an active search the detail pane can show a non-matching event while the list only shows matches.

Evidence
  • searchedItems is workspaceItems filtered by matchesSearch (lines 756–758).
  • selectedItem is resolved from workspaceItems (by eventId or workspaceItems[0]), not searchedItems.
  • The detail pane renders whenever searchedItems.length > 0, using that unfiltered selectedItem (lines 1280–1289).
  • Repro: oldest event A, newer event B; search matches only B → list shows B, detail still shows A.
Also found at 1 additional location
  • apps/local/src/App.tsx:1280-1289

Identified by Warden · code-review · X4J-M6J

Comment on lines +36 to +61
let disposed = false;

void highlighter
.then((instance) =>
instance.codeToHtml(formattedCode, {
lang: "json",
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: false,
}),
)
.then((result) => {
if (!disposed) {
setHtml(result);
}
})
.catch(() => {
// Keep the readable, escaped source fallback if syntax highlighting cannot load.
});

return () => {
disposed = true;
};
}, [formattedCode]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JsonView keeps previous envelope HTML when code changes

When code changes on an already-mounted JsonView, stale highlighted HTML from the previous value stays on screen until the new highlight finishes, and remains permanently if highlighting fails.

Evidence
  • html is React state and is only written inside the async codeToHtml success path; the effect never resets it when formattedCode changes.
  • RawEnvelopeDetail renders <JsonView code={item.text} /> without a key, and unlike EventDetail it is not keyed by selectedItem.id, so switching envelopes reuses the same JsonView instance.
  • While the new highlight is in flight—or if the .catch path runs after a prior success—the render still takes the html branch and paints the previous envelope via dangerouslySetInnerHTML.

Identified by Warden · code-review · 84A-UEE

Comment on lines +237 to +243
}
if (
state.version !== LOCAL_DAEMON_PROTOCOL_VERSION ||
!(await daemonResponds(state))
) {
await removeLocalDaemonState(directory);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRunningLocalDaemon wipes discovery while owner PID can still be alive

A failed HTTP probe deletes the daemon state file without checking state.pid, so a still-running owner becomes undiscoverable and unreplacable until the process is killed manually.

Evidence
  • getRunningLocalDaemon() calls removeLocalDaemonState() whenever daemonResponds() fails; it never inspects state.pid or isProcessRunning.
  • daemonResponds() is only a 500ms authenticated GET /sessions; any timeout, connection failure, or non-OK body counts as dead.
  • runLocalDaemonForeground() keeps local-control-plane.owner until after shutdownServer() and state cleanup, so the process can still own the lock when probes already fail.
  • Callers such as ensureLocalDaemon() / daemon restart treat undefined as safe to spawn, but the child loses claimDaemonOwnership() to the live owner and the parent spawns only once before timing out.
  • The running daemon never rewrites discovery metadata after startup, so stop/status/session lose the bearer token for the life of that process.

Identified by Warden · code-review · 4P9-5DU

Comment on lines +305 to +318
child.unref();
for (let attempt = 0; attempt < 40; attempt += 1) {
await sleep(125);
if (startupError) throw startupError;
const state = await getRunningLocalDaemon();
if (state) {
return state;
}
}
} finally {
await lock.close();
await rm(startupLockPath(directory), { force: true });
}
throw new Error("Timed out while starting the Sentry Local control plane.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureLocalDaemon times out before tryListen EADDRINUSE retry can succeed

The post-spawn readiness loop only waits ~5s, but the daemon child uses tryListen which sleeps 5s before its first EADDRINUSE retry, so a busy port makes ensureLocalDaemon throw even while the child is still starting and may bind successfully afterward.

Evidence
  • After spawn, ensureLocalDaemon polls only 40 × 125ms (~5000ms) then throws Timed out while starting the Sentry Local control plane.
  • The child runs runLocalDaemonForeground → tryListen, which on EADDRINUSE waits PORT_RETRY_DELAY_MS (5000ms) before the first retry and may retry three times.
  • Parent only observes child error (spawn failure), not exit or listen progress, so a delayed successful bind still surfaces as a hard failure and can leave a daemon running after the caller already aborted.
Also found at 1 additional location
  • packages/cli/src/commands/local/daemon.ts:278-287

Identified by Warden · code-review · RA2-V7Q

Comment on lines +63 to +66
{isProbing
? "Checking the default receiver on localhost:8969."
: "Connect this read-only viewer to an event stream."}
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probing subtitle always claims default localhost:8969 receiver

When phase is "probing", the subtitle always says the default localhost:8969 receiver is being checked even if endpoint is a custom port, session stream, or remote URL, so the landing misstates the active target after Connect or a non-default initial stream.

Evidence
  • ConnectionLanding receives the live endpoint prop but the probing subtitle is hardcoded to localhost:8969.
  • In App.tsx, phase is set to "probing" whenever connection === "connecting", including after connectToDraft() sets a parsed custom draftEndpoint/streamUrl.
  • Initial draftEndpoint also comes from resolveInitialStreamUrl(), which can return saved loopback/session streams rather than DEFAULT_STREAM_URL.
  • showConnectionLanding stays true while connecting with no retained items, so this wrong subtitle is shown for those non-default targets.

Identified by Warden · code-review · MSC-45D

Comment on lines +524 to +526
logger.info(
`SENTRY_SPOTLIGHT=${daemonSessionId ? `${new URL(spotlightUrl).origin}/_local/v1/sessions/${daemonSessionId}/stream` : spotlightUrl}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--session logs a non-ingestible SENTRY_SPOTLIGHT URL

With --session, the status log prints .../sessions/<id>/stream without an ingest capability, while the child actually receives session.ingestUrl. Copying the logged value yields HTTP 401 on envelope POST.

Evidence
  • spotlightUrl is set to daemonIngestUrl (.../sessions/<id>/ingest/<cap>), and that value is what buildChildEnv() / Wrangler injection use.
  • The log line replaces it with ${origin}/_local/v1/sessions/${daemonSessionId}/stream whenever daemonSessionId is set.
  • Control-plane ingest requires (param|query).cap === capabilities.ingest, so POST to that logged /stream path without the ingest cap returns 401.
  • Non-session mode logs the same URL that is injected; only the session branch diverges.

Identified by Warden · code-review · U4W-9GZ

Protect both ingest routes, keep live and replacement discovery intact,
wait through listen retries, and clean up failed startup. Keep viewer
selection, search, JSON and receiver state consistent, and guide legacy
commands when the managed daemon owns their port.

Integrate current main and verify scoped regressions, browser behavior,
full npm E2E and native Local E2E. Existing macOS installer/DerivedData
test failures remain documented outside this migration.

Co-Authored-By: GPT-6 Codex <noreply@openai.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant