Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli-docs/src/fragments/commands/local.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ sentry local --quiet

Runs a command with `SENTRY_SPOTLIGHT` injected into the environment. The Sentry SDK automatically detects this variable and sends envelopes to the local server. No code changes needed.

If nothing is listening on the port, a server is started in the background and shut down when your command exits. If something already is — the Spotlight desktop app's own sidecar, or a `sentry local serve` in another terminal — the command attaches to it as an SSE consumer, so events still tail to your terminal either way.

Env vars injected into the child process:

| Variable | Value |
Expand Down
65 changes: 51 additions & 14 deletions packages/cli/src/commands/local/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
*
* If no server is already running on the target port, one is started
* automatically in the background and shut down when the child exits.
* If one is already running, this command attaches to it as an SSE consumer
* so events still tail to the terminal.
*/

import { type ChildProcess, spawn } from "node:child_process";
Expand All @@ -24,6 +26,7 @@ import { formatEnvelopeLines } from "../../lib/formatters/local.js";
import { logger, printLine } from "../../lib/logger.js";
import {
buildApp,
consumeSSE,
DEFAULT_PORT,
isServerRunning,
parsePort,
Expand Down Expand Up @@ -94,20 +97,56 @@ function isPackageJsonSource(source: string): boolean {
return source.startsWith("package.json");
}

/** State for a background server started by `local run`. */
type BackgroundServer = {
/**
* A live event tail for the duration of the child process, plus the teardown
* to run once it exits. Produced either by {@link startBackgroundServer} (we
* own the server) or {@link attachToExistingServer} (someone else does).
*/
type EventTail = {
url: string;
cleanup: () => Promise<void>;
};

/**
* Tail events from a server this command did not start.
*
* Something else already owns the port — most often the Spotlight desktop
* app's own sidecar, or a `sentry local serve` in another terminal. Without
* this, `run` stayed completely silent for the whole session: envelopes
* reached the other server, but nothing was ever printed here. Attaching as
* an SSE consumer is what `sentry local serve` does in the same situation.
*/
function attachToExistingServer(url: string): EventTail {
const ac = new AbortController();
const tail = consumeSSE({
url,
Comment thread
cursor[bot] marked this conversation as resolved.
activeFilters: new Set<never>(),
signal: ac.signal,
}).catch((err: unknown) => {
if (!ac.signal.aborted) {
logger.debug(
`Event tail stopped: ${err instanceof Error ? err.message : String(err)}`
);
}
});

return {
url,
cleanup: async () => {
ac.abort();
await tail;
},
};
}

/**
* Start a background dev server and subscribe to its buffer so incoming
* envelopes are printed inline, matching the behavior of `sentry local serve`.
*/
async function startBackgroundServer(
port: number,
host: string
): Promise<BackgroundServer> {
): Promise<EventTail> {
const buffer = createSpotlightBuffer(BUFFER_SIZE);
const app = buildApp(buffer);
const { server, port: boundPort } = await tryListen(app, port, host);
Expand Down Expand Up @@ -271,13 +310,15 @@ export const runCommand = buildCommand({
}

let url = `http://${flags.host}:${flags.port}`;
let bg: BackgroundServer | undefined;
let tail: EventTail;

const alreadyRunning = await isServerRunning(url);
if (!alreadyRunning) {
if (await isServerRunning(url)) {
logger.info(`Connected to existing server at ${bold(url)}`);
tail = attachToExistingServer(url);
} else {
logger.info("No server detected, starting one in the background...");
bg = await startBackgroundServer(flags.port, flags.host);
url = bg.url;
tail = await startBackgroundServer(flags.port, flags.host);
url = tail.url;
logger.info(`Background server listening on ${bold(url)}`);
}

Expand All @@ -296,9 +337,7 @@ export const runCommand = buildCommand({
stdio: "inherit",
});
} catch (err) {
if (bg) {
await bg.cleanup();
}
await tail.cleanup();
throw new CliError(
`Failed to start "${args[0]}": ${err instanceof Error ? err.message : String(err)}`,
EXIT.GENERAL
Expand Down Expand Up @@ -342,9 +381,7 @@ export const runCommand = buildCommand({
}
process.removeListener("SIGINT", onSigint);
process.removeListener("SIGTERM", onSigterm);
if (bg) {
await bg.cleanup();
}
await tail.cleanup();
}

if (exitCode !== 0) {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/local/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ const SSE_INITIAL_RETRY_MS = 1000;
const SSE_MAX_RETRY_MS = 30_000;

/** Options for consuming an SSE stream. */
type ConsumeSSEOptions = {
export type ConsumeSSEOptions = {
url: string;
activeFilters: ReadonlySet<FilterValue>;
signal: AbortSignal;
Expand Down Expand Up @@ -471,7 +471,7 @@ async function sleepUnlessAborted(
* Reconnects automatically on connection loss with exponential backoff,
* using `Last-Event-ID` to resume from where the stream left off.
*/
async function consumeSSE(opts: ConsumeSSEOptions): Promise<void> {
export async function consumeSSE(opts: ConsumeSSEOptions): Promise<void> {
const {
url,
activeFilters,
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/test/commands/local/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@

import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
CLIENT_SPOTLIGHT_PREFIXES,
runCommand,
shutdownServer,
} from "../../../src/commands/local/run.js";
import { buildApp, tryListen } from "../../../src/commands/local/server.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";

/**
Expand Down Expand Up @@ -270,6 +274,57 @@ describe("sentry local run", () => {
);
});

test("tails events from a server it did not start", async () => {
// Reproduces the case where the Spotlight desktop app (or another
// `sentry local serve`) already owns the port: `run` must attach as an SSE
// consumer instead of going silent for the whole session.
const buffer = createSpotlightBuffer(10);
const { server, port } = await tryListen(buildApp(buffer), 0, "127.0.0.1");

// preload.ts mocks fetch to block external calls; this test talks to a
// loopback server it just started, so it needs the real implementation.
const savedFetch = globalThis.fetch;
const realFetch = (globalThis as { __originalFetch?: typeof fetch })
.__originalFetch;
if (realFetch) {
globalThis.fetch = realFetch;
}

const writes: string[] = [];
const spy = vi
.spyOn(process.stderr, "write")
.mockImplementation((chunk: string | Uint8Array) => {
writes.push(chunk.toString());
return true;
});

try {
// Buffered before we attach — SSE subscribers replay from the buffer
// head, so this arrives regardless of connection timing.
await fetch(`http://127.0.0.1:${port}/stream`, {
method: "POST",
headers: { "Content-Type": SENTRY_CONTENT_TYPE },
body: '{"sdk":{"name":"sentry.javascript.node"}}\n{"type":"log","item_count":1,"content_type":"application/vnd.sentry.items.log+json"}\n{"items":[{"timestamp":1750000000,"level":"info","body":"Hello from the server!","attributes":{}}]}',
});

const func = (await runCommand.loader()) as unknown as RunFunc;
await func.call(
makeContext(),
{ port, host: "127.0.0.1", verify: false, timeout: 0 },
"sleep",
"1"
);
} finally {
spy.mockRestore();
globalThis.fetch = savedFetch;
await shutdownServer(server);
}

const output = writes.join("");
expect(output).toContain("Connected to existing server");
expect(output).toContain("Hello from the server!");
});

test("injects spotlight URL under every framework client prefix", async () => {
const func = (await runCommand.loader()) as unknown as RunFunc;
const ctx = makeContext();
Expand Down
Loading