@backendkit-labs/auto-learning 0.1.3 → 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.
@@ -85,8 +85,8 @@ var PatternRegistry = class {
85
85
  });
86
86
  return (0, import_result.ok)(void 0);
87
87
  }
88
- getAggregates(windowMinutes) {
89
- const result = this.storage.getAggregates(windowMinutes);
88
+ getAggregates(windowMinutes, windowEnd) {
89
+ const result = this.storage.getAggregates(windowMinutes, windowEnd);
90
90
  if (!result.ok) {
91
91
  this.observability.error("Failed to get aggregates", { error: result.error });
92
92
  return (0, import_result.fail)(storageError("Failed to get aggregates", result.error));
@@ -120,12 +120,18 @@ var PatternRegistry = class {
120
120
  });
121
121
  }
122
122
  const uniqueEndpoints = new Set(all.map((p) => `${p.method}:${p.path}`));
123
- const timestamps = all.map((p) => p.timestamp.getTime());
123
+ let oldest = all[0].timestamp.getTime();
124
+ let newest = oldest;
125
+ for (const p of all) {
126
+ const t = p.timestamp.getTime();
127
+ if (t < oldest) oldest = t;
128
+ if (t > newest) newest = t;
129
+ }
124
130
  return (0, import_result.ok)({
125
131
  totalPatterns: all.length,
126
132
  uniqueEndpoints: uniqueEndpoints.size,
127
- oldestPattern: new Date(Math.min(...timestamps)),
128
- newestPattern: new Date(Math.max(...timestamps))
133
+ oldestPattern: new Date(oldest),
134
+ newestPattern: new Date(newest)
129
135
  });
130
136
  }
131
137
  };
@@ -165,23 +171,20 @@ var AnomalyDetector = class {
165
171
  });
166
172
  }
167
173
  }
