├── .env.example ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-push ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── src ├── graceful-shutdown.ts ├── index.ts ├── logger.ts └── sentry.ts ├── tests ├── basics.test.ts ├── graceful.test.ts ├── health.test.ts ├── integration │ ├── main.ts │ └── routes │ │ └── test.ts ├── jigs │ ├── delay.ts │ ├── log-testing.fixture.ts │ ├── log-testing.ts │ ├── plugins │ │ └── decorator.ts │ └── routes │ │ └── foo.ts ├── logger.test.ts ├── plugins.test.ts └── sentry.test.ts ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Read more: https://github.com/47ng/fastify-micro#environment-variables 2 | 3 | # -- Required environment variables -- 4 | 5 | ## Set NODE_ENV to one of: development, production 6 | ## The app will not start if it is not set. 7 | NODE_ENV=development 8 | 9 | 10 | # -- Optional environment variables (values are defaults) -- 11 | 12 | ## The listening port to use for the server. 13 | ## This will be used only if the `port` argument is omitted when 14 | ## calling `startServer`. 15 | PORT= 16 | 17 | 18 | ## Points to the git SHA-1 of the current revision 19 | ## Note: only the first 8 characters will be sent in the logs 20 | ## under the `commit` key. 21 | COMMIT_ID= 22 | 23 | 24 | ## Refers to an identifier for the machine that runs the code in production. 25 | ## Note: only the first 8 characters will be sent in the logs and to Sentry, 26 | ## under the `instance` key. 27 | INSTANCE_ID= 28 | 29 | 30 | ## The level of logs to output, passed to Pino. 31 | ## Note: if unspecified, is equal to 'info', unless the DEBUG environment 32 | ## variable is set to true, in which case it will be 'debug'. 33 | LOG_LEVEL=info 34 | 35 | 36 | ## Print more debugging info 37 | ## This switches the log level to 'debug' if LOG_LEVEL is not set. 38 | DEBUG= 39 | 40 | 41 | ## Pin the fingerprinting salt to match across instances/reboots 42 | ## If not set, the salt is randomly generated every time the app is created, 43 | ## /!\ This will reduce the privacy of your users, don't be evil (for real). 44 | ## Read more: https://github.com/47ng/fastify-micro#anonymising-request-id-fingerprints 45 | LOG_FINGERPRINT_SALT= 46 | 47 | 48 | ## Enable Sentry by passing a DSN 49 | SENTRY_DSN= 50 | 51 | 52 | ## The Sentry release to use when reporting errors 53 | ## This can also be set programmatically under options.sentry.release, 54 | ## in which case it takes precedence over the environment variable. 55 | ## Read more: https://github.com/47ng/fastify-micro#sentry 56 | SENTRY_RELEASE= 57 | 58 | 59 | ## If defined, will be passed to `trustProxy` in the server options. 60 | ## Read more: https://www.fastify.io/docs/latest/Server/#trustproxy 61 | ## 62 | ## Note: if you need to use a function, pass it explicitly in the options 63 | ## (the environment variable value will be ignored): 64 | ## ``` 65 | ## createServer({ trustProxy: () => true }) 66 | ## ``` 67 | TRUSTED_PROXY_IPS= 68 | 69 | 70 | ## Set to true to disable the underPressure plugin for service health monitoring. 71 | ## This can be useful for tests and CI environments. 72 | FASTIFY_MICRO_DISABLE_SERVICE_HEALTH_MONITORING= 73 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [franky47] 2 | liberapay: francoisbest 3 | custom: ['https://paypal.me/francoisbest?locale.x=fr_FR'] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | time: "09:00" 8 | timezone: Europe/Paris 9 | assignees: 10 | - franky47 11 | - package-ecosystem: github-actions 12 | directory: / 13 | schedule: 14 | interval: monthly 15 | assignees: 16 | - franky47 17 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - beta 8 | 9 | jobs: 10 | cd: 11 | name: Continuous Delivery 12 | runs-on: ubuntu-latest 13 | steps: 14 | - id: yarn-cache 15 | name: Get Yarn cache path 16 | run: echo "::set-output name=dir::$(yarn cache dir)" 17 | - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 18 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d 19 | with: 20 | node-version: 14.x 21 | - uses: actions/cache@937d24475381cd9c75ae6db12cb4e79714b926ed 22 | name: Load Yarn cache 23 | with: 24 | path: ${{ steps.yarn-cache.outputs.dir }} 25 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-yarn- 28 | - run: yarn install --ignore-scripts 29 | name: Install dependencies 30 | - run: yarn build 31 | name: Build package 32 | 33 | # Continuous Delivery Pipeline -- 34 | 35 | - uses: docker://ghcr.io/codfish/semantic-release-action@sha256:16ab6c16b1bff6bebdbcc6cfc07dfafff49d23c6818490500b8edb3babfff29e 36 | name: Semantic Release 37 | id: semantic 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - next 7 | - feature/* 8 | - dependabot/* 9 | pull_request: 10 | types: [opened, edited, reopened, synchronize] 11 | 12 | jobs: 13 | ci: 14 | name: Continuous Integration 15 | runs-on: ubuntu-latest 16 | steps: 17 | - id: yarn-cache 18 | name: Get Yarn cache path 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | - uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 21 | - uses: actions/setup-node@56899e050abffc08c2b3b61f3ec6a79a9dc3223d 22 | with: 23 | node-version: 14.x 24 | - uses: actions/cache@937d24475381cd9c75ae6db12cb4e79714b926ed 25 | name: Load Yarn cache 26 | with: 27 | path: ${{ steps.yarn-cache.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | - run: yarn install --ignore-scripts 32 | name: Install dependencies 33 | - run: yarn ci 34 | name: Run integration tests 35 | - uses: coverallsapp/github-action@9ba913c152ae4be1327bfb9085dc806cedb44057 36 | name: Report code coverage 37 | with: 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | - uses: 47ng/actions-slack-notify@main 40 | name: Notify on Slack 41 | if: always() 42 | with: 43 | status: ${{ job.status }} 44 | env: 45 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .env 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn ci 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests/** 2 | **/*.test.ts 3 | tsconfig.json 4 | .env 5 | .volumes/ 6 | yarn-error.log 7 | .github/** 8 | src/** 9 | coverage/ 10 | .dependabot/ 11 | .husky/ 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 François Best 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

fastify-micro

