Skip to content

Commit 66f9e5d

Browse files
committed
feat: introducing structured logging with a custom JSON formatter
1 parent 52c4a33 commit 66f9e5d

File tree

7 files changed

+330
-86
lines changed

7 files changed

+330
-86
lines changed

src/Logger.ts

Lines changed: 125 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import type { ToString, LogRecord, LogFormatter } from './types';
1+
import type { ToString, LogData, LogRecord, LogFormatter } from './types';
22
import type Handler from './Handler';
3-
43
import { LogLevel } from './types';
54
import ConsoleErrHandler from './handlers/ConsoleErrHandler';
5+
import * as utils from './utils';
66

77
class Logger {
88
public key: string;
@@ -75,48 +75,153 @@ class Logger {
7575
}
7676
}
7777

78-
public setFilter(filter: RegExp) {
78+
public setFilter(filter: RegExp): void {
7979
this.filter = filter;
8080
}
8181

82-
public debug(data: ToString, format?: LogFormatter): void {
83-
this.log(data.toString(), LogLevel.DEBUG, format);
82+
public unsetFilter(): void {
83+
delete this.filter;
8484
}
8585

86-
public info(data: ToString, format?: LogFormatter): void {
87-
this.log(data.toString(), LogLevel.INFO, format);
86+
public debug(msg: ToString | undefined, format?: LogFormatter): void;
87+
public debug(
88+
msg: ToString | undefined,
89+
data: LogData,
90+
format?: LogFormatter,
91+
): void;
92+
public debug(
93+
msg: ToString | undefined,
94+
formatOrData?: LogFormatter | LogData,
95+
format?: LogFormatter,
96+
): void {
97+
if (formatOrData == null || typeof formatOrData === 'function') {
98+
return this.log(msg, {}, LogLevel.DEBUG, formatOrData as LogFormatter);
99+
} else {
100+
return this.log(msg, formatOrData, LogLevel.DEBUG, format);
101+
}
88102
}
89103

90-
public warn(data: ToString, format?: LogFormatter): void {
91-
this.log(data.toString(), LogLevel.WARN, format);
104+
public info(msg: ToString | undefined, format?: LogFormatter): void;
105+
public info(
106+
msg: ToString | undefined,
107+
data: LogData,
108+
format?: LogFormatter,
109+
): void;
110+
public info(
111+
msg: ToString | undefined,
112+
formatOrData?: LogFormatter | LogData,
113+
format?: LogFormatter,
114+
): void {
115+
if (formatOrData == null || typeof formatOrData === 'function') {
116+
return this.log(msg, {}, LogLevel.INFO, formatOrData as LogFormatter);
117+
} else {
118+
return this.log(msg, formatOrData, LogLevel.INFO, format);
119+
}
92120
}
93121

94-
public error(data: ToString, format?: LogFormatter): void {
95-
this.log(data.toString(), LogLevel.ERROR, format);
122+
public warn(msg: ToString | undefined, format?: LogFormatter): void;
123+
public warn(
124+
msg: ToString | undefined,
125+
data: LogData,
126+
format?: LogFormatter,
127+
): void;
128+
public warn(
129+
msg: ToString | undefined,
130+
formatOrData?: LogFormatter | LogData,
131+
format?: LogFormatter,
132+
): void {
133+
if (formatOrData == null || typeof formatOrData === 'function') {
134+
return this.log(msg, {}, LogLevel.WARN, formatOrData as LogFormatter);
135+
} else {
136+
return this.log(msg, formatOrData, LogLevel.WARN, format);
137+
}
96138
}
97139

98-
protected log(msg: string, level: LogLevel, format?: LogFormatter): void {
99-
const record = this.makeRecord(msg, level);
100-
if (level >= this.getEffectiveLevel()) {
101-
this.callHandlers(record, format);
140+
public error(msg: ToString | undefined, format?: LogFormatter): void;
141+
public error(
142+
msg: ToString | undefined,
143+
data: LogData,
144+
format?: LogFormatter,
145+
): void;
146+
public error(
147+
msg: ToString | undefined,
148+
formatOrData?: LogFormatter | LogData,
149+
format?: LogFormatter,
150+
): void {
151+
if (formatOrData == null || typeof formatOrData === 'function') {
152+
return this.log(msg, {}, LogLevel.ERROR, formatOrData as LogFormatter);
153+
} else {
154+
return this.log(msg, formatOrData, LogLevel.ERROR, format);
102155
}
103156
}
104157

105-
protected makeRecord(msg: string, level: LogLevel): LogRecord {
158+
protected log(
159+
msg: ToString | undefined,
160+
data: LogData,
161+
level: LogLevel,
162+
format?: LogFormatter,
163+
): void {
164+
// Filter on level before making a record
165+
if (level < this.getEffectiveLevel()) return;
166+
const record = this.makeRecord(msg, data, level);
167+
this.callHandlers(record, level, format);
168+
}
169+
170+
/**
171+
* Constructs a `LogRecord`
172+
* The `LogRecord` can contain lazy values via wrapping with a lambda
173+
* This improves performance as they are not evaluated unless needed during formatting
174+
*/
175+
protected makeRecord(
176+
msg: ToString | undefined,
177+
data: LogData,
178+
level: LogLevel,
179+
): LogRecord {
106180
return {
181+
logger: this,
107182
key: this.key,
108183
date: new Date(),
109-
msg: msg,
110-
level: level,
111-
logger: this,
184+
level,
185+
msg: msg?.toString(),
186+
data,
187+
keys: () => {
188+
let logger: Logger = this;
189+
let keys = this.key;
190+
while (logger.parent != null) {
191+
logger = logger.parent;
192+
keys = `${logger.key}.${keys}`;
193+
}
194+
return keys;
195+
},
196+
stack: () => {
197+
let stack: string;
198+
if (utils.hasCaptureStackTrace && utils.hasStackTraceLimit) {
199+
Error.stackTraceLimit++;
200+
const error = {} as { stack: string };
201+
// @ts-ignore: protected `Logger.prototype.log`
202+
Error.captureStackTrace(error, Logger.prototype.log);
203+
Error.stackTraceLimit--;
204+
stack = error.stack;
205+
// Remove the stack title and the first stack line for `Logger.prototype.log`
206+
stack = stack.slice(stack.indexOf('\n', stack.indexOf('\n') + 1) + 1);
207+
} else {
208+
stack = new Error().stack ?? '';
209+
stack = stack.slice(stack.indexOf('\n') + 1);
210+
}
211+
return stack;
212+
},
112213
};
113214
}
114215

115216
protected callHandlers(
116217
record: LogRecord,
218+
level: LogLevel,
117219
format?: LogFormatter,
118220
keys: Array<string> = [],
119221
): void {
222+
// Filter on level before calling handlers
223+
// This is also called when traversing up the parent
224+
if (level < this.getEffectiveLevel()) return;
120225
keys.push(this.key);
121226
if (this.filter != null) {
122227
const keysPath = keys.reduce((prev, curr) => `${curr}.${prev}`);
@@ -126,7 +231,7 @@ class Logger {
126231
handler.handle(record, format);
127232
}
128233
if (this.parent) {
129-
this.parent.callHandlers(record, format, keys);
234+
this.parent.callHandlers(record, level, format, keys);
130235
}
131236
}
132237
}

src/formatting.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import type { LogRecord, LogFormatter } from './types';
2-
import Logger from './Logger';
3-
4-
import { levelToString } from './types';
2+
import * as utils from './utils';
53

64
const level = Symbol('level');
75
const key = Symbol('key');
86
const keys = Symbol('keys');
97
const date = Symbol('date');
108
const msg = Symbol('msg');
11-
const trace = Symbol('trace');
12-
13-
const hasCaptureStackTrace = 'captureStackTrace' in Error;
14-
const hasStackTraceLimit = 'stackTraceLimit' in Error;
9+
const stack = Symbol('stack');
10+
const data = Symbol('data');
1511

1612
function format(
1713
strings: TemplateStringsArray,
@@ -24,34 +20,17 @@ function format(
2420
if (value === key) {
2521
result += record.key;
2622
} else if (value === keys) {
27-
let logger = record.logger;
28-
let keysPath = logger.key;
29-
while (logger.parent != null) {
30-
logger = logger.parent;
31-
keysPath = `${logger.key}.${keysPath}`;
32-
}
33-
result += keysPath;
23+
result += record.keys();
3424
} else if (value === date) {
3525
result += record.date.toISOString();
3626
} else if (value === msg) {
37-
result += record.msg;
27+
if (record.msg != null) result += record.msg;
3828
} else if (value === level) {
39-
result += levelToString(record.level);
40-
} else if (value === trace) {
41-
let stack: string;
42-
if (hasCaptureStackTrace && hasStackTraceLimit) {
43-
Error.stackTraceLimit++;
44-
const error = {} as { stack: string };
45-
// @ts-ignore: protected `Logger.prototype.log`
46-
Error.captureStackTrace(error, Logger.prototype.log);
47-
Error.stackTraceLimit--;
48-
stack = error.stack;
49-
// Remove the stack title and the first stack line for `Logger.prototype.log`
50-
stack = stack.slice(stack.indexOf('\n', stack.indexOf('\n') + 1) + 1);
51-
} else {
52-
stack = new Error().stack ?? '';
53-
stack = stack.slice(stack.indexOf('\n') + 1);
54-
}
29+
result += utils.levelToString(record.level);
30+
} else if (value === data) {
31+
result += utils.evalLogData(record.data);
32+
} else if (value === stack) {
33+
const stack = record.stack();
5534
if (stack !== '') result += '\n' + stack;
5635
} else {
5736
result += value.toString();
@@ -62,6 +41,42 @@ function format(
6241
};
6342
}
6443

44+
/**
45+
* Default formatter
46+
* This only shows the level, key and msg
47+
*/
6548
const formatter = format`${level}:${key}:${msg}`;
6649

67-
export { level, key, keys, date, msg, trace, format, formatter };
50+
/**
51+
* Default JSON formatter for structured logging
52+
* You should replace this with a formatter based on your required schema
53+
* Note that `LogRecord` contains `LogData`, which may contain lazy values
54+
* You must use `utils.evalLogData` or `utils.evalLogDataValue` to evaluate
55+
* the `LogData`
56+
*/
57+
const jsonFormatter: LogFormatter = (record: LogRecord) => {
58+
return JSON.stringify(
59+
{
60+
level: utils.levelToString(record.level),
61+
key: record.key,
62+
keys: record.keys(),
63+
date: record.date.toISOString(),
64+
msg: record.msg,
65+
...record.data,
66+
},
67+
utils.evalLogDataValue,
68+
);
69+
};
70+
71+
export {
72+
level,
73+
key,
74+
keys,
75+
date,
76+
msg,
77+
stack,
78+
data,
79+
format,
80+
formatter,
81+
jsonFormatter,
82+
};

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default } from './Logger';
22
export { default as Handler } from './Handler';
3-
export * from './handlers';
43
export * as formatting from './formatting';
4+
export * from './handlers';
5+
export * from './utils';
56
export * from './types';

src/types.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,60 @@ enum LogLevel {
88
ERROR = 4,
99
}
1010

11-
function levelToString(level: LogLevel): string {
12-
switch (level) {
13-
case LogLevel.NOTSET:
14-
return 'NOTSET';
15-
break;
16-
case LogLevel.DEBUG:
17-
return 'DEBUG';
18-
break;
19-
case LogLevel.INFO:
20-
return 'INFO';
21-
break;
22-
case LogLevel.WARN:
23-
return 'WARN';
24-
break;
25-
case LogLevel.ERROR:
26-
return 'ERROR';
27-
break;
28-
}
29-
}
30-
3111
interface ToString {
3212
toString: () => string;
3313
}
3414

15+
interface ToJSON {
16+
toJSON: (key?: string) => string;
17+
}
18+
19+
type LogDataKey = string | number;
20+
21+
/**
22+
* Custom log data values
23+
* Values can be made lazy by wrapping it as a lambda
24+
*/
25+
type LogDataValue =
26+
| number
27+
| string
28+
| boolean
29+
| null
30+
| undefined
31+
| ToJSON
32+
| (() => LogDataValue)
33+
| Array<LogDataValue>
34+
| { [key: LogDataKey]: LogDataValue };
35+
36+
/**
37+
* Custom log data
38+
*/
39+
type LogData = Record<LogDataKey, LogDataValue>;
40+
41+
/**
42+
* Finalised log records
43+
*/
3544
type LogRecord = {
45+
logger: Logger;
3646
key: string;
3747
date: Date;
38-
msg: string;
3948
level: LogLevel;
40-
logger: Logger;
49+
msg: string | undefined;
50+
data: LogData;
51+
keys: () => string;
52+
stack: () => string;
4153
};
4254

4355
type LogFormatter = (record: LogRecord) => string;
4456

45-
export { LogLevel, levelToString };
57+
export { LogLevel };
4658

47-
export type { ToString, LogRecord, LogFormatter };
59+
export type {
60+
ToString,
61+
ToJSON,
62+
LogDataKey,
63+
LogDataValue,
64+
LogData,
65+
LogRecord,
66+
LogFormatter,
67+
};

0 commit comments

Comments
 (0)