From c40ad611af085806f08b00a63fe3ef6c3d0b8b6f Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 23 Jun 2026 19:53:15 +0000 Subject: [PATCH 1/3] feat(local): surface trace IDs, attribute table, and fix AI filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve `sentry local serve` output so events are easier to correlate and scan, and make `-f ai` catch GenAI activity on child spans. - Surface a short `[trace:…]` token on error, transaction, and log tail lines, and add `trace_id` to JSON output (logs read it from the `sentry.trace.trace_id` attribute). Lets agents group related events. - Add `--attributes`/`-a` to render an indented, aligned attribute table grouped into user-custom vs SDK-default sections, sorted by key. JSON output exposes the same split under `attributes.{user,sdk}`. - Fix `-f ai` missing Vercel-AI-style transactions: `gen_ai.*` attributes live on child spans of the HTTP handler, not the trace root, so the filter now scans child span attributes too. - Render user log attributes alphabetically so repeated lines align. Fixes #1132 --- .../skills/sentry-cli/references/local.md | 1 + src/commands/local/server.ts | 38 +- src/lib/formatters/local.ts | 329 +++++++++++++++++- src/lib/formatters/semantic-display.ts | 26 ++ test/lib/formatters/local.test.ts | 294 ++++++++++++++++ test/lib/formatters/semantic-display.test.ts | 28 ++ 6 files changed, 693 insertions(+), 23 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/local.md b/plugins/sentry-cli/skills/sentry-cli/references/local.md index c37110a25..47d3f5c21 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -21,6 +21,7 @@ Start the local dev server and tail events - `-q, --quiet - Suppress per-envelope tail output` - `-f, --filter ... - Only show items of this type (repeatable: error, transaction, log, ai)` - `-F, --format - Output format: human (default) or json (NDJSON) - (default: "human")` +- `-a, --attributes - Show a grouped attribute table (user vs SDK) under each transaction` ### `sentry local run ` diff --git a/src/commands/local/server.ts b/src/commands/local/server.ts index 2057fd917..0aa2ba67c 100644 --- a/src/commands/local/server.ts +++ b/src/commands/local/server.ts @@ -86,6 +86,7 @@ type LocalFlags = { readonly quiet: boolean; readonly filter: FilterValue[]; readonly format: FormatValue; + readonly attributes: boolean; }; /** @@ -407,6 +408,7 @@ type ConsumeSSEOptions = { signal: AbortSignal; quiet?: boolean; useJson?: boolean; + showAttributes?: boolean; }; /** Check whether an error is an abort signal. */ @@ -438,7 +440,14 @@ async function sleepUnlessAborted( * using `Last-Event-ID` to resume from where the stream left off. */ async function consumeSSE(opts: ConsumeSSEOptions): Promise { - const { url, activeFilters, signal, quiet = false, useJson = false } = opts; + const { + url, + activeFilters, + signal, + quiet = false, + useJson = false, + showAttributes = false, + } = opts; let lastEventId: string | undefined; let retries = 0; let retryDelay = SSE_INITIAL_RETRY_MS; @@ -451,6 +460,7 @@ async function consumeSSE(opts: ConsumeSSEOptions): Promise { signal, quiet, useJson, + showAttributes, lastEventId, onId: (id) => { lastEventId = id; @@ -528,6 +538,7 @@ type ConsumeSSEOnceOptions = { signal: AbortSignal; quiet: boolean; useJson: boolean; + showAttributes: boolean; lastEventId: string | undefined; onId: (id: string) => void; /** Called when the HTTP response is received (200 OK with body). */ @@ -546,6 +557,7 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { signal, quiet, useJson, + showAttributes, lastEventId, onId, onConnected, @@ -582,7 +594,7 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { onId(id); } if (type === SENTRY_CONTENT_TYPE) { - processSSEEvent(data, activeFilters, useJson); + processSSEEvent(data, activeFilters, useJson, showAttributes); } }; @@ -606,7 +618,8 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { function processSSEEvent( data: string, activeFilters: ReadonlySet, - useJson = false + useJson = false, + showAttributes = false ): void { try { const envelope = JSON.parse(data) as [ @@ -620,12 +633,13 @@ function processSSEEvent( continue; } const lines = useJson - ? formatItemJson(itemHeader.type, payload, header) + ? formatItemJson(itemHeader.type, payload, header, showAttributes) : formatItem( itemHeader.type, payload, header, - itemHeader.type ?? "envelope" + itemHeader.type ?? "envelope", + showAttributes ); for (const line of lines) { logger.log(line); @@ -682,6 +696,12 @@ export const serverCommand = buildCommand({ brief: "Output format: human (default) or json (NDJSON)", default: "human", }, + attributes: { + kind: "boolean", + brief: + "Show a grouped attribute table (user vs SDK) under each transaction", + default: false, + }, }, aliases: { p: "port", @@ -689,6 +709,7 @@ export const serverCommand = buildCommand({ q: "quiet", f: "filter", F: "format", + a: "attributes", }, }, auth: false, @@ -715,6 +736,7 @@ export const serverCommand = buildCommand({ signal: ac.signal, quiet: flags.quiet, useJson: flags.format === "json", + showAttributes: flags.attributes, }); } catch (err: unknown) { if (!(err instanceof DOMException && err.name === "AbortError")) { @@ -735,7 +757,11 @@ export const serverCommand = buildCommand({ const formatFn = useJson ? formatEnvelopeLinesJson : formatEnvelopeLines; buffer.subscribe((container) => { try { - for (const line of formatFn(container, activeFilters)) { + for (const line of formatFn( + container, + activeFilters, + flags.attributes + )) { logger.log(line); } } catch (err) { diff --git a/src/lib/formatters/local.ts b/src/lib/formatters/local.ts index 8ccce8d73..d86b8ac3e 100644 --- a/src/lib/formatters/local.ts +++ b/src/lib/formatters/local.ts @@ -1,13 +1,18 @@ /** Tail formatters for the local dev server. */ +import { logger } from "../logger.js"; import { blue, bold, cyan, green, muted, red, yellow } from "./colors.js"; import { stripAnsi } from "./plain-detect.js"; +import type { AttributeSource } from "./semantic-display.js"; import { + collectSpanAttributes, formatSemanticSpanDisplay, inferSemanticOp, mergeTransactionAttributes, } from "./semantic-display.js"; +const log = logger.withTag("local-formatter"); + /** * Characters unsafe for JSON terminal display: C1 control characters * (U+0080–U+009F, e.g. CSI=U+009B) and Unicode bidirectional overrides. @@ -73,6 +78,47 @@ export function formatTime(timestamp?: number | string): string { return date.toLocaleTimeString("en-US", { hour12: false }); } +/** 32-char lowercase-hex trace ID, as emitted by Sentry SDKs. */ +const TRACE_ID_RE = /^[0-9a-f]{32}$/i; + +/** + * Number of leading hex characters shown for a trace ID in human output. + * Eight characters is enough to visually group spans of the same trace while + * keeping the tail line compact; the full ID is preserved in JSON output. + */ +const TRACE_ID_SHORT_LEN = 8; + +/** + * Extract the full trace ID from an event item. + * + * The trace ID lives in `contexts.trace.trace_id` for errors and transactions. + * Returns undefined when absent or malformed so callers can omit the token + * rather than render garbage. + */ +export function extractTraceId( + event: Record +): string | undefined { + const trace = (event.contexts as Record | undefined) + ?.trace as { trace_id?: unknown } | undefined; + const traceId = trace?.trace_id; + if (typeof traceId === "string" && TRACE_ID_RE.test(traceId)) { + return traceId.toLowerCase(); + } + return; +} + +/** + * Build a muted, bracketed short-trace-ID token for tail output, e.g. + * ` [trace:1a2b3c4d]`. Returns an empty string when no valid trace ID exists. + */ +export function formatTraceIdHint(event: Record): string { + const traceId = extractTraceId(event); + if (!traceId) { + return ""; + } + return ` ${muted(`[trace:${traceId.slice(0, TRACE_ID_SHORT_LEN)}]`)}`; +} + /** Level → color map for tail output. */ const LEVEL_COLORS: Record string> = { error: (s) => red(bold(s)), @@ -202,6 +248,8 @@ export function formatErrorItem( msg += formatFrameHint(frames); } + msg += formatTraceIdHint(event); + const ts = formatTime(event.timestamp as number | undefined); return `${muted(ts)} ${formatType("error")} ${inferSource(header)} ${msg}`; } @@ -265,10 +313,149 @@ export function formatTransactionItem( msg += ` ${muted(`[${spans.length} span${spans.length === 1 ? "" : "s"}]`)}`; } + msg += formatTraceIdHint(event); + const ts = formatTime(event.timestamp as number | undefined); return `${muted(ts)} ${formatType("trace")} ${inferSource(header)} ${msg}`; } +/** + * OTel/Sentry semantic-convention attribute prefixes considered "SDK-default". + * + * Attributes under these namespaces are emitted by the SDK or by standard + * instrumentation (HTTP, DB, GenAI, messaging, …) rather than supplied by the + * application. Grouping them separately keeps user-custom attributes — the ones + * a developer is usually debugging — visually distinct and easy to scan. + */ +const SDK_ATTRIBUTE_PREFIXES = [ + "sentry.", + "gen_ai.", + "ai.", + "mcp.", + "db.", + "http.", + "url.", + "server.", + "client.", + "network.", + "rpc.", + "messaging.", + "faas.", + "cloud.", + "otel.", + "thread.", + "code.", + "exception.", + "user_agent.", +]; + +/** Two-space indent prefix for nested attribute-table lines. */ +const ATTR_INDENT = " "; + +/** + * Whether an attribute key belongs to the SDK-default group rather than being + * a user-custom attribute. Matching is case-insensitive against the known + * semantic-convention prefixes in {@link SDK_ATTRIBUTE_PREFIXES}. + */ +function isSdkAttribute(key: string): boolean { + const lower = key.toLowerCase(); + return SDK_ATTRIBUTE_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +/** + * Render a primitive attribute value for the table. Objects and arrays are + * JSON-encoded; everything else is stringified. The result is sanitized so + * untrusted envelope data can't inject terminal escapes. + */ +function formatAttrValue(value: unknown): string { + if (value === null) { + return "null"; + } + if (typeof value === "object") { + try { + return sanitize(JSON.stringify(value)); + } catch (err) { + log.debug("Failed to JSON-encode attribute value", err); + return sanitize(String(value)); + } + } + return sanitize(String(value)); +} + +/** + * Merge transaction-root attributes with all child-span attributes into a + * single flat map. Root attributes take precedence on key collision because + * they describe the transaction as a whole; span-level duplicates are + * lower-signal for a top-level scan. + */ +function collectAllAttributes( + event: Record +): Map { + const merged = new Map(); + const sources: AttributeSource[] = [ + ...collectSpanAttributes(event), + mergeTransactionAttributes(event), + ]; + for (const source of sources) { + for (const [key, value] of Object.entries(source)) { + if (value !== undefined) { + merged.set(key, value); + } + } + } + return merged; +} + +/** + * Build an aligned `key value` table for a group of attributes, sorted + * alphabetically by key. Keys are padded to a common width so values line up + * column-for-column. Returns an empty array when the group has no entries. + */ +function formatAttrGroup( + title: string, + entries: [string, unknown][] +): string[] { + if (entries.length === 0) { + return []; + } + const sorted = [...entries].sort(([a], [b]) => a.localeCompare(b)); + const keyWidth = Math.max(...sorted.map(([k]) => sanitize(k).length)); + const lines = [`${ATTR_INDENT}${muted(title)}`]; + for (const [key, value] of sorted) { + const safeKey = sanitize(key); + const padded = safeKey.padEnd(keyWidth); + lines.push( + `${ATTR_INDENT}${ATTR_INDENT}${cyan(padded)} ${formatAttrValue(value)}` + ); + } + return lines; +} + +/** + * Render a transaction's span/trace attributes as an indented, scannable + * table grouped into SDK-default vs user-custom sections. Within each group + * keys are sorted alphabetically and aligned. Returns an empty array when the + * transaction carries no attributes. + * + * Surfaced under `local serve --attributes`; correlate the rows with the + * one-liner above them via its `[trace:…]` token. + */ +export function formatAttributeTable(event: Record): string[] { + const merged = collectAllAttributes(event); + if (merged.size === 0) { + return []; + } + const sdk: [string, unknown][] = []; + const user: [string, unknown][] = []; + for (const entry of merged) { + (isSdkAttribute(entry[0]) ? sdk : user).push(entry); + } + return [ + ...formatAttrGroup("user attributes", user), + ...formatAttrGroup("sdk attributes", sdk), + ]; +} + /** Shape of a single log entry inside a log envelope item. */ export type LogEntry = { level?: string; @@ -277,7 +464,38 @@ export type LogEntry = { attributes?: Record; }; -/** Format one log entry into a colored tail line. */ +/** + * Whether a log attribute key is an SDK-default (`sentry.*`) attribute rather + * than a user-supplied one. SDK-default attributes are hidden from the tail + * line to reduce noise; their content (e.g. trace ID) is surfaced separately. + */ +function isUserLogAttribute(key: string): boolean { + return !key.startsWith("sentry."); +} + +/** + * Extract the trace ID from a log entry's attributes. + * + * Logs carry no `contexts.trace`; the trace ID lives in the SDK-default + * `sentry.trace.trace_id` attribute. Returns undefined when absent or + * malformed. + */ +function extractLogTraceId(logEntry: LogEntry): string | undefined { + const traceId = logEntry.attributes?.["sentry.trace.trace_id"]?.value; + if (typeof traceId === "string" && TRACE_ID_RE.test(traceId)) { + return traceId.toLowerCase(); + } + return; +} + +/** + * Format one log entry into a colored tail line. + * + * User-supplied attributes are rendered alphabetically by key so repeated log + * lines align column-for-column and are easy to scan. SDK-default `sentry.*` + * attributes are omitted from the inline list; the trace ID is surfaced as a + * compact `[trace:…]` token instead. + */ export function formatSingleLog(logEntry: LogEntry, source: string): string { const level = logEntry.level ?? "log"; let msg = sanitize(logEntry.body ?? ""); @@ -286,18 +504,24 @@ export function formatSingleLog(logEntry: LogEntry, source: string): string { const attrs = Object.entries(logEntry.attributes) .filter( ([k, v]) => - !k.startsWith("sentry.") && + isUserLogAttribute(k) && v !== null && v !== undefined && v.value !== null && v.value !== undefined ) + .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => muted(`[${sanitize(k)}=${sanitize(String(v.value))}]`)); if (attrs.length > 0) { msg += ` ${attrs.join(" ")}`; } } + const traceId = extractLogTraceId(logEntry); + if (traceId) { + msg += ` ${muted(`[trace:${traceId.slice(0, TRACE_ID_SHORT_LEN)}]`)}`; + } + const ts = formatTime(logEntry.timestamp); return `${muted(ts)} ${formatType(level)} ${source} ${msg}`; } @@ -386,6 +610,7 @@ function formatErrorJson( return JSON.stringify({ type: "error", timestamp: payload.timestamp, + trace_id: extractTraceId(payload), error_type: jsonSafe(first?.type) ?? "Error", message: jsonSafe(first?.value) ?? jsonSafe(payload.message) ?? "Unknown error", @@ -397,10 +622,40 @@ function formatErrorJson( }); } -/** Format a transaction item as a JSON object. */ +/** + * Build the `{ user, sdk }` attribute split for JSON output. Each side is a + * sorted object of stringified values, omitted (undefined) when empty so the + * envelope stays compact when `--attributes` isn't requested. + */ +function buildJsonAttributes(payload: Record): { + user?: Record; + sdk?: Record; +} { + const merged = collectAllAttributes(payload); + const user: Record = {}; + const sdk: Record = {}; + for (const [key, value] of [...merged].sort(([a], [b]) => + a.localeCompare(b) + )) { + const target = isSdkAttribute(key) ? sdk : user; + target[stripBidi(key)] = stripBidi(formatAttrValue(value)); + } + return { + user: Object.keys(user).length > 0 ? user : undefined, + sdk: Object.keys(sdk).length > 0 ? sdk : undefined, + }; +} + +/** + * Format a transaction item as a JSON object. + * + * When `includeAttributes` is true, a grouped `attributes` object (`user` / + * `sdk`) is added so automation can inspect the full span attribute bag. + */ function formatTransactionJson( payload: Record, - header: Record + header: Record, + includeAttributes = false ): string { const trace = (payload.contexts as Record | undefined) ?.trace as Record | undefined; @@ -418,6 +673,7 @@ function formatTransactionJson( return JSON.stringify({ type: "transaction", timestamp: payload.timestamp, + trace_id: extractTraceId(payload), op: inferSemanticOp(attrs) ?? trace?.op, label: stripBidi(semantic.label), metadata: @@ -427,6 +683,7 @@ function formatTransactionJson( duration_ms: durationMs, status: trace?.status, span_count: (payload.spans as unknown[] | undefined)?.length, + attributes: includeAttributes ? buildJsonAttributes(payload) : undefined, source: inferSourceName(header), }); } @@ -445,6 +702,7 @@ function formatLogJson( JSON.stringify({ type: "log", timestamp: entry.timestamp, + trace_id: extractLogTraceId(entry), level: entry.level ?? "log", message: stripBidi(entry.body ?? ""), attributes: entry.attributes @@ -452,7 +710,7 @@ function formatLogJson( Object.entries(entry.attributes) .filter( ([k, v]) => - !k.startsWith("sentry.") && + isUserLogAttribute(k) && v?.value !== null && v?.value !== undefined ) @@ -484,13 +742,14 @@ function formatLogJson( export function formatItemJson( itemType: string | undefined, payload: Record, - header: Record + header: Record, + showAttributes = false ): string[] { if (itemType && ERROR_TYPES.has(itemType)) { return [formatErrorJson(payload, header)]; } if (itemType === "transaction") { - return [formatTransactionJson(payload, header)]; + return [formatTransactionJson(payload, header, showAttributes)]; } if (itemType === "log") { return formatLogJson(payload, header); @@ -519,18 +778,29 @@ function inferSourceName(header: Record): string { return "server"; } -/** Format a single envelope item into one or more output lines. */ +/** + * Format a single envelope item into one or more output lines. + * + * When `showAttributes` is true, transaction items are followed by an indented + * attribute table (see {@link formatAttributeTable}). + */ +// biome-ignore lint/nursery/useMaxParams: established 4-param shape; showAttributes is a defaulted display toggle export function formatItem( itemType: string | undefined, payload: Record, header: Record, - fallbackLabel: string + fallbackLabel: string, + showAttributes = false ): string[] { if (itemType && ERROR_TYPES.has(itemType)) { return [formatErrorItem(payload, header)]; } if (itemType === "transaction") { - return [formatTransactionItem(payload, header)]; + const lines = [formatTransactionItem(payload, header)]; + if (showAttributes) { + lines.push(...formatAttributeTable(payload)); + } + return lines; } if (itemType === "log") { return formatLogItem(payload, header); @@ -558,9 +828,29 @@ export function isItemIncluded( } // The "ai" filter matches transactions with GenAI or MCP attributes. if (activeFilters.has("ai") && itemType === "transaction" && payload) { - const attrs = mergeTransactionAttributes(payload); - const op = inferSemanticOp(attrs); - return op === "gen_ai" || op === "mcp"; + return transactionHasAiActivity(payload); + } + return false; +} + +/** + * Whether a transaction carries GenAI or MCP activity. + * + * Checks the trace-root attributes first, then falls back to scanning child + * span attributes. The Vercel AI SDK and similar instrumentations attach + * `gen_ai.*` attributes to child spans of an HTTP handler transaction (e.g. + * `POST /api/ai/chat`), so root-only detection misses them. + */ +function transactionHasAiActivity(payload: Record): boolean { + const rootOp = inferSemanticOp(mergeTransactionAttributes(payload)); + if (rootOp === "gen_ai" || rootOp === "mcp") { + return true; + } + for (const spanAttrs of collectSpanAttributes(payload)) { + const op = inferSemanticOp(spanAttrs); + if (op === "gen_ai" || op === "mcp") { + return true; + } } return false; } @@ -579,7 +869,8 @@ export function formatEnvelopeLinesJson( getContentType: () => string; getEventTypes: () => string[] | null; }, - activeFilters: ReadonlySet + activeFilters: ReadonlySet, + showAttributes = false ): string[] { const parsed = container.getParsedEnvelope(); if (!parsed) { @@ -593,7 +884,9 @@ export function formatEnvelopeLinesJson( if (!isItemIncluded(itemHeader.type, activeFilters, payload)) { continue; } - lines.push(...formatItemJson(itemHeader.type, payload, header)); + lines.push( + ...formatItemJson(itemHeader.type, payload, header, showAttributes) + ); } return lines; } @@ -613,7 +906,8 @@ export function formatEnvelopeLines( getContentType: () => string; getEventTypes: () => string[] | null; }, - activeFilters: ReadonlySet + activeFilters: ReadonlySet, + showAttributes = false ): string[] { const parsed = container.getParsedEnvelope(); if (!parsed) { @@ -635,7 +929,8 @@ export function formatEnvelopeLines( itemHeader.type, payload, header, - itemHeader.type ?? container.getContentType() + itemHeader.type ?? container.getContentType(), + showAttributes ) ); } diff --git a/src/lib/formatters/semantic-display.ts b/src/lib/formatters/semantic-display.ts index 1d246070d..88228fbad 100644 --- a/src/lib/formatters/semantic-display.ts +++ b/src/lib/formatters/semantic-display.ts @@ -786,3 +786,29 @@ export function mergeTransactionAttributes( const data = trace?.data as Record | undefined; return data ?? {}; } + +/** + * Extract the `data` attribute object from each child span of a transaction. + * + * Child span attributes live in `span.data`. The Vercel AI SDK (and other + * instrumentations that wrap an HTTP handler) attach `gen_ai.*` attributes to + * child spans rather than the transaction root, so callers that need to detect + * AI activity must inspect these in addition to the trace-root attributes + * returned by {@link mergeTransactionAttributes}. + */ +export function collectSpanAttributes( + event: Record +): AttributeSource[] { + const spans = event.spans; + if (!Array.isArray(spans)) { + return []; + } + const result: AttributeSource[] = []; + for (const span of spans) { + const data = (span as Record | null)?.data; + if (data && typeof data === "object") { + result.push(data as AttributeSource); + } + } + return result; +} diff --git a/test/lib/formatters/local.test.ts b/test/lib/formatters/local.test.ts index 08da8de33..1741ca4a8 100644 --- a/test/lib/formatters/local.test.ts +++ b/test/lib/formatters/local.test.ts @@ -9,6 +9,8 @@ import { describe, expect, test } from "vitest"; import type { FilterValue } from "../../../src/lib/formatters/local.js"; import { + extractTraceId, + formatAttributeTable, formatErrorItem, formatItem, formatItemJson, @@ -133,6 +135,22 @@ describe("formatErrorItem", () => { const result = stripAnsi(formatErrorItem(event, serverHeader)); expect(result).toContain("[src/app.ts:10]"); }); + + test("surfaces a short trace ID when present", () => { + const event = { + timestamp: 1_700_000_000, + message: "boom", + contexts: { trace: { trace_id: "1a2b3c4d5e6f70819a2b3c4d5e6f7081" } }, + }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).toContain("[trace:1a2b3c4d]"); + }); + + test("omits the trace token when no trace ID", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const result = stripAnsi(formatErrorItem(event, serverHeader)); + expect(result).not.toContain("[trace:"); + }); }); describe("formatTransactionItem", () => { @@ -209,6 +227,22 @@ describe("formatTransactionItem", () => { expect(result).toContain("Transaction"); }); + test("surfaces a short trace ID when present", () => { + const event = { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /", + contexts: { + trace: { + op: "http.server", + trace_id: "deadbeefcafebabe0123456789abcdef", + }, + }, + }; + const result = stripAnsi(formatTransactionItem(event, browserHeader)); + expect(result).toContain("[trace:deadbeef]"); + }); + describe("semantic display from OTel attributes", () => { const serverHeader = { sdk: { name: "sentry.python" } }; @@ -379,6 +413,48 @@ describe("formatSingleLog", () => { const result = stripAnsi(formatSingleLog({}, "[SERVER] ")); expect(result).toContain("[LOG]"); }); + + test("renders user attributes in alphabetical order", () => { + const result = stripAnsi( + formatSingleLog( + { + level: "info", + body: "hello", + attributes: { + zeta: { value: 1 }, + alpha: { value: 2 }, + mid: { value: 3 }, + }, + }, + "[SERVER] " + ) + ); + const alphaIdx = result.indexOf("[alpha=2]"); + const midIdx = result.indexOf("[mid=3]"); + const zetaIdx = result.indexOf("[zeta=1]"); + expect(alphaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(midIdx); + expect(midIdx).toBeLessThan(zetaIdx); + }); + + test("surfaces trace ID from sentry.trace.trace_id attribute", () => { + const result = stripAnsi( + formatSingleLog( + { + level: "info", + body: "hello", + attributes: { + "sentry.trace.trace_id": { + value: "abcdef0123456789abcdef0123456789", + }, + }, + }, + "[SERVER] " + ) + ); + expect(result).toContain("[trace:abcdef01]"); + expect(result).not.toContain("sentry.trace.trace_id"); + }); }); describe("formatItem", () => { @@ -467,6 +543,83 @@ describe("isItemIncluded", () => { expect(isItemIncluded("attachment", filters)).toBe(false); expect(isItemIncluded(undefined, filters)).toBe(false); }); + + describe("ai filter", () => { + const ai = new Set(["ai"]); + + test("matches transaction with gen_ai attributes on the trace root", () => { + const payload = { + contexts: { + trace: { + op: "gen_ai.chat", + data: { "gen_ai.operation.name": "chat" }, + }, + }, + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches Vercel AI transaction with gen_ai on a child span", () => { + // The /api/ai/chat HTTP handler is the transaction root; the GenAI + // generation lives on a child span, where gen_ai.* attributes are set. + const payload = { + transaction: "POST /api/ai/chat", + contexts: { trace: { op: "http.server" } }, + spans: [ + { op: "http.server", data: { "http.request.method": "POST" } }, + { + op: "gen_ai.generate_text", + data: { + "gen_ai.operation.name": "chat", + "gen_ai.request.model": "gpt-4o", + }, + }, + ], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches transaction with mcp attributes on a child span", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "mcp.method.name": "tools/call" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("excludes a plain HTTP transaction with no AI spans", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "http.request.method": "GET" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(false); + }); + + test("excludes errors and logs", () => { + expect(isItemIncluded("error", ai, {})).toBe(false); + expect(isItemIncluded("log", ai, {})).toBe(false); + }); + }); +}); + +describe("extractTraceId", () => { + test("returns the lowercase trace ID from contexts.trace", () => { + const event = { + contexts: { trace: { trace_id: "ABCDEF0123456789ABCDEF0123456789" } }, + }; + expect(extractTraceId(event)).toBe("abcdef0123456789abcdef0123456789"); + }); + + test("returns undefined for a malformed trace ID", () => { + expect( + extractTraceId({ contexts: { trace: { trace_id: "nope" } } }) + ).toBeUndefined(); + }); + + test("returns undefined when absent", () => { + expect(extractTraceId({})).toBeUndefined(); + expect(extractTraceId({ contexts: {} })).toBeUndefined(); + }); }); describe("itemTypeToFilterCategory", () => { @@ -606,6 +759,52 @@ describe("formatItemJson", () => { expect(parsed.type).toBe("error"); }); + test("includes trace_id for errors and transactions", () => { + const traceId = "1a2b3c4d5e6f70819a2b3c4d5e6f7081"; + const errorLines = formatItemJson( + "error", + { + timestamp: 1_700_000_000, + message: "boom", + contexts: { trace: { trace_id: traceId } }, + }, + serverHeader + ); + expect(JSON.parse(errorLines[0]).trace_id).toBe(traceId); + + const txnLines = formatItemJson( + "transaction", + { + timestamp: 1_700_000_001, + start_timestamp: 1_700_000_000, + transaction: "GET /", + contexts: { trace: { op: "http.server", trace_id: traceId } }, + }, + serverHeader + ); + expect(JSON.parse(txnLines[0]).trace_id).toBe(traceId); + }); + + test("includes trace_id for logs from sentry.trace.trace_id attribute", () => { + const traceId = "abcdef0123456789abcdef0123456789"; + const lines = formatItemJson( + "log", + { + items: [ + { + level: "info", + body: "hello", + attributes: { + "sentry.trace.trace_id": { value: traceId }, + }, + }, + ], + }, + serverHeader + ); + expect(JSON.parse(lines[0]).trace_id).toBe(traceId); + }); + test("formats transaction with semantic attributes", () => { const event = { timestamp: 1_700_000_002, @@ -695,4 +894,99 @@ describe("formatItemJson", () => { const parsed = JSON.parse(lines[0]); expect(parsed.source).toBe("browser"); }); + + test("includes grouped attributes only when requested", () => { + const event = { + timestamp: 1_700_000_000, + contexts: { trace: { op: "http.server", data: { "user.id": "42" } } }, + }; + const without = JSON.parse( + formatItemJson("transaction", event, serverHeader)[0] + ); + expect(without.attributes).toBeUndefined(); + const withAttrs = JSON.parse( + formatItemJson("transaction", event, serverHeader, true)[0] + ); + expect(withAttrs.attributes.user).toEqual({ "user.id": "42" }); + }); +}); + +describe("formatAttributeTable", () => { + test("returns no lines when the transaction has no attributes", () => { + expect(formatAttributeTable({ timestamp: 1 })).toEqual([]); + }); + + test("splits SDK-default attributes from user-custom ones", () => { + const event = { + contexts: { + trace: { + data: { + "gen_ai.request.model": "gpt-4", + "http.method": "POST", + order_id: "abc", + }, + }, + }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain("user attributes"); + expect(out).toContain("order_id"); + expect(out).toContain("sdk attributes"); + expect(out).toContain("gen_ai.request.model"); + expect(out).toContain("http.method"); + // user group is rendered before the sdk group + expect(out.indexOf("user attributes")).toBeLessThan( + out.indexOf("sdk attributes") + ); + }); + + test("merges child-span attributes with the trace root", () => { + const event = { + contexts: { trace: { data: { "service.name": "api" } } }, + spans: [{ data: { "gen_ai.usage.input_tokens": 10, prompt: "hi" } }], + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain("gen_ai.usage.input_tokens"); + expect(out).toContain("prompt"); + }); + + test("sorts keys alphabetically within a group", () => { + const event = { + contexts: { trace: { data: { zeta: 1, alpha: 2, mid: 3 } } }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out.indexOf("alpha")).toBeLessThan(out.indexOf("mid")); + expect(out.indexOf("mid")).toBeLessThan(out.indexOf("zeta")); + }); + + test("JSON-encodes object-valued attributes", () => { + const event = { + contexts: { trace: { data: { meta: { nested: true } } } }, + }; + const out = formatAttributeTable(event).map(stripAnsi).join("\n"); + expect(out).toContain('{"nested":true}'); + }); +}); + +describe("formatItem with attributes", () => { + const serverHeader = { sdk: { name: "sentry.node" } }; + + test("appends the attribute table for transactions when enabled", () => { + const event = { + timestamp: 1_700_000_000, + transaction: "POST /api/ai/chat", + contexts: { trace: { op: "http.server", data: { tenant: "acme" } } }, + }; + const withoutAttrs = formatItem("transaction", event, serverHeader, "t"); + expect(withoutAttrs).toHaveLength(1); + const withAttrs = formatItem("transaction", event, serverHeader, "t", true); + expect(withAttrs.length).toBeGreaterThan(1); + expect(withAttrs.map(stripAnsi).join("\n")).toContain("tenant"); + }); + + test("does not append a table for errors even when enabled", () => { + const event = { timestamp: 1_700_000_000, message: "boom" }; + const lines = formatItem("error", event, serverHeader, "e", true); + expect(lines).toHaveLength(1); + }); }); diff --git a/test/lib/formatters/semantic-display.test.ts b/test/lib/formatters/semantic-display.test.ts index 836b7ca4e..599252614 100644 --- a/test/lib/formatters/semantic-display.test.ts +++ b/test/lib/formatters/semantic-display.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from "vitest"; import { type AttributeSource, + collectSpanAttributes, formatDisplayPart, formatSemanticSpanDisplay, inferSemanticOp, @@ -533,6 +534,33 @@ describe("mergeTransactionAttributes", () => { }); }); +describe("collectSpanAttributes", () => { + test("collects data from each child span", () => { + const event = { + spans: [ + { data: { "http.request.method": "POST" } }, + { data: { "gen_ai.operation.name": "chat" } }, + ], + }; + const collected = collectSpanAttributes(event); + expect(collected).toHaveLength(2); + expect(inferSemanticOp(collected[1])).toBe("gen_ai"); + }); + + test("skips spans without a data object", () => { + const event = { + spans: [{}, { data: null }, { data: { "db.system.name": "postgresql" } }], + }; + const collected = collectSpanAttributes(event); + expect(collected).toHaveLength(1); + }); + + test("returns empty array when no spans", () => { + expect(collectSpanAttributes({})).toEqual([]); + expect(collectSpanAttributes({ spans: "not-an-array" })).toEqual([]); + }); +}); + describe("formatDisplayPart", () => { test("formats string values", () => { expect(formatDisplayPart("hello", 64)).toBe("hello"); From 4741e5e7459d0111710570e0996c6f53632451e5 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 23 Jun 2026 22:31:25 +0000 Subject: [PATCH 2/3] fix(local): detect GenAI/MCP child spans by attribute prefix The -f ai filter relied on inferSemanticOp, which only flags gen_ai for gen_ai.operation.name/tool/agent keys. Spans carrying only gen_ai.request.model or gen_ai.usage.* (e.g. Vercel AI generations) were missed. Detect the full gen_ai.*/mcp.* namespace by key prefix instead. Also widen SDK_ATTRIBUTE_PREFIXES to cover graphql., aws.s3., cloudevents., feature_flag., process., and error. namespaces so the attribute-table SDK/user split matches the semantic display formatter. --- src/lib/formatters/local.ts | 18 ++++++++++++---- src/lib/formatters/semantic-display.ts | 26 ++++++++++++++++++++++ test/lib/formatters/local.test.ts | 30 ++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/lib/formatters/local.ts b/src/lib/formatters/local.ts index d86b8ac3e..54b7fc559 100644 --- a/src/lib/formatters/local.ts +++ b/src/lib/formatters/local.ts @@ -7,6 +7,7 @@ import type { AttributeSource } from "./semantic-display.js"; import { collectSpanAttributes, formatSemanticSpanDisplay, + hasAiAttributes, inferSemanticOp, mergeTransactionAttributes, } from "./semantic-display.js"; @@ -342,10 +343,16 @@ const SDK_ATTRIBUTE_PREFIXES = [ "messaging.", "faas.", "cloud.", + "cloudevents.", + "aws.s3.", + "graphql.", + "feature_flag.", + "process.", "otel.", "thread.", "code.", "exception.", + "error.", "user_agent.", ]; @@ -840,15 +847,18 @@ export function isItemIncluded( * span attributes. The Vercel AI SDK and similar instrumentations attach * `gen_ai.*` attributes to child spans of an HTTP handler transaction (e.g. * `POST /api/ai/chat`), so root-only detection misses them. + * + * Detection matches the full `gen_ai.*`/`mcp.*` namespace by key prefix rather + * than only the op-defining keys ({@link inferSemanticOp}) — a span carrying + * just `gen_ai.request.model` or `gen_ai.usage.input_tokens` is still AI + * activity the filter must surface. */ function transactionHasAiActivity(payload: Record): boolean { - const rootOp = inferSemanticOp(mergeTransactionAttributes(payload)); - if (rootOp === "gen_ai" || rootOp === "mcp") { + if (hasAiAttributes(mergeTransactionAttributes(payload))) { return true; } for (const spanAttrs of collectSpanAttributes(payload)) { - const op = inferSemanticOp(spanAttrs); - if (op === "gen_ai" || op === "mcp") { + if (hasAiAttributes(spanAttrs)) { return true; } } diff --git a/src/lib/formatters/semantic-display.ts b/src/lib/formatters/semantic-display.ts index 88228fbad..6e86753aa 100644 --- a/src/lib/formatters/semantic-display.ts +++ b/src/lib/formatters/semantic-display.ts @@ -63,6 +63,32 @@ const SEMANTIC_SPAN_FORMATTERS: SpanDisplayFormatter[] = [ */ export type AttributeSource = Record; +/** + * Semantic-convention key prefixes that mark an attribute source as carrying + * GenAI or MCP activity. Used by the `-f ai` filter, which must match any + * GenAI/MCP attribute — not just the `gen_ai.operation.name`/tool/agent keys + * that {@link inferSemanticOp} keys off. Vercel-AI-style spans frequently + * carry only `gen_ai.request.model` or `gen_ai.usage.*`, so prefix matching + * is required to avoid missing them. + */ +const AI_ATTRIBUTE_PREFIXES = ["gen_ai.", "mcp."] as const; + +/** + * Whether an attribute source contains any GenAI or MCP attribute, detected by + * key prefix. Unlike {@link inferSemanticOp}, this matches the full GenAI/MCP + * namespace (e.g. `gen_ai.request.model`, `gen_ai.usage.input_tokens`, + * `mcp.tool.name`), not just the handful of op-defining keys. + */ +export function hasAiAttributes(attrs: AttributeSource): boolean { + for (const key of Object.keys(attrs)) { + const lower = key.toLowerCase(); + if (AI_ATTRIBUTE_PREFIXES.some((prefix) => lower.startsWith(prefix))) { + return true; + } + } + return false; +} + /** Look up an attribute value by trying multiple keys in order. */ function getAttr( attrs: AttributeSource, diff --git a/test/lib/formatters/local.test.ts b/test/lib/formatters/local.test.ts index 1741ca4a8..574544274 100644 --- a/test/lib/formatters/local.test.ts +++ b/test/lib/formatters/local.test.ts @@ -587,6 +587,36 @@ describe("isItemIncluded", () => { expect(isItemIncluded("transaction", ai, payload)).toBe(true); }); + test("matches a child span carrying only gen_ai.request.model", () => { + // No gen_ai.operation.name/tool/agent key, so inferSemanticOp would not + // return "gen_ai" — prefix-based detection is required to catch this. + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "gen_ai.request.model": "gpt-4o" } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches a child span carrying only gen_ai.usage.input_tokens", () => { + const payload = { + contexts: { trace: { op: "http.server" } }, + spans: [{ data: { "gen_ai.usage.input_tokens": 1234 } }], + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + + test("matches a trace root carrying only gen_ai.provider.name", () => { + const payload = { + contexts: { + trace: { + op: "http.server", + data: { "gen_ai.provider.name": "anthropic" }, + }, + }, + }; + expect(isItemIncluded("transaction", ai, payload)).toBe(true); + }); + test("excludes a plain HTTP transaction with no AI spans", () => { const payload = { contexts: { trace: { op: "http.server" } }, From 916d2007a6c67f45fdb363bfe828e1b6adf92483 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Tue, 23 Jun 2026 22:46:49 +0000 Subject: [PATCH 3/3] fix(local): strip BiDi/C1 from log attribute keys in JSON output formatLogJson sanitized log attribute values but passed keys through raw, so C1 controls or BiDi overrides in envelope-supplied attribute key names survived JSON.stringify and could drive terminal injection. Apply stripBidi() to the key, matching buildJsonAttributes. --- src/lib/formatters/local.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/formatters/local.ts b/src/lib/formatters/local.ts index 54b7fc559..bfa22b8d9 100644 --- a/src/lib/formatters/local.ts +++ b/src/lib/formatters/local.ts @@ -722,7 +722,7 @@ function formatLogJson( v?.value !== undefined ) .map(([k, v]) => [ - k, + stripBidi(k), typeof v.value === "string" ? stripBidi(v.value) : v.value, ]) )