2 | 3 |
4 | 5 | [![NPM](https://img.shields.io/npm/v/fastify-micro?color=red)](https://www.npmjs.com/package/fastify-micro) 6 | [![MIT License](https://img.shields.io/github/license/47ng/fastify-micro.svg?color=blue)](https://github.com/47ng/fastify-micro/blob/master/LICENSE) 7 | [![Continuous Integration](https://github.com/47ng/fastify-micro/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/47ng/fastify-micro/actions) 8 | [![Coverage Status](https://coveralls.io/repos/github/47ng/fastify-micro/badge.svg?branch=next)](https://coveralls.io/github/47ng/fastify-micro?branch=next) 9 | 10 |
11 | 12 |

13 | Opinionated Node.js microservices framework built on Fastify. 14 |

15 | 16 | ## Features 17 | 18 | - Secure and useful logging 19 | - [Auto-load](https://github.com/fastify/fastify-autoload) routes & plugins from the filesystem _(opt-in)_ 20 | - Built-in [Sentry](#sentry) support for error reporting _(opt-in)_ 21 | - Service health monitoring 22 | - Graceful exit 23 | - First class TypeScript support 24 | 25 | ## Installation 26 | 27 | ```shell 28 | $ yarn add fastify-micro 29 | # or 30 | $ npm i fastify-micro 31 | ``` 32 | 33 | ## Usage 34 | 35 | Minimal example: 36 | 37 | ```ts 38 | import { createServer, startServer } from 'fastify-micro' 39 | 40 | const server = createServer() 41 | 42 | startServer(server) 43 | ``` 44 | 45 | ## Documentation 46 | 47 | ### Environment Variables 48 | 49 | Details of the required and accepted (optional) environment variables 50 | are available in the [`.env.example`](./.env.example) file. 51 | 52 | ### Listening port 53 | 54 | You can provide the port number where the server will be listening as 55 | the second argument of `startServer`: 56 | 57 | ```ts 58 | import { createServer, startServer } from 'fastify-micro' 59 | 60 | const server = createServer() 61 | startServer(server, 3000) 62 | ``` 63 | 64 | If omitted, the port number will be read from the `PORT` environment 65 | variable: 66 | 67 | ```ts 68 | // process.env.PORT = 4000 69 | 70 | import { createServer, startServer } from 'fastify-micro' 71 | 72 | const server = createServer() 73 | startServer(server) 74 | 75 | // Server started on 0.0.0.0:4000 76 | ``` 77 | 78 | If no value is specified either via code or environment, the default port will 79 | be 3000. 80 | 81 | ### Auto-loading plugins and routes from the filesystem 82 | 83 | Plugins and routes can be loaded from the filesystem using 84 | [`fastify-autoload`](https://github.com/fastify/fastify-autoload): 85 | 86 | ```ts 87 | import path from 'path' 88 | import { createServer } from 'fastify-micro' 89 | 90 | createServer({ 91 | plugins: { 92 | dir: path.join(__dirname, 'plugins') 93 | }, 94 | routes: { 95 | dir: path.join(__dirname, 'routes') 96 | } 97 | }) 98 | ``` 99 | 100 | The `plugins` and `routes` options are `fastify-autoload` configuration objects. 101 | 102 | As recommended by Fastify, plugins will be loaded first, then routes. 103 | Attach your external services, decorators and hooks as plugin files, so that 104 | they will be loaded when declaring your routes. 105 | 106 | #### Printing Routes 107 | 108 | In development, the server will log the route tree on startup. 109 | This can be configured: 110 | 111 | ```ts 112 | createServer({ 113 | printRoutes: 114 | | 'auto' // default: `console` in development, silent in production. 115 | | 'console' // always pretty-print routes using `console.info` (for humans) 116 | | 'logger' // always print as NDJSON as part of the app log stream (info level) 117 | | false // disable route printing 118 | }) 119 | ``` 120 | 121 | ### Other default plugins 122 | 123 | The following plugins are loaded by default: 124 | 125 | - [`fastify-sensible`](https://github.com/fastify/fastify-sensible), 126 | for convention-based error handling. 127 | 128 | ### Loading other plugins 129 | 130 | The server returned by `createServer` is a Fastify instance, you can 131 | register any Fastify-compatible plugin onto it, and use the full Fastify 132 | API: 133 | 134 | ```ts 135 | const server = createServer() 136 | 137 | server.register(require('fastify-cors')) 138 | 139 | server.get('/', () => 'Hello, world !') 140 | ``` 141 | 142 | ### Logging 143 | 144 | Fastify already has a great logging story with 145 | [pino](https://github.com/pinojs/pino), this builds upon it. 146 | 147 | #### Logs Redaction 148 | 149 | Logs should be safe: no accidental leaking of access tokens and other 150 | secrets through environment variables being logged. For this, 151 | [`redact-env`](https://github.com/47ng/redact-env) is used. 152 | 153 | By default, it will only redact the value of `SENTRY_DSN` (see 154 | [Sentry](#sentry) for more details), but you can pass it additional 155 | environment variables to redact: 156 | 157 | ```ts 158 | createServer({ 159 | // The values of these environment variables 160 | // will be redacted in the logs: 161 | redactEnv: [ 162 | 'JWT_SECRET', 163 | 'AWS_S3_TOKEN', 164 | 'DATABASE_URI' 165 | // etc... 166 | ] 167 | }) 168 | ``` 169 | 170 | You can also redact log fields by passing [Pino redact paths](https://getpino.io/#/docs/redaction) 171 | to the `redactLogPaths` option: 172 | 173 | ```ts 174 | createServer({ 175 | // The values of these headers 176 | // will be redacted in the logs: 177 | redactLogPaths: [ 178 | 'req.headers["x-myapp-client-secret"]', 179 | 'res.headers["x-myapp-server-secret"]' 180 | // etc... 181 | ] 182 | }) 183 | ``` 184 | 185 | The following security headers will be redacted by default: 186 | 187 | - Request headers: 188 | - `Cookie` 189 | - `Authorization` 190 | - `X-Secret-Token` 191 | - `X-CSRF-Token` 192 | - Response headers: 193 | - `Set-Cookie` 194 | 195 | #### Environment Context 196 | 197 | In case you want to perform log aggregation across your services, it can 198 | be useful to know who generated a log entry. 199 | 200 | For that, you can pass a `name` in the options. It will add a `from` 201 | field in the logs with that name: 202 | 203 | ```ts 204 | const server = createServer({ 205 | name: 'api' 206 | }) 207 | 208 | // The `name` property is now available on your server: 209 | server.log.info({ msg: `Hello, ${server.name}` }) 210 | // {"from":"api":"msg":"Hello, api",...} 211 | ``` 212 | 213 | To add more context to your logs, you can set the following optional 214 | environment variables: 215 | 216 | | Env Var Name | Log Key | Description | 217 | | ------------- | ---------- | -------------------------------------------------------- | 218 | | `INSTANCE_ID` | `instance` | An identifier for the machine that runs your application | 219 | | `COMMIT_ID` | `commit` | The git SHA-1 of your code | 220 | 221 | > _**Note**_: for both `INSTANCE_ID` and `COMMIT_ID`, only the first 8 222 | > characters will be logged or sent to Sentry. 223 | 224 | #### Request ID 225 | 226 | By default, Fastify uses an incremental integer for its request ID, which 227 | is fast but lacks context and immediate visual identification. 228 | 229 | Instead, `fastify-micro` uses a request ID that looks like this: 230 | 231 | ``` 232 | To9hgCK4MvOmFRVM.oPoAOhj93kEgbIdV 233 | ``` 234 | 235 | It is made of two parts, separated by a dot `'.'`: 236 | 237 | - `To9hgCK4MvOmFRVM` is the user fingerprint 238 | - `oPoAOhj93kEgbIdV` is a random identifier 239 | 240 | The user fingerprint is a hash of the following elements: 241 | 242 | - The source IP address 243 | - The user-agent header 244 | - A salt used for anonymization 245 | 246 | The second part of the request ID is a random string of base64 characters 247 | that will change for every request, but stay common across the lifetime 248 | of the request, making it easier to visualize which requests are linked 249 | in the logs: 250 | 251 | ```json 252 | // Other log fields removed for brievity 253 | {"reqId":"To9hgCK4MvOmFRVM.psM5GNErJq4l6OD6","req":{"method":"GET","url":"/foo"}} 254 | {"reqId":"To9hgCK4MvOmFRVM.psM5GNErJq4l6OD6","res":{"statusCode":200}} 255 | {"reqId":"To9hgCK4MvOmFRVM.oPoAOhj93kEgbIdV","req":{"method":"POST","url":"/bar"}} 256 | {"reqId":"To9hgCK4MvOmFRVM.oPoAOhj93kEgbIdV","res":{"statusCode":201}} 257 | {"reqId":"KyGsnkFDdtKLQUaW.Jj6TgkSAYJ4hcxLR","req":{"method":"GET","url":"/egg"}} 258 | {"reqId":"KyGsnkFDdtKLQUaW.Jj6TgkSAYJ4hcxLR","res":{"statusCode":200}} 259 | ``` 260 | 261 | Here we can quickly see that: 262 | 263 | - There are two users interacting with the service 264 | - User `To9hgCK4MvOmFRVM` made two requests: 265 | - `psM5GNErJq4l6OD6` - `GET /foo -> 200` 266 | - `oPoAOhj93kEgbIdV` - `POST /bar -> 201` 267 | - User `KyGsnkFDdtKLQUaW` made one request: 268 | - `Jj6TgkSAYJ4hcxLR` - `GET /egg -> 200` 269 | 270 | #### Anonymising Request ID Fingerprints 271 | 272 | By default, request ID fingerprints are rotated every time an app is 273 | built (when `createServer` is called). This will most likely correspond 274 | to when your app starts, and would make it impossible to track users 275 | across restarts of your app, or across multiple instances when scaling 276 | up. While it's good for privacy (while keeping a good debugging value 277 | per-service), it will be a pain for distributed systems. 278 | 279 | If you need reproducibility in the fingerprint, you can set the 280 | `LOG_FINGERPRINT_SALT` environment variable to a constant across your 281 | services / instances. 282 | 283 | ### Sentry 284 | 285 | Built-in support for [Sentry](https://sentry.io) is provided, and can be 286 | activated by setting the `SENTRY_DSN` environment variable to the 287 | [DSN](https://docs.sentry.io/error-reporting/quickstart/?platform=node#configure-the-sdk) 288 | that is found in your project settings. 289 | 290 | Sentry will receive any unhandled errors (5xx) thrown by your 291 | application. 4xx errors are considered "handled" errors and will not be 292 | reported. 293 | 294 | You can manually report an error, at the server or request level: 295 | 296 | ```ts 297 | // Anywhere you have access to the server object: 298 | const error = new Error('Manual error report') 299 | server.sentry.report(error) 300 | 301 | // In a route: 302 | const exampleRoute = (req, res) => { 303 | const error = new Error('Error from a route') 304 | // This will add request context to the error: 305 | req.sentry.report(error) 306 | } 307 | ``` 308 | 309 | #### Enriching error reports 310 | 311 | You can enrich your error reports by defining two async callbacks: 312 | 313 | - `getUser`, to retrieve user information to pass to Sentry 314 | - `getExtra`, to add two kinds of extra key:value information: 315 | - `tags`: tags are searchable string-based key/value pairs, useful for filtering issues/events. 316 | - `context`: extra data to display in issues/events, not searchable. 317 | 318 | Example: 319 | 320 | ```ts 321 | import { createServer } from 'fastify-micro' 322 | 323 | createServer({ 324 | sentry: { 325 | getUser: async (server, req) => { 326 | // Example: fetch user from database 327 | const user = await server.db.findUser(req.auth.userID) 328 | return user 329 | }, 330 | getExtra: async (server, req) => { 331 | // Req may be undefined here 332 | return { 333 | tags: { 334 | foo: 'bar' // Can search/filter issues by `foo` 335 | }, 336 | context: { 337 | egg: 'spam' 338 | } 339 | } 340 | } 341 | } 342 | }) 343 | ``` 344 | 345 | > _**ProTip**_: if you're returning Personally Identifiable Information 346 | > in your enrichment callbacks, don't forget to mention it in your 347 | > privacy policy 🙂 348 | 349 | You can also enrich manually-reported errors: 350 | 351 | ```ts 352 | const exampleRoute = (req, res) => { 353 | const error = new Error('Error from a route') 354 | // Add extra data to the error 355 | req.sentry.report(error, { 356 | tags: { 357 | projectID: req.params.projectID 358 | }, 359 | context: { 360 | performance: 42 361 | } 362 | }) 363 | } 364 | ``` 365 | 366 |
367 | 368 |

Note: v2 to v3 migration

369 |
370 | 371 | in versions <= 2.x.x, the request object was passed as the second argument to the `report` function. 372 | 373 | To migrate to version 3.x.x, you can remove this argument and use the `sentry` 374 | decoration on the request instead: 375 | 376 | ```ts 377 | const exampleRoute = (req, res) => { 378 | const error = new Error('Error from a route') 379 | 380 | // version 2.x.x 381 | server.sentry.report(error, req, { 382 | // Extra context 383 | }) 384 | 385 | // version 3.x.x 386 | req.sentry.report(error, { 387 | // Extra context 388 | }) 389 | } 390 | ``` 391 | 392 |
393 | 394 | #### Sentry Releases 395 | 396 | There are two ways to tell Sentry about which 397 | [Release](https://docs.sentry.io/workflow/releases/?platform=node) 398 | to use when reporting errors: 399 | 400 | - Via the `SENTRY_RELEASE` environment variable 401 | - Via the options: 402 | 403 | ```ts 404 | import { createServer } from 'fastify-micro' 405 | 406 | createServer({ 407 | sentry: { 408 | release: 'foo' 409 | } 410 | }) 411 | ``` 412 | 413 | A value passed in the options will take precedence over a value passed 414 | by the environment variable. 415 | 416 | ### Graceful exit 417 | 418 | When receiving `SIGINT` or `SIGTERM`, Fastify applications quit instantly, 419 | potentially leaking file descriptors or open resources. 420 | 421 | To clean up before exiting, add a `cleanupOnExit` callback in the options: 422 | 423 | ```ts 424 | createServer({ 425 | cleanupOnExit: async app => { 426 | // Release external resources 427 | await app.database.close() 428 | } 429 | }) 430 | ``` 431 | 432 | This uses the Fastify `onClose` hook, which will be called when receiving a 433 | termination signal. If the onClose hooks take too long to resolve, the process 434 | will perform a hard-exit after a timeout. 435 | 436 | You can specify the list of signals to handle gracefully, along with a few other 437 | options: 438 | 439 | ```ts 440 | createServer({ 441 | gracefulShutdown: { 442 | signals: ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGTSTP'], 443 | 444 | // How long to wait for the onClose hooks to resolve 445 | // before perfoming a hard-exit of the process (default 10s): 446 | timeoutMs: 20_000, 447 | 448 | // The exit code to use when hard-exiting (default 1) 449 | hardExitCode: 123 450 | } 451 | }) 452 | ``` 453 | 454 | ### Service availability monitoring & health check 455 | 456 | [`under-pressure`](https://github.com/fastify/under-pressure) 457 | is used to monitor the health of the service, and expose a health check 458 | route at `/_health`. 459 | 460 | Default configuration: 461 | 462 | - Max event loop delay: 1 second 463 | - Health check interval: 5 seconds 464 | 465 | Options for `under-pressure` can be provided under the `underPressure` 466 | key in the server options: 467 | 468 | ```ts 469 | createServer({ 470 | underPressure: { 471 | // Custom health check for testing attached services' health: 472 | healthCheck: async server => { 473 | try { 474 | const databaseOk = Boolean(await server.db.checkConnection()) 475 | // Returned data will show up in the endpoint's response: 476 | return { 477 | databaseOk 478 | } 479 | } catch (error) { 480 | server.sentry.report(error) 481 | return false 482 | } 483 | }, 484 | 485 | // You can also pass anything accepted by under-pressure options: 486 | maxEventLoopDelay: 3000 487 | } 488 | }) 489 | ``` 490 | 491 | If for some reason you wish to disable service health monitoring, you can set 492 | the `FASTIFY_MICRO_DISABLE_SERVICE_HEALTH_MONITORING` environment variable to `true`. 493 | 494 | ## Deprecated APIs 495 | 496 | - `configure` _(will be removed in v4.x)_: Use `plugins` with full `fastify-autoload` options. 497 | - `routesDir` _(will be removed in v4.x)_: Use `routes` with full `fastify-autoload` options. 498 | 499 | ## License 500 | 501 | [MIT](https://github.com/47ng/fastify-micro/blob/master/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com) 502 | 503 | Using this package at work ? [Sponsor me](https://github.com/sponsors/franky47) to help with support and maintenance. 504 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-micro", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Opinionated Node.js microservices framework built on fastify", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "license": "MIT", 8 | "author": { 9 | "name": "François Best", 10 | "email": "contact@francoisbest.com", 11 | "url": "https://francoisbest.com" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/47ng/fastify-micro" 16 | }, 17 | "keywords": [ 18 | "microservice-framework", 19 | "fastify" 20 | ], 21 | "publishConfig": { 22 | "access": "public" 23 | }, 24 | "scripts": { 25 | "test": "jest --coverage --runInBand", 26 | "test:watch": "jest --watch --runInBand", 27 | "build:clean": "rm -rf ./dist", 28 | "build:ts": "tsc", 29 | "build": "run-s build:clean build:ts", 30 | "ci": "run-s build test", 31 | "test:integration": "NODE_ENV=production ts-node ./tests/integration/main.ts", 32 | "prepare": "husky install" 33 | }, 34 | "dependencies": { 35 | "@47ng/check-env": "^2.1.0", 36 | "@sentry/node": "^6.18.1", 37 | "fastify": "^3.27.2", 38 | "fastify-autoload": "^3.11.0", 39 | "fastify-plugin": "^3.0.1", 40 | "fastify-sensible": "^3.1.2", 41 | "get-port": "^6.1.2", 42 | "nanoid": "^3.3.1", 43 | "redact-env": "^0.3.1", 44 | "sonic-boom": "^2.6.0", 45 | "under-pressure": "^5.8.0" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/config-conventional": "^16.2.1", 49 | "@types/jest": "^27.4.1", 50 | "@types/node": "^17.0.21", 51 | "@types/pino": "7.0.5", 52 | "@types/sonic-boom": "^2.1.1", 53 | "axios": "^0.26.0", 54 | "commitlint": "^16.2.1", 55 | "husky": "^7.0.4", 56 | "jest": "^27.5.1", 57 | "npm-run-all": "^4.1.5", 58 | "regenerator-runtime": "^0.13.9", 59 | "sentry-testkit": "^3.3.7", 60 | "ts-jest": "^27.1.3", 61 | "ts-node": "^10.6.0", 62 | "typescript": "^4.6.2", 63 | "wait-for-expect": "^3.0.2" 64 | }, 65 | "jest": { 66 | "verbose": true, 67 | "preset": "ts-jest/presets/js-with-ts", 68 | "testEnvironment": "node", 69 | "testPathIgnorePatterns": [ 70 | "/node_modules/", 71 | "/tests/integration/" 72 | ] 73 | }, 74 | "prettier": { 75 | "arrowParens": "avoid", 76 | "semi": false, 77 | "singleQuote": true, 78 | "tabWidth": 2, 79 | "trailingComma": "none", 80 | "useTabs": false 81 | }, 82 | "commitlint": { 83 | "extends": [ 84 | "@commitlint/config-conventional" 85 | ], 86 | "rules": { 87 | "type-enum": [ 88 | 2, 89 | "always", 90 | [ 91 | "build", 92 | "chore", 93 | "ci", 94 | "clean", 95 | "doc", 96 | "feat", 97 | "fix", 98 | "perf", 99 | "ref", 100 | "revert", 101 | "style", 102 | "test" 103 | ] 104 | ], 105 | "subject-case": [ 106 | 0, 107 | "always", 108 | "sentence-case" 109 | ], 110 | "body-leading-blank": [ 111 | 2, 112 | "always", 113 | true 114 | ] 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/graceful-shutdown.ts: -------------------------------------------------------------------------------- 1 | // Based on `fastify-graceful-shutdown`, with some tweaks: 2 | // - allow external signal handlers to be registered 3 | // - don't use specific handlers, use Fastify's `onClose` hooks. 4 | // - await async onClose hooks 5 | // - add some options 6 | 7 | import { FastifyPluginAsync } from 'fastify' 8 | import fp from 'fastify-plugin' 9 | import { performance } from 'node:perf_hooks' 10 | 11 | export interface GracefulShutdownOptions { 12 | /** 13 | * A list of signals to listen to and trigger 14 | * a graceful shutdown when received. 15 | * 16 | * Defaults to `["SIGINT", "SIGTERM"]`. 17 | */ 18 | signals?: string[] 19 | 20 | /** 21 | * How long to wait (in ms) for the signal handlers 22 | * to resolve before doing a hard exit to kill 23 | * the process with `process.exit()` 24 | * 25 | * Defaults to 10 seconds. 26 | */ 27 | timeoutMs?: number 28 | 29 | /** 30 | * The exit code to use when hard-exiting after 31 | * the timeout has expired. 32 | * 33 | * Defaults to 1. 34 | */ 35 | hardExitCode?: number 36 | } 37 | 38 | export const defaultGracefulShutdownOptions: Required = 39 | { 40 | signals: ['SIGINT', 'SIGTERM'], 41 | timeoutMs: 10_000, 42 | hardExitCode: 1 43 | } 44 | 45 | const gracefulShutdownPlugin: FastifyPluginAsync = 46 | async function gracefulShutdownPlugin(fastify, userOptions = {}) { 47 | const logger = fastify.log.child({ 48 | plugin: 'fastify-micro:graceful-shutdown' 49 | }) 50 | 51 | const options = { 52 | ...defaultGracefulShutdownOptions, 53 | ...userOptions 54 | } 55 | 56 | options.signals.forEach(signal => { 57 | process.once(signal, () => { 58 | const tick = performance.now() 59 | logger.info({ signal }, 'Received signal') 60 | const timeout = setTimeout(() => { 61 | logger.fatal({ signal }, 'Hard-exiting the process after timeout') 62 | process.exit(options.hardExitCode) 63 | }, options.timeoutMs) 64 | fastify.close().then( 65 | () => { 66 | const tock = performance.now() 67 | clearTimeout(timeout) 68 | logger.info( 69 | { signal, onCloseDuration: tock - tick }, 70 | 'Process terminated in time. Bye!' 71 | ) 72 | process.exit(0) 73 | }, 74 | error => { 75 | const tock = performance.now() 76 | logger.error( 77 | { signal, error, onCloseDuration: tock - tick }, 78 | 'Process terminated with error in `onClose` hook' 79 | ) 80 | process.exit(1) 81 | } 82 | ) 83 | }) 84 | }) 85 | } 86 | 87 | export default fp(gracefulShutdownPlugin, { 88 | fastify: '3.x', 89 | name: 'fastify-micro:graceful-shutdown' 90 | }) 91 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import checkEnv from '@47ng/check-env' 2 | import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' 3 | import { AutoloadPluginOptions, fastifyAutoload } from 'fastify-autoload' 4 | import 'fastify-sensible' 5 | import sensible from 'fastify-sensible' 6 | import underPressurePlugin from 'under-pressure' 7 | import gracefulShutdown, { GracefulShutdownOptions } from './graceful-shutdown' 8 | import { getLoggerOptions, makeReqIdGenerator } from './logger' 9 | import sentry, { SentryOptions } from './sentry' 10 | 11 | declare module 'fastify' { 12 | interface FastifyInstance { 13 | name?: string 14 | } 15 | } 16 | 17 | export type Options = FastifyServerOptions & { 18 | /** 19 | * The name of your service. 20 | * 21 | * It will show in the logs under the "from" key. 22 | */ 23 | name?: string 24 | 25 | /** 26 | * A list of environment variable names, whose values will be redacted in the logs. 27 | * 28 | * The following internal env var names will be added: 29 | * - SENTRY_DSN 30 | */ 31 | redactEnv?: string[] 32 | 33 | /** 34 | * To redact sensitive information, supply paths to log keys that hold sensitive data. 35 | * 36 | * The following headers are already redacted for security: 37 | * - req.headers["x-secret-token"] 38 | * - req.headers["x-csrf-token"] 39 | * - req.headers.cookie 40 | * - req.headers.authorization 41 | * - res.headers["set-cookie"] 42 | * 43 | * See https://getpino.io/#/docs/redaction. 44 | */ 45 | redactLogPaths?: string[] 46 | 47 | /** 48 | * @deprecated - Use `plugins` instead to load plugins from the filesystem. 49 | * 50 | * Add your own plugins in this callback. 51 | * 52 | * It's called after most built-in plugins have run, 53 | * but before loading your routes (if enabled with routesDir). 54 | * 55 | * This is where we recommend registering interfaces 56 | * to your service's data stores. 57 | */ 58 | configure?: (server: FastifyInstance) => void 59 | 60 | /** 61 | * Add custom options for under-pressure 62 | */ 63 | underPressure?: underPressurePlugin.UnderPressureOptions 64 | 65 | /** 66 | * Add custom options for graceful shutdown 67 | */ 68 | gracefulShutdown?: GracefulShutdownOptions | false 69 | 70 | /** 71 | * Add custom options for Sentry 72 | * 73 | * To enable Sentry, set the SENTRY_DSN environment variable to the 74 | * DSN (found in your project settings). 75 | */ 76 | sentry?: SentryOptions 77 | 78 | /** 79 | * Load plugins from the filesystem with `fastify-autoload`. 80 | * 81 | * Plugins are loaded before routes (see `routes` option). 82 | */ 83 | plugins?: AutoloadPluginOptions 84 | 85 | /** 86 | * Load routes from the filesystem with `fastify-autoload`. 87 | * 88 | * Routes are loaded after plugins (see `plugins` option). 89 | */ 90 | routes?: AutoloadPluginOptions 91 | 92 | /** 93 | * @deprecated - Use `routes` instead, with full `fastify-autoload` options. 94 | * 95 | * Path to a directory where to load routes. 96 | * 97 | * This directory will be walked recursively and any file encountered 98 | * will be registered as a fastify plugin. 99 | * Routes are loaded after `configure` has run (if specified). 100 | * 101 | * Pass `false` to disable (it is disabled by default). 102 | */ 103 | routesDir?: string | false 104 | 105 | /** 106 | * Run cleanup tasks before exiting. 107 | * 108 | * Eg: disconnecting backing services, closing files... 109 | */ 110 | cleanupOnExit?: (server: FastifyInstance) => Promise 111 | 112 | /** 113 | * Print routes after server has loaded 114 | * 115 | * By default, loaded routes are only printed in the console in 116 | * development, for debugging purposes. 117 | * You can use the following values: 118 | * - `auto` (default): `console` in development, silent in production. 119 | * - `console`: always pretty-print routes using `console.info` (for humans) 120 | * - `logger`: always print as NDJSON as part of the app log stream (info level) 121 | * - false: disable route printing 122 | */ 123 | printRoutes?: 'auto' | 'console' | 'logger' | false 124 | } 125 | 126 | export function createServer( 127 | options: Options = { 128 | printRoutes: 'auto', 129 | routesDir: false 130 | } 131 | ) { 132 | checkEnv({ required: ['NODE_ENV'] }) 133 | 134 | const server = Fastify({ 135 | logger: getLoggerOptions(options), 136 | genReqId: makeReqIdGenerator(), 137 | trustProxy: process.env.TRUSTED_PROXY_IPS, 138 | ...options 139 | }) 140 | if (options.name) { 141 | server.decorate('name', options.name) 142 | } 143 | 144 | server.register(sensible) 145 | server.register(sentry, options.sentry as any) 146 | 147 | try { 148 | if (options.plugins) { 149 | server.register(fastifyAutoload, options.plugins) 150 | } 151 | if (options.configure) { 152 | if (process.env.NODE_ENV === 'development') { 153 | console.warn( 154 | '[fastify-micro] Option `configure` is deprecated. Use `plugins` instead with full fastify-autoload options.' 155 | ) 156 | } 157 | options.configure(server) 158 | } 159 | 160 | const afterPlugins = server.after(error => { 161 | if (error) { 162 | throw error 163 | } 164 | }) 165 | 166 | // Registered after plugins to let the health check callback 167 | // monitor external services' health. 168 | if ( 169 | process.env.FASTIFY_MICRO_DISABLE_SERVICE_HEALTH_MONITORING !== 'true' 170 | ) { 171 | const underPressureOptions = options.underPressure || {} 172 | afterPlugins.register(underPressurePlugin, { 173 | maxEventLoopDelay: 1000, // 1s 174 | // maxHeapUsedBytes: 100 * (1 << 20), // 100 MiB 175 | // maxRssBytes: 100 * (1 << 20), // 100 MiB 176 | healthCheckInterval: 5000, // 5 seconds 177 | exposeStatusRoute: { 178 | url: '/_health', 179 | routeOpts: { 180 | logLevel: 'warn' 181 | } 182 | }, 183 | ...underPressureOptions 184 | }) 185 | } 186 | 187 | if (options.gracefulShutdown !== false) { 188 | afterPlugins.register(async fastify => { 189 | fastify.register( 190 | gracefulShutdown, 191 | options.gracefulShutdown as GracefulShutdownOptions | undefined 192 | ) 193 | }) 194 | } 195 | 196 | if (options.routes) { 197 | server.register(fastifyAutoload, options.routes) 198 | } 199 | if (options.routesDir) { 200 | if (process.env.NODE_ENV === 'development') { 201 | console.warn( 202 | '[fastify-micro] Option `routesDir` is deprecated. Use `routes` instead with full fastify-autoload options.' 203 | ) 204 | } 205 | server.register(fastifyAutoload, { 206 | dir: options.routesDir 207 | }) 208 | } 209 | 210 | if (options.cleanupOnExit) { 211 | server.addHook('onClose', options.cleanupOnExit) 212 | } 213 | 214 | server.ready(error => { 215 | if (error) { 216 | // This will let the server crash early 217 | // on plugin/routes loading errors. 218 | throw error 219 | } 220 | if (options.printRoutes === false) { 221 | return 222 | } 223 | const printRoutesOptions = { 224 | commonPrefix: false, // flatten the tree 225 | includeHooks: true 226 | } 227 | switch (options.printRoutes || 'auto') { 228 | default: 229 | case 'auto': 230 | if (process.env.NODE_ENV === 'development') { 231 | console.info(server.printRoutes(printRoutesOptions)) 232 | } 233 | break 234 | case 'console': 235 | console.info(server.printRoutes(printRoutesOptions)) 236 | break 237 | case 'logger': 238 | server.log.info({ 239 | msg: 'Routes loaded', 240 | routes: server.printRoutes(printRoutesOptions) 241 | }) 242 | break 243 | } 244 | }) 245 | } catch (error) { 246 | server.log.fatal(error) 247 | if (!server.sentry) { 248 | process.exit(1) 249 | } 250 | server.sentry 251 | .report(error as any) 252 | .catch(error => server.log.fatal(error)) 253 | .finally(() => process.exit(1)) 254 | } 255 | return server 256 | } 257 | 258 | /** 259 | * Wait for the server to be ready and start listening. 260 | * 261 | * The server is ready when all async plugins have finished loading. 262 | * 263 | * @param server - An instance of Fastify 264 | * @param port - Optional, the port to listen to. 265 | * Defaults to the value of the PORT environment variable, 266 | * or 3000 if not specified in the environment either. 267 | */ 268 | export async function startServer( 269 | server: FastifyInstance, 270 | port: number = parseInt(process.env.PORT || '3000') || 3000 271 | ) { 272 | await server.ready().then( 273 | () => { 274 | server.log.debug('Starting server') 275 | }, 276 | error => { 277 | if (error) { 278 | throw error 279 | } 280 | } 281 | ) 282 | return await new Promise(resolve => { 283 | server.listen({ port, host: '0.0.0.0' }, (error, address) => { 284 | if (error) { 285 | server.log.fatal({ msg: `Application startup error`, error, address }) 286 | process.exit(1) 287 | } else { 288 | resolve(server) 289 | } 290 | }) 291 | }) 292 | } 293 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyLoggerOptions, FastifyRequest } from 'fastify' 2 | import { nanoid } from 'nanoid' 3 | import crypto from 'node:crypto' 4 | import pino from 'pino' 5 | import redactEnv from 'redact-env' 6 | import SonicBoom from 'sonic-boom' 7 | import type { Options } from './index' 8 | 9 | function createRedactedStream( 10 | pipeTo: SonicBoom, 11 | secureEnv: string[] 12 | ): SonicBoom { 13 | const secrets = redactEnv.build(secureEnv, process.env) 14 | return Object.assign({}, pipeTo, { 15 | write: function writeRedacted(string: string) { 16 | const safeString = redactEnv.redact(string, secrets, '[secure]') 17 | return pipeTo.write(safeString) 18 | } 19 | }) 20 | } 21 | 22 | export function getLoggerOptions({ 23 | name, 24 | redactEnv = [], 25 | redactLogPaths = [] 26 | }: Options): FastifyLoggerOptions & pino.LoggerOptions & { stream: any } { 27 | return { 28 | level: 29 | process.env.LOG_LEVEL || 30 | (process.env.DEBUG === 'true' ? 'debug' : 'info'), 31 | redact: [ 32 | // Security redactions 33 | 'req.headers["x-secret-token"]', 34 | 'req.headers["x-csrf-token"]', 35 | 'req.headers.cookie', 36 | 'req.headers.authorization', 37 | 'res.headers["set-cookie"]', 38 | ...redactLogPaths 39 | ], 40 | stream: createRedactedStream(pino.destination(1), [ 41 | 'SENTRY_DSN', 42 | ...redactEnv 43 | ]), 44 | base: { 45 | from: name, 46 | instance: process.env.INSTANCE_ID?.slice(0, 8), 47 | commit: process.env.COMMIT_ID?.slice(0, 8) 48 | }, 49 | serializers: { 50 | req(req) { 51 | return { 52 | method: req.method, 53 | url: req.url, 54 | headers: req.headers 55 | } 56 | }, 57 | res(res) { 58 | // Response has already be sent at time of logging, 59 | // so we need to parse the headers to log them. 60 | // Trying to collect them earlier to show them here 61 | // is flaky and tightly couples things, moreover these 62 | // are the source of truth for what was sent to the user, 63 | // and includes framework-managed headers such as content-length. 64 | const headers = (((res as any)._header || '') as string) 65 | .split('\r\n') 66 | .slice(1) // Remove HTTP/1.1 {statusCode} {statusText} 67 | .reduce((obj, header: string) => { 68 | try { 69 | const [name, ...rest] = header.split(': ') 70 | if (['', 'date', 'connection'].includes(name.toLowerCase())) { 71 | return obj // Ignore those 72 | } 73 | const value = 74 | name === 'content-length' 75 | ? parseInt(rest[0], 10) 76 | : rest.join(': ') 77 | return { 78 | ...obj, 79 | [name]: value 80 | } 81 | } catch { 82 | return obj 83 | } 84 | }, {}) 85 | return { 86 | statusCode: res.statusCode, 87 | headers 88 | } 89 | } 90 | } 91 | } 92 | } 93 | 94 | export const makeReqIdGenerator = (defaultSalt: string = nanoid()) => 95 | function genReqId(req: FastifyRequest): string { 96 | let ipAddress: string = '' 97 | const xForwardedFor = req.headers['x-forwarded-for'] 98 | if (xForwardedFor) { 99 | ipAddress = 100 | typeof xForwardedFor === 'string' 101 | ? xForwardedFor.split(',')[0] 102 | : xForwardedFor[0].split(',')[0] 103 | } else { 104 | ipAddress = req.socket?.remoteAddress || '' 105 | } 106 | const hash = crypto.createHash('sha256') 107 | hash.update(ipAddress) 108 | hash.update(req.headers['user-agent'] || '') 109 | hash.update(process.env.LOG_FINGERPRINT_SALT || defaultSalt) 110 | const fingerprint = hash 111 | .digest('base64') 112 | .slice(0, 16) 113 | .replace(/\+/g, '-') 114 | .replace(/\//g, '_') 115 | 116 | return [fingerprint, nanoid(16)].join('.') 117 | } 118 | -------------------------------------------------------------------------------- /src/sentry.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node' 2 | import type { 3 | FastifyError, 4 | FastifyInstance, 5 | FastifyPluginCallback, 6 | FastifyRequest 7 | } from 'fastify' 8 | import fp from 'fastify-plugin' 9 | 10 | declare module 'fastify' { 11 | interface FastifyInstance { 12 | sentry: SentryDecoration 13 | } 14 | interface FastifyRequest { 15 | sentry: SentryDecoration 16 | } 17 | } 18 | 19 | export interface SentryExtra { 20 | /** 21 | * Tags are searchable key/value pairs, useful for filtering issues/events. 22 | */ 23 | tags: { 24 | [key: string]: string 25 | } 26 | /** 27 | * Context is additional data attached to an issue/event, 28 | * values here are not searchable. 29 | */ 30 | context: { 31 | [key: string]: any 32 | } 33 | } 34 | 35 | export type SentryReportFn = ( 36 | error: unknown, 37 | extra?: Partial 38 | ) => Promise 39 | 40 | export interface SentryDecoration { 41 | report: SentryReportFn 42 | } 43 | 44 | export interface SentryOptions extends Sentry.NodeOptions { 45 | getUser?: ( 46 | server: FastifyInstance, 47 | req: R 48 | ) => Promise 49 | getExtra?: ( 50 | server: FastifyInstance, 51 | req?: R 52 | ) => Promise> 53 | } 54 | 55 | // -- 56 | 57 | function sentryPlugin( 58 | server: FastifyInstance, 59 | options: SentryOptions, 60 | next: (err?: FastifyError) => void 61 | ) { 62 | Sentry.init({ 63 | dsn: process.env.SENTRY_DSN, 64 | release: options.release ?? process.env.SENTRY_RELEASE, 65 | environment: process.env.NODE_ENV, 66 | enabled: !!process.env.SENTRY_DSN, 67 | ...options 68 | }) 69 | 70 | const makeDecoration = (req?: FastifyRequest): SentryDecoration => ({ 71 | async report(error, extra = {}) { 72 | let user: Sentry.User | undefined 73 | if (options.getUser && req) { 74 | try { 75 | user = { 76 | ip_address: req.ip, 77 | ...(await options.getUser(server, req)) 78 | } 79 | } catch {} 80 | } 81 | let extraTags = extra.tags || {} 82 | let extraContext = extra.context || {} 83 | if (options.getExtra) { 84 | try { 85 | const globalExtra = await options.getExtra(server, req) 86 | extraTags = { 87 | ...extraTags, 88 | ...globalExtra.tags 89 | } 90 | extraContext = { 91 | ...extraContext, 92 | ...globalExtra.context 93 | } 94 | } catch {} 95 | } 96 | Sentry.withScope(scope => { 97 | if (user) { 98 | scope.setUser(user) 99 | } 100 | scope.setTags({ 101 | path: req?.raw.url ?? 'Not available', 102 | ...(server.name ? { service: server.name } : {}), 103 | ...extraTags 104 | }) 105 | scope.setExtras({ 106 | 'request ID': req?.id, 107 | instance: process.env.INSTANCE_ID?.slice(0, 8) ?? 'Not available', 108 | commit: process.env.COMMIT_ID?.slice(0, 8), 109 | ...extraContext 110 | }) 111 | Sentry.captureException(error) 112 | }) 113 | } 114 | }) 115 | 116 | server.decorate('sentry', makeDecoration()) 117 | // https://www.fastify.io/docs/latest/Decorators/#decoraterequest 118 | server.decorateRequest('sentry', null) 119 | server.addHook('onRequest', async req => { 120 | req.sentry = makeDecoration(req) 121 | }) 122 | 123 | server.setErrorHandler(async (error, req, res) => { 124 | if ( 125 | (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) || 126 | error.validation 127 | ) { 128 | req.log.warn({ err: error }) 129 | } else { 130 | req.log.error({ err: error }) 131 | } 132 | 133 | if (error.validation) { 134 | return res.status(400).send(error) 135 | } 136 | if (error.statusCode) { 137 | // Error object already contains useful information 138 | return res.send(error) 139 | } 140 | 141 | // Report the error to Sentry 142 | await req.sentry.report(error) 143 | 144 | // Pass to the generic error handler (500) 145 | return res.send(error) 146 | }) 147 | next() 148 | } 149 | 150 | export default fp(sentryPlugin as FastifyPluginCallback, { 151 | fastify: '3.x', 152 | name: 'fastify-micro:sentry' 153 | }) 154 | -------------------------------------------------------------------------------- /tests/basics.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { nanoid } from 'nanoid' 3 | import { createServer, startServer } from '../src' 4 | 5 | describe('Basics', () => { 6 | beforeEach(() => { 7 | process.env.LOG_LEVEL = 'silent' 8 | }) 9 | 10 | test('The specified `name` property is injected', () => { 11 | const unnamedServer = createServer() 12 | expect(unnamedServer.name).toBeUndefined() 13 | const namedServer = createServer({ name: 'foo' }) 14 | expect(namedServer.name).toBe('foo') 15 | }) 16 | 17 | test('Default port should be 3000', async () => { 18 | const key = nanoid() 19 | const server = createServer() 20 | server.get('/', (_, res) => { 21 | res.send({ key }) 22 | }) 23 | await startServer(server) 24 | const res = await axios.get('http://localhost:3000/') 25 | expect(res.data.key).toEqual(key) 26 | await server.close() 27 | }) 28 | 29 | test('Port should be configurable via the environment', async () => { 30 | process.env.PORT = '3001' 31 | const key = nanoid() 32 | const server = createServer() 33 | server.get('/', (_, res) => { 34 | res.send({ key }) 35 | }) 36 | await startServer(server) 37 | const res = await axios.get('http://localhost:3001/') 38 | expect(res.data.key).toEqual(key) 39 | await server.close() 40 | process.env.PORT = undefined 41 | }) 42 | 43 | test('Port can be passed as a second argument to `startServer`', async () => { 44 | const server = createServer() 45 | const key = nanoid() 46 | server.get('/', (_, res) => { 47 | res.send({ key }) 48 | }) 49 | await startServer(server, 3002) 50 | const res = await axios.get('http://localhost:3002/') 51 | expect(res.data.key).toEqual(key) 52 | await server.close() 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /tests/graceful.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer, startServer } from '../src' 2 | import { delay } from './jigs/delay' 3 | 4 | describe('Graceful shutdown', () => { 5 | test('with custom handler', async () => { 6 | const server = createServer({ name: 'foo' }) 7 | server.addHook('onClose', (fastify, done) => { 8 | expect(fastify.name).toEqual('foo') 9 | delay(100).then(() => done()) 10 | }) 11 | await startServer(server) 12 | await server.close() 13 | }) 14 | 15 | test('with custom async handler', async () => { 16 | const server = createServer({ name: 'foo' }) 17 | server.addHook('onClose', async fastify => { 18 | await delay(100) 19 | expect(fastify.name).toEqual('foo') 20 | }) 21 | await startServer(server) 22 | await server.close() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/health.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from '../src' 2 | 3 | describe('Health checks', () => { 4 | beforeEach(() => { 5 | process.env.LOG_LEVEL = 'silent' 6 | }) 7 | 8 | test('Default configuration exposes a /_health route', async () => { 9 | const server = createServer() 10 | await server.ready() 11 | const res = await server.inject({ method: 'GET', url: '/_health' }) 12 | expect(res.statusCode).toEqual(200) 13 | expect(res.json()).toEqual({ status: 'ok' }) 14 | }) 15 | 16 | test('Custom health check handler is called at startup and every 5 seconds', async () => { 17 | jest.useFakeTimers('legacy') 18 | const healthCheck = jest.fn().mockResolvedValue(true) 19 | const server = createServer({ 20 | underPressure: { 21 | healthCheck 22 | } 23 | }) 24 | await server.ready() 25 | expect(healthCheck).toHaveBeenCalledTimes(1) 26 | jest.advanceTimersByTime(5000) 27 | expect(healthCheck).toHaveBeenCalledTimes(2) 28 | jest.useRealTimers() 29 | }) 30 | 31 | test('Custom health check throwing results in 503', async () => { 32 | jest.useFakeTimers('legacy') 33 | const healthCheck = jest 34 | .fn() 35 | .mockResolvedValueOnce(true) // First call passes (setup) 36 | .mockResolvedValueOnce(true) // Second call passes (GET /_health 1) 37 | .mockRejectedValueOnce(false) // Then it fails (GET /_health 2) 38 | const server = createServer({ 39 | underPressure: { 40 | healthCheck, 41 | exposeStatusRoute: { 42 | url: '/_health', 43 | routeOpts: { 44 | logLevel: 'silent' 45 | } 46 | } 47 | } 48 | }) 49 | await server.ready() 50 | const res1 = await server.inject({ method: 'GET', url: '/_health' }) 51 | expect(res1.statusCode).toEqual(200) 52 | expect(res1.json()).toEqual({ status: 'ok' }) 53 | jest.advanceTimersByTime(5000) 54 | const res2 = await server.inject({ method: 'GET', url: '/_health' }) 55 | expect(res2.statusCode).toEqual(503) 56 | expect(res2.json()).toEqual({ 57 | code: 'FST_UNDER_PRESSURE', 58 | error: 'Service Unavailable', 59 | message: 'Service Unavailable', 60 | statusCode: 503 61 | }) 62 | jest.useRealTimers() 63 | }) 64 | 65 | test('Disabled health monitoring', async () => { 66 | process.env.FASTIFY_MICRO_DISABLE_SERVICE_HEALTH_MONITORING = 'true' 67 | jest.useFakeTimers('legacy') 68 | const healthCheck = jest.fn().mockResolvedValue(true) 69 | const server = createServer({ 70 | underPressure: { 71 | healthCheck 72 | } 73 | }) 74 | await server.ready() 75 | expect(healthCheck).not.toHaveBeenCalled() 76 | jest.advanceTimersByTime(5000) 77 | expect(healthCheck).not.toHaveBeenCalled() 78 | jest.useRealTimers() 79 | process.env.FASTIFY_MICRO_DISABLE_SERVICE_HEALTH_MONITORING = undefined 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /tests/integration/main.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { createServer, startServer } from '../../dist' 3 | 4 | async function main() { 5 | const server = createServer({ 6 | name: 'integration-test', 7 | printRoutes: 'console', 8 | routes: { 9 | dir: path.resolve(__dirname, './routes') 10 | } 11 | }) 12 | await startServer(server) 13 | } 14 | 15 | // -- 16 | 17 | if (require.main === module) { 18 | main() 19 | } 20 | -------------------------------------------------------------------------------- /tests/integration/routes/test.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | 3 | export default async (fastify: FastifyInstance) => { 4 | fastify.get('/routes-loaded-ok', async (req, res) => { 5 | res.send(req.headers) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /tests/jigs/delay.ts: -------------------------------------------------------------------------------- 1 | export async function delay(timeMs: number) { 2 | return new Promise(resolve => setTimeout(resolve, timeMs)) 3 | } 4 | -------------------------------------------------------------------------------- /tests/jigs/log-testing.fixture.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { createServer } from '../../src' 3 | 4 | async function main() { 5 | process.env.SECRET = 'supersecret' 6 | process.env.LOG_FINGERPRINT_SALT = 'make-tests-reproducible' 7 | const server = createServer({ 8 | name: 'foo', 9 | routesDir: path.resolve(__dirname, './routes'), 10 | redactEnv: ['SECRET'], 11 | redactLogPaths: ['secret'] 12 | }) 13 | await server.ready().then( 14 | () => {}, 15 | error => { 16 | throw error 17 | } 18 | ) 19 | await server.inject({ method: 'GET', path: '/foo?req1' }) 20 | await server.inject({ method: 'GET', path: '/foo?req2' }) 21 | await server.inject({ 22 | method: 'GET', 23 | path: '/foo?req3', 24 | remoteAddress: '1.2.3.4' 25 | }) 26 | await server.inject({ 27 | method: 'GET', 28 | path: '/foo?req4', 29 | headers: { 30 | 'user-agent': 'test that user-agent changes the reqId user fingerprint' 31 | } 32 | }) 33 | server.log.info({ dataTestID: 'name' }) 34 | server.log.info({ dataTestID: 'redact-env', env: process.env.SECRET }) 35 | server.log.info({ dataTestID: 'redact-path', secret: 'secret' }) 36 | } 37 | 38 | if (require.main === module) { 39 | main() 40 | } 41 | -------------------------------------------------------------------------------- /tests/jigs/log-testing.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'node:child_process' 2 | import path from 'node:path' 3 | 4 | export async function testLogs( 5 | matcher: (logLine: T) => boolean, 6 | expectedCount = 1 7 | ) { 8 | const subprocessFilePath = path.resolve(__dirname, 'log-testing.fixture.ts') 9 | const testProcess = spawn('ts-node', [subprocessFilePath]) 10 | const logLines: Set = new Set() 11 | 12 | return new Promise(resolve => { 13 | const callback = (data: string) => { 14 | data 15 | .toString() 16 | .split('\n') 17 | .filter(line => line.startsWith('{') && line.endsWith('}')) 18 | .map(text => JSON.parse(text)) 19 | .filter(matcher) 20 | .forEach(item => logLines.add(item)) 21 | 22 | if (logLines.size >= expectedCount) { 23 | testProcess.kill('SIGINT') 24 | testProcess.stdout.off('data', callback) 25 | resolve(Array.from(logLines.values())) 26 | } 27 | } 28 | testProcess.stdout.on('data', callback) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /tests/jigs/plugins/decorator.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | import fp from 'fastify-plugin' 3 | 4 | declare module 'fastify' { 5 | interface FastifyInstance { 6 | testPlugin: string 7 | } 8 | } 9 | 10 | export default fp(async (app: FastifyInstance) => { 11 | app.decorate('testPlugin', 'works') 12 | }) 13 | -------------------------------------------------------------------------------- /tests/jigs/routes/foo.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from 'fastify' 2 | 3 | export default async (app: FastifyInstance) => { 4 | app.get('/foo', async () => { 5 | return { foo: 'bar' } 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /tests/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { testLogs } from './jigs/log-testing' 2 | 3 | describe('Logger', () => { 4 | test('name property', async () => { 5 | const [logLine] = await testLogs(logLine => logLine.dataTestID === 'name') 6 | expect(logLine.from).toEqual('foo') 7 | }) 8 | 9 | test('redact environment variables', async () => { 10 | const [logLine] = await testLogs( 11 | logLine => logLine.dataTestID === 'redact-env' 12 | ) 13 | expect(logLine.env).toEqual('[secure]') 14 | }) 15 | 16 | test('redact log paths', async () => { 17 | const [logLine] = await testLogs( 18 | logLine => logLine.dataTestID === 'redact-path' 19 | ) 20 | expect(logLine.secret).toEqual('[Redacted]') 21 | }) 22 | 23 | test('request ID generator', async () => { 24 | type ReqResLog = { reqId: string } 25 | 26 | const [req1, res1, req2, res2, req3, res3, req4, res4] = 27 | await testLogs(logLine => Boolean(logLine.reqId), 8) 28 | const [clientId1, requestId1] = req1.reqId.split('.') 29 | const [clientId2, requestId2] = req2.reqId.split('.') 30 | const [clientId3, requestId3] = req3.reqId.split('.') 31 | const [clientId4, requestId4] = req4.reqId.split('.') 32 | expect(req1.reqId).toEqual(res1.reqId) 33 | expect(req2.reqId).toEqual(res2.reqId) 34 | expect(req3.reqId).toEqual(res3.reqId) 35 | expect(req4.reqId).toEqual(res4.reqId) 36 | expect(clientId1).toEqual('0GC65G13aziD6-bt') 37 | expect(clientId2).toEqual('0GC65G13aziD6-bt') 38 | expect(clientId3).toEqual('GTpLN93VxyzzyOyZ') 39 | expect(clientId4).toEqual('d5l1mrd8IzLwejLW') 40 | expect(requestId1).not.toEqual(requestId2) 41 | expect(requestId1).not.toEqual(requestId3) 42 | expect(requestId1).not.toEqual(requestId4) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tests/plugins.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { nanoid } from 'nanoid' 3 | import path from 'path' 4 | import { createServer, startServer } from '../src' 5 | import { delay } from './jigs/delay' 6 | import './jigs/plugins/decorator' // for declaration merging 7 | 8 | describe('Plugins', () => { 9 | beforeEach(() => { 10 | process.env.LOG_LEVEL = 'silent' 11 | }) 12 | 13 | test('Loading plugins & routes from filesystem', async () => { 14 | const server = createServer({ 15 | plugins: { 16 | dir: path.resolve(__dirname, './jigs/plugins') 17 | }, 18 | routes: { 19 | dir: path.resolve(__dirname, './jigs/routes') 20 | } 21 | }) 22 | await server.ready() 23 | expect(server.testPlugin).toEqual('works') 24 | const res = await server.inject({ method: 'GET', url: '/foo' }) 25 | expect(res.statusCode).toEqual(200) 26 | expect(res.json()).toEqual({ foo: 'bar' }) 27 | }) 28 | 29 | test('Graceful exit', async () => { 30 | //jest.setTimeout(10000) 31 | const key = nanoid() 32 | const server = createServer() 33 | server.get('/', async (_, res) => { 34 | await delay(1000) 35 | res.send({ key }) 36 | }) 37 | await startServer(server) 38 | const resBeforeP = axios.get('http://localhost:3000/') 39 | await delay(100) // Give time to the request to start before shutting down the server 40 | await server.close() 41 | const resBefore = await resBeforeP 42 | expect(resBefore.data.key).toEqual(key) 43 | }) 44 | 45 | test('Graceful exit with a slow onClose hook', async () => { 46 | const key = nanoid() 47 | const server = createServer() 48 | server.get('/', async (_, res) => { 49 | await delay(1000) 50 | res.send({ key }) 51 | }) 52 | server.addHook('onClose', async (_, done) => { 53 | await delay(3000) // Simulate slow shutdown of backing services 54 | done() 55 | }) 56 | await startServer(server) 57 | const resBeforeP = axios.get('http://localhost:3000/') 58 | await delay(100) // Give time to the request to start before shutting down the server 59 | await server.close() 60 | const resBefore = await resBeforeP 61 | expect(resBefore.data.key).toEqual(key) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /tests/sentry.test.ts: -------------------------------------------------------------------------------- 1 | import sentryTestkit from 'sentry-testkit' 2 | import waitForExpect from 'wait-for-expect' 3 | import { createServer } from '../src/index' 4 | 5 | describe('Sentry reporting', () => { 6 | const OLD_ENV = process.env 7 | 8 | beforeEach(() => { 9 | jest.resetModules() // this is important - it clears the cache 10 | process.env = { ...OLD_ENV } 11 | delete process.env.NODE_ENV 12 | }) 13 | 14 | afterEach(() => { 15 | process.env = OLD_ENV 16 | }) 17 | 18 | const setupEnv = () => { 19 | process.env.NODE_ENV = 'test' 20 | process.env.LOG_LEVEL = 'silent' 21 | process.env.SENTRY_DSN = 22 | 'https://00000000000000000000000000000000@sentry.io/000001' 23 | } 24 | 25 | // -- 26 | 27 | test('Sentry is disabled without a DSN', async () => { 28 | const { testkit, sentryTransport } = sentryTestkit() 29 | process.env.NODE_ENV = 'test' 30 | process.env.LOG_LEVEL = 'silent' 31 | const server = createServer({ 32 | sentry: { 33 | transport: sentryTransport 34 | } 35 | }) 36 | await server.ready() 37 | await server.sentry.report(new Error('crash')) 38 | waitForExpect(() => { 39 | expect(testkit.reports()).toHaveLength(0) 40 | }) 41 | }) 42 | 43 | // -- 44 | 45 | test('Sentry is enabled with a DSN', async () => { 46 | const { testkit, sentryTransport } = sentryTestkit() 47 | setupEnv() 48 | const server = createServer({ 49 | sentry: { 50 | transport: sentryTransport 51 | } 52 | }) 53 | await server.ready() 54 | await server.sentry.report(new Error('crash')) 55 | await waitForExpect(() => { 56 | expect(testkit.reports()).toHaveLength(1) 57 | }) 58 | const report = testkit.reports()[0] 59 | expect(report.error?.message).toEqual('crash') 60 | }) 61 | 62 | // -- 63 | 64 | test('Sentry report for route errors', async () => { 65 | const { testkit, sentryTransport } = sentryTestkit() 66 | setupEnv() 67 | const server = createServer({ 68 | sentry: { 69 | transport: sentryTransport 70 | } 71 | }) 72 | server.get('/', async () => { 73 | throw new Error('crash') 74 | }) 75 | await server.ready() 76 | const res = await server.inject({ method: 'GET', url: '/' }) 77 | expect(res.statusCode).toEqual(500) 78 | expect((res.json() as any).message).toEqual('crash') 79 | await waitForExpect(() => { 80 | expect(testkit.reports()).toHaveLength(1) 81 | }) 82 | const report = testkit.reports()[0] 83 | expect(report.error?.message).toEqual('crash') 84 | }) 85 | 86 | // -- 87 | 88 | test('Sentry manual reports at app level', async () => { 89 | const { testkit, sentryTransport } = sentryTestkit() 90 | setupEnv() 91 | const server = createServer({ 92 | sentry: { 93 | transport: sentryTransport 94 | } 95 | }) 96 | await server.ready() 97 | server.sentry.report(new Error('manual')) 98 | await waitForExpect(() => { 99 | expect(testkit.reports()).toHaveLength(1) 100 | }) 101 | const report = testkit.reports()[0] 102 | expect(report.error?.message).toEqual('manual') 103 | }) 104 | 105 | // -- 106 | 107 | test('Report enrichment in route', async () => { 108 | const { testkit, sentryTransport } = sentryTestkit() 109 | setupEnv() 110 | const server = createServer({ 111 | sentry: { 112 | transport: sentryTransport 113 | } 114 | }) 115 | server.get('/', async req => { 116 | await req.sentry.report(new Error('crash'), { 117 | tags: { 118 | foo: 'bar' 119 | }, 120 | context: { 121 | egg: 'spam' 122 | } 123 | }) 124 | return 'foo' 125 | }) 126 | await server.ready() 127 | await server.inject({ method: 'GET', url: '/' }) 128 | await waitForExpect(() => { 129 | expect(testkit.reports()).toHaveLength(1) 130 | }) 131 | const report = testkit.reports()[0] 132 | expect(report.error?.message).toEqual('crash') 133 | expect(report.tags.path).toEqual('/') 134 | expect(report.tags.foo).toEqual('bar') 135 | expect(report.extra?.egg).toEqual('spam') 136 | }) 137 | 138 | // -- 139 | 140 | test('Report enrichment, global getters', async () => { 141 | const { testkit, sentryTransport } = sentryTestkit() 142 | setupEnv() 143 | process.env.COMMIT_ID = 'git-sha1' 144 | process.env.INSTANCE_ID = 'localdev' 145 | const sentryOptions = { 146 | transport: sentryTransport, 147 | release: 'test-release', 148 | getExtra: () => 149 | Promise.resolve({ 150 | tags: { 151 | foo: 'bar' 152 | }, 153 | context: { 154 | egg: 'spam' 155 | } 156 | }), 157 | getUser: jest.fn() 158 | } 159 | 160 | const server = createServer({ 161 | name: 'test-server', 162 | sentry: sentryOptions 163 | }) 164 | await server.ready() 165 | await server.sentry.report(new Error('crash')) 166 | await waitForExpect(() => { 167 | expect(testkit.reports()).toHaveLength(1) 168 | }) 169 | // getUser is only called in the context of a request 170 | expect(sentryOptions.getUser).not.toHaveBeenCalled() 171 | const report = testkit.reports()[0] 172 | expect(report.error?.message).toEqual('crash') 173 | expect(report.tags.service).toEqual('test-server') 174 | expect(report.tags.foo).toEqual('bar') 175 | expect(report.extra?.egg).toEqual('spam') 176 | expect(report.extra?.commit).toEqual('git-sha1') 177 | expect(report.extra?.instance).toEqual('localdev') 178 | expect(report.release).toEqual('test-release') 179 | }) 180 | 181 | // -- 182 | 183 | test('User enrichment', async () => { 184 | const { testkit, sentryTransport } = sentryTestkit() 185 | setupEnv() 186 | const server = createServer({ 187 | sentry: { 188 | transport: sentryTransport, 189 | getUser: () => 190 | Promise.resolve({ 191 | email: 'foo@bar.com', 192 | username: 'foobar', 193 | id: 'eggspam' 194 | }) 195 | } 196 | }) 197 | server.get('/', async req => { 198 | await req.sentry.report(new Error('crash')) 199 | return 'foo' 200 | }) 201 | await server.ready() 202 | await server.inject({ method: 'GET', url: '/' }) 203 | await waitForExpect(() => { 204 | expect(testkit.reports()).toHaveLength(1) 205 | }) 206 | const report = testkit.reports()[0] 207 | expect(report.user?.email).toEqual('foo@bar.com') 208 | expect(report.user?.username).toEqual('foobar') 209 | expect(report.user?.id).toEqual('eggspam') 210 | }) 211 | 212 | // -- 213 | 214 | test('4xx statuses are not reported to Sentry', async () => { 215 | const { testkit, sentryTransport } = sentryTestkit() 216 | setupEnv() 217 | const server = createServer({ 218 | sentry: { 219 | transport: sentryTransport 220 | } 221 | }) 222 | server.get('/', async () => { 223 | throw server.httpErrors.unauthorized('nope') 224 | }) 225 | await server.ready() 226 | await server.inject({ method: 'GET', url: '/' }) 227 | await new Promise(r => setTimeout(r, 250)) 228 | expect(testkit.reports()).toHaveLength(0) 229 | }) 230 | 231 | // -- 232 | 233 | test('Schema validation errors are not reported to Sentry', async () => { 234 | const { testkit, sentryTransport } = sentryTestkit() 235 | setupEnv() 236 | const server = createServer({ 237 | sentry: { 238 | transport: sentryTransport 239 | } 240 | }) 241 | server.post<{ Body: { name: string } }>( 242 | '/', 243 | { 244 | schema: { 245 | body: { 246 | type: 'object', 247 | required: ['name'], 248 | properties: { 249 | name: { type: 'string' } 250 | } 251 | } 252 | } 253 | }, 254 | async req => { 255 | return `Hello, ${req.body.name}` 256 | } 257 | ) 258 | await server.ready() 259 | const res = await server.inject({ method: 'POST', url: '/' }) 260 | expect(res.statusCode).toEqual(400) 261 | await new Promise(r => setTimeout(r, 250)) 262 | expect(testkit.reports()).toHaveLength(0) 263 | }) 264 | }) 265 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es2018", 5 | "lib": ["DOM"], 6 | "module": "commonjs", 7 | "declaration": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "esModuleInterop": true, 11 | 12 | // Strict Type-Checking Options 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "strictPropertyInitialization": true, 18 | "noImplicitThis": true, 19 | "alwaysStrict": true, 20 | 21 | // Additional Checks 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | 27 | // Experimental Options 28 | "experimentalDecorators": true, 29 | "emitDecoratorMetadata": true, 30 | 31 | }, 32 | "ts-node": { 33 | "logError": true 34 | }, 35 | "include": ["./src/**/*.ts", "src/sentry.test.ts"], 36 | "exclude": ["./tests/**/*.test.ts", "./dist", "./node_modules"] 37 | } 38 | --------------------------------------------------------------------------------