Skip to content

Commit 1dd9eac

Browse files
committed
feat: introducing structured logging with a custom JSON formatter
1 parent f9fe5ce commit 1dd9eac

File tree

8 files changed

+313
-86
lines changed

8 files changed

+313
-86
lines changed

src/Logger.ts

Lines changed: 103 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 { 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,131 @@ 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: string, format?: LogFormatter): void;
87+
public debug(msg: string, data: LogData, format?: LogFormatter): void;
88+
public debug(
89+
msg: string,
90+
formatOrData?: LogFormatter | LogData,
91+
format?: LogFormatter,
92+
): void {
93+
if (formatOrData == null || typeof formatOrData === 'function') {
94+
return this.log(msg, {}, LogLevel.DEBUG, formatOrData as LogFormatter);
95+
} else {
96+
return this.log(msg, formatOrData, LogLevel.DEBUG, format);
97+
}
8898
}
8999

90-
public warn(data: ToString, format?: LogFormatter): void {
91-
this.log(data.toString(), LogLevel.WARN, format);
100+
public info(msg: string, format?: LogFormatter): void;
101+
public info(msg: string, data: LogData, format?: LogFormatter): void;
102+
public info(
103+
msg: string,
104+
formatOrData?: LogFormatter | LogData,
105+
format?: LogFormatter,
106+
): void {
107+
if (formatOrData == null || typeof formatOrData === 'function') {
108+
return this.log(msg, {}, LogLevel.INFO, formatOrData as LogFormatter);
109+
} else {
110+
return this.log(msg, formatOrData, LogLevel.INFO, format);
111+
}
92112
}
93113

94-
public error(data: ToString, format?: LogFormatter): void {
95-
this.log(data.toString(), LogLevel.ERROR, format);
114+
public warn(msg: string, format?: LogFormatter): void;
115+
public warn(msg: string, data: LogData, format?: LogFormatter): void;
116+
public warn(
117+
msg: string,
118+
formatOrData?: LogFormatter | LogData,
119+
format?: LogFormatter,
120+
): void {
121+
if (formatOrData == null || typeof formatOrData === 'function') {
122+
return this.log(msg, {}, LogLevel.WARN, formatOrData as LogFormatter);
123+
} else {
124+
return this.log(msg, formatOrData, LogLevel.WARN, format);
125+
}
96126
}
97127

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);
128+
public error(msg: string, format?: LogFormatter): void;
129+
public error(msg: string, data: LogData, format?: LogFormatter): void;
130+
public error(
131+
msg: string,
132+
formatOrData?: LogFormatter | LogData,
133+
format?: LogFormatter,
134+
): void {
135+
if (formatOrData == null || typeof formatOrData === 'function') {
136+
return this.log(msg, {}, LogLevel.ERROR, formatOrData as LogFormatter);
137+
} else {
138+
return this.log(msg, formatOrData, LogLevel.ERROR, format);
102139
}
103140
}
104141

105-
protected makeRecord(msg: string, level: LogLevel): LogRecord {
142+
protected log(
143+
msg: string,
144+
data: LogData,
145+
level: LogLevel,
146+
format?: LogFormatter,
147+
): void {
148+
const record = this.makeRecord(msg, data, level);
149+
this.callHandlers(record, level, format);
150+
}
151+
152+
/**
153+
* Constructs a `LogRecord`
154+
* The `LogRecord` can contain lazy values via wrapping with a lambda
155+
* This improves performance as they are not evaluated unless needed during formatting
156+
*/
157+
protected makeRecord(msg: string, data: LogData, level: LogLevel): LogRecord {
106158
return {
159+
logger: this,
107160
key: this.key,
108161
date: new Date(),
109-
msg: msg,
110-
level: level,
111-
logger: this,
162+
level,
163+
msg,
164+
data,
165+
keys: () => {
166+
let logger: Logger = this;
167+
let keys = this.key;
168+
while (logger.parent != null) {
169+
logger = logger.parent;
170+
keys = `${logger.key}.${keys}`;
171+
}
172+
return keys;
173+
},
174+
stack: () => {
175+
let stack: string;
176+
if (utils.hasCaptureStackTrace && utils.hasStackTraceLimit) {
177+
Error.stackTraceLimit++;
178+
const error = {} as { stack: string };
179+
// @ts-ignore: protected `Logger.prototype.log`
180+
Error.captureStackTrace(error, Logger.prototype.log);
181+
Error.stackTraceLimit--;
182+
stack = error.stack;
183+
// Remove the stack title and the first stack line for `Logger.prototype.log`
184+
stack = stack.slice(stack.indexOf('\n', stack.indexOf('\n') + 1) + 1);
185+
} else {
186+
stack = new Error().stack ?? '';
187+
stack = stack.slice(stack.indexOf('\n') + 1);
188+
}
189+
return stack;
190+
},
112191
};
113192
}
114193

115194
protected callHandlers(
116195
record: LogRecord,
196+
level: LogLevel,
117197
format?: LogFormatter,
118198
keys: Array<string> = [],
119199
): void {
200+
if (level < this.getEffectiveLevel()) {
201+
return;
202+
}
120203
keys.push(this.key);
121204
if (this.filter != null) {
122205
const keysPath = keys.reduce((prev, curr) => `${curr}.${prev}`);
@@ -126,7 +209,7 @@ class Logger {
126209
handler.handle(record, format);
127210
}
128211
if (this.parent) {
129-
this.parent.callHandlers(record, format, keys);
212+
this.parent.callHandlers(record, level, format, keys);
130213
}
131214
}
132215
}

src/formatting.ts

Lines changed: 46 additions & 31 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) {
3727
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: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,40 +8,48 @@ 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-
}
11+
interface ToJSON {
12+
toJSON: (key?: string) => string;
2913
}
3014

31-
interface ToString {
32-
toString: () => string;
33-
}
15+
type LogDataKey = string | number;
16+
17+
/**
18+
* Custom log data values
19+
* Values can be made lazy by wrapping it as a lambda
20+
*/
21+
type LogDataValue =
22+
| number
23+
| string
24+
| boolean
25+
| null
26+
| undefined
27+
| ToJSON
28+
| (() => LogDataValue)
29+
| Array<LogDataValue>
30+
| { [key: LogDataKey]: LogDataValue };
3431

32+
/**
33+
* Custom log data
34+
*/
35+
type LogData = Record<LogDataKey, LogDataValue>;
36+
37+
/**
38+
* Finalised log records
39+
*/
3540
type LogRecord = {
41+
logger: Logger;
3642
key: string;
3743
date: Date;
38-
msg: string;
3944
level: LogLevel;
40-
logger: Logger;
45+
msg: string;
46+
data: LogData;
47+
keys: () => string;
48+
stack: () => string;
4149
};
4250

4351
type LogFormatter = (record: LogRecord) => string;
4452

45-
export { LogLevel, levelToString };
53+
export { LogLevel };
4654

47-
export type { ToString, LogRecord, LogFormatter };
55+
export type { LogDataKey, LogDataValue, LogData, LogRecord, LogFormatter };

0 commit comments

Comments
 (0)