@checkstack/anomaly-backend 1.0.3 → 1.1.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 CHANGED
@@ -1,5 +1,128 @@
1
1
  # @checkstack/anomaly-backend
2
2
 
3
+ ## 1.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [7c97b43]
8
+ - Updated dependencies [9016526]
9
+ - @checkstack/healthcheck-backend@1.1.0
10
+ - @checkstack/common@0.10.0
11
+ - @checkstack/catalog-common@2.2.0
12
+ - @checkstack/healthcheck-common@1.1.0
13
+ - @checkstack/notification-common@1.1.0
14
+ - @checkstack/anomaly-common@1.2.0
15
+ - @checkstack/gitops-common@0.4.0
16
+ - @checkstack/backend-api@0.15.2
17
+ - @checkstack/catalog-backend@1.1.1
18
+ - @checkstack/gitops-backend@0.3.1
19
+ - @checkstack/signal-common@0.2.3
20
+ - @checkstack/cache-api@0.3.1
21
+ - @checkstack/queue-api@0.3.1
22
+ - @checkstack/cache-utils@0.2.6
23
+
24
+ ## 1.1.0
25
+
26
+ ### Minor Changes
27
+
28
+ - 42abfff: Remove global anomaly settings — configuration is now field-only.
29
+
30
+ `AnomalySettings` (template- and assignment-level) no longer carries
31
+ `sensitivity`, `confirmationWindow`, `driftEnabled`, or `driftThreshold`.
32
+ These were duplicating the per-field configuration path with awkward
33
+ cascade semantics, and a single global multiplier was meaningless across
34
+ fields with different units (ms, %, counts).
35
+
36
+ The schema retains only the truly global concerns:
37
+
38
+ - `enabled` — master kill switch for the assignment
39
+ - `baselineWindow` — there is one history per system, not per field
40
+ - `notify` — one notification preference per assignment
41
+ - `fieldOverrides` — per-field configuration (where everything else now lives)
42
+
43
+ `resolveEffectiveConfig` collapses to two layers: field override → schema
44
+ default → engine fallback constant. The plugin-author defaults set via
45
+ `x-anomaly-*` annotations now drive sensitivity/window/drift across the
46
+ detector and drift evaluator (previously only floors were threaded
47
+ through the schema layer).
48
+
49
+ **Breaking changes:**
50
+
51
+ - Any global `sensitivity`/`confirmationWindow`/`driftEnabled`/
52
+ `driftThreshold` values previously stored in `anomaly_configurations`
53
+ or `anomaly_assignments` are silently stripped on parse. Users who
54
+ customized these globals will revert to the plugin's tuned per-field
55
+ defaults; if they want to keep those values they must re-apply them
56
+ per field in the new UI.
57
+ - `AnomalySettingsForm` no longer renders the global sliders. The form
58
+ now shows: enable toggle, baseline window selector, notify toggle,
59
+ field overrides editor.
60
+ - `AnomalyFieldOverridesEditor` props `defaultSensitivity`,
61
+ `defaultConfirmationWindow`, `defaultDriftEnabled`, `defaultDriftThreshold`
62
+ are removed. Engine fallbacks (1.0, 3, true, 2) are now hard-coded
63
+ internal constants used only when neither field override nor schema
64
+ default is set.
65
+ - The GitOps `System.anomaly` entry schema (in `anomaly-gitops-kinds`)
66
+ drops `sensitivity`, `confirmationWindow`, `driftEnabled`, and
67
+ `driftThreshold` to match the new `AnomalySettings` shape. YAML files
68
+ declaring those fields will be rejected at parse time — operators
69
+ must move per-field tuning into `fieldOverrides`.
70
+
71
+ This change makes the override model trivial to explain ("plugin defaults,
72
+ overridden per field") and removes a class of confusing "where did this
73
+ threshold come from?" questions.
74
+
75
+ - 42abfff: Add practical-significance floors to anomaly detection.
76
+
77
+ Two new schema annotations — `x-anomaly-min-absolute-delta` and `x-anomaly-min-relative-delta` — let plugin authors and operators suppress alerts whose statistical deviation is large but practical impact is negligible. Both floors must clear in addition to the existing μ ± Nσ trigger; defaults are 0 (disabled) so existing behaviour is unchanged.
78
+
79
+ This is the fix for cases like a 6 ms latency baseline whose σ ≈ 1 ms causes routine 20 ms blips to fire as anomalies despite Δ=14 ms being operationally irrelevant. With `min-absolute-delta: 50` and `min-relative-delta: 0.5`, those blips stay silent while a 6 ms → 200 ms spike still fires.
80
+
81
+ Built-in plugins ship with sensible defaults applied to every per-run field: 50 ms + 50 % for ms-unit fields, 5 percentage points for `%`-unit fields, 1 + 25 % for counter fields, 1 GB + 5 % for disk fields, 50 MB + 10 % for memory fields, 1 day for TLS expiry, 0.5 + 25 % for load average, 1 + 5 % for Minecraft TPS. Operators can override per-system or per-field via the assignment UI.
82
+
83
+ - f6f9a5c: Add GitOps extensions for declarative anomaly configuration.
84
+
85
+ Two extensions are now registered against the kind registry:
86
+
87
+ - `Healthcheck.anomaly` — accepts the full `AnomalySettings` shape and
88
+ applies it to the healthcheck's anomaly template via
89
+ `updateAnomalyConfig` on reconcile.
90
+ - `System.anomaly` — accepts an array of per-healthcheck overrides,
91
+ each scoped via `healthcheckRef: { kind: Healthcheck, name: ... }`,
92
+ and applies them with `updateAnomalyAssignmentConfig`. The
93
+ healthcheck reference is the GitOps source of truth; UI edits to
94
+ managed entries are blocked by the existing assignment-level lock.
95
+
96
+ Spec schema documentation for `Healthcheck.anomaly.fieldOverrides` is
97
+ registered **per collector field**, conditioned on the selected
98
+ `collectors[].config` variant — same pattern the `collectors[].assertions`
99
+ docs use, so the kind-registry browser pre-populates the available
100
+ result fields once a collector is chosen. The System extension's
101
+ `fieldOverrides` falls back to a generic variant since the relevant
102
+ collector lives on the referenced Healthcheck rather than a sibling.
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies [42abfff]
107
+ - Updated dependencies [42abfff]
108
+ - Updated dependencies [f6f9a5c]
109
+ - Updated dependencies [1ef2e79]
110
+ - Updated dependencies [aa89bc5]
111
+ - @checkstack/anomaly-common@1.1.0
112
+ - @checkstack/common@0.9.0
113
+ - @checkstack/gitops-common@0.3.0
114
+ - @checkstack/gitops-backend@0.3.0
115
+ - @checkstack/catalog-common@2.1.0
116
+ - @checkstack/catalog-backend@1.1.0
117
+ - @checkstack/queue-api@0.3.0
118
+ - @checkstack/cache-api@0.3.0
119
+ - @checkstack/backend-api@0.15.1
120
+ - @checkstack/healthcheck-backend@1.0.4
121
+ - @checkstack/healthcheck-common@1.0.2
122
+ - @checkstack/notification-common@1.0.2
123
+ - @checkstack/signal-common@0.2.2
124
+ - @checkstack/cache-utils@0.2.5
125
+
3
126
  ## 1.0.3
4
127
 
5
128
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/anomaly-backend",
3
- "version": "1.0.3",
3
+ "version": "1.1.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,28 +14,30 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.14.1",
18
- "@checkstack/common": "0.7.0",
19
- "@checkstack/anomaly-common": "1.0.0",
20
- "@checkstack/signal-common": "0.2.0",
21
- "@checkstack/healthcheck-common": "1.0.0",
22
- "@checkstack/queue-api": "0.2.17",
23
- "@checkstack/cache-api": "0.2.3",
24
- "@checkstack/cache-utils": "0.2.3",
25
- "@checkstack/healthcheck-backend": "1.0.2",
26
- "@checkstack/catalog-backend": "1.0.1",
27
- "@checkstack/catalog-common": "2.0.0",
28
- "@checkstack/notification-common": "1.0.0",
17
+ "@checkstack/backend-api": "0.15.1",
18
+ "@checkstack/common": "0.9.0",
19
+ "@checkstack/anomaly-common": "1.1.0",
20
+ "@checkstack/signal-common": "0.2.2",
21
+ "@checkstack/healthcheck-common": "1.0.2",
22
+ "@checkstack/queue-api": "0.3.0",
23
+ "@checkstack/cache-api": "0.3.0",
24
+ "@checkstack/cache-utils": "0.2.5",
25
+ "@checkstack/healthcheck-backend": "1.0.4",
26
+ "@checkstack/catalog-backend": "1.1.0",
27
+ "@checkstack/gitops-backend": "0.3.0",
28
+ "@checkstack/gitops-common": "0.3.0",
29
+ "@checkstack/catalog-common": "2.1.0",
30
+ "@checkstack/notification-common": "1.0.2",
29
31
  "drizzle-orm": "^0.45.0",
30
32
  "hono": "^4.12.14",
31
33
  "zod": "^4.2.1",
32
34
  "@orpc/server": "^1.13.2"
33
35
  },
