@backendkit-labs/auto-learning 0.1.4 → 0.2.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/dist/index.js CHANGED
@@ -44,8 +44,8 @@ var PatternRegistry = class {
44
44
  });
45
45
  return ok(void 0);
46
46
  }
47
- getAggregates(windowMinutes) {
48
- const result = this.storage.getAggregates(windowMinutes);
47
+ getAggregates(windowMinutes, windowEnd) {
48
+ const result = this.storage.getAggregates(windowMinutes, windowEnd);
49
49
  if (!result.ok) {
50
50
  this.observability.error("Failed to get aggregates", { error: result.error });
51
51
  return fail(storageError("Failed to get aggregates", result.error));
@@ -79,12 +79,18 @@ var PatternRegistry = class {
79
79
  });
80
80
  }
81
81
  const uniqueEndpoints = new Set(all.map((p) => `${p.method}:${p.path}`));
82
- const timestamps = all.map((p) => p.timestamp.getTime());
82
+ let oldest = all[0].timestamp.getTime();
83
+ let newest = oldest;
84
+ for (const p of all) {
85
+ const t = p.timestamp.getTime();
86
+ if (t < oldest) oldest = t;
87
+ if (t > newest) newest = t;
88
+ }
83
89
  return ok({
84
90
  totalPatterns: all.length,
85
91
  uniqueEndpoints: uniqueEndpoints.size,
86
- oldestPattern: new Date(Math.min(...timestamps)),
87
- newestPattern: new Date(Math.max(...timestamps))
92
+ oldestPattern: new Date(oldest),
93
+ newestPattern: new Date(newest)
88
94
  });
89
95
  }
90
96
  };
@@ -124,23 +130,20 @@ var AnomalyDetector = class {
124
130
  });
125
131
  }
126
132
  }
