Skip to content

Commit 78931db

Browse files
committed
fix: address final review findings + add nice-to-haves
Review fixes: - Add stack frame info (filename, lineno, colno, function) to JSON error output for AI agent consumption - Add JSDoc to formatItemJson documenting sanitize design decision - Fix docs: SENTRY_TRACES_SAMPLE_RATE is '1 (unless already set)' - Fix ENOENT test to assert error message pattern, not just defined - Add comprehensive JSON format tests (error, transaction, log, source) Nice-to-haves: - Add startup banner with ingest URL, SSE endpoint, and connection hints - Add --filter ai to match transactions with GenAI/MCP OTel attributes
1 parent d3fa0e4 commit 78931db

5 files changed

Lines changed: 221 additions & 25 deletions

File tree

docs/src/fragments/commands/local.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Env vars injected into the child process:
3434
|----------|-------|
3535
| `SENTRY_SPOTLIGHT` | `http://localhost:<port>/stream` |
3636
| `NEXT_PUBLIC_SENTRY_SPOTLIGHT` | `http://localhost:<port>/stream` |
37-
| `SENTRY_TRACES_SAMPLE_RATE` | `1` |
37+
| `SENTRY_TRACES_SAMPLE_RATE` | `1` (unless already set) |
3838

3939
## Endpoints
4040

