diff --git a/package-lock.json b/package-lock.json index b6f9fdc..3c8d045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16753,16 +16753,16 @@ }, "packages/logger": { "name": "@shiftcode/logger", - "version": "3.0.4", + "version": "4.0.0-pr250.9", "license": "UNLICENSED", "devDependencies": { "@shiftcode/utilities": "^4.3.1" }, "engines": { - "node": "^20.0.0 || ^22.0.0" + "node": "^22.0.0 || ^24.0.0" }, "peerDependencies": { - "@shiftcode/utilities": "^4.0.0 || ^4.0.0-pr53" + "@shiftcode/utilities": "^4.0.0" } }, "packages/publish-helper": { diff --git a/packages/logger/package.json b/packages/logger/package.json index b5f68c9..302783c 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@shiftcode/logger", - "version": "3.0.4", + "version": "4.0.0-pr250.9", "description": "logger for local and aws lambda execution", "repository": "https://github.com/shiftcode/sc-commons-public", "license": "UNLICENSED", @@ -9,12 +9,16 @@ "type": "module", "exports": { ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + "types": "./dist/public-api.d.ts", + "default": "./dist/public-api.js" }, - "./test/*.js": { - "types": "./dist/test/*.d.ts", - "default": "./dist/test/*.js" + "./node": { + "types": "./dist/node/public-api.d.ts", + "default": "./dist/node/public-api.js" + }, + "./testing": { + "types": "./dist/testing/public-api.d.ts", + "default": "./dist/testing/public-api.js" } }, "scripts": { @@ -28,7 +32,7 @@ "lint:ci": "eslint ./src ./test", "lint:staged": "eslint --fix --cache", "prepublish": "node ../publish-helper/dist/prepare-dist.js", - "test": "NODE_OPTIONS=\"--experimental-vm-modules --trace-warnings\" npx jest --config jest.config.js", + "test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" npx jest --config jest.config.js", "test:ci": "npm run test", "test:watch": "npm run test -- --watch" }, @@ -36,10 +40,10 @@ "@shiftcode/utilities": "^4.3.1" }, "peerDependencies": { - "@shiftcode/utilities": "^4.0.0 || ^4.0.0-pr53" + "@shiftcode/utilities": "^4.0.0" }, "engines": { - "node": "^20.0.0 || ^22.0.0" + "node": "^22.0.0 || ^24.0.0" }, "publishConfig": { "directory": "dist" diff --git a/packages/logger/src/console-json-log-transport/console-json-log-transport-config.ts b/packages/logger/src/console-json-log-transport/console-json-log-transport-config.ts new file mode 100644 index 0000000..614fed0 --- /dev/null +++ b/packages/logger/src/console-json-log-transport/console-json-log-transport-config.ts @@ -0,0 +1,27 @@ +import { LogLevel } from '../model/log-level.enum.js' + +export interface ConsoleJsonLogTransportConfig { + logLevel: LogLevel + + /** + * function to alter the serialization of log messages. + * @default {@link jsonMapSetStringifyReplacer} + */ + jsonStringifyReplacer?: (key: string, value: any) => any + + /** + * Log messages below the configured level will be buffered up to this size + * and flushed when a log event with level Error occurs. + * set to 0 to disable this "below-level" buffering. + * @default 50 + */ + belowLevelLogBufferSize?: number + + /** + * when true, the log output will be a JS object instead of a JSON string. {@jsonStringifyReplacer} is still used. + * enable this option, when your lambda function is configured with `loggingFormat='JSON'` + * @hint when loggingFormat='JSON' is set, you should also configure `applicationLogLevelV2` with `TRACE` - otherwise you won't see all logging output. + * @default false + */ + logJsObject?: boolean +} diff --git a/packages/logger/src/console-json-log-transport/console-json-log.transport.spec.ts b/packages/logger/src/console-json-log-transport/console-json-log.transport.spec.ts new file mode 100644 index 0000000..d64e4f8 --- /dev/null +++ b/packages/logger/src/console-json-log-transport/console-json-log.transport.spec.ts @@ -0,0 +1,400 @@ +import { afterEach, beforeEach, describe, expect, test } from '@jest/globals' + +import { ConsoleMock, mockConsole, restoreConsole } from '../../test/console-mock.function.js' +import { LogLevel } from '../model/log-level.enum.js' +import { stringToColor } from '../utils/logger-helper.js' +import { ConsoleJsonLogTransport } from './console-json-log.transport.js' + +describe('uses console statement according to levels', () => { + let logger: ConsoleJsonLogTransport + let logArgs: any[] + let timestamp: Date + let consoleMock: ConsoleMock + + beforeEach(() => { + consoleMock = mockConsole() + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.DEBUG }) + logArgs = ['foo bar'] + timestamp = new Date() + }) + afterEach(restoreConsole) + + test('calls correct console level for DEBUG', () => { + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, logArgs) + expect(consoleMock.debug).toHaveBeenCalled() + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar', + }), + ) + }) + test('calls correct console level for INFO', () => { + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), timestamp, logArgs) + expect(consoleMock.info).toHaveBeenCalled() + expect(consoleMock.info).toHaveBeenCalledWith( + JSON.stringify({ + level: 'INFO', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar', + }), + ) + }) + test('calls correct console level for WARN', () => { + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), timestamp, logArgs) + expect(consoleMock.warn).toHaveBeenCalled() + expect(consoleMock.warn).toHaveBeenCalledWith( + JSON.stringify({ + level: 'WARN', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar', + }), + ) + }) + test('calls correct console level for ERROR', () => { + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), timestamp, logArgs) + expect(consoleMock.error).toHaveBeenCalled() + expect(consoleMock.error).toHaveBeenCalledWith( + JSON.stringify({ + level: 'ERROR', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar', + }), + ) + }) +}) + +describe('respects the configured level', () => { + let consoleMock: ConsoleMock + + beforeEach(() => { + consoleMock = mockConsole() + }) + afterEach(restoreConsole) + + test('respects level DEBUG', () => { + const logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.DEBUG, belowLevelLogBufferSize: 0 }) + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar debug']) + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(1) + expect(consoleMock.info).toHaveBeenCalledTimes(1) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level INFO', () => { + const logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.INFO, belowLevelLogBufferSize: 0 }) + // do not log DEBUG to keep this focused on allowed levels only + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(1) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level WARN', () => { + const logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.WARN, belowLevelLogBufferSize: 0 }) + // do not log DEBUG/INFO to keep this focused on allowed levels only + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(0) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level ERROR', () => { + const logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.ERROR, belowLevelLogBufferSize: 0 }) + // only log ERROR as allowed + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(0) + expect(consoleMock.warn).toHaveBeenCalledTimes(0) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) +}) + +describe('use first argument as message if string', () => { + let logger: ConsoleJsonLogTransport + let consoleMock: ConsoleMock + let timestamp: Date + + beforeEach(() => { + consoleMock = mockConsole() + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.DEBUG }) + timestamp = new Date() + }) + afterEach(restoreConsole) + + test('if only 1 arg', () => { + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, ['foo bar debug']) + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar debug', + }), + ) + }) + test('if second arg is object, put it to data', () => { + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, ['foo bar debug', { value: true }]) + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar debug', + data: { + value: true, + }, + }), + ) + }) + test('if several additional object args, put them to data', () => { + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, [ + 'foo bar debug', + { first: true }, + { second: false }, + ]) + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + message: 'foo bar debug', + data: [{ first: true }, { second: false }], + }), + ) + }) + + test('if first is not a string, message property is undefined', () => { + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, [{ name: 'foo bar' }]) + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + data: { name: 'foo bar' }, + }), + ) + }) +}) + +describe('handles circular references in log data', () => { + let logger: ConsoleJsonLogTransport + let consoleMock: ConsoleMock + let timestamp: Date + + beforeEach(() => { + consoleMock = mockConsole() + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.DEBUG }) + timestamp = new Date() + }) + afterEach(restoreConsole) + + test('in single object argument', () => { + const circularObj: any = { name: 'circular' } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + circularObj.self = circularObj + + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, [circularObj]) + + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + data: { + name: 'circular', + self: '', + }, + }), + ) + }) + + test('in nested object argument', () => { + const circularObj: any = { name: 'circular' } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + circularObj.nested = { inner: circularObj } + + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), timestamp, [circularObj]) + + expect(consoleMock.debug).toHaveBeenCalledWith( + JSON.stringify({ + level: 'DEBUG', + timestamp: timestamp.toISOString(), + logger: 'MyClass', + data: { + name: 'circular', + nested: { + inner: '', + }, + }, + }), + ) + }) +}) + +describe('respects logJsObject config', () => { + let consoleMock: ConsoleMock + let timestamp: Date + + beforeEach(() => { + consoleMock = mockConsole() + timestamp = new Date() + }) + afterEach(restoreConsole) + + test('logs as JSON string when logJsObject is false (default)', () => { + const logger = new ConsoleJsonLogTransport({ + logLevel: LogLevel.WARN, + logJsObject: false, + belowLevelLogBufferSize: 1, + }) + + logger.log(LogLevel.INFO, 'MyClass', '#000', timestamp, ['test message', { data: 'value' }]) + logger.log(LogLevel.WARN, 'MyClass', '#000', timestamp, ['test warn', { data: 'value' }]) + logger.log(LogLevel.ERROR, 'MyClass', '#000', timestamp, ['test error', { data: 'value' }]) + + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + + const warnLog = consoleMock.warn.mock.calls[0][0] + expect(typeof warnLog).toBe('string') + const parsedWarnLog = JSON.parse(warnLog as string) + expect(parsedWarnLog).toEqual(expect.objectContaining({ level: 'WARN', message: 'test warn' })) + + expect(consoleMock.error).toHaveBeenCalledTimes(1) + const errorLog = consoleMock.error.mock.calls[0][0] + expect(typeof errorLog).toBe('string') + const parsedErrorLog = JSON.parse(errorLog as string) + expect(parsedErrorLog).toEqual(expect.objectContaining({ level: 'ERROR', message: 'test error' })) + }) + + test('logs as JavaScript object when logJsObject is true', () => { + const logger = new ConsoleJsonLogTransport({ + logLevel: LogLevel.ERROR, + logJsObject: true, + belowLevelLogBufferSize: 1, + }) + + logger.log(LogLevel.WARN, 'MyClass', '#000', timestamp, ['test warning', { foo: 'bar' }]) + logger.log(LogLevel.ERROR, 'MyClass', '#000', timestamp, ['test error', { foo: 'bar' }]) + + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.warn.mock.calls[0][0]).toEqual( + expect.objectContaining({ level: 'WARN', message: 'test warning' }), + ) + + expect(consoleMock.error).toHaveBeenCalledTimes(1) + expect(consoleMock.error.mock.calls[0][0]).toEqual( + expect.objectContaining({ level: 'ERROR', message: 'test error' }), + ) + }) +}) + +describe('below-level buffering and flush on ERROR', () => { + let logger: ConsoleJsonLogTransport + let consoleMock: ConsoleMock + + beforeEach(() => void (consoleMock = mockConsole())) + afterEach(restoreConsole) + + const parseCallArg = (arg: unknown) => JSON.parse(arg as string) as { level: string; message: string } + + test('buffers below-level DEBUG/WARN logs and flushes them before ERROR', () => { + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.ERROR, belowLevelLogBufferSize: 10 }) + + const ts = new Date() + const color = stringToColor('MyClass') + + logger.log(LogLevel.DEBUG, 'MyClass', color, ts, ['debug-1']) + logger.log(LogLevel.WARN, 'MyClass', color, ts, ['warn-1']) + + // trigger flush + logger.log(LogLevel.ERROR, 'MyClass', color, ts, ['error-1']) + + expect(consoleMock.debug).toHaveBeenCalledTimes(1) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + + // verify order: flushed entries were logged before the ERROR + const debugOrder = consoleMock.debug.mock.invocationCallOrder[0] + const warnOrder = consoleMock.warn.mock.invocationCallOrder[0] + const errorOrder = consoleMock.error.mock.invocationCallOrder[0] + expect(debugOrder).toBeLessThan(warnOrder) + expect(warnOrder).toBeLessThan(errorOrder) + + // verify messages preserved + const debugPayload = parseCallArg(consoleMock.debug.mock.calls[0][0]) + const warnPayload = parseCallArg(consoleMock.warn.mock.calls[0][0]) + const errorPayload = parseCallArg(consoleMock.error.mock.calls[0][0]) + + expect(debugPayload).toEqual(expect.objectContaining({ level: 'DEBUG', message: 'debug-1' })) + expect(warnPayload).toEqual(expect.objectContaining({ level: 'WARN', message: 'warn-1' })) + expect(errorPayload).toEqual(expect.objectContaining({ level: 'ERROR', message: 'error-1' })) + }) + + test('respects buffer size: only keep the last N items', () => { + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.ERROR, belowLevelLogBufferSize: 2 }) + + const ts = new Date() + const color = stringToColor('MyClass') + + logger.log(LogLevel.DEBUG, 'MyClass', color, ts, ['a']) + logger.log(LogLevel.DEBUG, 'MyClass', color, ts, ['b']) + logger.log(LogLevel.DEBUG, 'MyClass', color, ts, ['c']) // should evict 'a' + + logger.log(LogLevel.ERROR, 'MyClass', color, ts, ['boom']) + + expect(consoleMock.debug).toHaveBeenCalledTimes(2) + const flushedMessages = consoleMock.debug.mock.calls.map((c: any[]) => parseCallArg(c[0]).message) + expect(flushedMessages).toEqual(['b', 'c']) + }) + + test('buffer size 0 disables buffering', () => { + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.ERROR, belowLevelLogBufferSize: 0 }) + + const ts = new Date() + const color = stringToColor('MyClass') + + logger.log(LogLevel.DEBUG, 'MyClass', color, ts, ['pre']) // not kept + logger.log(LogLevel.ERROR, 'MyClass', color, ts, ['err']) + + expect(consoleMock.debug).not.toHaveBeenCalled() + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + + test('does not flush when ERROR is not enabled (log level OFF)', () => { + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.OFF, belowLevelLogBufferSize: 5 }) + + const ts = new Date() + + logger.log(LogLevel.DEBUG, 'MyClass', '#000000', ts, ['pre']) + logger.log(LogLevel.ERROR, 'MyClass', '#000000', ts, ['err']) // below-level, so no flush + + expect(consoleMock.debug).not.toHaveBeenCalled() + expect(consoleMock.info).not.toHaveBeenCalled() + expect(consoleMock.warn).not.toHaveBeenCalled() + expect(consoleMock.error).not.toHaveBeenCalled() + }) + + test('clears buffer after flushing on ERROR', () => { + logger = new ConsoleJsonLogTransport({ logLevel: LogLevel.ERROR, belowLevelLogBufferSize: 5 }) + + const ts = new Date() + + logger.log(LogLevel.DEBUG, 'MyClass', '#000000', ts, ['once']) + logger.log(LogLevel.ERROR, 'MyClass', '#000000', ts, ['first']) // flushes 'once' + logger.log(LogLevel.ERROR, 'MyClass', '#000000', ts, ['second']) // no additional debug flushed + + expect(consoleMock.debug).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/logger/src/console-json-log-transport/console-json-log.transport.ts b/packages/logger/src/console-json-log-transport/console-json-log.transport.ts new file mode 100644 index 0000000..f76cbec --- /dev/null +++ b/packages/logger/src/console-json-log-transport/console-json-log.transport.ts @@ -0,0 +1,85 @@ +import { jsonMapSetStringifyReplacer } from '@shiftcode/utilities' + +import { LogLevel } from '../model/log-level.enum.js' +import { LogTransport } from '../model/log-transport.js' +import { createJsonLogObjectData } from '../utils/create-json-log-object-data.function.js' +import { getJsonStringifyReplacer } from '../utils/get-json-stringify-replacer.function.js' +import { pushToRingBuffer } from '../utils/push-to-ring-buffer.function.js' +import { ConsoleJsonLogTransportConfig } from './console-json-log-transport-config.js' + +interface BufferedLogMessage { + level: LogLevel + message: string +} + +export class ConsoleJsonLogTransport extends LogTransport { + private static readonly DEFAULT_BUFFER_SIZE = 50 + private readonly bufferSize: number + + /** Ring buffer for log events below the configured level, flushed on ERROR */ + private pendingBuffer: BufferedLogMessage[] = [] + + private readonly logJsObject: boolean + private readonly jsonStringifyReplacer: (key: string, value: any) => any + + constructor(config: ConsoleJsonLogTransportConfig) { + super(config.logLevel) + this.logJsObject = config.logJsObject ?? false + this.bufferSize = config.belowLevelLogBufferSize ?? ConsoleJsonLogTransport.DEFAULT_BUFFER_SIZE + this.jsonStringifyReplacer = config.jsonStringifyReplacer ?? jsonMapSetStringifyReplacer + } + + log(level: LogLevel, clazzName: string, _hexColor: string, timestamp: Date, args: any[]) { + if (level === LogLevel.OFF) { + return + } + + const logObject = createJsonLogObjectData(level, clazzName, timestamp, args) + + // we stringify at this point (instead of postpone it to when logging actually happens) + // to cut any potential references to objects that could potentially change until the log is actually written. + const message = JSON.stringify(logObject, getJsonStringifyReplacer(this.jsonStringifyReplacer)) + + if (!this.isLevelEnabled(level)) { + pushToRingBuffer(this.pendingBuffer, { level, message }, this.bufferSize) + return + } + + // on error enabled: flush buffered events first, then clear buffer + if (level === LogLevel.ERROR) { + for (const bufferedEntry of this.pendingBuffer) { + this.logToConsole(bufferedEntry.level, bufferedEntry.message) + } + this.pendingBuffer = [] + } + + // log current message + this.logToConsole(level, message) + } + + protected logToConsole(level: LogLevel, toLog: string) { + if (this.logJsObject) { + toLog = JSON.parse(toLog) + } + /* eslint-disable no-console */ + switch (level) { + case LogLevel.DEBUG: + console.debug(toLog) + break + case LogLevel.ERROR: + console.error(toLog) + break + case LogLevel.INFO: + console.info(toLog) + break + case LogLevel.WARN: + console.warn(toLog) + break + case LogLevel.OFF: + break + default: + return level // exhaustive check + } + /* eslint-enable no-console */ + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts deleted file mode 100644 index 55ed705..0000000 --- a/packages/logger/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './model/json-log-object-data.js' -export * from './model/json-log-transport.js' -export * from './model/log-level.enum.js' -export * from './model/log-transport.js' -export * from './model/logger.js' -export * from './services/base-logger.service.js' -export * from './utils/logger-helper.js' diff --git a/packages/logger/src/model/json-log-object-data.ts b/packages/logger/src/model/json-log-object-data.ts deleted file mode 100644 index 38ade41..0000000 --- a/packages/logger/src/model/json-log-object-data.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { getEnumKeyFromNum } from '@shiftcode/utilities' - -import { LogLevel } from './log-level.enum.js' - -export interface JsonLogObjectData { - level: string - logger: string - timestamp: string /* ISO string */ - message?: string - errorName?: string - exception?: string - data?: any[] -} - -export function createJsonLogObjectData( - level: LogLevel, - clazzName: string, - timestamp: Date, - args: any[], -): JsonLogObjectData { - const logObjectData: Partial = { - level: getEnumKeyFromNum(LogLevel, level), - timestamp: timestamp.toISOString(), - logger: clazzName, - } - - const msgOrError = args.shift() - if (typeof msgOrError === 'string') { - logObjectData.message = msgOrError - } else if (msgOrError instanceof Error) { - logObjectData.message = msgOrError.message - logObjectData.errorName = msgOrError.name - logObjectData.exception = msgOrError.stack?.toString() - } else { - // first param is neither string nor error, put it back to args to pass it to data - args = [msgOrError, ...args] - } - if (args.length) { - logObjectData.data = args.length === 1 ? args[0] : args - } - - return logObjectData as JsonLogObjectData -} diff --git a/packages/logger/src/model/log-transport.spec.ts b/packages/logger/src/model/log-transport.spec.ts index 3b0944c..b99856b 100644 --- a/packages/logger/src/model/log-transport.spec.ts +++ b/packages/logger/src/model/log-transport.spec.ts @@ -1,6 +1,6 @@ -import { SpyLogTransport } from '../../test/spy-log.transport.js' -import { LogLevel } from '../index.js' +import { SpyLogTransport } from '../testing/spy-log.transport.js' import { stringToColor } from '../utils/logger-helper.js' +import { LogLevel } from './log-level.enum.js' describe('respects the configured level', () => { test('respects level DEBUG', () => { diff --git a/packages/logger/src/model/logger.spec.ts b/packages/logger/src/model/logger.spec.ts index 345fc03..95bc603 100644 --- a/packages/logger/src/model/logger.spec.ts +++ b/packages/logger/src/model/logger.spec.ts @@ -1,6 +1,7 @@ -import { SpyLogTransport } from '../../test/spy-log.transport.js' -import { Logger, LogLevel } from '../index.js' +import { SpyLogTransport } from '../testing/spy-log.transport.js' import { stringToColor } from '../utils/logger-helper.js' +import { LogLevel } from './log-level.enum.js' +import { Logger } from './logger.js' describe('Logger behavior', () => { let logger: Logger diff --git a/packages/logger/src/node/create-console-logger.function.spec.ts b/packages/logger/src/node/create-console-logger.function.spec.ts new file mode 100644 index 0000000..b36f61c --- /dev/null +++ b/packages/logger/src/node/create-console-logger.function.spec.ts @@ -0,0 +1,33 @@ +import { ConsoleJsonLogTransport } from '../console-json-log-transport/console-json-log.transport.js' +import { LogLevel } from '../model/log-level.enum.js' +import { Logger } from '../model/logger.js' +import { createConsoleLogger } from './create-console-logger.function.js' +import { NodeConsoleLogTransport } from './node-console-log-transport/node-console-log.transport.js' + +describe('createConsoleLogger', () => { + it('should create logger with NodeConsoleLogTransport when type is "node"', () => { + const logger: Logger = createConsoleLogger( + 'test-logger', + { node: { logLevel: LogLevel.DEBUG }, json: { logLevel: LogLevel.ERROR } }, + 'node', + ) + + expect(logger).toBeDefined() + expect(logger['loggerTransports']).toHaveLength(1) + expect(logger['loggerTransports'][0]).toBeInstanceOf(NodeConsoleLogTransport) + expect(logger['loggerTransports'][0]['logLevel']).toBe(LogLevel.DEBUG) + }) + + it('should create logger with ConsoleJsonLogTransport when type is "json"', () => { + const logger: Logger = createConsoleLogger( + 'test-logger', + { node: { logLevel: LogLevel.DEBUG }, json: { logLevel: LogLevel.ERROR } }, + 'json', + ) + + expect(logger).toBeDefined() + expect(logger['loggerTransports']).toHaveLength(1) + expect(logger['loggerTransports'][0]).toBeInstanceOf(ConsoleJsonLogTransport) + expect(logger['loggerTransports'][0]['logLevel']).toBe(LogLevel.ERROR) + }) +}) diff --git a/packages/logger/src/node/create-console-logger.function.ts b/packages/logger/src/node/create-console-logger.function.ts new file mode 100644 index 0000000..0666cd7 --- /dev/null +++ b/packages/logger/src/node/create-console-logger.function.ts @@ -0,0 +1,34 @@ +import { ConsoleJsonLogTransport } from '../console-json-log-transport/console-json-log.transport.js' +import { ConsoleJsonLogTransportConfig } from '../console-json-log-transport/console-json-log-transport-config.js' +import { LogTransport } from '../model/log-transport.js' +import { Logger } from '../model/logger.js' +import { BaseLoggerService } from '../services/base-logger.service.js' +import { NodeConsoleLogTransport } from './node-console-log-transport/node-console-log.transport.js' +import { NodeConsoleLogTransportConfig } from './node-console-log-transport/node-console-log-transport-config.js' + +type Config = { + node: NodeConsoleLogTransportConfig + json: ConsoleJsonLogTransportConfig +} +/** + * Creates a simple {@link Logger} instance that logs to the console with the specified name and log level + + * @param name The name of the logger + * @param logLevel decides the minimum log level to be logged + * @param type decides whether to use {@link NodeConsoleLogTransport} or {@link ConsoleJsonLogTransport} + * @param config optional configuration for the selected transport + */ +export function createConsoleLogger(name: string, config: Config, type: 'node' | 'json'): Logger { + let transport: LogTransport + switch (type) { + case 'node': + transport = new NodeConsoleLogTransport(config.node) + break + case 'json': + transport = new ConsoleJsonLogTransport(config.json) + break + default: + transport = type + } + return new BaseLoggerService([transport]).getInstance(name) +} diff --git a/packages/logger/src/node/node-console-log-transport/node-console-log-transport-config.ts b/packages/logger/src/node/node-console-log-transport/node-console-log-transport-config.ts new file mode 100644 index 0000000..f4bfa8d --- /dev/null +++ b/packages/logger/src/node/node-console-log-transport/node-console-log-transport-config.ts @@ -0,0 +1,13 @@ +import { InspectOptions } from 'node:util' + +import { LogLevel } from '../../model/log-level.enum.js' + +export interface NodeConsoleLogTransportConfig { + logLevel: LogLevel + + /** + * options for {@link util.inspect} when logging objects. + * @default none (uses node.js defaults) + */ + nodeInspectOptions?: InspectOptions +} diff --git a/packages/logger/src/node/node-console-log-transport/node-console-log-transport.spec.ts b/packages/logger/src/node/node-console-log-transport/node-console-log-transport.spec.ts new file mode 100644 index 0000000..a1211a3 --- /dev/null +++ b/packages/logger/src/node/node-console-log-transport/node-console-log-transport.spec.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from '@jest/globals' + +import { ConsoleMock, mockConsole, restoreConsole } from '../../../test/console-mock.function.js' +import { LogLevel } from '../../model/log-level.enum.js' +import { stringToColor } from '../../utils/logger-helper.js' +import { NodeConsoleLogTransport } from './node-console-log.transport.js' + +describe('uses console statement according to levels', () => { + const logDate = Object.freeze(new Date('2020-07-04T12:34:56.789')) + const formattedDate = '12:34:56.789' + + let logger: NodeConsoleLogTransport + let logArgs: any[] + let consoleMock: ConsoleMock + const className = 'MyClass' + const color = stringToColor(className) + + beforeEach(() => { + consoleMock = mockConsole() + logger = new NodeConsoleLogTransport({ logLevel: LogLevel.DEBUG }) + logArgs = ['foo bar'] + }) + afterEach(restoreConsole) + + test('calls correct console level for DEBUG', () => { + logger.log(LogLevel.DEBUG, className, color, logDate, logArgs) + expect(consoleMock.debug).toHaveBeenCalled() + const loggedStr = consoleMock.debug.mock.calls[0][0] + expect(loggedStr.includes(className)).toBeTruthy() + expect(loggedStr.includes(logArgs[0])).toBeTruthy() + expect(loggedStr.includes(formattedDate)).toBeTruthy() + }) + test('calls correct console level for INFO', () => { + logger.log(LogLevel.INFO, className, color, logDate, logArgs) + expect(consoleMock.info).toHaveBeenCalled() + const loggedStr = consoleMock.info.mock.calls[0][0] + expect(loggedStr.includes(className)).toBeTruthy() + expect(loggedStr.includes(logArgs[0])).toBeTruthy() + expect(loggedStr.includes(formattedDate)).toBeTruthy() + }) + test('calls correct console level for WARN', () => { + logger.log(LogLevel.WARN, className, color, logDate, logArgs) + expect(consoleMock.warn).toHaveBeenCalled() + const loggedStr = consoleMock.warn.mock.calls[0][0] + expect(loggedStr.includes(className)).toBeTruthy() + expect(loggedStr.includes(logArgs[0])).toBeTruthy() + expect(loggedStr.includes(formattedDate)).toBeTruthy() + }) + test('calls correct console level for ERROR', () => { + logger.log(LogLevel.ERROR, className, color, logDate, logArgs) + expect(consoleMock.error).toHaveBeenCalled() + const loggedStr = consoleMock.error.mock.calls[0][0] + expect(loggedStr.includes(className)).toBeTruthy() + expect(loggedStr.includes(logArgs[0])).toBeTruthy() + expect(loggedStr.includes(formattedDate)).toBeTruthy() + }) +}) + +describe('respects the configured level', () => { + let consoleMock: ConsoleMock + + beforeEach(() => { + consoleMock = mockConsole() + }) + afterEach(restoreConsole) + + test('respects level DEBUG', () => { + const logger = new NodeConsoleLogTransport({ logLevel: LogLevel.DEBUG }) + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar debug']) + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(1) + expect(consoleMock.info).toHaveBeenCalledTimes(1) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level INFO', () => { + const logger = new NodeConsoleLogTransport({ logLevel: LogLevel.INFO }) + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar debug']) + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(1) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level WARN', () => { + const logger = new NodeConsoleLogTransport({ logLevel: LogLevel.WARN }) + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar debug']) + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(0) + expect(consoleMock.warn).toHaveBeenCalledTimes(1) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) + test('respects level ERROR', () => { + const logger = new NodeConsoleLogTransport({ logLevel: LogLevel.ERROR }) + logger.log(LogLevel.DEBUG, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar debug']) + logger.log(LogLevel.INFO, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar info']) + logger.log(LogLevel.WARN, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar warn']) + logger.log(LogLevel.ERROR, 'MyClass', stringToColor('MyClass'), new Date(), ['foo bar error']) + expect(consoleMock.debug).toHaveBeenCalledTimes(0) + expect(consoleMock.info).toHaveBeenCalledTimes(0) + expect(consoleMock.warn).toHaveBeenCalledTimes(0) + expect(consoleMock.error).toHaveBeenCalledTimes(1) + }) +}) + +describe('prints all the given arguments', () => { + const className = 'MyClass' + const logDate = new Date() + const color = stringToColor(className) + let logger: NodeConsoleLogTransport + let consoleMock: ConsoleMock + + beforeEach(() => { + consoleMock = mockConsole() + logger = new NodeConsoleLogTransport({ logLevel: LogLevel.DEBUG }) + }) + afterEach(restoreConsole) + + test('logs Error as Error', () => { + const error = new Error('fail') + logger.log(LogLevel.ERROR, className, color, logDate, [error]) + const usedArgs = consoleMock.error.mock.calls[0] + expect(typeof usedArgs[0]).toBe('string') + expect(usedArgs[1]).toMatch(/^Error: fail\n(\s+at.*)\n/) // stack trace follows + }) + + test('stringifies objects', () => { + const obj = { propA: true } + logger.log(LogLevel.DEBUG, className, color, logDate, [obj]) + const usedArgs = consoleMock.debug.mock.calls[0] + expect(typeof usedArgs[0]).toBe('string') + expect(usedArgs[1]).toBe('{ propA: true }') // not json, but util.inspect style + }) +}) diff --git a/packages/logger/src/node/node-console-log-transport/node-console-log.transport.ts b/packages/logger/src/node/node-console-log-transport/node-console-log.transport.ts new file mode 100644 index 0000000..579668c --- /dev/null +++ b/packages/logger/src/node/node-console-log-transport/node-console-log.transport.ts @@ -0,0 +1,66 @@ +import util from 'node:util' + +import { colorizeForConsole } from '@shiftcode/utilities' + +import { LogLevel } from '../../model/log-level.enum.js' +import { LogTransport } from '../../model/log-transport.js' +import { NodeConsoleLogTransportConfig } from './node-console-log-transport-config.js' + +export const logLevelEmoji: Record = ['🐞', '💬', '💣', '🔥', ''] + +const timeFormat = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 3, + hour12: false, +}) + +export class NodeConsoleLogTransport extends LogTransport { + private readonly inspectOpts: util.InspectOptions + + constructor(config: NodeConsoleLogTransportConfig) { + super(config.logLevel) + this.inspectOpts = config.nodeInspectOptions ?? {} + } + + log(level: LogLevel, clazzName: string, hexColor: string, timestamp: Date, args: any[]) { + if (this.isLevelEnabled(level)) { + const now = timeFormat.format(timestamp) + // make sure to not alter the input args array + if (typeof args[0] === 'string') { + // if first arg is string, also colorize it + args = [ + `${logLevelEmoji[level]} ${colorizeForConsole(`${now} - ${clazzName} :: ${args[0]}`, hexColor)}`, + ...args.slice(1).map((a) => util.inspect(a, this.inspectOpts)), + ] + } else { + args = [ + `${logLevelEmoji[level]} ${colorizeForConsole(`${now} - ${clazzName} ::`, hexColor)}`, + ...args.map((a) => util.inspect(a, this.inspectOpts)), + ] + } + + /* eslint-disable prefer-spread,no-console */ + switch (level) { + case LogLevel.DEBUG: + console.debug.apply(console, args) + break + case LogLevel.ERROR: + console.error.apply(console, args) + break + case LogLevel.INFO: + console.info.apply(console, args) + break + case LogLevel.WARN: + console.warn.apply(console, args) + break + case LogLevel.OFF: + break + default: + return level // exhaustive check + } + /* eslint-enable prefer-spread,no-console */ + } + } +} diff --git a/packages/logger/src/node/public-api.ts b/packages/logger/src/node/public-api.ts new file mode 100644 index 0000000..d0bc943 --- /dev/null +++ b/packages/logger/src/node/public-api.ts @@ -0,0 +1,4 @@ +export * from './create-console-logger.function.js' +export * from './node-console-log-transport/node-console-log.transport.js' +export * from './node-console-log-transport/node-console-log-transport-config.js' +export * from './simple-lambda-logger.function.js' diff --git a/packages/logger/src/node/simple-lambda-logger.function.ts b/packages/logger/src/node/simple-lambda-logger.function.ts new file mode 100644 index 0000000..eddb3a7 --- /dev/null +++ b/packages/logger/src/node/simple-lambda-logger.function.ts @@ -0,0 +1,14 @@ +import { LogLevel } from '../model/log-level.enum.js' +import { createConsoleLogger } from './create-console-logger.function.js' + +function isAwsLambdaEnv(): boolean { + const env = globalThis.process?.env + // aws sets the AWS_EXECUTION_ENV so does the serverless framework - + // to detect local invocations SLS additionally sets IS_LOCAL env var + return !!(env?.['AWS_EXECUTION_ENV'] && !env?.['IS_LOCAL']) +} + +export function simpleLambdaLogger(name: string, logLevel: LogLevel = LogLevel.DEBUG) { + const isLambda = isAwsLambdaEnv() + return createConsoleLogger(name, { json: { logLevel }, node: { logLevel } }, isLambda ? 'json' : 'node') +} diff --git a/packages/logger/src/public-api.ts b/packages/logger/src/public-api.ts new file mode 100644 index 0000000..9ca2ca2 --- /dev/null +++ b/packages/logger/src/public-api.ts @@ -0,0 +1,12 @@ +export * from './console-json-log-transport/console-json-log.transport.js' +export * from './console-json-log-transport/console-json-log-transport-config.js' +export * from './model/log-level.enum.js' +export * from './model/log-transport.js' +export * from './model/logger.js' +export * from './services/base-logger.service.js' +export * from './utils/create-json-log-object-data.function.js' +export * from './utils/format-error.function.js' +export * from './utils/get-json-stringify-replacer.function.js' +export * from './utils/json-log-transport.js' +export * from './utils/logger-helper.js' +export * from './utils/push-to-ring-buffer.function.js' diff --git a/packages/logger/src/services/base-logger.service.spec.ts b/packages/logger/src/services/base-logger.service.spec.ts index c4943c8..0985dd6 100644 --- a/packages/logger/src/services/base-logger.service.spec.ts +++ b/packages/logger/src/services/base-logger.service.spec.ts @@ -1,6 +1,6 @@ -import { SpyLogTransport } from '../../test/spy-log.transport.js' import { LogLevel } from '../model/log-level.enum.js' import { Logger } from '../model/logger.js' +import { SpyLogTransport } from '../testing/spy-log.transport.js' import { BaseLoggerService } from './base-logger.service.js' describe('BaseLoggerService with SpyLogTransport', () => { diff --git a/packages/logger/src/testing/public-api.ts b/packages/logger/src/testing/public-api.ts new file mode 100644 index 0000000..a8ef48c --- /dev/null +++ b/packages/logger/src/testing/public-api.ts @@ -0,0 +1 @@ +export * from './spy-log.transport.js' diff --git a/packages/logger/test/spy-log.transport.ts b/packages/logger/src/testing/spy-log.transport.ts similarity index 74% rename from packages/logger/test/spy-log.transport.ts rename to packages/logger/src/testing/spy-log.transport.ts index 30fbd5a..21a1a98 100644 --- a/packages/logger/test/spy-log.transport.ts +++ b/packages/logger/src/testing/spy-log.transport.ts @@ -1,6 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies import { jest } from '@jest/globals' -import { LogLevel, LogTransport } from '../src/index.js' +import { LogLevel } from '../model/log-level.enum.js' +import { LogTransport } from '../model/log-transport.js' export class SpyLogTransport extends LogTransport { private logMock = jest.fn() diff --git a/packages/logger/src/utils/create-json-log-object-data.function.ts b/packages/logger/src/utils/create-json-log-object-data.function.ts new file mode 100644 index 0000000..26979be --- /dev/null +++ b/packages/logger/src/utils/create-json-log-object-data.function.ts @@ -0,0 +1,59 @@ +import { getEnumKeyFromNum } from '@shiftcode/utilities' + +import { LogLevel } from '../model/log-level.enum.js' +import { ErrorAttributes, formatError } from './format-error.function.js' + +// used to enforce the usage of the factory function without any runtime overhead +// therefore not intended to be used in runtime code +declare const JSON_LOG_OBJECT_DATA_BRAND: unique symbol + +/** + * make sure to use the {@link createJsonLogObjectData} util function to create an instance of this interface + */ +export interface JsonLogObjectData { + [JSON_LOG_OBJECT_DATA_BRAND]: true + + level: string + logger: string + timestamp: string /* ISO string */ + + message?: string + error?: ErrorAttributes + + data?: unknown +} + +export function createJsonLogObjectData( + level: LogLevel, + clazzName: string, + timestamp: Date, + args: unknown[], +): JsonLogObjectData { + const logObjectData: Partial = { + level: getEnumKeyFromNum(LogLevel, level), + timestamp: timestamp.toISOString(), + logger: clazzName, + } + + // if first arg is string, it's the message + if (typeof args[0] === 'string') { + logObjectData.message = args.shift() as string + } + + // if any arg is Error, extract it (only first one) + // --> if no specific message, use error message as log message + const errIndex = args.findIndex((arg) => arg instanceof Error) + if (errIndex !== -1) { + logObjectData.error = formatError(args.splice(errIndex, 1)[0] as Error) + if (!logObjectData.message) { + logObjectData.message = logObjectData.error.message + } + } + + // remaining args go to data + if (args.length) { + logObjectData.data = args.length === 1 ? args[0] : args + } + + return logObjectData as JsonLogObjectData +} diff --git a/packages/logger/src/utils/format-error.function.ts b/packages/logger/src/utils/format-error.function.ts new file mode 100644 index 0000000..4b0de7b --- /dev/null +++ b/packages/logger/src/utils/format-error.function.ts @@ -0,0 +1,72 @@ +export interface ErrorAttributes { + [key: string]: unknown + + name: string + location: string + message: string + stack?: string + cause?: unknown +} + +/** + * Format an error into a loggable object. + * + * @example + * ```json + * { + * "name": "Error", + * "location": "file.js:1", + * "message": "An error occurred", + * "stack": "Error: An error occurred\n at file.js:1\n at file.js:2\n at file.js:3", + * "cause": { + * "name": "OtherError", + * "location": "file.js:2", + * "message": "Another error occurred", + * "stack": "Error: Another error occurred\n at file.js:2\n at file.js:3\n at file.js:4" + * } + * } + * ``` + * + * @param error - Error to format + */ +export function formatError(error: Error): ErrorAttributes { + const { name, message, stack, cause, ...errorAttributes } = error + const formattedError: ErrorAttributes = { + name, + location: getCodeLocation(error.stack), + message, + stack, + cause: cause instanceof Error ? formatError(cause) : cause, + } + for (const key in error) { + if (typeof key === 'string' && !['name', 'message', 'stack', 'cause'].includes(key)) { + formattedError[key] = (errorAttributes as Record)[key] + } + } + + return formattedError +} + +/** + * Get the location of an error from a stack trace. + * + * @param stack - stack trace to parse + */ +function getCodeLocation(stack?: string): string { + if (!stack) { + return '' + } + + const stackLines = stack.split('\n') + const regex = /\(([^()]*?):(\d+?):(\d+?)\)\\?$/ + + for (const item of stackLines) { + const match = regex.exec(item) + + if (Array.isArray(match)) { + return `${match[1]}:${Number(match[2])}` + } + } + + return '' +} diff --git a/packages/logger/src/utils/get-json-stringify-replacer.function.spec.ts b/packages/logger/src/utils/get-json-stringify-replacer.function.spec.ts new file mode 100644 index 0000000..97c8394 --- /dev/null +++ b/packages/logger/src/utils/get-json-stringify-replacer.function.spec.ts @@ -0,0 +1,127 @@ +import { expect } from '@jest/globals' + +import { getJsonStringifyReplacer } from './get-json-stringify-replacer.function.js' + +describe('getJsonStringifyReplacer', () => { + it('should handle BigInt values', () => { + const replacer = getJsonStringifyReplacer() + const result = replacer('key', 123456789123456789n) + + expect(result).toBe('123456789123456789') + }) + + it('should format Error instances', () => { + const error = new Error('Test error') + const replacer = getJsonStringifyReplacer() + const result = replacer('key', error) + + expect(result).toEqual({ + name: 'Error', + location: expect.any(String), + message: 'Test error', + stack: expect.any(String), + cause: undefined, + }) + }) + + it('should handle circular references', () => { + const obj: any = { name: 'test' } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + obj.self = obj + + const replacer = getJsonStringifyReplacer() + + // First call should return the object + const firstResult = replacer('obj', obj) + expect(firstResult).toBe(obj) + + // Second call with same object should return circular reference marker + const secondResult = replacer('self', obj) + expect(secondResult).toBe('') + }) + + it('should not alter null values', () => { + const replacer = getJsonStringifyReplacer() + const result = replacer('key', null) + + expect(result).toBe(null) + }) + + it('should not alter primitive values', () => { + const replacer = getJsonStringifyReplacer() + + expect(replacer('key', 'string')).toBe('string') + expect(replacer('key', 42)).toBe(42) + expect(replacer('key', true)).toBe(true) + expect(replacer('key', undefined)).toBe(undefined) + }) + + it('should not alter simple arrays', () => { + const arr = [1, 2, 3] + const replacer = getJsonStringifyReplacer() + const result = replacer('arr', arr) + expect(result).toBe(arr) + }) + + it('should apply custom replacer', () => { + const customReplacer = (key: string, value: unknown) => { + if (key === 'secret') { + return '***' + } + return value + } + + const replacer = getJsonStringifyReplacer(customReplacer) + + expect(replacer('secret', 'password123')).toBe('***') + }) + + it('should use custom replacer prior handling BigInt values', () => { + const doubleBigInts = (key: string, value: unknown) => { + if (typeof value === 'bigint') { + return value * 2n + } + return value + } + + const replacer = getJsonStringifyReplacer(doubleBigInts) + const result = replacer('key', 50n) + expect(result).toBe('100') + }) + + it('should use custom replacer prior handling Error instances', () => { + const wrapErrors = (key: string, value: unknown) => { + if (value instanceof Error) { + return new Error('Failed', { cause: value }) + } + return value + } + + const replacer = getJsonStringifyReplacer(wrapErrors) + const result = replacer('key', new Error('Original error')) + + expect(result).toHaveProperty('message', 'Failed') + expect(result).toHaveProperty('cause', expect.objectContaining({ message: 'Original error' })) + }) + + it('should work with JSON.stringify', () => { + const obj: any = { + name: 'test', + symbol: Symbol('test'), + bigNumber: 9007199254740991n, + error: new Error('Test error'), + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + obj.circular = obj + + const handleSymbols = (_: string, value: unknown) => (typeof value === 'symbol' ? value.toString() : value) + + const replacer = getJsonStringifyReplacer(handleSymbols) + const result = JSON.stringify(obj, replacer) + + expect(result).toContain('"name":"test"') + expect(result).toContain('"symbol":"Symbol(test)"') + expect(result).toContain('"bigNumber":"9007199254740991"') + expect(result).toContain('"circular":""') + }) +}) diff --git a/packages/logger/src/utils/get-json-stringify-replacer.function.ts b/packages/logger/src/utils/get-json-stringify-replacer.function.ts new file mode 100644 index 0000000..f643cd9 --- /dev/null +++ b/packages/logger/src/utils/get-json-stringify-replacer.function.ts @@ -0,0 +1,31 @@ +import { formatError } from './format-error.function.js' + +type JsonStringifyReplacer = (key: string, value: unknown) => unknown + +/** + * handles circular references & bigints and formats errors + * @param jsonStringifyReplacer - custom replacer function which is called prior to the built-in handling + */ +export function getJsonStringifyReplacer(jsonStringifyReplacer?: JsonStringifyReplacer): JsonStringifyReplacer { + const references = new WeakSet() + + return (key, value) => { + let replacedValue = jsonStringifyReplacer ? jsonStringifyReplacer(key, value) : value + + if (typeof replacedValue === 'bigint') { + return replacedValue.toString() + } else if (replacedValue instanceof Error) { + replacedValue = formatError(replacedValue) + } + + if (typeof replacedValue === 'object' && replacedValue !== null) { + if (references.has(replacedValue)) { + return `` + } else { + references.add(replacedValue) + } + } + + return replacedValue + } +} diff --git a/packages/logger/src/model/json-log-object-data.spec.ts b/packages/logger/src/utils/json-log-object-data.spec.ts similarity index 50% rename from packages/logger/src/model/json-log-object-data.spec.ts rename to packages/logger/src/utils/json-log-object-data.spec.ts index c462b9c..6f4073b 100644 --- a/packages/logger/src/model/json-log-object-data.spec.ts +++ b/packages/logger/src/utils/json-log-object-data.spec.ts @@ -1,5 +1,7 @@ -import { createJsonLogObjectData } from './json-log-object-data.js' -import { LogLevel } from './log-level.enum.js' +import { expect } from '@jest/globals' + +import { LogLevel } from '../model/log-level.enum.js' +import { createJsonLogObjectData } from './create-json-log-object-data.function.js' describe('createJsonLogObjectData', () => { it('should create a log object with a message', () => { @@ -17,6 +19,8 @@ describe('createJsonLogObjectData', () => { it('should create a log object with an error', () => { const error = new Error('Test error') + error.name = 'TestError' + const result = createJsonLogObjectData(LogLevel.ERROR, 'MyClass', new Date('2023-01-01T00:00:00.000Z'), [error]) expect(result).toEqual({ @@ -24,8 +28,37 @@ describe('createJsonLogObjectData', () => { logger: 'MyClass', timestamp: '2023-01-01T00:00:00.000Z', message: 'Test error', - errorName: 'Error', - exception: error.stack, + error: { + name: 'TestError', + message: 'Test error', + cause: undefined, + location: expect.stringContaining('json-log-object-data.spec.ts'), + stack: expect.stringContaining(error.name), + }, + }) + }) + + it('should create a log object with an message and an error', () => { + const error = new Error('Test error') + error.name = 'TestError' + + const result = createJsonLogObjectData(LogLevel.ERROR, 'MyClass', new Date('2023-01-01T00:00:00.000Z'), [ + 'Something Failed', + error, + ]) + + expect(result).toEqual({ + level: 'ERROR', + logger: 'MyClass', + timestamp: '2023-01-01T00:00:00.000Z', + message: 'Something Failed', + error: { + name: 'TestError', + message: 'Test error', + cause: undefined, + location: expect.stringContaining('json-log-object-data.spec.ts'), + stack: expect.stringContaining(error.name), + }, }) }) diff --git a/packages/logger/src/model/json-log-transport.ts b/packages/logger/src/utils/json-log-transport.ts similarity index 69% rename from packages/logger/src/model/json-log-transport.ts rename to packages/logger/src/utils/json-log-transport.ts index ca3f24d..612d223 100644 --- a/packages/logger/src/model/json-log-transport.ts +++ b/packages/logger/src/utils/json-log-transport.ts @@ -1,6 +1,6 @@ -import { createJsonLogObjectData, JsonLogObjectData } from './json-log-object-data.js' -import { LogLevel } from './log-level.enum.js' -import { LogTransport } from './log-transport.js' +import { LogLevel } from '../model/log-level.enum.js' +import { LogTransport } from '../model/log-transport.js' +import { createJsonLogObjectData, JsonLogObjectData } from './create-json-log-object-data.function.js' export abstract class JsonLogTransport extends LogTransport { protected constructor(logLevel: LogLevel) { diff --git a/packages/logger/src/utils/push-to-ring-buffer.function.spec.ts b/packages/logger/src/utils/push-to-ring-buffer.function.spec.ts new file mode 100644 index 0000000..946041d --- /dev/null +++ b/packages/logger/src/utils/push-to-ring-buffer.function.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect } from '@jest/globals' + +import { pushToRingBuffer } from './push-to-ring-buffer.function.js' + +describe('pushToRingBuffer', () => { + it('should push items to the buffer and maintain max size', () => { + const buffer: number[] = [] + const maxSize = 3 + + pushToRingBuffer(buffer, 1, maxSize) + pushToRingBuffer(buffer, 2, maxSize) + pushToRingBuffer(buffer, 3, maxSize) + expect(buffer).toEqual([1, 2, 3]) + + pushToRingBuffer(buffer, 4, maxSize) + expect(buffer).toEqual([2, 3, 4]) + }) + + it('removes all items that are too many and keeps the tail', () => { + const buffer = [1, 2, 3, 4] + const maxSize = 2 + + pushToRingBuffer(buffer, 5, maxSize) + expect(buffer).toEqual([4, 5]) + }) + + it('does not throw or fail for negative maxSize but simply clears the buffer', () => { + const buffer1: string[] = ['foo'] + pushToRingBuffer(buffer1, 'bar', -1) + expect(buffer1).toEqual([]) + }) + + it('does not throw or fail for zero maxSize but simply clears the buffer', () => { + const buffer2: string[] = ['foo'] + pushToRingBuffer(buffer2, 'bar', 0) + expect(buffer2).toEqual([]) + }) +}) diff --git a/packages/logger/src/utils/push-to-ring-buffer.function.ts b/packages/logger/src/utils/push-to-ring-buffer.function.ts new file mode 100644 index 0000000..381fa7b --- /dev/null +++ b/packages/logger/src/utils/push-to-ring-buffer.function.ts @@ -0,0 +1,16 @@ +/** + * Push an item to a ring buffer array. If the buffer exceeds maxSize, the oldest entry is removed. + */ +export function pushToRingBuffer(buffer: T[], item: T, maxSize: number): void { + if (maxSize <= 0) { + while (buffer.shift()) { + // just clears the buffer + } + return + } + + buffer.push(item) + while (buffer.length > maxSize) { + buffer.shift() + } +} diff --git a/packages/logger/test/console-mock.function.ts b/packages/logger/test/console-mock.function.ts new file mode 100644 index 0000000..a17f668 --- /dev/null +++ b/packages/logger/test/console-mock.function.ts @@ -0,0 +1,25 @@ +import { jest } from '@jest/globals' + +const originalConsole = { ...console } + +export interface ConsoleMock { + debug: jest.Mock + info: jest.Mock + warn: jest.Mock + error: jest.Mock + log?: jest.Mock +} + +export function mockConsole() { + console.log = jest.fn() + console.debug = jest.fn() + console.info = jest.fn() + console.warn = jest.fn() + console.error = jest.fn() + + return (console) +} + +export function restoreConsole() { + console = originalConsole +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json index 1c18066..86dd6f1 100644 --- a/packages/logger/tsconfig.json +++ b/packages/logger/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "rootDir": "./src", "baseUrl": "./", "outDir": "./dist", "declarationDir": "./dist"