Skip to content

Commit 6e59442

Browse files
committed
feat(formatters): add plain output mode with isTTY detection and env var overrides
Gate all markdown rendering behind isPlainOutput(): skip marked.parse() and return raw CommonMark when stdout is not a TTY. Override with env vars: - SENTRY_PLAIN_OUTPUT=1/0 — explicit project-specific control (highest priority) - NO_COLOR=1/0 — widely-supported standard (secondary) - process.stdout.isTTY — auto-detect (fallback) Both env vars treat '0', 'false', '' as falsy; everything else as truthy (case-insensitive). SENTRY_PLAIN_OUTPUT takes precedence over NO_COLOR. Add renderInlineMarkdown() using marked.parseInline() for inline-only rendering of individual cell values without block wrapping. Migrate streaming formatters to dual-mode output: - formatLogRow / formatLogsHeader: plain emits markdown table rows/header - formatTraceRow / formatTracesHeader: same This means piping to a file produces valid CommonMark; live TTY sessions get the existing ANSI-rendered output unchanged.
1 parent 0a3c651 commit 6e59442

File tree

6 files changed

+602
-18
lines changed

6 files changed

+602
-18
lines changed

src/lib/formatters/log.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
import type { DetailedSentryLog, SentryLog } from "../../types/index.js";
88
import { buildTraceUrl } from "../sentry-urls.js";
99
import { cyan, muted, red, yellow } from "./colors.js";
10-
import { escapeMarkdownCell, renderMarkdown } from "./markdown.js";
10+
import {
11+
escapeMarkdownCell,
12+
isPlainOutput,
13+
renderInlineMarkdown,
14+
renderMarkdown,
15+
} from "./markdown.js";
1116

