apiforgejs 1.0.1 → 1.0.3
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/package.json +3 -7
- package/src/database.js +39 -7
- package/src/insights.js +62 -23
- package/src/ui.html +16 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apiforgejs",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "API observability & intelligence SDK for Express.js — local-first, privacy-first",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -18,16 +18,12 @@
|
|
|
18
18
|
"author": "APIForge",
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"scripts": {
|
|
21
|
-
"
|
|
22
|
-
"test:smoke": "node --test
|
|
21
|
+
"tests": "node --test tests/**/*.test.js",
|
|
22
|
+
"test:smoke": "node --test tests/smoke.test.js"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
25
|
"node": ">=22.5.0"
|
|
26
26
|
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"react": "^18.0.0",
|
|
29
|
-
"react-dom": "^18.0.0"
|
|
30
|
-
},
|
|
31
27
|
"peerDependencies": {
|
|
32
28
|
"express": ">=4.0.0"
|
|
33
29
|
},
|
package/src/database.js
CHANGED
|
@@ -229,6 +229,19 @@ class ApiForgeDatabase {
|
|
|
229
229
|
this._begin.run();
|
|
230
230
|
try {
|
|
231
231
|
for (const r of routes) stmt.run(r.route, r.method);
|
|
232
|
+
// Remove routes no longer in the router that never had traffic
|
|
233
|
+
if (routes.length > 0) {
|
|
234
|
+
const keys = routes.map(r => `${r.route}|${r.method}`);
|
|
235
|
+
const ph = keys.map(() => '?').join(', ');
|
|
236
|
+
this.db.prepare(`
|
|
237
|
+
DELETE FROM known_routes
|
|
238
|
+
WHERE route || '|' || method NOT IN (${ph})
|
|
239
|
+
AND NOT EXISTS (
|
|
240
|
+
SELECT 1 FROM api_metrics m
|
|
241
|
+
WHERE m.route = known_routes.route AND m.method = known_routes.method
|
|
242
|
+
)
|
|
243
|
+
`).run(...keys);
|
|
244
|
+
}
|
|
232
245
|
this._commit.run();
|
|
233
246
|
} catch (err) {
|
|
234
247
|
this._rollback.run();
|
|
@@ -258,17 +271,36 @@ class ApiForgeDatabase {
|
|
|
258
271
|
|
|
259
272
|
getReleases() {
|
|
260
273
|
return this.db.prepare(`
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
274
|
+
WITH release_times AS (
|
|
275
|
+
SELECT release_tag, MIN(bucket_ts) AS release_ts
|
|
276
|
+
FROM api_metrics
|
|
277
|
+
WHERE release_tag IS NOT NULL AND release_tag != ''
|
|
278
|
+
GROUP BY release_tag
|
|
279
|
+
)
|
|
280
|
+
SELECT rt.release_tag,
|
|
281
|
+
rt.release_ts,
|
|
282
|
+
(SELECT COUNT(*) FROM known_routes WHERE first_seen <= rt.release_ts + 60) AS routes_affected
|
|
283
|
+
FROM release_times rt
|
|
284
|
+
ORDER BY rt.release_ts DESC
|
|
268
285
|
LIMIT 20
|
|
269
286
|
`).all();
|
|
270
287
|
}
|
|
271
288
|
|
|
289
|
+
// Returns one row per (route, method, day) for the last 30 days, used by drift detection
|
|
290
|
+
getDriftData() {
|
|
291
|
+
const since30d = nowSec() - 30 * 86_400;
|
|
292
|
+
return this.db.prepare(`
|
|
293
|
+
SELECT
|
|
294
|
+
route, method,
|
|
295
|
+
CAST(bucket_ts / 86400 AS INTEGER) as day_bucket,
|
|
296
|
+
AVG(lat_p90) as p90
|
|
297
|
+
FROM api_metrics
|
|
298
|
+
WHERE bucket_ts >= ? AND lat_p90 IS NOT NULL
|
|
299
|
+
GROUP BY route, method, day_bucket
|
|
300
|
+
ORDER BY route, method, day_bucket
|
|
301
|
+
`).all(since30d);
|
|
302
|
+
}
|
|
303
|
+
|
|
272
304
|
getGlobalTimeSeries(hours = 24) {
|
|
273
305
|
const since = nowSec() - hours * 3600;
|
|
274
306
|
return this.db.prepare(`
|
package/src/insights.js
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const DEAD_ENDPOINT_DAYS
|
|
4
|
-
const REGRESSION_THRESHOLD
|
|
5
|
-
const ANOMALY_Z_THRESHOLD
|
|
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
|
+
const DRIFT_SLOPE_THRESHOLD = 5; // ms/day above which progressive drift is reported
|
|
7
|
+
const DRIFT_MIN_DAYS = 7; // minimum number of daily data points required
|
|
6
8
|
|
|
7
9
|
function getInsights(db) {
|
|
8
10
|
const insights = [];
|
|
9
11
|
|
|
10
|
-
try {
|
|
11
|
-
|
|
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 (_) {}
|
|
12
|
+
try { insights.push(...detectLatencyAnomalies(db)); } catch (_) {}
|
|
13
|
+
try { insights.push(...detectDeadEndpoints(db)); } catch (_) {}
|
|
14
|
+
try { insights.push(...detectReleaseRegressions(db)); } catch (_) {}
|
|
15
|
+
try { insights.push(...detectUntrackedRoutes(db)); } catch (_) {}
|
|
16
|
+
try { insights.push(...detectDrift(db)); } catch (_) {}
|
|
25
17
|
|
|
26
18
|
return insights;
|
|
27
19
|
}
|
|
@@ -33,7 +25,7 @@ function detectUntrackedRoutes(db) {
|
|
|
33
25
|
severity: 'info',
|
|
34
26
|
route: r.route,
|
|
35
27
|
method: r.method,
|
|
36
|
-
message: `\`${r.method} ${r.route}\`
|
|
28
|
+
message: `\`${r.method} ${r.route}\` is declared but has received no requests since monitoring started.`,
|
|
37
29
|
data: { first_seen_ts: r.first_seen },
|
|
38
30
|
}));
|
|
39
31
|
}
|
|
@@ -69,7 +61,7 @@ function detectLatencyAnomalies(db) {
|
|
|
69
61
|
severity: 'warning',
|
|
70
62
|
route: r.route,
|
|
71
63
|
method: r.method,
|
|
72
|
-
message:
|
|
64
|
+
message: `\`${r.method} ${r.route}\` P99 latency is abnormally high this hour (${fmt(r.avg_p99)} vs baseline ${fmt(mean)} — Z-score ${z.toFixed(1)}).`,
|
|
73
65
|
data: { current_p99: r.avg_p99, baseline_p99: mean, z_score: z },
|
|
74
66
|
});
|
|
75
67
|
}
|
|
@@ -87,7 +79,7 @@ function detectDeadEndpoints(db) {
|
|
|
87
79
|
severity: 'info',
|
|
88
80
|
route: row.route,
|
|
89
81
|
method: row.method,
|
|
90
|
-
message: `\`${row.method} ${row.route}\`
|
|
82
|
+
message: `\`${row.method} ${row.route}\` has received no requests in ${daysSince} days. Consider deprecating this endpoint.`,
|
|
91
83
|
data: { last_seen_ts: row.last_seen, inactive_days: daysSince },
|
|
92
84
|
};
|
|
93
85
|
});
|
|
@@ -115,7 +107,7 @@ function detectReleaseRegressions(db) {
|
|
|
115
107
|
severity: 'error',
|
|
116
108
|
route: a.route,
|
|
117
109
|
method: a.method,
|
|
118
|
-
message:
|
|
110
|
+
message: `\`${a.method} ${a.route}\` P90 increased by ${pct(delta)} after ${release_tag}. Before: ${fmt(b.avg_p90)} — After: ${fmt(a.avg_p90)}.`,
|
|
119
111
|
data: {
|
|
120
112
|
release: release_tag,
|
|
121
113
|
before_p90: b.avg_p90,
|
|
@@ -129,7 +121,7 @@ function detectReleaseRegressions(db) {
|
|
|
129
121
|
severity: 'success',
|
|
130
122
|
route: a.route,
|
|
131
123
|
method: a.method,
|
|
132
|
-
message:
|
|
124
|
+
message: `${release_tag} improved \`${a.method} ${a.route}\` by ${pct(-delta)}. Before: ${fmt(b.avg_p90)} — After: ${fmt(a.avg_p90)}.`,
|
|
133
125
|
data: {
|
|
134
126
|
release: release_tag,
|
|
135
127
|
before_p90: b.avg_p90,
|
|
@@ -143,6 +135,53 @@ function detectReleaseRegressions(db) {
|
|
|
143
135
|
return insights;
|
|
144
136
|
}
|
|
145
137
|
|
|
138
|
+
function detectDrift(db) {
|
|
139
|
+
const rows = db.getDriftData();
|
|
140
|
+
if (rows.length === 0) return [];
|
|
141
|
+
|
|
142
|
+
// Group daily P90 samples by endpoint
|
|
143
|
+
const byEndpoint = new Map();
|
|
144
|
+
for (const row of rows) {
|
|
145
|
+
const key = `${row.method}|${row.route}`;
|
|
146
|
+
if (!byEndpoint.has(key)) byEndpoint.set(key, { method: row.method, route: row.route, points: [] });
|
|
147
|
+
byEndpoint.get(key).points.push({ x: row.day_bucket, y: row.p90 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const insights = [];
|
|
151
|
+
for (const { method, route, points } of byEndpoint.values()) {
|
|
152
|
+
if (points.length < DRIFT_MIN_DAYS) continue;
|
|
153
|
+
|
|
154
|
+
// Ordinary least squares on (day_index, p90) pairs
|
|
155
|
+
const x0 = points[0].x;
|
|
156
|
+
const xs = points.map(p => p.x - x0);
|
|
157
|
+
const ys = points.map(p => p.y);
|
|
158
|
+
const n = xs.length;
|
|
159
|
+
const sumX = xs.reduce((a, b) => a + b, 0);
|
|
160
|
+
const sumY = ys.reduce((a, b) => a + b, 0);
|
|
161
|
+
const sumXY = xs.reduce((s, x, i) => s + x * ys[i], 0);
|
|
162
|
+
const sumX2 = xs.reduce((s, x) => s + x * x, 0);
|
|
163
|
+
const denom = n * sumX2 - sumX * sumX;
|
|
164
|
+
if (denom === 0) continue;
|
|
165
|
+
|
|
166
|
+
const slope = (n * sumXY - sumX * sumY) / denom;
|
|
167
|
+
if (slope < DRIFT_SLOPE_THRESHOLD) continue;
|
|
168
|
+
|
|
169
|
+
const observedDays = xs[xs.length - 1];
|
|
170
|
+
const projection30 = Math.round(slope * 30);
|
|
171
|
+
|
|
172
|
+
insights.push({
|
|
173
|
+
type: 'DRIFT',
|
|
174
|
+
severity: 'warning',
|
|
175
|
+
route,
|
|
176
|
+
method,
|
|
177
|
+
message: `\`${method} ${route}\` has been progressively degrading for ${observedDays} day${observedDays !== 1 ? 's' : ''}: +${slope.toFixed(1)}ms/day. 30-day projection: +${projection30}ms.`,
|
|
178
|
+
data: { slope_ms_per_day: slope, observed_days: observedDays, projection_30d_ms: projection30 },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return insights;
|
|
183
|
+
}
|
|
184
|
+
|
|
146
185
|
function computeHealthScore(db) {
|
|
147
186
|
try {
|
|
148
187
|
const { recent, baseline, activeRoutes, totalRoutes } = db.getSummary();
|
package/src/ui.html
CHANGED
|
@@ -421,6 +421,7 @@ function mapInsights(insights) {
|
|
|
421
421
|
function mapReleases(releases) {
|
|
422
422
|
return (releases || []).map(r => ({
|
|
423
423
|
tag: r.release_tag,
|
|
424
|
+
ts: r.release_ts,
|
|
424
425
|
summary: `${r.routes_affected || 0} route${r.routes_affected !== 1 ? 's' : ''} recorded`,
|
|
425
426
|
age: formatAge(r.release_ts),
|
|
426
427
|
by: 'local',
|
|
@@ -739,11 +740,18 @@ function Overview({ timeRange, setRoute, setParams, lastUpdated }) {
|
|
|
739
740
|
const globalCalls = chartData?.calls || Array(fallbackPts).fill(0);
|
|
740
741
|
const xLabelsFinal = xLabels.length > 0 ? xLabels : Array.from({length:globalP90.length}, (_,i) => `${i}`);
|
|
741
742
|
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
743
|
+
const MARKER_COLORS = ['#b91c1c','#15803d','#2563eb','#b45309','#7c3aed'];
|
|
744
|
+
const nowTs = Date.now() / 1000;
|
|
745
|
+
const releaseMarkers = globalTs && globalTs.length > 0
|
|
746
|
+
? [...(RELEASES || [])]
|
|
747
|
+
.filter(r => r.ts != null && r.ts >= nowTs - hours * 3600)
|
|
748
|
+
.reverse()
|
|
749
|
+
.map((r, i) => {
|
|
750
|
+
const idx = globalTs.reduce((best, b, j) =>
|
|
751
|
+
Math.abs(b.bucket_ts - r.ts) < Math.abs(globalTs[best].bucket_ts - r.ts) ? j : best, 0);
|
|
752
|
+
return { idx, label: r.tag, color: MARKER_COLORS[i % MARKER_COLORS.length] };
|
|
753
|
+
})
|
|
754
|
+
: [];
|
|
747
755
|
|
|
748
756
|
const topSlow = [...ENDPOINTS].filter(e => !e.untracked && e.base_p90 > 0).sort((a,b) => b.base_p90-a.base_p90).slice(0,5);
|
|
749
757
|
const topCalled = [...ENDPOINTS].sort((a,b) => b.calls24h-a.calls24h).slice(0,5);
|
|
@@ -1233,8 +1241,9 @@ function Insights({ setRoute, setParams }) {
|
|
|
1233
1241
|
|
|
1234
1242
|
const types = [
|
|
1235
1243
|
{id:'ALL',label:'All'},{id:'PERF',label:'Performance'},
|
|
1236
|
-
{id:'
|
|
1237
|
-
{id:'
|
|
1244
|
+
{id:'DRIFT',label:'Drift'},{id:'ANOMALY',label:'Anomaly'},
|
|
1245
|
+
{id:'DEAD',label:'Dead'},{id:'UNTRACKED',label:'Untracked'},
|
|
1246
|
+
{id:'OK',label:'OK'},
|
|
1238
1247
|
];
|
|
1239
1248
|
const filtered = INSIGHTS.filter(i =>
|
|
1240
1249
|
(typeFilter === 'ALL' || i.type === typeFilter) &&
|