@checkstack/anomaly-common 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 +41 -0
- package/package.json +28 -0
- package/src/access.ts +25 -0
- package/src/engine/baseline.test.ts +94 -0
- package/src/engine/baseline.ts +56 -0
- package/src/engine/config.test.ts +211 -0
- package/src/engine/config.ts +71 -0
- package/src/engine/drift.test.ts +205 -0
- package/src/engine/drift.ts +80 -0
- package/src/engine/thresholds.test.ts +108 -0
- package/src/engine/thresholds.ts +72 -0
- package/src/index.ts +42 -0
- package/src/plugin-metadata.ts +5 -0
- package/src/rpc-contract.ts +158 -0
- package/src/schema.ts +60 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @checkstack/anomaly-common
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8d1ef12: ## Anomaly Detection & UI Improvements
|
|
8
|
+
|
|
9
|
+
### Anomaly Detection Enhancements (Phase 2)
|
|
10
|
+
|
|
11
|
+
- **`@checkstack/anomaly-backend`**: Implemented background baseline analyzer jobs and anomaly trend deviation detection mechanics.
|
|
12
|
+
- **`@checkstack/anomaly-common`**: Added new baseline statistical logic and inference rules.
|
|
13
|
+
- **`@checkstack/anomaly-frontend`**: Added new Anomaly Widget and refactored system detail rendering to be more human-readable.
|
|
14
|
+
- **`@checkstack/dashboard-frontend`**: Refined the global anomaly widget and fixed hardcoded access gating to render appropriately.
|
|
15
|
+
- **`@checkstack/healthcheck-backend`**: Connected executor telemetry to the anomaly pipeline.
|
|
16
|
+
- **`@checkstack/healthcheck-frontend`**: Reconciled baseline display consistency in Drawer and charts.
|
|
17
|
+
|
|
18
|
+
### Notification Identifiers
|
|
19
|
+
|
|
20
|
+
- **`@checkstack/incident-backend`**: Resolved system IDs to human-readable System Names within Incident notifications to eliminate ID-only alert content.
|
|
21
|
+
- **`@checkstack/maintenance-backend`**: Adopted the same resolution strategy for Maintenance notifications to keep parity.
|
|
22
|
+
|
|
23
|
+
### UI Experience
|
|
24
|
+
|
|
25
|
+
- **`@checkstack/incident-frontend`**: Fixed the "Back to X" BackLink to properly use `react-router` hook `useNavigate` instead of doing a full application reload.
|
|
26
|
+
- **`@checkstack/healthcheck-frontend`**: Implemented `useNavigate` for seamless SPA back-linking.
|
|
27
|
+
- **`@checkstack/integration-frontend`**: Updated connections and delivery logs links to navigate without hard reloads.
|
|
28
|
+
|
|
29
|
+
- 8d1ef12: Phase 2 of anomaly detection: trend drift detection.
|
|
30
|
+
|
|
31
|
+
The background baseline analyzer now computes a linear regression slope across each field's chronologically-ordered history and runs a `detectDrift` evaluator that catches gradual "creeping degradation" never reaching the 3σ spike threshold. Drifts share the same `anomalies` table as spike anomalies via a new `kind` column (`spike` | `drift`, default `spike`); the existing suspicious → anomaly → recovered lifecycle is reused, ticking at the analyzer's hourly cadence with a default 2-run confirmation window.
|
|
32
|
+
|
|
33
|
+
User-facing additions: a Trend Drift toggle and threshold slider on both the template and assignment anomaly settings panels (with per-field overrides), drift rows in the System Anomaly widget, dashed regression-line overlays on the auto-generated line charts, and a new `ANOMALY_TREND_DETECTED` signal for live UI updates. Plugin authors can disable drift per chartable field via `x-anomaly-drift-enabled: false` or tighten/loosen it via `x-anomaly-drift-threshold`.
|
|
34
|
+
|
|
35
|
+
- 8d1ef12: Added Categorical Anomaly Detection (Dominance Drift) support for non-numeric healthcheck values, and introduced Slider UI components for sensitivity and confirmation window anomaly settings.
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- Updated dependencies [8d1ef12]
|
|
40
|
+
- @checkstack/common@0.7.0
|
|
41
|
+
- @checkstack/signal-common@0.1.10
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/anomaly-common",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"import": "./src/index.ts"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@checkstack/common": "0.6.5",
|
|
12
|
+
"@checkstack/signal-common": "0.1.9",
|
|
13
|
+
"zod": "^4.2.1"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"typescript": "^5.7.2",
|
|
17
|
+
"@checkstack/tsconfig": "0.0.5",
|
|
18
|
+
"@checkstack/scripts": "0.1.2"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "bun run lint:code",
|
|
23
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
24
|
+
},
|
|
25
|
+
"checkstack": {
|
|
26
|
+
"type": "common"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { accessPair } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
export const anomalyAccess = {
|
|
4
|
+
feed: accessPair(
|
|
5
|
+
"anomaly_feed",
|
|
6
|
+
{
|
|
7
|
+
read: {
|
|
8
|
+
description: "View anomaly feed and status badges",
|
|
9
|
+
isDefault: true,
|
|
10
|
+
isPublic: true,
|
|
11
|
+
},
|
|
12
|
+
manage: {
|
|
13
|
+
description: "Manage anomaly configuration and noise floors",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
idParam: "systemId",
|
|
18
|
+
}
|
|
19
|
+
),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const anomalyAccessRules = [
|
|
23
|
+
anomalyAccess.feed.read,
|
|
24
|
+
anomalyAccess.feed.manage,
|
|
25
|
+
];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { computeMean, computeStdDev, computeLinearRegressionSlope, computeDominance } from "./baseline";
|
|
3
|
+
|
|
4
|
+
describe("Anomaly Engine - Baseline Mathematics", () => {
|
|
5
|
+
describe("computeMean", () => {
|
|
6
|
+
test("computes correct mean for standard array", () => {
|
|
7
|
+
expect(computeMean([2, 4, 6, 8])).toBe(5);
|
|
8
|
+
expect(computeMean([1, 1, 1, 1])).toBe(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("returns 0 for empty array", () => {
|
|
12
|
+
expect(computeMean([])).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("handles negative numbers and decimals", () => {
|
|
16
|
+
expect(computeMean([-1.5, 2.5, 0])).toBeCloseTo(0.33333333, 5);
|
|
17
|
+
expect(computeMean([-10, -20, -30])).toBe(-20);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("computeStdDev", () => {
|
|
22
|
+
test("computes correct population standard deviation", () => {
|
|
23
|
+
// Variance = 5, StdDev = sqrt(5) ≈ 2.236
|
|
24
|
+
expect(computeStdDev([2, 4, 4, 4, 5, 5, 7, 9])).toBeCloseTo(2.0, 5);
|
|
25
|
+
|
|
26
|
+
// Simple 0,2,4 -> mean 2 -> sum(sq(diff)) = 4+0+4 = 8 -> var = 8/3 = 2.666
|
|
27
|
+
// sqrt(2.666) ≈ 1.63299
|
|
28
|
+
expect(computeStdDev([0, 2, 4])).toBeCloseTo(1.63299, 5);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("returns 0 for single item or empty array", () => {
|
|
32
|
+
expect(computeStdDev([5])).toBe(0);
|
|
33
|
+
expect(computeStdDev([])).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("uses provided mean to optimize", () => {
|
|
37
|
+
// 0, 2, 4, mean = 2
|
|
38
|
+
expect(computeStdDev([0, 2, 4], 2)).toBeCloseTo(1.63299, 5);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("computeLinearRegressionSlope", () => {
|
|
43
|
+
test("computes correct slope for perfectly linear data", () => {
|
|
44
|
+
expect(computeLinearRegressionSlope([0, 2, 4, 6, 8])).toBe(2);
|
|
45
|
+
expect(computeLinearRegressionSlope([10, 8, 6, 4])).toBe(-2);
|
|
46
|
+
expect(computeLinearRegressionSlope([5, 5, 5, 5])).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("computes correct slope for noisy data", () => {
|
|
50
|
+
const values = [1, 3, 2, 4, 5];
|
|
51
|
+
// x: 0,1,2,3,4. mean x: 2. y: 1,3,2,4,5. mean y: 3
|
|
52
|
+
// cov(x,y)/var(x) -> 0.9
|
|
53
|
+
expect(computeLinearRegressionSlope(values)).toBeCloseTo(0.9, 5);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns 0 for insufficient data", () => {
|
|
57
|
+
expect(computeLinearRegressionSlope([5])).toBe(0);
|
|
58
|
+
expect(computeLinearRegressionSlope([])).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("computeDominance", () => {
|
|
63
|
+
test("computes correct dominance ratio and value", () => {
|
|
64
|
+
const result = computeDominance(["success", "success", "error", "success", "timeout"]);
|
|
65
|
+
expect(result.dominantValue).toBe("success");
|
|
66
|
+
expect(result.dominantRatio).toBe(0.6); // 3 / 5
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("handles boolean values", () => {
|
|
70
|
+
const result = computeDominance([true, true, false]);
|
|
71
|
+
expect(result.dominantValue).toBe(true);
|
|
72
|
+
expect(result.dominantRatio).toBeCloseTo(0.6667, 4);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("handles numeric values", () => {
|
|
76
|
+
const result = computeDominance([200, 200, 404, 500, 200]);
|
|
77
|
+
expect(result.dominantValue).toBe(200);
|
|
78
|
+
expect(result.dominantRatio).toBe(0.6);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("returns empty object for empty array", () => {
|
|
82
|
+
const result = computeDominance([]);
|
|
83
|
+
expect(result.dominantValue).toBeUndefined();
|
|
84
|
+
expect(result.dominantRatio).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns first dominant value if tied", () => {
|
|
88
|
+
const result = computeDominance(["a", "b", "a", "b"]);
|
|
89
|
+
// "a" was reached count=2 first
|
|
90
|
+
expect(result.dominantValue).toBe("a");
|
|
91
|
+
expect(result.dominantRatio).toBe(0.5);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function computeMean(values: number[]): number {
|
|
2
|
+
if (values.length === 0) return 0;
|
|
3
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
4
|
+
return sum / values.length;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function computeStdDev(values: number[], mean?: number): number {
|
|
8
|
+
if (values.length <= 1) return 0;
|
|
9
|
+
const m = mean ?? computeMean(values);
|
|
10
|
+
const variance = values.reduce((sq, n) => sq + Math.pow(n - m, 2), 0) / values.length;
|
|
11
|
+
return Math.sqrt(variance);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function computeLinearRegressionSlope(values: number[]): number {
|
|
15
|
+
if (values.length <= 1) return 0;
|
|
16
|
+
// x = index, y = value
|
|
17
|
+
const n = values.length;
|
|
18
|
+
let sumX = 0;
|
|
19
|
+
let sumY = 0;
|
|
20
|
+
let sumXY = 0;
|
|
21
|
+
let sumXX = 0;
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < n; i++) {
|
|
24
|
+
sumX += i;
|
|
25
|
+
sumY += values[i];
|
|
26
|
+
sumXY += i * values[i];
|
|
27
|
+
sumXX += i * i;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const denominator = (n * sumXX - sumX * sumX);
|
|
31
|
+
if (denominator === 0) return 0;
|
|
32
|
+
|
|
33
|
+
return (n * sumXY - sumX * sumY) / denominator;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function computeDominance(values: (string | boolean | number)[]): { dominantValue?: string | boolean | number, dominantRatio?: number } {
|
|
37
|
+
if (values.length === 0) return {};
|
|
38
|
+
|
|
39
|
+
const counts = new Map<string | boolean | number, number>();
|
|
40
|
+
let maxCount = 0;
|
|
41
|
+
let dominantValue: string | boolean | number | undefined = undefined;
|
|
42
|
+
|
|
43
|
+
for (const v of values) {
|
|
44
|
+
const count = (counts.get(v) || 0) + 1;
|
|
45
|
+
counts.set(v, count);
|
|
46
|
+
if (count > maxCount) {
|
|
47
|
+
maxCount = count;
|
|
48
|
+
dominantValue = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
dominantValue,
|
|
54
|
+
dominantRatio: maxCount / values.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { resolveEffectiveConfig } from "./config";
|
|
3
|
+
import type { AnomalySettings } from "../schema";
|
|
4
|
+
|
|
5
|
+
describe("Anomaly Engine - Config Override Mechanism", () => {
|
|
6
|
+
const defaultTemplate: AnomalySettings = {
|
|
7
|
+
enabled: true,
|
|
8
|
+
sensitivity: 1,
|
|
9
|
+
confirmationWindow: 3,
|
|
10
|
+
baselineWindow: "7d",
|
|
11
|
+
notify: true,
|
|
12
|
+
driftEnabled: true,
|
|
13
|
+
driftThreshold: 2,
|
|
14
|
+
fieldOverrides: {},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
test("uses defaults when nothing is provided", () => {
|
|
18
|
+
const result = resolveEffectiveConfig("some.field");
|
|
19
|
+
expect(result.enabled).toBe(true);
|
|
20
|
+
expect(result.sensitivity).toBe(1);
|
|
21
|
+
expect(result.confirmationWindow).toBe(3);
|
|
22
|
+
expect(result.direction).toBeUndefined();
|
|
23
|
+
expect(result.driftEnabled).toBe(true);
|
|
24
|
+
expect(result.driftThreshold).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("uses template configuration as base", () => {
|
|
28
|
+
const template: AnomalySettings = {
|
|
29
|
+
...defaultTemplate,
|
|
30
|
+
enabled: false,
|
|
31
|
+
sensitivity: 2,
|
|
32
|
+
confirmationWindow: 5,
|
|
33
|
+
};
|
|
34
|
+
const result = resolveEffectiveConfig("some.field", template);
|
|
35
|
+
expect(result.enabled).toBe(false);
|
|
36
|
+
expect(result.sensitivity).toBe(2);
|
|
37
|
+
expect(result.confirmationWindow).toBe(5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("assignment overrides template global settings", () => {
|
|
41
|
+
const template: AnomalySettings = {
|
|
42
|
+
...defaultTemplate,
|
|
43
|
+
sensitivity: 1,
|
|
44
|
+
confirmationWindow: 3,
|
|
45
|
+
};
|
|
46
|
+
const assignment: Partial<AnomalySettings> = {
|
|
47
|
+
sensitivity: 3,
|
|
48
|
+
confirmationWindow: 1,
|
|
49
|
+
};
|
|
50
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
51
|
+
expect(result.sensitivity).toBe(3);
|
|
52
|
+
expect(result.confirmationWindow).toBe(1);
|
|
53
|
+
expect(result.enabled).toBe(true); // fallbacks to template
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("template field override overrides template global", () => {
|
|
57
|
+
const template: AnomalySettings = {
|
|
58
|
+
...defaultTemplate,
|
|
59
|
+
sensitivity: 1,
|
|
60
|
+
fieldOverrides: {
|
|
61
|
+
"some.field": {
|
|
62
|
+
sensitivity: 5,
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const result = resolveEffectiveConfig("some.field", template);
|
|
67
|
+
expect(result.sensitivity).toBe(5);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("assignment field override takes absolute precedence", () => {
|
|
71
|
+
const template: AnomalySettings = {
|
|
72
|
+
...defaultTemplate,
|
|
73
|
+
sensitivity: 1,
|
|
74
|
+
fieldOverrides: {
|
|
75
|
+
"some.field": {
|
|
76
|
+
sensitivity: 5, // template field level
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const assignment: Partial<AnomalySettings> = {
|
|
81
|
+
sensitivity: 2, // assignment global level
|
|
82
|
+
fieldOverrides: {
|
|
83
|
+
"some.field": {
|
|
84
|
+
sensitivity: 10, // assignment field level
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
89
|
+
expect(result.sensitivity).toBe(10);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("template field override is not overridden by assignment global", () => {
|
|
93
|
+
// This is intentional: field-level overrides (from any layer) represent
|
|
94
|
+
// more specific intent and take precedence over global overrides.
|
|
95
|
+
// See resolveEffectiveConfig JSDoc for the full resolution order.
|
|
96
|
+
const template: AnomalySettings = {
|
|
97
|
+
...defaultTemplate,
|
|
98
|
+
fieldOverrides: {
|
|
99
|
+
"some.field": {
|
|
100
|
+
enabled: false,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const assignment: Partial<AnomalySettings> = {
|
|
105
|
+
enabled: true,
|
|
106
|
+
};
|
|
107
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
108
|
+
|
|
109
|
+
// fieldConfig exists (template field: { enabled: false })
|
|
110
|
+
// So fieldConfig.enabled is false — field-level is more specific than assignment global.
|
|
111
|
+
expect(result.enabled).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("template field override sets direction", () => {
|
|
115
|
+
const template: AnomalySettings = {
|
|
116
|
+
...defaultTemplate,
|
|
117
|
+
fieldOverrides: {
|
|
118
|
+
"some.field": {
|
|
119
|
+
direction: "higher-is-better",
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const result = resolveEffectiveConfig("some.field", template);
|
|
124
|
+
expect(result.direction).toBe("higher-is-better");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("assignment field override direction beats template field direction", () => {
|
|
128
|
+
const template: AnomalySettings = {
|
|
129
|
+
...defaultTemplate,
|
|
130
|
+
fieldOverrides: {
|
|
131
|
+
"some.field": { direction: "lower-is-better" },
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const assignment: Partial<AnomalySettings> = {
|
|
135
|
+
fieldOverrides: {
|
|
136
|
+
"some.field": { direction: "deviation" },
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
140
|
+
expect(result.direction).toBe("deviation");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("direction is undefined when no override sets it (caller falls back to schema)", () => {
|
|
144
|
+
const result = resolveEffectiveConfig("some.field", defaultTemplate);
|
|
145
|
+
expect(result.direction).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("preserves explicit falsy values", () => {
|
|
149
|
+
const template: AnomalySettings = {
|
|
150
|
+
...defaultTemplate,
|
|
151
|
+
sensitivity: 2,
|
|
152
|
+
};
|
|
153
|
+
const assignment: Partial<AnomalySettings> = {
|
|
154
|
+
sensitivity: 0,
|
|
155
|
+
};
|
|
156
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
157
|
+
expect(result.sensitivity).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("drift settings", () => {
|
|
161
|
+
test("template driftEnabled is honored", () => {
|
|
162
|
+
const template: AnomalySettings = {
|
|
163
|
+
...defaultTemplate,
|
|
164
|
+
driftEnabled: false,
|
|
165
|
+
};
|
|
166
|
+
const result = resolveEffectiveConfig("some.field", template);
|
|
167
|
+
expect(result.driftEnabled).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("assignment driftEnabled overrides template", () => {
|
|
171
|
+
const template: AnomalySettings = {
|
|
172
|
+
...defaultTemplate,
|
|
173
|
+
driftEnabled: true,
|
|
174
|
+
};
|
|
175
|
+
const assignment: Partial<AnomalySettings> = { driftEnabled: false };
|
|
176
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
177
|
+
expect(result.driftEnabled).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("field-level driftEnabled wins over assignment global", () => {
|
|
181
|
+
const template: AnomalySettings = {
|
|
182
|
+
...defaultTemplate,
|
|
183
|
+
fieldOverrides: {
|
|
184
|
+
"some.field": { driftEnabled: false },
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
const assignment: Partial<AnomalySettings> = { driftEnabled: true };
|
|
188
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
189
|
+
expect(result.driftEnabled).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("driftThreshold cascades through layers", () => {
|
|
193
|
+
const template: AnomalySettings = { ...defaultTemplate, driftThreshold: 3 };
|
|
194
|
+
const assignment: Partial<AnomalySettings> = { driftThreshold: 1.5 };
|
|
195
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
196
|
+
expect(result.driftThreshold).toBe(1.5);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("field-level driftThreshold wins over assignment global", () => {
|
|
200
|
+
const template: AnomalySettings = {
|
|
201
|
+
...defaultTemplate,
|
|
202
|
+
fieldOverrides: {
|
|
203
|
+
"some.field": { driftThreshold: 4 },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const assignment: Partial<AnomalySettings> = { driftThreshold: 1.5 };
|
|
207
|
+
const result = resolveEffectiveConfig("some.field", template, assignment);
|
|
208
|
+
expect(result.driftThreshold).toBe(4);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AnomalySettings, AnomalyDirection } from "../schema";
|
|
2
|
+
|
|
3
|
+
export interface EffectiveConfig {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
sensitivity: number;
|
|
6
|
+
confirmationWindow: number;
|
|
7
|
+
direction?: AnomalyDirection;
|
|
8
|
+
driftEnabled: boolean;
|
|
9
|
+
driftThreshold: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the effective anomaly detection config for a specific field path
|
|
14
|
+
* using the Three-Layer Override Model.
|
|
15
|
+
*
|
|
16
|
+
* Resolution order (highest to lowest precedence):
|
|
17
|
+
* 1. Assignment field override (most specific — user override for a specific field on a specific system)
|
|
18
|
+
* 2. Template field override (plugin developer annotation for a specific field)
|
|
19
|
+
* 3. Assignment global (user override for the entire system assignment)
|
|
20
|
+
* 4. Template global (plugin developer default for the entire health check)
|
|
21
|
+
* 5. Engine defaults (hardcoded fallback values)
|
|
22
|
+
*
|
|
23
|
+
* Note: Field-level overrides always win over global overrides because they
|
|
24
|
+
* represent more specific intent. If a template marks a field as `enabled: false`,
|
|
25
|
+
* only an assignment-level field override can re-enable it — the assignment global
|
|
26
|
+
* `enabled: true` won't override it, since the template field config is more specific.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveEffectiveConfig(
|
|
29
|
+
path: string,
|
|
30
|
+
templateConfig?: AnomalySettings,
|
|
31
|
+
assignmentConfig?: Partial<AnomalySettings>
|
|
32
|
+
): EffectiveConfig {
|
|
33
|
+
const fieldConfig = assignmentConfig?.fieldOverrides?.[path] ?? templateConfig?.fieldOverrides?.[path];
|
|
34
|
+
|
|
35
|
+
const enabled = fieldConfig?.enabled
|
|
36
|
+
?? assignmentConfig?.enabled
|
|
37
|
+
?? templateConfig?.enabled
|
|
38
|
+
?? true;
|
|
39
|
+
|
|
40
|
+
const sensitivity = fieldConfig?.sensitivity
|
|
41
|
+
?? assignmentConfig?.sensitivity
|
|
42
|
+
?? templateConfig?.sensitivity
|
|
43
|
+
?? 1;
|
|
44
|
+
|
|
45
|
+
const confirmationWindow = fieldConfig?.confirmationWindow
|
|
46
|
+
?? assignmentConfig?.confirmationWindow
|
|
47
|
+
?? templateConfig?.confirmationWindow
|
|
48
|
+
?? 3;
|
|
49
|
+
|
|
50
|
+
const direction = fieldConfig?.direction;
|
|
51
|
+
|
|
52
|
+
const driftEnabled = fieldConfig?.driftEnabled
|
|
53
|
+
?? assignmentConfig?.driftEnabled
|
|
54
|
+
?? templateConfig?.driftEnabled
|
|
55
|
+
?? true;
|
|
56
|
+
|
|
57
|
+
const driftThreshold = fieldConfig?.driftThreshold
|
|
58
|
+
?? assignmentConfig?.driftThreshold
|
|
59
|
+
?? templateConfig?.driftThreshold
|
|
60
|
+
?? 2;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
enabled,
|
|
64
|
+
sensitivity,
|
|
65
|
+
confirmationWindow,
|
|
66
|
+
direction,
|
|
67
|
+
driftEnabled,
|
|
68
|
+
driftThreshold,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { detectDrift } from "./drift";
|
|
3
|
+
|
|
4
|
+
describe("Anomaly Engine - Drift Detection", () => {
|
|
5
|
+
describe("trigger condition |slope × n| > threshold × σ × sensitivity", () => {
|
|
6
|
+
test("does not trigger when projected change is below threshold", () => {
|
|
7
|
+
// slope * n = 0.5 * 100 = 50; threshold * σ * sensitivity = 2 * 30 * 1 = 60
|
|
8
|
+
const result = detectDrift({
|
|
9
|
+
slope: 0.5,
|
|
10
|
+
stdDev: 30,
|
|
11
|
+
sampleCount: 100,
|
|
12
|
+
direction: "deviation",
|
|
13
|
+
sensitivity: 1,
|
|
14
|
+
threshold: 2,
|
|
15
|
+
});
|
|
16
|
+
expect(result.drifting).toBe(false);
|
|
17
|
+
expect(result.projectedChange).toBe(50);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("triggers when projected change exceeds threshold", () => {
|
|
21
|
+
// slope * n = 1 * 100 = 100; threshold * σ * sensitivity = 2 * 30 * 1 = 60
|
|
22
|
+
const result = detectDrift({
|
|
23
|
+
slope: 1,
|
|
24
|
+
stdDev: 30,
|
|
25
|
+
sampleCount: 100,
|
|
26
|
+
direction: "deviation",
|
|
27
|
+
sensitivity: 1,
|
|
28
|
+
threshold: 2,
|
|
29
|
+
});
|
|
30
|
+
expect(result.drifting).toBe(true);
|
|
31
|
+
expect(result.projectedChange).toBe(100);
|
|
32
|
+
expect(result.deviationSigmas).toBeCloseTo(100 / 30, 4);
|
|
33
|
+
expect(result.driftDirection).toBe("above");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("uses absolute value of slope so negative drifts also count", () => {
|
|
37
|
+
const result = detectDrift({
|
|
38
|
+
slope: -1,
|
|
39
|
+
stdDev: 30,
|
|
40
|
+
sampleCount: 100,
|
|
41
|
+
direction: "deviation",
|
|
42
|
+
sensitivity: 1,
|
|
43
|
+
threshold: 2,
|
|
44
|
+
});
|
|
45
|
+
expect(result.drifting).toBe(true);
|
|
46
|
+
expect(result.driftDirection).toBe("below");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("direction filtering", () => {
|
|
51
|
+
test("lower-is-better only flags positive slopes (metric getting worse)", () => {
|
|
52
|
+
const rising = detectDrift({
|
|
53
|
+
slope: 1,
|
|
54
|
+
stdDev: 30,
|
|
55
|
+
sampleCount: 100,
|
|
56
|
+
direction: "lower-is-better",
|
|
57
|
+
sensitivity: 1,
|
|
58
|
+
threshold: 2,
|
|
59
|
+
});
|
|
60
|
+
expect(rising.drifting).toBe(true);
|
|
61
|
+
|
|
62
|
+
const falling = detectDrift({
|
|
63
|
+
slope: -1,
|
|
64
|
+
stdDev: 30,
|
|
65
|
+
sampleCount: 100,
|
|
66
|
+
direction: "lower-is-better",
|
|
67
|
+
sensitivity: 1,
|
|
68
|
+
threshold: 2,
|
|
69
|
+
});
|
|
70
|
+
expect(falling.drifting).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("higher-is-better only flags negative slopes", () => {
|
|
74
|
+
const falling = detectDrift({
|
|
75
|
+
slope: -1,
|
|
76
|
+
stdDev: 30,
|
|
77
|
+
sampleCount: 100,
|
|
78
|
+
direction: "higher-is-better",
|
|
79
|
+
sensitivity: 1,
|
|
80
|
+
threshold: 2,
|
|
81
|
+
});
|
|
82
|
+
expect(falling.drifting).toBe(true);
|
|
83
|
+
|
|
84
|
+
const rising = detectDrift({
|
|
85
|
+
slope: 1,
|
|
86
|
+
stdDev: 30,
|
|
87
|
+
sampleCount: 100,
|
|
88
|
+
direction: "higher-is-better",
|
|
89
|
+
sensitivity: 1,
|
|
90
|
+
threshold: 2,
|
|
91
|
+
});
|
|
92
|
+
expect(rising.drifting).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("dominance never triggers drift", () => {
|
|
96
|
+
const result = detectDrift({
|
|
97
|
+
slope: 100,
|
|
98
|
+
stdDev: 1,
|
|
99
|
+
sampleCount: 100,
|
|
100
|
+
direction: "dominance",
|
|
101
|
+
sensitivity: 1,
|
|
102
|
+
threshold: 2,
|
|
103
|
+
});
|
|
104
|
+
expect(result.drifting).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("sensitivity scaling", () => {
|
|
109
|
+
test("higher sensitivity widens the band — fewer detections", () => {
|
|
110
|
+
// Borderline case: slope*n = 60, threshold*σ = 60. With sensitivity 2.0 the band doubles.
|
|
111
|
+
const baseline = detectDrift({
|
|
112
|
+
slope: 1,
|
|
113
|
+
stdDev: 30,
|
|
114
|
+
sampleCount: 100,
|
|
115
|
+
direction: "deviation",
|
|
116
|
+
sensitivity: 1,
|
|
117
|
+
threshold: 2,
|
|
118
|
+
});
|
|
119
|
+
expect(baseline.drifting).toBe(true);
|
|
120
|
+
|
|
121
|
+
const desensitized = detectDrift({
|
|
122
|
+
slope: 1,
|
|
123
|
+
stdDev: 30,
|
|
124
|
+
sampleCount: 100,
|
|
125
|
+
direction: "deviation",
|
|
126
|
+
sensitivity: 2,
|
|
127
|
+
threshold: 2,
|
|
128
|
+
});
|
|
129
|
+
expect(desensitized.drifting).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("lower sensitivity tightens the band — more detections", () => {
|
|
133
|
+
const dormant = detectDrift({
|
|
134
|
+
slope: 0.5,
|
|
135
|
+
stdDev: 30,
|
|
136
|
+
sampleCount: 100,
|
|
137
|
+
direction: "deviation",
|
|
138
|
+
sensitivity: 1,
|
|
139
|
+
threshold: 2,
|
|
140
|
+
});
|
|
141
|
+
expect(dormant.drifting).toBe(false);
|
|
142
|
+
|
|
143
|
+
const sensitive = detectDrift({
|
|
144
|
+
slope: 0.5,
|
|
145
|
+
stdDev: 30,
|
|
146
|
+
sampleCount: 100,
|
|
147
|
+
direction: "deviation",
|
|
148
|
+
sensitivity: 0.5,
|
|
149
|
+
threshold: 2,
|
|
150
|
+
});
|
|
151
|
+
expect(sensitive.drifting).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("degenerate inputs", () => {
|
|
156
|
+
test("zero stdDev with non-zero slope still drifts (any movement is anomalous)", () => {
|
|
157
|
+
const result = detectDrift({
|
|
158
|
+
slope: 1,
|
|
159
|
+
stdDev: 0,
|
|
160
|
+
sampleCount: 100,
|
|
161
|
+
direction: "deviation",
|
|
162
|
+
sensitivity: 1,
|
|
163
|
+
threshold: 2,
|
|
164
|
+
});
|
|
165
|
+
expect(result.drifting).toBe(true);
|
|
166
|
+
expect(result.deviationSigmas).toBe(Number.POSITIVE_INFINITY);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("zero slope never drifts", () => {
|
|
170
|
+
const result = detectDrift({
|
|
171
|
+
slope: 0,
|
|
172
|
+
stdDev: 30,
|
|
173
|
+
sampleCount: 100,
|
|
174
|
+
direction: "deviation",
|
|
175
|
+
sensitivity: 1,
|
|
176
|
+
threshold: 2,
|
|
177
|
+
});
|
|
178
|
+
expect(result.drifting).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("zero sampleCount never drifts", () => {
|
|
182
|
+
const result = detectDrift({
|
|
183
|
+
slope: 1,
|
|
184
|
+
stdDev: 30,
|
|
185
|
+
sampleCount: 0,
|
|
186
|
+
direction: "deviation",
|
|
187
|
+
sensitivity: 1,
|
|
188
|
+
threshold: 2,
|
|
189
|
+
});
|
|
190
|
+
expect(result.drifting).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("zero stdDev with zero slope does not drift", () => {
|
|
194
|
+
const result = detectDrift({
|
|
195
|
+
slope: 0,
|
|
196
|
+
stdDev: 0,
|
|
197
|
+
sampleCount: 100,
|
|
198
|
+
direction: "deviation",
|
|
199
|
+
sensitivity: 1,
|
|
200
|
+
threshold: 2,
|
|
201
|
+
});
|
|
202
|
+
expect(result.drifting).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { AnomalyDirection } from "../schema";
|
|
2
|
+
|
|
3
|
+
export interface DriftDetectionInput {
|
|
4
|
+
/** Linear regression slope across the chronologically-ordered baseline window. */
|
|
5
|
+
slope: number;
|
|
6
|
+
/** Population standard deviation of the same window. */
|
|
7
|
+
stdDev: number;
|
|
8
|
+
/** Number of samples in the window. */
|
|
9
|
+
sampleCount: number;
|
|
10
|
+
/** Schema-declared direction for the field. */
|
|
11
|
+
direction: AnomalyDirection;
|
|
12
|
+
/** Sensitivity multiplier (matches the spike-detection slider). */
|
|
13
|
+
sensitivity: number;
|
|
14
|
+
/** Sigma multiplier on the drift trigger band. Default 2 in callers. */
|
|
15
|
+
threshold: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DriftDetectionResult {
|
|
19
|
+
drifting: boolean;
|
|
20
|
+
/** slope × sampleCount — the projected change over the baseline window. */
|
|
21
|
+
projectedChange: number;
|
|
22
|
+
/** projectedChange / stdDev — how many σ the trend has shifted the baseline. */
|
|
23
|
+
deviationSigmas: number;
|
|
24
|
+
/** Which side of the baseline the drift is moving toward. */
|
|
25
|
+
driftDirection: "above" | "below";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pure drift detection.
|
|
30
|
+
*
|
|
31
|
+
* Triggers when |slope × sampleCount| > threshold × σ × sensitivity, i.e. when
|
|
32
|
+
* the regression projects the metric to walk by more than `threshold` standard
|
|
33
|
+
* deviations across the baseline window. Filtered by direction:
|
|
34
|
+
* - lower-is-better: only positive slope (worsening)
|
|
35
|
+
* - higher-is-better: only negative slope (worsening)
|
|
36
|
+
* - deviation: either
|
|
37
|
+
* - dominance: never (categorical fields don't drift continuously)
|
|
38
|
+
*
|
|
39
|
+
* Returns deviationSigmas = ∞ when stdDev is 0 and slope is non-zero, since any
|
|
40
|
+
* movement on a previously-constant metric is by definition outside the noise.
|
|
41
|
+
*/
|
|
42
|
+
export function detectDrift({
|
|
43
|
+
slope,
|
|
44
|
+
stdDev,
|
|
45
|
+
sampleCount,
|
|
46
|
+
direction,
|
|
47
|
+
sensitivity,
|
|
48
|
+
threshold,
|
|
49
|
+
}: DriftDetectionInput): DriftDetectionResult {
|
|
50
|
+
const projectedChange = slope * sampleCount;
|
|
51
|
+
const driftDirection: "above" | "below" = slope >= 0 ? "above" : "below";
|
|
52
|
+
|
|
53
|
+
const deviationSigmas =
|
|
54
|
+
stdDev === 0
|
|
55
|
+
? slope === 0
|
|
56
|
+
? 0
|
|
57
|
+
: Number.POSITIVE_INFINITY
|
|
58
|
+
: Math.abs(projectedChange) / stdDev;
|
|
59
|
+
|
|
60
|
+
if (sampleCount === 0 || slope === 0) {
|
|
61
|
+
return { drifting: false, projectedChange, deviationSigmas, driftDirection };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (direction === "dominance") {
|
|
65
|
+
return { drifting: false, projectedChange, deviationSigmas, driftDirection };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (direction === "lower-is-better" && slope < 0) {
|
|
69
|
+
return { drifting: false, projectedChange, deviationSigmas, driftDirection };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (direction === "higher-is-better" && slope > 0) {
|
|
73
|
+
return { drifting: false, projectedChange, deviationSigmas, driftDirection };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const triggerBand = threshold * stdDev * sensitivity;
|
|
77
|
+
const drifting = Math.abs(projectedChange) > triggerBand;
|
|
78
|
+
|
|
79
|
+
return { drifting, projectedChange, deviationSigmas, driftDirection };
|
|
80
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { computeThresholds, isAnomalous, isCategoricalAnomalous } from "./thresholds";
|
|
3
|
+
|
|
4
|
+
describe("Anomaly Engine - Thresholds", () => {
|
|
5
|
+
describe("computeThresholds", () => {
|
|
6
|
+
test("higher-is-better generates only lowerTrigger", () => {
|
|
7
|
+
// e.g. success rate. Mean 90, stdDev 5, sensitivity 1 -> margin 15
|
|
8
|
+
// lowerTrigger = 90 - 15 = 75
|
|
9
|
+
const thresholds = computeThresholds(90, 5, "higher-is-better", 1);
|
|
10
|
+
expect(thresholds.lowerTrigger).toBe(75);
|
|
11
|
+
expect(thresholds.upperTrigger).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("lower-is-better generates only upperTrigger", () => {
|
|
15
|
+
// e.g. latency. Mean 100, stdDev 20, sensitivity 1 -> margin 60
|
|
16
|
+
// upperTrigger = 100 + 60 = 160
|
|
17
|
+
const thresholds = computeThresholds(100, 20, "lower-is-better", 1);
|
|
18
|
+
expect(thresholds.upperTrigger).toBe(160);
|
|
19
|
+
expect(thresholds.lowerTrigger).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("deviation generates both triggers", () => {
|
|
23
|
+
// e.g. queue size. Mean 50, stdDev 10, sensitivity 1 -> margin 30
|
|
24
|
+
// triggers at 20 and 80
|
|
25
|
+
const thresholds = computeThresholds(50, 10, "deviation", 1);
|
|
26
|
+
expect(thresholds.lowerTrigger).toBe(20);
|
|
27
|
+
expect(thresholds.upperTrigger).toBe(80);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("scales margin with sensitivity multiplier", () => {
|
|
31
|
+
// deviation, sensitivity 0.5 (tighter bounds)
|
|
32
|
+
// margin = 3 * 10 * 0.5 = 15 -> triggers at 35 and 65
|
|
33
|
+
const strictThresholds = computeThresholds(50, 10, "deviation", 0.5);
|
|
34
|
+
expect(strictThresholds.lowerTrigger).toBe(35);
|
|
35
|
+
expect(strictThresholds.upperTrigger).toBe(65);
|
|
36
|
+
|
|
37
|
+
// deviation, sensitivity 2 (looser bounds)
|
|
38
|
+
// margin = 3 * 10 * 2 = 60 -> triggers at -10 and 110
|
|
39
|
+
const looseThresholds = computeThresholds(50, 10, "deviation", 2);
|
|
40
|
+
expect(looseThresholds.lowerTrigger).toBe(-10);
|
|
41
|
+
expect(looseThresholds.upperTrigger).toBe(110);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("isAnomalous", () => {
|
|
46
|
+
test("detects lower anomalies", () => {
|
|
47
|
+
const thresholds = { lowerTrigger: 50 };
|
|
48
|
+
expect(isAnomalous(40, thresholds)).toBe(true);
|
|
49
|
+
expect(isAnomalous(50, thresholds)).toBe(false); // Exactly at threshold is not anomalous
|
|
50
|
+
expect(isAnomalous(60, thresholds)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("detects upper anomalies", () => {
|
|
54
|
+
const thresholds = { upperTrigger: 100 };
|
|
55
|
+
expect(isAnomalous(110, thresholds)).toBe(true);
|
|
56
|
+
expect(isAnomalous(100, thresholds)).toBe(false);
|
|
57
|
+
expect(isAnomalous(90, thresholds)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detects bidirectional anomalies", () => {
|
|
61
|
+
const thresholds = { lowerTrigger: 20, upperTrigger: 80 };
|
|
62
|
+
expect(isAnomalous(10, thresholds)).toBe(true);
|
|
63
|
+
expect(isAnomalous(50, thresholds)).toBe(false);
|
|
64
|
+
expect(isAnomalous(90, thresholds)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("handles undefined thresholds gracefully", () => {
|
|
68
|
+
const thresholds = {};
|
|
69
|
+
expect(isAnomalous(1000, thresholds)).toBe(false);
|
|
70
|
+
expect(isAnomalous(-1000, thresholds)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("isCategoricalAnomalous", () => {
|
|
75
|
+
test("handles undefined baselines safely", () => {
|
|
76
|
+
expect(isCategoricalAnomalous(200, undefined, undefined, 1)).toBe(false);
|
|
77
|
+
expect(isCategoricalAnomalous(200, 200, undefined, 1)).toBe(false);
|
|
78
|
+
expect(isCategoricalAnomalous(200, undefined, 0.9, 1)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("triggers if value deviates and ratio is sufficiently high (sensitivity = 1)", () => {
|
|
82
|
+
// ratio 0.95 > 0.9, deviates
|
|
83
|
+
expect(isCategoricalAnomalous(500, 200, 0.95, 1)).toBe(true);
|
|
84
|
+
// ratio 0.95 > 0.9, no deviation
|
|
85
|
+
expect(isCategoricalAnomalous(200, 200, 0.95, 1)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("ignores deviation if baseline ratio is too noisy (sensitivity = 1)", () => {
|
|
89
|
+
// ratio 0.85 < 0.9
|
|
90
|
+
expect(isCategoricalAnomalous(500, 200, 0.85, 1)).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("scales dominance threshold with sensitivity", () => {
|
|
94
|
+
// High sensitivity (0.5) -> tighter bounds, more alerts.
|
|
95
|
+
// Required ratio = 0.9 * 0.5 = 0.45.
|
|
96
|
+
// Even if baseline ratio is 0.50 (very noisy), it still triggers an alert!
|
|
97
|
+
expect(isCategoricalAnomalous(500, 200, 0.50, 0.5)).toBe(true);
|
|
98
|
+
|
|
99
|
+
// Low sensitivity (2.0) -> looser bounds, fewer alerts.
|
|
100
|
+
// Required ratio = 0.9 * 2.0 = 1.8 -> clamped to 0.99.
|
|
101
|
+
// If baseline ratio is 0.95 (pretty stable but not 99%), it does NOT alert.
|
|
102
|
+
expect(isCategoricalAnomalous(500, 200, 0.95, 2.0)).toBe(false);
|
|
103
|
+
|
|
104
|
+
// If baseline ratio is 0.995 (extremely stable), it WILL alert even at sensitivity 2.0.
|
|
105
|
+
expect(isCategoricalAnomalous(500, 200, 0.995, 2.0)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { AnomalyDirection } from "../schema";
|
|
2
|
+
|
|
3
|
+
export interface Thresholds {
|
|
4
|
+
lowerTrigger?: number;
|
|
5
|
+
upperTrigger?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function computeThresholds(
|
|
9
|
+
mean: number,
|
|
10
|
+
stdDev: number,
|
|
11
|
+
direction: AnomalyDirection,
|
|
12
|
+
sensitivity: number = 1
|
|
13
|
+
): Thresholds {
|
|
14
|
+
const margin = 3 * stdDev * sensitivity;
|
|
15
|
+
|
|
16
|
+
switch (direction) {
|
|
17
|
+
case "higher-is-better": {
|
|
18
|
+
return {
|
|
19
|
+
lowerTrigger: mean - margin,
|
|
20
|
+
// No upper trigger, values can go as high as they want
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
case "lower-is-better": {
|
|
24
|
+
return {
|
|
25
|
+
upperTrigger: mean + margin,
|
|
26
|
+
// No lower trigger, getting faster/better is not an anomaly
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
case "deviation": {
|
|
30
|
+
return {
|
|
31
|
+
lowerTrigger: mean - margin,
|
|
32
|
+
upperTrigger: mean + margin,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
default: {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isAnomalous(
|
|
42
|
+
value: number,
|
|
43
|
+
thresholds: Thresholds
|
|
44
|
+
): boolean {
|
|
45
|
+
if (thresholds.lowerTrigger !== undefined && value < thresholds.lowerTrigger) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (thresholds.upperTrigger !== undefined && value > thresholds.upperTrigger) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isCategoricalAnomalous(
|
|
55
|
+
value: string | boolean | number,
|
|
56
|
+
dominantValue: string | boolean | number | undefined,
|
|
57
|
+
dominantRatio: number | undefined,
|
|
58
|
+
sensitivity: number = 1
|
|
59
|
+
): boolean {
|
|
60
|
+
if (dominantValue === undefined || dominantRatio === undefined) return false;
|
|
61
|
+
|
|
62
|
+
// Sensitivity scaling for categorical fields limits false positives.
|
|
63
|
+
// Base required dominance is 90% stability.
|
|
64
|
+
// Lower sensitivity values (e.g., 0.5) mean "tighter bounds, more alerts".
|
|
65
|
+
// Therefore, we lower the required ratio so we alert even if the baseline is noisier.
|
|
66
|
+
// Higher sensitivity values (e.g., 2.0) mean "looser bounds, fewer alerts".
|
|
67
|
+
// Therefore, we raise the required ratio so we only alert if the baseline was extremely stable.
|
|
68
|
+
const requiredRatio = Math.min(0.99, 0.9 * sensitivity);
|
|
69
|
+
if (dominantRatio < requiredRatio) return false;
|
|
70
|
+
|
|
71
|
+
return String(value) !== String(dominantValue);
|
|
72
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export * from "./schema";
|
|
2
|
+
export * from "./engine/baseline";
|
|
3
|
+
export * from "./engine/thresholds";
|
|
4
|
+
export * from "./engine/config";
|
|
5
|
+
export * from "./engine/drift";
|
|
6
|
+
export * from "./access";
|
|
7
|
+
export * from "./rpc-contract";
|
|
8
|
+
export * from "./plugin-metadata";
|
|
9
|
+
|
|
10
|
+
import { createSignal } from "@checkstack/signal-common";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { AnomalyStateSchema } from "./schema";
|
|
13
|
+
|
|
14
|
+
export const ANOMALY_STATE_CHANGED = createSignal(
|
|
15
|
+
"anomaly.state_changed",
|
|
16
|
+
z.object({
|
|
17
|
+
systemId: z.string(),
|
|
18
|
+
anomalyId: z.string(),
|
|
19
|
+
newState: AnomalyStateSchema,
|
|
20
|
+
})
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const ANOMALY_BASELINE_UPDATED = createSignal(
|
|
24
|
+
"anomaly.baseline_updated",
|
|
25
|
+
z.object({
|
|
26
|
+
systemId: z.string(),
|
|
27
|
+
configurationId: z.string(),
|
|
28
|
+
fieldPath: z.string(),
|
|
29
|
+
mean: z.number(),
|
|
30
|
+
stdDev: z.number(),
|
|
31
|
+
sampleCount: z.number(),
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export const ANOMALY_TREND_DETECTED = createSignal(
|
|
36
|
+
"anomaly.trend_detected",
|
|
37
|
+
z.object({
|
|
38
|
+
systemId: z.string(),
|
|
39
|
+
anomalyId: z.string(),
|
|
40
|
+
fieldPath: z.string(),
|
|
41
|
+
})
|
|
42
|
+
);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createClientDefinition, proc } from "@checkstack/common";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
4
|
+
import { AnomalyStateSchema, AnomalySettingsSchema, AnomalyFieldConfigSchema, AnomalyKindSchema } from "./schema";
|
|
5
|
+
import { anomalyAccess } from "./access";
|
|
6
|
+
|
|
7
|
+
export const AnomalyDtoSchema = z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
systemId: z.string(),
|
|
10
|
+
configurationId: z.string(),
|
|
11
|
+
fieldPath: z.string(),
|
|
12
|
+
kind: AnomalyKindSchema,
|
|
13
|
+
state: AnomalyStateSchema,
|
|
14
|
+
direction: z.enum(["above", "below", "changed"]),
|
|
15
|
+
baselineValue: z.number().nullable(),
|
|
16
|
+
baselineStdDev: z.number().nullable(),
|
|
17
|
+
observedValue: z.string(),
|
|
18
|
+
deviation: z.number(),
|
|
19
|
+
suspiciousRunCount: z.number(),
|
|
20
|
+
confirmationThreshold: z.number(),
|
|
21
|
+
startedAt: z.string(),
|
|
22
|
+
confirmedAt: z.string().nullable(),
|
|
23
|
+
recoveredAt: z.string().nullable(),
|
|
24
|
+
metadata: z.record(z.string(), z.unknown()).nullable(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type AnomalyDto = z.infer<typeof AnomalyDtoSchema>;
|
|
28
|
+
|
|
29
|
+
export const AnomalyBaselineDtoSchema = z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
systemId: z.string(),
|
|
32
|
+
configurationId: z.string(),
|
|
33
|
+
fieldPath: z.string(),
|
|
34
|
+
mean: z.number(),
|
|
35
|
+
stdDev: z.number(),
|
|
36
|
+
trendSlope: z.number(),
|
|
37
|
+
sampleCount: z.number(),
|
|
38
|
+
computedAt: z.string(),
|
|
39
|
+
dominantValue: z.string().nullable(),
|
|
40
|
+
dominantRatio: z.number().nullable(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type AnomalyBaselineDto = z.infer<typeof AnomalyBaselineDtoSchema>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Schema for a VersionedRecord wrapper used in RPC transport.
|
|
47
|
+
* Wraps the data with version metadata for backward-compatible schema evolution.
|
|
48
|
+
*/
|
|
49
|
+
const VersionedAnomalySettingsSchema = z.object({
|
|
50
|
+
version: z.number(),
|
|
51
|
+
data: AnomalySettingsSchema,
|
|
52
|
+
migratedAt: z.date().optional(),
|
|
53
|
+
originalVersion: z.number().optional(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Partial settings schema for assignment-level overrides.
|
|
58
|
+
* Only includes fields that the user explicitly sets.
|
|
59
|
+
*/
|
|
60
|
+
const PartialAnomalySettingsSchema = z.object({
|
|
61
|
+
enabled: z.boolean().optional(),
|
|
62
|
+
sensitivity: z.number().optional(),
|
|
63
|
+
confirmationWindow: z.number().int().optional(),
|
|
64
|
+
baselineWindow: z.string().optional(),
|
|
65
|
+
notify: z.boolean().optional(),
|
|
66
|
+
driftEnabled: z.boolean().optional(),
|
|
67
|
+
driftThreshold: z.number().optional(),
|
|
68
|
+
fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const VersionedPartialAnomalySettingsSchema = z.object({
|
|
72
|
+
version: z.number(),
|
|
73
|
+
data: PartialAnomalySettingsSchema,
|
|
74
|
+
migratedAt: z.date().optional(),
|
|
75
|
+
originalVersion: z.number().optional(),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
export const anomalyContract = {
|
|
79
|
+
getAnomalies: proc({
|
|
80
|
+
operationType: "query",
|
|
81
|
+
userType: "public",
|
|
82
|
+
access: [anomalyAccess.feed.read],
|
|
83
|
+
instanceAccess: { idParam: "systemId" },
|
|
84
|
+
})
|
|
85
|
+
.input(z.object({
|
|
86
|
+
systemId: z.string().optional(),
|
|
87
|
+
configurationId: z.string().optional(),
|
|
88
|
+
state: AnomalyStateSchema.optional(),
|
|
89
|
+
kind: AnomalyKindSchema.optional(),
|
|
90
|
+
limit: z.number().optional().default(50),
|
|
91
|
+
}))
|
|
92
|
+
.output(z.array(AnomalyDtoSchema)),
|
|
93
|
+
|
|
94
|
+
getAnomalyBaselines: proc({
|
|
95
|
+
operationType: "query",
|
|
96
|
+
userType: "public",
|
|
97
|
+
access: [anomalyAccess.feed.read],
|
|
98
|
+
instanceAccess: { idParam: "systemId" },
|
|
99
|
+
})
|
|
100
|
+
.input(z.object({
|
|
101
|
+
systemId: z.string(),
|
|
102
|
+
configurationId: z.string(),
|
|
103
|
+
}))
|
|
104
|
+
.output(z.array(AnomalyBaselineDtoSchema)),
|
|
105
|
+
|
|
106
|
+
getAnomalyConfig: proc({
|
|
107
|
+
operationType: "query",
|
|
108
|
+
userType: "authenticated",
|
|
109
|
+
access: [anomalyAccess.feed.manage],
|
|
110
|
+
})
|
|
111
|
+
.input(z.object({
|
|
112
|
+
configurationId: z.string(),
|
|
113
|
+
}))
|
|
114
|
+
.output(VersionedAnomalySettingsSchema),
|
|
115
|
+
|
|
116
|
+
updateAnomalyConfig: proc({
|
|
117
|
+
operationType: "mutation",
|
|
118
|
+
userType: "authenticated",
|
|
119
|
+
access: [anomalyAccess.feed.manage],
|
|
120
|
+
})
|
|
121
|
+
.input(z.object({
|
|
122
|
+
configurationId: z.string(),
|
|
123
|
+
config: AnomalySettingsSchema,
|
|
124
|
+
}))
|
|
125
|
+
.output(VersionedAnomalySettingsSchema),
|
|
126
|
+
|
|
127
|
+
getAnomalyAssignmentConfig: proc({
|
|
128
|
+
operationType: "query",
|
|
129
|
+
userType: "authenticated",
|
|
130
|
+
access: [anomalyAccess.feed.manage],
|
|
131
|
+
instanceAccess: { idParam: "systemId" },
|
|
132
|
+
})
|
|
133
|
+
.input(z.object({
|
|
134
|
+
systemId: z.string(),
|
|
135
|
+
configurationId: z.string(),
|
|
136
|
+
}))
|
|
137
|
+
.output(VersionedPartialAnomalySettingsSchema.nullable()),
|
|
138
|
+
|
|
139
|
+
updateAnomalyAssignmentConfig: proc({
|
|
140
|
+
operationType: "mutation",
|
|
141
|
+
userType: "authenticated",
|
|
142
|
+
access: [anomalyAccess.feed.manage],
|
|
143
|
+
instanceAccess: { idParam: "systemId" },
|
|
144
|
+
})
|
|
145
|
+
.input(z.object({
|
|
146
|
+
systemId: z.string(),
|
|
147
|
+
configurationId: z.string(),
|
|
148
|
+
config: PartialAnomalySettingsSchema,
|
|
149
|
+
}))
|
|
150
|
+
.output(VersionedPartialAnomalySettingsSchema),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type AnomalyContract = typeof anomalyContract;
|
|
154
|
+
|
|
155
|
+
export const AnomalyApi = createClientDefinition(
|
|
156
|
+
anomalyContract,
|
|
157
|
+
pluginMetadata
|
|
158
|
+
);
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const AnomalyDirectionSchema = z.enum([
|
|
4
|
+
"higher-is-better",
|
|
5
|
+
"lower-is-better",
|
|
6
|
+
"deviation",
|
|
7
|
+
"dominance",
|
|
8
|
+
]);
|
|
9
|
+
export type AnomalyDirection = z.infer<typeof AnomalyDirectionSchema>;
|
|
10
|
+
|
|
11
|
+
export const AnomalyStateSchema = z.enum([
|
|
12
|
+
"suspicious",
|
|
13
|
+
"anomaly",
|
|
14
|
+
"recovered",
|
|
15
|
+
]);
|
|
16
|
+
export type AnomalyState = z.infer<typeof AnomalyStateSchema>;
|
|
17
|
+
|
|
18
|
+
export const AnomalyKindSchema = z.enum(["spike", "drift"]);
|
|
19
|
+
export type AnomalyKind = z.infer<typeof AnomalyKindSchema>;
|
|
20
|
+
|
|
21
|
+
export const FieldBaselineSchema = z.object({
|
|
22
|
+
mean: z.number(),
|
|
23
|
+
stdDev: z.number(),
|
|
24
|
+
trendSlope: z.number(),
|
|
25
|
+
sampleCount: z.number(),
|
|
26
|
+
computedAt: z.string(), // ISO timestamp
|
|
27
|
+
dominantValue: z.union([z.string(), z.boolean(), z.number()]).optional(),
|
|
28
|
+
dominantRatio: z.number().optional(),
|
|
29
|
+
});
|
|
30
|
+
export type FieldBaseline = z.infer<typeof FieldBaselineSchema>;
|
|
31
|
+
|
|
32
|
+
export const AnomalyMetadataSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
trendData: z.record(z.string(), z.unknown()).optional(),
|
|
35
|
+
relatedAnomalies: z.array(z.string()).optional(), // UUIDs
|
|
36
|
+
})
|
|
37
|
+
.catchall(z.unknown());
|
|
38
|
+
export type AnomalyMetadata = z.infer<typeof AnomalyMetadataSchema>;
|
|
39
|
+
|
|
40
|
+
export const AnomalyFieldConfigSchema = z.object({
|
|
41
|
+
enabled: z.boolean().optional(),
|
|
42
|
+
sensitivity: z.number().optional(),
|
|
43
|
+
confirmationWindow: z.number().int().optional(),
|
|
44
|
+
direction: AnomalyDirectionSchema.optional(),
|
|
45
|
+
driftEnabled: z.boolean().optional(),
|
|
46
|
+
driftThreshold: z.number().optional(),
|
|
47
|
+
});
|
|
48
|
+
export type AnomalyFieldConfig = z.infer<typeof AnomalyFieldConfigSchema>;
|
|
49
|
+
|
|
50
|
+
export const AnomalySettingsSchema = z.object({
|
|
51
|
+
enabled: z.boolean().default(true),
|
|
52
|
+
sensitivity: z.number().default(1),
|
|
53
|
+
confirmationWindow: z.number().int().default(3),
|
|
54
|
+
baselineWindow: z.string().default("7d"),
|
|
55
|
+
notify: z.boolean().default(true),
|
|
56
|
+
driftEnabled: z.boolean().default(true),
|
|
57
|
+
driftThreshold: z.number().default(2),
|
|
58
|
+
fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
|
|
59
|
+
});
|
|
60
|
+
export type AnomalySettings = z.infer<typeof AnomalySettingsSchema>;
|