├── .eslintignore ├── src ├── constants.ts ├── index.ts ├── metrics │ ├── metrics.controller.ts │ ├── metrics.controller.spec.ts │ ├── metrics.service.ts │ └── metrics.service.spec.ts ├── interfaces.ts └── reporter │ ├── reporter.service.ts │ ├── reporter.module.ts │ ├── reporter.module.spec.ts │ └── reporter.service.spec.ts ├── .github └── workflows │ ├── CODEOWNERS │ ├── ci.yml │ └── release.yml ├── .gitignore ├── jest.config.ts ├── tsconfig.json ├── .eslintrc ├── CONTRIBUTING.md ├── package.json ├── README.md └── LICENSE /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | dist/* 3 | node_modules/ 4 | coverage/ -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONFIG_OPTIONS = Symbol( 'CONFIG_OPTIONS' ); 2 | -------------------------------------------------------------------------------- /.github/workflows/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Currently require approval from 2 | * @netanelavr -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .DS_Store 5 | *.log 6 | .env 7 | .idea -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './metrics/metrics.service'; 2 | export * from './reporter/reporter.module'; 3 | export * from './reporter/reporter.service'; 4 | export * from './metrics/metrics.controller'; 5 | export * from './interfaces'; -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ "**" ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: '20.x' 17 | - run: npm ci 18 | - run: npm run build 19 | - run: npm test -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | testMatch: ['/**/*.spec.ts'], 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | transform: { 9 | '^.+\\.tsx?$': 'ts-jest', 10 | }, 11 | moduleNameMapper: { 12 | '^src/(.*)$': '/src/$1', 13 | }, 14 | verbose: true, 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /src/metrics/metrics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { Registry } from 'prom-client'; 3 | import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | 5 | @ApiTags( 'Metrics' ) 6 | @Controller( 'metrics' ) 7 | export class MetricsController { 8 | constructor( private readonly registry: Registry ) {} 9 | 10 | @Get() 11 | @ApiOperation( { summary: 'Get Prometheus metrics' } ) 12 | @ApiOkResponse( { type: Object } ) 13 | async getMetrics(): Promise { 14 | return await this.registry.metrics(); 15 | } 16 | } -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | 3 | export interface MetricsConfig { 4 | defaultLabels?: Record; 5 | defaultMetricsEnabled?: boolean; 6 | interceptors?: Type[] 7 | pushgatewayUrl?: string; 8 | pushgatewayOptions?: { 9 | timeout?: number; 10 | headers?: Record; 11 | auth?: { 12 | username: string; 13 | password: string; 14 | }; 15 | }; 16 | } 17 | 18 | export interface ReporterAsyncOptions { 19 | imports?: any[]; 20 | useFactory: ( ...args: any[] )=> Promise | MetricsConfig; 21 | inject?: any[]; 22 | } 23 | 24 | export interface PushgatewayResponse { 25 | status: number; 26 | success: boolean; 27 | message?: string; 28 | } -------------------------------------------------------------------------------- /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 | "moduleResolution": "node", 21 | "esModuleInterop": true 22 | }, 23 | "include": ["src"], 24 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] 25 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: [ main ] 5 | 6 | permissions: 7 | contents: write 8 | issues: write 9 | pull-requests: write 10 | packages: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | persist-credentials: false 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: '24.x' 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Release 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 37 | run: npx semantic-release -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "indent": ["error", "tab"], 20 | "quotes": ["error", "single"], 21 | "semi": ["error", "always"], 22 | "@typescript-eslint/explicit-function-return-type": "off", 23 | "@typescript-eslint/no-explicit-any": "off", 24 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 25 | "object-curly-spacing": ["error", "always"], 26 | "@typescript-eslint/brace-style": ["error", "1tbs"], 27 | "@typescript-eslint/type-annotation-spacing": [ 28 | "error", 29 | { 30 | "before": false, 31 | "after": true, 32 | "overrides": { 33 | "parameter": { "before": false, "after": true }, 34 | "property": { "before": false, "after": true } 35 | } 36 | } 37 | ], 38 | "space-before-function-paren": [ 39 | "error", 40 | { 41 | "anonymous": "always", 42 | "named": "never", 43 | "asyncArrow": "always" 44 | } 45 | ], 46 | "space-in-parens": ["error", "always"], 47 | "keyword-spacing": [ 48 | "error", 49 | { 50 | "before": true, 51 | "after": true 52 | } 53 | ] 54 | }, 55 | "ignorePatterns": ["dist/", "dist/*"] 56 | } 57 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to nestjs-metrics-reporter 2 | 3 | 🎉 Thank you for considering contributing to nestjs-metrics-reporter! This document outlines the basics you need to know. 4 | 5 | ## Ways to Contribute 6 | 7 | - **Report Bugs**: Open an issue describing the bug and include steps to reproduce it 8 | - **Suggest Features**: Open an issue describing your proposed feature 9 | - **Submit Changes**: Fork the repo and submit a PR 10 | - **Improve Docs**: Fix typos, clarify explanations, add examples 11 | 12 | ## Development Process 13 | 14 | 1. Fork and clone the repository 15 | ```bash 16 | git clone https://github.com/your-username/nestjs-metrics-reporter.git 17 | ``` 18 | 19 | 2. Create a feature branch 20 | ```bash 21 | git checkout -b feature/your-feature 22 | # or 23 | git checkout -b fix/your-fix 24 | ``` 25 | 26 | 3. Make your changes and test 27 | ```bash 28 | npm install 29 | npm run test 30 | npm run lint: fix 31 | ``` 32 | 33 | 4. Commit your changes using semantic commits 34 | ```bash 35 | # Features 36 | git commit -m "feat: add new feature" 37 | 38 | # Bug fixes 39 | git commit -m "fix: resolve issue" 40 | 41 | # Documentation 42 | git commit -m "docs: update readme" 43 | ``` 44 | 45 | 5. Push and open a PR 46 | ```bash 47 | git push origin feature/your-feature 48 | ``` 49 | 50 | ## Pull Request Guidelines 51 | 52 | - Update documentation if needed 53 | - Add tests for new features 54 | - Maintain existing code style 55 | - Keep changes focused and atomic 56 | - Ensure all tests pass 57 | 58 | ## Getting Help 59 | 60 | - 📝 Check existing issues and documentation 61 | - 💬 Open a discussion for questions 62 | - 📧 Contact maintainers if stuck 63 | 64 | ## License 65 | 66 | By contributing, you agree that your contributions will be licensed under the Apache-2.0 License. -------------------------------------------------------------------------------- /src/metrics/metrics.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Registry, Counter } from 'prom-client'; 3 | import { MetricsController } from './metrics.controller'; 4 | 5 | describe( 'MetricsController', () => { 6 | let controller: MetricsController; 7 | let registry: Registry; 8 | 9 | beforeEach( async () => { 10 | registry = new Registry(); 11 | const module: TestingModule = await Test.createTestingModule( { 12 | controllers: [MetricsController], 13 | providers: [ 14 | { 15 | provide: Registry, 16 | useValue: registry, 17 | }, 18 | ], 19 | } ).compile(); 20 | 21 | controller = module.get( MetricsController ); 22 | } ); 23 | 24 | afterEach( () => { 25 | registry.clear(); 26 | } ); 27 | 28 | describe( 'getMetrics', () => { 29 | it( 'should return prometheus metrics', async () => { 30 | const counter = new Counter( { 31 | name: 'test_counter', 32 | help: 'test counter help', 33 | registers: [registry] 34 | } ); 35 | 36 | counter.inc(); 37 | 38 | const metrics = await controller.getMetrics(); 39 | expect( metrics ).toBeDefined(); 40 | expect( typeof metrics ).toBe( 'string' ); 41 | expect( metrics ).toContain( '# HELP test_counter test counter help' ); 42 | expect( metrics ).toContain( 'test_counter 1' ); 43 | } ); 44 | 45 | it( 'should handle empty metrics registry', async () => { 46 | const metrics = await controller.getMetrics(); 47 | expect( metrics ).toBeDefined(); 48 | expect( typeof metrics ).toBe( 'string' ); 49 | expect( metrics.trim() ).toBe( '' ); 50 | } ); 51 | 52 | it( 'should handle registry errors', async () => { 53 | jest.spyOn( registry, 'metrics' ).mockRejectedValue( new Error( 'Registry error' ) ); 54 | await expect( controller.getMetrics() ).rejects.toThrow( 'Registry error' ); 55 | } ); 56 | } ); 57 | } ); -------------------------------------------------------------------------------- /src/reporter/reporter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; 2 | import { MetricsService } from '../metrics/metrics.service'; 3 | 4 | @Injectable() 5 | export class ReporterService implements OnApplicationBootstrap { 6 | private static readonly logger = new Logger( ReporterService.name ); 7 | private static metricsService: MetricsService; 8 | 9 | constructor( private readonly metrics: MetricsService ) {} 10 | 11 | onApplicationBootstrap() { 12 | ReporterService.metricsService = this.metrics; 13 | } 14 | 15 | static counter( key: string, labels?: Record, value: number = 1 ): void { 16 | try { 17 | ReporterService.metricsService.incCounter( key, labels, value ); 18 | } catch ( error ) { 19 | this.logError( 'increment counter', key, labels, error ); 20 | } 21 | } 22 | 23 | static gauge( key: string, value: number, labels?: Record ): void { 24 | try { 25 | ReporterService.metricsService.setGauge( key, value, labels ); 26 | } catch ( error ) { 27 | this.logError( 'set gauge', key, labels, error ); 28 | } 29 | } 30 | 31 | static histogram( key: string, value: number, labels?: Record, buckets?: number[] ): void { 32 | try { 33 | ReporterService.metricsService.observeHistogram( key, value, labels, buckets ); 34 | } catch ( error ) { 35 | this.logError( 'observe histogram', key, labels, error ); 36 | } 37 | } 38 | 39 | static summary( key: string, value: number, labels?: Record, percentiles?: number[] ): void { 40 | try { 41 | ReporterService.metricsService.observeSummary( key, value, labels, percentiles ); 42 | } catch ( error ) { 43 | this.logError( 'observe summary', key, labels, error ); 44 | } 45 | } 46 | 47 | static async pushMetrics( jobName: string ): Promise { 48 | try { 49 | await ReporterService.metricsService.pushMetrics( jobName ); 50 | } catch ( e ) { 51 | this.logger.error( `Error pushing metrics: ${ e }` ); 52 | } 53 | } 54 | 55 | private static logError( 56 | action: string, 57 | key: string, 58 | labels: Record | undefined, 59 | error: unknown 60 | ): void { 61 | this.logger.error( { 62 | message: `Failed to ${action}`, 63 | metric: key, 64 | labels, 65 | error: error instanceof Error ? error.message : String( error ), 66 | } ); 67 | } 68 | } -------------------------------------------------------------------------------- /src/reporter/reporter.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Global, Module, Provider, Type } from '@nestjs/common'; 2 | import { ReporterService } from './reporter.service'; 3 | import { collectDefaultMetrics, Registry } from 'prom-client'; 4 | import { MetricsConfig, ReporterAsyncOptions } from '../interfaces'; 5 | import { MetricsService } from '../metrics/metrics.service'; 6 | import { MetricsController } from '../metrics/metrics.controller'; 7 | import { CONFIG_OPTIONS } from '../constants'; 8 | import { APP_INTERCEPTOR } from '@nestjs/core'; 9 | 10 | @Global() 11 | @Module( {} ) 12 | export class ReporterModule { 13 | static forRoot( config: MetricsConfig = {} ): DynamicModule { 14 | const registry: Registry = this.configureRegistry( config ); 15 | const providers: Provider[] = [ 16 | { 17 | provide: Registry, 18 | useValue: registry 19 | }, 20 | { 21 | provide: CONFIG_OPTIONS, 22 | useValue: config 23 | }, 24 | MetricsService, 25 | ReporterService 26 | ]; 27 | 28 | if ( config.interceptors ) { 29 | providers.push( ...config.interceptors.map( interceptor => ( { 30 | provide: APP_INTERCEPTOR, 31 | useClass: interceptor as Type, 32 | } ) ) ); 33 | } 34 | return { 35 | module: ReporterModule, 36 | providers, 37 | controllers: [ MetricsController ], 38 | exports: [ ReporterService ] 39 | }; 40 | } 41 | 42 | static forRootAsync( options: ReporterAsyncOptions ): DynamicModule { 43 | return { 44 | module: ReporterModule, 45 | imports: options.imports, 46 | providers: [ 47 | { 48 | provide: CONFIG_OPTIONS, 49 | useFactory: options.useFactory, 50 | inject: options.inject, 51 | }, 52 | { 53 | provide: Registry, 54 | useFactory: async ( config: MetricsConfig ) => { 55 | return ReporterModule.configureRegistry( config ); 56 | }, 57 | inject: [ CONFIG_OPTIONS ], 58 | }, 59 | MetricsService, 60 | ReporterService 61 | ], 62 | controllers: [ MetricsController ], 63 | exports: [ ReporterService ] 64 | }; 65 | } 66 | 67 | private static configureRegistry( config: MetricsConfig = {} ): Registry { 68 | const registry: Registry = new Registry(); 69 | 70 | if ( config.defaultLabels ) { 71 | registry.setDefaultLabels( config.defaultLabels ); 72 | } 73 | 74 | if ( config.defaultMetricsEnabled ) { 75 | collectDefaultMetrics( { register: registry } ); 76 | } 77 | 78 | return registry; 79 | } 80 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-metrics-reporter", 3 | "version": "1.0.0", 4 | "description": "A global static Prometheus metrics reporter for NestJS applications", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "engines": { 11 | "node": ">=16.0.0" 12 | }, 13 | "scripts": { 14 | "prebuild": "rimraf dist", 15 | "build": "tsc", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:debug": "jest debug --runInBand --detectOpenHandles", 19 | "lint": "eslint 'src/**/*.ts'", 20 | "lint:fix": "eslint . --ext .ts --fix", 21 | "clean": "rimraf dist", 22 | "prepare": "npm run build", 23 | "prepublishOnly": "npm run build", 24 | "semantic-release": "semantic-release" 25 | }, 26 | "keywords": [ 27 | "nestjs", 28 | "prometheus", 29 | "metrics", 30 | "monitoring", 31 | "global", 32 | "reporter", 33 | "prometheus-client", 34 | "prom-client" 35 | ], 36 | "author": { 37 | "name": "Netanel Avraham", 38 | "url": "https://github.com/netanelavr" 39 | }, 40 | "license": "Apache-2.0", 41 | "repository": { 42 | "type": "git", 43 | "url": "git+https://github.com/netanelavr/nestjs-metrics-reporter.git" 44 | }, 45 | "bugs": { 46 | "url": "https://github.com/netanelavr/nestjs-metrics-reporter/issues" 47 | }, 48 | "homepage": "https://github.com/netanelavr/nestjs-metrics-reporter#readme", 49 | "dependencies": { 50 | "prom-client": "^14.0.0" 51 | }, 52 | "peerDependencies": { 53 | "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 54 | "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", 55 | "@nestjs/swagger": "^7.4.0 <8.0.0 || ^11.0.0" 56 | }, 57 | "devDependencies": { 58 | "@nestjs/common": "^10.0.0", 59 | "@nestjs/core": "^10.0.0", 60 | "@nestjs/swagger": "^7.4.0", 61 | "@nestjs/testing": "^10.0.0", 62 | "@types/jest": "^29.5.0", 63 | "@types/node": "^20.0.0", 64 | "@typescript-eslint/eslint-plugin": "^6.0.0", 65 | "@typescript-eslint/parser": "^6.0.0", 66 | "eslint": "^8.0.0", 67 | "jest": "^29.5.0", 68 | "rimraf": "^5.0.0", 69 | "semantic-release": "^25.0.2", 70 | "ts-jest": "^29.1.0", 71 | "ts-node": "^10.9.2", 72 | "typescript": "5.3" 73 | }, 74 | "overrides": { 75 | "glob": "^10.5.0", 76 | "js-yaml": ">=4.1.1" 77 | }, 78 | "publishConfig": { 79 | "access": "public" 80 | }, 81 | "release": { 82 | "branches": [ 83 | "main" 84 | ], 85 | "plugins": [ 86 | "@semantic-release/commit-analyzer", 87 | "@semantic-release/release-notes-generator", 88 | "@semantic-release/npm", 89 | "@semantic-release/github" 90 | ] 91 | } 92 | } -------------------------------------------------------------------------------- /src/reporter/reporter.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Registry } from 'prom-client'; 3 | import { ReporterService } from './reporter.service'; 4 | import { ReporterModule } from './reporter.module'; 5 | import { MetricsService } from '../metrics/metrics.service'; 6 | 7 | describe( 'ReporterModule', () => { 8 | let module: TestingModule; 9 | 10 | afterEach( async () => { 11 | if ( module ) { 12 | await module.close(); 13 | } 14 | ( ReporterService as any ).metricsService = undefined; 15 | } ); 16 | 17 | describe( 'forRoot', () => { 18 | it( 'should configure default labels', async () => { 19 | module = await Test.createTestingModule( { 20 | imports: [ 21 | ReporterModule.forRoot( { 22 | defaultLabels: { 23 | app: 'test-app', 24 | environment: 'test' 25 | } 26 | } ) 27 | ], 28 | } ).compile(); 29 | 30 | const reporterService = module.get( ReporterService ); 31 | reporterService.onApplicationBootstrap(); 32 | 33 | ReporterService.counter( 'test_counter' ); 34 | 35 | const registry = module.get( Registry ); 36 | const metrics = await registry.metrics(); 37 | 38 | expect( metrics ).toContain( 'app="test-app"' ); 39 | expect( metrics ).toContain( 'environment="test"' ); 40 | } ); 41 | 42 | it( 'should work without configuration', async () => { 43 | module = await Test.createTestingModule( { 44 | imports: [ReporterModule.forRoot()], 45 | } ).compile(); 46 | 47 | expect( module.get( Registry ) ).toBeDefined(); 48 | expect( module.get( MetricsService ) ).toBeDefined(); 49 | } ); 50 | } ); 51 | 52 | describe( 'forRootAsync', () => { 53 | it( 'should support async configuration', async () => { 54 | module = await Test.createTestingModule( { 55 | imports: [ 56 | ReporterModule.forRootAsync( { 57 | useFactory: async () => ( { 58 | defaultLabels: { 59 | app: 'async-app' 60 | } 61 | } ) 62 | } ) 63 | ], 64 | } ).compile(); 65 | 66 | const reporterService = module.get( ReporterService ); 67 | reporterService.onApplicationBootstrap(); 68 | 69 | ReporterService.counter( 'test_counter' ); 70 | 71 | const registry = module.get( Registry ); 72 | const metrics = await registry.metrics(); 73 | 74 | expect( metrics ).toContain( 'app="async-app"' ); 75 | } ); 76 | 77 | it( 'should disable default metrics when configured', async () => { 78 | module = await Test.createTestingModule( { 79 | imports: [ 80 | ReporterModule.forRoot( { 81 | defaultMetricsEnabled: false 82 | } ) 83 | ], 84 | } ).compile(); 85 | 86 | const registry = module.get( Registry ); 87 | const metrics = await registry.metrics(); 88 | 89 | expect( metrics ).not.toContain( 'process_cpu_user_seconds_total' ); 90 | } ); 91 | 92 | it( 'should handle async factory errors', async () => { 93 | const errorFactory = async () => { 94 | throw new Error( 'Config error' ); 95 | }; 96 | 97 | await expect( Test.createTestingModule( { 98 | imports: [ 99 | ReporterModule.forRootAsync( { 100 | useFactory: errorFactory 101 | } ) 102 | ], 103 | } ).compile() ).rejects.toThrow( 'Config error' ); 104 | } ); 105 | } ); 106 | } ); -------------------------------------------------------------------------------- /src/metrics/metrics.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Counter, Gauge, Histogram, Pushgateway, Registry, Summary } from 'prom-client'; 3 | import { MetricsConfig, PushgatewayResponse } from '../interfaces'; 4 | import { CONFIG_OPTIONS } from '../constants'; 5 | 6 | @Injectable() 7 | export class MetricsService { 8 | private readonly counter: Record> = {}; 9 | private readonly gauge: Record> = {}; 10 | private readonly histogram: Record> = {}; 11 | private readonly summary: Record> = {}; 12 | private readonly pushgateway: Pushgateway; 13 | 14 | 15 | constructor( 16 | @Inject( Registry ) private readonly registry: Registry, 17 | @Inject( CONFIG_OPTIONS ) private readonly config: MetricsConfig 18 | ) { 19 | if ( this.config.pushgatewayUrl ) { 20 | this.pushgateway = new Pushgateway( 21 | this.config.pushgatewayUrl, 22 | this.config.pushgatewayOptions || [], 23 | this.registry, 24 | ); 25 | } 26 | } 27 | 28 | public incCounter( key: string, labels?: Record, value: number = 1 ): void { 29 | if ( ! this.counter[ key ] ) { 30 | this.counter[ key ] = new Counter( { 31 | name: key, 32 | help: `Counter for ${ key }`, 33 | labelNames: labels ? Object.keys( labels ) : [], 34 | registers: [ this.registry ], 35 | } ); 36 | } 37 | this.counter[ key ].inc( labels || {}, value ); 38 | } 39 | 40 | public setGauge( key: string, value: number, labels?: Record ): void { 41 | if ( ! this.gauge[ key ] ) { 42 | this.gauge[ key ] = new Gauge( { 43 | name: key, 44 | help: `Gauge for ${ key }`, 45 | labelNames: labels ? Object.keys( labels ) : [], 46 | registers: [ this.registry ], 47 | } ); 48 | } 49 | this.gauge[ key ].set( labels || {}, value ); 50 | } 51 | 52 | public observeHistogram( 53 | key: string, 54 | value: number, 55 | labels?: Record, 56 | buckets?: number[] 57 | ): void { 58 | if ( ! this.histogram[ key ] ) { 59 | this.histogram[ key ] = new Histogram( { 60 | name: key, 61 | help: `Histogram for ${ key }`, 62 | labelNames: labels ? Object.keys( labels ) : [], 63 | buckets: buckets || [ 0.1, 0.5, 1, 2, 5 ], 64 | registers: [ this.registry ], 65 | } ); 66 | } 67 | this.histogram[ key ].observe( labels || {}, value ); 68 | } 69 | 70 | public observeSummary( 71 | key: string, 72 | value: number, 73 | labels?: Record, 74 | percentiles?: number[] 75 | ): void { 76 | if ( ! this.summary[ key ] ) { 77 | this.summary[ key ] = new Summary( { 78 | name: key, 79 | help: `Summary for ${ key }`, 80 | labelNames: labels ? Object.keys( labels ) : [], 81 | percentiles: percentiles || [ 0.01, 0.05, 0.5, 0.9, 0.95, 0.99 ], 82 | registers: [ this.registry ], 83 | } ); 84 | } 85 | this.summary[ key ].observe( labels || {}, value ); 86 | } 87 | 88 | public async pushMetrics( jobName: string ): Promise { 89 | if ( !this.pushgateway ) { 90 | return { 91 | status: 400, 92 | success: false, 93 | message: 'Pushgateway is not configured' 94 | }; 95 | } 96 | 97 | try { 98 | await this.pushgateway.pushAdd( { jobName } ); 99 | return { status: 200, success: true }; 100 | } catch ( error ) { 101 | return { 102 | status: 500, 103 | success: false, 104 | message: error instanceof Error ? error.message : String( error ) 105 | }; 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 |
5 |

