@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 +48 -0
- package/package.json +4 -1
- package/src/index.d.ts +34 -0
- package/src/index.js +3 -1
- package/src/services/redMetricsService.js +108 -0
- package/src/services/shutdownService.js +54 -0
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.
|
|
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 };
|