|
| 1 | +// Metrics plugin over a real JSS from npm: healthz (with a loopback check), |
| 2 | +// Prometheus exposition, Bearer-token guard, and — the experiment this |
| 3 | +// plugin exists for — the empirical scope of an onResponse hook added on |
| 4 | +// api.fastify. A second "neighbor" plugin (test-fixture.js) is booted |
| 5 | +// alongside so the test can prove the hook sees OTHER plugins' routes |
| 6 | +// (all entries share one loader register scope) while core routes (/, |
| 7 | +// LDP paths, /.well-known) never appear in the counters. |
| 8 | +// |
| 9 | +// Two host quirks this suite encodes: |
| 10 | +// - The main boot passes `logger: true, logLevel: 'silent'` — under |
| 11 | +// `logger: false` core's access-log onResponse hook throws |
| 12 | +// (request.log.isLevelEnabled is missing on Fastify's null logger) and |
| 13 | +// silently kills every downstream onResponse hook, so the request |
| 14 | +// counters would stay at zero. See README Findings. |
| 15 | +// - The extra boots (no-loopback, dead-loopback) run LAST: a second |
| 16 | +// createServer in one process repoints JSS's module-global DATA_ROOT |
| 17 | +// (AGENT.md footgun). Harmless here — this plugin never touches |
| 18 | +// storage — but the safe ordering is kept anyway. |
| 19 | + |
| 20 | +import { describe, it, after } from 'node:test'; |
| 21 | +import assert from 'node:assert'; |
| 22 | +import path from 'node:path'; |
| 23 | +import { fileURLToPath } from 'node:url'; |
| 24 | +import { probePort, startJss } from '../helpers.js'; |
| 25 | + |
| 26 | +const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))); |
| 27 | +const module_ = path.join(__dirname, 'plugin.js'); |
| 28 | +const fixture_ = path.join(__dirname, 'test-fixture.js'); |
| 29 | + |
| 30 | +const TOKEN = 'metrics-scrape-token-1234567890'; |
| 31 | + |
| 32 | +describe('metrics plugin', () => { |
| 33 | + let jss; |
| 34 | + let base; |
| 35 | + const extra = []; // later boots, closed in after() |
| 36 | + |
| 37 | + after(async () => { |
| 38 | + for (const s of extra.reverse()) await s.close(); |
| 39 | + if (jss) await jss.close(); |
| 40 | + }); |
| 41 | + |
| 42 | + const scrape = async (auth = TOKEN) => { |
| 43 | + const res = await fetch(`${base}/metrics/metrics`, { |
| 44 | + headers: auth ? { authorization: `Bearer ${auth}` } : {}, |
| 45 | + }); |
| 46 | + return res; |
| 47 | + }; |
| 48 | + |
| 49 | + it('boots alongside a neighbor plugin (token + loopbackUrl configured)', async () => { |
| 50 | + const port = await probePort(); |
| 51 | + base = `http://127.0.0.1:${port}`; |
| 52 | + jss = await startJss({ |
| 53 | + port, |
| 54 | + logger: true, |
| 55 | + logLevel: 'silent', // real logger (so onResponse hooks run), no noise |
| 56 | + plugins: [ |
| 57 | + { |
| 58 | + id: 'metrics', |
| 59 | + module: module_, |
| 60 | + prefix: '/metrics', |
| 61 | + config: { loopbackUrl: base, token: TOKEN }, |
| 62 | + }, |
| 63 | + { id: 'neighbor', module: fixture_, prefix: '/neighbor' }, |
| 64 | + ], |
| 65 | + }); |
| 66 | + }); |
| 67 | + |
| 68 | + it('GET /metrics/healthz is 200 ok, open (no token), with the loopback check', async () => { |
| 69 | + const res = await fetch(`${base}/metrics/healthz`); // no Authorization |
| 70 | + assert.strictEqual(res.status, 200); |
| 71 | + const body = await res.json(); |
| 72 | + assert.strictEqual(body.status, 'ok'); |
| 73 | + assert.ok(typeof body.uptime_seconds === 'number' && body.uptime_seconds > 0); |
| 74 | + assert.strictEqual(body.checks.process.ok, true); |
| 75 | + assert.strictEqual(body.checks.loopback.ok, true, 'loopback check present and passing'); |
| 76 | + assert.ok(typeof body.checks.loopback.status === 'number'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('GET /metrics/metrics requires the Bearer token when config.token is set', async () => { |
| 80 | + const anon = await scrape(null); |
| 81 | + assert.strictEqual(anon.status, 401); |
| 82 | + assert.match(anon.headers.get('www-authenticate'), /^Bearer/); |
| 83 | + |
| 84 | + const wrong = await scrape('not-the-token'); |
| 85 | + assert.strictEqual(wrong.status, 401); |
| 86 | + |
| 87 | + // Same-length wrong token (the compare hashes first, so length is moot). |
| 88 | + const sameLen = await scrape('x'.repeat(TOKEN.length)); |
| 89 | + assert.strictEqual(sameLen.status, 401); |
| 90 | + |
| 91 | + const right = await scrape(); |
| 92 | + assert.strictEqual(right.status, 200); |
| 93 | + }); |
| 94 | + |
| 95 | + it('exposition is well-formed Prometheus text with the process gauges', async () => { |
| 96 | + const res = await scrape(); |
| 97 | + assert.strictEqual(res.status, 200); |
| 98 | + assert.match(res.headers.get('content-type'), /^text\/plain; version=0\.0\.4/); |
| 99 | + const text = await res.text(); |
| 100 | + |
| 101 | + assert.match(text, /^# TYPE process_uptime_seconds gauge$/m); |
| 102 | + assert.match(text, /^process_uptime_seconds \d+(\.\d+)?$/m, 'well-formed sample value'); |
| 103 | + for (const name of [ |
| 104 | + 'process_resident_memory_bytes', |
| 105 | + 'process_heap_used_bytes', |
| 106 | + 'process_heap_total_bytes', |
| 107 | + 'process_cpu_user_seconds_total', |
| 108 | + 'process_cpu_system_seconds_total', |
| 109 | + 'nodejs_eventloop_lag_seconds', |
| 110 | + ]) { |
| 111 | + assert.match(text, new RegExp(`^${name} \\d+(\\.\\d+)?(e[-+]?\\d+)?$`, 'mi'), name); |
| 112 | + } |
| 113 | + // Counters really count upward: rss/heap are positive. |
| 114 | + const rss = Number(/^process_resident_memory_bytes (\S+)$/m.exec(text)[1]); |
| 115 | + assert.ok(rss > 1e6, `plausible RSS, got ${rss}`); |
| 116 | + }); |
| 117 | + |
| 118 | + it('request counters observe THIS plugin and the NEIGHBOR plugin, never core (the discovered hook scope)', async () => { |
| 119 | + // Traffic: 3x own healthz, 2x the neighbor's route, and two core |
| 120 | + // routes (the UI root and an LDP pod path). |
| 121 | + for (let i = 0; i < 3; i++) assert.strictEqual((await fetch(`${base}/metrics/healthz`)).status, 200); |
| 122 | + for (let i = 0; i < 2; i++) assert.strictEqual((await fetch(`${base}/neighbor/ping`)).status, 200); |
| 123 | + await fetch(`${base}/`); |
| 124 | + await fetch(`${base}/nobody/nothing.txt`); |
| 125 | + |
| 126 | + const text = await (await scrape()).text(); |
| 127 | + const count = (method, route, status) => { |
| 128 | + const m = new RegExp( |
| 129 | + `^jss_plugin_http_requests_total\\{method="${method}",route="${route.replace(/[/*]/g, '\\$&')}",status="${status}"\\} (\\d+)$`, 'm', |
| 130 | + ).exec(text); |
| 131 | + return m ? Number(m[1]) : 0; |
| 132 | + }; |
| 133 | + |
| 134 | + // (a) Own routes: counted (>= because earlier tests hit healthz too). |
| 135 | + assert.ok(count('GET', '/metrics/healthz', 200) >= 3, 'own route counted'); |
| 136 | + // Self-scrapes count as well — /metrics/metrics saw the 401s and 200s above. |
| 137 | + assert.ok(count('GET', '/metrics/metrics', 200) >= 1, 'self-scrape counted'); |
| 138 | + assert.ok(count('GET', '/metrics/metrics', 401) >= 2, 'auth refusals counted'); |
| 139 | + |
| 140 | + // (b) ANOTHER plugin's route: counted. All plugin entries activate in |
| 141 | + // ONE shared Fastify register scope, so an api.fastify hook observes |
| 142 | + // every plugin's routes — a wider grant than "your own prefix". |
| 143 | + assert.strictEqual(count('GET', '/neighbor/ping', 200), 2, "neighbor plugin's route counted"); |
| 144 | + |
| 145 | + // (c) Core routes: NEVER counted. Fastify encapsulation shields the |
| 146 | + // parent instance — core's UI root, LDP catch-all and /.well-known |
| 147 | + // don't run plugin hooks, so no core series can appear. |
| 148 | + assert.ok(!text.includes('route="/"'), 'core UI root not counted'); |
| 149 | + assert.ok(!text.includes('route="/*"'), 'core catch-all not counted'); |
| 150 | + assert.ok(!text.includes('nobody'), 'core LDP path not counted'); |
| 151 | + |
| 152 | + // Duration summary covers the counted requests. |
| 153 | + const sum = Number(/^jss_plugin_http_request_duration_seconds_sum (\S+)$/m.exec(text)[1]); |
| 154 | + const n = Number(/^jss_plugin_http_request_duration_seconds_count (\d+)$/m.exec(text)[1]); |
| 155 | + assert.ok(n >= 8, `duration count covers plugin traffic, got ${n}`); |
| 156 | + assert.ok(sum > 0, 'duration sum accumulates'); |
| 157 | + }); |
| 158 | + |
| 159 | + it('without loopbackUrl, healthz degrades gracefully to plain process liveness (and /metrics is open without token)', async () => { |
| 160 | + const s = await startJss({ |
| 161 | + logger: true, |
| 162 | + logLevel: 'silent', |
| 163 | + plugins: [{ id: 'metrics', module: module_, prefix: '/metrics' }], // no config at all |
| 164 | + }); |
| 165 | + extra.push(s); |
| 166 | + const res = await fetch(`${s.base}/metrics/healthz`); |
| 167 | + assert.strictEqual(res.status, 200); |
| 168 | + const body = await res.json(); |
| 169 | + assert.strictEqual(body.status, 'ok'); |
| 170 | + assert.strictEqual(body.checks.loopback, undefined, 'no loopback check without loopbackUrl'); |
| 171 | + |
| 172 | + const metrics = await fetch(`${s.base}/metrics/metrics`); // no token configured -> open |
| 173 | + assert.strictEqual(metrics.status, 200); |
| 174 | + assert.match(await metrics.text(), /^process_uptime_seconds /m); |
| 175 | + }); |
| 176 | + |
| 177 | + it('a failing loopback probe turns healthz into 503 degraded', async () => { |
| 178 | + const deadPort = await probePort(); // probed then released: nothing listens |
| 179 | + const s = await startJss({ |
| 180 | + logger: true, |
| 181 | + logLevel: 'silent', |
| 182 | + plugins: [{ |
| 183 | + id: 'metrics', |
| 184 | + module: module_, |
| 185 | + prefix: '/metrics', |
| 186 | + config: { loopbackUrl: `http://127.0.0.1:${deadPort}` }, |
| 187 | + }], |
| 188 | + }); |
| 189 | + extra.push(s); |
| 190 | + const res = await fetch(`${s.base}/metrics/healthz`); |
| 191 | + assert.strictEqual(res.status, 503); |
| 192 | + const body = await res.json(); |
| 193 | + assert.strictEqual(body.status, 'degraded'); |
| 194 | + assert.strictEqual(body.checks.loopback.ok, false); |
| 195 | + assert.ok(body.checks.loopback.error, 'failure reason reported'); |
| 196 | + }); |
| 197 | +}); |
0 commit comments