src/commands/local/server.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -590,10 +590,10 @@ function processSSEEvent(
590590
];
591591
const [header, items] = envelope;
592592
for (const [itemHeader, itemPayload] of items) {
593-
if (!isItemIncluded(itemHeader.type, activeFilters)) {
593+
const payload = itemPayload as Record<string, unknown>;
594+
if (!isItemIncluded(itemHeader.type, activeFilters, payload)) {
594595
continue;
595596
}
596-
const payload = itemPayload as Record<string, unknown>;
597597
const lines = useJson
598598
? formatItemJson(itemHeader.type, payload, header)
599599
: formatItem(
@@ -647,7 +647,7 @@ export const serverCommand = buildCommand({
647647
kind: "parsed",
648648
parse: parseFilter,
649649
brief:
650-
"Only show items of this type (repeatable: error, transaction, log)",
650+
"Only show items of this type (repeatable: error, transaction, log, ai)",
651651
variadic: true,
652652
optional: true,
653653
},
@@ -730,10 +730,23 @@ export const serverCommand = buildCommand({
730730
);
731731

732732
const listenUrl = `http://${flags.host}:${boundPort}`;
733-
logger.info(`Listening on ${bold(listenUrl)}`);
733+
logger.info("Sentry Local Dev Server");
734+
logger.info(` Ingest: ${bold(`${listenUrl}/stream`)}`);
735+
logger.info(` Events: ${bold(`${listenUrl}/stream`)} (SSE)`);
736+
logger.info("");
737+
logger.info(
738+
` Set ${bold("SENTRY_SPOTLIGHT")}=${listenUrl}/stream in your app`
739+
);
740+
logger.info(
741+
` Or run: ${bold(`sentry local run -p ${boundPort} -- <your-command>`)}`
742+
);
734743
if (activeFilters.size > 0) {
735-
logger.info(`Filtering: ${[...activeFilters].join(", ")}`);
744+
logger.info(` Filtering: ${[...activeFilters].join(", ")}`);
745+
}
746+
if (useJson) {
747+
logger.info(" Output: JSON (NDJSON)");
736748
}
749+
logger.info("");
737750
logger.info("Press Ctrl-C to stop.");
738751

739752
await waitForShutdown(server);

src/lib/formatters/local.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const FORMAT_VALUES = ["human", "json"] as const;
3333
export type FormatValue = (typeof FORMAT_VALUES)[number];
3434

3535
/** Envelope item categories that can be filtered via `--filter`. */
36-
export const FILTER_VALUES = ["error", "transaction", "log"] as const;
36+
export const FILTER_VALUES = ["error", "transaction", "log", "ai"] as const;
3737
export type FilterValue = (typeof FILTER_VALUES)[number];
3838

3939
/** Format a local timestamp as HH:MM:SS from a Sentry timestamp. */
@@ -339,20 +339,33 @@ export function resolveUnparseableLabel(container: {
339339
return ct === "application/x-sentry-envelope" ? "envelope" : ct;
340340
}
341341

342-
/** Format an error item as a JSON object. */
342+
/** Format an error item as a JSON object, including the best stack frame. */
343343
function formatErrorJson(
344344
payload: Record<string, unknown>,
345345
header: Record<string, unknown>
346346
): string {
347347
const exception = payload.exception as
348-
| { values?: { type?: string; value?: string }[] }
348+
| {
349+
values?: {
350+
type?: string;
351+
value?: string;
352+
stacktrace?: { frames?: StackFrame[] };
353+
}[];
354+
}
349355
| undefined;
350356
const first = exception?.values?.at(-1);
357+
const frame =
358+
first?.stacktrace?.frames?.find((f) => f.in_app) ??
359+
first?.stacktrace?.frames?.at(-1);
351360
return JSON.stringify({
352361
type: "error",
353362
timestamp: payload.timestamp,
354363
error_type: first?.type ?? "Error",
355364
message: first?.value ?? payload.message ?? "Unknown error",
365+
filename: frame?.filename,
366+
lineno: frame?.lineno,
367+
colno: frame?.colno,
368+
function: frame?.function,
356369
source: inferSourceName(header),
357370
});
358371
}
@@ -427,6 +440,12 @@ function formatLogJson(
427440
* Produces a compact JSON object per item with `type`, `timestamp`,
428441
* and item-specific fields. Designed for machine consumption by AI
429442
* coding agents and automation tools.
443+
*
444+
* Unlike the human formatters, JSON output does NOT call `sanitize()` on
445+
* envelope data. This is intentional: `JSON.stringify()` escapes all
446+
* control characters to `\uXXXX` notation, making the output safe for
447+
* terminal display and downstream JSON parsers. Raw values are preserved
448+
* so consumers get the original data without lossy stripping.
430449
*/
431450
export function formatItemJson(
432451
itemType: string | undefined,
@@ -485,16 +504,31 @@ export function formatItem(
485504
return [formatFallbackLine(fallbackLabel)];
486505
}
487506

488-
/** Check whether an item should be shown given active filters. */
507+
/**
508+
* Check whether an item should be shown given active filters.
509+
*
510+
* When `payload` is provided and the `ai` filter is active, transactions
511+
* are checked for GenAI/MCP OTel attributes.
512+
*/
489513
export function isItemIncluded(
490514
itemType: string | undefined,
491-
activeFilters: ReadonlySet<FilterValue>
515+
activeFilters: ReadonlySet<FilterValue>,
516+
payload?: Record<string, unknown>
492517
): boolean {
493518
if (activeFilters.size === 0) {
494519
return true;
495520
}
496521
const category = itemTypeToFilterCategory(itemType);
497-
return category !== undefined && activeFilters.has(category);
522+
if (category !== undefined && activeFilters.has(category)) {
523+
return true;
524+
}
525+
// The "ai" filter matches transactions with GenAI or MCP attributes.
526+
if (activeFilters.has("ai") && itemType === "transaction" && payload) {
527+
const attrs = mergeTransactionAttributes(payload);
528+
const op = inferSemanticOp(attrs);
529+
return op === "gen_ai" || op === "mcp";
530+
}
531+
return false;
498532
}
499533

500534
/**
@@ -521,16 +555,11 @@ export function formatEnvelopeLinesJson(
521555
const [header, items] = parsed.envelope;
522556
const lines: string[] = [];
523557
for (const [itemHeader, itemPayload] of items) {
524-
if (!isItemIncluded(itemHeader.type, activeFilters)) {
558+
const payload = itemPayload as Record<string, unknown>;
559+
if (!isItemIncluded(itemHeader.type, activeFilters, payload)) {
525560
continue;
526561
}
527-
lines.push(
528-
...formatItemJson(
529-
itemHeader.type,
530-
itemPayload as Record<string, unknown>,
531-
header
532-
)
533-
);
562+
lines.push(...formatItemJson(itemHeader.type, payload, header));
534563
}
535564
return lines;
536565
}
@@ -563,13 +592,14 @@ export function formatEnvelopeLines(
563592
const [header, items] = parsed.envelope;
564593
const lines: string[] = [];
565594
for (const [itemHeader, itemPayload] of items) {
566-
if (!isItemIncluded(itemHeader.type, activeFilters)) {
595+
const payload = itemPayload as Record<string, unknown>;
596+
if (!isItemIncluded(itemHeader.type, activeFilters, payload)) {
567597
continue;
568598
}
569599
lines.push(
570600
...formatItem(
571601
itemHeader.type,
572-
itemPayload as Record<string, unknown>,
602+
payload,
573603
header,
574604
itemHeader.type ?? container.getContentType()
575605
)

test/commands/local/run.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe("sentry local run", () => {
9797
}
9898
});
9999

100-
test("throws CliError on ENOENT (command not found)", async () => {
100+
test("throws on ENOENT (command not found)", async () => {
101101
const func = (await runCommand.loader()) as unknown as RunFunc;
102102
const ctx = makeContext();
103103

@@ -109,8 +109,10 @@ describe("sentry local run", () => {
109109
);
110110
expect.unreachable("should have thrown");
111111
} catch (err) {
112-
// Either CliError from spawn failure or error propagation
113-
expect(err).toBeDefined();
112+
expect(err).toBeInstanceOf(Error);
113+
expect((err as Error).message).toMatch(
114+
/exited with code|Failed to start|ENOENT|spawn/i
115+
);
114116
}
115117
});
116118

test/lib/formatters/local.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FilterValue } from "../../../src/lib/formatters/local.js";
1111
import {
1212
formatErrorItem,
1313
formatItem,
14+
formatItemJson,
1415
formatSingleLog,
1516
formatTime,
1617
formatTransactionItem,
@@ -545,3 +546,153 @@ describe("inferSource", () => {
545546
expect(result).toContain("[SERVER]");
546547
});
547548
});
549+
550+
describe("formatItemJson", () => {
551+
const serverHeader = { sdk: { name: "sentry.node" } };
552+
const browserHeader = { sdk: { name: "sentry.javascript.browser" } };
553+
554+
test("formats error with exception and stack frame", () => {
555+
const event = {
556+
timestamp: 1_700_000_000,
557+
exception: {
558+
values: [
559+
{
560+
type: "TypeError",
561+
value: "x is not a function",
562+
stacktrace: {
563+
frames: [
564+
{
565+
filename: "src/handler.ts",
566+
lineno: 42,
567+
colno: 5,
568+
function: "handleRequest",
569+
in_app: true,
570+
},
571+
],
572+
},
573+
},
574+
],
575+
},
576+
};
577+
const lines = formatItemJson("error", event, serverHeader);
578+
expect(lines).toHaveLength(1);
579+
const parsed = JSON.parse(lines[0]);
580+
expect(parsed.type).toBe("error");
581+
expect(parsed.error_type).toBe("TypeError");
582+
expect(parsed.message).toBe("x is not a function");
583+
expect(parsed.filename).toBe("src/handler.ts");
584+
expect(parsed.lineno).toBe(42);
585+
expect(parsed.colno).toBe(5);
586+
expect(parsed.function).toBe("handleRequest");
587+
expect(parsed.source).toBe("server");
588+
});
589+
590+
test("formats error without stack frame", () => {
591+
const event = {
592+
timestamp: 1_700_000_000,
593+
message: "Something went wrong",
594+
};
595+
const lines = formatItemJson("error", event, serverHeader);
596+
const parsed = JSON.parse(lines[0]);
597+
expect(parsed.error_type).toBe("Error");
598+
expect(parsed.message).toBe("Something went wrong");
599+
expect(parsed.filename).toBeUndefined();
600+
});
601+
602+
test("formats event type as error", () => {
603+
const event = { timestamp: 1_700_000_000, message: "boom" };
604+
const lines = formatItemJson("event", event, serverHeader);
605+
const parsed = JSON.parse(lines[0]);
606+
expect(parsed.type).toBe("error");
607+
});
608+
609+
test("formats transaction with semantic attributes", () => {
610+
const event = {
611+
timestamp: 1_700_000_002,
612+
start_timestamp: 1_700_000_000,
613+
transaction: "process_request",
614+
contexts: {
615+
trace: {
616+
op: "ai.pipeline",
617+
data: {
618+
"gen_ai.operation.name": "chat",
619+
"gen_ai.request.model": "gpt-4o",
620+
},
621+
},
622+
},
623+
spans: [{}, {}, {}],
624+
};
625+
const lines = formatItemJson("transaction", event, serverHeader);
626+
expect(lines).toHaveLength(1);
627+
const parsed = JSON.parse(lines[0]);
628+
expect(parsed.type).toBe("transaction");
629+
expect(parsed.op).toBe("gen_ai");
630+
expect(parsed.label).toBe("chat gpt-4o");
631+
expect(parsed.duration_ms).toBe(2000);
632+
expect(parsed.span_count).toBe(3);
633+
expect(parsed.source).toBe("server");
634+
});
635+
636+
test("formats transaction without semantic attributes", () => {
637+
const event = {
638+
timestamp: 1_700_000_001,
639+
start_timestamp: 1_700_000_000,
640+
transaction: "GET /api/users",
641+
contexts: { trace: { op: "http.server" } },
642+
};
643+
const lines = formatItemJson("transaction", event, serverHeader);
644+
const parsed = JSON.parse(lines[0]);
645+
expect(parsed.label).toBe("GET /api/users");
646+
expect(parsed.op).toBe("http.server");
647+
});
648+
649+
test("formats log entries", () => {
650+
const event = {
651+
items: [
652+
{
653+
level: "info",
654+
body: "User logged in",
655+
timestamp: 1_700_000_000,
656+
attributes: {
657+
"sentry.sdk.name": { value: "node" },
658+
user_id: { value: 42 },
659+
},
660+
},
661+
{ level: "debug", body: "Cache hit" },
662+
],
663+
};
664+
const lines = formatItemJson("log", event, serverHeader);
665+
expect(lines).toHaveLength(2);
666+
667+
const first = JSON.parse(lines[0]);
668+
expect(first.type).toBe("log");
669+
expect(first.level).toBe("info");
670+
expect(first.message).toBe("User logged in");
671+
expect(first.attributes).toEqual({ user_id: 42 });
672+
expect(first.attributes["sentry.sdk.name"]).toBeUndefined();
673+
674+
const second = JSON.parse(lines[1]);
675+
expect(second.level).toBe("debug");
676+
expect(second.message).toBe("Cache hit");
677+
});
678+
679+
test("returns empty for log with no items", () => {
680+
const lines = formatItemJson("log", { items: [] }, serverHeader);
681+
expect(lines).toHaveLength(0);
682+
});
683+
684+
test("formats unknown item types", () => {
685+
const event = { timestamp: 1_700_000_000 };
686+
const lines = formatItemJson("attachment", event, serverHeader);
687+
expect(lines).toHaveLength(1);
688+
const parsed = JSON.parse(lines[0]);
689+
expect(parsed.type).toBe("attachment");
690+
});
691+
692+
test("detects browser source in JSON", () => {
693+
const event = { timestamp: 1_700_000_000, message: "error" };
694+
const lines = formatItemJson("error", event, browserHeader);
695+
const parsed = JSON.parse(lines[0]);
696+
expect(parsed.source).toBe("browser");
697+
});
698+
});

0 commit comments

Comments
 (0)