@ecdt/server-common 1.1.0 → 1.4.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
@@ -79,6 +79,54 @@ app.use(expressCors({ origins: [...], extraHeaders: ["X-Custom-Header"] }));
79
79
  app.use(expressCors({ origins: [...], headers: ["Content-Type"], methods: ["GET", "POST"] }));
80
80
  ```
81
81
 
82
+ ### `setupRedMetrics(app, options?)`
83
+
84
+ Instrumenta o servidor com métricas **RED** (Rate, Errors, Duration) e expõe o endpoint `/metrics` no formato Prometheus, em uma linha. Todas as requisições passam a alimentar o histograma `http_request_duration_seconds{method, route, status_code}`.
85
+
86
+ > Requer `prom-client` instalado no microsserviço (é uma `peerDependency`).
87
+
88
+ | Opção | Tipo | Descrição |
89
+ |---|---|---|
90
+ | `serviceName` | `string` | Nome do MS; vira o label `service` em todas as métricas. Fallback: env `SERVICE_NAME` > `npm_package_name` |
91
+ | `metricsPath` | `string` | Caminho do endpoint de métricas. Default: `/metrics` |
92
+ | `buckets` | `number[]` | Buckets de latência (segundos). Default: `[0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]` |
93
+ | `collectDefaultMetrics` | `boolean` | Coletar métricas default do Node (heap, event loop, GC...). Default: `true` |
94
+ | `registry` | `Registry` | Registry alternativo do prom-client. Default: registry global |
95
+
96
+ ```js
97
+ const express = require("express");
98
+ const { setupRedMetrics } = require("@ecdt/server-common");
99
+
100
+ const app = express();
101
+
102
+ // ANTES de montar as rotas:
103
+ setupRedMetrics(app, { serviceName: "dev-mkt-busca" });
104
+
105
+ app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
106
+
107
+ app.listen(3000);
108
+ ```
109
+
110
+ O label `route` usa o **template** da rota (`/users/:id`), nunca a URL crua — isso mantém a cardinalidade (e o custo de ingestão) sob controle.
111
+
112
+ #### Consultas (PromQL) habilitadas
113
+
114
+ ```promql
115
+ # Latência p95 por endpoint
116
+ histogram_quantile(0.95, sum by (le, route)(rate(http_request_duration_seconds_bucket{service="dev-mkt-busca"}[5m])))
117
+
118
+ # Taxa de requisições por segundo (RPS)
119
+ sum(rate(http_request_duration_seconds_count{service="dev-mkt-busca"}[1m]))
120
+
121
+ # Erros 5xx por segundo
122
+ sum(rate(http_request_duration_seconds_count{service="dev-mkt-busca",status_code=~"5.."}[5m]))
123
+
124
+ # Endpoints que mais geram erro
125
+ topk(10, sum by (route)(rate(http_request_duration_seconds_count{service="dev-mkt-busca",status_code=~"5.."}[5m])))
126
+ ```
127
+
128
+ Para wiring manual, a lib também exporta `redMetricsMiddleware(options?)` e `metricsHandler(registry?)`.
129
+
82
130
  ---
83
131
 
84
132
  ## Instalação
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecdt/server-common",
3
- "version": "1.1.0",
3
+ "version": "1.4.0",
4
4
  "description": "Conjunto de ferramentas e configurações comuns nos servidores da Econodata",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -11,5 +11,8 @@
11
11
  "dependencies": {
12
12
  "body-parser": "^2.2.2",
13
13
  "cors": "^2.8.6"
14
+ },
15
+ "peerDependencies": {
16
+ "prom-client": "^15.0.0"
14
17
  }
15
18
  }
package/src/index.d.ts CHANGED
@@ -38,4 +38,38 @@ declare module '@ecdt/server-common' {
38
38
  * Uso: app.use(expressCors({ origins: ["https://app.econodata.com.br"] }))
39
39
  */
40
40
  export function expressCors(options: ExpressCorsOptions): (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
41
+
42
+ export interface RedMetricsOptions {
43
+ /** Nome do microsserviço. Vira o label `service` em todas as métricas. Fallback: env SERVICE_NAME > npm_package_name. */
44
+ serviceName?: string;
45
+ /** Caminho do endpoint de métricas. Default: '/metrics'. */
46
+ metricsPath?: string;
47
+ /** Buckets do histograma de latência, em segundos. */
48
+ buckets?: number[];
49
+ /** Coletar métricas default do Node (heap, event loop, GC...). Default: true. */
50
+ collectDefaultMetrics?: boolean;
51
+ /** Registry alternativo do prom-client. Default: registry global. */
52
+ registry?: unknown;
53
+ }
54
+
55
+ /**
56
+ * Configura as métricas RED (Rate, Errors, Duration) no app express: registra o
57
+ * middleware de medição (antes das rotas) e o endpoint /metrics.
58
+ *
59
+ * Alimenta o histograma `http_request_duration_seconds{method,route,status_code}`,
60
+ * que habilita latência por endpoint, latência total, RPS, erros e top endpoints com erro.
61
+ *
62
+ * Requer `prom-client` instalado no microsserviço (peerDependency).
63
+ *
64
+ * Uso: setupRedMetrics(app, { serviceName: "dev-mkt-busca" })
65
+ */
66
+ export function setupRedMetrics(app: unknown, options?: RedMetricsOptions): void;
67
+
68
+ /** Middleware do express que alimenta o histograma `http_request_duration_seconds`. */
69
+ export function redMetricsMiddleware(
70
+ options?: Pick<RedMetricsOptions, "buckets" | "registry">
71
+ ): (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
72
+
73
+ /** Handler do endpoint de métricas (exposição no formato Prometheus). */
74
+ export function metricsHandler(registry?: unknown): (req: unknown, res: unknown) => void;
41
75
  }
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const bodyParser = require("body-parser");
2
2
  const { devMktTokenSanitaze } = require("./services/tokenService.js");
3
3
  const { expressCors } = require("./services/corsService.js");
4
+ const { setupGracefulShutdown } = require("./services/shutdownService.js");
5
+ const { setupRedMetrics, redMetricsMiddleware, metricsHandler } = require("./services/redMetricsService.js");
4
6
 
5
7
  function expressCommonMiddlewares({ cookieName } = {}){
6
8
  return [bodyParser.text(), bodyParser.json(), bodyParser.urlencoded({extended: false}), devMktTokenSanitaze({ cookieName })];
@@ -9,4 +11,4 @@ function expressCommonMiddlewares({ cookieName } = {}){
9
11
 
10
12
 
11
13
 
12
- module.exports = { expressCommonMiddlewares, expressCors }
14
+ module.exports = { expressCommonMiddlewares, expressCors, setupGracefulShutdown, setupRedMetrics, redMetricsMiddleware, metricsHandler }
@@ -0,0 +1,108 @@
1
+ const client = require("prom-client");
2
+
3
+ const METRIC_NAME = "http_request_duration_seconds";
4
+ const DEFAULT_BUCKETS = [0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
5
+
6
+ /**
7
+ * get-or-create do histograma. Reaproveita a métrica se já registrada, evitando
8
+ * o erro "A metric with the name ... has already been registered" em import duplo
9
+ * ou múltiplas chamadas de setup.
10
+ * @param {import("prom-client").Registry} registry
11
+ * @param {number[]} buckets
12
+ */
13
+ function getHistogram(registry, buckets) {
14
+ const existing = registry.getSingleMetric(METRIC_NAME);
15
+ if (existing) return existing;
16
+ return new client.Histogram({
17
+ name: METRIC_NAME,
18
+ help: "Duração das requisições HTTP, em segundos",
19
+ labelNames: ["method", "route", "status_code"],
20
+ buckets,
21
+ registers: [registry],
22
+ });
23
+ }
24
+
25
+ /**
26
+ * Extrai o TEMPLATE da rota (ex.: "/users/:id"), com o prefixo de routers aninhados.
27
+ * Nunca usa a URL crua — evita explosão de cardinalidade (e custo de ingestão no GMP).
28
+ */
29
+ function routeTemplate(req) {
30
+ const path = req.route && req.route.path;
31
+ if (!path) return "unmatched";
32
+ const p = Array.isArray(path) ? path[0] : path;
33
+ return `${req.baseUrl || ""}${p}` || "unmatched";
34
+ }
35
+
36
+ /**
37
+ * Middleware do express que mede cada requisição e alimenta o histograma RED
38
+ * (http_request_duration_seconds) com os labels method, route e status_code.
39
+ *
40
+ * @param {object} [options]
41
+ * @param {number[]} [options.buckets] - buckets de latência em segundos
42
+ * @param {import("prom-client").Registry} [options.registry] - registry (default: global do prom-client)
43
+ * @returns middleware do express
44
+ */
45
+ function redMetricsMiddleware({ buckets, registry } = {}) {
46
+ const reg = registry || client.register;
47
+ const histogram = getHistogram(reg, buckets || DEFAULT_BUCKETS);
48
+ return function (req, res, next) {
49
+ const stop = histogram.startTimer();
50
+ // 'finish' dispara após a rota resolver -> req.route já está preenchido aqui.
51
+ res.on("finish", () =>
52
+ stop({
53
+ method: req.method,
54
+ route: routeTemplate(req),
55
+ status_code: String(res.statusCode),
56
+ })
57
+ );
58
+ next();
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Handler do endpoint de métricas (exposição no formato Prometheus).
64
+ * @param {import("prom-client").Registry} [registry] - default: global do prom-client
65
+ * @returns handler do express
66
+ */
67
+ function metricsHandler(registry = client.register) {
68
+ return async function (_req, res) {
69
+ res.set("Content-Type", registry.contentType);
70
+ res.end(await registry.metrics());
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Configura as métricas RED no app express em uma linha. Registra o middleware
76
+ * (aplique ANTES das rotas) e o endpoint /metrics.
77
+ *
78
+ * Uso: setupRedMetrics(app, { serviceName: "dev-mkt-busca" })
79
+ *
80
+ * @param {object} app - instância do express
81
+ * @param {object} [options]
82
+ * @param {string} [options.serviceName] - nome do MS; vira o label `service` em todas as métricas.
83
+ * Fallback: env SERVICE_NAME > npm_package_name.
84
+ * @param {string} [options.metricsPath] - caminho do endpoint de métricas (default: "/metrics")
85
+ * @param {number[]} [options.buckets] - buckets de latência em segundos
86
+ * @param {boolean} [options.collectDefaultMetrics] - coletar métricas default do Node
87
+ * (heap, event loop, GC...). Default: true.
88
+ * @param {import("prom-client").Registry} [options.registry] - registry alternativo (default: global)
89
+ */
90
+ function setupRedMetrics(app, options = {}) {
91
+ const registry = options.registry || client.register;
92
+ const serviceName =
93
+ options.serviceName || process.env.SERVICE_NAME || process.env.npm_package_name;
94
+ if (serviceName) registry.setDefaultLabels({ service: serviceName });
95
+
96
+ // guard: collectDefaultMetrics chamado 2x joga "already registered".
97
+ if (
98
+ options.collectDefaultMetrics !== false &&
99
+ !registry.getSingleMetric("process_cpu_user_seconds_total")
100
+ ) {
101
+ client.collectDefaultMetrics({ register: registry });
102
+ }
103
+
104
+ app.use(redMetricsMiddleware({ buckets: options.buckets, registry }));
105
+ app.get(options.metricsPath || "/metrics", metricsHandler(registry));
106
+ }
107
+
108
+ module.exports = { setupRedMetrics, redMetricsMiddleware, metricsHandler };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Graceful shutdown padrão dos microsserviços da Econodata em Kubernetes.
3
+ * @param {import('http').Server} server - servidor retornado por http.createServer(app).listen(...) ou app.listen(...)
4
+ * @param {object} [options]
5
+ * @param {() => (Promise<void>|void)} [options.onShutdown] - cleanup extra (fechar pools, Redis, etc.) executado após o server fechar
6
+ * @param {number} [options.failsafeTimeoutMs] - tempo máximo do graceful antes de forçar a saída (default 10s: grace 40s − 20s de preStop)
7
+ */
8
+ function setupGracefulShutdown(server, { onShutdown, failsafeTimeoutMs = 10 * 1000 } = {}) {
9
+ let shuttingDown = false;
10
+ const gracefulShutdown = signal => {
11
+ if (shuttingDown) return;
12
+ shuttingDown = true;
13
+ console.warn(JSON.stringify({
14
+ severity: 'WARNING',
15
+ message: `[shutdown] ${signal} recebido — encerrando`,
16
+ }));
17
+
18
+ server.close(async err => {
19
+ if (err) {
20
+ console.error('[shutdown] Erro ao fechar o server HTTP:', err.message || err);
21
+ } else {
22
+ console.warn(JSON.stringify({
23
+ severity: 'WARNING',
24
+ message: '[shutdown] Server HTTP fechado, conexões em voo drenadas',
25
+ }));
26
+ }
27
+ if (onShutdown) {
28
+ try {
29
+ await onShutdown();
30
+ } catch (cleanupErr) {
31
+ console.warn(JSON.stringify({
32
+ severity: 'WARNING',
33
+ message: `[shutdown] Falha no cleanup: ${cleanupErr.message || cleanupErr}`,
34
+ }));
35
+ }
36
+ }
37
+ process.exit(err ? 1 : 0);
38
+ });
39
+
40
+ // fecha conexões keep-alive ociosas, senão um keepAliveTimeout alto segura o close()
41
+ server.closeIdleConnections();
42
+
43
+ // failsafe antes do SIGKILL
44
+ setTimeout(() => {
45
+ console.error('[shutdown] Timeout no graceful shutdown, forçando saída');
46
+ process.exit(1);
47
+ }, failsafeTimeoutMs).unref();
48
+ };
49
+
50
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
51
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
52
+ }
53
+
54
+ module.exports = { setupGracefulShutdown };