apiforgejs 0.1.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.
@@ -0,0 +1,298 @@
1
+ 'use strict';
2
+
3
+ // Suppress the built-in SQLite experimental warning before the first require —
4
+ // we intentionally depend on this API and the warning adds noise to user logs.
5
+ process.on('warning', function _sqLiteWarnFilter(w) {
6
+ if (w.name === 'ExperimentalWarning' && w.message.startsWith('SQLite')) {
7
+ process.off('warning', _sqLiteWarnFilter);
8
+ }
9
+ });
10
+
11
+ // node:sqlite is built into Node.js 22.5+ — no native addon required
12
+ const { DatabaseSync } = require('node:sqlite');
13
+
14
+ class ApiForgeDatabase {
15
+ constructor(dbPath) {
16
+ this.db = new DatabaseSync(dbPath);
17
+ this._init();
18
+ }
19
+
20
+ _init() {
21
+ this.db.exec("PRAGMA journal_mode = WAL");
22
+ this.db.exec("PRAGMA synchronous = NORMAL");
23
+
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS known_routes (
26
+ route TEXT NOT NULL,
27
+ method TEXT NOT NULL,
28
+ first_seen INTEGER NOT NULL DEFAULT (unixepoch()),
29
+ PRIMARY KEY (route, method)
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS api_metrics (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ bucket_ts INTEGER NOT NULL,
35
+ route TEXT NOT NULL,
36
+ method TEXT NOT NULL,
37
+ env TEXT NOT NULL DEFAULT 'production',
38
+ release_tag TEXT,
39
+ status_2xx INTEGER NOT NULL DEFAULT 0,
40
+ status_4xx INTEGER NOT NULL DEFAULT 0,
41
+ status_5xx INTEGER NOT NULL DEFAULT 0,
42
+ total_calls INTEGER NOT NULL DEFAULT 0,
43
+ lat_p50 REAL,
44
+ lat_p90 REAL,
45
+ lat_p99 REAL,
46
+ lat_min REAL,
47
+ lat_max REAL
48
+ );
49
+ CREATE INDEX IF NOT EXISTS idx_route_ts ON api_metrics (route, method, bucket_ts);
50
+ CREATE INDEX IF NOT EXISTS idx_bucket_ts ON api_metrics (bucket_ts);
51
+ CREATE INDEX IF NOT EXISTS idx_release ON api_metrics (release_tag) WHERE release_tag IS NOT NULL;
52
+ `);
53
+
54
+ this._stmtInsert = this.db.prepare(`
55
+ INSERT INTO api_metrics
56
+ (bucket_ts, route, method, env, release_tag,
57
+ status_2xx, status_4xx, status_5xx, total_calls,
58
+ lat_p50, lat_p90, lat_p99, lat_min, lat_max)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
60
+ `);
61
+
62
+ this._begin = this.db.prepare('BEGIN');
63
+ this._commit = this.db.prepare('COMMIT');
64
+ this._rollback = this.db.prepare('ROLLBACK');
65
+ }
66
+
67
+ insertBatch(rows) {
68
+ this._begin.run();
69
+ try {
70
+ for (const r of rows) {
71
+ this._stmtInsert.run(
72
+ r.bucket_ts, r.route, r.method, r.env, r.release_tag ?? null,
73
+ r.status_2xx, r.status_4xx, r.status_5xx, r.total_calls,
74
+ r.lat_p50, r.lat_p90, r.lat_p99, r.lat_min, r.lat_max
75
+ );
76
+ }
77
+ this._commit.run();
78
+ } catch (err) {
79
+ this._rollback.run();
80
+ throw err;
81
+ }
82
+ }
83
+
84
+ getSummary() {
85
+ const since24h = nowSec() - 86_400;
86
+ const since7d = nowSec() - 604_800;
87
+
88
+ const recent = this.db.prepare(`
89
+ SELECT
90
+ SUM(total_calls) as calls_total,
91
+ SUM(status_2xx) as calls_2xx,
92
+ SUM(status_4xx) as calls_4xx,
93
+ SUM(status_5xx) as calls_5xx,
94
+ AVG(lat_p90) as avg_p90,
95
+ AVG(lat_p99) as avg_p99
96
+ FROM api_metrics WHERE bucket_ts >= ?
97
+ `).get(since24h);
98
+
99
+ const baseline = this.db.prepare(`
100
+ SELECT AVG(lat_p90) as baseline_p90
101
+ FROM api_metrics WHERE bucket_ts >= ? AND bucket_ts < ?
102
+ `).get(since7d, since24h);
103
+
104
+ const activeRoutes = this.db.prepare(`
105
+ SELECT COUNT(DISTINCT route || '|' || method) as n
106
+ FROM api_metrics WHERE bucket_ts >= ?
107
+ `).get(since24h);
108
+
109
+ const totalRoutes = this.db.prepare(`
110
+ SELECT COUNT(DISTINCT route || '|' || method) as n
111
+ FROM api_metrics
112
+ `).get();
113
+
114
+ return {
115
+ recent,
116
+ baseline,
117
+ activeRoutes: activeRoutes?.n ?? 0,
118
+ totalRoutes: totalRoutes?.n ?? 0,
119
+ };
120
+ }
121
+
122
+ getRoutes(hours = 24) {
123
+ const since = nowSec() - hours * 3600;
124
+ return this.db.prepare(`
125
+ SELECT
126
+ route, method,
127
+ SUM(total_calls) as calls,
128
+ SUM(status_2xx) as calls_2xx,
129
+ SUM(status_4xx) as calls_4xx,
130
+ SUM(status_5xx) as calls_5xx,
131
+ AVG(lat_p50) as p50,
132
+ AVG(lat_p90) as p90,
133
+ AVG(lat_p99) as p99,
134
+ MAX(lat_max) as lat_max
135
+ FROM api_metrics
136
+ WHERE bucket_ts >= ?
137
+ GROUP BY route, method
138
+ ORDER BY calls DESC
139
+ LIMIT 50
140
+ `).all(since);
141
+ }
142
+
143
+ getTimeSeries(route, method, hours = 24) {
144
+ const since = nowSec() - hours * 3600;
145
+ return this.db.prepare(`
146
+ SELECT
147
+ bucket_ts,
148
+ SUM(total_calls) as calls,
149
+ AVG(lat_p50) as p50,
150
+ AVG(lat_p90) as p90,
151
+ AVG(lat_p99) as p99,
152
+ SUM(status_5xx) as errors
153
+ FROM api_metrics
154
+ WHERE route = ? AND method = ? AND bucket_ts >= ?
155
+ GROUP BY bucket_ts
156
+ ORDER BY bucket_ts ASC
157
+ `).all(route, method, since);
158
+ }
159
+
160
+ getDeadCandidates(inactiveDays = 21) {
161
+ const cutoff = nowSec() - inactiveDays * 86_400;
162
+ return this.db.prepare(`
163
+ SELECT route, method, MAX(bucket_ts) as last_seen
164
+ FROM api_metrics
165
+ GROUP BY route, method
166
+ HAVING last_seen < ?
167
+ ORDER BY last_seen ASC
168
+ `).all(cutoff);
169
+ }
170
+
171
+ getReleaseComparison() {
172
+ const latestRelease = this.db.prepare(`
173
+ SELECT release_tag, MIN(bucket_ts) as release_ts
174
+ FROM api_metrics
175
+ WHERE release_tag IS NOT NULL AND release_tag != ''
176
+ GROUP BY release_tag
177
+ ORDER BY release_ts DESC
178
+ LIMIT 1
179
+ `).get();
180
+
181
+ if (!latestRelease) return null;
182
+
183
+ const { release_tag, release_ts } = latestRelease;
184
+ const windowBefore = release_ts - 86_400;
185
+
186
+ const before = this.db.prepare(`
187
+ SELECT route, method, AVG(lat_p90) as avg_p90, SUM(total_calls) as calls
188
+ FROM api_metrics
189
+ WHERE bucket_ts >= ? AND bucket_ts < ?
190
+ GROUP BY route, method
191
+ `).all(windowBefore, release_ts);
192
+
193
+ const after = this.db.prepare(`
194
+ SELECT route, method, AVG(lat_p90) as avg_p90, SUM(total_calls) as calls
195
+ FROM api_metrics
196
+ WHERE bucket_ts >= ? AND release_tag = ?
197
+ GROUP BY route, method
198
+ `).all(release_ts, release_tag);
199
+
200
+ return { release_tag, release_ts, before, after };
201
+ }
202
+
203
+ getLatencyAnomalyData() {
204
+ const since1h = nowSec() - 3_600;
205
+ const since7d = nowSec() - 604_800;
206
+
207
+ const recent = this.db.prepare(`
208
+ SELECT route, method, AVG(lat_p99) as avg_p99
209
+ FROM api_metrics
210
+ WHERE bucket_ts >= ?
211
+ GROUP BY route, method
212
+ `).all(since1h);
213
+
214
+ const baselineRows = this.db.prepare(`
215
+ SELECT route, method, lat_p99
216
+ FROM api_metrics
217
+ WHERE bucket_ts >= ? AND bucket_ts < ? AND lat_p99 IS NOT NULL
218
+ `).all(since7d, since1h);
219
+
220
+ return { recent, baselineRows };
221
+ }
222
+
223
+ // Called once at startup with all routes discovered in the Express router
224
+ upsertKnownRoutes(routes) {
225
+ const stmt = this.db.prepare(`
226
+ INSERT INTO known_routes (route, method) VALUES (?, ?)
227
+ ON CONFLICT (route, method) DO NOTHING
228
+ `);
229
+ this._begin.run();
230
+ try {
231
+ for (const r of routes) stmt.run(r.route, r.method);
232
+ this._commit.run();
233
+ } catch (err) {
234
+ this._rollback.run();
235
+ throw err;
236
+ }
237
+ }
238
+
239
+ // Routes declared in Express but with zero traffic ever recorded
240
+ getUntrackedRoutes() {
241
+ return this.db.prepare(`
242
+ SELECT k.route, k.method, k.first_seen
243
+ FROM known_routes k
244
+ WHERE NOT EXISTS (
245
+ SELECT 1 FROM api_metrics m
246
+ WHERE m.route = k.route AND m.method = k.method
247
+ )
248
+ ORDER BY k.method, k.route
249
+ `).all();
250
+ }
251
+
252
+ // All known routes with their traffic status (for dashboard routes table)
253
+ getKnownRoutes() {
254
+ return this.db.prepare(`
255
+ SELECT route, method FROM known_routes ORDER BY method, route
256
+ `).all();
257
+ }
258
+
259
+ getReleases() {
260
+ return this.db.prepare(`
261
+ SELECT release_tag,
262
+ MIN(bucket_ts) as release_ts,
263
+ COUNT(DISTINCT route || '|' || method) as routes_affected
264
+ FROM api_metrics
265
+ WHERE release_tag IS NOT NULL AND release_tag != ''
266
+ GROUP BY release_tag
267
+ ORDER BY release_ts DESC
268
+ LIMIT 20
269
+ `).all();
270
+ }
271
+
272
+ getGlobalTimeSeries(hours = 24) {
273
+ const since = nowSec() - hours * 3600;
274
+ return this.db.prepare(`
275
+ SELECT
276
+ bucket_ts,
277
+ SUM(total_calls) as calls,
278
+ AVG(lat_p50) as p50,
279
+ AVG(lat_p90) as p90,
280
+ AVG(lat_p99) as p99,
281
+ SUM(status_5xx) as errors
282
+ FROM api_metrics
283
+ WHERE bucket_ts >= ?
284
+ GROUP BY bucket_ts
285
+ ORDER BY bucket_ts ASC
286
+ `).all(since);
287
+ }
288
+
289
+ close() {
290
+ this.db.close();
291
+ }
292
+ }
293
+
294
+ function nowSec() {
295
+ return Math.floor(Date.now() / 1000);
296
+ }
297
+
298
+ module.exports = { ApiForgeDatabase };
package/src/index.js ADDED
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const { createInterceptor } = require('./interceptor');
4
+ const { Aggregator } = require('./aggregator');
5
+ const { LocalTransport } = require('./transport');
6
+ const { ApiForgeDatabase } = require('./database');
7
+ const { startDashboard } = require('./dashboard');
8
+
9
+ /**
10
+ * APIForge Express middleware factory.
11
+ *
12
+ * @param {object} options
13
+ * @param {'local'} options.mode - Storage mode. Only 'local' (SQLite) in v0.x.
14
+ * @param {string} [options.dbPath] - SQLite file path. Default: '.apiforge.db'
15
+ * @param {number} [options.dashboardPort] - Dashboard port. Default: 4242. Set to 0 to disable.
16
+ * @param {number} [options.flushInterval] - Aggregation flush interval in ms. Default: 60000.
17
+ * @param {string} [options.env] - Environment label. Default: NODE_ENV or 'production'.
18
+ * @param {string} [options.release] - Release/version tag for deployment correlation.
19
+ * @param {string} [options.service] - Service name for multi-service setups.
20
+ * @param {number} [options.sampling] - Sample rate 0.0–1.0. Default: 1.0.
21
+ * @param {string[]}[options.ignorePaths] - Paths to skip. Default: ['/favicon.ico'].
22
+ */
23
+ function apiforge(options = {}) {
24
+ if (options.mode && options.mode !== 'local') {
25
+ throw new Error(`[apiforgejs] mode '${options.mode}' is not yet supported. Use 'local'.`);
26
+ }
27
+
28
+ const config = {
29
+ mode: 'local',
30
+ dbPath: options.dbPath ?? '.apiforge.db',
31
+ dashboardPort: options.dashboardPort !== undefined ? options.dashboardPort : 4242,
32
+ flushInterval: options.flushInterval ?? 60_000,
33
+ env: options.env ?? process.env.NODE_ENV ?? 'production',
34
+ release: options.release ?? process.env.APP_VERSION ?? null,
35
+ service: options.service ?? 'default',
36
+ sampling: options.sampling ?? 1.0,
37
+ ignorePaths: options.ignorePaths ?? ['/favicon.ico'],
38
+ };
39
+
40
+ const db = new ApiForgeDatabase(config.dbPath);
41
+ const transport = new LocalTransport(db);
42
+ const aggregator = new Aggregator(transport, config.flushInterval);
43
+
44
+ aggregator.start();
45
+
46
+ if (config.dashboardPort) {
47
+ startDashboard(db, config.dashboardPort);
48
+ }
49
+
50
+ const middleware = createInterceptor(aggregator, db, config);
51
+
52
+ middleware.shutdown = () => {
53
+ aggregator.stop();
54
+ db.close();
55
+ };
56
+
57
+ return middleware;
58
+ }
59
+
60
+ module.exports = { apiforge };
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const DEAD_ENDPOINT_DAYS = 21;
4
+ const REGRESSION_THRESHOLD = 0.20; // 20% worse P90 triggers regression insight
5
+ const ANOMALY_Z_THRESHOLD = 2.5; // Z-score threshold for latency anomaly
6
+
7
+ function getInsights(db) {
8
+ const insights = [];
9
+
10
+ try {
11
+ insights.push(...detectLatencyAnomalies(db));
12
+ } catch (_) {}
13
+
14
+ try {
15
+ insights.push(...detectDeadEndpoints(db));
16
+ } catch (_) {}
17
+
18
+ try {
19
+ insights.push(...detectReleaseRegressions(db));
20
+ } catch (_) {}
21
+
22
+ try {
23
+ insights.push(...detectUntrackedRoutes(db));
24
+ } catch (_) {}
25
+
26
+ return insights;
27
+ }
28
+
29
+ function detectUntrackedRoutes(db) {
30
+ const untracked = db.getUntrackedRoutes();
31
+ return untracked.map((r) => ({
32
+ type: 'UNTRACKED',
33
+ severity: 'info',
34
+ route: r.route,
35
+ method: r.method,
36
+ message: `\`${r.method} ${r.route}\` est déclaré dans l'application mais n'a reçu aucune requête depuis le début du monitoring.`,
37
+ data: { first_seen_ts: r.first_seen },
38
+ }));
39
+ }
40
+
41
+ function detectLatencyAnomalies(db) {
42
+ const { recent, baselineRows } = db.getLatencyAnomalyData();
43
+ if (recent.length === 0 || baselineRows.length === 0) return [];
44
+
45
+ // Build baseline stats per route+method
46
+ const baselineMap = new Map();
47
+ for (const row of baselineRows) {
48
+ const key = `${row.method}|${row.route}`;
49
+ if (!baselineMap.has(key)) baselineMap.set(key, []);
50
+ baselineMap.get(key).push(row.lat_p99);
51
+ }
52
+
53
+ const insights = [];
54
+ for (const r of recent) {
55
+ const key = `${r.method}|${r.route}`;
56
+ const samples = baselineMap.get(key);
57
+ if (!samples || samples.length < 5) continue; // not enough baseline data
58
+
59
+ const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
60
+ const variance = samples.reduce((s, v) => s + (v - mean) ** 2, 0) / samples.length;
61
+ const stdev = Math.sqrt(variance);
62
+
63
+ if (stdev === 0) continue;
64
+ const z = (r.avg_p99 - mean) / stdev;
65
+
66
+ if (z >= ANOMALY_Z_THRESHOLD) {
67
+ insights.push({
68
+ type: 'ANOMALY',
69
+ severity: 'warning',
70
+ route: r.route,
71
+ method: r.method,
72
+ message: `La latence P99 de \`${r.method} ${r.route}\` est anormalement élevée cette heure (${fmt(r.avg_p99)} vs moyenne ${fmt(mean)} — Z-score ${z.toFixed(1)}).`,
73
+ data: { current_p99: r.avg_p99, baseline_p99: mean, z_score: z },
74
+ });
75
+ }
76
+ }
77
+
78
+ return insights;
79
+ }
80
+
81
+ function detectDeadEndpoints(db) {
82
+ const candidates = db.getDeadCandidates(DEAD_ENDPOINT_DAYS);
83
+ return candidates.map((row) => {
84
+ const daysSince = Math.floor((Date.now() / 1000 - row.last_seen) / 86_400);
85
+ return {
86
+ type: 'DEAD',
87
+ severity: 'info',
88
+ route: row.route,
89
+ method: row.method,
90
+ message: `\`${row.method} ${row.route}\` n'a reçu aucune requête depuis ${daysSince} jours. Candidat à la déprécation.`,
91
+ data: { last_seen_ts: row.last_seen, inactive_days: daysSince },
92
+ };
93
+ });
94
+ }
95
+
96
+ function detectReleaseRegressions(db) {
97
+ const comparison = db.getReleaseComparison();
98
+ if (!comparison) return [];
99
+
100
+ const { release_tag, before, after } = comparison;
101
+
102
+ const beforeMap = new Map(before.map((r) => [`${r.method}|${r.route}`, r]));
103
+ const insights = [];
104
+
105
+ for (const a of after) {
106
+ const key = `${a.method}|${a.route}`;
107
+ const b = beforeMap.get(key);
108
+ if (!b || b.avg_p90 === null || a.avg_p90 === null || b.avg_p90 === 0) continue;
109
+
110
+ const delta = (a.avg_p90 - b.avg_p90) / b.avg_p90;
111
+
112
+ if (delta >= REGRESSION_THRESHOLD) {
113
+ insights.push({
114
+ type: 'PERF',
115
+ severity: 'error',
116
+ route: a.route,
117
+ method: a.method,
118
+ message: `La latence P90 de \`${a.method} ${a.route}\` a augmenté de ${pct(delta)} depuis le déploiement ${release_tag}. Avant : ${fmt(b.avg_p90)} — Après : ${fmt(a.avg_p90)}.`,
119
+ data: {
120
+ release: release_tag,
121
+ before_p90: b.avg_p90,
122
+ after_p90: a.avg_p90,
123
+ delta_pct: delta * 100,
124
+ },
125
+ });
126
+ } else if (delta <= -REGRESSION_THRESHOLD) {
127
+ insights.push({
128
+ type: 'OK',
129
+ severity: 'success',
130
+ route: a.route,
131
+ method: a.method,
132
+ message: `Le déploiement ${release_tag} a amélioré \`${a.method} ${a.route}\` de ${pct(-delta)}. Avant : ${fmt(b.avg_p90)} — Après : ${fmt(a.avg_p90)}.`,
133
+ data: {
134
+ release: release_tag,
135
+ before_p90: b.avg_p90,
136
+ after_p90: a.avg_p90,
137
+ delta_pct: delta * 100,
138
+ },
139
+ });
140
+ }
141
+ }
142
+
143
+ return insights;
144
+ }
145
+
146
+ function computeHealthScore(db) {
147
+ try {
148
+ const { recent, baseline, activeRoutes, totalRoutes } = db.getSummary();
149
+
150
+ const total = recent?.calls_total || 0;
151
+ if (total === 0) return null;
152
+
153
+ // Availability: 2xx rate (weight 30%)
154
+ const availability = Math.min(100, ((recent.calls_2xx || 0) / total) * 100);
155
+
156
+ // Performance: P90 vs 7d baseline (weight 30%)
157
+ let performance = 100;
158
+ if (baseline?.baseline_p90 && recent?.avg_p90 && baseline.baseline_p90 > 0) {
159
+ const ratio = recent.avg_p90 / baseline.baseline_p90;
160
+ performance = Math.max(0, Math.min(100, 100 - (ratio - 1) * 100));
161
+ }
162
+
163
+ // Stability: 100 by default at MVP (no complex drift detection)
164
+ const stability = 100;
165
+
166
+ // Quality: active routes vs total ever seen (weight 15%)
167
+ const quality = totalRoutes > 0 ? Math.min(100, (activeRoutes / totalRoutes) * 100) : 100;
168
+
169
+ const score = availability * 0.30 + performance * 0.30 + stability * 0.25 + quality * 0.15;
170
+ return Math.round(score);
171
+ } catch (_) {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function fmt(ms) {
177
+ if (ms == null) return 'N/A';
178
+ return `${Math.round(ms)}ms`;
179
+ }
180
+
181
+ function pct(ratio) {
182
+ return `${Math.round(ratio * 100)}%`;
183
+ }
184
+
185
+ module.exports = { getInsights, computeHealthScore };
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ const NUMERIC_SEGMENT = /\/\d+/g;
4
+ const UUID_SEGMENT = /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
5
+
6
+ function normalizePath(path) {
7
+ return path.replace(UUID_SEGMENT, '/:uuid').replace(NUMERIC_SEGMENT, '/:id');
8
+ }
9
+
10
+ // Walk Express router stack and return all declared routes
11
+ function extractExpressRoutes(router, prefix = '') {
12
+ const routes = [];
13
+ if (!router?.stack) return routes;
14
+
15
+ for (const layer of router.stack) {
16
+ if (layer.route) {
17
+ const path = prefix + layer.route.path;
18
+ const methods = Object.keys(layer.route.methods)
19
+ .filter(m => m !== '_all' && layer.route.methods[m])
20
+ .map(m => m.toUpperCase());
21
+ for (const method of methods) {
22
+ routes.push({ method, route: path || '/' });
23
+ }
24
+ } else if (layer.handle?.stack) {
25
+ // Nested router mounted via app.use(prefix, router)
26
+ const subPrefix = prefix + routerLayerPath(layer);
27
+ extractExpressRoutes(layer.handle, subPrefix).forEach(r => routes.push(r));
28
+ }
29
+ }
30
+ return routes;
31
+ }
32
+
33
+ // Best-effort extraction of the mount path from a router layer's compiled regexp.
34
+ // Works for simple static prefixes (/api, /v1/users, etc.) — skips prefixes with params.
35
+ function routerLayerPath(layer) {
36
+ if (!layer.regexp || layer.keys?.length > 0) return '';
37
+ const src = layer.regexp.source;
38
+ // regexp for app.use('/prefix') looks like: ^\/prefix\/?(?=\/|$)
39
+ const m = src.match(/^\^\\\/([^?]+?)\\\//);
40
+ if (!m) return '';
41
+ return '/' + m[1].replace(/\\\//g, '/');
42
+ }
43
+
44
+ function createInterceptor(aggregator, db, config) {
45
+ const { env, release, service, sampling, ignorePaths } = config;
46
+ const ignoreSet = new Set(ignorePaths);
47
+
48
+ let routesScanned = false;
49
+
50
+ function scanRoutes(app) {
51
+ try {
52
+ const routes = extractExpressRoutes(app._router);
53
+ if (routes.length > 0) db.upsertKnownRoutes(routes);
54
+ } catch (_) {
55
+ // Non-critical — never crash the host app
56
+ }
57
+ }
58
+
59
+ function middleware(req, res, next) {
60
+ // Scan all declared Express routes once, after the first request
61
+ // (guarantees all app.get/post/etc calls have already executed)
62
+ if (!routesScanned && req.app) {
63
+ routesScanned = true;
64
+ setImmediate(() => scanRoutes(req.app));
65
+ }
66
+
67
+ if (ignoreSet.has(req.path)) return next();
68
+ if (sampling < 1.0 && Math.random() > sampling) return next();
69
+
70
+ const startHr = process.hrtime.bigint();
71
+
72
+ res.on('finish', () => {
73
+ try {
74
+ const durationMs = Number(process.hrtime.bigint() - startHr) / 1_000_000;
75
+
76
+ // Use Express matched route pattern — never the concrete URL values
77
+ const routePattern = req.route
78
+ ? (req.baseUrl || '') + req.route.path
79
+ : normalizePath(req.path);
80
+
81
+ const contentLength = res.getHeader('content-length');
82
+
83
+ aggregator.record({
84
+ route: routePattern,
85
+ method: req.method,
86
+ status: res.statusCode,
87
+ duration_ms: durationMs,
88
+ timestamp: new Date().toISOString(),
89
+ env,
90
+ release: release || null,
91
+ service,
92
+ response_size: contentLength ? parseInt(contentLength, 10) : null,
93
+ });
94
+ } catch (_) {
95
+ // Never let instrumentation crash the host application
96
+ }
97
+ });
98
+
99
+ next();
100
+ }
101
+
102
+ return middleware;
103
+ }
104
+
105
+ module.exports = { createInterceptor, extractExpressRoutes };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const CIRCUIT_OPEN_MS = 60_000;
4
+ const FAILURE_THRESHOLD = 5;
5
+
6
+ class LocalTransport {
7
+ constructor(db) {
8
+ this.db = db;
9
+ this._failures = 0;
10
+ this._openUntil = 0;
11
+ }
12
+
13
+ write(rows) {
14
+ if (rows.length === 0) return;
15
+
16
+ // Circuit breaker: if too many consecutive failures, pause writes temporarily
17
+ if (Date.now() < this._openUntil) return;
18
+
19
+ try {
20
+ this.db.insertBatch(rows);
21
+ this._failures = 0;
22
+ } catch (err) {
23
+ this._failures++;
24
+ if (this._failures >= FAILURE_THRESHOLD) {
25
+ this._openUntil = Date.now() + CIRCUIT_OPEN_MS;
26
+ this._failures = 0;
27
+ console.warn(`[apiforgejs] SQLite write failures — pausing for ${CIRCUIT_OPEN_MS / 1000}s. Error: ${err.message}`);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ module.exports = { LocalTransport };