├── .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 |
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 |
14 |
15 |
16 | # Apitally SDK for Node.js
17 |
18 | [](https://github.com/apitally/apitally-js/actions)
19 | [](https://codecov.io/gh/apitally/apitally-js)
20 | [](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 |
--------------------------------------------------------------------------------