feat(local): add command to run a local Spotlight server#888
feat(local): add command to run a local Spotlight server#888MathurAditya724 wants to merge 23 commits into
Conversation
Adds 'sentry local', a long-running command that starts a minimal Hono
HTTP server wire-compatible with the Spotlight sidecar protocol. The
server uses @spotlightjs/spotlight/sdk's createSpotlightBuffer +
pushToSpotlightBuffer helpers to ingest envelopes from any Sentry SDK
running in the user's dev stack and tails them to the terminal.
Endpoints exposed:
POST /stream - Spotlight ingest
POST /api/{projectId}/envelope/ - Sentry SDK ingest path
GET /stream - SSE feed for the Spotlight overlay
GET /health - liveness check
Why a thin in-tree server instead of spawning npx @spotlightjs/spotlight:
the SDK helpers give us decompression + lazy parsing for free while
keeping the surface focused on a CLI-friendly tail UX, and bundling
through esbuild keeps the published binary self-contained per the
no-runtime-dependencies rule.
The command runs without auth (it's a local dev tool) and shuts down
gracefully on SIGINT/SIGTERM, force-closing keep-alive connections so
SSE subscribers don't block exit.
|
Codecov Results 📊✅ 6969 passed | Total: 6969 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
✨ No test changes detected All tests are passing successfully. ❌ Patch coverage is 15.11%. Project has 14698 uncovered lines. Files with missing lines (1)
Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
- Coverage 77.09% 76.39% -0.7%
==========================================
Files 320 321 +1
Lines 61568 62265 +697
Branches 0 0 —
==========================================
+ Hits 47465 47567 +102
- Misses 14103 14698 +595
- Partials 0 0 —Generated by Codecov Action |
Replace the minimal 'timestamp • type' one-liner with rich formatted output that shows actual event content: error type/message with stack location, transaction name/op/duration/span count, and log messages with attributes. Uses the CLI's own color system since Spotlight's humanFormatters aren't publicly exported from the package.
Adds a repeatable --filter flag that accepts error, transaction, or log. When set, only matching envelope items are rendered in the tail output; non-matching items are silently dropped. No filter = show everything. Usage: sentry local -f error # errors only sentry local -f error -f log # errors and logs sentry local -f transaction # transactions only
Three fixes based on audit against Spotlight's reference implementation: 1. Signal handling: process.once -> process.on so the 'second signal = force exit' code path is reachable (process.once unregisters after the first signal, making the shuttingDown check dead code). 2. SSE format: match the Spotlight protocol so the overlay UI works. - event name is the content type (not 'envelope') - id field is the Spotlight-assigned envelope UUID - data is the parsed envelope JSON (not base64-encoded raw bytes) - Last-Event-ID reconnection is now supported 3. Browser SDK: detect sendBeacon() payloads (Content-Type: text/plain with sentry_client query param) and override to the canonical application/x-sentry-envelope, matching Spotlight's workaround.
The --open flag opened the raw SSE endpoint in a browser, which just shows streaming text — not useful without the Spotlight overlay UI. Removed it and updated the fragment docs to document the new pretty-print tail output and --filter flag instead.
- Remove logger tag so 'local <timestamp>' no longer clutters every line - Remove 'Spotlight sidecar' wording, use 'Listening on <url>' instead - Remove endpoint listing and DSN instructions from banner - Add Spotlight docs link for getting started - Auto-increment port on EADDRINUSE (up to 10 attempts)
- Remove pointless `log = logger` alias; use `logger` directly - Strip narrating comments that restate the code - Rename buildSidecarApp → buildApp; drop remaining 'sidecar' references - Make onEnvelope callback optional instead of passing noop - Remove redundant type annotation on activeFilters
Startup banner keeps logger.info (shows ℹ icon), while tail output and shutdown messages use logger.log (no icon prefix).
The wildcard origin allowed any webpage to connect to the SSE stream and exfiltrate envelope data. Restrict to localhost/127.0.0.1 origins which is sufficient for local dev stacks (Vite, Next, Astro, etc.).
- Fix subscription leak: merge dual stream.onAbort() into one callback so unsubscribe and promise resolution both fire on disconnect - Sanitize envelope content with stripAnsi() before rendering to terminal to prevent ANSI escape injection from crafted payloads - Add 10 MB body size guard on ingest to reject oversized payloads (returns 413)
- Type labels: uppercase, bracketed, padded — [ERROR] [TRACE] [INFO] - Source labels: uppercase, bracketed, padded — [SERVER] [BROWSER] [MOBILE] - Source colors: match Spotlight Sentinel theme (mobile=blue) - Log attributes: per-attribute brackets [key=value] [key=value] - Update docs fragment example output to match
exception.values is ordered oldest→newest per the Sentry protocol, so values[0] is the root cause. Use .at(-1) to display the outermost exception, matching Sentry UI and Spotlight behavior.
|
fix-ci: attempt 1 — flaky property test |
@sentry/sqlish uppercases SQL keywords (e.g. "by" → "BY"), so identifiers that happen to match keywords fail the strict equality check. Compare lowercased strings instead.
- On startup, probe the target port for an existing Spotlight server. If one is running, attach as an SSE consumer instead of starting a duplicate server. Uses fetch-based SSE parsing since Bun lacks global EventSource. - Last-Event-ID reconnection already supported via the Spotlight SDK's subscribe(callback, lastEventId) parameter. - Port retry now uses 3 retries with 5s backoff (matching Spotlight) instead of 10 sequential port increments.
…rigins Returning a mismatched string still worked (browser blocks on mismatch) but returning null correctly omits the Access-Control-Allow-Origin header per Hono's CORS middleware API.
|
fix-ci: attempt 1 — biome formatting issue in |
- Close failed server on EADDRINUSE before retrying (prevents fd leak) - Fix SSE chunk boundary: use partial line buffer across chunks - Fix SSE data field: spec-compliant single-space strip instead of trimStart - Add logger.debug to isServerRunning catch block (repo policy) - Use process.once for signal handlers in consumer mode - Guard abort race in quiet consumer mode - Remove dead onEnvelope parameter from buildApp - Remove 'Learn more about Spotlight' line from startup banner
Prevents the command from hanging indefinitely if a non-HTTP service is listening on the target port.
These fields were interpolated into terminal output without sanitization, unlike other user-controlled fields in the same function.
Previously, --quiet mode waited only for SIGINT/SIGTERM and would hang indefinitely if the upstream server shut down. Now it consumes the SSE stream (draining chunks without parsing) so the for-await loop exits naturally on disconnect.
| const server = serve({ | ||
| fetch: app.fetch, | ||
| port, | ||
| hostname, | ||
| }) as unknown as Server; | ||
|
|
||
| server.once("listening", () => resolve({ server, port })); | ||
| server.once("error", async (err: NodeJS.ErrnoException) => { |
There was a problem hiding this comment.
Bug: The tryListen function may hang on startup due to a race condition where the listening event is emitted before its handler is attached.
Severity: MEDIUM
Suggested Fix
Refactor the code to use the idiomatic, callback-based API for @hono/node-server's serve() function, like serve(app, (info) => { ... }). This avoids the timing-sensitive event handling and removes the need for the unsafe type cast.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/commands/local.ts#L629-L636
Potential issue: The `tryListen` function initializes a server using
`@hono/node-server`'s `serve()` function and then attaches event listeners for
`listening` and `error`. This pattern is sensitive to timing. If the `serve()` function
starts the server and emits the `listening` event synchronously, before the
`server.once("listening", ...)` handler is attached, the event will be missed. This
would cause the promise returned by `tryListen` to never resolve, making the `sentry
local` command hang indefinitely on startup without providing any output or accepting
connections. The use of a double-cast `as unknown as Server` bypasses type safety and
suggests a workaround for a non-standard API usage, increasing the fragility of this
implementation.
There was a problem hiding this comment.
not a real race — @hono/node-server's serve() calls server.listen(port, hostname, cb) internally, which schedules the listening event asynchronously via the Node.js event loop. the .once("listening", ...) handler is attached synchronously on the next line, well before the event loop gets a chance to emit. this is the standard Node.js server startup pattern.
Summary
Adds
sentry local, a long-running command that starts a minimal Hono HTTP server wire-compatible with the Spotlight protocol. It uses@spotlightjs/spotlight/sdk'screateSpotlightBufferandpushToSpotlightBufferhelpers to ingest envelopes from any Sentry SDK running in the user's dev stack and tails them to the terminal.If a Spotlight server is already running on the target port, the command attaches as an SSE consumer instead of starting a duplicate server — matching Spotlight's own
tailbehavior.What's new
src/commands/local.ts— new command. Flags:--port/-p(default8969),--host/-H(defaultlocalhost),--quiet/-q,--filter/-f. Runs without auth.src/app.ts— wireslocalinto the top-level route map.package.json— adds@spotlightjs/spotlight,hono, and@hono/node-serveras devDependencies.docs/src/fragments/commands/local.md— hand-written examples + endpoint table.Key behaviors
http://host:port/healthon startup. If a server responds, attaches as an SSE consumer. Otherwise starts its own server.[TYPE] [SOURCE] messageconvention.Endpoints exposed (server mode)
POST/streamPOST/api/{projectId}/envelope/GET/streamGET/healthOut of scope