From 1636e33bdebf1e4b0a5291c30007220d89b80c76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 22:01:59 +0000 Subject: [PATCH] fix(local): print one timestamp per event line and identify as a Spotlight sidecar Event tails rendered their own event timestamp and then went through logger.log(), so consola's reporter appended a second wall-clock time to every line (right-aligned on wide terminals, a [HH:MM:SS] prefix on narrow ones). Print those pre-formatted lines straight to stderr via printLine() so only the event time survives. The local server also never advertised X-Powered-By: spotlight-by-sentry, which is what Spotlight's isSidecarRunning() probes for. Without it the desktop app treated the CLI server as 'no sidecar connected', tried to start its own on 8969, and hit EADDRINUSE. Set the header on every response and echo the ;base64 event-type suffix that Spotlight UI clients request via ?base64 on /stream. Co-authored-by: Aditya Mathur --- packages/cli/src/commands/local/run.ts | 4 +- packages/cli/src/commands/local/server.ts | 68 +++++++++++----- packages/cli/src/lib/logger.ts | 18 +++++ .../cli/test/commands/local/server.test.ts | 80 +++++++++++++++++++ packages/cli/test/lib/logger.test.ts | 33 +++++++- 5 files changed, 182 insertions(+), 21 deletions(-) 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