diff --git a/docs/DOCS.md b/docs/DOCS.md index 1b70ff7..edc058a 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -12,4 +12,5 @@ The documentation is organized into sections by module. 1. [SQS Client](/docs/services/SQS.md) 1. [Cognito Client](/docs/services/COGNITO.md) 1. [Email Service](/docs/services/EMAIL.md) +1. [Logging](/docs/utils/LOGGING.md) 1. [Errors](/docs/errors/ERRORS.md) diff --git a/docs/services/CONFIG.md b/docs/services/CONFIG.md index 73a5053..e740d99 100644 --- a/docs/services/CONFIG.md +++ b/docs/services/CONFIG.md @@ -28,7 +28,7 @@ This ready to use object is of type [`LambdaConfig`](/src/services/config.servic // some-component.ts import { lambdaConfigValues as config } from '@leanstacks/serverless-common'; -console.log(`The region is ${config.AWS_REGON}`); +Logger.debug(`The region is ${config.AWS_REGON}`); ``` ## Extending `LambdaConfig` with custom configuration attributes @@ -69,7 +69,7 @@ serverless component. For example... // some-component.ts import config from 'my-config'; -console.log(`The table name is ${config.TABLE_NAME}`); +Logger.debug(`The table name is ${config.TABLE_NAME}`); ``` ## Performance considerations diff --git a/docs/services/SQS.md b/docs/services/SQS.md index 193e5a2..34583d4 100644 --- a/docs/services/SQS.md +++ b/docs/services/SQS.md @@ -38,7 +38,8 @@ async send(task: Task): Promise { QueueUrl: config.TASK_QUEUE_URL, MessageBody: JSON.stringify(task); }); - console.log(`sent message ${output.MessageId} to queue`); + + Logger.debug(`sent message ${output.MessageId} to queue`); } ``` diff --git a/docs/utils/LOGGING.md b/docs/utils/LOGGING.md new file mode 100644 index 0000000..b17a089 --- /dev/null +++ b/docs/utils/LOGGING.md @@ -0,0 +1,114 @@ +:house: [Home](/README.md) | :books: [Docs](../DOCS.md) + +# Logging + +This document describes the `Logger` component from the `serverless-common` package. + +## How it works + +The `Logger` component is the preferred way to write logs from serverless applications. It uses the popular [winston](https://www.npmjs.com/package/winston) logger package to write messages from your serverless functions. These messages are automatically captured in AWS CloudWatch logs. + +## Using the `Logger` + +To use the logger in any component, first import it. + +```ts +import { Logger } from '@leanstacks/serverless-common'; +``` + +The `Logger` is the actual `winston` Logger that has been configured with some sensible defaults. To use the `Logger` in your code do something like this... + +```ts +Logger.info('my message'); +``` + +That will emit a log message which looks like this... + +```json +{ + "level": "info", + "message": "my message", + "requestId": "AWS Lambda Request Identifier", + "timestamp": "2023-12-19T115:20:25.608Z" +} +``` + +Logs are written as JSON because it is easier to [query and filter AWS CloudWatch Logs which use JSON](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html#matching-terms-json-log-events). You may also establish CloudWatch metrics and alarms from CloudWatch Logs filters. + +### Including `data` in messages + +Often log messages contain data whether that is a simple primitive value or an object. Data should be logged in a standard and predictable way to facilitate log filtering and querying. Building upon the example above, we can log data like this... + +```ts +cont tasks = await TaskService.list(); +Logger.info('my message', { data: { tasks } }); +``` + +That will emit a log message which looks like this... + +```json +{ + "data": { + "tasks": [] + }, + "level": "info", + "message": "my message", + "requestId": "AWS Lambda Request Identifier", + "timestamp": "2023-12-19T115:20:25.608Z" +} +``` + +### Including an `error` in messages + +You may include Errors in log messages. Like with `data`, we should include the Error in a standardized way... + +```ts +const error = new Error('System Error'); +Logger.info('my messsage', error); +``` + +That will emit a log message which looks like this... + +```json +{ + "data": { + "tasks": [] + }, + "level": "info", + "message": "my message System Error", + "requestId": "AWS Lambda Request Identifier", + "stack": "full stack trace from error", + "timestamp": "2023-12-19T115:20:25.608Z" +} +``` + +## Configuring the `Logger` + +The default configuration of the `Logger` is: + +- Logging is enabled +- Level `info`, meaning info, warn, and error logging statments are written, but not debug +- Log messages are written as JSON for improved filtering and querying + +### Configuration Options + +| Key | Description | Default | +| ----------------- | --------------------------------------------------------------------- | ------- | +| `LOGGING_ENABLED` | Enable or disable logging altogether. | `true` | +| `LOGGING_LEVEL` | The lowest logging level. One of `debug`, `info`, `warn`, or `error`. | `info` | + +To modify the default configuration, for example to write `debug` messages to the log, simply overwrite the configuration in your serverless component environment variables such as: + +_Example serverless.yml file_ + +```yml +params: + default: + loggingLevel: info + dev: + loggingLevel: debug + +provider: + environment: + LOGGING_LEVEL: ${param:loggingLevel} +``` diff --git a/docs/utils/MIDDYFY.md b/docs/utils/MIDDYFY.md index b58a8b5..c404806 100644 --- a/docs/utils/MIDDYFY.md +++ b/docs/utils/MIDDYFY.md @@ -81,13 +81,15 @@ export const handle = middyfyAPIGateway({ handler }); The handler function appears much the same as what you may be writing already. So what is advantage? The magic happens in the `middyfyAPIGateway` function. This simple function wraps the handler in several middlewares. -First, the [`http-event-normalizer`](https://middy.js.org/docs/middlewares/http-event-normalizer) official Middy middleware ensures that all of the optional elements of an APIGatewayProxyEvent are defined even when empty. +The `logger-initializer` middleware adds AWS Lambda Context metadata to the logger so that all messages may be correlated to a specific Lambda function invocation. -Next, the [`http-json-body-parser`](https://middy.js.org/docs/middlewares/http-json-body-parser) official Middy middleware parses the HTTP request body converting it from a string of JSON into an object. +The [`http-event-normalizer`](https://middy.js.org/docs/middlewares/http-event-normalizer) official Middy middleware ensures that all of the optional elements of an APIGatewayProxyEvent are defined even when empty. -Then, the `validator-joi` middleware validates the APIGatewayProxyEvent with a Joi schema, when a schema is provided in the middleware options. +The [`http-json-body-parser`](https://middy.js.org/docs/middlewares/http-json-body-parser) official Middy middleware parses the HTTP request body converting it from a string of JSON into an object. -Finally, the `http-error-handler` middleware processes errors thrown from the handler function, creating a standardized response based upon the error type. +The `validator-joi` middleware validates the APIGatewayProxyEvent with a Joi schema, when a schema is provided in the middleware options. + +The `http-error-handler` middleware processes errors thrown from the handler function, creating a standardized response based upon the error type. ## Creating an API Gateway handler with event validation @@ -148,7 +150,7 @@ export const handler = async (event: ScheduledEvent, context: Context): Promise< await TaskService.sendReminders(); } catch (error) { - console.error('Failed to send task reminders. Detail:', error); + Logger.error('Failed to send task reminders. Detail:', error); throw new ServiceError(error); } }; @@ -163,7 +165,7 @@ import { middyfyScheduled } from '@leanstacks/serverless-common'; import { handler } from './handler'; -export const handle = middyfyScheduled({ handler, logger: console.log }); +export const handle = middyfyScheduled({ handler }); ``` ## Creating a SNS event handler @@ -190,7 +192,7 @@ export const handler = async (event: SNSEvent, context: Context): Promise const tasks:Task[] = await Promise.all(promises); } catch (error) { - console.error('Failed to create tasks. Detail:', error); + Logger.error('Failed to create tasks. Detail:', error); throw new ServiceError(error); } }; @@ -205,13 +207,15 @@ import { middyfySNS } from '@leanstacks/serverless-common'; import { handler } from './handler'; -export const handle = middyfySNS({ handler, logger: console.log }); +export const handle = middyfySNS({ handler }); ``` The handler is wrapped with two middlewares. +1. The `logger-initializer` middleware adds AWS Lambda Context metadata to the logger so that all messages may be correlated to a specific Lambda function invocation. + 1. The `event-normalizer` middleware performs a JSON parse on the `Message`. -2. The `validator` middleware will validate the event when an `eventSchema` is provided in the options. +1. The `validator` middleware will validate the event when an `eventSchema` is provided in the options. ## Creating a SQS event handler @@ -247,7 +251,7 @@ export const handler = async (event: SNSEvent, context: Context): Promise=0.1.90" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1361,6 +1373,17 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "dev": true, + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3462,6 +3485,12 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "dev": true + }, "node_modules/@types/yargs": { "version": "17.0.31", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.31.tgz", @@ -3890,6 +3919,12 @@ "node": ">=8" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "dev": true + }, "node_modules/aws-sdk-client-mock": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-3.0.0.tgz", @@ -4562,6 +4597,16 @@ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", "dev": true }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -4577,6 +4622,26 @@ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "dev": true, + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4839,6 +4904,12 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "dev": true + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -5356,6 +5427,12 @@ "bser": "2.1.1" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "dev": true + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5470,6 +5547,12 @@ "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", "dev": true }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "dev": true + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -7793,6 +7876,12 @@ "node": ">=6" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "dev": true + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7851,6 +7940,23 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/logform": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", + "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "dev": true, + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -8084,6 +8190,15 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dev": true, + "dependencies": { + "fn.name": "1.x.x" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -8471,6 +8586,20 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -8682,6 +8811,15 @@ } ] }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8727,6 +8865,21 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + }, "node_modules/sinon": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/sinon/-/sinon-14.0.2.tgz", @@ -8849,6 +9002,15 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8870,6 +9032,15 @@ "node": ">=8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9081,6 +9252,12 @@ "node": "*" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "dev": true + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -9114,6 +9291,15 @@ "node": ">=8.0" } }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "dev": true, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.0.3.tgz", @@ -9342,6 +9528,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9395,6 +9587,42 @@ "node": ">= 8" } }, + "node_modules/winston": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz", + "integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==", + "dev": true, + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.4.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.6.0.tgz", + "integrity": "sha512-wbBA9PbPAHxKiygo7ub7BYRiKxms0tpfU2ljtWzb3SjRjv5yl6Ozuy/TkXf00HTAt+Uylo3gSkNwzc4ME0wiIg==", + "dev": true, + "dependencies": { + "logform": "^2.3.2", + "readable-stream": "^3.6.0", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index ea09a70..b350cab 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "ts-jest": "^29.1.1", "ts-node": "^10.9.1", "tslib": "^2.6.2", - "typescript": "^5.2.2" + "typescript": "^5.2.2", + "winston": "^3.11.0" }, "peerDependencies": { "@aws-sdk/client-cognito-identity-provider": "^3.474.0", @@ -70,6 +71,7 @@ "@middy/http-event-normalizer": "^4.7.0", "@middy/http-json-body-parser": "^4.7.0", "http-status": "^1.7.3", - "joi": "^17.11.0" + "joi": "^17.11.0", + "winston": "^3.11.0" } } diff --git a/src/middlewares/__tests__/error-handler-http.test.ts b/src/middlewares/__tests__/error-handler-http.test.ts index de1a3ee..8e28977 100644 --- a/src/middlewares/__tests__/error-handler-http.test.ts +++ b/src/middlewares/__tests__/error-handler-http.test.ts @@ -1,5 +1,4 @@ import { Request } from '@middy/core'; -import { jest } from '@jest/globals'; import { HttpError, ServiceError } from '../../errors'; @@ -46,28 +45,6 @@ describe('HttpErrorHandler', () => { expect(request.response).not.toBeDefined(); }); - it('should write log when logger provided', () => { - type loggerFn = (msg: string) => void; - const mockLogger = jest.fn(); - const request: Request = { - ...requestFixture, - error: new HttpError('message', 404), - }; - delete request.response; - - const middleware = httpErrorHandler({ - defaultMessage: 'message', - defaultStatusCode: 500, - logger: mockLogger, - }); - - expect(middleware.onError).toBeDefined(); - - middleware.onError?.(request); - - expect(mockLogger).toHaveBeenCalled(); - }); - it('should handle HttpError', () => { const expectedResponse = { body: JSON.stringify({ name: 'HttpError', message: 'message', code: 500, statusCode: 500 }), diff --git a/src/middlewares/__tests__/logger-initializer.test.ts b/src/middlewares/__tests__/logger-initializer.test.ts new file mode 100644 index 0000000..50936c1 --- /dev/null +++ b/src/middlewares/__tests__/logger-initializer.test.ts @@ -0,0 +1,25 @@ +import { loggerInitializer } from '../logger-initializer'; + +import Logger from '../../utils/logger'; +import { requestFixture } from '../../__fixtures__/middy.fixture'; + +describe('loggerInitializer', () => { + it('should create the middleware', () => { + const middleware = loggerInitializer(); + + expect(middleware.before).toBeDefined(); + }); + + it('should set defaultMeta', () => { + const middleware = loggerInitializer(); + + // expect Logger to not have defaultMeta before middleware runs + expect(Logger.defaultMeta).toBeNull(); + + // run the middleware + middleware.before?.(requestFixture); + + // expect Logger to have defaultMeta initialized + expect(Logger.defaultMeta).toEqual({ requestId: 'awsRequestId' }); + }); +}); diff --git a/src/middlewares/__tests__/validator-joi.test.ts b/src/middlewares/__tests__/validator-joi.test.ts index ef8e106..b89ed1f 100644 --- a/src/middlewares/__tests__/validator-joi.test.ts +++ b/src/middlewares/__tests__/validator-joi.test.ts @@ -1,4 +1,3 @@ -import { jest } from '@jest/globals'; import { Request } from '@middy/core'; import * as Joi from 'joi'; import { BadRequestError } from '../../errors/bad-request.error'; @@ -19,25 +18,6 @@ describe('JoiValidator', () => { expect(middleware.before).toBeDefined(); }); - it('should write log when logger provided', () => { - type loggerFn = (msg: string) => void; - const mockLogger = jest.fn(); - - const request: Request = { - ...requestFixture, - event: { - s: 'string', - b: 'true', - n: '100', - }, - }; - const middleware = validator({ eventSchema: schema, logger: mockLogger }); - - middleware.before?.(request); - - expect(mockLogger).toHaveBeenCalled(); - }); - it('should validate successfully', () => { const request: Request = { ...requestFixture, diff --git a/src/middlewares/error-handler-http.ts b/src/middlewares/error-handler-http.ts index 242fd99..5da59b3 100644 --- a/src/middlewares/error-handler-http.ts +++ b/src/middlewares/error-handler-http.ts @@ -1,8 +1,9 @@ import middy, { MiddlewareObj } from '@middy/core'; import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { ServiceError } from '../errors/service.error'; +import Logger from '../utils/logger'; import { HttpError } from '../errors/http.error'; +import { ServiceError } from '../errors/service.error'; /** * `HttpErrorHandlerOptions` provide configuration to the `http-error-handler` @@ -11,7 +12,6 @@ import { HttpError } from '../errors/http.error'; export type HttpErrorHandlerOptions = { defaultMessage?: string; defaultStatusCode?: number; - logger?(message: string): void; }; const DEFAULT_MESSAGE = 'Unhandled error'; @@ -38,16 +38,6 @@ export const httpErrorHandler = ( ...opts, }; - /** - * Write a message to the application log. - * @param message string - The message to write. - */ - const log = (message: string): void => { - if (options.logger) { - options.logger(message); - } - }; - /** * `ServiceError` type guard. * @param error An Error. @@ -87,7 +77,7 @@ export const httpErrorHandler = ( if (isHttpError(error)) { // type HttpError - log(`middleware::error-handler::HttpError ${error}`); + Logger.error(`middleware::http-error-handler::HttpError::${error}`, error); request.response = { statusCode: error.statusCode ?? options.defaultStatusCode, body: JSON.stringify({ @@ -99,7 +89,7 @@ export const httpErrorHandler = ( }; } else if (isServiceError(error)) { // type ServiceError - log(`middleware::error-handler::ServiceError ${error}`); + Logger.error(`middleware::http-error-handler::ServiceError::${error}`, error); request.response = { statusCode: error.statusCode ?? options.defaultStatusCode, body: JSON.stringify({ @@ -111,7 +101,7 @@ export const httpErrorHandler = ( }; } else { // any other type of Error - log(`middleware::error-handler::Error ${error}`); + Logger.error(`middleware::http-error-handler::Error::${error}`, error); request.response = { statusCode: options.defaultStatusCode ?? DEFAULT_STATUS_CODE, body: JSON.stringify({ diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts index 3b06386..8359183 100644 --- a/src/middlewares/index.ts +++ b/src/middlewares/index.ts @@ -1,2 +1,3 @@ export { HttpErrorHandlerOptions, httpErrorHandler } from './error-handler-http'; +export { loggerInitializer } from './logger-initializer'; export { ValidatorOptions, validator } from './validator-joi'; diff --git a/src/middlewares/logger-initializer.ts b/src/middlewares/logger-initializer.ts new file mode 100644 index 0000000..81a1029 --- /dev/null +++ b/src/middlewares/logger-initializer.ts @@ -0,0 +1,19 @@ +import middy, { MiddlewareObj } from '@middy/core'; + +import Logger from '../utils/logger'; + +/** + * Initialize the Logger. Adds event metadata to each logged event. + * @returns Middleware to initialize the Logger. + */ +export const loggerInitializer = (): MiddlewareObj => { + /** + * Initialize the Logger before the handler is invoked. + * @param request - Middy request context. + */ + const loggerInitializerBefore: middy.MiddlewareFn = (request): void => { + Logger.defaultMeta = { requestId: request.context.awsRequestId }; + }; + + return { before: loggerInitializerBefore }; +}; diff --git a/src/middlewares/validator-joi.ts b/src/middlewares/validator-joi.ts index 01c513f..525be39 100644 --- a/src/middlewares/validator-joi.ts +++ b/src/middlewares/validator-joi.ts @@ -1,13 +1,14 @@ import middy, { MiddlewareObj } from '@middy/core'; import * as Joi from 'joi'; -import { BadRequestError } from '../errors'; + +import Logger from '../utils/logger'; +import { BadRequestError } from '../errors/bad-request.error'; /** * ValidatorOptions */ export type ValidatorOptions = { eventSchema?: Joi.ObjectSchema; - logger?(message: string): void; }; /** @@ -24,16 +25,6 @@ const validationOptions: Joi.ValidationOptions = { * @returns Middleware to perform validation against a Joi schema. */ export const validator = (options: ValidatorOptions): MiddlewareObj => { - /** - * Write a message to the application log. - * @param message string - The message to write. - */ - const log = (message: string): void => { - if (options.logger) { - options.logger(message); - } - }; - /** * Validate a `value` using a Joi `schema`. * @param schema - Joi schema @@ -50,11 +41,11 @@ export const validator = (options: ValidatorOptions): MiddlewareObj => { * @param request - Middy request context. */ const before: middy.MiddlewareFn = (request): void => { - log(`middleware::validator::before`); + Logger.debug(`middleware::validator::before`); if (options.eventSchema) { const { error, value } = validate(options.eventSchema, request.event); if (error) { - log(`validation error::${error}`); + Logger.error(`middleware::validator::error::${error}`); throw new BadRequestError(error.message); } request.event = { diff --git a/src/services/__tests__/config.service-failure.test.ts b/src/services/__tests__/config.service-failure.test.ts index 9abfcd0..9ef21df 100644 --- a/src/services/__tests__/config.service-failure.test.ts +++ b/src/services/__tests__/config.service-failure.test.ts @@ -1,5 +1,5 @@ import { ServiceError } from '../../errors/service.error'; -import { LambdaConfig, lambdaConfigSchema, validateConfig } from '../config.service'; +import ConfigService, { BaseConfig, baseConfigSchema } from '../config.service'; describe('ConfigService Failure', () => { const originalEnv = process.env; @@ -15,6 +15,6 @@ describe('ConfigService Failure', () => { }); it('should throw error when validation fails', () => { - expect(() => validateConfig(lambdaConfigSchema)).toThrow(ServiceError); + expect(() => ConfigService.validateConfig(baseConfigSchema)).toThrow(ServiceError); }); }); diff --git a/src/services/__tests__/config.service.test.ts b/src/services/__tests__/config.service.test.ts index 4939812..9c991bf 100644 --- a/src/services/__tests__/config.service.test.ts +++ b/src/services/__tests__/config.service.test.ts @@ -1,14 +1,14 @@ -import { LambdaConfig, lambdaConfigSchema, validateConfig } from '../config.service'; +import ConfigService, { BaseConfig, baseConfigSchema } from '../config.service'; describe('ConfigService', () => { it('should validate successfully', () => { - const validatedConfig = validateConfig(lambdaConfigSchema); + const validatedConfig = ConfigService.validateConfig(baseConfigSchema); expect(validatedConfig).toBeDefined(); }); it('should return LambdaConfig attributes', () => { - const validatedConfig = validateConfig(lambdaConfigSchema); + const validatedConfig = ConfigService.validateConfig(baseConfigSchema); expect(validatedConfig).toBeDefined(); expect(validatedConfig.AWS_EXECUTION_ENV).toBe('aws-execution-env'); diff --git a/src/services/cognito-identity-provider-service.ts b/src/services/cognito-identity-provider-service.ts index 768c162..e6836a6 100644 --- a/src/services/cognito-identity-provider-service.ts +++ b/src/services/cognito-identity-provider-service.ts @@ -12,7 +12,7 @@ import { ListUsersCommandOutput, } from '@aws-sdk/client-cognito-identity-provider'; -import { lambdaConfigValues as config } from './config.service'; +import { baseConfigValues as config } from './config.service'; const clientConfig: CognitoIdentityProviderClientConfig = { region: config.AWS_REGION, diff --git a/src/services/config.service.ts b/src/services/config.service.ts index 2a3824b..5ca5dce 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -3,24 +3,26 @@ import * as Joi from 'joi'; import { ServiceError } from '../errors/service.error'; /** - * The `LambdaConfig` type describes the configuration values supplied to every AWS Lambda - * function by default. Extend `LambdaConfig` with your own service-specific configuration + * The `BaseConfig` type describes the configuration values supplied to every AWS Lambda + * function by default. Extend `BaseConfig` with your own service-specific configuration * type. * * Example: * ``` - * type ServiceConfig = LambdaConfig & { + * type ServiceConfig = BaseConfig & { * FOO: string; * BAR: number; * } * ``` */ -export type LambdaConfig = { +export type BaseConfig = { AWS_EXECUTION_ENV: string; AWS_LAMBDA_FUNCTION_NAME: string; AWS_LAMBDA_FUNCTION_MEMORY_SIZE: string; AWS_LAMBDA_FUNCTION_VERSION: string; AWS_REGION: string; + LOGGING_ENABLED: boolean; + LOGGING_LEVEL: string; }; /** @@ -31,7 +33,7 @@ export type LambdaConfig = { * otherwise throws a `ServiceError`. * @throws Throws a `ServiceError` when validation is unsuccessful. */ -export function validateConfig(schema: Joi.ObjectSchema): TConfig { +function validateConfig(schema: Joi.ObjectSchema): TConfig { const { error, value } = schema.validate(process.env, { abortEarly: false, allowUnknown: true, @@ -46,17 +48,25 @@ export function validateConfig(schema: Joi.ObjectSchema): TCon /** * A Joi ObjectSchema to validate the base AWS Lambda function configuration values. */ -export const lambdaConfigSchema = Joi.object({ +export const baseConfigSchema = Joi.object({ AWS_EXECUTION_ENV: Joi.string().required(), AWS_LAMBDA_FUNCTION_NAME: Joi.string().required(), AWS_LAMBDA_FUNCTION_MEMORY_SIZE: Joi.string().required(), AWS_LAMBDA_FUNCTION_VERSION: Joi.string().required(), AWS_REGION: Joi.string().required(), + LOGGING_ENABLED: Joi.boolean().default(true), + LOGGING_LEVEL: Joi.string().allow('debug', 'info', 'warn', 'error').default('info'), }); /** - * A `LambdaConfig` object containing the base AWS Lambda function configuration values. + * A `BaseConfig` object containing the base AWS Lambda function configuration values. * This is useful when your function does not have additional configuration attributes. - * @see {@link LambdaConfig} + * @see {@link BaseConfig} */ -export const lambdaConfigValues = validateConfig(lambdaConfigSchema); +export const baseConfigValues = validateConfig(baseConfigSchema); + +const ConfigService = { + validateConfig, +}; + +export default ConfigService; diff --git a/src/services/dynamo.service.ts b/src/services/dynamo.service.ts index 2af0c19..4842657 100644 --- a/src/services/dynamo.service.ts +++ b/src/services/dynamo.service.ts @@ -25,26 +25,26 @@ import { } from '@aws-sdk/lib-dynamodb'; import { DynamoDBClient, DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'; -import { lambdaConfigValues as config } from './config.service'; +import { baseConfigValues as config } from './config.service'; +import Logger from '../utils/logger'; const dynamoDbClientConfig: DynamoDBClientConfig = { region: config.AWS_REGION, }; -console.debug(`DynamoService::DynamoDBClientConfig::${JSON.stringify(dynamoDbClientConfig)}`); const marshallOptions = { convertEmptyValues: false, removeUndefinedValues: true, convertClassInstanceToMap: true, }; -console.debug(`DynamoService::marshallOptions::${JSON.stringify(marshallOptions)}`); const unmarshallOptions = { wrapNumbers: false, }; -console.debug(`DynamoService::unmarshallOptions::${JSON.stringify(unmarshallOptions)}`); const translateConfig: TranslateConfig = { marshallOptions, unmarshallOptions }; -console.debug('DynamoService::creating new DynamoDBDocumentClient'); +Logger.debug('DynamoService::creating new DynamoDBDocumentClient', { + data: { dynamoDbClientConfig, marshallOptions, unmarshallOptions }, +}); /** * Create a DynamoDBDocumentClient. This will be created once during AWS Lambda function * cold start and placed in the module loader. It will be reused across function invocations. diff --git a/src/services/index.ts b/src/services/index.ts index 0dcff31..228c865 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,9 +1,5 @@ -export { - LambdaConfig, - lambdaConfigSchema, - lambdaConfigValues, - validateConfig, -} from './config.service'; +export { BaseConfig, baseConfigSchema, baseConfigValues } from './config.service'; +export { default as ConfigService } from './config.service'; export { default as DynamoService } from './dynamo.service'; diff --git a/src/services/sqs-service.ts b/src/services/sqs-service.ts index 9746870..037c00f 100644 --- a/src/services/sqs-service.ts +++ b/src/services/sqs-service.ts @@ -6,14 +6,14 @@ import { SendMessageCommandOutput, } from '@aws-sdk/client-sqs'; -import { lambdaConfigValues as config } from './config.service'; +import { baseConfigValues as config } from './config.service'; +import Logger from '../utils/logger'; const clientConfig: SQSClientConfig = { region: config.AWS_LAMBDA_FUNCTION_VERSION, }; -console.debug(`SQSService::SQSClientConfig::${JSON.stringify(clientConfig)}`); -console.debug('SQSService::creating new SQSClient'); +Logger.debug('SQSService::creating new SQSClient', { data: { clientConfig } }); const sqsClient = new SQSClient(clientConfig); /** diff --git a/src/utils/__tests__/logging.test.ts b/src/utils/__tests__/logging.test.ts new file mode 100644 index 0000000..205ed2a --- /dev/null +++ b/src/utils/__tests__/logging.test.ts @@ -0,0 +1,33 @@ +import { Logger } from '../index'; + +/** + * Note: These tests are really just to illustrate how to use the `Logger`. + * The Logger itself is a `winston` logger instance. The `winston` package + * performs it's own testing so we do not need to test it ourselves. + */ +describe('Logger', () => { + it('should be defined', () => { + Logger.debug('I am a DEBUG message.'); + Logger.info('I am an INFO message.'); + Logger.warn('I am a WARN message.'); + Logger.error('I am an ERROR message.'); + + expect(Logger).toBeDefined(); + }); + + it('should include data', () => { + const foo = { + bar: 'baz', + }; + Logger.info('Message with data.', { data: { foo } }); + + expect(Logger).toBeDefined(); + }); + + it('should include error', () => { + const error = new Error('Something is wrong.'); + Logger.info('Message with an error.', error); + + expect(Logger).toBeDefined(); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts index 7a6d171..a46e61c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -18,3 +18,5 @@ export { } from './middyfy'; export { default as ID } from './id'; + +export { default as Logger } from './logger'; diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..c527541 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,17 @@ +import { createLogger, format, LoggerOptions, transports } from 'winston'; + +import { baseConfigValues as config } from '../services/config.service'; + +const { combine, json, timestamp } = format; + +const loggerOptions: LoggerOptions = { + format: combine(timestamp(), json()), + level: config.LOGGING_LEVEL, + silent: !config.LOGGING_ENABLED, + transports: [new transports.Console()], +}; + +const Logger = createLogger(loggerOptions); +Logger.debug('Logger::creating new Logger'); + +export default Logger; diff --git a/src/utils/middyfy.ts b/src/utils/middyfy.ts index f4d622e..2fb8820 100644 --- a/src/utils/middyfy.ts +++ b/src/utils/middyfy.ts @@ -12,6 +12,7 @@ import { } from 'aws-lambda'; import { ObjectSchema } from 'joi'; +import { loggerInitializer } from '../middlewares/logger-initializer'; import { validator } from '../middlewares/validator-joi'; import { httpErrorHandler } from '../middlewares/error-handler-http'; @@ -59,7 +60,6 @@ export type LambdaHandler = ( */ export type MiddyfyOptions = { handler: THandler; - logger?: (message: string) => void; }; /** @@ -109,14 +109,14 @@ export type LambdaMiddyfyOptions = MiddyfyO */ export const middyfyAPIGateway = (options: APIGatewayMiddyfyOptions) => { return middy(options.handler) + .use(loggerInitializer()) .use(httpEventNormalizer()) .use(jsonBodyParser()) - .use(validator({ eventSchema: options.eventSchema, logger: options.logger })) + .use(validator({ eventSchema: options.eventSchema })) .use( httpErrorHandler({ defaultMessage: options.defaultErrorMessage, defaultStatusCode: options.defaultErrorStatusCode, - logger: options.logger, }), ); }; @@ -128,9 +128,9 @@ export const middyfyAPIGateway = (options: APIGatewayMiddyfyOptions) => { * @returns A middyfied handler function. */ export const middyfyScheduled = (options: ScheduledMiddyfyOptions) => { - return middy(options.handler).use( - validator({ eventSchema: options.eventSchema, logger: options.logger }), - ); + return middy(options.handler) + .use(loggerInitializer()) + .use(validator({ eventSchema: options.eventSchema })); }; /** @@ -141,8 +141,9 @@ export const middyfyScheduled = (options: ScheduledMiddyfyOptions) => { */ export const middyfySNS = (options: SNSMiddyfyOptions) => { return middy(options.handler) + .use(loggerInitializer()) .use(eventNormalizer()) - .use(validator({ eventSchema: options.eventSchema, logger: options.logger })); + .use(validator({ eventSchema: options.eventSchema })); }; /** @@ -153,8 +154,9 @@ export const middyfySNS = (options: SNSMiddyfyOptions) => { */ export const middyfySQS = (options: SQSMiddyfyOptions) => { return middy(options.handler) + .use(loggerInitializer()) .use(eventNormalizer()) - .use(validator({ eventSchema: options.eventSchema, logger: options.logger })); + .use(validator({ eventSchema: options.eventSchema })); }; /** @@ -167,7 +169,7 @@ export const middyfySQS = (options: SQSMiddyfyOptions) => { * @see {@link https://docs.aws.amazon.com/lambda/latest/operatorguide/functions-calling-functions.html} */ export const middyfyLambda = (options: LambdaMiddyfyOptions) => { - return middy(options.handler).use( - validator({ eventSchema: options.eventSchema, logger: options.logger }), - ); + return middy(options.handler) + .use(loggerInitializer()) + .use(validator({ eventSchema: options.eventSchema })); };