diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index cf5906628..60e008b0f 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,34 @@ type ViewFlags = { readonly project?: string; readonly json: boolean; readonly web: boolean; + readonly spans?: number; +}; + +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`); + if (spanTreeLines && spanTreeLines.length > 0) { + stdout.write(`${spanTreeLines.join("\n")}\n`); + } + if (detectedFrom) { stdout.write(`\nDetected from ${detectedFrom}\n`); } @@ -88,6 +101,13 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + spans: { + kind: "parsed", + parse: (input: string) => Number(input === "" ? 1 : input), + brief: "Show span tree from the event's trace", + optional: true, + inferEmpty: true, + }, }, aliases: { w: "web" }, }, @@ -123,11 +143,31 @@ export const viewCommand = buildCommand({ const event = await getEvent(target.org, target.project, eventId); + let spanTreeLines: string[] | undefined; + if (flags.spans !== undefined && 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 !== undefined && !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..62eb84b4d 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 { getDetailedTrace, getLatestEvent } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; import { formatEventDetails, formatIssueDetails, + formatSimpleSpanTree, + 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?: number; } /** @@ -42,18 +45,22 @@ async function tryGetLatestEvent( try { return await getLatestEvent(orgSlug, issueId); } catch { + // Non-blocking: event fetch failures shouldn't prevent issue display return; } } +type HumanOutputOptions = { + issue: SentryIssue; + event?: SentryEvent; +}; + /** * Write human-readable issue output */ -function writeHumanOutput( - stdout: Writer, - issue: SentryIssue, - event?: SentryEvent -): void { +function writeHumanOutput(stdout: Writer, options: HumanOutputOptions): void { + const { issue, event } = options; + const issueLines = formatIssueDetails(issue); stdout.write(`${issueLines.join("\n")}\n`); @@ -68,6 +75,44 @@ function writeHumanOutput( } } +/** + * 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; + } + + 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; + } +} + export const viewCommand = buildCommand({ docs: { brief: "View details of a specific issue", @@ -97,6 +142,13 @@ export const viewCommand = buildCommand({ brief: "Open in browser", default: false, }, + spans: { + kind: "parsed", + parse: (input: string) => Number(input === "" ? 1 : input), + brief: "Show span tree with N levels of nesting depth", + optional: true, + inferEmpty: true, + }, }, aliases: { w: "web" }, }, @@ -132,7 +184,20 @@ export const viewCommand = buildCommand({ return; } - writeHumanOutput(stdout, issue, event); + writeHumanOutput(stdout, { issue, event }); + + 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, depth); + } else if (flags.spans !== undefined && !orgSlug) { + stdout.write( + muted("\nOrganization context required to fetch span tree.\n") + ); + } else if (flags.spans !== undefined && !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 6dbcf0c3d..c0d5fefa6 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -16,6 +16,8 @@ import { SentryOrganizationSchema, type SentryProject, SentryProjectSchema, + type TraceResponse, + type TraceSpan, } from "../types/index.js"; import type { AutofixResponse, AutofixState } from "../types/seer.js"; import { refreshToken } from "./config.js"; @@ -422,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/` ); } @@ -446,6 +445,52 @@ 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 Trace response with transactions array and orphan_errors + */ +export function getTrace( + orgSlug: string, + traceId: string +): Promise { + return apiRequest( + `/organizations/${orgSlug}/events-trace/${traceId}/` + ); +} + +/** + * 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, + // 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, + }, + } + ); +} + /** * Update an issue's status */ diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 910b72b3d..bad54b4c8 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -16,7 +16,11 @@ import type { SentryIssue, SentryOrganization, SentryProject, + Span, StackFrame, + TraceEvent, + TraceResponse, + TraceSpan, } from "../../types/index.js"; import { boldUnderline, @@ -798,6 +802,276 @@ function formatRequest(requestEntry: RequestEntry): string[] { return lines; } +// ───────────────────────────────────────────────────────────────────────────── +// 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 */ + 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"), or "?" if timestamps are invalid + */ +function formatSpanDuration(startTs: number, endTs: number): string { + if (!(Number.isFinite(startTs) && Number.isFinite(endTs))) { + return "?"; + } + const durationMs = Math.max(0, 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. + * Spans without a found parent are treated as roots (handles orphans gracefully). + * + * @param spans - Flat array of spans + * @returns Array of root-level span nodes with nested children + */ +function buildSpanTree(spans: Span[]): SpanNode[] { + const spanMap = new Map(); + const roots: SpanNode[] = []; + + for (const span of spans) { + spanMap.set(span.span_id, { span, children: [] }); + } + + 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 { + roots.push(node); + } + } + + 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); + } + + 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)"; + + const truncatedDesc = + description.length > SPAN_DESCRIPTION_MAX_LENGTH + ? `${description.slice(0, SPAN_DESCRIPTION_MAX_LENGTH - 3)}...` + : description; + + const branch = isLast ? "└─" : "├─"; + const childPrefix = prefix + (isLast ? " " : "│ "); + + const durationMs = (span.timestamp - span.start_timestamp) * 1000; + let durationText = duration; + if (durationMs > 5000) { + durationText = red(duration); + } else if (durationMs > 1000) { + durationText = yellow(duration); + } + + lines.push( + `${prefix}${branch} [${durationText}] ${truncatedDesc} ${muted(`(${op})`)}` + ); + + 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[] = []; + + 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})`)}`); + + const spans = traceEvent.spans ?? []; + if (spans.length > 0) { + const tree = buildSpanTree(spans); + 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 traceResponse - Response from the events-trace API containing transactions + * @returns Array of formatted lines ready for display + */ +export function formatSpanTree(traceResponse: TraceResponse): string[] { + const traceEvents = traceResponse.transactions; + + if (traceEvents.length === 0) { + return [muted("\nNo span data available.")]; + } + + const lines: string[] = []; + lines.push(""); + lines.push(muted("─── Span Tree ───")); + lines.push(""); + + 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(""); + } + + 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)"; + + const branch = isLast ? "└─" : "├─"; + const childPrefix = prefix + (isLast ? " " : "│ "); + + lines.push(`${prefix}${branch} ${muted(op)} — ${desc}`); + + 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). Values <= 0 show unlimited depth. + * @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 effectiveMaxDepth = maxDepth > 0 ? maxDepth : Number.MAX_SAFE_INTEGER; + + 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: effectiveMaxDepth, + }); + }); + + return lines; +} + // ───────────────────────────────────────────────────────────────────────────── // Environment Context Formatting // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/types/index.ts b/src/types/index.ts index 3f68dffde..81e7bfa34 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -71,9 +71,14 @@ export type { // Organization & Project SentryOrganization, SentryProject, + // Span/Trace types + Span, StackFrame, Stacktrace, TraceContext, + TraceEvent, + TraceResponse, + TraceSpan, UserGeo, } from "./sentry.js"; // Sentry API types and schemas @@ -97,9 +102,12 @@ export { SentryIssueSchema, SentryOrganizationSchema, SentryProjectSchema, + SpanSchema, StackFrameSchema, StacktraceSchema, TraceContextSchema, + TraceEventSchema, + TraceResponseSchema, UserGeoSchema, } from "./sentry.js"; diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 9c4f93164..20f714dee 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -239,6 +239,84 @@ 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; + +/** 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 new file mode 100644 index 000000000..9614c76db --- /dev/null +++ b/test/lib/formatters/span-tree.test.ts @@ -0,0 +1,667 @@ +/** + * Tests for span tree formatting + */ + +import { describe, expect, test } from "bun:test"; +import { + formatSimpleSpanTree, + formatSpanTree, +} from "../../../src/lib/formatters/human.js"; +import type { + Span, + TraceEvent, + TraceResponse, + TraceSpan, +} from "../../../src/types/index.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Test Helpers +// ───────────────────────────────────────────────────────────────────────────── +// Factory functions for creating test fixtures. These provide sensible defaults +// while allowing customization via options parameters. + +/** + * 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: [] }; +} + +/** + * 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 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, + 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 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, + 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(makeTraceResponse([])); + const output = stripAnsi(result.join("\n")); + expect(output).toContain("No span data available"); + }); + + test("handles trace event with no spans", () => { + const result = formatSpanTree(makeTraceResponse([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(makeTraceResponse([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(makeTraceResponse([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(makeTraceResponse([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( + 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( + makeTraceResponse([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( + 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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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(makeTraceResponse(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( + makeTraceResponse([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(makeTraceResponse(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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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( + makeTraceResponse([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(makeTraceResponse([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( + 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( + 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( + makeTraceResponse([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(makeTraceResponse(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(makeTraceResponse(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"); + }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Tests for formatSimpleSpanTree (new simple tree format) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * 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, + 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("├─"); + }); + }); +});