NestJS Metrics Reporter

6 | 7 | [![npm version](https://badge.fury.io/js/nestjs-metrics-reporter.svg)](https://badge.fury.io/js/nestjs-metrics-reporter) 8 | NPM Downloads 9 | Medium Article 10 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 11 | 12 | 📊 A **zero-dependency-injection** alternative to Prometheus metrics solutions for NestJS. 13 | Effortlessly report metrics from anywhere in your codebase without complex setup or dependency injection. 14 | 15 | [Overview](#overview) • 16 | [Quick Start](#quick-start) • 17 | [API Reference](#api-reference) • 18 | [Contributing](#contributing) • 19 | [License](#license) 20 | 21 |
22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm install nestjs-metrics-reporter 27 | ``` 28 | 29 | --- 30 | 31 | ## Overview 32 | 33 | `nestjs-metrics-reporter` is a lightweight, **zero-setup** solution for reporting metrics in your NestJS application. 34 | It eliminates the need for dependency injection or extensive configuration. Instantly report metrics from anywhere in 35 | your application using a global static reporter. 36 | 37 | ```typescript 38 | import { ReporterService } from 'nestjs-metrics-reporter'; 39 | 40 | ReporterService.counter( 'api_requests_total', { endpoint: '/users' } ); 41 | ``` 42 | 43 | ```mermaid 44 | graph TD 45 | subgraph Application["Application"] 46 | UserModule[User Module] 47 | ProductModule[Product Module] 48 | UserModule --> ReporterService 49 | ProductModule --> ReporterService 50 | ReporterService --> MetricsService 51 | MetricsService --> Registry["Prometheus Registry (Singleton)"] 52 | MetricsController[/metrics endpoint/] --> Registry 53 | end 54 | Prometheus[Prometheus Scraper] --> MetricsController 55 | 56 | MetricsService["Metrics Service (Counters, Gauges, etc.)"] 57 | ReporterService["Reporter Service (Global Wrapper)"] 58 | ``` 59 | --- 60 | 61 | ## Why Choose `nestjs-metrics-reporter`? 62 | 63 | 🚀 **No Dependency Injection** 64 | No need for cumbersome dependency injection, making your code much more cleaner. 65 | 66 | 🌟 **Effortless Integration** 67 | Start tracking metrics immediately with zero setup. 68 | 69 | 🎯 **Focus on Simplicity** 70 | Powerful metrics without the complexity of managing dependencies or boilerplate code. 71 | 72 | 📤 **Pushgateway Support** 73 | Easily push metrics to a Pushgateway server for batch job metrics. 74 | 75 | --- 76 | 77 | ## Quick Start 78 | 79 | ### 1. Import and Configure the Module 80 | 81 | Minimal setup required! Just import the `ReporterModule` in your `AppModule`. 82 | 83 | ```typescript 84 | import { Module } from "@nestjs/common"; 85 | import { ReporterModule } from 'nestjs-metrics-reporter'; 86 | 87 | @Module( { 88 | imports: [ 89 | ReporterModule.forRoot( { 90 | // Default metrics are disabled by default, set to true to enable. 91 | defaultMetricsEnabled: true, 92 | defaultLabels: { 93 | app: 'my-app', 94 | environment: 'production', 95 | }, 96 | 97 | // Optional: Configure interceptors for custom metrics 98 | interceptors: [ SomeInterceptor ], 99 | 100 | // Optional: Configure Pushgateway for batch job metrics 101 | pushgatewayUrl: 'http://pushgateway:9091', 102 | pushgatewayOptions: { 103 | timeout: 5000, 104 | headers: { 105 | 'Custom-Header': 'value' 106 | }, 107 | auth: { 108 | username: 'user', 109 | password: 'pass' 110 | } 111 | } 112 | } ), 113 | ], 114 | } ) 115 | export class AppModule { 116 | } 117 | ``` 118 | 119 | ### 2. Report Metrics Anywhere 120 | 121 | Once initialized, you can start reporting metrics instantly from anywhere in your application. 122 | 123 | ```typescript 124 | import { Injectable } from '@nestjs/common'; 125 | import { ReporterService } from 'nestjs-metrics-reporter'; 126 | 127 | @Injectable() 128 | export class UserService { 129 | async createUser() { 130 | // Increment user creation counter 131 | ReporterService.counter( 'users_created_total', { 132 | source: 'api', 133 | user_type: 'standard' 134 | } ); 135 | 136 | // Increment counter with custom value 137 | ReporterService.counter( 'batch_users_created_total', { 138 | source: 'batch', 139 | user_type: 'standard' 140 | }, 5 ); 141 | 142 | // Update active user gauge 143 | ReporterService.gauge( 'active_users', 42, { 144 | region: 'us-east-1' 145 | } ); 146 | 147 | // Push metrics to Pushgateway 148 | await ReporterService.pushMetrics( 'user_service_job' ); 149 | } 150 | } 151 | ``` 152 | 153 | --- 154 | 155 | ## API Reference 156 | 157 | The global static service for reporting metrics: 158 | 159 | | Method | Description | Parameters | 160 | |-----------------|-----------------------------|-------------------------------------------------------------| 161 | | `counter()` | Increment a counter metric | `key: string, labels?: Record, value?: number = 1` 162 | | `gauge()` | Set a gauge value | `key: string, value: number, labels?: Record` 163 | | `histogram()` | Record a histogram value | `key: string, value: number, labels?: Record, buckets?: number[]` 164 | | `summary()` | Record a summary value | `key: string, value: number, labels?: Record, percentiles?: number[]` 165 | | `pushMetrics()` | Push metrics to Pushgateway | `jobName: string` | 166 | 167 | ### Module Configuration 168 | 169 | #### `ReporterModule.forRoot(options)` 170 | 171 | | Option | Type | Default | Description | 172 | |-------------------------|--------------------------|-------------|---------------------------------------------| 173 | | `defaultMetricsEnabled` | `boolean` | `false` | Enable collection of default metrics | 174 | | `defaultLabels` | `Record` | `{}` | Labels automatically added to all metrics | 175 | | `pushgatewayUrl` | `string` | `undefined` | URL of the Pushgateway server | 176 | | `pushgatewayOptions` | `PushgatewayOptions` | `{}` | Additional options for Pushgateway requests | 177 | | `interceptors` | `Type[]` | `[]` | Interceptors for custom metrics reporting | 178 | 179 | #### `ReporterModule.forRootAsync(options)` 180 | 181 | Supports dynamic configuration with factory providers: 182 | 183 | ```typescript 184 | ReporterModule.forRootAsync( { 185 | imports: [ ConfigModule ], 186 | inject: [ ConfigService ], 187 | useFactory: () => ( { 188 | defaultLabels: { 189 | app: configService.get( 'APP_NAME' ) || 'default-app', 190 | environment: configService.get( 'NODE_ENV' ) || 'development', 191 | }, 192 | pushgatewayUrl: configService.get( 'PUSHGATEWAY_URL' ), 193 | pushgatewayOptions: { 194 | timeout: +configService.get( 'PUSHGATEWAY_TIMEOUT' ) || 5000 195 | } 196 | } ), 197 | } ); 198 | ``` 199 | 200 | --- 201 | 202 | ## Release 203 | 204 | This package uses semantic versioning via commit messages: 205 | 206 | ### Version Bumping Commits 207 | 208 | ```bash 209 | # Patch Release (1.0.X) 210 | fix: message # Bug fixes 211 | perf: message # Performance improvements 212 | 213 | # Minor Release (1.X.0) 214 | feat: message # New features 215 | 216 | # Major Release (X.0.0) 217 | feat!: message # Breaking change 218 | fix!: message # Breaking change 219 | BREAKING CHANGE: message # Breaking change anywhere in the commit body 220 | ``` 221 | 222 | ### Non-Version Bumping Commits 223 | 224 | Only these specific types are allowed: 225 | 226 | ```bash 227 | build: message # Changes to build system or dependencies 228 | chore: message # Maintenance tasks 229 | ci: message # CI configuration files and scripts 230 | docs: message # Documentation only 231 | refactor: message # Neither fixes a bug nor adds a feature 232 | style: message # Code style (formatting, semicolons, etc) 233 | test: message # Adding or correcting tests 234 | ``` 235 | 236 | Any other prefix will cause the commit to be ignored by semantic-release and won't appear anywhere in release notes. 237 | 238 | --- 239 | 240 | ## Contributing 241 | 242 | Contributions are welcome! Please check out our [Contributing Guide](CONTRIBUTING.md) to get started. 243 | 244 | --- 245 | 246 | ## License 247 | 248 | This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. 249 | 250 | --- -------------------------------------------------------------------------------- /src/metrics/metrics.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Registry, Pushgateway } from 'prom-client'; 3 | import { CONFIG_OPTIONS } from '../constants'; 4 | import { MetricsService } from './metrics.service'; 5 | 6 | const mockPushAdd = jest.fn().mockResolvedValue( undefined ); 7 | 8 | jest.mock( 'prom-client', () => { 9 | const actual = jest.requireActual( 'prom-client' ); 10 | return { 11 | ...actual, 12 | Pushgateway: jest.fn().mockImplementation( () => ( { 13 | pushAdd: mockPushAdd 14 | } ) ) 15 | }; 16 | } ); 17 | 18 | describe( 'MetricsService', () => { 19 | let service: MetricsService; 20 | let registry: Registry; 21 | 22 | const mockConfig = { 23 | pushgatewayUrl: 'http://test-pushgateway:9091' 24 | }; 25 | 26 | beforeEach( async () => { 27 | mockPushAdd.mockClear(); 28 | 29 | registry = new Registry(); 30 | const module: TestingModule = await Test.createTestingModule( { 31 | providers: [ 32 | MetricsService, 33 | { 34 | provide: Registry, 35 | useValue: registry, 36 | }, 37 | { 38 | provide: CONFIG_OPTIONS, 39 | useValue: mockConfig, 40 | }, 41 | ], 42 | } ).compile(); 43 | 44 | service = module.get( MetricsService ); 45 | } ); 46 | 47 | afterEach( () => { 48 | registry.clear(); 49 | jest.clearAllMocks(); 50 | } ); 51 | 52 | describe( 'constructor', () => { 53 | it( 'should initialize only in case a pushgateway URL is provided', () => { 54 | expect( Pushgateway ).toHaveBeenCalledWith( 'http://test-pushgateway:9091', [], registry ); 55 | } ); 56 | } ); 57 | 58 | describe( 'pushMetrics', () => { 59 | it( 'should successfully push metrics', async () => { 60 | mockPushAdd.mockResolvedValueOnce( undefined ); 61 | 62 | const result = await service.pushMetrics( 'test-job' ); 63 | 64 | expect( mockPushAdd ).toHaveBeenCalledWith( { jobName: 'test-job' } ); 65 | expect( result ).toEqual( { 66 | status: 200, 67 | success: true 68 | } ); 69 | } ); 70 | 71 | it( 'should handle push failure with Error object', async () => { 72 | const errorMessage = 'Failed to push metrics'; 73 | mockPushAdd.mockRejectedValueOnce( new Error( errorMessage ) ); 74 | 75 | const result = await service.pushMetrics( 'test-job' ); 76 | 77 | expect( result ).toEqual( { 78 | status: 500, 79 | success: false, 80 | message: errorMessage 81 | } ); 82 | } ); 83 | 84 | it( 'should handle push failure with string error', async () => { 85 | const errorMessage = 'Network error'; 86 | mockPushAdd.mockRejectedValueOnce( errorMessage ); 87 | 88 | const result = await service.pushMetrics( 'test-job' ); 89 | 90 | expect( result ).toEqual( { 91 | status: 500, 92 | success: false, 93 | message: errorMessage 94 | } ); 95 | } ); 96 | } ); 97 | 98 | describe( 'incCounter', () => { 99 | it( 'should create and increment counter without labels', async () => { 100 | service.incCounter( 'test_counter' ); 101 | const metrics = await registry.metrics(); 102 | expect( metrics ).toContain( '# HELP test_counter Counter for test_counter' ); 103 | expect( metrics ).toContain( '# TYPE test_counter counter' ); 104 | expect( metrics ).toContain( 'test_counter 1' ); 105 | } ); 106 | 107 | it( 'should create and increment counter with labels', async () => { 108 | service.incCounter( 'test_counter', { method: 'GET' } ); 109 | const metrics = await registry.metrics(); 110 | expect( metrics ).toContain( '# HELP test_counter Counter for test_counter' ); 111 | expect( metrics ).toContain( '# TYPE test_counter counter' ); 112 | expect( metrics ).toContain( 'test_counter{method="GET"} 1' ); 113 | } ); 114 | 115 | it( 'should increment existing counter', async () => { 116 | service.incCounter( 'test_counter' ); 117 | service.incCounter( 'test_counter' ); 118 | const metrics = await registry.metrics(); 119 | expect( metrics ).toContain( '# HELP test_counter Counter for test_counter' ); 120 | expect( metrics ).toContain( '# TYPE test_counter counter' ); 121 | expect( metrics ).toContain( 'test_counter 2' ); 122 | } ); 123 | 124 | it( 'should handle invalid labels', () => { 125 | expect( () => { 126 | service.incCounter( 'test_counter', { '': 'invalid' } ); 127 | } ).toThrow(); 128 | } ); 129 | 130 | it( 'should increment counter with custom value', async () => { 131 | service.incCounter( 'test_counter', {}, 5 ); 132 | const metrics = await registry.metrics(); 133 | expect( metrics ).toContain( 'test_counter 5' ); 134 | } ); 135 | 136 | it( 'should increment counter with custom value and labels', async () => { 137 | service.incCounter( 'test_counter', { method: 'GET' }, 3 ); 138 | const metrics = await registry.metrics(); 139 | expect( metrics ).toContain( 'test_counter{method="GET"} 3' ); 140 | } ); 141 | 142 | it( 'should accumulate custom values correctly', async () => { 143 | service.incCounter( 'test_counter', { method: 'GET' }, 3 ); 144 | service.incCounter( 'test_counter', { method: 'GET' }, 2 ); 145 | const metrics = await registry.metrics(); 146 | expect( metrics ).toContain( 'test_counter{method="GET"} 5' ); 147 | } ); 148 | } ); 149 | 150 | describe( 'setGauge', () => { 151 | it( 'should create and set gauge', async () => { 152 | service.setGauge( 'test_gauge', 42 ); 153 | const metrics = await registry.metrics(); 154 | expect( metrics ).toContain( '# HELP test_gauge Gauge for test_gauge' ); 155 | expect( metrics ).toContain( '# TYPE test_gauge gauge' ); 156 | expect( metrics ).toContain( 'test_gauge 42' ); 157 | } ); 158 | 159 | it( 'should create and set gauge with labels', async () => { 160 | service.setGauge( 'test_gauge', 42, { region: 'us' } ); 161 | const metrics = await registry.metrics(); 162 | expect( metrics ).toContain( '# HELP test_gauge Gauge for test_gauge' ); 163 | expect( metrics ).toContain( '# TYPE test_gauge gauge' ); 164 | expect( metrics ).toContain( 'test_gauge{region="us"} 42' ); 165 | } ); 166 | 167 | it( 'should update existing gauge value', async () => { 168 | service.setGauge( 'test_gauge', 42 ); 169 | service.setGauge( 'test_gauge', 84 ); 170 | const metrics = await registry.metrics(); 171 | expect( metrics ).toContain( '# HELP test_gauge Gauge for test_gauge' ); 172 | expect( metrics ).toContain( '# TYPE test_gauge gauge' ); 173 | expect( metrics ).toContain( 'test_gauge 84' ); 174 | } ); 175 | 176 | it( 'should handle negative values', async () => { 177 | service.setGauge( 'test_gauge', -42 ); 178 | const metrics = await registry.metrics(); 179 | expect( metrics ).toContain( '# HELP test_gauge Gauge for test_gauge' ); 180 | expect( metrics ).toContain( '# TYPE test_gauge gauge' ); 181 | expect( metrics ).toContain( 'test_gauge -42' ); 182 | } ); 183 | } ); 184 | 185 | describe( 'observeHistogram', () => { 186 | it( 'should create and observe histogram without labels', async () => { 187 | service.observeHistogram( 'test_histogram', 0.5 ); 188 | const metrics = await registry.metrics(); 189 | expect( metrics ).toContain( '# HELP test_histogram Histogram for test_histogram' ); 190 | expect( metrics ).toContain( '# TYPE test_histogram histogram' ); 191 | expect( metrics ).toContain( 'test_histogram_sum 0.5' ); 192 | expect( metrics ).toContain( 'test_histogram_count 1' ); 193 | } ); 194 | 195 | it( 'should create and observe histogram with labels', async () => { 196 | service.observeHistogram( 'test_histogram', 0.5, { method: 'GET' } ); 197 | const metrics = await registry.metrics(); 198 | expect( metrics ).toContain( '# HELP test_histogram Histogram for test_histogram' ); 199 | expect( metrics ).toContain( '# TYPE test_histogram histogram' ); 200 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.1",method="GET"} 0' ); 201 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.5",method="GET"} 1' ); 202 | expect( metrics ).toContain( 'test_histogram_sum{method="GET"} 0.5' ); 203 | expect( metrics ).toContain( 'test_histogram_count{method="GET"} 1' ); 204 | } ); 205 | 206 | it( 'should create histogram with custom buckets', async () => { 207 | service.observeHistogram( 'test_histogram', 0.5, {}, [0.1, 0.3, 0.5, 1.0] ); 208 | const metrics = await registry.metrics(); 209 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.1"} 0' ); 210 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.3"} 0' ); 211 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.5"} 1' ); 212 | expect( metrics ).toContain( 'test_histogram_bucket{le="1"} 1' ); 213 | expect( metrics ).toContain( 'test_histogram_count 1' ); 214 | } ); 215 | } ); 216 | 217 | describe( 'observeSummary', () => { 218 | it( 'should create and observe summary without labels', async () => { 219 | service.observeSummary( 'test_summary', 100 ); 220 | const metrics = await registry.metrics(); 221 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 222 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 223 | expect( metrics ).toContain( 'test_summary_sum 100' ); 224 | expect( metrics ).toContain( 'test_summary_count 1' ); 225 | } ); 226 | 227 | it( 'should create and observe summary with labels', async () => { 228 | service.observeSummary( 'test_summary', 100, { endpoint: '/api' } ); 229 | const metrics = await registry.metrics(); 230 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 231 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 232 | expect( metrics ).toContain( 'test_summary_sum{endpoint="/api"} 100' ); 233 | expect( metrics ).toContain( 'test_summary_count{endpoint="/api"} 1' ); 234 | } ); 235 | 236 | it( 'should create summary with custom percentiles', async () => { 237 | service.observeSummary( 'test_summary', 100, {}, [0.5, 0.9] ); 238 | const metrics = await registry.metrics(); 239 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 240 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 241 | expect( metrics ).toContain( 'test_summary{quantile="0.5"}' ); 242 | expect( metrics ).toContain( 'test_summary{quantile="0.9"}' ); 243 | expect( metrics ).toContain( 'test_summary_sum 100' ); 244 | expect( metrics ).toContain( 'test_summary_count 1' ); 245 | } ); 246 | } ); 247 | } ); -------------------------------------------------------------------------------- /src/reporter/reporter.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { Registry } from 'prom-client'; 3 | import { Logger } from '@nestjs/common'; 4 | import { CONFIG_OPTIONS } from '../constants'; 5 | import { ReporterService } from './reporter.service'; 6 | import { MetricsService } from '../metrics/metrics.service'; 7 | 8 | describe( 'ReporterService', () => { 9 | let registry: Registry; 10 | let loggerSpy: jest.SpyInstance; 11 | let reporterService: ReporterService; 12 | let metricsService: MetricsService; 13 | let pushMetricsSpy: jest.SpyInstance; 14 | 15 | beforeEach( async () => { 16 | registry = new Registry(); 17 | const module: TestingModule = await Test.createTestingModule( { 18 | providers: [ 19 | MetricsService, 20 | ReporterService, 21 | { 22 | provide: Registry, 23 | useValue: registry, 24 | }, 25 | { 26 | provide: CONFIG_OPTIONS, 27 | useValue: { 28 | pushgatewayUrl: 'http://localhost:9091', 29 | pushgatewayOptions: {} 30 | } 31 | } 32 | ], 33 | } ).compile(); 34 | 35 | reporterService = module.get( ReporterService ); 36 | metricsService = module.get( MetricsService ); 37 | 38 | reporterService.onApplicationBootstrap(); 39 | 40 | loggerSpy = jest.spyOn( Logger.prototype, 'error' ); 41 | pushMetricsSpy = jest.spyOn( metricsService, 'pushMetrics' ); 42 | } ); 43 | 44 | afterEach( () => { 45 | registry.clear(); 46 | jest.clearAllMocks(); 47 | ( ReporterService as any ).metricsService = undefined; 48 | } ); 49 | 50 | describe( 'counter', () => { 51 | it( 'should increment counter through static method', async () => { 52 | ReporterService.counter( 'test_counter', { method: 'POST' } ); 53 | const metrics = await registry.metrics(); 54 | expect( metrics ).toContain( 'test_counter{method="POST"} 1' ); 55 | } ); 56 | 57 | it( 'should handle errors gracefully', () => { 58 | ReporterService.counter( undefined as any, {} ); 59 | 60 | expect( loggerSpy ).toHaveBeenCalledWith( { 61 | message: 'Failed to increment counter', 62 | metric: undefined, 63 | labels: {}, 64 | error: 'Missing mandatory name parameter', 65 | } ); 66 | } ); 67 | 68 | it( 'should work without labels', async () => { 69 | ReporterService.counter( 'test_counter' ); 70 | const metrics = await registry.metrics(); 71 | expect( metrics ).toContain( 'test_counter 1' ); 72 | } ); 73 | 74 | it( 'should increment counter with custom value', async () => { 75 | ReporterService.counter( 'test_counter', undefined, 5 ); 76 | const metrics = await registry.metrics(); 77 | expect( metrics ).toContain( 'test_counter 5' ); 78 | } ); 79 | 80 | it( 'should increment counter with custom value and labels', async () => { 81 | ReporterService.counter( 'test_counter', { method: 'POST' }, 3 ); 82 | const metrics = await registry.metrics(); 83 | expect( metrics ).toContain( 'test_counter{method="POST"} 3' ); 84 | } ); 85 | 86 | it( 'should accumulate custom values correctly', async () => { 87 | ReporterService.counter( 'test_counter', { method: 'POST' }, 3 ); 88 | ReporterService.counter( 'test_counter', { method: 'POST' }, 2 ); 89 | const metrics = await registry.metrics(); 90 | expect( metrics ).toContain( 'test_counter{method="POST"} 5' ); 91 | } ); 92 | } ); 93 | 94 | describe( 'gauge', () => { 95 | it( 'should set gauge through static method', async () => { 96 | ReporterService.gauge( 'test_gauge', 42, { region: 'eu' } ); 97 | const metrics = await registry.metrics(); 98 | expect( metrics ).toContain( 'test_gauge{region="eu"} 42' ); 99 | } ); 100 | 101 | it( 'should handle errors gracefully', () => { 102 | ReporterService.gauge( undefined as any, 42, {} ); 103 | expect( loggerSpy ).toHaveBeenCalledWith( { 104 | message: 'Failed to set gauge', 105 | metric: undefined, 106 | labels: {}, 107 | error: 'Missing mandatory name parameter', 108 | } ); 109 | } ); 110 | 111 | it( 'should work without labels', async () => { 112 | ReporterService.gauge( 'test_gauge', 42 ); 113 | const metrics = await registry.metrics(); 114 | expect( metrics ).toContain( 'test_gauge 42' ); 115 | } ); 116 | } ); 117 | 118 | describe( 'histogram', () => { 119 | it( 'should observe histogram through static method', async () => { 120 | ReporterService.histogram( 'test_histogram', 0.5, { path: '/api' } ); 121 | const metrics = await registry.metrics(); 122 | expect( metrics ).toContain( '# HELP test_histogram Histogram for test_histogram' ); 123 | expect( metrics ).toContain( '# TYPE test_histogram histogram' ); 124 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.5",path="/api"} 1' ); 125 | expect( metrics ).toContain( 'test_histogram_sum{path="/api"} 0.5' ); 126 | expect( metrics ).toContain( 'test_histogram_count{path="/api"} 1' ); 127 | } ); 128 | 129 | it( 'should handle errors gracefully', () => { 130 | ReporterService.histogram( undefined as any, 0.5, {} ); 131 | expect( loggerSpy ).toHaveBeenCalledWith( { 132 | message: 'Failed to observe histogram', 133 | metric: undefined, 134 | labels: {}, 135 | error: 'Missing mandatory name parameter', 136 | } ); 137 | } ); 138 | 139 | it( 'should work without labels', async () => { 140 | ReporterService.histogram( 'test_histogram', 0.5 ); 141 | const metrics = await registry.metrics(); 142 | expect( metrics ).toContain( '# HELP test_histogram Histogram for test_histogram' ); 143 | expect( metrics ).toContain( '# TYPE test_histogram histogram' ); 144 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.5"} 1' ); 145 | expect( metrics ).toContain( 'test_histogram_sum 0.5' ); 146 | expect( metrics ).toContain( 'test_histogram_count 1' ); 147 | } ); 148 | 149 | it( 'should work with custom buckets', async () => { 150 | ReporterService.histogram( 'test_histogram', 0.5, {}, [0.1, 0.5, 1.0] ); 151 | const metrics = await registry.metrics(); 152 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.1"} 0' ); 153 | expect( metrics ).toContain( 'test_histogram_bucket{le="0.5"} 1' ); 154 | expect( metrics ).toContain( 'test_histogram_bucket{le="1"} 1' ); 155 | expect( metrics ).toContain( 'test_histogram_sum 0.5' ); 156 | expect( metrics ).toContain( 'test_histogram_count 1' ); 157 | } ); 158 | } ); 159 | 160 | describe( 'summary', () => { 161 | it( 'should observe summary through static method', async () => { 162 | ReporterService.summary( 'test_summary', 100, { endpoint: '/users' } ); 163 | const metrics = await registry.metrics(); 164 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 165 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 166 | expect( metrics ).toContain( 'test_summary_sum{endpoint="/users"} 100' ); 167 | expect( metrics ).toContain( 'test_summary_count{endpoint="/users"} 1' ); 168 | } ); 169 | 170 | it( 'should handle errors gracefully', () => { 171 | ReporterService.summary( undefined as any, 100, {} ); 172 | expect( loggerSpy ).toHaveBeenCalledWith( { 173 | message: 'Failed to observe summary', 174 | metric: undefined, 175 | labels: {}, 176 | error: 'Missing mandatory name parameter', 177 | } ); 178 | } ); 179 | 180 | it( 'should work without labels', async () => { 181 | ReporterService.summary( 'test_summary', 100 ); 182 | const metrics = await registry.metrics(); 183 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 184 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 185 | expect( metrics ).toContain( 'test_summary_sum 100' ); 186 | expect( metrics ).toContain( 'test_summary_count 1' ); 187 | } ); 188 | 189 | it( 'should work with custom percentiles', async () => { 190 | ReporterService.summary( 'test_summary', 100, {}, [0.5, 0.9] ); 191 | const metrics = await registry.metrics(); 192 | expect( metrics ).toContain( '# HELP test_summary Summary for test_summary' ); 193 | expect( metrics ).toContain( '# TYPE test_summary summary' ); 194 | expect( metrics ).toContain( 'test_summary{quantile="0.5"}' ); 195 | expect( metrics ).toContain( 'test_summary{quantile="0.9"}' ); 196 | expect( metrics ).toContain( 'test_summary_sum 100' ); 197 | expect( metrics ).toContain( 'test_summary_count 1' ); 198 | } ); 199 | 200 | it( 'should handle multiple observations', async () => { 201 | ReporterService.summary( 'test_summary', 100 ); 202 | ReporterService.summary( 'test_summary', 200 ); 203 | const metrics = await registry.metrics(); 204 | expect( metrics ).toContain( 'test_summary_sum 300' ); 205 | expect( metrics ).toContain( 'test_summary_count 2' ); 206 | } ); 207 | 208 | it( 'should handle multiple observations with labels', async () => { 209 | ReporterService.summary( 'test_summary', 100, { endpoint: '/api' } ); 210 | ReporterService.summary( 'test_summary', 200, { endpoint: '/api' } ); 211 | const metrics = await registry.metrics(); 212 | expect( metrics ).toContain( 'test_summary_sum{endpoint="/api"} 300' ); 213 | expect( metrics ).toContain( 'test_summary_count{endpoint="/api"} 2' ); 214 | } ); 215 | } ); 216 | 217 | describe( 'pushMetrics', () => { 218 | it( 'should successfully push metrics', async () => { 219 | pushMetricsSpy.mockResolvedValue( { status: 200, success: true } ); 220 | 221 | await ReporterService.pushMetrics( 'test-job' ); 222 | 223 | expect( pushMetricsSpy ).toHaveBeenCalledWith( 'test-job' ); 224 | expect( loggerSpy ).not.toHaveBeenCalled(); 225 | } ); 226 | 227 | it( 'should handle pushMetrics failure', async () => { 228 | const error = new Error( 'Push failed' ); 229 | pushMetricsSpy.mockRejectedValue( error ); 230 | 231 | await ReporterService.pushMetrics( 'test-job' ); 232 | 233 | expect( pushMetricsSpy ).toHaveBeenCalledWith( 'test-job' ); 234 | expect( loggerSpy ).toHaveBeenCalledWith( 'Error pushing metrics: Error: Push failed' ); 235 | } ); 236 | 237 | it( 'should handle pushgateway not configured', async () => { 238 | const moduleWithoutPushgateway: TestingModule = await Test.createTestingModule( { 239 | providers: [ 240 | MetricsService, 241 | ReporterService, 242 | { 243 | provide: Registry, 244 | useValue: registry, 245 | }, 246 | { 247 | provide: CONFIG_OPTIONS, 248 | useValue: {} 249 | } 250 | ], 251 | } ).compile(); 252 | 253 | const newReporterService = moduleWithoutPushgateway.get( ReporterService ); 254 | const newMetricsService = moduleWithoutPushgateway.get( MetricsService ); 255 | newReporterService.onApplicationBootstrap(); 256 | 257 | const newPushMetricsSpy = jest.spyOn( newMetricsService, 'pushMetrics' ); 258 | newPushMetricsSpy.mockResolvedValue( { 259 | status: 400, 260 | success: false, 261 | message: 'Pushgateway is not configured' 262 | } ); 263 | 264 | ( ReporterService as any ).metricsService = newMetricsService; 265 | 266 | await ReporterService.pushMetrics( 'test-job' ); 267 | 268 | expect( newPushMetricsSpy ).toHaveBeenCalledWith( 'test-job' ); 269 | expect( loggerSpy ).not.toHaveBeenCalled(); 270 | } ); 271 | 272 | it( 'should handle non-Error objects in error message', async () => { 273 | const nonErrorObject = 'String error message'; 274 | pushMetricsSpy.mockRejectedValue( nonErrorObject ); 275 | 276 | await ReporterService.pushMetrics( 'test-job' ); 277 | 278 | expect( pushMetricsSpy ).toHaveBeenCalledWith( 'test-job' ); 279 | expect( loggerSpy ).toHaveBeenCalledWith( 'Error pushing metrics: String error message' ); 280 | } ); 281 | } ); 282 | } ); 283 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------