1217
/** Color functions for log severity levels */
1318
const SEVERITY_COLORS: Record<string, (text: string) => string> = {
@@ -55,27 +60,45 @@ function formatTimestamp(timestamp: string): string {
5560
/**
5661
* Format a single log entry for human-readable output.
5762
*
58-
* Format: "TIMESTAMP SEVERITY MESSAGE [trace_id]"
59-
* Example: "2024-01-30 14:32:15 ERROR Failed to connect [abc12345]"
63+
* In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table
64+
* row so streamed output composes into a valid CommonMark document.
65+
* In rendered mode (TTY): emits padded ANSI-colored text for live display.
6066
*
6167
* @param log - The log entry to format
6268
* @returns Formatted log line with newline
6369
*/
6470
export function formatLogRow(log: SentryLog): string {
71+
if (isPlainOutput()) {
72+
const timestamp = formatTimestamp(log.timestamp);
73+
const severity = renderInlineMarkdown(
74+
`**${(log.severity ?? "info").toUpperCase()}**`
75+
);
76+
const message = escapeMarkdownCell(log.message ?? "");
77+
const trace = log.trace
78+
? ` ${renderInlineMarkdown(`\`[${log.trace.slice(0, 8)}]\``)}`
79+
: "";
80+
return `| ${timestamp} | ${severity} | ${message}${trace} |\n`;
81+
}
82+
6583
const timestamp = formatTimestamp(log.timestamp);
6684
const severity = formatSeverity(log.severity);
6785
const message = log.message ?? "";
6886
const trace = log.trace ? muted(` [${log.trace.slice(0, 8)}]`) : "";
69-
7087
return `${timestamp} ${severity} ${message}${trace}\n`;
7188
}
7289

7390
/**
7491
* Format column header for logs list (used in streaming/follow mode).
7592
*
76-
* @returns Header line with column titles and separator
93+
* In plain mode: emits a markdown table header + separator row.
94+
* In rendered mode: emits an ANSI-muted text header with a rule separator.
95+
*
96+
* @returns Header string (includes trailing newline)
7797
*/
7898
export function formatLogsHeader(): string {
99+
if (isPlainOutput()) {
100+
return "| Timestamp | Level | Message |\n| --- | --- | --- |\n";
101+
}
79102
const header = muted("TIMESTAMP LEVEL MESSAGE");
80103
return `${header}\n${muted("─".repeat(80))}\n`;
81104
}

src/lib/formatters/markdown.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,22 @@
22
* Markdown-to-Terminal Renderer
33
*
44
* Central utility for rendering markdown content as styled terminal output
5-
* using `marked` + `marked-terminal`. Provides a single `renderMarkdown()`
6-
* function that all formatters can use for rich text output.
5+
* using `marked` + `marked-terminal`. Provides `renderMarkdown()` and
6+
* `renderInlineMarkdown()` for rich text output, with automatic plain-mode
7+
* fallback when stdout is not a TTY or the user has opted out of rich output.
78
*
89
* Pre-rendered ANSI escape codes embedded in markdown source (e.g. inside
910
* table cells) survive the pipeline — `cli-table3` computes column widths
1011
* via `string-width`, which correctly treats ANSI codes as zero-width.
12+
*
13+
* ## Output mode resolution (highest → lowest priority)
14+
*
15+
* 1. `SENTRY_PLAIN_OUTPUT=1` → plain (raw CommonMark)
16+
* 2. `SENTRY_PLAIN_OUTPUT=0` → rendered (force rich, even when piped)
17+
* 3. `NO_COLOR=1` (or any truthy value) → plain
18+
* 4. `NO_COLOR=0` (or any falsy value) → rendered
19+
* 5. `!process.stdout.isTTY` → plain
20+
* 6. default (TTY, no overrides) → rendered
1121
*/
1222

1323
import chalk from "chalk";
@@ -67,6 +77,43 @@ marked.use(
6777
})
6878
);
6979

80+
/**
81+
* Returns true if an env var value should be treated as "truthy" for
82+
* purposes of enabling/disabling output modes.
83+
*
84+
* Falsy values: `"0"`, `"false"`, `""` (case-insensitive).
85+
* Everything else (e.g. `"1"`, `"true"`, `"yes"`) is truthy.
86+
*/
87+
function isTruthyEnv(val: string): boolean {
88+
const normalized = val.toLowerCase().trim();
89+
return normalized !== "0" && normalized !== "false" && normalized !== "";
90+
}
91+
92+
/**
93+
* Determines whether output should be plain CommonMark markdown (no ANSI).
94+
*
95+
* Evaluated fresh on each call so tests can flip env vars between assertions
96+
* and changes to `process.stdout.isTTY` are picked up immediately.
97+
*
98+
* Priority (highest first):
99+
* 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override
100+
* 2. `NO_COLOR` — widely-supported standard for disabling styled output
101+
* 3. `process.stdout.isTTY` — auto-detect interactive terminal
102+
*/
103+
export function isPlainOutput(): boolean {
104+
const plain = process.env.SENTRY_PLAIN_OUTPUT;
105+
if (plain !== undefined) {
106+
return isTruthyEnv(plain);
107+
}
108+
109+
const noColor = process.env.NO_COLOR;
110+
if (noColor !== undefined) {
111+
return isTruthyEnv(noColor);
112+
}
113+
114+
return !process.stdout.isTTY;
115+
}
116+
70117
/**
71118
* Escape a string for safe use inside a markdown table cell.
72119
*
@@ -81,7 +128,8 @@ export function escapeMarkdownCell(value: string): string {
81128
}
82129

83130
/**
84-
* Render a markdown string as styled terminal output.
131+
* Render a full markdown document as styled terminal output, or return the
132+
* raw CommonMark string when in plain mode.
85133
*
86134
* Supports the full CommonMark spec:
87135
* - Headings, bold, italic, strikethrough
@@ -96,8 +144,30 @@ export function escapeMarkdownCell(value: string): string {
96144
* Pre-rendered ANSI escape codes in the input are preserved.
97145
*
98146
* @param md - Markdown source text
99-
* @returns Styled terminal string with trailing whitespace trimmed
147+
* @returns Styled terminal string (TTY) or raw CommonMark (non-TTY / plain mode)
100148
*/
101149
export function renderMarkdown(md: string): string {
150+
if (isPlainOutput()) {
151+
return md.trimEnd();
152+
}
102153
return (marked.parse(md) as string).trimEnd();
103154
}
155+
156+
/**
157+
* Render inline markdown (bold, code spans, emphasis, links) as styled
158+
* terminal output, or return the raw markdown string when in plain mode.
159+
*
160+
* Unlike `renderMarkdown()`, this uses `marked.parseInline()` which handles
161+
* only inline-level constructs — no paragraph wrapping, no block elements.
162+
* Suitable for styling individual table cell values in streaming formatters
163+
* that write rows incrementally rather than as a complete table.
164+
*
165+
* @param md - Inline markdown text
166+
* @returns Styled string (TTY) or raw markdown text (non-TTY / plain mode)
167+
*/
168+
export function renderInlineMarkdown(md: string): string {
169+
if (isPlainOutput()) {
170+
return md;
171+
}
172+
return marked.parseInline(md) as string;
173+
}

src/lib/formatters/trace.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
import type { TraceSpan, TransactionListItem } from "../../types/index.js";
88
import { muted } from "./colors.js";
99
import { formatRelativeTime } from "./human.js";
10-
import { escapeMarkdownCell, renderMarkdown } from "./markdown.js";
10+
import {
11+
escapeMarkdownCell,
12+
isPlainOutput,
13+
renderInlineMarkdown,
14+
renderMarkdown,
15+
} from "./markdown.js";
1116

1217
/**
1318
* Format a duration in milliseconds to a human-readable string.
@@ -44,9 +49,15 @@ export function formatTraceDuration(ms: number): string {
4449
/**
4550
* Format column header for traces list (used before per-row output).
4651
*
47-
* @returns Header line with column titles and separator
52+
* In plain mode: emits a markdown table header + separator row.
53+
* In rendered mode: emits an ANSI-muted text header with a rule separator.
54+
*
55+
* @returns Header string (includes trailing newline)
4856
*/
4957
export function formatTracesHeader(): string {
58+
if (isPlainOutput()) {
59+
return "| Trace ID | Transaction | Duration | When |\n| --- | --- | ---: | --- |\n";
60+
}
5061
const header = muted(
5162
"TRACE ID TRANSACTION DURATION WHEN"
5263
);
@@ -65,10 +76,22 @@ const DURATION_WIDTH = 10;
6576
/**
6677
* Format a single transaction row for the traces list.
6778
*
79+
* In plain mode (non-TTY / `SENTRY_PLAIN_OUTPUT=1`): emits a markdown table
80+
* row so streamed output composes into a valid CommonMark document.
81+
* In rendered mode (TTY): emits padded ANSI-colored text for live display.
82+
*
6883
* @param item - Transaction list item from the API
6984
* @returns Formatted row string with newline
7085
*/
7186
export function formatTraceRow(item: TransactionListItem): string {
87+
if (isPlainOutput()) {
88+
const traceId = renderInlineMarkdown(`\`${item.trace}\``);
89+
const transaction = escapeMarkdownCell(item.transaction || "unknown");
90+
const duration = formatTraceDuration(item["transaction.duration"]);
91+
const when = formatRelativeTime(item.timestamp).trim();
92+
return `| ${traceId} | ${transaction} | ${duration} | ${when} |\n`;
93+
}
94+
7295
const traceId = item.trace.slice(0, TRACE_ID_WIDTH).padEnd(TRACE_ID_WIDTH);
7396
const transaction = (item.transaction || "unknown")
7497
.slice(0, MAX_TRANSACTION_LENGTH)
@@ -77,7 +100,6 @@ export function formatTraceRow(item: TransactionListItem): string {
77100
DURATION_WIDTH
78101
);
79102
const when = formatRelativeTime(item.timestamp);
80-
81103
return `${traceId} ${transaction} ${duration} ${when}\n`;
82104
}
83105

