Skip to content
Open
Show file tree
Hide file tree
Changes from 13 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
6 changes: 6 additions & 0 deletions .changeset/petite-humans-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@spotlightjs/sidecar": minor
"@spotlightjs/spotlight": minor
---

Add CLI -- `spotlight logs+errors` etc are now available
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"buildx",
"codesign",
"contextlines",
"decompressors",
"Endcaps",
"fontsource",
"getsentry",
Expand Down
1 change: 0 additions & 1 deletion packages/electron/src/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,6 @@ Promise.all([
setupSidecar({
port: 8969,
incomingPayload: storeIncomingPayload,
isStandalone: true,
}),
app.whenReady(),
]).then(() => {
Expand Down
1 change: 0 additions & 1 deletion packages/overlay/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export const DEFAULT_SIDECAR_URL = "http://localhost:8969";
export const DEFAULT_SIDECAR_STREAM_URL = new URL("/stream", DEFAULT_SIDECAR_URL).href;
export const DEFAULT_INITIAL_TAB = "/traces";
3 changes: 1 addition & 2 deletions packages/overlay/src/telemetry/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Envelope } from "@sentry/core";
import { SENTRY_CONTENT_TYPE } from "@spotlightjs/sidecar/constants";
import { useCallback, useEffect, useMemo, useState } from "react";
import { removeURLSuffix } from "~/lib/removeURLSuffix";
import { getSpotlightEventTarget } from "../lib/eventTarget";
Expand Down Expand Up @@ -33,8 +34,6 @@ type TelemetryRouteProps = {

type EventData = { contentType: string; data: string };

const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope";

export function Telemetry({ sidecarUrl }: TelemetryRouteProps) {
const [sentryEvents, setSentryEvents] = useState<Envelope[]>([]);
const [isOnline, setOnline] = useState(false);
Expand Down
5 changes: 4 additions & 1 deletion packages/sidecar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
"test": "vitest run",
"test:dev": "vitest"
},
"files": ["dist"],
"files": [
"dist"
],
"bin": {
"spotlight-sidecar": "dist/server.js"
},
Expand Down Expand Up @@ -45,6 +47,7 @@
"@modelcontextprotocol/sdk": "^1.16.0",
"@sentry/core": "catalog:",
"@sentry/node": "catalog:",
"eventsource": "^4.0.0",
"hono": "^4.9.7",
"launch-editor": "^2.9.1",
"mcp-proxy": "^5.6.0",
Expand Down
120 changes: 114 additions & 6 deletions packages/sidecar/server.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
#!/usr/bin/env node
import { captureException } from "@sentry/core";
import { EventSource } from "eventsource";
import { SENTRY_CONTENT_TYPE } from "./src/constants.js";
import { formatEnvelope } from "./src/format";
import { parseCLIArgs, setupSidecar } from "./src/main.js";
import type { ParsedEnvelope } from "./src/parser/processEnvelope.js";

const args = parseCLIArgs();
const connectUpstream = async (port: number) =>
new Promise<EventSource>((resolve, reject) => {
const client = new EventSource(`http://localhost:${port}/stream`);
client.onerror = reject;
client.onopen = () => resolve(client);
});

if (args.help) {
const SEPARATOR = Array(10).fill("─").join("");

function displayEnvelope(envelope: ParsedEnvelope["envelope"]) {
console.log(`${envelope[0].event_id} | ${envelope[1][0][0].type} | ${envelope[0].sdk?.name}\n\n`);
const lines = formatEnvelope(envelope);
if (lines.length > 0) {
console.log(lines.join(""));
} else {
console.log("No parser for the given event type");
}
console.log("\n");
console.log(SEPARATOR);
}

const printHelp = () => {
console.log(`
Spotlight Sidecar - Development proxy server for Spotlight

Usage: spotlight-sidecar [options]

Options:
-p, --port <port> Port to listen on (default: 8969)
--stdio-mcp Enable MCP stdio transport
-d, --debug Enable debug logging
-h, --help Show this help message

Expand All @@ -21,9 +44,94 @@ Examples:
spotlight-sidecar -p 3000 -d # Start on port 3000 with debug logging
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential bug: The code does not validate the result of envelope.getParsedEnvelope(), leading to a crash if the envelope is malformed (null) or empty (empty items array).
  • Description: The function envelope.getParsedEnvelope() can return null if the envelope header is malformed. The code destructures this result via const { event } = envelope.getParsedEnvelope() without a null check, which will cause the process to crash. Additionally, even with a valid envelope, if it contains no items, the event[1] array will be empty. The subsequent access event[1][0][0].type will then cause a crash due to an out-of-bounds access. Both scenarios, a malformed header or a valid but empty envelope, will lead to a server crash.

  • Suggested fix: Before destructuring or accessing properties, check if the return value of envelope.getParsedEnvelope() is null. Also, add a check to ensure the event[1] array (the envelope items) is not empty before attempting to access its elements. A guard clause like if (!envelope || !event || event[1].length === 0) { return; } would prevent both crashes.
    severity: 0.9, confidence: 0.95

Did we get this right? 👍 / 👎 to inform future reviews.

`);
process.exit(0);
};

let runServer = true;
const args = parseCLIArgs();
let stdioMCP = false;

if (args.help) {
runServer = false;
printHelp();
}

await setupSidecar({
...args,
isStandalone: true,
let onEnvelope: ((envelope: ParsedEnvelope["envelope"]) => void) | undefined = undefined;
const NAME_TO_TYPE_MAPPING: Record<string, string[]> = Object.freeze({
traces: ["transaction", "span"],
profiles: ["profile"],
logs: ["log"],
attachments: ["attachment"],
errors: ["event"],
sessions: ["session"],
replays: ["replay_video"],
// client_report
});
const EVERYTHING_MAGIC_WORDS = new Set(["everything", "all", "*"]);
export const SUPPORTED_ARGS = new Set([...Object.keys(NAME_TO_TYPE_MAPPING), ...EVERYTHING_MAGIC_WORDS]);

const cmd = args._positionals[0];
switch (cmd) {
case "help":
printHelp();
runServer = false;
break;
case "mcp":
stdioMCP = true;
break;
case "run":
// do crazy stuff
break;
case undefined:
case "":
break;
default: {
if (args._positionals.length > 1) {
console.error("Error: Too many positional arguments.");
printHelp();
}
const eventTypes = cmd.toLowerCase().split(/\s*[,+]\s*/gi);
for (const eventType of eventTypes) {
if (!SUPPORTED_ARGS.has(eventType)) {
console.error(`Error: Unsupported argument "${eventType}".`);
console.error(`Supported arguments are: ${[...SUPPORTED_ARGS].join(", ")}`);
process.exit(1);
}
}

if (eventTypes.some(type => EVERYTHING_MAGIC_WORDS.has(type))) {
onEnvelope = displayEnvelope;
} else {
const types = new Set([...eventTypes.flatMap(type => NAME_TO_TYPE_MAPPING[type] || [])]);
onEnvelope = envelope => {
for (const [header] of envelope[1]) {
if (header.type && types.has(header.type)) {
displayEnvelope(envelope);
}
}
};
}

// try to connect to an already existing server first
try {
const client = await connectUpstream(args.port);
runServer = false;
client.addEventListener(SENTRY_CONTENT_TYPE, event => onEnvelope!(JSON.parse(event.data)));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Callback Type Mismatch and Safety Issues

The onEnvelope callback has a few issues. It's called with an EventContainer in the stream route but expects a ParsedEnvelope["envelope"], causing runtime errors. In server.ts, the non-null assertion on onEnvelope is unsafe as it can be undefined based on CLI arguments, also leading to runtime errors. Additionally, when filtering, the callback displays the same envelope multiple times if it has several matching items.

Additional Locations (1)

Fix in Cursor Fix in Web

} catch (err) {
// if we fail, fine then we'll start our own
if (err instanceof Error && !err.message?.includes(args.port.toString())) {
captureException(err);
console.error("Error when trying to connect to upstream sidecar:", err);
process.exit(1);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Event Parsing Fails; Error Handling Inverted

The EventContainer constructor receives event.data as a string instead of a Buffer when connecting to an upstream sidecar, preventing correct envelope parsing. Additionally, the connectUpstream error handling is inverted: connection errors mentioning the port are ignored, preventing fallback, while other errors cause an unnecessary process exit.

Fix in Cursor Fix in Web

}
}

if (runServer) {
await setupSidecar({
...args,
stdioMCP,
onEnvelope,
isStandalone: true,
});
}
1 change: 1 addition & 0 deletions packages/sidecar/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export const DEFAULT_PORT = 8969;
export const SERVER_IDENTIFIER = "spotlight-by-sentry";
export const CONTEXT_LINES_ENDPOINT = "/contextlines";
export const RAW_TYPES = new Set(["attachment", "replay_video", "statsd"]);
export const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope";
10 changes: 5 additions & 5 deletions packages/sidecar/src/format/errors.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import type { ErrorEvent } from "@sentry/core";
import type { z } from "zod";
import { isErrorEvent } from "~/parser/index.js";
import { isErrorEvent, type SentryEvent } from "~/parser/index.js";
import type { EventContainer } from "~/utils/index.js";
import { formatEventOutput } from "./event.js";
import type { ErrorEventSchema } from "./schema.js";

export async function formatErrorEnvelope(container: EventContainer) {
export function formatErrorEnvelope(container: EventContainer) {
const processedEnvelope = container.getParsedEnvelope();

const {
event: [, items],
envelope: [, items],
} = processedEnvelope;

const formatted: string[] = [];
for (const item of items) {
const [{ type }, payload] = item;

if (type === "event" && isErrorEvent(payload)) {
formatted.push(formatEventOutput(processErrorEvent(payload)));
if (type === "event" && isErrorEvent(payload as SentryEvent)) {
formatted.push(formatEventOutput(processErrorEvent(payload as ErrorEvent)));
}
}

Expand Down
39 changes: 39 additions & 0 deletions packages/sidecar/src/format/general.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { ErrorEvent } from "@sentry/core";
import {
isErrorEvent,
isLogEvent,
type SentryLogEvent,
type ParsedEnvelope,
type SentryEvent,
} from "~/parser/index.js";
import { processErrorEvent } from "./errors.js";
import { formatEventOutput } from "./event.js";
import { processLogEvent } from "./logs.js";

export const eventHandlers = {
error: (payload: ErrorEvent) => formatEventOutput(processErrorEvent(payload)),
log: (payload: SentryLogEvent) => {
const content: string[] = [];
for (const log of payload.items) {
content.push(processLogEvent(log));
}
return content;
},
};

export function formatEnvelope(envelope: ParsedEnvelope["envelope"]): string[] {
const [, items] = envelope;

const formatted: string[] = [];
for (const item of items) {
const [{ type }, payload] = item;

if (type === "event" && isErrorEvent(payload as SentryEvent)) {
formatted.push(eventHandlers.error(payload as ErrorEvent));
} else if (type === "log" && isLogEvent(payload as SentryEvent)) {
formatted.push(...eventHandlers.log(payload as SentryLogEvent));
}
}

return formatted;
}
1 change: 1 addition & 0 deletions packages/sidecar/src/format/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./errors.js";
export * from "./logs.js";
export * from "./traces.js";
export * from "./event.js";
export * from "./general.js";
10 changes: 5 additions & 5 deletions packages/sidecar/src/format/logs.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import type { SerializedLog } from "@sentry/core";
import { isLogEvent } from "~/parser/index.js";
import { isLogEvent, type SentryLogEvent, type SentryEvent } from "~/parser/index.js";
import type { EventContainer } from "~/utils/index.js";

export async function formatLogEnvelope(container: EventContainer) {
export function formatLogEnvelope(container: EventContainer) {
const parsedEnvelope = container.getParsedEnvelope();

const {
event: [, items],
envelope: [, items],
} = parsedEnvelope;

const formatted: string[] = [];
for (const item of items) {
const [{ type }, payload] = item;

if (type === "log" && isLogEvent(payload)) {
for (const log of payload.items) {
if (type === "log" && isLogEvent(payload as SentryEvent)) {
for (const log of (payload as SentryLogEvent).items) {
formatted.push(processLogEvent(log));
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/sidecar/src/format/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ function extractTraceEventsFromContainer(container: EventContainer): TraceEvent[
data: container.getData(),
});

const [, items] = parsed.event;
const [, items] = parsed!.envelope;

for (const item of items) {
const [{ type }, payload] = item;
Expand Down
Loading
Loading