diff --git a/README.md b/README.md index d0e0613..2859110 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ const OpenTelemetryModuleConfig = OpenTelemetryModule.forRoot({ }, ignoreRoutes: ['/favicon.ico'], // You can ignore specific routes (See https://docs.nestjs.com/middleware#excluding-routes for options) ignoreUndefinedRoutes: false, //Records metrics for all URLs, even undefined ones + prefix: 'my_prefix', // Add a custom prefix to all API metrics }, }, }); diff --git a/src/interfaces/metric-options.interface.ts b/src/interfaces/metric-options.interface.ts new file mode 100644 index 0000000..00281df --- /dev/null +++ b/src/interfaces/metric-options.interface.ts @@ -0,0 +1,8 @@ +import { MetricOptions } from '@opentelemetry/api'; + +export interface OtelMetricOptions extends MetricOptions { + /** + * A prefix to add to the name of the metric. + */ + prefix?: string; +} diff --git a/src/interfaces/opentelemetry-options.interface.ts b/src/interfaces/opentelemetry-options.interface.ts index 505386b..b6e532d 100644 --- a/src/interfaces/opentelemetry-options.interface.ts +++ b/src/interfaces/opentelemetry-options.interface.ts @@ -48,5 +48,6 @@ export type OpenTelemetryMetrics = { defaultAttributes?: Attributes; ignoreRoutes?: (string | RouteInfo)[]; ignoreUndefinedRoutes?: boolean; + prefix?: string, }; }; diff --git a/src/metrics/decorators/common.ts b/src/metrics/decorators/common.ts index 2ee4a88..a2b4fd9 100644 --- a/src/metrics/decorators/common.ts +++ b/src/metrics/decorators/common.ts @@ -1,6 +1,7 @@ -import { Counter, MetricOptions } from '@opentelemetry/api'; +import { Counter } from '@opentelemetry/api'; import { copyMetadataFromFunctionToFunction } from '../../opentelemetry.utils'; import { getOrCreateCounter } from '../metric-data'; +import { OtelMetricOptions } from '../../interfaces/metric-options.interface'; /** * Create and increment a counter when a new instance is created @@ -8,7 +9,7 @@ import { getOrCreateCounter } from '../metric-data'; * @param originalClass */ export const OtelInstanceCounter = - (options?: MetricOptions) => + (options?: OtelMetricOptions) => (originalClass: T) => { const name = `app_${originalClass.name}_instances_total`; const description = `app_${originalClass.name} object instances total`; @@ -34,7 +35,7 @@ export const OtelInstanceCounter = * Create and increment a counter when the method is called */ export const OtelMethodCounter = - (options?: MetricOptions) => + (options?: OtelMetricOptions) => ( target: Object, propertyKey: string | symbol, diff --git a/src/metrics/decorators/counter.ts b/src/metrics/decorators/counter.ts index 885fc79..601d7ec 100644 --- a/src/metrics/decorators/counter.ts +++ b/src/metrics/decorators/counter.ts @@ -1,17 +1,19 @@ import { createParamDecorator } from '@nestjs/common'; -import { MetricOptions } from '@opentelemetry/api'; -import { getOrCreateCounter, MetricType } from '../metric-data'; +import { getOrCreateCounter } from '../metric-data'; +import { OtelMetricOptions } from '../../interfaces/metric-options.interface'; -export const OtelCounter = createParamDecorator((name: string, options?: MetricOptions) => { +export const OtelCounter = createParamDecorator((name: string, options?: OtelMetricOptions) => { if (!name || name.length === 0) { throw new Error('OtelCounter need a name argument'); } return getOrCreateCounter(name, options); }); -export const OtelUpDownCounter = createParamDecorator((name: string, options?: MetricOptions) => { - if (!name || name.length === 0) { - throw new Error('OtelUpDownCounter need a name argument'); - } - return getOrCreateCounter(name, options); -}); +export const OtelUpDownCounter = createParamDecorator( + (name: string, options?: OtelMetricOptions) => { + if (!name || name.length === 0) { + throw new Error('OtelUpDownCounter need a name argument'); + } + return getOrCreateCounter(name, options); + }, +); diff --git a/src/metrics/decorators/histogram.ts b/src/metrics/decorators/histogram.ts index 62785d1..c38e1e7 100644 --- a/src/metrics/decorators/histogram.ts +++ b/src/metrics/decorators/histogram.ts @@ -1,8 +1,8 @@ import { createParamDecorator } from '@nestjs/common'; -import { MetricOptions } from '@opentelemetry/api'; import { getOrCreateHistogram } from '../metric-data'; +import { OtelMetricOptions } from '../../interfaces/metric-options.interface'; -export const OtelHistogram = createParamDecorator((name: string, options?: MetricOptions) => { +export const OtelHistogram = createParamDecorator((name: string, options?: OtelMetricOptions) => { if (!name || name.length === 0) { throw new Error('OtelHistogram need a name argument'); } diff --git a/src/metrics/decorators/observable.ts b/src/metrics/decorators/observable.ts index 9ef9a30..346b3a8 100644 --- a/src/metrics/decorators/observable.ts +++ b/src/metrics/decorators/observable.ts @@ -1,20 +1,22 @@ import { createParamDecorator } from '@nestjs/common'; -import { MetricOptions } from '@opentelemetry/api'; import { getOrCreateObservableCounter, getOrCreateObservableGauge, getOrCreateObservableUpDownCounter, } from '../metric-data'; +import { OtelMetricOptions } from '../../interfaces/metric-options.interface'; -export const OtelObservableGauge = createParamDecorator((name: string, options?: MetricOptions) => { - if (!name || name.length === 0) { - throw new Error('OtelObservableGauge need a name argument'); - } - return getOrCreateObservableGauge(name, options); -}); +export const OtelObservableGauge = createParamDecorator( + (name: string, options?: OtelMetricOptions) => { + if (!name || name.length === 0) { + throw new Error('OtelObservableGauge need a name argument'); + } + return getOrCreateObservableGauge(name, options); + }, +); export const OtelObservableCounter = createParamDecorator( - (name: string, options?: MetricOptions) => { + (name: string, options?: OtelMetricOptions) => { if (!name || name.length === 0) { throw new Error('OtelObservableCounter need a name argument'); } @@ -23,7 +25,7 @@ export const OtelObservableCounter = createParamDecorator( ); export const OtelObservableUpDownCounter = createParamDecorator( - (name: string, options?: MetricOptions) => { + (name: string, options?: OtelMetricOptions) => { if (!name || name.length === 0) { throw new Error('OtelObservableUpDownCounter need a name argument'); } diff --git a/src/metrics/metric-data.ts b/src/metrics/metric-data.ts index 7214fb6..060402a 100644 --- a/src/metrics/metric-data.ts +++ b/src/metrics/metric-data.ts @@ -1,6 +1,5 @@ import { Counter, - MetricOptions, UpDownCounter, Histogram, ObservableGauge, @@ -9,6 +8,7 @@ import { metrics, } from '@opentelemetry/api'; import { OTEL_METER_NAME } from '../opentelemetry.constants'; +import { OtelMetricOptions } from '../interfaces/metric-options.interface'; export type GenericMetric = | Counter @@ -29,82 +29,53 @@ export enum MetricType { export const meterData: Map = new Map(); -export function getOrCreateHistogram(name: string, options: MetricOptions = {}): Histogram { - if (meterData.has(name)) { - return meterData.get(name) as Histogram; +function getOrCreate( + name: string, + options: OtelMetricOptions = {}, + type: MetricType +): GenericMetric | undefined { + const nameWithPrefix = options.prefix ? `${options.prefix}.${name}` : name; + let metric = meterData.get(nameWithPrefix); + if (metric === undefined) { + const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); + metric = meter[`create${type}`](nameWithPrefix, options); + meterData.set(nameWithPrefix, metric); } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); - const histogram = meter.createHistogram(name, options); - meterData.set(name, histogram); - return histogram; + return metric; } -export function getOrCreateCounter(name: string, options: MetricOptions = {}): Counter { - if (meterData.has(name)) { - return meterData.get(name) as Counter; - } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); - - const counter = meter.createCounter(name, options); - meterData.set(name, counter); - return counter; +export function getOrCreateHistogram(name: string, options: OtelMetricOptions = {}): Histogram { + return getOrCreate(name, options, MetricType.Histogram) as Histogram; } -export function getOrCreateUpDownCounter(name: string, options: MetricOptions = {}): UpDownCounter { - if (meterData.has(name)) { - return meterData.get(name) as UpDownCounter; - } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); +export function getOrCreateCounter(name: string, options: OtelMetricOptions = {}): Counter { + return getOrCreate(name, options, MetricType.Counter) as Counter; +} - const upDownCounter = meter.createUpDownCounter(name, options); - meterData.set(name, upDownCounter); - return upDownCounter; +export function getOrCreateUpDownCounter( + name: string, + options: OtelMetricOptions = {} +): UpDownCounter { + return getOrCreate(name, options, MetricType.UpDownCounter) as UpDownCounter; } export function getOrCreateObservableGauge( name: string, - options: MetricOptions = {} + options: OtelMetricOptions = {} ): ObservableGauge { - if (meterData.has(name)) { - return meterData.get(name) as ObservableGauge; - } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); - - const observableGauge = meter.createObservableGauge(name, options); - meterData.set(name, observableGauge); - return observableGauge; + return getOrCreate(name, options, MetricType.ObservableGauge) as ObservableGauge; } export function getOrCreateObservableCounter( name: string, - options: MetricOptions = {} + options: OtelMetricOptions = {} ): ObservableCounter { - if (meterData.has(name)) { - return meterData.get(name) as ObservableCounter; - } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); - - const observableCounter = meter.createObservableCounter(name, options); - meterData.set(name, observableCounter); - return observableCounter; + return getOrCreate(name, options, MetricType.ObservableCounter) as ObservableCounter; } export function getOrCreateObservableUpDownCounter( name: string, - options: MetricOptions = {} + options: OtelMetricOptions = {} ): ObservableUpDownCounter { - if (meterData.has(name)) { - return meterData.get(name) as ObservableUpDownCounter; - } - - const meter = metrics.getMeterProvider().getMeter(OTEL_METER_NAME); - - const observableCounter = meter.createObservableCounter(name, options); - meterData.set(name, observableCounter); - return observableCounter; + return getOrCreate(name, options, MetricType.ObservableUpDownCounter) as ObservableUpDownCounter; } diff --git a/src/metrics/metric.service.ts b/src/metrics/metric.service.ts index e79260d..15f4665 100644 --- a/src/metrics/metric.service.ts +++ b/src/metrics/metric.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { MetricOptions } from '@opentelemetry/api'; import { getOrCreateCounter, getOrCreateHistogram, @@ -8,30 +7,31 @@ import { getOrCreateObservableUpDownCounter, getOrCreateUpDownCounter, } from './metric-data'; +import { OtelMetricOptions } from '../interfaces/metric-options.interface'; @Injectable() export class MetricService { - getCounter(name: string, options?: MetricOptions) { + getCounter(name: string, options?: OtelMetricOptions) { return getOrCreateCounter(name, options); } - getUpDownCounter(name: string, options?: MetricOptions) { + getUpDownCounter(name: string, options?: OtelMetricOptions) { return getOrCreateUpDownCounter(name, options); } - getHistogram(name: string, options?: MetricOptions) { + getHistogram(name: string, options?: OtelMetricOptions) { return getOrCreateHistogram(name, options); } - getObservableCounter(name: string, options?: MetricOptions) { + getObservableCounter(name: string, options?: OtelMetricOptions) { return getOrCreateObservableCounter(name, options); } - getObservableGauge(name: string, options?: MetricOptions) { + getObservableGauge(name: string, options?: OtelMetricOptions) { return getOrCreateObservableGauge(name, options); } - getObservableUpDownCounter(name: string, options?: MetricOptions) { + getObservableUpDownCounter(name: string, options?: OtelMetricOptions) { return getOrCreateObservableUpDownCounter(name, options); } } diff --git a/src/middleware/api-metrics.middleware.ts b/src/middleware/api-metrics.middleware.ts index 6e8997f..9bbf869 100644 --- a/src/middleware/api-metrics.middleware.ts +++ b/src/middleware/api-metrics.middleware.ts @@ -34,7 +34,7 @@ export class ApiMetricsMiddleware implements NestMiddleware { @Inject(MetricService) private readonly metricService: MetricService, @Inject(OPENTELEMETRY_MODULE_OPTIONS) private readonly options: OpenTelemetryModuleOptions = {} ) { - const { defaultAttributes = {}, ignoreUndefinedRoutes = false } = + const { defaultAttributes = {}, ignoreUndefinedRoutes = false, prefix } = options?.metrics?.apiMetrics ?? {}; this.defaultAttributes = defaultAttributes; @@ -44,31 +44,37 @@ export class ApiMetricsMiddleware implements NestMiddleware { this.httpServerRequestCount = this.metricService.getCounter('http.server.request.count', { description: 'Total number of HTTP requests', unit: 'requests', + prefix, }); this.httpServerResponseCount = this.metricService.getCounter('http.server.response.count', { description: 'Total number of HTTP responses', unit: 'responses', + prefix, }); this.httpServerAbortCount = this.metricService.getCounter('http.server.abort.count', { description: 'Total number of data transfers aborted', unit: 'requests', + prefix, }); this.httpServerDuration = this.metricService.getHistogram('http.server.duration', { description: 'The duration of the inbound HTTP request', unit: 'ms', + prefix, }); this.httpServerRequestSize = this.metricService.getHistogram('http.server.request.size', { description: 'Size of incoming bytes', unit: 'By', + prefix, }); this.httpServerResponseSize = this.metricService.getHistogram('http.server.response.size', { description: 'Size of outgoing bytes', unit: 'By', + prefix, }); // Helpers @@ -77,21 +83,24 @@ export class ApiMetricsMiddleware implements NestMiddleware { { description: 'Total number of all successful responses', unit: 'responses', - } + prefix, + } ); this.httpServerResponseErrorCount = this.metricService.getCounter( 'http.server.response.error.count', { description: 'Total number of all response errors', - } + prefix, + } ); this.httpClientRequestErrorCount = this.metricService.getCounter( 'http.client.request.error.count', { description: 'Total number of client error requests', - } + prefix, + } ); } diff --git a/tests/e2e/metrics/metric.service.spec.ts b/tests/e2e/metrics/metric.service.spec.ts index 08ad143..1d20b9c 100644 --- a/tests/e2e/metrics/metric.service.spec.ts +++ b/tests/e2e/metrics/metric.service.spec.ts @@ -116,6 +116,32 @@ describe('MetricService', () => { // @ts-ignore expect(existingCounter._descriptor.description).toBe('test1 description'); }); + + it('uses prefix when provided', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [OpenTelemetryModule.forRoot({ + metrics: { + apiMetrics: { + enable: false, + }, + }, + })], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + metricService = moduleRef.get(MetricService); + // Starts empty + expect(meterData.size).toBe(0); + + const counter = metricService.getCounter('test1', { prefix: 'test_prefix' }); + counter.add(1); + + // Has new key record + const data = meterData; + expect(data.has('test_prefix.test1')).toBeTruthy(); + }); }); describe('getUpDownCounter', () => { @@ -175,6 +201,32 @@ describe('MetricService', () => { // @ts-ignore expect(existingCounter._descriptor.description).toBe('test1 description'); }); + + it('uses prefix when provided', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [OpenTelemetryModule.forRoot({ + metrics: { + apiMetrics: { + enable: false, + }, + }, + })], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + metricService = moduleRef.get(MetricService); + // Starts empty + expect(meterData.size).toBe(0); + + const counter = metricService.getUpDownCounter('test1', { prefix: 'test_prefix' }); + counter.add(1); + + // Has new key record + const data = meterData; + expect(data.has('test_prefix.test1')).toBeTruthy(); + }); }); describe('getHistogram', () => { @@ -232,5 +284,30 @@ describe('MetricService', () => { // @ts-ignore expect(existingCounter._descriptor.description).toBe('test1 description'); }); + + it('uses prefix when provided', async () => { + const moduleRef = await Test.createTestingModule({ + imports: [OpenTelemetryModule.forRoot({ + metrics: { + apiMetrics: { + enable: false, + }, + }, + })], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + metricService = moduleRef.get(MetricService); + // Starts empty + expect(meterData.size).toBe(0); + + metricService.getHistogram('test1', { prefix: 'test_prefix' }); + + // Has new key record + const data = meterData; + expect(data.has('test_prefix.test1')).toBeTruthy(); + }); }); }); diff --git a/tests/e2e/middleware/api-metrics.middleware.spec.ts b/tests/e2e/middleware/api-metrics.middleware.spec.ts index 78ecd96..6fcbed4 100644 --- a/tests/e2e/middleware/api-metrics.middleware.spec.ts +++ b/tests/e2e/middleware/api-metrics.middleware.spec.ts @@ -108,6 +108,60 @@ describe('Api Metrics Middleware', () => { }); }); + it('uses prefix when provided', async () => { + const testingModule = await Test.createTestingModule({ + imports: [OpenTelemetryModule.forRoot({ + metrics: { + apiMetrics: { + enable: true, + prefix: 'test_prefix', + }, + }, + })], + }).overrideProvider(MetricService) + .useValue(metricService) + .compile(); + + app = testingModule.createNestApplication(); + await app.init(); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.server.request.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.server.response.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.server.abort.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getHistogram).toHaveBeenCalledWith('http.server.duration', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getHistogram).toHaveBeenCalledWith('http.server.request.size', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getHistogram).toHaveBeenCalledWith('http.server.response.size', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.server.response.success.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.server.response.error.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + + expect(metricService.getCounter).toHaveBeenCalledWith('http.client.request.error.count', expect.objectContaining({ + prefix: 'test_prefix', + })); + }); + describe('metric: http.server.request.count', () => { it('succesfully request records', async () => { const testingModule = await Test.createTestingModule({