diff --git a/.changeset/itchy-rules-hunt.md b/.changeset/itchy-rules-hunt.md new file mode 100644 index 00000000..8f4a788f --- /dev/null +++ b/.changeset/itchy-rules-hunt.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/logger": minor +--- + +Added `LogLevel` diff --git a/.changeset/long-insects-jam.md b/.changeset/long-insects-jam.md new file mode 100644 index 00000000..e75c4956 --- /dev/null +++ b/.changeset/long-insects-jam.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/logger": minor +--- + +Added `Logger`. diff --git a/.changeset/shiny-rooms-turn.md b/.changeset/shiny-rooms-turn.md new file mode 100644 index 00000000..fcdeded7 --- /dev/null +++ b/.changeset/shiny-rooms-turn.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/logger": minor +--- + +Added `ConsoleLogger` diff --git a/.changeset/silly-wombats-fetch.md b/.changeset/silly-wombats-fetch.md new file mode 100644 index 00000000..3989f755 --- /dev/null +++ b/.changeset/silly-wombats-fetch.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/logger": minor +--- + +Added `LoggerOptions` diff --git a/.changeset/thin-feet-bathe.md b/.changeset/thin-feet-bathe.md new file mode 100644 index 00000000..c7ecf0c6 --- /dev/null +++ b/.changeset/thin-feet-bathe.md @@ -0,0 +1,5 @@ +--- +"@inversifyjs/logger": minor +--- + +Added `FileLogger`. diff --git a/codecov.yml b/codecov.yml index 15d44f6b..5d73aae3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -17,6 +17,9 @@ coverage: '@inversifyjs/http-core': flags: - '@inversifyjs/http-core' + '@inversifyjs/logger': + flags: + - '@inversifyjs/logger' '@inversifyjs/prototype-utils': flags: - '@inversifyjs/prototype-utils' @@ -39,6 +42,9 @@ flags: '@inversifyjs/http-core': paths: - packages/http/libraries/core + @inversifyjs/logger: + paths: + - packages/logger '@inversifyjs/prototype-utils': paths: - packages/foundation/libraries/prototype-utils diff --git a/knip.ts b/knip.ts index 962da2d8..13fa26fd 100644 --- a/knip.ts +++ b/knip.ts @@ -75,5 +75,6 @@ export default { project: defaultWorkspaceProjectConfig.project, }, "packages/http/libraries/*": defaultWorkspaceProjectConfig, + "packages/logger": defaultWorkspaceProjectConfig, }, } satisfies KnipConfig; diff --git a/packages/logger/.gitignore b/packages/logger/.gitignore new file mode 100644 index 00000000..288feba2 --- /dev/null +++ b/packages/logger/.gitignore @@ -0,0 +1,19 @@ +# Typescript compiled files +/lib/** + +/tsconfig.tsbuildinfo +/tsconfig.cjs.tsbuildinfo +/tsconfig.esm.tsbuildinfo + +# Test coverage report +/coverage + +# Test mutation report +/reports + +# node modules +/node_modules/ + +# Turborepo files +.turbo/ + diff --git a/packages/logger/.lintstagedrc.json b/packages/logger/.lintstagedrc.json new file mode 100644 index 00000000..0061dfc2 --- /dev/null +++ b/packages/logger/.lintstagedrc.json @@ -0,0 +1,9 @@ +{ + "*.js": [ + "prettier --write" + ], + "*.ts": [ + "prettier --write", + "eslint" + ] +} diff --git a/packages/logger/.npmignore b/packages/logger/.npmignore new file mode 100644 index 00000000..6b2fa298 --- /dev/null +++ b/packages/logger/.npmignore @@ -0,0 +1,25 @@ +/.turbo +/coverage +/reports + +**/*.spec.js +**/*.spec.js.map +**/*.ts +!lib/cjs/**/*.d.ts +lib/esm/**/*.d.ts.map +!lib/esm/index.d.ts +!lib/esm/index.d.ts.map + +.lintstagedrc.json +eslint.config.mjs +vitest.config.mjs +vitest.config.stryker.mjs +vitest.workspace.mjs +prettier.config.mjs +rollup.config.mjs +stryker.config.mjs +tsconfig.cjs.json +tsconfig.cjs.tsbuildinfo +tsconfig.esm.json +tsconfig.esm.tsbuildinfo +tsconfig.json diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 00000000..b428f75e --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,6 @@ +[![Test coverage](https://codecov.io/gh/inversify/monorepo/branch/main/graph/badge.svg?flag=%40inversifyjs%2Flogger)](https://codecov.io/gh/inversify/monorepo/branch/main/graph/badge.svg?flag=%40inversifyjs%2Flogger) +[![npm version](https://img.shields.io/github/package-json/v/inversify/monorepo?filename=packages%2Flogger%2Fpackage.json&style=plastic)](https://www.npmjs.com/package/@inversifyjs/logger) + +# @inversifyjs/logger + +Inversify monorepo logger modules. diff --git a/packages/logger/eslint.config.mjs b/packages/logger/eslint.config.mjs new file mode 100644 index 00000000..42002283 --- /dev/null +++ b/packages/logger/eslint.config.mjs @@ -0,0 +1,3 @@ +import myconfig from '@inversifyjs/foundation-eslint-config'; + +export default [...myconfig]; diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..88fe0cc1 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,74 @@ +{ + "author": "Adrián Martínez Jiménez", + "bugs": { + "url": "https://github.com/inversify/monorepo/issues" + }, + "description": "InversifyJs logger core package", + "devDependencies": { + "@stryker-mutator/core": "8.7.1", + "@stryker-mutator/typescript-checker": "8.7.1", + "@stryker-mutator/vitest-runner": "8.7.1", + "@types/node": "22.13.9", + "@vitest/coverage-v8": "3.0.7", + "eslint": "9.21.0", + "logform": "^2.7.0", + "prettier": "3.5.3", + "rimraf": "6.0.1", + "rollup": "4.34.9", + "ts-loader": "9.5.2", + "tslib": "2.8.1", + "typescript": "5.8.2", + "vitest": "3.0.7" + }, + "devEngines": { + "node": "^22.10.2", + "pnpm": "^9.12.1" + }, + "homepage": "https://inversify.io", + "keywords": [ + "dependency injection", + "dependency inversion", + "di", + "inversion of control container", + "ioc", + "javascript", + "node", + "typescript" + ], + "license": "MIT", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "exports": { + ".": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + } + }, + "name": "@inversifyjs/logger", + "peerDependencies": { + "winston": "^3.17.0" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/inversify/monorepo.git" + }, + "scripts": { + "build": "pnpm run build:cjs && pnpm run build:esm", + "build:cjs": "tsc --build tsconfig.cjs.json && pnpm exec foundation-ts-package-cjs ./lib/cjs", + "build:esm": "rollup -c ./rollup.config.mjs && pnpm exec foundation-ts-package-esm ./lib/esm", + "build:clean": "rimraf lib", + "format": "prettier --write ./src", + "lint": "eslint ./src", + "prebuild": "pnpm run build:clean", + "test": "vitest run", + "test:integration": "pnpm run test --project Integration", + "test:coverage": "pnpm run test:unit --coverage", + "test:mutation": "stryker run", + "test:uncommitted": "pnpm run test --changed=HEAD", + "test:unit": "pnpm run test --project Unit" + }, + "version": "1.0.0" +} diff --git a/packages/logger/prettier.config.mjs b/packages/logger/prettier.config.mjs new file mode 100644 index 00000000..70361db5 --- /dev/null +++ b/packages/logger/prettier.config.mjs @@ -0,0 +1,3 @@ +import config from '@inversifyjs/foundation-prettier-config'; + +export default config; diff --git a/packages/logger/rollup.config.mjs b/packages/logger/rollup.config.mjs new file mode 100644 index 00000000..c48ba50b --- /dev/null +++ b/packages/logger/rollup.config.mjs @@ -0,0 +1,4 @@ +import config from '@inversifyjs/foundation-rollup-config'; + +/** @type {!import("rollup").MergedRollupOptions[]} */ +export default config; diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 00000000..559090f5 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,7 @@ +import { ConsoleLogger } from './logger/ConsoleLogger'; +import { FileLogger } from './logger/FileLogger'; +import { Logger } from './logger/model/Logger'; +import { LoggerOptions } from './model/LoggerOptions'; +import { LogLevel } from './model/LogLevel'; + +export { ConsoleLogger, FileLogger, Logger, LoggerOptions, LogLevel }; diff --git a/packages/logger/src/logger/ConsoleLogger.ts b/packages/logger/src/logger/ConsoleLogger.ts new file mode 100644 index 00000000..764ecf4c --- /dev/null +++ b/packages/logger/src/logger/ConsoleLogger.ts @@ -0,0 +1,14 @@ +import { createLogger, transports } from 'winston'; + +import { LoggerOptions } from '../model/LoggerOptions'; +import { WinstonLoggerAdapter } from '../winston/adapter/WinstonLoggerAdapter'; + +export class ConsoleLogger extends WinstonLoggerAdapter { + constructor(context?: string, loggerOptions?: LoggerOptions) { + super( + createLogger({ transports: [new transports.Console()] }), + context, + loggerOptions, + ); + } +} diff --git a/packages/logger/src/logger/FileLogger.ts b/packages/logger/src/logger/FileLogger.ts new file mode 100644 index 00000000..d8bc63dc --- /dev/null +++ b/packages/logger/src/logger/FileLogger.ts @@ -0,0 +1,20 @@ +import { createLogger, transports } from 'winston'; + +import { LoggerOptions } from '../model/LoggerOptions'; +import { WinstonLoggerAdapter } from '../winston/adapter/WinstonLoggerAdapter'; + +export class FileLogger extends WinstonLoggerAdapter { + constructor( + fileName: string, + context?: string, + loggerOptions?: LoggerOptions, + ) { + super( + createLogger({ + transports: [new transports.File({ filename: fileName })], + }), + context, + loggerOptions, + ); + } +} diff --git a/packages/logger/src/logger/adapter/LoggerAdapter.spec.ts b/packages/logger/src/logger/adapter/LoggerAdapter.spec.ts new file mode 100644 index 00000000..bbd0011d --- /dev/null +++ b/packages/logger/src/logger/adapter/LoggerAdapter.spec.ts @@ -0,0 +1,461 @@ +import { + afterAll, + beforeAll, + describe, + expect, + it, + Mock, + vitest, +} from 'vitest'; + +import { LogLevel } from '../../model/LogLevel'; +import { ContextMetadata } from '../model/ContextMetadata'; +import { LoggerAdapter } from './LoggerAdapter'; + +class LoggerAdapterMock extends LoggerAdapter { + readonly #printLogMock: Mock< + (logType: LogLevel, message: string, context?: ContextMetadata) => void + >; + + constructor( + printLogMock: Mock< + (logType: LogLevel, message: string, context?: ContextMetadata) => void + >, + context?: string, + loggerOptions?: { json: boolean; logTypes: LogLevel[]; timestamp: boolean }, + ) { + super(context, loggerOptions); + + this.#printLogMock = printLogMock; + } + + protected override printLog( + logType: LogLevel, + message: string, + context?: ContextMetadata, + ): void { + this.#printLogMock(logType, message, context); + } +} + +describe(LoggerAdapter.name, () => { + let printLogMock: Mock< + (logType: LogLevel, message: string, context?: ContextMetadata) => void + >; + let loggerAdapter: LoggerAdapterMock; + + beforeAll(() => { + printLogMock = vitest.fn(); + loggerAdapter = new LoggerAdapterMock(printLogMock); + }); + + describe('.info', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'test message'; + contextMetadataFixture = { context: 'test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.info(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.INFO', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.INFO, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.http', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'http test message'; + contextMetadataFixture = { context: 'http test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.http(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.HTTP', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.HTTP, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.silly', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'silly test message'; + contextMetadataFixture = { context: 'silly test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.silly(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.SILLY', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.SILLY, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.error', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'error test message'; + contextMetadataFixture = { context: 'error test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.error(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.ERROR', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.ERROR, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.warn', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'warn test message'; + contextMetadataFixture = { context: 'warn test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.warn(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.WARN', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.WARN, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.debug', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'debug test message'; + contextMetadataFixture = { context: 'debug test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.debug(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.DEBUG', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.DEBUG, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.verbose', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata; + + beforeAll(() => { + messageFixture = 'verbose test message'; + contextMetadataFixture = { context: 'verbose test context' }; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.verbose(messageFixture, contextMetadataFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with LogType.VERBOSE', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + LogLevel.VERBOSE, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('.log', () => { + let messageFixture: string; + let contextMetadataFixture: ContextMetadata | undefined; + let logTypeFixture: LogLevel; + + beforeAll(() => { + messageFixture = 'log test message'; + contextMetadataFixture = { context: 'log test context' }; + logTypeFixture = LogLevel.INFO; + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapter.log( + logTypeFixture, + messageFixture, + contextMetadataFixture, + ); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with the provided parameters', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + logTypeFixture, + messageFixture, + contextMetadataFixture, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + + describe('having a log type not included in logTypes', () => { + let restrictedLoggerAdapter: LoggerAdapterMock; + + beforeAll(() => { + restrictedLoggerAdapter = new LoggerAdapterMock( + printLogMock, + undefined, + { + json: true, + logTypes: [LogLevel.ERROR], // Only ERROR logs allowed + timestamp: true, + }, + ); + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = restrictedLoggerAdapter.log( + LogLevel.INFO, + messageFixture, + contextMetadataFixture, + ); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should not call printLog', () => { + expect(printLogMock).not.toHaveBeenCalled(); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('having a LoggerAdapter with context', () => { + let contextFixture: string; + let loggerAdapterWithContext: LoggerAdapterMock; + + beforeAll(() => { + contextFixture = 'constructor context'; + loggerAdapterWithContext = new LoggerAdapterMock( + printLogMock, + contextFixture, + ); + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapterWithContext.log(logTypeFixture, messageFixture); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with context from constructor', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + logTypeFixture, + messageFixture, + { context: contextFixture }, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('having a LoggerAdapter with context and explicit context', () => { + let constructorContextFixture: string; + let explicitContextFixture: string; + let loggerAdapterWithContext: LoggerAdapterMock; + + beforeAll(() => { + constructorContextFixture = 'constructor context'; + explicitContextFixture = 'explicit context'; + loggerAdapterWithContext = new LoggerAdapterMock( + printLogMock, + constructorContextFixture, + ); + }); + + describe('when called', () => { + let result: unknown; + + beforeAll(() => { + result = loggerAdapterWithContext.log( + logTypeFixture, + messageFixture, + { + context: explicitContextFixture, + }, + ); + }); + + afterAll(() => { + vitest.clearAllMocks(); + }); + + it('should call printLog with the explicit context', () => { + expect(printLogMock).toHaveBeenCalledTimes(1); + expect(printLogMock).toHaveBeenCalledWith( + logTypeFixture, + messageFixture, + { context: explicitContextFixture }, + ); + }); + + it('should return undefined', () => { + expect(result).toBeUndefined(); + }); + }); + }); + }); +}); diff --git a/packages/logger/src/logger/adapter/LoggerAdapter.ts b/packages/logger/src/logger/adapter/LoggerAdapter.ts new file mode 100644 index 00000000..59cc19f5 --- /dev/null +++ b/packages/logger/src/logger/adapter/LoggerAdapter.ts @@ -0,0 +1,81 @@ +import { LoggerOptions } from '../../model/LoggerOptions'; +import { LogLevel } from '../../model/LogLevel'; +import { ContextMetadata } from '../model/ContextMetadata'; +import { Logger } from '../model/Logger'; + +type InternalLoggerOptions = Required; + +export abstract class LoggerAdapter implements Logger { + protected readonly _loggerOptions: InternalLoggerOptions; + readonly #context: string | undefined; + + constructor(context?: string, loggerOptions?: LoggerOptions) { + this._loggerOptions = this.#parseLoggingOptions(loggerOptions); + this.#context = context; + } + + public info(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.INFO, message, contextMetadata); + } + + public http(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.HTTP, message, contextMetadata); + } + + public silly(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.SILLY, message, contextMetadata); + } + + public log( + logType: LogLevel, + message: string, + contextMetadata?: ContextMetadata, + ): void { + if (this._loggerOptions.logTypes.includes(logType)) { + this.printLog(logType, message, { + ...contextMetadata, + context: contextMetadata?.context ?? this.#context, + }); + } + } + + public error(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.ERROR, message, contextMetadata); + } + + public warn(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.WARN, message, contextMetadata); + } + + public debug(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.DEBUG, message, contextMetadata); + } + + public verbose(message: string, contextMetadata?: ContextMetadata): void { + this.log(LogLevel.VERBOSE, message, contextMetadata); + } + + #parseLoggingOptions( + loggerOptions: LoggerOptions | undefined, + ): InternalLoggerOptions { + return { + json: loggerOptions?.json ?? false, + logTypes: loggerOptions?.logTypes ?? [ + LogLevel.DEBUG, + LogLevel.ERROR, + LogLevel.HTTP, + LogLevel.INFO, + LogLevel.SILLY, + LogLevel.VERBOSE, + LogLevel.WARN, + ], + timestamp: loggerOptions?.timestamp ?? true, + }; + } + + protected abstract printLog( + logType: LogLevel, + message: string, + context?: ContextMetadata, + ): void; +} diff --git a/packages/logger/src/logger/model/ContextMetadata.ts b/packages/logger/src/logger/model/ContextMetadata.ts new file mode 100644 index 00000000..9d1fd50e --- /dev/null +++ b/packages/logger/src/logger/model/ContextMetadata.ts @@ -0,0 +1,4 @@ +export interface ContextMetadata { + [key: string]: unknown; + context?: string | undefined; +} diff --git a/packages/logger/src/logger/model/Logger.ts b/packages/logger/src/logger/model/Logger.ts new file mode 100644 index 00000000..b4534e59 --- /dev/null +++ b/packages/logger/src/logger/model/Logger.ts @@ -0,0 +1,13 @@ +import { LogLevel } from '../../model/LogLevel'; +import { ContextMetadata } from './ContextMetadata'; + +export interface Logger { + log(logType: LogLevel, message: string, context?: ContextMetadata): void; + error(message: string, context?: ContextMetadata): void; + warn(message: string, context?: ContextMetadata): void; + info(message: string, context?: ContextMetadata): void; + http(message: string, context?: ContextMetadata): void; + verbose(message: string, context?: ContextMetadata): void; + debug(message: string, context?: ContextMetadata): void; + silly(message: string, context?: ContextMetadata): void; +} diff --git a/packages/logger/src/model/LogLevel.ts b/packages/logger/src/model/LogLevel.ts new file mode 100644 index 00000000..91b3c298 --- /dev/null +++ b/packages/logger/src/model/LogLevel.ts @@ -0,0 +1,9 @@ +export enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + HTTP = 'http', + VERBOSE = 'verbose', + DEBUG = 'debug', + SILLY = 'silly', +} diff --git a/packages/logger/src/model/LoggerOptions.ts b/packages/logger/src/model/LoggerOptions.ts new file mode 100644 index 00000000..fe5d6fbd --- /dev/null +++ b/packages/logger/src/model/LoggerOptions.ts @@ -0,0 +1,7 @@ +import { LogLevel } from './LogLevel'; + +export interface LoggerOptions { + json?: boolean; + logTypes?: LogLevel[]; + timestamp?: boolean; +} diff --git a/packages/logger/src/winston/adapter/WinstonLoggerAdapter.int.spec.ts b/packages/logger/src/winston/adapter/WinstonLoggerAdapter.int.spec.ts new file mode 100644 index 00000000..5fc690ff --- /dev/null +++ b/packages/logger/src/winston/adapter/WinstonLoggerAdapter.int.spec.ts @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +import stream from 'node:stream'; + +import { createLogger, transports } from 'winston'; + +import { LogLevel } from '../../model/LogLevel'; +import { WinstonLoggerAdapter } from './WinstonLoggerAdapter'; + +class TestStream extends stream.Writable { + public chunks: unknown[]; + + constructor() { + super({ objectMode: true }); + + this.chunks = []; + } + + public override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + this.chunks.push(chunk); + + callback(); + } +} + +describe(WinstonLoggerAdapter.name, () => { + describe('.log', () => { + describe('having a WinstonLoggerAdapter instance with timestamp flag', () => { + let stream: TestStream; + let winstonLoggerAdapter: WinstonLoggerAdapter; + + beforeAll(() => { + stream = new TestStream(); + + winstonLoggerAdapter = new WinstonLoggerAdapter( + createLogger({ + transports: [new transports.Stream({ stream })], + }), + 'test', + { + timestamp: true, + }, + ); + }); + + describe('when called', () => { + beforeAll(() => { + winstonLoggerAdapter.log(LogLevel.INFO, 'test-message', { + context: 'context-test', + }); + }); + + it('should write log to stream', () => { + const expectedChunks: unknown[] = [ + expect.objectContaining({ + context: 'context-test', + level: `\x1b[32m${LogLevel.INFO}\x1b[39m`, + message: '\x1b[32mtest-message\x1b[39m', + }), + ]; + + expect(stream.chunks).toStrictEqual(expectedChunks); + }); + }); + }); + + describe('having a WinstonLoggerAdapter instance with both json and timestamp flags', () => { + let stream: TestStream; + let winstonLoggerAdapter: WinstonLoggerAdapter; + + beforeAll(() => { + stream = new TestStream(); + + winstonLoggerAdapter = new WinstonLoggerAdapter( + createLogger({ + transports: [new transports.Stream({ stream })], + }), + 'test', + { + json: true, + timestamp: true, + }, + ); + }); + + describe('when called', () => { + beforeAll(() => { + winstonLoggerAdapter.log(LogLevel.INFO, 'test-message', { + context: 'context-test', + }); + }); + + it('should write log to stream', () => { + const expectedChunks: unknown[] = [ + expect.objectContaining({ + context: 'context-test', + level: LogLevel.INFO, + message: 'test-message', + }), + ]; + + expect(stream.chunks).toStrictEqual(expectedChunks); + }); + }); + }); + }); +}); diff --git a/packages/logger/src/winston/adapter/WinstonLoggerAdapter.ts b/packages/logger/src/winston/adapter/WinstonLoggerAdapter.ts new file mode 100644 index 00000000..65802416 --- /dev/null +++ b/packages/logger/src/winston/adapter/WinstonLoggerAdapter.ts @@ -0,0 +1,65 @@ +import { Format, TransformableInfo } from 'logform'; +import { format, Logger } from 'winston'; + +import { LoggerAdapter } from '../../logger/adapter/LoggerAdapter'; +import { ContextMetadata } from '../../logger/model/ContextMetadata'; +import { LoggerOptions } from '../../model/LoggerOptions'; +import { LogLevel } from '../../model/LogLevel'; + +export class WinstonLoggerAdapter extends LoggerAdapter { + readonly #logger: Logger; + + constructor(logger: Logger, context?: string, loggerOptions?: LoggerOptions) { + super(context, loggerOptions); + this.#logger = logger; + + this.#formatLogger(); + } + + protected override printLog( + logType: LogLevel, + message: string, + context?: ContextMetadata, + ): void { + this.#logger.log(logType, message, context); + } + + #buildOptionsFormatList(): Format[] { + const formatList: Format[] = []; + + if (this._loggerOptions.timestamp) { + formatList.push( + format.timestamp({ + format: 'YYYY-MM-DD hh:mm:ss.SSS A', + }), + ); + } + + if (this._loggerOptions.json) { + formatList.push(format.json(), format.prettyPrint()); + } else { + formatList.push( + format.colorize({ all: true }), + format.printf((info: TransformableInfo): string => + this.#stringifyInfo(info), + ), + ); + } + + return formatList; + } + + #formatLogger(): void { + this.#logger.format = format.combine( + this.#logger.format, + ...this.#buildOptionsFormatList(), + ); + } + + #stringifyInfo(info: TransformableInfo): string { + const prefix: string = + (info['context'] as string | undefined) ?? 'InversifyJS'; + + return `[${prefix}] - ${String(process.pid)}${this._loggerOptions.timestamp ? ` ${info['timestamp'] as string}` : ''} ${info.level}: ${info.message as string}`; + } +} diff --git a/packages/logger/stryker.config.mjs b/packages/logger/stryker.config.mjs new file mode 100644 index 00000000..c2513393 --- /dev/null +++ b/packages/logger/stryker.config.mjs @@ -0,0 +1,3 @@ +import config from '@inversifyjs/foundation-stryker-config'; + +export default config; diff --git a/packages/logger/tsconfig.cjs.json b/packages/logger/tsconfig.cjs.json new file mode 100644 index 00000000..72630fb8 --- /dev/null +++ b/packages/logger/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@inversifyjs/foundation-typescript-config/tsconfig.base.cjs.json", + "compilerOptions": { + "outDir": "./lib/cjs", + "rootDir": "./src", + "tsBuildInfoFile": "tsconfig.cjs.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/logger/tsconfig.esm.json b/packages/logger/tsconfig.esm.json new file mode 100644 index 00000000..e597177d --- /dev/null +++ b/packages/logger/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@inversifyjs/foundation-typescript-config/tsconfig.base.esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + "rootDir": "./src", + "tsBuildInfoFile": "tsconfig.esm.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 00000000..d3d8c589 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.cjs.json" +} diff --git a/packages/logger/vitest.config.mjs b/packages/logger/vitest.config.mjs new file mode 100644 index 00000000..94381efc --- /dev/null +++ b/packages/logger/vitest.config.mjs @@ -0,0 +1,3 @@ +import { defaultConfig } from '@inversifyjs/foundation-vitest-config'; + +export default defaultConfig; diff --git a/packages/logger/vitest.config.stryker.mjs b/packages/logger/vitest.config.stryker.mjs new file mode 100644 index 00000000..08dbb3c9 --- /dev/null +++ b/packages/logger/vitest.config.stryker.mjs @@ -0,0 +1,3 @@ +import { strykerConfig } from '@inversifyjs/foundation-vitest-config'; + +export default strykerConfig; diff --git a/packages/logger/vitest.workspace.mjs b/packages/logger/vitest.workspace.mjs new file mode 100644 index 00000000..b9b380c1 --- /dev/null +++ b/packages/logger/vitest.workspace.mjs @@ -0,0 +1,3 @@ +import { workspaceConfig } from '@inversifyjs/foundation-vitest-config'; + +export default workspaceConfig; diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0cd8c7e1..b4c9a73b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - packages/foundation/tools/* - packages/http/libraries/* - packages/http/tools/* + - packages/logger onlyBuiltDependencies: - '@swc/core'