@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.
package/dist/index.cjs CHANGED
@@ -92,8 +92,8 @@ var PatternRegistry = class {
92
92
  });
93
93
  return (0, import_result.ok)(void 0);
94
94
  }
95
- getAggregates(windowMinutes) {
96
- const result = this.storage.getAggregates(windowMinutes);
95
+ getAggregates(windowMinutes, windowEnd) {
96
+ const result = this.storage.getAggregates(windowMinutes, windowEnd);
97
97
  if (!result.ok) {
98
98
  this.observability.error("Failed to get aggregates", { error: result.error });
99
99
  return (0, import_result.fail)(storageError("Failed to get aggregates", result.error));
@@ -127,12 +127,18 @@ var PatternRegistry = class {
127
127
  });
128
128
  }
129
129
  const uniqueEndpoints = new Set(all.map((p) => `${p.method}:${p.path}`));
130
- const timestamps = all.map((p) => p.timestamp.getTime());
130
+ let oldest = all[0].timestamp.getTime();
131
+ let newest = oldest;
132
+ for (const p of all) {
133
+ const t = p.timestamp.getTime();
134
+ if (t < oldest) oldest = t;
135
+ if (t > newest) newest = t;
136
+ }
131
137
  return (0, import_result.ok)({
132
138
  totalPatterns: all.length,
133
139
  uniqueEndpoints: uniqueEndpoints.size,
134
- oldestPattern: new Date(Math.min(...timestamps)),
135
- newestPattern: new Date(Math.max(...timestamps))
140
+ oldestPattern: new Date(oldest),
141
+ newestPattern: new Date(newest)
136
142
  });
137
143
  }
138
144
  };
@@ -172,23 +178,20 @@ var AnomalyDetector = class {
172
178
  });
173
179
  }
174
180
  }
