Skip to content

Commit bbc3d9d

Browse files
committed
feat: add http metrics
1 parent 3431025 commit bbc3d9d

14 files changed

+1011
-67
lines changed

README.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
<img align="right" width="95" height="148" title="NestJS logotype" src="https://nestjs.com/img/logo-small.svg" alt='Nest.JS logo'/>
44

5-
Hot-shots Module for Nest.js Framework. A Node.js client for [Etsy](http://etsy.com)'s [StatsD](https://github.com/statsd/statsd) server, Datadog's [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?tab=hostagent) server, and [InfluxDB's](https://github.com/influxdata/telegraf) Telegraf
5+
Hot-shots Module for Nest.js Framework. A Node.js client for [Etsy](http://etsy.com)'s [StatsD](https://github.com/statsd/statsd) server,
6+
Datadog's [DogStatsD](https://docs.datadoghq.com/developers/dogstatsd/?tab=hostagent) server,
7+
and [InfluxDB's](https://github.com/influxdata/telegraf) Telegraf
68
StatsD server.
79

810
**Features**
@@ -64,6 +66,79 @@ export class AppMetrics {
6466
}
6567
```
6668

69+
### Metrics
70+
71+
You can use the `MetricsService` for metrics collection. It`s factory for creating metrics. It provides a set of methods to create different
72+
types of metrics, such as counters, gauges, and histograms.
73+
74+
```typescript
75+
import { Controller, Post } from '@nestjs/common';
76+
import { MetricsService } from 'nestjs-hot-shots';
77+
import { StatsD } from 'hot-shots';
78+
79+
@Controller
80+
export class BooksController {
81+
private readonly booksAdded = this.metricsService.getCounter('books.added.count');
82+
83+
public constructor(private readonly metricsService: MetricsService) {
84+
}
85+
86+
@Post()
87+
public async addBook() {
88+
// some logic
89+
this.booksAdded.add();
90+
}
91+
}
92+
```
93+
94+
| Method | Description |
95+
|----------------------------------|--------------------------------------------------------|
96+
| `getCounter(name: string)` | Returns a counter metric with the given name. |
97+
| `getGauge(name: string)` | Returns a gauge metric with the given name. |
98+
| `getHistogram(name: string)` | Returns a histogram metric with the given name. |
99+
| `getTimer(name: string)` | Returns a timer metric with the given name. |
100+
| `getUpDownCounter(name: string)` | Returns an up-down counter metric with the given name. |
101+
102+
### HTTP Metrics via Middleware
103+
104+
You can use the `HttpMetricsMiddleware` to collect HTTP metrics. It will automatically collect metrics for all incoming requests and
105+
outgoing responses.
106+
107+
```typescript
108+
import { Module } from '@nestjs/common';
109+
import { HotShotsModule } from 'nestjs-hot-shots';
110+
import { HttpMetricsMiddleware } from 'nestjs-hot-shots';
111+
112+
@Module({
113+
imports: [
114+
HotShotsModule.forRoot({
115+
...
116+
})
117+
]
118+
})
119+
export class AppModule {
120+
public configure(consumer: MiddlewareConsumer) {
121+
consumer
122+
.apply(HttpMetricsMiddleware)
123+
.forRoutes('*');
124+
}
125+
}
126+
```
127+
128+
| Metric | Description | Type |
129+
|--------------------------------------|-------------------------------------------------|-----------|
130+
| `http_server_request_count` | Total number of requests received by the server | Counter |
131+
| `http_server_response_count` | Total number of responses sent by the server | Counter |
132+
| `http_server_duration` | Total time taken to process requests | Histogram |
133+
| `http_server_request_size` | Size of incoming bytes. | Histogram |
134+
| `http_server_response_size` | Size of outgoing bytes. | Histogram |
135+
| `http_server_response_success_count` | Total number of all successful responses. | Counter |
136+
| `http_server_response_error_count` | Total number of server error responses. | Counter |
137+
| `http_client_request_error_count` | Total number of client error requests. | Counter |
138+
| `http_server_abort_count` | Total number of aborted requests | Counter |
139+
140+
> Inspired by [nestjs-otel](https://github.com/pragmaticivan/nestjs-otel)
141+
67142
See the [hot-shots](https://www.npmjs.com/package/hot-shots) module for more details.
68143

69144
## Stay in touch

package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
"contributors": [
4040
"Alexey Filippov <socket.someone@gmail.com>"
4141
],
42-
"dependencies": {},
42+
"dependencies": {
43+
"response-time": "^2.3.3"
44+
},
4345
"devDependencies": {
4446
"@commitlint/cli": "19.8.0",
4547
"@commitlint/config-angular": "19.8.0",
@@ -48,10 +50,13 @@
4850
"@favware/npm-deprecate": "2.0.0",
4951
"@nestjs/common": "11.0.20",
5052
"@nestjs/core": "11.0.20",
53+
"@nestjs/platform-express": "^11.0.20",
5154
"@nestjs/testing": "^11.0.20",
5255
"@release-it/conventional-changelog": "^10.0.0",
5356
"@types/jest": "^29.5.14",
5457
"@types/node": "22.14.1",
58+
"@types/response-time": "^2.3.8",
59+
"@types/supertest": "^6.0.3",
5560
"eslint": "^9.18.0",
5661
"eslint-config-prettier": "10.1.2",
5762
"eslint-plugin-import": "^2.31.0",
@@ -65,6 +70,7 @@
6570
"release-it": "18.1.2",
6671
"rimraf": "6.0.1",
6772
"rxjs": "7.8.2",
73+
"supertest": "^7.1.0",
6874
"ts-jest": "^29.3.2",
6975
"typescript": "5.8.3",
7076
"typescript-eslint": "^8.21.0"

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './metrics';
2+
export * from './middlewares';
23
export * from './hot-shots.module';
34
export * from './hot-shots-options.interface';
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as urlParser from 'url';
2+
import * as responseTime from 'response-time';
3+
import { Injectable, NestMiddleware } from '@nestjs/common';
4+
import { MetricsService } from '../metrics';
5+
6+
@Injectable()
7+
export class HttpMetricsMiddleware implements NestMiddleware {
8+
private httpServerRequestCount = this.metricsService.getCounter('http_server_request_count');
9+
10+
private httpServerResponseCount = this.metricsService.getCounter('http_server_response_count');
11+
12+
private httpServerDuration = this.metricsService.getHistogram('http_server_duration');
13+
14+
private httpServerRequestSize = this.metricsService.getHistogram('http_server_request_size');
15+
16+
private httpServerResponseSize = this.metricsService.getHistogram('http_server_response_size');
17+
18+
private httpServerResponseSuccessCount = this.metricsService.getCounter(
19+
'http_server_response_success_count'
20+
);
21+
22+
private httpServerResponseErrorCount = this.metricsService.getCounter(
23+
'http_server_response_error_count'
24+
);
25+
26+
private httpClientRequestErrorCount = this.metricsService.getCounter(
27+
'http_client_request_error_count'
28+
);
29+
30+
private httpServerAbortCount = this.metricsService.getCounter('http_server_abort_count');
31+
32+
public constructor(private readonly metricsService: MetricsService) {}
33+
34+
use(req: any, res: any, next: (error?: any) => void) {
35+
responseTime((req: any, res: any, time: number) => {
36+
const { route, url, method } = req;
37+
let path;
38+
39+
if (route) {
40+
path = route.path;
41+
} else {
42+
path = urlParser.parse(url).pathname;
43+
}
44+
45+
this.httpServerRequestCount.add(1, { method, path });
46+
47+
const requestLength = parseInt(req.headers['content-length'], 10) || 0;
48+
const responseLength: number = parseInt(res.getHeader('Content-Length'), 10) || 0;
49+
50+
const status = res.statusCode || 500;
51+
const attributes = {
52+
method,
53+
status,
54+
path
55+
};
56+
57+
this.httpServerRequestSize.record(requestLength, attributes);
58+
this.httpServerResponseSize.record(responseLength, attributes);
59+
60+
this.httpServerResponseCount.add(1, attributes);
61+
this.httpServerDuration.record(time, attributes);
62+
63+
const codeClass = this.getStatusCodeClass(status);
64+
65+
switch (codeClass) {
66+
case 'success':
67+
this.httpServerResponseSuccessCount.add(1);
68+
break;
69+
case 'redirect':
70+
// TODO: Review what should be appropriate for redirects.
71+
this.httpServerResponseSuccessCount.add(1);
72+
break;
73+
case 'client_error':
74+
this.httpClientRequestErrorCount.add(1);
75+
break;
76+
case 'server_error':
77+
this.httpServerResponseErrorCount.add(1);
78+
break;
79+
}
80+
81+
req.on('end', () => {
82+
if (req.aborted === true) {
83+
this.httpServerAbortCount.add(1);
84+
}
85+
});
86+
})(req, res, next);
87+
}
88+
89+
private getStatusCodeClass(code: number): string {
90+
if (code < 200) return 'info';
91+
if (code < 300) return 'success';
92+
if (code < 400) return 'redirect';
93+
if (code < 500) return 'client_error';
94+
return 'server_error';
95+
}
96+
}

src/middlewares/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './http-metrics.middleware';

test/metrics/collectors/counter.collector.spec.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,34 +21,34 @@ describe('CounterCollector', () => {
2121
});
2222

2323
it('should create CounterCollector', () => {
24-
const instance = metricsService.getCounter('test.metric');
24+
const instance = metricsService.getCounter('test_metric');
2525
expect(instance).toBeDefined();
2626
expect(instance).toBeInstanceOf(CounterCollector);
2727
});
2828

2929
it('should increment counter', () => {
30-
const instance = metricsService.getCounter('test.metric');
30+
const instance = metricsService.getCounter('test_metric');
3131
instance.add(1);
3232

33-
expect(statsD.mockBuffer[0]).toBe('test.metric:1|c');
33+
expect(statsD.mockBuffer[0]).toBe('test_metric:1|c');
3434
});
3535

3636
it('should increment counter with tags', () => {
37-
const instance = metricsService.getCounter('test.metric');
37+
const instance = metricsService.getCounter('test_metric');
3838
instance.add(1, { tag1: 'value1', tag2: 'value2' });
3939

40-
expect(statsD.mockBuffer[0]).toBe('test.metric:1|c|#tag1:value1,tag2:value2');
40+
expect(statsD.mockBuffer[0]).toBe('test_metric:1|c|#tag1:value1,tag2:value2');
4141
});
4242

4343
it('should increment counter with merge tags', () => {
44-
const instance = metricsService.getCounter('test.metric', { tags: { tag1: 'value1' } });
44+
const instance = metricsService.getCounter('test_metric', { tags: { tag1: 'value1' } });
4545
instance.add(1, { tag2: 'value2' });
4646

47-
expect(statsD.mockBuffer[0]).toBe('test.metric:1|c|#tag1:value1,tag2:value2');
47+
expect(statsD.mockBuffer[0]).toBe('test_metric:1|c|#tag1:value1,tag2:value2');
4848
});
4949

5050
it('should throw error when incrementing with negative value', () => {
51-
const instance = metricsService.getCounter('test.metric');
51+
const instance = metricsService.getCounter('test_metric');
5252
expect(() => instance.add(-1)).toThrowError('Counter value cannot be negative');
5353
});
5454

test/metrics/collectors/gauge.collector.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,30 @@ describe('GaugeCollector', () => {
2020
});
2121

2222
it('should create GaugeCollector', () => {
23-
const instance = metricsService.getGauge('test.metric');
23+
const instance = metricsService.getGauge('test_metric');
2424
expect(instance).toBeDefined();
2525
expect(instance).toBeInstanceOf(GaugeCollector);
2626
});
2727

2828
it('should set gauge value', () => {
29-
const instance = metricsService.getGauge('test.metric');
29+
const instance = metricsService.getGauge('test_metric');
3030
instance.set(10);
3131

32-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|g');
32+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|g');
3333
});
3434

3535
it('should set gauge value with tags', () => {
36-
const instance = metricsService.getGauge('test.metric');
36+
const instance = metricsService.getGauge('test_metric');
3737
instance.set(10, { tag1: 'value1', tag2: 'value2' });
3838

39-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|g|#tag1:value1,tag2:value2');
39+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|g|#tag1:value1,tag2:value2');
4040
});
4141

4242
it('should set gauge value with merge tags', () => {
43-
const instance = metricsService.getGauge('test.metric', { tags: { tag1: 'value1' } });
43+
const instance = metricsService.getGauge('test_metric', { tags: { tag1: 'value1' } });
4444
instance.set(10, { tag2: 'value2' });
4545

46-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|g|#tag1:value1,tag2:value2');
46+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|g|#tag1:value1,tag2:value2');
4747
});
4848

4949
afterEach(() => {

test/metrics/collectors/histogram.collector.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,30 @@ describe('HistogramCollector', () => {
2020
});
2121

2222
it('should create HistogramCollector', () => {
23-
const instance = metricsService.getHistogram('test.metric');
23+
const instance = metricsService.getHistogram('test_metric');
2424
expect(instance).toBeDefined();
2525
expect(instance).toBeInstanceOf(HistogramCollector);
2626
});
2727

2828
it('should record histogram value', () => {
29-
const instance = metricsService.getHistogram('test.metric');
29+
const instance = metricsService.getHistogram('test_metric');
3030
instance.record(10);
3131

32-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|h');
32+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|h');
3333
});
3434

3535
it('should record histogram value with tags', () => {
36-
const instance = metricsService.getHistogram('test.metric');
36+
const instance = metricsService.getHistogram('test_metric');
3737
instance.record(10, { tag1: 'value1', tag2: 'value2' });
3838

39-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|h|#tag1:value1,tag2:value2');
39+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|h|#tag1:value1,tag2:value2');
4040
});
4141

4242
it('should record histogram value with merge tags', () => {
43-
const instance = metricsService.getHistogram('test.metric', { tags: { tag1: 'value1' } });
43+
const instance = metricsService.getHistogram('test_metric', { tags: { tag1: 'value1' } });
4444
instance.record(10, { tag2: 'value2' });
4545

46-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|h|#tag1:value1,tag2:value2');
46+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|h|#tag1:value1,tag2:value2');
4747
});
4848

4949
afterEach(() => {

test/metrics/collectors/timing.collector.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,30 +20,30 @@ describe('TimingCollector', () => {
2020
});
2121

2222
it('should create TimingCollector', () => {
23-
const instance = metricsService.getTiming('test.metric');
23+
const instance = metricsService.getTiming('test_metric');
2424
expect(instance).toBeDefined();
2525
expect(instance).toBeInstanceOf(TimingCollector);
2626
});
2727

2828
it('should record timing value', () => {
29-
const instance = metricsService.getTiming('test.metric');
29+
const instance = metricsService.getTiming('test_metric');
3030
instance.record(10);
3131

32-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|ms');
32+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|ms');
3333
});
3434

3535
it('should record timing value with tags', () => {
36-
const instance = metricsService.getTiming('test.metric');
36+
const instance = metricsService.getTiming('test_metric');
3737
instance.record(10, { tag1: 'value1', tag2: 'value2' });
3838

39-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|ms|#tag1:value1,tag2:value2');
39+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|ms|#tag1:value1,tag2:value2');
4040
});
4141

4242
it('should record timing value with merge tags', () => {
43-
const instance = metricsService.getTiming('test.metric', { tags: { tag1: 'value1' } });
43+
const instance = metricsService.getTiming('test_metric', { tags: { tag1: 'value1' } });
4444
instance.record(10, { tag2: 'value2' });
4545

46-
expect(statsD.mockBuffer[0]).toBe('test.metric:10|ms|#tag1:value1,tag2:value2');
46+
expect(statsD.mockBuffer[0]).toBe('test_metric:10|ms|#tag1:value1,tag2:value2');
4747
});
4848

4949
afterEach(() => {

0 commit comments

Comments
 (0)