Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
125 changes: 119 additions & 6 deletions packages/sidecar/server.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,42 @@
#!/usr/bin/env node
import { EventSource } from "eventsource";
import { formatEnvelope } from "./src/format";
import { parseCLIArgs, setupSidecar } from "./src/main.js";
import { EventContainer } from "./src/utils/eventContainer.js";
import { SENTRY_CONTENT_TYPE } from "./src/constants.js";
import { captureException } from "@sentry/core";
import { exit } from "process";

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: EventContainer) {
const { event } = envelope.getParsedEnvelope();
console.log(`${event[0].event_id} | ${event[1][0][0].type} | ${event[0].sdk?.name}\n\n`);
Copy link

Choose a reason for hiding this comment

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

Bug: Envelope Parsing Error: Missing Existence Checks

Accessing event[1][0][0].type lacks checks for event[1] or event[1][0] being undefined or empty. This can lead to runtime errors if the ParsedEnvelope structure is malformed.

Fix in Cursor Fix in Web

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 +46,97 @@ 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: EventContainer) => 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 => {
const { event } = envelope.getParsedEnvelope();
for (const [header] of event[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!(new EventContainer(SENTRY_CONTENT_TYPE, event.data)),
);
} 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";
2 changes: 1 addition & 1 deletion packages/sidecar/src/format/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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 {
Expand Down
37 changes: 37 additions & 0 deletions packages/sidecar/src/format/general.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isErrorEvent, isLogEvent } from "~/parser/index.js";
import type { EventContainer } from "~/utils/index.js";
import { processErrorEvent } from "./errors.js";
import { formatEventOutput } from "./event.js";
import { processLogEvent } from "./logs.js";

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

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

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

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

if (type === "event" && isErrorEvent(payload)) {
formatted.push(eventHanlers.error(payload));
} else if (type === "log" && isLogEvent(payload)) {
formatted.push(...eventHanlers.log(payload));
}
}

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";
2 changes: 1 addition & 1 deletion packages/sidecar/src/format/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SerializedLog } from "@sentry/core";
import { isLogEvent } 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 {
Expand Down
Loading
Loading