@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.
@@ -48,8 +48,8 @@ var PatternRegistry = class {
48
48
  });
49
49
  return ok(void 0);
50
50
  }
51
- getAggregates(windowMinutes) {
52
- const result = this.storage.getAggregates(windowMinutes);
51
+ getAggregates(windowMinutes, windowEnd) {
52
+ const result = this.storage.getAggregates(windowMinutes, windowEnd);
53
53
  if (!result.ok) {
54
54
  this.observability.error("Failed to get aggregates", { error: result.error });
55
55
  return fail(storageError("Failed to get aggregates", result.error));
@@ -83,12 +83,18 @@ var PatternRegistry = class {
83
83
  });
84
84
  }
85
85
  const uniqueEndpoints = new Set(all.map((p) => `${p.method}:${p.path}`));
86
- const timestamps = all.map((p) => p.timestamp.getTime());
86
+ let oldest = all[0].timestamp.getTime();
87
+ let newest = oldest;
88
+ for (const p of all) {
89
+ const t = p.timestamp.getTime();
90
+ if (t < oldest) oldest = t;
91
+ if (t > newest) newest = t;
92
+ }
87
93
  return ok({
88
94
  totalPatterns: all.length,
89
95
  uniqueEndpoints: uniqueEndpoints.size,
90
- oldestPattern: new Date(Math.min(...timestamps)),
91
- newestPattern: new Date(Math.max(...timestamps))
96
+ oldestPattern: new Date(oldest),
97
+ newestPattern: new Date(newest)
92
98
  });
93
99
  }
94
100
  };
@@ -128,23 +134,20 @@ var AnomalyDetector = class {
128
134
  });
129
135
  }
130
136
  }
131
- if (current.statusCode >= 500 && baseline.errorCount >= 3) {
132
- const currentErrorRate = 1;
133
- if (currentErrorRate > baseline.errorRate * 2 && currentErrorRate > 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: currentErrorRate,
142
- deviation: currentErrorRate / Math.max(baseline.errorRate, 1e-3),
143
- detectedAt: /* @__PURE__ */ new Date()
144
- });
145
- }
137
+ if (current.statusCode >= 500 && baseline.errorRate < this.config.errorRateThreshold) {
138
+ reports.push({
139
+ id: uuid(),
140
+ endpoint: current.path,
141
+ method: current.method,
142
+ severity: "high",
143
+ metric: "error_rate",
144
+ expectedValue: baseline.errorRate,
145
+ actualValue: 1,
146
+ deviation: 1 / Math.max(baseline.errorRate, 1e-3),
147
+ detectedAt: /* @__PURE__ */ new Date()
148
+ });
146
149
  }
