-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin.js
More file actions
180 lines (163 loc) · 8.2 KB
/
Copy pathplugin.js
File metadata and controls
180 lines (163 loc) · 8.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
// Ops observability as a #206 loader plugin: a liveness/readiness endpoint
// and a Prometheus text-format exporter, from node builtins only.
//
// plugins: [{ id: 'metrics', module: 'metrics/plugin.js', prefix: '/metrics',
// config: { loopbackUrl: 'http://127.0.0.1:3000', // optional
// token: 'long-random-string' } }] // optional
//
// GET <prefix>/healthz liveness/readiness JSON (always open)
// GET <prefix>/metrics Prometheus exposition (text/plain; version=0.0.4),
// Bearer-guarded iff config.token is set
//
// Both config keys are OPTIONAL — a deliberate contrast with the plugins
// that throw on missing config: without loopbackUrl, /healthz still reports
// the process is up; it just can't vouch for the host's HTTP front door.
//
// The interesting part is jss_plugin_http_requests_total: it is fed by an
// `onResponse` hook on api.fastify, and what that hook can SEE was measured
// empirically (see README Findings). Short version: it fires for every
// route registered by ANY plugin in the loader (all entries share one
// Fastify register scope), and never for core's own routes (LDP, idp,
// /.well-known — encapsulation shields the parent instance). The metric
// names say "plugin", not "server", because that is what they honestly are.
//
// Footnote discovered along the way: on a host booted with `logger: false`,
// core's access-log onResponse hook throws (`request.log.isLevelEnabled` is
// not a function on Fastify's null logger) and silently aborts the whole
// downstream onResponse chain — including ours. Boot with logging enabled
// (the production default; tests use logLevel 'silent') or the request
// counters stay at zero. Details in README Findings.
import { createHash, timingSafeEqual } from 'node:crypto';
import { monitorEventLoopDelay } from 'node:perf_hooks';
const LOOPBACK_TIMEOUT_MS = 3000;
const MAX_SERIES = 1000; // label-cardinality guard for the request counter
const SEP = '\u0000'; // NUL can't occur in method/route/status, so keys never collide
/** Escape a Prometheus label value (backslash, quote, newline). */
function escapeLabel(value) {
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
}
export async function activate(api) {
const prefix = api.prefix || '';
const loopbackUrl = (api.config.loopbackUrl || '').replace(/\/$/, '');
const token = api.config.token ? String(api.config.token) : null;
// ---------------------------------------------------- event-loop lag
// monitorEventLoopDelay samples continuously; we report the mean since
// the previous scrape (reset after each read) and tear it down in
// deactivate so the test process can exit.
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
loopDelay.enable();
// ------------------------------------------------- http request stats
// Counter series keyed method|route|status. `route` is the registered
// route pattern (request.routeOptions.url), not the raw URL, so the
// cardinality is bounded by how many routes plugins register; MAX_SERIES
// is a belt-and-braces cap (overflow lumps into route="_other").
const requests = new Map();
let durationSum = 0; // seconds
let durationCount = 0;
api.fastify.addHook('onResponse', (request, reply, done) => {
const route = request.routeOptions?.url ?? request.routerPath ?? request.url;
let key = request.method + SEP + route + SEP + reply.statusCode;
if (!requests.has(key) && requests.size >= MAX_SERIES) {
key = request.method + SEP + '_other' + SEP + reply.statusCode;
}
requests.set(key, (requests.get(key) ?? 0) + 1);
const ms = typeof reply.elapsedTime === 'number'
? reply.elapsedTime
: (reply.getResponseTime?.() ?? 0);
durationSum += ms / 1000;
durationCount += 1;
done();
});
// ------------------------------------------------------------- render
function render() {
const mem = process.memoryUsage();
const cpu = process.cpuUsage(); // microseconds since process start
const lines = [];
const sample = (name, type, help, value) => {
lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`, `${name} ${value}`);
};
sample('process_uptime_seconds', 'gauge',
'Node.js process uptime in seconds.', process.uptime());
sample('process_resident_memory_bytes', 'gauge',
'Resident set size in bytes.', mem.rss);
sample('process_heap_used_bytes', 'gauge',
'V8 heap used in bytes.', mem.heapUsed);
sample('process_heap_total_bytes', 'gauge',
'V8 heap total in bytes.', mem.heapTotal);
sample('process_cpu_user_seconds_total', 'counter',
'Total user CPU time in seconds.', cpu.user / 1e6);
sample('process_cpu_system_seconds_total', 'counter',
'Total system CPU time in seconds.', cpu.system / 1e6);
const lag = loopDelay.mean / 1e9; // ns -> s; NaN when no samples yet
sample('nodejs_eventloop_lag_seconds', 'gauge',
'Mean event-loop delay in seconds since the previous scrape.',
Number.isFinite(lag) ? lag : 0);
loopDelay.reset();
lines.push(
'# HELP jss_plugin_http_requests_total HTTP requests served by plugin-registered routes (every plugin in the loader scope; core routes are invisible to plugin hooks — see README).',
'# TYPE jss_plugin_http_requests_total counter',
);
for (const [key, count] of requests) {
const [method, route, status] = key.split(SEP);
lines.push(`jss_plugin_http_requests_total{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${count}`);
}
lines.push(
'# HELP jss_plugin_http_request_duration_seconds Duration of plugin-route requests (summary: sum and count only).',
'# TYPE jss_plugin_http_request_duration_seconds summary',
`jss_plugin_http_request_duration_seconds_sum ${durationSum}`,
`jss_plugin_http_request_duration_seconds_count ${durationCount}`,
);
return lines.join('\n') + '\n';
}
// --------------------------------------------------------------- auth
// Bearer token on /metrics iff config.token is set. Constant-time compare
// over sha256 digests so length differences leak nothing either.
function authorized(request) {
if (!token) return true;
const header = request.headers.authorization;
if (typeof header !== 'string' || !/^Bearer /i.test(header)) return false;
const presented = createHash('sha256').update(header.slice(7).trim()).digest();
const expected = createHash('sha256').update(token).digest();
return timingSafeEqual(presented, expected);
}
// ------------------------------------------------------------- routes
api.fastify.get(prefix + '/healthz', async (request, reply) => {
const checks = { process: { ok: true, pid: process.pid } };
let ok = true;
if (loopbackUrl) {
// Probe the host's own front door over loopback: any response that
// is not a 5xx means the HTTP pipeline is answering (401/403/404 are
// policy, not sickness). No Authorization is forwarded — this is a
// liveness probe, not a data read.
try {
const res = await fetch(loopbackUrl + '/', {
method: 'HEAD',
redirect: 'manual',
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
});
const alive = res.status < 500;
checks.loopback = { ok: alive, status: res.status };
if (!alive) ok = false;
} catch (err) {
checks.loopback = { ok: false, error: err.message };
ok = false;
}
}
reply.code(ok ? 200 : 503);
return { status: ok ? 'ok' : 'degraded', uptime_seconds: process.uptime(), checks };
});
api.fastify.get(prefix + '/metrics', async (request, reply) => {
if (!authorized(request)) {
reply.code(401).header('www-authenticate', 'Bearer realm="metrics"');
return { error: 'unauthorized' };
}
reply.header('content-type', 'text/plain; version=0.0.4');
return render();
});
api.log.info(`metrics: healthz + prometheus exporter at ${prefix || '/'}${token ? ' (token-guarded /metrics)' : ''}${loopbackUrl ? `, probing ${loopbackUrl}` : ''}`);
return {
deactivate() {
loopDelay.disable(); // stop sampling; nothing else holds the loop open
},
};
}