Skip to content

Commit 7bc947c

Browse files
committed
refactor: migrate span commands to output config + fix markdown rendering
Migrate span list/view from imperative output to auto-rendered OutputConfig pattern, and fix plain-mode ANSI leaks in span tree/ancestor formatting. **Output system migration (span/list.ts, span/view.ts):** - Replace `output: "json"` shorthand with `output: { json: true, human: fn, jsonTransform: fn }` - Extract human formatters (formatSpanListHuman, formatSpanViewHuman) that return strings instead of writing to stdout directly - Extract JSON transforms (jsonTransformSpanList, jsonTransformSpanView) for the { data: [...], hasMore } envelope and --fields filtering - Return `{ data, hint }` from func() so the wrapper handles rendering - Remove manual `if (flags.json)` branching and direct stdout writes **Markdown rendering fixes (trace.ts, human.ts):** - formatAncestorChain: replace raw `muted()` (chalk ANSI) with `colorTag("muted", ...)` + `renderMarkdown()` so output respects NO_COLOR/isPlainOutput/non-TTY - formatSimpleSpanTree/formatSpanSimple: replace `muted()` with `plainSafeMuted()` that checks `isPlainOutput()` before applying ANSI - Span list header now renders via `renderMarkdown()` for proper styling **Formatter exports (trace.ts):** - Export SPAN_TABLE_COLUMNS for use by span/list formatter - Add formatSpanTable() return-based wrapper around formatTable()
1 parent 91424c0 commit 7bc947c

File tree

4 files changed

+178
-86
lines changed

4 files changed

+178
-86
lines changed

