Skip to content

Commit 302bb2f

Browse files
committed
feat(formatters): add StreamingTable for bordered streaming log/trace output
Add StreamingTable class to text-table.ts that renders incrementally: header (top border + column names + separator), row-by-row with side borders, and footer (bottom border) on stream end. Log streaming (--follow) now uses bordered tables in TTY mode: - createLogStreamingTable() factory with pre-configured column hints - SIGINT handler prints bottom border before exit - Plain mode (non-TTY) still emits raw markdown rows for pipe safety Trace streaming gets createTraceStreamingTable() factory (not yet wired to command — traces don't have --follow yet, but the factory is ready). Extract buildLogRowCells() and export buildTraceRowCells() so both streaming (StreamingTable.row) and batch (formatLogTable/formatTraceTable) paths share the same cell-building logic.
1 parent 6f6fc17 commit 302bb2f

File tree

5 files changed

+326
-57
lines changed

5 files changed

+326
-57
lines changed

src/commands/log/list.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import { listLogs } from "../../lib/api-client.js";
1212
import { validateLimit } from "../../lib/arg-parsing.js";
1313
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
1414
import {
15+
buildLogRowCells,
16+
createLogStreamingTable,
1517
formatLogRow,
1618
formatLogsHeader,
1719
formatLogTable,
20+
isPlainOutput,
1821
writeFooter,
1922
writeJson,
2023
} from "../../lib/formatters/index.js";
@@ -74,12 +77,24 @@ function parseFollow(value: string): number {
7477

7578
/**
7679
* Write logs to output in the appropriate format.
80+
*
81+
* When a StreamingTable is provided (TTY mode), renders rows through the
82+
* bordered table. Otherwise falls back to plain markdown rows.
7783
*/
78-
function writeLogs(stdout: Writer, logs: SentryLog[], asJson: boolean): void {
84+
function writeLogs(
85+
stdout: Writer,
86+
logs: SentryLog[],
87+
asJson: boolean,
88+
table?: import("../../lib/formatters/text-table.js").StreamingTable
89+
): void {
7990
if (asJson) {
8091
for (const log of logs) {
8192
writeJson(stdout, log);
8293
}
94+
} else if (table) {
95+
for (const log of logs) {
96+
stdout.write(table.row(buildLogRowCells(log)));
97+
}
8398
} else {
8499
for (const log of logs) {
85100
stdout.write(formatLogRow(log));
@@ -158,7 +173,12 @@ async function executeFollowMode(options: FollowModeOptions): Promise<void> {
158173
stderr.write("\n");
159174
}
160175

161-
// Track if header has been printed (for human mode)
176+
// In TTY mode, use a bordered StreamingTable for aligned columns.
177+
// In plain mode, use raw markdown rows for pipe-friendly output.
178+
const plain = flags.json || isPlainOutput();
179+
const table = plain ? undefined : createLogStreamingTable();
180+
181+
// Track if header has been printed (for human/plain mode)
162182
let headerPrinted = false;
163183

164184
// Initial fetch: only last minute for follow mode (we want recent logs, not historical)
@@ -170,13 +190,21 @@ async function executeFollowMode(options: FollowModeOptions): Promise<void> {
170190

171191
// Print header before initial logs (human mode only)
172192
if (!flags.json && initialLogs.length > 0) {
173-
stdout.write(formatLogsHeader());
193+
stdout.write(table ? table.header() : formatLogsHeader());
174194
headerPrinted = true;
175195
}
176196

177197
// Reverse for chronological order (API returns newest first, tail -f shows oldest first)
178198
const chronologicalInitial = [...initialLogs].reverse();
179-
writeLogs(stdout, chronologicalInitial, flags.json);
199+
writeLogs(stdout, chronologicalInitial, flags.json, table);
200+
201+
// Print bottom border on Ctrl+C so the table closes cleanly
202+
if (table) {
203+
process.once("SIGINT", () => {
204+
stdout.write(table.footer());
205+
process.exit(0);
206+
});
207+
}
180208

181209
// Track newest timestamp (logs are sorted -timestamp, so first is newest)
182210
// Use current time as fallback to avoid fetching old logs when initial fetch is empty
@@ -200,13 +228,13 @@ async function executeFollowMode(options: FollowModeOptions): Promise<void> {
200228
if (newestLog) {
201229
// Print header before first logs if not already printed
202230
if (!(flags.json || headerPrinted)) {
203-
stdout.write(formatLogsHeader());
231+
stdout.write(table ? table.header() : formatLogsHeader());
204232
headerPrinted = true;
205233
}
206234

207235
// Reverse for chronological order (oldest first for tail -f style)
208236
const chronologicalNew = [...newLogs].reverse();
209-
writeLogs(stdout, chronologicalNew, flags.json);
237+
writeLogs(stdout, chronologicalNew, flags.json, table);
210238

211239
// Update timestamp AFTER successful write to avoid losing logs on write failure
212240
lastTimestamp = newestLog.timestamp_precise;

src/lib/formatters/log.ts

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,14 @@ import type { DetailedSentryLog, SentryLog } from "../../types/index.js";
88
import { buildTraceUrl } from "../sentry-urls.js";
99
import {
1010
colorTag,
11-
divider,
1211
escapeMarkdownCell,
1312
escapeMarkdownInline,
14-
isPlainOutput,
1513
mdKvTable,
1614
mdRow,
1715
mdTableHeader,
1816
renderMarkdown,
1917
} from "./markdown.js";
18+
import { StreamingTable, type StreamingTableOptions } from "./text-table.js";
2019

2120
/** Markdown color tag names for log severity levels */
2221
const SEVERITY_TAGS: Record<string, Parameters<typeof colorTag>[0]> = {
@@ -66,39 +65,70 @@ function formatTimestamp(timestamp: string): string {
6665
}
6766

6867
/**
69-
* Format a single log entry for human-readable output.
68+
* Extract cell values for a log row (shared by streaming and batch paths).
7069
*
71-
* In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table
72-
* row so streamed output composes into a valid CommonMark document.
73-
* In rendered mode (TTY): emits padded ANSI-colored text for live display.
70+
* @param log - The log entry
71+
* @param padSeverity - Whether to pad severity to 7 chars for alignment
72+
* @returns `[timestamp, severity, message]` strings
73+
*/
74+
export function buildLogRowCells(
75+
log: SentryLog,
76+
padSeverity = true
77+
): [string, string, string] {
78+
const timestamp = formatTimestamp(log.timestamp);
79+
const level = padSeverity
80+
? formatSeverity(log.severity)
81+
: formatSeverityLabel(log.severity);
82+
const message = escapeMarkdownCell(log.message ?? "");
83+
const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : "";
84+
return [timestamp, level, `${message}${trace}`];
85+
}
86+
87+
/**
88+
* Format a single log entry as a plain markdown table row.
89+
* Used for non-TTY / piped output where StreamingTable isn't appropriate.
7490
*
7591
* @param log - The log entry to format
7692
* @returns Formatted log line with newline
7793
*/
7894
export function formatLogRow(log: SentryLog): string {
79-
const timestamp = formatTimestamp(log.timestamp);
80-
// Use formatSeverity() for per-level ANSI color (red/yellow/cyan/muted),
81-
// matching the batch-mode formatLogTable path.
82-
const level = formatSeverity(log.severity);
83-
const message = escapeMarkdownCell(log.message ?? "");
84-
const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : "";
85-
return mdRow([timestamp, level, `${message}${trace}`]);
95+
return mdRow(buildLogRowCells(log));
96+
}
97+
98+
/** Hint rows for column width estimation in streaming mode. */
99+
const LOG_HINT_ROWS: string[][] = [
100+
["2026-01-15 23:59:59", "WARNING", "A typical log message with some detail"],
101+
];
102+
103+
/**
104+
* Create a StreamingTable configured for log output.
105+
*
106+
* @param options - Override default table options
107+
* @returns A StreamingTable with log-specific column configuration
108+
*/
109+
export function createLogStreamingTable(
110+
options: Partial<StreamingTableOptions> = {}
111+
): StreamingTable {
112+
return new StreamingTable([...LOG_TABLE_COLS], {
113+
hintRows: LOG_HINT_ROWS,
114+
// Timestamp and Level are fixed-width; Message gets the rest
115+
shrinkable: [false, false, true],
116+
truncate: false,
117+
...options,
118+
});
86119
}
87120

88121
/**
89-
* Format column header for logs list (used in streaming/follow mode).
122+
* Format column header for logs list in plain (non-TTY) mode.
90123
*
91-
* In plain mode: emits a proper markdown table header + separator row so that
124+
* Emits a proper markdown table header + separator row so that
92125
* the streamed rows compose into a valid CommonMark document when redirected.
93-
* In rendered mode: emits an ANSI-muted text header with a rule separator.
126+
* In TTY mode, use {@link createLogStreamingTable} instead.
94127
*
95128
* @returns Header string (includes trailing newline)
96129
*/
97130
export function formatLogsHeader(): string {
98-
if (isPlainOutput()) {
99-
return `${mdTableHeader(LOG_TABLE_COLS)}\n`;
100-
}
101-
return `${mdRow(LOG_TABLE_COLS.map((c) => `**${c}**`))}${divider(80)}\n`;
131+
return `${mdTableHeader(LOG_TABLE_COLS)}\n`;
102132
}
103133

104134
/**
@@ -111,16 +141,7 @@ export function formatLogsHeader(): string {
111141
*/
112142
export function formatLogTable(logs: SentryLog[]): string {
113143
const rows = logs
114-
.map((log) => {
115-
const timestamp = formatTimestamp(log.timestamp);
116-
// formatSeverity wraps the padEnd label inside a color tag, so .trim()
117-
// on the result would be a no-op. Use formatSeverityLabel (no padding)
118-
// for the batch table which handles its own column sizing.
119-
const severity = formatSeverityLabel(log.severity);
120-
const message = escapeMarkdownCell(log.message ?? "");
121-
const trace = log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : "";
122-
return mdRow([timestamp, severity, `${message}${trace}`]).trimEnd();
123-
})
144+
.map((log) => mdRow(buildLogRowCells(log, false)).trimEnd())
124145
.join("\n");
125146

126147
return renderMarkdown(`${mdTableHeader(LOG_TABLE_COLS)}\n${rows}`);

src/lib/formatters/text-table.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,178 @@ function horizontalLine(
537537
const segments = columnWidths.map((w) => chars.horizontal.repeat(w));
538538
return `${chars.left}${segments.join(chars.junction)}${chars.right}`;
539539
}
540+
541+
/** Options for creating a streaming table. */
542+
export type StreamingTableOptions = {
543+
/** Border style. @default "rounded" */
544+
borderStyle?: BorderStyle;
545+
/** Horizontal cell padding (each side). @default 1 */
546+
cellPadding?: number;
547+
/** Maximum table width in columns. @default process.stdout.columns or 80 */
548+
maxWidth?: number;
549+
/** Per-column alignment (indexed by column). Defaults to "left". */
550+
alignments?: Array<Alignment | null>;
551+
/** Per-column minimum content widths. Columns will not shrink below these. */
552+
minWidths?: number[];
553+
/** Per-column shrinkable flags. Non-shrinkable columns keep intrinsic width. */
554+
shrinkable?: boolean[];
555+
/** Truncate cells to one line with "…" instead of wrapping. @default true */
556+
truncate?: boolean;
557+
/**
558+
* Hint rows used for column width measurement.
559+
* Pass representative sample data so column widths are computed correctly
560+
* without needing the full dataset upfront.
561+
*/
562+
hintRows?: string[][];
563+
};
564+
565+
/**
566+
* A bordered table that renders incrementally — header first, then one row
567+
* at a time, then a bottom border at the end. Column widths are fixed at
568+
* construction time based on headers + optional hint rows.
569+
*
570+
* Usage:
571+
* ```ts
572+
* const table = new StreamingTable(["Time", "Level", "Message"], opts);
573+
* writer.write(table.header());
574+
* writer.write(table.row(["2026-02-28 10:00", "ERROR", "something broke"]));
575+
* writer.write(table.footer());
576+
* ```
577+
*
578+
* In plain-output mode (non-TTY), emits raw CommonMark markdown table syntax
579+
* so piped/redirected output remains a valid document.
580+
*/
581+
export class StreamingTable {
582+
/** @internal */ readonly columnWidths: number[];
583+
/** @internal */ readonly border: BorderCharacters;
584+
/** @internal */ readonly cellPadding: number;
585+
/** @internal */ readonly alignments: Array<Alignment | null>;
586+
/** @internal */ readonly headers: string[];
587+
/** @internal */ readonly truncate: boolean;
588+
589+
constructor(headers: string[], options: StreamingTableOptions = {}) {
590+
const {
591+
borderStyle = "rounded",
592+
cellPadding = 1,
593+
maxWidth = process.stdout.columns || 80,
594+
alignments = [],
595+
minWidths = [],
596+
shrinkable = [],
597+
truncate = true,
598+
hintRows = [],
599+
} = options;
600+
601+
this.headers = headers;
602+
this.border = BorderChars[borderStyle];
603+
this.cellPadding = cellPadding;
604+
this.alignments = alignments;
605+
this.truncate = truncate;
606+
607+
const colCount = headers.length;
608+
const intrinsicWidths = measureIntrinsicWidths(
609+
headers,
610+
hintRows,
611+
colCount,
612+
{ cellPadding, minWidths }
613+
);
614+
615+
const borderOverhead = 2 + (colCount - 1);
616+
const maxContentWidth = Math.max(colCount, maxWidth - borderOverhead);
617+
this.columnWidths = fitColumns(intrinsicWidths, maxContentWidth, {
618+
cellPadding,
619+
fitter: "balanced",
620+
minWidths,
621+
shrinkable,
622+
});
623+
}
624+
625+
/**
626+
* Render the top border, header row, and header separator.
627+
* Call once at the start of streaming.
628+
*/
629+
header(): string {
630+
const { border, columnWidths, cellPadding, alignments, headers } = this;
631+
const hz = border.horizontal;
632+
const lines: string[] = [];
633+
634+
// Top border
635+
lines.push(
636+
horizontalLine(columnWidths, {
637+
left: border.topLeft,
638+
junction: border.topT,
639+
right: border.topRight,
640+
horizontal: hz,
641+
})
642+
);
643+
644+
// Header cells
645+
const wrappedHeader = wrapRow(headers, columnWidths, cellPadding, false);
646+
const rowHeight = Math.max(1, ...wrappedHeader.map((c) => c.length));
647+
for (let line = 0; line < rowHeight; line++) {
648+
const cellTexts: string[] = [];
649+
for (let c = 0; c < columnWidths.length; c++) {
650+
const cellLines = wrappedHeader[c] ?? [""];
651+
const text = cellLines[line] ?? "";
652+
const align = alignments[c] ?? "left";
653+
const colW = columnWidths[c] ?? 3;
654+
cellTexts.push(padCell(text, colW, align, cellPadding));
655+
}
656+
lines.push(
657+
`${border.vertical}${cellTexts.join(border.vertical)}${border.vertical}`
658+
);
659+
}
660+
661+
// Header separator
662+
lines.push(
663+
horizontalLine(columnWidths, {
664+
left: border.leftT,
665+
junction: border.cross,
666+
right: border.rightT,
667+
horizontal: hz,
668+
})
669+
);
670+
671+
return `${lines.join("\n")}\n`;
672+
}
673+
674+
/**
675+
* Render a single data row with side borders.
676+
* Call once per data item as it arrives.
677+
*/
678+
row(cells: string[]): string {
679+
const { border, columnWidths, cellPadding, alignments, truncate } = this;
680+
const wrappedCells = wrapRow(cells, columnWidths, cellPadding, truncate);
681+
const rowHeight = Math.max(1, ...wrappedCells.map((c) => c.length));
682+
const lines: string[] = [];
683+
684+
for (let line = 0; line < rowHeight; line++) {
685+
const cellTexts: string[] = [];
686+
for (let c = 0; c < columnWidths.length; c++) {
687+
const cellLines = wrappedCells[c] ?? [""];
688+
const text = cellLines[line] ?? "";
689+
const align = alignments[c] ?? "left";
690+
const colW = columnWidths[c] ?? 3;
691+
cellTexts.push(padCell(text, colW, align, cellPadding));
692+
}
693+
lines.push(
694+
`${border.vertical}${cellTexts.join(border.vertical)}${border.vertical}`
695+
);
696+
}
697+
698+
return `${lines.join("\n")}\n`;
699+
}
700+
701+
/**
702+
* Render the bottom border.
703+
* Call once when the stream ends.
704+
*/
705+
footer(): string {
706+
const { border, columnWidths } = this;
707+
return `${horizontalLine(columnWidths, {
708+
left: border.bottomLeft,
709+
junction: border.bottomT,
710+
right: border.bottomRight,
711+
horizontal: border.horizontal,
712+
})}\n`;
713+
}
714+
}

0 commit comments

Comments
 (0)