@quark-fw/plugin-metrics 0.0.0-stage → 0.1.27
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 +28 -2
- package/package.json +36 -5
- package/src/createHttpMetricsMiddleware.ts +39 -0
- package/src/createMetricsHandler.ts +33 -0
- package/src/global.d.ts +13 -0
- package/src/helpers.ts +16 -0
- package/src/index.ts +43 -0
- package/src/metrics.test.ts +55 -0
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @quark-fw/plugin-metrics
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`GET /metrics` in Prometheus format for Quark apps: Node process defaults (CPU, memory, event loop lag, GC) and `http_request_duration_seconds` per `method` / `route` / `status` for every request the app serves (SSR pages, tRPC, static).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm i @quark-fw/plugin-metrics
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import "@quark-fw/plugin-metrics"; // provides quark.metrics.registry and GET /metrics
|
|
15
|
+
|
|
16
|
+
// register your own metrics in the same registry
|
|
17
|
+
import { Counter } from "prom-client";
|
|
18
|
+
const imports = new Counter({
|
|
19
|
+
name: "jira_imports_total",
|
|
20
|
+
help: "Jira imports",
|
|
21
|
+
registers: [quark.metrics.registry],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Route labels are bounded on purpose: `/api/trpc/<proc>` becomes `trpc:<first procedure>`, other paths collapse to their first segment (`/projects/123` → `/projects`) and 404s to `(unmatched)` — raw URLs would create a series per id.
|
|
26
|
+
|
|
27
|
+
Env: `METRICS_TOKEN` — when set, `/metrics` requires `Authorization: Bearer <token>`; leave unset only when the scraper reaches the app over a private network.
|
|
28
|
+
|
|
29
|
+
Part of the [Quark framework](https://www.npmjs.com/package/@quark-fw/core).
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@quark-fw/plugin-metrics",
|
|
3
|
+
"version": "0.1.27",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "echo raw-src package, no build",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"quark",
|
|
16
|
+
"metrics",
|
|
17
|
+
"prometheus"
|
|
18
|
+
],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"description": "Prometheus metrics for the Quark framework: GET /metrics with process defaults and HTTP request duration per route (published as raw TS sources; consumers run tsx)",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@quark-fw/plugin": "0.1.27",
|
|
27
|
+
"express": "^5.2.1",
|
|
28
|
+
"prom-client": "^15.1.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@quark-fw/core": "0.1.27"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/express": "^5.0.6",
|
|
35
|
+
"vitest": "^5.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
import { Histogram, type Registry } from "prom-client";
|
|
3
|
+
import { routeLabel } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Гистограмма длительности HTTP-запросов по методу, маршруту и статусу.
|
|
7
|
+
* Ставится до роутеров плагинов (preRouter), чтобы считать и SSR, и API,
|
|
8
|
+
* и статику. _count гистограммы даёт и число запросов — отдельный счётчик
|
|
9
|
+
* не нужен.
|
|
10
|
+
*/
|
|
11
|
+
export function createHttpMetricsMiddleware(
|
|
12
|
+
registry: Registry,
|
|
13
|
+
): RequestHandler {
|
|
14
|
+
const duration = new Histogram({
|
|
15
|
+
name: "http_request_duration_seconds",
|
|
16
|
+
help: "Длительность HTTP-запросов",
|
|
17
|
+
labelNames: ["method", "route", "status"] as const,
|
|
18
|
+
// от 5 мс (статика) до 10 с (зависший SSR)
|
|
19
|
+
buckets: [0.005, 0.02, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
|
|
20
|
+
registers: [registry],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return (req, res, next) => {
|
|
24
|
+
const started = process.hrtime.bigint();
|
|
25
|
+
res.on("finish", () => {
|
|
26
|
+
const seconds =
|
|
27
|
+
Number(process.hrtime.bigint() - started) / 1_000_000_000;
|
|
28
|
+
duration.observe(
|
|
29
|
+
{
|
|
30
|
+
method: req.method,
|
|
31
|
+
route: routeLabel(req.originalUrl, res.statusCode),
|
|
32
|
+
status: String(res.statusCode),
|
|
33
|
+
},
|
|
34
|
+
seconds,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
next();
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RequestHandler } from "express";
|
|
2
|
+
import type { Registry } from "prom-client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /metrics в формате Prometheus. С METRICS_TOKEN отдаётся только по
|
|
6
|
+
* `Authorization: Bearer <token>`: метрики раскрывают версии, пути и
|
|
7
|
+
* нагрузку, а приложение обычно смотрит в интернет. Без токена — открыто,
|
|
8
|
+
* для скрейпа из внутренней сети.
|
|
9
|
+
*/
|
|
10
|
+
export function createMetricsHandler(
|
|
11
|
+
registry: Registry,
|
|
12
|
+
token: string | undefined,
|
|
13
|
+
): RequestHandler {
|
|
14
|
+
return (req, res) => {
|
|
15
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
16
|
+
res.status(405).end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (token && req.headers.authorization !== `Bearer ${token}`) {
|
|
20
|
+
res.status(401).end();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
void registry.metrics().then(
|
|
24
|
+
(body) => {
|
|
25
|
+
res.setHeader("content-type", registry.contentType);
|
|
26
|
+
res.end(body);
|
|
27
|
+
},
|
|
28
|
+
(error: unknown) => {
|
|
29
|
+
res.status(500).end(String(error));
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "@quark-fw/core/src/global.d.ts";
|
|
2
|
+
import type { Registry } from "prom-client";
|
|
3
|
+
|
|
4
|
+
export {};
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Quark {
|
|
8
|
+
metrics: {
|
|
9
|
+
/** реестр prom-client: приложение регистрирует в нём свои метрики */
|
|
10
|
+
registry: Registry;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Хелперы plugin-metrics: метка маршрута.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Метка route для метрик HTTP. Сырой URL в метку класть нельзя: каждый id
|
|
5
|
+
* в пути — новая серия, и Prometheus захлёбывается. Поэтому:
|
|
6
|
+
* - /api/trpc/<proc>[,<proc>…] → trpc:<первая процедура>;
|
|
7
|
+
* - 404 → (unmatched): боты перебирают /wp-admin, /.env и т.п.;
|
|
8
|
+
* - остальное → первый сегмент пути (/projects/123 → /projects).
|
|
9
|
+
*/
|
|
10
|
+
export function routeLabel(path: string, status: number): string {
|
|
11
|
+
if (status === 404) return "(unmatched)";
|
|
12
|
+
const trpc = path.match(/^\/api\/trpc\/([^/?,]+)/);
|
|
13
|
+
if (trpc) return `trpc:${trpc[1]}`;
|
|
14
|
+
const segment = path.split("?")[0]!.split("/")[1] ?? "";
|
|
15
|
+
return segment ? `/${segment}` : "/";
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { QuarkPlugin } from "@quark-fw/plugin";
|
|
2
|
+
import {
|
|
3
|
+
registerHttpMiddleware,
|
|
4
|
+
registerHttpPreMiddleware,
|
|
5
|
+
} from "@quark-fw/plugin/http.js";
|
|
6
|
+
import { Registry, collectDefaultMetrics } from "prom-client";
|
|
7
|
+
import {} from "@quark-fw/plugin-metrics/src/global.js";
|
|
8
|
+
import { createHttpMetricsMiddleware } from "./createHttpMetricsMiddleware.js";
|
|
9
|
+
import { createMetricsHandler } from "./createMetricsHandler.js";
|
|
10
|
+
|
|
11
|
+
export { routeLabel } from "./helpers.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GET /metrics (Prometheus): метрики процесса Node (CPU, память, event loop,
|
|
15
|
+
* GC) и http_request_duration_seconds по method/route/status. Свои метрики
|
|
16
|
+
* приложение регистрирует в quark.metrics.registry. METRICS_TOKEN закрывает
|
|
17
|
+
* эндпоинт bearer-токеном.
|
|
18
|
+
*/
|
|
19
|
+
await quark.registerPlugin({
|
|
20
|
+
name: "metrics",
|
|
21
|
+
extend(quark) {
|
|
22
|
+
// свой реестр, а не глобальный prom-client: тесты и воркеры threads
|
|
23
|
+
// в одном процессе не должны делить и дублировать метрики
|
|
24
|
+
const registry = new Registry();
|
|
25
|
+
collectDefaultMetrics({ register: registry });
|
|
26
|
+
quark.metrics = { registry };
|
|
27
|
+
},
|
|
28
|
+
onInit(quark) {
|
|
29
|
+
registerHttpPreMiddleware(
|
|
30
|
+
createHttpMetricsMiddleware(quark.metrics.registry),
|
|
31
|
+
);
|
|
32
|
+
registerHttpMiddleware(
|
|
33
|
+
"/metrics",
|
|
34
|
+
createMetricsHandler(
|
|
35
|
+
quark.metrics.registry,
|
|
36
|
+
process.env.METRICS_TOKEN || undefined,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
onDestroy(quark) {
|
|
41
|
+
quark.metrics.registry.clear();
|
|
42
|
+
},
|
|
43
|
+
} satisfies QuarkPlugin);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from "vitest";
|
|
2
|
+
import express from "express";
|
|
3
|
+
import type { Server } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { Registry } from "prom-client";
|
|
6
|
+
import { createHttpMetricsMiddleware } from "./createHttpMetricsMiddleware.js";
|
|
7
|
+
import { createMetricsHandler } from "./createMetricsHandler.js";
|
|
8
|
+
import { routeLabel } from "./helpers.js";
|
|
9
|
+
|
|
10
|
+
test("routeLabel: процедура tRPC, первый сегмент, 404 схлопывается", () => {
|
|
11
|
+
expect(routeLabel("/api/trpc/entity.task.list?batch=1", 200)).toBe(
|
|
12
|
+
"trpc:entity.task.list",
|
|
13
|
+
);
|
|
14
|
+
expect(routeLabel("/api/trpc/a.b,c.d?batch=1", 200)).toBe("trpc:a.b");
|
|
15
|
+
expect(routeLabel("/projects/01a1-uuid?x=1", 200)).toBe("/projects");
|
|
16
|
+
expect(routeLabel("/", 200)).toBe("/");
|
|
17
|
+
expect(routeLabel("/wp-admin/setup.php", 404)).toBe("(unmatched)");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
let server: Server;
|
|
21
|
+
let base: string;
|
|
22
|
+
const registry = new Registry();
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
const app = express();
|
|
26
|
+
app.use(createHttpMetricsMiddleware(registry));
|
|
27
|
+
app.get("/projects/:id", (_req, res) => res.send("ok"));
|
|
28
|
+
app.use("/metrics", createMetricsHandler(registry, "s3cret"));
|
|
29
|
+
await new Promise<void>((resolve) => {
|
|
30
|
+
server = app.listen(0, () => resolve());
|
|
31
|
+
});
|
|
32
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => server.close());
|
|
36
|
+
|
|
37
|
+
test("запросы попадают в гистограмму по route/status; /metrics под токеном", async () => {
|
|
38
|
+
await fetch(`${base}/projects/1`);
|
|
39
|
+
await fetch(`${base}/projects/2`);
|
|
40
|
+
await fetch(`${base}/nope`);
|
|
41
|
+
|
|
42
|
+
expect((await fetch(`${base}/metrics`)).status).toBe(401);
|
|
43
|
+
const r = await fetch(`${base}/metrics`, {
|
|
44
|
+
headers: { authorization: "Bearer s3cret" },
|
|
45
|
+
});
|
|
46
|
+
expect(r.status).toBe(200);
|
|
47
|
+
expect(r.headers.get("content-type")).toContain("text/plain");
|
|
48
|
+
const body = await r.text();
|
|
49
|
+
expect(body).toContain(
|
|
50
|
+
'http_request_duration_seconds_count{method="GET",route="/projects",status="200"} 2',
|
|
51
|
+
);
|
|
52
|
+
expect(body).toContain(
|
|
53
|
+
'http_request_duration_seconds_count{method="GET",route="(unmatched)",status="404"} 1',
|
|
54
|
+
);
|
|
55
|
+
});
|