diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e4f4d..d35f835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [4.0.0-beta.5](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.4...v4.0.0-beta.5) (2021-12-10) + + +### Bug Fixes + +* add generic support to LogMessage class for specifying the message type ([6e6775a](https://github.com/KyleRoss/node-lambda-log/commit/6e6775a44bf881d3d7d64d9ce2f40ff2bce858da)) +* add generic to LogObject type to specify type of Message ([cd1c746](https://github.com/KyleRoss/node-lambda-log/commit/cd1c74697a99840a48f29d49509ad3409e145de5)) +* explicitly set type to `string[]` for compiled tags returned from LogMessage ([fd873e3](https://github.com/KyleRoss/node-lambda-log/commit/fd873e3a2da24f3b90985708771adeb2901ce39c)) +* remove generic from Message type ([cc10311](https://github.com/KyleRoss/node-lambda-log/commit/cc10311b3e0580e840792e452d11dcc43e911544)) +* remove toJSON() method from LogMessage since it's been moved to a formatter ([36fbdaa](https://github.com/KyleRoss/node-lambda-log/commit/36fbdaa738fb1fa0daf046dc71eb84adc18ae55c)) +* rename the `log` method to `_log` ([7317bf5](https://github.com/KyleRoss/node-lambda-log/commit/7317bf52418af6fed65db8066b94d8c2dbe21023)) +* still generate the log message just don't log it when verbosity is set ([6e13564](https://github.com/KyleRoss/node-lambda-log/commit/6e13564da87fe5b189a2b9262d8e3613a3f138e5)) +* ts generics and typings on shortcut methods ([7e48904](https://github.com/KyleRoss/node-lambda-log/commit/7e48904076802adf571385f73ac83294c0d4f875)) + + +### Features + +* add `log` shortcut method as an alias for `info` to match the `console` pattern ([a3c1f16](https://github.com/KyleRoss/node-lambda-log/commit/a3c1f163976de3f68c49e05f3a84ab7c1791c0e5)) +* separate built-in formatters into separate files ([f8ecac7](https://github.com/KyleRoss/node-lambda-log/commit/f8ecac7ed12b25495c634a904f489857a7413f80)) + # [4.0.0-beta.4](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.3...v4.0.0-beta.4) (2021-11-25) diff --git a/package.json b/package.json index be17cf2..c72b325 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lambda-log", - "version": "4.0.0-beta.4", + "version": "4.0.0-beta.5", "description": "Lightweight logging library for any Node 12+ applications", "main": "./index.js", "module": "./dist/esm/lambda-log.js", @@ -71,28 +71,28 @@ "@commitlint/config-conventional": "^15.0.0", "@semantic-release/changelog": "^6.0.1", "@semantic-release/git": "^10.0.1", - "@types/jest": "^27.0.2", - "@typescript-eslint/eslint-plugin": "^5.4.0", - "@typescript-eslint/parser": "^5.4.0", + "@types/jest": "^27.0.3", + "@typescript-eslint/eslint-plugin": "^5.6.0", + "@typescript-eslint/parser": "^5.6.0", "cross-env": "^7.0.3", "eslint": "^7.23.0", "eslint-config-xo-space": "^0.30.0", "eslint-config-xo-typescript": "^0.45.2", - "eslint-plugin-jsdoc": "^37.0.3", + "eslint-plugin-jsdoc": "^37.2.0", "eslint-plugin-node": "^11.1.0", "expect-more-jest": "^5.4.0", "husky": "^7.0.4", "is-ci": "^3.0.1", - "jest": "^27.3.1", + "jest": "^27.4.3", "jest-ts-webcompat-resolver": "^1.0.0", "rimraf": "^3.0.2", - "semantic-release": "^18.0.0", - "ts-jest": "^27.0.7", + "semantic-release": "^18.0.1", + "ts-jest": "^27.1.1", "ts-node": "^10.4.0", - "typescript": "^4.5.2" + "typescript": "^4.5.3" }, "dependencies": { - "@types/node": "^16.11.9", + "@types/node": "^16.11.12", "fast-safe-stringify": "^2.1.1" } } diff --git a/src/LambdaLog.spec.ts b/src/LambdaLog.spec.ts index e8fe872..59b02c2 100644 --- a/src/LambdaLog.spec.ts +++ b/src/LambdaLog.spec.ts @@ -128,32 +128,33 @@ describe('LambdaLog', () => { mockConsole.debug.mockClear(); }); - describe('log()', () => { + describe('_log()', () => { it('should throw error for invalid log level', () => { const log = new LambdaLog(); - expect(() => log.log('foo' as any, 'test')).toThrowError(/^"foo" is not a valid log level$/); + expect(() => log._log('foo' as any, 'test')).toThrowError(/^"foo" is not a valid log level$/); }); it('should throw error for no log level', () => { const log = new LambdaLog(); - expect(() => log.log(null as any, 'test')).toThrowError(/is not a valid log level$/); + expect(() => log._log(null as any, 'test')).toThrowError(/is not a valid log level$/); }); - it('should return false for a disabled log level', () => { + it('should not log for a disabled log level', () => { const log = new LambdaLog({ level: 'fatal' }); - const result = log.log('debug', 'test'); - expect(result).toBe(false); + const result = log._log('debug', 'test'); + expect(result).toBeInstanceOf(LogMessage); + expect(mockConsole.debug).not.toHaveBeenCalled(); }); it('should return log message instance', () => { const log = new LambdaLog(); - const result = log.log('info', 'test'); + const result = log._log('info', 'test'); expect(result).toBeInstanceOf(LogMessage); }); it('should not log message when silent is enabled', () => { const log = new LambdaLog({ silent: true }); - log.log('info', 'test'); + log._log('info', 'test'); expect(mockConsole.info).toBeCalledTimes(0); }); @@ -165,7 +166,7 @@ describe('LambdaLog', () => { done(); }); - const res = log.log('error', 'test'); + const res = log._log('error', 'test'); expect(res).toBeInstanceOf(LogMessage); }); @@ -175,7 +176,7 @@ describe('LambdaLog', () => { }); const info = jest.spyOn(console, 'info'); - log.log('info', 'test'); + log._log('info', 'test'); expect(info).toBeCalled(); info.mockRestore(); }); @@ -206,11 +207,9 @@ describe('LambdaLog', () => { promise.then(msg => { expect(msg).toBeInstanceOf(LogMessage); - if(msg !== false) { - expect(msg.level).toBe('info'); - expect(msg.msg).toBe('Success!'); - done(); - } + expect(msg.level).toBe('info'); + expect(msg.msg).toBe('Success!'); + done(); }); }); @@ -220,11 +219,9 @@ describe('LambdaLog', () => { promise.then(msg => { expect(msg).toBeInstanceOf(LogMessage); - if(msg !== false) { - expect(msg.level).toBe('error'); - expect(msg.msg).toBe('Failed!'); - done(); - } + expect(msg.level).toBe('error'); + expect(msg.msg).toBe('Failed!'); + done(); }); }); }); @@ -248,6 +245,12 @@ describe('LambdaLog', () => { expect(mockConsole.info).toBeCalledTimes(1); }); + it('should log info message (log)', () => { + const log = new LambdaLog({ level: 'info' }); + log.log('test'); + expect(mockConsole.info).toBeCalledTimes(1); + }); + it('should log warn message', () => { const log = new LambdaLog({ level: 'warn' }); log.warn('test'); diff --git a/src/LambdaLog.ts b/src/LambdaLog.ts index d3317df..e7aac18 100644 --- a/src/LambdaLog.ts +++ b/src/LambdaLog.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events'; -import { LambdaLogOptions, Message, GenericRecord, LogLevels, LogObject, Tag, ConsoleObject } from './typings.js'; +import { LambdaLogOptions, Message, Metadata, LogLevels, LogObject, Tag, ConsoleObject } from './typings.js'; import LogMessage from './LogMessage.js'; +import * as logFormatters from './formatters/index.js'; import { toBool } from './utils.js'; @@ -38,26 +39,29 @@ export const defaultOptions: LambdaLogOptions = { logHandler: console, levelKey: '__level', messageKey: 'msg', - tagsKey: '__tags' + tagsKey: '__tags', + onFormat: logFormatters.json() }; +export const formatters = logFormatters; + export default class LambdaLog extends EventEmitter { + static defaultOptions = defaultOptions; + static formatters = formatters; + /** * Access to the uninstantiated LambdaLog class. * @readonly - * @type {LambdaLog} */ readonly LambdaLog = LambdaLog; /** * Access to the uninstantiated LogMessage class. - * @type {LogMessage} */ LogMessage = LogMessage; /** * The options object for this instance of LambdaLog. - * @type {LambdaLogOptions} */ options: LambdaLogOptions; @@ -69,7 +73,6 @@ export default class LambdaLog extends EventEmitter { /** * Returns the console object to use for logging. - * @readonly * @private * @returns {ConsoleObject} The configured console object or `console` if none is provided. */ @@ -116,39 +119,40 @@ export default class LambdaLog extends EventEmitter { * @template T The type of the message to log. * @param {string} level Log level (`info`, `debug`, `warn`, `error`, or `fatal`) * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {object} [meta={}] Optional meta data to attach to the log. + * @param {object|string|number} [meta={}] Optional meta data to attach to the log. * @param {string[]} [tags=[]] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - log(level: LogLevels, msg: T, meta: GenericRecord = {}, tags: Tag[] = []) { + _log(level: LogLevels, msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { const lvl = this.getLevel(level); if(!lvl) { throw new Error(`"${level}" is not a valid log level`); } - // Check if we can log this level - if(lvl.idx > this.maxLevelIdx) return false; - // Generate the log message instance const message = new this.LogMessage({ level, msg, meta, tags - } as LogObject, this.options); - - const consoleObj = this.console; - // Log the message to the console - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - consoleObj[lvl.method](message.toString()); - - /** - * The log event is emitted (using EventEmitter) for every log generated. This allows for custom integrations, such as logging to a thrid-party service. - * This event is emitted with the [LogMessage](#logmessage) instance for the log. You may control events using all the methods of EventEmitter. - * @event LambdaLog#log - * @type {LogMessage} - */ - this.emit('log', message); + } as LogObject, this.options); + + // Check if we can log this level + if(lvl.idx <= this.maxLevelIdx) { + const consoleObj = this.console; + // Log the message to the console + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + consoleObj[lvl.method](message.toString()); + + /** + * The log event is emitted (using EventEmitter) for every log generated. This allows for custom integrations, such as logging to a thrid-party service. + * This event is emitted with the [LogMessage](#logmessage) instance for the log. You may control events using all the methods of EventEmitter. + * @event LambdaLog#log + * @type {LogMessage} + */ + this.emit('log', message); + } + return message; } @@ -156,78 +160,89 @@ export default class LambdaLog extends EventEmitter { * Logs a message at the `trace` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - trace(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('trace', msg, meta, tags); + trace(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('trace', msg, meta, tags); } /** * Logs a message at the `debug` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - debug(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('debug', msg, meta, tags); + debug(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('debug', msg, meta, tags); } /** * Logs a message at the `info` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - info(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('info', msg, meta, tags); + info(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('info', msg, meta, tags); + } + + /** + * Alias for `info`. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + log(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('info', msg, meta, tags); } /** * Logs a message at the `warn` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - warn(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('warn', msg, meta, tags); + warn(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('warn', msg, meta, tags); } /** * Logs a message at the `error` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - error(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('error', msg, meta, tags); + error(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('error', msg, meta, tags); } /** * Logs a message at the `error` log level. * @template T The type of the message to log. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. - * @param {GenericRecord} [meta] Optional meta data to attach to the log. + * @param {Metadata} [meta] Optional meta data to attach to the log. * @param {Tag[]} [tags] Additional tags to append to this log. - * @returns {LogMessage|false} Returns instance of LogMessage or `false` if the level of the log exceeds to the maximum set log level. + * @returns {LogMessage} Returns instance of LogMessage. */ - fatal(msg: T, meta?: GenericRecord, tags?: Tag[]) { - return this.log('fatal', msg, meta, tags); + fatal(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('fatal', msg, meta, tags); } /** * Generates a log message if `test` is a falsy value. If `test` is truthy, the log message is skipped and returns `false`. Allows creating log messages without the need to * wrap them in an if statement. The log level will be `error`. - * @since 1.4.0 * @template T The type of the message to log. * @param {*} test Value to test for a falsy value. * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. @@ -235,27 +250,26 @@ export default class LambdaLog extends EventEmitter { * @param {string[]} [tags=[]] Additional tags to append to this log. * @returns {LogMessage|false} The generated log message or `false` if assertion passed. */ - assert(test: unknown, msg: T, meta?: GenericRecord, tags?: Tag[]) { + assert(test: unknown, msg: T, meta?: Metadata, tags?: Tag[]): LogMessage | false { if(test) return false; - return this.log('error', msg, meta, tags); + return this._log('error', msg, meta, tags); } /** * Generates a log message with the result or error provided by a promise. Useful for debugging and testing. - * @since 2.3.0 * @param {Promise<*>} promise Promise to log the results of. * @param {object} [meta={}] Optional meta data to attach to the log. * @param {string[]} [tags=[]] Additional tags to append to this log. - * @returns {Promise} A Promise that resolves with the log message. + * @returns {Promise} A Promise that resolves with the log message. */ - async result(promise: Promise, meta?: GenericRecord, tags?: Tag[]) { - const wrapper = new Promise(resolve => { + async result(promise: Promise, meta?: Metadata, tags?: Tag[]): Promise { + const wrapper = new Promise(resolve => { promise .then(value => { - resolve(this.log('info', value as string, meta, tags)); + resolve(this._log('info', value, meta, tags)); }) .catch(err => { - resolve(this.log('error', err as Error, meta, tags)); + resolve(this._log('error', err as Error, meta, tags)); }); }); @@ -268,7 +282,7 @@ export default class LambdaLog extends EventEmitter { * @param {string} level The provided log level string. * @returns {object} Returns the configuration for the provided log level. */ - private getLevel(level: string) { + private getLevel(level: string): { idx: number; name: string; method: string } | false { if(!level) return false; const lvl = levels.findIndex(l => l.name === level.toLowerCase()); if(lvl === -1) return false; @@ -281,11 +295,10 @@ export default class LambdaLog extends EventEmitter { /** * Returns the index of the configured maximum log level. - * @readonly * @private * @returns {number} The index of the configured maximum log level. */ - private get maxLevelIdx() { + private get maxLevelIdx(): number { if(!this.options.level || this.options.silent) return -1; return levels.findIndex(l => l.name === this.options.level); } diff --git a/src/LogMessage.spec.ts b/src/LogMessage.spec.ts index d9e7738..9d089c0 100644 --- a/src/LogMessage.spec.ts +++ b/src/LogMessage.spec.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import 'expect-more-jest'; import LogMessage from './LogMessage'; +import * as formatters from './formatters'; import { StubbedError } from './typings'; const logData = { @@ -412,43 +413,6 @@ describe('LogMessage', () => { }); describe('Methods', () => { - describe('toJSON()', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - meta: { ssn: '444-55-6666' }, - replacer(key, value) { - if(key === 'ssn') { - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-call - return `${value.substr(0, 3)}-**-****`; - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value; - } - }); - - it('should return log in JSON format', () => { - expect(msg.toJSON()).toBeJsonString(); - }); - - it('should run replacer function', () => { - expect(JSON.parse(msg.toJSON()).ssn).toBe('444-**-****'); - }); - - it('should not run replacer function when not defined', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - meta: { ssn: '444-55-6666' } - }); - - expect(JSON.parse(msg.toJSON()).ssn).toBe('444-55-6666'); - }); - - it('should pretty print JSON when format is "true"', () => { - expect(/\n/g.test(msg.toJSON(true))).toBe(true); - }); - }); - describe('toString()', () => { it('should return log in string format', () => { const msg = new LogMessage({ ...logData.info }, defaultOpts); @@ -459,37 +423,37 @@ describe('LogMessage', () => { it('should format as json with onFormat set to `json`', () => { const msg = new LogMessage({ ...logData.info }, { ...defaultOpts, - onFormat: 'json' + onFormat: formatters.json() }); expect(msg.toString()).toBeJsonString(); }); - it('should format with "clean" template with onFormat set to `clean`', () => { + it('should format with "full" template with onFormat set to `full`', () => { const msg = new LogMessage({ ...logData.info, tags: ['test'] }, { ...defaultOpts, - onFormat: 'clean' + onFormat: formatters.full() }); - expect(msg.toString()).toMatch(/^INFO\tinfo test\n\t├→ test/); + expect(msg.toString()).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)\tINFO\tinfo test\n→ test/); }); - it('should not add tags when none are set in `clean` formatter', () => { + it('should not add tags when none are set in `full` formatter', () => { const msg = new LogMessage({ ...logData.info, meta: { test: 123 } }, { ...defaultOpts, - onFormat: 'clean' + onFormat: formatters.full() }); - expect(msg.toString()).toMatch(/^INFO\tinfo test\n\t└→ \{/); + expect(msg.toString()).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)\tINFO\tinfo test\n→ \{/); }); it('should format with "minimal" template with onFormat set to `minimal`', () => { const msg = new LogMessage({ ...logData.info }, { ...defaultOpts, - onFormat: 'minimal' + onFormat: formatters.minimal() }); - expect(msg.toString()).toMatch(/^INFO\tinfo test$/); + expect(msg.toString()).toMatch(/^INFO | info test$/); }); it('should format with a custom `onFormat` function', () => { diff --git a/src/LogMessage.ts b/src/LogMessage.ts index 8f3c6f9..d31f124 100644 --- a/src/LogMessage.ts +++ b/src/LogMessage.ts @@ -1,6 +1,7 @@ import stringify from 'fast-safe-stringify'; -import { LambdaLogOptions, Message, LogObject, Tag, GenericRecord, Formatter, StubbedError, Empty } from './typings.js'; +import { LambdaLogOptions, Message, LogObject, Tag, GenericRecord, StubbedError, Empty, FormatPlugin } from './typings.js'; import { isError, stubError } from './utils.js'; +import jsonFormatter from './formatters/json.js'; export interface ILogMessage { readonly __opts: LambdaLogOptions; @@ -17,13 +18,11 @@ export interface ILogMessage { set message(msg: Message); get meta(): GenericRecord; set meta(obj: GenericRecord); - get tags(): Tag[]; + get tags(): string[]; set tags(tags: Tag[]); get value(): GenericRecord; get log(): GenericRecord; get throw(): void; - - toJSON(format: boolean): string; } /** @@ -31,7 +30,7 @@ export interface ILogMessage { * Having a seperate class and instance for each log allows chaining and the ability to further customize this module in the future without major breaking changes. The documentation * provided here is what is available to you for each log message. */ -export default class LogMessage implements ILogMessage { +export default class LogMessage implements ILogMessage { readonly __opts: LambdaLogOptions = {}; __level: string; __msg = ''; @@ -46,7 +45,7 @@ export default class LogMessage implements ILogMessage { * @param {LambdaLogOptions} opts The options for LambdaLog. * @class */ - constructor(log: LogObject, opts: LambdaLogOptions) { + constructor(log: LogObject, opts: LambdaLogOptions) { // LambdaLog options this.__opts = opts; // Log level @@ -94,7 +93,7 @@ export default class LogMessage implements ILogMessage { * Alias for `this.msg`. * @returns {string} The message for the log. */ - get message() { + get message(): string { return this.msg; } @@ -102,7 +101,7 @@ export default class LogMessage implements ILogMessage { * Alias for `this.msg = 'New message';` * @param {Message} msg The new message for this log. */ - set message(msg) { + set message(msg: Message) { this.msg = msg; } @@ -151,9 +150,9 @@ export default class LogMessage implements ILogMessage { * Array of tags attached to this log. Includes global tags. * @returns {Tag[]} The tags attached to this log. */ - get tags() { + get tags(): string[] { const { __opts, __tags } = this; - let tags: Tag[] = [...__tags]; + let tags = [...__tags]; if(Array.isArray(__opts.tags)) { tags = [...__opts.tags, ...tags]; @@ -178,7 +177,7 @@ export default class LogMessage implements ILogMessage { } return tag; - }).filter(tag => typeof tag === 'string' && tag); + }).filter(tag => typeof tag === 'string' && tag) as string[]; } /** @@ -190,7 +189,7 @@ export default class LogMessage implements ILogMessage { } /** - * The full log object. This is the object used in logMessage.toJSON() and when the log is written to the console. + * The log represented as an object that is useful for stringifying or pulling data from. * @returns {GenericRecord} The compiled log object. */ get value() { @@ -229,55 +228,28 @@ export default class LogMessage implements ILogMessage { throw err; } - /** - * Returns the compiled log object converted into JSON. This method utilizes `options.replacer` for the replacer function. It also uses - * [fast-safe-stringify](https://www.npmjs.com/package/fast-safe-stringify) to prevent circular reference issues. - * @param {boolean} format Whether to format the log object with line breaks and indentation. - * @returns {string} The JSON string. - */ - toJSON(format?: boolean) { - return stringify(this.value, this.__opts.replacer ?? undefined, format ? 2 : 0); - } - /** * Converts the log to a string using a specific/custom formatter. * @returns {string} The formatted log as a string. */ - toString() { + toString(): string { return this.formatMessage(this.__opts.onFormat); } /** * Converts the log to a string using a specific/custom formatter. * @protected - * @param {Formatter} [formatter] The formatter to use or custom formatter function. + * @param {FormatPlugin} [formatter] The formatter to use or custom formatter function. * @returns {string} The formatted log as a string. */ - protected formatMessage(formatter?: Formatter) { + protected formatMessage(formatter?: FormatPlugin): string { const { __opts } = this; - if(typeof formatter === 'function') { - return formatter.call(this, this, __opts, stringify); + if(!formatter || typeof formatter !== 'function') { + formatter = jsonFormatter(); } - switch (formatter) { - // Clean Formatter - case 'clean': - return [ - `${this.level.toUpperCase()}\t${this.msg}`, - this.tags.length ? `\t├→ ${this.tags.join(', ')}` : null, - Object.keys(this.meta).length ? `\t└→ ${stringify(this.meta, undefined, 4).replace(/\n/g, '\n\t┊ ')}` : null - ].filter(v => Boolean(v)).join('\n'); - - // Minimal Formatter - case 'minimal': - return `${this.level.toUpperCase()}\t${this.msg}`; - - // JSON Formatter (default) - case 'json': - default: - return this.toJSON(__opts.dev); - } + return formatter.call(this, this, __opts, stringify); } /** diff --git a/src/formatters/full.spec.ts b/src/formatters/full.spec.ts new file mode 100644 index 0000000..8136de0 --- /dev/null +++ b/src/formatters/full.spec.ts @@ -0,0 +1,64 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import fullFormatter from './full'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +describe('formatters/full', () => { + it('should export a closure function', () => { + expect(typeof fullFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof fullFormatter()).toBe('function'); + }); + + it('should overrride configuration', () => { + const formatter = fullFormatter({ + includeTimestamp: false, + includeTags: false, + includeMeta: false, + separator: '\t', + inspectOptions: { + colors: false, + maxArrayLength: 25 + } + }); + + const cfg = formatter._cfg!; + + expect(cfg.includeTimestamp).toBe(false); + expect(cfg.includeTags).toBe(false); + expect(cfg.includeMeta).toBe(false); + expect(cfg.separator).toBe('\t'); + expect(cfg.inspectOptions).toEqual({ + depth: Infinity, + colors: false, + maxArrayLength: 25 + }); + }); + + it('should skip the timestamp if includeTimestamp is false', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = fullFormatter({ + includeTimestamp: false + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^INFO\tinfo test$/); + }); +}); diff --git a/src/formatters/full.ts b/src/formatters/full.ts new file mode 100644 index 0000000..01b9bf4 --- /dev/null +++ b/src/formatters/full.ts @@ -0,0 +1,62 @@ +import { FormatPlugin } from '../typings.js'; +import { inspect, InspectOptions } from 'util'; + +type FullFormatterCfg = { + includeTimestamp?: boolean; + formatTimestamp?: (timestamp: Date) => string; + includeTags?: boolean; + includeMeta?: boolean; + separator?: string; + inspectOptions?: InspectOptions; +}; + +/** + * Full formatter for log messages. + * @param {object} cfg Configuration object for the formatter. + * @returns {FormatPlugin} The full formatter function. + */ +export default function fullFormatter(cfg: FullFormatterCfg = {}): FormatPlugin { + const fmCfg = { + includeTimestamp: true, + formatTimestamp: (timestamp: Date) => timestamp.toISOString(), + includeTags: true, + includeMeta: true, + separator: '\t', + ...cfg + }; + + fmCfg.inspectOptions = { + depth: Infinity, + colors: true, + ...(fmCfg.inspectOptions ?? {}) + }; + + const fullFmt: FormatPlugin = (ctx): string => { + const msg = []; + if(fmCfg.includeTimestamp) { + msg.push(fmCfg.formatTimestamp(new Date())); + } + + msg.push(ctx.level.toUpperCase(), ctx.msg); + + const parts = [ + msg.join(fmCfg.separator) + ]; + + if(fmCfg.includeTags && ctx.tags.length) { + const tags = ctx.tags.map(tag => `${tag}`).join(', '); + parts.push(`→ ${tags}`); + } + + if(fmCfg.includeMeta && Object.keys(ctx.meta).length) { + const meta = inspect(ctx.meta, fmCfg.inspectOptions!); + parts.push(`→ ${meta.replace(/\n/g, '\n ')}`); + } + + return parts.join('\n'); + }; + + fullFmt._cfg = fmCfg; + + return fullFmt; +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..2e0156a --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1,3 @@ +export { default as json } from './json.js'; +export { default as full } from './full.js'; +export { default as minimal } from './minimal.js'; diff --git a/src/formatters/json.spec.ts b/src/formatters/json.spec.ts new file mode 100644 index 0000000..4607a6f --- /dev/null +++ b/src/formatters/json.spec.ts @@ -0,0 +1,81 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import jsonFormatter from './json'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +const logObject = { + level: 'info', + msg: 'info test', + meta: {}, + tags: [] +}; + + +describe('formatters/json', () => { + it('should export a closure function', () => { + expect(typeof jsonFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof jsonFormatter()).toBe('function'); + }); + + const replacerOpts = { + ...defaultOpts, + meta: { ssn: '444-55-6666' }, + replacer(key: string, value: unknown) { + if(key === 'ssn') { + return `${(value as string).substring(0, 3)}-**-****`; + } + + return value; + } + }; + + const msg = new LogMessage({ ...logObject }, replacerOpts); + + const formatter = jsonFormatter(); + const result = formatter(msg, replacerOpts, stringify); + + it('should return log in JSON format', () => { + expect(result).toBeJsonString(); + }); + + it('should run replacer function', () => { + expect(JSON.parse(result).ssn).toBe('444-**-****'); + }); + + it('should not run replacer function when not defined', () => { + const msgNoReplacer = new LogMessage({ ...logObject }, { + ...defaultOpts, + meta: { ssn: '444-55-6666' } + }); + + const noReplacerResult = formatter(msgNoReplacer, defaultOpts, stringify); + + expect(JSON.parse(noReplacerResult).ssn).toBe('444-55-6666'); + }); + + it('should pretty print JSON when dev is "true"', () => { + const opts = { + ...defaultOpts, + dev: true, + meta: { ssn: '444-55-6666' } + }; + + const msgDev = new LogMessage({ ...logObject }, opts); + const prettyResult = formatter(msgDev, opts, stringify); + + expect(/\n/g.test(prettyResult)).toBe(true); + }); +}); diff --git a/src/formatters/json.ts b/src/formatters/json.ts new file mode 100644 index 0000000..24e1551 --- /dev/null +++ b/src/formatters/json.ts @@ -0,0 +1,12 @@ +import { FormatPlugin } from '../typings.js'; + +/** + * JSON formatter for log messages. + * @returns {FormatPlugin} The JSON formatter function. + */ +export default function jsonFormatter(): FormatPlugin { + const jsonFmt: FormatPlugin = (ctx, options, stringify): string => + stringify(ctx.value, options.replacer ?? undefined, options.dev ? 2 : 0); + + return jsonFmt; +} diff --git a/src/formatters/minimal.spec.ts b/src/formatters/minimal.spec.ts new file mode 100644 index 0000000..f851f45 --- /dev/null +++ b/src/formatters/minimal.spec.ts @@ -0,0 +1,73 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import minimalFormatter from './minimal'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +describe('formatters/minmal', () => { + it('should export a closure function', () => { + expect(typeof minimalFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof minimalFormatter()).toBe('function'); + }); + + it('should overrride configuration', () => { + const formatter = minimalFormatter({ + includeTimestamp: false, + separator: '\t' + }); + + const cfg = formatter._cfg!; + + expect(cfg.includeTimestamp).toBe(false); + expect(cfg.separator).toBe('\t'); + }); + + it('should format timestamp as ISO string by default', () => { + const formatter = minimalFormatter(); + + // @ts-expect-error - we're testing the internals here + expect((formatter._cfg!).formatTimestamp(new Date())).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/); + }); + + it('should include the timestamp if includeTimestamp is true', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = minimalFormatter({ + includeTimestamp: true + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z) | INFO | info test$/); + }); + + it('should skip the timestamp if includeTimestamp is false', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = minimalFormatter({ + includeTimestamp: false + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^INFO | info test$/); + }); +}); diff --git a/src/formatters/minimal.ts b/src/formatters/minimal.ts new file mode 100644 index 0000000..28c7cb6 --- /dev/null +++ b/src/formatters/minimal.ts @@ -0,0 +1,36 @@ +import { FormatPlugin } from '../typings.js'; + +type MinimalFormatterCfg = { + includeTimestamp?: boolean; + formatTimestamp?: (timestamp: Date) => string; + separator?: string; +}; + +/** + * Minimal formatter for log messages. + * @param {object} cfg Configuration object for the formatter. + * @returns {FormatPlugin} The minimal formatter function. + */ +export default function minimalFormatter(cfg: MinimalFormatterCfg = {}): FormatPlugin { + const fmCfg = { + includeTimestamp: false, + formatTimestamp: (timestamp: Date) => timestamp.toISOString(), + separator: ' | ', + ...cfg + }; + + const minimalFmt: FormatPlugin = (ctx): string => { + const parts = []; + if(fmCfg.includeTimestamp) { + parts.push(fmCfg.formatTimestamp(new Date())); + } + + parts.push(ctx.level.toUpperCase(), ctx.msg); + + return parts.join(fmCfg.separator); + }; + + minimalFmt._cfg = fmCfg; + + return minimalFmt; +} diff --git a/src/lambda-log.ts b/src/lambda-log.ts index 6fa05c5..588a3cf 100644 --- a/src/lambda-log.ts +++ b/src/lambda-log.ts @@ -1,6 +1,7 @@ import LambdaLog from './LambdaLog.js'; import LogMessage from './LogMessage.js'; +import * as formatters from './formatters/index.js'; import * as Types from './typings.js'; export default new LambdaLog(); -export { LambdaLog, LogMessage, Types }; +export { LambdaLog, LogMessage, formatters, Types }; diff --git a/src/typings.ts b/src/typings.ts index fe3726b..c883826 100644 --- a/src/typings.ts +++ b/src/typings.ts @@ -2,7 +2,8 @@ import LogMessage from './LogMessage.js'; import stringify from 'fast-safe-stringify'; export type GenericRecord = Record; -export type Message = T; +export type Message = string | number | Error; +export type Metadata = GenericRecord | string | number | null | undefined; export type Empty = false | null | undefined; type TagFnObject = { @@ -15,21 +16,22 @@ export type Tag = string | number | ((data: TagFnObject) => string | Empty) | Em type StringifyType = typeof stringify; -export type Formatter = - 'json' | - 'clean' | - 'minimal' | - ((ctx: LogMessage, options: LambdaLogOptions, stringify: StringifyType) => string); - -export type LogObject = { +export type LogObject = { level: string; - msg: Message; - meta?: GenericRecord; + msg: T; + meta?: Metadata; tags?: Tag[]; }; export type LogLevels = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; +export type ParsePlugin = (msg: Message, options: LambdaLogOptions) => { msg: string; meta?: GenericRecord; error?: Error; tags?: Tag[] } | Empty; +export type CompilePlugin = (level?: string, msg?: Message, meta?: GenericRecord, tags?: Tag[], options?: LambdaLogOptions) => GenericRecord; +export type FormatPlugin = { + (ctx: LogMessage, options: LambdaLogOptions, stringify: StringifyType): string; + _cfg?: Record; +}; + export type LambdaLogOptions = { [key: string]: any; meta?: GenericRecord; @@ -43,9 +45,9 @@ export type LambdaLogOptions = { levelKey?: string | false; messageKey?: string; tagsKey?: string | false; - onParse?: (msg: Message, options: LambdaLogOptions) => { msg: string; meta?: GenericRecord; error?: Error; tags?: Tag[] } | Empty; - onCompile?: (level?: string, msg?: Message, meta?: GenericRecord, tags?: Tag[], options?: LambdaLogOptions) => GenericRecord; - onFormat?: Formatter; + onParse?: ParsePlugin; + onCompile?: CompilePlugin; + onFormat?: FormatPlugin; }; export interface ConsoleObject extends Console { diff --git a/src/utils.ts b/src/utils.ts index ad4a5bf..9ed5d81 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -31,7 +31,7 @@ export function stubError(error: Error) { 'stack' ].concat(Object.keys(err)); - return keys.reduce((obj: GenericRecord, key) => { + return keys.reduce((obj: GenericRecord, key: string) => { /* istanbul ignore next */ if(key in err) { const val: unknown = err[key];