Skip to content

Commit 16731e3

Browse files
feat(local): connect to existing server, backoff port retry
- 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.
1 parent 32643be commit 16731e3

1 file changed

Lines changed: 166 additions & 44 deletions

File tree

src/commands/local.ts

Lines changed: 166 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,12 @@
11
/**
22
* sentry local
33
*
4-
* Run a local Spotlight-compatible server.
4+
* Run a local Spotlight-compatible server, or attach to one already running.
55
*
6-
* Spotlight (https://spotlightjs.com/) is "Sentry for Development" — a small
7-
* local proxy that ingests Sentry envelopes from SDKs running in your dev
8-
* stack and surfaces them in real time.
9-
*
10-
* This command starts a minimal Hono HTTP server that:
11-
*
12-
* 1. Accepts envelopes from Sentry SDKs at the standard endpoints:
13-
* - `POST /stream` (Spotlight-compatible)
14-
* - `POST /api/{projectId}/envelope/` (Sentry SDK ingest path)
15-
* 2. Pushes them into the buffer provided by `@spotlightjs/spotlight/sdk`,
16-
* which lazily parses each envelope.
17-
* 3. Streams new envelopes back to subscribers via Server-Sent Events at
18-
* `GET /stream` — compatible with the Spotlight overlay/UI.
19-
* 4. Tails events to the terminal as they arrive so you can see what your
20-
* app is sending without leaving the CLI.
6+
* On startup the command probes `http://<host>:<port>/health`. If a server
7+
* is already listening (e.g. a Spotlight sidecar or another `sentry local`),
8+
* the command attaches as an SSE consumer and tails events from it. Otherwise
9+
* it starts its own Hono HTTP server.
2110
*
2211
* Learn more: https://spotlightjs.com/docs/getting-started/
2312
*
@@ -57,6 +46,9 @@ const BUFFER_SIZE = 500;
5746
/** Canonical content type for Sentry envelopes. */
5847
const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope";
5948

49+
/** Trailing carriage return — stripped from SSE lines. */
50+
const CR_RE = /\r$/;
51+
6052
/** Maximum ingest body size (10 MB). Rejects oversized payloads early. */
6153
const MAX_BODY_BYTES = 10 * 1024 * 1024;
6254

@@ -619,24 +611,23 @@ function waitForShutdown(server: Server): Promise<void> {
619611
});
620612
}
621613

622-
/** Maximum number of consecutive ports to try before giving up. */
623-
const MAX_PORT_ATTEMPTS = 10;
614+
/** Maximum retries on EADDRINUSE before giving up. */
615+
const MAX_PORT_RETRIES = 3;
616+
617+
/** Delay between EADDRINUSE retries in milliseconds. */
618+
const PORT_RETRY_DELAY_MS = 5000;
624619

625620
/**
626-
* Try to start the HTTP server, auto-incrementing the port on EADDRINUSE.
621+
* Try to start the HTTP server, retrying with backoff on EADDRINUSE.
627622
*
628-
* `@hono/node-server`'s `serve()` calls `server.listen()` synchronously and
629-
* returns immediately — the actual bind happens asynchronously. We wrap it in
630-
* a Promise that resolves on the `listening` event and rejects on `error`.
631-
* When the port is busy we bump the port number and retry up to
632-
* {@link MAX_PORT_ATTEMPTS} times, warning the user on each bump.
623+
* Retries up to {@link MAX_PORT_RETRIES} times with a {@link PORT_RETRY_DELAY_MS}
624+
* delay between attempts, matching Spotlight's retry strategy.
633625
*/
634626
function tryListen(
635627
app: Hono,
636-
startPort: number,
628+
port: number,
637629
hostname: string
638630
): Promise<{ server: Server; port: number }> {
639-
let port = startPort;
640631
let attempts = 0;
641632

642633
const attempt = (): Promise<{ server: Server; port: number }> =>
@@ -648,20 +639,22 @@ function tryListen(
648639
}) as unknown as Server;
649640