34
36
  "devDependencies": {
35
- "@checkstack/drizzle-helper": "0.0.4",
36
- "@checkstack/scripts": "0.1.2",
37
- "@checkstack/test-utils-backend": "0.1.23",
38
- "@checkstack/tsconfig": "0.0.6",
37
+ "@checkstack/drizzle-helper": "0.0.5",
38
+ "@checkstack/scripts": "0.3.1",
39
+ "@checkstack/test-utils-backend": "0.1.25",
40
+ "@checkstack/tsconfig": "0.0.7",
39
41
  "@types/bun": "^1.0.0",
40
42
  "date-fns": "^4.1.0",
41
43
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import type { ReconcileContext } from "@checkstack/gitops-common";
3
+ import type { AnomalyService } from "./service";
4
+ import {
5
+ buildHealthcheckAnomalyExtension,
6
+ buildSystemAnomalyExtension,
7
+ } from "./anomaly-gitops-kinds";
8
+
9
+ /**
10
+ * Reconcile-time tests for the anomaly-backend GitOps kind extensions.
11
+ *
12
+ * Exercises the `Healthcheck.anomaly` and `System.anomalyExceptions`
13
+ * extensions in isolation against a stub AnomalyService.
14
+ */
15
+
16
+ const noopLogger = {
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ };
22
+
23
+ const buildContext = (
24
+ overrides: Partial<ReconcileContext> = {},
25
+ ): ReconcileContext =>
26
+ ({
27
+ logger: noopLogger,
28
+ resolveEntityRef: async () => undefined,
29
+ resolveSecretsBySchema: async ({ value }) => ({
30
+ resolved: value,
31
+ warnings: [],
32
+ }),
33
+ ...overrides,
34
+ }) as ReconcileContext;
35
+
36
+ const stubService = () => {
37
+ const updateAnomalyConfig = mock(async () => ({}));
38
+ const updateAnomalyAssignmentConfig = mock(async () => ({}));
39
+ return {
40
+ updateAnomalyConfig,
41
+ updateAnomalyAssignmentConfig,
42
+ service: {
43
+ updateAnomalyConfig,
44
+ updateAnomalyAssignmentConfig,
45
+ } as unknown as AnomalyService,
46
+ };
47
+ };
48
+
49
+ describe("buildHealthcheckAnomalyExtension", () => {
50
+ let stub: ReturnType<typeof stubService>;
51
+ let extension: ReturnType<typeof buildHealthcheckAnomalyExtension>;
52
+
53
+ beforeEach(() => {
54
+ stub = stubService();
55
+ extension = buildHealthcheckAnomalyExtension({
56
+ getService: () => stub.service,
57
+ });
58
+ });
59
+
60
+ it("registers under the right kind/namespace", () => {
61
+ expect(extension.kind).toBe("Healthcheck");
62
+ expect(extension.namespace).toBe("anomaly");
63
+ });
64
+
65
+ it("calls updateAnomalyConfig with the spec when present", async () => {
66
+ await extension.reconcile({
67
+ entity: {} as never,
68
+ extensionSpec: {
69
+ enabled: false,
70
+ baselineWindow: "14d",
71
+ notify: false,
72
+ },
73
+ entityId: "hc-1",
74
+ context: buildContext(),
75
+ });
76
+ expect(stub.updateAnomalyConfig).toHaveBeenCalledTimes(1);
77
+ expect(stub.updateAnomalyConfig).toHaveBeenCalledWith("hc-1", {
78
+ enabled: false,
79
+ baselineWindow: "14d",
80
+ notify: false,
81
+ });
82
+ });
83
+
84
+ it("is a no-op when extensionSpec is undefined", async () => {
85
+ await extension.reconcile({
86
+ entity: {} as never,
87
+ extensionSpec: undefined,
88
+ entityId: "hc-1",
89
+ context: buildContext(),
90
+ });
91
+ expect(stub.updateAnomalyConfig).not.toHaveBeenCalled();
92
+ });
93
+ });
94
+
95
+ describe("buildSystemAnomalyExtension", () => {
96
+ let stub: ReturnType<typeof stubService>;
97
+ let extension: ReturnType<typeof buildSystemAnomalyExtension>;
98
+
99
+ beforeEach(() => {
100
+ stub = stubService();
101
+ extension = buildSystemAnomalyExtension({
102
+ getService: () => stub.service,
103
+ });
104
+ });
105
+
106
+ it("registers on the System kind under namespace 'anomaly'", () => {
107
+ expect(extension.kind).toBe("System");
108
+ expect(extension.namespace).toBe("anomaly");
109
+ });
110
+
111
+ it("resolves each healthcheckRef and writes assignment overrides", async () => {
112
+ const refMap: Record<string, string> = {
113
+ "hc-cpu": "cfg-cpu",
114
+ "hc-mem": "cfg-mem",
115
+ };
116
+
117
+ await extension.reconcile({
118
+ entity: {} as never,
119
+ extensionSpec: [
120
+ {
121
+ healthcheckRef: { kind: "Healthcheck", name: "hc-cpu" },
122
+ enabled: false,
123
+ fieldOverrides: {
124
+ "cpu.usage": { sensitivity: 0.5 },
125
+ },
126
+ },
127
+ {
128
+ healthcheckRef: { kind: "Healthcheck", name: "hc-mem" },
129
+ baselineWindow: "30d",
130
+ },
131
+ ],
132
+ entityId: "sys-1",
133
+ context: buildContext({
134
+ resolveEntityRef: async ({ entityName }) => refMap[entityName],
135
+ }),
136
+ });
137
+
138
+ expect(stub.updateAnomalyAssignmentConfig).toHaveBeenCalledTimes(2);
139
+ expect(stub.updateAnomalyAssignmentConfig).toHaveBeenNthCalledWith(
140
+ 1,
141
+ "sys-1",
142
+ "cfg-cpu",
143
+ {
144
+ enabled: false,
145
+ fieldOverrides: { "cpu.usage": { sensitivity: 0.5 } },
146
+ },
147
+ );
148
+ expect(stub.updateAnomalyAssignmentConfig).toHaveBeenNthCalledWith(
149
+ 2,
150
+ "sys-1",
151
+ "cfg-mem",
152
+ { baselineWindow: "30d" },
153
+ );
154
+ });
155
+
156
+ it("throws when a healthcheck ref cannot be resolved", async () => {
157
+ await expect(
158
+ extension.reconcile({
159
+ entity: {} as never,
160
+ extensionSpec: [
161
+ {
162
+ healthcheckRef: { kind: "Healthcheck", name: "missing" },
163
+ enabled: false,
164
+ },
165
+ ],
166
+ entityId: "sys-1",
167
+ context: buildContext({
168
+ resolveEntityRef: async () => undefined,
169
+ }),
170
+ }),
171
+ ).rejects.toThrow(/Cannot resolve Healthcheck ref "missing"/);
172
+ });
173
+
174
+ it("is a no-op for empty/undefined specs", async () => {
175
+ await extension.reconcile({
176
+ entity: {} as never,
177
+ extensionSpec: [],
178
+ entityId: "sys-1",
179
+ context: buildContext(),
180
+ });
181
+ await extension.reconcile({
182
+ entity: {} as never,
183
+ extensionSpec: undefined,
184
+ entityId: "sys-1",
185
+ context: buildContext(),
186
+ });
187
+ expect(stub.updateAnomalyAssignmentConfig).not.toHaveBeenCalled();
188
+ });
189
+ });
@@ -0,0 +1,234 @@
1
+ import { z } from "zod";
2
+ import {
3
+ CHECKSTACK_API_VERSION,
4
+ type EntityKindExtensionDefinition,
5
+ type EntityKindRegistry,
6
+ type ReconcileContext,
7
+ } from "@checkstack/gitops-common";
8
+ import {
9
+ AnomalySettingsSchema,
10
+ AnomalyFieldConfigSchema,
11
+ } from "@checkstack/anomaly-common";
12
+ import type { CollectorRegistry } from "@checkstack/backend-api";
13
+ import type { AnomalyService } from "./service";
14
+
15
+ interface AnomalyGitOpsKindsDeps {
16
+ /**
17
+ * Lazy accessor — populated during init(), invoked at reconcile time.
18
+ * Mirrors the pattern healthcheck-backend uses to expose its service to
19
+ * the GitOps reconciler without bleeding init order into kind registration.
20
+ */
21
+ getService: () => AnomalyService;
22
+ }
23
+
24
+ // ─── Shared schemas ─────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * A reference to a Healthcheck entity. Constrained so YAML callers can't
28
+ * accidentally point an anomaly override at a non-Healthcheck kind.
29
+ */
30
+ const healthcheckRefSchema = z
31
+ .object({
32
+ kind: z.literal("Healthcheck"),
33
+ name: z.string().min(1),
34
+ })
35
+ .describe("Reference to the Healthcheck this anomaly override applies to.");
36
+
37
+ // ─── Healthcheck → anomaly extension ────────────────────────────────────────
38
+ //
39
+ // Declares the *template-level* AnomalySettings for a Healthcheck. GitOps is
40
+ // the source of truth, so the entire AnomalySettings record is replaced.
41
+
42
+ const healthcheckAnomalyExtensionSchema = AnomalySettingsSchema.optional();
43
+ type HealthcheckAnomalyExtension = z.infer<
44
+ typeof healthcheckAnomalyExtensionSchema
45
+ >;
46
+
47
+ export function buildHealthcheckAnomalyExtension(
48
+ deps: AnomalyGitOpsKindsDeps,
49
+ ): EntityKindExtensionDefinition<HealthcheckAnomalyExtension> {
50
+ return {
51
+ apiVersion: CHECKSTACK_API_VERSION,
52
+ kind: "Healthcheck",
53
+ namespace: "anomaly",
54
+ specSchema: healthcheckAnomalyExtensionSchema,
55
+
56
+ reconcile: async ({
57
+ extensionSpec,
58
+ entityId,
59
+ context,
60
+ }: {
61
+ extensionSpec: HealthcheckAnomalyExtension;
62
+ entityId: string;
63
+ context: ReconcileContext;
64
+ }) => {
65
+ if (!extensionSpec) return;
66
+ await deps.getService().updateAnomalyConfig(entityId, extensionSpec);
67
+ context.logger.info(
68
+ `GitOps: applied anomaly defaults to Healthcheck (id: ${entityId})`,
69
+ );
70
+ },
71
+ };
72
+ }
73
+
74
+ // ─── System → anomaly extension ─────────────────────────────────────────────
75
+ //
76
+ // Declares per-assignment AnomalySettings overrides ("exceptions"), keyed by
77
+ // healthcheck ref. Lives under namespace `anomaly` on the System kind so the
78
+ // YAML naming mirrors the Healthcheck.anomaly extension.
79
+
80
+ const systemAnomalyEntrySchema = z.object({
81
+ healthcheckRef: healthcheckRefSchema,
82
+ enabled: z.boolean().optional(),
83
+ baselineWindow: z.string().optional(),
84
+ notify: z.boolean().optional(),
85
+ fieldOverrides: z.record(z.string(), AnomalyFieldConfigSchema).optional(),
86
+ });
87
+
88
+ const systemAnomalyExtensionSchema = z.array(systemAnomalyEntrySchema).optional();
89
+
90
+ type SystemAnomalyExtension = z.infer<typeof systemAnomalyExtensionSchema>;
91
+
92
+ export function buildSystemAnomalyExtension(
93
+ deps: AnomalyGitOpsKindsDeps,
94
+ ): EntityKindExtensionDefinition<SystemAnomalyExtension> {
95
+ return {
96
+ apiVersion: CHECKSTACK_API_VERSION,
97
+ kind: "System",
98
+ namespace: "anomaly",
99
+ specSchema: systemAnomalyExtensionSchema,
100
+
101
+ reconcile: async ({
102
+ extensionSpec,
103
+ entityId,
104
+ context,
105
+ }: {
106
+ extensionSpec: SystemAnomalyExtension;
107
+ entityId: string;
108
+ context: ReconcileContext;
109
+ }) => {
110
+ if (!extensionSpec || extensionSpec.length === 0) return;
111
+
112
+ const service = deps.getService();
113
+ const systemId = entityId;
114
+
115
+ for (const entry of extensionSpec) {
116
+ const configurationId = await context.resolveEntityRef({
117
+ kind: entry.healthcheckRef.kind,
118
+ entityName: entry.healthcheckRef.name,
119
+ });
120
+ if (!configurationId) {
121
+ throw new Error(
122
+ `Cannot resolve Healthcheck ref "${entry.healthcheckRef.name}" — anomaly overrides require the healthcheck to exist`,
123
+ );
124
+ }
125
+
126
+ const { healthcheckRef: _ref, ...overrides } = entry;
127
+ void _ref;
128
+
129
+ await service.updateAnomalyAssignmentConfig(
130
+ systemId,
131
+ configurationId,
132
+ overrides,
133
+ );
134
+
135
+ context.logger.info(
136
+ `GitOps: applied anomaly overrides for Healthcheck "${entry.healthcheckRef.name}" on System (id: ${systemId})`,
137
+ );
138
+ }
139
+ },
140
+ };
141
+ }
142
+
143
+ // ─── Documentation ──────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Mirrors the unwrap helper in healthcheck-gitops-kinds — peels Optional /
147
+ * Nullable / Default wrappers so we can introspect a collector's result
148
+ * shape and enumerate its field names.
149
+ */
150
+ function unwrapZodType(type: z.ZodTypeAny): z.ZodTypeAny {
151
+ let current = type;
152
+ while (current) {
153
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
154
+ current = current.unwrap() as z.ZodTypeAny;
155
+ } else if (current instanceof z.ZodDefault) {
156
+ current = current._def.innerType as z.ZodTypeAny;
157
+ } else if ("innerType" in current && typeof current.innerType === "function") {
158
+ current = current.innerType() as z.ZodTypeAny;
159
+ } else {
160
+ break;
161
+ }
162
+ }
163
+ return current;
164
+ }
165
+
166
+ /**
167
+ * Register schema documentation for the anomaly extensions. Called from
168
+ * anomaly-backend's `afterPluginsReady` once the kind registry and the
169
+ * collector registry have been populated.
170
+ *
171
+ * The Healthcheck.anomaly.fieldOverrides documentation is registered
172
+ * **per collector field**: each variant is gated on the matching
173
+ * collectors[].config selection so the kind-registry browser can pre-populate
174
+ * the available result fields once the user picks a collector.
175
+ *
176
+ * The System.anomaly[].fieldOverrides path can't be conditioned on a sibling
177
+ * config (the collector lives on the referenced Healthcheck, not on the
178
+ * System spec), so it falls back to a generic AnomalyFieldConfig variant.
179
+ */
180
+ export function registerAnomalyGitOpsDocumentation({
181
+ kindRegistry,
182
+ collectorRegistry,
183
+ }: {
184
+ kindRegistry: EntityKindRegistry;
185
+ collectorRegistry: CollectorRegistry;
186
+ }): void {
187
+ // Per-collector, per-field variants conditioned on the chosen collector.
188
+ for (const registered of collectorRegistry.getCollectors()) {
189
+ const unwrapped = unwrapZodType(registered.collector.result.schema);
190
+ if (!(unwrapped instanceof z.ZodObject)) continue;
191
+
192
+ for (const fieldName of Object.keys(unwrapped.shape)) {
193
+ kindRegistry.registerSpecSchemaDocumentation({
194
+ apiVersion: CHECKSTACK_API_VERSION,
195
+ kind: "Healthcheck",
196
+ fieldPath: "anomaly.fieldOverrides",
197
+ variantId: `${registered.qualifiedId}.field.${fieldName}`,
198
+ label: `Field: ${fieldName}`,
199
+ description: `Anomaly engine override for the "${fieldName}" result field of ${registered.collector.displayName}. The map key is "${fieldName}".`,
200
+ schema: AnomalyFieldConfigSchema,
201
+ conditions: [
202
+ {
203
+ fieldPath: "collectors[].config",
204
+ variantIds: [registered.qualifiedId],
205
+ },
206
+ ],
207
+ });
208
+ }
209
+ }
210
+
211
+ // Generic fallback for the System extension — the relevant collector lives
212
+ // on the referenced Healthcheck, so we can't gate on a sibling.
213
+ kindRegistry.registerSpecSchemaDocumentation({
214
+ apiVersion: CHECKSTACK_API_VERSION,
215
+ kind: "System",
216
+ fieldPath: "anomaly[].fieldOverrides",
217
+ label: "Anomaly field override",
218
+ description:
219
+ "Per-result-field overrides applied to the System ↔ Healthcheck assignment. The map key is the field path of the referenced healthcheck's result (e.g. \"cpu.usage\", \"latencyMs\").",
220
+ schema: AnomalyFieldConfigSchema,
221
+ });
222
+ }
223
+
224
+ // ─── Registration entry point ───────────────────────────────────────────────
225
+
226
+ export function registerAnomalyGitOpsKinds({
227
+ kindRegistry,
228
+ ...deps
229
+ }: AnomalyGitOpsKindsDeps & {
230
+ kindRegistry: EntityKindRegistry;
231
+ }): void {
232
+ kindRegistry.registerKindExtension(buildHealthcheckAnomalyExtension(deps));
233
+ kindRegistry.registerKindExtension(buildSystemAnomalyExtension(deps));
234
+ }
@@ -516,7 +516,7 @@ describe("Anomaly Detector — processCheckCompleted", () => {
516
516
  const db = createMockDb({
517
517
  configRecord: {
518
518
  version: 1,
519
- data: { enabled: false, sensitivity: 1, confirmationWindow: 3, baselineWindow: "7d", notify: true, driftEnabled: true, driftThreshold: 2 } satisfies AnomalySettings,
519
+ data: { enabled: false, baselineWindow: "7d", notify: true } satisfies AnomalySettings,
520
520
  },
521
521
  });
522
522
 
package/src/detector.ts CHANGED
@@ -157,18 +157,13 @@ export async function processCheckCompleted({
157
157
  continue; // Learning phase (no baseline yet)
158
158
  }
159
159
 
160
- const {
161
- enabled: effectiveEnabled,
162
- sensitivity: effectiveSensitivity,
163
- confirmationWindow: effectiveConfirmation,
164
- direction: effectiveDirection,
165
- } = resolveEffectiveConfig(path, templateConfig, assignmentConfig);
166
-
167
- if (!effectiveEnabled) {
168
- continue;
169
- }
170
-
171
160
  let schemaDirection: AnomalyDirection | undefined;
161
+ let schemaSensitivity: number | undefined;
162
+ let schemaConfirmationWindow: number | undefined;
163
+ let schemaDriftEnabled: boolean | undefined;
164
+ let schemaDriftThreshold: number | undefined;
165
+ let schemaMinAbsoluteDelta: number | undefined;
166
+ let schemaMinRelativeDelta: number | undefined;
172
167
  const collector = collectorRegistry.getCollector(collectorId);
173
168
  if (collector) {
174
169
  const collectorSchema = collector.collector.result.schema;
@@ -178,10 +173,36 @@ export async function processCheckCompleted({
178
173
  if (fieldSchema) {
179
174
  const meta = getHealthResultMeta(fieldSchema);
180
175
  schemaDirection = meta?.["x-anomaly-direction"];
176
+ schemaSensitivity = meta?.["x-anomaly-sensitivity"];
177
+ schemaConfirmationWindow = meta?.["x-anomaly-confirmation-window"];
178
+ schemaDriftEnabled = meta?.["x-anomaly-drift-enabled"];
179
+ schemaDriftThreshold = meta?.["x-anomaly-drift-threshold"];
180
+ schemaMinAbsoluteDelta = meta?.["x-anomaly-min-absolute-delta"];
181
+ schemaMinRelativeDelta = meta?.["x-anomaly-min-relative-delta"];
181
182
  }
182
183
  }
183
184
  }
184
185
 
186
+ const {
187
+ enabled: effectiveEnabled,
188
+ sensitivity: effectiveSensitivity,
189
+ confirmationWindow: effectiveConfirmation,
190
+ direction: effectiveDirection,
191
+ minAbsoluteDelta: effectiveMinAbsolute,
192
+ minRelativeDelta: effectiveMinRelative,
193
+ } = resolveEffectiveConfig(path, templateConfig, assignmentConfig, {
194
+ sensitivity: schemaSensitivity,
195
+ confirmationWindow: schemaConfirmationWindow,
196
+ driftEnabled: schemaDriftEnabled,
197
+ driftThreshold: schemaDriftThreshold,
198
+ minAbsoluteDelta: schemaMinAbsoluteDelta,
199
+ minRelativeDelta: schemaMinRelativeDelta,
200
+ });
201
+
202
+ if (!effectiveEnabled) {
203
+ continue;
204
+ }
205
+
185
206
  const direction = effectiveDirection ?? schemaDirection;
186
207
 
187
208
  if (!direction) {
@@ -206,7 +227,13 @@ export async function processCheckCompleted({
206
227
  direction,
207
228
  effectiveSensitivity,
208
229
  );
209
- anomalous = isAnomalous(value, thresholds);
230
+ anomalous = isAnomalous({
231
+ value,
232
+ mean: baseline.mean,
233
+ thresholds,
234
+ minAbsoluteDelta: effectiveMinAbsolute,
235
+ minRelativeDelta: effectiveMinRelative,
236
+ });
210
237
  deviation =
211
238
  baseline.stdDev > 0
212
239
  ? Math.abs(value - baseline.mean) / baseline.stdDev
@@ -121,12 +121,8 @@ const stableBaseline = createBaseline({
121
121
 
122
122
  const defaultTemplate: AnomalySettings = {
123
123
  enabled: true,
124
- sensitivity: 1,
125
- confirmationWindow: 3,
126
124
  baselineWindow: "7d",
127
125
  notify: true,
128
- driftEnabled: true,
129
- driftThreshold: 2,
130
126
  };
131
127
 
132
128
  describe("evaluateDrift", () => {
@@ -162,16 +158,21 @@ describe("evaluateDrift", () => {
162
158
  expect(db._insertCalls.length).toBe(0);
163
159
  });
164
160
 
165
- test("does nothing when driftEnabled is false", async () => {
161
+ test("does nothing when drift is disabled at the field level", async () => {
166
162
  const db = createMockDb();
167
163
  await evaluateDrift({
168
164
  ...baseProps,
169
165
  baseline: driftingBaseline,
170
166
  schemaDirection: "lower-is-better",
171
- templateConfig: { ...defaultTemplate, driftEnabled: false },
167
+ templateConfig: {
168
+ ...defaultTemplate,
169
+ fieldOverrides: {
170
+ "collectors.http.request.responseTimeMs": { driftEnabled: false },
171
+ },
172
+ },
172
173
  db: db as never,
173
174
  catalogClient: createMockCatalogClient() as never,
174
- notificationClient: createMockNotificationClient() as never,
175
+ notificationClient: createMockNotificationClient() as never,
175
176
  logger: createMockLogger() as never,
176
177
  });
177
178
  expect(db._insertCalls.length).toBe(0);
@@ -33,6 +33,18 @@ export interface EvaluateDriftInput {
33
33
  baseline: FieldBaseline;
34
34
  /** Direction declared by the schema for this field, if any. */
35
35
  schemaDirection?: AnomalyDirection;
36
+ /** Schema-declared sensitivity multiplier (plugin author default). */
37
+ schemaSensitivity?: number;
38
+ /** Schema-declared confirmation window (plugin author default). */
39
+ schemaConfirmationWindow?: number;
40
+ /** Schema-declared drift toggle (plugin author default). */
41
+ schemaDriftEnabled?: boolean;
42
+ /** Schema-declared drift threshold sigma multiplier (plugin author default). */
43
+ schemaDriftThreshold?: number;
44
+ /** Schema-declared practical-significance floor on absolute change. */
45
+ schemaMinAbsoluteDelta?: number;
46
+ /** Schema-declared practical-significance floor on relative change. */
47
+ schemaMinRelativeDelta?: number;
36
48
  templateConfig?: AnomalySettings;
37
49
  assignmentConfig?: Partial<AnomalySettings>;
38
50
  }
@@ -58,6 +70,12 @@ export async function evaluateDrift({
58
70
  fieldPath,
59
71
  baseline,
60
72
  schemaDirection,
73
+ schemaSensitivity,
74
+ schemaConfirmationWindow,
75
+ schemaDriftEnabled,
76
+ schemaDriftThreshold,
77
+ schemaMinAbsoluteDelta,
78
+ schemaMinRelativeDelta,
61
79
  templateConfig,
62
80
  assignmentConfig,
63
81
  }: EvaluateDriftInput): Promise<void> {
@@ -67,7 +85,16 @@ export async function evaluateDrift({
67
85
  direction: configDirection,
68
86
  driftEnabled,
69
87
  driftThreshold,
70
- } = resolveEffectiveConfig(fieldPath, templateConfig, assignmentConfig);
88
+ minAbsoluteDelta,
89
+ minRelativeDelta,
90
+ } = resolveEffectiveConfig(fieldPath, templateConfig, assignmentConfig, {
91
+ sensitivity: schemaSensitivity,
92
+ confirmationWindow: schemaConfirmationWindow,
93
+ driftEnabled: schemaDriftEnabled,
94
+ driftThreshold: schemaDriftThreshold,
95
+ minAbsoluteDelta: schemaMinAbsoluteDelta,
96
+ minRelativeDelta: schemaMinRelativeDelta,
97
+ });
71
98
 
72
99
  const direction = configDirection ?? schemaDirection;
73
100
 
@@ -83,6 +110,9 @@ export async function evaluateDrift({
83
110
  direction,
84
111
  sensitivity,
85
112
  threshold: driftThreshold,
113
+ mean: baseline.mean,
114
+ minAbsoluteDelta,
115
+ minRelativeDelta,
86
116
  });
87
117
 
88
118
  const [existing] = await db
@@ -205,7 +205,7 @@ export async function setupBaselineAnalyzerJob({
205
205
  const fieldName = fieldNames[path];
206
206
  if (!collectorId || !fieldName) continue;
207
207
 
208
- const schemaDirection = lookupSchemaDirection({
208
+ const schemaInfo = lookupSchemaInfo({
209
209
  collectorRegistry,
210
210
  collectorId,
211
211
  fieldName,
@@ -226,7 +226,13 @@ export async function setupBaselineAnalyzerJob({
226
226
  configurationId: assignment.configurationId,
227
227
  fieldPath: path,
228
228
  baseline: baselineDto,
229
- schemaDirection,
229
+ schemaDirection: schemaInfo.direction,
230
+ schemaSensitivity: schemaInfo.sensitivity,
231
+ schemaConfirmationWindow: schemaInfo.confirmationWindow,
232
+ schemaDriftEnabled: schemaInfo.driftEnabled,
233
+ schemaDriftThreshold: schemaInfo.driftThreshold,
234
+ schemaMinAbsoluteDelta: schemaInfo.minAbsoluteDelta,
235
+ schemaMinRelativeDelta: schemaInfo.minRelativeDelta,
230
236
  templateConfig,
231
237
  assignmentConfig,
232
238
  });
@@ -252,7 +258,17 @@ export async function setupBaselineAnalyzerJob({
252
258
  logger.debug("Anomaly baseline analyzer job scheduled.");
253
259
  }
254
260
 
255
- function lookupSchemaDirection({
261
+ interface SchemaInfo {
262
+ direction?: AnomalyDirection;
263
+ sensitivity?: number;
264
+ confirmationWindow?: number;
265
+ driftEnabled?: boolean;
266
+ driftThreshold?: number;
267
+ minAbsoluteDelta?: number;
268
+ minRelativeDelta?: number;
269
+ }
270
+
271
+ function lookupSchemaInfo({
256
272
  collectorRegistry,
257
273
  collectorId,
258
274
  fieldName,
@@ -260,14 +276,22 @@ function lookupSchemaDirection({
260
276
  collectorRegistry: CollectorRegistry;
261
277
  collectorId: string;
262
278
  fieldName: string;
263
- }): AnomalyDirection | undefined {
279
+ }): SchemaInfo {
264
280
  const collector = collectorRegistry.getCollector(collectorId);
265
- if (!collector) return undefined;
281
+ if (!collector) return {};
266
282
  const collectorSchema = collector.collector.result.schema;
267
- if (!("shape" in collectorSchema)) return undefined;
283
+ if (!("shape" in collectorSchema)) return {};
268
284
  const shape = collectorSchema.shape as Record<string, z.ZodTypeAny>;
269
285
  const fieldSchema = shape[fieldName];
270
- if (!fieldSchema) return undefined;
286
+ if (!fieldSchema) return {};
271
287
  const meta = getHealthResultMeta(fieldSchema);
272
- return meta?.["x-anomaly-direction"];
288
+ return {
289
+ direction: meta?.["x-anomaly-direction"],
290
+ sensitivity: meta?.["x-anomaly-sensitivity"],
291
+ confirmationWindow: meta?.["x-anomaly-confirmation-window"],
292
+ driftEnabled: meta?.["x-anomaly-drift-enabled"],
293
+ driftThreshold: meta?.["x-anomaly-drift-threshold"],
294
+ minAbsoluteDelta: meta?.["x-anomaly-min-absolute-delta"],
295
+ minRelativeDelta: meta?.["x-anomaly-min-relative-delta"],
296
+ };
273
297
  }
package/src/plugin.ts CHANGED
@@ -16,6 +16,11 @@ import {
16
16
  } from "@checkstack/anomaly-common";
17
17
  import { specToRegistration } from "@checkstack/notification-common";
18
18
  import { HealthCheckApi } from "@checkstack/healthcheck-common";
19
+ import { entityKindExtensionPoint } from "@checkstack/gitops-backend";
20
+ import {
21
+ registerAnomalyGitOpsKinds,
22
+ registerAnomalyGitOpsDocumentation,
23
+ } from "./anomaly-gitops-kinds";
19
24
 
20
25
  import { definePluginMetadata } from "@checkstack/common";
21
26
 
@@ -37,6 +42,20 @@ export const plugin = createBackendPlugin({
37
42
 
38
43
  let routerCache: AnomalyRouterCache | undefined;
39
44
 
45
+ // ─── GitOps Entity Kind Registration ─────────────────────────────
46
+ // Mutable ref populated during init(); the reconciler closure pulls
47
+ // the service via the lazy accessor at sync time.
48
+ let gitopsService: AnomalyService | undefined;
49
+ const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
50
+ registerAnomalyGitOpsKinds({
51
+ kindRegistry,
52
+ getService: () => {
53
+ if (!gitopsService)
54
+ throw new Error("AnomalyService not initialized");
55
+ return gitopsService;
56
+ },
57
+ });
58
+
40
59
  env.registerInit({
41
60
  schema,
42
61
  deps: {
@@ -71,6 +90,7 @@ export const plugin = createBackendPlugin({
71
90
  });
72
91
 
73
92
  const service = new AnomalyService(typedDb);
93
+ gitopsService = service;
74
94
  routerCache = createAnomalyRouterCache({ cacheManager, logger });
75
95
  const router = createRouter(service, logger, routerCache);
76
96
  rpc.registerRouter(router, anomalyContract);
@@ -97,6 +117,14 @@ export const plugin = createBackendPlugin({
97
117
  ),
98
118
  ]);
99
119
 
120
+ // GitOps spec-schema docs need the collector registry to be
121
+ // populated so we can enumerate per-collector result fields and
122
+ // register conditional variants under `anomaly.fieldOverrides`.
123
+ registerAnomalyGitOpsDocumentation({
124
+ kindRegistry,
125
+ collectorRegistry,
126
+ });
127
+
100
128
  onHook(healthCheckHooks.checkCompleted, async (payload) => {
101
129
  await processCheckCompleted({
102
130
  ...payload,
package/src/service.ts CHANGED
@@ -83,12 +83,8 @@ export class AnomalyService {
83
83
  // Return default configuration wrapper
84
84
  return anomalySettingsConfig.create({
85
85
  enabled: true,
86
- sensitivity: 1,
87
- confirmationWindow: 3,
88
86
  baselineWindow: "7d",
89
87
  notify: true,
90
- driftEnabled: true,
91
- driftThreshold: 2,
92
88
  });
93
89
  }
94
90
 
package/tsconfig.json CHANGED
@@ -28,6 +28,12 @@
28
28
  {
29
29
  "path": "../drizzle-helper"
30
30
  },
31
+ {
32
+ "path": "../gitops-backend"
33
+ },
34
+ {
35
+ "path": "../gitops-common"
36
+ },
31
37
  {
32
38
  "path": "../healthcheck-backend"
33
39
  },