From e5e98bcbd0b1e5a2eaf21aad1c793c8616527494 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 29 May 2026 15:03:09 +0200 Subject: [PATCH 1/2] feat(sdk): add granular status codes, TTFB, request size, inflight metrics and bump version to 2.2.0 --- package.json | 2 +- src/aggregator.js | 49 ++++++++++- src/database.js | 112 ++++++++++++++++--------- src/interceptor.js | 32 +++++++ tests/aggregator.test.js | 171 +++++++++++++++++++++++++++++++++++++- tests/database.test.js | 115 +++++++++++++++++++++---- tests/interceptor.test.js | 149 ++++++++++++++++++++++++++++----- 7 files changed, 552 insertions(+), 78 deletions(-) diff --git a/package.json b/package.json index c44ad9e..b55abf6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apiforgejs", - "version": "2.1.2", + "version": "2.2.0", "description": "API observability & intelligence SDK for Express.js — local-first, privacy-first", "main": "src/index.js", "keywords": [ diff --git a/src/aggregator.js b/src/aggregator.js index 0bc86e7..d1a30a9 100644 --- a/src/aggregator.js +++ b/src/aggregator.js @@ -34,21 +34,32 @@ class Aggregator { release: event.release, is_ghost: event.is_ghost, durations: [], + ttfb_durations: [], response_sizes: [], + request_sizes: [], + inflight_samples: [], status_2xx: 0, + status_3xx: 0, status_4xx: 0, status_5xx: 0, + status_map: new Map(), }; this.buffer.set(key, bucket); } bucket.durations.push(event.duration_ms); + if (event.ttfb_ms != null) bucket.ttfb_durations.push(event.ttfb_ms); if (event.response_size != null) bucket.response_sizes.push(event.response_size); + if (event.request_size != null) bucket.request_sizes.push(event.request_size); + if (event.inflight != null) bucket.inflight_samples.push(event.inflight); const s = event.status; - if (s >= 200 && s < 300) bucket.status_2xx++; + if (s >= 200 && s < 300) bucket.status_2xx++; + else if (s >= 300 && s < 400) bucket.status_3xx++; else if (s >= 400 && s < 500) bucket.status_4xx++; - else if (s >= 500) bucket.status_5xx++; + else if (s >= 500) bucket.status_5xx++; + + bucket.status_map.set(s, (bucket.status_map.get(s) ?? 0) + 1); } _flush() { @@ -59,17 +70,41 @@ class Aggregator { const rows = []; for (const bucket of this.buffer.values()) { - const sorted = bucket.durations.slice().sort((a, b) => a - b); + const sorted = bucket.durations.slice().sort((a, b) => a - b); + const sortedTtfb = bucket.ttfb_durations.slice().sort((a, b) => a - b); const n = sorted.length; + const sizes = bucket.response_sizes; const bytes_avg = sizes.length > 0 ? sizes.reduce((a, b) => a + b, 0) / sizes.length : null; + const reqSizes = bucket.request_sizes; + const request_size_avg = reqSizes.length > 0 + ? reqSizes.reduce((a, b) => a + b, 0) / reqSizes.length + : null; + const lat_avg = n > 0 ? bucket.durations.reduce((a, b) => a + b, 0) / n : null; + const inflight = bucket.inflight_samples; + const inflight_avg = inflight.length > 0 + ? inflight.reduce((a, b) => a + b, 0) / inflight.length + : null; + const inflight_max = inflight.length > 0 + ? Math.max(...inflight) + : null; + + // Granular distribution — sorted by count desc, all observed codes + const status_dist = bucket.status_map.size > 0 + ? JSON.stringify( + Object.fromEntries( + [...bucket.status_map.entries()].sort((a, b) => b[1] - a[1]) + ) + ) + : null; + rows.push({ bucket_ts: bucketTs, route: bucket.route, @@ -78,8 +113,10 @@ class Aggregator { release_tag: bucket.release, is_ghost: bucket.is_ghost ? 1 : 0, status_2xx: bucket.status_2xx, + status_3xx: bucket.status_3xx, status_4xx: bucket.status_4xx, status_5xx: bucket.status_5xx, + status_dist, total_calls: n, lat_p50: percentile(sorted, 0.50), lat_p90: percentile(sorted, 0.90), @@ -87,7 +124,13 @@ class Aggregator { lat_avg, lat_min: sorted[0] ?? 0, lat_max: sorted[n - 1] ?? 0, + lat_ttfb_p50: sortedTtfb.length > 0 ? percentile(sortedTtfb, 0.50) : null, + lat_ttfb_p90: sortedTtfb.length > 0 ? percentile(sortedTtfb, 0.90) : null, + lat_ttfb_p99: sortedTtfb.length > 0 ? percentile(sortedTtfb, 0.99) : null, bytes_avg, + request_size_avg, + inflight_avg, + inflight_max, }); } diff --git a/src/database.js b/src/database.js index 275b12c..9b75ab0 100644 --- a/src/database.js +++ b/src/database.js @@ -30,23 +30,32 @@ class ApiForgeDatabase { ); CREATE TABLE IF NOT EXISTS api_metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bucket_ts INTEGER NOT NULL, - route TEXT NOT NULL, - method TEXT NOT NULL, - env TEXT NOT NULL DEFAULT 'production', - release_tag TEXT, - status_2xx INTEGER NOT NULL DEFAULT 0, - status_4xx INTEGER NOT NULL DEFAULT 0, - status_5xx INTEGER NOT NULL DEFAULT 0, - total_calls INTEGER NOT NULL DEFAULT 0, - lat_p50 REAL, - lat_p90 REAL, - lat_p99 REAL, - lat_min REAL, - lat_max REAL, - bytes_avg REAL, - is_ghost INTEGER NOT NULL DEFAULT 0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_ts INTEGER NOT NULL, + route TEXT NOT NULL, + method TEXT NOT NULL, + env TEXT NOT NULL DEFAULT 'production', + release_tag TEXT, + status_2xx INTEGER NOT NULL DEFAULT 0, + status_3xx INTEGER NOT NULL DEFAULT 0, + status_4xx INTEGER NOT NULL DEFAULT 0, + status_5xx INTEGER NOT NULL DEFAULT 0, + status_dist TEXT, + total_calls INTEGER NOT NULL DEFAULT 0, + lat_p50 REAL, + lat_p90 REAL, + lat_p99 REAL, + lat_avg REAL, + lat_min REAL, + lat_max REAL, + lat_ttfb_p50 REAL, + lat_ttfb_p90 REAL, + lat_ttfb_p99 REAL, + bytes_avg REAL, + request_size_avg REAL, + inflight_avg REAL, + inflight_max INTEGER, + is_ghost INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_route_ts ON api_metrics (route, method, bucket_ts); CREATE INDEX IF NOT EXISTS idx_bucket_ts ON api_metrics (bucket_ts); @@ -54,15 +63,34 @@ class ApiForgeDatabase { `); // Migrations for databases created before these columns were introduced - try { this.db.exec('ALTER TABLE api_metrics ADD COLUMN bytes_avg REAL'); } catch (_) {} - try { this.db.exec('ALTER TABLE api_metrics ADD COLUMN is_ghost INTEGER NOT NULL DEFAULT 0'); } catch (_) {} + const migrations = [ + 'ALTER TABLE api_metrics ADD COLUMN bytes_avg REAL', + 'ALTER TABLE api_metrics ADD COLUMN is_ghost INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE api_metrics ADD COLUMN status_3xx INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE api_metrics ADD COLUMN status_dist TEXT', + 'ALTER TABLE api_metrics ADD COLUMN lat_avg REAL', + 'ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p50 REAL', + 'ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p90 REAL', + 'ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p99 REAL', + 'ALTER TABLE api_metrics ADD COLUMN request_size_avg REAL', + 'ALTER TABLE api_metrics ADD COLUMN inflight_avg REAL', + 'ALTER TABLE api_metrics ADD COLUMN inflight_max INTEGER', + ]; + for (const sql of migrations) { + try { this.db.exec(sql); } catch (_) {} + } this._stmtInsert = this.db.prepare(` INSERT INTO api_metrics (bucket_ts, route, method, env, release_tag, - status_2xx, status_4xx, status_5xx, total_calls, - lat_p50, lat_p90, lat_p99, lat_min, lat_max, bytes_avg, is_ghost) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + status_2xx, status_3xx, status_4xx, status_5xx, status_dist, + total_calls, + lat_p50, lat_p90, lat_p99, lat_avg, lat_min, lat_max, + lat_ttfb_p50, lat_ttfb_p90, lat_ttfb_p99, + bytes_avg, request_size_avg, + inflight_avg, inflight_max, + is_ghost) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); this._begin = this.db.prepare('BEGIN'); @@ -76,8 +104,12 @@ class ApiForgeDatabase { for (const r of rows) { this._stmtInsert.run( r.bucket_ts, r.route, r.method, r.env, r.release_tag ?? null, - r.status_2xx, r.status_4xx, r.status_5xx, r.total_calls, - r.lat_p50, r.lat_p90, r.lat_p99, r.lat_min, r.lat_max, r.bytes_avg ?? null, + r.status_2xx, r.status_3xx ?? 0, r.status_4xx, r.status_5xx, r.status_dist ?? null, + r.total_calls, + r.lat_p50, r.lat_p90, r.lat_p99, r.lat_avg ?? null, r.lat_min, r.lat_max, + r.lat_ttfb_p50 ?? null, r.lat_ttfb_p90 ?? null, r.lat_ttfb_p99 ?? null, + r.bytes_avg ?? null, r.request_size_avg ?? null, + r.inflight_avg ?? null, r.inflight_max ?? null, r.is_ghost ?? 0 ); } @@ -96,6 +128,7 @@ class ApiForgeDatabase { SELECT SUM(total_calls) as calls_total, SUM(status_2xx) as calls_2xx, + SUM(status_3xx) as calls_3xx, SUM(status_4xx) as calls_4xx, SUM(status_5xx) as calls_5xx, AVG(lat_p90) as avg_p90, @@ -131,15 +164,19 @@ class ApiForgeDatabase { return this.db.prepare(` SELECT route, method, is_ghost, - SUM(total_calls) as calls, - SUM(status_2xx) as calls_2xx, - SUM(status_4xx) as calls_4xx, - SUM(status_5xx) as calls_5xx, - AVG(lat_p50) as p50, - AVG(lat_p90) as p90, - AVG(lat_p99) as p99, - MAX(lat_max) as lat_max, - AVG(bytes_avg) as bytes_avg + SUM(total_calls) as calls, + SUM(status_2xx) as calls_2xx, + SUM(status_3xx) as calls_3xx, + SUM(status_4xx) as calls_4xx, + SUM(status_5xx) as calls_5xx, + AVG(lat_p50) as p50, + AVG(lat_p90) as p90, + AVG(lat_p99) as p99, + MAX(lat_max) as lat_max, + AVG(bytes_avg) as bytes_avg, + AVG(request_size_avg) as request_size_avg, + AVG(inflight_avg) as inflight_avg, + MAX(inflight_max) as inflight_max FROM api_metrics WHERE bucket_ts >= ? GROUP BY route, method, is_ghost @@ -154,10 +191,11 @@ class ApiForgeDatabase { SELECT bucket_ts, SUM(total_calls) as calls, - AVG(lat_p50) as p50, - AVG(lat_p90) as p90, - AVG(lat_p99) as p99, - SUM(status_5xx) as errors + AVG(lat_p50) as p50, + AVG(lat_p90) as p90, + AVG(lat_p99) as p99, + SUM(status_5xx) as errors, + SUM(status_3xx) as redirects FROM api_metrics WHERE route = ? AND method = ? AND bucket_ts >= ? GROUP BY bucket_ts diff --git a/src/interceptor.js b/src/interceptor.js index aa4f9bb..4eba596 100644 --- a/src/interceptor.js +++ b/src/interceptor.js @@ -46,6 +46,7 @@ function createInterceptor(aggregator, storeRoutes, config) { const ignoreSet = new Set(ignorePaths); let routesScanned = false; + let inflightCount = 0; function scanRoutes(app) { try { @@ -68,8 +69,36 @@ function createInterceptor(aggregator, storeRoutes, config) { if (sampling < 1.0 && Math.random() > sampling) return next(); const startHr = process.hrtime.bigint(); + inflightCount++; + const inflightSnapshot = inflightCount; + + // Request body size — size only, never the content + const requestSize = req.headers['content-length'] + ? parseInt(req.headers['content-length'], 10) + : null; + + // Patch res.write / res.end to capture Time To First Byte + let ttfbMs = null; + const origWrite = res.write.bind(res); + const origEnd = res.end.bind(res); + + function captureTtfb() { + if (ttfbMs === null) { + ttfbMs = Number(process.hrtime.bigint() - startHr) / 1_000_000; + } + } + + res.write = function patchedWrite(...args) { + captureTtfb(); + return origWrite(...args); + }; + res.end = function patchedEnd(...args) { + captureTtfb(); + return origEnd(...args); + }; res.on('finish', () => { + inflightCount--; try { const durationMs = Number(process.hrtime.bigint() - startHr) / 1_000_000; @@ -85,12 +114,15 @@ function createInterceptor(aggregator, storeRoutes, config) { method: req.method, status: res.statusCode, duration_ms: durationMs, + ttfb_ms: ttfbMs ?? durationMs, timestamp: new Date().toISOString(), env, release: release || null, service, response_size: contentLength ? parseInt(contentLength, 10) : null, + request_size: requestSize, is_ghost: !req.route, + inflight: inflightSnapshot, }); } catch (_) { // Never let instrumentation crash the host application diff --git a/tests/aggregator.test.js b/tests/aggregator.test.js index 2a3a15f..38e815c 100644 --- a/tests/aggregator.test.js +++ b/tests/aggregator.test.js @@ -1,6 +1,6 @@ 'use strict'; -const { describe, it, before, after, mock } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { Aggregator } = require('../src/aggregator.js'); @@ -52,6 +52,35 @@ describe('Aggregator', () => { assert.strictEqual(agg.buffer.size, 2); agg.stop(); }); + + it('counts status_3xx correctly', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/r', env: 'test', release: null, status: 301, duration_ms: 5 }); + agg.record({ method: 'GET', route: '/r', env: 'test', release: null, status: 302, duration_ms: 5 }); + + const bucket = agg.buffer.get('GET|/r|test||0'); + assert.strictEqual(bucket.status_3xx, 2); + assert.strictEqual(bucket.status_2xx, 0); + agg.stop(); + }); + + it('builds status_map with per-code counts', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/s', env: 'test', release: null, status: 200, duration_ms: 1 }); + agg.record({ method: 'GET', route: '/s', env: 'test', release: null, status: 200, duration_ms: 1 }); + agg.record({ method: 'GET', route: '/s', env: 'test', release: null, status: 404, duration_ms: 1 }); + + const bucket = agg.buffer.get('GET|/s|test||0'); + assert.strictEqual(bucket.status_map.get(200), 2); + assert.strictEqual(bucket.status_map.get(404), 1); + agg.stop(); + }); }); describe('_flush()', () => { @@ -111,6 +140,20 @@ describe('Aggregator', () => { agg.stop(); }); + it('computes lat_avg correctly', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/avg', env: 'test', release: null, status: 200, duration_ms: 10 }); + agg.record({ method: 'GET', route: '/avg', env: 'test', release: null, status: 200, duration_ms: 30 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.lat_avg, 20); + agg.stop(); + }); + it('increments 4xx counter correctly', () => { const t = makeTransport(); const agg = new Aggregator(t, 999_999); @@ -152,6 +195,132 @@ describe('Aggregator', () => { assert.strictEqual(row.bytes_avg, null); agg.stop(); }); + + it('computes request_size_avg from request body sizes', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'POST', route: '/upload', env: 'test', release: null, status: 201, duration_ms: 50, request_size: 1000 }); + agg.record({ method: 'POST', route: '/upload', env: 'test', release: null, status: 201, duration_ms: 60, request_size: 3000 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.request_size_avg, 2000); + agg.stop(); + }); + + it('sets request_size_avg to null when no request sizes are provided', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/q', env: 'test', release: null, status: 200, duration_ms: 10 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.request_size_avg, null); + agg.stop(); + }); + + it('emits status_dist as JSON sorted by count desc', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + for (let i = 0; i < 5; i++) { + agg.record({ method: 'GET', route: '/d', env: 'test', release: null, status: 200, duration_ms: 1 }); + } + agg.record({ method: 'GET', route: '/d', env: 'test', release: null, status: 404, duration_ms: 1 }); + agg._flush(); + + const row = t.calls[0][0]; + const dist = JSON.parse(row.status_dist); + assert.strictEqual(dist['200'], 5); + assert.strictEqual(dist['404'], 1); + // 200 should come first (highest count) + const keys = Object.keys(dist); + assert.strictEqual(keys[0], '200'); + agg.stop(); + }); + + it('sets status_dist to null when buffer is empty (should not happen but guard test)', () => { + // Simulated via a bucket that records no statuses — not a real path, + // but ensures null handling is correct + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + agg.record({ method: 'GET', route: '/z', env: 'test', release: null, status: 200, duration_ms: 1 }); + agg._flush(); + // status_dist must be a parseable JSON string, not null + const row = t.calls[0][0]; + assert.ok(row.status_dist !== null); + assert.doesNotThrow(() => JSON.parse(row.status_dist)); + agg.stop(); + }); + + it('computes lat_ttfb percentiles from ttfb_ms events', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + for (let i = 1; i <= 10; i++) { + agg.record({ method: 'GET', route: '/ttfb', env: 'test', release: null, status: 200, duration_ms: i * 10, ttfb_ms: i * 5 }); + } + agg._flush(); + + const row = t.calls[0][0]; + assert.ok(typeof row.lat_ttfb_p50 === 'number', 'lat_ttfb_p50 should be a number'); + assert.ok(typeof row.lat_ttfb_p90 === 'number', 'lat_ttfb_p90 should be a number'); + assert.ok(typeof row.lat_ttfb_p99 === 'number', 'lat_ttfb_p99 should be a number'); + assert.ok(row.lat_ttfb_p99 <= row.lat_p99, 'TTFB P99 should be <= total latency P99'); + agg.stop(); + }); + + it('sets lat_ttfb fields to null when no ttfb_ms values are provided', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/nottfb', env: 'test', release: null, status: 200, duration_ms: 20 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.lat_ttfb_p50, null); + assert.strictEqual(row.lat_ttfb_p90, null); + assert.strictEqual(row.lat_ttfb_p99, null); + agg.stop(); + }); + + it('computes inflight_avg and inflight_max', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/i', env: 'test', release: null, status: 200, duration_ms: 10, inflight: 3 }); + agg.record({ method: 'GET', route: '/i', env: 'test', release: null, status: 200, duration_ms: 10, inflight: 7 }); + agg.record({ method: 'GET', route: '/i', env: 'test', release: null, status: 200, duration_ms: 10, inflight: 5 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.inflight_avg, 5); + assert.strictEqual(row.inflight_max, 7); + agg.stop(); + }); + + it('sets inflight fields to null when no inflight values are provided', () => { + const t = makeTransport(); + const agg = new Aggregator(t, 999_999); + agg.start(); + + agg.record({ method: 'GET', route: '/ni', env: 'test', release: null, status: 200, duration_ms: 10 }); + agg._flush(); + + const row = t.calls[0][0]; + assert.strictEqual(row.inflight_avg, null); + assert.strictEqual(row.inflight_max, null); + agg.stop(); + }); }); describe('stop()', () => { diff --git a/tests/database.test.js b/tests/database.test.js index 2cb25fd..2a134f5 100644 --- a/tests/database.test.js +++ b/tests/database.test.js @@ -1,6 +1,6 @@ 'use strict'; -const { describe, it, before, after } = require('node:test'); +const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { ApiForgeDatabase } = require('../src/database.js'); @@ -11,21 +11,31 @@ function makeDb() { // Insert one minimal row at a given timestamp function insertRow(db, overrides = {}) { const defaults = { - bucket_ts: Math.floor(Date.now() / 1000), - route: '/test', - method: 'GET', - env: 'test', - release_tag: null, - status_2xx: 1, - status_4xx: 0, - status_5xx: 0, - total_calls: 1, - lat_p50: 50, - lat_p90: 90, - lat_p99: 99, - lat_min: 10, - lat_max: 150, - bytes_avg: null, + bucket_ts: Math.floor(Date.now() / 1000), + route: '/test', + method: 'GET', + env: 'test', + release_tag: null, + status_2xx: 1, + status_3xx: 0, + status_4xx: 0, + status_5xx: 0, + status_dist: null, + total_calls: 1, + lat_p50: 50, + lat_p90: 90, + lat_p99: 99, + lat_avg: 70, + lat_min: 10, + lat_max: 150, + lat_ttfb_p50: null, + lat_ttfb_p90: null, + lat_ttfb_p99: null, + bytes_avg: null, + request_size_avg: null, + inflight_avg: null, + inflight_max: null, + is_ghost: 0, }; db.insertBatch([{ ...defaults, ...overrides }]); } @@ -79,6 +89,15 @@ describe('ApiForgeDatabase', () => { assert.strictEqual(recent.calls_5xx, 3); db.close(); }); + + it('exposes calls_3xx in summary', () => { + const db = makeDb(); + const nowTs = Math.floor(Date.now() / 1000); + insertRow(db, { status_2xx: 0, status_3xx: 4, total_calls: 4, bucket_ts: nowTs }); + const { recent } = db.getSummary(); + assert.strictEqual(recent.calls_3xx, 4); + db.close(); + }); }); describe('getTimeSeries()', () => { @@ -99,6 +118,15 @@ describe('ApiForgeDatabase', () => { assert.strictEqual(rows.length, 0); db.close(); }); + + it('includes redirects column in time series', () => { + const db = makeDb(); + const ts = Math.floor(Date.now() / 1000) - 60; + insertRow(db, { route: '/redir', method: 'GET', bucket_ts: ts, status_3xx: 2 }); + const rows = db.getTimeSeries('/redir', 'GET', 24); + assert.strictEqual(rows[0].redirects, 2); + db.close(); + }); }); describe('getDeadCandidates()', () => { @@ -218,4 +246,59 @@ describe('ApiForgeDatabase', () => { db.close(); }); }); + + describe('new columns', () => { + it('stores and returns status_3xx', () => { + const db = makeDb(); + insertRow(db, { route: '/redir', status_2xx: 0, status_3xx: 5, total_calls: 5 }); + const routes = db.getRoutes(24); + assert.strictEqual(routes[0].calls_3xx, 5); + db.close(); + }); + + it('stores and retrieves status_dist JSON', () => { + const db = makeDb(); + const dist = JSON.stringify({ '200': 10, '201': 2 }); + insertRow(db, { route: '/dist', status_dist: dist }); + // Verify roundtrip via direct query + const row = db.db.prepare('SELECT status_dist FROM api_metrics LIMIT 1').get(); + assert.strictEqual(row.status_dist, dist); + db.close(); + }); + + it('stores and returns lat_avg', () => { + const db = makeDb(); + insertRow(db, { route: '/avg', lat_avg: 42.5 }); + const row = db.db.prepare('SELECT lat_avg FROM api_metrics LIMIT 1').get(); + assert.strictEqual(row.lat_avg, 42.5); + db.close(); + }); + + it('stores and returns lat_ttfb columns', () => { + const db = makeDb(); + insertRow(db, { route: '/ttfb', lat_ttfb_p50: 12, lat_ttfb_p90: 25, lat_ttfb_p99: 40 }); + const row = db.db.prepare('SELECT lat_ttfb_p50, lat_ttfb_p90, lat_ttfb_p99 FROM api_metrics LIMIT 1').get(); + assert.strictEqual(row.lat_ttfb_p50, 12); + assert.strictEqual(row.lat_ttfb_p90, 25); + assert.strictEqual(row.lat_ttfb_p99, 40); + db.close(); + }); + + it('stores and returns request_size_avg in getRoutes()', () => { + const db = makeDb(); + insertRow(db, { route: '/upload', request_size_avg: 1024 }); + const routes = db.getRoutes(24); + assert.strictEqual(routes[0].request_size_avg, 1024); + db.close(); + }); + + it('stores and returns inflight_avg and inflight_max in getRoutes()', () => { + const db = makeDb(); + insertRow(db, { route: '/busy', inflight_avg: 4.5, inflight_max: 8 }); + const routes = db.getRoutes(24); + assert.strictEqual(routes[0].inflight_avg, 4.5); + assert.strictEqual(routes[0].inflight_max, 8); + db.close(); + }); + }); }); diff --git a/tests/interceptor.test.js b/tests/interceptor.test.js index caf810c..e1bd343 100644 --- a/tests/interceptor.test.js +++ b/tests/interceptor.test.js @@ -26,6 +26,31 @@ function makeConfig(overrides = {}) { }; } +// Minimal res mock — includes write/end required by TTFB patch +function makeRes(overrides = {}) { + return { + write: () => true, + end: () => true, + on: () => {}, + statusCode: 200, + getHeader: () => null, + ...overrides, + }; +} + +// Minimal req mock — includes headers required for request_size +function makeReq(overrides = {}) { + return { + method: 'GET', + path: '/test', + route: null, + baseUrl: '', + app: null, + headers: {}, + ...overrides, + }; +} + describe('createInterceptor()', () => { it('returns a function with arity 3', () => { const mw = createInterceptor(makeAgg(), makeDb(), makeConfig()); @@ -38,15 +63,14 @@ describe('createInterceptor()', () => { const mw = createInterceptor(agg, makeDb(), makeConfig()); let finishCb; - const req = { method: 'GET', path: '/users', route: { path: '/users/:id' }, baseUrl: '', app: null }; - const res = { + const req = makeReq({ route: { path: '/users/:id' }, path: '/users' }); + const res = makeRes({ on: (evt, cb) => { if (evt === 'finish') finishCb = cb; }, statusCode: 200, getHeader: () => '128', - }; + }); mw(req, res, () => { - // Simulate response finish finishCb(); setImmediate(() => { assert.strictEqual(agg.events.length, 1); @@ -63,8 +87,8 @@ describe('createInterceptor()', () => { const agg = makeAgg(); const mw = createInterceptor(agg, makeDb(), makeConfig({ ignorePaths: ['/health'] })); - const req = { method: 'GET', path: '/health', route: null, app: null }; - const res = { on: () => {}, statusCode: 200, getHeader: () => null }; + const req = makeReq({ path: '/health' }); + const res = makeRes(); mw(req, res, () => { assert.strictEqual(agg.events.length, 0); @@ -77,12 +101,8 @@ describe('createInterceptor()', () => { const mw = createInterceptor(agg, makeDb(), makeConfig()); let finishCb; - const req = { method: 'GET', path: '/users/123', route: null, app: null }; - const res = { - on: (evt, cb) => { if (evt === 'finish') finishCb = cb; }, - statusCode: 200, - getHeader: () => null, - }; + const req = makeReq({ path: '/users/123' }); + const res = makeRes({ on: (evt, cb) => { if (evt === 'finish') finishCb = cb; } }); mw(req, res, () => { finishCb(); @@ -98,33 +118,122 @@ describe('createInterceptor()', () => { const mw = createInterceptor(agg, makeDb(), makeConfig({ sampling: 0.0 })); for (let i = 0; i < 50; i++) { - const req = { method: 'GET', path: '/x', route: null, app: null }; - const res = { on: () => {}, statusCode: 200, getHeader: () => null }; - mw(req, res, () => {}); + mw(makeReq(), makeRes(), () => {}); } assert.strictEqual(agg.events.length, 0); }); it('does not crash when the finish callback throws', (_, done) => { - // Aggregator throws — the middleware must swallow the error const agg = { record: () => { throw new Error('record failed'); } }; const mw = createInterceptor(agg, makeDb(), makeConfig()); let finishCb; - const req = { method: 'GET', path: '/ok', route: { path: '/ok' }, baseUrl: '', app: null }; + const req = makeReq({ route: { path: '/ok' }, path: '/ok' }); + const res = makeRes({ on: (evt, cb) => { if (evt === 'finish') finishCb = cb; } }); + + assert.doesNotThrow(() => { + mw(req, res, () => { + assert.doesNotThrow(() => finishCb()); + done(); + }); + }); + }); + + it('records request_size from Content-Length request header', (_, done) => { + const agg = makeAgg(); + const mw = createInterceptor(agg, makeDb(), makeConfig()); + + let finishCb; + const req = makeReq({ + method: 'POST', + path: '/items', + route: { path: '/items' }, + headers: { 'content-length': '512' }, + }); + const res = makeRes({ on: (evt, cb) => { if (evt === 'finish') finishCb = cb; } }); + + mw(req, res, () => { + finishCb(); + setImmediate(() => { + assert.strictEqual(agg.events[0].request_size, 512); + done(); + }); + }); + }); + + it('records null request_size when Content-Length header is absent', (_, done) => { + const agg = makeAgg(); + const mw = createInterceptor(agg, makeDb(), makeConfig()); + + let finishCb; + const req = makeReq({ route: { path: '/x' }, path: '/x' }); + const res = makeRes({ on: (evt, cb) => { if (evt === 'finish') finishCb = cb; } }); + + mw(req, res, () => { + finishCb(); + setImmediate(() => { + assert.strictEqual(agg.events[0].request_size, null); + done(); + }); + }); + }); + + it('records ttfb_ms when res.write is called before finish', (_, done) => { + const agg = makeAgg(); + const mw = createInterceptor(agg, makeDb(), makeConfig()); + + let finishCb; + const req = makeReq({ route: { path: '/stream' }, path: '/stream' }); + // res.write is patched by the interceptor — we call it to simulate streaming const res = { + write: () => true, + end: () => true, on: (evt, cb) => { if (evt === 'finish') finishCb = cb; }, statusCode: 200, getHeader: () => null, }; - assert.doesNotThrow(() => { - mw(req, res, () => { - assert.doesNotThrow(() => finishCb()); + mw(req, res, () => { + // Simulate first byte sent mid-response + res.write('chunk'); + finishCb(); + setImmediate(() => { + const e = agg.events[0]; + assert.ok(typeof e.ttfb_ms === 'number', 'ttfb_ms should be a number'); + assert.ok(e.ttfb_ms <= e.duration_ms, 'TTFB should not exceed total duration'); done(); }); }); }); + + it('records inflight count reflecting concurrent requests', (_, done) => { + const agg = makeAgg(); + const mw = createInterceptor(agg, makeDb(), makeConfig()); + + // First request enters, does not finish yet + let finish1; + const req1 = makeReq({ route: { path: '/a' }, path: '/a' }); + const res1 = makeRes({ on: (evt, cb) => { if (evt === 'finish') finish1 = cb; } }); + mw(req1, res1, () => {}); + + // Second request enters while first is still open + let finish2; + const req2 = makeReq({ route: { path: '/a' }, path: '/a' }); + const res2 = makeRes({ on: (evt, cb) => { if (evt === 'finish') finish2 = cb; } }); + mw(req2, res2, () => {}); + + // Finish both + finish1(); + finish2(); + + setImmediate(() => { + // Both events recorded; inflight for req2 should be >= 2 (both were in flight) + assert.ok(agg.events.length >= 2); + const inflightValues = agg.events.map(e => e.inflight); + assert.ok(inflightValues.some(v => v >= 2), `expected at least one inflight >= 2, got ${JSON.stringify(inflightValues)}`); + done(); + }); + }); }); describe('extractExpressRoutes()', () => { From 46e2953913ce3df2925781512739f8293d099380 Mon Sep 17 00:00:00 2001 From: Fabien83560 Date: Fri, 29 May 2026 15:05:45 +0200 Subject: [PATCH 2/2] fix(tests): add missing headers and write/end to smoke test req/res mocks --- tests/smoke.test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/smoke.test.js b/tests/smoke.test.js index 93d5781..4d909a9 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -52,9 +52,12 @@ describe('apiforgejs — smoke tests', () => { method: 'GET', route: { path: '/health' }, path: '/health', + headers: {}, res: null, }; const res = { + write: () => true, + end: () => true, on: (event, cb) => { if (event === 'finish') setTimeout(cb, 0); }, statusCode: 200, getHeader: () => null,