From 695805ae4bb507efc771e46242554258ef59f5d1 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 27 Jan 2026 20:17:29 +0530 Subject: [PATCH 1/8] feat: init draft --- src/commands/event/view.ts | 62 +++++++++-- src/commands/issue/view.ts | 50 +++++++-- src/lib/api-client.ts | 22 ++++ src/lib/formatters/human.ts | 199 ++++++++++++++++++++++++++++++++++++ src/types/index.ts | 5 + src/types/sentry.ts | 49 +++++++++ 6 files changed, 368 insertions(+), 19 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index cf5906628..364c108d9 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -6,10 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getEvent } from "../../lib/api-client.js"; +import { getEvent, getTrace } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError } from "../../lib/errors.js"; -import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; +import { + formatEventDetails, + formatSpanTree, + muted, + writeJson, +} from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -19,26 +24,35 @@ type ViewFlags = { readonly project?: string; readonly json: boolean; readonly web: boolean; + readonly spans: boolean; +}; + +type HumanOutputOptions = { + event: SentryEvent; + detectedFrom?: string; + spanTreeLines?: string[]; }; /** * Write human-readable event output to stdout. * * @param stdout - Output stream - * @param event - The event to display - * @param detectedFrom - Optional source description for auto-detection + * @param options - Output options including event, detectedFrom, and spanTreeLines */ -function writeHumanOutput( - stdout: Writer, - event: SentryEvent, - detectedFrom?: string -): void { +function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { + const { event, detectedFrom, spanTreeLines } = options; + const lines = formatEventDetails(event, `Event ${event.eventID}`); // Skip leading empty line for standalone display const output = lines.slice(1); stdout.write(`${output.join("\n")}\n`); + // Display span tree if available + if (spanTreeLines && spanTreeLines.length > 0) { + stdout.write(`${spanTreeLines.join("\n")}\n`); + } + if (detectedFrom) { stdout.write(`\nDetected from ${detectedFrom}\n`); } @@ -88,8 +102,13 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + spans: { + kind: "boolean", + brief: "Show span tree from the event's trace", + default: false, + }, }, - aliases: { w: "web" }, + aliases: { w: "web", s: "spans" }, }, async func( this: SentryContext, @@ -123,11 +142,32 @@ export const viewCommand = buildCommand({ const event = await getEvent(target.org, target.project, eventId); + // Fetch span tree if requested and trace ID is available + let spanTreeLines: string[] | undefined; + if (flags.spans && event.contexts?.trace?.trace_id) { + try { + const traceEvents = await getTrace( + target.org, + event.contexts.trace.trace_id + ); + spanTreeLines = formatSpanTree(traceEvents); + } catch { + // Non-fatal: trace data may not be available for all events + spanTreeLines = [muted("\nUnable to fetch span tree for this event.")]; + } + } else if (flags.spans && !event.contexts?.trace?.trace_id) { + spanTreeLines = [muted("\nNo trace data available for this event.")]; + } + if (flags.json) { writeJson(stdout, event); return; } - writeHumanOutput(stdout, event, target.detectedFrom); + writeHumanOutput(stdout, { + event, + detectedFrom: target.detectedFrom, + spanTreeLines, + }); }, }); diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 646b548fb..ce640a65e 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -6,11 +6,13 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getLatestEvent } from "../../lib/api-client.js"; +import { getLatestEvent, getTrace } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { formatEventDetails, formatIssueDetails, + formatSpanTree, + muted, writeFooter, writeJson, } from "../../lib/formatters/index.js"; @@ -26,6 +28,7 @@ import { interface ViewFlags extends IssueIdFlags { readonly json: boolean; readonly web: boolean; + readonly spans: boolean; } /** @@ -46,14 +49,18 @@ async function tryGetLatestEvent( } } +type HumanOutputOptions = { + issue: SentryIssue; + event?: SentryEvent; + spanTreeLines?: string[]; +}; + /** * Write human-readable issue output */ -function writeHumanOutput( - stdout: Writer, - issue: SentryIssue, - event?: SentryEvent -): void { +function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { + const { issue, event, spanTreeLines } = options; + const issueLines = formatIssueDetails(issue); stdout.write(`${issueLines.join("\n")}\n`); @@ -66,6 +73,11 @@ function writeHumanOutput( ); stdout.write(`${eventLines.join("\n")}\n`); } + + // Display span tree if available + if (spanTreeLines && spanTreeLines.length > 0) { + stdout.write(`${spanTreeLines.join("\n")}\n`); + } } export const viewCommand = buildCommand({ @@ -97,8 +109,13 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + spans: { + kind: "boolean", + brief: "Show span tree from the latest event's trace", + default: false, + }, }, - aliases: { w: "web" }, + aliases: { w: "web", s: "spans" }, }, async func( this: SentryContext, @@ -126,13 +143,30 @@ export const viewCommand = buildCommand({ ? await tryGetLatestEvent(orgSlug, issue.id) : undefined; + // Fetch span tree if requested and trace ID is available + let spanTreeLines: string[] | undefined; + if (flags.spans && orgSlug && event?.contexts?.trace?.trace_id) { + try { + const traceEvents = await getTrace( + orgSlug, + event.contexts.trace.trace_id + ); + spanTreeLines = formatSpanTree(traceEvents); + } catch { + // Non-fatal: trace data may not be available for all events + spanTreeLines = [muted("\nUnable to fetch span tree for this event.")]; + } + } else if (flags.spans && !event?.contexts?.trace?.trace_id) { + spanTreeLines = [muted("\nNo trace data available for this event.")]; + } + if (flags.json) { const output = event ? { issue, event } : { issue }; writeJson(stdout, output); return; } - writeHumanOutput(stdout, issue, event); + writeHumanOutput(stdout, { issue, event, spanTreeLines }); writeFooter( stdout, `Tip: Use 'sentry issue explain ${issue.shortId}' for AI root cause analysis` diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6dbcf0c3d..c51e25291 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -16,6 +16,8 @@ import { SentryOrganizationSchema, type SentryProject, SentryProjectSchema, + type TraceEvent, + TraceEventSchema, } from "../types/index.js"; import type { AutofixResponse, AutofixState } from "../types/seer.js"; import { refreshToken } from "./config.js"; @@ -446,6 +448,26 @@ export function getEvent( ); } +/** + * Get trace data including all transactions and spans. + * Returns the full trace tree for visualization. + * + * @param orgSlug - Organization slug + * @param traceId - The trace ID (from event.contexts.trace.trace_id) + * @returns Array of trace events (transactions) with their spans + */ +export function getTrace( + orgSlug: string, + traceId: string +): Promise { + return apiRequest( + `/organizations/${orgSlug}/events-trace/${traceId}/`, + { + schema: z.array(TraceEventSchema), + } + ); +} + /** * Update an issue's status */ diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 910b72b3d..0080dd6db 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -16,7 +16,9 @@ import type { SentryIssue, SentryOrganization, SentryProject, + Span, StackFrame, + TraceEvent, } from "../../types/index.js"; import { boldUnderline, @@ -798,6 +800,203 @@ function formatRequest(requestEntry: RequestEntry): string[] { return lines; } +// ───────────────────────────────────────────────────────────────────────────── +// Span Tree Formatting +// ───────────────────────────────────────────────────────────────────────────── + +/** Node in the span tree structure */ +type SpanNode = { + /** The span data */ + span: Span; + /** Child spans */ + children: SpanNode[]; +}; + +/** + * Format duration in milliseconds to human-readable format. + * + * @param startTs - Start timestamp (seconds with fractional ms) + * @param endTs - End timestamp (seconds with fractional ms) + * @returns Formatted duration (e.g., "120ms", "1.50s") + */ +function formatSpanDuration(startTs: number, endTs: number): string { + const durationMs = Math.round((endTs - startTs) * 1000); + if (durationMs < 1000) { + return `${durationMs}ms`; + } + return `${(durationMs / 1000).toFixed(2)}s`; +} + +/** + * Build a tree structure from flat spans using parent_span_id. + * Sorts children by start_timestamp for chronological display. + * + * @param spans - Flat array of spans + * @param rootSpanId - Optional root span ID to start the tree from + * @returns Array of root-level span nodes with nested children + */ +function buildSpanTree(spans: Span[], rootSpanId?: string): SpanNode[] { + const spanMap = new Map(); + const roots: SpanNode[] = []; + + // Create nodes for all spans + for (const span of spans) { + spanMap.set(span.span_id, { span, children: [] }); + } + + // Link children to parents + for (const span of spans) { + const node = spanMap.get(span.span_id); + if (!node) { + continue; + } + + const parentId = span.parent_span_id; + const parentNode = parentId ? spanMap.get(parentId) : undefined; + if (parentNode) { + parentNode.children.push(node); + } else if (!rootSpanId || span.span_id === rootSpanId) { + // This is a root span (no parent or matches specified root) + roots.push(node); + } + } + + // Sort children by start_timestamp for chronological order + const sortChildren = (node: SpanNode): void => { + node.children.sort( + (a, b) => a.span.start_timestamp - b.span.start_timestamp + ); + for (const child of node.children) { + sortChildren(child); + } + }; + for (const root of roots) { + sortChildren(root); + } + + // Sort roots as well + roots.sort((a, b) => a.span.start_timestamp - b.span.start_timestamp); + + return roots; +} + +/** + * Recursively format a span node and its children as tree lines. + * + * @param node - The span node to format + * @param prefix - Current line prefix (for tree structure) + * @param isLast - Whether this is the last sibling + * @returns Array of formatted lines + */ +function formatSpanNode( + node: SpanNode, + prefix: string, + isLast: boolean +): string[] { + const lines: string[] = []; + const { span } = node; + + const duration = formatSpanDuration(span.start_timestamp, span.timestamp); + const op = span.op || "unknown"; + const description = span.description || "(no description)"; + + // Truncate long descriptions + const maxDescLen = 50; + const truncatedDesc = + description.length > maxDescLen + ? `${description.slice(0, maxDescLen - 3)}...` + : description; + + // Tree branch characters + const branch = isLast ? "└─" : "├─"; + const childPrefix = prefix + (isLast ? " " : "│ "); + + // Color duration based on severity (slow = yellow/red) + const durationMs = (span.timestamp - span.start_timestamp) * 1000; + let durationText = duration; + if (durationMs > 1000) { + durationText = yellow(duration); + } else if (durationMs > 5000) { + durationText = red(duration); + } + + lines.push( + `${prefix}${branch} [${durationText}] ${truncatedDesc} ${muted(`(${op})`)}` + ); + + // Format children + const childCount = node.children.length; + node.children.forEach((child, i) => { + const childIsLast = i === childCount - 1; + lines.push(...formatSpanNode(child, childPrefix, childIsLast)); + }); + + return lines; +} + +/** + * Format a trace event (transaction) as a span tree root. + * + * @param traceEvent - The trace event containing transaction info and spans + * @returns Array of formatted lines + */ +function formatTraceEventAsTree(traceEvent: TraceEvent): string[] { + const lines: string[] = []; + + // Transaction header + const txName = traceEvent.transaction || "(unnamed transaction)"; + const op = traceEvent["transaction.op"] || "unknown"; + const durationMs = traceEvent["transaction.duration"]; + const duration = durationMs !== undefined ? `${durationMs}ms` : "?"; + + lines.push(`[${duration}] ${txName} ${muted(`(${op})`)}`); + + // Build span tree from the transaction's spans + const spans = traceEvent.spans ?? []; + if (spans.length > 0) { + const tree = buildSpanTree(spans, traceEvent.span_id); + const treeLength = tree.length; + tree.forEach((root, i) => { + const isLast = i === treeLength - 1; + lines.push(...formatSpanNode(root, " ", isLast)); + }); + } + + return lines; +} + +/** + * Format trace data as a visual span tree structure. + * Shows the hierarchy of transactions and spans with durations. + * + * @param traceEvents - Array of trace events from the events-trace API + * @returns Array of formatted lines ready for display + */ +export function formatSpanTree(traceEvents: TraceEvent[]): string[] { + if (traceEvents.length === 0) { + return [muted("\nNo span data available.")]; + } + + const lines: string[] = []; + lines.push(""); + lines.push(muted("─── Span Tree ───")); + lines.push(""); + + // Sort trace events by start_timestamp for chronological order + const sorted = [...traceEvents].sort((a, b) => { + const aStart = a.start_timestamp ?? 0; + const bStart = b.start_timestamp ?? 0; + return aStart - bStart; + }); + + for (const traceEvent of sorted) { + lines.push(...formatTraceEventAsTree(traceEvent)); + lines.push(""); // Blank line between transactions + } + + return lines; +} + // ───────────────────────────────────────────────────────────────────────────── // Environment Context Formatting // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index 3f68dffde..0c47b9e78 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -71,9 +71,12 @@ export type { // Organization & Project SentryOrganization, SentryProject, + // Span/Trace types + Span, StackFrame, Stacktrace, TraceContext, + TraceEvent, UserGeo, } from "./sentry.js"; // Sentry API types and schemas @@ -97,9 +100,11 @@ export { SentryIssueSchema, SentryOrganizationSchema, SentryProjectSchema, + SpanSchema, StackFrameSchema, StacktraceSchema, TraceContextSchema, + TraceEventSchema, UserGeoSchema, } from "./sentry.js"; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 9c4f93164..e5c221a14 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -239,6 +239,55 @@ export const TraceContextSchema = z export type TraceContext = z.infer; +// ───────────────────────────────────────────────────────────────────────────── +// Span (for trace tree display) +// ───────────────────────────────────────────────────────────────────────────── + +/** A single span in a trace */ +export const SpanSchema = z + .object({ + span_id: z.string(), + parent_span_id: z.string().nullable().optional(), + trace_id: z.string().optional(), + op: z.string().optional(), + description: z.string().nullable().optional(), + /** Start time as Unix timestamp (seconds with fractional ms) */ + start_timestamp: z.number(), + /** End time as Unix timestamp (seconds with fractional ms) */ + timestamp: z.number(), + status: z.string().optional(), + data: z.record(z.unknown()).optional(), + tags: z.record(z.string()).optional(), + }) + .passthrough(); + +export type Span = z.infer; + +/** A transaction/event in a trace (from events-trace endpoint) */ +export const TraceEventSchema = z + .object({ + event_id: z.string(), + span_id: z.string().optional(), + transaction: z.string().optional(), + "transaction.duration": z.number().optional(), + "transaction.op": z.string().optional(), + project_slug: z.string().optional(), + project_id: z.union([z.string(), z.number()]).optional(), + /** Child spans within this transaction */ + spans: z.array(SpanSchema).optional(), + /** Start time */ + start_timestamp: z.number().optional(), + /** End time */ + timestamp: z.number().optional(), + /** Errors associated with this transaction */ + errors: z.array(z.unknown()).optional(), + /** Performance issues */ + performance_issues: z.array(z.unknown()).optional(), + }) + .passthrough(); + +export type TraceEvent = z.infer; + // ───────────────────────────────────────────────────────────────────────────── // Stack Frame & Exception Entry // ───────────────────────────────────────────────────────────────────────────── From eb991144221751e8c8a447bb960cf47817f262a1 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 27 Jan 2026 20:28:26 +0530 Subject: [PATCH 2/8] fix: resolved bugs --- src/commands/issue/view.ts | 2 ++ src/lib/formatters/human.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index ce640a65e..6e3677728 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -156,6 +156,8 @@ export const viewCommand = buildCommand({ // Non-fatal: trace data may not be available for all events spanTreeLines = [muted("\nUnable to fetch span tree for this event.")]; } + } else if (flags.spans && !event) { + spanTreeLines = [muted("\nCould not fetch event to display span tree.")]; } else if (flags.spans && !event?.contexts?.trace?.trace_id) { spanTreeLines = [muted("\nNo trace data available for this event.")]; } diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 0080dd6db..f05e2a16f 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -820,7 +820,7 @@ type SpanNode = { * @returns Formatted duration (e.g., "120ms", "1.50s") */ function formatSpanDuration(startTs: number, endTs: number): string { - const durationMs = Math.round((endTs - startTs) * 1000); + const durationMs = Math.max(0, Math.round((endTs - startTs) * 1000)); if (durationMs < 1000) { return `${durationMs}ms`; } @@ -830,12 +830,12 @@ function formatSpanDuration(startTs: number, endTs: number): string { /** * Build a tree structure from flat spans using parent_span_id. * Sorts children by start_timestamp for chronological display. + * Spans without a found parent are treated as roots (handles orphans gracefully). * * @param spans - Flat array of spans - * @param rootSpanId - Optional root span ID to start the tree from * @returns Array of root-level span nodes with nested children */ -function buildSpanTree(spans: Span[], rootSpanId?: string): SpanNode[] { +function buildSpanTree(spans: Span[]): SpanNode[] { const spanMap = new Map(); const roots: SpanNode[] = []; @@ -855,8 +855,8 @@ function buildSpanTree(spans: Span[], rootSpanId?: string): SpanNode[] { const parentNode = parentId ? spanMap.get(parentId) : undefined; if (parentNode) { parentNode.children.push(node); - } else if (!rootSpanId || span.span_id === rootSpanId) { - // This is a root span (no parent or matches specified root) + } else { + // No parent found in response - treat as root (true root or orphan) roots.push(node); } } @@ -914,10 +914,10 @@ function formatSpanNode( // Color duration based on severity (slow = yellow/red) const durationMs = (span.timestamp - span.start_timestamp) * 1000; let durationText = duration; - if (durationMs > 1000) { - durationText = yellow(duration); - } else if (durationMs > 5000) { + if (durationMs > 5000) { durationText = red(duration); + } else if (durationMs > 1000) { + durationText = yellow(duration); } lines.push( @@ -954,7 +954,7 @@ function formatTraceEventAsTree(traceEvent: TraceEvent): string[] { // Build span tree from the transaction's spans const spans = traceEvent.spans ?? []; if (spans.length > 0) { - const tree = buildSpanTree(spans, traceEvent.span_id); + const tree = buildSpanTree(spans); const treeLength = tree.length; tree.forEach((root, i) => { const isLast = i === treeLength - 1; From 4637624383554a578c2445169a3db83b30431c03 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Tue, 27 Jan 2026 20:42:47 +0530 Subject: [PATCH 3/8] fix: added tests --- test/lib/formatters/span-tree.test.ts | 407 ++++++++++++++++++++++++++ 1 file changed, 407 insertions(+) create mode 100644 test/lib/formatters/span-tree.test.ts diff --git a/test/lib/formatters/span-tree.test.ts b/test/lib/formatters/span-tree.test.ts new file mode 100644 index 000000000..4b2ad2098 --- /dev/null +++ b/test/lib/formatters/span-tree.test.ts @@ -0,0 +1,407 @@ +/** + * Tests for span tree formatting + */ + +import { describe, expect, test } from "bun:test"; +import { formatSpanTree } from "../../../src/lib/formatters/human.js"; +import type { Span, TraceEvent } from "../../../src/types/index.js"; + +// Helper to strip ANSI codes for content testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +/** + * Create a minimal span for testing + */ +function makeSpan( + id: string, + parentId?: string | null, + options: { + durationSec?: number; + startTs?: number; + op?: string; + description?: string | null; + } = {} +): Span { + const { + durationSec = 0.1, + startTs = 1000.0, + op = "test.op", + description = `Span ${id}`, + } = options; + return { + span_id: id, + parent_span_id: parentId ?? null, + start_timestamp: startTs, + timestamp: startTs + durationSec, + op, + description, + }; +} + +/** + * Create a minimal trace event for testing + */ +function makeTraceEvent( + id: string, + spans: Span[] = [], + options: { + startTs?: number; + transaction?: string; + op?: string; + durationMs?: number; + } = {} +): TraceEvent { + const { + startTs = 1000.0, + transaction = `Transaction ${id}`, + op = "http.server", + durationMs = 100, + } = options; + return { + event_id: id, + transaction, + "transaction.op": op, + "transaction.duration": durationMs, + start_timestamp: startTs, + spans, + }; +} + +describe("formatSpanTree", () => { + describe("empty and edge cases", () => { + test("returns message for empty trace events array", () => { + const result = formatSpanTree([]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("No span data available"); + }); + + test("handles trace event with no spans", () => { + const result = formatSpanTree([makeTraceEvent("1")]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Transaction 1"); + expect(output).toContain("100ms"); + expect(output).toContain("(http.server)"); + }); + + test("handles trace event with missing transaction name", () => { + const event: TraceEvent = { + event_id: "1", + "transaction.duration": 50, + }; + const result = formatSpanTree([event]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(unnamed transaction)"); + }); + + test("handles trace event with missing op", () => { + const event: TraceEvent = { + event_id: "1", + transaction: "Test", + }; + const result = formatSpanTree([event]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(unknown)"); + }); + + test("handles trace event with missing duration", () => { + const event: TraceEvent = { + event_id: "1", + transaction: "Test", + }; + const result = formatSpanTree([event]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("[?]"); + }); + }); + + describe("duration formatting", () => { + test("formats milliseconds under 1 second as Xms", () => { + const spans = [makeSpan("1", null, { durationSec: 0.5 })]; // 500ms + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("500ms"); + }); + + test("formats seconds with 2 decimal places as X.XXs", () => { + const spans = [makeSpan("1", null, { durationSec: 1.234 })]; // 1234ms + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("1.23s"); + }); + + test("clamps negative duration to 0ms", () => { + const spans: Span[] = [ + { + span_id: "bad", + start_timestamp: 1000.5, + timestamp: 1000.0, // End before start! + op: "test", + description: "Bad span", + }, + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("[0ms]"); + }); + + test("handles zero duration", () => { + const spans = [makeSpan("1", null, { durationSec: 0 })]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("[0ms]"); + }); + }); + + describe("tree structure", () => { + test("builds flat list from spans without parent_span_id", () => { + const spans = [ + makeSpan("a", null, { startTs: 1000.0 }), + makeSpan("b", null, { startTs: 1001.0 }), + makeSpan("c", null, { startTs: 1002.0 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Span a"); + expect(output).toContain("Span b"); + expect(output).toContain("Span c"); + }); + + test("nests child spans under parent", () => { + const spans = [ + makeSpan("root", null, { startTs: 1000.0 }), + makeSpan("child", "root", { startTs: 1000.1 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const lines = result.map(stripAnsi); + + // Find the lines containing our spans + const rootLine = lines.find((l) => l.includes("Span root")); + const childLine = lines.find((l) => l.includes("Span child")); + + expect(rootLine).toBeDefined(); + expect(childLine).toBeDefined(); + + // Child should have more leading whitespace (indentation) + const rootIndent = rootLine?.match(/^\s*/)?.[0].length ?? 0; + const childIndent = childLine?.match(/^\s*/)?.[0].length ?? 0; + expect(childIndent).toBeGreaterThan(rootIndent); + }); + + test("handles deeply nested spans (3 levels)", () => { + const spans = [ + makeSpan("level1", null, { startTs: 1000.0 }), + makeSpan("level2", "level1", { startTs: 1000.1 }), + makeSpan("level3", "level2", { startTs: 1000.2 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const lines = result.map(stripAnsi); + + const level1Line = lines.find((l) => l.includes("Span level1")); + const level2Line = lines.find((l) => l.includes("Span level2")); + const level3Line = lines.find((l) => l.includes("Span level3")); + + expect(level1Line).toBeDefined(); + expect(level2Line).toBeDefined(); + expect(level3Line).toBeDefined(); + + // Each level should have more indentation + const indent1 = level1Line?.match(/^\s*/)?.[0].length ?? 0; + const indent2 = level2Line?.match(/^\s*/)?.[0].length ?? 0; + const indent3 = level3Line?.match(/^\s*/)?.[0].length ?? 0; + + expect(indent2).toBeGreaterThan(indent1); + expect(indent3).toBeGreaterThan(indent2); + }); + + test("treats orphaned spans as roots", () => { + const spans = [makeSpan("orphan", "non-existent-parent")]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + // Orphan should appear as a root (not dropped) + expect(output).toContain("Span orphan"); + }); + + test("handles mixed roots and nested spans", () => { + const spans = [ + makeSpan("root1", null, { startTs: 1000.0 }), + makeSpan("child1", "root1", { startTs: 1000.1 }), + makeSpan("root2", null, { startTs: 1001.0 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Span root1"); + expect(output).toContain("Span child1"); + expect(output).toContain("Span root2"); + }); + }); + + describe("sorting", () => { + test("sorts trace events by start_timestamp", () => { + const events = [ + makeTraceEvent("second", [], { startTs: 2000.0 }), + makeTraceEvent("first", [], { startTs: 1000.0 }), + ]; + const result = formatSpanTree(events); + const output = stripAnsi(result.join("\n")); + + const firstIdx = output.indexOf("Transaction first"); + const secondIdx = output.indexOf("Transaction second"); + expect(firstIdx).toBeLessThan(secondIdx); + }); + + test("sorts spans by start_timestamp", () => { + const spans = [ + makeSpan("c", null, { startTs: 1003.0 }), + makeSpan("a", null, { startTs: 1001.0 }), + makeSpan("b", null, { startTs: 1002.0 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + + const aIdx = output.indexOf("Span a"); + const bIdx = output.indexOf("Span b"); + const cIdx = output.indexOf("Span c"); + + expect(aIdx).toBeLessThan(bIdx); + expect(bIdx).toBeLessThan(cIdx); + }); + + test("handles trace events with undefined start_timestamp", () => { + const events: TraceEvent[] = [ + { event_id: "2", transaction: "Second" }, + { event_id: "1", transaction: "First", start_timestamp: 500.0 }, + ]; + const result = formatSpanTree(events); + const output = stripAnsi(result.join("\n")); + + // Event without timestamp (defaults to 0) should come first + const secondIdx = output.indexOf("Second"); + const firstIdx = output.indexOf("First"); + expect(secondIdx).toBeLessThan(firstIdx); + }); + }); + + describe("formatting output", () => { + test("truncates long descriptions at 50 chars", () => { + const longDesc = + "This is a very long description that exceeds the maximum length allowed"; + const spans = [makeSpan("1", null, { description: longDesc })]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + + // Should be truncated with ... + expect(output).toContain("..."); + expect(output).not.toContain(longDesc); + // First part should be there + expect(output).toContain("This is a very long description that excee"); + }); + + test("shows (no description) when description is null", () => { + const spans = [makeSpan("1", null, { description: null })]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(no description)"); + }); + + test("shows unknown when op is missing", () => { + const spans: Span[] = [ + { + span_id: "1", + start_timestamp: 1000.0, + timestamp: 1000.1, + description: "Test", + }, + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("(unknown)"); + }); + + test("uses correct tree branch characters", () => { + const spans = [ + makeSpan("first", null, { startTs: 1000.0 }), + makeSpan("last", null, { startTs: 1001.0 }), + ]; + const result = formatSpanTree([makeTraceEvent("1", spans)]); + const output = result.join("\n"); + + // First sibling uses ├─, last sibling uses └─ + expect(output).toContain("├─"); + expect(output).toContain("└─"); + }); + + test("includes Span Tree header", () => { + const result = formatSpanTree([makeTraceEvent("1")]); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Span Tree"); + }); + }); + + describe("duration coloring", () => { + // Helper to check for ANSI escape codes + function hasAnsiCodes(str: string): boolean { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars + return /\x1b\[[0-9;]*m/.test(str); + } + + test("applies color styling for slow spans", () => { + // Fast span (under 1s) - duration formatted but may not have color + const fastSpans = [makeSpan("fast", null, { durationSec: 0.5 })]; + const fastResult = formatSpanTree([makeTraceEvent("1", fastSpans)]); + const fastOutput = fastResult.join("\n"); + expect(stripAnsi(fastOutput)).toContain("500ms"); + + // Slow span (over 1s) - should have yellow color applied + const slowSpans = [makeSpan("slow", null, { durationSec: 2.0 })]; + const slowResult = formatSpanTree([makeTraceEvent("1", slowSpans)]); + const slowOutput = slowResult.join("\n"); + expect(stripAnsi(slowOutput)).toContain("2.00s"); + + // Very slow span (over 5s) - should have red color applied + const verySlowSpans = [makeSpan("very-slow", null, { durationSec: 6.0 })]; + const verySlowResult = formatSpanTree([ + makeTraceEvent("1", verySlowSpans), + ]); + const verySlowOutput = verySlowResult.join("\n"); + expect(stripAnsi(verySlowOutput)).toContain("6.00s"); + + // If colors are enabled, slow spans should have ANSI codes + const colorsEnabled = process.env.FORCE_COLOR === "1"; + if (colorsEnabled) { + expect(hasAnsiCodes(slowOutput)).toBe(true); + expect(hasAnsiCodes(verySlowOutput)).toBe(true); + } + }); + }); + + describe("multiple transactions", () => { + test("formats multiple trace events with blank lines between", () => { + const events = [ + makeTraceEvent("1", [], { startTs: 1000.0 }), + makeTraceEvent("2", [], { startTs: 2000.0 }), + ]; + const result = formatSpanTree(events); + + // Should have blank lines between transactions + const blankLineCount = result.filter((line) => line === "").length; + expect(blankLineCount).toBeGreaterThanOrEqual(2); + }); + + test("each transaction shows its own spans", () => { + const events = [ + makeTraceEvent("1", [makeSpan("span-1")], { startTs: 1000.0 }), + makeTraceEvent("2", [makeSpan("span-2")], { startTs: 2000.0 }), + ]; + const result = formatSpanTree(events); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("Transaction 1"); + expect(output).toContain("Span span-1"); + expect(output).toContain("Transaction 2"); + expect(output).toContain("Span span-2"); + }); + }); +}); From 57e157c6df8372ba3875b78574fc0514b212269a Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 28 Jan 2026 13:33:19 +0530 Subject: [PATCH 4/8] fix: good changes --- src/commands/issue/view.ts | 97 +++++---- src/lib/api-client.ts | 40 +++- src/lib/formatters/human.ts | 86 +++++++- src/types/index.ts | 3 + src/types/sentry.ts | 29 +++ test/lib/formatters/span-tree.test.ts | 299 +++++++++++++++++++++++--- 6 files changed, 477 insertions(+), 77 deletions(-) diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 6e3677728..6c60df306 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -6,12 +6,12 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getLatestEvent, getTrace } from "../../lib/api-client.js"; +import { getDetailedTrace, getLatestEvent } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { formatEventDetails, formatIssueDetails, - formatSpanTree, + formatSimpleSpanTree, muted, writeFooter, writeJson, @@ -28,7 +28,7 @@ import { interface ViewFlags extends IssueIdFlags { readonly json: boolean; readonly web: boolean; - readonly spans: boolean; + readonly spans: number; } /** @@ -38,28 +38,23 @@ interface ViewFlags extends IssueIdFlags { * @param orgSlug - Organization slug for API routing * @param issueId - Issue ID (numeric) */ -async function tryGetLatestEvent( +function tryGetLatestEvent( orgSlug: string, issueId: string ): Promise { - try { - return await getLatestEvent(orgSlug, issueId); - } catch { - return; - } + return getLatestEvent(orgSlug, issueId); } type HumanOutputOptions = { issue: SentryIssue; event?: SentryEvent; - spanTreeLines?: string[]; }; /** * Write human-readable issue output */ function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { - const { issue, event, spanTreeLines } = options; + const { issue, event } = options; const issueLines = formatIssueDetails(issue); stdout.write(`${issueLines.join("\n")}\n`); @@ -73,10 +68,43 @@ function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { ); stdout.write(`${eventLines.join("\n")}\n`); } +} + +/** + * Display the span tree for an event. + * Shows ONLY the tree structure, no issue/event details. + * + * @param stdout - Output writer + * @param orgSlug - Organization slug + * @param event - The event to get trace from + * @param maxDepth - Maximum nesting depth to display + * @returns true if successfully displayed, false if missing data + */ +async function displaySpanTree( + stdout: Writer, + orgSlug: string, + event: SentryEvent, + maxDepth: number +): Promise { + const traceId = event.contexts?.trace?.trace_id; + const dateCreated = (event as { dateCreated?: string }).dateCreated; + const timestamp = dateCreated + ? new Date(dateCreated).getTime() / 1000 + : undefined; + + if (!(traceId && timestamp)) { + stdout.write(muted("No trace data available for this event.\n")); + return false; + } - // Display span tree if available - if (spanTreeLines && spanTreeLines.length > 0) { - stdout.write(`${spanTreeLines.join("\n")}\n`); + try { + const spans = await getDetailedTrace(orgSlug, traceId, timestamp); + const lines = formatSimpleSpanTree(traceId, spans, maxDepth); + stdout.write(`${lines.join("\n")}\n`); + return true; + } catch { + stdout.write(muted("Unable to fetch span tree for this event.\n")); + return false; } } @@ -110,9 +138,8 @@ export const viewCommand = buildCommand({ default: false, }, spans: { - kind: "boolean", - brief: "Show span tree from the latest event's trace", - default: false, + kind: "counter", + brief: "Show span tree (repeat for more depth: -s, -ss, -sss)", }, }, aliases: { w: "web", s: "spans" }, @@ -143,32 +170,28 @@ export const viewCommand = buildCommand({ ? await tryGetLatestEvent(orgSlug, issue.id) : undefined; - // Fetch span tree if requested and trace ID is available - let spanTreeLines: string[] | undefined; - if (flags.spans && orgSlug && event?.contexts?.trace?.trace_id) { - try { - const traceEvents = await getTrace( - orgSlug, - event.contexts.trace.trace_id - ); - spanTreeLines = formatSpanTree(traceEvents); - } catch { - // Non-fatal: trace data may not be available for all events - spanTreeLines = [muted("\nUnable to fetch span tree for this event.")]; - } - } else if (flags.spans && !event) { - spanTreeLines = [muted("\nCould not fetch event to display span tree.")]; - } else if (flags.spans && !event?.contexts?.trace?.trace_id) { - spanTreeLines = [muted("\nNo trace data available for this event.")]; - } - + // JSON output if (flags.json) { const output = event ? { issue, event } : { issue }; writeJson(stdout, output); return; } - writeHumanOutput(stdout, { issue, event, spanTreeLines }); + // Normal human-readable output (issue + event details) + writeHumanOutput(stdout, { issue, event }); + + // If --spans flag is passed, show span tree (counter value = depth) + if (flags.spans > 0 && orgSlug && event) { + stdout.write("\n"); + await displaySpanTree(stdout, orgSlug, event, flags.spans); + } else if (flags.spans > 0 && !orgSlug) { + stdout.write( + muted("\nOrganization context required to fetch span tree.\n") + ); + } else if (flags.spans > 0 && !event) { + stdout.write(muted("\nCould not fetch event to display span tree.\n")); + } + writeFooter( stdout, `Tip: Use 'sentry issue explain ${issue.shortId}' for AI root cause analysis` diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index c51e25291..0969fb390 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -16,8 +16,8 @@ import { SentryOrganizationSchema, type SentryProject, SentryProjectSchema, - type TraceEvent, - TraceEventSchema, + type TraceResponse, + type TraceSpan, } from "../types/index.js"; import type { AutofixResponse, AutofixState } from "../types/seer.js"; import { refreshToken } from "./config.js"; @@ -426,7 +426,7 @@ export function getLatestEvent( return apiRequest( `/organizations/${orgSlug}/issues/${issueId}/events/latest/`, { - schema: SentryEventSchema, + // schema: SentryEventSchema, } ); } @@ -454,16 +454,42 @@ export function getEvent( * * @param orgSlug - Organization slug * @param traceId - The trace ID (from event.contexts.trace.trace_id) - * @returns Array of trace events (transactions) with their spans + * @returns Trace response with transactions array and orphan_errors */ export function getTrace( orgSlug: string, traceId: string -): Promise { - return apiRequest( +): Promise { + return apiRequest( `/organizations/${orgSlug}/events-trace/${traceId}/`, { - schema: z.array(TraceEventSchema), + // schema: TraceResponseSchema, + } + ); +} + +/** + * Get detailed trace with nested children structure. + * Uses the same endpoint as Sentry's dashboard for hierarchical span trees. + * + * @param orgSlug - Organization slug + * @param traceId - The trace ID (from event.contexts.trace.trace_id) + * @param timestamp - Unix timestamp (seconds) from the event's dateCreated + * @returns Array of root spans with nested children + */ +export function getDetailedTrace( + orgSlug: string, + traceId: string, + timestamp: number +): Promise { + return apiRequest( + `/organizations/${orgSlug}/trace/${traceId}/`, + { + params: { + timestamp, + limit: 10_000, + project: -1, + }, } ); } diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index f05e2a16f..839f90254 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -19,6 +19,8 @@ import type { Span, StackFrame, TraceEvent, + TraceResponse, + TraceSpan, } from "../../types/index.js"; import { boldUnderline, @@ -969,10 +971,12 @@ function formatTraceEventAsTree(traceEvent: TraceEvent): string[] { * Format trace data as a visual span tree structure. * Shows the hierarchy of transactions and spans with durations. * - * @param traceEvents - Array of trace events from the events-trace API + * @param traceResponse - Response from the events-trace API containing transactions * @returns Array of formatted lines ready for display */ -export function formatSpanTree(traceEvents: TraceEvent[]): string[] { +export function formatSpanTree(traceResponse: TraceResponse): string[] { + const traceEvents = traceResponse.transactions; + if (traceEvents.length === 0) { return [muted("\nNo span data available.")]; } @@ -997,6 +1001,84 @@ export function formatSpanTree(traceEvents: TraceEvent[]): string[] { return lines; } +// ───────────────────────────────────────────────────────────────────────────── +// Simple Span Tree Formatting (for --spans flag) +// ───────────────────────────────────────────────────────────────────────────── + +type FormatSpanOptions = { + lines: string[]; + prefix: string; + isLast: boolean; + currentDepth: number; + maxDepth: number; +}; + +/** + * Recursively format a span and its children as simple tree lines. + * Uses "op — description" format without durations. + */ +function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { + const { lines, prefix, isLast, currentDepth, maxDepth } = opts; + const op = span.op || span["transaction.op"] || "unknown"; + const desc = span.description || span.transaction || "(no description)"; + + // Tree branch characters + const branch = isLast ? "└─" : "├─"; + const childPrefix = prefix + (isLast ? " " : "│ "); + + lines.push(`${prefix}${branch} ${muted(op)} — ${desc}`); + + // Only recurse into children if we haven't reached maxDepth + if (currentDepth < maxDepth) { + const children = span.children ?? []; + const childCount = children.length; + children.forEach((child, i) => { + formatSpanSimple(child, { + lines, + prefix: childPrefix, + isLast: i === childCount - 1, + currentDepth: currentDepth + 1, + maxDepth, + }); + }); + } +} + +/** + * Format trace as simple tree (op — description). + * No durations, just hierarchy like Sentry's dashboard. + * + * @param traceId - The trace ID for the header + * @param spans - Root-level spans from the /trace/ API + * @param maxDepth - Maximum nesting depth to display (default: unlimited) + * @returns Array of formatted lines ready for display + */ +export function formatSimpleSpanTree( + traceId: string, + spans: TraceSpan[], + maxDepth = Number.MAX_SAFE_INTEGER +): string[] { + if (spans.length === 0) { + return [muted("No span data available.")]; + } + + const lines: string[] = []; + lines.push(`${muted("Trace —")} ${traceId}`); + + const spanCount = spans.length; + spans.forEach((span, i) => { + formatSpanSimple(span, { + lines, + prefix: "", + isLast: i === spanCount - 1, + currentDepth: 1, + maxDepth, + }); + }); + + return lines; +} + // ───────────────────────────────────────────────────────────────────────────── // Environment Context Formatting // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index 0c47b9e78..81e7bfa34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,6 +77,8 @@ export type { Stacktrace, TraceContext, TraceEvent, + TraceResponse, + TraceSpan, UserGeo, } from "./sentry.js"; // Sentry API types and schemas @@ -105,6 +107,7 @@ export { StacktraceSchema, TraceContextSchema, TraceEventSchema, + TraceResponseSchema, UserGeoSchema, } from "./sentry.js"; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index e5c221a14..20f714dee 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -288,6 +288,35 @@ export const TraceEventSchema = z export type TraceEvent = z.infer; +/** Response from /events-trace/{traceId}/ endpoint */ +export const TraceResponseSchema = z.object({ + /** Transactions with their nested children (span trees) */ + transactions: z.array(TraceEventSchema), + /** Errors not associated with any transaction */ + orphan_errors: z.array(z.unknown()).optional(), +}); + +export type TraceResponse = z.infer; + +/** + * Span from /trace/{traceId}/ endpoint with nested children. + * This endpoint returns a hierarchical structure unlike /events-trace/. + */ +export type TraceSpan = { + span_id: string; + parent_span_id?: string | null; + op?: string; + description?: string | null; + start_timestamp: number; + timestamp: number; + transaction?: string; + "transaction.op"?: string; + project_slug?: string; + event_id?: string; + /** Nested child spans */ + children?: TraceSpan[]; +}; + // ───────────────────────────────────────────────────────────────────────────── // Stack Frame & Exception Entry // ───────────────────────────────────────────────────────────────────────────── diff --git a/test/lib/formatters/span-tree.test.ts b/test/lib/formatters/span-tree.test.ts index 4b2ad2098..6ed6cf852 100644 --- a/test/lib/formatters/span-tree.test.ts +++ b/test/lib/formatters/span-tree.test.ts @@ -3,8 +3,23 @@ */ import { describe, expect, test } from "bun:test"; -import { formatSpanTree } from "../../../src/lib/formatters/human.js"; -import type { Span, TraceEvent } from "../../../src/types/index.js"; +import { + formatSimpleSpanTree, + formatSpanTree, +} from "../../../src/lib/formatters/human.js"; +import type { + Span, + TraceEvent, + TraceResponse, + TraceSpan, +} from "../../../src/types/index.js"; + +/** + * Helper to create a TraceResponse from trace events + */ +function makeTraceResponse(transactions: TraceEvent[]): TraceResponse { + return { transactions, orphan_errors: [] }; +} // Helper to strip ANSI codes for content testing function stripAnsi(str: string): string { @@ -73,13 +88,13 @@ function makeTraceEvent( describe("formatSpanTree", () => { describe("empty and edge cases", () => { test("returns message for empty trace events array", () => { - const result = formatSpanTree([]); + const result = formatSpanTree(makeTraceResponse([])); const output = stripAnsi(result.join("\n")); expect(output).toContain("No span data available"); }); test("handles trace event with no spans", () => { - const result = formatSpanTree([makeTraceEvent("1")]); + const result = formatSpanTree(makeTraceResponse([makeTraceEvent("1")])); const output = stripAnsi(result.join("\n")); expect(output).toContain("Transaction 1"); expect(output).toContain("100ms"); @@ -91,7 +106,7 @@ describe("formatSpanTree", () => { event_id: "1", "transaction.duration": 50, }; - const result = formatSpanTree([event]); + const result = formatSpanTree(makeTraceResponse([event])); const output = stripAnsi(result.join("\n")); expect(output).toContain("(unnamed transaction)"); }); @@ -101,7 +116,7 @@ describe("formatSpanTree", () => { event_id: "1", transaction: "Test", }; - const result = formatSpanTree([event]); + const result = formatSpanTree(makeTraceResponse([event])); const output = stripAnsi(result.join("\n")); expect(output).toContain("(unknown)"); }); @@ -111,7 +126,7 @@ describe("formatSpanTree", () => { event_id: "1", transaction: "Test", }; - const result = formatSpanTree([event]); + const result = formatSpanTree(makeTraceResponse([event])); const output = stripAnsi(result.join("\n")); expect(output).toContain("[?]"); }); @@ -120,14 +135,18 @@ describe("formatSpanTree", () => { describe("duration formatting", () => { test("formats milliseconds under 1 second as Xms", () => { const spans = [makeSpan("1", null, { durationSec: 0.5 })]; // 500ms - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("500ms"); }); test("formats seconds with 2 decimal places as X.XXs", () => { const spans = [makeSpan("1", null, { durationSec: 1.234 })]; // 1234ms - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("1.23s"); }); @@ -142,14 +161,18 @@ describe("formatSpanTree", () => { description: "Bad span", }, ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("[0ms]"); }); test("handles zero duration", () => { const spans = [makeSpan("1", null, { durationSec: 0 })]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("[0ms]"); }); @@ -162,7 +185,9 @@ describe("formatSpanTree", () => { makeSpan("b", null, { startTs: 1001.0 }), makeSpan("c", null, { startTs: 1002.0 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("Span a"); expect(output).toContain("Span b"); @@ -174,7 +199,9 @@ describe("formatSpanTree", () => { makeSpan("root", null, { startTs: 1000.0 }), makeSpan("child", "root", { startTs: 1000.1 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const lines = result.map(stripAnsi); // Find the lines containing our spans @@ -196,7 +223,9 @@ describe("formatSpanTree", () => { makeSpan("level2", "level1", { startTs: 1000.1 }), makeSpan("level3", "level2", { startTs: 1000.2 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const lines = result.map(stripAnsi); const level1Line = lines.find((l) => l.includes("Span level1")); @@ -218,7 +247,9 @@ describe("formatSpanTree", () => { test("treats orphaned spans as roots", () => { const spans = [makeSpan("orphan", "non-existent-parent")]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); // Orphan should appear as a root (not dropped) expect(output).toContain("Span orphan"); @@ -230,7 +261,9 @@ describe("formatSpanTree", () => { makeSpan("child1", "root1", { startTs: 1000.1 }), makeSpan("root2", null, { startTs: 1001.0 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("Span root1"); expect(output).toContain("Span child1"); @@ -244,7 +277,7 @@ describe("formatSpanTree", () => { makeTraceEvent("second", [], { startTs: 2000.0 }), makeTraceEvent("first", [], { startTs: 1000.0 }), ]; - const result = formatSpanTree(events); + const result = formatSpanTree(makeTraceResponse(events)); const output = stripAnsi(result.join("\n")); const firstIdx = output.indexOf("Transaction first"); @@ -258,7 +291,9 @@ describe("formatSpanTree", () => { makeSpan("a", null, { startTs: 1001.0 }), makeSpan("b", null, { startTs: 1002.0 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); const aIdx = output.indexOf("Span a"); @@ -274,7 +309,7 @@ describe("formatSpanTree", () => { { event_id: "2", transaction: "Second" }, { event_id: "1", transaction: "First", start_timestamp: 500.0 }, ]; - const result = formatSpanTree(events); + const result = formatSpanTree(makeTraceResponse(events)); const output = stripAnsi(result.join("\n")); // Event without timestamp (defaults to 0) should come first @@ -289,7 +324,9 @@ describe("formatSpanTree", () => { const longDesc = "This is a very long description that exceeds the maximum length allowed"; const spans = [makeSpan("1", null, { description: longDesc })]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); // Should be truncated with ... @@ -301,7 +338,9 @@ describe("formatSpanTree", () => { test("shows (no description) when description is null", () => { const spans = [makeSpan("1", null, { description: null })]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("(no description)"); }); @@ -315,7 +354,9 @@ describe("formatSpanTree", () => { description: "Test", }, ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = stripAnsi(result.join("\n")); expect(output).toContain("(unknown)"); }); @@ -325,7 +366,9 @@ describe("formatSpanTree", () => { makeSpan("first", null, { startTs: 1000.0 }), makeSpan("last", null, { startTs: 1001.0 }), ]; - const result = formatSpanTree([makeTraceEvent("1", spans)]); + const result = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", spans)]) + ); const output = result.join("\n"); // First sibling uses ├─, last sibling uses └─ @@ -334,7 +377,7 @@ describe("formatSpanTree", () => { }); test("includes Span Tree header", () => { - const result = formatSpanTree([makeTraceEvent("1")]); + const result = formatSpanTree(makeTraceResponse([makeTraceEvent("1")])); const output = stripAnsi(result.join("\n")); expect(output).toContain("Span Tree"); }); @@ -350,21 +393,25 @@ describe("formatSpanTree", () => { test("applies color styling for slow spans", () => { // Fast span (under 1s) - duration formatted but may not have color const fastSpans = [makeSpan("fast", null, { durationSec: 0.5 })]; - const fastResult = formatSpanTree([makeTraceEvent("1", fastSpans)]); + const fastResult = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", fastSpans)]) + ); const fastOutput = fastResult.join("\n"); expect(stripAnsi(fastOutput)).toContain("500ms"); // Slow span (over 1s) - should have yellow color applied const slowSpans = [makeSpan("slow", null, { durationSec: 2.0 })]; - const slowResult = formatSpanTree([makeTraceEvent("1", slowSpans)]); + const slowResult = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", slowSpans)]) + ); const slowOutput = slowResult.join("\n"); expect(stripAnsi(slowOutput)).toContain("2.00s"); // Very slow span (over 5s) - should have red color applied const verySlowSpans = [makeSpan("very-slow", null, { durationSec: 6.0 })]; - const verySlowResult = formatSpanTree([ - makeTraceEvent("1", verySlowSpans), - ]); + const verySlowResult = formatSpanTree( + makeTraceResponse([makeTraceEvent("1", verySlowSpans)]) + ); const verySlowOutput = verySlowResult.join("\n"); expect(stripAnsi(verySlowOutput)).toContain("6.00s"); @@ -383,7 +430,7 @@ describe("formatSpanTree", () => { makeTraceEvent("1", [], { startTs: 1000.0 }), makeTraceEvent("2", [], { startTs: 2000.0 }), ]; - const result = formatSpanTree(events); + const result = formatSpanTree(makeTraceResponse(events)); // Should have blank lines between transactions const blankLineCount = result.filter((line) => line === "").length; @@ -395,7 +442,7 @@ describe("formatSpanTree", () => { makeTraceEvent("1", [makeSpan("span-1")], { startTs: 1000.0 }), makeTraceEvent("2", [makeSpan("span-2")], { startTs: 2000.0 }), ]; - const result = formatSpanTree(events); + const result = formatSpanTree(makeTraceResponse(events)); const output = stripAnsi(result.join("\n")); expect(output).toContain("Transaction 1"); @@ -405,3 +452,193 @@ describe("formatSpanTree", () => { }); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Tests for formatSimpleSpanTree (new simple tree format) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Create a minimal TraceSpan for testing (with nested children support) + */ +function makeTraceSpan( + op: string, + description: string, + children: TraceSpan[] = [] +): TraceSpan { + return { + span_id: `span-${op}`, + op, + description, + start_timestamp: 1000.0, + timestamp: 1001.0, + children, + }; +} + +describe("formatSimpleSpanTree", () => { + describe("empty and edge cases", () => { + test("returns message for empty spans array", () => { + const result = formatSimpleSpanTree("trace-123", []); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("No span data available"); + }); + + test("includes trace ID in header", () => { + const spans = [makeTraceSpan("http.server", "GET /api")]; + const result = formatSimpleSpanTree("abc123def456", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("Trace —"); + expect(output).toContain("abc123def456"); + }); + }); + + describe("simple tree format", () => { + test("shows op — description format", () => { + const spans = [makeTraceSpan("http.server", "GET /api/users")]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("http.server"); + expect(output).toContain("—"); + expect(output).toContain("GET /api/users"); + }); + + test("does not show durations", () => { + const spans = [makeTraceSpan("db.query", "SELECT * FROM users")]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + // Should not contain any duration patterns like "100ms" or "1.00s" + expect(output).not.toMatch(/\d+ms/); + expect(output).not.toMatch(/\d+\.\d+s/); + }); + + test("handles missing op gracefully", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + description: "Some operation", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("unknown"); + expect(output).toContain("Some operation"); + }); + + test("handles missing description gracefully", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + op: "http.client", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("http.client"); + expect(output).toContain("(no description)"); + }); + + test("uses transaction as fallback for description", () => { + const spans: TraceSpan[] = [ + { + span_id: "1", + op: "cli", + transaction: "My CLI Command", + start_timestamp: 1000.0, + timestamp: 1001.0, + }, + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("My CLI Command"); + }); + }); + + describe("nested children", () => { + test("renders nested children with indentation", () => { + const spans = [ + makeTraceSpan("cli", "Spotlight CLI", [ + makeTraceSpan("cli.setup", "Setup Spotlight", [ + makeTraceSpan("cli.setup.assets", "Setup Server Assets"), + ]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const lines = result.map(stripAnsi); + + // Check that all spans are present + const output = lines.join("\n"); + expect(output).toContain("cli — Spotlight CLI"); + expect(output).toContain("cli.setup — Setup Spotlight"); + expect(output).toContain("cli.setup.assets — Setup Server Assets"); + + // Check indentation increases for nested children + const cliLine = lines.find((l) => l.includes("Spotlight CLI")); + const setupLine = lines.find((l) => l.includes("Setup Spotlight")); + const assetsLine = lines.find((l) => l.includes("Setup Server Assets")); + + expect(cliLine).toBeDefined(); + expect(setupLine).toBeDefined(); + expect(assetsLine).toBeDefined(); + + // Nested lines should have more leading whitespace + const cliIndent = cliLine?.match(/^\s*/)?.[0].length ?? 0; + const setupIndent = setupLine?.match(/^\s*/)?.[0].length ?? 0; + const assetsIndent = assetsLine?.match(/^\s*/)?.[0].length ?? 0; + + expect(setupIndent).toBeGreaterThan(cliIndent); + expect(assetsIndent).toBeGreaterThan(setupIndent); + }); + + test("handles multiple root spans", () => { + const spans = [ + makeTraceSpan("http.server", "POST /api"), + makeTraceSpan("db.query", "SELECT *"), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("http.server — POST /api"); + expect(output).toContain("db.query — SELECT *"); + }); + + test("handles deeply nested spans (3+ levels)", () => { + const spans = [ + makeTraceSpan("level1", "First", [ + makeTraceSpan("level2", "Second", [ + makeTraceSpan("level3", "Third", [ + makeTraceSpan("level4", "Fourth"), + ]), + ]), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = stripAnsi(result.join("\n")); + + expect(output).toContain("level1 — First"); + expect(output).toContain("level2 — Second"); + expect(output).toContain("level3 — Third"); + expect(output).toContain("level4 — Fourth"); + }); + }); + + describe("tree branch characters", () => { + test("uses tree branch characters", () => { + const spans = [ + makeTraceSpan("root", "Root Span", [ + makeTraceSpan("child1", "First Child"), + makeTraceSpan("child2", "Second Child"), + ]), + ]; + const result = formatSimpleSpanTree("trace-123", spans); + const output = result.join("\n"); + + // Should use tree branch characters + expect(output).toContain("└─"); + expect(output).toContain("├─"); + }); + }); +}); From b0d7896818b6dc22c7f45595135d1bac06c159e4 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 28 Jan 2026 13:49:48 +0530 Subject: [PATCH 5/8] chore: minor changes --- src/commands/event/view.ts | 13 +++++++------ src/commands/issue/view.ts | 21 ++++++++++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 364c108d9..5d619b9e3 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -24,7 +24,7 @@ type ViewFlags = { readonly project?: string; readonly json: boolean; readonly web: boolean; - readonly spans: boolean; + readonly spans?: number; }; type HumanOutputOptions = { @@ -103,12 +103,13 @@ export const viewCommand = buildCommand({ default: false, }, spans: { - kind: "boolean", + kind: "parsed", + parse: Number, brief: "Show span tree from the event's trace", - default: false, + optional: true, }, }, - aliases: { w: "web", s: "spans" }, + aliases: { w: "web" }, }, async func( this: SentryContext, @@ -144,7 +145,7 @@ export const viewCommand = buildCommand({ // Fetch span tree if requested and trace ID is available let spanTreeLines: string[] | undefined; - if (flags.spans && event.contexts?.trace?.trace_id) { + if (flags.spans !== undefined && event.contexts?.trace?.trace_id) { try { const traceEvents = await getTrace( target.org, @@ -155,7 +156,7 @@ export const viewCommand = buildCommand({ // Non-fatal: trace data may not be available for all events spanTreeLines = [muted("\nUnable to fetch span tree for this event.")]; } - } else if (flags.spans && !event.contexts?.trace?.trace_id) { + } else if (flags.spans !== undefined && !event.contexts?.trace?.trace_id) { spanTreeLines = [muted("\nNo trace data available for this event.")]; } diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 6c60df306..dcb3b6fea 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -28,7 +28,7 @@ import { interface ViewFlags extends IssueIdFlags { readonly json: boolean; readonly web: boolean; - readonly spans: number; + readonly spans?: number; } /** @@ -138,11 +138,13 @@ export const viewCommand = buildCommand({ default: false, }, spans: { - kind: "counter", - brief: "Show span tree (repeat for more depth: -s, -ss, -sss)", + kind: "parsed", + parse: Number, + brief: "Show span tree with N levels of nesting depth", + optional: true, }, }, - aliases: { w: "web", s: "spans" }, + aliases: { w: "web" }, }, async func( this: SentryContext, @@ -180,15 +182,16 @@ export const viewCommand = buildCommand({ // Normal human-readable output (issue + event details) writeHumanOutput(stdout, { issue, event }); - // If --spans flag is passed, show span tree (counter value = depth) - if (flags.spans > 0 && orgSlug && event) { + // If --spans flag is passed, show span tree with specified depth + if (flags.spans !== undefined && orgSlug && event) { + const depth = flags.spans > 0 ? flags.spans : Number.MAX_SAFE_INTEGER; stdout.write("\n"); - await displaySpanTree(stdout, orgSlug, event, flags.spans); - } else if (flags.spans > 0 && !orgSlug) { + await displaySpanTree(stdout, orgSlug, event, depth); + } else if (flags.spans !== undefined && !orgSlug) { stdout.write( muted("\nOrganization context required to fetch span tree.\n") ); - } else if (flags.spans > 0 && !event) { + } else if (flags.spans !== undefined && !event) { stdout.write(muted("\nCould not fetch event to display span tree.\n")); } From 24b43b3d9e59040ff38ada283377a7c9e90ce186 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 28 Jan 2026 13:53:35 +0530 Subject: [PATCH 6/8] chore: minor changes --- src/commands/event/view.ts | 3 ++- src/commands/issue/view.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 5d619b9e3..673a6f962 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -104,9 +104,10 @@ export const viewCommand = buildCommand({ }, spans: { kind: "parsed", - parse: Number, + parse: (input: string) => Number(input === "" ? 1 : input), brief: "Show span tree from the event's trace", optional: true, + inferEmpty: true, }, }, aliases: { w: "web" }, diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index dcb3b6fea..702d335ff 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -139,9 +139,10 @@ export const viewCommand = buildCommand({ }, spans: { kind: "parsed", - parse: Number, + parse: (input: string) => Number(input === "" ? 1 : input), brief: "Show span tree with N levels of nesting depth", optional: true, + inferEmpty: true, }, }, aliases: { w: "web" }, From 94e7eabfab8b5c01a33e3b68ce7e8255435e8241 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Wed, 28 Jan 2026 14:02:48 +0530 Subject: [PATCH 7/8] chore: minor changes --- src/commands/issue/view.ts | 9 ++++++-- src/lib/api-client.ts | 13 ++++------- src/lib/formatters/human.ts | 21 ++++++++++++----- test/lib/formatters/span-tree.test.ts | 33 +++++++++++++++++++++++---- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 702d335ff..8d93d5d16 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -38,11 +38,16 @@ interface ViewFlags extends IssueIdFlags { * @param orgSlug - Organization slug for API routing * @param issueId - Issue ID (numeric) */ -function tryGetLatestEvent( +async function tryGetLatestEvent( orgSlug: string, issueId: string ): Promise { - return getLatestEvent(orgSlug, issueId); + try { + return await getLatestEvent(orgSlug, issueId); + } catch { + // Non-blocking: event fetch failures shouldn't prevent issue display + return; + } } type HumanOutputOptions = { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0969fb390..c0d5fefa6 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -424,10 +424,7 @@ export function getLatestEvent( issueId: string ): Promise { return apiRequest( - `/organizations/${orgSlug}/issues/${issueId}/events/latest/`, - { - // schema: SentryEventSchema, - } + `/organizations/${orgSlug}/issues/${issueId}/events/latest/` ); } @@ -461,10 +458,7 @@ export function getTrace( traceId: string ): Promise { return apiRequest( - `/organizations/${orgSlug}/events-trace/${traceId}/`, - { - // schema: TraceResponseSchema, - } + `/organizations/${orgSlug}/events-trace/${traceId}/` ); } @@ -487,7 +481,10 @@ export function getDetailedTrace( { params: { timestamp, + // Maximum spans to fetch - 10k is sufficient for most traces while + // preventing excessive response sizes for very large traces limit: 10_000, + // -1 means "all projects" - required since trace can span multiple projects project: -1, }, } diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 839f90254..b54ca254f 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -806,6 +806,9 @@ function formatRequest(requestEntry: RequestEntry): string[] { // Span Tree Formatting // ───────────────────────────────────────────────────────────────────────────── +/** Maximum length for span descriptions before truncation */ +const SPAN_DESCRIPTION_MAX_LENGTH = 50; + /** Node in the span tree structure */ type SpanNode = { /** The span data */ @@ -819,9 +822,13 @@ type SpanNode = { * * @param startTs - Start timestamp (seconds with fractional ms) * @param endTs - End timestamp (seconds with fractional ms) - * @returns Formatted duration (e.g., "120ms", "1.50s") + * @returns Formatted duration (e.g., "120ms", "1.50s"), or "?" if timestamps are invalid */ function formatSpanDuration(startTs: number, endTs: number): string { + // Guard against invalid timestamps (NaN, undefined coerced to NaN, etc.) + if (!(Number.isFinite(startTs) && Number.isFinite(endTs))) { + return "?"; + } const durationMs = Math.max(0, Math.round((endTs - startTs) * 1000)); if (durationMs < 1000) { return `${durationMs}ms`; @@ -903,10 +910,9 @@ function formatSpanNode( const description = span.description || "(no description)"; // Truncate long descriptions - const maxDescLen = 50; const truncatedDesc = - description.length > maxDescLen - ? `${description.slice(0, maxDescLen - 3)}...` + description.length > SPAN_DESCRIPTION_MAX_LENGTH + ? `${description.slice(0, SPAN_DESCRIPTION_MAX_LENGTH - 3)}...` : description; // Tree branch characters @@ -1050,7 +1056,7 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { * * @param traceId - The trace ID for the header * @param spans - Root-level spans from the /trace/ API - * @param maxDepth - Maximum nesting depth to display (default: unlimited) + * @param maxDepth - Maximum nesting depth to display (default: unlimited). Values <= 0 show unlimited depth. * @returns Array of formatted lines ready for display */ export function formatSimpleSpanTree( @@ -1062,6 +1068,9 @@ export function formatSimpleSpanTree( return [muted("No span data available.")]; } + // Normalize maxDepth: treat non-positive values as unlimited + const effectiveMaxDepth = maxDepth > 0 ? maxDepth : Number.MAX_SAFE_INTEGER; + const lines: string[] = []; lines.push(`${muted("Trace —")} ${traceId}`); @@ -1072,7 +1081,7 @@ export function formatSimpleSpanTree( prefix: "", isLast: i === spanCount - 1, currentDepth: 1, - maxDepth, + maxDepth: effectiveMaxDepth, }); }); diff --git a/test/lib/formatters/span-tree.test.ts b/test/lib/formatters/span-tree.test.ts index 6ed6cf852..9614c76db 100644 --- a/test/lib/formatters/span-tree.test.ts +++ b/test/lib/formatters/span-tree.test.ts @@ -14,21 +14,35 @@ import type { TraceSpan, } from "../../../src/types/index.js"; +// ───────────────────────────────────────────────────────────────────────────── +// Test Helpers +// ───────────────────────────────────────────────────────────────────────────── +// Factory functions for creating test fixtures. These provide sensible defaults +// while allowing customization via options parameters. + /** - * Helper to create a TraceResponse from trace events + * Create a TraceResponse wrapper from an array of trace events. + * Used to match the API response structure. */ function makeTraceResponse(transactions: TraceEvent[]): TraceResponse { return { transactions, orphan_errors: [] }; } -// Helper to strip ANSI codes for content testing +/** + * Strip ANSI escape codes from a string for content assertions. + * Allows tests to verify text content without color interference. + */ function stripAnsi(str: string): string { // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI codes use control chars return str.replace(/\x1b\[[0-9;]*m/g, ""); } /** - * Create a minimal span for testing + * Create a minimal Span for testing tree building and formatting. + * + * @param id - Unique span identifier + * @param parentId - Parent span ID (null for root spans) + * @param options - Override defaults for duration, timestamps, op, description */ function makeSpan( id: string, @@ -57,7 +71,11 @@ function makeSpan( } /** - * Create a minimal trace event for testing + * Create a minimal TraceEvent (transaction) for testing. + * + * @param id - Event ID + * @param spans - Child spans within this transaction + * @param options - Override defaults for timestamps, transaction name, op, duration */ function makeTraceEvent( id: string, @@ -458,7 +476,12 @@ describe("formatSpanTree", () => { // ───────────────────────────────────────────────────────────────────────────── /** - * Create a minimal TraceSpan for testing (with nested children support) + * Create a minimal TraceSpan for testing the simple span tree format. + * TraceSpan differs from Span in that it has nested children (hierarchical structure). + * + * @param op - Operation name (e.g., "http.server", "db.query") + * @param description - Human-readable description of the span + * @param children - Nested child spans (already in tree form) */ function makeTraceSpan( op: string, From 52cf219491ad4dbed43fa74647707d208a592008 Mon Sep 17 00:00:00 2001 From: mathuraditya724 Date: Thu, 29 Jan 2026 01:42:21 +0530 Subject: [PATCH 8/8] fix: desloped --- src/commands/event/view.ts | 2 -- src/commands/issue/view.ts | 3 --- src/lib/formatters/human.ts | 18 +----------------- 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 673a6f962..60e008b0f 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -48,7 +48,6 @@ function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { const output = lines.slice(1); stdout.write(`${output.join("\n")}\n`); - // Display span tree if available if (spanTreeLines && spanTreeLines.length > 0) { stdout.write(`${spanTreeLines.join("\n")}\n`); } @@ -144,7 +143,6 @@ export const viewCommand = buildCommand({ const event = await getEvent(target.org, target.project, eventId); - // Fetch span tree if requested and trace ID is available let spanTreeLines: string[] | undefined; if (flags.spans !== undefined && event.contexts?.trace?.trace_id) { try { diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 8d93d5d16..62eb84b4d 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -178,17 +178,14 @@ export const viewCommand = buildCommand({ ? await tryGetLatestEvent(orgSlug, issue.id) : undefined; - // JSON output if (flags.json) { const output = event ? { issue, event } : { issue }; writeJson(stdout, output); return; } - // Normal human-readable output (issue + event details) writeHumanOutput(stdout, { issue, event }); - // If --spans flag is passed, show span tree with specified depth if (flags.spans !== undefined && orgSlug && event) { const depth = flags.spans > 0 ? flags.spans : Number.MAX_SAFE_INTEGER; stdout.write("\n"); diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index b54ca254f..bad54b4c8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -825,7 +825,6 @@ type SpanNode = { * @returns Formatted duration (e.g., "120ms", "1.50s"), or "?" if timestamps are invalid */ function formatSpanDuration(startTs: number, endTs: number): string { - // Guard against invalid timestamps (NaN, undefined coerced to NaN, etc.) if (!(Number.isFinite(startTs) && Number.isFinite(endTs))) { return "?"; } @@ -848,12 +847,10 @@ function buildSpanTree(spans: Span[]): SpanNode[] { const spanMap = new Map(); const roots: SpanNode[] = []; - // Create nodes for all spans for (const span of spans) { spanMap.set(span.span_id, { span, children: [] }); } - // Link children to parents for (const span of spans) { const node = spanMap.get(span.span_id); if (!node) { @@ -865,12 +862,10 @@ function buildSpanTree(spans: Span[]): SpanNode[] { if (parentNode) { parentNode.children.push(node); } else { - // No parent found in response - treat as root (true root or orphan) roots.push(node); } } - // Sort children by start_timestamp for chronological order const sortChildren = (node: SpanNode): void => { node.children.sort( (a, b) => a.span.start_timestamp - b.span.start_timestamp @@ -883,7 +878,6 @@ function buildSpanTree(spans: Span[]): SpanNode[] { sortChildren(root); } - // Sort roots as well roots.sort((a, b) => a.span.start_timestamp - b.span.start_timestamp); return roots; @@ -909,17 +903,14 @@ function formatSpanNode( const op = span.op || "unknown"; const description = span.description || "(no description)"; - // Truncate long descriptions const truncatedDesc = description.length > SPAN_DESCRIPTION_MAX_LENGTH ? `${description.slice(0, SPAN_DESCRIPTION_MAX_LENGTH - 3)}...` : description; - // Tree branch characters const branch = isLast ? "└─" : "├─"; const childPrefix = prefix + (isLast ? " " : "│ "); - // Color duration based on severity (slow = yellow/red) const durationMs = (span.timestamp - span.start_timestamp) * 1000; let durationText = duration; if (durationMs > 5000) { @@ -932,7 +923,6 @@ function formatSpanNode( `${prefix}${branch} [${durationText}] ${truncatedDesc} ${muted(`(${op})`)}` ); - // Format children const childCount = node.children.length; node.children.forEach((child, i) => { const childIsLast = i === childCount - 1; @@ -951,7 +941,6 @@ function formatSpanNode( function formatTraceEventAsTree(traceEvent: TraceEvent): string[] { const lines: string[] = []; - // Transaction header const txName = traceEvent.transaction || "(unnamed transaction)"; const op = traceEvent["transaction.op"] || "unknown"; const durationMs = traceEvent["transaction.duration"]; @@ -959,7 +948,6 @@ function formatTraceEventAsTree(traceEvent: TraceEvent): string[] { lines.push(`[${duration}] ${txName} ${muted(`(${op})`)}`); - // Build span tree from the transaction's spans const spans = traceEvent.spans ?? []; if (spans.length > 0) { const tree = buildSpanTree(spans); @@ -992,7 +980,6 @@ export function formatSpanTree(traceResponse: TraceResponse): string[] { lines.push(muted("─── Span Tree ───")); lines.push(""); - // Sort trace events by start_timestamp for chronological order const sorted = [...traceEvents].sort((a, b) => { const aStart = a.start_timestamp ?? 0; const bStart = b.start_timestamp ?? 0; @@ -1001,7 +988,7 @@ export function formatSpanTree(traceResponse: TraceResponse): string[] { for (const traceEvent of sorted) { lines.push(...formatTraceEventAsTree(traceEvent)); - lines.push(""); // Blank line between transactions + lines.push(""); } return lines; @@ -1028,13 +1015,11 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void { const op = span.op || span["transaction.op"] || "unknown"; const desc = span.description || span.transaction || "(no description)"; - // Tree branch characters const branch = isLast ? "└─" : "├─"; const childPrefix = prefix + (isLast ? " " : "│ "); lines.push(`${prefix}${branch} ${muted(op)} — ${desc}`); - // Only recurse into children if we haven't reached maxDepth if (currentDepth < maxDepth) { const children = span.children ?? []; const childCount = children.length; @@ -1068,7 +1053,6 @@ export function formatSimpleSpanTree( return [muted("No span data available.")]; } - // Normalize maxDepth: treat non-positive values as unlimited const effectiveMaxDepth = maxDepth > 0 ? maxDepth : Number.MAX_SAFE_INTEGER; const lines: string[] = [];