@ecdt/server-common 1.5.0 → 1.5.1

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
@@ -127,6 +127,35 @@ topk(10, sum by (route)(rate(http_request_duration_seconds_count{service="dev-mk
127
127
 
128
128
  Para wiring manual, a lib também exporta `redMetricsMiddleware(options?)` e `metricsHandler(registry?)`.
129
129
 
130
+ #### Servidor que não é express (Nitro/h3, fastify, http puro)
131
+
132
+ `setupRedMetrics` precisa de um `app` do express. Fora dele, monte as três peças à mão:
133
+
134
+ - `initRedMetrics(options?)` — label `service`, métricas default do Node e o histograma. Devolve `{ registry, histogram }`.
135
+ - `redMetricsMiddleware({ routeResolver })` — o middleware `(req, res, next)`. Sem `req.route` (que é do express) o label `route` cai em `unmatched`, então informe um resolver. **Nunca use a URL crua**: cada id na rota vira uma série temporal nova e a métrica fica impagável.
136
+ - `renderMetrics(registry?)` — `{ contentType, body }` para servir o endpoint.
137
+
138
+ ```js
139
+ // Nitro: server/plugins/red-metrics.ts
140
+ import { initRedMetrics, redMetricsMiddleware } from "@ecdt/server-common";
141
+
142
+ initRedMetrics({ serviceName: "site-vue3" });
143
+
144
+ // Nitro: server/middleware/01.red-metrics.ts
145
+ export default fromNodeMiddleware(
146
+ redMetricsMiddleware({ routeResolver: (req) => templateDaRota(req.url) }),
147
+ );
148
+
149
+ // Nitro: server/routes/metrics.get.ts
150
+ import { renderMetrics } from "@ecdt/server-common";
151
+
152
+ export default defineEventHandler(async (event) => {
153
+ const { contentType, body } = await renderMetrics();
154
+ setResponseHeader(event, "content-type", contentType);
155
+ return body;
156
+ });
157
+ ```
158
+
130
159
  ---
131
160
 
132
161
  ## Instalação
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ecdt/server-common",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Conjunto de ferramentas e configurações comuns nos servidores da Econodata",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "node --test"
9
9
  },
10
10
  "author": "",
11
11
  "license": "ISC",
package/src/index.d.ts CHANGED
@@ -104,10 +104,34 @@ export interface RedMetricsOptions {
104
104
  */
105
105
  export declare function setupRedMetrics(app: Express, options?: RedMetricsOptions): void;
106
106
 
107
- /** Middleware do express que alimenta o histograma `http_request_duration_seconds`. */
107
+ /**
108
+ * Registra o label `service`, as métricas default do Node e o histograma RED, sem depender de
109
+ * framework. Use quando o servidor não é express (ex.: Nitro/h3) e você mesmo vai montar o
110
+ * middleware e a rota de métricas.
111
+ */
112
+ export declare function initRedMetrics(
113
+ options?: Omit<RedMetricsOptions, 'metricsPath'>
114
+ ): { registry: MetricsRegistry; histogram: unknown };
115
+
116
+ /**
117
+ * Middleware `(req, res, next)` que alimenta o histograma `http_request_duration_seconds`.
118
+ * Compatível com express e com adaptadores de middleware de Node (ex.: `fromNodeMiddleware` do h3).
119
+ */
108
120
  export declare function redMetricsMiddleware(
109
- options?: Pick<RedMetricsOptions, 'buckets' | 'registry'>
121
+ options?: Pick<RedMetricsOptions, 'buckets' | 'registry'> & {
122
+ /**
123
+ * Resolve o label `route`. Default: template do express (`req.route.path`). Framework sem
124
+ * `req.route` precisa informar o seu, senão todo request cai em `unmatched` — e nunca use a
125
+ * URL crua, que explode a cardinalidade da métrica.
126
+ */
127
+ routeResolver?: (req: unknown) => string | undefined;
128
+ }
110
129
  ): RequestHandler;
111
130
 
112
131
  /** Handler do endpoint de métricas (exposição no formato Prometheus). */
113
132
  export declare function metricsHandler(registry?: MetricsRegistry): RequestHandler;
133
+
134
+ /** Content-type e corpo das métricas, para servir fora do express. */
135
+ export declare function renderMetrics(
136
+ registry?: MetricsRegistry
137
+ ): Promise<{ contentType: string; body: string }>;
package/src/index.js CHANGED
@@ -2,7 +2,7 @@ const bodyParser = require("body-parser");
2
2
  const { devMktTokenSanitaze } = require("./services/tokenService.js");
3
3
  const { expressCors } = require("./services/corsService.js");
4
4
  const { setupGracefulShutdown } = require("./services/shutdownService.js");
5
- const { setupRedMetrics, redMetricsMiddleware, metricsHandler } = require("./services/redMetricsService.js");
5
+ const { setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics } = require("./services/redMetricsService.js");
6
6
 
7
7
  function expressCommonMiddlewares({ cookieName } = {}){
8
8
  return [bodyParser.text(), bodyParser.json(), bodyParser.urlencoded({extended: false}), devMktTokenSanitaze({ cookieName })];
@@ -11,4 +11,4 @@ function expressCommonMiddlewares({ cookieName } = {}){
11
11
 
12
12
 
13
13
 
14
- module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, redMetricsMiddleware, metricsHandler }
14
+ module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics }
@@ -34,24 +34,28 @@ function routeTemplate(req) {
34
34
  }
35
35
 
36
36
  /**
37
- * Middleware do express que mede cada requisição e alimenta o histograma RED
37
+ * Middleware (req, res, next) que mede cada requisição e alimenta o histograma RED
38
38
  * (http_request_duration_seconds) com os labels method, route e status_code.
39
39
  *
40
40
  * @param {object} [options]
41
41
  * @param {number[]} [options.buckets] - buckets de latência em segundos
42
42
  * @param {import("prom-client").Registry} [options.registry] - registry (default: global do prom-client)
43
- * @returns middleware do express
43
+ * @param {(req: object) => string} [options.routeResolver] - resolve o label `route`. Default: template
44
+ * do express (`req.route.path`). Framework sem `req.route` (ex.: h3/Nitro) precisa informar o
45
+ * seu, senão todo request cai em "unmatched".
46
+ * @returns middleware compatível com express e com adaptadores de middleware de Node
44
47
  */
45
- function redMetricsMiddleware({ buckets, registry } = {}) {
48
+ function redMetricsMiddleware({ buckets, registry, routeResolver } = {}) {
46
49
  const reg = registry || client.register;
47
50
  const histogram = getHistogram(reg, buckets || DEFAULT_BUCKETS);
51
+ const resolveRoute = routeResolver || routeTemplate;
48
52
  return function (req, res, next) {
49
53
  const stop = histogram.startTimer();
50
54
  // 'finish' dispara após a rota resolver -> req.route já está preenchido aqui.
51
55
  res.on("finish", () =>
52
56
  stop({
53
57
  method: req.method,
54
- route: routeTemplate(req),
58
+ route: resolveRoute(req) || "unmatched",
55
59
  status_code: String(res.statusCode),
56
60
  })
57
61
  );
@@ -59,6 +63,16 @@ function redMetricsMiddleware({ buckets, registry } = {}) {
59
63
  };
60
64
  }
61
65
 
66
+ /**
67
+ * Exposição das métricas sem depender de framework: devolve o content-type e o corpo no
68
+ * formato Prometheus. Use quando o servidor não é express (ex.: rota do Nitro).
69
+ * @param {import("prom-client").Registry} [registry] - default: global do prom-client
70
+ * @returns {Promise<{ contentType: string, body: string }>}
71
+ */
72
+ async function renderMetrics(registry = client.register) {
73
+ return { contentType: registry.contentType, body: await registry.metrics() };
74
+ }
75
+
62
76
  /**
63
77
  * Handler do endpoint de métricas (exposição no formato Prometheus).
64
78
  * @param {import("prom-client").Registry} [registry] - default: global do prom-client
@@ -66,28 +80,26 @@ function redMetricsMiddleware({ buckets, registry } = {}) {
66
80
  */
67
81
  function metricsHandler(registry = client.register) {
68
82
  return async function (_req, res) {
69
- res.set("Content-Type", registry.contentType);
70
- res.end(await registry.metrics());
83
+ const { contentType, body } = await renderMetrics(registry);
84
+ res.set("Content-Type", contentType);
85
+ res.end(body);
71
86
  };
72
87
  }
73
88
 
74
89
  /**
75
- * Configura as métricas RED no app express em uma linha. Registra o middleware
76
- * (aplique ANTES das rotas) e o endpoint /metrics.
90
+ * Registra o label `service`, as métricas default do Node e o histograma RED — sem tocar em
91
+ * framework nenhum. É a parte do setup que serve para express e para qualquer outro servidor.
77
92
  *
78
- * Uso: setupRedMetrics(app, { serviceName: "dev-mkt-busca" })
79
- *
80
- * @param {object} app - instância do express
81
93
  * @param {object} [options]
82
94
  * @param {string} [options.serviceName] - nome do MS; vira o label `service` em todas as métricas.
83
95
  * Fallback: env SERVICE_NAME > npm_package_name.
84
- * @param {string} [options.metricsPath] - caminho do endpoint de métricas (default: "/metrics")
85
96
  * @param {number[]} [options.buckets] - buckets de latência em segundos
86
97
  * @param {boolean} [options.collectDefaultMetrics] - coletar métricas default do Node
87
98
  * (heap, event loop, GC...). Default: true.
88
99
  * @param {import("prom-client").Registry} [options.registry] - registry alternativo (default: global)
100
+ * @returns {{ registry: import("prom-client").Registry, histogram: object }}
89
101
  */
90
- function setupRedMetrics(app, options = {}) {
102
+ function initRedMetrics(options = {}) {
91
103
  const registry = options.registry || client.register;
92
104
  const serviceName =
93
105
  options.serviceName || process.env.SERVICE_NAME || process.env.npm_package_name;
@@ -101,8 +113,24 @@ function setupRedMetrics(app, options = {}) {
101
113
  client.collectDefaultMetrics({ register: registry });
102
114
  }
103
115
 
116
+ return { registry, histogram: getHistogram(registry, options.buckets || DEFAULT_BUCKETS) };
117
+ }
118
+
119
+ /**
120
+ * Configura as métricas RED no app express em uma linha. Registra o middleware
121
+ * (aplique ANTES das rotas) e o endpoint /metrics.
122
+ *
123
+ * Uso: setupRedMetrics(app, { serviceName: "dev-mkt-busca" })
124
+ *
125
+ * @param {object} app - instância do express
126
+ * @param {object} [options] - ver initRedMetrics, mais:
127
+ * @param {string} [options.metricsPath] - caminho do endpoint de métricas (default: "/metrics")
128
+ */
129
+ function setupRedMetrics(app, options = {}) {
130
+ const { registry } = initRedMetrics(options);
131
+
104
132
  app.use(redMetricsMiddleware({ buckets: options.buckets, registry }));
105
133
  app.get(options.metricsPath || "/metrics", metricsHandler(registry));
106
134
  }
107
135
 
108
- module.exports = { setupRedMetrics, redMetricsMiddleware, metricsHandler };
136
+ module.exports = { setupRedMetrics, initRedMetrics, redMetricsMiddleware, metricsHandler, renderMetrics };
@@ -0,0 +1,97 @@
1
+ const { test, describe } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { EventEmitter } = require('node:events');
4
+ const client = require('prom-client');
5
+
6
+ const {
7
+ initRedMetrics,
8
+ redMetricsMiddleware,
9
+ renderMetrics,
10
+ } = require('../src/services/redMetricsService.js');
11
+
12
+ const registryLimpo = () => new client.Registry();
13
+
14
+ const medir = (registry, req, options = {}) => {
15
+ const middleware = redMetricsMiddleware({ registry, ...options });
16
+ const res = Object.assign(new EventEmitter(), { statusCode: 200 });
17
+ let chamouNext = false;
18
+
19
+ middleware(req, res, () => {
20
+ chamouNext = true;
21
+ });
22
+ res.emit('finish');
23
+
24
+ return { chamouNext };
25
+ };
26
+
27
+ describe('redMetricsMiddleware', () => {
28
+ test('usa o routeResolver quando o framework não tem req.route', async () => {
29
+ const registry = registryLimpo();
30
+
31
+ const { chamouNext } = medir(registry, { method: 'GET', url: '/consulta-empresa/123-x' }, {
32
+ routeResolver: () => '/consulta-empresa/:slug',
33
+ });
34
+
35
+ assert.equal(chamouNext, true);
36
+ const texto = await registry.metrics();
37
+ assert.match(texto, /route="\/consulta-empresa\/:slug"/);
38
+ assert.match(texto, /method="GET"/);
39
+ assert.match(texto, /status_code="200"/);
40
+ });
41
+
42
+ test('sem req.route e sem resolver o label cai em unmatched', async () => {
43
+ const registry = registryLimpo();
44
+
45
+ medir(registry, { method: 'POST', url: '/qualquer/coisa' });
46
+
47
+ assert.match(await registry.metrics(), /route="unmatched"/);
48
+ });
49
+
50
+ test('resolver que devolve vazio também cai em unmatched', async () => {
51
+ const registry = registryLimpo();
52
+
53
+ medir(registry, { method: 'GET', url: '/x' }, { routeResolver: () => undefined });
54
+
55
+ assert.match(await registry.metrics(), /route="unmatched"/);
56
+ });
57
+
58
+ test('template do express continua sendo o default', async () => {
59
+ const registry = registryLimpo();
60
+
61
+ medir(registry, { method: 'GET', baseUrl: '/api', route: { path: '/users/:id' } });
62
+
63
+ assert.match(await registry.metrics(), /route="\/api\/users\/:id"/);
64
+ });
65
+ });
66
+
67
+ describe('initRedMetrics', () => {
68
+ test('aplica o label service e devolve registry e histograma', async () => {
69
+ const registry = registryLimpo();
70
+
71
+ const { histogram } = initRedMetrics({ registry, serviceName: 'site-vue3', collectDefaultMetrics: false });
72
+
73
+ assert.ok(histogram);
74
+ histogram.observe({ method: 'GET', route: '/', status_code: '200' }, 0.1);
75
+ assert.match(await registry.metrics(), /service="site-vue3"/);
76
+ });
77
+
78
+ test('chamar duas vezes não duplica métrica', () => {
79
+ const registry = registryLimpo();
80
+
81
+ initRedMetrics({ registry, collectDefaultMetrics: false });
82
+
83
+ assert.doesNotThrow(() => initRedMetrics({ registry, collectDefaultMetrics: false }));
84
+ });
85
+ });
86
+
87
+ describe('renderMetrics', () => {
88
+ test('devolve content-type e corpo no formato Prometheus', async () => {
89
+ const registry = registryLimpo();
90
+ initRedMetrics({ registry, serviceName: 'site-vue3', collectDefaultMetrics: false });
91
+
92
+ const { contentType, body } = await renderMetrics(registry);
93
+
94
+ assert.match(contentType, /text\/plain/);
95
+ assert.match(body, /http_request_duration_seconds/);
96
+ });
97
+ });