175
- if (current.statusCode >= 500 && baseline.errorCount >= 3) {
176
- const currentErrorRate = 1;
177
- if (currentErrorRate > baseline.errorRate * 2 && currentErrorRate > this.config.errorRateThreshold) {
178
- reports.push({
179
- id: (0, import_uuid.v4)(),
180
- endpoint: current.path,
181
- method: current.method,
182
- severity: "high",
183
- metric: "error_rate",
184
- expectedValue: baseline.errorRate,
185
- actualValue: currentErrorRate,
186
- deviation: currentErrorRate / Math.max(baseline.errorRate, 1e-3),
187
- detectedAt: /* @__PURE__ */ new Date()
188
- });
189
- }
181
+ if (current.statusCode >= 500 && baseline.errorRate < this.config.errorRateThreshold) {
182
+ reports.push({
183
+ id: (0, import_uuid.v4)(),
184
+ endpoint: current.path,
185
+ method: current.method,
186
+ severity: "high",
187
+ metric: "error_rate",
188
+ expectedValue: baseline.errorRate,
189
+ actualValue: 1,
190
+ deviation: 1 / Math.max(baseline.errorRate, 1e-3),
191
+ detectedAt: /* @__PURE__ */ new Date()
192
+ });
190
193
  }
191
- return (0, import_result2.ok)(reports.length > 0 ? reports[0] : null);
194
+ return (0, import_result2.ok)(reports);
192
195
  } catch (e) {
193
196
  return (0, import_result2.fail)(
194
197
  anomalyDetectionFailed(
@@ -204,11 +207,13 @@ var AnomalyDetector = class {
204
207
  baselineMap.set(`${b.method}:${b.path}`, b);
205
208
  }
206
209
  const reports = [];
210
+ const seenUnknown = /* @__PURE__ */ new Set();
207
211
  for (const pattern of windowPatterns) {
208
212
  const key = `${pattern.method}:${pattern.path}`;
209
213
  const baseline = baselineMap.get(key);
210
214
  if (!baseline) {
211
- if (this.config.enableUnknownEndpointDetection) {
215
+ if (this.config.enableUnknownEndpointDetection && !seenUnknown.has(key)) {
216
+ seenUnknown.add(key);
212
217
  reports.push({
213
218
  id: (0, import_uuid.v4)(),
214
219
  endpoint: pattern.path,
@@ -224,8 +229,8 @@ var AnomalyDetector = class {
224
229
  continue;
225
230
  }
226
231
  const result = this.analyze(pattern, baseline);
227
- if (result.ok && result.value) {
228
- reports.push(result.value);
232
+ if (result.ok) {
233
+ reports.push(...result.value);
229
234
  }
230
235
  }
231
236
  return (0, import_result2.ok)(reports);
@@ -253,7 +258,8 @@ var DEFAULT_TUNER_CONFIG = {
253
258
  minTimeoutMs: 1e3,
254
259
  maxTimeoutMs: 3e4,
255
260
  smoothingFactor: 0.3,
256
- adjustmentStepMs: 500
261
+ adjustmentStepMs: 500,
262
+ cooldownMs: 6e4
257
263
  };
258
264
 
259
265
  // src/core/config-tuner/config-tuner.ts
@@ -288,48 +294,11 @@ var ConfigTuner = class {
288
294
  if (aggregates.length === 0) {
289
295
  return (0, import_result3.ok)(this.getCurrentConfig());
290
296
  }
291
- const newConfig = {
292
- circuitBreaker: { ...this.currentConfig.circuitBreaker },
293
- bulkhead: { ...this.currentConfig.bulkhead },
294
- httpClient: { ...this.currentConfig.httpClient }
295
- };
296
- const changedSections = /* @__PURE__ */ new Set();
297
- const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
298
- const targetTimeout = Math.min(
299
- Math.max(maxP95 * 2, this.config.minTimeoutMs),
300
- this.config.maxTimeoutMs
301
- );
302
- if (Math.abs(targetTimeout - newConfig.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
303
- newConfig.httpClient.timeoutMs = this.smoothValue(newConfig.httpClient.timeoutMs, targetTimeout);
304
- changedSections.add("httpClient");
305
- }
306
- const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
307
- if (avgErrorRate > 0.1) {
308
- newConfig.httpClient.maxRetries = Math.min(newConfig.httpClient.maxRetries + 1, 5);
309
- changedSections.add("httpClient");
310
- } else if (avgErrorRate < 0.01 && newConfig.httpClient.maxRetries > 1) {
311
- newConfig.httpClient.maxRetries = Math.max(newConfig.httpClient.maxRetries - 1, 0);
312
- changedSections.add("httpClient");
313
- }
314
- const criticalAnomalies = anomalies.filter(
315
- (a) => a.severity === "critical" || a.severity === "high"
316
- ).length;
317
- if (criticalAnomalies > 0) {
318
- newConfig.circuitBreaker.failureThreshold = Math.max(
319
- this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
320
- 10
321
- );
322
- changedSections.add("circuitBreaker");
323
- } else if (anomalies.length === 0) {
324
- newConfig.circuitBreaker.failureThreshold = Math.min(
325
- this.currentConfig.circuitBreaker.failureThreshold + 5,
326
- 80
327
- );
328
- changedSections.add("circuitBreaker");
329
- }
297
+ const newConfig = this.computeNext(aggregates, anomalies);
298
+ const changedSections = this.diffSections(this.currentConfig, newConfig);
330
299
  if (changedSections.size > 0) {
331
300
  const now = Date.now();
332
- if (now - this.lastChangeAt > 6e4) {
301
+ if (now - this.lastChangeAt > this.config.cooldownMs) {
333
302
  this.currentConfig = newConfig;
334
303
  this.lastChangeAt = now;
335
304
  const saveResult = this.storage.saveConfig(newConfig);
@@ -366,6 +335,57 @@ var ConfigTuner = class {
366
335
  }
367
336
  onConfigChange(callback) {
368
337
  this.listeners.push(callback);
338
+ return () => {
339
+ const idx = this.listeners.indexOf(callback);
340
+ if (idx >= 0) this.listeners.splice(idx, 1);
341
+ };
342
+ }
343
+ // Pure computation — derives the next config from aggregates and anomalies
344
+ // without mutating any state or applying cooldown checks.
345
+ computeNext(aggregates, anomalies) {
346
+ const next = {
347
+ circuitBreaker: { ...this.currentConfig.circuitBreaker },
348
+ bulkhead: { ...this.currentConfig.bulkhead },
349
+ httpClient: { ...this.currentConfig.httpClient }
350
+ };
351
+ const maxP95 = Math.max(...aggregates.map((a) => a.p95Ms));
352
+ const targetTimeout = Math.min(
353
+ Math.max(maxP95 * 2, this.config.minTimeoutMs),
354
+ this.config.maxTimeoutMs
355
+ );
356
+ if (Math.abs(targetTimeout - next.httpClient.timeoutMs) > this.config.adjustmentStepMs) {
357
+ next.httpClient.timeoutMs = this.smoothValue(next.httpClient.timeoutMs, targetTimeout);
358
+ }
359
+ const avgErrorRate = aggregates.reduce((sum, a) => sum + a.errorRate, 0) / aggregates.length;
360
+ if (avgErrorRate > 0.1) {
361
+ next.httpClient.maxRetries = Math.min(next.httpClient.maxRetries + 1, 5);
362
+ } else if (avgErrorRate < 0.01 && next.httpClient.maxRetries > 1) {
363
+ next.httpClient.maxRetries = Math.max(next.httpClient.maxRetries - 1, 0);
364
+ }
365
+ const criticalAnomalies = anomalies.filter(
366
+ (a) => a.severity === "critical" || a.severity === "high"
367
+ ).length;
368
+ if (criticalAnomalies > 0) {
369
+ next.circuitBreaker.failureThreshold = Math.max(
370
+ this.currentConfig.circuitBreaker.failureThreshold - 10 * criticalAnomalies,
371
+ 10
372
+ );
373
+ } else if (anomalies.length === 0) {
374
+ next.circuitBreaker.failureThreshold = Math.min(
375
+ this.currentConfig.circuitBreaker.failureThreshold + 5,
376
+ 80
377
+ );
378
+ }
379
+ return next;
380
+ }
381
+ diffSections(prev, next) {
382
+ const changed = /* @__PURE__ */ new Set();
383
+ for (const key of Object.keys(next)) {
384
+ if (JSON.stringify(next[key]) !== JSON.stringify(prev[key])) {
385
+ changed.add(key);
386
+ }
387
+ }
388
+ return changed;
369
389
  }
370
390
  smoothValue(current, target) {
371
391
  return current + (target - current) * this.config.smoothingFactor;
@@ -377,7 +397,7 @@ var DEFAULT_LOOP_CONFIG = {
377
397
  defaultIntervalMs: 6e4,
378
398
  windowSizeMinutes: 5,
379
399
  minSamplesBeforeTuning: 10,
380
- cooldownBetweenChangesMs: 12e4
400
+ pruneTtlHours: 24
381
401
  };
382
402
 
383
403
  // src/core/feedback-loop/feedback-loop.ts
@@ -398,6 +418,7 @@ var FeedbackLoop = class {
398
418
  storage;
399
419
  observability;
400
420
  timerId = null;
421
+ isProcessing = false;
401
422
  config;
402
423
  cycleListeners = [];
403
424
  start(intervalMs) {
@@ -407,14 +428,20 @@ var FeedbackLoop = class {
407
428
  }
408
429
  const interval = intervalMs ?? this.config.defaultIntervalMs;
409
430
  this.observability.info("Feedback loop started", { intervalMs: interval });
410
- this.timerId = setInterval(() => {
411
- this.runOnce().then((result) => {
431
+ this.timerId = setInterval(async () => {
432
+ if (this.isProcessing) {
433
+ this.observability.warn("Skipping cycle: previous cycle still running");
434
+ return;
435
+ }
436
+ this.isProcessing = true;
437
+ try {
438
+ const result = await this.runOnce();
412
439
  if (!result.ok) {
413
- this.observability.error("Feedback loop cycle failed", {
414
- error: result.error
415
- });
440
+ this.observability.error("Feedback loop cycle failed", { error: result.error });
416
441
  }
417
- });
442
+ } finally {
443
+ this.isProcessing = false;
444
+ }
418
445
  }, interval);
419
446
  }
420
447
  stop() {
@@ -432,11 +459,10 @@ var FeedbackLoop = class {
432
459
  async runOnce() {
433
460
  const cycleId = (0, import_uuid2.v4)();
434
461
  const startTime = Date.now();
462
+ const windowEnd = /* @__PURE__ */ new Date();
463
+ const windowStart = new Date(windowEnd.getTime() - this.config.windowSizeMinutes * 6e4);
435
464
  this.observability.debug("Feedback cycle started", { cycleId });
436
- const patternsResult = this.storage.getPatterns(
437
- new Date(Date.now() - this.config.windowSizeMinutes * 6e4),
438
- /* @__PURE__ */ new Date()
439
- );
465
+ const patternsResult = this.storage.getPatterns(windowStart, windowEnd);
440
466
  if (!patternsResult.ok) {
441
467
  return (0, import_result4.fail)(storageError("Failed to collect patterns", patternsResult.error));
442
468
  }
@@ -457,7 +483,8 @@ var FeedbackLoop = class {
457
483
  return (0, import_result4.ok)(skippedEvent);
458
484
  }
459
485
  const aggregatesResult = this.patternRegistry.getAggregates(
460
- this.config.windowSizeMinutes
486
+ this.config.windowSizeMinutes,
487
+ windowEnd
461
488
  );
462
489
  if (!aggregatesResult.ok) {
463
490
  return (0, import_result4.fail)(aggregatesResult.error);
@@ -469,7 +496,10 @@ var FeedbackLoop = class {
469
496
  }
470
497
  const anomalies = anomaliesResult.value;
471
498
  for (const anomaly of anomalies) {
472
- this.storage.saveAnomaly(anomaly);
499
+ const saveAnomalyResult = this.storage.saveAnomaly(anomaly);
500
+ if (!saveAnomalyResult.ok) {
501
+ this.observability.warn("Failed to persist anomaly", { error: saveAnomalyResult.error });
502
+ }
473
503
  }
474
504
  if (anomalies.length > 0) {
475
505
  this.observability.warn("Anomalies detected", {
@@ -478,12 +508,12 @@ var FeedbackLoop = class {
478
508
  });
479
509
  this.observability.incrementMetric("anomalies.detected", anomalies.length);
480
510
  }
511
+ const previousConfig = this.configTuner.getCurrentConfig();
481
512
  const tuneResult = this.configTuner.tune(aggregates, anomalies);
482
513
  if (!tuneResult.ok) {
483
514
  return (0, import_result4.fail)(tuneResult.error);
484
515
  }
485
516
  const newConfig = tuneResult.value;
486
- const previousConfig = this.configTuner.getCurrentConfig();
487
517
  const configChanges = {};
488
518
  for (const key of Object.keys(newConfig)) {
489
519
  if (JSON.stringify(newConfig[key]) !== JSON.stringify(previousConfig[key])) {
@@ -506,6 +536,11 @@ var FeedbackLoop = class {
506
536
  for (const listener of this.cycleListeners) {
507
537
  listener(cycleEvent);
508
538
  }
539
+ const pruneCutoff = new Date(Date.now() - this.config.pruneTtlHours * 36e5);
540
+ const pruneResult = this.storage.prune(pruneCutoff);
541
+ if (pruneResult.ok && pruneResult.value > 0) {
542
+ this.observability.debug("Pruned old records", { count: pruneResult.value });
543
+ }
509
544
  this.observability.info("Feedback cycle completed", {
510
545
  cycleId,
511
546
  patternsProcessed: cycleEvent.patternsProcessed,
@@ -518,6 +553,10 @@ var FeedbackLoop = class {
518
553
  }
519
554
  onCycle(callback) {
520
555
  this.cycleListeners.push(callback);
556
+ return () => {
557
+ const idx = this.cycleListeners.indexOf(callback);
558
+ if (idx >= 0) this.cycleListeners.splice(idx, 1);
559
+ };
521
560
  }
522
561
  };
523
562
 
@@ -528,6 +567,11 @@ var DEFAULT_CONFIG2 = {
528
567
  bulkhead: { maxConcurrentCalls: 10 },
529
568
  httpClient: { timeoutMs: 1e4, maxRetries: 3 }
530
569
  };
570
+ var DEFAULT_LIMITS = {
571
+ maxPatterns: 1e4,
572
+ maxAnomalies: 1e3,
573
+ maxCycles: 1e3
574
+ };
531
575
  function percentile(sorted, p) {
532
576
  if (sorted.length === 0) return 0;
533
577
  const index = Math.ceil(p / 100 * sorted.length) - 1;
@@ -538,9 +582,16 @@ var InMemoryStorage = class {
538
582
  anomalies = [];
539
583
  config = { ...DEFAULT_CONFIG2 };
540
584
  cycles = [];
585
+ limits;
586
+ constructor(limits) {
587
+ this.limits = { ...DEFAULT_LIMITS, ...limits };
588
+ }
541
589
  savePattern(pattern) {
542
590
  try {
543
591
  this.patterns.push(pattern);
592
+ if (this.patterns.length > this.limits.maxPatterns) {
593
+ this.patterns.shift();
594
+ }
544
595
  return (0, import_result5.ok)(void 0);
545
596
  } catch (e) {
546
597
  return (0, import_result5.fail)(storageError("Failed to save pattern", e));
@@ -557,26 +608,30 @@ var InMemoryStorage = class {
557
608
  return (0, import_result5.fail)(storageError("Failed to get patterns", e));
558
609
  }
559
610
  }
560
- getAggregates(windowMinutes) {
611
+ getAggregates(windowMinutes, windowEnd) {
561
612
  try {
562
- const cutoff = new Date(Date.now() - windowMinutes * 6e4);
563
- const recent = this.patterns.filter((p) => p.timestamp >= cutoff);
613
+ const end = windowEnd ?? /* @__PURE__ */ new Date();
614
+ const cutoff = new Date(end.getTime() - windowMinutes * 6e4);
615
+ const recent = this.patterns.filter((p) => p.timestamp >= cutoff && p.timestamp <= end);
564
616
  const groups = /* @__PURE__ */ new Map();
565
617
  for (const p of recent) {
566
- const key = `${p.method}:${p.path}`;
567
- if (!groups.has(key)) groups.set(key, []);
568
- groups.get(key).push(p);
618
+ const key = `${p.method}\0${p.path}`;
619
+ let g = groups.get(key);
620
+ if (!g) {
621
+ g = { method: p.method, path: p.path, items: [] };
622
+ groups.set(key, g);
623
+ }
624
+ g.items.push(p);
569
625
  }
570
626
  const aggregates = [];
571
- for (const [key, items] of groups) {
572
- const [method, path] = key.split(":");
627
+ for (const { method, path, items } of groups.values()) {
573
628
  const durations = items.map((i) => i.durationMs).sort((a, b) => a - b);
574
629
  const errors = items.filter((i) => i.statusCode >= 500).length;
575
630
  aggregates.push({
576
631
  method,
577
632
  path,
578
633
  windowStart: cutoff,
579
- windowEnd: /* @__PURE__ */ new Date(),
634
+ windowEnd: end,
580
635
  count: items.length,
581
636
  avgDurationMs: durations.reduce((a, b) => a + b, 0) / durations.length,
582
637
  p50Ms: percentile(durations, 50),
@@ -594,6 +649,9 @@ var InMemoryStorage = class {
594
649
  saveAnomaly(report) {
595
650
  try {
596
651
  this.anomalies.push(report);
652
+ if (this.anomalies.length > this.limits.maxAnomalies) {
653
+ this.anomalies.shift();
654
+ }
597
655
  return (0, import_result5.ok)(void 0);
598
656
  } catch (e) {
599
657
  return (0, import_result5.fail)(storageError("Failed to save anomaly", e));
@@ -632,6 +690,9 @@ var InMemoryStorage = class {
632
690
  saveCycleEvent(event) {
633
691
  try {
634
692
  this.cycles.push(event);
693
+ if (this.cycles.length > this.limits.maxCycles) {
694
+ this.cycles.shift();
695
+ }
635
696
  return (0, import_result5.ok)(void 0);
636
697
  } catch (e) {
637
698
  return (0, import_result5.fail)(storageError("Failed to save cycle event", e));
@@ -754,10 +815,10 @@ var AutoLearningCore = class _AutoLearningCore {
754
815
  return this.feedbackLoop.runOnce();
755
816
  }
756
817
  onConfigChange(callback) {
757
- this.configTuner.onConfigChange(callback);
818
+ return this.configTuner.onConfigChange(callback);
758
819
  }
759
820
  onCycle(callback) {
760
- this.feedbackLoop.onCycle(callback);
821
+ return this.feedbackLoop.onCycle(callback);
761
822
  }
762
823
  };
763
824
 
@@ -783,6 +844,7 @@ var AutoLearningInterceptor = class {
783
844
  reflector;
784
845
  core;
785
846
  intercept(context, next) {
847
+ if (context.getType() !== "http") return next.handle();
786
848
  const options = this.reflector.get(
787
849
  AUTO_LEARN_METADATA,
788
850
  context.getHandler()
@@ -793,26 +855,42 @@ var AutoLearningInterceptor = class {
793
855
  const start = Date.now();
794
856
  const req = context.switchToHttp().getRequest();
795
857
  const { method, path } = this.extractRequestInfo(req);
858
+ const record = (statusCode) => {
859
+ const result = this.core.recordPattern({
860
+ method,
861
+ path,
862
+ statusCode,
863
+ durationMs: Date.now() - start,
864
+ timestamp: /* @__PURE__ */ new Date(),
865
+ metadata: this.buildMetadata(req, options)
866
+ });
867
+ if (!result.ok) {
868
+ this.core.observability.error("Failed to record pattern", { error: result.error });
869
+ }
870
+ };
796
871
  return next.handle().pipe(
797
- (0, import_rxjs.tap)(() => {
798
- const duration = Date.now() - start;
799
- const status = context.switchToHttp().getResponse().statusCode;
800
- this.core.recordPattern({
801
- method,
802
- path,
803
- statusCode: status,
804
- durationMs: duration,
805
- timestamp: /* @__PURE__ */ new Date(),
806
- metadata: options.customMetadata ? options.customMetadata(req) : void 0
807
- });
872
+ (0, import_rxjs.tap)({
873
+ next: () => record(context.switchToHttp().getResponse().statusCode),
874
+ error: (err) => {
875
+ const status = typeof err?.getStatus === "function" ? err.getStatus() : err?.status ?? 500;
876
+ record(status);
877
+ }
808
878
  })
809
879
  );
810
880
  }
811
881
  extractRequestInfo(req) {
812
882
  const method = req.method ?? "UNKNOWN";
813
- const path = req.route?.path ?? req.path ?? req.url ?? "/";
883
+ const raw = req.route?.path ?? req.path ?? req.url ?? "/";
884
+ const path = raw.split("?")[0];
814
885
  return { method, path };
815
886
  }
887
+ buildMetadata(req, options) {
888
+ const meta = {};
889
+ if (options.trackParams && req.params) meta.params = req.params;
890
+ if (options.trackBody && req.body) meta.body = req.body;
891
+ if (options.customMetadata) Object.assign(meta, options.customMetadata(req));
892
+ return Object.keys(meta).length > 0 ? meta : void 0;
893
+ }
816
894
  };
817
895
  AutoLearningInterceptor = __decorateClass([
818
896
  (0, import_common.Injectable)(),
@@ -832,11 +910,23 @@ var AutoLearningAdaptersService = class {
832
910
  moduleRef;
833
911
  cbRegistry = null;
834
912
  bhRegistry = null;
913
+ unsubConfigChange = null;
835
914
  async onModuleInit() {
836
915
  await this.resolveRegistries();
837
916
  if (this.cbRegistry || this.bhRegistry) {
838
- this.core.onConfigChange((config) => this.applyConfig(config));
917
+ this.unsubConfigChange = this.core.onConfigChange((config) => this.applyConfig(config));
918
+ }
919
+ }
920
+ onApplicationBootstrap() {
921
+ if (this.options.autoStart !== false) {
922
+ this.core.startFeedbackLoop(this.options.intervalMs);
923
+ }
924
+ }
925
+ onModuleDestroy() {
926
+ if (this.core.isFeedbackLoopRunning()) {
927
+ this.core.stopFeedbackLoop();
839
928
  }
929
+ this.unsubConfigChange?.();
840
930
  }
841
931
  async resolveRegistries() {
842
932
  if (this.options.adapters?.circuitBreaker) {