Skip to content

Commit 376c7b4

Browse files
committed
feat(logger): support JSON log output
Adds an opt-in `jsonOutput` mode that emits a single-line JSON object per log call instead of ANSI-coloured text. Each entry carries the `level` ("debug" | "info" | "warn" | "error") and `message`; when `shouldIncludeTimestamp` is also set, an ISO `timestamp` field is included alongside. This addresses the case in #1991 where a Datadog ingestion pipeline (or any other log consumer) sees every line as unstructured text and defaults `error` records to `info`. The third constructor parameter is now `boolean | LoggerOptions`. The boolean form preserves the existing call sites verbatim (`new Logger(stdout, INFO, true)`); the options form unlocks the new `jsonOutput` flag and remains forward-compatible. A `setJsonOutput` runtime toggle is also exposed alongside the existing `setShouldIncludeTime`. Tests cover both modes — log-level gating, timestamp inclusion / omission, ANSI-stripping in JSON mode, and a JSON→text toggle.
1 parent 58b9b84 commit 376c7b4

2 files changed

Lines changed: 185 additions & 11 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import {Logger, LogLevel} from '../logger'
2+
3+
describe('Logger', () => {
4+
const collect = () => {
5+
const lines: string[] = []
6+
const write = (s: string) => {
7+
lines.push(s)
8+
}
9+
10+
return {lines, write}
11+
}
12+
13+
describe('text mode (default)', () => {
14+
it('writes each level with a trailing newline', () => {
15+
const {lines, write} = collect()
16+
const logger = new Logger(write, LogLevel.DEBUG)
17+
18+
logger.debug('d')
19+
logger.info('i')
20+
logger.warn('w')
21+
logger.error('e')
22+
23+
expect(lines).toHaveLength(4)
24+
expect(lines.every((line) => line.endsWith('\n'))).toBe(true)
25+
})
26+
27+
it('respects the configured log level', () => {
28+
const {lines, write} = collect()
29+
const logger = new Logger(write, LogLevel.WARN)
30+
31+
logger.debug('d')
32+
logger.info('i')
33+
logger.warn('w')
34+
logger.error('e')
35+
36+
expect(lines).toHaveLength(2)
37+
})
38+
39+
it('prefixes lines with an ISO timestamp when enabled (legacy boolean)', () => {
40+
const {lines, write} = collect()
41+
const logger = new Logger(write, LogLevel.INFO, true)
42+
43+
logger.info('hello')
44+
45+
expect(lines[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.+: hello\n$/)
46+
})
47+
48+
it('accepts the options-bag form for the timestamp flag', () => {
49+
const {lines, write} = collect()
50+
const logger = new Logger(write, LogLevel.INFO, {shouldIncludeTimestamp: true})
51+
52+
logger.info('hello')
53+
54+
expect(lines[0]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.+: hello\n$/)
55+
})
56+
})
57+
58+
describe('JSON mode', () => {
59+
it('emits a single-line JSON object per call with the correct level', () => {
60+
const {lines, write} = collect()
61+
const logger = new Logger(write, LogLevel.DEBUG, {jsonOutput: true})
62+
63+
logger.debug('d')
64+
logger.info('i')
65+
logger.warn('w')
66+
logger.error('e')
67+
68+
expect(lines).toHaveLength(4)
69+
70+
const parsed = lines.map((line) => {
71+
expect(line.endsWith('\n')).toBe(true)
72+
73+
return JSON.parse(line.trim()) as Record<string, string>
74+
})
75+
76+
expect(parsed.map((entry) => entry.level)).toEqual(['debug', 'info', 'warn', 'error'])
77+
expect(parsed.map((entry) => entry.message)).toEqual(['d', 'i', 'w', 'e'])
78+
})
79+
80+
it('does not apply ANSI colour codes to JSON output', () => {
81+
const {lines, write} = collect()
82+
const logger = new Logger(write, LogLevel.ERROR, {jsonOutput: true})
83+
84+
logger.error('boom')
85+
86+
const entry = JSON.parse(lines[0].trim()) as Record<string, string>
87+
expect(entry.message).toBe('boom')
88+
// eslint-disable-next-line no-control-regex
89+
expect(lines[0]).not.toMatch(/\[/)
90+
})
91+
92+
it('includes an ISO timestamp field when timestamps are enabled', () => {
93+
const {lines, write} = collect()
94+
const logger = new Logger(write, LogLevel.INFO, {jsonOutput: true, shouldIncludeTimestamp: true})
95+
96+
logger.info('hello')
97+
98+
const entry = JSON.parse(lines[0].trim()) as Record<string, string>
99+
expect(entry.message).toBe('hello')
100+
expect(entry.level).toBe('info')
101+
expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
102+
})
103+
104+
it('omits the timestamp field when timestamps are disabled', () => {
105+
const {lines, write} = collect()
106+
const logger = new Logger(write, LogLevel.INFO, {jsonOutput: true})
107+
108+
logger.info('hello')
109+
110+
const entry = JSON.parse(lines[0].trim()) as Record<string, string>
111+
expect(entry.timestamp).toBeUndefined()
112+
})
113+
114+
it('toggles back to text via setJsonOutput(false)', () => {
115+
const {lines, write} = collect()
116+
const logger = new Logger(write, LogLevel.INFO, {jsonOutput: true})
117+
118+
logger.info('json')
119+
logger.setJsonOutput(false)
120+
logger.info('plain')
121+
122+
expect(() => JSON.parse(lines[0].trim())).not.toThrow()
123+
expect(() => JSON.parse(lines[1].trim())).toThrow()
124+
})
125+
})
126+
})

packages/base/src/helpers/logger.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,43 @@ export enum LogLevel {
77
ERROR,
88
}
99

10+
const LEVEL_NAMES: Record<LogLevel, string> = {
11+
[LogLevel.DEBUG]: 'debug',
12+
[LogLevel.INFO]: 'info',
13+
[LogLevel.WARN]: 'warn',
14+
[LogLevel.ERROR]: 'error',
15+
}
16+
17+
export interface LoggerOptions {
18+
shouldIncludeTimestamp?: boolean
19+
/**
20+
* When `true`, every log call emits a single-line JSON object instead of
21+
* ANSI-coloured text. Useful when Datadog itself (or any other log
22+
* pipeline) ingests CLI output: each line parses cleanly and `level` is
23+
* preserved instead of every `error` showing up as `info`.
24+
*/
25+
jsonOutput?: boolean
26+
}
27+
1028
export class Logger {
1129
private loglevel: LogLevel
12-
private writeMessage: (s: string) => void
30+
private rawWriteMessage: (s: string) => void
1331
private shouldIncludeTimestamp: boolean
32+
private jsonOutput: boolean
1433

15-
constructor(writeMessage: (s: string) => void, loglevel: LogLevel, shouldIncludeTimestamp?: boolean) {
16-
this.shouldIncludeTimestamp = shouldIncludeTimestamp ?? false
17-
this.writeMessage = (s: string) => {
18-
const message = this.shouldIncludeTimestamp ? `${new Date().toISOString()}: ${s}` : s
34+
constructor(
35+
writeMessage: (s: string) => void,
36+
loglevel: LogLevel,
37+
shouldIncludeTimestampOrOptions?: boolean | LoggerOptions
38+
) {
39+
const options: LoggerOptions =
40+
typeof shouldIncludeTimestampOrOptions === 'boolean'
41+
? {shouldIncludeTimestamp: shouldIncludeTimestampOrOptions}
42+
: (shouldIncludeTimestampOrOptions ?? {})
1943

20-
return writeMessage(message)
21-
}
44+
this.rawWriteMessage = writeMessage
45+
this.shouldIncludeTimestamp = options.shouldIncludeTimestamp ?? false
46+
this.jsonOutput = options.jsonOutput ?? false
2247
this.loglevel = loglevel
2348
}
2449

@@ -30,27 +55,50 @@ export class Logger {
3055
this.shouldIncludeTimestamp = newShouldIncludeTimestamp
3156
}
3257

58+
public setJsonOutput(newJsonOutput: boolean) {
59+
this.jsonOutput = newJsonOutput
60+
}
61+
3362
public error(s: string) {
3463
if (this.loglevel <= LogLevel.ERROR) {
35-
this.writeMessage(chalk.red(s) + '\n')
64+
this.emit(LogLevel.ERROR, s, chalk.red)
3665
}
3766
}
3867

3968
public warn(s: string) {
4069
if (this.loglevel <= LogLevel.WARN) {
41-
this.writeMessage(chalk.yellow(s) + '\n')
70+
this.emit(LogLevel.WARN, s, chalk.yellow)
4271
}
4372
}
4473

4574
public info(s: string) {
4675
if (this.loglevel <= LogLevel.INFO) {
47-
this.writeMessage(s + '\n')
76+
this.emit(LogLevel.INFO, s)
4877
}
4978
}
5079

5180
public debug(s: string) {
5281
if (this.loglevel <= LogLevel.DEBUG) {
53-
this.writeMessage(s + '\n')
82+
this.emit(LogLevel.DEBUG, s)
83+
}
84+
}
85+
86+
private emit(level: LogLevel, message: string, colorize?: (s: string) => string) {
87+
if (this.jsonOutput) {
88+
const payload: Record<string, string> = {
89+
level: LEVEL_NAMES[level],
90+
message,
91+
}
92+
if (this.shouldIncludeTimestamp) {
93+
payload.timestamp = new Date().toISOString()
94+
}
95+
this.rawWriteMessage(JSON.stringify(payload) + '\n')
96+
97+
return
5498
}
99+
100+
const prefix = this.shouldIncludeTimestamp ? `${new Date().toISOString()}: ` : ''
101+
const body = colorize ? colorize(message) : message
102+
this.rawWriteMessage(prefix + body + '\n')
55103
}
56104
}

0 commit comments

Comments
 (0)