168
- if (current.statusCode >= 500 && baseline.errorCount >= 3) {
169
- const currentErrorRate = 1;
170
- if (currentErrorRate > baseline.errorRate * 2 && currentErrorRate > this.config.errorRateThreshold) {
171
- reports.push({
172
- id: (0, import_uuid.v4)(),
173
- endpoint: current.path,
174
- method: current.method,
175
- severity: "high",
176
- metric: "error_rate",
177
- expectedValue: baseline.errorRate,
178
- actualValue: currentErrorRate,
179
- deviation: currentErrorRate / Math.max(baseline.errorRate, 1e-3),
180
- detectedAt: /* @__PURE__ */ new Date()
181
- });
182
- }
174
+ if (current.statusCode >= 500 && baseline.errorRate < this.config.errorRateThreshold) {
175
+ reports.push({
176
+ id: (0, import_uuid.v4)(),
177
+ endpoint: current.path,
178
+ method: current.method,
179
+ severity: "high",
180
+ metric: "error_rate",
181
+ expectedValue: baseline.errorRate,
182
+ actualValue: 1,
183
+ deviation: 1 / Math.max(baseline.errorRate, 1e-3),
184
+ detectedAt: /* @__PURE__ */ new Date()
185
+ });
183
186
  }
184
- return (0, import_result2.ok)(reports.length > 0 ? reports[0] : null);
187
+ return (0, import_result2.ok)(reports);
185
188
  } catch (e) {
186
189
  return (0, import_result2.fail)(
187
190
  anomalyDetectionFailed(
@@ -197,11 +200,13 @@ var AnomalyDetector = class {
197
200
  baselineMap.set(`${b.method}:${b.path}`, b);
198
201
  }
199
202
  const reports = [];
203
+ const seenUnknown = /* @__PURE__ */ new Set();
200
204
  for (const pattern of windowPatterns) {
201
205
  const key = `${pattern.method}:${pattern.path}`;
202
206
  const baseline = baselineMap.get(key);
203
207
  if (!baseline) {
204
- if (this.config.enableUnknownEndpointDetection) {
208
+ if (this.config.enableUnknownEndpointDetection && !seenUnknown.has(key)) {
209
+ seenUnknown.add(key);
205
210
  reports.push({
206
211
  id: (0, import_uuid.v4)(),
207
212
  endpoint: pattern.path,
@@ -217,8 +222,8 @@ var AnomalyDetector = class {
217
222
  continue;
218
223
  }
219
224
  const result = this.analyze(pattern, baseline);
220
- if (result.ok && result.value) {
221
- reports.push(result.value);
225
+ if (result.ok) {
226
+ reports.push(...result.value);
222
227
  }
223
228
  }
224
229
  return (0, import_result2.ok)(reports);
@@ -246,7 +251,8 @@ var DEFAULT_TUNER_CONFIG = {
246
251
  minTimeoutMs: 1e3,
247
252
  maxTimeoutMs: 3e4,
248
253
  smoothingFactor: 0.3,
249
- adjustmentStepMs: 500
254
+ adjustmentStepMs: 500,
255
+ cooldownMs: 6e4
250
256
  };
251
257
 
252
258
  // src/core/config-tuner/config-tuner.ts
@@ -281,48 +287,11 @@ var ConfigTuner = class {
281
287
  if (aggregates.length === 0) {
282
288
  return (0, import_result3.ok)(this.getCurrentConfig());
283
289
  }
284
- const newConfig = {
285
- circuitBreaker: { ...this.currentConfig.circuitBreaker },
286
- bulkhead: { ...this.currentConfig.bulkhead },
287
- httpClient: { ...this.currentConfig.httpClient }
288
- };
289
- const changedSections = /* @__PURE__ */ new Set();
290
- const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
291
- const targetTimeout = Math.min(
292
- Math.max(maxP95 * 2, this.config.minTimeoutMs),
293
- this.config.maxTimeoutMs
294
- );
295
- if (Math.abs(targetTimeout - newConfig.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
296
- newConfig.httpClient.timeoutMs = this.smoothValue(newConfig.httpClient.timeoutMs, targetTimeout);
297
- changedSections.add("httpClient");
298
- }
299
- const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
300
- if (avgErrorRate > 0.1) {
301
- newConfig.httpClient.maxRetries = Math.min(newConfig.httpClient.maxRetries + 1, 5);
302
- changedSections.add("httpClient");
303
- } else if (avgErrorRate < 0.01 && newConfig.httpClient.maxRetries > 1) {
304
- newConfig.httpClient.maxRetries = Math.max(newConfig.httpClient.maxRetries - 1, 0);
305
- changedSections.add("httpClient");
306
- }
307
- const criticalAnomalies = anomalies.filter(
308
- (a) => a.severity === "critical" || a.severity === "high"
309
- ).length;
310
- if (criticalAnomalies > 0) {
311
- newConfig.circuitBreaker.failureThreshold = Math.max(
312
- this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
313
- 10
314
- );
315
- changedSections.add("circuitBreaker");
316
- } else if (anomalies.length === 0) {
317
- newConfig.circuitBreaker.failureThreshold = Math.min(
318
- this.currentConfig.circuitBreaker.failureThreshold + 5,
319
- 80
320
- );
321
- changedSections.add("circuitBreaker");
322
- }
290
+ const newConfig = this.computeNext(aggregates, anomalies);
291
+ const changedSections = this.diffSections(this.currentConfig, newConfig);
323
292
  if (changedSections.size > 0) {
324
293
  const now = Date.now();
325
- if (now - this.lastChangeAt > 6e4) {
294
+ if (now - this.lastChangeAt > this.config.cooldownMs) {
326
295
  this.currentConfig = newConfig;
327
296
  this.lastChangeAt = now;
328
297
  const saveResult = this.storage.saveConfig(newConfig);
@@ -359,6 +328,57 @@ var ConfigTuner = class {
359
328
  }
360
329
  onConfigChange(callback) {
361
330
  this.listeners.push(callback);
331
+ return () => {
332
+ const idx = this.listeners.indexOf(callback);
333
+ if (idx >= 0) this.listeners.splice(idx, 1);
334
+ };
335
+ }
336
+ // Pure computation — derives the next config from aggregates and anomalies
337
+ // without mutating any state or applying cooldown checks.
338
+ computeNext(aggregates, anomalies) {
339
+ const next = {
340
+ circuitBreaker: { ...this.currentConfig.circuitBreaker },
341
+ bulkhead: { ...this.currentConfig.bulkhead },
342
+ httpClient: { ...this.currentConfig.httpClient }
343
+ };
344
+ const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
345
+ const targetTimeout = Math.min(
346
+ Math.max(maxP95 * 2, this.config.minTimeoutMs),
347
+ this.config.maxTimeoutMs
348
+ );
349
+ if (Math.abs(targetTimeout - next.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
350
+ next.httpClient.timeoutMs = this.smoothValue(next.httpClient.timeoutMs, targetTimeout);
351
+ }
352
+ const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
353
+ if (avgErrorRate > 0.1) {
354
+ next.httpClient.maxRetries = Math.min(next.httpClient.maxRetries + 1, 5);
355
+ } else if (avgErrorRate < 0.01 && next.httpClient.maxRetries > 1) {
356
+ next.httpClient.maxRetries = Math.max(next.httpClient.maxRetries - 1, 0);
357
+ }
358
+ const criticalAnomalies = anomalies.filter(
359
+ (a) => a.severity === "critical" || a.severity === "high"
360
+ ).length;
361
+ if (criticalAnomalies > 0) {
362
+ next.circuitBreaker.failureThreshold = Math.max(
363
+ this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
364
+ 10
365
+ );
366
+ } else if (anomalies.length === 0) {
367
+ next.circuitBreaker.failureThreshold = Math.min(
368
+ this.currentConfig.circuitBreaker.failureThreshold + 5,
369
+ 80
370
+ );
371
+ }
372
+ return next;
373
+ }
374
+ diffSections(prev, next) {
375
+ const changed = /* @__PURE__ */ new Set();
376
+ for (const key of Object.keys(next)) {
377
+ if (JSON.stringify(next[key]) !== JSON.stringify(prev[key])) {
378
+ changed.add(key);
379
+ }
380
+ }
381
+ return changed;
362
382
  }
363
383
  smoothValue(current, target) {
364
384
  return current + (target - current) * this.config.smoothingFactor;
@@ -370,7 +390,7 @@ var DEFAULT_LOOP_CONFIG = {
370
390
  defaultIntervalMs: 6e4,
371
391
  windowSizeMinutes: 5,
372
392
  minSamplesBeforeTuning: 10,
373
- cooldownBetweenChangesMs: 12e4
393
+ pruneTtlHours: 24
374
394
  };
375
395
 
376
396
  // src/core/feedback-loop/feedback-loop.ts
@@ -391,6 +411,7 @@ var FeedbackLoop = class {
391
411
  storage;
392
412
  observability;
393
413
  timerId = null;
414
+ isProcessing = false;
394
415
  config;
395
416
  cycleListeners = [];
396
417
  start(intervalMs) {
@@ -400,14 +421,20 @@ var FeedbackLoop = class {
400
421
  }
401
422
  const interval = intervalMs ?? this.config.defaultIntervalMs;
402
423
  this.observability.info("Feedback loop started", { intervalMs: interval });
403
- this.timerId = setInterval(() => {
404
- this.runOnce().then((result) => {
424
+ this.timerId = setInterval(async () => {
425
+ if (this.isProcessing) {
426
+ this.observability.warn("Skipping cycle: previous cycle still running");
427
+ return;
428
+ }
429
+ this.isProcessing = true;
430
+ try {
431
+ const result = await this.runOnce();
405
432
  if (!result.ok) {
406
- this.observability.error("Feedback loop cycle failed", {
407
- error: result.error
408
- });
433
+ this.observability.error("Feedback loop cycle failed", { error: result.error });
409
434
  }
410
- });
435
+ } finally {
436
+ this.isProcessing = false;
437
+ }
411
438
  }, interval);
412
439
  }
413
440
  stop() {
@@ -425,11 +452,10 @@ var FeedbackLoop = class {
425
452
  async runOnce() {
426
453
  const cycleId = (0, import_uuid2.v4)();
427
454
  const startTime = Date.now();
455
+ const windowEnd = /* @__PURE__ */ new Date();
456
+ const windowStart = new Date(windowEnd.getTime() - this.config.windowSizeMinutes * 6e4);
428
457
  this.observability.debug("Feedback cycle started", { cycleId });
429
- const patternsResult = this.storage.getPatterns(
430
- new Date(Date.now() - this.config.windowSizeMinutes * 6e4),
431
- /* @__PURE__ */ new Date()
432
- );
458
+ const patternsResult = this.storage.getPatterns(windowStart, windowEnd);
433
459
  if (!patternsResult.ok) {
434
460
  return (0, import_result4.fail)(storageError("Failed to collect patterns", patternsResult.error));
435
461
  }
@@ -450,7 +476,8 @@ var FeedbackLoop = class {
450
476
  return (0, import_result4.ok)(skippedEvent);
451
477
  }
452
478
  const aggregatesResult = this.patternRegistry.getAggregates(
453
- this.config.windowSizeMinutes
479
+ this.config.windowSizeMinutes,
480
+ windowEnd
454
481
  );
455
482
  if (!aggregatesResult.ok) {
456
483
  return (0, import_result4.fail)(aggregatesResult.error);
@@ -462,7 +489,10 @@ var FeedbackLoop = class {
462
489
  }
463
490
  const anomalies = anomaliesResult.value;
464
491
  for (const anomaly of anomalies) {
465
- this.storage.saveAnomaly(anomaly);
492
+ const saveAnomalyResult = this.storage.saveAnomaly(anomaly);
493
+ if (!saveAnomalyResult.ok) {
494
+ this.observability.warn("Failed to persist anomaly", { error: saveAnomalyResult.error });
495
+ }
466
496
  }
467
497
  if (anomalies.length > 0) {
468
498
  this.observability.warn("Anomalies detected", {
@@ -471,12 +501,12 @@ var FeedbackLoop = class {
471
501
  });
472
502
  this.observability.incrementMetric("anomalies.detected", anomalies.length);
473
503
  }
504
+ const previousConfig = this.configTuner.getCurrentConfig();
474
505
  const tuneResult = this.configTuner.tune(aggregates, anomalies);
475
506
  if (!tuneResult.ok) {
476
507
  return (0, import_result4.fail)(tuneResult.error);
477
508
  }
478
509
  const newConfig = tuneResult.value;
479
- const previousConfig = this.configTuner.getCurrentConfig();
480
510
  const configChanges = {};
481
511
  for (const key of Object.keys(newConfig)) {
482
512
  if (JSON.stringify(newConfig[key]) !== JSON.stringify(previousConfig[key])) {
@@ -499,6 +529,11 @@ var FeedbackLoop = class {
499
529
  for (const listener of this.cycleListeners) {
500
530
  listener(cycleEvent);
501
531
  }
532
+ const pruneCutoff = new Date(Date.now() - this.config.pruneTtlHours * 36e5);
533
+ const pruneResult = this.storage.prune(pruneCutoff);
534
+ if (pruneResult.ok && pruneResult.value > 0) {
535
+ this.observability.debug("Pruned old records", { count: pruneResult.value });
536
+ }
502
537
  this.observability.info("Feedback cycle completed", {
503
538
  cycleId,
504
539
  patternsProcessed: cycleEvent.patternsProcessed,
@@ -511,6 +546,10 @@ var FeedbackLoop = class {
511
546
  }
512
547
  onCycle(callback) {
513
548
  this.cycleListeners.push(callback);
549
+ return () => {
550
+ const idx = this.cycleListeners.indexOf(callback);
551
+ if (idx >= 0) this.cycleListeners.splice(idx, 1);
552
+ };
514
553
  }
515
554
  };
516
555
 
@@ -521,6 +560,11 @@ var DEFAULT_CONFIG2 = {
521
560
  bulkhead: { maxConcurrentCalls: 10 },
522
561
  httpClient: { timeoutMs: 1e4, maxRetries: 3 }
523
562
  };
563
+ var DEFAULT_LIMITS = {
564
+ maxPatterns: 1e4,
565
+ maxAnomalies: 1e3,
566
+ maxCycles: 1e3
567
+ };
524
568
  function percentile(sorted, p) {
525
569
  if (sorted.length === 0) return 0;
526
570
  const index = Math.ceil(p / 100 * sorted.length) - 1;
@@ -531,9 +575,16 @@ var InMemoryStorage = class {
531
575
  anomalies = [];
532
576
  config = { ...DEFAULT_CONFIG2 };
533
577
  cycles = [];
578
+ limits;
579
+ constructor(limits) {
580
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
581
+ }
534
582
  savePattern(pattern) {
535
583
  try {
536
584
  this.patterns.push(pattern);
585
+ if (this.patterns.length > this.limits.maxPatterns) {
586
+ this.patterns.shift();
587
+ }
537
588
  return (0, import_result5.ok)(void 0);
538
589
  } catch (e) {
539
590
  return (0, import_result5.fail)(storageError("Failed to save pattern", e));
@@ -550,26 +601,30 @@ var InMemoryStorage = class {
550
601
  return (0, import_result5.fail)(storageError("Failed to get patterns", e));
551
602
  }
552
603
  }
553
- getAggregates(windowMinutes) {
604
+ getAggregates(windowMinutes, windowEnd) {
554
605
  try {
555
- const cutoff = new Date(Date.now() - windowMinutes * 6e4);
556
- const recent = this.patterns.filter((p) => p.timestamp >= cutoff);
606
+ const end = windowEnd ?? /* @__PURE__ */ new Date();
607
+ const cutoff = new Date(end.getTime() - windowMinutes * 6e4);
608
+ const recent = this.patterns.filter((p) => p.timestamp >= cutoff && p.timestamp <= end);
557
609
  const groups = /* @__PURE__ */ new Map();
558
610
  for (const p of recent) {
559
- const key = `${p.method}:${p.path}`;
560
- if (!groups.has(key)) groups.set(key, []);
561
- groups.get(key).push(p);
611
+ const key = `${p.method}\0${p.path}`;
612
+ let g = groups.get(key);
613
+ if (!g) {
614
+ g = { method: p.method, path: p.path, items: [] };
615
+ groups.set(key, g);
616
+ }
617
+ g.items.push(p);
562
618
  }
563
619
  const aggregates = [];
564
- for (const [key, items] of groups) {
565
- const [method, path] = key.split(":");
620
+ for (const { method, path, items } of groups.values()) {
566
621
  const durations = items.map((i) => i.durationMs).sort((a, b) => a - b);
567
622
  const errors = items.filter((i) => i.statusCode >= 500).length;
568
623
  aggregates.push({
569
624
  method,
570
625
  path,
571
626
  windowStart: cutoff,
572
- windowEnd: /* @__PURE__ */ new Date(),
627
+ windowEnd: end,
573
628
  count: items.length,
574
629
  avgDurationMs: durations.reduce((a, b) => a + b, 0) / durations.length,
575
630
  p50Ms: percentile(durations, 50),
@@ -587,6 +642,9 @@ var InMemoryStorage = class {
587
642
  saveAnomaly(report) {
588
643
  try {
589
644
  this.anomalies.push(report);
645
+ if (this.anomalies.length > this.limits.maxAnomalies) {
646
+ this.anomalies.shift();
647
+ }
590
648
  return (0, import_result5.ok)(void 0);
591
649
  } catch (e) {
592
650
  return (0, import_result5.fail)(storageError("Failed to save anomaly", e));
@@ -625,6 +683,9 @@ var InMemoryStorage = class {
625
683
  saveCycleEvent(event) {
626
684
  try {
627
685
  this.cycles.push(event);
686
+ if (this.cycles.length > this.limits.maxCycles) {
687
+ this.cycles.shift();
688
+ }
628
689
  return (0, import_result5.ok)(void 0);
629
690
  } catch (e) {
630
691
  return (0, import_result5.fail)(storageError("Failed to save cycle event", e));
@@ -713,10 +774,10 @@ var AutoLearningCore = class _AutoLearningCore {
713
774
  return this.feedbackLoop.runOnce();
714
775
  }
715
776
  onConfigChange(callback) {
716
- this.configTuner.onConfigChange(callback);
777
+ return this.configTuner.onConfigChange(callback);
717
778
  }
718
779
  onCycle(callback) {
719
- this.feedbackLoop.onCycle(callback);
780
+ return this.feedbackLoop.onCycle(callback);
720
781
  }
721
782
  };
722
783
 
@@ -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
- (0, import_rxjs.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
+ (0, import_rxjs.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
  (0, import_common.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) {