├── .gitignore ├── .huskyrc ├── .lintstagedrc ├── .npmignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── global.d.ts ├── index.spec.ts ├── index.ts ├── integration.spec.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── class-wrapper.service.spec.ts ├── class-wrapper.service.ts ├── config.service.spec.ts ├── config.service.ts ├── constants.ts ├── formatter.service.spec.ts ├── formatter.service.ts ├── log-class.decorator.spec.ts ├── log-class.decorator.ts ├── log.decorator.spec.ts └── log.decorator.ts ├── tsconfig.json ├── tsconfig.prod.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | /dist 4 | *.tgz 5 | /coverage -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npx lint-staged" 4 | } 5 | } -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts,tsx,json,css,less,yml}": [ 3 | "npx prettier --write", 4 | "git add" 5 | ], 6 | "*.md": [ 7 | "npx doctoc", 8 | "npx prettier --write", 9 | "git add" 10 | ] 11 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | /src 4 | /docs 5 | /tsconfig* 6 | /tslint* 7 | /index.ts 8 | /global.d.ts 9 | .prettierignore 10 | .vscode 11 | *.tgz 12 | *.md 13 | /coverage -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": false, 4 | "singleQuote": true, 5 | "arrowParens": "always", 6 | "printWidth": 120 7 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | script: 5 | - npm run lint 6 | - npm test 7 | sudo: false 8 | after_success: 9 | - npm run coverage-report 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Andrey Goncharov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # class-logger [![Build Status](https://travis-ci.org/aigoncharov/class-logger.svg?branch=master)](https://travis-ci.org/aigoncharov/class-logger) [![Coverage Status](https://coveralls.io/repos/github/aigoncharov/class-logger/badge.svg?branch=master)](https://coveralls.io/github/aigoncharov/class-logger?branch=master) [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Boilerplate-free%20decorator-based%20class%20logging.&url=https://github.com/aigoncharov/class-logger&hashtags=typescript,javascript,decorators,logging) 2 | 3 | Boilerplate-free decorator-based class logging. Log method calls and creation of your class easily with the help of two decorators. No prototype mutation. Highly configurable. Built with TypeScript. Works with Node.js and in browser. 4 | 5 | ```ts 6 | @LogClass() 7 | class Test { 8 | @Log() 9 | method1() { 10 | return 123 11 | } 12 | } 13 | ``` 14 | 15 | Logs `Test.construct. Args: [].` before a class instance is created. 16 | Logs `Test.method1. Args: [].` before the method call. 17 | Logs `Test.method1 -> done. Args: []. Res: 123.` after it. 18 | 19 | 20 | 21 | 22 | - [Installation](#installation) 23 | - [Requirements](#requirements) 24 | - [Quick start (Live demo)](#quick-start-live-demo) 25 | - [Configuration](#configuration) 26 | - [Configuration object](#configuration-object) 27 | - [Hierarchical config (Live demo)](#hierarchical-config-live-demo) 28 | - [Global config](#global-config) 29 | - [Class config](#class-config) 30 | - [Method config](#method-config) 31 | - [Include](#include) 32 | - [classInstance](#classinstance) 33 | - [Examples](#examples) 34 | - [Disable logging of arguments for all messages](#disable-logging-of-arguments-for-all-messages) 35 | - [Disable logging of arguments for end messages](#disable-logging-of-arguments-for-end-messages) 36 | - [Enable logging of a formatted class instance for all messages](#enable-logging-of-a-formatted-class-instance-for-all-messages) 37 | - [Enable logging of a formatted class instance for end messages](#enable-logging-of-a-formatted-class-instance-for-end-messages) 38 | - [Disable logging of class construction](#disable-logging-of-class-construction) 39 | - [Disable logging of method's return value (or thrown error)](#disable-logging-of-methods-return-value-or-thrown-error) 40 | - [Change logger](#change-logger) 41 | - [Formatting](#formatting) 42 | - [Examples](#examples-1) 43 | - [Add timestamp (Live demo)](#add-timestamp-live-demo) 44 | - [FAQ](#faq) 45 | - [Proxy performance](#proxy-performance) 46 | 47 | 48 | 49 | ## Installation 50 | 51 | 1. Run 52 | 53 | ``` 54 | npm i class-logger reflect-metadata 55 | ``` 56 | 57 | 2. If you use TypeScript set in you tsconfig.json 58 | 59 | ```json 60 | { 61 | "compilerOptions": { 62 | "experimentalDecorators": true, 63 | "emitDecoratorMetadata": true 64 | } 65 | } 66 | ``` 67 | 68 | 3. If you use JavaScript configure your babel to support decorators and class properties 69 | 4. At the top of your project root file add 70 | 71 | ```ts 72 | import 'reflect-metadata' 73 | ``` 74 | 75 | ## Requirements 76 | 77 | Your environment must support [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy). For Node.js it's [6.4.0+](https://node.green/), for browsers it's [Edge 12+, Firefox 18+, Chrome 49+, Safari 10+](https://caniuse.com/#search=proxy). 78 | 79 | ## Quick start [(Live demo)](https://stackblitz.com/edit/class-logger-demo-basic) 80 | 81 | You can log: 82 | 83 | - Class construction 84 | - Prototype and static method calls, both: synchronous and asynchronous. Any thrown errors are properly logged and re-thrown. 85 | - Own and static property calls if those properties return functions (synchronous or asynchronous). Error handling is the same as for method calls. 86 | 87 | ```ts 88 | import { LogClass, Log } from 'class-logger' 89 | 90 | @LogClass() 91 | class Test { 92 | @Log() 93 | method1() { 94 | return 123 95 | } 96 | 97 | @Log() 98 | async methodAsync1() { 99 | // do something asynchronous 100 | return Symbol() 101 | } 102 | 103 | @Log() 104 | methodError() { 105 | throw new Error() 106 | } 107 | 108 | @Log() 109 | property1 = () => null 110 | 111 | @Log() 112 | static methodStatic1(arg1) { 113 | return { 114 | prop1: 'test', 115 | } 116 | } 117 | } 118 | 119 | // Logs to the console before the method call: 120 | // 'Test.methodStatic1. Args: [42].' 121 | Test.methodStatic1(42) 122 | // Logs to the console after the method call: 123 | // 'Test.methodStatic1 -> done. Args: [42]. Res: {"prop1":"test"}.' 124 | 125 | // Logs to the console before the class' construction: 126 | // 'Test.construct. Args: [].' 127 | const test = new Test() 128 | 129 | // Logs to the console before the method call: 130 | // 'Test.method1. Args: [].' 131 | test.method1() 132 | // Logs to the console after the method call: 133 | // 'Test.method1 -> done. Args: []. Res: 123.' 134 | 135 | // Logs to the console before the method call: 136 | // 'Test.methodAsync1. Args: [].' 137 | test.methodAsync1() 138 | // Logs to the console after the method call (after the promise is resolved): 139 | // 'Test.methodAsync1 -> done. Args: []. Res: Symbol().' 140 | 141 | // Logs to the console before the method call: 142 | // 'Test.methodError. Args: [].' 143 | test.methodError() 144 | // Logs to the console after the method call: 145 | // 'Test.methodError -> error. Args: []. Res: Error {"name":"Error","message":"","stack":"some stack trace"}.' 146 | 147 | // Logs to the console before the method call: 148 | // 'Test.property1. Args: [].' 149 | test.property1() 150 | // Logs to the console after the method call: 151 | // 'Test.property1 -> done. Args: []. Res: null.' 152 | ``` 153 | 154 | ## Configuration 155 | 156 | ### Configuration object 157 | 158 | Here's how the configuration object looks like: 159 | 160 | ```ts 161 | interface IClassLoggerConfig { 162 | // Function to do the actual logging of the final formatted message. 163 | // It's used to log messages before method calls, after successful method calls and before class construction calls. 164 | // Default: console.log 165 | log?: (message: string) => void 166 | // Function to do the actual logging of the final formatted error message. 167 | // It's used to log messages after error method calls. 168 | // Default: console.error 169 | logError?: (message: string) => void 170 | // An object with methods `start` and `end` used to format message data into a string. 171 | // That string is consumed by `log` and `logError`. 172 | // Scroll down to 'Formatting' section to read more. 173 | // Default: new ClassLoggerFormatterService() 174 | formatter?: { 175 | start: (data: IClassLoggerFormatterStartData) => string 176 | end: (data: IClassLoggerFormatterEndData) => string 177 | } 178 | // Config of what should be included in the final message 179 | include?: { 180 | // Whether to include a list of method arguments. 181 | // Could be a boolean or an object with boolean properties `start` and `end`. 182 | // If it's a boolean, it enables/disables the argument list for all log messages. 183 | // If it's an object, then 184 | // the `start` property enables/disables the argument list for log messages before method calls and class construction calls, 185 | // the `end` property enables/disables the argument list for log messages after successful and error method calls. 186 | // Default: `true` 187 | args: 188 | | boolean 189 | | { 190 | start: boolean 191 | end: boolean 192 | } 193 | // Whether to log class construction or not 194 | // Default: `true` 195 | construct: boolean 196 | // Whether to include the result for log messages after successful method calls 197 | // or the error after error method calls. 198 | // Default: `true` 199 | result: boolean 200 | // Whether to include a formatted instance of the class. Useful if have complex logic inside of your methods relying on some properties in your instance. Read about it more down below in a dedicated section. 201 | // Could be a boolean or an object with boolean properties `start` and `end`. 202 | // If it's a boolean, it enables/disables the formatted class instance for all log messages. 203 | // If it's an object, then 204 | // the `start` property enables/disables the formatted class instance for log messages before method calls and class construction calls, 205 | // the `end` property enables/disables the formatted class instance for log messages after successful and error method calls. 206 | // Default: `false` 207 | classInstance: 208 | | boolean 209 | | { 210 | start: boolean 211 | end: boolean 212 | } 213 | } 214 | } 215 | ``` 216 | 217 | ### Hierarchical config [(Live demo)](https://stackblitz.com/edit/class-logger-demo-hierarchical-config) 218 | 219 | There're 3 layers of config: 220 | 221 | - Global 222 | - Class 223 | - Method 224 | 225 | Every time `class-logger` logs a message all 3 of them are merged together. 226 | 227 | #### Global config 228 | 229 | You can set it using `setConfig` function from `class-logger`. 230 | 231 | ```ts 232 | import { setConfig } from 'class-logger' 233 | 234 | setConfig({ 235 | // Your config override. 236 | // It's merged with the default config. 237 | }) 238 | ``` 239 | 240 | #### Class config 241 | 242 | You can set it using `LogClass` decorator from `class-logger`. 243 | 244 | ```ts 245 | import { LogClass } from 'class-logger' 246 | 247 | LogClass({ 248 | // Your config override. 249 | // It's later merged with the global config for every method call. 250 | // It means you can set it dynamically. 251 | }) 252 | class Test {} 253 | ``` 254 | 255 | #### Method config 256 | 257 | You can set it using `Log` decorator from `class-logger`. 258 | 259 | ```ts 260 | import { Log } from 'class-logger' 261 | 262 | LogClass() 263 | class Test { 264 | @Log({ 265 | // Your config override. 266 | // It's later merged with the class config and the global config for every method call. 267 | // It means you can set it dynamically. 268 | }) 269 | method1() {} 270 | } 271 | ``` 272 | 273 | ### Include 274 | 275 | #### classInstance 276 | 277 | It enables/disabled including the formatted class instance to your log messages. But what does 'formatted' really mean here? So if you decide to include it (remember, it's `false` by default), default class formatter (`ClassLoggerFormatterService`) is going to execute this sequence: 278 | 279 | - Take own (non-prototype) properties of an instance. 280 | - Why? It's a rare case when your prototype changes dynamically, therefore it hardly makes any sense to log it. 281 | - Drop any of them that have `function` type. 282 | - Why? Most of the time `function` properties are just immutable arrow functions used instead of regular class methods to preserve `this` context. It doesn't make much sense to bloat your logs with stringified bodies of those functions. 283 | - Transform any of them that are not plain objects recursively. 284 | - What objects are plain ones? `ClassLoggerFormatterService` considers an object a plain object if its prototype is strictly equal to `Object.prototype`. 285 | - Why? Often we include instances of other classes as properties (inject them as dependencies). By stringifying them using the same algorithm we can see what we injected. 286 | - Stringify what's left. 287 | 288 | Example: 289 | 290 | ```ts 291 | class ServiceA {} 292 | 293 | @LogClass({ 294 | include: { 295 | classInstance: true, 296 | }, 297 | }) 298 | class Test { 299 | private serviceA = new ServiceA() 300 | private prop1 = 42 301 | private prop2 = { test: 42 } 302 | private method1 = () => null 303 | 304 | @Log() 305 | public method2() { 306 | return 42 307 | } 308 | } 309 | 310 | // Logs to the console before the class' construction: 311 | // 'Test.construct. Args: []. Class instance: {"serviceA": ServiceA {},"prop1":42,"prop2":{"test":42}}.' 312 | const test = new Test() 313 | 314 | // Logs to the console before the method call: 315 | // 'Test.method2. Args: []. Class instance: {"serviceA": ServiceA {},"prop1":42,"prop2":{"test":42}}.' 316 | test.method2() 317 | // Logs to the console after the method call: 318 | // 'Test.method2 -> done. Args: []. Class instance: {"serviceA": ServiceA {},"prop1":42,"prop2":{"test":42}}. Res: 42.' 319 | ``` 320 | 321 | > If a class instance is not available at the moment (e.g. for class construction or calls of static methods), it logs `N/A`. 322 | 323 | ### Examples 324 | 325 | #### Disable logging of arguments for all messages 326 | 327 | ```ts 328 | { 329 | include: { 330 | args: false 331 | } 332 | } 333 | ``` 334 | 335 | #### Disable logging of arguments for end messages 336 | 337 | ```ts 338 | { 339 | include: { 340 | args: { 341 | start: true 342 | end: false 343 | } 344 | } 345 | } 346 | ``` 347 | 348 | #### Enable logging of a formatted class instance for all messages 349 | 350 | ```ts 351 | { 352 | include: { 353 | classInstance: true 354 | } 355 | } 356 | ``` 357 | 358 | #### Enable logging of a formatted class instance for end messages 359 | 360 | ```ts 361 | { 362 | include: { 363 | classInstance: { 364 | start: true 365 | end: false 366 | } 367 | } 368 | } 369 | ``` 370 | 371 | #### Disable logging of class construction 372 | 373 | ```ts 374 | { 375 | include: { 376 | construct: false 377 | } 378 | } 379 | ``` 380 | 381 | #### Disable logging of method's return value (or thrown error) 382 | 383 | ```ts 384 | { 385 | include: { 386 | result: false 387 | } 388 | } 389 | ``` 390 | 391 | #### Change logger 392 | 393 | ```ts 394 | { 395 | log: myLogger.debug, 396 | logError: myLogger.error 397 | } 398 | ``` 399 | 400 | Which could look like this in real world: 401 | 402 | ```ts 403 | import { setConfig } from 'class-logger' 404 | import { createLogger } from 'winston' 405 | 406 | const logger = createLogger() 407 | 408 | setConfig({ 409 | log: logger.debug.bind(logger), 410 | logError: logger.error.bind(logger), 411 | }) 412 | ``` 413 | 414 | ## Formatting 415 | 416 | You can pass your own custom formatter to the config to format messages to your liking. 417 | 418 | ```ts 419 | { 420 | formatter: myCustomFormatter 421 | } 422 | ``` 423 | 424 | Your custom formatter must be an object with properties `start` and `end`. It must comply with the following interface: 425 | 426 | ```ts 427 | interface IClassLoggerFormatter { 428 | start: (data: IClassLoggerFormatterStartData) => string 429 | end: (data: IClassLoggerFormatterEndData) => string 430 | } 431 | ``` 432 | 433 | where `IClassLoggerFormatterStartData` is: 434 | 435 | ```ts 436 | interface IClassLoggerFormatterStartData { 437 | args: any[] 438 | className: string 439 | propertyName: string | symbol 440 | classInstance?: any 441 | include: { 442 | args: 443 | | boolean 444 | | { 445 | start: boolean 446 | end: boolean 447 | } 448 | construct: boolean 449 | result: boolean 450 | classInstance: 451 | | boolean 452 | | { 453 | start: boolean 454 | end: boolean 455 | } 456 | } 457 | } 458 | ``` 459 | 460 | and `IClassLoggerFormatterEndData` is: 461 | 462 | ```ts 463 | interface IClassLoggerFormatterEndData { 464 | args: any[] 465 | className: string 466 | propertyName: string | symbol 467 | classInstance?: any 468 | result: any 469 | error: boolean 470 | include: { 471 | args: 472 | | boolean 473 | | { 474 | start: boolean 475 | end: boolean 476 | } 477 | construct: boolean 478 | result: boolean 479 | classInstance: 480 | | boolean 481 | | { 482 | start: boolean 483 | end: boolean 484 | } 485 | } 486 | } 487 | ``` 488 | 489 | You can provide your own object with these two properties, but the easiest way to modify the formatting logic of `class-logger` is to subclass the default formatter - `ClassLoggerFormatterService`. 490 | 491 | `ClassLoggerFormatterService` has these `protected` methods which are building blocks of final messages: 492 | 493 | - `base` 494 | - `operation` 495 | - `args` 496 | - `classInstance` 497 | - `result` 498 | - `final` 499 | 500 | Generally speaking, `start` method of `ClassLoggerFormatterService` is `base` + `args` + `classInstance` + `final`. `end` is `base` + `operation` + `args` + `classInstance` + `result` + `final`. 501 | 502 | ### Examples 503 | 504 | #### Add timestamp [(Live demo)](https://stackblitz.com/edit/class-logger-demo-custom-formatter-add-timestamp) 505 | 506 | Let's take a look at how we could add a timestamp to the beginning of each message: 507 | 508 | ```ts 509 | import { ClassLoggerFormatterService, IClassLoggerFormatterStartData, setConfig } from 'class-logger' 510 | 511 | class ClassLoggerTimestampFormatterService extends ClassLoggerFormatterService { 512 | protected base(data: IClassLoggerFormatterStartData) { 513 | const baseSuper = super.base(data) 514 | const timestamp = Date.now() 515 | const baseWithTimestamp = `${timestamp}:${baseSuper}` 516 | return baseWithTimestamp 517 | } 518 | } 519 | 520 | setConfig({ 521 | formatter: new ClassLoggerTimestampFormatterService(), 522 | }) 523 | ``` 524 | 525 | > FYI, [winston](https://github.com/winstonjs/winston), [pino](https://github.com/pinojs/pino) and pretty much any other logger are capable of adding timestamps on their own, so this example is purely educative. I'd advice to use your logger's built-in mechanism for creating timestamps if possible. 526 | 527 | ## FAQ 528 | 529 | ### Proxy performance 530 | https://github.com/aigoncharov/class-logger/issues/1 531 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | -------------------------------------------------------------------------------- /index.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { Log, LogClass, setConfig } from './index' 4 | 5 | describe('index', () => { 6 | setConfig({ 7 | include: { 8 | classInstance: true, 9 | }, 10 | }) 11 | 12 | const successRes = 'syncSuccessResTest' 13 | const stackMock = 'stackTest' 14 | const codeMock = 'codeTest' 15 | class TestError extends Error { 16 | public code = codeMock 17 | public stack = stackMock 18 | constructor() { 19 | super('messageTest') 20 | } 21 | } 22 | @LogClass({ 23 | log: (message) => console.info(message), // tslint:disable-line no-console 24 | }) 25 | class Test { 26 | @Log() 27 | public static staticSuccess(arg1: string, arg2: string) { 28 | return successRes 29 | } 30 | @Log() 31 | public static staticError(arg1: string, arg2: string) { 32 | throw new TestError() 33 | } 34 | 35 | public prop1 = 123 36 | 37 | @Log() 38 | public propSyncSuccess = () => successRes 39 | @Log() 40 | public propSyncError = () => { 41 | throw new TestError() 42 | } 43 | 44 | @Log({ 45 | log: (message) => console.debug(message), // tslint:disable-line no-console 46 | }) 47 | public syncSuccess() { 48 | return successRes 49 | } 50 | @Log({ 51 | log: (message) => console.debug(message), // tslint:disable-line no-console 52 | }) 53 | public syncError() { 54 | throw new TestError() 55 | } 56 | @Log({ 57 | logError: (message) => console.debug(message), // tslint:disable-line no-console 58 | }) 59 | public async asyncSuccess(arg1: symbol) { 60 | return successRes 61 | } 62 | @Log({ 63 | logError: (message) => console.debug(message), // tslint:disable-line no-console 64 | }) 65 | public async asyncError(arg1: symbol) { 66 | throw new TestError() 67 | } 68 | } 69 | 70 | test('staticSuccess', () => { 71 | const spyConsoleInfo = jest.spyOn(console, 'info') 72 | const spyConsoleError = jest.spyOn(console, 'error') 73 | const res = Test.staticSuccess('test1', 'test2') 74 | expect(res).toBe(successRes) 75 | expect(spyConsoleError).toBeCalledTimes(0) 76 | expect(spyConsoleInfo).toBeCalledTimes(2) 77 | expect(spyConsoleInfo).toHaveBeenNthCalledWith(1, 'Test.staticSuccess. Args: [test1, test2]. Class instance: N/A.') 78 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 79 | 2, 80 | 'Test.staticSuccess -> done. Args: [test1, test2]. Class instance: N/A. Res: syncSuccessResTest.', 81 | ) 82 | }) 83 | test('staticError', () => { 84 | const spyConsoleInfo = jest.spyOn(console, 'info') 85 | const spyConsoleError = jest.spyOn(console, 'error') 86 | expect(() => Test.staticError('test1', 'test2')).toThrow(TestError) 87 | expect(spyConsoleError).toBeCalledTimes(1) 88 | expect(spyConsoleInfo).toBeCalledTimes(1) 89 | expect(spyConsoleInfo).toBeCalledWith('Test.staticError. Args: [test1, test2]. Class instance: N/A.') 90 | expect(spyConsoleError).toBeCalledWith( 91 | 'Test.staticError -> error. Args: [test1, test2]. Class instance: N/A. Res: TestError {"code":"codeTest","message":"messageTest","name":"Error","stack":"stackTest"}.', 92 | ) 93 | }) 94 | test('propSyncSuccess', () => { 95 | const spyConsoleInfo = jest.spyOn(console, 'info') 96 | const spyConsoleError = jest.spyOn(console, 'error') 97 | const res = new Test().propSyncSuccess() 98 | expect(res).toBe(successRes) 99 | expect(spyConsoleError).toBeCalledTimes(0) 100 | expect(spyConsoleInfo).toBeCalledTimes(3) 101 | expect(spyConsoleInfo).toHaveBeenNthCalledWith(1, 'Test.construct. Args: []. Class instance: N/A.') 102 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 103 | 2, 104 | 'Test.propSyncSuccess. Args: []. Class instance: Test {"prop1":123}.', 105 | ) 106 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 107 | 3, 108 | 'Test.propSyncSuccess -> done. Args: []. Class instance: Test {"prop1":123}. Res: syncSuccessResTest.', 109 | ) 110 | }) 111 | test('propSyncError', () => { 112 | const spyConsoleInfo = jest.spyOn(console, 'info') 113 | const spyConsoleError = jest.spyOn(console, 'error') 114 | expect(() => new Test().propSyncError()).toThrow(TestError) 115 | expect(spyConsoleError).toBeCalledTimes(1) 116 | expect(spyConsoleInfo).toBeCalledTimes(2) 117 | expect(spyConsoleInfo).toHaveBeenNthCalledWith(1, 'Test.construct. Args: []. Class instance: N/A.') 118 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 119 | 2, 120 | 'Test.propSyncError. Args: []. Class instance: Test {"prop1":123}.', 121 | ) 122 | expect(spyConsoleError).toBeCalledWith( 123 | 'Test.propSyncError -> error. Args: []. Class instance: Test {"prop1":123}. Res: TestError {"code":"codeTest","message":"messageTest","name":"Error","stack":"stackTest"}.', 124 | ) 125 | }) 126 | test('syncSuccess', () => { 127 | const spyConsoleInfo = jest.spyOn(console, 'info') 128 | const spyConsoleDebug = jest.spyOn(console, 'debug') 129 | const spyConsoleError = jest.spyOn(console, 'error') 130 | const res = new Test().syncSuccess() 131 | expect(res).toBe(successRes) 132 | expect(spyConsoleError).toBeCalledTimes(0) 133 | expect(spyConsoleInfo).toBeCalledTimes(1) 134 | expect(spyConsoleDebug).toBeCalledTimes(2) 135 | expect(spyConsoleInfo).toBeCalledWith('Test.construct. Args: []. Class instance: N/A.') 136 | expect(spyConsoleDebug).toHaveBeenNthCalledWith( 137 | 1, 138 | 'Test.syncSuccess. Args: []. Class instance: Test {"prop1":123}.', 139 | ) 140 | expect(spyConsoleDebug).toHaveBeenNthCalledWith( 141 | 2, 142 | 'Test.syncSuccess -> done. Args: []. Class instance: Test {"prop1":123}. Res: syncSuccessResTest.', 143 | ) 144 | }) 145 | test('syncError', () => { 146 | const spyConsoleInfo = jest.spyOn(console, 'info') 147 | const spyConsoleDebug = jest.spyOn(console, 'debug') 148 | const spyConsoleError = jest.spyOn(console, 'error') 149 | expect(() => new Test().syncError()).toThrow(TestError) 150 | expect(spyConsoleError).toBeCalledTimes(1) 151 | expect(spyConsoleInfo).toBeCalledTimes(1) 152 | expect(spyConsoleDebug).toBeCalledTimes(1) 153 | expect(spyConsoleInfo).toBeCalledWith('Test.construct. Args: []. Class instance: N/A.') 154 | expect(spyConsoleDebug).toBeCalledWith('Test.syncError. Args: []. Class instance: Test {"prop1":123}.') 155 | expect(spyConsoleError).toBeCalledWith( 156 | 'Test.syncError -> error. Args: []. Class instance: Test {"prop1":123}. Res: TestError {"code":"codeTest","message":"messageTest","name":"Error","stack":"stackTest"}.', 157 | ) 158 | }) 159 | test('asyncSuccess', async () => { 160 | const spyConsoleInfo = jest.spyOn(console, 'info') 161 | const spyConsoleDebug = jest.spyOn(console, 'debug') 162 | const spyConsoleError = jest.spyOn(console, 'error') 163 | const res = await new Test().asyncSuccess(Symbol()) 164 | expect(res).toBe(successRes) 165 | expect(spyConsoleError).toBeCalledTimes(0) 166 | expect(spyConsoleInfo).toBeCalledTimes(3) 167 | expect(spyConsoleDebug).toBeCalledTimes(0) 168 | expect(spyConsoleInfo).toHaveBeenNthCalledWith(1, 'Test.construct. Args: []. Class instance: N/A.') 169 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 170 | 2, 171 | 'Test.asyncSuccess. Args: [Symbol()]. Class instance: Test {"prop1":123}.', 172 | ) 173 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 174 | 3, 175 | 'Test.asyncSuccess -> done. Args: [Symbol()]. Class instance: Test {"prop1":123}. Res: syncSuccessResTest.', 176 | ) 177 | }) 178 | test('asyncError', async () => { 179 | const spyConsoleInfo = jest.spyOn(console, 'info') 180 | const spyConsoleDebug = jest.spyOn(console, 'debug') 181 | const spyConsoleError = jest.spyOn(console, 'error') 182 | await expect(new Test().asyncError(Symbol())).rejects.toThrow(TestError) 183 | expect(spyConsoleError).toBeCalledTimes(0) 184 | expect(spyConsoleInfo).toBeCalledTimes(2) 185 | expect(spyConsoleDebug).toBeCalledTimes(1) 186 | expect(spyConsoleInfo).toHaveBeenNthCalledWith(1, 'Test.construct. Args: []. Class instance: N/A.') 187 | expect(spyConsoleInfo).toHaveBeenNthCalledWith( 188 | 2, 189 | 'Test.asyncError. Args: [Symbol()]. Class instance: Test {"prop1":123}.', 190 | ) 191 | expect(spyConsoleDebug).toBeCalledWith( 192 | 'Test.asyncError -> error. Args: [Symbol()]. Class instance: Test {"prop1":123}. Res: TestError {"code":"codeTest","message":"messageTest","name":"Error","stack":"stackTest"}.', 193 | ) 194 | }) 195 | 196 | test('keeps third-party metadata', () => { 197 | class TestMeta { 198 | @Log() 199 | public static static1() {} // tslint:disable-line no-empty 200 | 201 | @Log() 202 | public method1() {} // tslint:disable-line no-empty 203 | } 204 | 205 | const keyClass = Symbol() 206 | Reflect.defineMetadata(keyClass, 42, TestMeta) 207 | 208 | const keyPrototype = Symbol() 209 | Reflect.defineMetadata(keyPrototype, 43, TestMeta.prototype) 210 | 211 | const keyProp = Symbol() 212 | Reflect.defineMetadata(keyProp, 44, TestMeta.prototype, 'method1') 213 | 214 | const keyStatic = Symbol() 215 | Reflect.defineMetadata(keyStatic, 45, TestMeta, 'static1') 216 | 217 | const TestMetaWrapped = LogClass()(TestMeta) 218 | 219 | expect(Reflect.getMetadata(keyClass, TestMeta)).toBe(42) 220 | expect(Reflect.getMetadata(keyStatic, TestMeta, 'static1')).toBe(45) 221 | 222 | const instance = new TestMetaWrapped() 223 | expect(Reflect.getMetadata(keyPrototype, instance)).toBe(43) 224 | expect(Reflect.getMetadata(keyProp, instance, 'method1')).toBe(44) 225 | }) 226 | }) 227 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { CLASS_LOGGER_METADATA_KEY } from './src/constants' 2 | import { ConfigService } from './src/config.service' 3 | export { IClassLoggerConfig, IClassLoggerConfigComplete } from './src/config.service' 4 | export { 5 | IClassLoggerIncludeConfig, 6 | IClassLoggerFormatterStartData, 7 | IClassLoggerFormatterEndData, 8 | IClassLoggerFormatter, 9 | ClassLoggerFormatterService, 10 | } from './src/formatter.service' 11 | export { LogClass } from './src/log-class.decorator' 12 | export { Log } from './src/log.decorator' 13 | 14 | export const setConfig = ConfigService.setConfig.bind(ConfigService) 15 | -------------------------------------------------------------------------------- /integration.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { Container, inject, injectable } from 'inversify' 4 | 5 | import { Log, LogClass, setConfig } from './index' 6 | 7 | describe('integration', () => { 8 | // https://github.com/aigoncharov/class-logger/issues/6 9 | describe('inversify', () => { 10 | it('', () => { 11 | const logFn = jest.fn() 12 | 13 | setConfig({ 14 | include: { 15 | construct: true, 16 | }, 17 | log: logFn, 18 | }) 19 | 20 | const TYPES = { 21 | AI: Symbol.for('AI'), 22 | CPU: Symbol.for('CPU'), 23 | } 24 | 25 | @LogClass() 26 | @injectable() 27 | class CPU { 28 | private readonly res = 42 29 | 30 | @Log() 31 | public calc() { 32 | return this.res 33 | } 34 | } 35 | 36 | @LogClass() 37 | @injectable() 38 | class AI { 39 | constructor(@inject(TYPES.CPU) private readonly _cpu: CPU) {} 40 | 41 | @Log() 42 | public takeOverTheWorld() { 43 | return this._cpu.calc() * 2 44 | } 45 | } 46 | 47 | const myContainer = new Container() 48 | myContainer.bind(TYPES.CPU).to(CPU) 49 | myContainer.bind(TYPES.AI).to(AI) 50 | 51 | const cpu = myContainer.get(TYPES.CPU) 52 | expect(cpu).toBeInstanceOf(CPU) 53 | 54 | const ai = myContainer.get(TYPES.AI) 55 | expect(ai).toBeInstanceOf(AI) 56 | 57 | const res = ai.takeOverTheWorld() 58 | expect(res).toBe(84) 59 | 60 | expect(logFn).toBeCalledTimes(7) 61 | expect(logFn.mock.calls).toEqual([ 62 | // Getting CPU from the container explicitly 63 | ['CPU.construct. Args: [].'], 64 | // Injecting CPU into AI 65 | ['CPU.construct. Args: [].'], 66 | ['AI.construct. Args: [CPU {"res":42}].'], 67 | ['AI.takeOverTheWorld. Args: [].'], 68 | ['CPU.calc. Args: [].'], 69 | ['CPU.calc -> done. Args: []. Res: 42.'], 70 | ['AI.takeOverTheWorld -> done. Args: []. Res: 84.'], 71 | ]) 72 | }) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [''], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '^.+\\.(test|spec)\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | restoreMocks: true, 9 | collectCoverage: true, 10 | coveragePathIgnorePatterns: ['/dist/', '/node_modules/'], 11 | coverageThreshold: { 12 | global: { 13 | branches: 100, 14 | functions: 100, 15 | lines: 100, 16 | statements: 100, 17 | }, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "class-logger", 3 | "version": "1.3.0", 4 | "description": "Boilerplate-free decorator-based class logging", 5 | "keywords": [ 6 | "decorator", 7 | "log", 8 | "logging", 9 | "logger", 10 | "class", 11 | "bolierplate", 12 | "proxy" 13 | ], 14 | "main": "dist/index.js", 15 | "scripts": { 16 | "test": "npx jest -i", 17 | "compile": "npx shx rm -rf dist && npx tsc -p tsconfig.prod.json", 18 | "lint": "npx tsc -p tsconfig.json --noEmit && npx tslint -c tslint.json -p tsconfig.json", 19 | "prepack": "npm run compile", 20 | "coverage-report": "npx shx cat coverage/lcov.info | coveralls" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/keenondrums/class-logger.git" 25 | }, 26 | "author": "keenondrums (andrey@goncharov.page)", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/keenondrums/class-logger/issues" 30 | }, 31 | "homepage": "https://github.com/keenondrums/class-logger#readme", 32 | "devDependencies": { 33 | "@types/jest": "^24.0.11", 34 | "coveralls": "^3.0.3", 35 | "doctoc": "^1.4.0", 36 | "husky": "^1.3.1", 37 | "inversify": "^5.0.1", 38 | "jest": "^24.5.0", 39 | "lint-staged": "^8.1.5", 40 | "prettier": "^1.16.4", 41 | "reflect-metadata": "^0.1.13", 42 | "shx": "^0.3.2", 43 | "ts-jest": "^24.0.0", 44 | "tslint": "^5.14.0", 45 | "tslint-config-prettier": "^1.18.0", 46 | "tslint-config-standard": "^8.0.1", 47 | "typescript": "^3.7.5" 48 | }, 49 | "peerDependencies": { 50 | "reflect-metadata": "^0.1.13" 51 | }, 52 | "dependencies": { 53 | "fast-safe-stringify": "^2.0.6" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/class-wrapper.service.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ClassWrapperService } from './class-wrapper.service' 4 | import { ConfigService, IClassLoggerConfig } from './config.service' 5 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 6 | 7 | describe(ClassWrapperService.name, () => { 8 | describe('isPromise', () => { 9 | test('returns true for native Promises', () => { 10 | const classWrapperService: any = new ClassWrapperService() 11 | const isPromise = classWrapperService.isPromise(Promise.resolve()) 12 | expect(isPromise).toBe(true) 13 | }) 14 | test('returns true for Promise-like objects', () => { 15 | const classWrapperService: any = new ClassWrapperService() 16 | const isPromise = classWrapperService.isPromise({ then: () => undefined, catch: () => undefined }) 17 | expect(isPromise).toBe(true) 18 | }) 19 | test('returns false for non-Promise-like objects', () => { 20 | const classWrapperService: any = new ClassWrapperService() 21 | const isPromise = classWrapperService.isPromise({}) 22 | expect(isPromise).toBe(false) 23 | }) 24 | test('returns false for null', () => { 25 | const classWrapperService: any = new ClassWrapperService() 26 | const isPromise = classWrapperService.isPromise(null) 27 | expect(isPromise).toBe(false) 28 | }) 29 | test('returns false for non-objects', () => { 30 | const classWrapperService: any = new ClassWrapperService() 31 | const isPromise = classWrapperService.isPromise('invalid') 32 | expect(isPromise).toBe(false) 33 | }) 34 | }) 35 | 36 | describe('classGetConfigMerged', () => { 37 | test('returns a merged config', () => { 38 | class Test {} 39 | const meta: IClassLoggerConfig = { 40 | include: { 41 | args: false, 42 | classInstance: false, 43 | construct: false, 44 | result: false, 45 | }, 46 | } 47 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, meta, Test.prototype) 48 | const classWrapperService: any = new ClassWrapperService() 49 | const spyConfigsMerge = jest.spyOn(ConfigService, 'configsMerge') 50 | const configRes = classWrapperService.classGetConfigMerged(Test.prototype) 51 | expect(configRes).toEqual({ ...ConfigService.config, ...meta }) 52 | expect(spyConfigsMerge).toBeCalledTimes(1) 53 | }) 54 | }) 55 | 56 | describe('wrapFunction', () => { 57 | const logsSuccess = async (fn: jest.Mock, fnRes: any, ctx?: any) => { 58 | const config = ConfigService.config 59 | const className = 'Test' 60 | const propertyName = 'test' 61 | const classInstance = {} 62 | const argsTest = [Symbol(), Symbol()] 63 | 64 | const spyFormatterStart = jest.spyOn(config.formatter, 'start') 65 | const spyFormatterStartMockRes = 'spyFormatterStartMockRes' 66 | spyFormatterStart.mockImplementation(() => spyFormatterStartMockRes) 67 | 68 | const spyFormatterEnd = jest.spyOn(config.formatter, 'end') 69 | const spyFormatterEndMockRes = 'spyFormatterEndMockRes' 70 | spyFormatterEnd.mockImplementation(() => spyFormatterEndMockRes) 71 | 72 | const spyLog = jest.spyOn(config, 'log') 73 | const spyLogError = jest.spyOn(config, 'logError') 74 | 75 | const classWrapperService: any = new ClassWrapperService() 76 | const fnWrapped: (...args: any) => any = classWrapperService.wrapFunction( 77 | config, 78 | fn, 79 | className, 80 | propertyName, 81 | classInstance, 82 | ) 83 | const fnWrappedRes = await fnWrapped.apply(ctx, argsTest) 84 | expect(fnWrappedRes).toBe(fnRes) 85 | expect(fn).toBeCalledTimes(1) 86 | expect(fn).toBeCalledWith(...argsTest) 87 | expect(spyFormatterStart).toBeCalledTimes(1) 88 | expect(spyFormatterStart).toBeCalledWith({ 89 | args: argsTest, 90 | classInstance, 91 | className, 92 | include: config.include, 93 | propertyName, 94 | }) 95 | expect(spyFormatterEnd).toBeCalledTimes(1) 96 | expect(spyFormatterEnd).toBeCalledWith({ 97 | args: argsTest, 98 | classInstance, 99 | className, 100 | error: false, 101 | include: config.include, 102 | propertyName, 103 | result: fnRes, 104 | }) 105 | expect(spyLog).toBeCalledTimes(2) 106 | expect(spyLog).toHaveBeenNthCalledWith(1, spyFormatterStartMockRes) 107 | expect(spyLog).toHaveBeenNthCalledWith(2, spyFormatterEndMockRes) 108 | expect(spyLogError).toBeCalledTimes(0) 109 | } 110 | 111 | const logError = async (fn: jest.Mock, error: Error) => { 112 | const config = ConfigService.config 113 | const className = 'Test' 114 | const propertyName = 'test' 115 | const classInstance = {} 116 | const argsTest = [Symbol(), Symbol()] 117 | 118 | const spyFormatterStart = jest.spyOn(config.formatter, 'start') 119 | const spyFormatterStartMockRes = 'spyFormatterStartMockRes' 120 | spyFormatterStart.mockImplementation(() => spyFormatterStartMockRes) 121 | 122 | const spyFormatterEnd = jest.spyOn(config.formatter, 'end') 123 | const spyFormatterEndMockRes = 'spyFormatterEndMockRes' 124 | spyFormatterEnd.mockImplementation(() => spyFormatterEndMockRes) 125 | 126 | const spyLog = jest.spyOn(config, 'log') 127 | const spyLogError = jest.spyOn(config, 'logError') 128 | 129 | const classWrapperService: any = new ClassWrapperService() 130 | const fnWrapped: (...args: any[]) => any = classWrapperService.wrapFunction( 131 | config, 132 | fn, 133 | className, 134 | propertyName, 135 | classInstance, 136 | ) 137 | const fnWrappedPromise = (async () => fnWrapped(...argsTest))() 138 | await expect(fnWrappedPromise).rejects.toThrow(error) 139 | expect(fn).toBeCalledTimes(1) 140 | expect(fn).toBeCalledWith(...argsTest) 141 | expect(spyFormatterStart).toBeCalledTimes(1) 142 | expect(spyFormatterStart).toBeCalledWith({ 143 | args: argsTest, 144 | classInstance, 145 | className, 146 | include: config.include, 147 | propertyName, 148 | }) 149 | expect(spyFormatterEnd).toBeCalledTimes(1) 150 | expect(spyFormatterEnd).toBeCalledWith({ 151 | args: argsTest, 152 | classInstance, 153 | className, 154 | error: true, 155 | include: config.include, 156 | propertyName, 157 | result: error, 158 | }) 159 | expect(spyLog).toBeCalledTimes(1) 160 | expect(spyLog).toBeCalledWith(spyFormatterStartMockRes) 161 | expect(spyLogError).toBeCalledTimes(1) 162 | expect(spyLogError).toBeCalledWith(spyFormatterEndMockRes) 163 | } 164 | 165 | test('copies own properties of a target function', () => { 166 | const testProp = 'test' 167 | const testPropVal = Symbol() 168 | 169 | const fn = jest.fn() as any 170 | fn[testProp] = testPropVal 171 | 172 | const config = ConfigService.config 173 | const className = 'Test' 174 | const propertyName = 'test' 175 | const classInstance = {} 176 | 177 | const classWrapperService: any = new ClassWrapperService() 178 | const fnWrapped = classWrapperService.wrapFunction(config, fn, className, propertyName, classInstance) 179 | 180 | expect(fnWrapped[testProp]).toBe(testPropVal) 181 | }) 182 | 183 | describe('synchronous target function', () => { 184 | test('logs success', async () => { 185 | const fnRes = Symbol() 186 | const fn = jest.fn(() => fnRes) 187 | await logsSuccess(fn, fnRes) 188 | }) 189 | test('logs error', async () => { 190 | class ErrorTest extends Error {} 191 | const error = new ErrorTest() 192 | const fn = jest.fn(() => { 193 | throw error 194 | }) 195 | await logError(fn, error) 196 | }) 197 | test('preserves this context', async () => { 198 | const fnRes = Symbol() 199 | const ctx = {} 200 | // tslint:disable-next-line only-arrow-functions 201 | const fn = jest.fn(function(this: any) { 202 | expect(this).toBe(ctx) 203 | return fnRes 204 | }) 205 | await logsSuccess(fn, fnRes, ctx) 206 | }) 207 | }) 208 | 209 | describe('asynchronous target function', () => { 210 | test('logs success', async () => { 211 | const fnRes = Symbol() 212 | const fn = jest.fn(async () => fnRes) 213 | await logsSuccess(fn, fnRes) 214 | }) 215 | test('logs error', async () => { 216 | class ErrorTest extends Error {} 217 | const error = new ErrorTest() 218 | const fn = jest.fn(async () => { 219 | throw error 220 | }) 221 | await logError(fn, error) 222 | }) 223 | }) 224 | }) 225 | 226 | describe('wrapClassInstance', () => { 227 | test('returns non-function properties', () => { 228 | const propName = 'test' 229 | const propVal = Symbol() 230 | const instance = { 231 | [propName]: propVal, 232 | } 233 | 234 | const classWrapperService: any = new ClassWrapperService() 235 | const instanceWrapped = classWrapperService.wrapClassInstance(instance) 236 | 237 | const spyReflectGetMetadata = jest.spyOn(Reflect, 'getMetadata') 238 | const res = instanceWrapped[propName] 239 | expect(res).toBe(propVal) 240 | expect(spyReflectGetMetadata).toBeCalledTimes(0) 241 | }) 242 | test('returns function properties without meta', () => { 243 | const propName = 'test' 244 | const propVal = () => Symbol() 245 | const instance = { 246 | [propName]: propVal, 247 | } 248 | 249 | const classWrapperService: any = new ClassWrapperService() 250 | const instanceWrapped = classWrapperService.wrapClassInstance(instance) 251 | 252 | const spyClassGetConfigMerged = jest.spyOn(ClassWrapperService.prototype as any, 'classGetConfigMerged') 253 | const spyReflectGetMetadata = jest.spyOn(Reflect, 'getMetadata') 254 | const res = instanceWrapped[propName] 255 | expect(res).toBe(propVal) 256 | expect(spyReflectGetMetadata).toBeCalledTimes(1) 257 | expect(spyClassGetConfigMerged).toBeCalledTimes(0) 258 | }) 259 | test('returns wrapped function properties', () => { 260 | const propName = 'test' 261 | const propValRes = Symbol() 262 | const propVal = jest.fn(() => propValRes) 263 | const instance = { 264 | [propName]: propVal, 265 | } 266 | 267 | const classWrapperService: any = new ClassWrapperService() 268 | const instanceWrapped = classWrapperService.wrapClassInstance(instance) 269 | 270 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, {}, instance) 271 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, {}, instance, propName) 272 | 273 | const spyClassGetConfigMerged = jest.spyOn(ClassWrapperService.prototype as any, 'classGetConfigMerged') 274 | const spyConfigsMerge = jest.spyOn(ConfigService, 'configsMerge') 275 | const spyWrapFunction = jest.spyOn(ClassWrapperService.prototype as any, 'wrapFunction') 276 | const spyReflectGetMetadata = jest.spyOn(Reflect, 'getMetadata') 277 | const resFn = instanceWrapped[propName] 278 | expect(spyReflectGetMetadata).toBeCalledTimes(2) 279 | expect(spyClassGetConfigMerged).toBeCalledTimes(1) 280 | expect(spyConfigsMerge).toBeCalledTimes(2) 281 | expect(spyWrapFunction).toBeCalledTimes(1) 282 | const res = resFn() 283 | expect(res).toBe(propValRes) 284 | expect(propVal).toBeCalledTimes(1) 285 | }) 286 | }) 287 | 288 | describe('wrap', () => { 289 | test('wraps class and logs its construction', () => { 290 | const config: IClassLoggerConfig = {} 291 | 292 | class Test {} 293 | 294 | const classWrapperService = new ClassWrapperService() 295 | const TestWrapped = classWrapperService.wrap(Test) 296 | 297 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, config, Test.prototype) 298 | 299 | const spyFormatterServiceStart = jest.spyOn(ConfigService.config.formatter, 'start') 300 | const spyWrapClassInstance = jest.spyOn(classWrapperService as any, 'wrapClassInstance') 301 | expect(new TestWrapped()).toBeInstanceOf(Test) 302 | expect(spyFormatterServiceStart).toBeCalledTimes(1) 303 | expect(spyFormatterServiceStart).toBeCalledWith({ 304 | args: [], 305 | className: Test.name, 306 | include: ConfigService.config.include, 307 | propertyName: 'construct', 308 | }) 309 | expect(spyWrapClassInstance).toBeCalledTimes(1) 310 | }) 311 | test("wraps class and doesn't log its construction", () => { 312 | const config: IClassLoggerConfig = { 313 | include: { 314 | construct: false, 315 | }, 316 | } 317 | 318 | class Test {} 319 | 320 | const classWrapperService = new ClassWrapperService() 321 | const TestWrapped = classWrapperService.wrap(Test) 322 | 323 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, config, Test.prototype) 324 | 325 | const spyFormatterServiceStart = jest.spyOn(ConfigService.config.formatter, 'start') 326 | const spyWrapClassInstance = jest.spyOn(classWrapperService as any, 'wrapClassInstance') 327 | expect(new TestWrapped()).toBeInstanceOf(Test) 328 | expect(spyFormatterServiceStart).toBeCalledTimes(0) 329 | expect(spyWrapClassInstance).toBeCalledTimes(1) 330 | }) 331 | }) 332 | }) 333 | -------------------------------------------------------------------------------- /src/class-wrapper.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService, IClassLoggerConfig, IClassLoggerConfigComplete } from './config.service' 2 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 3 | 4 | export class ClassWrapperService { 5 | public wrap any>(targetWrap: T) { 6 | const get = this.makeProxyTrapGet(targetWrap.name) 7 | const proxied = new Proxy(targetWrap, { 8 | construct: this.proxyTrapConstruct, 9 | // We need get trap for static properties and methods 10 | get, 11 | }) 12 | Reflect.getMetadataKeys(targetWrap).forEach((metadataKey) => { 13 | Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, targetWrap), proxied) 14 | }) 15 | return proxied as T 16 | } 17 | 18 | protected wrapClassInstance(instance: object) { 19 | const get = this.makeProxyTrapGet(instance.constructor.name) 20 | return new Proxy(instance, { 21 | get, 22 | }) 23 | } 24 | 25 | protected wrapFunction any>( 26 | config: IClassLoggerConfigComplete, 27 | fn: T, 28 | className: string, 29 | propertyName: string | symbol, 30 | classInstance: object, 31 | ): T { 32 | const classWrapper = this 33 | // Use non-arrow function to allow dynamic context 34 | // tslint:disable-next-line only-arrow-functions 35 | const res = function(this: any, ...args: any[]) { 36 | const messageStart = config.formatter.start({ 37 | args, 38 | classInstance, 39 | className, 40 | include: config.include, 41 | propertyName, 42 | }) 43 | config.log(messageStart) 44 | 45 | const logEnd = (result: any, error?: boolean) => { 46 | const messageEnd = config.formatter.end({ 47 | args, 48 | classInstance, 49 | className, 50 | error: !!error, 51 | include: config.include, 52 | propertyName, 53 | result, 54 | }) 55 | let logFn = config.log 56 | if (error) { 57 | logFn = config.logError 58 | } 59 | logFn(messageEnd) 60 | } 61 | 62 | try { 63 | let fnRes = fn.apply(this, args) 64 | if (classWrapper.isPromise(fnRes)) { 65 | fnRes = fnRes 66 | .then((result: any) => { 67 | logEnd(result) 68 | return result 69 | }) 70 | .catch((error: Error) => { 71 | logEnd(error, true) 72 | throw error 73 | }) 74 | return fnRes 75 | } 76 | logEnd(fnRes) 77 | return fnRes 78 | } catch (error) { 79 | logEnd(error, true) 80 | throw error 81 | } 82 | } as T 83 | 84 | // Functions are objects as well. They might have own properties. 85 | ;(Object.keys(fn) as Array).forEach((prop) => { 86 | res[prop] = fn[prop] 87 | }) 88 | 89 | return res 90 | } 91 | 92 | protected isPromise(val: any) { 93 | return !!val && typeof val === 'object' && typeof val.then === 'function' && typeof val.catch === 'function' 94 | } 95 | 96 | protected classGetConfigMerged(target: object) { 97 | const configClassMeta: IClassLoggerConfig = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, target) 98 | const configRes = ConfigService.configsMerge(ConfigService.config, configClassMeta) 99 | return configRes 100 | } 101 | 102 | protected proxyTrapConstruct = any>(target: T, args: any, newTarget: any) => { 103 | const config = this.classGetConfigMerged(target.prototype) 104 | if (config.include.construct) { 105 | const messageStart = config.formatter.start({ 106 | args, 107 | className: target.name, 108 | include: config.include, 109 | propertyName: 'construct', 110 | }) 111 | config.log(messageStart) 112 | } 113 | const instance = Reflect.construct(target, args, newTarget) 114 | const instanceWrapped = this.wrapClassInstance(instance) 115 | return instanceWrapped 116 | } 117 | protected makeProxyTrapGet = (className: string) => (target: object, property: string | symbol, receiver: any) => { 118 | const prop = Reflect.get(target, property, receiver) 119 | if (typeof prop !== 'function') { 120 | return prop 121 | } 122 | const configProp = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, target, property) 123 | if (!configProp) { 124 | return prop 125 | } 126 | const configClass = this.classGetConfigMerged(target) 127 | const configFinal = ConfigService.configsMerge(configClass, configProp) 128 | const propWrapped = this.wrapFunction(configFinal, prop, className, property, target) 129 | return propWrapped 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/config.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService, IClassLoggerConfig } from './config.service' 2 | 3 | describe(ConfigService.name, () => { 4 | describe(ConfigService.configsMerge.name, () => { 5 | test('returns a merged config', () => { 6 | const config = ConfigService.config 7 | ConfigService.config = new Proxy(config, { 8 | set() { 9 | throw new Error('Must not reassign config properties!') 10 | }, 11 | }) 12 | 13 | const configOverride: IClassLoggerConfig = { 14 | include: { 15 | args: { 16 | end: true, 17 | start: false, 18 | }, 19 | }, 20 | logError: () => undefined, 21 | } 22 | const configRes = ConfigService.configsMerge(ConfigService.config, configOverride) 23 | expect(configRes).toEqual({ 24 | ...config, 25 | ...configOverride, 26 | include: { 27 | ...config.include, 28 | ...configOverride.include, 29 | }, 30 | }) 31 | 32 | ConfigService.config = config 33 | }) 34 | }) 35 | 36 | describe(ConfigService.setConfig.name, () => { 37 | test(`sets ${ConfigService.name}.config`, () => { 38 | const spyConfigsMerge = jest.spyOn(ConfigService, 'configsMerge') 39 | 40 | const configOld = ConfigService.config 41 | const configOverride: IClassLoggerConfig = { 42 | include: { 43 | args: { 44 | end: true, 45 | start: false, 46 | }, 47 | }, 48 | logError: () => undefined, 49 | } 50 | ConfigService.setConfig(configOverride) 51 | expect(spyConfigsMerge).toBeCalledTimes(1) 52 | expect(spyConfigsMerge).toBeCalledWith(configOld, configOverride) 53 | expect(ConfigService.config).not.toBe(configOld) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/config.service.ts: -------------------------------------------------------------------------------- 1 | import { ClassLoggerFormatterService, IClassLoggerFormatter, IClassLoggerIncludeConfig } from './formatter.service' 2 | 3 | type ClassLoggerFormatterLogger = (message: string) => void 4 | export interface IClassLoggerConfigComplete { 5 | log: ClassLoggerFormatterLogger 6 | logError: ClassLoggerFormatterLogger 7 | formatter: IClassLoggerFormatter 8 | include: IClassLoggerIncludeConfig 9 | } 10 | export interface IClassLoggerConfig { 11 | log?: ClassLoggerFormatterLogger 12 | logError?: ClassLoggerFormatterLogger 13 | formatter?: IClassLoggerFormatter 14 | include?: Partial 15 | } 16 | 17 | export class ConfigService { 18 | public static config: IClassLoggerConfigComplete = { 19 | formatter: new ClassLoggerFormatterService(), 20 | include: { 21 | args: true, 22 | classInstance: false, 23 | construct: true, 24 | result: true, 25 | }, 26 | log: (message) => console.log(message), // tslint:disable-line no-console 27 | logError: (message) => console.error(message), // tslint:disable-line no-console 28 | } 29 | 30 | public static configsMerge(config: IClassLoggerConfigComplete, ...configsPartial: IClassLoggerConfig[]) { 31 | return configsPartial.reduce( 32 | (configRes, configPartial) => ({ 33 | ...configRes, 34 | ...configPartial, 35 | include: { 36 | ...configRes.include, 37 | ...configPartial.include, 38 | }, 39 | }), 40 | config, 41 | ) 42 | } 43 | 44 | public static setConfig(config: IClassLoggerConfig) { 45 | this.config = this.configsMerge(this.config, config) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CLASS_LOGGER_METADATA_KEY = Symbol() 2 | -------------------------------------------------------------------------------- /src/formatter.service.spec.ts: -------------------------------------------------------------------------------- 1 | import stringify from 'fast-safe-stringify' 2 | 3 | import { 4 | ClassLoggerFormatterService, 5 | IClassLoggerFormatterEndData, 6 | IClassLoggerFormatterStartData, 7 | } from './formatter.service' 8 | 9 | describe(ClassLoggerFormatterService.name, () => { 10 | const classLoggerFormatterService = new ClassLoggerFormatterService() 11 | 12 | class TestService {} 13 | const valTestClassProp1 = 'prop1' 14 | const valTestClassProp2 = [Symbol(), 123] 15 | const valTestClassProp3 = { 16 | prop3: 'prop3', 17 | } 18 | class TestClass { 19 | public prop1 = valTestClassProp1 20 | public prop2 = valTestClassProp2 21 | public testService = new TestService() 22 | public propNull = null 23 | public prop3 = valTestClassProp3 24 | public propFn = () => null 25 | } 26 | const testClassStringExpected = stringify({ 27 | prop1: valTestClassProp1, 28 | prop2: valTestClassProp2, 29 | testService: 'TestService {}', 30 | // tslint:disable-next-line object-literal-sort-keys 31 | propNull: 'null', 32 | prop3: valTestClassProp3, 33 | }) 34 | const dataStart: IClassLoggerFormatterStartData = { 35 | args: ['test', Symbol(), { test: '123' }, undefined], 36 | classInstance: new TestClass(), 37 | className: 'ClassNameTest', 38 | include: { 39 | args: true, 40 | classInstance: false, 41 | construct: true, 42 | result: true, 43 | }, 44 | propertyName: 'propertyNameTest', 45 | } 46 | const dataEnd: IClassLoggerFormatterEndData = { 47 | ...dataStart, 48 | error: false, 49 | result: 'resultTest', 50 | } 51 | 52 | describe('includeComplex', () => { 53 | test('returns true for boolean', () => { 54 | const res = (classLoggerFormatterService as any).includeComplex(true, 'start') 55 | expect(res).toBe(true) 56 | }) 57 | test('returns false for boolean', () => { 58 | const res = (classLoggerFormatterService as any).includeComplex(false, 'start') 59 | expect(res).toBe(false) 60 | }) 61 | test('returns true for start', () => { 62 | const res = (classLoggerFormatterService as any).includeComplex({ start: true, end: true }, 'start') 63 | expect(res).toBe(true) 64 | }) 65 | test('returns false for start', () => { 66 | const res = (classLoggerFormatterService as any).includeComplex({ start: false, end: true }, 'start') 67 | expect(res).toBe(false) 68 | }) 69 | test('returns true for end', () => { 70 | const res = (classLoggerFormatterService as any).includeComplex({ start: false, end: true }, 'end') 71 | expect(res).toBe(true) 72 | }) 73 | test('returns false for end', () => { 74 | const res = (classLoggerFormatterService as any).includeComplex({ start: false, end: false }, 'end') 75 | expect(res).toBe(false) 76 | }) 77 | }) 78 | 79 | describe('base', () => { 80 | test('returns base', () => { 81 | const baseStr = (classLoggerFormatterService as any).base(dataStart) 82 | expect(baseStr).toBe(`${dataStart.className}.${dataStart.propertyName.toString()}`) 83 | }) 84 | }) 85 | describe('operation', () => { 86 | test('returns done', () => { 87 | const operationStr = (classLoggerFormatterService as any).operation(dataEnd) 88 | expect(operationStr).toBe(' -> done') 89 | }) 90 | test('returns error', () => { 91 | const operationStr = (classLoggerFormatterService as any).operation({ ...dataEnd, error: true }) 92 | expect(operationStr).toBe(' -> error') 93 | }) 94 | }) 95 | describe('args', () => { 96 | test('returns stringified args', () => { 97 | const argsStr = (classLoggerFormatterService as any).args(dataStart) 98 | expect(argsStr).toBe( 99 | `. Args: [${dataStart.args[0]}, ${dataStart.args[1].toString()}, ${stringify(dataStart.args[2])}, undefined]`, 100 | ) 101 | }) 102 | }) 103 | describe('classInstance', () => { 104 | test('returns stringified classInstance', () => { 105 | const argsStr = (classLoggerFormatterService as any).classInstance(dataStart) 106 | expect(argsStr).toBe(`. Class instance: TestClass ${testClassStringExpected}`) 107 | }) 108 | test('returns a placeholder', () => { 109 | const argsStr = (classLoggerFormatterService as any).classInstance({ 110 | ...dataStart, 111 | classInstance: undefined, 112 | }) 113 | expect(argsStr).toBe(`. Class instance: ${(classLoggerFormatterService as any).placeholderNotAvailable}`) 114 | }) 115 | }) 116 | describe('result', () => { 117 | test('returns non-object result', () => { 118 | const resStr = (classLoggerFormatterService as any).result(dataEnd) 119 | expect(resStr).toBe(`. Res: ${dataEnd.result}`) 120 | }) 121 | test('returns undefined', () => { 122 | const resStr = (classLoggerFormatterService as any).result({ 123 | ...dataEnd, 124 | result: undefined, 125 | }) 126 | expect(resStr).toBe(`. Res: undefined`) 127 | }) 128 | test('returns stringified object result', () => { 129 | const resultObj = { 130 | test: 42, 131 | } 132 | const resStr = (classLoggerFormatterService as any).result({ 133 | ...dataEnd, 134 | result: resultObj, 135 | }) 136 | expect(resStr).toBe(`. Res: ${stringify(resultObj)}`) 137 | }) 138 | test('returns stringified array result', () => { 139 | const resultArr = [ 140 | { 141 | test: 42, 142 | }, 143 | 34, 144 | ] 145 | const resStr = (classLoggerFormatterService as any).result({ 146 | ...dataEnd, 147 | result: resultArr, 148 | }) 149 | expect(resStr).toBe(`. Res: [${stringify(resultArr[0])}, ${resultArr[1]}]`) 150 | }) 151 | test('returns a serialized error result', () => { 152 | class TestError extends Error { 153 | public code = 101 154 | } 155 | const result = new TestError() 156 | result.stack = 'test' 157 | const resStr = (classLoggerFormatterService as any).result({ 158 | ...dataEnd, 159 | result, 160 | }) 161 | expect(resStr).toBe(`. Res: TestError ${stringify({ code: 101, message: '', name: 'Error', stack: 'test' })}`) 162 | }) 163 | test('returns serialized complex object result', () => { 164 | class A { 165 | // @ts-ignore 166 | private test = 42 167 | } 168 | const resultObj = new A() 169 | const resStr = (classLoggerFormatterService as any).result({ 170 | ...dataEnd, 171 | result: resultObj, 172 | }) 173 | expect(resStr).toBe(`. Res: A ${stringify({ test: 42 })}`) 174 | }) 175 | test('returns serialized complex built-in object result', () => { 176 | const resultObj = new Map([['test', 42]]) 177 | const resStr = (classLoggerFormatterService as any).result({ 178 | ...dataEnd, 179 | result: resultObj, 180 | }) 181 | expect(resStr).toBe(`. Res: Map {}`) 182 | }) 183 | }) 184 | 185 | describe(ClassLoggerFormatterService.prototype.start.name, () => { 186 | let spyBase: jest.MockInstance 187 | let spyArgs: jest.MockInstance 188 | let spyClassInstance: jest.MockInstance 189 | let spyFinal: jest.MockInstance 190 | beforeEach(() => { 191 | spyBase = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'base') 192 | spyArgs = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'args') 193 | spyClassInstance = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'classInstance') 194 | spyFinal = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'final') 195 | }) 196 | 197 | test('includes: args, no class instance', () => { 198 | const message = classLoggerFormatterService.start(dataStart) 199 | expect(message).toBeTruthy() 200 | 201 | expect(spyBase).toBeCalledTimes(1) 202 | expect(spyArgs).toBeCalledTimes(1) 203 | expect(spyClassInstance).toBeCalledTimes(0) 204 | expect(spyFinal).toBeCalledTimes(1) 205 | }) 206 | test('includes: args (complex), no class instance', () => { 207 | const message = classLoggerFormatterService.start({ 208 | ...dataStart, 209 | include: { 210 | ...dataStart.include, 211 | args: { 212 | end: false, 213 | start: true, 214 | }, 215 | }, 216 | }) 217 | expect(message).toBeTruthy() 218 | 219 | expect(spyBase).toBeCalledTimes(1) 220 | expect(spyArgs).toBeCalledTimes(1) 221 | expect(spyClassInstance).toBeCalledTimes(0) 222 | expect(spyFinal).toBeCalledTimes(1) 223 | }) 224 | test('includes: args, class instance', () => { 225 | const message = classLoggerFormatterService.start({ 226 | ...dataStart, 227 | include: { 228 | ...dataStart.include, 229 | classInstance: true, 230 | }, 231 | }) 232 | expect(message).toBeTruthy() 233 | 234 | expect(spyBase).toBeCalledTimes(1) 235 | expect(spyArgs).toBeCalledTimes(1) 236 | expect(spyClassInstance).toBeCalledTimes(1) 237 | expect(spyFinal).toBeCalledTimes(1) 238 | }) 239 | test('includes: no args, class instance', () => { 240 | const message = classLoggerFormatterService.start({ 241 | ...dataStart, 242 | include: { 243 | ...dataStart.include, 244 | args: false, 245 | classInstance: true, 246 | }, 247 | }) 248 | expect(message).toBeTruthy() 249 | 250 | expect(spyBase).toBeCalledTimes(1) 251 | expect(spyArgs).toBeCalledTimes(0) 252 | expect(spyClassInstance).toBeCalledTimes(1) 253 | expect(spyFinal).toBeCalledTimes(1) 254 | }) 255 | test('includes: no args, no class instance', () => { 256 | const message = classLoggerFormatterService.start({ 257 | ...dataStart, 258 | include: { 259 | ...dataStart.include, 260 | args: false, 261 | }, 262 | }) 263 | expect(message).toBeTruthy() 264 | 265 | expect(spyBase).toBeCalledTimes(1) 266 | expect(spyArgs).toBeCalledTimes(0) 267 | expect(spyClassInstance).toBeCalledTimes(0) 268 | expect(spyFinal).toBeCalledTimes(1) 269 | }) 270 | }) 271 | 272 | describe(ClassLoggerFormatterService.prototype.end.name, () => { 273 | let spyBase: jest.MockInstance 274 | let spyOperation: jest.MockInstance 275 | let spyArgs: jest.MockInstance 276 | let spyClassInstance: jest.MockInstance 277 | let spyResult: jest.MockInstance 278 | let spyFinal: jest.MockInstance 279 | beforeEach(() => { 280 | spyBase = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'base') 281 | spyOperation = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'operation') 282 | spyArgs = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'args') 283 | spyClassInstance = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'classInstance') 284 | spyResult = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'result') 285 | spyFinal = jest.spyOn(ClassLoggerFormatterService.prototype as any, 'final') 286 | }) 287 | 288 | test('includes: args, no class instance, result', () => { 289 | const message = classLoggerFormatterService.end(dataEnd) 290 | expect(message).toBeTruthy() 291 | 292 | expect(spyBase).toBeCalledTimes(1) 293 | expect(spyOperation).toBeCalledTimes(1) 294 | expect(spyArgs).toBeCalledTimes(1) 295 | expect(spyClassInstance).toBeCalledTimes(0) 296 | expect(spyResult).toBeCalledTimes(1) 297 | expect(spyFinal).toBeCalledTimes(1) 298 | }) 299 | test('includes: args (complex), no class instance, result', () => { 300 | const message = classLoggerFormatterService.end({ 301 | ...dataEnd, 302 | include: { 303 | ...dataEnd.include, 304 | args: { 305 | end: true, 306 | start: false, 307 | }, 308 | }, 309 | }) 310 | expect(message).toBeTruthy() 311 | 312 | expect(spyBase).toBeCalledTimes(1) 313 | expect(spyOperation).toBeCalledTimes(1) 314 | expect(spyArgs).toBeCalledTimes(1) 315 | expect(spyClassInstance).toBeCalledTimes(0) 316 | expect(spyResult).toBeCalledTimes(1) 317 | expect(spyFinal).toBeCalledTimes(1) 318 | }) 319 | test('includes: args, no class instance, no result', () => { 320 | const message = classLoggerFormatterService.end({ 321 | ...dataEnd, 322 | include: { 323 | ...dataEnd.include, 324 | result: false, 325 | }, 326 | }) 327 | expect(message).toBeTruthy() 328 | 329 | expect(spyBase).toBeCalledTimes(1) 330 | expect(spyOperation).toBeCalledTimes(1) 331 | expect(spyArgs).toBeCalledTimes(1) 332 | expect(spyClassInstance).toBeCalledTimes(0) 333 | expect(spyResult).toBeCalledTimes(0) 334 | expect(spyFinal).toBeCalledTimes(1) 335 | }) 336 | test('includes: args, class instance, result', () => { 337 | const message = classLoggerFormatterService.end({ 338 | ...dataEnd, 339 | include: { 340 | ...dataEnd.include, 341 | classInstance: true, 342 | }, 343 | }) 344 | expect(message).toBeTruthy() 345 | 346 | expect(spyBase).toBeCalledTimes(1) 347 | expect(spyOperation).toBeCalledTimes(1) 348 | expect(spyArgs).toBeCalledTimes(1) 349 | expect(spyClassInstance).toBeCalledTimes(1) 350 | expect(spyResult).toBeCalledTimes(1) 351 | expect(spyFinal).toBeCalledTimes(1) 352 | }) 353 | test('includes: args, class instance, no result', () => { 354 | const message = classLoggerFormatterService.end({ 355 | ...dataEnd, 356 | include: { 357 | ...dataEnd.include, 358 | classInstance: true, 359 | result: false, 360 | }, 361 | }) 362 | expect(message).toBeTruthy() 363 | 364 | expect(spyBase).toBeCalledTimes(1) 365 | expect(spyOperation).toBeCalledTimes(1) 366 | expect(spyArgs).toBeCalledTimes(1) 367 | expect(spyClassInstance).toBeCalledTimes(1) 368 | expect(spyResult).toBeCalledTimes(0) 369 | expect(spyFinal).toBeCalledTimes(1) 370 | }) 371 | test('includes: no args, class instance, result', () => { 372 | const message = classLoggerFormatterService.end({ 373 | ...dataEnd, 374 | include: { 375 | ...dataEnd.include, 376 | args: false, 377 | classInstance: true, 378 | }, 379 | }) 380 | expect(message).toBeTruthy() 381 | 382 | expect(spyBase).toBeCalledTimes(1) 383 | expect(spyOperation).toBeCalledTimes(1) 384 | expect(spyArgs).toBeCalledTimes(0) 385 | expect(spyClassInstance).toBeCalledTimes(1) 386 | expect(spyResult).toBeCalledTimes(1) 387 | expect(spyFinal).toBeCalledTimes(1) 388 | }) 389 | test('includes: no args, class instance, no result', () => { 390 | const message = classLoggerFormatterService.end({ 391 | ...dataEnd, 392 | include: { 393 | ...dataEnd.include, 394 | args: false, 395 | classInstance: true, 396 | result: false, 397 | }, 398 | }) 399 | expect(message).toBeTruthy() 400 | 401 | expect(spyBase).toBeCalledTimes(1) 402 | expect(spyOperation).toBeCalledTimes(1) 403 | expect(spyArgs).toBeCalledTimes(0) 404 | expect(spyClassInstance).toBeCalledTimes(1) 405 | expect(spyResult).toBeCalledTimes(0) 406 | expect(spyFinal).toBeCalledTimes(1) 407 | }) 408 | test('includes: no args, no class instance, result', () => { 409 | const message = classLoggerFormatterService.end({ 410 | ...dataEnd, 411 | include: { 412 | ...dataEnd.include, 413 | args: false, 414 | }, 415 | }) 416 | expect(message).toBeTruthy() 417 | 418 | expect(spyBase).toBeCalledTimes(1) 419 | expect(spyOperation).toBeCalledTimes(1) 420 | expect(spyArgs).toBeCalledTimes(0) 421 | expect(spyClassInstance).toBeCalledTimes(0) 422 | expect(spyResult).toBeCalledTimes(1) 423 | expect(spyFinal).toBeCalledTimes(1) 424 | }) 425 | test('includes: no args, no class instance, no result', () => { 426 | const message = classLoggerFormatterService.end({ 427 | ...dataEnd, 428 | include: { 429 | ...dataEnd.include, 430 | args: false, 431 | result: false, 432 | }, 433 | }) 434 | expect(message).toBeTruthy() 435 | 436 | expect(spyBase).toBeCalledTimes(1) 437 | expect(spyOperation).toBeCalledTimes(1) 438 | expect(spyArgs).toBeCalledTimes(0) 439 | expect(spyClassInstance).toBeCalledTimes(0) 440 | expect(spyResult).toBeCalledTimes(0) 441 | expect(spyFinal).toBeCalledTimes(1) 442 | }) 443 | }) 444 | }) 445 | -------------------------------------------------------------------------------- /src/formatter.service.ts: -------------------------------------------------------------------------------- 1 | import stringify from 'fast-safe-stringify' 2 | 3 | export interface IClassLoggerLogData { 4 | args: any[] 5 | className: string 6 | propertyName: string | symbol 7 | classInstance?: any 8 | } 9 | export interface IClassLoggerLogResData { 10 | result: any 11 | error: boolean 12 | } 13 | export type IClassLoggerFormatterStartData = IClassLoggerLogData & { include: IClassLoggerIncludeConfig } 14 | export type IClassLoggerFormatterEndData = IClassLoggerFormatterStartData & IClassLoggerLogResData 15 | export interface IClassLoggerFormatter { 16 | start: (data: IClassLoggerFormatterStartData) => string 17 | end: (data: IClassLoggerFormatterEndData) => string 18 | } 19 | 20 | export interface IClassLoggerMessageConfigIncludeComplex { 21 | start: boolean 22 | end: boolean 23 | } 24 | export interface IClassLoggerIncludeConfig { 25 | args: boolean | IClassLoggerMessageConfigIncludeComplex 26 | construct: boolean 27 | result: boolean 28 | classInstance: boolean | IClassLoggerMessageConfigIncludeComplex 29 | } 30 | 31 | export class ClassLoggerFormatterService implements IClassLoggerFormatter { 32 | protected readonly placeholderNotAvailable = 'N/A' 33 | protected readonly placeholderUndefined = 'undefined' 34 | 35 | public start(data: IClassLoggerFormatterStartData) { 36 | let message = this.base(data) 37 | if (this.includeComplex(data.include.args, 'start')) { 38 | message += this.args(data) 39 | } 40 | if (this.includeComplex(data.include.classInstance, 'start')) { 41 | message += this.classInstance(data) 42 | } 43 | message += this.final() 44 | return message 45 | } 46 | public end(data: IClassLoggerFormatterEndData) { 47 | let message = this.base(data) 48 | message += this.operation(data) 49 | if (this.includeComplex(data.include.args, 'end')) { 50 | message += this.args(data) 51 | } 52 | if (this.includeComplex(data.include.classInstance, 'end')) { 53 | message += this.classInstance(data) 54 | } 55 | if (data.include.result) { 56 | message += this.result(data) 57 | } 58 | message += this.final() 59 | return message 60 | } 61 | 62 | protected base({ className, propertyName }: IClassLoggerFormatterStartData) { 63 | return `${className}.${propertyName.toString()}` 64 | } 65 | protected operation({ error }: IClassLoggerFormatterEndData) { 66 | return error ? ' -> error' : ' -> done' 67 | } 68 | protected args({ args }: IClassLoggerFormatterStartData) { 69 | return `. Args: ${this.valueToString(args)}` 70 | } 71 | protected classInstance({ classInstance }: IClassLoggerFormatterStartData) { 72 | return `. Class instance: ${this.complexObjectToString(classInstance)}` 73 | } 74 | protected result({ result }: IClassLoggerFormatterEndData) { 75 | return `. Res: ${this.valueToString(result)}` 76 | } 77 | protected final() { 78 | return '.' 79 | } 80 | 81 | protected complexObjectToString(obj: any) { 82 | if (typeof obj !== 'object') { 83 | return this.placeholderNotAvailable 84 | } 85 | 86 | if (obj === null) { 87 | return stringify(obj) 88 | } 89 | 90 | const classInstanceFiltered: { [key: string]: any } = {} 91 | 92 | let keys = Object.keys(obj) 93 | if (obj instanceof Map || obj instanceof Set) { 94 | keys = [...obj.keys()] 95 | } 96 | 97 | keys.forEach((key) => { 98 | const value = obj[key] 99 | if (typeof value === 'function') { 100 | return 101 | } 102 | classInstanceFiltered[key] = 103 | typeof value === 'object' && !this.isPlainObjectOrArray(value) ? this.complexObjectToString(value) : value 104 | }) 105 | return `${obj.constructor.name} ${stringify(classInstanceFiltered)}` 106 | } 107 | protected valueToString(val: any): string { 108 | if (val === undefined) { 109 | return this.placeholderUndefined 110 | } 111 | if (typeof val !== 'object') { 112 | return val.toString() 113 | } 114 | if (val instanceof Error) { 115 | return this.errorToString(val) 116 | } 117 | if (!this.isPlainObjectOrArray(val)) { 118 | return this.complexObjectToString(val) 119 | } 120 | if (Array.isArray(val)) { 121 | const arrayWithStringifiedElements = val.map(this.valueToString.bind(this)) 122 | return `[${arrayWithStringifiedElements.join(', ')}]` 123 | } 124 | return stringify(val) 125 | } 126 | 127 | protected includeComplex( 128 | includeComplex: boolean | IClassLoggerMessageConfigIncludeComplex, 129 | type: keyof IClassLoggerMessageConfigIncludeComplex, 130 | ) { 131 | if (typeof includeComplex === 'boolean') { 132 | return includeComplex 133 | } 134 | return includeComplex[type] 135 | } 136 | protected isPlainObjectOrArray(obj: object | null) { 137 | if (!obj) { 138 | return false 139 | } 140 | const proto = Object.getPrototypeOf(obj) 141 | return proto === Object.prototype || proto === Array.prototype 142 | } 143 | protected errorToString(error: Error & { code?: string }) { 144 | const data = { 145 | code: error.code, 146 | message: error.message, 147 | name: error.name, 148 | stack: error.stack, 149 | } 150 | return `${error.constructor.name} ${stringify(data)}` 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/log-class.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { ClassWrapperService } from './class-wrapper.service' 4 | import { IClassLoggerConfig } from './config.service' 5 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 6 | import { LogClass } from './log-class.decorator' 7 | 8 | describe('@LogClass', () => { 9 | test('adds meta for class constructor', () => { 10 | const config: IClassLoggerConfig = { 11 | include: { 12 | args: false, 13 | }, 14 | } 15 | @LogClass(config) 16 | class Test {} 17 | const configExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test.prototype) 18 | expect(configExpected).toBe(config) 19 | }) 20 | test(`calls ${ClassWrapperService.name}.${ClassWrapperService.prototype.wrap.name}`, () => { 21 | const spyWrap = jest.spyOn(ClassWrapperService.prototype, 'wrap') 22 | class Test {} 23 | LogClass()(Test) 24 | const configExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test.prototype) 25 | expect(configExpected).toEqual({}) 26 | expect(spyWrap).toBeCalledTimes(1) 27 | expect(spyWrap).toBeCalledWith(Test) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/log-class.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ClassWrapperService } from './class-wrapper.service' 2 | import { IClassLoggerConfig } from './config.service' 3 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 4 | 5 | const classWrapper = new ClassWrapperService() 6 | export const LogClass = (config: IClassLoggerConfig = {}) => any>(target: T) => { 7 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, config, target.prototype) 8 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, config, target) 9 | return classWrapper.wrap(target) 10 | } 11 | -------------------------------------------------------------------------------- /src/log.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata' 2 | 3 | import { IClassLoggerConfig } from './config.service' 4 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 5 | import { Log } from './log.decorator' 6 | 7 | describe('@Log', () => { 8 | test('adds meta for class methods and properties', () => { 9 | const configMethod: IClassLoggerConfig = { 10 | include: { 11 | args: false, 12 | }, 13 | } 14 | const methodName = 'method' 15 | const propertyName = 'property' 16 | const staticMethodName = 'staticMethod' 17 | const staticPropertyName = 'staticProperty' 18 | class Test { 19 | @Log(configMethod) 20 | public static [staticMethodName]() { 21 | return null 22 | } 23 | @Log() 24 | public static [staticPropertyName] = () => null 25 | @Log(configMethod) 26 | public [methodName]() { 27 | return null 28 | } 29 | @Log() 30 | public [propertyName] = () => null 31 | } 32 | const configStaticMethodExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test, staticMethodName) 33 | expect(configStaticMethodExpected).toBe(configMethod) 34 | const configStaticPropertyExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test, staticPropertyName) 35 | expect(configStaticPropertyExpected).toEqual({}) 36 | const configMethodExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test.prototype, methodName) 37 | expect(configMethodExpected).toBe(configMethod) 38 | const configPropertyExpected = Reflect.getMetadata(CLASS_LOGGER_METADATA_KEY, Test.prototype, propertyName) 39 | expect(configPropertyExpected).toEqual({}) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/log.decorator.ts: -------------------------------------------------------------------------------- 1 | import { IClassLoggerConfig } from './config.service' 2 | import { CLASS_LOGGER_METADATA_KEY } from './constants' 3 | 4 | export const Log = (config: IClassLoggerConfig = {}) => (target: object, propertyKey: string | symbol) => { 5 | Reflect.defineMetadata(CLASS_LOGGER_METADATA_KEY, config, target, propertyKey) 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "strict": true, 5 | "noUnusedLocals": true, 6 | "noFallthroughCasesInSwitch": true, 7 | "target": "es2015", 8 | "baseUrl": "./", 9 | "moduleResolution": "node", 10 | "declaration": true, 11 | "removeComments": true, 12 | "module": "commonjs", 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true 15 | }, 16 | "include": ["./**/*.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["./index.ts", "./global.d.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-config-standard", "tslint-config-prettier"], 4 | "rules": { 5 | "max-classes-per-file": [false, 1] 6 | } 7 | } 8 | --------------------------------------------------------------------------------