diff --git a/.gitignore b/.gitignore index 195ab2b..817c18a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ # Dependency directories node_modules/ +# Caches +.eslintcache + # IDEs and editors .idea/ .project/ diff --git a/package-lock.json b/package-lock.json index e82ae8e..bad2c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3164,6 +3164,10 @@ "resolved": "packages/eslint-plugin-rules", "link": true }, + "node_modules/@shiftcode/logger": { + "resolved": "packages/logger", + "link": true + }, "node_modules/@shiftcode/publish-helper": { "resolved": "packages/publish-helper", "link": true @@ -14076,6 +14080,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/logger": { + "name": "@shiftcode/logger", + "version": "1.1.0-pr12.0", + "license": "UNLICENSED", + "peerDependencies": { + "@shiftcode/utilities": "^3.0.0 || ^3.0.0-pr28.3" + } + }, "packages/publish-helper": { "name": "@shiftcode/publish-helper", "version": "3.0.1", diff --git a/packages/logger/.eslintrc.cjs b/packages/logger/.eslintrc.cjs new file mode 100644 index 0000000..9538cb6 --- /dev/null +++ b/packages/logger/.eslintrc.cjs @@ -0,0 +1,9 @@ +/* eslint-env node */ +module.exports = { + parserOptions: { + project: [ + './tsconfig.json', + './tsconfig.jest.json', + ], + }, +} diff --git a/packages/logger/.lintstagedrc.yml b/packages/logger/.lintstagedrc.yml new file mode 100644 index 0000000..3830437 --- /dev/null +++ b/packages/logger/.lintstagedrc.yml @@ -0,0 +1,8 @@ +# Reformat all .ts / .js +"**/*.(t|j)s": + - npm run prettier:staged + - npm run lint:staged + +# sort package.json keys +./package.json: + - sort-package-json \ No newline at end of file diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 0000000..47715a5 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,30 @@ +# logger + +> 🎯 Target runtime: es2023 ([Node >= 20](https://node.green/#ES2023)) + +A simple logger to use with minimal dependencies. +By default, the logger is standalone and can be easily configured to log +messages to various transports. + +# Usage + +````typescript +import { Logger, LogLevel, LogTransport } from '@shiftcode/logger' + +// Create a transport for logging to the console with a specific log level +const transport = new LogTransport( + LogLevel.DEBUG, // This controls the minimum log level +) + +// LoggerService is used to manage loggers and their transports +const loggerService = new LoggerService([transport]) + +// Create a logger instance with a specific name and color +const logger = loggerService.getInstance('MyLogger', '#abcdef') + +// Logging messages at different levels +logger.debug('This is a debug message') +logger.info('This is an info message') +logger.warn('This is a warning message') +logger.error('This is an error message') +```` \ No newline at end of file diff --git a/packages/logger/jest.config.js b/packages/logger/jest.config.js new file mode 100644 index 0000000..a67eb3e --- /dev/null +++ b/packages/logger/jest.config.js @@ -0,0 +1,18 @@ +/* eslint-env node,es2023 */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { pathsToModuleNameMapper } from 'ts-jest' +import { readFileSync } from 'node:fs' + +const tsConfig = JSON.parse(readFileSync('./tsconfig.jest.json', 'utf-8')) + +export default { + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.jest.json', useESM: true }], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths ?? {}, { prefix: '' }), + }, +} diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 0000000..79f1988 --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,43 @@ +{ + "name": "@shiftcode/logger", + "version": "1.1.0-pr12.0", + "description": "logger for local and aws lambda execution", + "repository": "https://github.com/shiftcode/sc-commons-public", + "license": "UNLICENSED", + "author": "shiftcode GmbH ", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./test/*.js": { + "types": "./dist/test/*.d.ts", + "default": "./dist/test/*.js" + } + }, + "scripts": { + "prebuild": "rm -rf ./dist", + "build": "tsc", + "lint": "npm run lint:src:fix && npm run lint:test:fix", + "lint:ci": "npm run lint:src && npm run lint:test", + "lint:src": "eslint ./src", + "lint:src:fix": "eslint ./src --cache --fix", + "lint:staged": "npm run lint", + "lint:test": "eslint ./test", + "lint:test:fix": "eslint ./test --cache --fix", + "prettier": "prettier --write --config ../../.prettierrc.yml '{src,e2e,test}/**/*.ts'", + "prettier:staged": "prettier --write --config ../../.prettierrc.yml", + "prepublish": "node ../publish-helper/dist/prepare-dist.js", + "test": "NODE_OPTIONS=\"--experimental-vm-modules --trace-warnings\" npx jest --config jest.config.js", + "test:ci": "npm run test", + "test:watch": "npm run test -- --watch" + }, + "peerDependencies": { + "@shiftcode/utilities": "^3.0.0 || ^3.0.0-pr28.3" + }, + "publishConfig": { + "directory": "dist" + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 0000000..f732ae4 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,9 @@ +export * from './model/logger.js' +export * from './model/log-level.enum.js' +export * from './model/log-transport.js' +export * from './model/json-log-transport.js' +export * from './model/json-log-object-data.js' + +export * from './services/logger.service.js' + +export * from './utils/logger-helper.js' diff --git a/packages/logger/src/model/json-log-object-data.spec.ts b/packages/logger/src/model/json-log-object-data.spec.ts new file mode 100644 index 0000000..c462b9c --- /dev/null +++ b/packages/logger/src/model/json-log-object-data.spec.ts @@ -0,0 +1,46 @@ +import { createJsonLogObjectData } from './json-log-object-data.js' +import { LogLevel } from './log-level.enum.js' + +describe('createJsonLogObjectData', () => { + it('should create a log object with a message', () => { + const result = createJsonLogObjectData(LogLevel.INFO, 'MyClass', new Date('2023-01-01T00:00:00.000Z'), [ + 'Test message', + ]) + + expect(result).toEqual({ + level: 'INFO', + logger: 'MyClass', + timestamp: '2023-01-01T00:00:00.000Z', + message: 'Test message', + }) + }) + + it('should create a log object with an error', () => { + const error = new Error('Test error') + const result = createJsonLogObjectData(LogLevel.ERROR, 'MyClass', new Date('2023-01-01T00:00:00.000Z'), [error]) + + expect(result).toEqual({ + level: 'ERROR', + logger: 'MyClass', + timestamp: '2023-01-01T00:00:00.000Z', + message: 'Test error', + errorName: 'Error', + exception: error.stack, + }) + }) + + it('should handle additional data', () => { + const result = createJsonLogObjectData(LogLevel.DEBUG, 'MyClass', new Date('2023-01-01T00:00:00.000Z'), [ + 'Test message', + { key: 'value' }, + ]) + + expect(result).toEqual({ + level: 'DEBUG', + logger: 'MyClass', + timestamp: '2023-01-01T00:00:00.000Z', + message: 'Test message', + data: { key: 'value' }, + }) + }) +}) diff --git a/packages/logger/src/model/json-log-object-data.ts b/packages/logger/src/model/json-log-object-data.ts new file mode 100644 index 0000000..aceae3d --- /dev/null +++ b/packages/logger/src/model/json-log-object-data.ts @@ -0,0 +1,42 @@ +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/json-log-transport.ts b/packages/logger/src/model/json-log-transport.ts new file mode 100644 index 0000000..43a3812 --- /dev/null +++ b/packages/logger/src/model/json-log-transport.ts @@ -0,0 +1,18 @@ +import { LogTransport } from './log-transport.js' +import { LogLevel } from './log-level.enum.js' +import { createJsonLogObjectData, JsonLogObjectData } from './json-log-object-data.js' + +export abstract class JsonLogTransport extends LogTransport { + protected constructor(logLevel: LogLevel) { + super(logLevel) + } + + log(level: LogLevel, clazzName: string, _hexColor: string, timestamp: Date, args: any[]) { + if (this.isLevelEnabled(level)) { + const logObject = createJsonLogObjectData(level, clazzName, timestamp, args) + this.transportLog(level, logObject) + } + } + + abstract transportLog(level: LogLevel, logDataObject: JsonLogObjectData): void +} diff --git a/packages/logger/src/model/log-level.enum.ts b/packages/logger/src/model/log-level.enum.ts new file mode 100644 index 0000000..c4d70cb --- /dev/null +++ b/packages/logger/src/model/log-level.enum.ts @@ -0,0 +1,7 @@ +export enum LogLevel { + DEBUG, + INFO, + WARN, + ERROR, + OFF, +} diff --git a/packages/logger/src/model/log-transport.spec.ts b/packages/logger/src/model/log-transport.spec.ts new file mode 100644 index 0000000..f2bbfdb --- /dev/null +++ b/packages/logger/src/model/log-transport.spec.ts @@ -0,0 +1,48 @@ +import { LogLevel } from '../index.js' +import { SpyLogTransport } from '../../test/spy-log.transport.js' +import { stringToColor } from '../utils/logger-helper.js' + +describe('respects the configured level', () => { + test('respects level DEBUG', () => { + const logger = new SpyLogTransport(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(logger.mock).toHaveBeenCalledTimes(4) + expect(logger.calls[0][0]).toBe(LogLevel.DEBUG) + expect(logger.calls[1][0]).toBe(LogLevel.INFO) + expect(logger.calls[2][0]).toBe(LogLevel.WARN) + expect(logger.calls[3][0]).toBe(LogLevel.ERROR) + }) + test('respects level INFO', () => { + const logger = new SpyLogTransport(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(logger.mock).toHaveBeenCalledTimes(3) + expect(logger.calls[0][0]).toBe(LogLevel.INFO) + expect(logger.calls[1][0]).toBe(LogLevel.WARN) + expect(logger.calls[2][0]).toBe(LogLevel.ERROR) + }) + test('respects level WARN', () => { + const logger = new SpyLogTransport(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(logger.mock).toHaveBeenCalledTimes(2) + expect(logger.calls[0][0]).toBe(LogLevel.WARN) + expect(logger.calls[1][0]).toBe(LogLevel.ERROR) + }) + test('respects level ERROR', () => { + const logger = new SpyLogTransport(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(logger.mock).toHaveBeenCalledTimes(1) + expect(logger.calls[0][0]).toBe(LogLevel.ERROR) + }) +}) diff --git a/packages/logger/src/model/log-transport.ts b/packages/logger/src/model/log-transport.ts new file mode 100644 index 0000000..c6299d4 --- /dev/null +++ b/packages/logger/src/model/log-transport.ts @@ -0,0 +1,15 @@ +import { LogLevel } from './log-level.enum.js' + +export abstract class LogTransport { + protected logLevel: LogLevel + + protected constructor(logLevel: LogLevel) { + this.logLevel = logLevel + } + + abstract log(level: LogLevel, clazzName: string, hexColor: string, timestamp: Date, args: any[]): void + + protected isLevelEnabled(level: LogLevel) { + return level >= this.logLevel + } +} diff --git a/packages/logger/src/model/logger.spec.ts b/packages/logger/src/model/logger.spec.ts new file mode 100644 index 0000000..e372951 --- /dev/null +++ b/packages/logger/src/model/logger.spec.ts @@ -0,0 +1,29 @@ +import { Logger, LogLevel } from '../index.js' +import { SpyLogTransport } from '../../test/spy-log.transport.js' +import { stringToColor } from '../utils/logger-helper.js' + +describe('Logger behavior', () => { + let logger: Logger + let spyTransport1: SpyLogTransport + let spyTransport2: SpyLogTransport + const className = 'TestLogger' + const color = stringToColor(className) + + it('should send logs to all transports', () => { + spyTransport1 = new SpyLogTransport(LogLevel.DEBUG) + spyTransport2 = new SpyLogTransport(LogLevel.INFO) + logger = new Logger(className, color, [spyTransport1, spyTransport2]) + logger.error(['foo bar']) + expect(spyTransport1.calls.length).toBe(1) + expect(spyTransport2.calls.length).toBe(1) + }) + + it('should respect log level', () => { + spyTransport1 = new SpyLogTransport(LogLevel.DEBUG) + spyTransport2 = new SpyLogTransport(LogLevel.INFO) + logger = new Logger(className, color, [spyTransport1, spyTransport2]) + logger.debug(['foo bar']) + expect(spyTransport1.calls[0][0]).toBe(LogLevel.DEBUG) + expect(spyTransport2.calls.length).toBe(0) + }) +}) diff --git a/packages/logger/src/model/logger.ts b/packages/logger/src/model/logger.ts new file mode 100644 index 0000000..137d4fe --- /dev/null +++ b/packages/logger/src/model/logger.ts @@ -0,0 +1,33 @@ +import { LogTransport } from './log-transport.js' +import { LogLevel } from './log-level.enum.js' + +export class Logger { + constructor( + private name: string, + private color: string, + private loggerTransports: LogTransport[], + ) {} + + info(...args: any[]) { + this.log(LogLevel.INFO, args) + } + + warn(...args: any[]) { + this.log(LogLevel.WARN, args) + } + + error(...args: any[]) { + this.log(LogLevel.ERROR, args) + } + + debug(...args: any[]) { + this.log(LogLevel.DEBUG, args) + } + + private log = (logLevel: LogLevel, args: any[]): void => { + const now = new Date() + this.loggerTransports.forEach((loggerTransport) => { + loggerTransport.log(logLevel, this.name, this.color, now, args.slice()) + }) + } +} diff --git a/packages/logger/src/services/logger.service.spec.ts b/packages/logger/src/services/logger.service.spec.ts new file mode 100644 index 0000000..673d686 --- /dev/null +++ b/packages/logger/src/services/logger.service.spec.ts @@ -0,0 +1,20 @@ +import { SpyLogTransport } from '../../test/spy-log.transport.js' +import { LoggerService } from './logger.service.js' +import { LogLevel } from '../model/log-level.enum.js' +import { Logger } from '../model/logger.js' + +describe('LoggerService with SpyLogTransport', () => { + it('should use the spy log transport', () => { + const loggerService = new LoggerService([new SpyLogTransport(LogLevel.DEBUG)]) + const logger: Logger = loggerService.getInstance('MyLogger', '#abcdef') + expect(logger['loggerTransports'][0] instanceof SpyLogTransport).toBeTruthy() + expect(logger['loggerTransports'][0]['logLevel']).toBe(LogLevel.DEBUG) + }) + + it('should have the custom name and color passed to the logger service', () => { + const loggerService = new LoggerService([new SpyLogTransport(LogLevel.DEBUG)]) + const logger: Logger = loggerService.getInstance('MyLogger', '#abcdef') + expect(logger['name']).toBe('MyLogger') + expect(logger['color']).toBe('#abcdef') + }) +}) diff --git a/packages/logger/src/services/logger.service.ts b/packages/logger/src/services/logger.service.ts new file mode 100644 index 0000000..a2dce7d --- /dev/null +++ b/packages/logger/src/services/logger.service.ts @@ -0,0 +1,23 @@ +import { Logger } from '../model/logger.js' +import { stringToColor } from '../utils/logger-helper.js' +import { LogTransport } from '../model/log-transport.js' + +export class LoggerService { + private loggers = new Map() + + constructor(private readonly logTransports: LogTransport[]) {} + + getInstance(name: string, hexColor?: string): Logger { + hexColor = hexColor || stringToColor(name) + + if (this.loggers.has(name)) { + const count = this.loggers.get(name) || 2 + this.loggers.set(name, count + 1) + name += `_${count}` + } else { + this.loggers.set(name, 2) + } + + return new Logger(name, hexColor, this.logTransports) + } +} diff --git a/packages/logger/src/utils/logger-helper.ts b/packages/logger/src/utils/logger-helper.ts new file mode 100644 index 0000000..e052aa9 --- /dev/null +++ b/packages/logger/src/utils/logger-helper.ts @@ -0,0 +1,15 @@ +/** + * returns a hex color generated from given string + */ +export function stringToColor(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash) + } + let color = '#' + for (let k = 0; k < 3; k++) { + const value = (hash >> (k * 8)) & 0xff + color += ('00' + value.toString(16)).slice(-2) + } + return color +} diff --git a/packages/logger/test/spy-log.transport.ts b/packages/logger/test/spy-log.transport.ts new file mode 100644 index 0000000..e99e44c --- /dev/null +++ b/packages/logger/test/spy-log.transport.ts @@ -0,0 +1,25 @@ +import { LogLevel, LogTransport } from '../src/index.js' +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals' + +export class SpyLogTransport extends LogTransport { + private logMock = jest.fn() + + constructor(logLevel = LogLevel.INFO) { + super(logLevel) + } + + log(level: LogLevel, clazzName: string, hexColor: string, timestamp: Date, args: any[]) { + if (this.isLevelEnabled(level)) { + this.logMock(level, clazzName, hexColor, timestamp, args) + } + } + + get mock() { + return this.logMock + } + + get calls() { + return this.logMock.mock.calls + } +} diff --git a/packages/logger/tsconfig.jest.json b/packages/logger/tsconfig.jest.json new file mode 100644 index 0000000..4873dcd --- /dev/null +++ b/packages/logger/tsconfig.jest.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.jest.json", + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@shiftcode/utilities": ["../utilities/src"] + } + }, + "include": [ + "src/**/*.spec.ts", + "test/**/*.ts" + ] +} \ No newline at end of file diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 0000000..7d0e9d0 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist", + "declarationDir": "./dist", + }, + "include": ["src/**/*.ts"] +}