Skip to content

Commit

Permalink
feat: introducing structured logging with a custom JSON formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
CMCDragonkai committed Jul 20, 2022
1 parent 52c4a33 commit 66f9e5d
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 86 deletions.
145 changes: 125 additions & 20 deletions src/Logger.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { ToString, LogRecord, LogFormatter } from './types';
import type { ToString, LogData, LogRecord, LogFormatter } from './types';
import type Handler from './Handler';

import { LogLevel } from './types';
import ConsoleErrHandler from './handlers/ConsoleErrHandler';
import * as utils from './utils';

class Logger {
public key: string;
Expand Down Expand Up @@ -75,48 +75,153 @@ class Logger {
}
}

public setFilter(filter: RegExp) {
public setFilter(filter: RegExp): void {
this.filter = filter;
}

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

public info(data: ToString, format?: LogFormatter): void {
this.log(data.toString(), LogLevel.INFO, format);
public debug(msg: ToString | undefined, format?: LogFormatter): void;
public debug(
msg: ToString | undefined,
data: LogData,
format?: LogFormatter,
): void;
public debug(
msg: ToString | undefined,
formatOrData?: LogFormatter | LogData,
format?: LogFormatter,
): void {
if (formatOrData == null || typeof formatOrData === 'function') {
return this.log(msg, {}, LogLevel.DEBUG, formatOrData as LogFormatter);
} else {
return this.log(msg, formatOrData, LogLevel.DEBUG, format);
}
}

public warn(data: ToString, format?: LogFormatter): void {
this.log(data.toString(), LogLevel.WARN, format);
public info(msg: ToString | undefined, format?: LogFormatter): void;
public info(
msg: ToString | undefined,
data: LogData,
format?: LogFormatter,
): void;
public info(
msg: ToString | undefined,
formatOrData?: LogFormatter | LogData,
format?: LogFormatter,
): void {
if (formatOrData == null || typeof formatOrData === 'function') {
return this.log(msg, {}, LogLevel.INFO, formatOrData as LogFormatter);
} else {
return this.log(msg, formatOrData, LogLevel.INFO, format);
}
}

public error(data: ToString, format?: LogFormatter): void {
this.log(data.toString(), LogLevel.ERROR, format);
public warn(msg: ToString | undefined, format?: LogFormatter): void;
public warn(
msg: ToString | undefined,
data: LogData,
format?: LogFormatter,
): void;
public warn(
msg: ToString | undefined,
formatOrData?: LogFormatter | LogData,
format?: LogFormatter,
): void {
if (formatOrData == null || typeof formatOrData === 'function') {
return this.log(msg, {}, LogLevel.WARN, formatOrData as LogFormatter);
} else {
return this.log(msg, formatOrData, LogLevel.WARN, format);
}
}

protected log(msg: string, level: LogLevel, format?: LogFormatter): void {
const record = this.makeRecord(msg, level);
if (level >= this.getEffectiveLevel()) {
this.callHandlers(record, format);
public error(msg: ToString | undefined, format?: LogFormatter): void;
public error(
msg: ToString | undefined,
data: LogData,
format?: LogFormatter,
): void;
public error(
msg: ToString | undefined,
formatOrData?: LogFormatter | LogData,
format?: LogFormatter,
): void {
if (formatOrData == null || typeof formatOrData === 'function') {
return this.log(msg, {}, LogLevel.ERROR, formatOrData as LogFormatter);
} else {
return this.log(msg, formatOrData, LogLevel.ERROR, format);
}
}

protected makeRecord(msg: string, level: LogLevel): LogRecord {
protected log(
msg: ToString | undefined,
data: LogData,
level: LogLevel,
format?: LogFormatter,
): void {
// Filter on level before making a record
if (level < this.getEffectiveLevel()) return;
const record = this.makeRecord(msg, data, level);
this.callHandlers(record, level, format);
}

/**
* Constructs a `LogRecord`
* The `LogRecord` can contain lazy values via wrapping with a lambda
* This improves performance as they are not evaluated unless needed during formatting
*/
protected makeRecord(
msg: ToString | undefined,
data: LogData,
level: LogLevel,
): LogRecord {
return {
logger: this,
key: this.key,
date: new Date(),
msg: msg,
level: level,
logger: this,
level,
msg: msg?.toString(),
data,
keys: () => {
let logger: Logger = this;
let keys = this.key;
while (logger.parent != null) {
logger = logger.parent;
keys = `${logger.key}.${keys}`;
}
return keys;
},
stack: () => {
let stack: string;
if (utils.hasCaptureStackTrace && utils.hasStackTraceLimit) {
Error.stackTraceLimit++;
const error = {} as { stack: string };
// @ts-ignore: protected `Logger.prototype.log`
Error.captureStackTrace(error, Logger.prototype.log);
Error.stackTraceLimit--;
stack = error.stack;
// Remove the stack title and the first stack line for `Logger.prototype.log`
stack = stack.slice(stack.indexOf('\n', stack.indexOf('\n') + 1) + 1);
} else {
stack = new Error().stack ?? '';
stack = stack.slice(stack.indexOf('\n') + 1);
}
return stack;
},
};
}

protected callHandlers(
record: LogRecord,
level: LogLevel,
format?: LogFormatter,
keys: Array<string> = [],
): void {
// Filter on level before calling handlers
// This is also called when traversing up the parent
if (level < this.getEffectiveLevel()) return;
keys.push(this.key);
if (this.filter != null) {
const keysPath = keys.reduce((prev, curr) => `${curr}.${prev}`);
Expand All @@ -126,7 +231,7 @@ class Logger {
handler.handle(record, format);
}
if (this.parent) {
this.parent.callHandlers(record, format, keys);
this.parent.callHandlers(record, level, format, keys);
}
}
}
Expand Down
79 changes: 47 additions & 32 deletions src/formatting.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import type { LogRecord, LogFormatter } from './types';
import Logger from './Logger';

import { levelToString } from './types';
import * as utils from './utils';

const level = Symbol('level');
const key = Symbol('key');
const keys = Symbol('keys');
const date = Symbol('date');
const msg = Symbol('msg');
const trace = Symbol('trace');

const hasCaptureStackTrace = 'captureStackTrace' in Error;
const hasStackTraceLimit = 'stackTraceLimit' in Error;
const stack = Symbol('stack');
const data = Symbol('data');

function format(
strings: TemplateStringsArray,
Expand All @@ -24,34 +20,17 @@ function format(
if (value === key) {
result += record.key;
} else if (value === keys) {
let logger = record.logger;
let keysPath = logger.key;
while (logger.parent != null) {
logger = logger.parent;
keysPath = `${logger.key}.${keysPath}`;
}
result += keysPath;
result += record.keys();
} else if (value === date) {
result += record.date.toISOString();
} else if (value === msg) {
result += record.msg;
if (record.msg != null) result += record.msg;
} else if (value === level) {
result += levelToString(record.level);
} else if (value === trace) {
let stack: string;
if (hasCaptureStackTrace && hasStackTraceLimit) {
Error.stackTraceLimit++;
const error = {} as { stack: string };
// @ts-ignore: protected `Logger.prototype.log`
Error.captureStackTrace(error, Logger.prototype.log);
Error.stackTraceLimit--;
stack = error.stack;
// Remove the stack title and the first stack line for `Logger.prototype.log`
stack = stack.slice(stack.indexOf('\n', stack.indexOf('\n') + 1) + 1);
} else {
stack = new Error().stack ?? '';
stack = stack.slice(stack.indexOf('\n') + 1);
}
result += utils.levelToString(record.level);
} else if (value === data) {
result += utils.evalLogData(record.data);
} else if (value === stack) {
const stack = record.stack();
if (stack !== '') result += '\n' + stack;
} else {
result += value.toString();
Expand All @@ -62,6 +41,42 @@ function format(
};
}

/**
* Default formatter
* This only shows the level, key and msg
*/
const formatter = format`${level}:${key}:${msg}`;

export { level, key, keys, date, msg, trace, format, formatter };
/**
* Default JSON formatter for structured logging
* You should replace this with a formatter based on your required schema
* Note that `LogRecord` contains `LogData`, which may contain lazy values
* You must use `utils.evalLogData` or `utils.evalLogDataValue` to evaluate
* the `LogData`
*/
const jsonFormatter: LogFormatter = (record: LogRecord) => {
return JSON.stringify(
{
level: utils.levelToString(record.level),
key: record.key,
keys: record.keys(),
date: record.date.toISOString(),
msg: record.msg,
...record.data,
},
utils.evalLogDataValue,
);
};

export {
level,
key,
keys,
date,
msg,
stack,
data,
format,
formatter,
jsonFormatter,
};
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { default } from './Logger';
export { default as Handler } from './Handler';
export * from './handlers';
export * as formatting from './formatting';
export * from './handlers';
export * from './utils';
export * from './types';
68 changes: 44 additions & 24 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,60 @@ enum LogLevel {
ERROR = 4,
}

function levelToString(level: LogLevel): string {
switch (level) {
case LogLevel.NOTSET:
return 'NOTSET';
break;
case LogLevel.DEBUG:
return 'DEBUG';
break;
case LogLevel.INFO:
return 'INFO';
break;
case LogLevel.WARN:
return 'WARN';
break;
case LogLevel.ERROR:
return 'ERROR';
break;
}
}

interface ToString {
toString: () => string;
}

interface ToJSON {
toJSON: (key?: string) => string;
}

type LogDataKey = string | number;

/**
* Custom log data values
* Values can be made lazy by wrapping it as a lambda
*/
type LogDataValue =
| number
| string
| boolean
| null
| undefined
| ToJSON
| (() => LogDataValue)
| Array<LogDataValue>
| { [key: LogDataKey]: LogDataValue };

/**
* Custom log data
*/
type LogData = Record<LogDataKey, LogDataValue>;

/**
* Finalised log records
*/
type LogRecord = {
logger: Logger;
key: string;
date: Date;
msg: string;
level: LogLevel;
logger: Logger;
msg: string | undefined;
data: LogData;
keys: () => string;
stack: () => string;
};

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

export { LogLevel, levelToString };
export { LogLevel };

export type { ToString, LogRecord, LogFormatter };
export type {
ToString,
ToJSON,
LogDataKey,
LogDataValue,
LogData,
LogRecord,
LogFormatter,
};
Loading

0 comments on commit 66f9e5d

Please sign in to comment.