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
4 changes: 2 additions & 2 deletions packages/cli/src/commands/local/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
68 changes: 50 additions & 18 deletions packages/cli/src/commands/local/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<void>;
}) {
/**
* 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<void>;
},
useBase64 = false
) {
const base64Indicator = useBase64 ? ";base64" : "";
return (container: {
getParsedEnvelope: () => {
envelope: [Record<string, unknown>, unknown[]];
Expand All @@ -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) => {
Expand All @@ -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({
Expand Down Expand Up @@ -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<void>((resolve) => {
Expand All @@ -247,8 +279,8 @@ export function buildApp(
resolve();
});
});
})
);
});
});

return app;
}
Expand Down Expand Up @@ -642,7 +674,7 @@ function processSSEEvent(
showAttributes
);
for (const line of lines) {
logger.log(line);
printLine(line);
}
}
} catch (err) {
Expand Down Expand Up @@ -762,7 +794,7 @@ export const serverCommand = buildCommand({
activeFilters,
flags.attributes
)) {
logger.log(line);
printLine(line);
}
} catch (err) {
logger.debug(
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions packages/cli/test/commands/local/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string> {
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);
Expand Down Expand Up @@ -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", () => {
Expand Down
33 changes: 32 additions & 1 deletion packages/cli/test/lib/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
* 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,
LOG_LEVEL_ENV_VAR,
LOG_LEVEL_NAMES,
logger,
parseLogLevel,
printLine,
setLogLevel,
} from "../../src/lib/logger.js";

Expand Down Expand Up @@ -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
Expand Down
Loading