├── .babelrc.json ├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── build.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── APM-Grafana-Dashboard.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── images ├── apm-dashboard-1.png ├── apm-dashboard-2.png └── apm-dashboard-3.png ├── package-lock.json ├── package.json ├── playground ├── README.md ├── app.js ├── nest │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── nest-cli.json │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ └── main.ts │ ├── test │ │ ├── app.e2e-spec.ts │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json └── next │ ├── app │ ├── app-apis │ │ ├── [id] │ │ │ └── route.js │ │ └── route.js │ ├── labels │ │ └── route.js │ ├── layout.js │ ├── page.js │ └── users │ │ ├── [id] │ │ ├── delete │ │ │ └── page.js │ │ └── page.js │ │ └── page.js │ └── next.js ├── run-tests.sh ├── src ├── OpenAPM.ts ├── async-local-storage.http.ts ├── async-local-storage.ts ├── clients │ ├── express.ts │ ├── mysql2.ts │ ├── nestjs.ts │ └── nextjs.ts ├── index.ts ├── levitate │ ├── events.ts │ └── tokenHelpers.ts ├── shimmer.ts └── utils.ts ├── tests ├── enabled.test.ts ├── express.test.ts ├── mysql2.test.ts ├── nestjs │ ├── .eslintrc.js │ ├── .prettierrc │ ├── nest-cli.json │ ├── nestjs.test.ts │ ├── src │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ └── app.service.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── nextjs │ ├── .eslintrc.json │ ├── app │ │ ├── app-apis │ │ │ ├── [id] │ │ │ │ └── route.js │ │ │ └── route.js │ │ ├── labels │ │ │ └── route.js │ │ ├── layout.js │ │ ├── page.js │ │ ├── styles.css │ │ └── users │ │ │ ├── [id] │ │ │ ├── delete │ │ │ │ └── page.js │ │ │ └── page.js │ │ │ └── page.js │ ├── next-env.d.ts │ ├── next.config.js │ ├── nextjs.test.ts │ ├── pages │ │ ├── about.tsx │ │ ├── api │ │ │ ├── auth │ │ │ │ └── [...nextAuth].ts │ │ │ └── hello.ts │ │ └── blog │ │ │ └── [id].tsx │ ├── server.js │ └── tsconfig.json ├── prisma │ ├── prisma.test.ts │ └── schema.prisma └── utils.ts ├── tsconfig.json ├── tsup.config.ts └── vite.config.js /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | rollup.config.mjs 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['semistandard', 'plugin:@typescript-eslint/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | root: true, 6 | rules: { 7 | 'func-call-spacing': 'off', 8 | 'space-before-function-paren': 'off', 9 | '@typescript-eslint/no-var-requires': 'off', 10 | '@typescript-eslint/ban-ts-comment': 'off', 11 | indent: '10' 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 🏗️ Build @last9/openapm 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: 18.19.0 16 | - uses: shogo82148/actions-setup-mysql@v1.23.0 17 | with: 18 | mysql-version: '8.0' 19 | 20 | - name: Test MySQL Connection 21 | run: mysql -h localhost -u root -e 'SELECT version()' 22 | 23 | - name: Create test DB 24 | run: mysql -h localhost -u root -e 'CREATE DATABASE test_db' 25 | 26 | - name: 🛎 Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: 🧶 Get NPM cache 30 | uses: actions/cache@v3 31 | id: cache-npm 32 | env: 33 | cache-name: cache-node-modules 34 | with: 35 | path: ~/.npm 36 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ 37 | hashFiles('**/package-lock.json') }} 38 | restore-keys: | 39 | ${{ runner.os }}-build-${{ env.cache-name }}- 40 | ${{ runner.os }}-build- 41 | ${{ runner.os }}- 42 | 43 | - name: 📦 Install dependencies 44 | run: npm install --frozen-lockfile 45 | 46 | - name: 📦 Install Playwright Deps 47 | run: npx playwright install --with-deps 48 | 49 | - name: 🧪 Test 50 | env: 51 | DB_HOST: localhost 52 | DB_PORT: 3306 53 | DB_NAME: test_db 54 | DB_USER: root 55 | CI: true 56 | run: npm run test 57 | 58 | - name: 👷 Build 59 | run: npm run build 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | .idea 5 | .env 6 | .next -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | node_modules 3 | README.md 4 | tsconfig* 5 | .babelrc.json 6 | tests 7 | playground -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none" 4 | } 5 | -------------------------------------------------------------------------------- /APM-Grafana-Dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 1, 27 | "id": 1427, 28 | "links": [], 29 | "liveNow": false, 30 | "panels": [ 31 | { 32 | "collapsed": false, 33 | "datasource": { 34 | "type": "datasource", 35 | "uid": "grafana" 36 | }, 37 | "gridPos": { 38 | "h": 1, 39 | "w": 24, 40 | "x": 0, 41 | "y": 0 42 | }, 43 | "id": 16, 44 | "panels": [], 45 | "targets": [ 46 | { 47 | "datasource": { 48 | "type": "datasource", 49 | "uid": "grafana" 50 | }, 51 | "refId": "A" 52 | } 53 | ], 54 | "title": "Application Layer", 55 | "type": "row" 56 | }, 57 | { 58 | "datasource": { 59 | "type": "prometheus", 60 | "uid": "${datasource}" 61 | }, 62 | "description": "This metric measures the response time percentiles of web transactions, indicating the distribution of transaction response times.\n\nAggregation: Percentiles (e.g., 50th percentile, 90th percentile, 99th percentile)", 63 | "fieldConfig": { 64 | "defaults": { 65 | "color": { 66 | "mode": "palette-classic" 67 | }, 68 | "custom": { 69 | "axisCenteredZero": false, 70 | "axisColorMode": "text", 71 | "axisLabel": "", 72 | "axisPlacement": "auto", 73 | "barAlignment": 0, 74 | "drawStyle": "line", 75 | "fillOpacity": 0, 76 | "gradientMode": "none", 77 | "hideFrom": { 78 | "legend": false, 79 | "tooltip": false, 80 | "viz": false 81 | }, 82 | "lineInterpolation": "linear", 83 | "lineWidth": 1, 84 | "pointSize": 5, 85 | "scaleDistribution": { 86 | "type": "linear" 87 | }, 88 | "showPoints": "auto", 89 | "spanNulls": true, 90 | "stacking": { 91 | "group": "A", 92 | "mode": "none" 93 | }, 94 | "thresholdsStyle": { 95 | "mode": "off" 96 | } 97 | }, 98 | "mappings": [], 99 | "thresholds": { 100 | "mode": "absolute", 101 | "steps": [ 102 | { 103 | "color": "green", 104 | "value": null 105 | }, 106 | { 107 | "color": "red", 108 | "value": 80 109 | } 110 | ] 111 | }, 112 | "unit": "ms" 113 | }, 114 | "overrides": [] 115 | }, 116 | "gridPos": { 117 | "h": 8, 118 | "w": 10, 119 | "x": 0, 120 | "y": 1 121 | }, 122 | "id": 1, 123 | "options": { 124 | "legend": { 125 | "calcs": [], 126 | "displayMode": "list", 127 | "placement": "bottom", 128 | "showLegend": true 129 | }, 130 | "tooltip": { 131 | "mode": "single", 132 | "sort": "none" 133 | } 134 | }, 135 | "targets": [ 136 | { 137 | "datasource": { 138 | "type": "prometheus", 139 | "uid": "${datasource}" 140 | }, 141 | "editorMode": "code", 142 | "exemplar": true, 143 | "expr": "histogram_quantile(0.95, sum by (le) (increase(http_requests_duration_milliseconds_bucket{program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[1m])))", 144 | "instant": false, 145 | "interval": "1m", 146 | "legendFormat": "Duration (95%)", 147 | "range": true, 148 | "refId": "A" 149 | }, 150 | { 151 | "datasource": { 152 | "type": "prometheus", 153 | "uid": "${datasource}" 154 | }, 155 | "editorMode": "code", 156 | "exemplar": true, 157 | "expr": "histogram_quantile(0.99, sum by (le) (increase(http_requests_duration_milliseconds_bucket{program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[1m])))", 158 | "hide": false, 159 | "instant": false, 160 | "interval": "1m", 161 | "legendFormat": "Duration (99%)", 162 | "range": true, 163 | "refId": "B" 164 | }, 165 | { 166 | "datasource": { 167 | "type": "prometheus", 168 | "uid": "${datasource}" 169 | }, 170 | "editorMode": "code", 171 | "exemplar": true, 172 | "expr": "histogram_quantile(0.5, sum by (le) (increase(http_requests_duration_milliseconds_bucket{program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[1m])))", 173 | "hide": false, 174 | "instant": false, 175 | "interval": "1m", 176 | "legendFormat": "Median", 177 | "range": true, 178 | "refId": "C" 179 | } 180 | ], 181 | "title": "Web Transactions Time (percentile)", 182 | "type": "timeseries" 183 | }, 184 | { 185 | "datasource": { 186 | "type": "prometheus", 187 | "uid": "${datasource}" 188 | }, 189 | "description": "The Apdex score is a ratio value of the number of satisfied and tolerating requests to the total requests made. Each satisfied request counts as one request, while each tolerating request counts as half a satisfied request.\n\nAn Apdex score varies from 0 to 1, with 0 as the worst possible score (100% of response times were Frustrated), and 1 as the best possible score (100% of response times were Satisfied).\n\nAppDex Score = (Weighted Sum of Request Counts within Thresholds) / (Total Request Count)\n\nWhere thresholds are the number of requests that were served under p50, p90 and p95 respectively.", 190 | "fieldConfig": { 191 | "defaults": { 192 | "color": { 193 | "mode": "palette-classic" 194 | }, 195 | "custom": { 196 | "axisCenteredZero": false, 197 | "axisColorMode": "text", 198 | "axisLabel": "", 199 | "axisPlacement": "auto", 200 | "axisSoftMax": 1, 201 | "axisSoftMin": 0, 202 | "barAlignment": 0, 203 | "drawStyle": "line", 204 | "fillOpacity": 8, 205 | "gradientMode": "none", 206 | "hideFrom": { 207 | "legend": false, 208 | "tooltip": false, 209 | "viz": false 210 | }, 211 | "lineInterpolation": "linear", 212 | "lineStyle": { 213 | "fill": "solid" 214 | }, 215 | "lineWidth": 1, 216 | "pointSize": 5, 217 | "scaleDistribution": { 218 | "type": "linear" 219 | }, 220 | "showPoints": "never", 221 | "spanNulls": false, 222 | "stacking": { 223 | "group": "A", 224 | "mode": "none" 225 | }, 226 | "thresholdsStyle": { 227 | "mode": "area" 228 | } 229 | }, 230 | "decimals": 2, 231 | "mappings": [], 232 | "max": 1, 233 | "min": 0, 234 | "thresholds": { 235 | "mode": "absolute", 236 | "steps": [ 237 | { 238 | "color": "red", 239 | "value": null 240 | }, 241 | { 242 | "color": "orange", 243 | "value": 0.5 244 | }, 245 | { 246 | "color": "yellow", 247 | "value": 0.7 248 | }, 249 | { 250 | "color": "green", 251 | "value": 0.85 252 | }, 253 | { 254 | "color": "blue", 255 | "value": 0.95 256 | } 257 | ] 258 | } 259 | }, 260 | "overrides": [] 261 | }, 262 | "gridPos": { 263 | "h": 8, 264 | "w": 8, 265 | "x": 10, 266 | "y": 1 267 | }, 268 | "id": 2, 269 | "options": { 270 | "legend": { 271 | "calcs": [], 272 | "displayMode": "list", 273 | "placement": "bottom", 274 | "showLegend": true 275 | }, 276 | "tooltip": { 277 | "mode": "single", 278 | "sort": "none" 279 | } 280 | }, 281 | "pluginVersion": "9.3.2", 282 | "targets": [ 283 | { 284 | "datasource": { 285 | "type": "prometheus", 286 | "uid": "${datasource}" 287 | }, 288 | "editorMode": "code", 289 | "exemplar": false, 290 | "expr": "apdex_score_with_threshold(0.5, \"$program\", http_requests_duration_milliseconds_bucket, http_requests_duration_milliseconds_count, 100)", 291 | "format": "time_series", 292 | "hide": false, 293 | "instant": false, 294 | "interval": "5m", 295 | "legendFormat": "{{program}}", 296 | "range": true, 297 | "refId": "A" 298 | } 299 | ], 300 | "title": "Appdex Score", 301 | "type": "timeseries" 302 | }, 303 | { 304 | "datasource": { 305 | "type": "prometheus", 306 | "uid": "${datasource}" 307 | }, 308 | "description": "This metric measures the rate or percentage of successful operations or transactions.\n\nAggregation: Typically, success rate is calculated as the ratio of successful operations to the total number of operations within a given timeframe.", 309 | "fieldConfig": { 310 | "defaults": { 311 | "color": { 312 | "mode": "thresholds" 313 | }, 314 | "decimals": 2, 315 | "mappings": [], 316 | "max": 100, 317 | "thresholds": { 318 | "mode": "absolute", 319 | "steps": [ 320 | { 321 | "color": "red", 322 | "value": null 323 | }, 324 | { 325 | "color": "orange", 326 | "value": 50 327 | }, 328 | { 329 | "color": "yellow", 330 | "value": 85 331 | }, 332 | { 333 | "color": "light-green", 334 | "value": 99 335 | }, 336 | { 337 | "color": "green", 338 | "value": 99.9 339 | }, 340 | { 341 | "color": "semi-dark-green", 342 | "value": 99.99 343 | }, 344 | { 345 | "color": "dark-green", 346 | "value": 99.999 347 | } 348 | ] 349 | }, 350 | "unit": "percent" 351 | }, 352 | "overrides": [] 353 | }, 354 | "gridPos": { 355 | "h": 8, 356 | "w": 6, 357 | "x": 18, 358 | "y": 1 359 | }, 360 | "id": 14, 361 | "options": { 362 | "colorMode": "value", 363 | "graphMode": "area", 364 | "justifyMode": "auto", 365 | "orientation": "horizontal", 366 | "reduceOptions": { 367 | "calcs": [], 368 | "fields": "", 369 | "values": true 370 | }, 371 | "textMode": "auto" 372 | }, 373 | "pluginVersion": "9.3.2", 374 | "targets": [ 375 | { 376 | "datasource": { 377 | "type": "prometheus", 378 | "uid": "${datasource}" 379 | }, 380 | "editorMode": "code", 381 | "exemplar": false, 382 | "expr": "(sum(increase(http_requests_total{status=~\"2.*\", program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[5m])) by (program) / sum(increase(http_requests_total{program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[5m])) by (program) ) * 100 ", 383 | "format": "time_series", 384 | "instant": true, 385 | "interval": "", 386 | "legendFormat": "", 387 | "range": false, 388 | "refId": "A" 389 | } 390 | ], 391 | "title": "Success Rate (Over Last 5 Mins)", 392 | "type": "stat" 393 | }, 394 | { 395 | "datasource": { 396 | "type": "prometheus", 397 | "uid": "${datasource}" 398 | }, 399 | "description": "Throughput is a way to measure the amount of work this service is handling. It measures how many requests are being processed per minute.", 400 | "fieldConfig": { 401 | "defaults": { 402 | "color": { 403 | "mode": "palette-classic" 404 | }, 405 | "custom": { 406 | "axisCenteredZero": false, 407 | "axisColorMode": "text", 408 | "axisLabel": "", 409 | "axisPlacement": "auto", 410 | "axisSoftMin": 0, 411 | "barAlignment": 0, 412 | "drawStyle": "line", 413 | "fillOpacity": 0, 414 | "gradientMode": "none", 415 | "hideFrom": { 416 | "legend": false, 417 | "tooltip": false, 418 | "viz": false 419 | }, 420 | "lineInterpolation": "linear", 421 | "lineWidth": 1, 422 | "pointSize": 5, 423 | "scaleDistribution": { 424 | "type": "linear" 425 | }, 426 | "showPoints": "auto", 427 | "spanNulls": true, 428 | "stacking": { 429 | "group": "A", 430 | "mode": "none" 431 | }, 432 | "thresholdsStyle": { 433 | "mode": "off" 434 | } 435 | }, 436 | "mappings": [], 437 | "thresholds": { 438 | "mode": "absolute", 439 | "steps": [ 440 | { 441 | "color": "green", 442 | "value": null 443 | }, 444 | { 445 | "color": "red", 446 | "value": 80 447 | } 448 | ] 449 | }, 450 | "unit": "opm" 451 | }, 452 | "overrides": [ 453 | { 454 | "matcher": { 455 | "id": "byFrameRefID", 456 | "options": "B" 457 | }, 458 | "properties": [ 459 | { 460 | "id": "custom.showPoints", 461 | "value": "always" 462 | }, 463 | { 464 | "id": "custom.stacking", 465 | "value": { 466 | "group": "A", 467 | "mode": "normal" 468 | } 469 | }, 470 | { 471 | "id": "custom.pointSize", 472 | "value": 7 473 | }, 474 | { 475 | "id": "custom.scaleDistribution", 476 | "value": { 477 | "type": "linear" 478 | } 479 | }, 480 | { 481 | "id": "custom.spanNulls", 482 | "value": false 483 | }, 484 | { 485 | "id": "custom.axisSoftMin", 486 | "value": 0 487 | }, 488 | { 489 | "id": "custom.axisSoftMax", 490 | "value": 1 491 | } 492 | ] 493 | } 494 | ] 495 | }, 496 | "gridPos": { 497 | "h": 8, 498 | "w": 14, 499 | "x": 0, 500 | "y": 9 501 | }, 502 | "id": 3, 503 | "options": { 504 | "legend": { 505 | "calcs": [], 506 | "displayMode": "list", 507 | "placement": "bottom", 508 | "showLegend": true 509 | }, 510 | "tooltip": { 511 | "mode": "single", 512 | "sort": "none" 513 | } 514 | }, 515 | "targets": [ 516 | { 517 | "datasource": { 518 | "type": "prometheus", 519 | "uid": "${datasource}" 520 | }, 521 | "editorMode": "code", 522 | "exemplar": false, 523 | "expr": "sum by (path, method) (increase(http_requests_total{program=~\"$program\", environment=~\"$environment\", version=~\"$version\"}[1m]))", 524 | "instant": false, 525 | "interval": "1m", 526 | "legendFormat": "{{method}} {{path}}", 527 | "range": true, 528 | "refId": "A" 529 | } 530 | ], 531 | "title": "Throughput", 532 | "type": "timeseries" 533 | }, 534 | { 535 | "datasource": { 536 | "type": "prometheus", 537 | "uid": "${datasource}" 538 | }, 539 | "description": "This chart shows the percentage of transactions that resulted in an error. For more detail about how APM reports errors, and to learn how to set up your own error-reporting", 540 | "fieldConfig": { 541 | "defaults": { 542 | "color": { 543 | "mode": "palette-classic" 544 | }, 545 | "custom": { 546 | "axisCenteredZero": false, 547 | "axisColorMode": "text", 548 | "axisLabel": "", 549 | "axisPlacement": "auto", 550 | "barAlignment": 0, 551 | "drawStyle": "line", 552 | "fillOpacity": 0, 553 | "gradientMode": "none", 554 | "hideFrom": { 555 | "legend": false, 556 | "tooltip": false, 557 | "viz": false 558 | }, 559 | "lineInterpolation": "linear", 560 | "lineWidth": 1, 561 | "pointSize": 5, 562 | "scaleDistribution": { 563 | "type": "linear" 564 | }, 565 | "showPoints": "auto", 566 | "spanNulls": true, 567 | "stacking": { 568 | "group": "A", 569 | "mode": "none" 570 | }, 571 | "thresholdsStyle": { 572 | "mode": "off" 573 | } 574 | }, 575 | "mappings": [], 576 | "thresholds": { 577 | "mode": "absolute", 578 | "steps": [ 579 | { 580 | "color": "green", 581 | "value": null 582 | }, 583 | { 584 | "color": "red", 585 | "value": 80 586 | } 587 | ] 588 | }, 589 | "unit": "percent" 590 | }, 591 | "overrides": [] 592 | }, 593 | "gridPos": { 594 | "h": 8, 595 | "w": 10, 596 | "x": 14, 597 | "y": 9 598 | }, 599 | "id": 4, 600 | "options": { 601 | "legend": { 602 | "calcs": [], 603 | "displayMode": "list", 604 | "placement": "bottom", 605 | "showLegend": true 606 | }, 607 | "tooltip": { 608 | "mode": "single", 609 | "sort": "none" 610 | } 611 | }, 612 | "targets": [ 613 | { 614 | "datasource": { 615 | "type": "prometheus", 616 | "uid": "${datasource}" 617 | }, 618 | "editorMode": "code", 619 | "exemplar": true, 620 | "expr": "((sum by (program) (increase(http_requests_total{status=~\"5..\", program=~\"$program\", version=~\"$version\", environment=\"$environment\"}[1m])) or 0) / sum by (program) (increase(http_requests_total{program=\"$program\", version=~\"$version\", environment=\"$environment\"}[1m]))) * 100", 621 | "instant": false, 622 | "interval": "1m", 623 | "legendFormat": "{{program}}", 624 | "range": true, 625 | "refId": "A" 626 | } 627 | ], 628 | "title": "Error Rate", 629 | "type": "timeseries" 630 | }, 631 | { 632 | "datasource": { 633 | "type": "prometheus", 634 | "uid": "${datasource}" 635 | }, 636 | "description": "A transaction is the activity that begins when your service receives a request and ends when it sends a response. Use this display, and the displays for database operations and external services, to understand your transaction performance.", 637 | "fieldConfig": { 638 | "defaults": { 639 | "color": { 640 | "mode": "palette-classic" 641 | }, 642 | "custom": { 643 | "axisCenteredZero": false, 644 | "axisColorMode": "text", 645 | "axisLabel": "", 646 | "axisPlacement": "auto", 647 | "barAlignment": 0, 648 | "drawStyle": "line", 649 | "fillOpacity": 0, 650 | "gradientMode": "none", 651 | "hideFrom": { 652 | "legend": false, 653 | "tooltip": false, 654 | "viz": false 655 | }, 656 | "lineInterpolation": "linear", 657 | "lineWidth": 1, 658 | "pointSize": 5, 659 | "scaleDistribution": { 660 | "type": "linear" 661 | }, 662 | "showPoints": "auto", 663 | "spanNulls": true, 664 | "stacking": { 665 | "group": "A", 666 | "mode": "none" 667 | }, 668 | "thresholdsStyle": { 669 | "mode": "off" 670 | } 671 | }, 672 | "mappings": [], 673 | "thresholds": { 674 | "mode": "absolute", 675 | "steps": [ 676 | { 677 | "color": "green", 678 | "value": null 679 | }, 680 | { 681 | "color": "red", 682 | "value": 80 683 | } 684 | ] 685 | }, 686 | "unit": "ms" 687 | }, 688 | "overrides": [] 689 | }, 690 | "gridPos": { 691 | "h": 9, 692 | "w": 24, 693 | "x": 0, 694 | "y": 17 695 | }, 696 | "id": 5, 697 | "options": { 698 | "legend": { 699 | "calcs": [], 700 | "displayMode": "list", 701 | "placement": "bottom", 702 | "showLegend": true 703 | }, 704 | "tooltip": { 705 | "mode": "single", 706 | "sort": "none" 707 | } 708 | }, 709 | "targets": [ 710 | { 711 | "datasource": { 712 | "type": "prometheus", 713 | "uid": "${datasource}" 714 | }, 715 | "editorMode": "code", 716 | "exemplar": true, 717 | "expr": "topk(5, histogram_quantile (0.99, sum by (method, path, le) (increase(http_requests_duration_milliseconds_bucket{program=~\"$program\", version=~\"$version\", environment=~\"$environment\"}[1m]))))", 718 | "instant": false, 719 | "interval": "1m", 720 | "legendFormat": "{{method}} {{path}}", 721 | "range": true, 722 | "refId": "A" 723 | } 724 | ], 725 | "title": "Transactions 5 most time consuming (p99)", 726 | "type": "timeseries" 727 | }, 728 | { 729 | "collapsed": false, 730 | "datasource": { 731 | "type": "datasource", 732 | "uid": "grafana" 733 | }, 734 | "gridPos": { 735 | "h": 1, 736 | "w": 24, 737 | "x": 0, 738 | "y": 26 739 | }, 740 | "id": 23, 741 | "panels": [], 742 | "targets": [ 743 | { 744 | "datasource": { 745 | "type": "datasource", 746 | "uid": "grafana" 747 | }, 748 | "refId": "A" 749 | } 750 | ], 751 | "title": "Infrastructure Layer", 752 | "type": "row" 753 | }, 754 | { 755 | "datasource": { 756 | "type": "prometheus", 757 | "uid": "${datasource}" 758 | }, 759 | "description": "This metric measures the percentage of CPU utilization of a container and its pods.\n\nCPU can be greater than 100% if no limits are set on the k8s deployment spec", 760 | "fieldConfig": { 761 | "defaults": { 762 | "color": { 763 | "mode": "palette-classic" 764 | }, 765 | "custom": { 766 | "axisCenteredZero": false, 767 | "axisColorMode": "text", 768 | "axisLabel": "", 769 | "axisPlacement": "auto", 770 | "barAlignment": 0, 771 | "drawStyle": "line", 772 | "fillOpacity": 0, 773 | "gradientMode": "none", 774 | "hideFrom": { 775 | "legend": false, 776 | "tooltip": false, 777 | "viz": false 778 | }, 779 | "lineInterpolation": "linear", 780 | "lineWidth": 1, 781 | "pointSize": 5, 782 | "scaleDistribution": { 783 | "type": "linear" 784 | }, 785 | "showPoints": "auto", 786 | "spanNulls": false, 787 | "stacking": { 788 | "group": "A", 789 | "mode": "none" 790 | }, 791 | "thresholdsStyle": { 792 | "mode": "off" 793 | } 794 | }, 795 | "mappings": [], 796 | "thresholds": { 797 | "mode": "absolute", 798 | "steps": [ 799 | { 800 | "color": "green" 801 | }, 802 | { 803 | "color": "red", 804 | "value": 80 805 | } 806 | ] 807 | }, 808 | "unit": "percent" 809 | }, 810 | "overrides": [] 811 | }, 812 | "gridPos": { 813 | "h": 8, 814 | "w": 12, 815 | "x": 0, 816 | "y": 27 817 | }, 818 | "id": 12, 819 | "options": { 820 | "legend": { 821 | "calcs": [], 822 | "displayMode": "list", 823 | "placement": "bottom", 824 | "showLegend": true 825 | }, 826 | "tooltip": { 827 | "mode": "single", 828 | "sort": "none" 829 | } 830 | }, 831 | "targets": [ 832 | { 833 | "datasource": { 834 | "type": "prometheus", 835 | "uid": "${datasource}" 836 | }, 837 | "editorMode": "code", 838 | "exemplar": true, 839 | "expr": "sum by (pod) (irate(container_cpu_usage_seconds_total{container=~\"$program\"}[1m]) * 60) * 100", 840 | "hide": false, 841 | "interval": "1m", 842 | "legendFormat": "{{instance}}", 843 | "range": true, 844 | "refId": "A" 845 | } 846 | ], 847 | "title": "Pod CPU Utilization %", 848 | "type": "timeseries" 849 | }, 850 | { 851 | "datasource": { 852 | "type": "prometheus", 853 | "uid": "${datasource}" 854 | }, 855 | "description": "This metric measures the amount of memory used by the container and its pods.", 856 | "fieldConfig": { 857 | "defaults": { 858 | "color": { 859 | "mode": "palette-classic" 860 | }, 861 | "custom": { 862 | "axisCenteredZero": false, 863 | "axisColorMode": "text", 864 | "axisLabel": "", 865 | "axisPlacement": "auto", 866 | "barAlignment": 0, 867 | "drawStyle": "line", 868 | "fillOpacity": 0, 869 | "gradientMode": "none", 870 | "hideFrom": { 871 | "legend": false, 872 | "tooltip": false, 873 | "viz": false 874 | }, 875 | "lineInterpolation": "linear", 876 | "lineWidth": 1, 877 | "pointSize": 5, 878 | "scaleDistribution": { 879 | "type": "linear" 880 | }, 881 | "showPoints": "auto", 882 | "spanNulls": false, 883 | "stacking": { 884 | "group": "A", 885 | "mode": "none" 886 | }, 887 | "thresholdsStyle": { 888 | "mode": "off" 889 | } 890 | }, 891 | "mappings": [], 892 | "thresholds": { 893 | "mode": "absolute", 894 | "steps": [ 895 | { 896 | "color": "green" 897 | }, 898 | { 899 | "color": "red", 900 | "value": 80 901 | } 902 | ] 903 | }, 904 | "unit": "decbytes" 905 | }, 906 | "overrides": [] 907 | }, 908 | "gridPos": { 909 | "h": 8, 910 | "w": 12, 911 | "x": 12, 912 | "y": 27 913 | }, 914 | "id": 15, 915 | "options": { 916 | "legend": { 917 | "calcs": [], 918 | "displayMode": "list", 919 | "placement": "bottom", 920 | "showLegend": true 921 | }, 922 | "tooltip": { 923 | "mode": "single", 924 | "sort": "none" 925 | } 926 | }, 927 | "targets": [ 928 | { 929 | "datasource": { 930 | "type": "prometheus", 931 | "uid": "${datasource}" 932 | }, 933 | "editorMode": "code", 934 | "exemplar": true, 935 | "expr": "sum(container_memory_rss{container=~\"$program\"}) by (pod)", 936 | "interval": "1m", 937 | "legendFormat": "{{instance}}", 938 | "range": true, 939 | "refId": "A" 940 | } 941 | ], 942 | "title": "Memory Used (in bytes)", 943 | "type": "timeseries" 944 | } 945 | ], 946 | "refresh": false, 947 | "schemaVersion": 37, 948 | "style": "dark", 949 | "tags": [ 950 | "nodejs", 951 | "APM" 952 | ], 953 | "templating": { 954 | "list": [ 955 | { 956 | "current": { 957 | "selected": false, 958 | "text": "ProdProboMetrics - ap-south-1 (system)", 959 | "value": "ProdProboMetrics - ap-south-1 (system)" 960 | }, 961 | "hide": 0, 962 | "includeAll": false, 963 | "label": "Levitate Instance", 964 | "multi": false, 965 | "name": "datasource", 966 | "options": [], 967 | "query": "prometheus", 968 | "queryValue": "", 969 | "refresh": 1, 970 | "regex": "", 971 | "skipUrlSync": false, 972 | "type": "datasource" 973 | }, 974 | { 975 | "current": { 976 | "isNone": true, 977 | "selected": true, 978 | "text": "None", 979 | "value": "" 980 | }, 981 | "datasource": { 982 | "type": "prometheus", 983 | "uid": "lhP5jxa4z" 984 | }, 985 | "definition": "label_values(http_requests_total{}, program)", 986 | "hide": 0, 987 | "includeAll": false, 988 | "label": "Program", 989 | "multi": false, 990 | "name": "program", 991 | "options": [], 992 | "query": { 993 | "query": "label_values(http_requests_total{}, program)", 994 | "refId": "StandardVariableQuery" 995 | }, 996 | "refresh": 1, 997 | "regex": "", 998 | "skipUrlSync": false, 999 | "sort": 1, 1000 | "type": "query" 1001 | }, 1002 | { 1003 | "current": { 1004 | "isNone": true, 1005 | "selected": false, 1006 | "text": "None", 1007 | "value": "" 1008 | }, 1009 | "datasource": { 1010 | "type": "prometheus", 1011 | "uid": "lhP5jxa4z" 1012 | }, 1013 | "definition": "label_values(http_requests_total{program=~\"$program\"}, environment)", 1014 | "hide": 0, 1015 | "includeAll": false, 1016 | "label": "Environment", 1017 | "multi": false, 1018 | "name": "environment", 1019 | "options": [], 1020 | "query": { 1021 | "query": "label_values(http_requests_total{program=~\"$program\"}, environment)", 1022 | "refId": "StandardVariableQuery" 1023 | }, 1024 | "refresh": 2, 1025 | "regex": "", 1026 | "skipUrlSync": false, 1027 | "sort": 0, 1028 | "type": "query" 1029 | }, 1030 | { 1031 | "current": { 1032 | "isNone": true, 1033 | "selected": true, 1034 | "text": "None", 1035 | "value": "" 1036 | }, 1037 | "datasource": { 1038 | "type": "prometheus", 1039 | "uid": "lhP5jxa4z" 1040 | }, 1041 | "definition": "label_values(http_requests_total{program=\"$program\"}, version)", 1042 | "hide": 0, 1043 | "includeAll": false, 1044 | "label": "Version", 1045 | "multi": false, 1046 | "name": "version", 1047 | "options": [], 1048 | "query": { 1049 | "query": "label_values(http_requests_total{program=\"$program\"}, version)", 1050 | "refId": "StandardVariableQuery" 1051 | }, 1052 | "refresh": 1, 1053 | "regex": "", 1054 | "skipUrlSync": false, 1055 | "sort": 1, 1056 | "type": "query" 1057 | } 1058 | ] 1059 | }, 1060 | "time": { 1061 | "from": "now-12h", 1062 | "to": "now" 1063 | }, 1064 | "timepicker": {}, 1065 | "timezone": "Asia/Kolkata", 1066 | "title": "OpenAPM Dashboard", 1067 | "uid": "ef4ad4ce8f", 1068 | "version": 7, 1069 | "weekStart": "" 1070 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.9.2] 9 | 10 | ### Added 11 | 12 | - Exposed `prom-client` using `getMetricClient` function. 13 | 14 | ### Updates 15 | 16 | - Upgrade `prom-client` 17 | 18 | ## [0.9.1] 19 | 20 | ### Updated 21 | 22 | - Improved Next.js parameterized route capturing capability 23 | 24 | ## [0.9.0] 25 | 26 | ### Added 27 | 28 | - Optional `additionalLabels` option. The added label keys will be emitted with all metrics if the label value is set using `setOpenAPMLabels`. 29 | - `setOpenAPMLabels` function to set custom labels to the metrics emitted by OpenAPM. 30 | 31 | ## [0.8.0] 32 | 33 | ### Added 34 | 35 | - Optional `enableMetricsServer` option to enable or disable the metrics server. 36 | - Optional `enabled` option to conditionally enable or disable OpenAPM. 37 | - Exposed the `getMetrics` function to get the metrics in prometheus exposition format. 38 | - Add support to instrument applications based on the `Next.js` framework in the Node.js environment. 39 | - Automatically add Prisma metrics if they are available. 40 | 41 | ## [0.7.0] - 2024-04-01 42 | 43 | ### Added 44 | 45 | - **BREAKING** The OpenAPM metrics server will not close automatically when the application shuts down. Call the `shutdown` function explicitly to shut down OpenAPM. 46 | 47 | ## [0.6.1] - 2023-12-26 48 | 49 | ### Added 50 | 51 | - Skip the `OPTIONS` requests from the instrumentation process. 52 | 53 | ## [0.6.0] - 2023-12-22 54 | 55 | ### Added 56 | 57 | - The ability to automatically detect the correct `path` labels based on user routes in their applications. This will solve the cardinality blowup of endpoints that are not being folded correctly. 58 | 59 | ### Deprecated 60 | 61 | - Optional `customPathsToMask` option to mask certain values as it is no longer needed now. It will be removed in 62 | future releases. 63 | 64 | ## [0.5.0] - 2023-12-4 65 | 66 | ### Added 67 | 68 | - Optional [Change Events](https://docs.last9.io/docs/change-events) Support. Track the `application_start` event for Express applications in Levitate along with other APM metrics. 69 | 70 | ## [0.4.0] - 2023-11-2 71 | 72 | ### Added 73 | 74 | - Add `excludeDefaultLabels` to the options. 75 | - Add support to instrument `NestJS` applications. 76 | 77 | ### Changed 78 | 79 | - Migrate from `Rollup` to `tsup` for building package. 80 | 81 | ## [0.3.0] - 2023-10-9 82 | 83 | ### Added 84 | 85 | - Add `extractable` to the options to extract any labels from the URL params, such as a tenant or org name, allowing support for multi-tenant monitoring. 86 | - Gracefully shutdown metrics server 87 | 88 | ## [0.2.2] - 2023-08-28 89 | 90 | ### Added 91 | 92 | - Track the success or failure of database queries with the `status` label in the `db_requests_duration_milliseconds` metric. Supported values - `success` and `failure`. 93 | 94 | ## [0.2.1] - 2023-08-23 95 | 96 | ### Added 97 | 98 | - Auto instrumentation of Rate and Duration metrics for MySQL DB 99 | - Preconfigured Grafana dashboard JSON that can be imported directly 100 | 101 | ## [0.1.1] - 2023-07-26 102 | 103 | ### Added 104 | 105 | - Auto instrumentation of R.E.D (Rate, Errors & Duration) metrics for Express.js 106 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hi@last9.io. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @last9/openapm 4 | 5 | An APM solution based on metrics and open-source tools such as Prometheus and Grafana for NodeJs-based applications. 6 | 7 | ## Table of Contents 8 | 9 | 1. [Installation](#installation) 10 | 2. [Usage](#usage) 11 | 3. [Options](#options) 12 | 4. [API Reference](#api-reference) 13 | 5. [Setup Locally](#setup-locally) 14 | 6. [Grafana Dashboard View](#grafana-dashboard-view) 15 | 16 | ## Installation 17 | 18 | ``` 19 | npm install --save @last9/openapm@latest 20 | ``` 21 | 22 | ## Usage 23 | 24 | ```js 25 | const express = require('express'); 26 | const { OpenAPM } = require('@last9/openapm'); 27 | 28 | const app = express(); 29 | const openapm = new OpenAPM(); 30 | 31 | // Instrument services 32 | 33 | app.listen(3000); 34 | 35 | const gracefullyShutdown = () => { 36 | app.close(() => { 37 | openapm 38 | .shutdown() 39 | .then(() => { 40 | console.log('OpenAPM shutdown successful.'); 41 | }) 42 | .catch((err) => { 43 | console.log('Error shutting down OpenAPM', err); 44 | }); 45 | }); 46 | }; 47 | 48 | process.on('SIGINT', gracefullyShutdown); 49 | process.on('SIGTERM', gracefullyShutdown); 50 | ``` 51 | 52 | 1. [Express](#express) 53 | 2. [MySQL](#mysql) 54 | 3. [NestJS](#nestjs) 55 | 4. [Next.js](#nextjs) 56 | 57 | ### Express 58 | 59 | In the example below, the metrics will be served on `localhost:9097/metrics`. To 60 | change the port, you can update it through the options 61 | ([See the options documentation](#options)). 62 | 63 | ```js 64 | const { OpenAPM } = require('@last9/openapm'); 65 | const openapm = new OpenAPM(); 66 | 67 | openapm.instrument('express'); 68 | ``` 69 | 70 | ### MySQL 71 | 72 | This currently supports instrumentation for all Node.js ORMs, which are [mysql2](https://www.npmjs.com/package/mysql2) compatible. 73 | 74 | Ensure to add this line of code before you initialize db `connection/pool/poolCluster`. 75 | 76 | ```js 77 | openapm.instrument('mysql'); 78 | ``` 79 | 80 | ### NestJS 81 | 82 | OpenAPM currently supports RED Metrics for NestJS v4 and above. 83 | 84 | ```js 85 | openapm.instrument('nestjs'); 86 | ``` 87 | 88 | ### Next.js 89 | 90 | OpenAPM supports RED metrics for both pages and app router in a Next.js application. 91 | 92 | ```js 93 | openapm.instrument('nextjs'); 94 | ``` 95 | 96 | > Note: You can only use the library if Next.js runs in a Node.js environment. Since OpenAPM relies on prom-client for capturing metrics data, a serverless environment might not be able persist them. 97 | 98 | ## Options 99 | 100 | ### Usage 101 | 102 | ```js 103 | const openapm = new OpenAPM({ 104 | // Options go here 105 | }); 106 | ``` 107 | 108 | 1. `path`: The path at which the metrics will be served. For eg. `/metrics` 109 | 2. `metricsServerPort`: (Optional) The port at which the metricsServer will run. 110 | 3. `environment`: (Optional) The application environment. Defaults to 111 | `production`. 112 | 4. `defaultLabels`: (Optional) Any default labels to be included. 113 | 5. `requestsCounterConfig`: (Optional) Requests counter configuration, same as 114 | [Counter](https://github.com/siimon/prom-client#counter) in `prom-client`. 115 | Defaults to 116 | 117 | ```js 118 | { 119 | name: 'http_requests_total', 120 | help: 'Total number of requests', 121 | labelNames: ['path', 'method', 'status'], 122 | } 123 | ``` 124 | 125 | 6. `requestDurationHistogramConfig`: (Optional) Requests Duration histogram 126 | configuration, the same as 127 | [Histogram](https://github.com/siimon/prom-client#histogram) in 128 | `prom-client`. Defaults to 129 | ```js 130 | { 131 | name: 'http_requests_duration_milliseconds', 132 | help: 'Duration of HTTP requests in milliseconds', 133 | labelNames: ['path', 'method', 'status'], 134 | buckets: promClient.exponentialBuckets(0.25, 1.5, 31), 135 | } 136 | ``` 137 | 7. `extractLabels`: (Optional) Extract labels from URL params (WIP: Headers, Subdomain) 138 | ```js 139 | // To extract from the URL params 140 | { 141 | ... 142 | extractLabels: { 143 | tenant: { // Here 'tenant' is the label name 144 | from : 'params', 145 | key: 'org' // Which key to extract from the params 146 | mask: ':org' // Replacement string 147 | } 148 | } 149 | } 150 | ``` 151 | 8. `excludeDefaultLabels`: (Optional) Provide labels to exclude from the default labels 152 | 153 | ```js 154 | { 155 | ... 156 | excludeDefaultLabels: ['environment', 'version'] 157 | } 158 | ``` 159 | 160 | 9. `levitateConfig`: (Optional) Configuration for Levitate TSDB. Adding this configuration will enable the [Change Events](https://docs.last9.io/docs/change-events). 161 | 162 | 10. `enableMetricsServer`: (Optional) Defaults to `true`. When set to `false` the OpenAPM won't start a metrics server. To get the metrics users can rely on the `.getMetrics()` function. 163 | 164 | ```js 165 | { 166 | ... 167 | levitateConfig: { 168 | host: 'https://app.last9.io', 169 | orgSlug: 'last9', /** The slug can be obtained from the Last9 dashboard.*/ 170 | dataSourceName: 'data-source', /** The data source can be obtained from the data source pages in the Last9 dashboard*/ 171 | refreshTokens: { 172 | write: '0d2a1a9a45XXXXXXXXXXXXXX3f1342790d2a1a9a45XXXXXXXXXXXXXX3f1342790d2a1a9a45XXXXXXXXXXXXXX3f134279' /** You can get this from the API access page on Last9 dashboard*/ 173 | } 174 | } 175 | } 176 | ``` 177 | 178 | 11. `enabled`: (Optional) Defaults to `true`. When set to `false` OpenAPM will be disabled and no metrics will be collected or emitted. 179 | 180 | ``` 181 | const openapm = new OpenAPM({ 182 | enabled: process.env.NODE_ENV === 'production' 183 | }) 184 | ``` 185 | 186 | 12. `additionalLabels`: (Optional) Accepts an array of label keys that will be emitted with the metrics. This option is used in tandem with the `setOpenAPMLabels` API. Checkout [API Reference](#api-reference) 187 | 188 | ``` 189 | const openapm = new OpenAPM({ 190 | additionalLabels: ['slug'] 191 | }) 192 | ``` 193 | 194 | ## API Reference 195 | 196 | 1. `instrument`: Used to instrument supported technologies. Refer the [usage](#usage) section. 197 | 198 | 2. `getMetrics`: Returns a Promise of string which contains metrics in Prometheus exposition format. You can use this function to expose a metrics endpoint if `enableMetricsServer` is set to false. For example, 199 | 200 | ```js 201 | const openapm = new OpenAPM({ 202 | enableMetricsServer: false 203 | }); 204 | 205 | openapm.instrument('express'); 206 | 207 | const app = express(); 208 | 209 | app.get('/metrics', async (_, res) => { 210 | const metrics = await openapm.getMetrics(); 211 | res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); 212 | res.end(metrics); 213 | }); 214 | ``` 215 | 216 | 3. `shutdown`: Returns a promise which is resolved after the cleanup in OpenAPM. The cleanup includes closing the metrics server if it has started and cleared the prom-client register. 217 | 218 | ```js 219 | const gracefullyShutdown = () => { 220 | server.close(() => { 221 | openapm 222 | .shutdown() 223 | .then(() => { 224 | console.log('OpenAPM shutdown successful.'); 225 | }) 226 | .catch((err) => { 227 | console.log('Error shutting down OpenAPM', err); 228 | }); 229 | }); 230 | }; 231 | 232 | process.on('SIGINT', gracefullyShutdown); 233 | process.on('SIGTERM', gracefullyShutdown); 234 | ``` 235 | 236 | 4. `setOpenAPMLabels`: Unlike other APIs. You can directly import `setOpenAPMLabels` in any file to set custom labels to the request. Make sure to mention the label key in `additionalLabels` option. This function can set multiple labels in the metrics emitted by the ongoing HTTP request. 237 | 238 | Note: `setOpenAPMLabels` currently works with **express** and **Nest.js** only. 239 | 240 | ```js 241 | import { OpenAPM, setOpenAPMLabels } from '@last9/openapm'; 242 | 243 | const openapm = new OpenAPM({ 244 | additionalLabels: ['slug'] 245 | }); 246 | 247 | const handler = () => { 248 | setOpenAPMLabels({ slug: 'org-slug' }); 249 | }; 250 | ``` 251 | 252 | 5. Defining custom metrics 253 | 254 | OpenAPM exposes underlying `prom-client` via `getMetricClient` function. 255 | 256 | ```js 257 | const { getMetricClient } = require('@last9/openapm'); 258 | 259 | // initialize custom metric 260 | const client = getMetricClient(); 261 | const counter = new client.Counter({ 262 | name: 'cancelation_calls', 263 | help: 'no. of times cancel operation is called' 264 | }); 265 | 266 | // handler 267 | app.get('/cancel/:ids', (req, res) => { 268 | counter.inc(); 269 | res.status(200).json({}); 270 | }); 271 | ``` 272 | 273 | Follow the [documentation of prom-client](https://github.com/siimon/prom-client?tab=readme-ov-file#custom-metrics) 274 | to get familiar with the DSL for defining custom metrics. 275 | 276 | ## Setup locally 277 | 278 | Make sure you are in the express directory. 279 | 280 | - Install packages 281 | 282 | ``` 283 | npm install 284 | ``` 285 | 286 | - Build package 287 | 288 | - This will build the package and store the JS and type declaration files in 289 | the `dist` folder. 290 | 291 | ``` 292 | npm run build 293 | ``` 294 | 295 | # Grafana Dashboard View 296 | 297 | 1. Import [this](./APM-Grafana-Dashboard.json) dashboard into your Grafana 298 | 2. Select your data source 299 | 3. Save the dashboard 300 | 301 | ![APM Dashboard](images/apm-dashboard-1.png) 302 | ![APM Dashboard](images/apm-dashboard-2.png) 303 | ![APM Dashboard](images/apm-dashboard-3.png) 304 | 305 | # About Last9 306 | 307 | [Last9](https://last9.io) builds reliability tools for SRE and DevOps. 308 | 309 | 310 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please get in touch with us at [hi@last9.io](mailto:hi@last9.io) to report the vulnerability. 6 | -------------------------------------------------------------------------------- /images/apm-dashboard-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last9/openapm-nodejs/6b0ded711b972cfe310568f5250b037bd8863ebd/images/apm-dashboard-1.png -------------------------------------------------------------------------------- /images/apm-dashboard-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last9/openapm-nodejs/6b0ded711b972cfe310568f5250b037bd8863ebd/images/apm-dashboard-2.png -------------------------------------------------------------------------------- /images/apm-dashboard-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last9/openapm-nodejs/6b0ded711b972cfe310568f5250b037bd8863ebd/images/apm-dashboard-3.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@last9/openapm", 3 | "description": "OpenAPM for Node.js", 4 | "version": "0.9.2", 5 | "main": "dist/src/index.js", 6 | "module": "dist/esm/src/index.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "tsup", 10 | "build:watch": "tsup --watch", 11 | "vitest": "vitest", 12 | "test": "chmod +x ./run-tests.sh && ./run-tests.sh" 13 | }, 14 | "license": "Apache-2.0", 15 | "author": "Last9 team ", 16 | "contributors": [ 17 | "Tushar Choudhari (https://github.com/chtushar)", 18 | "Aniket Rao (https://github.com/anik3tra0)" 19 | ], 20 | "peerDependencies": { 21 | "express": "4.x" 22 | }, 23 | "keywords": [ 24 | "last9", 25 | "metrics", 26 | "apm", 27 | "prometheus" 28 | ], 29 | "devDependencies": { 30 | "@babel/core": "^7.22.5", 31 | "@babel/preset-env": "^7.22.5", 32 | "@nestjs/core": "^10.2.7", 33 | "@nestjs/platform-express": "^10.3.8", 34 | "@nestjs/testing": "^10.3.8", 35 | "@swc/core": "^1.3.95", 36 | "@types/express": "^4.17.17", 37 | "@types/mysql2": "github:types/mysql2", 38 | "@types/node": "^20.4.4", 39 | "@types/react": "^18.2.79", 40 | "@types/response-time": "^2.3.5", 41 | "@typescript-eslint/eslint-plugin": "^5.61.0", 42 | "@typescript-eslint/parser": "^5.61.0", 43 | "@vitest/ui": "^0.34.1", 44 | "axios": "^1.4.0", 45 | "dotenv": "^16.3.1", 46 | "eslint": "^8.44.0", 47 | "eslint-config-semistandard": "^17.0.0", 48 | "eslint-config-standard": "^17.1.0", 49 | "eslint-plugin-import": "^2.27.5", 50 | "eslint-plugin-n": "^15.7.0", 51 | "eslint-plugin-promise": "^6.1.1", 52 | "express": "^4.18.2", 53 | "mysql2": "^3.6.0", 54 | "next": "^14.2.2", 55 | "node-fetch": "^3.3.1", 56 | "parse-prometheus-text-format": "^1.1.1", 57 | "playwright": "^1.44.0", 58 | "prettier": "2.8.8", 59 | "prisma": "^5.13.0", 60 | "react": "^18.2.0", 61 | "supertest": "^6.3.3", 62 | "tiny-glob": "^0.2.9", 63 | "ts-node": "^10.9.1", 64 | "tslib": "^2.6.0", 65 | "tsup": "^7.2.0", 66 | "typescript": "^5.1.6", 67 | "vitest": "^0.32.4" 68 | }, 69 | "dependencies": { 70 | "@prisma/client": "^5.13.0", 71 | "@rollup/plugin-commonjs": "^25.0.7", 72 | "@types/supertest": "^6.0.2", 73 | "chalk": "^4.1.2", 74 | "chokidar": "^3.6.0", 75 | "prom-client": "^15.1.2", 76 | "response-time": "^2.3.2", 77 | "rollup": "^4.14.3", 78 | "undici": "^5.27.2", 79 | "url-value-parser": "^2.2.0" 80 | }, 81 | "peerDependenciesMeta": { 82 | "@nestjs/core": { 83 | "optional": true 84 | }, 85 | "mysql2": { 86 | "optional": true 87 | }, 88 | "express": { 89 | "optional": true 90 | }, 91 | "@prisma/client": { 92 | "optional": true 93 | }, 94 | "next": { 95 | "optional": true 96 | } 97 | }, 98 | "directories": { 99 | "test": "tests" 100 | }, 101 | "repository": { 102 | "type": "git", 103 | "url": "git+https://github.com/last9/nodejs-openapm.git" 104 | }, 105 | "bugs": { 106 | "url": "https://github.com/last9/nodejs-openapm/issues" 107 | }, 108 | "homepage": "https://github.com/last9/nodejs-openapm#readme", 109 | "private": false 110 | } 111 | -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | # Playground 2 | 3 | This app doesn't necessarily showcase the usage of the library rather it is just a testing environment that can be used while developing the app locally. 4 | Please do not consider the code in the playground app as a source of truth. There are no set rules to maintain this app since it'll keep evolving. 5 | -------------------------------------------------------------------------------- /playground/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Make sure to build the library before you run the app 3 | * Also, comment out the things that you are not using. For example, you can comment out the mysql code if you are 4 | * not testing or developing for the same 5 | * */ 6 | require('dotenv').config(); 7 | const express = require('express'); 8 | const { 9 | OpenAPM, 10 | setOpenAPMLabels, 11 | metricClient 12 | } = require('../dist/src/index.js'); 13 | const mysql2 = require('mysql2'); 14 | 15 | const openapm = new OpenAPM({ 16 | extractLabels: { 17 | tenant: { 18 | from: 'params', 19 | key: 'org', 20 | mask: ':org' 21 | } 22 | }, 23 | levitateConfig: { 24 | orgSlug: process.env.LEVITATE_ORG_SLUG, 25 | dataSourceName: process.env.LEVITATE_DATASOURCE, 26 | refreshTokens: { 27 | write: process.env.LEVITATE_WRITE_REFRESH_TOKEN 28 | } 29 | }, 30 | customPathsToMask: [/\b\d+(?:,\d+)*\b/gm], 31 | excludeDefaultLabels: ['host', 'program'], 32 | additionalLabels: ['slug'] 33 | }); 34 | 35 | openapm.instrument('express'); 36 | openapm.instrument('mysql'); 37 | 38 | const app = express(); 39 | 40 | const pool = mysql2.createPool( 41 | 'mysql://express-app:password@127.0.0.1/express' // If this throws an error, Change the db url to the one you're running on your machine locally or the testing instance you might have hosted. 42 | ); 43 | 44 | const client = metricClient(); 45 | const counter = new client.Counter({ 46 | name: 'cancelation_calls', 47 | help: 'no. of times cancel operation is called' 48 | }); 49 | 50 | app.get('/result', (req, res) => { 51 | pool.getConnection((err, conn) => { 52 | conn.query( 53 | { 54 | sql: 'SELECT SLEEP(RAND() * 10)' 55 | }, 56 | (...args) => { 57 | console.log(args); 58 | } 59 | ); 60 | }); 61 | 62 | res.status(200).json({}); 63 | }); 64 | 65 | app.get('/organizations/:org/users', (req, res) => { 66 | console.log(req.params.org); 67 | 68 | res.status(200).json({}); 69 | }); 70 | 71 | app.get('/cancel/:ids', (req, res) => { 72 | counter.inc(); 73 | res.status(200).json({}); 74 | }); 75 | 76 | app.post('/api/v2/product/search/:term', (req, res) => { 77 | res.status(200).json({}); 78 | }); 79 | 80 | app.all('/api/v1/slug/:slug', (req, res) => { 81 | setOpenAPMLabels({ slug: req.params.slug }); 82 | res.status(200).json({}); 83 | }); 84 | 85 | const server = app.listen(3000, () => { 86 | console.log('serving at 3000'); 87 | }); 88 | 89 | const gracefullyShutdownServer = () => { 90 | server.close(() => { 91 | openapm 92 | .shutdown() 93 | .then(() => { 94 | console.log('Server gracefully shutdown'); 95 | process.exit(0); 96 | }) 97 | .catch((err) => { 98 | console.log(err); 99 | process.exit(1); 100 | }); 101 | }); 102 | }; 103 | 104 | process.on('SIGINT', gracefullyShutdownServer); 105 | process.on('SIGTERM', gracefullyShutdownServer); 106 | -------------------------------------------------------------------------------- /playground/nest/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /playground/nest/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /playground/nest/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /playground/nest/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /playground/nest/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /playground/nest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" 16 | }, 17 | "dependencies": { 18 | "@last9/openapm": "^0.8.0", 19 | "@nestjs/common": "^10.0.0", 20 | "@nestjs/core": "^10.0.0", 21 | "@nestjs/platform-express": "^10.3.7", 22 | "reflect-metadata": "^0.1.13", 23 | "rxjs": "^7.8.1" 24 | }, 25 | "devDependencies": { 26 | "@nestjs/cli": "^10.0.0", 27 | "@nestjs/schematics": "^10.0.0", 28 | "@nestjs/testing": "^10.0.0", 29 | "@types/express": "^4.17.17", 30 | "@types/jest": "^29.5.2", 31 | "@types/node": "^20.3.1", 32 | "@types/supertest": "^2.0.12", 33 | "@typescript-eslint/eslint-plugin": "^6.0.0", 34 | "@typescript-eslint/parser": "^6.0.0", 35 | "eslint": "^8.42.0", 36 | "eslint-config-prettier": "^9.0.0", 37 | "eslint-plugin-prettier": "^5.0.0", 38 | "jest": "^29.5.0", 39 | "prettier": "^3.0.0", 40 | "source-map-support": "^0.5.21", 41 | "supertest": "^6.3.3", 42 | "ts-jest": "^29.1.0", 43 | "ts-loader": "^9.4.3", 44 | "ts-node": "^10.9.1", 45 | "tsconfig-paths": "^4.2.0", 46 | "typescript": "^5.1.3" 47 | }, 48 | "jest": { 49 | "moduleFileExtensions": [ 50 | "js", 51 | "json", 52 | "ts" 53 | ], 54 | "rootDir": "src", 55 | "testRegex": ".*\\.spec\\.ts$", 56 | "transform": { 57 | "^.+\\.(t|j)s$": "ts-jest" 58 | }, 59 | "collectCoverageFrom": [ 60 | "**/*.(t|j)s" 61 | ], 62 | "coverageDirectory": "../coverage", 63 | "testEnvironment": "node" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /playground/nest/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get('organizations/:org/users') 9 | getUsers(): string { 10 | return this.appService.getHello(); 11 | } 12 | 13 | @Get('cancel/:ids') 14 | cancel(): string { 15 | return this.appService.getHello(); 16 | } 17 | 18 | @Get('api/v2/product/search/:term') 19 | search(): string { 20 | return this.appService.getHello(); 21 | } 22 | @Get('api/v1/slug/:slug') 23 | v1Slug(): string { 24 | return this.appService.getHello(); 25 | } 26 | 27 | @Get() 28 | getHello(): string { 29 | return this.appService.getHello(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /playground/nest/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [AppController], 8 | providers: [AppService], 9 | }) 10 | export class AppModule {} 11 | -------------------------------------------------------------------------------- /playground/nest/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /playground/nest/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { OpenAPM } from '@last9/openapm'; 4 | 5 | async function bootstrap() { 6 | const openapm = new OpenAPM(); 7 | openapm.instrument('nestjs'); 8 | 9 | const app = await NestFactory.create(AppModule); 10 | await app.listen(3000); 11 | } 12 | bootstrap(); 13 | -------------------------------------------------------------------------------- /playground/nest/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /playground/nest/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /playground/nest/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /playground/nest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /playground/next/app/app-apis/[id]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { setOpenAPMLabels } from '../../../../../src/async-local-storage.http'; 3 | 4 | export async function GET(request) { 5 | setOpenAPMLabels({ 6 | id: request.params.id 7 | }); 8 | 9 | return NextResponse.json({ 10 | status: 200, 11 | body: { 12 | message: 'GET method called' 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /playground/next/app/app-apis/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { setOpenAPMLabels } from '../../../../src/async-local-storage.http'; 3 | 4 | export async function GET(request) { 5 | setOpenAPMLabels({ 6 | slug: 'route' 7 | }); 8 | 9 | return NextResponse.json({ 10 | status: 200, 11 | body: { 12 | message: 'GET method called' 13 | } 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /playground/next/app/labels/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { 3 | getHTTPRequestStore, 4 | setOpenAPMLabels 5 | } from '../../../../src/async-local-storage.http'; 6 | 7 | export async function GET(request) { 8 | const store = getHTTPRequestStore(); 9 | console.log('store', store); 10 | 11 | return NextResponse.json({ 12 | status: 200, 13 | body: { 14 | message: 'GET method called', 15 | store: typeof store 16 | } 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /playground/next/app/layout.js: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | } 5 | 6 | export default function RootLayout({ children }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /playground/next/app/page.js: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Page
; 3 | } 4 | -------------------------------------------------------------------------------- /playground/next/app/users/[id]/delete/page.js: -------------------------------------------------------------------------------- 1 | export default function Page({ params: { id } }) { 2 | return
Delete: {id}
; 3 | } 4 | -------------------------------------------------------------------------------- /playground/next/app/users/[id]/page.js: -------------------------------------------------------------------------------- 1 | export default function Page({ params: { id } }) { 2 | return
Delete: {id}
; 3 | } 4 | -------------------------------------------------------------------------------- /playground/next/app/users/page.js: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
User
; 3 | } 4 | -------------------------------------------------------------------------------- /playground/next/next.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const http = require('http'); 3 | const next = require('next'); 4 | const { parse } = require('url'); 5 | const { OpenAPM } = require('../../dist/src/index.js'); 6 | 7 | const openapm = new OpenAPM({ 8 | metricsServerPort: 9098, 9 | additionalLabels: ['slug'] 10 | }); 11 | 12 | openapm.instrument('nextjs'); 13 | 14 | async function main() { 15 | const app = express(); 16 | const server = http.createServer(app); 17 | 18 | // 'dev' is a boolean that indicates whether the app should run in development mode 19 | const dev = process.env.NODE_ENV !== 'production'; 20 | const port = 3002; 21 | 22 | // 'dir' is a string that specifies the directory where the app is located 23 | const dir = './playground/next'; 24 | const nextApp = next({ 25 | dev, 26 | dir, 27 | customServer: true, 28 | httpServer: server, 29 | port 30 | }); 31 | // openapm.instrument('nextjs', nextApp); 32 | const handle = nextApp.getRequestHandler(); 33 | 34 | app.get('/metrics', async (_, res) => { 35 | const metrics = await openapm.getMetrics(); 36 | res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); 37 | res.end(metrics); 38 | }); 39 | 40 | app.all('*', async (req, res) => { 41 | const parsedUrl = parse(req.url, true); 42 | await handle(req, res, parsedUrl); 43 | }); 44 | 45 | // 'hostname' is a string that specifies the domain name of the server 46 | // For local development, this is typically 'localhost' 47 | const hostname = 'localhost'; 48 | 49 | await nextApp.prepare(); 50 | server.listen(port, hostname); 51 | server.on('error', async (err) => { 52 | console.error(err); 53 | }); 54 | server.once('listening', async () => {}); 55 | } 56 | 57 | main(); 58 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | setupNext() { 2 | echo "Setting up Next.js" 3 | npx next build ./tests/nextjs 4 | } 5 | 6 | setupPrisma() { 7 | echo "Setting up Prisma" 8 | npx prisma generate --schema=./tests/prisma/schema.prisma 9 | npx prisma migrate dev --schema=./tests/prisma/schema.prisma --name init 10 | } 11 | 12 | # Run all tests 13 | runAllTests() { 14 | setupNext 15 | setupPrisma 16 | npm run vitest 17 | } 18 | 19 | # Run Next.js tests 20 | runNextJsTests() { 21 | setupNext 22 | npm run vitest -t ./tests/nextjs/nextjs.test.ts 23 | } 24 | 25 | # Run Nest.js tests 26 | runNestJsTests() { 27 | npm run vitest -t ./tests/nestjs/nestjs.test.ts 28 | } 29 | 30 | # Run Prisma tests 31 | runPrismaTests() { 32 | setupPrisma 33 | npm run vitest -t ./tests/prisma/*.test.ts 34 | } 35 | 36 | # Run MySQL tests 37 | runMysqlTests() { 38 | npm run vitest -t ./tests/mysql2.test.ts 39 | } 40 | 41 | # Check if a variable is passed 42 | if [ "$1" = "express" ]; then 43 | npm run vitest -t ./tests/express.test.ts 44 | elif [ "$1" = "nextjs" ]; then 45 | # Run Next.js tests without setting up 46 | if [ "$2" = "--no-setup" ]; then 47 | npm run vitest -t ./tests/nextjs/nextjs.test.ts 48 | else 49 | runNextJsTests 50 | fi 51 | elif [ "$1" = "nestjs" ]; then 52 | runNestJsTests 53 | elif [ "$1" = "prisma" ]; then 54 | runPrismaTests 55 | elif [ "$1" = "mysql2" ]; then 56 | runAllTests 57 | else 58 | runAllTests 59 | fi -------------------------------------------------------------------------------- /src/OpenAPM.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import http from 'http'; 3 | import ResponseTime from 'response-time'; 4 | import promClient from 'prom-client'; 5 | import path from 'path'; 6 | 7 | import type { 8 | Counter, 9 | CounterConfiguration, 10 | Histogram, 11 | HistogramConfiguration 12 | } from 'prom-client'; 13 | import type { NextFunction, Request, Response } from 'express'; 14 | import type { IncomingMessage, ServerResponse, Server } from 'http'; 15 | 16 | import { getHostIpAddress, getPackageJson, getSanitizedPath } from './utils'; 17 | 18 | import { instrumentExpress } from './clients/express'; 19 | import { instrumentMySQL } from './clients/mysql2'; 20 | import { instrumentNestFactory } from './clients/nestjs'; 21 | import { instrumentNextjs } from './clients/nextjs'; 22 | 23 | import { LevitateConfig, LevitateEvents } from './levitate/events'; 24 | import { 25 | getHTTPRequestStore, 26 | runInHTTPRequestStore 27 | } from './async-local-storage.http'; 28 | 29 | export type ExtractFromParams = { 30 | from: 'params'; 31 | key: string; 32 | mask: string; 33 | }; 34 | 35 | export type DefaultLabels = 36 | | 'environment' 37 | | 'program' 38 | | 'version' 39 | | 'host'; 40 | 41 | export interface OpenAPMOptions { 42 | /** 43 | * Enable the OpenAPM 44 | */ 45 | enabled?: boolean; 46 | /** 47 | * Enable the metrics server 48 | * @default true 49 | */ 50 | enableMetricsServer?: boolean; 51 | /** Route where the metrics will be exposed 52 | * @default "/metrics" 53 | */ 54 | path?: string; 55 | /** Port for the metrics server 56 | * @default 9097 57 | */ 58 | metricsServerPort?: number; 59 | /** Application environment 60 | * @default 'production' 61 | */ 62 | environment?: string; 63 | /** Any default labels you want to include */ 64 | defaultLabels?: Record; 65 | /** Accepts configuration for Prometheus Counter */ 66 | requestsCounterConfig?: Omit, 'labelNames'>; 67 | /** Accepts configuration for Prometheus Histogram */ 68 | requestDurationHistogramConfig?: Omit< 69 | HistogramConfiguration, 70 | 'labelNames' 71 | >; 72 | /** Additional Labels for the HTTP requests */ 73 | additionalLabels?: Array; 74 | /** Extract labels from URL params, subdomain, header */ 75 | extractLabels?: Record; 76 | /** 77 | * @deprecated This option is deprecated and won't have any impact on masking the pathnames. 78 | * */ 79 | customPathsToMask?: Array; 80 | /** Skip mentioned labels */ 81 | excludeDefaultLabels?: Array; 82 | /** Levitate Config */ 83 | levitateConfig?: LevitateConfig; 84 | } 85 | 86 | export type SupportedModules = 'express' | 'mysql' | 'nestjs' | 'nextjs'; 87 | 88 | const moduleNames = { 89 | express: 'express', 90 | mysql: 'mysql2', 91 | nestjs: '@nestjs/core', 92 | nextjs: 'next' 93 | }; 94 | 95 | const packageJson = getPackageJson(); 96 | 97 | export class OpenAPM extends LevitateEvents { 98 | public simpleCache: Record = {}; 99 | private path: string; 100 | private metricsServerPort: number; 101 | private enabled: boolean; 102 | private enableMetricsServer: boolean; 103 | readonly environment: string; 104 | readonly program: string; 105 | private defaultLabels?: Record; 106 | readonly requestsCounterConfig: CounterConfiguration; 107 | readonly requestDurationHistogramConfig: HistogramConfiguration; 108 | readonly requestLabels: Array = []; 109 | private requestsCounter?: Counter; 110 | private requestsDurationHistogram?: Histogram; 111 | private extractLabels?: Record; 112 | private customPathsToMask?: Array; 113 | private excludeDefaultLabels?: Array; 114 | 115 | public metricsServer?: Server; 116 | 117 | constructor(options?: OpenAPMOptions) { 118 | super(options); 119 | // Initializing all the options 120 | this.enabled = options?.enabled ?? true; 121 | this.path = options?.path ?? '/metrics'; 122 | this.metricsServerPort = options?.metricsServerPort ?? 9097; 123 | this.enableMetricsServer = options?.enableMetricsServer ?? true; 124 | this.environment = options?.environment ?? 'production'; 125 | this.program = packageJson?.name ?? ''; 126 | this.defaultLabels = options?.defaultLabels; 127 | this.requestLabels = [ 128 | 'path', 129 | 'method', 130 | 'status', 131 | ...(options?.extractLabels ? Object.keys(options?.extractLabels) : []), 132 | ...(options?.additionalLabels ?? []) 133 | ]; 134 | this.requestsCounterConfig = this.setRequestCounterConfig(options); 135 | this.requestDurationHistogramConfig = 136 | this.setRequestDurationHistogramConfig(options); 137 | 138 | this.extractLabels = options?.extractLabels ?? {}; 139 | this.customPathsToMask = options?.customPathsToMask; 140 | this.excludeDefaultLabels = options?.excludeDefaultLabels; 141 | 142 | if (this.enabled) { 143 | this.initiateMetricsRoute(); 144 | this.initiatePromClient(); 145 | } 146 | } 147 | 148 | private setRequestCounterConfig = (options?: OpenAPMOptions) => { 149 | const { requestsCounterConfig, extractLabels } = options ?? {}; 150 | const defaultConfig = { 151 | name: 'http_requests_total', 152 | help: 'Total number of requests', 153 | labelNames: this.requestLabels 154 | }; 155 | 156 | // @ts-ignore 157 | const { labelNames: _, ...restConfig } = requestsCounterConfig ?? {}; 158 | 159 | return { 160 | ...defaultConfig, 161 | ...(restConfig ?? {}) 162 | }; 163 | }; 164 | 165 | private setRequestDurationHistogramConfig = (options?: OpenAPMOptions) => { 166 | const { requestDurationHistogramConfig, extractLabels } = options ?? {}; 167 | const defaultConfig = { 168 | name: 'http_requests_duration_milliseconds', 169 | help: 'Duration of HTTP requests in milliseconds', 170 | labelNames: this.requestLabels, 171 | buckets: promClient.exponentialBuckets(0.25, 1.5, 31) 172 | }; 173 | 174 | // @ts-ignore 175 | const { labelNames: _, ...restConfig } = 176 | requestDurationHistogramConfig ?? {}; 177 | 178 | return { 179 | ...defaultConfig, 180 | ...(restConfig ?? {}) 181 | }; 182 | }; 183 | 184 | private getDefaultLabels = () => { 185 | const defaultLabels = { 186 | environment: this.environment, 187 | program: packageJson?.name ?? '', 188 | version: packageJson?.version ?? '', 189 | host: os.hostname(), 190 | ...this.defaultLabels 191 | }; 192 | 193 | if (Array.isArray(this.excludeDefaultLabels)) { 194 | for (const label of this.excludeDefaultLabels) { 195 | Reflect.deleteProperty(defaultLabels, label); 196 | } 197 | } 198 | 199 | return defaultLabels; 200 | }; 201 | 202 | private initiatePromClient = () => { 203 | promClient.register.setDefaultLabels(this.getDefaultLabels()); 204 | 205 | promClient.collectDefaultMetrics({ 206 | gcDurationBuckets: this.requestDurationHistogramConfig.buckets 207 | }); 208 | 209 | // Initiate the Counter for the requests 210 | this.requestsCounter = new promClient.Counter(this.requestsCounterConfig); 211 | // Initiate the Duration Histogram for the requests 212 | this.requestsDurationHistogram = new promClient.Histogram( 213 | this.requestDurationHistogramConfig 214 | ); 215 | }; 216 | 217 | public shutdown = async () => { 218 | return new Promise((resolve, reject) => { 219 | if (!this.enabled) { 220 | resolve(undefined); 221 | } 222 | if (this.enableMetricsServer) { 223 | console.log('Shutting down metrics server gracefully.'); 224 | } 225 | this.metricsServer?.close((err) => { 226 | if (err) { 227 | reject(err); 228 | return; 229 | } 230 | 231 | resolve(undefined); 232 | console.log('Metrics server shut down gracefully.'); 233 | }); 234 | 235 | promClient.register.clear(); 236 | resolve(undefined); 237 | }); 238 | }; 239 | 240 | private initiateMetricsRoute = () => { 241 | // Enabling metrics server runs a separate process for the metrics server that a Prometheus agent can scrape. If it is not enabled, metrics are exposed in the same process as the web application. 242 | if (!this.enableMetricsServer) { 243 | return; 244 | } 245 | // Creating native http server 246 | this.metricsServer = http.createServer(async (req, res) => { 247 | // Sanitize the path 248 | const path = getSanitizedPath(req.url ?? '/'); 249 | if (path === this.path && req.method === 'GET') { 250 | res.setHeader('Content-Type', promClient.register.contentType); 251 | const metrics = await this.getMetrics(); 252 | return res.end(metrics); 253 | } else { 254 | res.statusCode = 404; 255 | res.end('404 Not found'); 256 | } 257 | }); 258 | 259 | // Start listening at the given port defaults to 9097 260 | this.metricsServer?.listen(this.metricsServerPort, () => { 261 | console.log(`Metrics server running at ${this.metricsServerPort}`); 262 | }); 263 | }; 264 | 265 | private parseLabelsFromParams = ( 266 | pathname: string, 267 | params?: Request['params'] 268 | ) => { 269 | const labels = {} as Record; 270 | let parsedPathname = pathname; 271 | if (typeof params === 'undefined' || params === null) { 272 | return { 273 | pathname, 274 | labels 275 | }; 276 | } 277 | // Get the label configs and filter it only for param values 278 | const configs = Object.keys(this.extractLabels ?? {}).map((labelName) => { 279 | return { 280 | ...this.extractLabels?.[labelName], 281 | label: labelName 282 | }; 283 | }); 284 | 285 | for (const item of configs) { 286 | if ( 287 | item.key && 288 | item.label && 289 | item.from === 'params' && 290 | params?.[item.key] 291 | ) { 292 | const labelValue = params[item.key]; 293 | const escapedLabelValue = labelValue.replace( 294 | /[.*+?^${}()|[\]\\]/g, 295 | '\\$&' 296 | ); 297 | const regex = new RegExp(escapedLabelValue, 'g'); 298 | 299 | // Replace the param with a generic mask that user has specified 300 | if (item.mask) { 301 | parsedPathname = parsedPathname.replace(regex, item.mask); 302 | } 303 | 304 | // Add the value to the label set 305 | labels[item.label] = escapedLabelValue; 306 | } 307 | } 308 | 309 | return { 310 | pathname: parsedPathname, 311 | labels 312 | }; 313 | }; 314 | 315 | /** 316 | * Middleware Function, which is essentially the response-time middleware with a callback that captures the 317 | * metrics 318 | */ 319 | 320 | private _REDMiddleware = ( 321 | req: Request, 322 | res: Response, 323 | next: NextFunction 324 | ) => { 325 | runInHTTPRequestStore(() => { 326 | ResponseTime( 327 | ( 328 | req: IncomingMessage & Request, 329 | res: ServerResponse, 330 | time: number 331 | ) => { 332 | if (!this.enabled) { 333 | return; 334 | } 335 | const store = getHTTPRequestStore(); 336 | const sanitizedPathname = getSanitizedPath(req.originalUrl ?? '/'); 337 | // Extract labels from the request params 338 | const { pathname, labels: parsedLabelsFromPathname } = 339 | this.parseLabelsFromParams(sanitizedPathname, req.params); 340 | 341 | // Skip the OPTIONS requests not to blow up cardinality. Express does not provide 342 | // information about the route for OPTIONS requests, which makes it very 343 | // hard to detect correct PATH. Until we fix it properly, the requests are skipped 344 | // to not blow up the cardinality. 345 | if (!req.route && req.method === 'OPTIONS') { 346 | return; 347 | } 348 | 349 | // Make sure you copy baseURL in case of nested routes. 350 | const path = req.route ? req.baseUrl + req.route?.path : pathname; 351 | 352 | const labels: Record = { 353 | path, 354 | status: res.statusCode.toString(), 355 | method: req.method as string, 356 | ...parsedLabelsFromPathname, 357 | ...(store?.labels ?? {}) 358 | }; 359 | 360 | // Create an array of arguments in the same sequence as label names 361 | const requestsCounterArgs = 362 | this.requestsCounterConfig.labelNames?.map((labelName) => { 363 | return labels[labelName] ?? ''; 364 | }); 365 | 366 | try { 367 | if (requestsCounterArgs) { 368 | this.requestsCounter?.inc(labels); 369 | this.requestsDurationHistogram?.observe(labels, time); 370 | // ?.labels(...requestsCounterArgs) 371 | } 372 | } catch (err) { 373 | console.error('OpenAPM:', err); 374 | } 375 | } 376 | )(req, res, next); 377 | }); 378 | }; 379 | 380 | /** 381 | * Middleware Function, which is essentially the response-time middleware with a callback that captures the 382 | * metrics 383 | * @deprecated 384 | */ 385 | public REDMiddleware = this._REDMiddleware; 386 | 387 | public getMetrics = async (): Promise => { 388 | let metrics = ''; 389 | if (!this.enabled) { 390 | return metrics; 391 | } 392 | if ( 393 | typeof this.simpleCache['prisma:installed'] === 'undefined' || 394 | this.simpleCache['prisma:installed'] 395 | ) { 396 | try { 397 | // TODO: Make prisma implementation more generic so that it can be used with other ORMs, DBs and libraries 398 | const { PrismaClient } = require('@prisma/client'); 399 | const prisma = new PrismaClient(); 400 | const prismaMetrics = prisma ? await prisma.$metrics.prometheus() : ''; 401 | metrics += prisma ? prismaMetrics : ''; 402 | 403 | this.simpleCache['prisma:installed'] = true; 404 | await prisma.$disconnect(); 405 | } catch (error) { 406 | this.simpleCache['prisma:installed'] = false; 407 | } 408 | } 409 | 410 | metrics += await promClient.register.metrics(); 411 | 412 | if (metrics.startsWith('"') && metrics.endsWith('"')) { 413 | metrics = metrics.slice(1, -1); 414 | } 415 | 416 | return metrics.trim(); 417 | }; 418 | 419 | public instrument(moduleName: SupportedModules): boolean { 420 | if (!this.enabled) { 421 | return false; 422 | } 423 | try { 424 | if (moduleName === 'express') { 425 | const express = require('express'); 426 | instrumentExpress(express, this._REDMiddleware, this); 427 | } 428 | if (moduleName === 'mysql') { 429 | const mysql2 = require('mysql2'); 430 | instrumentMySQL(mysql2); 431 | } 432 | if (moduleName === 'nestjs') { 433 | const { NestFactory } = require('@nestjs/core'); 434 | instrumentNestFactory(NestFactory, this._REDMiddleware); 435 | } 436 | if (moduleName === 'nextjs') { 437 | const nextServer = require(path.resolve( 438 | 'node_modules/next/dist/server/next-server.js' 439 | )); 440 | 441 | instrumentNextjs( 442 | nextServer.default, 443 | { 444 | getRequestMeta: require(path.resolve( 445 | 'node_modules/next/dist/server/request-meta.js' 446 | )).getRequestMeta 447 | }, 448 | { 449 | counter: this.requestsCounter, 450 | histogram: this.requestsDurationHistogram 451 | }, 452 | this 453 | ); 454 | } 455 | 456 | return true; 457 | } catch (error) { 458 | console.error('OpenAPM:', error); 459 | if (Object.keys(moduleNames).includes(moduleName)) { 460 | throw new Error( 461 | `OpenAPM couldn't import the ${moduleNames[moduleName]} package, please install it.` 462 | ); 463 | } else { 464 | throw new Error( 465 | `OpenAPM doesn't support the following module: ${moduleName}` 466 | ); 467 | } 468 | } 469 | } 470 | } 471 | 472 | export function getMetricClient() { 473 | return promClient; 474 | } 475 | 476 | export default OpenAPM; 477 | -------------------------------------------------------------------------------- /src/async-local-storage.http.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'async_hooks'; 2 | 3 | export type HTTPRequestStore = { 4 | labels: Record; 5 | }; 6 | 7 | export const asyncLocalStorage = new AsyncLocalStorage(); 8 | 9 | export const getHTTPRequestStore = () => { 10 | return asyncLocalStorage.getStore(); 11 | }; 12 | 13 | export const runInHTTPRequestStore = (fn: any) => { 14 | return asyncLocalStorage.run( 15 | { 16 | labels: {} 17 | }, 18 | fn 19 | ); 20 | }; 21 | 22 | export const setOpenAPMLabels = (labels: Record) => { 23 | const store = getHTTPRequestStore(); 24 | if (typeof store !== 'undefined') { 25 | store.labels = { 26 | ...store.labels, 27 | ...labels 28 | }; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/async-local-storage.ts: -------------------------------------------------------------------------------- 1 | import { AsyncLocalStorage } from 'node:async_hooks'; 2 | 3 | export const storeAsyncLocalStorageInGlobalThis = ( 4 | key: string, 5 | asyncLocalStorage: AsyncLocalStorage 6 | ) => { 7 | (globalThis as any)[key] = asyncLocalStorage; 8 | }; 9 | 10 | export const createAsyncLocalStorage = () => { 11 | return new AsyncLocalStorage(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/clients/express.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | import type * as Express from 'express'; 3 | import { isWrapped, wrap } from '../shimmer'; 4 | import type OpenAPM from '../OpenAPM'; 5 | import { Server } from 'http'; 6 | import { AsyncLocalStorage } from 'async_hooks'; 7 | import type { HTTPRequestStore } from '../async-local-storage.http'; 8 | 9 | export const instrumentExpress = ( 10 | express: typeof Express, 11 | redMiddleware: Express.RequestHandler, 12 | openapm: OpenAPM 13 | ) => { 14 | let redMiddlewareAdded = false; 15 | 16 | const routerProto = express.Router as unknown as Express.Router['prototype']; 17 | 18 | wrap(routerProto, 'use', (original) => { 19 | return function wrappedUse( 20 | this: typeof original, 21 | ...args: Parameters 22 | ) { 23 | if (!redMiddlewareAdded) { 24 | original.apply(this, [redMiddleware]); 25 | redMiddlewareAdded = true; 26 | } 27 | return original.apply(this, args); 28 | }; 29 | }); 30 | 31 | if (!isWrapped(express.application, 'listen')) { 32 | wrap( 33 | express.application, 34 | 'listen', 35 | function ( 36 | original: (typeof Express)['application']['listen']['prototype'] 37 | ) { 38 | return function ( 39 | this: typeof original, 40 | ...args: Parameters 41 | ) { 42 | openapm.emit('application_started', { 43 | timestamp: new Date().toISOString(), 44 | event_name: `${openapm.program}_app`, 45 | event_state: 'start', 46 | entity_type: 'app', 47 | workspace: os.hostname(), 48 | namespace: openapm.environment, 49 | data_source_name: openapm.levitateConfig?.dataSourceName ?? '' 50 | }); 51 | const server = original.apply(this, args) as Server; 52 | return server; 53 | }; 54 | } 55 | ); 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /src/clients/mysql2.ts: -------------------------------------------------------------------------------- 1 | import promClient, { Histogram } from 'prom-client'; 2 | import type { 3 | Connection, 4 | ConnectionConfig, 5 | Pool, 6 | PoolCluster, 7 | PoolConnection, 8 | createConnection, 9 | createPool, 10 | createPoolCluster 11 | } from 'mysql2'; 12 | import { maskValuesInSQLQuery } from '../utils'; 13 | 14 | interface Context { 15 | histogram: Histogram; 16 | database_name?: string; 17 | query?: string; 18 | } 19 | 20 | ////// Constants //////////////////////// 21 | export const symbols = { 22 | WRAP_CONNECTION: Symbol('WRAP_CONNECTION'), 23 | WRAP_POOL: Symbol('WRAP_POOL'), 24 | WRAP_GET_CONNECTION_CB: Symbol('WRAP_GET_CONNECTION_CB'), 25 | WRAP_POOL_CLUSTER: Symbol('WRAP_POOL_CLUSTER'), 26 | WRAP_POOL_CLUSTER_OF: Symbol('WRAP_POOL_CLUSTER_OF'), 27 | WRAP_QUERYABLE_CB: Symbol('WRAP_QUERYABLE_CB') 28 | }; 29 | 30 | ///////////////////////////////////////// 31 | 32 | //// Utils ///////////////////////////// 33 | 34 | function getConnectionConfig(poolConfig: { 35 | connectionConfig: ConnectionConfig; 36 | }): ConnectionConfig; 37 | function getConnectionConfig( 38 | connectionConfig: ConnectionConfig 39 | ): ConnectionConfig; 40 | function getConnectionConfig(config: any): ConnectionConfig { 41 | return config.connectionConfig ?? config; 42 | } 43 | 44 | ////////////////////////////////////// 45 | 46 | const wrapQueryableCB = ( 47 | cb: Parameters['2'], 48 | ctx: Context 49 | ) => { 50 | const end = ctx.histogram.startTimer({}); 51 | 52 | if (typeof cb === 'undefined') { 53 | return function ( 54 | this: Parameters['2'], 55 | ...args: Parameters['2']>> 56 | ) { 57 | end({ 58 | database_name: ctx.database_name, 59 | query: ctx.query, 60 | status: args[0] === null ? 'success' : 'failure' 61 | }); 62 | return; 63 | }; 64 | } 65 | 66 | return function ( 67 | this: Parameters['2'], 68 | ...args: Parameters['2']>> 69 | ) { 70 | end({ 71 | database_name: ctx.database_name, 72 | query: ctx.query, 73 | status: args[0] === null ? 'success' : 'failure' 74 | }); 75 | return cb.apply(this, args); 76 | }; 77 | }; 78 | 79 | /** 80 | * 81 | * @param fn queryable function that needs to be intercepted and instrumented 82 | * @param connectionConfig config for the connection/pool/pool cluster 83 | * @param metricRegisterFns array of functions that could be used to register metrics 84 | */ 85 | export function interceptQueryable( 86 | fn: Connection['query'], 87 | connectionConfig: 88 | | Connection['config'] 89 | | Pool['config'] 90 | | PoolCluster['config'], 91 | ctx: Context 92 | ): Connection['query']; 93 | export function interceptQueryable( 94 | fn: Connection['execute'], 95 | connectionConfig: 96 | | Connection['config'] 97 | | Pool['config'] 98 | | PoolCluster['config'], 99 | ctx: Context 100 | ): Connection['execute']; 101 | export function interceptQueryable( 102 | fn: any, 103 | connectionConfig: 104 | | Connection['config'] 105 | | Pool['config'] 106 | | PoolCluster['config'], 107 | ctx: Context 108 | ): any { 109 | return function ( 110 | this: Connection['query'] | Connection['execute'], 111 | ...args: Parameters 112 | ) { 113 | const lastArgIndex = args.length - 1; 114 | const dbName = 115 | getConnectionConfig(connectionConfig as any).database ?? '[db-name]'; 116 | 117 | const query = maskValuesInSQLQuery( 118 | typeof args[0] === 'string' ? args[0] : args[0].sql 119 | ).substring(0, 100); 120 | 121 | const hasCallback = 122 | typeof args[lastArgIndex] !== 'string' && 123 | typeof args[lastArgIndex] !== 'object'; 124 | 125 | args[hasCallback ? lastArgIndex : 1] = wrapQueryableCB( 126 | hasCallback ? args[lastArgIndex] : undefined, 127 | { 128 | ...ctx, 129 | database_name: dbName, 130 | query 131 | } 132 | ); 133 | 134 | return fn.apply(this, args) as ReturnType; 135 | }; 136 | } 137 | 138 | /** 139 | * The function will get the prototype of the connection object and mutate the values of queryable 140 | * with the intercepted versions of them 141 | * 142 | * @param connection Connection object that contains queryables 143 | * @param metricRegisterFns 144 | * @returns Returns wrapped connection 145 | */ 146 | export const wrapConnection = ( 147 | connection: Connection | PoolConnection, 148 | ctx: { 149 | histogram: Histogram; 150 | } 151 | ): Connection | PoolConnection => { 152 | // Get ProtoType for the connection 153 | const connectionProto = Object.getPrototypeOf(connection); 154 | if (!connectionProto?.[symbols.WRAP_CONNECTION]) { 155 | /** 156 | * Intercept the query Function 157 | */ 158 | connectionProto.query = interceptQueryable( 159 | connection.query, 160 | connection.config, 161 | ctx 162 | ); 163 | /** 164 | * Intercept only if the execute is available 165 | */ 166 | if (typeof connection.execute !== 'undefined') { 167 | connectionProto.execute = interceptQueryable( 168 | connection.execute, 169 | connection.config, 170 | ctx 171 | ); 172 | } 173 | /** 174 | * This is to make sure we are only wrapping the connection once 175 | */ 176 | connectionProto[symbols.WRAP_CONNECTION] = true; 177 | } 178 | return connection; 179 | }; 180 | 181 | export const wrapPoolGetConnectionCB = ( 182 | cb: Parameters['0'], 183 | ctx: Context 184 | ): Parameters['0'] => { 185 | return function (this: Parameters['0'], ...args) { 186 | const wrappedConn = wrapConnection(args[1], ctx) as PoolConnection; 187 | return cb.apply(this, [args[0], wrappedConn]); 188 | }; 189 | }; 190 | 191 | export const wrapPoolGetConnection = ( 192 | getConnectionFn: Pool['getConnection'], 193 | ctx: Context 194 | ) => { 195 | return function ( 196 | this: Pool['getConnection'], 197 | ...args: Parameters 198 | ) { 199 | const getConnectionFnProto = Object.getPrototypeOf(getConnectionFn); 200 | if ( 201 | !getConnectionFnProto?.[symbols.WRAP_GET_CONNECTION_CB] && 202 | typeof args[0] !== 'undefined' 203 | ) { 204 | args[0] = wrapPoolGetConnectionCB(args[0], ctx); 205 | getConnectionFnProto[symbols.WRAP_GET_CONNECTION_CB] = true; 206 | } 207 | return getConnectionFn.apply(this, args); 208 | }; 209 | }; 210 | 211 | export const wrapPoolClusterOfFn = ( 212 | of: PoolCluster['of'], 213 | poolClusterConfig: PoolCluster['config'], 214 | ctx: Context 215 | ) => { 216 | return function ( 217 | this: PoolCluster['of'], 218 | ...args: Parameters 219 | ) { 220 | const poolNamespace = of.apply(this, args); 221 | const poolNamespaceProto = Object.getPrototypeOf(poolNamespace); 222 | if (!poolNamespaceProto?.[symbols.WRAP_POOL_CLUSTER_OF]) { 223 | poolNamespaceProto.query = interceptQueryable( 224 | poolNamespace.query, 225 | poolClusterConfig, 226 | ctx 227 | ); 228 | 229 | if (typeof poolNamespace.execute !== 'undefined') { 230 | poolNamespaceProto.execute = interceptQueryable( 231 | poolNamespace.execute, 232 | poolClusterConfig, 233 | ctx 234 | ); 235 | } 236 | 237 | poolNamespaceProto.getConnection = wrapPoolGetConnection( 238 | poolNamespace['getConnection'], 239 | ctx 240 | ); 241 | 242 | poolNamespaceProto[symbols.WRAP_POOL_CLUSTER_OF] = true; 243 | } 244 | return poolNamespace; 245 | }; 246 | }; 247 | 248 | /** 249 | * This function will get the proto type of the pool and intercept the queryable functions. 250 | * It will also wrap getConnection function of the pool so that it can wrap the callback function which consists of the db connection. 251 | * @param pool MySQL Pool 252 | * @param metricRegisterFns 253 | * @returns MySQL Pool 254 | */ 255 | export const wrapPool = ( 256 | pool: Pool, 257 | ctx: { 258 | histogram: Histogram; 259 | } 260 | ) => { 261 | const poolProto = Object.getPrototypeOf(pool); 262 | if (!poolProto?.[symbols.WRAP_POOL]) { 263 | poolProto.query = interceptQueryable(pool.query, pool.config, ctx); 264 | 265 | if (typeof pool.execute !== 'undefined') { 266 | poolProto.execute = interceptQueryable(pool.execute, pool.config, ctx); 267 | } 268 | 269 | poolProto.getConnection = wrapPoolGetConnection(pool['getConnection'], ctx); 270 | 271 | poolProto[symbols.WRAP_POOL] = true; 272 | } 273 | 274 | return pool; 275 | }; 276 | 277 | export const wrapPoolCluster = (poolCluster: PoolCluster, ctx: Context) => { 278 | let poolClusterProto = Object.getPrototypeOf(poolCluster); 279 | if (!poolClusterProto?.[symbols.WRAP_POOL_CLUSTER]) { 280 | poolClusterProto.of = wrapPoolClusterOfFn( 281 | poolCluster.of, 282 | poolCluster.config, 283 | ctx 284 | ); 285 | poolClusterProto[symbols.WRAP_POOL_CLUSTER] = true; 286 | } 287 | return poolCluster; 288 | }; 289 | 290 | export const instrumentMySQL = (mysql: { 291 | createConnection: typeof createConnection; 292 | createPool: typeof createPool; 293 | createPoolCluster: typeof createPoolCluster; 294 | }) => { 295 | // Default histogram metrics 296 | const histogram = new promClient.Histogram({ 297 | name: 'db_requests_duration_milliseconds', 298 | help: 'Duration of DB transactions in milliseconds', 299 | labelNames: ['database_name', 'query', 'status'], 300 | buckets: promClient.exponentialBuckets(0.25, 1.5, 31) 301 | }); 302 | 303 | /** 304 | * Create Proxy for the createConnection where we will wrap the connection 305 | * to intercept the query 306 | * */ 307 | mysql.createConnection = new Proxy(mysql.createConnection, { 308 | apply: (target, prop, args) => { 309 | const connection = Reflect.apply(target, prop, args); 310 | // Instrument Connection 311 | return wrapConnection(connection, { 312 | histogram 313 | }); 314 | } 315 | }); 316 | 317 | /** 318 | * Create Proxy for the createPool where we will wrap the connection 319 | * to intercept the query 320 | * */ 321 | mysql.createPool = new Proxy(mysql.createPool, { 322 | apply: (target, prop, args) => { 323 | const pool = Reflect.apply(target, prop, args); 324 | // Instrument Pool 325 | 326 | return wrapPool(pool, { 327 | histogram 328 | }); 329 | } 330 | }); 331 | 332 | /** 333 | * Create Proxy for the createPoolCluster where we will wrap the connection 334 | * to intercept the query 335 | * */ 336 | mysql.createPoolCluster = new Proxy(mysql.createPoolCluster, { 337 | apply: (target, prop, args) => { 338 | const poolCluster = Reflect.apply(target, prop, args); 339 | // Instrument poolCluster 340 | return wrapPoolCluster(poolCluster, { 341 | histogram 342 | }); 343 | } 344 | }); 345 | }; 346 | -------------------------------------------------------------------------------- /src/clients/nestjs.ts: -------------------------------------------------------------------------------- 1 | import type { NestFactoryStatic } from '@nestjs/core/nest-factory'; 2 | import { isWrapped, wrap } from '../shimmer'; 3 | 4 | export const instrumentNestFactory = ( 5 | nestFactory: NestFactoryStatic, 6 | redMiddleware: Function 7 | ) => { 8 | // Check if the NestFactory is already wrapped 9 | if (!isWrapped(nestFactory, 'create')) { 10 | // Wrap using the wrapper function 11 | wrap( 12 | nestFactory, 13 | 'create', 14 | function (original: NestFactoryStatic['create']) { 15 | return async function (this: NestFactoryStatic['create'], ...args) { 16 | const app = await original.apply( 17 | this, 18 | args as Parameters 19 | ); 20 | // Add a global RED Middleware to the application 21 | app.use(redMiddleware); 22 | return app; 23 | }; 24 | } 25 | ); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/clients/nextjs.ts: -------------------------------------------------------------------------------- 1 | import type NextNodeServer from 'next/dist/server/next-server'; 2 | import type { 3 | NextIncomingMessage, 4 | RequestMeta 5 | } from 'next/dist/server/request-meta'; 6 | 7 | import prom, { Counter, Histogram } from 'prom-client'; 8 | import { wrap } from '../shimmer'; 9 | import OpenAPM from '../OpenAPM'; 10 | 11 | interface NextUtilities { 12 | getRequestMeta: ( 13 | req: NextIncomingMessage, 14 | key?: K 15 | ) => RequestMeta[K] | RequestMeta; 16 | } 17 | 18 | export const instrumentNextjs = ( 19 | nextServer: typeof NextNodeServer, 20 | nextUtilities: NextUtilities, 21 | { counter, histogram }: { counter?: Counter; histogram?: Histogram }, 22 | openapm: OpenAPM 23 | ) => { 24 | const { getRequestMeta } = nextUtilities; 25 | 26 | if (typeof counter === 'undefined') { 27 | counter = new prom.Counter(openapm.requestsCounterConfig); 28 | } 29 | 30 | if (typeof histogram === 'undefined') { 31 | histogram = new prom.Histogram(openapm.requestDurationHistogramConfig); 32 | } 33 | 34 | const wrappedHandler = ( 35 | handler: ReturnType 36 | ) => { 37 | return async ( 38 | ...args: Parameters> 39 | ) => { 40 | const [req, res] = args; 41 | const start = process.hrtime.bigint(); 42 | 43 | const result = handler(...args); 44 | if (result instanceof Promise) { 45 | await result; 46 | } 47 | const end = process.hrtime.bigint(); 48 | const duration = Number(end - start) / 1e6; 49 | const requestMetaMatch = getRequestMeta( 50 | req, 51 | 'match' 52 | ) as RequestMeta['match']; 53 | const parsedPath = requestMetaMatch?.definition.pathname; 54 | 55 | if ( 56 | parsedPath && 57 | !parsedPath.startsWith('/_next/static/') && 58 | !parsedPath.startsWith('/favicon.ico') 59 | ) { 60 | counter?.inc({ 61 | path: parsedPath !== '' ? parsedPath : '/', 62 | method: req.method ?? 'GET', 63 | status: res.statusCode?.toString() ?? '500' 64 | // ...(store?.labels ?? {}) -> // TODO: Implement dynamic labels 65 | }); 66 | 67 | histogram?.observe( 68 | { 69 | path: parsedPath !== '' ? parsedPath : '/', 70 | method: req.method ?? 'GET', 71 | status: res.statusCode?.toString() ?? '500' 72 | // ...(store?.labels ?? {}) -> // TODO: Implement dynamic labels 73 | }, 74 | duration 75 | ); 76 | } 77 | 78 | return result; 79 | }; 80 | }; 81 | 82 | wrap(nextServer.prototype, 'getRequestHandler', function (original) { 83 | return function ( 84 | this: NextNodeServer['getRequestHandler'], 85 | ...args: Parameters 86 | ) { 87 | const handler = original.apply(this, args) as ReturnType< 88 | NextNodeServer['getRequestHandler'] 89 | >; 90 | return wrappedHandler(handler); 91 | }; 92 | }); 93 | }; 94 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as OpenAPM, getMetricClient } from './OpenAPM'; 2 | export { setOpenAPMLabels } from './async-local-storage.http'; 3 | export type { OpenAPMOptions } from './OpenAPM'; 4 | -------------------------------------------------------------------------------- /src/levitate/events.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | import chalk from 'chalk'; 3 | import type { OpenAPMOptions } from '../OpenAPM'; 4 | import { request } from 'undici'; 5 | 6 | export interface LevitateConfig { 7 | host?: string; 8 | orgSlug: string; 9 | dataSourceName: string; 10 | refreshTokens: { 11 | write: string; 12 | }; 13 | } 14 | 15 | export interface DomainEventsBody { 16 | [key: string]: any; 17 | event_name: string; 18 | event_state: 'start' | 'stop'; 19 | workspace?: string; 20 | namespace?: string; 21 | entity_type?: string; 22 | data_source_name: string; 23 | } 24 | 25 | const defaultHost = 'https://app.last9.io'; 26 | 27 | export class LevitateEvents extends EventEmitter { 28 | private eventsUrl: URL; 29 | readonly levitateConfig?: LevitateConfig; 30 | constructor(options?: OpenAPMOptions) { 31 | super(); 32 | this.levitateConfig = options?.levitateConfig; 33 | this.eventsUrl = new URL( 34 | `/api/v4/organizations/${this.levitateConfig?.orgSlug}/domain_events`, 35 | this.levitateConfig?.host ?? defaultHost 36 | ); 37 | this.initiateEventListeners(); 38 | } 39 | 40 | // Making the emit and on methods type safe 41 | public emit( 42 | event: 'application_started', 43 | ...args: (DomainEventsBody | any)[] 44 | ): boolean; 45 | public emit(event: any, ...args: any[]): any { 46 | return super.emit(event, ...args); 47 | } 48 | 49 | public on( 50 | event: 'application_started', 51 | listener: (...args: (DomainEventsBody | any)[]) => void 52 | ): this; 53 | public on(event: any, listener: (...args: any[]) => void): this { 54 | return super.on(event, listener); 55 | } 56 | 57 | public once( 58 | event: 'application_started', 59 | listener: (...args: (DomainEventsBody | any)[]) => void 60 | ): this; 61 | public once(event: any, listener: (...args: any[]) => void): this { 62 | return super.on(event, listener); 63 | } 64 | 65 | private initiateEventListeners() { 66 | if (typeof this.levitateConfig?.refreshTokens?.write === 'string') { 67 | console.log( 68 | chalk.green(`\nYou've enabled Events powered by Levitate 🚀`) 69 | ); 70 | console.log( 71 | 'For more info checkout https://docs.last9.io/change-events\n' 72 | ); 73 | this.once('application_started', this.putDomainEvents); 74 | } 75 | } 76 | 77 | private generateAccessToken = async () => { 78 | const endpoint = '/api/v4/oauth/access_token'; 79 | const url = new URL(endpoint, this.levitateConfig?.host ?? defaultHost); 80 | 81 | return request(url.toString(), { 82 | method: 'POST', 83 | body: JSON.stringify({ 84 | refresh_token: this.levitateConfig?.refreshTokens.write ?? '' 85 | }) 86 | }) 87 | .then((response) => { 88 | return response.body.json(); 89 | }) 90 | .catch((error) => { 91 | console.log(error); 92 | return; 93 | }); 94 | }; 95 | 96 | private async putDomainEvents(body: DomainEventsBody) { 97 | if (!!body) { 98 | try { 99 | const tokenResponse = (await this.generateAccessToken()) as 100 | | { access_token: string } 101 | | undefined; 102 | await request(this.eventsUrl.toString(), { 103 | method: 'PUT', 104 | headers: { 105 | 'Content-Type': 'application/json', 106 | 'X-LAST9-API-TOKEN': `Bearer ${tokenResponse?.access_token}` 107 | }, 108 | body: JSON.stringify(body) 109 | }); 110 | } catch (error) { 111 | console.log(error); 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/levitate/tokenHelpers.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/last9/openapm-nodejs/6b0ded711b972cfe310568f5250b037bd8863ebd/src/levitate/tokenHelpers.ts -------------------------------------------------------------------------------- /src/shimmer.ts: -------------------------------------------------------------------------------- 1 | export const defineProperty = < 2 | V extends any, 3 | N extends string | number | symbol 4 | >( 5 | object: Record, 6 | name: N, 7 | value: V 8 | ) => { 9 | const enumerable = 10 | !!object[name] && 11 | typeof object === 'object' && 12 | object !== null && 13 | object.propertyIsEnumerable(name); 14 | 15 | Object.defineProperty(object, name, { 16 | enumerable, 17 | value, 18 | configurable: true, 19 | writable: true 20 | }); 21 | }; 22 | 23 | export const symbols = { 24 | WRAPPED: Symbol('WRAPPED'), // Symbol indicating that a function or property has been wrapped. 25 | ORIGINAL: Symbol('ORIGINAL'), // Symbol used to store the original version of the function or property prior to wrapping. 26 | UNWRAP: Symbol('UNWRAP') // Symbol pointing to a function that undoes the wrap, restoring the original function or property. 27 | }; 28 | 29 | export function isWrapped< 30 | T extends Record, 31 | K extends keyof T 32 | >(nodule: T, name: K) { 33 | const original = nodule?.[name]; 34 | return original.hasOwnProperty(symbols.WRAPPED); 35 | } 36 | 37 | /** 38 | * @description Wraps a property (typically a function) of a given object (nodule) with a provided wrapper function. 39 | * 40 | * @param {T} nodule - The object containing the property to be wrapped. 41 | * @param {K} name - The key of the property to be wrapped. 42 | * @param {(original: T[K]) => T[K]} wrapper - The function that will be used to wrap the original property. 43 | * 44 | * @returns {T[K]} The wrapped function. 45 | */ 46 | export function wrap< 47 | T extends Record, 48 | K extends keyof T 49 | >(nodule: T, name: K, wrapper: (original: T[K]) => T[K]) { 50 | // Retrieve the original property from the object using the given key. 51 | const original = nodule?.[name]; 52 | 53 | // If the object doesn't exist or doesn't have the given property, log an error and exit. 54 | if (name && (!nodule || !original)) { 55 | console.error(`Function ${String(name)} does not exists`); 56 | return; 57 | } 58 | 59 | // If neither the wrapper nor the original property is a function, log an error and exit. 60 | if (typeof wrapper !== 'function' && typeof original !== 'function') { 61 | console.error( 62 | 'The wrapper and the original object property must be a function' 63 | ); 64 | return; 65 | } 66 | 67 | // Create the wrapped function by invoking the wrapper with the original function. 68 | const wrappedFn = wrapper(original); 69 | 70 | // Add a property to the wrapped function to store the original function for later reference. 71 | defineProperty(wrappedFn, symbols.ORIGINAL, original); 72 | 73 | // Add a property to the wrapped function that allows the object to be restored to its original state. 74 | defineProperty(wrappedFn, symbols.UNWRAP, () => { 75 | if (nodule[name] === wrappedFn) { 76 | defineProperty(nodule, symbols.WRAPPED, false); 77 | defineProperty(nodule, name, original); 78 | } 79 | }); 80 | 81 | // Mark the wrapped function as wrapped. 82 | defineProperty(wrappedFn, symbols.WRAPPED, true); 83 | // Replace the original property on the object with the wrapped function. 84 | defineProperty(nodule, name, wrappedFn); 85 | 86 | return wrappedFn; 87 | } 88 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | 5 | export const getPackageJson = () => { 6 | const packageJsonPath = path.join(process.cwd(), 'package.json'); 7 | try { 8 | const packageJson = fs.readFileSync(packageJsonPath, 'utf-8'); 9 | return JSON.parse(packageJson); 10 | } catch (error) { 11 | console.error('Error parsing package.json'); 12 | return null; 13 | } 14 | }; 15 | 16 | export const getHostIpAddress = () => { 17 | const networkInterfaces = os.networkInterfaces(); 18 | 19 | // Iterate over network interfaces to find a non-internal IPv4 address 20 | for (const interfaceName in networkInterfaces) { 21 | const interfaces = networkInterfaces[interfaceName]; 22 | if (typeof interfaces !== 'undefined') { 23 | for (const iface of interfaces) { 24 | // Skip internal and non-IPv4 addresses 25 | if (!iface.internal && iface.family === 'IPv4') { 26 | return iface.address; 27 | } 28 | } 29 | } 30 | } 31 | 32 | // Return null if no IP address is found 33 | return null; 34 | }; 35 | 36 | export const getSanitizedPath = (pathname: string) => { 37 | /** 38 | * Regex will remove any hashes or the search param in the pathname 39 | * @example /foo?bar=zar -> /foo 40 | * @example /foo#intro -> /foo 41 | * @example /foo?lorem=ipsum&bar=zar -> /foo 42 | */ 43 | const sanitizedPath = pathname.replace( 44 | /(\/[^?#]+)(?:\?[^#]*)?(?:#.*)?$/, 45 | '$1' 46 | ); 47 | return sanitizedPath; 48 | }; 49 | 50 | export const maskValuesInSQLQuery = (query: string) => { 51 | let counter = 1; 52 | // Regular expression to match strings and numbers. 53 | // Assumes strings are wrapped with single quotes. 54 | const regex = /'[^']*'|(\b\d+\b)/g; 55 | 56 | return query.replace(regex, (match) => { 57 | // If the match is a number or a string, replace it. 58 | if (match.match(/^\d+$/) || match.startsWith("'")) { 59 | return `$${counter++}`; 60 | } 61 | // If not, return the original match (should not occur with the current regex) 62 | return match; 63 | }); 64 | }; 65 | 66 | export const isCJS = () => { 67 | return typeof exports === 'object' && typeof module !== 'undefined'; 68 | }; 69 | -------------------------------------------------------------------------------- /tests/enabled.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, beforeAll, expect } from 'vitest'; 2 | import { OpenAPM } from '../src/OpenAPM'; 3 | 4 | describe('Enabled Option', () => { 5 | let openapm: OpenAPM; 6 | beforeAll(async () => { 7 | openapm = new OpenAPM({ 8 | enabled: false 9 | }); 10 | }); 11 | 12 | test('metricsServer', async () => { 13 | expect(openapm.metricsServer).toBeUndefined(); 14 | }); 15 | test('instrument', async () => { 16 | expect(openapm.instrument('express')).toBe(false); 17 | }); 18 | test('getMetrics', async () => { 19 | expect(await openapm.getMetrics()).toBe(''); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/express.test.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import { test, expect, describe, beforeAll, afterAll } from 'vitest'; 3 | 4 | import OpenAPM, { getMetricClient } from '../src/OpenAPM'; 5 | import { addRoutes, makeRequest, sendTestRequests } from './utils'; 6 | import prom from 'prom-client'; 7 | 8 | describe('REDMiddleware', () => { 9 | const NUMBER_OF_REQUESTS = 300; 10 | const MEANING_OF_LIFE = 42; 11 | let openapm: OpenAPM; 12 | let app: Express; 13 | 14 | const getMetrics = async () => { 15 | const client = getMetricClient(); 16 | const parsedData = await client.register.getMetricsAsJSON(); 17 | return parsedData; 18 | }; 19 | 20 | beforeAll(async () => { 21 | openapm = new OpenAPM({ 22 | additionalLabels: ['id'] 23 | }); 24 | openapm.instrument('express'); 25 | 26 | app = express(); 27 | 28 | addRoutes(app); 29 | app.listen(3002); 30 | 31 | const out = await sendTestRequests(app, NUMBER_OF_REQUESTS); 32 | }); 33 | 34 | afterAll(async () => { 35 | openapm.metricsServer?.close(() => { 36 | console.log('Closing the metrics server'); 37 | prom.register.clear(); 38 | }); 39 | }); 40 | 41 | test; 42 | 43 | test('Captures Counter Metrics - App', async () => { 44 | const parsedData = await getMetrics(); 45 | 46 | expect( 47 | parsedData.find((m) => m.name === 'http_requests_total')?.values[0].value 48 | ).toBe(NUMBER_OF_REQUESTS); 49 | }); 50 | 51 | test('Captures Custom Counter Metric - App', async () => { 52 | const parsedData = await getMetrics(); 53 | 54 | const customCounterMetric = parsedData.find( 55 | (m) => m.name === 'custom_counter_total' 56 | )?.values[0]; 57 | 58 | expect(customCounterMetric?.value).toBe(NUMBER_OF_REQUESTS); 59 | 60 | const labels = customCounterMetric?.labels; 61 | // { 62 | // service: 'express', 63 | // environment: 'production', 64 | // program: '@last9/openapm', 65 | // version: '0.9.3-alpha', 66 | // host: 'Adityas-MacBook-Pro-2.local', 67 | // ip: '192.168.1.110' 68 | // } 69 | 70 | expect(labels?.service).toBe('express'); 71 | expect(labels?.environment).toBe('production'); 72 | expect(labels?.program).toBe('@last9/openapm'); 73 | }); 74 | 75 | test('Captures Custom Gauge Metric - App', async () => { 76 | const parsedData = await getMetrics(); 77 | 78 | const customGaugeMetric = parsedData.find((m) => m.name === 'custom_gauge') 79 | ?.values[0]; 80 | 81 | expect(customGaugeMetric?.value).toBe(MEANING_OF_LIFE); 82 | 83 | const labels = customGaugeMetric?.labels; 84 | 85 | // { 86 | // environment: 'production', 87 | // program: '@last9/openapm', 88 | // version: '0.9.3-alpha', 89 | // host: 'Adityas-MacBook-Pro-2.local', 90 | // ip: '192.168.1.110' 91 | // } 92 | 93 | expect(labels?.environment).toBe('production'); 94 | expect(labels?.program).toBe('@last9/openapm'); 95 | }); 96 | 97 | test('Captures Counter Metrics - Router', async () => { 98 | const parsedData = await getMetrics(); 99 | const metric = parsedData.find((m) => m.name === 'http_requests_total'); 100 | 101 | expect( 102 | metric?.values.find((m) => m.labels.path === '/api/router/:id')?.value 103 | ).toBe(1); 104 | }); 105 | 106 | test('Captures Histogram Metrics', async () => { 107 | const parsedData = await getMetrics(); 108 | 109 | const metric = parsedData.find( 110 | (m) => m.name === 'http_requests_duration_milliseconds' 111 | ); 112 | 113 | expect(metric?.values?.length && metric.values.length > 0).toBe(true); 114 | }); 115 | 116 | test('Masks the path - App', async () => { 117 | const parsedData = await getMetrics(); 118 | 119 | const metric = parsedData.find((m) => m.name === 'http_requests_total'); 120 | 121 | expect(metric?.values[0].labels.path).match(/api(?:\/router)?\/:id/); 122 | }); 123 | 124 | test('Masks the path - Router', async () => { 125 | const client = getMetricClient(); 126 | const parsedData = await client.register.getMetricsAsJSON(); 127 | const metric = parsedData.find((m) => m.name === 'http_requests_total'); 128 | 129 | expect(metric?.values[1].labels.path).match(/api(?:\/router)?\/:id/); 130 | }); 131 | 132 | test('Captures Dynamic Labels', async () => { 133 | await makeRequest(app, '/api/labels/123'); 134 | // @ts-ignore 135 | const parsedData = await getMetrics(); 136 | 137 | const metricValues = parsedData?.find( 138 | (m) => m.name === 'http_requests_total' 139 | )?.values; 140 | 141 | expect( 142 | metricValues?.find((m) => m.labels.path === '/api/labels/:id')?.labels.id 143 | ).toBe('123'); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /tests/mysql2.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, expect, test, afterAll } from 'vitest'; 2 | import mysql2, { Connection, Pool, PoolCluster } from 'mysql2'; 3 | import { instrumentMySQL, symbols } from '../src/clients/mysql2'; 4 | import prom, { Histogram } from 'prom-client'; 5 | 6 | const connectionUri = 'mysql://root@localhost:3306/test_db'; 7 | 8 | const sendTestRequest = async (conn: Connection | Pool, query: string) => { 9 | return new Promise((resolve) => { 10 | conn.query(query, () => { 11 | resolve(true); 12 | }); 13 | }); 14 | }; 15 | 16 | const performUpMigration = async (conn: Connection) => { 17 | return new Promise((resolve, reject) => { 18 | conn.query( 19 | 'CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255));', 20 | () => { 21 | resolve(true); 22 | } 23 | ); 24 | }); 25 | }; 26 | 27 | const performDownMigration = (conn: Connection) => { 28 | return new Promise((resolve, reject) => { 29 | conn.query('DROP TABLE users;', () => { 30 | resolve(true); 31 | }); 32 | }); 33 | }; 34 | 35 | const promisifyCreateConnection = async (): Promise => { 36 | return new Promise((resolve) => { 37 | const conn = mysql2.createConnection(connectionUri); 38 | resolve(conn); 39 | }); 40 | }; 41 | 42 | const promisifyCreatePool = async (): Promise => { 43 | return new Promise((resolve) => { 44 | const pool = mysql2.createPool(connectionUri); 45 | resolve(pool); 46 | }); 47 | }; 48 | 49 | const promisifyCreatePoolCluster = async (): Promise => { 50 | return new Promise((resolve) => { 51 | const poolCluster = mysql2.createPoolCluster(); 52 | resolve(poolCluster); 53 | }); 54 | }; 55 | 56 | describe('mysql2', () => { 57 | let conn: Connection, pool: Pool, poolCluster: PoolCluster; 58 | 59 | beforeAll(async () => { 60 | instrumentMySQL(mysql2); 61 | 62 | conn = await promisifyCreateConnection(); 63 | pool = await promisifyCreatePool(); 64 | poolCluster = await promisifyCreatePoolCluster(); 65 | 66 | await performUpMigration(conn); 67 | }); 68 | 69 | afterAll(async () => { 70 | await performDownMigration(conn); 71 | conn.end(); 72 | prom.register.clear(); 73 | }); 74 | 75 | test('Connection - Wrapped?', () => { 76 | expect(conn[symbols.WRAP_CONNECTION]).toBe(true); 77 | }); 78 | 79 | test('Connection - query success?', async () => { 80 | const NUMBER_OF_REQUESTS = 5; 81 | for (let i = 0; i < NUMBER_OF_REQUESTS; i++) { 82 | await sendTestRequest(conn, 'SELECT * FROM users;'); 83 | } 84 | const histogram = prom.register.getSingleMetric( 85 | 'db_requests_duration_milliseconds' 86 | ) as Histogram; 87 | 88 | expect( 89 | // @ts-ignore 90 | histogram.hashMap[ 91 | 'database_name:test_db,query:SELECT * FROM users;,status:success,' 92 | ]?.count 93 | ).toBe(NUMBER_OF_REQUESTS); 94 | }); 95 | 96 | test('Connection - query failure?', async () => { 97 | const NUMBER_OF_REQUESTS = 5; 98 | for (let i = 0; i < NUMBER_OF_REQUESTS; i++) { 99 | await sendTestRequest(conn, 'SELECT * FROM user;'); 100 | } 101 | const histogram = prom.register.getSingleMetric( 102 | 'db_requests_duration_milliseconds' 103 | ) as Histogram; 104 | 105 | expect( 106 | // @ts-ignore 107 | histogram.hashMap[ 108 | 'database_name:test_db,query:SELECT * FROM user;,status:failure,' 109 | ]?.count 110 | ).toBe(NUMBER_OF_REQUESTS); 111 | }); 112 | 113 | test('Pool - Wrapped?', () => { 114 | expect(pool[symbols.WRAP_POOL]).toBe(true); 115 | }); 116 | 117 | test('Pool - query success?', async () => { 118 | const NUMBER_OF_REQUESTS = 5; 119 | for (let i = 0; i < NUMBER_OF_REQUESTS; i++) { 120 | await sendTestRequest(pool, 'SELECT * FROM users;'); 121 | } 122 | const histogram = prom.register.getSingleMetric( 123 | 'db_requests_duration_milliseconds' 124 | ) as Histogram; 125 | 126 | expect( 127 | // @ts-ignore 128 | histogram.hashMap[ 129 | 'database_name:test_db,query:SELECT * FROM users;,status:success,' 130 | ]?.count 131 | ).toBe(NUMBER_OF_REQUESTS * 2); 132 | }); 133 | 134 | test('Pool - query failure?', async () => { 135 | const NUMBER_OF_REQUESTS = 5; 136 | for (let i = 0; i < NUMBER_OF_REQUESTS; i++) { 137 | await sendTestRequest(pool, 'SELECT * FROM user;'); 138 | } 139 | const histogram = prom.register.getSingleMetric( 140 | 'db_requests_duration_milliseconds' 141 | ) as Histogram; 142 | 143 | expect( 144 | // @ts-ignore 145 | histogram.hashMap[ 146 | 'database_name:test_db,query:SELECT * FROM users;,status:success,' 147 | ]?.count 148 | ).toBe(NUMBER_OF_REQUESTS * 2); 149 | }); 150 | 151 | test('PoolCluster - Wrapped?', () => { 152 | expect(poolCluster[symbols.WRAP_POOL_CLUSTER]).toBe(true); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /tests/nestjs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /tests/nestjs/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /tests/nestjs/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/nestjs/nestjs.test.ts: -------------------------------------------------------------------------------- 1 | import { test, describe, beforeAll, afterAll, expect } from 'vitest'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { INestApplication } from '@nestjs/common'; 4 | import request from 'supertest'; 5 | import { AppModule } from './src/app.module'; 6 | import { OpenAPM } from '../../src/OpenAPM'; 7 | import parsePrometheusTextFormat from 'parse-prometheus-text-format'; 8 | 9 | describe('Nest.js', () => { 10 | let app: INestApplication; 11 | let openapm: OpenAPM; 12 | 13 | async function bootstrap() { 14 | openapm = new OpenAPM({ 15 | additionalLabels: ['slug'], 16 | }); 17 | openapm.instrument('nestjs'); 18 | 19 | app = await NestFactory.create(AppModule); 20 | await app.listen(3000); 21 | } 22 | 23 | beforeAll(async () => { 24 | await bootstrap(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await app.getHttpServer().close(); 29 | await openapm.shutdown(); 30 | }); 31 | 32 | test('Dynamically set labels', async () => { 33 | await request(app.getHttpServer()).get('/').expect(200); 34 | const res = await request(openapm.metricsServer).get('/metrics'); 35 | 36 | const parsedData = parsePrometheusTextFormat(res.text); 37 | 38 | expect( 39 | parsedData?.find((m) => m.name === 'http_requests_total')?.metrics[0] 40 | .labels['slug'], 41 | ).toBe('custom-slug'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/nestjs/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { setOpenAPMLabels } from '../../../src/async-local-storage.http'; 4 | 5 | @Controller() 6 | export class AppController { 7 | constructor(private readonly appService: AppService) {} 8 | 9 | @Get() 10 | getHello(): string { 11 | setOpenAPMLabels({ slug: 'custom-slug' }); 12 | return this.appService?.getHello(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/nestjs/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [AppController], 8 | providers: [AppService], 9 | }) 10 | export class AppModule {} 11 | -------------------------------------------------------------------------------- /tests/nestjs/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { setOpenAPMLabels } from '../../../src/async-local-storage.http'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | getHello(): string { 7 | return 'Hello World!'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/nestjs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tests/nestjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next" 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/app/app-apis/[id]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET(request) { 4 | return NextResponse.json({ 5 | status: 200, 6 | body: { 7 | message: 'GET method called' 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /tests/nextjs/app/app-apis/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | const handler = async (req) => { 4 | return NextResponse.json({ 5 | status: 200, 6 | body: { 7 | message: 'GET method called' 8 | } 9 | }); 10 | }; 11 | 12 | export const GET = handler; 13 | export const POST = handler; 14 | export const PUT = handler; 15 | export const DELETE = handler; 16 | export const PATCH = handler; 17 | export const OPTIONS = handler; 18 | export const HEAD = handler; 19 | -------------------------------------------------------------------------------- /tests/nextjs/app/labels/route.js: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | export async function GET(request) { 4 | return NextResponse.json({ 5 | status: 200, 6 | body: { 7 | message: 'GET method called' 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /tests/nextjs/app/layout.js: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | 3 | export const metadata = { 4 | title: 'Next.js', 5 | description: 'Generated by Next.js' 6 | }; 7 | 8 | export default function RootLayout({ children }) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /tests/nextjs/app/page.js: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Page
; 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/app/styles.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/app/users/[id]/delete/page.js: -------------------------------------------------------------------------------- 1 | export default function Page({ params: { id } }) { 2 | return
Delete: {id}
; 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/app/users/[id]/page.js: -------------------------------------------------------------------------------- 1 | export default function Page({ params: { id } }) { 2 | return
Delete: {id}
; 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/app/users/page.js: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
User
; 3 | } 4 | -------------------------------------------------------------------------------- /tests/nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /tests/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /tests/nextjs/nextjs.test.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http'; 2 | import next from 'next'; 3 | import express from 'express'; 4 | import request from 'supertest'; 5 | import { describe, afterAll, beforeAll, test, expect } from 'vitest'; 6 | import OpenAPM from '../../src/OpenAPM'; 7 | import parsePrometheusTextFormat from 'parse-prometheus-text-format'; 8 | import { resolve } from 'path'; 9 | import { makeRequest } from '../utils'; 10 | import { chromium } from 'playwright'; 11 | 12 | describe('Next.js', () => { 13 | let openapm: OpenAPM; 14 | let server: Server; 15 | let parsedData: Array>; 16 | let expressApp: express.Express; 17 | 18 | beforeAll(async () => { 19 | openapm = new OpenAPM({ 20 | enableMetricsServer: false, 21 | additionalLabels: ['slug'] 22 | }); 23 | openapm.instrument('nextjs'); 24 | 25 | const app = next({ 26 | customServer: false, 27 | httpServer: server, 28 | dir: resolve(__dirname), 29 | conf: {} 30 | }); 31 | 32 | expressApp = express(); 33 | expressApp.get('/metrics', async (_, res) => { 34 | let metrics = await openapm.getMetrics(); 35 | res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); 36 | res.end(metrics); 37 | }); 38 | 39 | const handler = app.getRequestHandler(); 40 | 41 | expressApp.all('*', async (req, res) => { 42 | return await handler(req, res); 43 | }); 44 | 45 | await app.prepare(); 46 | server = expressApp.listen(3003); 47 | }); 48 | 49 | afterAll(async () => { 50 | await openapm.shutdown(); 51 | server?.close(); 52 | }); 53 | 54 | test('App router: Page Route', async () => { 55 | const res = await makeRequest(expressApp, '/'); 56 | expect(res.statusCode).toBe(200); 57 | }); 58 | 59 | test('App router: Route does not exists', async () => { 60 | const res = await makeRequest(expressApp, '/non-existent-route'); 61 | expect(res.statusCode).toBe(404); 62 | }); 63 | 64 | test('App router: API Route (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD)', async () => { 65 | const route = '/app-apis'; 66 | let res = await request(expressApp).get(route); 67 | expect(res.statusCode).toBe(200); 68 | 69 | res = await request(expressApp).post(route); 70 | expect(res.statusCode).toBe(200); 71 | 72 | res = await request(expressApp).put(route); 73 | expect(res.statusCode).toBe(200); 74 | 75 | res = await request(expressApp).delete(route); 76 | expect(res.statusCode).toBe(200); 77 | 78 | res = await request(expressApp).patch(route); 79 | expect(res.statusCode).toBe(200); 80 | 81 | res = await request(expressApp).head(route); 82 | expect(res.statusCode).toBe(200); 83 | 84 | res = await request(expressApp).options(route); 85 | expect(res.statusCode).toBe(200); 86 | }); 87 | 88 | test('Page router: Page Route', async () => { 89 | const res = await makeRequest(expressApp, '/about'); 90 | expect(res.statusCode).toBe(200); 91 | }); 92 | 93 | test('Page router: API Route (GET, POST, PUT, DELETE, PATCH, HEAD)', async () => { 94 | const route = '/api/hello'; 95 | let res = await request(expressApp).get(route); 96 | expect(res.statusCode).toBe(200); 97 | 98 | res = await request(expressApp).post(route); 99 | expect(res.statusCode).toBe(200); 100 | 101 | res = await request(expressApp).put(route); 102 | expect(res.statusCode).toBe(200); 103 | 104 | res = await request(expressApp).delete(route); 105 | expect(res.statusCode).toBe(200); 106 | 107 | res = await request(expressApp).head(route); 108 | expect(res.statusCode).toBe(200); 109 | 110 | res = await request(expressApp).patch(route); 111 | expect(res.statusCode).toBe(200); 112 | }); 113 | 114 | test('Metrics are captured', async () => { 115 | parsedData = parsePrometheusTextFormat( 116 | (await makeRequest(expressApp, '/metrics')).text 117 | ); 118 | expect(parsedData).toBeDefined(); 119 | }); 120 | 121 | test('Captures Counter Metrics', async () => { 122 | const counterMetrics = parsedData?.find( 123 | (m) => m.name === 'http_requests_total' 124 | )?.metrics; 125 | expect(counterMetrics.length > 0).toBe(true); 126 | }); 127 | 128 | test('Captures Histogram Metrics', async () => { 129 | expect( 130 | Object.keys( 131 | parsedData?.find( 132 | (m) => m.name === 'http_requests_duration_milliseconds' 133 | )?.metrics[0].buckets 134 | ).length > 0 135 | ).toBe(true); 136 | }); 137 | 138 | test('App router route paths masked', async () => { 139 | await makeRequest(expressApp, '/app-apis/123'); 140 | parsedData = parsePrometheusTextFormat( 141 | (await makeRequest(expressApp, '/metrics')).text 142 | ); 143 | expect( 144 | parsedData 145 | ?.find((m) => m.name === 'http_requests_total') 146 | ?.metrics.find((m) => m.labels.path === '/app-apis/[id]') 147 | ).toBeDefined(); 148 | }); 149 | 150 | test('App router page paths masked', async () => { 151 | await makeRequest(expressApp, '/users/123'); 152 | parsedData = parsePrometheusTextFormat( 153 | (await makeRequest(expressApp, '/metrics')).text 154 | ); 155 | expect( 156 | parsedData 157 | ?.find((m) => m.name === 'http_requests_total') 158 | ?.metrics.find((m) => m.labels.path === '/users/[id]') 159 | ).toBeDefined(); 160 | }); 161 | 162 | test('Page router page paths masked', async () => { 163 | await makeRequest(expressApp, '/blog/123'); 164 | parsedData = parsePrometheusTextFormat( 165 | (await makeRequest(expressApp, '/metrics')).text 166 | ); 167 | expect( 168 | parsedData 169 | ?.find((m) => m.name === 'http_requests_total') 170 | ?.metrics.find((m) => m.labels.path === '/blog/[id]') 171 | ).toBeDefined(); 172 | }); 173 | 174 | test('Page router route paths masked', async () => { 175 | await makeRequest(expressApp, '/api/auth/login'); 176 | parsedData = parsePrometheusTextFormat( 177 | (await makeRequest(expressApp, '/metrics')).text 178 | ); 179 | expect( 180 | parsedData 181 | ?.find((m) => m.name === 'http_requests_total') 182 | ?.metrics.find((m) => m.labels.path === '/api/auth/[...nextAuth]') 183 | ).toBeDefined(); 184 | }); 185 | 186 | test('Static files should not be captured in metrics', async () => { 187 | const res = await makeRequest(expressApp, '/about'); 188 | const browser = await chromium.launch(); 189 | const page = await browser.newPage(); 190 | 191 | page.setContent(res.text); 192 | console.log(res.text); 193 | 194 | const elements = await page.$$('script'); 195 | 196 | for (let el of elements) { 197 | const src = await el.getAttribute('src'); 198 | if (src) { 199 | await makeRequest(expressApp, src); 200 | } 201 | } 202 | parsedData = parsePrometheusTextFormat( 203 | (await makeRequest(expressApp, '/metrics')).text 204 | ); 205 | 206 | expect( 207 | parsedData 208 | ?.find((m) => m.name === 'http_requests_total') 209 | ?.metrics.find((m) => m.labels.path.endsWith('.js')) 210 | ).toBeUndefined(); 211 | }); 212 | }); 213 | 214 | /** 215 | * Test Cases: 216 | * - [x] App router correctly routes requests page routes 217 | * - [x] Next gives 404 for non-existent routes 218 | * - [x] App router correctly routes requests route routes (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD) 219 | * - [x] Page router correctly routes requests to the react components 220 | * - [x] Page router correctly routes requests to the API routes (GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD) 221 | * - [x] Metrics are captured 222 | * - [x] Captures Counter Metrics 223 | * - [x] Captures Histogram Metrics 224 | * - [x] App router route paths getting masked correctly 225 | * - [x] App router page paths getting masked correctly 226 | * - [x] Page router page paths getting masked correctly 227 | * - [x] Page router route paths getting masked correctly 228 | * - [ ] Static files should not be captured in metrics 229 | */ 230 | -------------------------------------------------------------------------------- /tests/nextjs/pages/about.tsx: -------------------------------------------------------------------------------- 1 | const About = () => { 2 | return ( 3 |
4 |

About

5 |
6 | ); 7 | }; 8 | 9 | export default About; 10 | -------------------------------------------------------------------------------- /tests/nextjs/pages/api/auth/[...nextAuth].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 4 | res.status(200).json({ text: "Hello" }); 5 | } 6 | -------------------------------------------------------------------------------- /tests/nextjs/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | export default function handler(req: NextApiRequest, res: NextApiResponse) { 4 | res.status(200).json({ text: 'Hello' }); 5 | } 6 | -------------------------------------------------------------------------------- /tests/nextjs/pages/blog/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | const Blog = () => { 4 | const router = useRouter(); 5 | const { id } = router.query; 6 | return ( 7 |
8 |

Blog Post {id}

9 |
10 | ); 11 | }; 12 | 13 | export default Blog; 14 | -------------------------------------------------------------------------------- /tests/nextjs/server.js: -------------------------------------------------------------------------------- 1 | const { OpenAPM } = require('../../'); 2 | const openapm = new OpenAPM({ 3 | metricsServerPort: 9098, 4 | additionalLabels: ['slug'] 5 | }); 6 | const dir = './tests/nextjs'; 7 | 8 | openapm.instrument('nextjs', { 9 | dir 10 | }); 11 | 12 | const express = require('express'); 13 | const http = require('http'); 14 | const next = require('next'); 15 | const { parse } = require('url'); 16 | 17 | async function main() { 18 | const app = express(); 19 | const server = http.createServer(app); 20 | 21 | // 'dev' is a boolean that indicates whether the app should run in development mode 22 | const dev = process.env.NODE_ENV !== 'production'; 23 | const port = 3002; 24 | 25 | // 'dir' is a string that specifies the directory where the app is located 26 | const nextApp = next({ 27 | dev, 28 | dir, 29 | customServer: true, 30 | httpServer: server, 31 | port 32 | }); 33 | // openapm.instrument('nextjs', nextApp); 34 | const handle = nextApp.getRequestHandler(); 35 | 36 | app.get('/metrics', async (_, res) => { 37 | const metrics = await openapm.getMetrics(); 38 | res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); 39 | res.end(metrics); 40 | }); 41 | 42 | app.all('*', async (req, res) => { 43 | const parsedUrl = parse(req.url, true); 44 | await handle(req, res, parsedUrl); 45 | }); 46 | 47 | // 'hostname' is a string that specifies the domain name of the server 48 | // For local development, this is typically 'localhost' 49 | const hostname = 'localhost'; 50 | 51 | await nextApp.prepare(); 52 | server.listen(port, hostname); 53 | server.on('error', async (err) => { 54 | console.error(err); 55 | }); 56 | server.once('listening', async () => {}); 57 | } 58 | 59 | main(); 60 | -------------------------------------------------------------------------------- /tests/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": false, 7 | "noEmit": true, 8 | "incremental": true, 9 | "module": "esnext", 10 | "esModuleInterop": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "strictNullChecks": true 21 | }, 22 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /tests/prisma/prisma.test.ts: -------------------------------------------------------------------------------- 1 | import express, { Express } from 'express'; 2 | import { test, expect, describe, beforeAll, afterAll, vi } from 'vitest'; 3 | 4 | import OpenAPM from '../../src/OpenAPM'; 5 | import { addRoutes, makeRequest } from '../utils'; 6 | import { Server } from 'http'; 7 | 8 | class OpenAPMExtended extends OpenAPM { 9 | public simpleCache: Record; 10 | 11 | constructor() { 12 | super({ 13 | enableMetricsServer: false 14 | }); 15 | this.simpleCache = {}; 16 | } 17 | 18 | clearSimpleCache() { 19 | this.simpleCache = {}; 20 | } 21 | } 22 | 23 | async function mock(mockedUri, stub) { 24 | const { Module } = await import('module'); 25 | 26 | // @ts-ignore 27 | Module._load_original = Module._load; 28 | // @ts-ignore 29 | Module._load = (uri, parent) => { 30 | if (uri === mockedUri) return stub; 31 | // @ts-ignore 32 | return Module._load_original(uri, parent); 33 | }; 34 | } 35 | 36 | async function unmock() { 37 | const { Module } = await import('module'); 38 | 39 | // @ts-ignore 40 | Module._load = Module._load_original; 41 | 42 | // @ts-ignore 43 | delete Module._load_original; 44 | } 45 | 46 | describe('Prisma', () => { 47 | let openapm: OpenAPM; 48 | let app: Express; 49 | let server: Server; 50 | 51 | vi.hoisted(async () => { 52 | await mock('@prisma/client', { 53 | PrismaClient: class PrismaClient { 54 | constructor() { 55 | throw new Error('Cannot find module "@prisma/client"'); 56 | } 57 | } 58 | }); 59 | }); 60 | 61 | beforeAll(async () => { 62 | openapm = new OpenAPMExtended(); 63 | openapm.instrument('express'); 64 | 65 | app = express(); 66 | 67 | app.get('/metrics', async (req, res) => { 68 | res.status(200).send(await openapm.getMetrics()); 69 | }); 70 | 71 | addRoutes(app); 72 | server = app.listen(3004); 73 | }); 74 | 75 | afterAll(async () => { 76 | await openapm.shutdown(); 77 | server.close(); 78 | }); 79 | 80 | test('prisma:installed - false', async () => { 81 | await makeRequest(app, '/api/10'); 82 | await makeRequest(app, '/metrics'); 83 | 84 | expect(openapm.simpleCache['prisma:installed']).toBe(false); 85 | }); 86 | 87 | test('simpleCache', async () => { 88 | expect(openapm.simpleCache['prisma:installed']).toBe(false); 89 | }); 90 | 91 | test('metrics', async () => { 92 | await unmock(); 93 | (openapm as OpenAPMExtended).clearSimpleCache(); 94 | const res = await makeRequest(app, '/metrics'); 95 | expect(res.text).toContain('prisma_client_queries_total'); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /tests/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = "mysql://root@localhost:3306/test_db" 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | previewFeatures = ["metrics"] 9 | } 10 | 11 | model Todo { 12 | id Int @id @default(autoincrement()) 13 | title String 14 | completed Boolean @default(false) 15 | } 16 | -------------------------------------------------------------------------------- /tests/utils.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import express from 'express'; 3 | import type { Express } from 'express'; 4 | import { setOpenAPMLabels } from '../src/async-local-storage.http'; 5 | import { getMetricClient } from '../src/OpenAPM'; 6 | 7 | export const addRoutes = (app: Express) => { 8 | const router = express.Router(); 9 | 10 | const client = getMetricClient(); 11 | 12 | const gauge = new client.Gauge({ 13 | name: 'custom_gauge', 14 | help: 'custom gauge' 15 | }); 16 | 17 | const counter = new client.Counter({ 18 | name: 'custom_counter_total', 19 | help: 'custom counter', 20 | labelNames: ['service'] 21 | }); 22 | 23 | router.get('/:id', (req, res) => { 24 | const { id } = req.params; 25 | ``; 26 | gauge.set(42); 27 | res.status(200).send(id); 28 | }); 29 | 30 | app.use('/api/router/', router); 31 | 32 | app.get('/api/:id', (req, res) => { 33 | const { id } = req.params; 34 | counter.inc({ service: 'express' }); 35 | res.status(200).send(id); 36 | }); 37 | 38 | app.get('/api/labels/:id', (req, res) => { 39 | const { id } = req.params; 40 | setOpenAPMLabels({ id }); 41 | res.status(200).send(id); 42 | }); 43 | 44 | return app; 45 | }; 46 | 47 | function getRandomId() { 48 | const min = 10; 49 | const max = 30; 50 | return String(Math.floor(Math.random() * (max - min + 1)) + min); 51 | } 52 | 53 | export const makeRequest = async (app: Express, path: string) => { 54 | // @ts-ignore 55 | const res = await request(app).get(path); 56 | return res; 57 | }; 58 | 59 | export const sendTestRequests = async (app: Express, num: number) => { 60 | for (let index = 0; index < num; index++) { 61 | const id = getRandomId(); 62 | try { 63 | await makeRequest(app, `/api/${id}`); 64 | } catch (err) { 65 | throw new Error(err); 66 | } 67 | } 68 | const id = getRandomId(); 69 | try { 70 | await makeRequest(app, `/api/router/${id}`); 71 | } catch (err) { 72 | throw new Error(err); 73 | } 74 | }; 75 | 76 | export const sendTestRequestNextJS = async (app: Express, num: number) => { 77 | const endpoints = [ 78 | '/', 79 | '/users', 80 | '/users/:id', 81 | '/app-apis', 82 | '/app-apis/:id', 83 | '/api/hello', 84 | '/api/auth/login', 85 | '/api/auth/register' 86 | ]; 87 | 88 | const randomIndex = Math.floor(Math.random() * endpoints.length); 89 | let endpoint = endpoints[randomIndex]; 90 | 91 | if (endpoint.includes(':id')) { 92 | const randomId = Math.floor(Math.random() * 100); 93 | endpoint = endpoint.replace(':id', randomId.toString()); 94 | } 95 | 96 | await makeRequest(app, endpoint); 97 | await makeRequest(app, '/labels'); 98 | }; 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "ES2020", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "allowJs": false, 10 | "declaration": true, 11 | "declarationMap": true, 12 | "emitDeclarationOnly": false, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "skipLibCheck": true, 16 | "downlevelIteration": true, 17 | "types": ["node", "mysql2", "@nestjs/core"], 18 | "outDir": "dist" 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "**/*.spec.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | import glob from 'tiny-glob'; 3 | 4 | export default defineConfig(async () => ({ 5 | entry: await glob('src/**/*.ts'), 6 | sourcemap: false, 7 | bundle: false, 8 | format: ['cjs', 'esm'], 9 | legacyOutput: true, 10 | cjsInterop: true, 11 | treeshake: true, 12 | shims: true, 13 | external: ['mysql2', '@nestjs/core', '@prisma/client', 'express', 'next'], 14 | dts: true, 15 | clean: true, 16 | splitting: false, 17 | esbuildOptions: (options, context) => { 18 | options.outbase = './'; 19 | } 20 | })); 21 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, configDefaults } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | exclude: [...configDefaults.exclude, 'playground/*'] 6 | } 7 | }); 8 | --------------------------------------------------------------------------------