@viktorbarzin/metrics 0.0.0-stage → 1.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,3 +1,40 @@
1
- # Temporary Holding Version
1
+ # @viktorbarzin/metrics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Prometheus metrics for an app: one prom-client registry per process with the
4
+ default process metrics, the HTTP request metrics every app exports, and
5
+ `/metrics` on a port of its own that the ingress never routes (point the
6
+ pod's `prometheus.io/port` annotation at it).
7
+
8
+ <!-- typecheck-preamble
9
+ declare const request: Request;
10
+ declare const response: Response;
11
+ -->
12
+ ```ts
13
+ import { getLogger } from "@viktorbarzin/log";
14
+ import { createHandlerLabeler, getRegistry, observeRequest, parseMetricsPort, startMetricsServer } from "@viktorbarzin/metrics";
15
+ import { Counter } from "prom-client";
16
+
17
+ // at boot (Bun)
18
+ const metrics = startMetricsServer({ port: parseMetricsPort(process.env.METRICS_PORT) });
19
+
20
+ // request middleware
21
+ const label = createHandlerLabeler({ known: ["/api/items", "/healthz"] });
22
+ const done = observeRequest(request, { label, accessLog: getLogger("myapp.http") });
23
+ // ... handle ...
24
+ done(response.status);
25
+
26
+ // app metrics
27
+ new Counter({ name: "myapp_things_total", help: "...", registers: [getRegistry()] });
28
+ ```
29
+
30
+ | Export | |
31
+ |---|---|
32
+ | `getRegistry()` | the process registry, with `collectDefaultMetrics` |
33
+ | `httpMetrics()` | `http_requests_total{method,handler,status}` (status as `2xx`), `http_request_duration_seconds{method,handler}` |
34
+ | `createHandlerLabeler({ known, buckets? })` | known paths by name; default buckets `/api/auth/` → `/api/auth`, `/api/` → `/api/other`, `/_serverFn/` → `/_serverFn`, else `page` |
35
+ | `observeRequest(request, { label, accessLog?, accessLogPrefix? })` | raises the idle timeout, tracks the request, returns `done(status)` that records the metrics and one access-log line `{method, path, status, duration_ms}` under the prefix (default `/api/`) |
36
+ | `metricsResponse(registry?)` | the text format |
37
+ | `parseMetricsPort(raw)` | a port or null (ERROR logged for junk) |
38
+ | `startMetricsServer({ port, hostname?, registry? })` | `Bun.serve` answering `/metrics` only; null without a port |
39
+
40
+ Peers: `prom-client` ^15.1, `@viktorbarzin/log`, `@viktorbarzin/runtime`.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Prometheus metrics for an app: one prom-client registry per process (with
3
+ * the default process metrics), the HTTP request metrics every app exports,
4
+ * and /metrics on its own port, which the ingress never routes. The pod's
5
+ * `prometheus.io/*` annotations point at that port.
6
+ *
7
+ * App-specific metrics are registered by the app on getRegistry().
8
+ */
9
+ import { type Logger } from "@viktorbarzin/log";
10
+ import { type Counter, type Histogram, type Registry } from "prom-client";
11
+ /** The process registry, created once with the default metrics. */
12
+ export declare function getRegistry(): Registry;
13
+ export interface HttpMetrics {
14
+ requestsTotal: Counter<"method" | "handler" | "status">;
15
+ requestDuration: Histogram<"method" | "handler">;
16
+ }
17
+ export declare const HTTP_DURATION_BUCKETS: number[];
18
+ /** http_requests_total and http_request_duration_seconds on the process registry. */
19
+ export declare function httpMetrics(): HttpMetrics;
20
+ export declare const DEFAULT_HANDLER_BUCKETS: ReadonlyArray<readonly [prefix: string, label: string]>;
21
+ /**
22
+ * The `handler` label: known paths by name, everything else folded into a few
23
+ * prefix buckets (first match wins) or "page", so the label set stays small.
24
+ */
25
+ export declare function createHandlerLabeler(opts: {
26
+ known: readonly string[];
27
+ buckets?: ReadonlyArray<readonly [prefix: string, label: string]>;
28
+ }): (pathname: string) => string;
29
+ /**
30
+ * Start observing one request: raises Bun's idle timeout, counts it as in
31
+ * flight, and returns a function to call with the final status. That records
32
+ * the counter (status as "2xx" etc.), the histogram and, for paths under
33
+ * `accessLogPrefix`, one access-log line {method, path, status, duration_ms}.
34
+ */
35
+ export declare function observeRequest(request: Request, opts: {
36
+ label: (pathname: string) => string;
37
+ accessLog?: Logger;
38
+ accessLogPrefix?: string;
39
+ }): (status: number) => void;
40
+ export declare function metricsResponse(registry?: Registry): Promise<Response>;
41
+ /** METRICS_PORT as a port number; null when unset or not a port (logged as ERROR). */
42
+ export declare function parseMetricsPort(raw: string | undefined): number | null;
43
+ export interface MetricsServer {
44
+ port: number;
45
+ stop(): void;
46
+ }
47
+ /** Serve /metrics (and nothing else) on its own Bun listener; null without a port. */
48
+ export declare function startMetricsServer(opts: {
49
+ port: number | null;
50
+ hostname?: string;
51
+ registry?: Registry;
52
+ }): MetricsServer | null;
package/dist/index.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Prometheus metrics for an app: one prom-client registry per process (with
3
+ * the default process metrics), the HTTP request metrics every app exports,
4
+ * and /metrics on its own port, which the ingress never routes. The pod's
5
+ * `prometheus.io/*` annotations point at that port.
6
+ *
7
+ * App-specific metrics are registered by the app on getRegistry().
8
+ */
9
+ import { getLogger } from "@viktorbarzin/log";
10
+ import { extendIdleTimeout, processSingleton, trackRequest, } from "@viktorbarzin/runtime";
11
+ import client, {} from "prom-client";
12
+ const log = getLogger("metrics");
13
+ /** The process registry, created once with the default metrics. */
14
+ export function getRegistry() {
15
+ return processSingleton("metrics.registry", () => {
16
+ const registry = new client.Registry();
17
+ client.collectDefaultMetrics({ register: registry });
18
+ return registry;
19
+ });
20
+ }
21
+ export const HTTP_DURATION_BUCKETS = [
22
+ 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
23
+ ];
24
+ /** http_requests_total and http_request_duration_seconds on the process registry. */
25
+ export function httpMetrics() {
26
+ return processSingleton("metrics.http", () => {
27
+ const registry = getRegistry();
28
+ return {
29
+ requestsTotal: new client.Counter({
30
+ name: "http_requests_total",
31
+ help: "HTTP requests handled by the app server.",
32
+ labelNames: ["method", "handler", "status"],
33
+ registers: [registry],
34
+ }),
35
+ requestDuration: new client.Histogram({
36
+ name: "http_request_duration_seconds",
37
+ help: "HTTP request latency in seconds.",
38
+ labelNames: ["method", "handler"],
39
+ buckets: HTTP_DURATION_BUCKETS,
40
+ registers: [registry],
41
+ }),
42
+ };
43
+ });
44
+ }
45
+ export const DEFAULT_HANDLER_BUCKETS = [
46
+ ["/api/auth/", "/api/auth"],
47
+ ["/api/", "/api/other"],
48
+ ["/_serverFn/", "/_serverFn"],
49
+ ];
50
+ /**
51
+ * The `handler` label: known paths by name, everything else folded into a few
52
+ * prefix buckets (first match wins) or "page", so the label set stays small.
53
+ */
54
+ export function createHandlerLabeler(opts) {
55
+ const known = new Set(opts.known);
56
+ const buckets = opts.buckets ?? DEFAULT_HANDLER_BUCKETS;
57
+ return (pathname) => {
58
+ if (known.has(pathname))
59
+ return pathname;
60
+ for (const [prefix, label] of buckets)
61
+ if (pathname.startsWith(prefix))
62
+ return label;
63
+ return "page";
64
+ };
65
+ }
66
+ /**
67
+ * Start observing one request: raises Bun's idle timeout, counts it as in
68
+ * flight, and returns a function to call with the final status. That records
69
+ * the counter (status as "2xx" etc.), the histogram and, for paths under
70
+ * `accessLogPrefix`, one access-log line {method, path, status, duration_ms}.
71
+ */
72
+ export function observeRequest(request, opts) {
73
+ extendIdleTimeout(request);
74
+ const untrack = trackRequest();
75
+ const started = performance.now();
76
+ const { pathname } = new URL(request.url);
77
+ const prefix = opts.accessLogPrefix ?? "/api/";
78
+ let recorded = false;
79
+ return (status) => {
80
+ if (recorded)
81
+ return;
82
+ recorded = true;
83
+ untrack();
84
+ const seconds = (performance.now() - started) / 1000;
85
+ const handler = opts.label(pathname);
86
+ const metrics = httpMetrics();
87
+ metrics.requestsTotal.inc({
88
+ method: request.method,
89
+ handler,
90
+ status: `${Math.floor(status / 100)}xx`,
91
+ });
92
+ metrics.requestDuration.observe({ method: request.method, handler }, seconds);
93
+ if (opts.accessLog && pathname.startsWith(prefix)) {
94
+ opts.accessLog.info("request", {
95
+ method: request.method,
96
+ path: pathname,
97
+ status,
98
+ duration_ms: Math.round(seconds * 1000),
99
+ });
100
+ }
101
+ };
102
+ }
103
+ export async function metricsResponse(registry = getRegistry()) {
104
+ return new Response(await registry.metrics(), {
105
+ headers: { "content-type": registry.contentType },
106
+ });
107
+ }
108
+ /** METRICS_PORT as a port number; null when unset or not a port (logged as ERROR). */
109
+ export function parseMetricsPort(raw) {
110
+ if (raw === undefined || raw.trim() === "")
111
+ return null;
112
+ const port = Number(raw);
113
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
114
+ log.error("METRICS_PORT is not a port; metrics are not served", {
115
+ value: raw,
116
+ });
117
+ return null;
118
+ }
119
+ return port;
120
+ }
121
+ /** Serve /metrics (and nothing else) on its own Bun listener; null without a port. */
122
+ export function startMetricsServer(opts) {
123
+ if (opts.port === null)
124
+ return null;
125
+ const registry = opts.registry ?? getRegistry();
126
+ try {
127
+ const server = Bun.serve({
128
+ hostname: opts.hostname ?? "0.0.0.0",
129
+ port: opts.port,
130
+ fetch: (request) => new URL(request.url).pathname === "/metrics"
131
+ ? metricsResponse(registry)
132
+ : new Response("not found\n", { status: 404 }),
133
+ });
134
+ log.info("metrics listening", { port: server.port });
135
+ return {
136
+ port: server.port,
137
+ stop: () => void server.stop(true),
138
+ };
139
+ }
140
+ catch (err) {
141
+ log.exception("metrics server failed to start", err, { port: opts.port });
142
+ return null;
143
+ }
144
+ }
package/package.json CHANGED
@@ -1,6 +1,46 @@
1
1
  {
2
- "name": "@viktorbarzin/metrics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@viktorbarzin/metrics",
3
+ "version": "1.0.0",
4
+ "description": "One prom-client registry per process, HTTP request metrics and a /metrics listener on its own port.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "bun": ">=1.4.3"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/ViktorBarzin/app-kit.git",
25
+ "directory": "packages/metrics"
26
+ },
27
+ "homepage": "https://github.com/ViktorBarzin/app-kit/tree/master/packages/metrics#readme",
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "scripts": {
33
+ "build": "tsc -b",
34
+ "test": "bun test"
35
+ },
36
+ "peerDependencies": {
37
+ "@viktorbarzin/log": "^1.0.0",
38
+ "@viktorbarzin/runtime": "^1.0.0",
39
+ "prom-client": "^15.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@viktorbarzin/log": "^1.0.0",
43
+ "@viktorbarzin/runtime": "^1.0.0",
44
+ "prom-client": "^15.1.3"
45
+ }
46
+ }