@checkstack/anomaly-backend 0.2.1 → 1.0.1
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/CHANGELOG.md +168 -0
- package/drizzle/0004_gray_trauma.sql +10 -0
- package/drizzle/meta/0004_snapshot.json +401 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +14 -10
- package/src/detector.test.ts +44 -8
- package/src/detector.ts +7 -0
- package/src/drift-evaluator.test.ts +28 -3
- package/src/drift-evaluator.ts +7 -0
- package/src/jobs/baseline-analyzer.ts +4 -0
- package/src/notification-mute.test.ts +155 -0
- package/src/notification.ts +47 -17
- package/src/plugin.ts +36 -5
- package/src/router.ts +41 -3
- package/src/schema.ts +26 -0
- package/src/service.ts +103 -1
package/src/detector.test.ts
CHANGED
|
@@ -43,6 +43,14 @@ function createMockCatalogClient() {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function createMockNotificationClient(subscriberIds: string[] = ["user-1"]) {
|
|
47
|
+
return {
|
|
48
|
+
notifyForSubscription: mock(async () => ({
|
|
49
|
+
notifiedCount: subscriberIds.length,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
function createMockLogger() {
|
|
47
55
|
return {
|
|
48
56
|
debug: mock(() => {}),
|
|
@@ -248,6 +256,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
248
256
|
cache,
|
|
249
257
|
logger: createMockLogger() as never,
|
|
250
258
|
catalogClient: createMockCatalogClient() as never,
|
|
259
|
+
notificationClient: createMockNotificationClient() as never,
|
|
251
260
|
});
|
|
252
261
|
expect(cache.get).not.toHaveBeenCalled();
|
|
253
262
|
});
|
|
@@ -263,6 +272,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
263
272
|
cache,
|
|
264
273
|
logger: createMockLogger() as never,
|
|
265
274
|
catalogClient: createMockCatalogClient() as never,
|
|
275
|
+
notificationClient: createMockNotificationClient() as never,
|
|
266
276
|
});
|
|
267
277
|
expect(cache.get).not.toHaveBeenCalled();
|
|
268
278
|
});
|
|
@@ -280,6 +290,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
280
290
|
cache,
|
|
281
291
|
logger: createMockLogger() as never,
|
|
282
292
|
catalogClient: createMockCatalogClient() as never,
|
|
293
|
+
notificationClient: createMockNotificationClient() as never,
|
|
283
294
|
});
|
|
284
295
|
// Two fields (statusText, isRunning) means cache.get should be called twice
|
|
285
296
|
expect(cache.get).toHaveBeenCalledTimes(2);
|
|
@@ -301,6 +312,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
301
312
|
cache,
|
|
302
313
|
logger: createMockLogger() as never,
|
|
303
314
|
catalogClient: createMockCatalogClient() as never,
|
|
315
|
+
notificationClient: createMockNotificationClient() as never,
|
|
304
316
|
});
|
|
305
317
|
|
|
306
318
|
expect(db._insertCalls.length).toBe(1);
|
|
@@ -326,6 +338,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
326
338
|
cache,
|
|
327
339
|
logger: createMockLogger() as never,
|
|
328
340
|
catalogClient: createMockCatalogClient() as never,
|
|
341
|
+
notificationClient: createMockNotificationClient() as never,
|
|
329
342
|
});
|
|
330
343
|
expect(cache.get).toHaveBeenCalledTimes(1);
|
|
331
344
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -345,6 +358,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
345
358
|
cache,
|
|
346
359
|
logger: createMockLogger() as never,
|
|
347
360
|
catalogClient: createMockCatalogClient() as never,
|
|
361
|
+
notificationClient: createMockNotificationClient() as never,
|
|
348
362
|
});
|
|
349
363
|
expect(db._insertCalls.length).toBe(0);
|
|
350
364
|
});
|
|
@@ -363,6 +377,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
363
377
|
cache,
|
|
364
378
|
logger: createMockLogger() as never,
|
|
365
379
|
catalogClient: createMockCatalogClient() as never,
|
|
380
|
+
notificationClient: createMockNotificationClient() as never,
|
|
366
381
|
});
|
|
367
382
|
expect(db._insertCalls.length).toBe(1);
|
|
368
383
|
expect(db._insertCalls[0]).toMatchObject({
|
|
@@ -380,6 +395,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
380
395
|
const baseline = createBaseline({ mean: 100, stdDev: 10 });
|
|
381
396
|
const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
|
|
382
397
|
const catalogClient = createMockCatalogClient();
|
|
398
|
+
const notificationClient = createMockNotificationClient(["user-1"]);
|
|
383
399
|
const db = createMockDb({
|
|
384
400
|
existingAnomaly: {
|
|
385
401
|
id: "anomaly-existing",
|
|
@@ -400,18 +416,19 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
400
416
|
cache,
|
|
401
417
|
logger: createMockLogger() as never,
|
|
402
418
|
catalogClient: catalogClient as never,
|
|
419
|
+
notificationClient: notificationClient as never,
|
|
403
420
|
});
|
|
404
421
|
|
|
405
422
|
expect(db._updateCalls.length).toBe(1);
|
|
406
423
|
expect(db._updateCalls[0]).toMatchObject({ state: "anomaly" });
|
|
407
424
|
|
|
408
|
-
expect(
|
|
409
|
-
const notifArgs = (
|
|
425
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
426
|
+
const notifArgs = (notificationClient.notifyForSubscription as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
|
|
410
427
|
const notifPayload = notifArgs[0] as Record<string, unknown>;
|
|
411
428
|
expect(notifPayload).toMatchObject({
|
|
412
|
-
|
|
429
|
+
specId: "anomaly.system",
|
|
430
|
+
resourceKeys: [systemId],
|
|
413
431
|
importance: "warning",
|
|
414
|
-
includeGroupSubscribers: true,
|
|
415
432
|
});
|
|
416
433
|
expect(notifPayload.title).toContain("Anomaly Detected");
|
|
417
434
|
});
|
|
@@ -422,6 +439,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
422
439
|
const baseline = createBaseline({ mean: 100, stdDev: 10 });
|
|
423
440
|
const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
|
|
424
441
|
const catalogClient = createMockCatalogClient();
|
|
442
|
+
const notificationClient = createMockNotificationClient(["user-1"]);
|
|
425
443
|
const db = createMockDb({
|
|
426
444
|
existingAnomaly: {
|
|
427
445
|
id: "anomaly-confirmed",
|
|
@@ -442,16 +460,18 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
442
460
|
cache,
|
|
443
461
|
logger: createMockLogger() as never,
|
|
444
462
|
catalogClient: catalogClient as never,
|
|
463
|
+
notificationClient: notificationClient as never,
|
|
445
464
|
});
|
|
446
465
|
|
|
447
466
|
expect(db._updateCalls.length).toBe(1);
|
|
448
467
|
expect(db._updateCalls[0]).toMatchObject({ state: "recovered" });
|
|
449
468
|
|
|
450
|
-
expect(
|
|
451
|
-
const notifArgs = (
|
|
469
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
470
|
+
const notifArgs = (notificationClient.notifyForSubscription as Mock<(...args: unknown[]) => unknown>).mock.calls[0] as unknown[];
|
|
452
471
|
const notifPayload = notifArgs[0] as Record<string, unknown>;
|
|
453
472
|
expect(notifPayload).toMatchObject({
|
|
454
|
-
|
|
473
|
+
specId: "anomaly.system",
|
|
474
|
+
resourceKeys: [systemId],
|
|
455
475
|
importance: "info",
|
|
456
476
|
});
|
|
457
477
|
expect(notifPayload.title).toContain("Recovered");
|
|
@@ -482,6 +502,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
482
502
|
cache,
|
|
483
503
|
logger: createMockLogger() as never,
|
|
484
504
|
catalogClient: createMockCatalogClient() as never,
|
|
505
|
+
notificationClient: createMockNotificationClient() as never,
|
|
485
506
|
});
|
|
486
507
|
|
|
487
508
|
expect(db._deleteCalls.length).toBe(1);
|
|
@@ -507,6 +528,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
507
528
|
cache,
|
|
508
529
|
logger: createMockLogger() as never,
|
|
509
530
|
catalogClient: createMockCatalogClient() as never,
|
|
531
|
+
notificationClient: createMockNotificationClient() as never,
|
|
510
532
|
});
|
|
511
533
|
|
|
512
534
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -535,6 +557,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
535
557
|
cache,
|
|
536
558
|
logger: createMockLogger() as never,
|
|
537
559
|
catalogClient: createMockCatalogClient() as never,
|
|
560
|
+
notificationClient: createMockNotificationClient() as never,
|
|
538
561
|
});
|
|
539
562
|
|
|
540
563
|
expect(cache.get).toHaveBeenCalledTimes(2);
|
|
@@ -566,6 +589,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
566
589
|
cache,
|
|
567
590
|
logger: createMockLogger() as never,
|
|
568
591
|
catalogClient: createMockCatalogClient() as never,
|
|
592
|
+
notificationClient: createMockNotificationClient() as never,
|
|
569
593
|
});
|
|
570
594
|
|
|
571
595
|
expect(cache.set).toHaveBeenCalledTimes(1);
|
|
@@ -593,6 +617,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
593
617
|
cache,
|
|
594
618
|
logger: createMockLogger() as never,
|
|
595
619
|
catalogClient: createMockCatalogClient() as never,
|
|
620
|
+
notificationClient: createMockNotificationClient() as never,
|
|
596
621
|
});
|
|
597
622
|
|
|
598
623
|
expect(db._insertCalls.length).toBe(1);
|
|
@@ -618,6 +643,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
618
643
|
cache,
|
|
619
644
|
logger: createMockLogger() as never,
|
|
620
645
|
catalogClient: createMockCatalogClient() as never,
|
|
646
|
+
notificationClient: createMockNotificationClient() as never,
|
|
621
647
|
});
|
|
622
648
|
|
|
623
649
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -639,6 +665,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
639
665
|
cache,
|
|
640
666
|
logger: createMockLogger() as never,
|
|
641
667
|
catalogClient: createMockCatalogClient() as never,
|
|
668
|
+
notificationClient: createMockNotificationClient() as never,
|
|
642
669
|
});
|
|
643
670
|
|
|
644
671
|
expect(db._insertCalls.length).toBe(1);
|
|
@@ -661,6 +688,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
661
688
|
cache,
|
|
662
689
|
logger: createMockLogger() as never,
|
|
663
690
|
catalogClient: createMockCatalogClient() as never,
|
|
691
|
+
notificationClient: createMockNotificationClient() as never,
|
|
664
692
|
});
|
|
665
693
|
|
|
666
694
|
// Baseline lookup happens, but direction resolution short-circuits before insert.
|
|
@@ -687,6 +715,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
687
715
|
cache,
|
|
688
716
|
logger: createMockLogger() as never,
|
|
689
717
|
catalogClient: createMockCatalogClient() as never,
|
|
718
|
+
notificationClient: createMockNotificationClient() as never,
|
|
690
719
|
});
|
|
691
720
|
|
|
692
721
|
// No schema direction available, no config direction → silently skipped
|
|
@@ -718,6 +747,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
718
747
|
cache,
|
|
719
748
|
logger: createMockLogger() as never,
|
|
720
749
|
catalogClient: createMockCatalogClient() as never,
|
|
750
|
+
notificationClient: createMockNotificationClient() as never,
|
|
721
751
|
});
|
|
722
752
|
|
|
723
753
|
expect(db._updateCalls.length).toBe(1);
|
|
@@ -730,6 +760,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
730
760
|
const baseline = createBaseline({ mean: 100, stdDev: 10 });
|
|
731
761
|
const cache = createMockCache(new Map([[cacheKeyPrefix, baseline]]));
|
|
732
762
|
const catalogClient = createMockCatalogClient();
|
|
763
|
+
const notificationClient = createMockNotificationClient();
|
|
733
764
|
const db = createMockDb({
|
|
734
765
|
existingAnomaly: {
|
|
735
766
|
id: "anomaly-confirmed",
|
|
@@ -750,11 +781,12 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
750
781
|
cache,
|
|
751
782
|
logger: createMockLogger() as never,
|
|
752
783
|
catalogClient: catalogClient as never,
|
|
784
|
+
notificationClient: notificationClient as never,
|
|
753
785
|
});
|
|
754
786
|
|
|
755
787
|
// Should update observed value but not send another notification
|
|
756
788
|
expect(db._updateCalls.length).toBe(1);
|
|
757
|
-
expect(
|
|
789
|
+
expect(notificationClient.notifyForSubscription).not.toHaveBeenCalled();
|
|
758
790
|
});
|
|
759
791
|
|
|
760
792
|
// ─── Signal emission (F8) ─────────────────────────────────────────────
|
|
@@ -774,6 +806,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
774
806
|
cache,
|
|
775
807
|
logger: createMockLogger() as never,
|
|
776
808
|
catalogClient: createMockCatalogClient() as never,
|
|
809
|
+
notificationClient: createMockNotificationClient() as never,
|
|
777
810
|
signalService,
|
|
778
811
|
});
|
|
779
812
|
|
|
@@ -808,6 +841,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
808
841
|
cache,
|
|
809
842
|
logger: createMockLogger() as never,
|
|
810
843
|
catalogClient: createMockCatalogClient() as never,
|
|
844
|
+
notificationClient: createMockNotificationClient() as never,
|
|
811
845
|
signalService,
|
|
812
846
|
});
|
|
813
847
|
|
|
@@ -842,6 +876,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
842
876
|
cache,
|
|
843
877
|
logger: createMockLogger() as never,
|
|
844
878
|
catalogClient: createMockCatalogClient() as never,
|
|
879
|
+
notificationClient: createMockNotificationClient() as never,
|
|
845
880
|
signalService,
|
|
846
881
|
});
|
|
847
882
|
|
|
@@ -883,6 +918,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
|
|
|
883
918
|
cache,
|
|
884
919
|
logger: logger as never,
|
|
885
920
|
catalogClient: failingClient as never,
|
|
921
|
+
notificationClient: createMockNotificationClient() as never,
|
|
886
922
|
});
|
|
887
923
|
|
|
888
924
|
// State transition still happened despite notification failure
|
package/src/detector.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@checkstack/anomaly-common";
|
|
12
12
|
import type { Logger } from "@checkstack/backend-api";
|
|
13
13
|
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
14
|
+
import type { NotificationApi } from "@checkstack/notification-common";
|
|
14
15
|
import type { InferClient } from "@checkstack/common";
|
|
15
16
|
import { AnomalyService } from "./service";
|
|
16
17
|
import type { AnomalySettings, AnomalyDirection } from "@checkstack/anomaly-common";
|
|
@@ -38,6 +39,7 @@ export async function processCheckCompleted({
|
|
|
38
39
|
routerCache,
|
|
39
40
|
logger,
|
|
40
41
|
catalogClient,
|
|
42
|
+
notificationClient,
|
|
41
43
|
signalService,
|
|
42
44
|
collectorRegistry,
|
|
43
45
|
}: {
|
|
@@ -54,6 +56,7 @@ export async function processCheckCompleted({
|
|
|
54
56
|
};
|
|
55
57
|
logger: Logger;
|
|
56
58
|
catalogClient: InferClient<typeof CatalogApi>;
|
|
59
|
+
notificationClient: InferClient<typeof NotificationApi>;
|
|
57
60
|
signalService?: SignalService;
|
|
58
61
|
collectorRegistry: CollectorRegistry;
|
|
59
62
|
}) {
|
|
@@ -295,6 +298,8 @@ export async function processCheckCompleted({
|
|
|
295
298
|
observedValue: value,
|
|
296
299
|
baselineMean: baseline.mean,
|
|
297
300
|
catalogClient,
|
|
301
|
+
notificationClient,
|
|
302
|
+
db,
|
|
298
303
|
logger,
|
|
299
304
|
});
|
|
300
305
|
} else {
|
|
@@ -352,6 +357,8 @@ export async function processCheckCompleted({
|
|
|
352
357
|
observedValue: value,
|
|
353
358
|
baselineMean: baseline.mean,
|
|
354
359
|
catalogClient,
|
|
360
|
+
notificationClient,
|
|
361
|
+
db,
|
|
355
362
|
logger,
|
|
356
363
|
});
|
|
357
364
|
}
|
|
@@ -21,6 +21,14 @@ function createMockCatalogClient() {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function createMockNotificationClient(subscriberIds: string[] = ["user-1"]) {
|
|
25
|
+
return {
|
|
26
|
+
notifyForSubscription: mock(async () => ({
|
|
27
|
+
notifiedCount: subscriberIds.length,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
function createMockLogger() {
|
|
25
33
|
return {
|
|
26
34
|
debug: mock(() => {}),
|
|
@@ -132,6 +140,7 @@ describe("evaluateDrift", () => {
|
|
|
132
140
|
templateConfig: defaultTemplate,
|
|
133
141
|
db: db as never,
|
|
134
142
|
catalogClient: createMockCatalogClient() as never,
|
|
143
|
+
notificationClient: createMockNotificationClient() as never,
|
|
135
144
|
logger: createMockLogger() as never,
|
|
136
145
|
});
|
|
137
146
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -147,6 +156,7 @@ describe("evaluateDrift", () => {
|
|
|
147
156
|
templateConfig: defaultTemplate,
|
|
148
157
|
db: db as never,
|
|
149
158
|
catalogClient: createMockCatalogClient() as never,
|
|
159
|
+
notificationClient: createMockNotificationClient() as never,
|
|
150
160
|
logger: createMockLogger() as never,
|
|
151
161
|
});
|
|
152
162
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -161,6 +171,7 @@ describe("evaluateDrift", () => {
|
|
|
161
171
|
templateConfig: { ...defaultTemplate, driftEnabled: false },
|
|
162
172
|
db: db as never,
|
|
163
173
|
catalogClient: createMockCatalogClient() as never,
|
|
174
|
+
notificationClient: createMockNotificationClient() as never,
|
|
164
175
|
logger: createMockLogger() as never,
|
|
165
176
|
});
|
|
166
177
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -175,6 +186,7 @@ describe("evaluateDrift", () => {
|
|
|
175
186
|
templateConfig: { ...defaultTemplate, enabled: false },
|
|
176
187
|
db: db as never,
|
|
177
188
|
catalogClient: createMockCatalogClient() as never,
|
|
189
|
+
notificationClient: createMockNotificationClient() as never,
|
|
178
190
|
logger: createMockLogger() as never,
|
|
179
191
|
});
|
|
180
192
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -189,6 +201,7 @@ describe("evaluateDrift", () => {
|
|
|
189
201
|
templateConfig: defaultTemplate,
|
|
190
202
|
db: db as never,
|
|
191
203
|
catalogClient: createMockCatalogClient() as never,
|
|
204
|
+
notificationClient: createMockNotificationClient() as never,
|
|
192
205
|
logger: createMockLogger() as never,
|
|
193
206
|
});
|
|
194
207
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -206,6 +219,7 @@ describe("evaluateDrift", () => {
|
|
|
206
219
|
templateConfig: defaultTemplate,
|
|
207
220
|
db: db as never,
|
|
208
221
|
catalogClient: createMockCatalogClient() as never,
|
|
222
|
+
notificationClient: createMockNotificationClient() as never,
|
|
209
223
|
logger: createMockLogger() as never,
|
|
210
224
|
signalService: signalService as never,
|
|
211
225
|
});
|
|
@@ -234,6 +248,7 @@ describe("evaluateDrift", () => {
|
|
|
234
248
|
templateConfig: defaultTemplate,
|
|
235
249
|
db: db as never,
|
|
236
250
|
catalogClient: createMockCatalogClient() as never,
|
|
251
|
+
notificationClient: createMockNotificationClient() as never,
|
|
237
252
|
logger: createMockLogger() as never,
|
|
238
253
|
});
|
|
239
254
|
expect(db._updateCalls.length).toBe(1);
|
|
@@ -250,6 +265,7 @@ describe("evaluateDrift", () => {
|
|
|
250
265
|
};
|
|
251
266
|
const db = createMockDb({ existingAnomaly: existing });
|
|
252
267
|
const catalog = createMockCatalogClient();
|
|
268
|
+
const notification = createMockNotificationClient();
|
|
253
269
|
const signalService = createMockSignalService();
|
|
254
270
|
await evaluateDrift({
|
|
255
271
|
...baseProps,
|
|
@@ -258,12 +274,13 @@ describe("evaluateDrift", () => {
|
|
|
258
274
|
templateConfig: defaultTemplate,
|
|
259
275
|
db: db as never,
|
|
260
276
|
catalogClient: catalog as never,
|
|
277
|
+
notificationClient: notification as never,
|
|
261
278
|
logger: createMockLogger() as never,
|
|
262
279
|
signalService: signalService as never,
|
|
263
280
|
});
|
|
264
281
|
expect(db._updateCalls.length).toBe(1);
|
|
265
282
|
expect(db._updateCalls[0].state).toBe("anomaly");
|
|
266
|
-
expect(
|
|
283
|
+
expect(notification.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
267
284
|
// Two signals: state change + trend detected
|
|
268
285
|
expect(signalService.broadcast).toHaveBeenCalledTimes(2);
|
|
269
286
|
});
|
|
@@ -277,6 +294,7 @@ describe("evaluateDrift", () => {
|
|
|
277
294
|
};
|
|
278
295
|
const db = createMockDb({ existingAnomaly: existing });
|
|
279
296
|
const catalog = createMockCatalogClient();
|
|
297
|
+
const notification = createMockNotificationClient();
|
|
280
298
|
await evaluateDrift({
|
|
281
299
|
...baseProps,
|
|
282
300
|
baseline: driftingBaseline,
|
|
@@ -284,11 +302,12 @@ describe("evaluateDrift", () => {
|
|
|
284
302
|
templateConfig: defaultTemplate,
|
|
285
303
|
db: db as never,
|
|
286
304
|
catalogClient: catalog as never,
|
|
305
|
+
notificationClient: notification as never,
|
|
287
306
|
logger: createMockLogger() as never,
|
|
288
307
|
});
|
|
289
308
|
expect(db._updateCalls.length).toBe(1);
|
|
290
309
|
expect(db._updateCalls[0].state).toBeUndefined();
|
|
291
|
-
expect(
|
|
310
|
+
expect(notification.notifyForSubscription).not.toHaveBeenCalled();
|
|
292
311
|
});
|
|
293
312
|
|
|
294
313
|
test("deletes suspicious row when drift goes away before confirmation", async () => {
|
|
@@ -306,6 +325,7 @@ describe("evaluateDrift", () => {
|
|
|
306
325
|
templateConfig: defaultTemplate,
|
|
307
326
|
db: db as never,
|
|
308
327
|
catalogClient: createMockCatalogClient() as never,
|
|
328
|
+
notificationClient: createMockNotificationClient() as never,
|
|
309
329
|
logger: createMockLogger() as never,
|
|
310
330
|
});
|
|
311
331
|
expect(db._deleteCalls.length).toBe(1);
|
|
@@ -320,6 +340,7 @@ describe("evaluateDrift", () => {
|
|
|
320
340
|
};
|
|
321
341
|
const db = createMockDb({ existingAnomaly: existing });
|
|
322
342
|
const catalog = createMockCatalogClient();
|
|
343
|
+
const notification = createMockNotificationClient();
|
|
323
344
|
await evaluateDrift({
|
|
324
345
|
...baseProps,
|
|
325
346
|
baseline: stableBaseline,
|
|
@@ -327,11 +348,12 @@ describe("evaluateDrift", () => {
|
|
|
327
348
|
templateConfig: defaultTemplate,
|
|
328
349
|
db: db as never,
|
|
329
350
|
catalogClient: catalog as never,
|
|
351
|
+
notificationClient: notification as never,
|
|
330
352
|
logger: createMockLogger() as never,
|
|
331
353
|
});
|
|
332
354
|
expect(db._updateCalls.length).toBe(1);
|
|
333
355
|
expect(db._updateCalls[0].state).toBe("recovered");
|
|
334
|
-
expect(
|
|
356
|
+
expect(notification.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
335
357
|
});
|
|
336
358
|
|
|
337
359
|
test("does nothing when no row and no drift (steady state)", async () => {
|
|
@@ -343,6 +365,7 @@ describe("evaluateDrift", () => {
|
|
|
343
365
|
templateConfig: defaultTemplate,
|
|
344
366
|
db: db as never,
|
|
345
367
|
catalogClient: createMockCatalogClient() as never,
|
|
368
|
+
notificationClient: createMockNotificationClient() as never,
|
|
346
369
|
logger: createMockLogger() as never,
|
|
347
370
|
});
|
|
348
371
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -362,6 +385,7 @@ describe("evaluateDrift", () => {
|
|
|
362
385
|
templateConfig: defaultTemplate,
|
|
363
386
|
db: db as never,
|
|
364
387
|
catalogClient: createMockCatalogClient() as never,
|
|
388
|
+
notificationClient: createMockNotificationClient() as never,
|
|
365
389
|
logger: createMockLogger() as never,
|
|
366
390
|
});
|
|
367
391
|
expect(db._insertCalls.length).toBe(0);
|
|
@@ -374,6 +398,7 @@ describe("evaluateDrift", () => {
|
|
|
374
398
|
templateConfig: defaultTemplate,
|
|
375
399
|
db: db2 as never,
|
|
376
400
|
catalogClient: createMockCatalogClient() as never,
|
|
401
|
+
notificationClient: createMockNotificationClient() as never,
|
|
377
402
|
logger: createMockLogger() as never,
|
|
378
403
|
});
|
|
379
404
|
expect(db2._insertCalls.length).toBe(1);
|
package/src/drift-evaluator.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Logger, SafeDatabase } from "@checkstack/backend-api";
|
|
2
2
|
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
3
|
+
import type { NotificationApi } from "@checkstack/notification-common";
|
|
3
4
|
import type { InferClient } from "@checkstack/common";
|
|
4
5
|
import type { SignalService } from "@checkstack/signal-common";
|
|
5
6
|
import {
|
|
@@ -24,6 +25,7 @@ export interface EvaluateDriftInput {
|
|
|
24
25
|
db: SafeDatabase<typeof schema>;
|
|
25
26
|
logger: Logger;
|
|
26
27
|
catalogClient: InferClient<typeof CatalogApi>;
|
|
28
|
+
notificationClient: InferClient<typeof NotificationApi>;
|
|
27
29
|
signalService?: SignalService;
|
|
28
30
|
systemId: string;
|
|
29
31
|
configurationId: string;
|
|
@@ -49,6 +51,7 @@ export async function evaluateDrift({
|
|
|
49
51
|
db,
|
|
50
52
|
logger,
|
|
51
53
|
catalogClient,
|
|
54
|
+
notificationClient,
|
|
52
55
|
signalService,
|
|
53
56
|
systemId,
|
|
54
57
|
configurationId,
|
|
@@ -160,6 +163,8 @@ export async function evaluateDrift({
|
|
|
160
163
|
baselineMean: baseline.mean,
|
|
161
164
|
projectedChange: driftResult.projectedChange,
|
|
162
165
|
catalogClient,
|
|
166
|
+
notificationClient,
|
|
167
|
+
db,
|
|
163
168
|
logger,
|
|
164
169
|
});
|
|
165
170
|
return;
|
|
@@ -225,6 +230,8 @@ export async function evaluateDrift({
|
|
|
225
230
|
observedValue: baseline.mean,
|
|
226
231
|
baselineMean: baseline.mean,
|
|
227
232
|
catalogClient,
|
|
233
|
+
notificationClient,
|
|
234
|
+
db,
|
|
228
235
|
logger,
|
|
229
236
|
});
|
|
230
237
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CollectorRegistry, Logger, SafeDatabase } from "@checkstack/backend-api";
|
|
2
2
|
import type { CacheProvider } from "@checkstack/cache-api";
|
|
3
3
|
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
4
|
+
import type { NotificationApi } from "@checkstack/notification-common";
|
|
4
5
|
import type { InferClient } from "@checkstack/common";
|
|
5
6
|
import type { HealthCheckApi, HealthCheckRunResult } from "@checkstack/healthcheck-common";
|
|
6
7
|
import { getHealthResultMeta } from "@checkstack/healthcheck-common";
|
|
@@ -33,6 +34,7 @@ export async function setupBaselineAnalyzerJob({
|
|
|
33
34
|
healthCheckClient,
|
|
34
35
|
signalService,
|
|
35
36
|
catalogClient,
|
|
37
|
+
notificationClient,
|
|
36
38
|
collectorRegistry,
|
|
37
39
|
}: {
|
|
38
40
|
db: SafeDatabase<typeof schema>;
|
|
@@ -42,6 +44,7 @@ export async function setupBaselineAnalyzerJob({
|
|
|
42
44
|
healthCheckClient: InferClient<typeof HealthCheckApi>;
|
|
43
45
|
signalService?: SignalService;
|
|
44
46
|
catalogClient: InferClient<typeof CatalogApi>;
|
|
47
|
+
notificationClient: InferClient<typeof NotificationApi>;
|
|
45
48
|
collectorRegistry: CollectorRegistry;
|
|
46
49
|
}) {
|
|
47
50
|
const queue = queueManager.getQueue(BASELINE_ANALYZER_QUEUE);
|
|
@@ -217,6 +220,7 @@ export async function setupBaselineAnalyzerJob({
|
|
|
217
220
|
db,
|
|
218
221
|
logger,
|
|
219
222
|
catalogClient,
|
|
223
|
+
notificationClient,
|
|
220
224
|
signalService,
|
|
221
225
|
systemId: assignment.systemId,
|
|
222
226
|
configurationId: assignment.configurationId,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
2
|
+
import { dispatchAnomalyNotification } from "./notification";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The mute filter behaviour is the integration point that the per-field
|
|
6
|
+
* opt-out feature hangs on. These tests exercise dispatchAnomalyNotification
|
|
7
|
+
* end-to-end against an in-memory db mock that controls which userIds appear
|
|
8
|
+
* as muted, since AnomalyService.getMutedUserIds is what the dispatcher
|
|
9
|
+
* relies on. The other suites (detector / drift-evaluator) cover the
|
|
10
|
+
* surrounding state-machine logic.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
function createMockCatalogClient() {
|
|
14
|
+
return {
|
|
15
|
+
getSystem: mock(async () => ({ name: "Production API" })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMockNotificationClient(subscriberIds: string[]) {
|
|
20
|
+
// The new dispatch goes through notifyForSubscription. This mock
|
|
21
|
+
// simulates the server-side resolution by emitting a synthetic
|
|
22
|
+
// "userIds passed to notify" surface that tests assert on. Includes
|
|
23
|
+
// simulated mute filtering since the dispatcher applies excludeUserIds.
|
|
24
|
+
return {
|
|
25
|
+
notifyForSubscription: mock(async (input: {
|
|
26
|
+
excludeUserIds?: string[];
|
|
27
|
+
}) => {
|
|
28
|
+
const excluded = new Set(input.excludeUserIds ?? []);
|
|
29
|
+
const recipients = subscriberIds.filter((id) => !excluded.has(id));
|
|
30
|
+
return { notifiedCount: recipients.length, recipients };
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal Drizzle-shaped mock that only answers the one query
|
|
37
|
+
* AnomalyService.getMutedUserIds issues. Returns the supplied muted ids.
|
|
38
|
+
*/
|
|
39
|
+
function createDbWithMutes(mutedUserIds: string[]) {
|
|
40
|
+
const rows = mutedUserIds.map((userId) => ({ userId }));
|
|
41
|
+
return {
|
|
42
|
+
select: mock(() => ({
|
|
43
|
+
from: mock(() => ({
|
|
44
|
+
where: mock(() => Promise.resolve(rows)),
|
|
45
|
+
})),
|
|
46
|
+
})),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const baseInput = {
|
|
51
|
+
systemId: "sys-1",
|
|
52
|
+
fieldPath: "collectors.http.request.responseTimeMs",
|
|
53
|
+
observedValue: 250,
|
|
54
|
+
baselineMean: 100,
|
|
55
|
+
logger: { warn: mock(() => undefined), debug: mock(() => undefined) },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("dispatchAnomalyNotification — mute filtering", () => {
|
|
59
|
+
test("notifies all subscribers when none have muted the field", async () => {
|
|
60
|
+
const catalogClient = createMockCatalogClient();
|
|
61
|
+
const notificationClient = createMockNotificationClient([
|
|
62
|
+
"alice",
|
|
63
|
+
"bob",
|
|
64
|
+
"carol",
|
|
65
|
+
]);
|
|
66
|
+
const db = createDbWithMutes([]);
|
|
67
|
+
|
|
68
|
+
await dispatchAnomalyNotification({
|
|
69
|
+
...baseInput,
|
|
70
|
+
action: "confirmed",
|
|
71
|
+
catalogClient: catalogClient as never,
|
|
72
|
+
notificationClient: notificationClient as never,
|
|
73
|
+
db: db as never,
|
|
74
|
+
logger: baseInput.logger as never,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
78
|
+
const payload = (
|
|
79
|
+
notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
|
|
80
|
+
)[0] as { excludeUserIds?: string[]; resourceKeys: string[] };
|
|
81
|
+
expect(payload.excludeUserIds ?? []).toEqual([]);
|
|
82
|
+
expect(payload.resourceKeys).toEqual(["sys-1"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("excludes users who muted the specific field", async () => {
|
|
86
|
+
const catalogClient = createMockCatalogClient();
|
|
87
|
+
const notificationClient = createMockNotificationClient([
|
|
88
|
+
"alice",
|
|
89
|
+
"bob",
|
|
90
|
+
"carol",
|
|
91
|
+
]);
|
|
92
|
+
const db = createDbWithMutes(["bob"]);
|
|
93
|
+
|
|
94
|
+
await dispatchAnomalyNotification({
|
|
95
|
+
...baseInput,
|
|
96
|
+
action: "confirmed",
|
|
97
|
+
catalogClient: catalogClient as never,
|
|
98
|
+
notificationClient: notificationClient as never,
|
|
99
|
+
db: db as never,
|
|
100
|
+
logger: baseInput.logger as never,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
104
|
+
const payload = (
|
|
105
|
+
notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
|
|
106
|
+
)[0] as { excludeUserIds?: string[] };
|
|
107
|
+
expect(payload.excludeUserIds ?? []).toEqual(["bob"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("forwards every muted user as excludeUserIds", async () => {
|
|
111
|
+
const catalogClient = createMockCatalogClient();
|
|
112
|
+
const notificationClient = createMockNotificationClient(["alice", "bob"]);
|
|
113
|
+
const db = createDbWithMutes(["alice", "bob"]);
|
|
114
|
+
|
|
115
|
+
await dispatchAnomalyNotification({
|
|
116
|
+
...baseInput,
|
|
117
|
+
action: "confirmed",
|
|
118
|
+
catalogClient: catalogClient as never,
|
|
119
|
+
notificationClient: notificationClient as never,
|
|
120
|
+
db: db as never,
|
|
121
|
+
logger: baseInput.logger as never,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
125
|
+
const payload = (
|
|
126
|
+
notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
|
|
127
|
+
)[0] as { excludeUserIds?: string[] };
|
|
128
|
+
expect((payload.excludeUserIds ?? []).sort()).toEqual(["alice", "bob"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("delegates dispatch even when there are no mutes — backend resolves subscribers", async () => {
|
|
132
|
+
const catalogClient = createMockCatalogClient();
|
|
133
|
+
const notificationClient = createMockNotificationClient([]);
|
|
134
|
+
const db = createDbWithMutes([]);
|
|
135
|
+
|
|
136
|
+
await dispatchAnomalyNotification({
|
|
137
|
+
...baseInput,
|
|
138
|
+
action: "confirmed",
|
|
139
|
+
catalogClient: catalogClient as never,
|
|
140
|
+
notificationClient: notificationClient as never,
|
|
141
|
+
db: db as never,
|
|
142
|
+
logger: baseInput.logger as never,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Anomaly always delegates to the backend now — subscriber-set
|
|
146
|
+
// resolution happens server-side. The mock just needs to have been
|
|
147
|
+
// invoked once with the right specId / resourceKeys.
|
|
148
|
+
expect(notificationClient.notifyForSubscription).toHaveBeenCalledTimes(1);
|
|
149
|
+
const payload = (
|
|
150
|
+
notificationClient.notifyForSubscription.mock.calls[0] as unknown[]
|
|
151
|
+
)[0] as { specId: string; resourceKeys: string[] };
|
|
152
|
+
expect(payload.specId).toBe("anomaly.system");
|
|
153
|
+
expect(payload.resourceKeys).toEqual(["sys-1"]);
|
|
154
|
+
});
|
|
155
|
+
});
|