650641
server.once("listening", () => resolve({ server, port }));
651-
server.once("error", (err: NodeJS.ErrnoException) => {
642+
server.once("error", async (err: NodeJS.ErrnoException) => {
652643
if (err.code === "EADDRINUSE") {
653644
attempts += 1;
654-
if (attempts >= MAX_PORT_ATTEMPTS) {
645+
if (attempts > MAX_PORT_RETRIES) {
655646
reject(
656647
new ValidationError(
657-
`Port ${startPort} is in use and no open port found after ${MAX_PORT_ATTEMPTS} attempts`,
648+
`Port ${port} is in use after ${MAX_PORT_RETRIES} retries`,
658649
"port"
659650
)
660651
);
661652
return;
662653
}
663-
logger.warn(`Port ${port} is in use, trying ${port + 1}...`);
664-
port += 1;
654+
logger.warn(
655+
`Port ${port} is in use, retrying in ${PORT_RETRY_DELAY_MS / 1000}s (attempt ${attempts}/${MAX_PORT_RETRIES})...`
656+
);
657+
await Bun.sleep(PORT_RETRY_DELAY_MS);
665658
resolve(attempt());
666659
return;
667660
}
@@ -672,22 +665,120 @@ function tryListen(
672665
return attempt();
673666
}
674667

668+
/**
669+
* Check whether a Spotlight server is already running on the given URL.
670+
* Returns `true` if the health endpoint responds successfully.
671+
*/
672+
async function isServerRunning(url: string): Promise<boolean> {
673+
try {
674+
const res = await fetch(`${url}/health`);
675+
return res.ok;
676+
} catch {
677+
return false;
678+
}
679+
}
680+
681+
/** Mutable state for the SSE line parser. */
682+
type SSEParserState = {
683+
eventType: string;
684+
dataLines: string[];
685+
};
686+
687+
/** Process a single SSE line, dispatching complete events via callback. */
688+
function feedSSELine(
689+
line: string,
690+
state: SSEParserState,
691+
onEvent: (type: string, data: string) => void
692+
): void {
693+
if (line.startsWith("event:")) {
694+
state.eventType = line.slice(6).trim();
695+
} else if (line.startsWith("data:")) {
696+
state.dataLines.push(line.slice(5).trimStart());
697+
} else if (line === "" && state.dataLines.length > 0) {
698+
onEvent(state.eventType, state.dataLines.join("\n"));
699+
state.eventType = "";
700+
state.dataLines = [];
701+
}
702+
}
703+
704+
/**
705+
* Consume SSE events from an upstream Spotlight server and print them.
706+
*
707+
* Bun doesn't have a global `EventSource`, so we use `fetch` with a
708+
* streaming body and parse the SSE wire format manually.
709+
*/
710+
async function consumeSSE(
711+
url: string,
712+
activeFilters: ReadonlySet<FilterValue>,
713+
signal: AbortSignal
714+
): Promise<void> {
715+
const res = await fetch(`${url}/stream`, {
716+
headers: { Accept: "text/event-stream" },
717+
signal,
718+
});
719+
if (!res.body) {
720+
return;
721+
}
722+
723+
const decoder = new TextDecoder();
724+
const state: SSEParserState = { eventType: "", dataLines: [] };
725+
726+
for await (const chunk of res.body) {
727+
const text = decoder.decode(chunk as Uint8Array, { stream: true });
728+
for (const rawLine of text.split("\n")) {
729+
feedSSELine(rawLine.replace(CR_RE, ""), state, (type, data) => {
730+
if (type === SENTRY_CONTENT_TYPE) {
731+
processSSEEvent(data, activeFilters);
732+
}
733+
});
734+
}
735+
}
736+
}
737+
738+
/** Parse and format a single SSE data payload from upstream. */
739+
function processSSEEvent(
740+
data: string,
741+
activeFilters: ReadonlySet<FilterValue>
742+
): void {
743+
try {
744+
const envelope = JSON.parse(data) as [
745+
Record<string, unknown>,
746+
[{ type?: string }, unknown][],
747+
];
748+
const [header, items] = envelope;
749+
for (const [itemHeader, itemPayload] of items) {
750+
if (!isItemIncluded(itemHeader.type, activeFilters)) {
751+
continue;
752+
}
753+
for (const line of formatItem(
754+
itemHeader.type,
755+
itemPayload as Record<string, unknown>,
756+
header,
757+
itemHeader.type ?? "envelope"
758+
)) {
759+
logger.log(line);
760+
}
761+
}
762+
} catch (err) {
763+
logger.debug(
764+
`Failed to parse SSE event: ${err instanceof Error ? err.message : String(err)}`
765+
);
766+
}
767+
}
768+
675769
export const localCommand = buildCommand({
676770
docs: {
677771
brief: "Run a local Spotlight server to capture dev SDK events",
678772
fullDescription:
679-
"Start a local Spotlight-compatible server.\n\n" +
773+
"Start a local Spotlight-compatible server, or attach to one\n" +
774+
"already running on the same port.\n\n" +
680775
"Spotlight is Sentry for Development — it gives you a live view of\n" +
681-
"errors, traces, and logs emitted by Sentry SDKs in your dev stack.\n" +
682-
"This command runs a minimal Hono server that ingests envelopes\n" +
683-
"from any Sentry SDK and tails them to your terminal.\n\n" +
684-
"Endpoints:\n" +
685-
" POST /stream — Spotlight ingest\n" +
686-
" POST /api/{projectId}/envelope/ — Sentry SDK ingest\n" +
687-
" GET /stream — SSE feed (for the Spotlight overlay)\n" +
688-
" GET /health — health check\n\n" +
776+
"errors, traces, and logs emitted by Sentry SDKs in your dev stack.\n\n" +
777+
"If a server is already listening on the port, the command connects\n" +
778+
"as an SSE consumer and tails events from it. Otherwise it starts\n" +
779+
"its own server.\n\n" +
689780
"Learn more: https://spotlightjs.com/docs/getting-started/\n\n" +
690-
"Press Ctrl-C to stop the server.",
781+
"Press Ctrl-C to stop.",
691782
},
692783
parameters: {
693784
flags: {
@@ -726,8 +817,39 @@ export const localCommand = buildCommand({
726817
},
727818
auth: false,
728819
async *func(this: SentryContext, flags: LocalFlags) {
729-
const buffer = createSpotlightBuffer(BUFFER_SIZE);
730820
const activeFilters = new Set(flags.filter);
821+
const url = `http://${flags.host}:${flags.port}`;
822+
823+
if (await isServerRunning(url)) {
824+
logger.info(`Connected to existing server at ${bold(url)}`);
825+
if (activeFilters.size > 0) {
826+
logger.info(`Filtering: ${[...activeFilters].join(", ")}`);
827+
}
828+
logger.info("Press Ctrl-C to stop.");
829+
830+
const ac = new AbortController();
831+
const stop = () => ac.abort();
832+
process.on("SIGINT", stop);
833+
process.on("SIGTERM", stop);
834+
835+
if (flags.quiet) {
836+
await new Promise<void>((resolve) => {
837+
ac.signal.addEventListener("abort", () => resolve());
838+
});
839+
} else {
840+
await consumeSSE(url, activeFilters, ac.signal).catch(
841+
(err: unknown) => {
842+
if (!(err instanceof DOMException && err.name === "AbortError")) {
843+
throw err;
844+
}
845+
}
846+
);
847+
}
848+
logger.log("Disconnected.");
849+
return;
850+
}
851+
852+
const buffer = createSpotlightBuffer(BUFFER_SIZE);
731853

732854
if (!flags.quiet) {
733855
buffer.subscribe((container) => {
@@ -745,8 +867,8 @@ export const localCommand = buildCommand({
745867
flags.host
746868
);
747869

748-
const url = `http://${flags.host}:${boundPort}`;
749-
logger.info(`Listening on ${bold(url)}`);
870+
const listenUrl = `http://${flags.host}:${boundPort}`;
871+
logger.info(`Listening on ${bold(listenUrl)}`);
750872
if (activeFilters.size > 0) {
751873
logger.info(`Filtering: ${[...activeFilters].join(", ")}`);
752874
}

0 commit comments

Comments
 (0)