├── .github └── workflows │ ├── publish.yaml │ ├── summary.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── adonisjs │ ├── index.ts │ ├── middleware.ts │ └── provider.ts ├── common │ ├── client.ts │ ├── consumerRegistry.ts │ ├── logging.ts │ ├── packageVersions.ts │ ├── paramValidation.ts │ ├── requestCounter.ts │ ├── requestLogger.ts │ ├── sentry.ts │ ├── serverErrorCounter.ts │ ├── tempGzipFile.ts │ ├── types.ts │ ├── utils.ts │ └── validationErrorCounter.ts ├── express │ ├── index.ts │ ├── middleware.ts │ └── utils.js ├── fastify │ ├── index.ts │ └── plugin.ts ├── hono │ ├── index.ts │ └── middleware.ts ├── index.ts ├── koa │ ├── index.ts │ └── middleware.ts └── nestjs │ └── index.ts ├── tests ├── adonisjs │ ├── app.test.ts │ └── app.ts ├── common │ ├── client.test.ts │ ├── consumerRegistry.test.ts │ ├── packageVersions.test.ts │ ├── requestLogger.test.ts │ ├── serverErrorCounter.test.ts │ └── tempGzipFile.test.ts ├── express │ ├── app.test.ts │ └── app.ts ├── fastify │ ├── app.test.ts │ └── app.ts ├── hono │ ├── app.test.ts │ └── app.ts ├── koa │ ├── app.test.ts │ └── app.ts ├── nestjs │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.test.ts │ └── app.ts └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | environment: release 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | fetch-depth: 0 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 22.4.x 16 | cache: npm 17 | registry-url: https://registry.npmjs.org 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Run checks 21 | run: npm run check 22 | - name: Run tests 23 | run: npm test 24 | - name: Build package 25 | run: npm run build 26 | - name: Update version in package.json 27 | run: npm version from-git --no-git-tag-version 28 | - name: Publish to npm 29 | run: npm publish 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/summary.yaml: -------------------------------------------------------------------------------- 1 | name: Summary 2 | on: 3 | push: 4 | branches-ignore: 5 | - main 6 | jobs: 7 | wait-for-triggered-checks: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | checks: read 11 | steps: 12 | - name: Wait for all triggered status checks 13 | uses: poseidon/wait-for-status-checks@v0.6.0 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | ignore_pattern: ^codecov/.+ 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | paths-ignore: 5 | - .gitignore 6 | - LICENSE 7 | - README.md 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | check-pre-commit: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.13" 20 | - uses: pre-commit/action@v3.0.1 21 | 22 | check-attw: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 22.4.x 29 | cache: npm 30 | - name: Install dependencies 31 | run: npm ci 32 | - name: Build package 33 | run: npm run build 34 | - name: Check if types are wrong with attw 35 | run: npx -p @arethetypeswrong/cli attw --pack . 36 | 37 | test-coverage: 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: 22.4.x 44 | cache: npm 45 | - name: Install dependencies 46 | run: npm ci 47 | - name: Run checks 48 | run: npm run check 49 | - name: Run tests with coverage 50 | run: npm test 51 | - name: Upload coverage report to Codecov 52 | uses: codecov/codecov-action@v5 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | directory: ./coverage/ 56 | 57 | test-matrix: 58 | runs-on: ubuntu-latest 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | node: ["18", "20", "22.4.x"] 63 | deps: 64 | - express@4.21 65 | - express@4.19 66 | - express@4.18 67 | - fastify@5 fastify-plugin@5 68 | - fastify@4 fastify-plugin@4 69 | - fastify@3 fastify-plugin@3 70 | - koa@2.15 71 | - koa@2.14 72 | - koa@2.13 73 | - "@nestjs/core@10 @nestjs/common@10 @nestjs/platform-express@10 @nestjs/testing@10" 74 | - "@nestjs/core@9 @nestjs/common@9 @nestjs/platform-express@9 @nestjs/testing@9" 75 | - "@nestjs/core@8 @nestjs/common@8 @nestjs/platform-express@8 @nestjs/testing@8" 76 | - hono@4.6 77 | - hono@4.5 78 | - hono@4.4 79 | - "@adonisjs/core@6.17" 80 | - "@adonisjs/core@6.12" 81 | steps: 82 | - uses: actions/checkout@v4 83 | - uses: actions/setup-node@v4 84 | with: 85 | node-version: "${{ matrix.node }}" 86 | cache: npm 87 | - name: Install dependencies 88 | run: npm ci 89 | - name: Override dependencies from matrix 90 | run: npm install --no-save ${{ matrix.deps }} 91 | - name: Run tests for Express 92 | run: npm test -- tests/express 93 | if: contains(matrix.deps, 'express') 94 | - name: Run tests for Fastify 95 | run: npm test -- tests/fastify 96 | if: contains(matrix.deps, 'fastify') 97 | - name: Run tests for Koa 98 | run: npm test -- tests/koa 99 | if: contains(matrix.deps, 'koa') 100 | - name: Run tests for NestJS 101 | run: npm test -- tests/nestjs 102 | if: contains(matrix.deps, 'nestjs') 103 | - name: Run tests for Hono 104 | run: npm test -- tests/hono 105 | if: contains(matrix.deps, 'hono') 106 | - name: Run tests for AdonisJS 107 | run: npm test -- tests/adonisjs 108 | if: contains(matrix.deps, 'adonisjs') 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /dist 3 | /node_modules 4 | 5 | # misc 6 | .DS_Store 7 | .vscode/ 8 | npm-debug.log* 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: trailing-whitespace 7 | - id: mixed-line-ending 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Apitally 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 |

2 | 3 | 4 | 5 | 6 | Apitally logo 7 | 8 | 9 |

10 |

Simple, privacy-focused API monitoring & analytics

11 |

Apitally gives you the visibility you need to build better APIs – with just a few lines of code.

