@phil-r/stats 1.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,179 +1,112 @@
1
1
  # stats
2
2
 
3
- > Request statistics middleware
4
-
5
- [![NPM version](http://img.shields.io/npm/v/@phil-r/stats.svg?style=flat-square)](https://www.npmjs.com/package/@phil-r/stats)
6
- [![build status](https://img.shields.io/travis/phil-r/stats/master.svg?style=flat-square)](https://travis-ci.org/phil-r/stats)
3
+ [![NPM version](https://img.shields.io/npm/v/@phil-r/stats.svg?style=flat-square)](https://www.npmjs.com/package/@phil-r/stats)
4
+ [![build status](https://github.com/phil-r/stats/workflows/Testing/badge.svg)](https://github.com/phil-r/stats/actions)
7
5
  [![code coverage](https://img.shields.io/codecov/c/github/phil-r/stats.svg?style=flat-square)](https://codecov.io/gh/phil-r/stats)
8
- [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier)
9
-
10
- ## Installation
11
6
 
12
- ```bash
13
- npm i @phil-r/stats
14
- ```
7
+ Small in-memory request statistics collector for Elysia, Express/Node HTTP,
8
+ Fastify, Hono and NestJS (Express and Fastify platforms). TypeScript-first,
9
+ ESM-only, for Node **22+** and Bun. Other frameworks may be integrated through
10
+ an appropriate adapter.
15
11
 
16
- ## API
12
+ ## Installation
17
13
 
18
- ```js
19
- const initStats = require('@phil-r/stats');
20
- const { statsMiddleware, getStats } = initStats({
21
- endpointStats: true,
22
- complexEndpoints: ['/user/:id'],
23
- customStats: true,
24
- addHeader: true
25
- });
14
+ ```sh
15
+ npm install @phil-r/stats
16
+ # or: bun add @phil-r/stats
26
17
  ```
27
18
 
28
- ### initStats([options])
29
-
30
- Returns `statsMiddleware` middleware function and `getStats` function,
31
- that returns current stats
32
-
33
- #### Options
34
-
35
- `initStats` accepts optional `options` object that may contain any of the following keys:
36
-
37
- ##### endpointStats
38
-
39
- Defaults to `false`
40
-
41
- Boolean that indicates whether to track per endpoint stats.
19
+ Install your framework separately. Framework peers are optional, and the root
20
+ export loads no framework. No stats endpoint is exposed automatically; protect
21
+ any endpoint you add.
42
22
 
43
- ##### complexEndpoints
23
+ ## Usage
44
24
 
45
- Defaults to `[]`
25
+ Create a collector and install the adapter **before routes or middleware that
26
+ may respond early**. Share a collector to aggregate counts across adapters;
27
+ install each adapter once per request path (Elysia deduplicates by collector).
46
28
 
47
- Used in conjunction with `endpointStats`
29
+ ### Elysia
48
30
 
49
- Use it in case your application has routes with params or wildcard routes
31
+ ```ts
32
+ import { Elysia } from 'elysia';
33
+ import { createStats } from '@phil-r/stats';
34
+ import { elysiaStats } from '@phil-r/stats/elysia';
50
35
 
51
- **Recommended** for applications that have endpoints like `/user/123`
52
-
53
- ##### customStats
54
-
55
- Defaults to `false`
36
+ const stats = createStats({ endpointStats: true, addHeader: true });
37
+ new Elysia()
38
+ .use(elysiaStats(stats))
39
+ .get('/', () => 'Hello')
40
+ .get('/stats', () => stats.getStats())
41
+ .listen(8080);
42
+ ```
56
43
 
57
- Adds `startMeasurement` and `finishMeasurement` functions to the request objects
58
- and allows measuring any parts of the app.
44
+ ### Express / Node HTTP
59
45
 
60
- **Usage:**
46
+ ```ts
47
+ import express from 'express';
48
+ import { createStats } from '@phil-r/stats';
49
+ import { expressStats } from '@phil-r/stats/express';
61
50
 
62
- ```js
63
- function handler(req, res) {
64
- const measurement = req.startMeasurement('measurementName');
65
- // Some code...
66
- req.finishMeasurement(measurement);
67
- }
51
+ const stats = createStats();
52
+ const app = express();
53
+ app.use(expressStats(stats));
54
+ app.get('/', (_req, res) => res.send('Hello'));
55
+ app.listen(8080);
68
56
  ```
69
57
 
70
- ##### addHeader
58
+ The middleware also accepts Node `IncomingMessage`/`ServerResponse` and `next`,
59
+ without importing Express at runtime.
71
60
 
72
- Defaults to `false`
61
+ ### Fastify and Hono
73
62
 
74
- Adds `X-Response-Time` header to all responses, can be used to replace
75
- [`expressjs/response-time`](https://github.com/expressjs/response-time)
63
+ With a collector named `stats`, register the appropriate adapter:
76
64
 
77
- ## Example
65
+ ```ts
66
+ import { fastifyStats } from '@phil-r/stats/fastify';
67
+ // Fastify: native HTTP/1 hook, not a plugin for register().
68
+ app.addHook('onRequest', fastifyStats(stats));
69
+ ```
78
70
 
79
- Examples for popular node web frameworks can be found [here](./examples)
71
+ ```ts
72
+ import { honoStats } from '@phil-r/stats/hono';
73
+ // Hono on Node/Bun, not edge runtimes: install first.
74
+ app.use('*', honoStats(stats));
75
+ ```
80
76
 
81
- Here is the example of usage in express app:
77
+ For NestJS, use its platform's adapter after `create`, before `init`/`listen`.
78
+ See [Nest examples and integration details](./docs/guide.md) or
79
+ [runnable apps](./examples).
82
80
 
83
- ```js
84
- const app = require('express')();
85
- const initStats = require('@phil-r/stats');
81
+ ## Collector API
86
82
 
87
- const { statsMiddleware, getStats } = initStats({
88
- endpointStats: true,
89
- complexEndpoints: ['/user/:id'],
90
- customStats: true,
91
- addHeader: true
92
- });
83
+ `createStats(options?)` returns an independent collector. All options default to
84
+ false (or `[]`):
93
85
 
94
- app.use(statsMiddleware);
95
- app.get('/', (req, res) => res.end('Hello'));
96
- app.get('/user/:id', (req, res) => res.end(`Hello ${req.params.id}`));
97
- app.get('/long', async (req, res) => {
98
- const measurement = req.startMeasurement('long');
99
- await new Promise(resolve => {
100
- setTimeout(() => resolve(), 2000);
101
- });
102
- req.finishMeasurement(measurement);
103
- res.end(`Long job finished`);
104
- });
105
- app.get('/stats', (req, res) => res.send(getStats()));
86
+ - `endpointStats`: per-method/path totals, excluding query strings.
87
+ - `complexEndpoints`: ordered regexparam patterns, e.g. `['/user/:id']`; first
88
+ match groups dynamic URLs. Use with `endpointStats` to limit unique keys.
89
+ - `customStats`: include named custom measurements in output.
90
+ - `addHeader`: add `X-Response-Time: Nms`, preserving existing values.
106
91
 
107
- app.listen(8080);
108
- console.log('Server listens at http://localhost:8080');
109
- ```
92
+ `stats.getStats()` returns request count, total/average milliseconds, status
93
+ counts, process identity/uptime and enabled endpoint/custom totals. Nested maps
94
+ are live; treat them as read-only. Counters are process-local and unbounded.
110
95
 
111
- Visiting http://localhost:8080/stats will give following result:
112
-
113
- ```json
114
- {
115
- "uptime": 63881,
116
- "uptimeHumanReadable": "1m 4s",
117
- "statusCodes": {
118
- "200": 5,
119
- "404": 4
120
- },
121
- "uuid": "330d9cc6-7d40-4964-888c-4d2817905ee1",
122
- "pid": 90603,
123
- "totalTime": 4020.3912830000004,
124
- "averageTime": 446.7101425555556,
125
- "count": 9,
126
- "endpointStats": {
127
- "GET /long": {
128
- "totalTime": 4009.410922,
129
- "averageTime": 2004.705461,
130
- "count": 2,
131
- "statusCodes": {
132
- "200": 2
133
- }
134
- },
135
- "GET /favicon.ico": {
136
- "totalTime": 4.286955,
137
- "averageTime": 1.07173875,
138
- "count": 4,
139
- "statusCodes": {
140
- "404": 4
141
- }
142
- },
143
- "GET /stats": {
144
- "totalTime": 6.227342999999999,
145
- "averageTime": 6.227342999999999,
146
- "count": 1,
147
- "statusCodes": {
148
- "200": 1
149
- }
150
- },
151
- "GET /user/:id": {
152
- "totalTime": 0.466063,
153
- "averageTime": 0.2330315,
154
- "count": 2,
155
- "statusCodes": {
156
- "200": 2
157
- }
158
- }
159
- },
160
- "customStats": {
161
- "long": {
162
- "totalTime": 4005.556455,
163
- "averageTime": 2002.7782275,
164
- "started": 2,
165
- "count": 2
166
- }
167
- }
96
+ ```ts
97
+ const stats = createStats({ customStats: true });
98
+ const measurement = stats.startMeasurement('work');
99
+ try {
100
+ await doWork();
101
+ } finally {
102
+ stats.finishMeasurement(measurement);
168
103
  }
169
104
  ```
170
105
 
171
- All time related results are in milliseconds
172
-
173
- # [License](LICENSE)
174
-
175
- This is a fork of [zenmate/stats](https://github.com/zenmate/stats)
106
+ Finish each token once on its originating collector. Timings are monotonic
107
+ milliseconds, not body-transmission times. See the [guide](./docs/guide.md) for
108
+ precise adapter timing, supported versions, migration and development commands.
176
109
 
177
- # Inspired by
110
+ ## License
178
111
 
179
- [`expressjs/response-time`](https://github.com/expressjs/response-time) and [`thoas/stats`](https://github.com/thoas/stats)
112
+ [MIT](LICENSE). Fork of zenmate/stats.
@@ -0,0 +1,13 @@
1
+ // src/internal.ts
2
+ var collectors = new WeakMap;
3
+ function getCollector(stats) {
4
+ const state = collectors.get(stats);
5
+ if (!state)
6
+ throw new TypeError("Expected a collector from createStats()");
7
+ return state;
8
+ }
9
+ function hrTimeToMs([s, ns]) {
10
+ return s * 1000 + ns * 0.000001;
11
+ }
12
+
13
+ export { collectors, getCollector, hrTimeToMs };
@@ -0,0 +1,25 @@
1
+ import {
2
+ getCollector,
3
+ hrTimeToMs
4
+ } from "./chunk-etf182vd.js";
5
+
6
+ // src/express.ts
7
+ import onHeaders from "on-headers";
8
+ function expressStats2(stats) {
9
+ const { record, addHeader } = getCollector(stats);
10
+ return function statsMiddleware(req, res, next) {
11
+ const start = process.hrtime();
12
+ const method = req.method || "GET";
13
+ const url = req.originalUrl || req.url || "/";
14
+ onHeaders(res, () => {
15
+ const elapsed = hrTimeToMs(process.hrtime(start));
16
+ if (addHeader && !res.hasHeader("X-Response-Time")) {
17
+ res.setHeader("X-Response-Time", `${elapsed.toFixed(0)}ms`);
18
+ }
19
+ record(method, url, res.statusCode, elapsed);
20
+ });
21
+ next();
22
+ };
23
+ }
24
+
25
+ export { expressStats2 };
@@ -0,0 +1,10 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { StatsCollector } from './types.js';
3
+ /**
4
+ * Create a plugin measuring until Elysia produces its final Response, not body transmission.
5
+ * Install with `.use(elysiaStats(stats))` before routes. The collector UUID seeds
6
+ * plugin deduplication: reusing one collector counts each request only once.
7
+ * Existing response-time headers are preserved. Requires Elysia 1.4.x on Bun.
8
+ * @param stats - Collector returned by createStats().
9
+ */
10
+ export declare function elysiaStats(stats: StatsCollector): Elysia;
package/dist/elysia.js ADDED
@@ -0,0 +1,29 @@
1
+ import {
2
+ getCollector,
3
+ hrTimeToMs
4
+ } from "./chunk-etf182vd.js";
5
+
6
+ // src/elysia.ts
7
+ import { Elysia } from "elysia";
8
+ function elysiaStats(stats) {
9
+ const { record, addHeader } = getCollector(stats);
10
+ const wrap = (handle) => async (request, ...rest) => {
11
+ const start = process.hrtime();
12
+ let response = await handle(request, ...rest);
13
+ const elapsed = hrTimeToMs(process.hrtime(start));
14
+ if (addHeader && !response.headers.has("X-Response-Time")) {
15
+ try {
16
+ response.headers.set("X-Response-Time", `${elapsed.toFixed(0)}ms`);
17
+ } catch {
18
+ response = new Response(response.body, response);
19
+ response.headers.set("X-Response-Time", `${elapsed.toFixed(0)}ms`);
20
+ }
21
+ }
22
+ record(request.method, request.url, response.status, elapsed);
23
+ return response;
24
+ };
25
+ return new Elysia({ name: "stats", seed: stats.getStats().uuid }).wrap(wrap);
26
+ }
27
+ export {
28
+ elysiaStats
29
+ };
@@ -0,0 +1,13 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { StatsCollector } from './types.js';
3
+ /**
4
+ * Create Express/Node HTTP middleware that records when response headers are written.
5
+ * Install before routes and any middleware that may respond early; for Nest's
6
+ * Express platform, install after create and before init/listen. Uses originalUrl
7
+ * for mounted routers and preserves existing response-time headers. Aborted
8
+ * requests without headers are not counted; body transmission is not measured.
9
+ * @param stats - Collector returned by createStats(); install once per request path.
10
+ */
11
+ export declare function expressStats(stats: StatsCollector): (req: IncomingMessage & {
12
+ originalUrl?: string;
13
+ }, res: ServerResponse, next: () => void) => void;
@@ -0,0 +1,6 @@
1
+ import {
2
+ expressStats2
3
+ } from "./chunk-nwt5rk8q.js";
4
+ export {
5
+ expressStats2 as expressStats
6
+ };
@@ -0,0 +1,10 @@
1
+ import type { onRequestHookHandler } from 'fastify';
2
+ import type { StatsCollector } from './types.js';
3
+ /**
4
+ * Create an HTTP/1 onRequest hook (not a plugin for register()).
5
+ * Add before routes and early hooks, once in the desired encapsulation scope.
6
+ * For Nest/Fastify, add to the native instance after create, before init/listen.
7
+ * Records at header write, not body completion; preserves staged/raw timing headers.
8
+ * @param stats - Collector returned by createStats().
9
+ */
10
+ export declare function fastifyStats(stats: StatsCollector): onRequestHookHandler;
@@ -0,0 +1,29 @@
1
+ import {
2
+ expressStats2
3
+ } from "./chunk-nwt5rk8q.js";
4
+ import {
5
+ getCollector
6
+ } from "./chunk-etf182vd.js";
7
+
8
+ // src/fastify.ts
9
+ import onHeaders from "on-headers";
10
+ function fastifyStats(stats) {
11
+ const middleware = expressStats2(stats);
12
+ const { addHeader } = getCollector(stats);
13
+ return function(request, reply, done) {
14
+ middleware(request.raw, reply.raw, () => {
15
+ if (addHeader) {
16
+ onHeaders(reply.raw, () => {
17
+ const existing = reply.getHeader("X-Response-Time");
18
+ if (!reply.raw.hasHeader("X-Response-Time") && existing !== undefined) {
19
+ reply.raw.setHeader("X-Response-Time", existing);
20
+ }
21
+ });
22
+ }
23
+ done();
24
+ });
25
+ };
26
+ }
27
+ export {
28
+ fastifyStats
29
+ };
package/dist/hono.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ import type { StatsCollector } from './types.js';
3
+ /**
4
+ * Create Node/Bun middleware; install first with app.use('*', honoStats(stats)).
5
+ * Records after downstream middleware and Hono's error handler finalize a Response,
6
+ * not after streaming its body. Escaping errors without a Response are not counted.
7
+ * Preserves existing response-time headers. Install once per request path.
8
+ * @param stats - Collector returned by createStats().
9
+ */
10
+ export declare function honoStats(stats: StatsCollector): MiddlewareHandler;
package/dist/hono.js ADDED
@@ -0,0 +1,25 @@
1
+ import {
2
+ getCollector,
3
+ hrTimeToMs
4
+ } from "./chunk-etf182vd.js";
5
+
6
+ // src/hono.ts
7
+ function honoStats(stats) {
8
+ const { record, addHeader } = getCollector(stats);
9
+ return async (context, next) => {
10
+ const start = process.hrtime();
11
+ const method = context.req.method;
12
+ const url = context.req.url;
13
+ await next();
14
+ const elapsed = hrTimeToMs(process.hrtime(start));
15
+ if (addHeader && !context.res.headers.has("X-Response-Time")) {
16
+ const response = new Response(context.res.body, context.res);
17
+ response.headers.set("X-Response-Time", `${elapsed.toFixed(0)}ms`);
18
+ context.res = response;
19
+ }
20
+ record(method, url, context.res.status, elapsed);
21
+ };
22
+ }
23
+ export {
24
+ honoStats
25
+ };
@@ -0,0 +1,9 @@
1
+ import type { StatsOptions, StatsCollector } from './types.js';
2
+ export type { StatsOptions, StatsCollector, Stats, Timing, EndpointStats, CustomStats, Measurement, } from './types.js';
3
+ /**
4
+ * Create an independent in-memory collector for one or more framework adapters.
5
+ * Counters are unbounded and process-local; configure endpoint patterns to group
6
+ * dynamic URLs. No endpoint is exposed automatically.
7
+ * @param opts - Collection and response-header options, disabled by default.
8
+ */
9
+ export declare function createStats(opts?: StatsOptions): StatsCollector;
package/dist/index.js ADDED
@@ -0,0 +1,92 @@
1
+ import {
2
+ collectors,
3
+ hrTimeToMs
4
+ } from "./chunk-etf182vd.js";
5
+
6
+ // src/index.ts
7
+ import { randomUUID as uuidv4 } from "node:crypto";
8
+ import { parse } from "regexparam";
9
+ import prettyTime from "pretty-time";
10
+ function createStats(opts = {}) {
11
+ const active = new WeakSet;
12
+ const stats = {
13
+ uuid: uuidv4(),
14
+ pid: process.pid,
15
+ totalTime: 0,
16
+ averageTime: 0,
17
+ count: 0
18
+ };
19
+ const statusCodes = {};
20
+ const endpointStats = {};
21
+ const customStats = {};
22
+ const complexEndpoints = (opts.complexEndpoints || []).map((path) => ({
23
+ ...parse(path),
24
+ path
25
+ }));
26
+ function getStats() {
27
+ const result = {
28
+ uptime: process.uptime() * 1000,
29
+ uptimeHumanReadable: prettyTime(Math.floor(process.uptime() * 1e9)),
30
+ statusCodes,
31
+ ...stats
32
+ };
33
+ if (opts.endpointStats)
34
+ result.endpointStats = endpointStats;
35
+ if (opts.customStats)
36
+ result.customStats = customStats;
37
+ return result;
38
+ }
39
+ function startMeasurement(name) {
40
+ if (!Object.prototype.hasOwnProperty.call(customStats, name)) {
41
+ Object.defineProperty(customStats, name, {
42
+ value: { totalTime: 0, averageTime: 0, started: 0, count: 0 },
43
+ enumerable: true
44
+ });
45
+ }
46
+ customStats[name].started++;
47
+ const measurement = { start: process.hrtime(), name };
48
+ active.add(measurement);
49
+ return measurement;
50
+ }
51
+ function finishMeasurement(measurement) {
52
+ if (!active.has(measurement))
53
+ throw new TypeError("Unknown or finished measurement");
54
+ active.delete(measurement);
55
+ const { name, start } = measurement;
56
+ const time = hrTimeToMs(process.hrtime(start));
57
+ customStats[name].totalTime += time;
58
+ customStats[name].count++;
59
+ customStats[name].averageTime = customStats[name].totalTime / customStats[name].count;
60
+ }
61
+ function record(method, url, status, time) {
62
+ stats.totalTime += time;
63
+ stats.count++;
64
+ stats.averageTime = stats.totalTime / stats.count;
65
+ statusCodes[status] = (statusCodes[status] || 0) + 1;
66
+ if (!opts.endpointStats)
67
+ return;
68
+ let path = url.startsWith("/") ? url.split(/[?#]/, 1)[0] : new URL(url, "http://localhost").pathname;
69
+ const complexPath = complexEndpoints.find((endpoint) => endpoint.pattern.test(path));
70
+ path = complexPath ? complexPath.path : path;
71
+ const endpoint = `${method} ${path}`;
72
+ if (!endpointStats[endpoint]) {
73
+ endpointStats[endpoint] = {
74
+ totalTime: 0,
75
+ averageTime: 0,
76
+ count: 0,
77
+ statusCodes: {}
78
+ };
79
+ }
80
+ const stat = endpointStats[endpoint];
81
+ stat.totalTime += time;
82
+ stat.count++;
83
+ stat.averageTime = stat.totalTime / stat.count;
84
+ stat.statusCodes[status] = (stat.statusCodes[status] || 0) + 1;
85
+ }
86
+ const collector = { getStats, startMeasurement, finishMeasurement };
87
+ collectors.set(collector, { record, addHeader: !!opts.addHeader });
88
+ return collector;
89
+ }
90
+ export {
91
+ createStats
92
+ };
@@ -0,0 +1,10 @@
1
+ import type { StatsCollector } from './types.js';
2
+ export declare const collectors: WeakMap<StatsCollector, {
3
+ addHeader: boolean;
4
+ record(method: string, url: string, status: number, time: number): void;
5
+ }>;
6
+ export declare function getCollector(stats: StatsCollector): {
7
+ addHeader: boolean;
8
+ record(method: string, url: string, status: number, time: number): void;
9
+ };
10
+ export declare function hrTimeToMs([s, ns]: [number, number]): number;
@@ -0,0 +1,66 @@
1
+ /** Collection features; every option defaults to false or an empty list. */
2
+ export interface StatsOptions {
3
+ /** Include counters keyed by HTTP method and path, excluding query strings. */
4
+ endpointStats?: boolean;
5
+ /** Ordered regexparam patterns; first match groups dynamic URLs. Requires endpointStats. */
6
+ complexEndpoints?: string[];
7
+ /** Include custom measurements in getStats(); measurement methods always work. */
8
+ customStats?: boolean;
9
+ /** Add X-Response-Time in milliseconds unless the response already has it. */
10
+ addHeader?: boolean;
11
+ }
12
+ /** Token owned by its originating collector; pass it unchanged to finishMeasurement. */
13
+ export interface Measurement {
14
+ /** Monotonic start time, not a wall-clock timestamp. */
15
+ readonly start: [number, number];
16
+ /** Name used to aggregate this measurement. */
17
+ readonly name: string;
18
+ }
19
+ /** Completed request or custom measurement totals. All times are milliseconds. */
20
+ export interface Timing {
21
+ /** Sum of completed durations in milliseconds. */
22
+ totalTime: number;
23
+ /** totalTime / count, or zero before completion. */
24
+ averageTime: number;
25
+ /** Number of completed requests or measurements. */
26
+ count: number;
27
+ }
28
+ /** Request totals for a single method/path group. */
29
+ export interface EndpointStats extends Timing {
30
+ /** Counts keyed by final HTTP status code. */
31
+ statusCodes: Record<string, number>;
32
+ }
33
+ /** Totals for a named custom measurement, including unfinished starts. */
34
+ export interface CustomStats extends Timing {
35
+ /** Number of starts, including measurements not yet finished. */
36
+ started: number;
37
+ }
38
+ /** Collector totals; nested maps are live and should be treated as read-only. */
39
+ export interface Stats extends Timing {
40
+ /** Unique collector identity, also used for Elysia plugin deduplication. */
41
+ uuid: string;
42
+ /** ID of the hosting process. */
43
+ pid: number;
44
+ /** Process uptime in milliseconds, not collector age. */
45
+ uptime: number;
46
+ /** Human-readable process uptime. */
47
+ uptimeHumanReadable: string;
48
+ /** Request counts keyed by final HTTP status code. */
49
+ statusCodes: Record<string, number>;
50
+ /** Method/path totals, present only when endpointStats is enabled. */
51
+ endpointStats?: Record<string, EndpointStats>;
52
+ /** Named measurement totals, present only when customStats is enabled. */
53
+ customStats?: Record<string, CustomStats>;
54
+ }
55
+ /** Independent collector that can be shared across framework adapters. */
56
+ export interface StatsCollector {
57
+ /** Return current totals with live nested maps; clone for a durable snapshot. */
58
+ getStats(): Stats;
59
+ /** Start a named monotonic timing; overlapping measurements are supported. */
60
+ startMeasurement(name: string): Measurement;
61
+ /**
62
+ * Finish a token exactly once on its originating collector and update totals.
63
+ * @throws TypeError - If the token is unknown or already finished.
64
+ */
65
+ finishMeasurement(measurement: Measurement): void;
66
+ }
package/docs/guide.md ADDED
@@ -0,0 +1,82 @@
1
+ # Integration guide
2
+
3
+ ## NestJS
4
+
5
+ Use the platform adapter **after `create`, before `init`/`listen`** and before
6
+ middleware that may respond early. No Nest module, interceptor or `/nestjs`
7
+ export is needed. Install Nest and its platform dependencies separately.
8
+
9
+ ```ts
10
+ import { NestFactory } from '@nestjs/core';
11
+ import { createStats } from '@phil-r/stats';
12
+ import { expressStats } from '@phil-r/stats/express';
13
+ import { AppModule } from './app.module.js';
14
+
15
+ const stats = createStats();
16
+ const app = await NestFactory.create(AppModule);
17
+ app.use(expressStats(stats));
18
+ await app.listen(8080);
19
+ ```
20
+
21
+ For Fastify, create with `new FastifyAdapter()` from
22
+ `@nestjs/platform-fastify`, then register its native hook instead of `app.use`:
23
+
24
+ ```ts
25
+ import { fastifyStats } from '@phil-r/stats/fastify';
26
+
27
+ app.getHttpAdapter().getInstance().addHook('onRequest', fastifyStats(stats));
28
+ ```
29
+
30
+ Group patterns must include any global prefix, e.g. `/api/user/:id`.
31
+ See [runnable examples](../examples) for both platforms.
32
+
33
+ ## Timing and adapter limits
34
+
35
+ - Express/Node, Fastify and Nest record the final status when headers are written;
36
+ `originalUrl` preserves mounted paths. Fastify's HTTP/1 hook is not a
37
+ `register()` plugin: register at root for all routes/404s, or inside a plugin
38
+ before its routes for that scope. HTTP/2 is unsupported.
39
+ - Elysia and Hono record when the final Response is ready, including early
40
+ responses, errors, validation failures and 404s. Streams are not buffered or
41
+ awaited; immutable headers do not discard the body. Hono requires Node/Bun,
42
+ must be registered first, and error handlers must return a Response normally.
43
+ Errors escaping without a Response and requests rejected by Bun before Elysia
44
+ are not counted.
45
+ - No adapter measures body transmission or counts requests aborted before a
46
+ response/headers. A stats JSON response excludes its own request, which is
47
+ recorded after serialization.
48
+ - Times are monotonic milliseconds; averages are total/count. Custom `started`
49
+ counts all starts, `count` only finishes. Uptime is process uptime, not collector
50
+ age. Counters and unique keys are in-memory and unbounded; nested output maps
51
+ are live/read-only. Clone or serialize for a durable snapshot.
52
+
53
+ Tested: Elysia **1.4.32** (advanced `wrap` API; restricted to 1.4.x), Express
54
+ **5.3.0**, Fastify **5.12.8**, Hono **4.13.18** (`@hono/node-server` **2.1.8**),
55
+ Nest and both platform adapters **12.5.0**, on Node **22.23.5/24.22.2** and Bun.
56
+ Older Nest majors and other platforms are unverified; retest before widening
57
+ Elysia support.
58
+
59
+ ## From v1
60
+
61
+ Use named ESM `createStats` and framework-specific adapter imports, passing
62
+ the collector instead of options. Replace `statsMiddleware`/`statsPlugin` and
63
+ move measurement calls onto the collector. Node 22+ is required; there is no
64
+ CJS build, framework registry or dashboard.
65
+
66
+ ## Development
67
+
68
+ Use the pinned [Bun](../.bun-version) and [Node](../.node-version) versions.
69
+
70
+ ```sh
71
+ bun install --frozen-lockfile
72
+ bun run format:check # bun run format to fix
73
+ bun check
74
+ bun test
75
+ bun run build
76
+ bun run test:node
77
+ bun run test:package
78
+ ```
79
+
80
+ Build emits ESM and declarations; checks do not emit. CI tests Node 22/24,
81
+ including real framework listeners. The package smoke test verifies packed
82
+ exports and peer-free Node imports/declarations, not framework behavior.
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@phil-r/stats",
3
- "version": "1.4.3",
3
+ "version": "2.0.0",
4
4
  "license": "MIT",
5
5
  "author": "Phil Rukin <philipp@rukin.me> (https://rukin.me)",
6
6
  "description": "Request statistics middleware",
7
- "main": "index.js",
7
+ "main": "./dist/index.js",
8
8
  "keywords": [
9
9
  "middleware",
10
10
  "statistics",
@@ -16,7 +16,10 @@
16
16
  "koa",
17
17
  "rayo",
18
18
  "polka",
19
- "fastify"
19
+ "fastify",
20
+ "elysia",
21
+ "hono",
22
+ "nestjs"
20
23
  ],
21
24
  "bugs": {
22
25
  "url": "https://github.com/phil-r/stats/issues"
@@ -27,33 +30,92 @@
27
30
  },
28
31
  "homepage": "https://github.com/phil-r/stats#readme",
29
32
  "scripts": {
30
- "test": "npm run lint && npm run ava",
31
- "ava": "nyc --reporter=lcov --reporter=text ava",
32
- "lint": "eslint --ignore-path .gitignore **/*.js"
33
- },
34
- "husky": {
35
- "hooks": {
36
- "pre-commit": "pretty-quick --staged"
37
- }
38
- },
39
- "prettier": {
40
- "singleQuote": true
33
+ "check": "bun check --no-script",
34
+ "build": "bun scripts/build.ts",
35
+ "test": "bun test tests/*.test.ts",
36
+ "test:node": "node --test tests/*.integration.mjs",
37
+ "test:package": "bun scripts/package-smoke.ts",
38
+ "prepack": "bun run build",
39
+ "format": "bun fmt --no-script",
40
+ "format:check": "bun fmt --no-script --check",
41
+ "prepare": "node -e \"if (require('node:fs').existsSync('.git') && process.env.NODE_ENV !== 'production' && process.env.HUSKY !== '0') import('husky').then(({default: install}) => install())\""
41
42
  },
42
43
  "dependencies": {
43
- "on-headers": "^1.0.1",
44
+ "on-headers": "^1.1.0",
44
45
  "pretty-time": "^1.1.0",
45
- "regexparam": "^1.3.0",
46
- "uuid": "^7.0.2"
46
+ "regexparam": "^3.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "ava": "^3.3.0",
50
- "eslint": "^6.8.0",
51
- "eslint-config-prettier": "^6.0.0",
52
- "eslint-plugin-prettier": "^3.1.2",
53
- "husky": "^4.2.3",
54
- "nyc": "^15.0.0",
55
- "prettier": "^1.19.1",
56
- "pretty-quick": "^2.0.1",
57
- "supertest": "^4.0.2"
49
+ "@hono/node-server": "^2.1.8",
50
+ "@nestjs/common": "^12.5.0",
51
+ "@nestjs/core": "^12.5.0",
52
+ "@nestjs/platform-express": "^12.5.0",
53
+ "@nestjs/platform-fastify": "^12.5.0",
54
+ "@types/bun": "^1.3.0",
55
+ "@types/express": "^5.0.0",
56
+ "@types/node": "^22.19.1",
57
+ "@types/on-headers": "^1.0.4",
58
+ "@types/pretty-time": "^1.1.5",
59
+ "elysia": "1.4.32",
60
+ "express": "^5.2.1",
61
+ "fastify": "^5.12.8",
62
+ "hono": "^4.13.18",
63
+ "husky": "^9.1.7",
64
+ "reflect-metadata": "^0.2.2",
65
+ "rxjs": "^7.8.2",
66
+ "typescript": "5.9.3"
67
+ },
68
+ "peerDependencies": {
69
+ "elysia": ">=1.4.32 <1.5.0",
70
+ "express": ">=4.18.0 <6",
71
+ "fastify": ">=5.12.8 <6",
72
+ "hono": ">=4.13.18 <5"
73
+ },
74
+ "peerDependenciesMeta": {
75
+ "elysia": {
76
+ "optional": true
77
+ },
78
+ "express": {
79
+ "optional": true
80
+ },
81
+ "fastify": {
82
+ "optional": true
83
+ },
84
+ "hono": {
85
+ "optional": true
86
+ }
87
+ },
88
+ "type": "module",
89
+ "types": "./dist/index.d.ts",
90
+ "files": [
91
+ "dist",
92
+ "docs/guide.md"
93
+ ],
94
+ "engines": {
95
+ "node": ">=22"
96
+ },
97
+ "packageManager": "bun@1.4.4",
98
+ "sideEffects": false,
99
+ "exports": {
100
+ ".": {
101
+ "types": "./dist/index.d.ts",
102
+ "import": "./dist/index.js"
103
+ },
104
+ "./elysia": {
105
+ "types": "./dist/elysia.d.ts",
106
+ "import": "./dist/elysia.js"
107
+ },
108
+ "./express": {
109
+ "types": "./dist/express.d.ts",
110
+ "import": "./dist/express.js"
111
+ },
112
+ "./fastify": {
113
+ "types": "./dist/fastify.d.ts",
114
+ "import": "./dist/fastify.js"
115
+ },
116
+ "./hono": {
117
+ "types": "./dist/hono.d.ts",
118
+ "import": "./dist/hono.js"
119
+ }
58
120
  }
59
121
  }
package/.eslintrc DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "env": {
3
- "node": true,
4
- "es6": true
5
- },
6
- "parserOptions": { "ecmaVersion": 9 },
7
-
8
- "extends": ["eslint:recommended", "plugin:prettier/recommended"],
9
-
10
- "plugins": ["prettier"],
11
-
12
- "rules": {
13
- "prettier/prettier": "error"
14
- }
15
- }
package/index.js DELETED
@@ -1,122 +0,0 @@
1
- const urlParse = require('url').parse;
2
- const onHeaders = require('on-headers');
3
- const { v4: uuidv4 } = require('uuid');
4
- const regexparam = require('regexparam');
5
- const prettyTime = require('pretty-time');
6
-
7
- const { hrTimeToMs } = require('./utils');
8
-
9
- function initMiddleware(opts = {}) {
10
- const stats = {
11
- uuid: uuidv4(),
12
- pid: process.pid,
13
- totalTime: 0,
14
- averageTime: 0,
15
- count: 0
16
- };
17
-
18
- const statusCodes = {};
19
- const endpointStats = {};
20
- const complexEndpoints =
21
- (opts.complexEndpoints &&
22
- opts.complexEndpoints.map(path => ({ ...regexparam(path), path }))) ||
23
- [];
24
- const customStats = {};
25
-
26
- function getStats() {
27
- const result = {
28
- uptime: process.uptime() * 1e3, // convert to ms
29
- uptimeHumanReadable: prettyTime(Math.floor(process.uptime() * 1e9)),
30
- statusCodes,
31
- ...stats
32
- };
33
- if (opts.endpointStats) {
34
- result.endpointStats = endpointStats;
35
- }
36
- if (opts.customStats) {
37
- result.customStats = customStats;
38
- }
39
- return result;
40
- }
41
-
42
- function startMeasurement(name) {
43
- if (!customStats[name]) {
44
- customStats[name] = {
45
- totalTime: 0,
46
- averageTime: 0,
47
- started: 0,
48
- count: 0
49
- };
50
- }
51
- customStats[name].started++;
52
- return { start: process.hrtime(), name };
53
- }
54
-
55
- function finishMeasurement({ name, start }) {
56
- const time = hrTimeToMs(process.hrtime(start));
57
- customStats[name].totalTime += time;
58
- customStats[name].count++;
59
- customStats[name].averageTime =
60
- customStats[name].totalTime / customStats[name].count;
61
- }
62
-
63
- function statsMiddleware(req, res, next) {
64
- const requestStart = process.hrtime();
65
-
66
- if (opts.customStats) {
67
- req.startMeasurement = startMeasurement;
68
- req.finishMeasurement = finishMeasurement;
69
- }
70
-
71
- onHeaders(res, () => {
72
- const time = hrTimeToMs(process.hrtime(requestStart));
73
-
74
- if (opts.addHeader) {
75
- if (!res.getHeader('X-Response-Time')) {
76
- res.setHeader('X-Response-Time', `${time.toFixed(0)}ms`);
77
- }
78
- }
79
-
80
- stats.totalTime += time;
81
- stats.count++;
82
- stats.averageTime = stats.totalTime / stats.count;
83
- statusCodes[res.statusCode] = statusCodes[res.statusCode]
84
- ? statusCodes[res.statusCode] + 1
85
- : 1;
86
-
87
- if (opts.endpointStats) {
88
- // prefer using `req.originalUrl` as some frameworks replace `req.url`
89
- const url = req.originalUrl || req.url;
90
- let path = urlParse(url).pathname;
91
- const complexPath = complexEndpoints.find(endpoint =>
92
- endpoint.pattern.test(path)
93
- );
94
- path = complexPath ? complexPath.path : path;
95
- const endpoint = `${req.method} ${path}`;
96
-
97
- if (!endpointStats[endpoint]) {
98
- endpointStats[endpoint] = {
99
- totalTime: 0,
100
- averageTime: 0,
101
- count: 0,
102
- statusCodes: {}
103
- };
104
- }
105
-
106
- endpointStats[endpoint].totalTime += time;
107
- endpointStats[endpoint].count++;
108
- endpointStats[endpoint].averageTime =
109
- endpointStats[endpoint].totalTime / endpointStats[endpoint].count;
110
- const eStatusCodes = endpointStats[endpoint].statusCodes;
111
- eStatusCodes[res.statusCode] = eStatusCodes[res.statusCode]
112
- ? eStatusCodes[res.statusCode] + 1
113
- : 1;
114
- }
115
- });
116
-
117
- return next();
118
- }
119
- return { getStats, statsMiddleware };
120
- }
121
-
122
- module.exports = initMiddleware;
package/utils.js DELETED
@@ -1,5 +0,0 @@
1
- function hrTimeToMs([s, ns]) {
2
- return s * 1e3 + ns * 1e-6; // convert to ms
3
- }
4
-
5
- module.exports = { hrTimeToMs };