diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 1ba41b366c..00af884fa7 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -21,7 +21,7 @@ import { detectDevCommand } from "../../lib/dev-script.js"; import { CliError, EXIT, ValidationError } from "../../lib/errors.js"; import { bold } from "../../lib/formatters/colors.js"; import { formatEnvelopeLines } from "../../lib/formatters/local.js"; -import { logger } from "../../lib/logger.js"; +import { logger, printLine } from "../../lib/logger.js"; import { buildApp, DEFAULT_PORT, @@ -117,7 +117,7 @@ async function startBackgroundServer( const subscriptionId = buffer.subscribe((container) => { try { for (const line of formatEnvelopeLines(container, noFilters)) { - logger.log(line); + printLine(line); } } catch (err) { logger.debug( diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 0aa2ba67c5..78ccb853c5 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -36,11 +36,23 @@ import { isItemIncluded, SENTRY_CONTENT_TYPE, } from "../../lib/formatters/local.js"; -import { logger } from "../../lib/logger.js"; +import { logger, printLine } from "../../lib/logger.js"; /** Default port for the local dev server. */ export const DEFAULT_PORT = 8969; +/** + * Value advertised in the `X-Powered-By` response header. + * + * Spotlight clients (the desktop app, the browser overlay) probe `/health` and + * treat a sidecar as present only when this exact header value comes back — + * see `isSidecarRunning()` in `@spotlightjs/spotlight`. Without it they assume + * no sidecar is running, try to start their own on the same port, fail with + * EADDRINUSE, and report "not connected" even though this server is happily + * receiving envelopes. + */ +export const SERVER_IDENTIFIER = "spotlight-by-sentry"; + /** Buffer size: how many recent envelopes to retain for late subscribers. */ const BUFFER_SIZE = 500; @@ -118,14 +130,25 @@ const LOCALHOST_ORIGIN_RE = * arbitrary remote origins to read the SSE envelope stream. */ -/** Build a subscriber callback that serializes envelopes to an SSE stream. */ -function buildSSEHandler(stream: { - writeSSE: (event: { - id?: string; - event?: string; - data: string; - }) => Promise; -}) { +/** + * Build a subscriber callback that serializes envelopes to an SSE stream. + * + * @param stream - Hono SSE stream to write to + * @param useBase64 - Whether the client requested the `;base64` event-type + * suffix (via `?base64` on `/stream`). Spotlight UI clients use the suffix + * to pick their decode path, so it must be echoed back or they drop events. + */ +function buildSSEHandler( + stream: { + writeSSE: (event: { + id?: string; + event?: string; + data: string; + }) => Promise; + }, + useBase64 = false +) { + const base64Indicator = useBase64 ? ";base64" : ""; return (container: { getParsedEnvelope: () => { envelope: [Record, unknown[]]; @@ -142,7 +165,7 @@ function buildSSEHandler(stream: { stream .writeSSE({ id: envelopeId ? String(envelopeId) : undefined, - event: container.getContentType(), + event: `${container.getContentType()}${base64Indicator}`, data: JSON.stringify(parsed.envelope), }) .catch((err: unknown) => { @@ -165,6 +188,11 @@ export function buildApp( ): Hono { const app = new Hono(); + app.use("*", async (c, next) => { + c.header("X-Powered-By", SERVER_IDENTIFIER); + await next(); + }); + app.use( "*", cors({ @@ -231,14 +259,18 @@ export function buildApp( /** * SSE stream — overlay / UI clients connect here to receive a * live feed of envelopes. The SSE event format: - * - `event` is the content type (e.g., "application/x-sentry-envelope") + * - `event` is the content type (e.g., "application/x-sentry-envelope"), + * suffixed with `;base64` when the client passed `?base64` * - `id` is the envelope UUID (enables reconnection) * - `data` is the parsed envelope JSON ([header, items]) */ - app.get("/stream", (c) => - streamSSE(c, async (stream) => { + app.get("/stream", (c) => { + // Presence, not value: clients connect with a bare `?base64` as often + // as `?base64=1`, so an empty string still means "yes". + const useBase64 = c.req.query("base64") !== undefined; + return streamSSE(c, async (stream) => { const lastEventId = c.req.header("Last-Event-ID"); - const onEnvelope = buildSSEHandler(stream); + const onEnvelope = buildSSEHandler(stream, useBase64); const readerId = spotlightBuffer.subscribe(onEnvelope, lastEventId); await new Promise((resolve) => { @@ -247,8 +279,8 @@ export function buildApp( resolve(); }); }); - }) - ); + }); + }); return app; } @@ -642,7 +674,7 @@ function processSSEEvent( showAttributes ); for (const line of lines) { - logger.log(line); + printLine(line); } } } catch (err) { @@ -762,7 +794,7 @@ export const serverCommand = buildCommand({ activeFilters, flags.attributes )) { - logger.log(line); + printLine(line); } } catch (err) { logger.debug( diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index 8cacd28056..c5acfb5f82 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -170,6 +170,24 @@ export const logger = createConsola({ stderr: process.stderr, }); +/** + * Write an already-formatted line to stderr verbatim, bypassing consola. + * + * Event tails (`sentry local`) render their own prefix — an event timestamp, + * level tag, and source tag — before printing. Routing those lines through + * `logger.log()` makes consola's reporter prepend/append a *second* timestamp + * (the wall-clock print time), so every line carried two different times. + * Writing directly keeps the single, meaningful event timestamp. + * + * Uses the same stream as {@link logger} so ordering with surrounding + * `logger.info()` status messages is preserved. + * + * @param line - Fully formatted line, without a trailing newline + */ +export function printLine(line: string): void { + process.stderr.write(`${line}\n`); +} + /** * Patch a consola instance's `withTag` so every child (and grandchild) * is registered in {@link scopedLoggers} for {@link setLogLevel} propagation. diff --git a/packages/cli/test/commands/local/server.test.ts b/packages/cli/test/commands/local/server.test.ts index 47aa41ac17..e550c46db2 100644 --- a/packages/cli/test/commands/local/server.test.ts +++ b/packages/cli/test/commands/local/server.test.ts @@ -12,6 +12,7 @@ import { feedSSELine, isServerRunning, parsePort, + SERVER_IDENTIFIER, } from "../../../src/commands/local/server.js"; import { ValidationError } from "../../../src/lib/errors.js"; import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; @@ -143,6 +144,52 @@ describe("feedSSELine", () => { }); }); +/** Minimal well-formed Sentry envelope used by the ingest/SSE tests. */ +const TEST_ENVELOPE = + '{"sdk":{"name":"sentry.node"}}\n{"type":"event"}\n{"message":"test"}'; + +/** + * Subscribe to the SSE stream at `streamPath`, ingest one envelope, and return + * the decoded text of the first frame the subscriber receives. + * + * The subscription is registered inside `streamSSE`'s async callback, so the + * envelope must not be posted until that callback has had a chance to run — + * otherwise the subscriber misses it and the read hangs. + */ +async function readFirstSSEEvent(streamPath: string): Promise { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request(streamPath, { + headers: { Accept: "text/event-stream" }, + }); + if (!res.body) { + throw new Error("SSE response had no body"); + } + const reader = res.body.getReader(); + try { + await new Promise((resolve) => setImmediate(resolve)); + await app.request("/stream", { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: TEST_ENVELOPE, + }); + + const decoder = new TextDecoder(); + let text = ""; + while (!text.includes("event: ")) { + const { done, value } = await reader.read(); + if (done) { + throw new Error(`SSE stream closed before an event arrived: ${text}`); + } + text += decoder.decode(value, { stream: true }); + } + return text; + } finally { + await reader.cancel(); + } +} + describe("buildApp", () => { test("health endpoint returns OK", async () => { const buffer = createSpotlightBuffer(10); @@ -232,6 +279,39 @@ describe("buildApp", () => { await res.body.cancel(); } }); + + test("advertises itself as a Spotlight sidecar via X-Powered-By", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/health"); + // The literal is spelled out rather than compared against the constant: + // Spotlight's isSidecarRunning() matches this exact string, so a rename + // here would silently break desktop-app detection. + expect(res.headers.get("x-powered-by")).toBe("spotlight-by-sentry"); + }); + + test("sets X-Powered-By on ingest responses too", async () => { + const buffer = createSpotlightBuffer(10); + const app = buildApp(buffer); + + const res = await app.request("/stream", { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: TEST_ENVELOPE, + }); + expect(res.headers.get("x-powered-by")).toBe(SERVER_IDENTIFIER); + }); + + test("SSE event type carries no base64 suffix by default", async () => { + const chunk = await readFirstSSEEvent("/stream"); + expect(chunk).toContain(`event: ${SENTRY_CONTENT_TYPE}\n`); + }); + + test("SSE event type gains ;base64 suffix when the client asks for it", async () => { + const chunk = await readFirstSSEEvent("/stream?base64=1"); + expect(chunk).toContain(`event: ${SENTRY_CONTENT_TYPE};base64\n`); + }); }); describe("isServerRunning", () => { diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index 08f6f74529..e81899cd9e 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -5,7 +5,7 @@ * attachSentryReporter, and the logger instance configuration. */ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { attachSentryReporter, getEnvLogLevel, @@ -13,6 +13,7 @@ import { LOG_LEVEL_NAMES, logger, parseLogLevel, + printLine, setLogLevel, } from "../../src/lib/logger.js"; @@ -210,6 +211,36 @@ describe("logger instance", () => { }); }); +/** Matches an `HH:MM:SS` clock time anywhere in a rendered log line. */ +const TIME_RE = /\d{2}:\d{2}:\d{2}/g; + +describe("printLine", () => { + test("writes the line verbatim to stderr with a trailing newline", () => { + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + printLine("12:34:56 [INFO] [SERVER] Hello"); + expect(spy).toHaveBeenCalledWith("12:34:56 [INFO] [SERVER] Hello\n"); + } finally { + spy.mockRestore(); + } + }); + + test("does not add a second timestamp the way logger.log does", () => { + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + printLine("12:34:56 [INFO] [SERVER] Hello"); + const written = spy.mock.calls.map((call) => String(call[0])).join(""); + expect(written.match(TIME_RE)).toHaveLength(1); + } finally { + spy.mockRestore(); + } + }); +}); + describe("attachSentryReporter", () => { test("can be called without error", () => { // attachSentryReporter is idempotent and safe to call even when