src/commands/span/list.ts

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@ import {
1414
import { buildCommand } from "../../lib/command.js";
1515
import { ContextError, ValidationError } from "../../lib/errors.js";
1616
import {
17+
type FlatSpan,
18+
formatSpanTable,
1719
spanListItemToFlatSpan,
1820
translateSpanQuery,
19-
writeFooter,
20-
writeJsonList,
21-
writeSpanTable,
2221
} from "../../lib/formatters/index.js";
22+
import { filterFields } from "../../lib/formatters/json.js";
23+
import { renderMarkdown } from "../../lib/formatters/markdown.js";
2324
import {
2425
applyFreshFlag,
2526
FRESH_ALIASES,
@@ -119,6 +120,50 @@ export function parseSort(value: string): SortValue {
119120
return value as SortValue;
120121
}
121122

123+
// ---------------------------------------------------------------------------
124+
// Output config types and formatters
125+
// ---------------------------------------------------------------------------
126+
127+
/** Structured data returned by the command for both JSON and human output */
128+
type SpanListData = {
129+
/** Flattened span items for display */
130+
flatSpans: FlatSpan[];
131+
/** Whether more results are available beyond the limit */
132+
hasMore: boolean;
133+
/** The trace ID being queried */
134+
traceId: string;
135+
};
136+
137+
/**
138+
* Format span list data for human-readable terminal output.
139+
*
140+
* Uses `renderMarkdown()` for the header and `formatSpanTable()` for the table,
141+
* ensuring proper rendering in both TTY and plain output modes.
142+
*/
143+
function formatSpanListHuman(data: SpanListData): string {
144+
if (data.flatSpans.length === 0) {
145+
return "No spans matched the query.";
146+
}
147+
const parts: string[] = [];
148+
parts.push(renderMarkdown(`Spans in trace \`${data.traceId}\`:\n`));
149+
parts.push(formatSpanTable(data.flatSpans));
150+
return parts.join("\n");
151+
}
152+
153+
/**
154+
* Transform span list data for JSON output.
155+
*
156+
* Produces a `{ data: [...], hasMore }` envelope matching the standard
157+
* paginated list format. Applies `--fields` filtering per element.
158+
*/
159+
function jsonTransformSpanList(data: SpanListData, fields?: string[]): unknown {
160+
const items =
161+
fields && fields.length > 0
162+
? data.flatSpans.map((item) => filterFields(item, fields))
163+
: data.flatSpans;
164+
return { data: items, hasMore: data.hasMore };
165+
}
166+
122167
export const listCommand = buildCommand({
123168
docs: {
124169
brief: "List spans in a trace",
@@ -136,7 +181,11 @@ export const listCommand = buildCommand({
136181
" sentry span list <trace-id> --sort duration # Sort by slowest first\n" +
137182
' sentry span list <trace-id> -q "duration:>100ms" # Spans slower than 100ms',
138183
},
139-
output: "json",
184+
output: {
185+
json: true,
186+
human: formatSpanListHuman,
187+
jsonTransform: jsonTransformSpanList,
188+
},
140189
parameters: {
141190
positional: {
142191
kind: "array",
@@ -176,13 +225,9 @@ export const listCommand = buildCommand({
176225
s: "sort",
177226
},
178227
},
179-
async func(
180-
this: SentryContext,
181-
flags: ListFlags,
182-
...args: string[]
183-
): Promise<void> {
228+
async func(this: SentryContext, flags: ListFlags, ...args: string[]) {
184229
applyFreshFlag(flags);
185-
const { stdout, cwd, setContext } = this;
230+
const { cwd, setContext } = this;
186231
const log = logger.withTag("span.list");
187232

188233
// Parse positional args
@@ -250,32 +295,15 @@ export const listCommand = buildCommand({
250295
const flatSpans = spanItems.map(spanListItemToFlatSpan);
251296
const hasMore = nextCursor !== undefined;
252297

253-
if (flags.json) {
254-
writeJsonList(stdout, flatSpans, {
255-
hasMore,
256-
fields: flags.fields,
257-
});
258-
return;
298+
// Build hint footer
299+
let hint: string | undefined;
300+
if (flatSpans.length > 0) {
301+
const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`;
302+
hint = hasMore
303+
? `${countText} Use --limit to see more.`
304+
: `${countText} Use 'sentry span view <span-id> --trace ${traceId}' to view span details.`;
259305
}
260306

261-
if (flatSpans.length === 0) {
262-
stdout.write("No spans matched the query.\n");
263-
return;
264-
}
265-
266-
stdout.write(`Spans in trace ${traceId}:\n\n`);
267-
writeSpanTable(stdout, flatSpans);
268-
269-
// Footer
270-
const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`;
271-
272-
if (hasMore) {
273-
writeFooter(stdout, `${countText} Use --limit to see more.`);
274-
} else {
275-
writeFooter(
276-
stdout,
277-
`${countText} Use 'sentry span view <span-id> --trace ${traceId}' to view span details.`
278-
);
279-
}
307+
return { data: { flatSpans, hasMore, traceId }, hint };
280308
},
281309
});

src/commands/span/view.ts

Lines changed: 72 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ import {
1818
findSpanById,
1919
formatSimpleSpanTree,
2020
formatSpanDetails,
21-
writeJson,
2221
} from "../../lib/formatters/index.js";
22+
import { filterFields } from "../../lib/formatters/json.js";
2323
import {
2424
applyFreshFlag,
2525
FRESH_ALIASES,
@@ -187,14 +187,28 @@ async function resolveTarget(
187187
}
188188
}
189189

190+
// ---------------------------------------------------------------------------
191+
// Output config types and formatters
192+
// ---------------------------------------------------------------------------
193+
190194
/** Resolved span result from tree search. */
191195
type SpanResult = FoundSpan & { spanId: string };
192196

197+
/** Structured data returned by the command for both JSON and human output */
198+
type SpanViewData = {
199+
/** Found span results with ancestors and depth */
200+
results: SpanResult[];
201+
/** The trace ID for context */
202+
traceId: string;
203+
/** Maximum child tree depth to display (from --spans flag) */
204+
spansDepth: number;
205+
};
206+
193207
/**
194208
* Serialize span results for JSON output.
195209
*/
196-
function buildJsonResults(results: SpanResult[], traceId: string): unknown {
197-
const mapped = results.map((r) => ({
210+
function buildJsonResults(results: SpanResult[], traceId: string): unknown[] {
211+
return results.map((r) => ({
198212
span_id: r.span.span_id,
199213
parent_span_id: r.span.parent_span_id,
200214
trace_id: traceId,
@@ -217,6 +231,51 @@ function buildJsonResults(results: SpanResult[], traceId: string): unknown {
217231
description: c.description || c.transaction,
218232
})),
219233
}));
234+
}
235+
236+
/**
237+
* Format span view data for human-readable terminal output.
238+
*
239+
* Renders each span's details (KV table + ancestor chain) and optionally
240+
* shows the child span tree. Multiple spans are separated by `---`.
241+
*/
242+
function formatSpanViewHuman(data: SpanViewData): string {
243+
const parts: string[] = [];
244+
for (let i = 0; i < data.results.length; i++) {
245+
if (i > 0) {
246+
parts.push("\n---\n");
247+
}
248+
const result = data.results[i];
249+
if (!result) {
250+
continue;
251+
}
252+
parts.push(formatSpanDetails(result.span, result.ancestors, data.traceId));
253+
254+
// Show child tree if --spans > 0 and the span has children
255+
const children = result.span.children ?? [];
256+
if (data.spansDepth > 0 && children.length > 0) {
257+
const treeLines = formatSimpleSpanTree(
258+
data.traceId,
259+
[result.span],
260+
data.spansDepth
261+
);
262+
if (treeLines.length > 0) {
263+
parts.push(`${treeLines.join("\n")}\n`);
264+
}
265+
}
266+
}
267+
return parts.join("");
268+
}
269+
270+
/**
271+
* Transform span view data for JSON output.
272+
* Applies `--fields` filtering per element.
273+
*/
274+
function jsonTransformSpanView(data: SpanViewData, fields?: string[]): unknown {
275+
const mapped = buildJsonResults(data.results, data.traceId);
276+
if (fields && fields.length > 0) {
277+
return mapped.map((item) => filterFields(item, fields));
278+
}
220279
return mapped;
221280
}
222281

@@ -235,7 +294,11 @@ export const viewCommand = buildCommand({
235294
" sentry span view a1b2c3d4e5f67890 --trace <trace-id>\n" +
236295
" sentry span view a1b2c3d4e5f67890 b2c3d4e5f6789012 --trace <trace-id>",
237296
},
238-
output: "json",
297+
output: {
298+
json: true,
299+
human: formatSpanViewHuman,
300+
jsonTransform: jsonTransformSpanView,
301+
},
239302
parameters: {
240303
positional: {
241304
kind: "array",
@@ -257,14 +320,9 @@ export const viewCommand = buildCommand({
257320
},
258321
aliases: { ...FRESH_ALIASES, t: "trace" },
259322
},
260-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: view command with multi-span support
261-
async func(
262-
this: SentryContext,
263-
flags: ViewFlags,
264-
...args: string[]
265-
): Promise<void> {
323+
async func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
266324
applyFreshFlag(flags);
267-
const { stdout, cwd, setContext } = this;
325+
const { cwd, setContext } = this;
268326
const cmdLog = logger.withTag("span.view");
269327

270328
const traceId = flags.trace;
@@ -323,33 +381,8 @@ export const viewCommand = buildCommand({
323381

324382
warnMissingIds(spanIds, foundIds);
325383

326-
if (flags.json) {
327-
writeJson(stdout, buildJsonResults(results, traceId), flags.fields);
328-
return;
329-
}
330-
331-
// Human output
332-
let first = true;
333-
for (const result of results) {
334-
if (!first) {
335-
stdout.write("\n---\n\n");
336-
}
337-
stdout.write(formatSpanDetails(result.span, result.ancestors, traceId));
338-
339-
// Show child tree if --spans > 0 and the span has children
340-
const children = result.span.children ?? [];
341-
if (flags.spans > 0 && children.length > 0) {
342-
const treeLines = formatSimpleSpanTree(
343-
traceId,
344-
[result.span],
345-
flags.spans
346-
);
347-
if (treeLines.length > 0) {
348-
stdout.write(`${treeLines.join("\n")}\n`);
349-
}
350-
}
351-
352-
first = false;
353-
}
384+
return {
385+
data: { results, traceId, spansDepth: flags.spans },
386+
};
354387
},
355388
});

src/lib/formatters/human.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
colorTag,
3131
escapeMarkdownCell,
3232
escapeMarkdownInline,
33+
isPlainOutput,
3334
mdKvTable,
3435
mdRow,
3536
mdTableHeader,
@@ -1077,6 +1078,17 @@ function buildRequestMarkdown(requestEntry: RequestEntry): string {
10771078

10781079
// Span Tree Formatting
10791080

1081+
/**
1082+
* Apply muted styling only in TTY/colored mode.
1083+
*
1084+
* Tree output uses box-drawing characters and indentation that can't go
1085+
* through full `renderMarkdown()`. This helper ensures no raw ANSI escapes
1086+
* leak when `NO_COLOR` is set, output is piped, or `isPlainOutput()` is true.
1087+
*/
1088+
function plainSafeMuted(text: string): string {
1089+
return isPlainOutput() ? text : muted(text);
1090+
}
1091+
10801092
type FormatSpanOptions = {
10811093
lines: string[];
10821094
prefix: string;
@@ -1098,14 +1110,14 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void {
10981110
const branch = isLast ? "└─" : "├─";
10991111
const childPrefix = prefix + (isLast ? " " : "│ ");
11001112

1101-
let line = `${prefix}${branch} ${muted(op)}${desc}`;
1113+
let line = `${prefix}${branch} ${plainSafeMuted(op)}${desc}`;
11021114

11031115
const durationMs = computeSpanDurationMs(span);
11041116
if (durationMs !== undefined) {
1105-
line += ` ${muted(`(${prettyMs(durationMs)})`)}`;
1117+
line += ` ${plainSafeMuted(`(${prettyMs(durationMs)})`)}`;
11061118
}
11071119

1108-
line += ` ${muted(span.span_id)}`;
1120+
line += ` ${plainSafeMuted(span.span_id)}`;
11091121

11101122
lines.push(line);
11111123

@@ -1161,9 +1173,9 @@ export function formatSimpleSpanTree(
11611173

11621174
const lines: string[] = [];
11631175
lines.push("");
1164-
lines.push(muted("─── Span Tree ───"));
1176+
lines.push(plainSafeMuted("─── Span Tree ───"));
11651177
lines.push("");
1166-
lines.push(`${muted("Trace —")} ${traceId}`);
1178+
lines.push(`${plainSafeMuted("Trace —")} ${traceId}`);
11671179

11681180
const totalRootSpans = spans.length;
11691181
const truncated = totalRootSpans > MAX_ROOT_SPANS;
@@ -1183,7 +1195,7 @@ export function formatSimpleSpanTree(
11831195
if (truncated) {
11841196
const remaining = totalRootSpans - MAX_ROOT_SPANS;
11851197
lines.push(
1186-
`└─ ${muted(`... ${remaining} more root span${remaining === 1 ? "" : "s"} (${totalRootSpans} total). Use --json to see all.`)}`
1198+
`└─ ${plainSafeMuted(`... ${remaining} more root span${remaining === 1 ? "" : "s"} (${totalRootSpans} total). Use --json to see all.`)}`
11871199
);
11881200
}
11891201

0 commit comments

Comments
 (0)