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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiforgejs",
3
- "version": "1.0.1",
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
- "test": "node --test test/**/*.test.js",
22
- "test:smoke": "node --test test/smoke.test.js"
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
- 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
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 = 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
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
- 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 (_) {}
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}\` est déclaré dans l'application mais n'a reçu aucune requête depuis le début du monitoring.`,
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: `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)}).`,
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}\` n'a reçu aucune requête depuis ${daysSince} jours. Candidat à la déprécation.`,
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: `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)}.`,
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: `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)}.`,
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 releaseMarkers = (RELEASES || []).slice(0,2).map((r, i) => ({
743
- idx: Math.min(Math.floor(globalP90.length * (0.45 + i * 0.35)), globalP90.length - 1),
744
- label: r.tag,
745
- color: i === 0 ? '#b91c1c' : '#15803d',
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:'ANOMALY',label:'Anomaly'},{id:'DEAD',label:'Dead'},
1237
- {id:'UNTRACKED',label:'Untracked'},{id:'OK',label:'OK'},
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) &&