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.
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/package.json +33 -0
- package/src/aggregator.js +91 -0
- package/src/dashboard.js +186 -0
- package/src/database.js +298 -0
- package/src/index.js +60 -0
- package/src/insights.js +185 -0
- package/src/interceptor.js +105 -0
- package/src/transport.js +33 -0
- package/src/ui.html +1648 -0
package/src/database.js
ADDED
|
@@ -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 };
|
package/src/insights.js
ADDED
|
@@ -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 };
|
package/src/transport.js
ADDED
|
@@ -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 };
|