(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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------