test/lib/formatters/log.test.ts

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,46 @@
22
* Tests for log formatters
33
*/
44

5-
import { describe, expect, test } from "bun:test";
5+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
66
import {
77
formatLogDetails,
88
formatLogRow,
99
formatLogsHeader,
1010
} from "../../../src/lib/formatters/log.js";
1111
import type { DetailedSentryLog, SentryLog } from "../../../src/types/index.js";
1212

13+
/** Force rendered (TTY) mode for a describe block */
14+
function useRenderedMode() {
15+
let savedPlain: string | undefined;
16+
beforeEach(() => {
17+
savedPlain = process.env.SENTRY_PLAIN_OUTPUT;
18+
process.env.SENTRY_PLAIN_OUTPUT = "0";
19+
});
20+
afterEach(() => {
21+
if (savedPlain === undefined) {
22+
delete process.env.SENTRY_PLAIN_OUTPUT;
23+
} else {
24+
process.env.SENTRY_PLAIN_OUTPUT = savedPlain;
25+
}
26+
});
27+
}
28+
29+
/** Force plain mode for a describe block */
30+
function usePlainMode() {
31+
let savedPlain: string | undefined;
32+
beforeEach(() => {
33+
savedPlain = process.env.SENTRY_PLAIN_OUTPUT;
34+
process.env.SENTRY_PLAIN_OUTPUT = "1";
35+
});
36+
afterEach(() => {
37+
if (savedPlain === undefined) {
38+
delete process.env.SENTRY_PLAIN_OUTPUT;
39+
} else {
40+
process.env.SENTRY_PLAIN_OUTPUT = savedPlain;
41+
}
42+
});
43+
}
44+
1345
function createTestLog(overrides: Partial<SentryLog> = {}): SentryLog {
1446
return {
1547
"sentry.item_id": "test-id-123",
@@ -28,7 +60,9 @@ function stripAnsi(str: string): string {
2860
return str.replace(/\x1b\[[0-9;]*m/g, "");
2961
}
3062

31-
describe("formatLogRow", () => {
63+
describe("formatLogRow (rendered mode)", () => {
64+
useRenderedMode();
65+
3266
test("formats basic log entry", () => {
3367
const log = createTestLog();
3468
const result = formatLogRow(log);
@@ -116,7 +150,9 @@ describe("formatLogRow", () => {
116150
});
117151
});
118152

119-
describe("formatLogsHeader", () => {
153+
describe("formatLogsHeader (rendered mode)", () => {
154+
useRenderedMode();
155+
120156
test("contains column titles", () => {
121157
const result = stripAnsi(formatLogsHeader());
122158

@@ -138,6 +174,65 @@ describe("formatLogsHeader", () => {
138174
});
139175
});
140176

177+
describe("formatLogRow (plain mode)", () => {
178+
usePlainMode();
179+
180+
test("emits a markdown table row", () => {
181+
const log = createTestLog();
182+
const result = formatLogRow(log);
183+
expect(result).toMatch(/^\|.+\|.+\|.+\|\n$/);
184+
});
185+
186+
test("contains timestamp, severity, message", () => {
187+
const log = createTestLog({
188+
severity: "error",
189+
message: "connection failed",
190+
});
191+
const result = formatLogRow(log);
192+
expect(result).toContain("connection failed");
193+
expect(result).toContain("ERROR");
194+
expect(result).toMatch(/\d{4}-\d{2}-\d{2}/);
195+
});
196+
197+
test("contains trace ID as inline code", () => {
198+
const log = createTestLog({ trace: "abc123def456" });
199+
const result = formatLogRow(log);
200+
expect(result).toContain("[abc123de]");
201+
});
202+
203+
test("omits trace cell when trace is null", () => {
204+
const log = createTestLog({ trace: null });
205+
const result = formatLogRow(log);
206+
expect(result).not.toContain("[");
207+
});
208+
209+
test("escapes pipe characters in message", () => {
210+
const log = createTestLog({ message: "a|b" });
211+
const result = formatLogRow(log);
212+
// Raw pipe in message must be escaped so it doesn't break the table
213+
expect(result).toContain("a\\|b");
214+
});
215+
216+
test("ends with newline", () => {
217+
const result = formatLogRow(createTestLog());
218+
expect(result).toEndWith("\n");
219+
});
220+
});
221+
222+
describe("formatLogsHeader (plain mode)", () => {
223+
usePlainMode();
224+
225+
test("emits markdown table header and separator", () => {
226+
const result = formatLogsHeader();
227+
expect(result).toContain("| Timestamp | Level | Message |");
228+
expect(result).toContain("| --- | --- | --- |");
229+
});
230+
231+
test("ends with newline", () => {
232+
expect(formatLogsHeader()).toEndWith("\n");
233+
});
234+
});
235+
141236
function createDetailedTestLog(
142237
overrides: Partial<DetailedSentryLog> = {}
143238
): DetailedSentryLog {

0 commit comments

Comments
 (0)