diff --git a/.codeclimate.yml b/.codeclimate.yml index 34d70ca..73ff741 100644 --- a/.codeclimate.yml +++ b/.codeclimate.yml @@ -11,7 +11,7 @@ checks: file-lines: enabled: true config: - threshold: 350 + threshold: 500 method-complexity: enabled: true config: @@ -50,4 +50,7 @@ exclude_patterns: - "**/node_modules/" - "site/" - "**/*.d.ts" + - "**/*.test.ts" - "**/*.test.js" + - "**/*.spec.ts" + - "**/*.spec.js" diff --git a/.eslintrc.yml b/.eslintrc.yml index 7af189e..5fa6e13 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,8 +1,10 @@ --- root: true -parserOptions: - ecmaVersion: 2019 +ignorePatterns: + - 'node_modules/**/*' + - 'dist' + - 'site' env: node: true @@ -11,12 +13,11 @@ env: plugins: - 'jsdoc' - - 'node' + - '@typescript-eslint/eslint-plugin' extends: - 'xo-space' - 'plugin:jsdoc/recommended' - - 'plugin:node/recommended' rules: array-element-newline: off @@ -29,5 +30,17 @@ rules: no-negated-condition: off keyword-spacing: ['error', { overrides: { if: { after: false }, for: { after: false }, while: { after: false }, catch: { after: false } } }] jsdoc/newline-after-description: off - node/no-unsupported-features/es-syntax: ['error', { version: '>=10.0.0' }] - node/no-missing-require: off + +overrides: + - files: ['*.ts'] + parserOptions: + project: + - './tsconfig.eslint.json' + - './tsconfig.spec.json' + extends: + - 'xo-typescript/space' + rules: + '@typescript-eslint/keyword-spacing': ['error', { overrides: { if: { after: false }, for: { after: false }, while: { after: false }, catch: { after: false } } }] + '@typescript-eslint/object-curly-spacing': ['error', 'always'] + '@typescript-eslint/comma-dangle': ['error', 'never'] + '@typescript-eslint/naming-convention': off diff --git a/.github/workflows/module.yml b/.github/workflows/module.yml index 95be265..8d27d6b 100644 --- a/.github/workflows/module.yml +++ b/.github/workflows/module.yml @@ -3,15 +3,22 @@ on: push: branches: - master + - next + - beta + - alpha + paths-ignore: + - 'site/**' pull_request: branches: - master + paths-ignore: + - 'site/**' jobs: test: strategy: matrix: - node: [ '10', '12', '14', '16' ] + node: [ '12', '14', '16' ] name: test/node v${{ matrix.node }} runs-on: ubuntu-latest steps: @@ -25,32 +32,9 @@ jobs: run: npm i - name: Run Tests run: npm test - - coverage: - name: Code Coverage - needs: [ test ] - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@master - - name: Setup Node - uses: actions/setup-node@master - with: - node-version: 14 - - name: Install Dependencies - run: npm i - - name: Run Tests - if: ${{ github.event_name == 'pull_request' }} - run: npm test - - name: Comment Coverage on PR - if: ${{ github.event_name == 'pull_request' }} - uses: artiomtr/jest-coverage-report-action@fdabb5bd42fa8a55bcfdfb55d855122cabfb7911 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - threshold: 100 - name: Upload Coverage to Code Climate - uses: paambaati/codeclimate-action@v2.7.2 - if: ${{ github.event_name == 'push' }} + uses: paambaati/codeclimate-action@v3.0.0 + if: ${{ matrix.node == '16' }} env: CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} with: @@ -68,7 +52,7 @@ jobs: - name: Setup Node uses: actions/setup-node@master with: - node-version: 14 + node-version: 16 - name: Install Dependencies run: npm i - name: Delete expired artifacts @@ -79,12 +63,12 @@ jobs: dry_run: "false" - uses: bradennapier/eslint-plus-action@v3.4.2 with: - includeGlob: 'index.js,index.test.js,lib/*.js' + includeGlob: 'src/*.ts' release: name: Release if: ${{ github.event_name == 'push' }} - needs: [ test, coverage ] + needs: [ test ] runs-on: ubuntu-latest steps: - name: Checkout @@ -92,9 +76,11 @@ jobs: - name: Setup Node uses: actions/setup-node@master with: - node-version: 14 + node-version: 16 - name: Install Dependencies run: npm i + - name: Build Package + run: npm run build - name: Publish to NPM env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 379f65c..2aa195e 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -5,6 +5,8 @@ on: - master tags: - '!*' + paths: + - 'gh-pages/**' jobs: build: diff --git a/.gitignore b/.gitignore index f83be66..56b12f6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ package-lock.json # DS_Store file .DS_Store +dist res diff --git a/.npmignore b/.npmignore index 8faba7d..2c6e44c 100644 --- a/.npmignore +++ b/.npmignore @@ -4,12 +4,17 @@ gh-pages site .eslintrc.yml -.releaserc +release.config.js commitlint.config.js -*.test.js -jest.config.js +tsconfig.json +tsconfig.eslint.json +tsconfig.spec.json +*.test.ts +*.spec.ts +jest.config.ts .all-contributorsrc .codeclimate.yml babel.config.js CHANGELOG.md README.md +src diff --git a/.releaserc b/.releaserc deleted file mode 100644 index 3c8e3cc..0000000 --- a/.releaserc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "plugins": [ - "@semantic-release/commit-analyzer", - "@semantic-release/release-notes-generator", - "@semantic-release/changelog", - "@semantic-release/npm", - ["@semantic-release/git", { - "assets": ["package.json", "CHANGELOG.md"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" - }], - "@semantic-release/github" - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 1d8964a..a67b0ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,7 +15,14 @@ "changeProcessCWD": true } ], + "eslint.validate": [ + "mdx", + "javascript", + "javascriptreact", + "typescript" + ], "[mdx]": { "editor.wordWrap": "on" - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/CHANGELOG.md b/CHANGELOG.md index dce465f..d35f835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,72 @@ +# [4.0.0-beta.5](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.4...v4.0.0-beta.5) (2021-12-10) + + +### Bug Fixes + +* add generic support to LogMessage class for specifying the message type ([6e6775a](https://github.com/KyleRoss/node-lambda-log/commit/6e6775a44bf881d3d7d64d9ce2f40ff2bce858da)) +* add generic to LogObject type to specify type of Message ([cd1c746](https://github.com/KyleRoss/node-lambda-log/commit/cd1c74697a99840a48f29d49509ad3409e145de5)) +* explicitly set type to `string[]` for compiled tags returned from LogMessage ([fd873e3](https://github.com/KyleRoss/node-lambda-log/commit/fd873e3a2da24f3b90985708771adeb2901ce39c)) +* remove generic from Message type ([cc10311](https://github.com/KyleRoss/node-lambda-log/commit/cc10311b3e0580e840792e452d11dcc43e911544)) +* remove toJSON() method from LogMessage since it's been moved to a formatter ([36fbdaa](https://github.com/KyleRoss/node-lambda-log/commit/36fbdaa738fb1fa0daf046dc71eb84adc18ae55c)) +* rename the `log` method to `_log` ([7317bf5](https://github.com/KyleRoss/node-lambda-log/commit/7317bf52418af6fed65db8066b94d8c2dbe21023)) +* still generate the log message just don't log it when verbosity is set ([6e13564](https://github.com/KyleRoss/node-lambda-log/commit/6e13564da87fe5b189a2b9262d8e3613a3f138e5)) +* ts generics and typings on shortcut methods ([7e48904](https://github.com/KyleRoss/node-lambda-log/commit/7e48904076802adf571385f73ac83294c0d4f875)) + + +### Features + +* add `log` shortcut method as an alias for `info` to match the `console` pattern ([a3c1f16](https://github.com/KyleRoss/node-lambda-log/commit/a3c1f163976de3f68c49e05f3a84ab7c1791c0e5)) +* separate built-in formatters into separate files ([f8ecac7](https://github.com/KyleRoss/node-lambda-log/commit/f8ecac7ed12b25495c634a904f489857a7413f80)) + +# [4.0.0-beta.4](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.3...v4.0.0-beta.4) (2021-11-25) + + +### Bug Fixes + +* destructure import of EventEmitter to support TS projects ([9fc1857](https://github.com/KyleRoss/node-lambda-log/commit/9fc18573373e62a09c46f71a3517f862b74aac77)) + +# [4.0.0-beta.3](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.2...v4.0.0-beta.3) (2021-11-19) + + +### Bug Fixes + +* add entrypoint file for advanced usage ([d7861de](https://github.com/KyleRoss/node-lambda-log/commit/d7861de9269532ab31631e559ec8da7a77c2870a)) +* add package.json to esm and cjs directories after build ([6c77c79](https://github.com/KyleRoss/node-lambda-log/commit/6c77c7971def43eb8b3f5e66a071f8b87d8b6bb8)) +* change legacy cjs file to require `lambda-log.js` instead ([89db707](https://github.com/KyleRoss/node-lambda-log/commit/89db70742c011405534c6ce1a3c8916190d85a66)) +* rename index to `lambda-log` to ensure the types are generated properly ([467c9e4](https://github.com/KyleRoss/node-lambda-log/commit/467c9e42628ed05a6ba28bb2cea79f4129d6a2f5)) + + +### Features + +* add support for direct entrypoint ([50dd03d](https://github.com/KyleRoss/node-lambda-log/commit/50dd03d8651799ea4f865fcadae5b3d5abcfe121)) + +# [4.0.0-beta.2](https://github.com/KyleRoss/node-lambda-log/compare/v4.0.0-beta.1...v4.0.0-beta.2) (2021-11-18) + + +### Bug Fixes + +* add `.js` file extensions on imports to support node ESM ([2fcf770](https://github.com/KyleRoss/node-lambda-log/commit/2fcf7707528c7a07c7d0e02ab4e1ecf1b699363d)) +* add `module` field to point to esm ([1323bf4](https://github.com/KyleRoss/node-lambda-log/commit/1323bf4866ba14fb1c86b6daae4e0daad2bcfcba)) +* add `src` as a module directory to jest ([d268e91](https://github.com/KyleRoss/node-lambda-log/commit/d268e91f8d2f4e26e1ddf0d798635a87c13c6a7d)) +* add `ts-node` as a dev dependency for jest ([433cb70](https://github.com/KyleRoss/node-lambda-log/commit/433cb70287352dd9e16b9b51f3ce5b31c4f8f098)) +* add build:declaration script and run it during build ([c43deef](https://github.com/KyleRoss/node-lambda-log/commit/c43deef9aa2080637055e48a93851f5fc537bc20)) +* add root index file to alleviate the need to call `.default` in cjs ([62da1ec](https://github.com/KyleRoss/node-lambda-log/commit/62da1ec8d119bbcdee783db75545252e90755310)) +* ignore jest.config.ts when publishing to npm ([1c6376c](https://github.com/KyleRoss/node-lambda-log/commit/1c6376c027014473e31123cdd39d61c260e36bad)) +* point `types` to separate declaration file ([c65fd52](https://github.com/KyleRoss/node-lambda-log/commit/c65fd52cbcf6cf5d08dc6cde4f221f70dd4e92a3)) +* update `main` to point to root index.js file for cjs usage ([39a9f43](https://github.com/KyleRoss/node-lambda-log/commit/39a9f437637a3ec3443fef79e79cd93d641d876f)) + +# [4.0.0-beta.1](https://github.com/KyleRoss/node-lambda-log/compare/v3.2.0...v4.0.0-beta.1) (2021-11-17) + + +### Features + +* 4.0.0-beta ([fe34fe6](https://github.com/KyleRoss/node-lambda-log/commit/fe34fe6cb26f05c966d5879c6a87baa1681d0c57)) + + +### BREAKING CHANGES + +* This is the beta version for the next major release of LambdaLog. Do not use in production yet! Please report any issues you may find! + # [3.1.0](https://github.com/KyleRoss/node-lambda-log/compare/v3.0.2...v3.1.0) (2021-10-14) diff --git a/README.md b/README.md index 1b35847..d6c4a9d 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,80 @@ [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-) +## CAUTION! + +This branch and release (`@next`) is under heavy development. This exists for testing and anyone who is willing to help out with testing. **DO NOT USE IN PRODUCTION!** + +If you do decide to try out this brand new version of LambdaLog, please report any issues you face as I can only catch so much. Any help would be greatly appreciated, especially if you are more versed in TypeScript than I am! + + + +### What's new? + +This is the next major release of LambdaLog (v4.0.0) which contains many major breaking changes, new features, and TypeScript support. + +#### Breaking Changes + +- Complete rewrite of most of the core functionality into TypeScript. +- Custom Log Levels has been removed. +- Log levels are now hardcoded and cannot be changed. This greatly simplifies the code and allows us to support TypeScript. +- Use of `Symbol` has been removed along with any static properties on the classes to retrieve the symbols. +- The `debug` option has been removed. +- The default key name for `levelKey` has changed from `_logLevel` to `__level`. +- The default key name for `tagsKey` has changed from `_tags` to `__tags`. +- The shortcut log methods are now hardcoded instead of dynamically generated. +- Removed `addLevel()` method from LambdaLog class. +- Removed `isError()` and `stubError()` static methods from the LogMessage class and moved them into utility functions. +- Environment variables that configure different options now take precedence over locally configured options. +- Internally used properties on the LogMessage class have been renamed. + +#### New Features + +- You can now configure the verbosity of your log messages through the new `level` option or `LAMBDALOG_LEVEL` env variable. +- Added `fatal` and `trace` log levels. +- Added `onParse` option that allows for custom parsing of log messages. +- Added `onCompile` option that allows for custom structuring of the object that is logged to the console. +- Added `onFormat` option that allows for custom formatting of the logged message. This gives the ability to render log messages in any format, not just JSON. (#75) + - Added `clean` formatter that logs a message in a readable format. + - Added `minimal` formatter that logs a message in a readable format without tags and metadata. + - Ability to provide your own custom formatter as a function. +- Added ability to configure the `level`, `dev`, and `silent` options via environment variables. +- Full written in TypeScript with complete type definitions. (#63, #64) +- The package now provides an ESM and CommonJS version to support your project. + +#### Fixes + +- Fixed issue where directly setting `log.options.logHandler` did not work (#45). + +#### Misc. Changes + +- Rewrote most tests into TypeScript and still maintaining 100% coverage. + + + +### What's left? + +It has been a pretty large undertaking for me, but one that this package desperately needed. This was my first real dive into TypeScript, so I'm sure there are things than can be written better. **If there is anyone with more advanced TypeScript knowledge, I'm in great need of someone who can review the code and suggest any changes or help refactor the code to get this production-ready.** _(I'll even add you as a maintainer!)_ + +- Testing package implementation in Node for both vanilla JavaScript and TypeScript. +- Testing ESM support with Node and ensuring CommonJS works correctly as well. +- Ensuring the end-user has access to all the proper files and functionality. +- Documentation. + + + +### Release Date + +As much as I am trying to rush this out the door to get these features in the hands of all you awesome developers/engineers, I'm also limited on time (eg. I'm currently writing this documentation at midnight knowing that I have to wake up at 5:30am). Not only that, but I want to ensure I ship this next version of LambdaLog with performance, features, and stability in mind. I hope this will be the last major version for awhile. + +Enough with all of that cheesy sob-story, let's talk about when this will be released, well, what I'm aiming for at this point. I hope to finish up most of my to-do list in the next couple of weeks and try to solicit some outside help for testing/analysis. Once that is complete, I hope to get this released as the latest stable version towards the end of 2021 or at the beginning of 2022. That's not guarantee at this point, but it will definitely be feasible if anyone in the community can help out! + +--- + LambdaLog is a [Node.js package](https://www.npmjs.com/package/lambda-log) facilitates and enforces logging standards in Node.js processes or applications **anywhere** by formatting your log messages as JSON for simple parsing and filtering within log management tools, such as CloudWatch Logs. _Works with all of the supported versions of Node.js on Lambda._ Originally created for AWS Lambda Functions, LambdaLog is a lightweight and feature-rich library that has **no** dependency on AWS or Lambda, meaning you can use it in any type of Node.js project you wish. **It's really a universal JSON logger.** - **Why another lambda logger?** There are plenty of other logging libraries in the NPM ecosystem but most are convoluted, included more functionality than needed, not maintained, or are not configurable enough. I created LambdaLog to include the important functionality from other loggers, but still maintaining simplicity with minimal dependencies. @@ -26,41 +95,14 @@ Anyone can log JSON to the `console`, but with Lambda Log you also get: - Over 1.5 million downloads and more than 35k weekly downloads. - Small footprint. -#### New in Version 3.0.0 - -Version 3.0.0 of Lambda Log brings a bunch of changes, new features, and a [new website](https://lambdalog.dev). - -**Broad Changes:** - -* Refactor all code to meet new ESLint specifications and to stay up-to-date with newer ecmascript specifications. -* New website with better documentation. -* Tests are now using Jest instead of Mocha. -* Switched from TravisCI to Github Actions. - -**New Features:** - -- Added `levelKey` configuration option to be able to change the key name for log levels. -- Added `messageKey` configuration option to be able to change the key name for log messages. -- Added `tagsKey` configuration option to be able to change the key name for tags. -- Added ability to remove log level and tags from the outputted log JSON. -- Added `addLevel()` method to quickly add a custom log level to an instance of LambdaLog. -- Tags can now be functions that return a dynamic tag for log messages. -- Tags now have variable support. -- Tags that are `null`, `undefined` or `""` are now removed from the tags array. -- Metadata that contains `Error` objects are now automatically converted to a plain object. - -**Breaking Changes:** - -- All of the private properties of both the LambdaLog and LogMessage classes are stored using Symbols. This may break some advanced uses of Lambda Log from version 2. -- Tags no longer contain any default, built-in tags and are empty by default. -- Some of the properties of LogMessage have been moved from the constructor to their own getter functions. - ## Documentation Documentation for Lambda Log has moved to our [new website](https://lambdalog.dev). +*Note: The documentation has NOT been updated for this new release yet. I'm working on it!* + ## Tests diff --git a/commitlint.config.js b/commitlint.config.js index 8fb8ba0..43580ac 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,6 +1,7 @@ module.exports = { extends: ['@commitlint/config-conventional'], rules: { - 'body-max-line-length': [0] + 'body-max-line-length': [0], + 'footer-max-line-length': [0] } }; diff --git a/index.js b/index.js index b7a4919..893e2bc 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,2 @@ -const LambdaLog = require('./lib/LambdaLog'); -/** - * Instance of the LambdaLog class which is exported when calling `require('lambda-log')`. For more - * advanced usage, you can create a new instance of the LambdaLog class via `new log.LambdaLog()`. - * @type {LambdaLog} - */ -const log = new LambdaLog(); - -module.exports = log; +const main = require('./dist/cjs/lambda-log.js'); +module.exports = main.default; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 0a96c75..0000000 --- a/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - clearMocks: true, - collectCoverage: true, - coverageDirectory: 'coverage', - coverageProvider: 'v8', - moduleFileExtensions: [ - 'js' - ], - testEnvironment: 'node' -}; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..b4f1e3b --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,26 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + preset: 'ts-jest', + testEnvironment: 'node', + testRegex: '\\.spec\\.ts$', + resolver: 'jest-ts-webcompat-resolver', + collectCoverage: true, + coverageReporters: [['json', { file: 'report.json' }], 'lcov', 'text', 'text-summary', 'html'], + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: 'coverage', + coveragePathIgnorePatterns: ['/node_modules/', '/dist/', '/src/index.ts', '/src/typings.ts'], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + moduleFileExtensions: ['ts', 'js', 'json'], + moduleDirectories: ['node_modules', 'src'], + watchPathIgnorePatterns: ['/node_modules/', '/dist/', '/site/'], + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.spec.json' + } + } +}; + +export default config; diff --git a/lib/LambdaLog.js b/lib/LambdaLog.js deleted file mode 100644 index 16d071d..0000000 --- a/lib/LambdaLog.js +++ /dev/null @@ -1,207 +0,0 @@ -const EventEmitter = require('events'); -const LogMessage = require('./LogMessage'); - -const symbols = { - LEVELS: Symbol('levels') -}; - -/** - * @typedef {object} LambdaLogOptions - Configuration object for LambdaLog. - * @property {object} [meta={}] Global metadata to be included in all logs. - * @property {string[]|Function[]} [tags=[]] Global tags to be included in all logs. - * @property {Function} [dynamicMeta=null] Function that runs for each log that returns additional metadata. See [Dynamic Metadata](#dynamic-metadata). - * @property {boolean} [debug=false] Enables `log.debug()`. - * @property {boolean} [dev=false] Enable development mode which pretty-prints JSON to the console. - * @property {boolean} [silent=false] Disables logging to `console` but messages and events are still generated. - * @property {Function} [replacer=null] Replacer function for `JSON.stringify()` to allow handling of sensitive data before logs are written. See [JSON.stringify](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter). - * @property {object} [logHandler=console] A console-like object containing all standard console functions. Allows logs to be written to any custom location. See [Log Handler](#loghandler). - * @property {?string} [levelKey=_logLevel] Override the key name for the log level. Set to `null` to remove the key from the output. - * @property {string} [messageKey=msg] Override the key name for the message. - * @property {?string} [tagsKey=_tags] Override the key name for the tags. Set to `null` to remove the key from the output. - */ - -/** - * @augments EventEmitter - */ -class LambdaLog extends EventEmitter { - /** - * Constructor for the LambdaLog class. Provided to be utilized in more advanced cases to allow overriding and configuration. - * By default, this module will export an instance of this class, but you may access the class and create your own instance - * via `log.LambdaLog`. - * @class - * @param {LambdaLogOptions} [options={}] Options for configuring LambdaLog. - * @param {object.} [levels={}] Allows adding and customizing log levels. DEPRECATED - */ - constructor(options = {}, levels = {}) { - super(); - /** - * Access to the uninstantiated LambdaLog class. This allows more advanced functionality and customization. - * @type {LambdaLog} - */ - this.LambdaLog = LambdaLog; - - /** - * Access to the uninstantiated LogMessage class. You can override this property to use a custom logging class that - * inherits the same methods. - * @type {LogMessage} - * @since 2.2.0 - */ - this.LogMessage = LogMessage; - - /** - * @type {LambdaLogOptions} - */ - this.options = { - meta: {}, - tags: [], - dynamicMeta: null, - debug: false, - dev: false, - silent: ['true', 'yes', 'y', '1'].includes(process.env.LAMBDALOG_SILENT), - replacer: null, - logHandler: console, - levelKey: '_logLevel', - messageKey: 'msg', - tagsKey: '_tags', - ...options - }; - - /** - * Global configuration for log levels - * @type {object} - */ - this[symbols.LEVELS] = { - info: 'info', - warn: 'warn', - error: 'error', - debug() { - if(this.options.debug) return 'debug'; - return false; - }, - ...levels - }; - - /** - * Console-like log handler to use for logging messages - * @type {object} - */ - this.console = this.options.logHandler; - - const levelsConfig = this[symbols.LEVELS]; - for(const lvl in levelsConfig) { - if(Object.prototype.hasOwnProperty.call(levelsConfig, lvl)) { - this.addLevel(lvl, levelsConfig[lvl]); - } - } - } - - /** - * Add a new log level to this instance of LambdaLog. - * @since 3.0.0 - * @deprecated - * @param {string} name The name of the new log level. - * @param {string|Function} handler The string name of the `console` method to call or a function that returns a string method name. - * @returns {this} Instance of LambdaLog. - */ - addLevel(name, handler) { - this[symbols.LEVELS][name] = handler; - - /** - * Shortcut methods for `log.log()`. By default, the following methods are available: `log.info()`, `log.warn()`, `log.error()` and `log.debug()`. - * Additional methods will be added for any [custom log levels](#custom-log-levels) provided.