127
- if (current.statusCode >= 500 && baseline.errorCount >= 3) {
128
- const currentErrorRate = 1;
129
- if (currentErrorRate > baseline.errorRate * 2 && currentErrorRate > this.config.errorRateThreshold) {
130
- reports.push({
131
- id: uuid(),
132
- endpoint: current.path,
133
- method: current.method,
134
- severity: "high",
135
- metric: "error_rate",
136
- expectedValue: baseline.errorRate,
137
- actualValue: currentErrorRate,
138
- deviation: currentErrorRate / Math.max(baseline.errorRate, 1e-3),
139
- detectedAt: /* @__PURE__ */ new Date()
140
- });
141
- }
133
+ if (current.statusCode >= 500 && baseline.errorRate < this.config.errorRateThreshold) {
134
+ reports.push({
135
+ id: uuid(),
136
+ endpoint: current.path,
137
+ method: current.method,
138
+ severity: "high",
139
+ metric: "error_rate",
140
+ expectedValue: baseline.errorRate,
141
+ actualValue: 1,
142
+ deviation: 1 / Math.max(baseline.errorRate, 1e-3),
143
+ detectedAt: /* @__PURE__ */ new Date()
144
+ });
142
145
  }
143
- return ok2(reports.length > 0 ? reports[0] : null);
146
+ return ok2(reports);
144
147
  } catch (e) {
145
148
  return fail2(
146
149
  anomalyDetectionFailed(
@@ -156,11 +159,13 @@ var AnomalyDetector = class {
156
159
  baselineMap.set(`${b.method}:${b.path}`, b);
157
160
  }
158
161
  const reports = [];
162
+ const seenUnknown = /* @__PURE__ */ new Set();
159
163
  for (const pattern of windowPatterns) {
160
164
  const key = `${pattern.method}:${pattern.path}`;
161
165
  const baseline = baselineMap.get(key);
162
166
  if (!baseline) {
163
- if (this.config.enableUnknownEndpointDetection) {
167
+ if (this.config.enableUnknownEndpointDetection && !seenUnknown.has(key)) {
168
+ seenUnknown.add(key);
164
169
  reports.push({
165
170
  id: uuid(),
166
171
  endpoint: pattern.path,
@@ -176,8 +181,8 @@ var AnomalyDetector = class {
176
181
  continue;
177
182
  }
178
183
  const result = this.analyze(pattern, baseline);
179
- if (result.ok && result.value) {
180
- reports.push(result.value);
184
+ if (result.ok) {
185
+ reports.push(...result.value);
181
186
  }
182
187
  }
183
188
  return ok2(reports);
@@ -205,7 +210,8 @@ var DEFAULT_TUNER_CONFIG = {
205
210
  minTimeoutMs: 1e3,
206
211
  maxTimeoutMs: 3e4,
207
212
  smoothingFactor: 0.3,
208
- adjustmentStepMs: 500
213
+ adjustmentStepMs: 500,
214
+ cooldownMs: 6e4
209
215
  };
210
216
 
211
217
  // src/core/config-tuner/config-tuner.ts
@@ -240,48 +246,11 @@ var ConfigTuner = class {
240
246
  if (aggregates.length === 0) {
241
247
  return ok3(this.getCurrentConfig());
242
248
  }
243
- const newConfig = {
244
- circuitBreaker: { ...this.currentConfig.circuitBreaker },
245
- bulkhead: { ...this.currentConfig.bulkhead },
246
- httpClient: { ...this.currentConfig.httpClient }
247
- };
248
- const changedSections = /* @__PURE__ */ new Set();
249
- const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
250
- const targetTimeout = Math.min(
251
- Math.max(maxP95 * 2, this.config.minTimeoutMs),
252
- this.config.maxTimeoutMs
253
- );
254
- if (Math.abs(targetTimeout - newConfig.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
255
- newConfig.httpClient.timeoutMs = this.smoothValue(newConfig.httpClient.timeoutMs, targetTimeout);
256
- changedSections.add("httpClient");
257
- }
258
- const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
259
- if (avgErrorRate > 0.1) {
260
- newConfig.httpClient.maxRetries = Math.min(newConfig.httpClient.maxRetries + 1, 5);
261
- changedSections.add("httpClient");
262
- } else if (avgErrorRate < 0.01 && newConfig.httpClient.maxRetries > 1) {
263
- newConfig.httpClient.maxRetries = Math.max(newConfig.httpClient.maxRetries - 1, 0);
264
- changedSections.add("httpClient");
265
- }
266
- const criticalAnomalies = anomalies.filter(
267
- (a) => a.severity === "critical" || a.severity === "high"
268
- ).length;
269
- if (criticalAnomalies > 0) {
270
- newConfig.circuitBreaker.failureThreshold = Math.max(
271
- this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
272
- 10
273
- );
274
- changedSections.add("circuitBreaker");
275
- } else if (anomalies.length === 0) {
276
- newConfig.circuitBreaker.failureThreshold = Math.min(
277
- this.currentConfig.circuitBreaker.failureThreshold + 5,
278
- 80
279
- );
280
- changedSections.add("circuitBreaker");
281
- }
249
+ const newConfig = this.computeNext(aggregates, anomalies);
250
+ const changedSections = this.diffSections(this.currentConfig, newConfig);
282
251
  if (changedSections.size > 0) {
283
252
  const now = Date.now();
284
- if (now - this.lastChangeAt > 6e4) {
253
+ if (now - this.lastChangeAt > this.config.cooldownMs) {
285
254
  this.currentConfig = newConfig;
286
255
  this.lastChangeAt = now;
287
256
  const saveResult = this.storage.saveConfig(newConfig);
@@ -318,6 +287,57 @@ var ConfigTuner = class {
318
287
  }
319
288
  onConfigChange(callback) {
320
289
  this.listeners.push(callback);
290
+ return () => {
291
+ const idx = this.listeners.indexOf(callback);
292
+ if (idx >= 0) this.listeners.splice(idx, 1);
293
+ };
294
+ }
295
+ // Pure computation — derives the next config from aggregates and anomalies
296
+ // without mutating any state or applying cooldown checks.
297
+ computeNext(aggregates, anomalies) {
298
+ const next = {
299
+ circuitBreaker: { ...this.currentConfig.circuitBreaker },
300
+ bulkhead: { ...this.currentConfig.bulkhead },
301
+ httpClient: { ...this.currentConfig.httpClient }
302
+ };
303
+ const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
304
+ const targetTimeout = Math.min(
305
+ Math.max(maxP95 * 2, this.config.minTimeoutMs),
306
+ this.config.maxTimeoutMs
307
+ );
308
+ if (Math.abs(targetTimeout - next.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
309
+ next.httpClient.timeoutMs = this.smoothValue(next.httpClient.timeoutMs, targetTimeout);
310
+ }
311
+ const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
312
+ if (avgErrorRate > 0.1) {
313
+ next.httpClient.maxRetries = Math.min(next.httpClient.maxRetries + 1, 5);
314
+ } else if (avgErrorRate < 0.01 && next.httpClient.maxRetries > 1) {
315
+ next.httpClient.maxRetries = Math.max(next.httpClient.maxRetries - 1, 0);
316
+ }
317
+ const criticalAnomalies = anomalies.filter(
318
+ (a) => a.severity === "critical" || a.severity === "high"
319
+ ).length;
320
+ if (criticalAnomalies > 0) {
321
+ next.circuitBreaker.failureThreshold = Math.max(
322
+ this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
323
+ 10
324
+ );
325
+ } else if (anomalies.length === 0) {
326
+ next.circuitBreaker.failureThreshold = Math.min(
327
+ this.currentConfig.circuitBreaker.failureThreshold + 5,
328
+ 80
329
+ );
330
+ }
331
+ return next;
332
+ }
333
+ diffSections(prev, next) {
334
+ const changed = /* @__PURE__ */ new Set();
335
+ for (const key of Object.keys(next)) {
336
+ if (JSON.stringify(next[key]) !== JSON.stringify(prev[key])) {
337
+ changed.add(key);
338
+ }
339
+ }
340
+ return changed;
321
341
  }
322
342
  smoothValue(current, target) {
323
343
  return current + (target - current) * this.config.smoothingFactor;
@@ -329,7 +349,7 @@ var DEFAULT_LOOP_CONFIG = {
329
349
  defaultIntervalMs: 6e4,
330
350
  windowSizeMinutes: 5,
331
351
  minSamplesBeforeTuning: 10,
332
- cooldownBetweenChangesMs: 12e4
352
+ pruneTtlHours: 24
333
353
  };
334
354
 
335
355
  // src/core/feedback-loop/feedback-loop.ts
@@ -350,6 +370,7 @@ var FeedbackLoop = class {
350
370
  storage;
351
371
  observability;
352
372
  timerId = null;
373
+ isProcessing = false;
353
374
  config;
354
375
  cycleListeners = [];
355
376
  start(intervalMs) {
@@ -359,14 +380,20 @@ var FeedbackLoop = class {
359
380
  }
360
381
  const interval = intervalMs ?? this.config.defaultIntervalMs;
361
382
  this.observability.info("Feedback loop started", { intervalMs: interval });
362
- this.timerId = setInterval(() => {
363
- this.runOnce().then((result) => {
383
+ this.timerId = setInterval(async () => {
384
+ if (this.isProcessing) {
385
+ this.observability.warn("Skipping cycle: previous cycle still running");
386
+ return;
387
+ }
388
+ this.isProcessing = true;
389
+ try {
390
+ const result = await this.runOnce();
364
391
  if (!result.ok) {
365
- this.observability.error("Feedback loop cycle failed", {
366
- error: result.error
367
- });
392
+ this.observability.error("Feedback loop cycle failed", { error: result.error });
368
393
  }
369
- });
394
+ } finally {
395
+ this.isProcessing = false;
396
+ }
370
397
  }, interval);
371
398
  }
372
399
  stop() {
@@ -384,11 +411,10 @@ var FeedbackLoop = class {
384
411
  async runOnce() {
385
412
  const cycleId = uuid2();
386
413
  const startTime = Date.now();
414
+ const windowEnd = /* @__PURE__ */ new Date();
415
+ const windowStart = new Date(windowEnd.getTime() - this.config.windowSizeMinutes * 6e4);
387
416
  this.observability.debug("Feedback cycle started", { cycleId });
388
- const patternsResult = this.storage.getPatterns(
389
- new Date(Date.now() - this.config.windowSizeMinutes * 6e4),
390
- /* @__PURE__ */ new Date()
391
- );
417
+ const patternsResult = this.storage.getPatterns(windowStart, windowEnd);
392
418
  if (!patternsResult.ok) {
393
419
  return fail4(storageError("Failed to collect patterns", patternsResult.error));
394
420
  }
@@ -409,7 +435,8 @@ var FeedbackLoop = class {
409
435
  return ok4(skippedEvent);
410
436
  }
411
437
  const aggregatesResult = this.patternRegistry.getAggregates(
412
- this.config.windowSizeMinutes
438
+ this.config.windowSizeMinutes,
439
+ windowEnd
413
440
  );
414
441
  if (!aggregatesResult.ok) {
415
442
  return fail4(aggregatesResult.error);
@@ -421,7 +448,10 @@ var FeedbackLoop = class {
421
448
  }
422
449
  const anomalies = anomaliesResult.value;
423
450
  for (const anomaly of anomalies) {
424
- this.storage.saveAnomaly(anomaly);
451
+ const saveAnomalyResult = this.storage.saveAnomaly(anomaly);
452
+ if (!saveAnomalyResult.ok) {
453
+ this.observability.warn("Failed to persist anomaly", { error: saveAnomalyResult.error });
454
+ }
425
455
  }
426
456
  if (anomalies.length > 0) {
427
457
  this.observability.warn("Anomalies detected", {
@@ -430,12 +460,12 @@ var FeedbackLoop = class {
430
460
  });
431
461
  this.observability.incrementMetric("anomalies.detected", anomalies.length);
432
462
  }
463
+ const previousConfig = this.configTuner.getCurrentConfig();
433
464
  const tuneResult = this.configTuner.tune(aggregates, anomalies);
434
465
  if (!tuneResult.ok) {
435
466
  return fail4(tuneResult.error);
436
467
  }
437
468
  const newConfig = tuneResult.value;
438
- const previousConfig = this.configTuner.getCurrentConfig();
439
469
  const configChanges = {};
440
470
  for (const key of Object.keys(newConfig)) {
441
471
  if (JSON.stringify(newConfig[key]) !== JSON.stringify(previousConfig[key])) {
@@ -458,6 +488,11 @@ var FeedbackLoop = class {
458
488
  for (const listener of this.cycleListeners) {
459
489
  listener(cycleEvent);
460
490
  }
491
+ const pruneCutoff = new Date(Date.now() - this.config.pruneTtlHours * 36e5);
492
+ const pruneResult = this.storage.prune(pruneCutoff);
493
+ if (pruneResult.ok && pruneResult.value > 0) {
494
+ this.observability.debug("Pruned old records", { count: pruneResult.value });
495
+ }
461
496
  this.observability.info("Feedback cycle completed", {
462
497
  cycleId,
463
498
  patternsProcessed: cycleEvent.patternsProcessed,
@@ -470,6 +505,10 @@ var FeedbackLoop = class {
470
505
  }
471
506
  onCycle(callback) {
472
507
  this.cycleListeners.push(callback);
508
+ return () => {
509
+ const idx = this.cycleListeners.indexOf(callback);
510
+ if (idx >= 0) this.cycleListeners.splice(idx, 1);
511
+ };
473
512
  }
474
513
  };
475
514
 
@@ -480,6 +519,11 @@ var DEFAULT_CONFIG2 = {
480
519
  bulkhead: { maxConcurrentCalls: 10 },
481
520
  httpClient: { timeoutMs: 1e4, maxRetries: 3 }
482
521
  };
522
+ var DEFAULT_LIMITS = {
523
+ maxPatterns: 1e4,
524
+ maxAnomalies: 1e3,
525
+ maxCycles: 1e3
526
+ };
483
527
  function percentile(sorted, p) {
484
528
  if (sorted.length === 0) return 0;
485
529
  const index = Math.ceil(p / 100 * sorted.length) - 1;
@@ -490,9 +534,16 @@ var InMemoryStorage = class {
490
534
  anomalies = [];
491
535
  config = { ...DEFAULT_CONFIG2 };
492
536
  cycles = [];
537
+ limits;
538
+ constructor(limits) {
539
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
540
+ }
493
541
  savePattern(pattern) {
494
542
  try {
495
543
  this.patterns.push(pattern);
544
+ if (this.patterns.length > this.limits.maxPatterns) {
545
+ this.patterns.shift();
546
+ }
496
547
  return ok5(void 0);
497
548
  } catch (e) {
498
549
  return fail5(storageError("Failed to save pattern", e));
@@ -509,26 +560,30 @@ var InMemoryStorage = class {
509
560
  return fail5(storageError("Failed to get patterns", e));
510
561
  }
511
562
  }
512
- getAggregates(windowMinutes) {
563
+ getAggregates(windowMinutes, windowEnd) {
513
564
  try {
514
- const cutoff = new Date(Date.now() - windowMinutes * 6e4);
515
- const recent = this.patterns.filter((p) => p.timestamp >= cutoff);
565
+ const end = windowEnd ?? /* @__PURE__ */ new Date();
566
+ const cutoff = new Date(end.getTime() - windowMinutes * 6e4);
567
+ const recent = this.patterns.filter((p) => p.timestamp >= cutoff && p.timestamp <= end);
516
568
  const groups = /* @__PURE__ */ new Map();
517
569
  for (const p of recent) {
518
- const key = `${p.method}:${p.path}`;
519
- if (!groups.has(key)) groups.set(key, []);
520
- groups.get(key).push(p);
570
+ const key = `${p.method}\0${p.path}`;
571
+ let g = groups.get(key);
572
+ if (!g) {
573
+ g = { method: p.method, path: p.path, items: [] };
574
+ groups.set(key, g);
575
+ }
576
+ g.items.push(p);
521
577
  }
522
578
  const aggregates = [];
523
- for (const [key, items] of groups) {
524
- const [method, path] = key.split(":");
579
+ for (const { method, path, items } of groups.values()) {
525
580
  const durations = items.map((i) => i.durationMs).sort((a, b) => a - b);
526
581
  const errors = items.filter((i) => i.statusCode >= 500).length;
527
582
  aggregates.push({
528
583
  method,
529
584
  path,
530
585
  windowStart: cutoff,
531
- windowEnd: /* @__PURE__ */ new Date(),
586
+ windowEnd: end,
532
587
  count: items.length,
533
588
  avgDurationMs: durations.reduce((a, b) => a + b, 0) / durations.length,
534
589
  p50Ms: percentile(durations, 50),
@@ -546,6 +601,9 @@ var InMemoryStorage = class {
546
601
  saveAnomaly(report) {
547
602
  try {
548
603
  this.anomalies.push(report);
604
+ if (this.anomalies.length > this.limits.maxAnomalies) {
605
+ this.anomalies.shift();
606
+ }
549
607
  return ok5(void 0);
550
608
  } catch (e) {
551
609
  return fail5(storageError("Failed to save anomaly", e));
@@ -584,6 +642,9 @@ var InMemoryStorage = class {
584
642
  saveCycleEvent(event) {
585
643
  try {
586
644
  this.cycles.push(event);
645
+ if (this.cycles.length > this.limits.maxCycles) {
646
+ this.cycles.shift();
647
+ }
587
648
  return ok5(void 0);
588
649
  } catch (e) {
589
650
  return fail5(storageError("Failed to save cycle event", e));
@@ -706,10 +767,10 @@ var AutoLearningCore = class _AutoLearningCore {
706
767
  return this.feedbackLoop.runOnce();
707
768
  }
708
769
  onConfigChange(callback) {
709
- this.configTuner.onConfigChange(callback);
770
+ return this.configTuner.onConfigChange(callback);
710
771
  }
711
772
  onCycle(callback) {
712
- this.feedbackLoop.onCycle(callback);
773
+ return this.feedbackLoop.onCycle(callback);
713
774
  }
714
775
  };
715
776
 
@@ -738,6 +799,7 @@ var AutoLearningInterceptor = class {
738
799
  reflector;
739
800
  core;
740
801
  intercept(context, next) {
802
+ if (context.getType() !== "http") return next.handle();
741
803
  const options = this.reflector.get(
742
804
  AUTO_LEARN_METADATA,
743
805
  context.getHandler()
@@ -748,26 +810,42 @@ var AutoLearningInterceptor = class {
748
810
  const start = Date.now();
749
811
  const req = context.switchToHttp().getRequest();
750
812
  const { method, path } = this.extractRequestInfo(req);
813
+ const record = (statusCode) => {
814
+ const result = this.core.recordPattern({
815
+ method,
816
+ path,
817
+ statusCode,
818
+ durationMs: Date.now() - start,
819
+ timestamp: /* @__PURE__ */ new Date(),
820
+ metadata: this.buildMetadata(req, options)
821
+ });
822
+ if (!result.ok) {
823
+ this.core.observability.error("Failed to record pattern", { error: result.error });
824
+ }
825
+ };
751
826
  return next.handle().pipe(
752
- tap(() => {
753
- const duration = Date.now() - start;
754
- const status = context.switchToHttp().getResponse().statusCode;
755
- this.core.recordPattern({
756
- method,
757
- path,
758
- statusCode: status,
759
- durationMs: duration,
760
- timestamp: /* @__PURE__ */ new Date(),
761
- metadata: options.customMetadata ? options.customMetadata(req) : void 0
762
- });
827
+ tap({
828
+ next: () => record(context.switchToHttp().getResponse().statusCode),
829
+ error: (err) => {
830
+ const status = typeof err?.getStatus === "function" ? err.getStatus() : err?.status ?? 500;
831
+ record(status);
832
+ }
763
833
  })
764
834
  );
765
835
  }
766
836
  extractRequestInfo(req) {
767
837
  const method = req.method ?? "UNKNOWN";
768
- const path = req.route?.path ?? req.path ?? req.url ?? "/";
838
+ const raw = req.route?.path ?? req.path ?? req.url ?? "/";
839
+ const path = raw.split("?")[0];
769
840
  return { method, path };
770
841
  }
842
+ buildMetadata(req, options) {
843
+ const meta = {};
844
+ if (options.trackParams && req.params) meta.params = req.params;
845
+ if (options.trackBody && req.body) meta.body = req.body;
846
+ if (options.customMetadata) Object.assign(meta, options.customMetadata(req));
847
+ return Object.keys(meta).length > 0 ? meta : void 0;
848
+ }
771
849
  };
772
850
  AutoLearningInterceptor = __decorateClass([
773
851
  Injectable(),
@@ -787,11 +865,23 @@ var AutoLearningAdaptersService = class {
787
865
  moduleRef;
788
866
  cbRegistry = null;
789
867
  bhRegistry = null;
868
+ unsubConfigChange = null;
790
869
  async onModuleInit() {
791
870
  await this.resolveRegistries();
792
871
  if (this.cbRegistry || this.bhRegistry) {
793
- this.core.onConfigChange((config) => this.applyConfig(config));
872
+ this.unsubConfigChange = this.core.onConfigChange((config) => this.applyConfig(config));
873
+ }
874
+ }
875
+ onApplicationBootstrap() {
876
+ if (this.options.autoStart !== false) {
877
+ this.core.startFeedbackLoop(this.options.intervalMs);
878
+ }
879
+ }
880
+ onModuleDestroy() {
881
+ if (this.core.isFeedbackLoopRunning()) {
882
+ this.core.stopFeedbackLoop();
794
883
  }
884
+ this.unsubConfigChange?.();
795
885
  }
796
886
  async resolveRegistries() {
797
887
  if (this.options.adapters?.circuitBreaker) {