12 |
13 | Apitally screenshots 14 |
15 | 16 | # Apitally SDK for Node.js 17 | 18 | [![Tests](https://github.com/apitally/apitally-js/actions/workflows/tests.yaml/badge.svg?event=push)](https://github.com/apitally/apitally-js/actions) 19 | [![Codecov](https://codecov.io/gh/apitally/apitally-js/graph/badge.svg?token=j5jqlrL7Pd)](https://codecov.io/gh/apitally/apitally-js) 20 | [![npm](https://img.shields.io/npm/v/apitally?logo=npm&color=%23cb0000)](https://www.npmjs.com/package/apitally) 21 | 22 | This SDK for Apitally currently supports the following Node.js web 23 | frameworks: 24 | 25 | - [Express](https://docs.apitally.io/frameworks/express) 26 | - [NestJS](https://docs.apitally.io/frameworks/nestjs) (with Express) 27 | - [Fastify](https://docs.apitally.io/frameworks/fastify) 28 | - [Koa](https://docs.apitally.io/frameworks/koa) 29 | - [Hono](https://docs.apitally.io/frameworks/hono) 30 | - [AdonisJS](https://docs.apitally.io/frameworks/adonisjs) 31 | 32 | Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out 33 | the 📚 [documentation](https://docs.apitally.io). 34 | 35 | ## Key features 36 | 37 | ### API analytics 38 | 39 | Track traffic, error and performance metrics for your API, each endpoint and individual API consumers, allowing you to make informed, data-driven engineering and product decisions. 40 | 41 | ### Error tracking 42 | 43 | Understand which validation rules in your endpoints cause client errors. Capture error details and stack traces for 500 error responses, and have them linked to Sentry issues automatically. 44 | 45 | ### Request logging 46 | 47 | Drill down from insights to individual requests or use powerful filtering to understand how consumers have interacted with your API. Configure exactly what is included in the logs to meet your requirements. 48 | 49 | ### API monitoring & alerting 50 | 51 | Get notified immediately if something isn't right using custom alerts, synthetic uptime checks and heartbeat monitoring. Notifications can be delivered via email, Slack or Microsoft Teams. 52 | 53 | ## Installation 54 | 55 | You can install this library in your project using `npm` or `yarn`: 56 | 57 | ```bash 58 | npm install apitally 59 | ``` 60 | 61 | or 62 | 63 | ```bash 64 | yarn add apitally 65 | ``` 66 | 67 | ## Usage 68 | 69 | Our comprehensive [setup guides](https://docs.apitally.io/quickstart) include 70 | all the details you need to get started. 71 | 72 | ### Express 73 | 74 | This is an example of how to use the Apitally middleware with an Express 75 | application. For further instructions, see our 76 | [setup guide for Express](https://docs.apitally.io/frameworks/express). 77 | 78 | ```javascript 79 | const express = require("express"); 80 | const { useApitally } = require("apitally/express"); 81 | 82 | const app = express(); 83 | app.use(express.json()); 84 | 85 | useApitally(app, { 86 | clientId: "your-client-id", 87 | env: "dev", // or "prod" etc. 88 | }); 89 | ``` 90 | 91 | ### NestJS 92 | 93 | This is an example of how to use the Apitally middleware with a NestJS 94 | application. For further instructions, see our 95 | [setup guide for NestJS](https://docs.apitally.io/frameworks/nestjs). 96 | 97 | _Note_: Currently only NestJS applications that use Express as the underlying 98 | HTTP server are supported (the default). 99 | 100 | ```javascript 101 | const { NestFactory } = require("@nestjs/core"); 102 | const { useApitally } = require("apitally/nestjs"); 103 | const { AppModule } = require("./app.module"); 104 | 105 | const app = await NestFactory.create(AppModule); 106 | 107 | useApitally(app, { 108 | clientId: "your-client-id", 109 | env: "dev", // or "prod" etc. 110 | }); 111 | ``` 112 | 113 | ### Fastify 114 | 115 | This is an example of how to register the Apitally plugin with a Fastify 116 | application. For further instructions, see our 117 | [setup guide for Fastify](https://docs.apitally.io/frameworks/fastify). 118 | 119 | The Apitally plugin requires the 120 | [`fastify-plugin`](https://www.npmjs.com/package/fastify-plugin) package to be 121 | installed. 122 | 123 | ```bash 124 | npm install fastify-plugin 125 | ``` 126 | 127 | ```javascript 128 | const fastify = require("fastify")({ logger: true }); 129 | const { apitallyPlugin } = require("apitally/fastify"); 130 | 131 | fastify.register(apitallyPlugin, { 132 | clientId: "your-client-id", 133 | env: "dev", // or "prod" etc. 134 | }); 135 | 136 | // Wrap your routes in a plugin, so Apitally can detect them 137 | fastify.register((instance, opts, done) => { 138 | instance.get("/", (request, reply) => { 139 | reply.send("hello"); 140 | }); 141 | done(); 142 | }); 143 | ``` 144 | 145 | _Note:_ If your project uses ES modules you can use `await fastify.register(...)` and don't need to wrap your routes in a plugin. See the [Fastify V4 migration guide](https://fastify.dev/docs/latest/Guides/Migration-Guide-V4/#synchronous-route-definitions-2954) for more details. 146 | 147 | ### Koa 148 | 149 | This is an example of how to use the Apitally middleware with a Koa application. 150 | For further instructions, see our 151 | [setup guide for Koa](https://docs.apitally.io/frameworks/koa). 152 | 153 | ```javascript 154 | const Koa = require("koa"); 155 | const { useApitally } = require("apitally/koa"); 156 | 157 | const app = new Koa(); 158 | 159 | useApitally(app, { 160 | clientId: "your-client-id", 161 | env: "dev", // or "prod" etc. 162 | }); 163 | ``` 164 | 165 | ### Hono 166 | 167 | This is an example of how to use the Apitally middleware with a Hono application. 168 | For further instructions, see our 169 | [setup guide for Hono](https://docs.apitally.io/frameworks/hono). 170 | 171 | ```javascript 172 | import { Hono } from "hono"; 173 | import { useApitally } from "apitally/hono"; 174 | 175 | const app = new Hono(); 176 | 177 | useApitally(app, { 178 | clientId: "your-client-id", 179 | env: "dev", // or "prod" etc. 180 | }); 181 | ``` 182 | 183 | ### AdonisJS 184 | 185 | This is an example of how to use the Apitally middleware with a AdonisJS application. 186 | For further instructions, see our 187 | [setup guide for AdonisJS](https://docs.apitally.io/frameworks/adonisjs). 188 | 189 | Create a configuration file at `config/apitally.ts`: 190 | 191 | ```javascript 192 | import { defineConfig } from "apitally/adonisjs"; 193 | 194 | const apitallyConfig = defineConfig({ 195 | clientId: "your-client-id", 196 | env: "dev", // or "prod" etc. 197 | }); 198 | 199 | export default apitallyConfig; 200 | ``` 201 | 202 | Register the Apitally provider in your `adonisrc.ts` file: 203 | 204 | ```javascript 205 | export default defineConfig({ 206 | // ... existing code ... 207 | providers: [ 208 | // ... existing providers ... 209 | () => import("apitally/adonisjs/provider"), 210 | ], 211 | }); 212 | ``` 213 | 214 | Register the Apitally middleware in your `start/kernel.ts` file: 215 | 216 | ```javascript 217 | router.use([ 218 | () => import("apitally/adonisjs/middleware"), 219 | // ... other middleware ... 220 | ]); 221 | ``` 222 | 223 | ## Getting help 224 | 225 | If you need help please [create a new discussion](https://github.com/orgs/apitally/discussions/categories/q-a) on GitHub 226 | or [join our Slack workspace](https://join.slack.com/t/apitally-community/shared_invite/zt-2b3xxqhdu-9RMq2HyZbR79wtzNLoGHrg). 227 | 228 | ## License 229 | 230 | This library is licensed under the terms of the MIT license. 231 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apitally", 3 | "version": "1.0.0", 4 | "description": "Simple API monitoring & analytics for REST APIs built with Express, Fastify, Hono, Koa, and NestJS.", 5 | "author": "Apitally ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "analytics", 9 | "api", 10 | "apitally", 11 | "express", 12 | "fastify", 13 | "hono", 14 | "koa", 15 | "logging", 16 | "metrics", 17 | "middleware", 18 | "monitor", 19 | "monitoring", 20 | "nestjs", 21 | "node", 22 | "nodejs", 23 | "plugin", 24 | "rest", 25 | "restful" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+ssh://git@github.com/apitally/apitally-js.git" 30 | }, 31 | "bugs": { 32 | "url": "https://github.com/apitally/apitally-js/issues" 33 | }, 34 | "homepage": "https://apitally.io", 35 | "type": "module", 36 | "scripts": { 37 | "build": "tsup src", 38 | "format": "prettier --write .", 39 | "check": "tsc --noEmit && eslint src tests && prettier --check .", 40 | "test": "vitest" 41 | }, 42 | "files": [ 43 | "dist/", 44 | "README.md", 45 | "LICENSE" 46 | ], 47 | "exports": { 48 | "./adonisjs": { 49 | "import": { 50 | "default": "./dist/adonisjs/index.js", 51 | "types": "./dist/adonisjs/index.d.ts" 52 | }, 53 | "require": { 54 | "default": "./dist/adonisjs/index.cjs", 55 | "types": "./dist/adonisjs/index.d.cts" 56 | } 57 | }, 58 | "./adonisjs/middleware": { 59 | "import": { 60 | "default": "./dist/adonisjs/middleware.js", 61 | "types": "./dist/adonisjs/middleware.d.ts" 62 | }, 63 | "require": { 64 | "default": "./dist/adonisjs/middleware.cjs", 65 | "types": "./dist/adonisjs/middleware.d.cts" 66 | } 67 | }, 68 | "./adonisjs/provider": { 69 | "import": { 70 | "default": "./dist/adonisjs/provider.js", 71 | "types": "./dist/adonisjs/provider.d.ts" 72 | }, 73 | "require": { 74 | "default": "./dist/adonisjs/provider.cjs", 75 | "types": "./dist/adonisjs/provider.d.cts" 76 | } 77 | }, 78 | "./express": { 79 | "import": { 80 | "default": "./dist/express/index.js", 81 | "types": "./dist/express/index.d.ts" 82 | }, 83 | "require": { 84 | "default": "./dist/express/index.cjs", 85 | "types": "./dist/express/index.d.cts" 86 | } 87 | }, 88 | "./fastify": { 89 | "import": { 90 | "default": "./dist/fastify/index.js", 91 | "types": "./dist/fastify/index.d.ts" 92 | }, 93 | "require": { 94 | "default": "./dist/fastify/index.cjs", 95 | "types": "./dist/fastify/index.d.cts" 96 | } 97 | }, 98 | "./hono": { 99 | "import": { 100 | "default": "./dist/hono/index.js", 101 | "types": "./dist/hono/index.d.ts" 102 | }, 103 | "require": { 104 | "default": "./dist/hono/index.cjs", 105 | "types": "./dist/hono/index.d.cts" 106 | } 107 | }, 108 | "./koa": { 109 | "import": { 110 | "default": "./dist/koa/index.js", 111 | "types": "./dist/koa/index.d.ts" 112 | }, 113 | "require": { 114 | "default": "./dist/koa/index.cjs", 115 | "types": "./dist/koa/index.d.cts" 116 | } 117 | }, 118 | "./nestjs": { 119 | "import": { 120 | "default": "./dist/nestjs/index.js", 121 | "types": "./dist/nestjs/index.d.ts" 122 | }, 123 | "require": { 124 | "default": "./dist/nestjs/index.cjs", 125 | "types": "./dist/nestjs/index.d.cts" 126 | } 127 | }, 128 | "./package.json": "./package.json" 129 | }, 130 | "typesVersions": { 131 | "*": { 132 | "adonisjs": [ 133 | "./dist/adonisjs/index.d.ts" 134 | ], 135 | "adonisjs/middleware": [ 136 | "./dist/adonisjs/middleware.d.ts" 137 | ], 138 | "adonisjs/provider": [ 139 | "./dist/adonisjs/provider.d.ts" 140 | ], 141 | "express": [ 142 | "./dist/express/index.d.ts" 143 | ], 144 | "fastify": [ 145 | "./dist/fastify/index.d.ts" 146 | ], 147 | "hono": [ 148 | "./dist/hono/index.d.ts" 149 | ], 150 | "koa": [ 151 | "./dist/koa/index.d.ts" 152 | ], 153 | "nestjs": [ 154 | "./dist/nestjs/index.d.ts" 155 | ] 156 | } 157 | }, 158 | "eslintConfig": { 159 | "parser": "@typescript-eslint/parser", 160 | "extends": [ 161 | "eslint:recommended", 162 | "plugin:@typescript-eslint/recommended" 163 | ], 164 | "plugins": [ 165 | "@typescript-eslint" 166 | ], 167 | "rules": { 168 | "@typescript-eslint/no-explicit-any": "off", 169 | "@typescript-eslint/no-var-requires": "off", 170 | "@typescript-eslint/no-unused-vars": [ 171 | "error", 172 | { 173 | "argsIgnorePattern": "^_$" 174 | } 175 | ] 176 | }, 177 | "env": { 178 | "node": true 179 | }, 180 | "root": true 181 | }, 182 | "prettier": { 183 | "printWidth": 80 184 | }, 185 | "dependencies": { 186 | "async-lock": "^1", 187 | "fetch-retry": "^6", 188 | "winston": "^3" 189 | }, 190 | "peerDependencies": { 191 | "@adonisjs/core": "^6", 192 | "@nestjs/common": "^8 || ^9 || ^10", 193 | "@nestjs/core": "^8 || ^9 || ^10", 194 | "@nestjs/platform-express": "^8 || ^9 || ^10", 195 | "@sentry/node": "^8.1.0", 196 | "express": "^4", 197 | "fastify": "^3 || ^4 || ^5", 198 | "fastify-plugin": "^3 || ^4 || ^5", 199 | "hono": "^4", 200 | "koa": "^2" 201 | }, 202 | "peerDependenciesMeta": { 203 | "@adonisjs/core": { 204 | "optional": true 205 | }, 206 | "@nestjs/common": { 207 | "optional": true 208 | }, 209 | "@nestjs/core": { 210 | "optional": true 211 | }, 212 | "@nestjs/platform-express": { 213 | "optional": true 214 | }, 215 | "@sentry/node": { 216 | "optional": true 217 | }, 218 | "express": { 219 | "optional": true 220 | }, 221 | "fastify": { 222 | "optional": true 223 | }, 224 | "fastify-plugin": { 225 | "optional": true 226 | }, 227 | "hono": { 228 | "optional": true 229 | }, 230 | "koa": { 231 | "optional": true 232 | } 233 | }, 234 | "devDependencies": { 235 | "@adonisjs/core": "^6.17.2", 236 | "@hono/zod-validator": "^0.7.0", 237 | "@koa/router": "^13.0.0", 238 | "@nestjs/common": "^10.2.9", 239 | "@nestjs/core": "^10.2.9", 240 | "@nestjs/platform-express": "^10.2.9", 241 | "@nestjs/testing": "^10.2.9", 242 | "@sentry/node": "^8.8.0", 243 | "@swc/core": "^1.3.104", 244 | "@types/async-lock": "^1.4.2", 245 | "@types/express": "^4.17.21", 246 | "@types/koa": "^2.13.11", 247 | "@types/koa__router": "^12.0.4", 248 | "@types/koa-bodyparser": "^4.3.12", 249 | "@types/koa-route": "^3.2.8", 250 | "@types/node": "^20.9.0", 251 | "@types/supertest": "^6.0.0", 252 | "@typescript-eslint/eslint-plugin": "^7.0.0", 253 | "@typescript-eslint/parser": "^7.0.0", 254 | "@vinejs/vine": "^2.1.0 || ^3.0.1", 255 | "@vitest/coverage-v8": "^1.5.0", 256 | "celebrate": "^15.0.3", 257 | "class-transformer": "^0.5.1", 258 | "class-validator": "^0.14.0", 259 | "eslint": "^8.54.0", 260 | "express": "^4.18.2", 261 | "express-validator": "^7.0.1", 262 | "fastify": "^5.0.0", 263 | "fastify-plugin": "^5.0.1", 264 | "hono": "^4.6.5", 265 | "install": "^0.13.0", 266 | "koa": "^2.14.2", 267 | "koa-bodyparser": "^4.4.1", 268 | "koa-route": "^4.0.0", 269 | "nock": "^14.0.0-beta.7", 270 | "npm": "^10.2.4", 271 | "prettier": "^3.1.0", 272 | "supertest": "^7.0.0", 273 | "ts-node": "^10.9.1", 274 | "tsup": "^8.1.0", 275 | "typescript": "^5.2.2", 276 | "unplugin-swc": "^1.5.1", 277 | "vitest": "^1.5.0", 278 | "zod": "^3.23.8" 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>apitally/renovate-config"], 4 | "packageRules": [ 5 | { 6 | "matchPackageNames": ["supertest", "@types/supertest"], 7 | "groupName": "Supertest" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/adonisjs/index.ts: -------------------------------------------------------------------------------- 1 | import type { HttpContext } from "@adonisjs/core/http"; 2 | 3 | import type { ApitallyConfig } from "../common/types.js"; 4 | export type { ApitallyConfig, ApitallyConsumer } from "../common/types.js"; 5 | 6 | export const defineConfig = (config: ApitallyConfig) => { 7 | return config; 8 | }; 9 | 10 | export const captureError = (error: unknown, ctx: HttpContext) => { 11 | if (error instanceof Error) { 12 | ctx.apitallyError = error; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/adonisjs/middleware.ts: -------------------------------------------------------------------------------- 1 | import { HttpContext } from "@adonisjs/core/http"; 2 | import { NextFn } from "@adonisjs/core/types/http"; 3 | import type { OutgoingHttpHeaders } from "http"; 4 | import { performance } from "perf_hooks"; 5 | 6 | import type { ApitallyClient } from "../common/client.js"; 7 | import { consumerFromStringOrObject } from "../common/consumerRegistry.js"; 8 | import { convertHeaders } from "../common/requestLogger.js"; 9 | import type { ApitallyConsumer } from "../common/types.js"; 10 | import { parseContentLength } from "../common/utils.js"; 11 | 12 | declare module "@adonisjs/core/http" { 13 | interface HttpContext { 14 | apitallyConsumer?: ApitallyConsumer | string; 15 | apitallyError?: Error; 16 | } 17 | } 18 | 19 | export default class ApitallyMiddleware { 20 | async handle(ctx: HttpContext, next: NextFn) { 21 | const client: ApitallyClient = 22 | await ctx.containerResolver.make("apitallyClient"); 23 | 24 | if (!client.isEnabled()) { 25 | await next(); 26 | return; 27 | } 28 | 29 | const path = ctx.route?.pattern; 30 | const timestamp = Date.now() / 1000; 31 | const startTime = performance.now(); 32 | 33 | await next(); 34 | 35 | const responseTime = performance.now() - startTime; 36 | const requestSize = parseContentLength( 37 | ctx.request.header("content-length"), 38 | ); 39 | const requestContentType = ctx.request.header("content-type")?.toString(); 40 | let responseStatus = ctx.response.getStatus(); 41 | let responseHeaders = ctx.response.getHeaders(); 42 | let responseSize: number | undefined; 43 | let responseContentType: string | undefined; 44 | 45 | const consumer = ctx.apitallyConsumer 46 | ? consumerFromStringOrObject(ctx.apitallyConsumer) 47 | : null; 48 | client.consumerRegistry.addOrUpdateConsumer(consumer); 49 | 50 | const onWriteHead = (statusCode: number, headers: OutgoingHttpHeaders) => { 51 | responseStatus = statusCode; 52 | responseHeaders = headers; 53 | responseSize = parseContentLength(headers["content-length"]); 54 | responseContentType = headers["content-type"]?.toString(); 55 | if (path) { 56 | client.requestCounter.addRequest({ 57 | consumer: consumer?.identifier, 58 | method: ctx.request.method(), 59 | path, 60 | statusCode: responseStatus, 61 | responseTime, 62 | requestSize, 63 | responseSize, 64 | }); 65 | 66 | if ( 67 | responseStatus === 422 && 68 | ctx.apitallyError && 69 | "code" in ctx.apitallyError && 70 | "messages" in ctx.apitallyError && 71 | ctx.apitallyError.code === "E_VALIDATION_ERROR" && 72 | Array.isArray(ctx.apitallyError.messages) 73 | ) { 74 | ctx.apitallyError.messages.forEach((message) => { 75 | client.validationErrorCounter.addValidationError({ 76 | consumer: consumer?.identifier, 77 | method: ctx.request.method(), 78 | path, 79 | loc: message.field, 80 | msg: message.message, 81 | type: message.rule, 82 | }); 83 | }); 84 | } 85 | 86 | if (responseStatus === 500 && ctx.apitallyError) { 87 | client.serverErrorCounter.addServerError({ 88 | consumer: consumer?.identifier, 89 | method: ctx.request.method(), 90 | path, 91 | type: ctx.apitallyError.name, 92 | msg: ctx.apitallyError.message, 93 | traceback: ctx.apitallyError.stack || "", 94 | }); 95 | } 96 | } 97 | }; 98 | 99 | // Capture the final status code and response headers just before they are sent 100 | const originalWriteHead = ctx.response.response.writeHead; 101 | ctx.response.response.writeHead = (...args: any) => { 102 | originalWriteHead.apply(ctx.response.response, args); 103 | onWriteHead(args[0], typeof args[1] === "string" ? args[2] : args[1]); 104 | return ctx.response.response; 105 | }; 106 | 107 | if (client.requestLogger.enabled) { 108 | const onEnd = (chunk: any) => { 109 | const requestBody = 110 | client.requestLogger.config.logRequestBody && 111 | client.requestLogger.isSupportedContentType(requestContentType) 112 | ? ctx.request.raw() 113 | : undefined; 114 | const responseBody = 115 | client.requestLogger.config.logResponseBody && 116 | client.requestLogger.isSupportedContentType(responseContentType) 117 | ? chunk 118 | : undefined; 119 | 120 | client.requestLogger.logRequest( 121 | { 122 | timestamp, 123 | method: ctx.request.method(), 124 | path, 125 | url: ctx.request.completeUrl(true), 126 | headers: convertHeaders(ctx.request.headers()), 127 | size: requestSize, 128 | consumer: consumer?.identifier, 129 | body: requestBody ? Buffer.from(requestBody) : undefined, 130 | }, 131 | { 132 | statusCode: responseStatus, 133 | responseTime: responseTime / 1000, 134 | headers: convertHeaders(responseHeaders), 135 | size: responseSize, 136 | body: responseBody ? Buffer.from(responseBody) : undefined, 137 | }, 138 | ctx.apitallyError, 139 | ); 140 | }; 141 | 142 | // Capture the final response body just before it is sent 143 | const originalEnd = ctx.response.response.end; 144 | ctx.response.response.end = (...args: any) => { 145 | originalEnd.apply(ctx.response.response, args); 146 | onEnd(typeof args[0] !== "function" ? args[0] : undefined); 147 | return ctx.response.response; 148 | }; 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/adonisjs/provider.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "@adonisjs/core/http"; 2 | import type { ApplicationService } from "@adonisjs/core/types"; 3 | 4 | import { ApitallyClient } from "../common/client.js"; 5 | import { getPackageVersion } from "../common/packageVersions.js"; 6 | import type { ApitallyConfig, PathInfo, StartupData } from "../common/types.js"; 7 | 8 | declare module "@adonisjs/core/types" { 9 | interface ContainerBindings { 10 | apitallyClient: ApitallyClient; 11 | } 12 | } 13 | 14 | export default class ApitallyProvider { 15 | constructor(protected app: ApplicationService) {} 16 | 17 | register() { 18 | this.app.container.singleton("apitallyClient", () => { 19 | const config: ApitallyConfig = this.app.config.get("apitally"); 20 | return new ApitallyClient(config); 21 | }); 22 | } 23 | 24 | async ready() { 25 | const client = await this.app.container.make("apitallyClient"); 26 | const router = await this.app.container.make("router"); 27 | 28 | const paths = listRoutes(router); 29 | const versions = getVersions(this.app.config.get("apitally.appVersion")); 30 | const startupData: StartupData = { 31 | paths, 32 | versions, 33 | client: "js:adonisjs", 34 | }; 35 | client.setStartupData(startupData); 36 | } 37 | 38 | async shutdown() { 39 | const client = await this.app.container.make("apitallyClient"); 40 | await client.handleShutdown(); 41 | } 42 | } 43 | 44 | const listRoutes = (router: Router) => { 45 | const routes = router.toJSON(); 46 | const paths: Array = []; 47 | for (const domain in routes) { 48 | for (const route of routes[domain]) { 49 | for (const method of route.methods) { 50 | if (!["HEAD", "OPTIONS"].includes(method.toUpperCase())) { 51 | paths.push({ 52 | method: method.toUpperCase(), 53 | path: route.pattern, 54 | }); 55 | } 56 | } 57 | } 58 | } 59 | return paths; 60 | }; 61 | 62 | const getVersions = (appVersion?: string) => { 63 | const versions = [["nodejs", process.version.replace(/^v/, "")]]; 64 | const adonisJsVersion = getPackageVersion("@adonisjs/core"); 65 | const apitallyVersion = getPackageVersion("../.."); 66 | if (adonisJsVersion) { 67 | versions.push(["adonisjs", adonisJsVersion]); 68 | } 69 | if (apitallyVersion) { 70 | versions.push(["apitally", apitallyVersion]); 71 | } 72 | if (appVersion) { 73 | versions.push(["app", appVersion]); 74 | } 75 | return Object.fromEntries(versions); 76 | }; 77 | -------------------------------------------------------------------------------- /src/common/client.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from "crypto"; 2 | import fetchRetry from "fetch-retry"; 3 | 4 | import ConsumerRegistry from "./consumerRegistry.js"; 5 | import { Logger, getLogger } from "./logging.js"; 6 | import { isValidClientId, isValidEnv } from "./paramValidation.js"; 7 | import RequestCounter from "./requestCounter.js"; 8 | import RequestLogger from "./requestLogger.js"; 9 | import ServerErrorCounter from "./serverErrorCounter.js"; 10 | import { 11 | ApitallyConfig, 12 | StartupData, 13 | StartupPayload, 14 | SyncPayload, 15 | } from "./types.js"; 16 | import ValidationErrorCounter from "./validationErrorCounter.js"; 17 | 18 | const SYNC_INTERVAL = 60000; // 60 seconds 19 | const INITIAL_SYNC_INTERVAL = 10000; // 10 seconds 20 | const INITIAL_SYNC_INTERVAL_DURATION = 3600000; // 1 hour 21 | const MAX_QUEUE_TIME = 3.6e6; // 1 hour 22 | 23 | class HTTPError extends Error { 24 | public response: Response; 25 | 26 | constructor(response: Response) { 27 | const reason = response.status 28 | ? `status code ${response.status}` 29 | : "an unknown error"; 30 | super(`Request failed with ${reason}`); 31 | this.response = response; 32 | } 33 | } 34 | 35 | export class ApitallyClient { 36 | private clientId: string; 37 | private env: string; 38 | 39 | private static instance?: ApitallyClient; 40 | private instanceUuid: string; 41 | private syncDataQueue: SyncPayload[]; 42 | private syncIntervalId?: NodeJS.Timeout; 43 | public startupData?: StartupData; 44 | private startupDataSent: boolean = false; 45 | private enabled: boolean = true; 46 | 47 | public requestCounter: RequestCounter; 48 | public requestLogger: RequestLogger; 49 | public validationErrorCounter: ValidationErrorCounter; 50 | public serverErrorCounter: ServerErrorCounter; 51 | public consumerRegistry: ConsumerRegistry; 52 | public logger: Logger; 53 | 54 | constructor({ 55 | clientId, 56 | env = "dev", 57 | requestLoggingConfig, 58 | logger, 59 | }: ApitallyConfig) { 60 | if (ApitallyClient.instance) { 61 | throw new Error("Apitally client is already initialized"); 62 | } 63 | if (!isValidClientId(clientId)) { 64 | throw new Error( 65 | `Invalid Apitally client ID '${clientId}' (expecting hexadecimal UUID format)`, 66 | ); 67 | } 68 | if (!isValidEnv(env)) { 69 | throw new Error( 70 | `Invalid env '${env}' (expecting 1-32 alphanumeric lowercase characters and hyphens only)`, 71 | ); 72 | } 73 | 74 | ApitallyClient.instance = this; 75 | this.clientId = clientId; 76 | this.env = env; 77 | this.instanceUuid = randomUUID(); 78 | this.syncDataQueue = []; 79 | this.requestCounter = new RequestCounter(); 80 | this.requestLogger = new RequestLogger(requestLoggingConfig); 81 | this.validationErrorCounter = new ValidationErrorCounter(); 82 | this.serverErrorCounter = new ServerErrorCounter(); 83 | this.consumerRegistry = new ConsumerRegistry(); 84 | this.logger = logger || getLogger(); 85 | 86 | this.startSync(); 87 | this.handleShutdown = this.handleShutdown.bind(this); 88 | } 89 | 90 | public static getInstance() { 91 | if (!ApitallyClient.instance) { 92 | throw new Error("Apitally client is not initialized"); 93 | } 94 | return ApitallyClient.instance; 95 | } 96 | 97 | public isEnabled() { 98 | return this.enabled; 99 | } 100 | 101 | public static async shutdown() { 102 | if (ApitallyClient.instance) { 103 | await ApitallyClient.instance.handleShutdown(); 104 | } 105 | } 106 | 107 | public async handleShutdown() { 108 | this.enabled = false; 109 | this.stopSync(); 110 | await this.sendSyncData(); 111 | await this.sendLogData(); 112 | await this.requestLogger.close(); 113 | ApitallyClient.instance = undefined; 114 | } 115 | 116 | private getHubUrlPrefix() { 117 | const baseURL = 118 | process.env.APITALLY_HUB_BASE_URL || "https://hub.apitally.io"; 119 | const version = "v2"; 120 | return `${baseURL}/${version}/${this.clientId}/${this.env}/`; 121 | } 122 | 123 | private async sendData(url: string, payload: any) { 124 | const fetchWithRetry = fetchRetry(fetch, { 125 | retries: 3, 126 | retryDelay: 1000, 127 | retryOn: [408, 429, 500, 502, 503, 504], 128 | }); 129 | const response = await fetchWithRetry(this.getHubUrlPrefix() + url, { 130 | method: "POST", 131 | body: JSON.stringify(payload), 132 | headers: { "Content-Type": "application/json" }, 133 | }); 134 | if (!response.ok) { 135 | throw new HTTPError(response); 136 | } 137 | } 138 | 139 | private startSync() { 140 | this.sync(); 141 | this.syncIntervalId = setInterval(() => { 142 | this.sync(); 143 | }, INITIAL_SYNC_INTERVAL); 144 | setTimeout(() => { 145 | clearInterval(this.syncIntervalId); 146 | this.syncIntervalId = setInterval(() => { 147 | this.sync(); 148 | }, SYNC_INTERVAL); 149 | }, INITIAL_SYNC_INTERVAL_DURATION); 150 | } 151 | 152 | private async sync() { 153 | try { 154 | const promises = [this.sendSyncData(), this.sendLogData()]; 155 | if (!this.startupDataSent) { 156 | promises.push(this.sendStartupData()); 157 | } 158 | await Promise.all(promises); 159 | } catch (error) { 160 | this.logger.error("Error while syncing with Apitally Hub", { 161 | error, 162 | }); 163 | } 164 | } 165 | 166 | private stopSync() { 167 | if (this.syncIntervalId) { 168 | clearInterval(this.syncIntervalId); 169 | this.syncIntervalId = undefined; 170 | } 171 | } 172 | 173 | public setStartupData(data: StartupData) { 174 | this.startupData = data; 175 | this.startupDataSent = false; 176 | this.sendStartupData(); 177 | } 178 | 179 | private async sendStartupData() { 180 | if (this.startupData) { 181 | this.logger.debug("Sending startup data to Apitally Hub"); 182 | const payload: StartupPayload = { 183 | instance_uuid: this.instanceUuid, 184 | message_uuid: randomUUID(), 185 | ...this.startupData, 186 | }; 187 | try { 188 | await this.sendData("startup", payload); 189 | this.startupDataSent = true; 190 | } catch (error) { 191 | const handled = this.handleHubError(error); 192 | if (!handled) { 193 | this.logger.error((error as Error).message); 194 | this.logger.debug( 195 | "Error while sending startup data to Apitally Hub (will retry)", 196 | { error }, 197 | ); 198 | } 199 | } 200 | } 201 | } 202 | 203 | private async sendSyncData() { 204 | this.logger.debug("Synchronizing data with Apitally Hub"); 205 | const newPayload: SyncPayload = { 206 | timestamp: Date.now() / 1000, 207 | instance_uuid: this.instanceUuid, 208 | message_uuid: randomUUID(), 209 | requests: this.requestCounter.getAndResetRequests(), 210 | validation_errors: 211 | this.validationErrorCounter.getAndResetValidationErrors(), 212 | server_errors: this.serverErrorCounter.getAndResetServerErrors(), 213 | consumers: this.consumerRegistry.getAndResetUpdatedConsumers(), 214 | }; 215 | this.syncDataQueue.push(newPayload); 216 | 217 | let i = 0; 218 | while (this.syncDataQueue.length > 0) { 219 | const payload = this.syncDataQueue.shift(); 220 | if (payload) { 221 | try { 222 | if (Date.now() - payload.timestamp * 1000 <= MAX_QUEUE_TIME) { 223 | if (i > 0) { 224 | await this.randomDelay(); 225 | } 226 | await this.sendData("sync", payload); 227 | i += 1; 228 | } 229 | } catch (error) { 230 | const handled = this.handleHubError(error); 231 | if (!handled) { 232 | this.logger.debug( 233 | "Error while synchronizing data with Apitally Hub (will retry)", 234 | { error }, 235 | ); 236 | this.syncDataQueue.push(payload); 237 | break; 238 | } 239 | } 240 | } 241 | } 242 | } 243 | 244 | private async sendLogData() { 245 | this.logger.debug("Sending request log data to Apitally Hub"); 246 | await this.requestLogger.rotateFile(); 247 | 248 | const fetchWithRetry = fetchRetry(fetch, { 249 | retries: 3, 250 | retryDelay: 1000, 251 | retryOn: [408, 429, 500, 502, 503, 504], 252 | }); 253 | 254 | let i = 0; 255 | let logFile; 256 | while ((logFile = this.requestLogger.getFile())) { 257 | if (i > 0) { 258 | await this.randomDelay(); 259 | } 260 | 261 | try { 262 | const response = await fetchWithRetry( 263 | `${this.getHubUrlPrefix()}log?uuid=${logFile.uuid}`, 264 | { 265 | method: "POST", 266 | body: await logFile.getContent(), 267 | }, 268 | ); 269 | 270 | if (response.status === 402 && response.headers.has("Retry-After")) { 271 | const retryAfter = parseInt( 272 | response.headers.get("Retry-After") ?? "0", 273 | ); 274 | if (retryAfter > 0) { 275 | this.requestLogger.suspendUntil = Date.now() + retryAfter * 1000; 276 | this.requestLogger.clear(); 277 | return; 278 | } 279 | } 280 | 281 | if (!response.ok) { 282 | throw new HTTPError(response); 283 | } 284 | 285 | logFile.delete(); 286 | } catch (error) { 287 | this.requestLogger.retryFileLater(logFile); 288 | break; 289 | } 290 | 291 | i++; 292 | if (i >= 10) break; 293 | } 294 | } 295 | 296 | private handleHubError(error: unknown) { 297 | if (error instanceof HTTPError) { 298 | if (error.response.status === 404) { 299 | this.logger.error(`Invalid Apitally client ID: '${this.clientId}'`); 300 | this.enabled = false; 301 | this.stopSync(); 302 | return true; 303 | } 304 | if (error.response.status === 422) { 305 | this.logger.error("Received validation error from Apitally Hub"); 306 | return true; 307 | } 308 | } 309 | return false; 310 | } 311 | 312 | private async randomDelay() { 313 | const delay = 100 + Math.random() * 400; 314 | await new Promise((resolve) => setTimeout(resolve, delay)); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/common/consumerRegistry.ts: -------------------------------------------------------------------------------- 1 | import { ApitallyConsumer } from "./types.js"; 2 | 3 | export const consumerFromStringOrObject = ( 4 | consumer: ApitallyConsumer | string, 5 | ) => { 6 | if (typeof consumer === "string") { 7 | consumer = String(consumer).trim().substring(0, 128); 8 | return consumer ? { identifier: consumer } : null; 9 | } else { 10 | consumer.identifier = String(consumer.identifier).trim().substring(0, 128); 11 | consumer.name = consumer.name?.trim().substring(0, 64); 12 | consumer.group = consumer.group?.trim().substring(0, 64); 13 | return consumer.identifier ? consumer : null; 14 | } 15 | }; 16 | 17 | export default class ConsumerRegistry { 18 | private consumers: Map; 19 | private updated: Set; 20 | 21 | constructor() { 22 | this.consumers = new Map(); 23 | this.updated = new Set(); 24 | } 25 | 26 | public addOrUpdateConsumer(consumer?: ApitallyConsumer | null) { 27 | if (!consumer || (!consumer.name && !consumer.group)) { 28 | return; 29 | } 30 | const existing = this.consumers.get(consumer.identifier); 31 | if (!existing) { 32 | this.consumers.set(consumer.identifier, consumer); 33 | this.updated.add(consumer.identifier); 34 | } else { 35 | if (consumer.name && consumer.name !== existing.name) { 36 | existing.name = consumer.name; 37 | this.updated.add(consumer.identifier); 38 | } 39 | if (consumer.group && consumer.group !== existing.group) { 40 | existing.group = consumer.group; 41 | this.updated.add(consumer.identifier); 42 | } 43 | } 44 | } 45 | 46 | public getAndResetUpdatedConsumers() { 47 | const data: Array = []; 48 | this.updated.forEach((identifier) => { 49 | const consumer = this.consumers.get(identifier); 50 | if (consumer) { 51 | data.push(consumer); 52 | } 53 | }); 54 | this.updated.clear(); 55 | return data; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/common/logging.ts: -------------------------------------------------------------------------------- 1 | import { createLogger, format, transports } from "winston"; 2 | 3 | export interface Logger { 4 | debug: (message: string, meta?: object) => void; 5 | info: (message: string, meta?: object) => void; 6 | warn: (message: string, meta?: object) => void; 7 | error: (message: string, meta?: object) => void; 8 | } 9 | 10 | export const getLogger = () => { 11 | return createLogger({ 12 | level: process.env.APITALLY_DEBUG ? "debug" : "warn", 13 | format: format.combine( 14 | format.colorize(), 15 | format.timestamp(), 16 | format.printf( 17 | (info) => `${info.timestamp} ${info.level}: ${info.message}`, 18 | ), 19 | ), 20 | transports: [new transports.Console()], 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/common/packageVersions.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module"; 2 | 3 | export function getPackageVersion(name: string): string | null { 4 | const packageJsonPath = `${name}/package.json`; 5 | try { 6 | return require(packageJsonPath).version || null; 7 | } catch (error) { 8 | try { 9 | const _require = createRequire(import.meta.url); 10 | return _require(packageJsonPath).version || null; 11 | } catch (error) { 12 | return null; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/common/paramValidation.ts: -------------------------------------------------------------------------------- 1 | export function isValidClientId(clientId: string): boolean { 2 | const regexExp = 3 | /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; 4 | return regexExp.test(clientId); 5 | } 6 | 7 | export function isValidEnv(env: string): boolean { 8 | const regexExp = /^[\w-]{1,32}$/; 9 | return regexExp.test(env); 10 | } 11 | -------------------------------------------------------------------------------- /src/common/requestCounter.ts: -------------------------------------------------------------------------------- 1 | import { RequestInfo, RequestsItem } from "./types.js"; 2 | 3 | export default class RequestCounter { 4 | private requestCounts: Map; 5 | private requestSizeSums: Map; 6 | private responseSizeSums: Map; 7 | private responseTimes: Map>; 8 | private requestSizes: Map>; 9 | private responseSizes: Map>; 10 | 11 | constructor() { 12 | this.requestCounts = new Map(); 13 | this.requestSizeSums = new Map(); 14 | this.responseSizeSums = new Map(); 15 | this.responseTimes = new Map>(); 16 | this.requestSizes = new Map>(); 17 | this.responseSizes = new Map>(); 18 | } 19 | 20 | private getKey(requestInfo: RequestInfo) { 21 | return [ 22 | requestInfo.consumer || "", 23 | requestInfo.method.toUpperCase(), 24 | requestInfo.path, 25 | requestInfo.statusCode, 26 | ].join("|"); 27 | } 28 | 29 | addRequest(requestInfo: RequestInfo) { 30 | const key = this.getKey(requestInfo); 31 | 32 | // Increment request count 33 | this.requestCounts.set(key, (this.requestCounts.get(key) || 0) + 1); 34 | 35 | // Add response time 36 | if (!this.responseTimes.has(key)) { 37 | this.responseTimes.set(key, new Map()); 38 | } 39 | const responseTimeMap = this.responseTimes.get(key)!; 40 | const responseTimeMsBin = Math.floor(requestInfo.responseTime / 10) * 10; // Rounded to nearest 10ms 41 | responseTimeMap.set( 42 | responseTimeMsBin, 43 | (responseTimeMap.get(responseTimeMsBin) || 0) + 1, 44 | ); 45 | 46 | // Add request size 47 | if (requestInfo.requestSize !== undefined) { 48 | requestInfo.requestSize = Number(requestInfo.requestSize); 49 | this.requestSizeSums.set( 50 | key, 51 | (this.requestSizeSums.get(key) || 0) + requestInfo.requestSize, 52 | ); 53 | if (!this.requestSizes.has(key)) { 54 | this.requestSizes.set(key, new Map()); 55 | } 56 | const requestSizeMap = this.requestSizes.get(key)!; 57 | const requestSizeKbBin = Math.floor(requestInfo.requestSize / 1000); // Rounded down to nearest KB 58 | requestSizeMap.set( 59 | requestSizeKbBin, 60 | (requestSizeMap.get(requestSizeKbBin) || 0) + 1, 61 | ); 62 | } 63 | 64 | // Add response size 65 | if (requestInfo.responseSize !== undefined) { 66 | requestInfo.responseSize = Number(requestInfo.responseSize); 67 | this.responseSizeSums.set( 68 | key, 69 | (this.responseSizeSums.get(key) || 0) + requestInfo.responseSize, 70 | ); 71 | if (!this.responseSizes.has(key)) { 72 | this.responseSizes.set(key, new Map()); 73 | } 74 | const responseSizeMap = this.responseSizes.get(key)!; 75 | const responseSizeKbBin = Math.floor(requestInfo.responseSize / 1000); // Rounded down to nearest KB 76 | responseSizeMap.set( 77 | responseSizeKbBin, 78 | (responseSizeMap.get(responseSizeKbBin) || 0) + 1, 79 | ); 80 | } 81 | } 82 | 83 | getAndResetRequests() { 84 | const data: Array = []; 85 | this.requestCounts.forEach((count, key) => { 86 | const [consumer, method, path, statusCodeStr] = key.split("|"); 87 | const responseTimes = 88 | this.responseTimes.get(key) || new Map(); 89 | const requestSizes = 90 | this.requestSizes.get(key) || new Map(); 91 | const responseSizes = 92 | this.responseSizes.get(key) || new Map(); 93 | data.push({ 94 | consumer: consumer || null, 95 | method, 96 | path, 97 | status_code: parseInt(statusCodeStr), 98 | request_count: count, 99 | request_size_sum: this.requestSizeSums.get(key) || 0, 100 | response_size_sum: this.responseSizeSums.get(key) || 0, 101 | response_times: Object.fromEntries(responseTimes), 102 | request_sizes: Object.fromEntries(requestSizes), 103 | response_sizes: Object.fromEntries(responseSizes), 104 | }); 105 | }); 106 | 107 | // Reset the counts and times 108 | this.requestCounts.clear(); 109 | this.requestSizeSums.clear(); 110 | this.responseSizeSums.clear(); 111 | this.responseTimes.clear(); 112 | this.requestSizes.clear(); 113 | this.responseSizes.clear(); 114 | 115 | return data; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/common/requestLogger.ts: -------------------------------------------------------------------------------- 1 | import AsyncLock from "async-lock"; 2 | import { Buffer } from "buffer"; 3 | import { randomUUID } from "crypto"; 4 | import { unlinkSync, writeFileSync } from "fs"; 5 | import { IncomingHttpHeaders, OutgoingHttpHeaders } from "http"; 6 | import { tmpdir } from "os"; 7 | import { join } from "path"; 8 | 9 | import { getSentryEventId } from "./sentry.js"; 10 | import { 11 | truncateExceptionMessage, 12 | truncateExceptionStackTrace, 13 | } from "./serverErrorCounter.js"; 14 | import TempGzipFile from "./tempGzipFile.js"; 15 | 16 | const MAX_BODY_SIZE = 50_000; // 50 KB (uncompressed) 17 | const MAX_FILE_SIZE = 1_000_000; // 1 MB (compressed) 18 | const MAX_FILES = 50; 19 | const MAX_PENDING_WRITES = 100; 20 | const BODY_TOO_LARGE = Buffer.from(""); 21 | const BODY_MASKED = Buffer.from(""); 22 | const MASKED = "******"; 23 | const ALLOWED_CONTENT_TYPES = ["application/json", "text/plain"]; 24 | const EXCLUDE_PATH_PATTERNS = [ 25 | /\/_?healthz?$/i, 26 | /\/_?health[_-]?checks?$/i, 27 | /\/_?heart[_-]?beats?$/i, 28 | /\/ping$/i, 29 | /\/ready$/i, 30 | /\/live$/i, 31 | ]; 32 | const EXCLUDE_USER_AGENT_PATTERNS = [ 33 | /health[-_ ]?check/i, 34 | /microsoft-azure-application-lb/i, 35 | /googlehc/i, 36 | /kube-probe/i, 37 | ]; 38 | const MASK_QUERY_PARAM_PATTERNS = [ 39 | /auth/i, 40 | /api-?key/i, 41 | /secret/i, 42 | /token/i, 43 | /password/i, 44 | /pwd/i, 45 | ]; 46 | const MASK_HEADER_PATTERNS = [ 47 | /auth/i, 48 | /api-?key/i, 49 | /secret/i, 50 | /token/i, 51 | /cookie/i, 52 | ]; 53 | 54 | export type Request = { 55 | timestamp: number; 56 | method: string; 57 | path?: string; 58 | url: string; 59 | headers: [string, string][]; 60 | size?: number; 61 | consumer?: string; 62 | body?: Buffer; 63 | }; 64 | 65 | export type Response = { 66 | statusCode: number; 67 | responseTime: number; 68 | headers: [string, string][]; 69 | size?: number; 70 | body?: Buffer; 71 | }; 72 | 73 | export type RequestLoggingConfig = { 74 | enabled: boolean; 75 | logQueryParams: boolean; 76 | logRequestHeaders: boolean; 77 | logRequestBody: boolean; 78 | logResponseHeaders: boolean; 79 | logResponseBody: boolean; 80 | logException: boolean; 81 | maskQueryParams: RegExp[]; 82 | maskHeaders: RegExp[]; 83 | maskRequestBodyCallback?: (request: Request) => Buffer | null | undefined; 84 | maskResponseBodyCallback?: ( 85 | request: Request, 86 | response: Response, 87 | ) => Buffer | null | undefined; 88 | excludePaths: RegExp[]; 89 | excludeCallback?: (request: Request, response: Response) => boolean; 90 | }; 91 | 92 | const DEFAULT_CONFIG: RequestLoggingConfig = { 93 | enabled: false, 94 | logQueryParams: true, 95 | logRequestHeaders: false, 96 | logRequestBody: false, 97 | logResponseHeaders: true, 98 | logResponseBody: false, 99 | logException: true, 100 | maskQueryParams: [], 101 | maskHeaders: [], 102 | excludePaths: [], 103 | }; 104 | 105 | export default class RequestLogger { 106 | public config: RequestLoggingConfig; 107 | public enabled: boolean; 108 | public suspendUntil: number | null = null; 109 | private pendingWrites: string[] = []; 110 | private currentFile: TempGzipFile | null = null; 111 | private files: TempGzipFile[] = []; 112 | private maintainIntervalId?: NodeJS.Timeout; 113 | private lock = new AsyncLock(); 114 | 115 | constructor(config?: Partial) { 116 | this.config = { ...DEFAULT_CONFIG, ...config }; 117 | this.enabled = this.config.enabled && checkWritableFs(); 118 | 119 | if (this.enabled) { 120 | this.maintainIntervalId = setInterval(() => { 121 | this.maintain(); 122 | }, 1000); 123 | } 124 | } 125 | 126 | private shouldExcludePath(urlPath: string) { 127 | const patterns = [...this.config.excludePaths, ...EXCLUDE_PATH_PATTERNS]; 128 | return matchPatterns(urlPath, patterns); 129 | } 130 | 131 | private shouldExcludeUserAgent(userAgent?: string) { 132 | return userAgent 133 | ? matchPatterns(userAgent, EXCLUDE_USER_AGENT_PATTERNS) 134 | : false; 135 | } 136 | 137 | private shouldMaskQueryParam(name: string) { 138 | const patterns = [ 139 | ...this.config.maskQueryParams, 140 | ...MASK_QUERY_PARAM_PATTERNS, 141 | ]; 142 | return matchPatterns(name, patterns); 143 | } 144 | 145 | private shouldMaskHeader(name: string) { 146 | const patterns = [...this.config.maskHeaders, ...MASK_HEADER_PATTERNS]; 147 | return matchPatterns(name, patterns); 148 | } 149 | 150 | private hasSupportedContentType(headers: [string, string][]) { 151 | const contentType = headers.find( 152 | ([k]) => k.toLowerCase() === "content-type", 153 | )?.[1]; 154 | return this.isSupportedContentType(contentType); 155 | } 156 | 157 | public isSupportedContentType(contentType?: string | null) { 158 | return ( 159 | typeof contentType === "string" && 160 | ALLOWED_CONTENT_TYPES.some((t) => contentType.startsWith(t)) 161 | ); 162 | } 163 | 164 | private maskQueryParams(search: string) { 165 | const params = new URLSearchParams(search); 166 | for (const [key] of params) { 167 | if (this.shouldMaskQueryParam(key)) { 168 | params.set(key, MASKED); 169 | } 170 | } 171 | return params.toString(); 172 | } 173 | 174 | private maskHeaders(headers: [string, string][]): [string, string][] { 175 | return headers.map(([k, v]) => [k, this.shouldMaskHeader(k) ? MASKED : v]); 176 | } 177 | 178 | logRequest(request: Request, response: Response, error?: Error) { 179 | if (!this.enabled || this.suspendUntil !== null) return; 180 | 181 | const url = new URL(request.url); 182 | const path = request.path ?? url.pathname; 183 | const userAgent = request.headers.find( 184 | ([k]) => k.toLowerCase() === "user-agent", 185 | )?.[1]; 186 | 187 | if ( 188 | this.shouldExcludePath(path) || 189 | this.shouldExcludeUserAgent(userAgent) || 190 | (this.config.excludeCallback?.(request, response) ?? false) 191 | ) { 192 | return; 193 | } 194 | 195 | // Process query params 196 | url.search = this.config.logQueryParams 197 | ? this.maskQueryParams(url.search) 198 | : ""; 199 | request.url = url.toString(); 200 | 201 | // Process request body 202 | if ( 203 | !this.config.logRequestBody || 204 | !this.hasSupportedContentType(request.headers) 205 | ) { 206 | request.body = undefined; 207 | } else if (request.body) { 208 | if (request.body.length > MAX_BODY_SIZE) { 209 | request.body = BODY_TOO_LARGE; 210 | } else if (this.config.maskRequestBodyCallback) { 211 | try { 212 | request.body = 213 | this.config.maskRequestBodyCallback(request) ?? BODY_MASKED; 214 | if (request.body.length > MAX_BODY_SIZE) { 215 | request.body = BODY_TOO_LARGE; 216 | } 217 | } catch { 218 | request.body = undefined; 219 | } 220 | } 221 | } 222 | 223 | // Process response body 224 | if ( 225 | !this.config.logResponseBody || 226 | !this.hasSupportedContentType(response.headers) 227 | ) { 228 | response.body = undefined; 229 | } else if (response.body) { 230 | if (response.body.length > MAX_BODY_SIZE) { 231 | response.body = BODY_TOO_LARGE; 232 | } else if (this.config.maskResponseBodyCallback) { 233 | try { 234 | response.body = 235 | this.config.maskResponseBodyCallback(request, response) ?? 236 | BODY_MASKED; 237 | if (response.body.length > MAX_BODY_SIZE) { 238 | response.body = BODY_TOO_LARGE; 239 | } 240 | } catch { 241 | response.body = undefined; 242 | } 243 | } 244 | } 245 | 246 | // Process headers 247 | request.headers = this.config.logRequestHeaders 248 | ? this.maskHeaders(request.headers) 249 | : []; 250 | response.headers = this.config.logResponseHeaders 251 | ? this.maskHeaders(response.headers) 252 | : []; 253 | 254 | const item = { 255 | uuid: randomUUID(), 256 | request: skipEmptyValues(request), 257 | response: skipEmptyValues(response), 258 | exception: 259 | error && this.config.logException 260 | ? { 261 | type: error.name, 262 | message: truncateExceptionMessage(error.message), 263 | stacktrace: truncateExceptionStackTrace(error.stack || ""), 264 | sentryEventId: getSentryEventId(), 265 | } 266 | : null, 267 | }; 268 | [item.request.body, item.response.body].forEach((body) => { 269 | if (body) { 270 | // @ts-expect-error Different return type 271 | body.toJSON = function () { 272 | return this.toString("base64"); 273 | }; 274 | } 275 | }); 276 | this.pendingWrites.push(JSON.stringify(item)); 277 | 278 | if (this.pendingWrites.length > MAX_PENDING_WRITES) { 279 | this.pendingWrites.shift(); 280 | } 281 | } 282 | 283 | async writeToFile() { 284 | if (!this.enabled || this.pendingWrites.length === 0) { 285 | return; 286 | } 287 | return this.lock.acquire("file", async () => { 288 | if (!this.currentFile) { 289 | this.currentFile = new TempGzipFile(); 290 | } 291 | while (this.pendingWrites.length > 0) { 292 | const item = this.pendingWrites.shift(); 293 | if (item) { 294 | await this.currentFile.writeLine(Buffer.from(item)); 295 | } 296 | } 297 | }); 298 | } 299 | 300 | getFile() { 301 | return this.files.shift(); 302 | } 303 | 304 | retryFileLater(file: TempGzipFile) { 305 | this.files.unshift(file); 306 | } 307 | 308 | async rotateFile() { 309 | return this.lock.acquire("file", async () => { 310 | if (this.currentFile) { 311 | await this.currentFile.close(); 312 | this.files.push(this.currentFile); 313 | this.currentFile = null; 314 | } 315 | }); 316 | } 317 | 318 | async maintain() { 319 | await this.writeToFile(); 320 | if (this.currentFile && this.currentFile.size > MAX_FILE_SIZE) { 321 | await this.rotateFile(); 322 | } 323 | while (this.files.length > MAX_FILES) { 324 | const file = this.files.shift(); 325 | file?.delete(); 326 | } 327 | if (this.suspendUntil !== null && this.suspendUntil < Date.now()) { 328 | this.suspendUntil = null; 329 | } 330 | } 331 | 332 | async clear() { 333 | this.pendingWrites = []; 334 | await this.rotateFile(); 335 | this.files.forEach((file) => { 336 | file.delete(); 337 | }); 338 | this.files = []; 339 | } 340 | 341 | async close() { 342 | this.enabled = false; 343 | await this.clear(); 344 | if (this.maintainIntervalId) { 345 | clearInterval(this.maintainIntervalId); 346 | } 347 | } 348 | } 349 | 350 | export function convertHeaders( 351 | headers: 352 | | Headers 353 | | IncomingHttpHeaders 354 | | OutgoingHttpHeaders 355 | | Record, 356 | ) { 357 | if (headers instanceof Headers) { 358 | return Array.from(headers.entries()); 359 | } 360 | return Object.entries(headers).flatMap(([key, value]) => { 361 | if (value === undefined) { 362 | return []; 363 | } 364 | if (Array.isArray(value)) { 365 | return value.map((v) => [key, v]); 366 | } 367 | return [[key, value.toString()]]; 368 | }) as [string, string][]; 369 | } 370 | 371 | export function convertBody(body: any, contentType?: string | null) { 372 | if (!body || !contentType) { 373 | return; 374 | } 375 | try { 376 | if (contentType.startsWith("application/json")) { 377 | if (isValidJsonString(body)) { 378 | return Buffer.from(body); 379 | } else { 380 | return Buffer.from(JSON.stringify(body)); 381 | } 382 | } 383 | if (contentType.startsWith("text/") && typeof body === "string") { 384 | return Buffer.from(body); 385 | } 386 | } catch (error) { 387 | return; 388 | } 389 | } 390 | 391 | function isValidJsonString(body: any) { 392 | if (typeof body !== "string") { 393 | return false; 394 | } 395 | try { 396 | JSON.parse(body); 397 | return true; 398 | } catch { 399 | return false; 400 | } 401 | } 402 | 403 | function matchPatterns(value: string, patterns: RegExp[]) { 404 | return patterns.some((pattern) => { 405 | return pattern.test(value); 406 | }); 407 | } 408 | 409 | function skipEmptyValues>(data: T) { 410 | return Object.fromEntries( 411 | Object.entries(data).filter(([_, v]) => { 412 | if (v == null || Number.isNaN(v)) return false; 413 | if (Array.isArray(v) || Buffer.isBuffer(v) || typeof v === "string") { 414 | return v.length > 0; 415 | } 416 | return true; 417 | }), 418 | ) as Partial; 419 | } 420 | 421 | function checkWritableFs() { 422 | try { 423 | const testPath = join(tmpdir(), `apitally-${randomUUID()}`); 424 | writeFileSync(testPath, "test"); 425 | unlinkSync(testPath); 426 | return true; 427 | } catch (error) { 428 | return false; 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /src/common/sentry.ts: -------------------------------------------------------------------------------- 1 | import type * as Sentry from "@sentry/node"; 2 | 3 | let sentry: typeof Sentry | undefined; 4 | 5 | // Initialize Sentry when the module is loaded 6 | (async () => { 7 | try { 8 | sentry = await import("@sentry/node"); 9 | } catch (e) { 10 | // Sentry SDK is not installed, ignore 11 | } 12 | })(); 13 | 14 | /** 15 | * Returns the last Sentry event ID if available 16 | */ 17 | export function getSentryEventId(): string | undefined { 18 | if (sentry && sentry.lastEventId) { 19 | return sentry.lastEventId(); 20 | } 21 | return undefined; 22 | } 23 | -------------------------------------------------------------------------------- /src/common/serverErrorCounter.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | import { getSentryEventId } from "./sentry.js"; 4 | import { ConsumerMethodPath, ServerError, ServerErrorsItem } from "./types.js"; 5 | 6 | const MAX_MSG_LENGTH = 2048; 7 | const MAX_STACKTRACE_LENGTH = 65536; 8 | 9 | export default class ServerErrorCounter { 10 | private errorCounts: Map; 11 | private errorDetails: Map; 12 | private sentryEventIds: Map; 13 | 14 | constructor() { 15 | this.errorCounts = new Map(); 16 | this.errorDetails = new Map(); 17 | this.sentryEventIds = new Map(); 18 | } 19 | 20 | public addServerError(serverError: ConsumerMethodPath & ServerError) { 21 | const key = this.getKey(serverError); 22 | if (!this.errorDetails.has(key)) { 23 | this.errorDetails.set(key, serverError); 24 | } 25 | this.errorCounts.set(key, (this.errorCounts.get(key) || 0) + 1); 26 | 27 | const sentryEventId = getSentryEventId(); 28 | if (sentryEventId) { 29 | this.sentryEventIds.set(key, sentryEventId); 30 | } 31 | } 32 | 33 | public getAndResetServerErrors() { 34 | const data: Array = []; 35 | this.errorCounts.forEach((count, key) => { 36 | const serverError = this.errorDetails.get(key); 37 | if (serverError) { 38 | data.push({ 39 | consumer: serverError.consumer || null, 40 | method: serverError.method, 41 | path: serverError.path, 42 | type: serverError.type, 43 | msg: truncateExceptionMessage(serverError.msg), 44 | traceback: truncateExceptionStackTrace(serverError.traceback), 45 | sentry_event_id: this.sentryEventIds.get(key) || null, 46 | error_count: count, 47 | }); 48 | } 49 | }); 50 | this.errorCounts.clear(); 51 | this.errorDetails.clear(); 52 | return data; 53 | } 54 | 55 | private getKey(serverError: ConsumerMethodPath & ServerError) { 56 | const hashInput = [ 57 | serverError.consumer || "", 58 | serverError.method.toUpperCase(), 59 | serverError.path, 60 | serverError.type, 61 | serverError.msg.trim(), 62 | serverError.traceback.trim(), 63 | ].join("|"); 64 | return createHash("md5").update(hashInput).digest("hex"); 65 | } 66 | } 67 | 68 | export function truncateExceptionMessage(msg: string) { 69 | if (msg.length <= MAX_MSG_LENGTH) { 70 | return msg; 71 | } 72 | const suffix = "... (truncated)"; 73 | const cutoff = MAX_MSG_LENGTH - suffix.length; 74 | return msg.substring(0, cutoff) + suffix; 75 | } 76 | 77 | export function truncateExceptionStackTrace(stack: string) { 78 | const suffix = "... (truncated) ..."; 79 | const cutoff = MAX_STACKTRACE_LENGTH - suffix.length; 80 | const lines = stack.trim().split("\n"); 81 | const truncatedLines: string[] = []; 82 | let length = 0; 83 | for (const line of lines) { 84 | if (length + line.length + 1 > cutoff) { 85 | truncatedLines.push(suffix); 86 | break; 87 | } 88 | truncatedLines.push(line); 89 | length += line.length + 1; 90 | } 91 | return truncatedLines.join("\n"); 92 | } 93 | -------------------------------------------------------------------------------- /src/common/tempGzipFile.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import { randomUUID } from "crypto"; 3 | import { createWriteStream, readFile, unlinkSync, WriteStream } from "fs"; 4 | import { tmpdir } from "os"; 5 | import { join } from "path"; 6 | import { createGzip, Gzip } from "zlib"; 7 | 8 | export default class TempGzipFile { 9 | public uuid: string; 10 | private filePath: string; 11 | private gzip: Gzip; 12 | private writeStream: WriteStream; 13 | private readyPromise: Promise; 14 | private closedPromise: Promise; 15 | 16 | constructor() { 17 | this.uuid = randomUUID(); 18 | this.filePath = join(tmpdir(), `apitally-${this.uuid}.gz`); 19 | this.writeStream = createWriteStream(this.filePath); 20 | this.readyPromise = new Promise((resolve, reject) => { 21 | this.writeStream.once("ready", resolve); 22 | this.writeStream.once("error", reject); 23 | }); 24 | this.closedPromise = new Promise((resolve, reject) => { 25 | this.writeStream.once("close", resolve); 26 | this.writeStream.once("error", reject); 27 | }); 28 | this.gzip = createGzip(); 29 | this.gzip.pipe(this.writeStream); 30 | } 31 | 32 | get size() { 33 | return this.writeStream.bytesWritten; 34 | } 35 | 36 | async writeLine(data: Buffer) { 37 | await this.readyPromise; 38 | return new Promise((resolve, reject) => { 39 | this.gzip.write(Buffer.concat([data, Buffer.from("\n")]), (error) => { 40 | if (error) { 41 | reject(error); 42 | } else { 43 | resolve(); 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | async getContent() { 50 | return new Promise((resolve, reject) => { 51 | readFile(this.filePath, (error, data) => { 52 | if (error) { 53 | reject(error); 54 | } else { 55 | resolve(data); 56 | } 57 | }); 58 | }); 59 | } 60 | 61 | async close() { 62 | await new Promise((resolve) => { 63 | this.gzip.end(() => { 64 | resolve(); 65 | }); 66 | }); 67 | await this.closedPromise; 68 | } 69 | 70 | async delete() { 71 | await this.close(); 72 | unlinkSync(this.filePath); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "./logging.js"; 2 | import { RequestLoggingConfig } from "./requestLogger.js"; 3 | 4 | export type ApitallyConfig = { 5 | clientId: string; 6 | env?: string; 7 | requestLoggingConfig?: Partial; 8 | appVersion?: string; 9 | logger?: Logger; 10 | }; 11 | 12 | export type ApitallyConsumer = { 13 | identifier: string; 14 | name?: string | null; 15 | group?: string | null; 16 | }; 17 | 18 | export type PathInfo = { 19 | method: string; 20 | path: string; 21 | }; 22 | 23 | export type StartupData = { 24 | paths: PathInfo[]; 25 | versions: Record; 26 | client: string; 27 | }; 28 | 29 | export type StartupPayload = { 30 | instance_uuid: string; 31 | message_uuid: string; 32 | } & StartupData; 33 | 34 | export type ConsumerMethodPath = { 35 | consumer?: string | null; 36 | method: string; 37 | path: string; 38 | }; 39 | 40 | export type RequestInfo = ConsumerMethodPath & { 41 | statusCode: number; 42 | responseTime: number; 43 | requestSize?: string | number | null; 44 | responseSize?: string | number | null; 45 | }; 46 | 47 | export type RequestsItem = ConsumerMethodPath & { 48 | status_code: number; 49 | request_count: number; 50 | request_size_sum: number; 51 | response_size_sum: number; 52 | response_times: Record; 53 | request_sizes: Record; 54 | response_sizes: Record; 55 | }; 56 | 57 | export type ValidationError = { 58 | loc: string; 59 | msg: string; 60 | type: string; 61 | }; 62 | 63 | export type ValidationErrorsItem = ConsumerMethodPath & { 64 | loc: Array; 65 | msg: string; 66 | type: string; 67 | error_count: number; 68 | }; 69 | 70 | export type ServerError = { 71 | type: string; 72 | msg: string; 73 | traceback: string; 74 | }; 75 | 76 | export type ServerErrorsItem = ConsumerMethodPath & { 77 | type: string; 78 | msg: string; 79 | traceback: string; 80 | sentry_event_id: string | null; 81 | error_count: number; 82 | }; 83 | 84 | export type ConsumerItem = ApitallyConsumer; 85 | 86 | export type SyncPayload = { 87 | timestamp: number; 88 | instance_uuid: string; 89 | message_uuid: string; 90 | requests: Array; 91 | validation_errors: Array; 92 | server_errors: Array; 93 | consumers: Array; 94 | }; 95 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { OutgoingHttpHeader } from "http"; 2 | 3 | export function parseContentLength( 4 | contentLength: OutgoingHttpHeader | undefined | null, 5 | ): number | undefined { 6 | if (contentLength === undefined || contentLength === null) { 7 | return undefined; 8 | } 9 | if (typeof contentLength === "number") { 10 | return contentLength; 11 | } 12 | if (typeof contentLength === "string") { 13 | const parsed = parseInt(contentLength); 14 | return isNaN(parsed) ? undefined : parsed; 15 | } 16 | if (Array.isArray(contentLength)) { 17 | return parseContentLength(contentLength[0]); 18 | } 19 | return undefined; 20 | } 21 | -------------------------------------------------------------------------------- /src/common/validationErrorCounter.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | 3 | import { 4 | ConsumerMethodPath, 5 | ValidationError, 6 | ValidationErrorsItem, 7 | } from "./types.js"; 8 | 9 | export default class ValidationErrorCounter { 10 | private errorCounts: Map; 11 | private errorDetails: Map; 12 | 13 | constructor() { 14 | this.errorCounts = new Map(); 15 | this.errorDetails = new Map(); 16 | } 17 | 18 | public addValidationError( 19 | validationError: ConsumerMethodPath & ValidationError, 20 | ) { 21 | const key = this.getKey(validationError); 22 | if (!this.errorDetails.has(key)) { 23 | this.errorDetails.set(key, validationError); 24 | } 25 | this.errorCounts.set(key, (this.errorCounts.get(key) || 0) + 1); 26 | } 27 | 28 | public getAndResetValidationErrors() { 29 | const data: Array = []; 30 | this.errorCounts.forEach((count, key) => { 31 | const validationError = this.errorDetails.get(key); 32 | if (validationError) { 33 | data.push({ 34 | consumer: validationError.consumer || null, 35 | method: validationError.method, 36 | path: validationError.path, 37 | loc: validationError.loc.split("."), 38 | msg: validationError.msg, 39 | type: validationError.type, 40 | error_count: count, 41 | }); 42 | } 43 | }); 44 | this.errorCounts.clear(); 45 | this.errorDetails.clear(); 46 | return data; 47 | } 48 | 49 | private getKey(validationError: ConsumerMethodPath & ValidationError) { 50 | const hashInput = [ 51 | validationError.consumer || "", 52 | validationError.method.toUpperCase(), 53 | validationError.path, 54 | validationError.loc, 55 | validationError.msg.trim(), 56 | validationError.type, 57 | ].join("|"); 58 | return createHash("md5").update(hashInput).digest("hex"); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/express/index.ts: -------------------------------------------------------------------------------- 1 | export type { ApitallyConsumer } from "../common/types.js"; 2 | export { useApitally } from "./middleware.js"; 3 | -------------------------------------------------------------------------------- /src/express/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Express, NextFunction, Request, Response, Router } from "express"; 2 | import { performance } from "perf_hooks"; 3 | 4 | import { ApitallyClient } from "../common/client.js"; 5 | import { consumerFromStringOrObject } from "../common/consumerRegistry.js"; 6 | import { getPackageVersion } from "../common/packageVersions.js"; 7 | import { convertBody, convertHeaders } from "../common/requestLogger.js"; 8 | import { 9 | ApitallyConfig, 10 | ApitallyConsumer, 11 | StartupData, 12 | ValidationError, 13 | } from "../common/types.js"; 14 | import { parseContentLength } from "../common/utils.js"; 15 | import { getEndpoints, parseExpressPath } from "./utils.js"; 16 | 17 | declare module "express" { 18 | interface Request { 19 | apitallyConsumer?: ApitallyConsumer | string | null; 20 | consumerIdentifier?: ApitallyConsumer | string | null; // For backwards compatibility 21 | } 22 | } 23 | 24 | export const useApitally = ( 25 | app: Express | Router, 26 | config: ApitallyConfig & { basePath?: string }, 27 | ) => { 28 | const client = new ApitallyClient(config); 29 | const middleware = getMiddleware(app, client); 30 | app.use(middleware); 31 | setTimeout(() => { 32 | client.setStartupData(getAppInfo(app, config.basePath, config.appVersion)); 33 | }, 1000); 34 | }; 35 | 36 | const getMiddleware = (app: Express | Router, client: ApitallyClient) => { 37 | let errorHandlerConfigured = false; 38 | 39 | return (req: Request, res: Response, next: NextFunction) => { 40 | if (!client.isEnabled()) { 41 | next(); 42 | return; 43 | } 44 | if (!errorHandlerConfigured) { 45 | // Add error handling middleware to the bottom of the stack when handling the first request 46 | app.use( 47 | (err: Error, req: Request, res: Response, next: NextFunction): void => { 48 | res.locals.serverError = err; 49 | next(err); 50 | }, 51 | ); 52 | errorHandlerConfigured = true; 53 | } 54 | try { 55 | const startTime = performance.now(); 56 | const originalSend = res.send; 57 | res.send = (body) => { 58 | const contentType = res.get("content-type"); 59 | if (client.requestLogger.isSupportedContentType(contentType)) { 60 | res.locals.body = body; 61 | } 62 | return originalSend.call(res, body); 63 | }; 64 | 65 | res.on("finish", () => { 66 | try { 67 | const responseTime = performance.now() - startTime; 68 | const path = getRoutePath(req); 69 | const consumer = getConsumer(req); 70 | client.consumerRegistry.addOrUpdateConsumer(consumer); 71 | 72 | const requestSize = parseContentLength(req.get("content-length")); 73 | const responseSize = parseContentLength(res.get("content-length")); 74 | 75 | if (path) { 76 | client.requestCounter.addRequest({ 77 | consumer: consumer?.identifier, 78 | method: req.method, 79 | path, 80 | statusCode: res.statusCode, 81 | responseTime: responseTime, 82 | requestSize, 83 | responseSize, 84 | }); 85 | if ( 86 | (res.statusCode === 400 || res.statusCode === 422) && 87 | res.locals.body 88 | ) { 89 | let jsonBody: any; 90 | try { 91 | jsonBody = JSON.parse(res.locals.body); 92 | } catch { 93 | // Ignore 94 | } 95 | if (jsonBody) { 96 | const validationErrors: ValidationError[] = []; 97 | if (validationErrors.length === 0) { 98 | validationErrors.push( 99 | ...extractExpressValidatorErrors(jsonBody), 100 | ); 101 | } 102 | if (validationErrors.length === 0) { 103 | validationErrors.push(...extractCelebrateErrors(jsonBody)); 104 | } 105 | if (validationErrors.length === 0) { 106 | validationErrors.push( 107 | ...extractNestValidationErrors(jsonBody), 108 | ); 109 | } 110 | validationErrors.forEach((error) => { 111 | client.validationErrorCounter.addValidationError({ 112 | consumer: consumer?.identifier, 113 | method: req.method, 114 | path: req.route.path, 115 | ...error, 116 | }); 117 | }); 118 | } 119 | } 120 | if (res.statusCode === 500 && res.locals.serverError) { 121 | const serverError = res.locals.serverError as Error; 122 | client.serverErrorCounter.addServerError({ 123 | consumer: consumer?.identifier, 124 | method: req.method, 125 | path: req.route.path, 126 | type: serverError.name, 127 | msg: serverError.message, 128 | traceback: serverError.stack || "", 129 | }); 130 | } 131 | } 132 | if (client.requestLogger.enabled) { 133 | client.requestLogger.logRequest( 134 | { 135 | timestamp: Date.now() / 1000, 136 | method: req.method, 137 | path, 138 | url: `${req.protocol}://${req.host}${req.originalUrl}`, 139 | headers: convertHeaders(req.headers), 140 | size: requestSize, 141 | consumer: consumer?.identifier, 142 | body: convertBody(req.body, req.get("content-type")), 143 | }, 144 | { 145 | statusCode: res.statusCode, 146 | responseTime: responseTime / 1000, 147 | headers: convertHeaders(res.getHeaders()), 148 | size: responseSize, 149 | body: convertBody(res.locals.body, res.get("content-type")), 150 | }, 151 | res.locals.serverError, 152 | ); 153 | } 154 | } catch (error) { 155 | client.logger.error( 156 | "Error while logging request in Apitally middleware.", 157 | { request: req, response: res, error }, 158 | ); 159 | } 160 | }); 161 | } catch (error) { 162 | client.logger.error("Error in Apitally middleware.", { 163 | request: req, 164 | response: res, 165 | error, 166 | }); 167 | } finally { 168 | next(); 169 | } 170 | }; 171 | }; 172 | 173 | const getRoutePath = (req: Request) => { 174 | if (!req.route) { 175 | return; 176 | } 177 | if (req.baseUrl) { 178 | const routerPath = getRouterPath(req.app._router.stack, req.baseUrl); 179 | return req.route.path === "/" ? routerPath : routerPath + req.route.path; 180 | } 181 | return req.route.path; 182 | }; 183 | 184 | const getRouterPath = (stack: any[], baseUrl: string) => { 185 | const routerPaths: string[] = []; 186 | while (stack && stack.length > 0) { 187 | const routerLayer = stack.find( 188 | (layer) => 189 | layer.name === "router" && layer.path && layer.regexp?.test(baseUrl), 190 | ); 191 | if (routerLayer) { 192 | if (routerLayer.keys.length > 0) { 193 | const parsedPath = parseExpressPath( 194 | routerLayer.regexp, 195 | routerLayer.keys, 196 | ); 197 | routerPaths.push("/" + parsedPath); 198 | } else { 199 | routerPaths.push(routerLayer.path); 200 | } 201 | stack = routerLayer.handle?.stack; 202 | baseUrl = baseUrl.slice(routerLayer.path.length); 203 | } else { 204 | break; 205 | } 206 | } 207 | return routerPaths.filter((path) => path !== "/").join(""); 208 | }; 209 | 210 | const getConsumer = (req: Request) => { 211 | if (req.apitallyConsumer) { 212 | return consumerFromStringOrObject(req.apitallyConsumer); 213 | } else if (req.consumerIdentifier) { 214 | // For backwards compatibility 215 | process.emitWarning( 216 | "The consumerIdentifier property on the request object is deprecated. Use apitallyConsumer instead.", 217 | "DeprecationWarning", 218 | ); 219 | return consumerFromStringOrObject(req.consumerIdentifier); 220 | } 221 | return null; 222 | }; 223 | 224 | const extractExpressValidatorErrors = (responseBody: any) => { 225 | try { 226 | const errors: ValidationError[] = []; 227 | if ( 228 | responseBody && 229 | responseBody.errors && 230 | Array.isArray(responseBody.errors) 231 | ) { 232 | responseBody.errors.forEach((error: any) => { 233 | if (error.location && error.path && error.msg && error.type) { 234 | errors.push({ 235 | loc: `${error.location}.${error.path}`, 236 | msg: error.msg, 237 | type: error.type, 238 | }); 239 | } 240 | }); 241 | } 242 | return errors; 243 | } catch (error) { 244 | return []; 245 | } 246 | }; 247 | 248 | const extractCelebrateErrors = (responseBody: any) => { 249 | try { 250 | const errors: ValidationError[] = []; 251 | if (responseBody && responseBody.validation) { 252 | Object.values(responseBody.validation).forEach((error: any) => { 253 | if ( 254 | error.source && 255 | error.keys && 256 | Array.isArray(error.keys) && 257 | error.message 258 | ) { 259 | error.keys.forEach((key: string) => { 260 | errors.push({ 261 | loc: `${error.source}.${key}`, 262 | msg: subsetJoiMessage(error.message, key), 263 | type: "", 264 | }); 265 | }); 266 | } 267 | }); 268 | } 269 | return errors; 270 | } catch (error) { 271 | return []; 272 | } 273 | }; 274 | 275 | const extractNestValidationErrors = (responseBody: any) => { 276 | try { 277 | const errors: ValidationError[] = []; 278 | if (responseBody && Array.isArray(responseBody.message)) { 279 | responseBody.message.forEach((message: any) => { 280 | errors.push({ 281 | loc: "", 282 | msg: message, 283 | type: "", 284 | }); 285 | }); 286 | } 287 | return errors; 288 | } catch (error) { 289 | return []; 290 | } 291 | }; 292 | 293 | const subsetJoiMessage = (message: string, key: string) => { 294 | const messageWithKey = message 295 | .split(". ") 296 | .find((message) => message.includes(`"${key}"`)); 297 | return messageWithKey ? messageWithKey : message; 298 | }; 299 | 300 | const getAppInfo = ( 301 | app: Express | Router, 302 | basePath?: string, 303 | appVersion?: string, 304 | ): StartupData => { 305 | const versions: Array<[string, string]> = [ 306 | ["nodejs", process.version.replace(/^v/, "")], 307 | ]; 308 | const expressVersion = getPackageVersion("express"); 309 | const nestjsVersion = getPackageVersion("@nestjs/core"); 310 | const apitallyVersion = getPackageVersion("../.."); 311 | if (expressVersion) { 312 | versions.push(["express", expressVersion]); 313 | } 314 | if (nestjsVersion) { 315 | versions.push(["nestjs", nestjsVersion]); 316 | } 317 | if (apitallyVersion) { 318 | versions.push(["apitally", apitallyVersion]); 319 | } 320 | if (appVersion) { 321 | versions.push(["app", appVersion]); 322 | } 323 | return { 324 | paths: getEndpoints(app, basePath || ""), 325 | versions: Object.fromEntries(versions), 326 | client: "js:express", 327 | }; 328 | }; 329 | -------------------------------------------------------------------------------- /src/express/utils.js: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/AlbertoFdzM/express-list-endpoints/blob/305535d43008b46f34e18b01947762e039af6d2d/src/index.js 2 | // and also incorporated changes from https://github.com/AlbertoFdzM/express-list-endpoints/pull/96 3 | 4 | /** 5 | * @typedef {Object} Route 6 | * @property {Object} methods 7 | * @property {string | string[]} path 8 | * @property {any[]} stack 9 | * 10 | * @typedef {Object} Endpoint 11 | * @property {string} path Path name 12 | * @property {string[]} methods Methods handled 13 | * @property {string[]} middlewares Mounted middlewares 14 | */ 15 | 16 | const regExpToParseExpressPathRegExp = 17 | /^\/\^\\?\/?(?:(:?[\w\\.-]*(?:\\\/:?[\w\\.-]*)*)|(\(\?:\\?\/?\([^)]+\)\)))\\\/.*/; 18 | const regExpToReplaceExpressPathRegExpParams = /\(\?:\\?\/?\([^)]+\)\)/; 19 | const regexpExpressParamRegexp = /\(\?:\\?\\?\/?\([^)]+\)\)/g; 20 | const regexpExpressPathParamRegexp = /(:[^)]+)\([^)]+\)/g; 21 | 22 | const EXPRESS_ROOT_PATH_REGEXP_VALUE = "/^\\/?(?=\\/|$)/i"; 23 | const STACK_ITEM_VALID_NAMES = ["router", "bound dispatch", "mounted_app"]; 24 | 25 | /** 26 | * Returns all the verbs detected for the passed route 27 | * @param {Route} route 28 | */ 29 | const getRouteMethods = function (route) { 30 | let methods = Object.keys(route.methods); 31 | 32 | methods = methods.filter((method) => method !== "_all"); 33 | methods = methods.map((method) => method.toUpperCase()); 34 | 35 | return methods; 36 | }; 37 | 38 | /** 39 | * Returns the names (or anonymous) of all the middlewares attached to the 40 | * passed route 41 | * @param {Route} route 42 | * @returns {string[]} 43 | */ 44 | const getRouteMiddlewares = function (route) { 45 | return route.stack.map((item) => { 46 | return item.handle.name || "anonymous"; 47 | }); 48 | }; 49 | 50 | /** 51 | * Returns true if found regexp related with express params 52 | * @param {string} expressPathRegExp 53 | * @returns {boolean} 54 | */ 55 | const hasParams = function (expressPathRegExp) { 56 | return regexpExpressParamRegexp.test(expressPathRegExp); 57 | }; 58 | 59 | /** 60 | * @param {Route} route Express route object to be parsed 61 | * @param {string} basePath The basePath the route is on 62 | * @return {Endpoint[]} Endpoints info 63 | */ 64 | const parseExpressRoute = function (route, basePath) { 65 | const paths = []; 66 | 67 | if (Array.isArray(route.path)) { 68 | paths.push(...route.path); 69 | } else { 70 | paths.push(route.path); 71 | } 72 | 73 | /** @type {Endpoint[]} */ 74 | const endpoints = paths.map((path) => { 75 | const completePath = 76 | basePath && path === "/" ? basePath : `${basePath}${path}`; 77 | 78 | /** @type {Endpoint} */ 79 | const endpoint = { 80 | path: completePath.replace(regexpExpressPathParamRegexp, "$1"), 81 | methods: getRouteMethods(route), 82 | middlewares: getRouteMiddlewares(route), 83 | }; 84 | 85 | return endpoint; 86 | }); 87 | 88 | return endpoints; 89 | }; 90 | 91 | /** 92 | * @param {RegExp} expressPathRegExp 93 | * @param {any[]} params 94 | * @returns {string} 95 | */ 96 | export const parseExpressPath = function (expressPathRegExp, params) { 97 | let parsedRegExp = expressPathRegExp.toString(); 98 | let expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp); 99 | let paramIndex = 0; 100 | 101 | while (hasParams(parsedRegExp)) { 102 | const paramName = params[paramIndex].name; 103 | const paramId = `:${paramName}`; 104 | 105 | parsedRegExp = parsedRegExp.replace( 106 | regExpToReplaceExpressPathRegExpParams, 107 | (str) => { 108 | // Express >= 4.20.0 uses a different RegExp for parameters: it 109 | // captures the slash as part of the parameter. We need to check 110 | // for this case and add the slash to the value that will replace 111 | // the parameter in the path. 112 | if (str.startsWith("(?:\\/")) { 113 | return `\\/${paramId}`; 114 | } 115 | 116 | return paramId; 117 | }, 118 | ); 119 | 120 | paramIndex++; 121 | } 122 | 123 | if (parsedRegExp !== expressPathRegExp.toString()) { 124 | expressPathRegExpExec = regExpToParseExpressPathRegExp.exec(parsedRegExp); 125 | } 126 | 127 | const parsedPath = expressPathRegExpExec[1].replace(/\\\//g, "/"); 128 | 129 | return parsedPath; 130 | }; 131 | 132 | /** 133 | * @param {import('express').Express | import('express').Router | any} app 134 | * @param {string} [basePath] 135 | * @param {Endpoint[]} [endpoints] 136 | * @returns {Endpoint[]} 137 | */ 138 | const parseEndpoints = function (app, basePath, endpoints) { 139 | const stack = app.stack || (app._router && app._router.stack); 140 | 141 | endpoints = endpoints || []; 142 | basePath = basePath || ""; 143 | 144 | if (!stack) { 145 | if (endpoints.length) { 146 | endpoints = addEndpoints(endpoints, [ 147 | { 148 | path: basePath, 149 | methods: [], 150 | middlewares: [], 151 | }, 152 | ]); 153 | } 154 | } else { 155 | endpoints = parseStack(stack, basePath, endpoints); 156 | } 157 | 158 | return endpoints; 159 | }; 160 | 161 | /** 162 | * Ensures the path of the new endpoints isn't yet in the array. 163 | * If the path is already in the array merges the endpoints with the existing 164 | * one, if not, it adds them to the array. 165 | * 166 | * @param {Endpoint[]} currentEndpoints Array of current endpoints 167 | * @param {Endpoint[]} endpointsToAdd New endpoints to be added to the array 168 | * @returns {Endpoint[]} Updated endpoints array 169 | */ 170 | const addEndpoints = function (currentEndpoints, endpointsToAdd) { 171 | endpointsToAdd.forEach((newEndpoint) => { 172 | const existingEndpoint = currentEndpoints.find( 173 | (endpoint) => endpoint.path === newEndpoint.path, 174 | ); 175 | 176 | if (existingEndpoint !== undefined) { 177 | const newMethods = newEndpoint.methods.filter( 178 | (method) => !existingEndpoint.methods.includes(method), 179 | ); 180 | 181 | existingEndpoint.methods = existingEndpoint.methods.concat(newMethods); 182 | } else { 183 | currentEndpoints.push(newEndpoint); 184 | } 185 | }); 186 | 187 | return currentEndpoints; 188 | }; 189 | 190 | /** 191 | * @param {any[]} stack 192 | * @param {string} basePath 193 | * @param {Endpoint[]} endpoints 194 | * @returns {Endpoint[]} 195 | */ 196 | const parseStack = function (stack, basePath, endpoints) { 197 | stack.forEach((stackItem) => { 198 | if (stackItem.route) { 199 | const newEndpoints = parseExpressRoute(stackItem.route, basePath); 200 | 201 | endpoints = addEndpoints(endpoints, newEndpoints); 202 | } else if (STACK_ITEM_VALID_NAMES.includes(stackItem.name)) { 203 | const isExpressPathRegexp = regExpToParseExpressPathRegExp.test( 204 | stackItem.regexp, 205 | ); 206 | 207 | let newBasePath = basePath; 208 | 209 | if (isExpressPathRegexp) { 210 | const parsedPath = parseExpressPath(stackItem.regexp, stackItem.keys); 211 | 212 | newBasePath += `/${parsedPath}`; 213 | } else if ( 214 | !stackItem.path && 215 | stackItem.regexp && 216 | stackItem.regexp.toString() !== EXPRESS_ROOT_PATH_REGEXP_VALUE 217 | ) { 218 | const regExpPath = ` RegExp(${stackItem.regexp}) `; 219 | 220 | newBasePath += `/${regExpPath}`; 221 | } 222 | 223 | endpoints = parseEndpoints(stackItem.handle, newBasePath, endpoints); 224 | } 225 | }); 226 | 227 | return endpoints; 228 | }; 229 | 230 | export const getEndpoints = function (app, basePath) { 231 | const endpoints = parseEndpoints(app); 232 | return endpoints.flatMap((route) => 233 | route.methods 234 | .filter((method) => !["HEAD", "OPTIONS"].includes(method.toUpperCase())) 235 | .map((method) => ({ 236 | method, 237 | path: (basePath + route.path).replace(/\/\//g, "/"), 238 | })), 239 | ); 240 | }; 241 | -------------------------------------------------------------------------------- /src/fastify/index.ts: -------------------------------------------------------------------------------- 1 | export type { ApitallyConsumer } from "../common/types.js"; 2 | export { default as apitallyPlugin } from "./plugin.js"; 3 | -------------------------------------------------------------------------------- /src/fastify/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FastifyError, 3 | FastifyPluginAsync, 4 | FastifyReply, 5 | FastifyRequest, 6 | } from "fastify"; 7 | import fp from "fastify-plugin"; 8 | 9 | import { ApitallyClient } from "../common/client.js"; 10 | import { consumerFromStringOrObject } from "../common/consumerRegistry.js"; 11 | import { getPackageVersion } from "../common/packageVersions.js"; 12 | import { convertBody, convertHeaders } from "../common/requestLogger.js"; 13 | import { 14 | ApitallyConfig, 15 | ApitallyConsumer, 16 | PathInfo, 17 | ValidationError, 18 | } from "../common/types.js"; 19 | import { parseContentLength } from "../common/utils.js"; 20 | 21 | declare module "fastify" { 22 | interface FastifyReply { 23 | payload: any; 24 | serverError?: FastifyError; 25 | } 26 | 27 | interface FastifyRequest { 28 | apitallyConsumer?: ApitallyConsumer | string | null; 29 | consumerIdentifier?: ApitallyConsumer | string | null; // For backwards compatibility 30 | } 31 | } 32 | 33 | const apitallyPlugin: FastifyPluginAsync = async ( 34 | fastify, 35 | config, 36 | ) => { 37 | const client = new ApitallyClient(config); 38 | const routes: PathInfo[] = []; 39 | 40 | fastify.decorateRequest("apitallyConsumer", null); 41 | fastify.decorateRequest("consumerIdentifier", null); // For backwards compatibility 42 | fastify.decorateReply("payload", null); 43 | 44 | fastify.addHook("onRoute", (routeOptions) => { 45 | const methods = Array.isArray(routeOptions.method) 46 | ? routeOptions.method 47 | : [routeOptions.method]; 48 | methods.forEach((method) => { 49 | if (!["HEAD", "OPTIONS"].includes(method.toUpperCase())) { 50 | routes.push({ 51 | method: method.toUpperCase(), 52 | path: routeOptions.url, 53 | }); 54 | } 55 | }); 56 | }); 57 | 58 | fastify.addHook("onReady", () => { 59 | client.setStartupData(getAppInfo(routes, config.appVersion)); 60 | }); 61 | 62 | fastify.addHook("onClose", async () => { 63 | await client.handleShutdown(); 64 | }); 65 | 66 | fastify.addHook("onSend", (request, reply, payload: any, done) => { 67 | const contentType = reply.getHeader("content-type") as string | undefined; 68 | if (client.requestLogger.isSupportedContentType(contentType)) { 69 | reply.payload = payload; 70 | } 71 | done(); 72 | }); 73 | 74 | fastify.addHook("onError", (request, reply, error, done) => { 75 | if (!error.statusCode || error.statusCode === 500) { 76 | reply.serverError = error; 77 | } 78 | done(); 79 | }); 80 | 81 | fastify.addHook("onResponse", (request, reply, done) => { 82 | if (client.isEnabled() && request.method.toUpperCase() !== "OPTIONS") { 83 | // Get path from routeOptions if available (from v4), otherwise fallback to deprecated routerPath 84 | const consumer = getConsumer(request); 85 | const path = 86 | "routeOptions" in request 87 | ? (request as any).routeOptions.url 88 | : (request as any).routerPath; 89 | const requestSize = parseContentLength(request.headers["content-length"]); 90 | const responseSize = parseContentLength( 91 | reply.getHeader("content-length"), 92 | ); 93 | const responseTime = getResponseTime(reply); 94 | client.consumerRegistry.addOrUpdateConsumer(consumer); 95 | client.requestCounter.addRequest({ 96 | consumer: consumer?.identifier, 97 | method: request.method, 98 | path, 99 | statusCode: reply.statusCode, 100 | responseTime, 101 | requestSize: requestSize, 102 | responseSize: responseSize, 103 | }); 104 | if ( 105 | (reply.statusCode === 400 || reply.statusCode === 422) && 106 | reply.payload 107 | ) { 108 | try { 109 | const parsedPayload = JSON.parse(reply.payload); 110 | if ( 111 | (!parsedPayload.code || 112 | parsedPayload.code === "FST_ERR_VALIDATION") && 113 | typeof parsedPayload.message === "string" 114 | ) { 115 | const validationErrors = extractAjvErrors(parsedPayload.message); 116 | validationErrors.forEach((error) => { 117 | client.validationErrorCounter.addValidationError({ 118 | consumer: consumer?.identifier, 119 | method: request.method, 120 | path: path, 121 | ...error, 122 | }); 123 | }); 124 | } 125 | } catch (error) {} // eslint-disable-line no-empty 126 | } 127 | if (reply.statusCode === 500 && reply.serverError) { 128 | client.serverErrorCounter.addServerError({ 129 | consumer: consumer?.identifier, 130 | method: request.method, 131 | path: path, 132 | type: reply.serverError.name, 133 | msg: reply.serverError.message, 134 | traceback: reply.serverError.stack || "", 135 | }); 136 | } 137 | if (client.requestLogger.enabled) { 138 | client.requestLogger.logRequest( 139 | { 140 | timestamp: Date.now() / 1000, 141 | method: request.method, 142 | path, 143 | url: `${request.protocol}://${request.host ?? request.hostname}${request.originalUrl ?? request.url}`, 144 | headers: convertHeaders(request.headers), 145 | size: Number(requestSize), 146 | consumer: consumer?.identifier, 147 | body: convertBody(request.body, request.headers["content-type"]), 148 | }, 149 | { 150 | statusCode: reply.statusCode, 151 | responseTime: responseTime / 1000, 152 | headers: convertHeaders(reply.getHeaders()), 153 | size: Number(responseSize), 154 | body: convertBody( 155 | reply.payload, 156 | reply.getHeader("content-type")?.toString(), 157 | ), 158 | }, 159 | reply.serverError, 160 | ); 161 | } 162 | } 163 | done(); 164 | }); 165 | }; 166 | 167 | const getAppInfo = (routes: PathInfo[], appVersion?: string) => { 168 | const versions = [["nodejs", process.version.replace(/^v/, "")]]; 169 | const fastifyVersion = getPackageVersion("fastify"); 170 | const apitallyVersion = getPackageVersion("../.."); 171 | if (fastifyVersion) { 172 | versions.push(["fastify", fastifyVersion]); 173 | } 174 | if (apitallyVersion) { 175 | versions.push(["apitally", apitallyVersion]); 176 | } 177 | if (appVersion) { 178 | versions.push(["app", appVersion]); 179 | } 180 | return { 181 | paths: routes, 182 | versions: Object.fromEntries(versions), 183 | client: "js:fastify", 184 | }; 185 | }; 186 | 187 | const getConsumer = (request: FastifyRequest) => { 188 | if (request.apitallyConsumer) { 189 | return consumerFromStringOrObject(request.apitallyConsumer); 190 | } else if (request.consumerIdentifier) { 191 | // For backwards compatibility 192 | process.emitWarning( 193 | "The consumerIdentifier property on the request object is deprecated. Use apitallyConsumer instead.", 194 | "DeprecationWarning", 195 | ); 196 | return consumerFromStringOrObject(request.consumerIdentifier); 197 | } 198 | return null; 199 | }; 200 | 201 | const getResponseTime = (reply: FastifyReply) => { 202 | if (reply.elapsedTime !== undefined) { 203 | return reply.elapsedTime; 204 | } else if ((reply as any).getResponseTime !== undefined) { 205 | return (reply as any).getResponseTime(); 206 | } 207 | return 0; 208 | }; 209 | 210 | const extractAjvErrors = (message: string): ValidationError[] => { 211 | const regex = 212 | /(?<=^|, )((?:headers|params|query|querystring|body)[/.][^ ]+)(?= )/g; 213 | const matches: { match: string; index: number }[] = []; 214 | let match: RegExpExecArray | null; 215 | while ((match = regex.exec(message)) !== null) { 216 | matches.push({ match: match[0], index: match.index }); 217 | } 218 | 219 | return matches.map((m, i) => { 220 | const endIndex = 221 | i + 1 < matches.length ? matches[i + 1].index - 2 : message.length; 222 | const matchSplit = m.match.split(/[/.]/); 223 | if (matchSplit[0] === "querystring") { 224 | matchSplit[0] = "query"; 225 | } 226 | return { 227 | loc: matchSplit.join("."), 228 | msg: message.substring(m.index, endIndex), 229 | type: "", 230 | }; 231 | }); 232 | }; 233 | 234 | export default fp(apitallyPlugin, { 235 | name: "apitally", 236 | }); 237 | -------------------------------------------------------------------------------- /src/hono/index.ts: -------------------------------------------------------------------------------- 1 | export type { ApitallyConsumer } from "../common/types.js"; 2 | export { useApitally } from "./middleware.js"; 3 | -------------------------------------------------------------------------------- /src/hono/middleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Hono } from "hono"; 2 | import { MiddlewareHandler } from "hono/types"; 3 | import { isMiddleware } from "hono/utils/handler"; 4 | import { performance } from "perf_hooks"; 5 | import type { ZodError } from "zod"; 6 | 7 | import { ApitallyClient } from "../common/client.js"; 8 | import { consumerFromStringOrObject } from "../common/consumerRegistry.js"; 9 | import { getPackageVersion } from "../common/packageVersions.js"; 10 | import { convertHeaders } from "../common/requestLogger.js"; 11 | import { 12 | ApitallyConfig, 13 | ApitallyConsumer, 14 | PathInfo, 15 | StartupData, 16 | ValidationError, 17 | } from "../common/types.js"; 18 | import { parseContentLength } from "../common/utils.js"; 19 | 20 | declare module "hono" { 21 | interface ContextVariableMap { 22 | apitallyConsumer?: ApitallyConsumer | string; 23 | } 24 | } 25 | 26 | export const useApitally = (app: Hono, config: ApitallyConfig) => { 27 | const client = new ApitallyClient(config); 28 | const middleware = getMiddleware(client); 29 | app.use(middleware); 30 | setTimeout(() => { 31 | client.setStartupData(getAppInfo(app, config.appVersion)); 32 | }, 1000); 33 | }; 34 | 35 | const getMiddleware = (client: ApitallyClient): MiddlewareHandler => { 36 | return async (c, next) => { 37 | if (!client.isEnabled()) { 38 | await next(); 39 | return; 40 | } 41 | const startTime = performance.now(); 42 | await next(); 43 | let response; 44 | const responseTime = performance.now() - startTime; 45 | const [responseSize, newResponse] = await measureResponseSize(c.res); 46 | const requestSize = parseContentLength(c.req.header("content-length")); 47 | const consumer = getConsumer(c); 48 | client.consumerRegistry.addOrUpdateConsumer(consumer); 49 | client.requestCounter.addRequest({ 50 | consumer: consumer?.identifier, 51 | method: c.req.method, 52 | path: c.req.routePath, 53 | statusCode: c.res.status, 54 | responseTime, 55 | requestSize, 56 | responseSize, 57 | }); 58 | response = newResponse; 59 | if (c.res.status === 400) { 60 | const [responseJson, newResponse] = await getResponseJson(response); 61 | const validationErrors = extractZodErrors(responseJson); 62 | validationErrors.forEach((error) => { 63 | client.validationErrorCounter.addValidationError({ 64 | consumer: consumer?.identifier, 65 | method: c.req.method, 66 | path: c.req.routePath, 67 | ...error, 68 | }); 69 | }); 70 | response = newResponse; 71 | } 72 | if (c.error) { 73 | client.serverErrorCounter.addServerError({ 74 | consumer: consumer?.identifier, 75 | method: c.req.method, 76 | path: c.req.routePath, 77 | type: c.error.name, 78 | msg: c.error.message, 79 | traceback: c.error.stack || "", 80 | }); 81 | } 82 | if (client.requestLogger.enabled) { 83 | let requestBody; 84 | let responseBody; 85 | let newResponse = response; 86 | const responseContentType = c.res.headers.get("content-type"); 87 | if (client.requestLogger.config.logRequestBody) { 88 | requestBody = Buffer.from(await c.req.arrayBuffer()); 89 | } 90 | if ( 91 | client.requestLogger.config.logResponseBody && 92 | client.requestLogger.isSupportedContentType(responseContentType) 93 | ) { 94 | [responseBody, newResponse] = await getResponseBody(response); 95 | response = newResponse; 96 | } 97 | client.requestLogger.logRequest( 98 | { 99 | timestamp: Date.now() / 1000, 100 | method: c.req.method, 101 | path: c.req.routePath, 102 | url: c.req.url, 103 | headers: convertHeaders(c.req.header()), 104 | size: Number(requestSize), 105 | consumer: consumer?.identifier, 106 | body: requestBody, 107 | }, 108 | { 109 | statusCode: c.res.status, 110 | responseTime: responseTime / 1000, 111 | headers: convertHeaders(c.res.headers), 112 | size: responseSize, 113 | body: responseBody, 114 | }, 115 | c.error, 116 | ); 117 | } 118 | c.res = response; 119 | }; 120 | }; 121 | 122 | const getConsumer = (c: Context) => { 123 | const consumer = c.get("apitallyConsumer"); 124 | if (consumer) { 125 | return consumerFromStringOrObject(consumer); 126 | } 127 | return null; 128 | }; 129 | 130 | const measureResponseSize = async ( 131 | response: Response, 132 | ): Promise<[number, Response]> => { 133 | const [newResponse1, newResponse2] = await teeResponse(response); 134 | let size = 0; 135 | if (newResponse2.body) { 136 | let done = false; 137 | const reader = newResponse2.body.getReader(); 138 | while (!done) { 139 | const result = await reader.read(); 140 | done = result.done; 141 | if (!done && result.value) { 142 | size += result.value.byteLength; 143 | } 144 | } 145 | } 146 | return [size, newResponse1]; 147 | }; 148 | 149 | const getResponseBody = async ( 150 | response: Response, 151 | ): Promise<[Buffer, Response]> => { 152 | const [newResponse1, newResponse2] = await teeResponse(response); 153 | const responseBuffer = Buffer.from(await newResponse2.arrayBuffer()); 154 | return [responseBuffer, newResponse1]; 155 | }; 156 | 157 | const getResponseJson = async ( 158 | response: Response, 159 | ): Promise<[any, Response]> => { 160 | const contentType = response.headers.get("content-type"); 161 | if (contentType && contentType.includes("application/json")) { 162 | const [newResponse1, newResponse2] = await teeResponse(response); 163 | const responseJson = await newResponse2.json(); 164 | return [responseJson, newResponse1]; 165 | } 166 | return [null, response]; 167 | }; 168 | 169 | const teeResponse = async ( 170 | response: Response, 171 | ): Promise<[Response, Response]> => { 172 | if (!response.body) { 173 | return [response, response]; 174 | } 175 | const [stream1, stream2] = response.body.tee(); 176 | const newResponse1 = new Response(stream1, { 177 | status: response.status, 178 | statusText: response.statusText, 179 | headers: response.headers, 180 | }); 181 | const newResponse2 = new Response(stream2, { 182 | status: response.status, 183 | statusText: response.statusText, 184 | headers: response.headers, 185 | }); 186 | return [newResponse1, newResponse2]; 187 | }; 188 | 189 | const extractZodErrors = (responseJson: any) => { 190 | const errors: ValidationError[] = []; 191 | if ( 192 | responseJson && 193 | responseJson.success === false && 194 | responseJson.error && 195 | responseJson.error.name === "ZodError" 196 | ) { 197 | const zodError = responseJson.error as ZodError; 198 | zodError.issues.forEach((zodIssue) => { 199 | errors.push({ 200 | loc: zodIssue.path.join("."), 201 | msg: zodIssue.message, 202 | type: zodIssue.code, 203 | }); 204 | }); 205 | } 206 | return errors; 207 | }; 208 | 209 | const getAppInfo = (app: Hono, appVersion?: string): StartupData => { 210 | const versions: Array<[string, string]> = []; 211 | if (process.versions.node) { 212 | versions.push(["nodejs", process.versions.node]); 213 | } 214 | if (process.versions.bun) { 215 | versions.push(["bun", process.versions.bun]); 216 | } 217 | const honoVersion = getPackageVersion("hono"); 218 | const apitallyVersion = getPackageVersion("../.."); 219 | if (honoVersion) { 220 | versions.push(["hono", honoVersion]); 221 | } 222 | if (apitallyVersion) { 223 | versions.push(["apitally", apitallyVersion]); 224 | } 225 | if (appVersion) { 226 | versions.push(["app", appVersion]); 227 | } 228 | return { 229 | paths: listEndpoints(app), 230 | versions: Object.fromEntries(versions), 231 | client: "js:hono", 232 | }; 233 | }; 234 | 235 | const listEndpoints = (app: Hono) => { 236 | const endpoints: Array = []; 237 | app.routes.forEach((route) => { 238 | if (route.method !== "ALL" && !isMiddleware(route.handler)) { 239 | endpoints.push({ 240 | method: route.method.toUpperCase(), 241 | path: route.path, 242 | }); 243 | } 244 | }); 245 | return endpoints; 246 | }; 247 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apitally/apitally-js/3c09d27cf8e37b23f1b2a9a228a4c578794f9a65/src/index.ts -------------------------------------------------------------------------------- /src/koa/index.ts: -------------------------------------------------------------------------------- 1 | export type { ApitallyConsumer } from "../common/types.js"; 2 | export { useApitally } from "./middleware.js"; 3 | -------------------------------------------------------------------------------- /src/koa/middleware.ts: -------------------------------------------------------------------------------- 1 | import Koa from "koa"; 2 | 3 | import { ApitallyClient } from "../common/client.js"; 4 | import { consumerFromStringOrObject } from "../common/consumerRegistry.js"; 5 | import { getPackageVersion } from "../common/packageVersions.js"; 6 | import { convertBody, convertHeaders } from "../common/requestLogger.js"; 7 | import { ApitallyConfig, PathInfo, StartupData } from "../common/types.js"; 8 | 9 | export const useApitally = (app: Koa, config: ApitallyConfig) => { 10 | const client = new ApitallyClient(config); 11 | const middleware = getMiddleware(client); 12 | app.use(middleware); 13 | setTimeout(() => { 14 | client.setStartupData(getAppInfo(app, config.appVersion)); 15 | }, 1000); 16 | }; 17 | 18 | const getMiddleware = (client: ApitallyClient) => { 19 | return async (ctx: Koa.Context, next: Koa.Next) => { 20 | if (!client.isEnabled()) { 21 | await next(); 22 | return; 23 | } 24 | let path: string | undefined; 25 | let statusCode: number | undefined; 26 | let serverError: Error | undefined; 27 | const startTime = performance.now(); 28 | try { 29 | await next(); 30 | } catch (error: any) { 31 | path = getPath(ctx); 32 | statusCode = error.statusCode || error.status || 500; 33 | if (path && statusCode === 500 && error instanceof Error) { 34 | serverError = error; 35 | client.serverErrorCounter.addServerError({ 36 | consumer: getConsumer(ctx)?.identifier, 37 | method: ctx.request.method, 38 | path, 39 | type: error.name, 40 | msg: error.message, 41 | traceback: error.stack || "", 42 | }); 43 | } 44 | throw error; 45 | } finally { 46 | const responseTime = performance.now() - startTime; 47 | const consumer = getConsumer(ctx); 48 | client.consumerRegistry.addOrUpdateConsumer(consumer); 49 | if (!path) { 50 | path = getPath(ctx); 51 | } 52 | if (path) { 53 | try { 54 | client.requestCounter.addRequest({ 55 | consumer: consumer?.identifier, 56 | method: ctx.request.method, 57 | path, 58 | statusCode: statusCode || ctx.response.status, 59 | responseTime, 60 | requestSize: ctx.request.length, 61 | responseSize: ctx.response.length, 62 | }); 63 | } catch (error) { 64 | client.logger.error( 65 | "Error while logging request in Apitally middleware.", 66 | { context: ctx, error }, 67 | ); 68 | } 69 | } 70 | if (client.requestLogger.enabled) { 71 | client.requestLogger.logRequest( 72 | { 73 | timestamp: Date.now() / 1000, 74 | method: ctx.request.method, 75 | path, 76 | url: ctx.request.href, 77 | headers: convertHeaders(ctx.request.headers), 78 | size: ctx.request.length, 79 | consumer: consumer?.identifier, 80 | body: convertBody( 81 | ctx.request.body, 82 | ctx.request.get("content-type"), 83 | ), 84 | }, 85 | { 86 | statusCode: statusCode || ctx.response.status, 87 | responseTime: responseTime / 1000, 88 | headers: convertHeaders(ctx.response.headers), 89 | size: ctx.response.length, 90 | body: convertBody( 91 | ctx.response.body, 92 | ctx.response.get("content-type"), 93 | ), 94 | }, 95 | serverError, 96 | ); 97 | } 98 | } 99 | }; 100 | }; 101 | 102 | const getPath = (ctx: Koa.Context) => { 103 | return ctx._matchedRoute || ctx.routePath; // _matchedRoute is set by koa-router, routePath is set by koa-route 104 | }; 105 | 106 | const getConsumer = (ctx: Koa.Context) => { 107 | if (ctx.state.apitallyConsumer) { 108 | return consumerFromStringOrObject(ctx.state.apitallyConsumer); 109 | } else if (ctx.state.consumerIdentifier) { 110 | // For backwards compatibility 111 | process.emitWarning( 112 | "The consumerIdentifier property on the ctx.state object is deprecated. Use apitallyConsumer instead.", 113 | "DeprecationWarning", 114 | ); 115 | return consumerFromStringOrObject(ctx.state.consumerIdentifier); 116 | } 117 | return null; 118 | }; 119 | 120 | const getAppInfo = (app: Koa, appVersion?: string): StartupData => { 121 | const versions: Array<[string, string]> = [ 122 | ["nodejs", process.version.replace(/^v/, "")], 123 | ]; 124 | const koaVersion = getPackageVersion("koa"); 125 | const apitallyVersion = getPackageVersion("../.."); 126 | if (koaVersion) { 127 | versions.push(["koa", koaVersion]); 128 | } 129 | if (apitallyVersion) { 130 | versions.push(["apitally", apitallyVersion]); 131 | } 132 | if (appVersion) { 133 | versions.push(["app", appVersion]); 134 | } 135 | return { 136 | paths: listEndpoints(app), 137 | versions: Object.fromEntries(versions), 138 | client: "js:koa", 139 | }; 140 | }; 141 | 142 | const isKoaRouterMiddleware = (middleware: any) => { 143 | return ( 144 | typeof middleware === "function" && 145 | middleware.router && 146 | Array.isArray(middleware.router.stack) 147 | ); 148 | }; 149 | 150 | const listEndpoints = (app: Koa) => { 151 | const endpoints: Array = []; 152 | app.middleware.forEach((middleware: any) => { 153 | if (isKoaRouterMiddleware(middleware)) { 154 | middleware.router.stack.forEach((layer: any) => { 155 | if (layer.methods && layer.methods.length > 0) { 156 | layer.methods.forEach((method: string) => { 157 | if (!["HEAD", "OPTIONS"].includes(method.toUpperCase())) { 158 | endpoints.push({ 159 | method: method.toUpperCase(), 160 | path: layer.path, 161 | }); 162 | } 163 | }); 164 | } 165 | }); 166 | } 167 | }); 168 | return endpoints; 169 | }; 170 | -------------------------------------------------------------------------------- /src/nestjs/index.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, INestApplication } from "@nestjs/common"; 2 | import { BaseExceptionFilter } from "@nestjs/core"; 3 | import { Response } from "express"; 4 | 5 | import type { ApitallyConfig } from "../common/types.js"; 6 | import { useApitally as useApitallyExpress } from "../express/index.js"; 7 | export type { ApitallyConsumer } from "../common/types.js"; 8 | 9 | export const useApitally = (app: INestApplication, config: ApitallyConfig) => { 10 | const httpAdapter = app.getHttpAdapter(); 11 | const expressInstance = httpAdapter.getInstance(); 12 | useApitallyExpress(expressInstance, config); 13 | app.useGlobalFilters(new AllExceptionsFilter(httpAdapter)); 14 | }; 15 | 16 | @Catch() 17 | class AllExceptionsFilter extends BaseExceptionFilter { 18 | catch(exception: unknown, host: ArgumentsHost) { 19 | const ctx = host.switchToHttp(); 20 | const res = ctx.getResponse(); 21 | res.locals.serverError = exception; 22 | super.catch(exception, host); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/adonisjs/app.test.ts: -------------------------------------------------------------------------------- 1 | import { ServerFactory } from "@adonisjs/core/factories/http"; 2 | import { ApplicationService } from "@adonisjs/core/types"; 3 | import { createServer } from "http"; 4 | import supertest from "supertest"; 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 6 | 7 | import ApitallyProvider from "../../src/adonisjs/provider.js"; 8 | import { ApitallyClient } from "../../src/common/client.js"; 9 | import { mockApitallyHub } from "../utils.js"; 10 | import { createApp, createRoutes } from "./app.js"; 11 | 12 | describe("Middleware for AdonisJS", () => { 13 | let app: ApplicationService; 14 | let provider: ApitallyProvider; 15 | let client: ApitallyClient; 16 | let testAgent: supertest.Agent; 17 | 18 | beforeEach(async () => { 19 | mockApitallyHub(); 20 | 21 | app = await createApp(); 22 | provider = new ApitallyProvider(app); 23 | provider.register(); 24 | 25 | const server = new ServerFactory().merge({ app }).create(); 26 | const router = server.getRouter(); 27 | createRoutes(router); 28 | app.container.bindValue("router", router); 29 | await server.boot(); 30 | 31 | client = await app.container.make("apitallyClient"); 32 | 33 | const httpServer = createServer(server.handle.bind(server)); 34 | testAgent = supertest(httpServer); 35 | }); 36 | 37 | it("Provider sets startup data", async () => { 38 | const spy = vi.spyOn(client, "setStartupData"); 39 | await provider.ready(); 40 | expect(spy).toHaveBeenCalledOnce(); 41 | 42 | const startupData = spy.mock.lastCall?.[0]; 43 | expect(startupData?.paths).toEqual([ 44 | { 45 | method: "GET", 46 | path: "/hello", 47 | }, 48 | { 49 | method: "GET", 50 | path: "/hello/:id", 51 | }, 52 | { 53 | method: "POST", 54 | path: "/hello", 55 | }, 56 | { 57 | method: "GET", 58 | path: "/error", 59 | }, 60 | ]); 61 | expect(startupData?.versions["adonisjs"]).toBeDefined(); 62 | expect(startupData?.versions["app"]).toBe("1.2.3"); 63 | expect(startupData?.client).toBe("js:adonisjs"); 64 | }); 65 | 66 | it("Request counter", async () => { 67 | await testAgent.get("/hello?name=John&age=20").expect(200); 68 | await testAgent.get("/hello?name=Mary&age=19").expect(200); 69 | await testAgent.post("/hello").send({ name: "John", age: 20 }).expect(200); 70 | await testAgent.get("/error").expect(500); 71 | 72 | const requests = client.requestCounter.getAndResetRequests(); 73 | expect(requests.length).toBe(3); 74 | expect( 75 | requests.some( 76 | (r) => 77 | r.method === "GET" && 78 | r.path === "/hello" && 79 | r.status_code === 200 && 80 | r.request_size_sum === 0 && 81 | r.response_size_sum > 0 && 82 | r.request_count === 2, 83 | ), 84 | ).toBe(true); 85 | expect( 86 | requests.some( 87 | (r) => 88 | r.method === "POST" && 89 | r.path === "/hello" && 90 | r.status_code === 200 && 91 | r.request_size_sum > 0 && 92 | r.response_size_sum > 0 && 93 | r.request_count === 1, 94 | ), 95 | ).toBe(true); 96 | expect( 97 | requests.some((r) => r.status_code === 500 && r.request_count === 1), 98 | ).toBe(true); 99 | }); 100 | 101 | it("Request logger", async () => { 102 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 103 | let call; 104 | 105 | await testAgent.get("/hello?name=John&age=20").expect(200); 106 | expect(spy).toHaveBeenCalledOnce(); 107 | call = spy.mock.calls[0]; 108 | expect(call[0].method).toBe("GET"); 109 | expect(call[0].path).toBe("/hello"); 110 | expect(call[0].url).toMatch( 111 | /^http:\/\/127\.0\.0\.1(:\d+)?\/hello\?name=John&age=20$/, 112 | ); 113 | expect(call[1].statusCode).toBe(200); 114 | expect(call[1].responseTime).toBeGreaterThan(0); 115 | expect(call[1].responseTime).toBeLessThan(1); 116 | expect(call[1].size).toBeGreaterThan(0); 117 | expect(call[1].headers).toContainEqual([ 118 | "content-type", 119 | "text/plain; charset=utf-8", 120 | ]); 121 | expect(call[1].body).toBeInstanceOf(Buffer); 122 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 123 | spy.mockReset(); 124 | 125 | await testAgent.post("/hello").send({ name: "John", age: 20 }).expect(200); 126 | expect(spy).toHaveBeenCalledOnce(); 127 | call = spy.mock.calls[0]; 128 | expect(call[0].method).toBe("POST"); 129 | expect(call[0].path).toBe("/hello"); 130 | expect(call[0].headers).toContainEqual([ 131 | "content-type", 132 | "application/json", 133 | ]); 134 | expect(call[0].body).toBeInstanceOf(Buffer); 135 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 136 | expect(call[1].body).toBeInstanceOf(Buffer); 137 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 138 | }); 139 | 140 | it("Validation error counter", async () => { 141 | await testAgent.post("/hello").send({ name: "X", age: 1 }).expect(422); 142 | 143 | const validationErrors = 144 | client.validationErrorCounter.getAndResetValidationErrors(); 145 | expect(validationErrors.length).toBe(2); 146 | expect( 147 | validationErrors.some( 148 | (e) => 149 | e.loc[0] == "name" && e.type == "minLength" && e.error_count == 1, 150 | ), 151 | ).toBe(true); 152 | expect( 153 | validationErrors.some( 154 | (e) => e.loc[0] == "age" && e.type == "min" && e.error_count == 1, 155 | ), 156 | ).toBe(true); 157 | }); 158 | 159 | it("Server error counter", async () => { 160 | await testAgent.get("/error").expect(500); 161 | 162 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 163 | expect(serverErrors.length).toBe(1); 164 | expect( 165 | serverErrors.some( 166 | (e) => 167 | e.type === "Error" && 168 | e.msg === "test" && 169 | e.traceback && 170 | e.error_count === 1, 171 | ), 172 | ).toBe(true); 173 | }); 174 | 175 | afterEach(async () => { 176 | await provider.shutdown(); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /tests/adonisjs/app.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig as defineBodyParserConfig } from "@adonisjs/core/bodyparser"; 2 | import BodyParserMiddleware from "@adonisjs/core/bodyparser_middleware"; 3 | import { AppFactory } from "@adonisjs/core/factories/app"; 4 | import { Router } from "@adonisjs/core/http"; 5 | import { ApplicationService } from "@adonisjs/core/types"; 6 | import vine from "@vinejs/vine"; 7 | 8 | import { 9 | captureError, 10 | defineConfig as defineApitallyConfig, 11 | } from "../../src/adonisjs/index.js"; 12 | import ApitallyMiddleware from "../../src/adonisjs/middleware.js"; 13 | import { CLIENT_ID, ENV } from "../utils.js"; 14 | 15 | const BASE_URL = new URL("./tmp/", import.meta.url); 16 | 17 | export const createApp = async () => { 18 | const app = new AppFactory().create(BASE_URL) as ApplicationService; 19 | 20 | app.useConfig({ 21 | apitally: defineApitallyConfig({ 22 | clientId: CLIENT_ID, 23 | env: ENV, 24 | appVersion: "1.2.3", 25 | requestLoggingConfig: { 26 | enabled: true, 27 | logQueryParams: true, 28 | logRequestHeaders: true, 29 | logRequestBody: true, 30 | logResponseHeaders: true, 31 | logResponseBody: true, 32 | }, 33 | }), 34 | }); 35 | 36 | await app.init(); 37 | await app.boot(); 38 | return app; 39 | }; 40 | 41 | export const createRoutes = (router: Router) => { 42 | const apitallyMiddleware = new ApitallyMiddleware(); 43 | const apitallyHandle = apitallyMiddleware.handle.bind(apitallyMiddleware); 44 | 45 | const bodyParserConfig = defineBodyParserConfig({}); 46 | const bodyParserMiddleware = new BodyParserMiddleware(bodyParserConfig); 47 | const bodyParserHandle = 48 | bodyParserMiddleware.handle.bind(bodyParserMiddleware); 49 | 50 | const helloValidator = vine.compile( 51 | vine.object({ 52 | name: vine.string().trim().minLength(3), 53 | age: vine.number().min(18), 54 | }), 55 | ); 56 | 57 | router 58 | .get("/hello", async ({ request, response }) => { 59 | const name = request.qs().name; 60 | const age = request.qs().age; 61 | response.type("txt"); 62 | return `Hello ${name}! You are ${age} years old!`; 63 | }) 64 | .middleware(apitallyHandle); 65 | 66 | router 67 | .get("/hello/:id", async ({ params }) => { 68 | const id = params.id; 69 | return `Hello ID ${id}!`; 70 | }) 71 | .where("id", /^\d+$/) 72 | .middleware(apitallyHandle); 73 | 74 | router 75 | .post("/hello", async (ctx) => { 76 | const data = ctx.request.all(); 77 | try { 78 | const { name, age } = await helloValidator.validate(data); 79 | ctx.response.type("txt"); 80 | return `Hello ${name}! You are ${age} years old!`; 81 | } catch (error) { 82 | captureError(error, ctx); 83 | throw error; 84 | } 85 | }) 86 | .middleware(bodyParserHandle) 87 | .middleware(apitallyHandle); 88 | 89 | router 90 | .get("/error", async (ctx) => { 91 | const error = new Error("test"); 92 | captureError(error, ctx); 93 | throw error; 94 | }) 95 | .middleware(apitallyHandle); 96 | 97 | return router; 98 | }; 99 | -------------------------------------------------------------------------------- /tests/common/client.test.ts: -------------------------------------------------------------------------------- 1 | import nock from "nock"; 2 | import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; 3 | 4 | import { ApitallyClient } from "../../src/common/client.js"; 5 | import { APITALLY_HUB_BASE_URL, CLIENT_ID, ENV } from "../utils.js"; 6 | 7 | describe("Client", () => { 8 | beforeAll(() => { 9 | nock(APITALLY_HUB_BASE_URL) 10 | .persist() 11 | .post(/\/sync$/) 12 | .reply(202); 13 | }); 14 | 15 | it("Argument validation on instantiation", () => { 16 | expect(() => new ApitallyClient({ clientId: "xxx" })).toThrow("xxx"); 17 | expect( 18 | () => new ApitallyClient({ clientId: CLIENT_ID, env: "..." }), 19 | ).toThrow("..."); 20 | }); 21 | 22 | it("Singleton instantiation", () => { 23 | expect(() => ApitallyClient.getInstance()).toThrow("not initialized"); 24 | expect(() => { 25 | new ApitallyClient({ clientId: CLIENT_ID, env: ENV }); 26 | new ApitallyClient({ clientId: CLIENT_ID, env: "other" }); 27 | }).toThrow("already initialized"); 28 | }); 29 | 30 | it("Stop sync if client ID is invalid", async () => { 31 | nock(APITALLY_HUB_BASE_URL) 32 | .persist() 33 | .post(/\/(startup|sync)$/) 34 | .reply(404, `Client ID '${CLIENT_ID}' not found`); 35 | 36 | const client = new ApitallyClient({ 37 | clientId: CLIENT_ID, 38 | env: ENV, 39 | }); 40 | vi.spyOn(client.logger, "error").mockImplementation(() => {}); 41 | client.setStartupData({ paths: [], versions: {}, client: "js:test" }); 42 | 43 | await new Promise((resolve) => setTimeout(resolve, 100)); 44 | expect(client["syncIntervalId"]).toBeUndefined(); 45 | }); 46 | 47 | afterEach(async () => { 48 | await ApitallyClient.shutdown(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/common/consumerRegistry.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import ConsumerRegistry, { 4 | consumerFromStringOrObject, 5 | } from "../../src/common/consumerRegistry.js"; 6 | 7 | describe("Consumer registry", () => { 8 | it("Consumer from string or object", () => { 9 | let consumer = consumerFromStringOrObject(""); 10 | expect(consumer).toBeNull(); 11 | 12 | consumer = consumerFromStringOrObject({ identifier: " " }); 13 | expect(consumer).toBeNull(); 14 | 15 | consumer = consumerFromStringOrObject("test"); 16 | expect(consumer).toEqual({ 17 | identifier: "test", 18 | }); 19 | 20 | consumer = consumerFromStringOrObject({ identifier: "test" }); 21 | expect(consumer).toEqual({ 22 | identifier: "test", 23 | }); 24 | 25 | consumer = consumerFromStringOrObject({ 26 | identifier: "test", 27 | name: "Test ", 28 | group: " Testers ", 29 | }); 30 | expect(consumer).toEqual({ 31 | identifier: "test", 32 | name: "Test", 33 | group: "Testers", 34 | }); 35 | }); 36 | 37 | it("Add or update consumers", () => { 38 | const consumerRegistry = new ConsumerRegistry(); 39 | consumerRegistry.addOrUpdateConsumer(null); 40 | consumerRegistry.addOrUpdateConsumer({ identifier: "test" }); 41 | let data = consumerRegistry.getAndResetUpdatedConsumers(); 42 | expect(data.length).toBe(0); 43 | 44 | const testConsumer = { 45 | identifier: "test", 46 | name: "Test", 47 | group: "Testers", 48 | }; 49 | consumerRegistry.addOrUpdateConsumer(testConsumer); 50 | data = consumerRegistry.getAndResetUpdatedConsumers(); 51 | expect(data.length).toBe(1); 52 | expect(data[0]).toEqual(testConsumer); 53 | 54 | consumerRegistry.addOrUpdateConsumer(testConsumer); 55 | data = consumerRegistry.getAndResetUpdatedConsumers(); 56 | expect(data.length).toBe(0); 57 | 58 | consumerRegistry.addOrUpdateConsumer({ 59 | identifier: "test", 60 | name: "Test 2", 61 | group: "Testers 2", 62 | }); 63 | data = consumerRegistry.getAndResetUpdatedConsumers(); 64 | expect(data.length).toBe(1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /tests/common/packageVersions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { getPackageVersion } from "../../src/common/packageVersions.js"; 4 | 5 | describe("Package versions", () => { 6 | it("Get package version", () => { 7 | expect(getPackageVersion("vitest")).not.toBeNull(); 8 | expect(getPackageVersion("nonexistent")).toBeNull(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/common/requestLogger.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; 2 | import { gunzipSync } from "zlib"; 3 | 4 | import RequestLogger, { 5 | Request, 6 | Response, 7 | } from "../../src/common/requestLogger.js"; 8 | 9 | describe("Request logger", () => { 10 | let requestLogger: RequestLogger; 11 | 12 | beforeEach(() => { 13 | requestLogger = new RequestLogger({ 14 | enabled: true, 15 | logQueryParams: true, 16 | logRequestHeaders: true, 17 | logRequestBody: true, 18 | logResponseHeaders: true, 19 | logResponseBody: true, 20 | }); 21 | }); 22 | 23 | it("End to end", async () => { 24 | expect(requestLogger.enabled).toBe(true); 25 | 26 | for (let i = 0; i < 3; i++) { 27 | requestLogger.logRequest( 28 | { 29 | timestamp: Date.now() / 1000, 30 | method: "GET", 31 | path: "/test", 32 | url: "http://localhost:8000/test?foo=bar", 33 | headers: [ 34 | ["accept", "text/plain"], 35 | ["content-type", "text/plain"], 36 | ], 37 | size: 4, 38 | consumer: "test", 39 | body: Buffer.from("test"), 40 | }, 41 | { 42 | statusCode: 200, 43 | responseTime: 0.123, 44 | headers: [["content-type", "text/plain"]], 45 | size: 4, 46 | body: Buffer.from("test"), 47 | }, 48 | new Error("test"), 49 | ); 50 | } 51 | 52 | await requestLogger.writeToFile(); 53 | await requestLogger.rotateFile(); 54 | const file = requestLogger.getFile(); 55 | expect(file).toBeDefined(); 56 | 57 | const compressedData = await file!.getContent(); 58 | expect(compressedData.length).toBeGreaterThan(0); 59 | 60 | file!.delete(); 61 | 62 | const jsonLines = gunzipSync(compressedData) 63 | .toString() 64 | .trimEnd() 65 | .split("\n"); 66 | expect(jsonLines.length).toBe(3); 67 | 68 | const items = jsonLines.map((line) => JSON.parse(line)); 69 | expect(items[0].request.method).toBe("GET"); 70 | expect(items[0].request.url).toBe("http://localhost:8000/test?foo=bar"); 71 | expect(atob(items[0].request.body)).toBe("test"); 72 | expect(items[0].response.statusCode).toBe(200); 73 | expect(atob(items[0].response.body)).toBe("test"); 74 | expect(items[0].exception.type).toBe("Error"); 75 | expect(items[0].exception.message).toBe("test"); 76 | }); 77 | 78 | it("Log exclusions", async () => { 79 | requestLogger.config.logQueryParams = false; 80 | requestLogger.config.logRequestHeaders = false; 81 | requestLogger.config.logRequestBody = false; 82 | requestLogger.config.logResponseHeaders = false; 83 | requestLogger.config.logResponseBody = false; 84 | requestLogger.config.excludePaths = [/\/excluded$/i]; 85 | requestLogger.config.excludeCallback = (request, response) => 86 | response.statusCode === 404; 87 | 88 | const request: Request = { 89 | timestamp: Date.now() / 1000, 90 | method: "GET", 91 | path: "/test", 92 | url: "http://localhost:8000/test?foo=bar", 93 | headers: [ 94 | ["accept", "text/plain"], 95 | ["content-type", "text/plain"], 96 | ], 97 | size: 4, 98 | consumer: "test", 99 | body: Buffer.from("test"), 100 | }; 101 | const response: Response = { 102 | statusCode: 200, 103 | responseTime: 0.123, 104 | headers: [["content-type", "text/plain"]], 105 | size: 4, 106 | body: Buffer.from("test"), 107 | }; 108 | 109 | requestLogger.logRequest(request, response); 110 | expect(requestLogger["pendingWrites"].length).toBe(1); 111 | const item = JSON.parse(requestLogger["pendingWrites"][0]); 112 | expect(item.request.url).toBe("http://localhost:8000/test"); 113 | expect(item.request.headers).toBeUndefined(); 114 | expect(item.request.body).toBeUndefined(); 115 | expect(item.response.headers).toBeUndefined(); 116 | expect(item.response.body).toBeUndefined(); 117 | 118 | response.statusCode = 404; 119 | requestLogger.logRequest(request, response); 120 | expect(requestLogger["pendingWrites"].length).toBe(1); 121 | response.statusCode = 200; 122 | 123 | request.path = "/api/excluded"; 124 | requestLogger.logRequest(request, response); 125 | expect(requestLogger["pendingWrites"].length).toBe(1); 126 | 127 | request.path = "/healthz"; 128 | requestLogger.logRequest(request, response); 129 | expect(requestLogger["pendingWrites"].length).toBe(1); 130 | }); 131 | 132 | it("Log masking", async () => { 133 | requestLogger.config.maskQueryParams = [/test/i]; 134 | requestLogger.config.maskHeaders = [/test/i]; 135 | requestLogger.config.maskRequestBodyCallback = (request) => 136 | request.path !== "/test" ? request.body : null; 137 | requestLogger.config.maskResponseBodyCallback = (request, response) => 138 | request.path !== "/test" ? response.body : null; 139 | 140 | const request: Request = { 141 | timestamp: Date.now() / 1000, 142 | method: "GET", 143 | path: "/test", 144 | url: "http://localhost/test?secret=123456&test=123456&other=abcdef", 145 | headers: [ 146 | ["accept", "text/plain"], 147 | ["content-type", "text/plain"], 148 | ["authorization", "Bearer 123456"], 149 | ["x-test", "123456"], 150 | ], 151 | size: 4, 152 | body: Buffer.from("test"), 153 | }; 154 | const response: Response = { 155 | statusCode: 200, 156 | responseTime: 0.123, 157 | headers: [["content-type", "text/plain"]], 158 | size: 4, 159 | body: Buffer.from("test"), 160 | }; 161 | 162 | requestLogger.logRequest(request, response); 163 | expect(requestLogger["pendingWrites"].length).toBe(1); 164 | const item = JSON.parse(requestLogger["pendingWrites"][0]); 165 | expect(item.request.url).toBe( 166 | "http://localhost/test?secret=******&test=******&other=abcdef", 167 | ); 168 | expect(item.request.headers).toEqual([ 169 | ["accept", "text/plain"], 170 | ["content-type", "text/plain"], 171 | ["authorization", "******"], 172 | ["x-test", "******"], 173 | ]); 174 | expect(atob(item.request.body)).toBe(""); 175 | expect(atob(item.response.body)).toBe(""); 176 | }); 177 | 178 | afterEach(async () => { 179 | if (requestLogger) { 180 | await requestLogger.close(); 181 | } 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /tests/common/serverErrorCounter.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import ServerErrorCounter from "../../src/common/serverErrorCounter.js"; 4 | 5 | describe("Server error counter", () => { 6 | it("Message and stacktrace truncation", () => { 7 | const serverErrorCounter = new ServerErrorCounter(); 8 | const serverError = { 9 | consumer: "test", 10 | method: "GET", 11 | path: "/test", 12 | type: "error", 13 | msg: "a".repeat(3000), 14 | traceback: "one line\n".repeat(8000), 15 | }; 16 | serverErrorCounter.addServerError(serverError); 17 | const serverErrors = serverErrorCounter.getAndResetServerErrors(); 18 | expect(serverErrors.length).toBe(1); 19 | expect(serverErrors[0].msg.length).toBe(2048); 20 | expect(serverErrors[0].msg).toContain("(truncated)"); 21 | expect(serverErrors[0].traceback.length).toBeLessThan(65536); 22 | expect(serverErrors[0].traceback).toContain("(truncated)"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /tests/common/tempGzipFile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { gunzipSync } from "zlib"; 3 | 4 | import TempGzipFile from "../../src/common/tempGzipFile.js"; 5 | 6 | describe("Temporary gzip file", () => { 7 | it("End to end", async () => { 8 | const file = new TempGzipFile(); 9 | expect(file.size).toBe(0); 10 | 11 | await file.writeLine(Buffer.from("test1")); 12 | await file.writeLine(Buffer.from("test2")); 13 | expect(file.size).toBeGreaterThan(0); 14 | 15 | await file.close(); 16 | 17 | const compressedData = await file.getContent(); 18 | const content = gunzipSync(compressedData).toString(); 19 | expect(content).toBe("test1\ntest2\n"); 20 | 21 | await file.delete(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /tests/express/app.test.ts: -------------------------------------------------------------------------------- 1 | import { Express } from "express"; 2 | import request from "supertest"; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | 5 | import { ApitallyClient } from "../../src/common/client.js"; 6 | import { mockApitallyHub } from "../utils.js"; 7 | import { 8 | getAppWithCelebrate, 9 | getAppWithMiddlewareOnRouter, 10 | getAppWithNestedRouters, 11 | getAppWithValidator, 12 | } from "./app.js"; 13 | 14 | const testCases = [ 15 | { 16 | name: "Middleware for Express with celebrate", 17 | getApp: getAppWithCelebrate, 18 | }, 19 | { 20 | name: "Middleware for Express with express-validator", 21 | getApp: getAppWithValidator, 22 | }, 23 | ]; 24 | 25 | testCases.forEach(({ name, getApp }) => { 26 | describe(name, () => { 27 | let app: Express; 28 | let appTest: request.Agent; 29 | let client: ApitallyClient; 30 | 31 | beforeEach(async () => { 32 | mockApitallyHub(); 33 | app = getApp(); 34 | appTest = request(app); 35 | client = ApitallyClient.getInstance(); 36 | 37 | // Wait for 1.1 seconds for startup data to be set 38 | await new Promise((resolve) => setTimeout(resolve, 1100)); 39 | }); 40 | 41 | it("Request counter", async () => { 42 | await appTest.get("/hello?name=John&age=20").expect(200); 43 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 44 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 45 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 46 | await appTest.get("/error").expect(500); 47 | 48 | const requests = client.requestCounter.getAndResetRequests(); 49 | expect(requests.length).toBe(4); 50 | expect( 51 | requests.some( 52 | (r) => 53 | r.consumer === "test" && 54 | r.method === "GET" && 55 | r.path === "/hello" && 56 | r.status_code === 200 && 57 | r.request_size_sum == 0 && 58 | r.response_size_sum > 0 && 59 | r.consumer === "test", 60 | ), 61 | ).toBe(true); 62 | expect( 63 | requests.some( 64 | (r) => 65 | r.method === "POST" && 66 | r.path === "/hello" && 67 | r.status_code === 200 && 68 | r.request_size_sum > 0 && 69 | r.response_size_sum > 0, 70 | ), 71 | ).toBe(true); 72 | expect( 73 | requests.some((r) => r.status_code === 400 && r.request_count === 2), 74 | ).toBe(true); 75 | expect( 76 | requests.some((r) => r.status_code === 500 && r.request_count === 1), 77 | ).toBe(true); 78 | }); 79 | 80 | it("Request logger", async () => { 81 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 82 | let call; 83 | 84 | await appTest.get("/hello?name=John&age=20").expect(200); 85 | expect(spy).toHaveBeenCalledOnce(); 86 | call = spy.mock.calls[0]; 87 | expect(call[0].method).toBe("GET"); 88 | expect(call[0].path).toBe("/hello"); 89 | expect(call[0].url).toMatch( 90 | /^http:\/\/127\.0\.0\.1(:\d+)?\/hello\?name=John&age=20$/, 91 | ); 92 | expect(call[0].consumer).toBe("test"); 93 | expect(call[1].statusCode).toBe(200); 94 | expect(call[1].responseTime).toBeGreaterThan(0); 95 | expect(call[1].responseTime).toBeLessThan(1); 96 | expect(call[1].size).toBeGreaterThan(0); 97 | expect(call[1].headers).toContainEqual([ 98 | "content-type", 99 | "text/plain; charset=utf-8", 100 | ]); 101 | expect(call[1].body).toBeInstanceOf(Buffer); 102 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 103 | spy.mockReset(); 104 | 105 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 106 | expect(spy).toHaveBeenCalledOnce(); 107 | call = spy.mock.calls[0]; 108 | expect(call[0].method).toBe("POST"); 109 | expect(call[0].path).toBe("/hello"); 110 | expect(call[0].headers).toContainEqual([ 111 | "content-type", 112 | "application/json", 113 | ]); 114 | expect(call[0].body).toBeInstanceOf(Buffer); 115 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 116 | expect(call[1].body).toBeInstanceOf(Buffer); 117 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 118 | }); 119 | 120 | it("Validation error counter", async () => { 121 | await appTest.get("/hello?name=John&age=20").expect(200); 122 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 123 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 124 | 125 | const validationErrors = 126 | client.validationErrorCounter.getAndResetValidationErrors(); 127 | expect(validationErrors.length).toBe(2); 128 | expect( 129 | validationErrors.find((e) => e.loc[0] == "query" && e.loc[1] == "age") 130 | ?.error_count, 131 | ).toBe(2); 132 | }); 133 | 134 | it("Server error counter", async () => { 135 | await appTest.get("/error").expect(500); 136 | 137 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 138 | expect(serverErrors.length).toBe(1); 139 | expect( 140 | serverErrors.some( 141 | (e) => 142 | e.type === "Error" && 143 | e.msg === "test" && 144 | e.traceback && 145 | e.error_count === 1, 146 | ), 147 | ).toBe(true); 148 | }); 149 | 150 | it("List endpoints", async () => { 151 | expect(client.startupData?.paths).toEqual([ 152 | { 153 | method: "GET", 154 | path: "/hello", 155 | }, 156 | { 157 | method: "POST", 158 | path: "/hello", 159 | }, 160 | { 161 | method: "GET", 162 | path: "/hello/:id", 163 | }, 164 | { 165 | method: "GET", 166 | path: "/error", 167 | }, 168 | ]); 169 | }); 170 | 171 | afterEach(async () => { 172 | if (client) { 173 | await client.handleShutdown(); 174 | } 175 | }); 176 | }); 177 | }); 178 | 179 | describe("Middleware for Express router", () => { 180 | let app: Express; 181 | let appTest: request.Agent; 182 | let client: ApitallyClient; 183 | 184 | beforeEach(async () => { 185 | mockApitallyHub(); 186 | app = getAppWithMiddlewareOnRouter(); 187 | appTest = request(app); 188 | client = ApitallyClient.getInstance(); 189 | 190 | // Wait for 1.2 seconds for startup data to be set 191 | await new Promise((resolve) => setTimeout(resolve, 1200)); 192 | }); 193 | 194 | it("Request counter", async () => { 195 | await appTest.get("/api/hello").expect(200); 196 | 197 | const requests = client.requestCounter.getAndResetRequests(); 198 | expect(requests.length).toBe(1); 199 | expect( 200 | requests.some( 201 | (r) => 202 | r.method === "GET" && 203 | r.path === "/api/hello" && 204 | r.status_code === 200, 205 | ), 206 | ).toBe(true); 207 | }); 208 | 209 | it("List endpoints", async () => { 210 | expect(client.startupData?.paths).toEqual([ 211 | { 212 | method: "GET", 213 | path: "/api/hello", 214 | }, 215 | ]); 216 | }); 217 | 218 | afterEach(async () => { 219 | if (client) { 220 | await client.handleShutdown(); 221 | } 222 | }); 223 | }); 224 | 225 | describe("Middleware for Express with nested routers", () => { 226 | let app: Express; 227 | let appTest: request.Agent; 228 | let client: ApitallyClient; 229 | 230 | beforeEach(async () => { 231 | mockApitallyHub(); 232 | app = getAppWithNestedRouters(); 233 | appTest = request(app); 234 | client = ApitallyClient.getInstance(); 235 | 236 | // Wait for 1.2 seconds for startup data to be set 237 | await new Promise((resolve) => setTimeout(resolve, 1200)); 238 | }); 239 | 240 | it("Request counter", async () => { 241 | await appTest.get("/health").expect(200); 242 | await appTest.get("/api/v1/hello/bob").expect(200); 243 | await appTest.get("/api/v2/goodbye/world").expect(200); 244 | await appTest.get("/test").expect(200); 245 | 246 | const requests = client.requestCounter.getAndResetRequests(); 247 | expect(requests.length).toBe(4); 248 | expect( 249 | requests.some( 250 | (r) => 251 | r.method === "GET" && r.path === "/health" && r.status_code === 200, 252 | ), 253 | ).toBe(true); 254 | expect( 255 | requests.some( 256 | (r) => 257 | r.method === "GET" && 258 | r.path === "/api/:version/hello/:name" && 259 | r.status_code === 200, 260 | ), 261 | ).toBe(true); 262 | expect( 263 | requests.some( 264 | (r) => 265 | r.method === "GET" && 266 | r.path === "/api/:version/goodbye/world" && 267 | r.status_code === 200, 268 | ), 269 | ).toBe(true); 270 | expect( 271 | requests.some( 272 | (r) => 273 | r.method === "GET" && r.path === "/test" && r.status_code === 200, 274 | ), 275 | ).toBe(true); 276 | }); 277 | 278 | it("List endpoints", async () => { 279 | expect(client.startupData?.paths).toEqual([ 280 | { 281 | method: "GET", 282 | path: "/health", 283 | }, 284 | { 285 | method: "GET", 286 | path: "/api/:version/hello/:name", 287 | }, 288 | { 289 | method: "GET", 290 | path: "/api/:version/goodbye/world", 291 | }, 292 | { 293 | method: "GET", 294 | path: "/test", 295 | }, 296 | ]); 297 | }); 298 | 299 | afterEach(async () => { 300 | if (client) { 301 | await client.handleShutdown(); 302 | } 303 | }); 304 | }); 305 | -------------------------------------------------------------------------------- /tests/express/app.ts: -------------------------------------------------------------------------------- 1 | import { Joi, Segments, celebrate, errors } from "celebrate"; 2 | import type { Request } from "express"; 3 | import express from "express"; 4 | import { body, query, validationResult } from "express-validator"; 5 | 6 | import { useApitally } from "../../src/express/index.js"; 7 | import { CLIENT_ID, ENV } from "../utils.js"; 8 | 9 | const requestLoggingConfig = { 10 | enabled: true, 11 | logQueryParams: true, 12 | logRequestHeaders: true, 13 | logRequestBody: true, 14 | logResponseHeaders: true, 15 | logResponseBody: true, 16 | }; 17 | 18 | export const getAppWithCelebrate = () => { 19 | const app = express(); 20 | app.use(express.json()); 21 | 22 | useApitally(app, { 23 | clientId: CLIENT_ID, 24 | env: ENV, 25 | requestLoggingConfig, 26 | }); 27 | 28 | app.get( 29 | "/hello", 30 | celebrate( 31 | { 32 | [Segments.QUERY]: { 33 | name: Joi.string().required().min(2), 34 | age: Joi.number().required().min(18), 35 | }, 36 | }, 37 | { abortEarly: false }, 38 | ), 39 | (req: Request, res) => { 40 | req.apitallyConsumer = "test"; 41 | res.type("txt"); 42 | res.send( 43 | `Hello ${req.query?.name}! You are ${req.query?.age} years old!`, 44 | ); 45 | }, 46 | ); 47 | app.get("/hello/:id(\\d+)", (req, res) => { 48 | res.send(`Hello ID ${req.params.id}!`); 49 | }); 50 | app.post( 51 | "/hello", 52 | celebrate( 53 | { 54 | [Segments.BODY]: { 55 | name: Joi.string().required().min(2), 56 | age: Joi.number().required().min(18), 57 | }, 58 | }, 59 | { abortEarly: false }, 60 | ), 61 | (req, res) => { 62 | res.type("txt"); 63 | res.send(`Hello ${req.body?.name}! You are ${req.body?.age} years old!`); 64 | }, 65 | ); 66 | app.get("/error", () => { 67 | throw new Error("test"); 68 | }); 69 | 70 | app.use(errors()); 71 | return app; 72 | }; 73 | 74 | export const getAppWithValidator = () => { 75 | const app = express(); 76 | app.use(express.json()); 77 | 78 | useApitally(app, { 79 | clientId: CLIENT_ID, 80 | env: ENV, 81 | appVersion: "1.2.3", 82 | requestLoggingConfig, 83 | }); 84 | 85 | app.get( 86 | "/hello", 87 | query("name").isString().isLength({ min: 2 }), 88 | query("age").isInt({ min: 18 }), 89 | (req: Request, res) => { 90 | req.apitallyConsumer = "test"; 91 | const result = validationResult(req); 92 | if (result.isEmpty()) { 93 | res.type("txt"); 94 | return res.send( 95 | `Hello ${req.query?.name}! You are ${req.query?.age} years old!`, 96 | ); 97 | } 98 | res.status(400).send({ errors: result.array() }); 99 | }, 100 | ); 101 | app.get("/hello/:id(\\d+)", (req, res) => { 102 | res.send(`Hello ID ${req.params.id}!`); 103 | }); 104 | app.post( 105 | "/hello", 106 | body("name").isString().isLength({ min: 2 }), 107 | body("age").isInt({ min: 18 }), 108 | (req, res) => { 109 | const result = validationResult(req); 110 | if (result.isEmpty()) { 111 | res.type("txt"); 112 | return res.send( 113 | `Hello ${req.body?.name}! You are ${req.body?.age} years old!`, 114 | ); 115 | } 116 | res.status(400).send({ errors: result.array() }); 117 | }, 118 | ); 119 | app.get("/error", () => { 120 | throw new Error("test"); 121 | }); 122 | 123 | return app; 124 | }; 125 | 126 | export const getAppWithMiddlewareOnRouter = () => { 127 | const app = express(); 128 | const router = express.Router(); 129 | 130 | useApitally(router, { 131 | clientId: CLIENT_ID, 132 | env: ENV, 133 | appVersion: "1.2.3", 134 | basePath: "/api", 135 | requestLoggingConfig, 136 | }); 137 | 138 | router.get("/hello", (req, res) => { 139 | res.send("Hello!"); 140 | }); 141 | 142 | app.use("/api", router); 143 | return app; 144 | }; 145 | 146 | export const getAppWithNestedRouters = () => { 147 | const app = express(); 148 | const router1 = express.Router(); 149 | const router2 = express.Router({ mergeParams: true }); 150 | const router3 = express.Router(); 151 | const router4 = express.Router(); 152 | 153 | useApitally(app, { 154 | clientId: CLIENT_ID, 155 | env: ENV, 156 | appVersion: "1.2.3", 157 | requestLoggingConfig, 158 | }); 159 | 160 | router1.get("/health", (req, res) => { 161 | res.send("OK"); 162 | }); 163 | 164 | router2.get("/hello/:name", (req, res) => { 165 | res.send(`Hello ${req.params.name}!`); 166 | }); 167 | 168 | router3.get("/world", (req, res) => { 169 | res.send("World!"); 170 | }); 171 | 172 | router4.get("/", (req, res) => { 173 | res.send("Success!"); 174 | }); 175 | 176 | router2.use("/goodbye", router3); 177 | app.use("/", router1); 178 | app.use("/api/:version", router2); 179 | app.use("/test", router4); 180 | return app; 181 | }; 182 | -------------------------------------------------------------------------------- /tests/fastify/app.test.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import request from "supertest"; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 | 5 | import { ApitallyClient } from "../../src/common/client.js"; 6 | import { mockApitallyHub } from "../utils.js"; 7 | import { getApp } from "./app.js"; 8 | 9 | describe("Plugin for Fastify", () => { 10 | let app: FastifyInstance; 11 | let appTest: request.Agent; 12 | let client: ApitallyClient; 13 | 14 | beforeEach(async () => { 15 | mockApitallyHub(); 16 | app = await getApp(); 17 | appTest = request(app.server); 18 | client = ApitallyClient.getInstance(); 19 | await app.ready(); 20 | 21 | // Wait for 0.2 seconds for startup data to be set 22 | await new Promise((resolve) => setTimeout(resolve, 200)); 23 | }); 24 | 25 | it("Request counter", async () => { 26 | await appTest.get("/hello?name=John&age=20").expect(200); 27 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 28 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 29 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 30 | await appTest.get("/error").expect(500); 31 | 32 | const requests = client.requestCounter.getAndResetRequests(); 33 | expect(requests.length).toBe(4); 34 | expect( 35 | requests.some( 36 | (r) => 37 | r.consumer === "test" && 38 | r.method === "GET" && 39 | r.path === "/hello" && 40 | r.status_code === 200 && 41 | r.request_size_sum == 0 && 42 | r.response_size_sum > 0 && 43 | r.consumer === "test", 44 | ), 45 | ).toBe(true); 46 | expect( 47 | requests.some( 48 | (r) => 49 | r.method === "POST" && 50 | r.path === "/hello" && 51 | r.status_code === 200 && 52 | r.request_size_sum > 0 && 53 | r.response_size_sum > 0, 54 | ), 55 | ).toBe(true); 56 | expect( 57 | requests.some((r) => r.status_code === 400 && r.request_count === 2), 58 | ).toBe(true); 59 | expect( 60 | requests.some((r) => r.status_code === 500 && r.request_count === 1), 61 | ).toBe(true); 62 | }); 63 | 64 | it("Request logger", async () => { 65 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 66 | let call; 67 | 68 | await appTest.get("/hello?name=John&age=20").expect(200); 69 | expect(spy).toHaveBeenCalledOnce(); 70 | call = spy.mock.calls[0]; 71 | expect(call[0].method).toBe("GET"); 72 | expect(call[0].path).toBe("/hello"); 73 | expect(call[0].url).toMatch( 74 | /^http:\/\/127\.0\.0\.1:\d+\/hello\?name=John&age=20$/, 75 | ); 76 | expect(call[0].consumer).toBe("test"); 77 | expect(call[1].statusCode).toBe(200); 78 | expect(call[1].responseTime).toBeGreaterThan(0); 79 | expect(call[1].responseTime).toBeLessThan(1); 80 | expect(call[1].size).toBeGreaterThan(0); 81 | expect(call[1].headers).toContainEqual([ 82 | "content-type", 83 | "text/plain; charset=utf-8", 84 | ]); 85 | expect(call[1].body).toBeInstanceOf(Buffer); 86 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 87 | spy.mockReset(); 88 | 89 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 90 | expect(spy).toHaveBeenCalledOnce(); 91 | call = spy.mock.calls[0]; 92 | expect(call[0].method).toBe("POST"); 93 | expect(call[0].path).toBe("/hello"); 94 | expect(call[0].headers).toContainEqual([ 95 | "content-type", 96 | "application/json", 97 | ]); 98 | expect(call[0].body).toBeInstanceOf(Buffer); 99 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 100 | expect(call[1].body).toBeInstanceOf(Buffer); 101 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 102 | }); 103 | 104 | it("Validation error counter", async () => { 105 | await appTest.get("/hello?name=John&age=20").expect(200); 106 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 107 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 108 | 109 | const validationErrors = 110 | client.validationErrorCounter.getAndResetValidationErrors(); 111 | expect(validationErrors.length).toBe(2); 112 | expect( 113 | validationErrors.find((e) => e.loc[0] == "query" && e.loc[1] == "age") 114 | ?.error_count, 115 | ).toBe(2); 116 | }); 117 | 118 | it("Server error counter", async () => { 119 | await appTest.get("/error").expect(500); 120 | 121 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 122 | expect(serverErrors.length).toBe(1); 123 | expect( 124 | serverErrors.some( 125 | (e) => 126 | e.type === "Error" && 127 | e.msg === "test" && 128 | e.traceback && 129 | e.error_count === 1, 130 | ), 131 | ).toBe(true); 132 | }); 133 | 134 | it("List endpoints", async () => { 135 | expect(client.startupData?.paths).toEqual([ 136 | { 137 | method: "GET", 138 | path: "/hello", 139 | }, 140 | { 141 | method: "GET", 142 | path: "/hello/:id", 143 | }, 144 | { 145 | method: "POST", 146 | path: "/hello", 147 | }, 148 | { 149 | method: "GET", 150 | path: "/error", 151 | }, 152 | ]); 153 | }); 154 | 155 | afterEach(async () => { 156 | if (client) { 157 | await client.handleShutdown(); 158 | } 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /tests/fastify/app.ts: -------------------------------------------------------------------------------- 1 | import Fastify from "fastify"; 2 | 3 | import { apitallyPlugin } from "../../src/fastify/index.js"; 4 | import { CLIENT_ID, ENV } from "../utils.js"; 5 | 6 | export const getApp = async () => { 7 | const app = Fastify({ 8 | ajv: { customOptions: { allErrors: true } }, 9 | }); 10 | 11 | await app.register(apitallyPlugin, { 12 | clientId: CLIENT_ID, 13 | env: ENV, 14 | appVersion: "1.2.3", 15 | requestLoggingConfig: { 16 | enabled: true, 17 | logQueryParams: true, 18 | logRequestHeaders: true, 19 | logRequestBody: true, 20 | logResponseHeaders: true, 21 | logResponseBody: true, 22 | }, 23 | }); 24 | 25 | interface HelloParams { 26 | name: string; 27 | age: number; 28 | } 29 | 30 | interface HelloIDParams { 31 | id: number; 32 | } 33 | 34 | app.get<{ Querystring: HelloParams }>( 35 | "/hello", 36 | { 37 | schema: { 38 | querystring: { 39 | type: "object", 40 | properties: { 41 | name: { type: "string", minLength: 2 }, 42 | age: { type: "integer", minimum: 18 }, 43 | }, 44 | required: ["name", "age"], 45 | }, 46 | }, 47 | }, 48 | async function (request) { 49 | const { name, age } = request.query; 50 | request.apitallyConsumer = "test"; 51 | return `Hello ${name}! You are ${age} years old!`; 52 | }, 53 | ); 54 | app.get<{ Params: HelloIDParams }>("/hello/:id", async function (request) { 55 | const { id } = request.params; 56 | return `Hello ${id}!`; 57 | }); 58 | app.post<{ Body: HelloParams }>( 59 | "/hello", 60 | { 61 | schema: { 62 | body: { 63 | type: "object", 64 | properties: { 65 | name: { type: "string", minLength: 2 }, 66 | age: { type: "integer", minimum: 18 }, 67 | }, 68 | required: ["name", "age"], 69 | }, 70 | }, 71 | }, 72 | async function (request) { 73 | const { name, age } = request.body; 74 | return `Hello ${name}! You are ${age} years old!`; 75 | }, 76 | ); 77 | app.get("/error", async function () { 78 | throw new Error("test"); 79 | }); 80 | 81 | return app; 82 | }; 83 | -------------------------------------------------------------------------------- /tests/hono/app.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 | 4 | import { ApitallyClient } from "../../src/common/client.js"; 5 | import { mockApitallyHub } from "../utils.js"; 6 | import { getApp } from "./app.js"; 7 | 8 | describe("Middleware for Hono", () => { 9 | let app: Hono; 10 | let client: ApitallyClient; 11 | 12 | beforeEach(async () => { 13 | mockApitallyHub(); 14 | app = await getApp(); 15 | client = ApitallyClient.getInstance(); 16 | 17 | // Wait for 1.1 seconds for startup data to be set 18 | await new Promise((resolve) => setTimeout(resolve, 1100)); 19 | }); 20 | 21 | it("Request counter", async () => { 22 | let res; 23 | res = await app.request("/hello?name=John&age=20"); 24 | expect(res.status).toBe(200); 25 | 26 | const body = JSON.stringify({ name: "John", age: 20 }); 27 | res = await app.request("/hello", { 28 | method: "POST", 29 | body, 30 | headers: { 31 | "Content-Type": "application/json", 32 | "Content-Length": body.length.toString(), 33 | }, 34 | }); 35 | const resText = await res.text(); 36 | expect(res.status).toBe(200); 37 | expect(resText).toBe("Hello John! You are 20 years old!"); 38 | 39 | res = await app.request("/hello/123"); 40 | expect(res.status).toBe(200); 41 | 42 | res = await app.request("/hello?name=Bob&age=17"); 43 | const resJson = await res.json(); 44 | expect(res.status).toBe(400); // invalid (age < 18) 45 | expect(resJson.success).toBe(false); 46 | expect(resJson.error.name).toBe("ZodError"); 47 | 48 | res = await app.request("/hello?name=X&age=1"); 49 | expect(res.status).toBe(400); // invalid (name too short and age < 18) 50 | 51 | res = await app.request("/error"); 52 | expect(res.status).toBe(500); 53 | 54 | const requests = client.requestCounter.getAndResetRequests(); 55 | expect(requests.length).toBe(5); 56 | expect( 57 | requests.some( 58 | (r) => 59 | r.consumer === "test" && 60 | r.method === "GET" && 61 | r.path === "/hello" && 62 | r.status_code === 200 && 63 | r.request_size_sum == 0 && 64 | r.response_size_sum > 0, 65 | ), 66 | ).toBe(true); 67 | expect( 68 | requests.some( 69 | (r) => 70 | r.method === "GET" && 71 | r.path === "/hello/:id" && 72 | r.status_code === 200, 73 | ), 74 | ).toBe(true); 75 | expect( 76 | requests.some( 77 | (r) => 78 | r.method === "POST" && 79 | r.path === "/hello" && 80 | r.status_code === 200 && 81 | r.request_size_sum > 0 && 82 | r.response_size_sum > 0, 83 | ), 84 | ).toBe(true); 85 | expect( 86 | requests.some((r) => r.status_code === 400 && r.request_count === 2), 87 | ).toBe(true); 88 | expect( 89 | requests.some((r) => r.status_code === 500 && r.request_count === 1), 90 | ).toBe(true); 91 | }); 92 | 93 | it("Request logger", async () => { 94 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 95 | let call; 96 | let res; 97 | 98 | res = await app.request("/hello?name=John&age=20"); 99 | expect(res.status).toBe(200); 100 | expect(spy).toHaveBeenCalledOnce(); 101 | call = spy.mock.calls[0]; 102 | expect(call[0].method).toBe("GET"); 103 | expect(call[0].path).toBe("/hello"); 104 | expect(call[0].url).toBe("http://localhost/hello?name=John&age=20"); 105 | expect(call[0].consumer).toBe("test"); 106 | expect(call[1].statusCode).toBe(200); 107 | expect(call[1].responseTime).toBeGreaterThan(0); 108 | expect(call[1].responseTime).toBeLessThan(1); 109 | expect(call[1].size).toBeGreaterThan(0); 110 | expect(call[1].headers).toContainEqual([ 111 | "content-type", 112 | "text/plain;charset=UTF-8", 113 | ]); 114 | expect(call[1].body).toBeInstanceOf(Buffer); 115 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 116 | spy.mockReset(); 117 | 118 | const body = JSON.stringify({ name: "John", age: 20 }); 119 | res = await app.request("/hello", { 120 | method: "POST", 121 | body, 122 | headers: { 123 | "Content-Type": "application/json", 124 | "Content-Length": body.length.toString(), 125 | }, 126 | }); 127 | expect(res.status).toBe(200); 128 | expect(spy).toHaveBeenCalledOnce(); 129 | call = spy.mock.calls[0]; 130 | expect(call[0].method).toBe("POST"); 131 | expect(call[0].path).toBe("/hello"); 132 | expect(call[0].headers).toContainEqual([ 133 | "content-type", 134 | "application/json", 135 | ]); 136 | expect(call[0].body).toBeInstanceOf(Buffer); 137 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 138 | expect(call[1].body).toBeInstanceOf(Buffer); 139 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 140 | }); 141 | 142 | it("Validation error counter", async () => { 143 | await app.request("/hello?name=Bob&age=20"); 144 | await app.request("/hello?name=Bob&age=17"); 145 | await app.request("/hello?name=X&age=1"); 146 | 147 | const validationErrors = 148 | client.validationErrorCounter.getAndResetValidationErrors(); 149 | expect(validationErrors.length).toBe(2); 150 | expect(validationErrors.find((e) => e.loc[0] == "age")?.error_count).toBe( 151 | 2, 152 | ); 153 | expect(validationErrors.find((e) => e.loc[0] == "name")?.error_count).toBe( 154 | 1, 155 | ); 156 | }); 157 | 158 | it("Server error counter", async () => { 159 | const res = await app.request("/error"); 160 | expect(res.status).toBe(500); 161 | 162 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 163 | expect(serverErrors.length).toBe(1); 164 | expect( 165 | serverErrors.some( 166 | (e) => 167 | e.type === "Error" && 168 | e.msg === "test" && 169 | e.traceback && 170 | e.error_count === 1, 171 | ), 172 | ).toBe(true); 173 | }); 174 | 175 | it("List endpoints", async () => { 176 | expect(client.startupData?.paths).toEqual([ 177 | { 178 | method: "GET", 179 | path: "/hello", 180 | }, 181 | { 182 | method: "GET", 183 | path: "/hello/:id", 184 | }, 185 | { 186 | method: "POST", 187 | path: "/hello", 188 | }, 189 | { 190 | method: "GET", 191 | path: "/error", 192 | }, 193 | ]); 194 | }); 195 | 196 | afterEach(async () => { 197 | if (client) { 198 | await client.handleShutdown(); 199 | } 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /tests/hono/app.ts: -------------------------------------------------------------------------------- 1 | import { zValidator } from "@hono/zod-validator"; 2 | import { Hono } from "hono"; 3 | import { z } from "zod"; 4 | 5 | import { useApitally } from "../../src/hono/index.js"; 6 | import { CLIENT_ID, ENV } from "../utils.js"; 7 | 8 | export const getApp = async () => { 9 | const app = new Hono(); 10 | 11 | useApitally(app, { 12 | clientId: CLIENT_ID, 13 | env: ENV, 14 | appVersion: "1.2.3", 15 | requestLoggingConfig: { 16 | enabled: true, 17 | logQueryParams: true, 18 | logRequestHeaders: true, 19 | logRequestBody: true, 20 | logResponseHeaders: true, 21 | logResponseBody: true, 22 | }, 23 | }); 24 | 25 | app.get( 26 | "/hello", 27 | zValidator( 28 | "query", 29 | z.object({ 30 | name: z.string().min(2), 31 | age: z.coerce.number().min(18), 32 | }), 33 | ), 34 | (c) => { 35 | c.set("apitallyConsumer", "test"); 36 | return c.text( 37 | `Hello ${c.req.query("name")}! You are ${c.req.query("age")} years old!`, 38 | ); 39 | }, 40 | ); 41 | app.get("/hello/:id", (c) => { 42 | return c.text(`Hello ${c.req.param("id")}!`); 43 | }); 44 | app.post("/hello", async (c) => { 45 | const requestBody = await c.req.json(); 46 | return c.text( 47 | `Hello ${requestBody.name}! You are ${requestBody.age} years old!`, 48 | ); 49 | }); 50 | app.get("/error", () => { 51 | throw new Error("test"); 52 | }); 53 | 54 | return app; 55 | }; 56 | -------------------------------------------------------------------------------- /tests/koa/app.test.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import Koa from "koa"; 3 | import request from "supertest"; 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 | 6 | import { ApitallyClient } from "../../src/common/client.js"; 7 | import { mockApitallyHub } from "../utils.js"; 8 | import { getAppWithKoaRoute, getAppWithKoaRouter } from "./app.js"; 9 | 10 | const testCases = [ 11 | { 12 | name: "Middleware for Koa with koa-router", 13 | router: "koa-router", 14 | getApp: getAppWithKoaRouter, 15 | }, 16 | { 17 | name: "Middleware for Koa with koa-route", 18 | router: "koa-route", 19 | getApp: getAppWithKoaRoute, 20 | }, 21 | ]; 22 | 23 | testCases.forEach(({ name, router, getApp }) => { 24 | describe(name, () => { 25 | let app: Koa; 26 | let appTest: request.Agent; 27 | let client: ApitallyClient; 28 | 29 | beforeEach(async () => { 30 | mockApitallyHub(); 31 | app = getApp(); 32 | const server = http.createServer(app.callback()); 33 | appTest = request(server); 34 | client = ApitallyClient.getInstance(); 35 | 36 | // Wait for 1.2 seconds for startup data to be set 37 | await new Promise((resolve) => setTimeout(resolve, 1200)); 38 | }); 39 | 40 | it("Request counter", async () => { 41 | await appTest.get("/hello?name=John&age=20").expect(200); 42 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 43 | 44 | const consoleSpy = vi 45 | .spyOn(console, "error") 46 | .mockImplementation(() => {}); 47 | await appTest.get("/error").expect(500); 48 | consoleSpy.mockRestore(); 49 | 50 | const requests = client.requestCounter.getAndResetRequests(); 51 | expect(requests.length).toBe(3); 52 | expect( 53 | requests.some( 54 | (r) => 55 | r.consumer === "test" && 56 | r.method === "GET" && 57 | r.path === "/hello" && 58 | r.status_code === 200 && 59 | r.request_size_sum == 0 && 60 | r.response_size_sum > 0 && 61 | r.consumer === "test", 62 | ), 63 | ).toBe(true); 64 | expect( 65 | requests.some( 66 | (r) => 67 | r.method === "POST" && 68 | r.path === "/hello" && 69 | r.status_code === 200 && 70 | r.request_size_sum > 0 && 71 | r.response_size_sum > 0, 72 | ), 73 | ).toBe(true); 74 | expect(requests.some((r) => r.status_code === 500)).toBe(true); 75 | }); 76 | 77 | it("Request logger", async () => { 78 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 79 | let call; 80 | 81 | await appTest.get("/hello?name=John&age=20").expect(200); 82 | expect(spy).toHaveBeenCalledOnce(); 83 | call = spy.mock.calls[0]; 84 | expect(call[0].method).toBe("GET"); 85 | expect(call[0].path).toBe("/hello"); 86 | expect(call[0].url).toMatch( 87 | /^http:\/\/127\.0\.0\.1:\d+\/hello\?name=John&age=20$/, 88 | ); 89 | expect(call[0].consumer).toBe("test"); 90 | expect(call[1].statusCode).toBe(200); 91 | expect(call[1].responseTime).toBeGreaterThan(0); 92 | expect(call[1].responseTime).toBeLessThan(1); 93 | expect(call[1].size).toBeGreaterThan(0); 94 | expect(call[1].headers).toContainEqual([ 95 | "content-type", 96 | "text/plain; charset=utf-8", 97 | ]); 98 | expect(call[1].body).toBeInstanceOf(Buffer); 99 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 100 | spy.mockReset(); 101 | 102 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(200); 103 | expect(spy).toHaveBeenCalledOnce(); 104 | call = spy.mock.calls[0]; 105 | expect(call[0].method).toBe("POST"); 106 | expect(call[0].path).toBe("/hello"); 107 | expect(call[0].headers).toContainEqual([ 108 | "content-type", 109 | "application/json", 110 | ]); 111 | expect(call[0].body).toBeInstanceOf(Buffer); 112 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 113 | expect(call[1].body).toBeInstanceOf(Buffer); 114 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 115 | }); 116 | 117 | it("Server error counter", async () => { 118 | const consoleSpy = vi 119 | .spyOn(console, "error") 120 | .mockImplementation(() => {}); 121 | await appTest.get("/error").expect(500); 122 | consoleSpy.mockRestore(); 123 | 124 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 125 | expect(serverErrors.length).toBe(1); 126 | expect( 127 | serverErrors.some( 128 | (e) => 129 | e.type === "Error" && 130 | e.msg === "test" && 131 | e.traceback && 132 | e.error_count === 1, 133 | ), 134 | ).toBe(true); 135 | }); 136 | 137 | if (router === "koa-router") { 138 | it("List endpoints", async () => { 139 | expect(client.startupData?.paths).toEqual([ 140 | { 141 | method: "GET", 142 | path: "/hello", 143 | }, 144 | { 145 | method: "GET", 146 | path: "/hello/:id", 147 | }, 148 | { 149 | method: "POST", 150 | path: "/hello", 151 | }, 152 | { 153 | method: "GET", 154 | path: "/error", 155 | }, 156 | ]); 157 | }); 158 | } 159 | 160 | afterEach(async () => { 161 | if (client) { 162 | await client.handleShutdown(); 163 | } 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /tests/koa/app.ts: -------------------------------------------------------------------------------- 1 | import Router from "@koa/router"; 2 | import Koa from "koa"; 3 | import bodyParser from "koa-bodyparser"; 4 | import route from "koa-route"; 5 | 6 | import { useApitally } from "../../src/koa/index.js"; 7 | import { CLIENT_ID, ENV } from "../utils.js"; 8 | 9 | const requestLoggingConfig = { 10 | enabled: true, 11 | logQueryParams: true, 12 | logRequestHeaders: true, 13 | logRequestBody: true, 14 | logResponseHeaders: true, 15 | logResponseBody: true, 16 | }; 17 | 18 | export const getAppWithKoaRouter = () => { 19 | const app = new Koa(); 20 | const router = new Router(); 21 | 22 | useApitally(app, { 23 | clientId: CLIENT_ID, 24 | env: ENV, 25 | appVersion: "1.2.3", 26 | requestLoggingConfig, 27 | }); 28 | 29 | router.get("/hello", async (ctx) => { 30 | ctx.state.apitallyConsumer = "test"; 31 | ctx.body = `Hello ${ctx.query.name}! You are ${ctx.query.age} years old!`; 32 | }); 33 | router.get("/hello/:id", async (ctx) => { 34 | ctx.body = `Hello ${ctx.params.id}!`; 35 | }); 36 | router.post("/hello", async (ctx) => { 37 | const requestBody = ctx.request.body as any; 38 | ctx.body = `Hello ${requestBody.name}! You are ${requestBody.age} years old!`; 39 | }); 40 | router.get("/error", async () => { 41 | throw new Error("test"); 42 | }); 43 | 44 | app.use(bodyParser()); 45 | app.use(router.routes()); 46 | app.use(router.allowedMethods()); 47 | 48 | return app; 49 | }; 50 | 51 | export const getAppWithKoaRoute = () => { 52 | const app = new Koa(); 53 | 54 | useApitally(app, { 55 | clientId: CLIENT_ID, 56 | env: ENV, 57 | appVersion: "1.2.3", 58 | requestLoggingConfig, 59 | }); 60 | 61 | app.use(bodyParser()); 62 | app.use( 63 | route.get("/hello", async (ctx) => { 64 | ctx.state.apitallyConsumer = "test"; 65 | ctx.body = `Hello ${ctx.query.name}! You are ${ctx.query.age} years old!`; 66 | }), 67 | ); 68 | app.use( 69 | route.get("/hello/:id", async (ctx, id) => { 70 | ctx.body = `Hello ${id}!`; 71 | }), 72 | ); 73 | app.use( 74 | route.post("/hello", async (ctx) => { 75 | const requestBody = ctx.request.body as any; 76 | ctx.body = `Hello ${requestBody.name}! You are ${requestBody.age} years old!`; 77 | }), 78 | ); 79 | app.use( 80 | route.get("/error", async () => { 81 | throw new Error("test"); 82 | }), 83 | ); 84 | 85 | return app; 86 | }; 87 | -------------------------------------------------------------------------------- /tests/nestjs/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | CanActivate, 4 | Controller, 5 | ExecutionContext, 6 | Get, 7 | Header, 8 | Injectable, 9 | Param, 10 | ParseIntPipe, 11 | Post, 12 | Query, 13 | UseGuards, 14 | ValidationPipe, 15 | } from "@nestjs/common"; 16 | import { IsInt, IsNotEmpty, Min, MinLength } from "class-validator"; 17 | 18 | @Injectable() 19 | export class AuthGuard implements CanActivate { 20 | async canActivate(context: ExecutionContext) { 21 | const request = context.switchToHttp().getRequest(); 22 | request.apitallyConsumer = "test"; 23 | return true; 24 | } 25 | } 26 | 27 | export class HelloQueryDTO { 28 | @IsNotEmpty() 29 | @MinLength(2) 30 | name?: string; 31 | 32 | @IsInt() 33 | @Min(18) 34 | age?: number; 35 | } 36 | 37 | export class HelloBodyDTO { 38 | @IsNotEmpty() 39 | @MinLength(2) 40 | name?: string; 41 | 42 | @IsInt() 43 | @Min(18) 44 | age?: number; 45 | } 46 | 47 | @Controller() 48 | @UseGuards(AuthGuard) 49 | export class AppController { 50 | @Get("/hello") 51 | @Header("Content-Type", "text/plain") 52 | getHello( 53 | @Query( 54 | new ValidationPipe({ 55 | transform: true, 56 | transformOptions: { enableImplicitConversion: true }, 57 | }), 58 | ) 59 | { name, age }: HelloQueryDTO, 60 | ) { 61 | return `Hello ${name}! You are ${age} years old!`; 62 | } 63 | 64 | @Post("/hello") 65 | @Header("Content-Type", "text/plain") 66 | postHello( 67 | @Body( 68 | new ValidationPipe({ 69 | transform: true, 70 | transformOptions: { enableImplicitConversion: true }, 71 | }), 72 | ) 73 | { name, age }: HelloBodyDTO, 74 | ) { 75 | return `Hello ${name}! You are ${age} years old!`; 76 | } 77 | 78 | @Get("/hello/:id") 79 | @Header("Content-Type", "text/plain") 80 | getHelloById(@Param("id", new ParseIntPipe()) id: number) { 81 | return `Hello ID ${id}!`; 82 | } 83 | 84 | @Get("/error") 85 | getError() { 86 | throw new Error("test"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /tests/nestjs/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { AppController } from "./app.controller.js"; 3 | 4 | @Module({ 5 | imports: [], 6 | controllers: [AppController], 7 | providers: [], 8 | }) 9 | export class AppModule {} 10 | -------------------------------------------------------------------------------- /tests/nestjs/app.test.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from "@nestjs/common"; 2 | import { BaseExceptionFilter } from "@nestjs/core"; 3 | import request from "supertest"; 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 5 | 6 | import { ApitallyClient } from "../../src/common/client.js"; 7 | import { mockApitallyHub } from "../utils.js"; 8 | import { getApp } from "./app.js"; 9 | 10 | describe("Middleware for NestJS", () => { 11 | let app: INestApplication; 12 | let appTest: request.Agent; 13 | let client: ApitallyClient; 14 | 15 | beforeEach(async () => { 16 | mockApitallyHub(); 17 | app = await getApp(); 18 | appTest = request(app.getHttpServer()); 19 | client = ApitallyClient.getInstance(); 20 | 21 | // Wait for 1.2 seconds for startup data to be set 22 | await new Promise((resolve) => setTimeout(resolve, 1200)); 23 | }); 24 | 25 | it("Request counter", async () => { 26 | await appTest.get("/hello?name=John&age=20").expect(200); 27 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(201); 28 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 29 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 30 | 31 | const loggerSpy = vi 32 | .spyOn((BaseExceptionFilter as any).logger, "error") 33 | .mockImplementation(() => {}); 34 | await appTest.get("/error").expect(500); 35 | loggerSpy.mockRestore(); 36 | 37 | const requests = client.requestCounter.getAndResetRequests(); 38 | expect(requests.length).toBe(4); 39 | expect( 40 | requests.some( 41 | (r) => 42 | r.method === "GET" && 43 | r.path === "/hello" && 44 | r.status_code === 200 && 45 | r.response_size_sum > 0, 46 | ), 47 | ).toBe(true); 48 | expect( 49 | requests.some( 50 | (r) => 51 | r.method === "POST" && 52 | r.path === "/hello" && 53 | r.status_code === 201 && 54 | r.request_size_sum > 0 && 55 | r.response_size_sum > 0, 56 | ), 57 | ).toBe(true); 58 | expect( 59 | requests.some((r) => r.status_code === 400 && r.request_count === 2), 60 | ).toBe(true); 61 | expect( 62 | requests.some((r) => r.status_code === 500 && r.request_count === 1), 63 | ).toBe(true); 64 | }); 65 | 66 | it("Request logger", async () => { 67 | const spy = vi.spyOn(client.requestLogger, "logRequest"); 68 | let call; 69 | 70 | await appTest.get("/hello?name=John&age=20").expect(200); 71 | expect(spy).toHaveBeenCalledOnce(); 72 | call = spy.mock.calls[0]; 73 | expect(call[0].method).toBe("GET"); 74 | expect(call[0].path).toBe("/hello"); 75 | expect(call[0].url).toMatch( 76 | /^http:\/\/127\.0\.0\.1(:\d+)?\/hello\?name=John&age=20$/, 77 | ); 78 | expect(call[0].consumer).toBe("test"); 79 | expect(call[1].statusCode).toBe(200); 80 | expect(call[1].responseTime).toBeGreaterThan(0); 81 | expect(call[1].responseTime).toBeLessThan(1); 82 | expect(call[1].size).toBeGreaterThan(0); 83 | expect(call[1].headers).toContainEqual([ 84 | "content-type", 85 | "text/plain; charset=utf-8", 86 | ]); 87 | expect(call[1].body).toBeInstanceOf(Buffer); 88 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 89 | spy.mockReset(); 90 | 91 | await appTest.post("/hello").send({ name: "John", age: 20 }).expect(201); 92 | expect(spy).toHaveBeenCalledOnce(); 93 | call = spy.mock.calls[0]; 94 | expect(call[0].method).toBe("POST"); 95 | expect(call[0].path).toBe("/hello"); 96 | expect(call[0].headers).toContainEqual([ 97 | "content-type", 98 | "application/json", 99 | ]); 100 | expect(call[0].body).toBeInstanceOf(Buffer); 101 | expect(call[0].body!.toString()).toMatch(/^{"name":"John","age":20}$/); 102 | expect(call[1].body).toBeInstanceOf(Buffer); 103 | expect(call[1].body!.toString()).toMatch(/^Hello John!/); 104 | }); 105 | 106 | it("Validation error counter", async () => { 107 | await appTest.get("/hello?name=John&age=20").expect(200); 108 | await appTest.get("/hello?name=Bob&age=17").expect(400); // invalid (age < 18) 109 | await appTest.get("/hello?name=X&age=1").expect(400); // invalid (name too short and age < 18) 110 | 111 | const validationErrors = 112 | client.validationErrorCounter.getAndResetValidationErrors(); 113 | expect(validationErrors.length).toBe(2); 114 | expect( 115 | validationErrors.find((e) => e.msg.startsWith("age"))?.error_count, 116 | ).toBe(2); 117 | }); 118 | 119 | it("Server error counter", async () => { 120 | const loggerSpy = vi 121 | .spyOn((BaseExceptionFilter as any).logger, "error") 122 | .mockImplementation(() => {}); 123 | await appTest.get("/error").expect(500); 124 | loggerSpy.mockRestore(); 125 | 126 | const serverErrors = client.serverErrorCounter.getAndResetServerErrors(); 127 | expect(serverErrors.length).toBe(1); 128 | expect( 129 | serverErrors.some( 130 | (e) => 131 | e.type === "Error" && 132 | e.msg === "test" && 133 | e.traceback && 134 | e.error_count === 1, 135 | ), 136 | ).toBe(true); 137 | }); 138 | 139 | it("List endpoints", async () => { 140 | expect(client.startupData?.paths).toEqual([ 141 | { 142 | method: "GET", 143 | path: "/hello", 144 | }, 145 | { 146 | method: "POST", 147 | path: "/hello", 148 | }, 149 | { 150 | method: "GET", 151 | path: "/hello/:id", 152 | }, 153 | { 154 | method: "GET", 155 | path: "/error", 156 | }, 157 | ]); 158 | }); 159 | 160 | afterEach(async () => { 161 | if (client) { 162 | await client.handleShutdown(); 163 | } 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /tests/nestjs/app.ts: -------------------------------------------------------------------------------- 1 | import { Test } from "@nestjs/testing"; 2 | 3 | import { useApitally } from "../../src/nestjs/index.js"; 4 | import { CLIENT_ID, ENV } from "../utils.js"; 5 | import { AppModule } from "./app.module.js"; 6 | 7 | export async function getApp() { 8 | const moduleFixture = await Test.createTestingModule({ 9 | imports: [AppModule], 10 | providers: [], 11 | }).compile(); 12 | const app = moduleFixture.createNestApplication(); 13 | 14 | useApitally(app, { 15 | clientId: CLIENT_ID, 16 | env: ENV, 17 | appVersion: "1.2.3", 18 | requestLoggingConfig: { 19 | enabled: true, 20 | logQueryParams: true, 21 | logRequestHeaders: true, 22 | logRequestBody: true, 23 | logResponseHeaders: true, 24 | logResponseBody: true, 25 | }, 26 | }); 27 | 28 | await app.init(); 29 | return app; 30 | } 31 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import nock from "nock"; 2 | 3 | export const APITALLY_HUB_BASE_URL = "https://hub.apitally.io"; 4 | export const CLIENT_ID = "fa4f144d-33be-4694-95e4-f5c18b0f151d"; 5 | export const ENV = "dev"; 6 | 7 | export const mockApitallyHub = () => { 8 | nock(APITALLY_HUB_BASE_URL) 9 | .persist() 10 | .post(/\/(startup|sync)$/) 11 | .reply(202); 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "tests"], 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "esModuleInterop": true, 6 | "outDir": "./dist/tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "isolatedModules": true, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | format: ["cjs", "esm"], 5 | platform: "node", 6 | dts: true, 7 | sourcemap: true, 8 | splitting: false, 9 | clean: true, 10 | }); 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import swc from "unplugin-swc"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [swc.vite(), swc.rollup()], 6 | test: { 7 | coverage: { 8 | enabled: true, 9 | include: ["src/**/*"], 10 | exclude: ["src/**/types.ts"], 11 | reporter: ["text", "lcovonly"], 12 | }, 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------