The provided msg can be any type, although a string - * or `Error` is recommended. If `Error` is provided the stack trace is added as metadata to the log as `stack`. - * @param {*} msg Message to log. Can be any type, but string or `Error` reccommended. - * @param {object} [meta={}] Optional meta data to attach to the log. - * @param {string[]} [tags=[]] Additional tags to append to this log. - * @returns {LogMessage} The LogMessage instance for the log. - */ - this[name] = (msg, meta = {}, tags = []) => this.log(name, msg, meta, tags); - - return this; - } - - /** - * Generates JSON log message based on the provided parameters and the global configuration. Once the JSON message is created, it is properly logged to the `console` - * and emitted through an event. If an `Error` or `Error`-like object is provided for `msg`, it will parse out the message and include the stacktrace in the metadata. - * @throws {Error} If improper log level is provided. - * @param {string} level Log level (`info`, `debug`, `warn`, `error` or a [custom log level](#custom-log-levels)) - * @param {*} msg Message to log. Can be any type, but string or `Error` reccommended. - * @param {object} [meta={}] Optional meta data to attach to the log. - * @param {string[]} [tags=[]] Additional tags to append to this log. - * @returns {LogMessage|boolean} Returns instance of LogMessage or `false` if `level = "debug"` and `options.debug = false`. May also return `false` when a [custom log level](#custom-log-levels) handler function prevents the log from being logged. - */ - log(level, msg, meta = {}, tags = []) { - if(!Object.prototype.hasOwnProperty.call(this[symbols.LEVELS], level)) { - throw new Error(`"${level}" is not a valid log level`); - } - - const message = new this.LogMessage({ - level, - msg, - meta, - tags - }, this.options); - - let method = this[symbols.LEVELS][level]; - - if(typeof method === 'function') { - method = method.call(this, message); - } - - if(!method) return false; - - if(!this.options.silent) { - this.console[method](message.toJSON(this.options.dev)); - } - - /** - * The log event is emitted (using EventEmitter) for every log generated. This allows for custom integrations, such as logging to a thrid-party service. - * This event is emitted with the [LogMessage](#logmessage) instance for the log. You may control events using all the methods of EventEmitter. - * @event LambdaLog#log - * @type {LogMessage} - */ - this.emit('log', message); - return message; - } - - /** - * Generates a log message if `test` is a falsy value. If `test` is truthy, the log message is skipped and returns `false`. Allows creating log messages without the need to - * wrap them in an if statement. The log level will be `error`. - * @since 1.4.0 - * @param {*} test A value which is tested for a falsy value. - * @param {*} msg Message to log if `test` is falsy. Can be any type, but string or `Error` reccommended. - * @param {object} [meta={}] Optional meta data to attach to the log. - * @param {string[]|Function[]} [tags=[]] Additional tags to append to this log. - * @returns {LogMessage|boolean} The LogMessage instance for the log or `false` if test passed. - */ - assert(test, msg, meta = {}, tags = []) { - if(test) return false; - return this.log('error', msg, meta, tags); - } - - /** - * Generates a log message with the result or error provided by a promise. Useful for debugging and testing. - * @since 2.3.0 - * @param {Promise} promise A promise or promise-like object to retrieve a value from. - * @param {object} [meta={}] Optional meta data to attach to the log. - * @param {string[]|Function[]} [tags=[]] Additional tags to append to this log. - * @returns {Promise} A new Promise that resolves with the LogMessage object after the promise completes. - */ - result(promise, meta = {}, tags = []) { - if(!promise || typeof promise.then !== 'function') { - throw new Error('A promise must be provided as the first argument'); - } - - const wrapper = new Promise(resolve => { - promise - .then(value => resolve(this.log('info', value, meta, tags))) - .catch(err => resolve(this.log('error', err, meta, tags))); - }); - - return wrapper; - } -} - -LambdaLog.symbols = symbols; - -module.exports = LambdaLog; diff --git a/lib/LambdaLog.test.js b/lib/LambdaLog.test.js deleted file mode 100644 index 38ab17a..0000000 --- a/lib/LambdaLog.test.js +++ /dev/null @@ -1,285 +0,0 @@ -const LambdaLog = require('./LambdaLog'); -const LogMessage = require('./LogMessage'); - -let log; - -const noop = function () {}; -const mockConsole = { - log: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn() -}; - -beforeEach(() => { - log = new LambdaLog(); -}); - -describe('Statics', () => { - test('should have static property "symbols"', () => { - expect(LambdaLog.symbols).toBeDefined(); - }); - - describe('Static symbols', () => { - test('should have a symbol for levels', () => { - expect(LambdaLog.symbols).toHaveProperty('LEVELS'); - expect(LambdaLog.symbols.LEVELS.toString()).toEqual('Symbol(levels)'); - }); - }); -}); - -describe('Constructor', () => { - test.each([ - ['meta', {}], - ['tags', []], - ['dynamicMeta', null], - ['debug', false], - ['dev', false], - ['silent', false], - ['replacer', null], - ['logHandler', console], - ['levelKey', '_logLevel'], - ['messageKey', 'msg'], - ['tagsKey', '_tags'] - ])('should set default option %s to equal %p', (key, expected) => { - expect(log.options[key]).toStrictEqual(expected); - }); - - test.each([ - ['meta', { test: 'test' }], - ['tags', ['custom-tag']], - ['dynamicMeta', function () {}], - ['debug', true], - ['dev', true], - ['silent', true], - ['replacer', function () {}], - ['logHandler', function () {}], - ['levelKey', 'level'], - ['messageKey', 'message'], - ['tagsKey', 'tags'] - ])('should override the default option for %s with %p', (key, expected) => { - const log = new LambdaLog({ - [key]: expected - }); - - expect(log.options[key]).toStrictEqual(expected); - }); - - describe('Environment Variables', () => { - afterEach(() => { - process.env.LAMBALOG_SILENT = null; - }); - - test.each([ - ['true'], - ['yes'], - ['y'], - ['1'] - ])('should enable silent mode when LAMBDALOG_SILENT is set to %s', val => { - process.env.LAMBDALOG_SILENT = val; - const log = new LambdaLog(); - - expect(log.options.silent).toBe(true); - }); - }); - - describe('Custom Log Levels', () => { - test('should add custom log level to configuration', () => { - const log = new LambdaLog({}, { - test: 'log' - }); - - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('test', 'log'); - }); - }); -}); - -describe('Properties', () => { - test.each([ - ['LambdaLog', LambdaLog], - ['LogMessage', LogMessage] - ])('should have access to uninstantiated %s class', (name, expected) => { - expect(log).toHaveProperty(name); - expect(log[name]).toBe(expected); - }); - - describe('Log Levels', () => { - test('should have log levels configuration object', () => { - expect(typeof log[LambdaLog.symbols.LEVELS]).toBe('object'); - }); - - test('should have default log level info', () => { - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('info', 'info'); - }); - - test('should have default log level warn', () => { - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('warn', 'warn'); - }); - - test('should have default log level error', () => { - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('error', 'error'); - }); - - test('should have default log level debug', () => { - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('debug'); - expect(typeof log[LambdaLog.symbols.LEVELS].debug).toBe('function'); - }); - - test('should return false from debug level function when options.debug is false', () => { - expect(log[LambdaLog.symbols.LEVELS].debug.call(log)).toBe(false); - }); - - test('should return debug from debug level function when options.debug is true', () => { - const log = new LambdaLog({ debug: true }); - expect(log[LambdaLog.symbols.LEVELS].debug.call(log)).toBe('debug'); - }); - }); - - test('should have console property that is equal to options.logHandler', () => { - expect(log.console).toBe(console); - }); -}); - -describe('Methods', () => { - describe('addLevel()', () => { - test('should add a custom log level to the configuration', () => { - const log = new LambdaLog(); - log.addLevel('test', 'log'); - - expect(log[LambdaLog.symbols.LEVELS]).toHaveProperty('test', 'log'); - }); - - test('should add a dynamic method to the class', () => { - const log = new LambdaLog(); - log.addLevel('test', 'log'); - - expect(typeof log.test).toBe('function'); - }); - }); - - describe('log()', () => { - test('should throw error when unknown log level is passed in', () => { - expect(() => { - log.log('foo', 'test'); - }).toThrow('"foo" is not a valid log level'); - }); - - test('should return false for a disable log level', () => { - const result = log.log('debug', 'test'); - expect(result).toBe(false); - }); - - test('should not log message when silent is enabled', () => { - const log = new LambdaLog({ logHandler: mockConsole, silent: true }); - log.log('info', 'test'); - expect(mockConsole.info).toBeCalledTimes(0); - }); - - test('should return instance of LogMessage', () => { - const result = log.log('info', 'test'); - expect(result).toBeInstanceOf(LogMessage); - }); - - test('should emit "log" event with instance of LogMessage', done => { - const log = new LambdaLog({ logHandler: mockConsole }); - - log.on('log', msg => { - expect(msg).toBeInstanceOf(LogMessage); - done(); - }); - - log.log('info', 'test'); - }); - }); - - describe('assert()', () => { - test('should return false when test is a truthy value', () => { - const result = log.assert(true, 'test'); - expect(result).toBe(false); - }); - - test('should log error if test is a falsy value', () => { - const log = new LambdaLog({ logHandler: mockConsole }); - const result = log.assert(false, 'test'); - - expect(result).toBeInstanceOf(LogMessage); - expect(result.level).toBe('error'); - }); - }); - - describe('result()', () => { - test('should throw error if a Promise is not provided', () => { - expect(() => { - log.result(null); - }).toThrow('A promise must be provided as the first argument'); - }); - - test('should throw error if non-Promise is provided', () => { - expect(() => { - log.result(noop); - }).toThrow('A promise must be provided as the first argument'); - }); - - test('should log promise results as info message on resolve', done => { - const log = new LambdaLog({ logHandler: mockConsole }); - const promise = log.result(Promise.resolve('Success!')); - - promise.then(msg => { - expect(msg).toBeInstanceOf(LogMessage); - expect(msg.level).toBe('info'); - expect(msg.msg).toBe('Success!'); - done(); - }); - }); - - test('should log promise error as an error message on reject', done => { - const log = new LambdaLog({ logHandler: mockConsole }); - const promise = log.result(Promise.reject(new Error('Failed!'))); - - promise.then(msg => { - expect(msg).toBeInstanceOf(LogMessage); - expect(msg.level).toBe('error'); - expect(msg.msg).toBe('Failed!'); - done(); - }); - }); - }); -}); - -describe('Dynamic Methods', () => { - test.each([ - ['info'], - ['warn'], - ['error'], - ['debug'] - ])('should automatically create method log.%s', method => { - const log = new LambdaLog({ - logHandler: mockConsole, - silent: false, - debug: true - }); - - expect(typeof log[method]).toBe('function'); - const message = log[method]('test'); - expect(mockConsole[method]).toHaveBeenCalled(); - - expect(message).toBeInstanceOf(LogMessage); - }); - - test('should automatically create method for custom log levels', () => { - const log = new LambdaLog({ - logHandler: mockConsole, - silent: false, - debug: true - }, { - test: 'log' - }); - - expect(typeof log.test).toBe('function'); - const message = log.test('test'); - expect(mockConsole.log).toHaveBeenCalled(); - - expect(message).toBeInstanceOf(LogMessage); - }); -}); diff --git a/lib/LogMessage.js b/lib/LogMessage.js deleted file mode 100644 index 7feea64..0000000 --- a/lib/LogMessage.js +++ /dev/null @@ -1,259 +0,0 @@ -const stringify = require('fast-safe-stringify'); - -const symbols = { - LOG: Symbol('log'), - META: Symbol('meta'), - ERROR: Symbol('error'), - OPTS: Symbol('opts') -}; - -/** - * The LogMessage class is a private/internal class that is used for generating log messages. All log methods return an instance of LogMessage allowing for a chainable api. - * Having a seperate class and instance for each log allows chaining and the ability to further customize this module in the future without major breaking changes. The documentation - * provided here is what is available to you for each log message. - */ -class LogMessage { - /** - * Constructor for LogMessage - * @class - * @param {object} log Object containing all the information for a log. - * @param {string} log.level The log level. - * @param {*} log.msg The message for the log. - * @param {object} [log.meta] Metadata attached to the log. - * @param {string[]|Function[]} [log.tags] Additional tags to attach to the log. - * @param {object} opts Configuration options from LambdaLog. - */ - constructor(log, opts) { - this[symbols.LOG] = log; - this[symbols.META] = {}; - this[symbols.ERROR] = null; - this[symbols.OPTS] = opts; - - const { meta, tags } = this[symbols.LOG]; - if(meta && (typeof meta !== 'object' || Array.isArray(meta))) { - this[symbols.LOG].meta = { meta }; - } - - if(!meta) this[symbols.LOG].meta = {}; - if(!tags) this[symbols.LOG].tags = []; - - // If `msg` is an Error-like object, use the message and add the `stack` to `meta` - if(LogMessage.isError(log.msg)) { - const err = log.msg; - this[symbols.ERROR] = err; - this[symbols.META].stack = err.stack; - this[symbols.LOG].msg = err.message; - } - } - - /** - * String log level of the message. - * @type {string} - */ - get level() { - return this[symbols.LOG].level; - } - - /** - * The message for the log. If an Error was provided, it will be the message of the error. - * @type {string} - */ - get msg() { - return this[symbols.LOG].msg; - } - - /** - * Update the message for this log to something else. - * @param {string} msg A string to update the message with. - */ - set msg(msg) { - this[symbols.LOG].msg = msg; - } - - /** - * Alias for `this.msg`. - * @type {string} - */ - get message() { - return this.msg; - } - - /** - * Alias for `this.msg = 'New message';` - * @param {string} msg A string to update the message with. - */ - set message(msg) { - this.msg = msg; - } - - /** - * The fully compiled metadata object for the log. Includes global and dynamic metadata. - * @type {object} - */ - get meta() { - const opts = this[symbols.OPTS]; - - let meta = { - ...this[symbols.META], - ...this[symbols.OPTS].meta, - ...this[symbols.LOG].meta - }; - - if(opts.dynamicMeta && typeof opts.dynamicMeta === 'function') { - const dynMeta = opts.dynamicMeta.call(this, this, opts); - - if(typeof dynMeta === 'object') { - meta = Object.assign(meta, dynMeta); - } - } - - for(const [key, val] of Object.entries(meta)) { - if(typeof val !== 'object') continue; - if(LogMessage.isError(val)) { - meta[key] = LogMessage.stubError(val); - } - } - - return meta; - } - - /** - * Set additional metadata on the log message. - * @param {object} obj An object with properties to append or overwrite in the metadata. - */ - set meta(obj) { - this[symbols.LOG].meta = { - ...this[symbols.LOG].meta, - ...obj - }; - } - - /** - * Array of tags attached to this log. Includes global tags. - * @type {string[]} - */ - get tags() { - const opts = this[symbols.OPTS]; - const tags = [].concat(opts.tags, this[symbols.LOG].tags); - - return tags.map(tag => { - if(typeof tag === 'function') { - return tag.call(this, { - level: this.level, - meta: this.meta, - options: opts - }); - } - - const hasVar = tag.match(/(<<(.*)>>)/); - if(!hasVar) return tag; - - const varName = hasVar[2]; - if(varName === 'level') return tag.replace(hasVar[1], this.level); - - return tag; - }).filter(tag => tag !== null && tag !== undefined && tag !== ''); - } - - /** - * Appends additional tags to this log message. - * @param {string[]|Function[]} tags Array of string tags or enhanced tag functions to append to the tags array. - */ - set tags(tags) { - this[symbols.LOG].tags = this[symbols.LOG].tags.concat(tags); - } - - /** - * The full log object. This is the object used in logMessage.toJSON() and when the log is written to the console. - * @returns {object} The full log object. - */ - get value() { - const opts = this[symbols.OPTS]; - return { - [opts.levelKey]: opts.levelKey ? this.level : undefined, - [opts.messageKey]: this.msg, - ...this.meta, - [opts.tagsKey]: opts.tagsKey ? this.tags : undefined - }; - } - - /** - * Alias of `logMessage.value`. - * @returns {object} The full log object. - */ - get log() { - return this.value; - } - - /** - * Throws the log. If an error was not provided, one will be generated for you and thrown. This is useful in cases where you need to log an - * error, but also throw it. - * @throws {Error} The provided error, or a newly generated error. - */ - get throw() { - const err = this[symbols.ERROR] || new Error(this.msg); - err.log = this; - - throw err; - } - - /** - * Returns the compiled log object converted into JSON. This method utilizes `options.replacer` for the replacer function. It also uses - * [fast-safe-stringify](https://www.npmjs.com/package/fast-safe-stringify) to prevent circular reference issues. - * @param {boolean} [format=false] Enable pretty-printing of the JSON object (4 space indentation). - * @returns {string} Log object stringified as JSON. - */ - toJSON(format) { - return stringify(this.value, this[symbols.OPTS].replacer || null, format ? 4 : 0); - } - - /** - * Checks if value is an Error or Error-like object - * @static - * @param {*} val Value to test - * @returns {boolean} Whether the value is an Error or Error-like object - */ - static isError(val) { - return Boolean(val) && typeof val === 'object' && ( - val instanceof Error || ( - Object.prototype.hasOwnProperty.call(val, 'message') && - Object.prototype.hasOwnProperty.call(val, 'stack') - ) - ); - } - - /** - * Stubs an Error or Error-like object to include a toJSON method. - * @static - * @param {Error} err An Error or Error-like object. - * @returns {Error} The original error stubbed with a toJSON() method. - */ - static stubError(err) { - if(typeof err.toJSON === 'function') return err; - - err.toJSON = function () { - const keys = [ - 'name', - 'message', - 'stack' - ].concat(Object.keys(err)); - - return keys.reduce((obj, key) => { - if(key in err) { - const val = err[key]; - - if(typeof val === 'function') return obj; - obj[key] = val; - } - - return obj; - }, {}); - }; - - return err; - } -} - -LogMessage.symbols = symbols; - -module.exports = LogMessage; diff --git a/lib/LogMessage.test.js b/lib/LogMessage.test.js deleted file mode 100644 index 0408323..0000000 --- a/lib/LogMessage.test.js +++ /dev/null @@ -1,419 +0,0 @@ -const LogMessage = require('./LogMessage'); - -const logData = { - info: { - level: 'info', - msg: 'info test', - meta: {}, - tags: [] - }, - - error: { - level: 'error', - msg: new Error('error test'), - meta: {}, - tags: [] - }, - - withMeta: { - level: 'info', - msg: 'info test', - meta: { test: true }, - tags: [] - }, - - withTags: { - level: 'info', - msg: 'info test', - meta: {}, - tags: ['test'] - } -}; - -const defaultOpts = { - meta: {}, - dynamicMeta: null, - tags: [], - levelKey: '_logLevel', - messageKey: 'msg', - tagsKey: '_tags', - replacer: null -}; - -describe('Statics', () => { - test('should have static property "symbols"', () => { - expect(LogMessage.symbols).toBeDefined(); - }); - - describe('Static symbols', () => { - test.each([ - ['LOG', 'Symbol(log)'], - ['META', 'Symbol(meta)'], - ['ERROR', 'Symbol(error)'], - ['OPTS', 'Symbol(opts)'] - ])('should have a symbol for %s', (prop, value) => { - expect(LogMessage.symbols).toHaveProperty(prop); - expect(LogMessage.symbols[prop].toString()).toEqual(value); - }); - }); - - describe('Static Methods', () => { - describe('isError()', () => { - test('should return true for an Error', () => { - expect(LogMessage.isError(new Error('test'))).toBe(true); - }); - - test('should return true for an Error-like object', () => { - expect(LogMessage.isError({ - message: 'test', - stack: 'stack' - })).toBe(true); - }); - - test('should return false for anything else', () => { - expect(LogMessage.isError()).toBe(false); - expect(LogMessage.isError(null)).toBe(false); - expect(LogMessage.isError(true)).toBe(false); - expect(LogMessage.isError(123)).toBe(false); - expect(LogMessage.isError('A string')).toBe(false); - expect(LogMessage.isError({ message: 'test' })).toBe(false); - expect(LogMessage.isError([1, 2, 3, 'test'])).toBe(false); - expect(LogMessage.isError({ stack: 'stack' })).toBe(false); - }); - }); - - describe('stubError()', () => { - let err; - beforeEach(() => { - const e = new Error('test'); - e.test = true; - err = LogMessage.stubError(e); - }); - - test('should add a toJSON method to an error', () => { - expect(err).toHaveProperty('toJSON'); - expect(typeof err.toJSON).toBe('function'); - }); - - test('calling toJSON should create an serializable object', () => { - const result = err.toJSON(); - - expect(typeof result).toBe('object'); - expect(result).toHaveProperty('name', 'Error'); - expect(result).toHaveProperty('message', 'test'); - expect(result).toHaveProperty('stack', err.stack); - expect(result).toHaveProperty('test', true); - }); - - test('should skip custom toJSON fn if error already has one', () => { - const e = new Error('test'); - const noop = () => { /* noop */ }; - Object.defineProperty(e, 'toJSON', { - value: noop - }); - - const error = LogMessage.stubError(e); - expect(error.toJSON.toString()).toBe(noop.toString()); - }); - }); - }); -}); - -describe('Constructor', () => { - test.each([ - [LogMessage.symbols.LOG, logData.info], - [LogMessage.symbols.META, {}], - [LogMessage.symbols.ERROR, null], - [LogMessage.symbols.OPTS, defaultOpts] - ])('should set property %p on class', (symbol, expected) => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - - expect(msg[symbol]).toStrictEqual(expected); - }); - - test.each([ - ['string', 'string meta'], - ['array', ['array', 'meta']], - ['boolean', true], - ['number', 123] - ])('should convert non-object (%s) meta to an object', (_, value) => { - const msg = new LogMessage({ - ...logData.info, - meta: value - }, defaultOpts); - - expect(msg[LogMessage.symbols.LOG].meta).toStrictEqual({ meta: value }); - }); - - test('should set log meta as an empty object if not provided', () => { - const msg = new LogMessage({ ...logData.info, meta: undefined }, defaultOpts); - - expect(msg[LogMessage.symbols.LOG].meta).toStrictEqual({}); - }); - - test('should set log tags as an empty array if not provided', () => { - const msg = new LogMessage({ ...logData.info, tags: undefined }, defaultOpts); - - expect(msg[LogMessage.symbols.LOG].tags).toStrictEqual([]); - }); - - test('should update all properties when an error is passed in', () => { - const msg = new LogMessage({ ...logData.error }, defaultOpts); - - expect(msg[LogMessage.symbols.LOG].msg).toBe('error test'); - expect(msg[LogMessage.symbols.ERROR]).toStrictEqual(logData.error.msg); - expect(msg[LogMessage.symbols.META].stack).toBe(logData.error.msg.stack); - }); -}); - -describe('Getters', () => { - test('"level" should return the log level', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - - expect(msg.level).toBe('info'); - }); - - test('"msg" and "message" should return the log message', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - - expect(msg.msg).toBe('info test'); - expect(msg.message).toBe(msg.msg); - }); - - test('"meta" should return metadata object', () => { - const msg = new LogMessage({ ...logData.withMeta }, defaultOpts); - - expect(msg.meta).toHaveProperty('test', true); - }); - - test('"meta" should combine all metadata', () => { - const msg = new LogMessage({ ...logData.withMeta }, { - ...defaultOpts, - meta: { foo: 'bar' }, - dynamicMeta: () => ({ dynamic: 'meta' }) - }); - - expect(msg.meta).toHaveProperty('test', true); - expect(msg.meta).toHaveProperty('foo', 'bar'); - expect(msg.meta).toHaveProperty('dynamic', 'meta'); - }); - - test('"meta" should call dynamicMeta', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - dynamicMeta(cls, opts) { - expect(this).toBeInstanceOf(LogMessage); - expect(cls).toBeInstanceOf(LogMessage); - expect(typeof opts).toBe('object'); - return { dynamic: 'meta' }; - } - }); - - expect(msg.meta).toHaveProperty('dynamic', 'meta'); - }); - - test('"meta" should convert errors in metadata to object', () => { - const msg = new LogMessage({ ...logData.withMeta }, { - ...defaultOpts, - meta: { - foo: 'bar', - prop: new Error('meta error'), - obj: { not: 'an error' } - } - }); - - expect(typeof msg.meta.prop).toBe('object'); - expect(msg.meta.foo).toBe('bar'); - expect(typeof msg.meta.obj).toBe('object'); - }); - - test('"tags" should return tags array', () => { - const msg = new LogMessage({ ...logData.withTags }, defaultOpts); - - expect(Array.isArray(msg.tags)).toBe(true); - expect(msg.tags).toHaveLength(1); - expect(msg.tags).toContain('test'); - }); - - test('"tags" should combine all tags', () => { - const msg = new LogMessage({ ...logData.withTags }, { - ...defaultOpts, - tags: ['global'] - }); - - expect(msg.tags).toHaveLength(2); - expect(msg.tags).toContain('test'); - expect(msg.tags).toContain('global'); - }); - - test('"tags" should execute functions', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - tags: [function (props) { - expect(this).toBeInstanceOf(LogMessage); - expect(typeof props).toBe('object'); - expect(props).toHaveProperty('level', 'info'); - expect(props).toHaveProperty('meta', {}); - expect(props).toHaveProperty('options'); - - return 'dynamic'; - }] - }); - - const { tags } = msg; - - expect(tags).toHaveLength(1); - expect(tags).toContain('dynamic'); - }); - - test('"tags" should replace <> variable with log level', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - tags: ['', '<>', '<>'] - }); - - const { tags } = msg; - - expect(tags).toHaveLength(3); - expect(tags).toContain(''); - expect(tags).toContain('info'); - expect(tags).toContain('<>'); - }); - - test('"value" should return log object', () => { - const msg = new LogMessage({ ...logData.info, meta: { test: true }, tags: ['test'] }, defaultOpts); - const { value } = msg; - - expect(value).toHaveProperty('_logLevel', 'info'); - expect(value).toHaveProperty('msg', 'info test'); - expect(value).toHaveProperty('test', true); - expect(value).toHaveProperty('_tags', ['test']); - }); - - test('"value" should change key names based on options', () => { - const msg = new LogMessage({ ...logData.info, meta: { test: true }, tags: ['test'] }, { - ...defaultOpts, - levelKey: 'lvl', - messageKey: 'message', - tagsKey: 'tags' - }); - const { value } = msg; - - expect(value).toHaveProperty('lvl', 'info'); - expect(value).toHaveProperty('message', 'info test'); - expect(value).toHaveProperty('test', true); - expect(value).toHaveProperty('tags', ['test']); - }); - - test('"value" should hide level and tags when options is set to falsy value', () => { - const msg = new LogMessage({ ...logData.info, tags: ['test'] }, { - ...defaultOpts, - levelKey: undefined, - tagsKey: null - }); - const { value } = msg; - - expect(value).not.toHaveProperty('_logLevel'); - expect(value).toHaveProperty('msg', 'info test'); - expect(value).not.toHaveProperty('_tags'); - }); - - test('"log" should match "value"', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - - expect(msg.log).toStrictEqual(msg.value); - }); - - test('"throw" should throw the error passed to the log', () => { - const msg = new LogMessage({ ...logData.error }, defaultOpts); - - expect(() => msg.throw).toThrow('error test'); - }); - - test('"throw" should include access to LogMessage instance in thrown error', () => { - const msg = new LogMessage({ ...logData.error }, defaultOpts); - - try { - return msg.throw; - } catch(err) { - expect(err.log).toBeInstanceOf(LogMessage); - } - }); - - test('"throw" should throw a new error for non-errors passed to the log', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - - expect(() => msg.throw).toThrow('info test'); - }); -}); - -describe('Setters', () => { - test('set "msg" should overwrite the log message', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - msg.msg = 'test'; - - expect(msg.msg).toBe('test'); - }); - - test('set "message" should overwrite the log message', () => { - const msg = new LogMessage({ ...logData.info }, defaultOpts); - msg.message = 'test'; - - expect(msg.message).toBe('test'); - }); - - test('set "meta" should add additional properties to the log message', () => { - const msg = new LogMessage({ ...logData.info, meta: { custom: 123 } }, defaultOpts); - msg.meta = { prop: 'test' }; - - expect(msg.meta).toHaveProperty('prop', 'test'); - expect(msg.meta).toHaveProperty('custom', 123); - }); - - test('set "tags" should add new tags to the log message', () => { - const msg = new LogMessage({ ...logData.info, tags: ['test'] }, defaultOpts); - msg.tags = ['another-tag']; - - expect(msg.tags).toHaveLength(2); - expect(msg.tags).toContain('test'); - expect(msg.tags).toContain('another-tag'); - }); -}); - -describe('Methods', () => { - describe('toJSON()', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - meta: { ssn: '444-55-6666' }, - replacer(key, value) { - if(key === 'ssn') { - return `${value.substr(0, 3)}-**-****`; - } - - return value; - } - }); - - test('should return log in JSON format', () => { - expect(typeof msg.toJSON()).toBe('string'); - expect(msg.toJSON()).toMatch(/^\{.*\}$/); - }); - - test('should run replacer function', () => { - expect(JSON.parse(msg.toJSON()).ssn).toBe('444-**-****'); - }); - - test('should not run replacer function when not defined', () => { - const msg = new LogMessage({ ...logData.info }, { - ...defaultOpts, - meta: { ssn: '444-55-6666' } - }); - - expect(JSON.parse(msg.toJSON()).ssn).toBe('444-55-6666'); - }); - - test('should pretty print JSON when format is "true"', () => { - expect(/\n/g.test(msg.toJSON(true))).toBe(true); - }); - }); -}); diff --git a/package.json b/package.json index e29ea25..c72b325 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,32 @@ { "name": "lambda-log", - "version": "3.1.0", - "description": "Lightweight logging library for any Node 10+ applications", - "main": "index.js", + "version": "4.0.0-beta.5", + "description": "Lightweight logging library for any Node 12+ applications", + "main": "./index.js", + "module": "./dist/esm/lambda-log.js", + "types": "./dist/types/index.d.ts", + "exports": { + ".": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm/lambda-log.js", + "require": "./index.js" + }, + "./esm": { + "types": "./dist/types/index.d.ts", + "import": "./dist/esm", + "require": "./dist/esm" + } + }, "scripts": { - "test": "jest", + "test": "cross-env NODE_OPTIONS=--experimental-vm-modules npx jest", + "test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules npx jest --watchAll", "prepare": "is-ci || husky install", - "lint": "eslint index.js index.test.js \"lib/*.js\"" + "build": "npm run clean && npm run build:esm && npm run build:cjs && npm run build:declaration", + "build:esm": "tsc --module ES2020 --outDir dist/esm && echo \"{\n \\\"type\\\": \\\"module\\\"\n}\" > ./dist/esm/package.json", + "build:cjs": "tsc --module commonjs --outDir dist/cjs && echo \"{\n \\\"type\\\": \\\"commonjs\\\"\n}\" > ./dist/cjs/package.json", + "build:declaration": "tsc --module none --declaration --emitDeclarationOnly --outFile dist/types/index.d.ts", + "lint": "eslint \"src/*.ts\"", + "clean": "rimraf dist" }, "repository": { "type": "git", @@ -34,7 +54,8 @@ "cloudwatch", "logs", "function", - "cloud" + "cloud", + "typescript" ], "author": "Kyle Ross ", "license": "MIT", @@ -43,23 +64,35 @@ }, "homepage": "https://lambdalog.dev", "engines": { - "node": ">=10.0.0" + "node": ">=12" }, "devDependencies": { - "@commitlint/cli": "^13.2.1", - "@commitlint/config-conventional": "^13.2.0", - "@semantic-release/changelog": "^6.0.0", - "@semantic-release/git": "^10.0.0", + "@commitlint/cli": "^15.0.0", + "@commitlint/config-conventional": "^15.0.0", + "@semantic-release/changelog": "^6.0.1", + "@semantic-release/git": "^10.0.1", + "@types/jest": "^27.0.3", + "@typescript-eslint/eslint-plugin": "^5.6.0", + "@typescript-eslint/parser": "^5.6.0", + "cross-env": "^7.0.3", "eslint": "^7.23.0", "eslint-config-xo-space": "^0.30.0", - "eslint-plugin-jsdoc": "^36.1.1", + "eslint-config-xo-typescript": "^0.45.2", + "eslint-plugin-jsdoc": "^37.2.0", "eslint-plugin-node": "^11.1.0", - "husky": "^7.0.2", - "is-ci": "^3.0.0", - "jest": "^27.2.5", - "semantic-release": "^18.0.0" + "expect-more-jest": "^5.4.0", + "husky": "^7.0.4", + "is-ci": "^3.0.1", + "jest": "^27.4.3", + "jest-ts-webcompat-resolver": "^1.0.0", + "rimraf": "^3.0.2", + "semantic-release": "^18.0.1", + "ts-jest": "^27.1.1", + "ts-node": "^10.4.0", + "typescript": "^4.5.3" }, "dependencies": { + "@types/node": "^16.11.12", "fast-safe-stringify": "^2.1.1" } } diff --git a/release.config.js b/release.config.js new file mode 100644 index 0000000..4a1f6d4 --- /dev/null +++ b/release.config.js @@ -0,0 +1,14 @@ +module.exports = { + plugins: [ + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + '@semantic-release/changelog', + '@semantic-release/npm', + ['@semantic-release/git', { + assets: ['package.json', 'CHANGELOG.md'], + // eslint-disable-next-line no-template-curly-in-string + message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}' + }], + '@semantic-release/github' + ] +}; diff --git a/src/LambdaLog.spec.ts b/src/LambdaLog.spec.ts new file mode 100644 index 0000000..59b02c2 --- /dev/null +++ b/src/LambdaLog.spec.ts @@ -0,0 +1,273 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import LambdaLog, { defaultOptions } from './LambdaLog'; +import LogMessage from './LogMessage'; +import { ConsoleObject } from './typings'; + +const mockConsole = { + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn() +}; + +describe('LambdaLog', () => { + it.each([ + ['meta', {}], + ['tags', []], + ['dynamicMeta', null], + ['dev', false], + ['silent', false], + ['replacer', null], + ['logHandler', console], + ['levelKey', '__level'], + ['messageKey', 'msg'], + ['tagsKey', '__tags'] + ])('should set default option %s to equal %p', (key, expected) => { + const log = new LambdaLog(); + expect(log.options[key]).toStrictEqual(expected); + }); + + it.each([ + ['meta', { test: 'test' }], + ['tags', ['custom-tag']], + ['dynamicMeta', function () {}], + ['debug', true], + ['dev', true], + ['silent', true], + ['replacer', function () {}], + ['logHandler', function () {}], + ['levelKey', 'level'], + ['messageKey', 'message'], + ['tagsKey', 'tags'] + ])('should override the default option for %s with %p', (key, expected) => { + const log = new LambdaLog({ + [key]: expected + }); + + expect(log.options[key]).toStrictEqual(expected); + }); + + describe('Environment Variables', () => { + const truthyValues = ['true', '1', 'yes', 'y', 'on']; + const falsyValues = ['false', '0', 'no', 'n', 'off']; + + describe('LAMBALOG_LEVEL', () => { + it.each([ + 'trace', 'debug', 'info', 'warn', 'error', 'fatal' + ])('should set max log level when LAMBDALOG_LEVEL is set to %s', val => { + process.env.LAMBDALOG_LEVEL = val; + const log = new LambdaLog(); + + expect(log.options.level).toBe(val); + delete process.env.LAMBDALOG_LEVEL; + }); + + it('should only set the max log level when LAMBDALOG_LEVEL is set to a valid log level', () => { + process.env.LAMBDALOG_LEVEL = 'foo'; + const log = new LambdaLog(); + + expect(log.options.level).toBe('info'); + delete process.env.LAMBDALOG_LEVEL; + }); + }); + + describe.each([ + ['enable', truthyValues, true], + ['disable', falsyValues, false] + ])('LAMBALOG_DEV', (action, vals, expected) => { + it.each(vals)(`should ${action} dev mode when LAMBDALOG_DEV is set to %s`, val => { + process.env.LAMBDALOG_DEV = val; + const log = new LambdaLog(); + + expect(log.options.dev).toBe(expected); + delete process.env.LAMBDALOG_DEV; + }); + }); + + describe.each([ + ['enable', truthyValues, true], + ['disable', falsyValues, false] + ])('LAMBALOG_SILENT', (action, vals, expected) => { + it.each(vals)(`should ${action} silent mode when LAMBDALOG_SILENT is set to %s`, val => { + process.env.LAMBDALOG_SILENT = val; + const log = new LambdaLog(); + + expect(log.options.silent).toBe(expected); + delete process.env.LAMBDALOG_SILENT; + }); + }); + }); + + describe('Properties', () => { + it.each([ + ['LambdaLog', LambdaLog], + ['LogMessage', LogMessage] + ])('should have access to uninstantiated %s class', (name, expected) => { + const log = new LambdaLog(); + expect(log).toHaveProperty(name); + expect(log[name]).toBe(expected); + }); + }); + + describe('Methods', () => { + beforeEach(() => { + defaultOptions.logHandler = mockConsole as unknown as ConsoleObject; + }); + + afterAll(() => { + defaultOptions.logHandler = console; + }); + + afterEach(() => { + mockConsole.log.mockClear(); + mockConsole.info.mockClear(); + mockConsole.warn.mockClear(); + mockConsole.error.mockClear(); + mockConsole.debug.mockClear(); + }); + + describe('_log()', () => { + it('should throw error for invalid log level', () => { + const log = new LambdaLog(); + expect(() => log._log('foo' as any, 'test')).toThrowError(/^"foo" is not a valid log level$/); + }); + + it('should throw error for no log level', () => { + const log = new LambdaLog(); + expect(() => log._log(null as any, 'test')).toThrowError(/is not a valid log level$/); + }); + + it('should not log for a disabled log level', () => { + const log = new LambdaLog({ level: 'fatal' }); + const result = log._log('debug', 'test'); + expect(result).toBeInstanceOf(LogMessage); + expect(mockConsole.debug).not.toHaveBeenCalled(); + }); + + it('should return log message instance', () => { + const log = new LambdaLog(); + const result = log._log('info', 'test'); + expect(result).toBeInstanceOf(LogMessage); + }); + + it('should not log message when silent is enabled', () => { + const log = new LambdaLog({ silent: true }); + log._log('info', 'test'); + expect(mockConsole.info).toBeCalledTimes(0); + }); + + it('should emit "log" event with instance of LogMessage', done => { + const log = new LambdaLog(); + + log.on('log', msg => { + expect(msg).toBeInstanceOf(LogMessage); + done(); + }); + + const res = log._log('error', 'test'); + expect(res).toBeInstanceOf(LogMessage); + }); + + it('should default to `console` if logHandler is not provided', () => { + const log = new LambdaLog({ + logHandler: undefined + }); + + const info = jest.spyOn(console, 'info'); + log._log('info', 'test'); + expect(info).toBeCalled(); + info.mockRestore(); + }); + }); + + describe('assert()', () => { + it('should return false when test is a truthy value', () => { + const log = new LambdaLog(); + const result = log.assert(true, 'test'); + expect(result).toBe(false); + }); + + it('should log error if test is a falsy value', () => { + const log = new LambdaLog(); + const result = log.assert(false, 'test'); + + expect(result).toBeInstanceOf(LogMessage); + if(result !== false) { + expect(result.level).toBe('error'); + } + }); + }); + + describe('result()', () => { + it('should log promise results as info message on resolve', done => { + const log = new LambdaLog(); + const promise = log.result(Promise.resolve('Success!')); + + promise.then(msg => { + expect(msg).toBeInstanceOf(LogMessage); + expect(msg.level).toBe('info'); + expect(msg.msg).toBe('Success!'); + done(); + }); + }); + + it('should log promise error as an error message on reject', done => { + const log = new LambdaLog(); + const promise = log.result(Promise.reject(new Error('Failed!'))); + + promise.then(msg => { + expect(msg).toBeInstanceOf(LogMessage); + expect(msg.level).toBe('error'); + expect(msg.msg).toBe('Failed!'); + done(); + }); + }); + }); + + describe('shortcut methods', () => { + it('should log trace message', () => { + const log = new LambdaLog({ level: 'trace' }); + log.trace('test'); + expect(mockConsole.debug).toBeCalledTimes(1); + }); + + it('should log debug message', () => { + const log = new LambdaLog({ level: 'debug' }); + log.debug('test'); + expect(mockConsole.debug).toBeCalledTimes(1); + }); + + it('should log info message', () => { + const log = new LambdaLog({ level: 'info' }); + log.info('test'); + expect(mockConsole.info).toBeCalledTimes(1); + }); + + it('should log info message (log)', () => { + const log = new LambdaLog({ level: 'info' }); + log.log('test'); + expect(mockConsole.info).toBeCalledTimes(1); + }); + + it('should log warn message', () => { + const log = new LambdaLog({ level: 'warn' }); + log.warn('test'); + expect(mockConsole.warn).toBeCalledTimes(1); + }); + + it('should log error message', () => { + const log = new LambdaLog({ level: 'error' }); + log.error('test'); + expect(mockConsole.error).toBeCalledTimes(1); + }); + + it('should log fatal message', () => { + const log = new LambdaLog({ level: 'fatal' }); + log.fatal('test'); + expect(mockConsole.error).toBeCalledTimes(1); + }); + }); + }); +}); diff --git a/src/LambdaLog.ts b/src/LambdaLog.ts new file mode 100644 index 0000000..e7aac18 --- /dev/null +++ b/src/LambdaLog.ts @@ -0,0 +1,305 @@ +import { EventEmitter } from 'events'; +import { LambdaLogOptions, Message, Metadata, LogLevels, LogObject, Tag, ConsoleObject } from './typings.js'; +import LogMessage from './LogMessage.js'; +import * as logFormatters from './formatters/index.js'; +import { toBool } from './utils.js'; + + +const levels = [{ + name: 'fatal', + method: 'error' +}, { + name: 'error', + method: 'error' +}, { + name: 'warn', + method: 'warn' +}, { + name: 'info', + method: 'info' +}, { + name: 'debug', + method: 'debug' +}, { + name: 'trace', + method: 'debug' +}]; + +/** + * Default options for the LambdaLog class. Stored globally so that it can be modified by the user. + */ +export const defaultOptions: LambdaLogOptions = { + meta: {}, + tags: [], + dynamicMeta: null, + level: 'info', + dev: false, + silent: false, + replacer: null, + logHandler: console, + levelKey: '__level', + messageKey: 'msg', + tagsKey: '__tags', + onFormat: logFormatters.json() +}; + +export const formatters = logFormatters; + + +export default class LambdaLog extends EventEmitter { + static defaultOptions = defaultOptions; + static formatters = formatters; + + /** + * Access to the uninstantiated LambdaLog class. + * @readonly + */ + readonly LambdaLog = LambdaLog; + /** + * Access to the uninstantiated LogMessage class. + */ + LogMessage = LogMessage; + + /** + * The options object for this instance of LambdaLog. + */ + options: LambdaLogOptions; + + /** + * Access to the log levels configuration. + * @readonly + */ + readonly levels = levels; + + /** + * Returns the console object to use for logging. + * @private + * @returns {ConsoleObject} The configured console object or `console` if none is provided. + */ + private get console(): ConsoleObject { + return this.options.logHandler ?? console; + } + + /** + * Constructor for the LambdaLog class. Provided to be utilized in more advanced cases to allow overriding and configuration. + * By default, this module will export an instance of this class, but you may access the class and create your own instance + * via `log.LambdaLog`. + * @class + * @param {LambdaLogOptions} [options={}] Configuration options for LambdaLog. + */ + constructor(options: LambdaLogOptions = {}) { + super(); + + this.options = { + ...defaultOptions, + ...options + }; + + // Override the max log level from the environment variable + if(process.env.LAMBDALOG_LEVEL) { + const lvl = this.getLevel(process.env.LAMBDALOG_LEVEL); + if(lvl) this.options.level = lvl.name as keyof LambdaLogOptions['level']; + } + + // Override the dev setting from the environment variable + if(process.env.LAMBDALOG_DEV) { + this.options.dev = toBool(process.env.LAMBDALOG_DEV); + } + + // Override the silent setting from the environment variable + if(process.env.LAMBDALOG_SILENT) { + this.options.silent = toBool(process.env.LAMBDALOG_SILENT); + } + } + + /** + * Generates JSON log message based on the provided parameters and the global configuration. Once the JSON message is created, it is properly logged to the `console` + * and emitted through an event. If an `Error` or `Error`-like object is provided for `msg`, it will parse out the message and include the stacktrace in the metadata. + * @throws {Error} If improper log level is provided. + * @template T The type of the message to log. + * @param {string} level Log level (`info`, `debug`, `warn`, `error`, or `fatal`) + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {object|string|number} [meta={}] Optional meta data to attach to the log. + * @param {string[]} [tags=[]] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + _log(level: LogLevels, msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + const lvl = this.getLevel(level); + if(!lvl) { + throw new Error(`"${level}" is not a valid log level`); + } + + // Generate the log message instance + const message = new this.LogMessage({ + level, + msg, + meta, + tags + } as LogObject, this.options); + + // Check if we can log this level + if(lvl.idx <= this.maxLevelIdx) { + const consoleObj = this.console; + // Log the message to the console + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + consoleObj[lvl.method](message.toString()); + + /** + * The log event is emitted (using EventEmitter) for every log generated. This allows for custom integrations, such as logging to a thrid-party service. + * This event is emitted with the [LogMessage](#logmessage) instance for the log. You may control events using all the methods of EventEmitter. + * @event LambdaLog#log + * @type {LogMessage} + */ + this.emit('log', message); + } + + return message; + } + + /** + * Logs a message at the `trace` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + trace(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('trace', msg, meta, tags); + } + + /** + * Logs a message at the `debug` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + debug(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('debug', msg, meta, tags); + } + + /** + * Logs a message at the `info` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + info(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('info', msg, meta, tags); + } + + /** + * Alias for `info`. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + log(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('info', msg, meta, tags); + } + + /** + * Logs a message at the `warn` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + warn(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('warn', msg, meta, tags); + } + + /** + * Logs a message at the `error` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + error(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('error', msg, meta, tags); + } + + /** + * Logs a message at the `error` log level. + * @template T The type of the message to log. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {Metadata} [meta] Optional meta data to attach to the log. + * @param {Tag[]} [tags] Additional tags to append to this log. + * @returns {LogMessage} Returns instance of LogMessage. + */ + fatal(msg: T, meta?: Metadata, tags?: Tag[]): LogMessage { + return this._log('fatal', msg, meta, tags); + } + + /** + * Generates a log message if `test` is a falsy value. If `test` is truthy, the log message is skipped and returns `false`. Allows creating log messages without the need to + * wrap them in an if statement. The log level will be `error`. + * @template T The type of the message to log. + * @param {*} test Value to test for a falsy value. + * @param {T} msg Message to log. Can be any type, but string or `Error` is reccommended. + * @param {object} [meta={}] Optional meta data to attach to the log. + * @param {string[]} [tags=[]] Additional tags to append to this log. + * @returns {LogMessage|false} The generated log message or `false` if assertion passed. + */ + assert(test: unknown, msg: T, meta?: Metadata, tags?: Tag[]): LogMessage | false { + if(test) return false; + return this._log('error', msg, meta, tags); + } + + /** + * Generates a log message with the result or error provided by a promise. Useful for debugging and testing. + * @param {Promise<*>} promise Promise to log the results of. + * @param {object} [meta={}] Optional meta data to attach to the log. + * @param {string[]} [tags=[]] Additional tags to append to this log. + * @returns {Promise} A Promise that resolves with the log message. + */ + async result(promise: Promise, meta?: Metadata, tags?: Tag[]): Promise { + const wrapper = new Promise(resolve => { + promise + .then(value => { + resolve(this._log('info', value, meta, tags)); + }) + .catch(err => { + resolve(this._log('error', err as Error, meta, tags)); + }); + }); + + return wrapper; + } + + /** + * Validates and gets the configuration for the provided log level. + * @private + * @param {string} level The provided log level string. + * @returns {object} Returns the configuration for the provided log level. + */ + private getLevel(level: string): { idx: number; name: string; method: string } | false { + if(!level) return false; + const lvl = levels.findIndex(l => l.name === level.toLowerCase()); + if(lvl === -1) return false; + + return { + idx: lvl, + ...levels[lvl] + }; + } + + /** + * Returns the index of the configured maximum log level. + * @private + * @returns {number} The index of the configured maximum log level. + */ + private get maxLevelIdx(): number { + if(!this.options.level || this.options.silent) return -1; + return levels.findIndex(l => l.name === this.options.level); + } +} diff --git a/src/LogMessage.spec.ts b/src/LogMessage.spec.ts new file mode 100644 index 0000000..9d089c0 --- /dev/null +++ b/src/LogMessage.spec.ts @@ -0,0 +1,474 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import 'expect-more-jest'; +import LogMessage from './LogMessage'; +import * as formatters from './formatters'; +import { StubbedError } from './typings'; + +const logData = { + info: { + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, + + error: { + level: 'error', + msg: new Error('error test'), + meta: {}, + tags: [] + }, + + withMeta: { + level: 'info', + msg: 'info test', + meta: { test: true }, + tags: [] + }, + + withTags: { + level: 'info', + msg: 'info test', + meta: {}, + tags: ['test'] + } +}; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +describe('LogMessage', () => { + it.each([ + ['__msg', logData.info.msg], + ['__meta', {}], + ['__tags', []], + ['__error', null], + ['__opts', defaultOpts] + ])('should set property %s on class', (key, expected) => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(msg[key]).toStrictEqual(expected); + }); + + it.each([ + ['string', 'string meta'], + ['array', ['array', 'meta']], + ['boolean', true], + ['number', 123] + ])('should convert non-object (%s) meta to an object', (_, value) => { + const msg = new LogMessage({ + ...logData.info, + meta: value as any + }, defaultOpts); + + expect(msg.__meta).toStrictEqual({ meta: value }); + }); + + it('should set log meta as an empty object if not provided', () => { + const msg = new LogMessage({ ...logData.info, meta: undefined }, defaultOpts); + + expect(msg.__meta).toStrictEqual({}); + }); + + it('should set log tags as an empty array if not provided', () => { + const msg = new LogMessage({ ...logData.info, tags: undefined }, defaultOpts); + + expect(msg.__tags).toStrictEqual([]); + }); + + it('should update all properties when an error is passed in', () => { + const msg = new LogMessage({ ...logData.error }, defaultOpts); + + expect(msg.__msg).toBe('error test'); + expect(msg.__error).toStrictEqual(logData.error.msg); + expect(msg.__meta.stack).toBe(logData.error.msg.stack); + }); + + + describe('Getters', () => { + it('"level" should return the log level', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(msg.level).toBe('info'); + }); + + it('"msg" and "message" should return the log message', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(msg.msg).toBe('info test'); + expect(msg.message).toBe(msg.msg); + }); + + it('"meta" should return metadata object', () => { + const msg = new LogMessage({ ...logData.withMeta }, defaultOpts); + + expect(msg.meta).toHaveProperty('test', true); + }); + + it('"meta" should combine all metadata', () => { + const msg = new LogMessage({ ...logData.withMeta }, { + ...defaultOpts, + meta: { foo: 'bar' }, + dynamicMeta: () => ({ dynamic: 'meta' }) + }); + + expect(msg.meta).toHaveProperty('test', true); + expect(msg.meta).toHaveProperty('foo', 'bar'); + expect(msg.meta).toHaveProperty('dynamic', 'meta'); + }); + + it('"meta" should call dynamicMeta', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + dynamicMeta(cls, opts) { + expect(this).toBeInstanceOf(LogMessage); + expect(cls).toBeInstanceOf(LogMessage); + expect(typeof opts).toBe('object'); + return { dynamic: 'meta' }; + } + }); + + expect(msg.meta).toHaveProperty('dynamic', 'meta'); + }); + + it('"meta" should skip dynamicMeta if function does not return an object', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + dynamicMeta: () => false + }); + + expect(msg.meta).toBeEmptyObject(); + }); + + it('"meta" should convert errors in metadata to object', () => { + const msg = new LogMessage({ ...logData.withMeta }, { + ...defaultOpts, + meta: { + foo: 'bar', + prop: new Error('meta error'), + obj: { not: 'an error' } + } + }); + + expect(msg.meta.prop).toBeInstanceOf(Error); + expect(msg.meta.foo).toBe('bar'); + expect(msg.meta.obj).toBeObject(); + }); + + it('"tags" should return tags array', () => { + const msg = new LogMessage({ ...logData.withTags }, defaultOpts); + + expect(msg.tags).toBeArrayIncludingOnly(['test']); + }); + + it('"tags" should combine all tags', () => { + const msg = new LogMessage({ ...logData.withTags }, { + ...defaultOpts, + tags: ['global'] + }); + + expect(msg.tags).toHaveLength(2); + expect(msg.tags).toBeArrayIncludingOnly(['test', 'global']); + }); + + it('"tags" should execute functions', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + tags: [function (this: typeof LogMessage, props) { + expect(this).toBeInstanceOf(LogMessage); + expect(props).toBeObject(); + expect(props).toHaveProperty('level', 'info'); + expect(props).toHaveProperty('meta', {}); + expect(props).toHaveProperty('options'); + + return 'dynamic'; + }] + }); + + const { tags } = msg; + + expect(tags).toHaveLength(1); + expect(tags).toContain('dynamic'); + }); + + it('"tags" should skip values that are falsy', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + tags: [function () { + return false; + }] + }); + + const { tags } = msg; + + expect(tags).toHaveLength(0); + }); + + it('"tags" should replace <> variable with log level', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + tags: ['', '<>', '<>'] + }); + + const { tags } = msg; + + expect(tags).toHaveLength(3); + expect(tags).toBeArrayIncludingOnly(['', 'info', '<>']); + }); + + it('"tags" should not combine global tags if not an array', () => { + const msg = new LogMessage({ ...logData.withTags }, { + ...defaultOpts, + tags: 'invalid' as any + }); + + expect(msg.tags).toBeArrayIncludingOnly(['test']); + }); + + it('"value" should return log object', () => { + const msg = new LogMessage({ ...logData.info, meta: { test: true }, tags: ['test'] }, defaultOpts); + const { value } = msg; + + expect(value).toHaveProperty('_logLevel', 'info'); + expect(value).toHaveProperty('msg', 'info test'); + expect(value).toHaveProperty('test', true); + expect(value).toHaveProperty('_tags', ['test']); + }); + + it('"value" should change key names based on options', () => { + const msg = new LogMessage({ ...logData.info, meta: { test: true }, tags: ['test'] }, { + ...defaultOpts, + levelKey: 'lvl', + messageKey: 'message', + tagsKey: 'tags' + }); + const { value } = msg; + + expect(value).toHaveProperty('lvl', 'info'); + expect(value).toHaveProperty('message', 'info test'); + expect(value).toHaveProperty('test', true); + expect(value).toHaveProperty('tags', ['test']); + }); + + it('"value" should hide level and tags when options is set to falsy value', () => { + const msg = new LogMessage({ ...logData.info, tags: ['test'] }, { + ...defaultOpts, + levelKey: false, + tagsKey: false, + messageKey: undefined + }); + const { value } = msg; + + expect(value).not.toHaveProperty('__level'); + expect(value).toHaveProperty('msg', 'info test'); + expect(value).not.toHaveProperty('__tags'); + }); + + it('"value" should default level and tags key when set to null or undefined', () => { + const msg = new LogMessage({ ...logData.info, tags: ['test'] }, { + ...defaultOpts, + levelKey: undefined, + tagsKey: undefined + }); + const { value } = msg; + + expect(value).toHaveProperty('__level'); + expect(value).toHaveProperty('__tags'); + }); + + it('"value" should compile the log with `onCompile` is set in the options', () => { + const msg = new LogMessage({ ...logData.info, meta: { test: true }, tags: ['test'] }, { + ...defaultOpts, + onCompile(level, msg, meta, tags) { + return { + testLevel: level, + testMsg: msg, + testMeta: meta, + testTags: tags + }; + } + }); + + const { value } = msg; + + expect(value).toHaveProperty('testLevel', 'info'); + expect(value).toHaveProperty('testMsg', 'info test'); + expect(value).toHaveProperty('testMeta', { test: true }); + expect(value).toHaveProperty('testTags', ['test']); + }); + + it('"log" should match "value"', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(msg.log).toStrictEqual(msg.value); + }); + + it('"throw" should throw the error passed to the log', () => { + const msg = new LogMessage({ ...logData.error }, defaultOpts); + + expect(() => msg.throw).toThrow('error test'); + }); + + it('"throw" should include access to LogMessage instance in thrown error', () => { + const msg = new LogMessage({ ...logData.error }, defaultOpts); + + try { + return msg.throw; + } catch(err: unknown) { + expect((err as StubbedError).log).toBeInstanceOf(LogMessage); + } + }); + + it('"throw" should throw a new error for non-errors passed to the log', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(() => msg.throw).toThrow('info test'); + }); + }); + + + describe('Setters', () => { + it('set "msg" should overwrite the log message', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + msg.msg = 'test'; + + expect(msg.msg).toBe('test'); + }); + + it('set "msg" should utilize `onParse` if set in the options', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + onParse(msg) { + return { msg: `${msg as string} custom`, meta: { test: 'meta' }, tags: ['custom'], error: new Error('custom error') }; + } + }); + + expect(msg.msg).toBe('info test custom'); + expect(msg.meta).toHaveProperty('test', 'meta'); + expect(msg.tags).toHaveLength(1); + expect(msg.tags).toContain('custom'); + expect(msg.__error).toBeInstanceOf(Error); + expect((msg.__error!).message).toBe('custom error'); + }); + + it('set "msg" should skip `onParse` if the custom function does not return an object', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + onParse() { + return false; + } + }); + + expect(msg.msg).toBe('info test'); + expect(msg.tags).toHaveLength(0); + expect(msg.__error).toBe(null); + }); + + it('set "msg" should json stringify a message that is an object', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + msg.msg = { test: true } as any; + + expect(msg.msg).toBe('{"test":true}'); + }); + + it('set "msg" should return empty string if message is null or undefined', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + msg.msg = null as any; + expect(msg.msg).toBe(''); + + msg.msg = undefined as any; + expect(msg.msg).toBe(''); + }); + + it('set "message" should overwrite the log message', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + msg.message = 'test'; + + expect(msg.message).toBe('test'); + }); + + it('set "meta" should add additional properties to the log message', () => { + const msg = new LogMessage({ ...logData.info, meta: { custom: 123 } }, defaultOpts); + msg.meta = { prop: 'test' }; + + expect(msg.meta).toHaveProperty('prop', 'test'); + expect(msg.meta).toHaveProperty('custom', 123); + }); + + it('set "tags" should add new tags to the log message', () => { + const msg = new LogMessage({ ...logData.info, tags: ['test'] }, defaultOpts); + msg.tags = ['another-tag']; + + expect(msg.tags).toHaveLength(2); + expect(msg.tags).toBeArrayIncludingOnly(['another-tag', 'test']); + }); + }); + + describe('Methods', () => { + describe('toString()', () => { + it('should return log in string format', () => { + const msg = new LogMessage({ ...logData.info }, defaultOpts); + + expect(msg.toString()).toBeString(); + }); + + it('should format as json with onFormat set to `json`', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + onFormat: formatters.json() + }); + + expect(msg.toString()).toBeJsonString(); + }); + + it('should format with "full" template with onFormat set to `full`', () => { + const msg = new LogMessage({ ...logData.info, tags: ['test'] }, { + ...defaultOpts, + onFormat: formatters.full() + }); + + expect(msg.toString()).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)\tINFO\tinfo test\n→ test/); + }); + + it('should not add tags when none are set in `full` formatter', () => { + const msg = new LogMessage({ ...logData.info, meta: { test: 123 } }, { + ...defaultOpts, + onFormat: formatters.full() + }); + + expect(msg.toString()).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)\tINFO\tinfo test\n→ \{/); + }); + + it('should format with "minimal" template with onFormat set to `minimal`', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + onFormat: formatters.minimal() + }); + + expect(msg.toString()).toMatch(/^INFO | info test$/); + }); + + it('should format with a custom `onFormat` function', () => { + const msg = new LogMessage({ ...logData.info }, { + ...defaultOpts, + tags: ['tag1', 'tag2'], + onFormat(message: LogMessage, opts, stringify) { + expect(stringify).toBeInstanceOf(Function); + + return `custom ${message.msg} ${opts.tags ? opts.tags.join('|') : ''}`; + } + }); + + expect(msg.toString()).toBe('custom info test tag1|tag2'); + }); + }); + }); +}); diff --git a/src/LogMessage.ts b/src/LogMessage.ts new file mode 100644 index 0000000..d31f124 --- /dev/null +++ b/src/LogMessage.ts @@ -0,0 +1,298 @@ +import stringify from 'fast-safe-stringify'; +import { LambdaLogOptions, Message, LogObject, Tag, GenericRecord, StubbedError, Empty, FormatPlugin } from './typings.js'; +import { isError, stubError } from './utils.js'; +import jsonFormatter from './formatters/json.js'; + +export interface ILogMessage { + readonly __opts: LambdaLogOptions; + __level: string; + __msg: string; + __meta: GenericRecord; + __tags: Tag[]; + __error: Error | StubbedError | Empty; + + get level(): string; + get msg(): string; + set msg(msg: Message); + get message(): string; + set message(msg: Message); + get meta(): GenericRecord; + set meta(obj: GenericRecord); + get tags(): string[]; + set tags(tags: Tag[]); + get value(): GenericRecord; + get log(): GenericRecord; + get throw(): void; +} + +/** + * The LogMessage class is a private/internal class that is used for generating log messages. All log methods return an instance of LogMessage allowing for a chainable api. + * Having a seperate class and instance for each log allows chaining and the ability to further customize this module in the future without major breaking changes. The documentation + * provided here is what is available to you for each log message. + */ +export default class LogMessage implements ILogMessage { + readonly __opts: LambdaLogOptions = {}; + __level: string; + __msg = ''; + __meta: GenericRecord = {}; + __tags: Tag[] = []; + __error: Error | null | undefined = null; + + + /** + * Constructor for LogMessage + * @param {LogObject} log The log object to construct the LogMessage from. + * @param {LambdaLogOptions} opts The options for LambdaLog. + * @class + */ + constructor(log: LogObject, opts: LambdaLogOptions) { + // LambdaLog options + this.__opts = opts; + // Log level + this.__level = log.level; + + // Compile log metadata + if(log.meta) { + this.__meta = typeof log.meta !== 'object' || Array.isArray(log.meta) ? { meta: log.meta } : log.meta; + } + + // Compile log tags + if(log.tags && Array.isArray(log.tags)) { + this.__tags = log.tags; + } + + // Compile log message + this.setMessage(log.msg); + } + + /** + * String log level of the message. + * @returns {string} The log level. + */ + get level() { + return this.__level; + } + + /** + * The message for the log. If an Error was provided, it will be the message of the error. + * @returns {string} The message for the log. + */ + get msg(): string { + return this.__msg; + } + + /** + * Update the message for this log to something else. + * @param {Message} msg The new message for this log. + */ + set msg(msg: Message) { + this.setMessage(msg); + } + + /** + * Alias for `this.msg`. + * @returns {string} The message for the log. + */ + get message(): string { + return this.msg; + } + + /** + * Alias for `this.msg = 'New message';` + * @param {Message} msg The new message for this log. + */ + set message(msg: Message) { + this.msg = msg; + } + + /** + * The fully compiled metadata object for the log. Includes global and dynamic metadata. + * @returns {GenericRecord} The metadata object. + */ + get meta(): GenericRecord { + const { __opts, __meta } = this; + + let meta: GenericRecord = { + ...__meta, + ...__opts.meta + }; + + if(typeof __opts.dynamicMeta === 'function') { + const dynMeta = __opts.dynamicMeta.call(this, this, __opts); + + if(typeof dynMeta === 'object') { + meta = { ...meta, ...dynMeta }; + } + } + + for(const [key, val] of Object.entries(meta)) { + if(typeof val !== 'object' || !val) continue; + if(isError(val)) { + meta[key] = stubError(val as Error); + } + } + + return meta; + } + + /** + * Set additional metadata on the log message. + * @param {GenericRecord} obj The metadata to add to the log. + */ + set meta(obj) { + this.__meta = { + ...this.__meta, + ...obj + }; + } + + /** + * Array of tags attached to this log. Includes global tags. + * @returns {Tag[]} The tags attached to this log. + */ + get tags(): string[] { + const { __opts, __tags } = this; + let tags = [...__tags]; + + if(Array.isArray(__opts.tags)) { + tags = [...__opts.tags, ...tags]; + } + + return tags.map(tag => { + if(typeof tag === 'function') { + const tagRes = tag.call(this, { + level: this.level, + meta: this.meta, + options: __opts + }); + + if(tagRes) tag = tagRes; + } + + if(typeof tag === 'string') { + tag = tag.replace(/(<<([a-z0-9_]+)>>)/gi, (_, tplVar: string, key: string) => { + if(key === 'level') return this.level; + return tplVar; + }); + } + + return tag; + }).filter(tag => typeof tag === 'string' && tag) as string[]; + } + + /** + * Appends additional tags to this log message. + * @param {Tag[]} tags The tags to add to this log. + */ + set tags(tags: Tag[]) { + this.__tags = [...this.__tags, ...tags]; + } + + /** + * The log represented as an object that is useful for stringifying or pulling data from. + * @returns {GenericRecord} The compiled log object. + */ + get value() { + const { __opts } = this; + + if(typeof __opts.onCompile === 'function') { + return __opts.onCompile.call(this, this.level, this.msg, this.meta, this.tags, __opts); + } + + let log: GenericRecord = {}; + if(__opts.levelKey !== false) log[__opts.levelKey ?? '__level'] = this.level; + log[__opts.messageKey ?? 'msg'] = this.msg; + log = { ...log, ...this.meta }; + if(__opts.tagsKey !== false) log[__opts.tagsKey ?? '__tags'] = this.tags; + + return log; + } + + /** + * Alias of `logMessage.value`. + * @returns {GenericRecord} The compiled log object. + */ + get log() { + return this.value; + } + + /** + * Throws the log. If an error was not provided, one will be generated for you and thrown. This is useful in cases where you need to log an + * error, but also throw it. + * @throws {Error} The provided error, or a newly generated error. + */ + get throw() { + const err = (this.__error ?? new Error(this.msg)) as StubbedError; + err.log = this; + + throw err; + } + + /** + * Converts the log to a string using a specific/custom formatter. + * @returns {string} The formatted log as a string. + */ + toString(): string { + return this.formatMessage(this.__opts.onFormat); + } + + /** + * Converts the log to a string using a specific/custom formatter. + * @protected + * @param {FormatPlugin} [formatter] The formatter to use or custom formatter function. + * @returns {string} The formatted log as a string. + */ + protected formatMessage(formatter?: FormatPlugin): string { + const { __opts } = this; + + if(!formatter || typeof formatter !== 'function') { + formatter = jsonFormatter(); + } + + return formatter.call(this, this, __opts, stringify); + } + + /** + * Takes a log message, parses it, and sets any corresponding properties on the log. + * @protected + * @param {Message} message The incoming message. + */ + protected setMessage(message: Message) { + // Compile log message + const { msg, meta, error, tags } = this.parseMessage(message); + this.__msg = msg; + + // Add any meta, error object, or tags derrived from the message + if(meta) this.__meta = { ...this.__meta, ...meta }; + if(error) this.__error = error; + if(tags) this.__tags = [...this.__tags, ...tags]; + } + + /** + * Parses a log message and returns any meta, error object, or tags derrived from the message. + * @protected + * @param {Message} msg The incoming message. + * @returns {object} Object continaing msg, meta, error, and tags. + */ + protected parseMessage(msg: Message): { msg: string; meta?: GenericRecord; error?: Error; tags?: Tag[] } { + const { __opts } = this; + + if(typeof __opts.onParse === 'function') { + const res = __opts.onParse.call(this, msg, __opts); + if(res && typeof res === 'object') return res; + } + + if(msg === null || msg === undefined) return { msg: '' }; + + if(isError(msg)) { + const err = msg as Error; + return { msg: err.message, meta: { stack: err.stack }, error: err }; + } + + if(typeof msg === 'object') { + return { msg: JSON.stringify(msg) }; + } + + return { msg: msg.toString() }; + } +} diff --git a/src/formatters/full.spec.ts b/src/formatters/full.spec.ts new file mode 100644 index 0000000..8136de0 --- /dev/null +++ b/src/formatters/full.spec.ts @@ -0,0 +1,64 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import fullFormatter from './full'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +describe('formatters/full', () => { + it('should export a closure function', () => { + expect(typeof fullFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof fullFormatter()).toBe('function'); + }); + + it('should overrride configuration', () => { + const formatter = fullFormatter({ + includeTimestamp: false, + includeTags: false, + includeMeta: false, + separator: '\t', + inspectOptions: { + colors: false, + maxArrayLength: 25 + } + }); + + const cfg = formatter._cfg!; + + expect(cfg.includeTimestamp).toBe(false); + expect(cfg.includeTags).toBe(false); + expect(cfg.includeMeta).toBe(false); + expect(cfg.separator).toBe('\t'); + expect(cfg.inspectOptions).toEqual({ + depth: Infinity, + colors: false, + maxArrayLength: 25 + }); + }); + + it('should skip the timestamp if includeTimestamp is false', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = fullFormatter({ + includeTimestamp: false + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^INFO\tinfo test$/); + }); +}); diff --git a/src/formatters/full.ts b/src/formatters/full.ts new file mode 100644 index 0000000..01b9bf4 --- /dev/null +++ b/src/formatters/full.ts @@ -0,0 +1,62 @@ +import { FormatPlugin } from '../typings.js'; +import { inspect, InspectOptions } from 'util'; + +type FullFormatterCfg = { + includeTimestamp?: boolean; + formatTimestamp?: (timestamp: Date) => string; + includeTags?: boolean; + includeMeta?: boolean; + separator?: string; + inspectOptions?: InspectOptions; +}; + +/** + * Full formatter for log messages. + * @param {object} cfg Configuration object for the formatter. + * @returns {FormatPlugin} The full formatter function. + */ +export default function fullFormatter(cfg: FullFormatterCfg = {}): FormatPlugin { + const fmCfg = { + includeTimestamp: true, + formatTimestamp: (timestamp: Date) => timestamp.toISOString(), + includeTags: true, + includeMeta: true, + separator: '\t', + ...cfg + }; + + fmCfg.inspectOptions = { + depth: Infinity, + colors: true, + ...(fmCfg.inspectOptions ?? {}) + }; + + const fullFmt: FormatPlugin = (ctx): string => { + const msg = []; + if(fmCfg.includeTimestamp) { + msg.push(fmCfg.formatTimestamp(new Date())); + } + + msg.push(ctx.level.toUpperCase(), ctx.msg); + + const parts = [ + msg.join(fmCfg.separator) + ]; + + if(fmCfg.includeTags && ctx.tags.length) { + const tags = ctx.tags.map(tag => `${tag}`).join(', '); + parts.push(`→ ${tags}`); + } + + if(fmCfg.includeMeta && Object.keys(ctx.meta).length) { + const meta = inspect(ctx.meta, fmCfg.inspectOptions!); + parts.push(`→ ${meta.replace(/\n/g, '\n ')}`); + } + + return parts.join('\n'); + }; + + fullFmt._cfg = fmCfg; + + return fullFmt; +} diff --git a/src/formatters/index.ts b/src/formatters/index.ts new file mode 100644 index 0000000..2e0156a --- /dev/null +++ b/src/formatters/index.ts @@ -0,0 +1,3 @@ +export { default as json } from './json.js'; +export { default as full } from './full.js'; +export { default as minimal } from './minimal.js'; diff --git a/src/formatters/json.spec.ts b/src/formatters/json.spec.ts new file mode 100644 index 0000000..4607a6f --- /dev/null +++ b/src/formatters/json.spec.ts @@ -0,0 +1,81 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import jsonFormatter from './json'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +const logObject = { + level: 'info', + msg: 'info test', + meta: {}, + tags: [] +}; + + +describe('formatters/json', () => { + it('should export a closure function', () => { + expect(typeof jsonFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof jsonFormatter()).toBe('function'); + }); + + const replacerOpts = { + ...defaultOpts, + meta: { ssn: '444-55-6666' }, + replacer(key: string, value: unknown) { + if(key === 'ssn') { + return `${(value as string).substring(0, 3)}-**-****`; + } + + return value; + } + }; + + const msg = new LogMessage({ ...logObject }, replacerOpts); + + const formatter = jsonFormatter(); + const result = formatter(msg, replacerOpts, stringify); + + it('should return log in JSON format', () => { + expect(result).toBeJsonString(); + }); + + it('should run replacer function', () => { + expect(JSON.parse(result).ssn).toBe('444-**-****'); + }); + + it('should not run replacer function when not defined', () => { + const msgNoReplacer = new LogMessage({ ...logObject }, { + ...defaultOpts, + meta: { ssn: '444-55-6666' } + }); + + const noReplacerResult = formatter(msgNoReplacer, defaultOpts, stringify); + + expect(JSON.parse(noReplacerResult).ssn).toBe('444-55-6666'); + }); + + it('should pretty print JSON when dev is "true"', () => { + const opts = { + ...defaultOpts, + dev: true, + meta: { ssn: '444-55-6666' } + }; + + const msgDev = new LogMessage({ ...logObject }, opts); + const prettyResult = formatter(msgDev, opts, stringify); + + expect(/\n/g.test(prettyResult)).toBe(true); + }); +}); diff --git a/src/formatters/json.ts b/src/formatters/json.ts new file mode 100644 index 0000000..24e1551 --- /dev/null +++ b/src/formatters/json.ts @@ -0,0 +1,12 @@ +import { FormatPlugin } from '../typings.js'; + +/** + * JSON formatter for log messages. + * @returns {FormatPlugin} The JSON formatter function. + */ +export default function jsonFormatter(): FormatPlugin { + const jsonFmt: FormatPlugin = (ctx, options, stringify): string => + stringify(ctx.value, options.replacer ?? undefined, options.dev ? 2 : 0); + + return jsonFmt; +} diff --git a/src/formatters/minimal.spec.ts b/src/formatters/minimal.spec.ts new file mode 100644 index 0000000..f851f45 --- /dev/null +++ b/src/formatters/minimal.spec.ts @@ -0,0 +1,73 @@ +import 'expect-more-jest'; +import stringify from 'fast-safe-stringify'; +import LogMessage from '../LogMessage'; +import minimalFormatter from './minimal'; + +const defaultOpts = { + meta: {}, + dynamicMeta: null, + tags: [], + levelKey: '_logLevel', + messageKey: 'msg', + tagsKey: '_tags', + replacer: null +}; + +describe('formatters/minmal', () => { + it('should export a closure function', () => { + expect(typeof minimalFormatter).toBe('function'); + }); + + it('should return a formatter function', () => { + expect(typeof minimalFormatter()).toBe('function'); + }); + + it('should overrride configuration', () => { + const formatter = minimalFormatter({ + includeTimestamp: false, + separator: '\t' + }); + + const cfg = formatter._cfg!; + + expect(cfg.includeTimestamp).toBe(false); + expect(cfg.separator).toBe('\t'); + }); + + it('should format timestamp as ISO string by default', () => { + const formatter = minimalFormatter(); + + // @ts-expect-error - we're testing the internals here + expect((formatter._cfg!).formatTimestamp(new Date())).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)$/); + }); + + it('should include the timestamp if includeTimestamp is true', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = minimalFormatter({ + includeTimestamp: true + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z) | INFO | info test$/); + }); + + it('should skip the timestamp if includeTimestamp is false', () => { + const msg = new LogMessage({ + level: 'info', + msg: 'info test', + meta: {}, + tags: [] + }, defaultOpts); + + const formatter = minimalFormatter({ + includeTimestamp: false + }); + + expect(formatter(msg, defaultOpts, stringify)).toMatch(/^INFO | info test$/); + }); +}); diff --git a/src/formatters/minimal.ts b/src/formatters/minimal.ts new file mode 100644 index 0000000..28c7cb6 --- /dev/null +++ b/src/formatters/minimal.ts @@ -0,0 +1,36 @@ +import { FormatPlugin } from '../typings.js'; + +type MinimalFormatterCfg = { + includeTimestamp?: boolean; + formatTimestamp?: (timestamp: Date) => string; + separator?: string; +}; + +/** + * Minimal formatter for log messages. + * @param {object} cfg Configuration object for the formatter. + * @returns {FormatPlugin} The minimal formatter function. + */ +export default function minimalFormatter(cfg: MinimalFormatterCfg = {}): FormatPlugin { + const fmCfg = { + includeTimestamp: false, + formatTimestamp: (timestamp: Date) => timestamp.toISOString(), + separator: ' | ', + ...cfg + }; + + const minimalFmt: FormatPlugin = (ctx): string => { + const parts = []; + if(fmCfg.includeTimestamp) { + parts.push(fmCfg.formatTimestamp(new Date())); + } + + parts.push(ctx.level.toUpperCase(), ctx.msg); + + return parts.join(fmCfg.separator); + }; + + minimalFmt._cfg = fmCfg; + + return minimalFmt; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0b016ec --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import lambdaLog from './lambda-log.js'; + +export default lambdaLog; diff --git a/index.test.js b/src/lambda-log.spec.ts similarity index 53% rename from index.test.js rename to src/lambda-log.spec.ts index 9083681..5fcb9ab 100644 --- a/index.test.js +++ b/src/lambda-log.spec.ts @@ -1,5 +1,6 @@ -const log = require('.'); -const LambdaLog = require('./lib/LambdaLog'); +import log from './lambda-log'; +import LambdaLog from './LambdaLog'; +import LogMessage from './LogMessage'; describe('Default Export', () => { test('returns an instance of the LambdaLog class', () => { @@ -9,4 +10,8 @@ describe('Default Export', () => { test('to have access to uninstantiated LambdaLog class', () => { expect(log.LambdaLog).toEqual(LambdaLog); }); + + test('to have access to uninstantiated LogMessage class', () => { + expect(log.LogMessage).toEqual(LogMessage); + }); }); diff --git a/src/lambda-log.ts b/src/lambda-log.ts new file mode 100644 index 0000000..588a3cf --- /dev/null +++ b/src/lambda-log.ts @@ -0,0 +1,7 @@ +import LambdaLog from './LambdaLog.js'; +import LogMessage from './LogMessage.js'; +import * as formatters from './formatters/index.js'; +import * as Types from './typings.js'; + +export default new LambdaLog(); +export { LambdaLog, LogMessage, formatters, Types }; diff --git a/src/typings.ts b/src/typings.ts new file mode 100644 index 0000000..c883826 --- /dev/null +++ b/src/typings.ts @@ -0,0 +1,66 @@ +import LogMessage from './LogMessage.js'; +import stringify from 'fast-safe-stringify'; + +export type GenericRecord = Record; +export type Message = string | number | Error; +export type Metadata = GenericRecord | string | number | null | undefined; +export type Empty = false | null | undefined; + +type TagFnObject = { + level: string; + meta: GenericRecord; + options: LambdaLogOptions; +}; + +export type Tag = string | number | ((data: TagFnObject) => string | Empty) | Empty; + +type StringifyType = typeof stringify; + +export type LogObject = { + level: string; + msg: T; + meta?: Metadata; + tags?: Tag[]; +}; + +export type LogLevels = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; + +export type ParsePlugin = (msg: Message, options: LambdaLogOptions) => { msg: string; meta?: GenericRecord; error?: Error; tags?: Tag[] } | Empty; +export type CompilePlugin = (level?: string, msg?: Message, meta?: GenericRecord, tags?: Tag[], options?: LambdaLogOptions) => GenericRecord; +export type FormatPlugin = { + (ctx: LogMessage, options: LambdaLogOptions, stringify: StringifyType): string; + _cfg?: Record; +}; + +export type LambdaLogOptions = { + [key: string]: any; + meta?: GenericRecord; + tags?: Tag[]; + dynamicMeta?: ((ctx: LogMessage, options: LambdaLogOptions) => GenericRecord | Empty) | Empty; + level?: false | LogLevels; + dev?: boolean; + silent?: boolean; + replacer?: ((key: string, val: any) => any) | null | undefined; + logHandler?: ConsoleObject; + levelKey?: string | false; + messageKey?: string; + tagsKey?: string | false; + onParse?: ParsePlugin; + onCompile?: CompilePlugin; + onFormat?: FormatPlugin; +}; + +export interface ConsoleObject extends Console { + [key: string]: any; + log: (...params: any) => void; + info: (...params: any) => void; + warn: (...params: any) => void; + error: (...params: any) => void; + debug: (...params: any) => void; +} + +export interface StubbedError extends Error { + [key: string]: any; + log?: LogMessage; + toJSON?: () => GenericRecord; +} diff --git a/src/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 0000000..c92c220 --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,93 @@ +import { StubbedError } from './typings'; +import { isError, stubError, toBool } from './utils'; + +interface TestError extends StubbedError { + test?: boolean; +} + +describe('utils', () => { + describe('isError()', () => { + it('should return true for an Error', () => { + expect(isError(new Error('test'))).toBe(true); + }); + + it('should return true for an Error-like object', () => { + expect(isError({ + message: 'test', + stack: 'stack' + })).toBe(true); + }); + + it('should return false for anything else', () => { + expect(isError(null)).toBe(false); + expect(isError(true)).toBe(false); + expect(isError(123)).toBe(false); + expect(isError('A string')).toBe(false); + expect(isError({ message: 'test' })).toBe(false); + expect(isError([1, 2, 3, 'test'])).toBe(false); + expect(isError({ stack: 'stack' })).toBe(false); + }); + }); + + describe('stubError()', () => { + it('should add a toJSON method to an error', () => { + const err = stubError(new Error('test')); + expect(err).toHaveProperty('toJSON'); + expect(typeof err.toJSON).toBe('function'); + }); + + it('calling toJSON should create an serializable object', () => { + const e = new Error('test') as TestError; + e.test = true; + const err = stubError(e); + const result = err.toJSON!(); + + expect(typeof result).toBe('object'); + expect(result).toHaveProperty('name', 'Error'); + expect(result).toHaveProperty('message', 'test'); + expect(result).toHaveProperty('stack', err.stack); + expect(result).toHaveProperty('test', true); + }); + + it('should skip custom toJSON fn if error already has one', () => { + const e = new Error('test'); + const noop = () => { /* noop */ }; + Object.defineProperty(e, 'toJSON', { + value: noop + }); + + const error = stubError(e); + expect(error.toJSON!.toString()).toBe(noop.toString()); + }); + + it('should skip over keys that are not on the Error instance', () => { + const e = new Error('test'); + delete e.stack; + const err = stubError(e); + expect(err).not.toHaveProperty('stack'); + }); + }); + + describe('toBool()', () => { + it.each([ + ['true', true], + ['false', false], + ['1', true], + ['0', false], + ['yes', true], + ['no', false], + ['y', true], + ['n', false], + ['on', true], + ['off', false], + ['', false], + ['test', false], + [null, false], + [undefined, false], + [1, true], + [0, false] + ])('should accept "%s" and return %p', (input, expected) => { + expect(toBool(input as any)).toBe(expected); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9ed5d81 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,65 @@ +import { GenericRecord, StubbedError } from './typings.js'; + +/** + * Checks if value is an Error or Error-like object + * @param {any} val The value to check + * @returns {boolean} Whether the value is an Error or Error-like object + */ +export function isError(val: unknown) { + return Boolean(val) && typeof val === 'object' && ( + val instanceof Error || ( + Object.prototype.hasOwnProperty.call(val, 'message') && + Object.prototype.hasOwnProperty.call(val, 'stack') + ) + ); +} + +/** + * Stubs an Error or Error-like object to include a toJSON method. + * @param {Error} error The error to stub. + * @returns {StubbedError} The original error object with a toJSON method. + */ +export function stubError(error: Error) { + const err = error as StubbedError; + + if(typeof err.toJSON === 'function') return err; + + err.toJSON = function () { + const keys = [ + 'name', + 'message', + 'stack' + ].concat(Object.keys(err)); + + return keys.reduce((obj: GenericRecord, key: string) => { + /* istanbul ignore next */ + if(key in err) { + const val: unknown = err[key]; + if(typeof val !== 'function') { + obj[key] = val; + } + } + + return obj; + }, {}); + }; + + return err; +} + +/** + * Converts a string or number value to a boolean. + * @param {string|number|boolean} val The value to convert. + * @returns {boolean} The converted value as a boolean. + */ +export function toBool(val: string | number | boolean) { + if(typeof val === 'string') { + return ['true', 'yes', 'y', 'on', '1'].includes(val.toLowerCase()); + } + + if(typeof val === 'number') { + return val === 1; + } + + return Boolean(val); +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..a785379 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "*.ts", + "*.js", + "src/**/*.ts" + ], + "compilerOptions": { + "types": ["jest", "node"] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5c187ad --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "include": [ + "src/index.ts" + ], + "compilerOptions": { + "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], + "target": "es2019", + "module": "commonjs", + "moduleResolution": "node", + "types": ["node"], + + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "suppressImplicitAnyIndexErrors": true, + "declaration": false, + "outDir": "./dist" + } +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..0860d46 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "types": ["jest", "node"], + "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], + "target": "es2019", + "module": "commonjs", + + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "suppressImplicitAnyIndexErrors": true, + "declaration": false + }, + "include": [ + "src/*.spec.ts" + ] +}