147
- return ok2(reports.length > 0 ? reports[0] : null);
150
+ return ok2(reports);
148
151
  } catch (e) {
149
152
  return fail2(
150
153
  anomalyDetectionFailed(
@@ -160,11 +163,13 @@ var AnomalyDetector = class {
160
163
  baselineMap.set(`${b.method}:${b.path}`, b);
161
164
  }
162
165
  const reports = [];
166
+ const seenUnknown = /* @__PURE__ */ new Set();
163
167
  for (const pattern of windowPatterns) {
164
168
  const key = `${pattern.method}:${pattern.path}`;
165
169
  const baseline = baselineMap.get(key);
166
170
  if (!baseline) {
167
- if (this.config.enableUnknownEndpointDetection) {
171
+ if (this.config.enableUnknownEndpointDetection && !seenUnknown.has(key)) {
172
+ seenUnknown.add(key);
168
173
  reports.push({
169
174
  id: uuid(),
170
175
  endpoint: pattern.path,
@@ -180,8 +185,8 @@ var AnomalyDetector = class {
180
185
  continue;
181
186
  }
182
187
  const result = this.analyze(pattern, baseline);
183
- if (result.ok && result.value) {
184
- reports.push(result.value);
188
+ if (result.ok) {
189
+ reports.push(...result.value);
185
190
  }
186
191
  }
187
192
  return ok2(reports);
@@ -209,7 +214,8 @@ var DEFAULT_TUNER_CONFIG = {
209
214
  minTimeoutMs: 1e3,
210
215
  maxTimeoutMs: 3e4,
211
216
  smoothingFactor: 0.3,
212
- adjustmentStepMs: 500
217
+ adjustmentStepMs: 500,
218
+ cooldownMs: 6e4
213
219
  };
214
220
 
215
221
  // src/core/config-tuner/config-tuner.ts
@@ -244,48 +250,11 @@ var ConfigTuner = class {
244
250
  if (aggregates.length === 0) {
245
251
  return ok3(this.getCurrentConfig());
246
252
  }
247
- const newConfig = {
248
- circuitBreaker: { ...this.currentConfig.circuitBreaker },
249
- bulkhead: { ...this.currentConfig.bulkhead },
250
- httpClient: { ...this.currentConfig.httpClient }
251
- };
252
- const changedSections = /* @__PURE__ */ new Set();
253
- const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
254
- const targetTimeout = Math.min(
255
- Math.max(maxP95 * 2, this.config.minTimeoutMs),
256
- this.config.maxTimeoutMs
257
- );
258
- if (Math.abs(targetTimeout - newConfig.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
259
- newConfig.httpClient.timeoutMs = this.smoothValue(newConfig.httpClient.timeoutMs, targetTimeout);
260
- changedSections.add("httpClient");
261
- }
262
- const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
263
- if (avgErrorRate > 0.1) {
264
- newConfig.httpClient.maxRetries = Math.min(newConfig.httpClient.maxRetries + 1, 5);
265
- changedSections.add("httpClient");
266
- } else if (avgErrorRate < 0.01 && newConfig.httpClient.maxRetries > 1) {
267
- newConfig.httpClient.maxRetries = Math.max(newConfig.httpClient.maxRetries - 1, 0);
268
- changedSections.add("httpClient");
269
- }
270
- const criticalAnomalies = anomalies.filter(
271
- (a) => a.severity === "critical" || a.severity === "high"
272
- ).length;
273
- if (criticalAnomalies > 0) {
274
- newConfig.circuitBreaker.failureThreshold = Math.max(
275
- this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
276
- 10
277
- );
278
- changedSections.add("circuitBreaker");
279
- } else if (anomalies.length === 0) {
280
- newConfig.circuitBreaker.failureThreshold = Math.min(
281
- this.currentConfig.circuitBreaker.failureThreshold + 5,
282
- 80
283
- );
284
- changedSections.add("circuitBreaker");
285
- }
253
+ const newConfig = this.computeNext(aggregates, anomalies);
254
+ const changedSections = this.diffSections(this.currentConfig, newConfig);
286
255
  if (changedSections.size > 0) {
287
256
  const now = Date.now();
288
- if (now - this.lastChangeAt > 6e4) {
257
+ if (now - this.lastChangeAt > this.config.cooldownMs) {
289
258
  this.currentConfig = newConfig;
290
259
  this.lastChangeAt = now;
291
260
  const saveResult = this.storage.saveConfig(newConfig);
@@ -322,6 +291,57 @@ var ConfigTuner = class {
322
291
  }
323
292
  onConfigChange(callback) {
324
293
  this.listeners.push(callback);
294
+ return () => {
295
+ const idx = this.listeners.indexOf(callback);
296
+ if (idx >= 0) this.listeners.splice(idx, 1);
297
+ };
298
+ }
299
+ // Pure computation — derives the next config from aggregates and anomalies
300
+ // without mutating any state or applying cooldown checks.
301
+ computeNext(aggregates, anomalies) {
302
+ const next = {
303
+ circuitBreaker: { ...this.currentConfig.circuitBreaker },
304
+ bulkhead: { ...this.currentConfig.bulkhead },
305
+ httpClient: { ...this.currentConfig.httpClient }
306
+ };
307
+ const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
308
+ const targetTimeout = Math.min(
309
+ Math.max(maxP95 * 2, this.config.minTimeoutMs),
310
+ this.config.maxTimeoutMs
311
+ );
312
+ if (Math.abs(targetTimeout - next.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
313
+ next.httpClient.timeoutMs = this.smoothValue(next.httpClient.timeoutMs, targetTimeout);
314
+ }
315
+ const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
316
+ if (avgErrorRate > 0.1) {
317
+ next.httpClient.maxRetries = Math.min(next.httpClient.maxRetries + 1, 5);
318
+ } else if (avgErrorRate < 0.01 && next.httpClient.maxRetries > 1) {
319
+ next.httpClient.maxRetries = Math.max(next.httpClient.maxRetries - 1, 0);
320
+ }
321
+ const criticalAnomalies = anomalies.filter(
322
+ (a) => a.severity === "critical" || a.severity === "high"
323
+ ).length;
324
+ if (criticalAnomalies > 0) {
325
+ next.circuitBreaker.failureThreshold = Math.max(
326
+ this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
327
+ 10
328
+ );
329
+ } else if (anomalies.length === 0) {
330
+ next.circuitBreaker.failureThreshold = Math.min(
331
+ this.currentConfig.circuitBreaker.failureThreshold + 5,
332
+ 80
333
+ );
334
+ }
335
+ return next;
336
+ }
337
+ diffSections(prev, next) {
338
+ const changed = /* @__PURE__ */ new Set();
339
+ for (const key of Object.keys(next)) {
340
+ if (JSON.stringify(next[key]) !== JSON.stringify(prev[key])) {
341
+ changed.add(key);
342
+ }
343
+ }
344
+ return changed;
325
345
  }
326
346
  smoothValue(current, target) {
327
347
  return current + (target - current) * this.config.smoothingFactor;
@@ -333,7 +353,7 @@ var DEFAULT_LOOP_CONFIG = {
333
353
  defaultIntervalMs: 6e4,
334
354
  windowSizeMinutes: 5,
335
355
  minSamplesBeforeTuning: 10,
336
- cooldownBetweenChangesMs: 12e4
356
+ pruneTtlHours: 24
337
357
  };
338
358
 
339
359
  // src/core/feedback-loop/feedback-loop.ts
@@ -354,6 +374,7 @@ var FeedbackLoop = class {
354
374
  storage;
355
375
  observability;
356
376
  timerId = null;
377
+ isProcessing = false;
357
378
  config;
358
379
  cycleListeners = [];
359
380
  start(intervalMs) {
@@ -363,14 +384,20 @@ var FeedbackLoop = class {
363
384
  }
364
385
  const interval = intervalMs ?? this.config.defaultIntervalMs;
365
386
  this.observability.info("Feedback loop started", { intervalMs: interval });
366
- this.timerId = setInterval(() => {
367
- this.runOnce().then((result) => {
387
+ this.timerId = setInterval(async () => {
388
+ if (this.isProcessing) {
389
+ this.observability.warn("Skipping cycle: previous cycle still running");
390
+ return;
391
+ }
392
+ this.isProcessing = true;
393
+ try {
394
+ const result = await this.runOnce();
368
395
  if (!result.ok) {
369
- this.observability.error("Feedback loop cycle failed", {
370
- error: result.error
371
- });
396
+ this.observability.error("Feedback loop cycle failed", { error: result.error });
372
397
  }
373
- });
398
+ } finally {
399
+ this.isProcessing = false;
400
+ }
374
401
  }, interval);
375
402
  }
376
403
  stop() {
@@ -388,11 +415,10 @@ var FeedbackLoop = class {
388
415
  async runOnce() {
389
416
  const cycleId = uuid2();
390
417
  const startTime = Date.now();
418
+ const windowEnd = /* @__PURE__ */ new Date();
419
+ const windowStart = new Date(windowEnd.getTime() - this.config.windowSizeMinutes * 6e4);
391
420
  this.observability.debug("Feedback cycle started", { cycleId });
392
- const patternsResult = this.storage.getPatterns(
393
- new Date(Date.now() - this.config.windowSizeMinutes * 6e4),
394
- /* @__PURE__ */ new Date()
395
- );
421
+ const patternsResult = this.storage.getPatterns(windowStart, windowEnd);
396
422
  if (!patternsResult.ok) {
397
423
  return fail4(storageError("Failed to collect patterns", patternsResult.error));
398
424
  }
@@ -413,7 +439,8 @@ var FeedbackLoop = class {
413
439
  return ok4(skippedEvent);
414
440
  }
415
441
  const aggregatesResult = this.patternRegistry.getAggregates(
416
- this.config.windowSizeMinutes
442
+ this.config.windowSizeMinutes,
443
+ windowEnd
417
444
  );
418
445
  if (!aggregatesResult.ok) {
419
446
  return fail4(aggregatesResult.error);
@@ -425,7 +452,10 @@ var FeedbackLoop = class {
425
452
  }
426
453
  const anomalies = anomaliesResult.value;
427
454
  for (const anomaly of anomalies) {
428
- this.storage.saveAnomaly(anomaly);
455
+ const saveAnomalyResult = this.storage.saveAnomaly(anomaly);
456
+ if (!saveAnomalyResult.ok) {
457
+ this.observability.warn("Failed to persist anomaly", { error: saveAnomalyResult.error });
458
+ }
429
459
  }
430
460
  if (anomalies.length > 0) {
431
461
  this.observability.warn("Anomalies detected", {
@@ -434,12 +464,12 @@ var FeedbackLoop = class {
434
464
  });
435
465
  this.observability.incrementMetric("anomalies.detected", anomalies.length);
436
466
  }
467
+ const previousConfig = this.configTuner.getCurrentConfig();
437
468
  const tuneResult = this.configTuner.tune(aggregates, anomalies);
438
469
  if (!tuneResult.ok) {
439
470
  return fail4(tuneResult.error);
440
471
  }
441
472
  const newConfig = tuneResult.value;
442
- const previousConfig = this.configTuner.getCurrentConfig();
443
473
  const configChanges = {};
444
474
  for (const key of Object.keys(newConfig)) {
445
475
  if (JSON.stringify(newConfig[key]) !== JSON.stringify(previousConfig[key])) {
@@ -462,6 +492,11 @@ var FeedbackLoop = class {
462
492
  for (const listener of this.cycleListeners) {
463
493
  listener(cycleEvent);
464
494
  }
495
+ const pruneCutoff = new Date(Date.now() - this.config.pruneTtlHours * 36e5);
496
+ const pruneResult = this.storage.prune(pruneCutoff);
497
+ if (pruneResult.ok && pruneResult.value > 0) {
498
+ this.observability.debug("Pruned old records", { count: pruneResult.value });
499
+ }
465
500
  this.observability.info("Feedback cycle completed", {
466
501
  cycleId,
467
502
  patternsProcessed: cycleEvent.patternsProcessed,
@@ -474,6 +509,10 @@ var FeedbackLoop = class {
474
509
  }
475
510
  onCycle(callback) {
476
511
  this.cycleListeners.push(callback);
512
+ return () => {
513
+ const idx = this.cycleListeners.indexOf(callback);
514
+ if (idx >= 0) this.cycleListeners.splice(idx, 1);
515
+ };
477
516
  }
478
517
  };
479
518
 
@@ -484,6 +523,11 @@ var DEFAULT_CONFIG2 = {
484
523
  bulkhead: { maxConcurrentCalls: 10 },
485
524
  httpClient: { timeoutMs: 1e4, maxRetries: 3 }
486
525
  };
526
+ var DEFAULT_LIMITS = {
527
+ maxPatterns: 1e4,
528
+ maxAnomalies: 1e3,
529
+ maxCycles: 1e3
530
+ };
487
531
  function percentile(sorted, p) {
488
532
  if (sorted.length === 0) return 0;
489
533
  const index = Math.ceil(p / 100 * sorted.length) - 1;
@@ -494,9 +538,16 @@ var InMemoryStorage = class {
494
538
  anomalies = [];
495
539
  config = { ...DEFAULT_CONFIG2 };
496
540
  cycles = [];
541
+ limits;
542
+ constructor(limits) {
543
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
544
+ }
497
545
  savePattern(pattern) {
498
546
  try {
499
547
  this.patterns.push(pattern);
548
+ if (this.patterns.length > this.limits.maxPatterns) {
549
+ this.patterns.shift();
550
+ }
500
551
  return ok5(void 0);
501
552
  } catch (e) {
502
553
  return fail5(storageError("Failed to save pattern", e));
@@ -513,26 +564,30 @@ var InMemoryStorage = class {
513
564
  return fail5(storageError("Failed to get patterns", e));
514
565
  }
515
566
  }
516
- getAggregates(windowMinutes) {
567
+ getAggregates(windowMinutes, windowEnd) {
517
568
  try {
518
- const cutoff = new Date(Date.now() - windowMinutes * 6e4);
519
- const recent = this.patterns.filter((p) => p.timestamp >= cutoff);
569
+ const end = windowEnd ?? /* @__PURE__ */ new Date();
570
+ const cutoff = new Date(end.getTime() - windowMinutes * 6e4);
571
+ const recent = this.patterns.filter((p) => p.timestamp >= cutoff && p.timestamp <= end);
520
572
  const groups = /* @__PURE__ */ new Map();
521
573
  for (const p of recent) {
522
- const key = `${p.method}:${p.path}`;
523
- if (!groups.has(key)) groups.set(key, []);
524
- groups.get(key).push(p);
574
+ const key = `${p.method}\0${p.path}`;
575
+ let g = groups.get(key);
576
+ if (!g) {
577
+ g = { method: p.method, path: p.path, items: [] };
578
+ groups.set(key, g);
579
+ }
580
+ g.items.push(p);
525
581
  }
526
582
  const aggregates = [];
527
- for (const [key, items] of groups) {
528
- const [method, path] = key.split(":");
583
+ for (const { method, path, items } of groups.values()) {
529
584
  const durations = items.map((i) => i.durationMs).sort((a, b) => a - b);
530
585
  const errors = items.filter((i) => i.statusCode >= 500).length;
531
586
  aggregates.push({
532
587
  method,
533
588
  path,
534
589
  windowStart: cutoff,
535
- windowEnd: /* @__PURE__ */ new Date(),
590
+ windowEnd: end,
536
591
  count: items.length,
537
592
  avgDurationMs: durations.reduce((a, b) => a + b, 0) / durations.length,
538
593
  p50Ms: percentile(durations, 50),
@@ -550,6 +605,9 @@ var InMemoryStorage = class {
550
605
  saveAnomaly(report) {
551
606
  try {
552
607
  this.anomalies.push(report);
608
+ if (this.anomalies.length > this.limits.maxAnomalies) {
609
+ this.anomalies.shift();
610
+ }
553
611
  return ok5(void 0);
554
612
  } catch (e) {
555
613
  return fail5(storageError("Failed to save anomaly", e));
@@ -588,6 +646,9 @@ var InMemoryStorage = class {
588
646
  saveCycleEvent(event) {
589
647
  try {
590
648
  this.cycles.push(event);
649
+ if (this.cycles.length > this.limits.maxCycles) {
650
+ this.cycles.shift();
651
+ }
591
652
  return ok5(void 0);
592
653
  } catch (e) {
593
654
  return fail5(storageError("Failed to save cycle event", e));
@@ -676,10 +737,10 @@ var AutoLearningCore = class _AutoLearningCore {
676
737
  return this.feedbackLoop.runOnce();
677
738
  }
678
739
  onConfigChange(callback) {
679
- this.configTuner.onConfigChange(callback);
740
+ return this.configTuner.onConfigChange(callback);
680
741
  }
681
742
  onCycle(callback) {
682
- this.feedbackLoop.onCycle(callback);
743
+ return this.feedbackLoop.onCycle(callback);
683
744
  }
684
745
  };
685
746
 
@@ -704,6 +765,7 @@ var AutoLearningInterceptor = class {
704
765
  reflector;
705
766
  core;
706
767
  intercept(context, next) {
768
+ if (context.getType() !== "http") return next.handle();
707
769
  const options = this.reflector.get(
708
770
  AUTO_LEARN_METADATA,
709
771
  context.getHandler()
@@ -714,26 +776,42 @@ var AutoLearningInterceptor = class {
714
776
  const start = Date.now();
715
777
  const req = context.switchToHttp().getRequest();
716
778
  const { method, path } = this.extractRequestInfo(req);
779
+ const record = (statusCode) => {
780
+ const result = this.core.recordPattern({
781
+ method,
782
+ path,
783
+ statusCode,
784
+ durationMs: Date.now() - start,
785
+ timestamp: /* @__PURE__ */ new Date(),
786
+ metadata: this.buildMetadata(req, options)
787
+ });
788
+ if (!result.ok) {
789
+ this.core.observability.error("Failed to record pattern", { error: result.error });
790
+ }
791
+ };
717
792
  return next.handle().pipe(
718
- tap(() => {
719
- const duration = Date.now() - start;
720
- const status = context.switchToHttp().getResponse().statusCode;
721
- this.core.recordPattern({
722
- method,
723
- path,
724
- statusCode: status,
725
- durationMs: duration,
726
- timestamp: /* @__PURE__ */ new Date(),
727
- metadata: options.customMetadata ? options.customMetadata(req) : void 0
728
- });
793
+ tap({
794
+ next: () => record(context.switchToHttp().getResponse().statusCode),
795
+ error: (err) => {
796
+ const status = typeof err?.getStatus === "function" ? err.getStatus() : err?.status ?? 500;
797
+ record(status);
798
+ }
729
799
  })
730
800
  );
731
801
  }
732
802
  extractRequestInfo(req) {
733
803
  const method = req.method ?? "UNKNOWN";
734
- const path = req.route?.path ?? req.path ?? req.url ?? "/";
804
+ const raw = req.route?.path ?? req.path ?? req.url ?? "/";
805
+ const path = raw.split("?")[0];
735
806
  return { method, path };
736
807
  }
808
+ buildMetadata(req, options) {
809
+ const meta = {};
810
+ if (options.trackParams && req.params) meta.params = req.params;
811
+ if (options.trackBody && req.body) meta.body = req.body;
812
+ if (options.customMetadata) Object.assign(meta, options.customMetadata(req));
813
+ return Object.keys(meta).length > 0 ? meta : void 0;
814
+ }
737
815
  };
738
816
  AutoLearningInterceptor = __decorateClass([
739
817
  Injectable(),
@@ -753,11 +831,23 @@ var AutoLearningAdaptersService = class {
753
831
  moduleRef;
754
832
  cbRegistry = null;
755
833
  bhRegistry = null;
834
+ unsubConfigChange = null;
756
835
  async onModuleInit() {
757
836
  await this.resolveRegistries();
758
837
  if (this.cbRegistry || this.bhRegistry) {
759
- this.core.onConfigChange((config) => this.applyConfig(config));
838
+ this.unsubConfigChange = this.core.onConfigChange((config) => this.applyConfig(config));
839
+ }
840
+ }
841
+ onApplicationBootstrap() {
842
+ if (this.options.autoStart !== false) {
843
+ this.core.startFeedbackLoop(this.options.intervalMs);
844
+ }
845
+ }
846
+ onModuleDestroy() {
847
+ if (this.core.isFeedbackLoopRunning()) {
848
+ this.core.stopFeedbackLoop();
760
849
  }
850
+ this.unsubConfigChange?.();
761
851
  }
762
852
  async resolveRegistries() {
763
853
  if (this.options.adapters?.circuitBreaker) {