@checkstack/anomaly-backend 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/CHANGELOG.md +125 -0
- package/drizzle/0000_soft_amphibian.sql +20 -0
- package/drizzle/0001_warm_spyke.sql +13 -0
- package/drizzle/0002_peaceful_krista_starr.sql +13 -0
- package/drizzle/0003_easy_maginty.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +152 -0
- package/drizzle/meta/0001_snapshot.json +232 -0
- package/drizzle/meta/0002_snapshot.json +307 -0
- package/drizzle/meta/0003_snapshot.json +323 -0
- package/drizzle/meta/_journal.json +34 -0
- package/drizzle.config.ts +7 -0
- package/package.json +39 -0
- package/src/config.ts +8 -0
- package/src/detector.test.ts +894 -0
- package/src/detector.ts +361 -0
- package/src/drift-evaluator.test.ts +383 -0
- package/src/drift-evaluator.ts +231 -0
- package/src/index.ts +4 -0
- package/src/jobs/baseline-analyzer.ts +269 -0
- package/src/notification.ts +139 -0
- package/src/plugin.ts +85 -0
- package/src/router-cache.ts +89 -0
- package/src/router.ts +74 -0
- package/src/schema.ts +87 -0
- package/src/service.ts +163 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { describe, test, expect, mock } from "bun:test";
|
|
2
|
+
import { evaluateDrift } from "./drift-evaluator";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import type { AnomalySettings, FieldBaseline } from "@checkstack/anomaly-common";
|
|
5
|
+
|
|
6
|
+
function createBaseline(overrides: Partial<FieldBaseline> = {}): FieldBaseline {
|
|
7
|
+
return {
|
|
8
|
+
mean: 100,
|
|
9
|
+
stdDev: 10,
|
|
10
|
+
trendSlope: 0,
|
|
11
|
+
sampleCount: 100,
|
|
12
|
+
computedAt: "2026-04-29T00:00:00.000Z",
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function createMockCatalogClient() {
|
|
18
|
+
return {
|
|
19
|
+
getSystem: mock(async () => ({ name: "Test System" })),
|
|
20
|
+
notifySystemSubscribers: mock(async () => ({ notifiedCount: 0 })),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createMockLogger() {
|
|
25
|
+
return {
|
|
26
|
+
debug: mock(() => {}),
|
|
27
|
+
info: mock(() => {}),
|
|
28
|
+
warn: mock(() => {}),
|
|
29
|
+
error: mock(() => {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMockSignalService() {
|
|
34
|
+
return {
|
|
35
|
+
broadcast: mock(async () => {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createMockDb({ existingAnomaly }: { existingAnomaly?: Record<string, unknown> } = {}) {
|
|
40
|
+
const insertCalls: Array<Record<string, unknown>> = [];
|
|
41
|
+
const updateCalls: Array<Record<string, unknown>> = [];
|
|
42
|
+
const deleteCalls: unknown[] = [];
|
|
43
|
+
|
|
44
|
+
const makeThenable = (rows: unknown[]) => {
|
|
45
|
+
const promise = Promise.resolve(rows);
|
|
46
|
+
return {
|
|
47
|
+
then: promise.then.bind(promise),
|
|
48
|
+
catch: promise.catch.bind(promise),
|
|
49
|
+
limit: mock(() => Promise.resolve(rows)),
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const makeWhereChain = (rows: unknown[]) => ({
|
|
53
|
+
where: mock(() => makeThenable(rows)),
|
|
54
|
+
...makeThenable(rows),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const db = {
|
|
58
|
+
select: mock(() => ({
|
|
59
|
+
from: mock((table: unknown) => {
|
|
60
|
+
if (table === schema.anomalies) {
|
|
61
|
+
return makeWhereChain(existingAnomaly ? [existingAnomaly] : []);
|
|
62
|
+
}
|
|
63
|
+
return makeWhereChain([]);
|
|
64
|
+
}),
|
|
65
|
+
})),
|
|
66
|
+
insert: mock(() => ({
|
|
67
|
+
values: mock((values: Record<string, unknown>) => {
|
|
68
|
+
insertCalls.push(values);
|
|
69
|
+
return {
|
|
70
|
+
returning: mock(() =>
|
|
71
|
+
Promise.resolve([{ id: `drift-${insertCalls.length}` }]),
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}),
|
|
75
|
+
})),
|
|
76
|
+
update: mock(() => ({
|
|
77
|
+
set: mock((setValues: Record<string, unknown>) => ({
|
|
78
|
+
where: mock(() => {
|
|
79
|
+
updateCalls.push(setValues);
|
|
80
|
+
return Promise.resolve();
|
|
81
|
+
}),
|
|
82
|
+
})),
|
|
83
|
+
})),
|
|
84
|
+
delete: mock(() => ({
|
|
85
|
+
where: mock(() => {
|
|
86
|
+
deleteCalls.push(true);
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}),
|
|
89
|
+
})),
|
|
90
|
+
_insertCalls: insertCalls,
|
|
91
|
+
_updateCalls: updateCalls,
|
|
92
|
+
_deleteCalls: deleteCalls,
|
|
93
|
+
};
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const baseProps = {
|
|
98
|
+
systemId: "sys-1",
|
|
99
|
+
configurationId: "config-1",
|
|
100
|
+
fieldPath: "collectors.http.request.responseTimeMs",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const driftingBaseline = createBaseline({
|
|
104
|
+
mean: 200,
|
|
105
|
+
stdDev: 10,
|
|
106
|
+
trendSlope: 1.5, // slope*n = 150 > 2*10*1 = 20 → drifts hard
|
|
107
|
+
sampleCount: 100,
|
|
108
|
+
});
|
|
109
|
+
const stableBaseline = createBaseline({
|
|
110
|
+
trendSlope: 0,
|
|
111
|
+
sampleCount: 100,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const defaultTemplate: AnomalySettings = {
|
|
115
|
+
enabled: true,
|
|
116
|
+
sensitivity: 1,
|
|
117
|
+
confirmationWindow: 3,
|
|
118
|
+
baselineWindow: "7d",
|
|
119
|
+
notify: true,
|
|
120
|
+
driftEnabled: true,
|
|
121
|
+
driftThreshold: 2,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("evaluateDrift", () => {
|
|
125
|
+
describe("early exits", () => {
|
|
126
|
+
test("does nothing when sampleCount below cold-start threshold", async () => {
|
|
127
|
+
const db = createMockDb();
|
|
128
|
+
await evaluateDrift({
|
|
129
|
+
...baseProps,
|
|
130
|
+
baseline: createBaseline({ trendSlope: 5, stdDev: 10, sampleCount: 23 }),
|
|
131
|
+
schemaDirection: "lower-is-better",
|
|
132
|
+
templateConfig: defaultTemplate,
|
|
133
|
+
db: db as never,
|
|
134
|
+
catalogClient: createMockCatalogClient() as never,
|
|
135
|
+
logger: createMockLogger() as never,
|
|
136
|
+
});
|
|
137
|
+
expect(db._insertCalls.length).toBe(0);
|
|
138
|
+
expect(db._updateCalls.length).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("does nothing when direction is dominance", async () => {
|
|
142
|
+
const db = createMockDb();
|
|
143
|
+
await evaluateDrift({
|
|
144
|
+
...baseProps,
|
|
145
|
+
baseline: driftingBaseline,
|
|
146
|
+
schemaDirection: "dominance",
|
|
147
|
+
templateConfig: defaultTemplate,
|
|
148
|
+
db: db as never,
|
|
149
|
+
catalogClient: createMockCatalogClient() as never,
|
|
150
|
+
logger: createMockLogger() as never,
|
|
151
|
+
});
|
|
152
|
+
expect(db._insertCalls.length).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("does nothing when driftEnabled is false", async () => {
|
|
156
|
+
const db = createMockDb();
|
|
157
|
+
await evaluateDrift({
|
|
158
|
+
...baseProps,
|
|
159
|
+
baseline: driftingBaseline,
|
|
160
|
+
schemaDirection: "lower-is-better",
|
|
161
|
+
templateConfig: { ...defaultTemplate, driftEnabled: false },
|
|
162
|
+
db: db as never,
|
|
163
|
+
catalogClient: createMockCatalogClient() as never,
|
|
164
|
+
logger: createMockLogger() as never,
|
|
165
|
+
});
|
|
166
|
+
expect(db._insertCalls.length).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("does nothing when overall anomaly enabled is false", async () => {
|
|
170
|
+
const db = createMockDb();
|
|
171
|
+
await evaluateDrift({
|
|
172
|
+
...baseProps,
|
|
173
|
+
baseline: driftingBaseline,
|
|
174
|
+
schemaDirection: "lower-is-better",
|
|
175
|
+
templateConfig: { ...defaultTemplate, enabled: false },
|
|
176
|
+
db: db as never,
|
|
177
|
+
catalogClient: createMockCatalogClient() as never,
|
|
178
|
+
logger: createMockLogger() as never,
|
|
179
|
+
});
|
|
180
|
+
expect(db._insertCalls.length).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("does nothing when no direction available", async () => {
|
|
184
|
+
const db = createMockDb();
|
|
185
|
+
await evaluateDrift({
|
|
186
|
+
...baseProps,
|
|
187
|
+
baseline: driftingBaseline,
|
|
188
|
+
schemaDirection: undefined,
|
|
189
|
+
templateConfig: defaultTemplate,
|
|
190
|
+
db: db as never,
|
|
191
|
+
catalogClient: createMockCatalogClient() as never,
|
|
192
|
+
logger: createMockLogger() as never,
|
|
193
|
+
});
|
|
194
|
+
expect(db._insertCalls.length).toBe(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("state transitions", () => {
|
|
199
|
+
test("inserts a suspicious row when drift first detected", async () => {
|
|
200
|
+
const db = createMockDb();
|
|
201
|
+
const signalService = createMockSignalService();
|
|
202
|
+
await evaluateDrift({
|
|
203
|
+
...baseProps,
|
|
204
|
+
baseline: driftingBaseline,
|
|
205
|
+
schemaDirection: "lower-is-better",
|
|
206
|
+
templateConfig: defaultTemplate,
|
|
207
|
+
db: db as never,
|
|
208
|
+
catalogClient: createMockCatalogClient() as never,
|
|
209
|
+
logger: createMockLogger() as never,
|
|
210
|
+
signalService: signalService as never,
|
|
211
|
+
});
|
|
212
|
+
expect(db._insertCalls.length).toBe(1);
|
|
213
|
+
const inserted = db._insertCalls[0];
|
|
214
|
+
expect(inserted.kind).toBe("drift");
|
|
215
|
+
expect(inserted.state).toBe("suspicious");
|
|
216
|
+
expect(inserted.suspiciousRunCount).toBe(1);
|
|
217
|
+
expect(inserted.confirmationThreshold).toBe(2);
|
|
218
|
+
expect(inserted.direction).toBe("above");
|
|
219
|
+
expect(signalService.broadcast).toHaveBeenCalledTimes(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("increments count on second drifting analyzer run while still suspicious", async () => {
|
|
223
|
+
const existing = {
|
|
224
|
+
id: "drift-1",
|
|
225
|
+
state: "suspicious",
|
|
226
|
+
suspiciousRunCount: 1,
|
|
227
|
+
confirmationThreshold: 3, // not 2 — verifies confirmationThreshold from row, not const
|
|
228
|
+
};
|
|
229
|
+
const db = createMockDb({ existingAnomaly: existing });
|
|
230
|
+
await evaluateDrift({
|
|
231
|
+
...baseProps,
|
|
232
|
+
baseline: driftingBaseline,
|
|
233
|
+
schemaDirection: "lower-is-better",
|
|
234
|
+
templateConfig: defaultTemplate,
|
|
235
|
+
db: db as never,
|
|
236
|
+
catalogClient: createMockCatalogClient() as never,
|
|
237
|
+
logger: createMockLogger() as never,
|
|
238
|
+
});
|
|
239
|
+
expect(db._updateCalls.length).toBe(1);
|
|
240
|
+
expect(db._updateCalls[0].suspiciousRunCount).toBe(2);
|
|
241
|
+
expect(db._updateCalls[0].state).toBeUndefined(); // not promoted yet
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("promotes suspicious → anomaly + dispatches notification + broadcasts trend signal", async () => {
|
|
245
|
+
const existing = {
|
|
246
|
+
id: "drift-1",
|
|
247
|
+
state: "suspicious",
|
|
248
|
+
suspiciousRunCount: 1,
|
|
249
|
+
confirmationThreshold: 2,
|
|
250
|
+
};
|
|
251
|
+
const db = createMockDb({ existingAnomaly: existing });
|
|
252
|
+
const catalog = createMockCatalogClient();
|
|
253
|
+
const signalService = createMockSignalService();
|
|
254
|
+
await evaluateDrift({
|
|
255
|
+
...baseProps,
|
|
256
|
+
baseline: driftingBaseline,
|
|
257
|
+
schemaDirection: "lower-is-better",
|
|
258
|
+
templateConfig: defaultTemplate,
|
|
259
|
+
db: db as never,
|
|
260
|
+
catalogClient: catalog as never,
|
|
261
|
+
logger: createMockLogger() as never,
|
|
262
|
+
signalService: signalService as never,
|
|
263
|
+
});
|
|
264
|
+
expect(db._updateCalls.length).toBe(1);
|
|
265
|
+
expect(db._updateCalls[0].state).toBe("anomaly");
|
|
266
|
+
expect(catalog.notifySystemSubscribers).toHaveBeenCalledTimes(1);
|
|
267
|
+
// Two signals: state change + trend detected
|
|
268
|
+
expect(signalService.broadcast).toHaveBeenCalledTimes(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("refreshes observedValue/deviation while staying in anomaly state", async () => {
|
|
272
|
+
const existing = {
|
|
273
|
+
id: "drift-1",
|
|
274
|
+
state: "anomaly",
|
|
275
|
+
suspiciousRunCount: 2,
|
|
276
|
+
confirmationThreshold: 2,
|
|
277
|
+
};
|
|
278
|
+
const db = createMockDb({ existingAnomaly: existing });
|
|
279
|
+
const catalog = createMockCatalogClient();
|
|
280
|
+
await evaluateDrift({
|
|
281
|
+
...baseProps,
|
|
282
|
+
baseline: driftingBaseline,
|
|
283
|
+
schemaDirection: "lower-is-better",
|
|
284
|
+
templateConfig: defaultTemplate,
|
|
285
|
+
db: db as never,
|
|
286
|
+
catalogClient: catalog as never,
|
|
287
|
+
logger: createMockLogger() as never,
|
|
288
|
+
});
|
|
289
|
+
expect(db._updateCalls.length).toBe(1);
|
|
290
|
+
expect(db._updateCalls[0].state).toBeUndefined();
|
|
291
|
+
expect(catalog.notifySystemSubscribers).not.toHaveBeenCalled();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("deletes suspicious row when drift goes away before confirmation", async () => {
|
|
295
|
+
const existing = {
|
|
296
|
+
id: "drift-1",
|
|
297
|
+
state: "suspicious",
|
|
298
|
+
suspiciousRunCount: 1,
|
|
299
|
+
confirmationThreshold: 2,
|
|
300
|
+
};
|
|
301
|
+
const db = createMockDb({ existingAnomaly: existing });
|
|
302
|
+
await evaluateDrift({
|
|
303
|
+
...baseProps,
|
|
304
|
+
baseline: stableBaseline,
|
|
305
|
+
schemaDirection: "lower-is-better",
|
|
306
|
+
templateConfig: defaultTemplate,
|
|
307
|
+
db: db as never,
|
|
308
|
+
catalogClient: createMockCatalogClient() as never,
|
|
309
|
+
logger: createMockLogger() as never,
|
|
310
|
+
});
|
|
311
|
+
expect(db._deleteCalls.length).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("transitions anomaly → recovered when drift clears + dispatches recovery", async () => {
|
|
315
|
+
const existing = {
|
|
316
|
+
id: "drift-1",
|
|
317
|
+
state: "anomaly",
|
|
318
|
+
suspiciousRunCount: 2,
|
|
319
|
+
confirmationThreshold: 2,
|
|
320
|
+
};
|
|
321
|
+
const db = createMockDb({ existingAnomaly: existing });
|
|
322
|
+
const catalog = createMockCatalogClient();
|
|
323
|
+
await evaluateDrift({
|
|
324
|
+
...baseProps,
|
|
325
|
+
baseline: stableBaseline,
|
|
326
|
+
schemaDirection: "lower-is-better",
|
|
327
|
+
templateConfig: defaultTemplate,
|
|
328
|
+
db: db as never,
|
|
329
|
+
catalogClient: catalog as never,
|
|
330
|
+
logger: createMockLogger() as never,
|
|
331
|
+
});
|
|
332
|
+
expect(db._updateCalls.length).toBe(1);
|
|
333
|
+
expect(db._updateCalls[0].state).toBe("recovered");
|
|
334
|
+
expect(catalog.notifySystemSubscribers).toHaveBeenCalledTimes(1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("does nothing when no row and no drift (steady state)", async () => {
|
|
338
|
+
const db = createMockDb();
|
|
339
|
+
await evaluateDrift({
|
|
340
|
+
...baseProps,
|
|
341
|
+
baseline: stableBaseline,
|
|
342
|
+
schemaDirection: "lower-is-better",
|
|
343
|
+
templateConfig: defaultTemplate,
|
|
344
|
+
db: db as never,
|
|
345
|
+
catalogClient: createMockCatalogClient() as never,
|
|
346
|
+
logger: createMockLogger() as never,
|
|
347
|
+
});
|
|
348
|
+
expect(db._insertCalls.length).toBe(0);
|
|
349
|
+
expect(db._updateCalls.length).toBe(0);
|
|
350
|
+
expect(db._deleteCalls.length).toBe(0);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("direction-specific behavior", () => {
|
|
355
|
+
test("higher-is-better only drifts on negative slope", async () => {
|
|
356
|
+
const db = createMockDb();
|
|
357
|
+
// Positive slope on a higher-is-better field is improvement, not drift.
|
|
358
|
+
await evaluateDrift({
|
|
359
|
+
...baseProps,
|
|
360
|
+
baseline: createBaseline({ trendSlope: 1.5, stdDev: 10, sampleCount: 100, mean: 95 }),
|
|
361
|
+
schemaDirection: "higher-is-better",
|
|
362
|
+
templateConfig: defaultTemplate,
|
|
363
|
+
db: db as never,
|
|
364
|
+
catalogClient: createMockCatalogClient() as never,
|
|
365
|
+
logger: createMockLogger() as never,
|
|
366
|
+
});
|
|
367
|
+
expect(db._insertCalls.length).toBe(0);
|
|
368
|
+
|
|
369
|
+
const db2 = createMockDb();
|
|
370
|
+
await evaluateDrift({
|
|
371
|
+
...baseProps,
|
|
372
|
+
baseline: createBaseline({ trendSlope: -1.5, stdDev: 10, sampleCount: 100, mean: 95 }),
|
|
373
|
+
schemaDirection: "higher-is-better",
|
|
374
|
+
templateConfig: defaultTemplate,
|
|
375
|
+
db: db2 as never,
|
|
376
|
+
catalogClient: createMockCatalogClient() as never,
|
|
377
|
+
logger: createMockLogger() as never,
|
|
378
|
+
});
|
|
379
|
+
expect(db2._insertCalls.length).toBe(1);
|
|
380
|
+
expect(db2._insertCalls[0].direction).toBe("below");
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import type { Logger, SafeDatabase } from "@checkstack/backend-api";
|
|
2
|
+
import type { CatalogApi } from "@checkstack/catalog-common";
|
|
3
|
+
import type { InferClient } from "@checkstack/common";
|
|
4
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
5
|
+
import {
|
|
6
|
+
ANOMALY_STATE_CHANGED,
|
|
7
|
+
ANOMALY_TREND_DETECTED,
|
|
8
|
+
detectDrift,
|
|
9
|
+
resolveEffectiveConfig,
|
|
10
|
+
type AnomalyDirection,
|
|
11
|
+
type AnomalySettings,
|
|
12
|
+
type FieldBaseline,
|
|
13
|
+
} from "@checkstack/anomaly-common";
|
|
14
|
+
import { and, eq } from "drizzle-orm";
|
|
15
|
+
import * as schema from "./schema";
|
|
16
|
+
import { dispatchAnomalyNotification } from "./notification";
|
|
17
|
+
|
|
18
|
+
/** Minimum analyzer-run count required before suspicious drift is confirmed. */
|
|
19
|
+
const DRIFT_CONFIRMATION_THRESHOLD = 2;
|
|
20
|
+
/** Minimum sample count before drift detection runs (matches spike cold-start). */
|
|
21
|
+
const DRIFT_MIN_SAMPLES = 24;
|
|
22
|
+
|
|
23
|
+
export interface EvaluateDriftInput {
|
|
24
|
+
db: SafeDatabase<typeof schema>;
|
|
25
|
+
logger: Logger;
|
|
26
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
27
|
+
signalService?: SignalService;
|
|
28
|
+
systemId: string;
|
|
29
|
+
configurationId: string;
|
|
30
|
+
fieldPath: string;
|
|
31
|
+
baseline: FieldBaseline;
|
|
32
|
+
/** Direction declared by the schema for this field, if any. */
|
|
33
|
+
schemaDirection?: AnomalyDirection;
|
|
34
|
+
templateConfig?: AnomalySettings;
|
|
35
|
+
assignmentConfig?: Partial<AnomalySettings>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Drives the drift state machine for a single field at the cadence of the
|
|
40
|
+
* background baseline analyzer. Counterpart to the spike `processCheckCompleted`
|
|
41
|
+
* inline detector — same lifecycle, same `anomalies` table, but `kind = 'drift'`.
|
|
42
|
+
*
|
|
43
|
+
* Idempotent under repeat analyzer runs: if drift is detected and no row exists,
|
|
44
|
+
* a suspicious row is created; subsequent runs increment the count, transition
|
|
45
|
+
* to anomaly when the threshold is reached, and delete/recover when slope falls
|
|
46
|
+
* back inside the band.
|
|
47
|
+
*/
|
|
48
|
+
export async function evaluateDrift({
|
|
49
|
+
db,
|
|
50
|
+
logger,
|
|
51
|
+
catalogClient,
|
|
52
|
+
signalService,
|
|
53
|
+
systemId,
|
|
54
|
+
configurationId,
|
|
55
|
+
fieldPath,
|
|
56
|
+
baseline,
|
|
57
|
+
schemaDirection,
|
|
58
|
+
templateConfig,
|
|
59
|
+
assignmentConfig,
|
|
60
|
+
}: EvaluateDriftInput): Promise<void> {
|
|
61
|
+
const {
|
|
62
|
+
enabled,
|
|
63
|
+
sensitivity,
|
|
64
|
+
direction: configDirection,
|
|
65
|
+
driftEnabled,
|
|
66
|
+
driftThreshold,
|
|
67
|
+
} = resolveEffectiveConfig(fieldPath, templateConfig, assignmentConfig);
|
|
68
|
+
|
|
69
|
+
const direction = configDirection ?? schemaDirection;
|
|
70
|
+
|
|
71
|
+
// Skip when feature is off, direction unsupported, or in cold start.
|
|
72
|
+
if (!enabled || !driftEnabled) return;
|
|
73
|
+
if (!direction || direction === "dominance") return;
|
|
74
|
+
if (baseline.sampleCount < DRIFT_MIN_SAMPLES) return;
|
|
75
|
+
|
|
76
|
+
const driftResult = detectDrift({
|
|
77
|
+
slope: baseline.trendSlope,
|
|
78
|
+
stdDev: baseline.stdDev,
|
|
79
|
+
sampleCount: baseline.sampleCount,
|
|
80
|
+
direction,
|
|
81
|
+
sensitivity,
|
|
82
|
+
threshold: driftThreshold,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const [existing] = await db
|
|
86
|
+
.select()
|
|
87
|
+
.from(schema.anomalies)
|
|
88
|
+
.where(
|
|
89
|
+
and(
|
|
90
|
+
eq(schema.anomalies.systemId, systemId),
|
|
91
|
+
eq(schema.anomalies.configurationId, configurationId),
|
|
92
|
+
eq(schema.anomalies.fieldPath, fieldPath),
|
|
93
|
+
eq(schema.anomalies.kind, "drift"),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
.limit(1);
|
|
97
|
+
|
|
98
|
+
if (driftResult.drifting) {
|
|
99
|
+
if (!existing) {
|
|
100
|
+
const [inserted] = await db
|
|
101
|
+
.insert(schema.anomalies)
|
|
102
|
+
.values({
|
|
103
|
+
systemId,
|
|
104
|
+
configurationId,
|
|
105
|
+
fieldPath,
|
|
106
|
+
kind: "drift",
|
|
107
|
+
state: "suspicious",
|
|
108
|
+
direction: driftResult.driftDirection,
|
|
109
|
+
baselineValue: baseline.mean,
|
|
110
|
+
baselineStdDev: baseline.stdDev,
|
|
111
|
+
observedValue: baseline.mean.toString(),
|
|
112
|
+
deviation: driftResult.deviationSigmas,
|
|
113
|
+
suspiciousRunCount: 1,
|
|
114
|
+
confirmationThreshold: DRIFT_CONFIRMATION_THRESHOLD,
|
|
115
|
+
})
|
|
116
|
+
.returning({ id: schema.anomalies.id });
|
|
117
|
+
|
|
118
|
+
if (signalService && inserted) {
|
|
119
|
+
await signalService.broadcast(ANOMALY_STATE_CHANGED, {
|
|
120
|
+
systemId,
|
|
121
|
+
anomalyId: inserted.id,
|
|
122
|
+
newState: "suspicious",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (existing.state === "suspicious") {
|
|
129
|
+
const newCount = existing.suspiciousRunCount + 1;
|
|
130
|
+
if (newCount >= existing.confirmationThreshold) {
|
|
131
|
+
await db
|
|
132
|
+
.update(schema.anomalies)
|
|
133
|
+
.set({
|
|
134
|
+
state: "anomaly",
|
|
135
|
+
confirmedAt: new Date(),
|
|
136
|
+
observedValue: baseline.mean.toString(),
|
|
137
|
+
deviation: driftResult.deviationSigmas,
|
|
138
|
+
})
|
|
139
|
+
.where(eq(schema.anomalies.id, existing.id));
|
|
140
|
+
logger.warn(`Drift confirmed for ${systemId} on ${fieldPath}`);
|
|
141
|
+
|
|
142
|
+
if (signalService) {
|
|
143
|
+
await signalService.broadcast(ANOMALY_STATE_CHANGED, {
|
|
144
|
+
systemId,
|
|
145
|
+
anomalyId: existing.id,
|
|
146
|
+
newState: "anomaly",
|
|
147
|
+
});
|
|
148
|
+
await signalService.broadcast(ANOMALY_TREND_DETECTED, {
|
|
149
|
+
systemId,
|
|
150
|
+
anomalyId: existing.id,
|
|
151
|
+
fieldPath,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await dispatchAnomalyNotification({
|
|
156
|
+
action: "drift_confirmed",
|
|
157
|
+
systemId,
|
|
158
|
+
fieldPath,
|
|
159
|
+
observedValue: baseline.mean,
|
|
160
|
+
baselineMean: baseline.mean,
|
|
161
|
+
projectedChange: driftResult.projectedChange,
|
|
162
|
+
catalogClient,
|
|
163
|
+
logger,
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
await db
|
|
168
|
+
.update(schema.anomalies)
|
|
169
|
+
.set({
|
|
170
|
+
suspiciousRunCount: newCount,
|
|
171
|
+
observedValue: baseline.mean.toString(),
|
|
172
|
+
deviation: driftResult.deviationSigmas,
|
|
173
|
+
})
|
|
174
|
+
.where(eq(schema.anomalies.id, existing.id));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (existing.state === "anomaly") {
|
|
179
|
+
await db
|
|
180
|
+
.update(schema.anomalies)
|
|
181
|
+
.set({
|
|
182
|
+
observedValue: baseline.mean.toString(),
|
|
183
|
+
deviation: driftResult.deviationSigmas,
|
|
184
|
+
})
|
|
185
|
+
.where(eq(schema.anomalies.id, existing.id));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// For 'recovered' rows: leave the historical record alone — a fresh drift
|
|
190
|
+
// would create a new row on the next cycle.
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Not drifting now.
|
|
195
|
+
if (!existing) return;
|
|
196
|
+
|
|
197
|
+
if (existing.state === "suspicious") {
|
|
198
|
+
await db.delete(schema.anomalies).where(eq(schema.anomalies.id, existing.id));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (existing.state === "anomaly") {
|
|
203
|
+
await db
|
|
204
|
+
.update(schema.anomalies)
|
|
205
|
+
.set({
|
|
206
|
+
state: "recovered",
|
|
207
|
+
recoveredAt: new Date(),
|
|
208
|
+
observedValue: baseline.mean.toString(),
|
|
209
|
+
})
|
|
210
|
+
.where(eq(schema.anomalies.id, existing.id));
|
|
211
|
+
logger.info(`Drift recovered for ${systemId} on ${fieldPath}`);
|
|
212
|
+
|
|
213
|
+
if (signalService) {
|
|
214
|
+
await signalService.broadcast(ANOMALY_STATE_CHANGED, {
|
|
215
|
+
systemId,
|
|
216
|
+
anomalyId: existing.id,
|
|
217
|
+
newState: "recovered",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await dispatchAnomalyNotification({
|
|
222
|
+
action: "drift_recovered",
|
|
223
|
+
systemId,
|
|
224
|
+
fieldPath,
|
|
225
|
+
observedValue: baseline.mean,
|
|
226
|
+
baselineMean: baseline.mean,
|
|
227
|
+
catalogClient,
|
|
228
|
+
logger,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/index.ts
ADDED