Repository navigation
feat(settings): configurable anomaly detector thresholds via PATCH /settings/anomaly/detectors #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
KIvanow
merged 6 commits into
BetterDB-inc:master
from
amitkojha05:feat/configurable-anomaly-detector-thresholds
Oct 20, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e8d13c1
feat(settings): configurable anomaly detector thresholds via PATCH /s…
amitkojha05 540ef67
fix: register detector config counter in semconv map and add field to…
amitkojha05 7c17097
fix(settings): reject detector config on PUT, preserve on reset, seri…
amitkojha05 fd5eb26
fix(settings): preserve disabled absolute thresholds across SQL JSON …
amitkojha05 70bef2f
fix(settings): show disabled overrides as 0, count resets in detector…
amitkojha05 8f8279b
fix(settings): reject null detector fields, skip no-op updates, sync …
amitkojha05 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { ANOMALY_DETECTOR_METRICS } from '@betterdb/shared'; | ||
| import { | ||
| API_METRIC_TYPES, | ||
| DETECTOR_DEFAULTS, | ||
| MetricType, | ||
| normalizeDetectorConfigMap, | ||
| resolveDetectorConfig, | ||
| sanitizeStoredOverride, | ||
| toApiDetectorConfig, | ||
| } from '../anomaly.types'; | ||
|
|
||
| describe('disabled absolute threshold normalization', () => { | ||
| it('maps null absolutes back to Infinity', () => { | ||
| const out = normalizeDetectorConfigMap({ | ||
| acl_denied: { criticalAbsolute: null, warningAbsolute: 5 }, | ||
| }); | ||
| expect(out.acl_denied?.criticalAbsolute).toBe(Number.POSITIVE_INFINITY); | ||
| expect(out.acl_denied?.warningAbsolute).toBe(5); | ||
| }); | ||
|
|
||
| it('returns {} for null / non-object input', () => { | ||
| expect(normalizeDetectorConfigMap(null)).toEqual({}); | ||
| expect(normalizeDetectorConfigMap('x')).toEqual({}); | ||
| }); | ||
|
|
||
| it('resolveDetectorConfig never yields a null absolute', () => { | ||
| const resolved = resolveDetectorConfig(MetricType.acl_denied, { | ||
| acl_denied: { criticalAbsolute: null as unknown as number }, | ||
| }); | ||
| expect(resolved.criticalAbsolute).toBe(Number.POSITIVE_INFINITY); | ||
| }); | ||
|
|
||
| it('sanitizers never emit null; overrides map disabled to 0', () => { | ||
| expect(sanitizeStoredOverride({ criticalAbsolute: null as unknown as number })).toEqual({ | ||
| criticalAbsolute: 0, | ||
| }); | ||
| expect( | ||
| sanitizeStoredOverride({ criticalAbsolute: Number.POSITIVE_INFINITY, warningZScore: 2.5 }), | ||
| ).toEqual({ criticalAbsolute: 0, warningZScore: 2.5 }); | ||
| expect(sanitizeStoredOverride({ warningZScore: 2.5 })).toEqual({ warningZScore: 2.5 }); | ||
| const api = toApiDetectorConfig( | ||
| resolveDetectorConfig(MetricType.acl_denied, { | ||
| acl_denied: { criticalAbsolute: Number.POSITIVE_INFINITY }, | ||
| }), | ||
| ); | ||
| expect(api).not.toHaveProperty('criticalAbsolute'); | ||
| expect(JSON.stringify(api)).not.toContain('null'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('non-finite non-absolute fields', () => { | ||
| it('resolveDetectorConfig falls back to defaults for null z-score/count/cooldown', () => { | ||
| const r = resolveDetectorConfig(MetricType.connections, { | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| connections: { warningZScore: null, consecutiveRequired: null, cooldownMs: null } as any, | ||
| }); | ||
| expect(r.warningZScore).toBe(DETECTOR_DEFAULTS.connections.warningZScore); | ||
| expect(r.consecutiveRequired).toBe(DETECTOR_DEFAULTS.connections.consecutiveRequired); | ||
| expect(r.cooldownMs).toBe(DETECTOR_DEFAULTS.connections.cooldownMs); | ||
| }); | ||
|
|
||
| it('normalizeDetectorConfigMap drops persisted nulls and empty overrides', () => { | ||
| const out = normalizeDetectorConfigMap({ | ||
| connections: { warningZScore: null, cooldownMs: 5000 }, | ||
| ops_per_sec: { criticalZScore: null }, | ||
| }); | ||
| expect(out.connections).toEqual({ cooldownMs: 5000 }); | ||
| expect(out).not.toHaveProperty('ops_per_sec'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('metric list sync', () => { | ||
| it('shared metric list matches MetricType', () => { | ||
| expect([...ANOMALY_DETECTOR_METRICS].sort()).toEqual([...API_METRIC_TYPES].sort()); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,220 @@ | ||
| import type { SpikeDetectorConfig } from '@proprietary/anomaly-detection/types'; | ||
| import { ANOMALY_DETECTOR_METRICS, AnomalyDetectorMetric } from '@betterdb/shared'; | ||
|
|
||
| export enum MetricType { | ||
| connections = 'connections', | ||
| ops_per_sec = 'ops_per_sec', | ||
| memory_used = 'memory_used', | ||
| input_kbps = 'input_kbps', | ||
| output_kbps = 'output_kbps', | ||
| slowlog_last_id = 'slowlog_last_id', | ||
| acl_denied = 'acl_denied', | ||
| evicted_keys = 'evicted_keys', | ||
| blocked_clients = 'blocked_clients', | ||
| keyspace_misses = 'keyspace_misses', | ||
| fragmentation_ratio = 'fragmentation_ratio', | ||
| } | ||
|
|
||
| export interface DetectorConfig { | ||
| warningZScore?: number; | ||
| criticalZScore?: number; | ||
| warningAbsolute?: number; | ||
| criticalAbsolute?: number; | ||
| consecutiveRequired?: number; | ||
| cooldownMs?: number; | ||
| } | ||
|
|
||
| export type DetectorConfigMap = Partial<Record<MetricType, DetectorConfig>>; | ||
|
|
||
| // Fails to compile if MetricType and the shared list diverge. | ||
| const _metricsInSync: [AnomalyDetectorMetric] extends [`${MetricType}`] | ||
| ? [`${MetricType}`] extends [AnomalyDetectorMetric] | ||
| ? true | ||
| : never | ||
| : never = true; | ||
| void _metricsInSync; | ||
|
|
||
| export const ABSOLUTE_FIELDS = ['warningAbsolute', 'criticalAbsolute'] as const; | ||
|
|
||
| /** Non-absolute fields: must always be finite numbers. A null/non-finite value | ||
| * (e.g. persisted before null was rejected) falls back to the default. */ | ||
| export const FINITE_FIELDS = [ | ||
| 'warningZScore', | ||
| 'criticalZScore', | ||
| 'consecutiveRequired', | ||
| 'cooldownMs', | ||
| ] as const; | ||
|
|
||
| /** | ||
| * SQL adapters persist detector config as JSON, which serializes the Infinity | ||
| * "disabled" sentinel as null. Map null back to Infinity so a disabled | ||
| * absolute threshold never becomes `value >= null` (i.e. `>= 0`). | ||
| * Also tolerates null/non-object input (returns {}). | ||
| */ | ||
| export function normalizeDetectorConfigMap(map: unknown): DetectorConfigMap { | ||
| if (!map || typeof map !== 'object') return {}; | ||
| const out: DetectorConfigMap = {}; | ||
| for (const [key, raw] of Object.entries(map as Record<string, unknown>)) { | ||
| if (!raw || typeof raw !== 'object') continue; | ||
| const cfg = { ...(raw as DetectorConfig) }; | ||
| for (const f of ABSOLUTE_FIELDS) { | ||
| if ((cfg as Record<string, unknown>)[f] === null) cfg[f] = Number.POSITIVE_INFINITY; | ||
| } | ||
| for (const f of FINITE_FIELDS) { | ||
| const v = (cfg as Record<string, unknown>)[f]; | ||
| if (v !== undefined && !(typeof v === 'number' && Number.isFinite(v))) delete cfg[f]; | ||
| } | ||
| if (Object.keys(cfg).length === 0) continue; | ||
| out[key as MetricType] = cfg; | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** Stable comparison of two override maps. Treats Infinity/null absolutes as the | ||
| * same "disabled" value, ignores key order, and ignores empty overrides. */ | ||
| export function detectorConfigEquals(a: DetectorConfigMap, b: DetectorConfigMap): boolean { | ||
| const canon = (m: DetectorConfigMap) => { | ||
| const n = normalizeDetectorConfigMap(m); | ||
| return JSON.stringify( | ||
| Object.keys(n) | ||
| .sort() | ||
| .map((k) => { | ||
| const cfg = n[k as MetricType] as Record<string, number>; | ||
| return [ | ||
| k, | ||
| Object.keys(cfg) | ||
| .sort() | ||
| .map((f) => [f, Number.isFinite(cfg[f]) ? cfg[f] : 'Infinity']), | ||
| ]; | ||
| }), | ||
| ); | ||
| }; | ||
| return canon(a) === canon(b); | ||
| } | ||
|
|
||
| const NO_ABSOLUTE = Number.POSITIVE_INFINITY; | ||
|
|
||
| const zScoreDefaults = { | ||
| warningZScore: 2.0, | ||
| criticalZScore: 3.0, | ||
| warningAbsolute: NO_ABSOLUTE, | ||
| criticalAbsolute: NO_ABSOLUTE, | ||
| consecutiveRequired: 3, | ||
| cooldownMs: 60_000, | ||
| } satisfies Required<DetectorConfig>; | ||
|
|
||
| /** Defaults mirror hardcoded thresholds in proprietary/anomaly-detection/anomaly.service.ts */ | ||
| export const DETECTOR_DEFAULTS: Record<MetricType, Required<DetectorConfig>> = { | ||
| [MetricType.connections]: { ...zScoreDefaults }, | ||
| [MetricType.ops_per_sec]: { ...zScoreDefaults }, | ||
| [MetricType.memory_used]: { | ||
| ...zScoreDefaults, | ||
| warningZScore: 2.5, | ||
| criticalZScore: 3.5, | ||
| }, | ||
| [MetricType.input_kbps]: { ...zScoreDefaults }, | ||
| [MetricType.output_kbps]: { ...zScoreDefaults }, | ||
| [MetricType.slowlog_last_id]: { | ||
| ...zScoreDefaults, | ||
| warningZScore: 1.5, | ||
| criticalZScore: 2.5, | ||
| consecutiveRequired: 1, | ||
| cooldownMs: 30_000, | ||
| }, | ||
| [MetricType.acl_denied]: { | ||
| ...zScoreDefaults, | ||
| warningZScore: 1.5, | ||
| criticalZScore: 2.5, | ||
| warningAbsolute: 10, | ||
| criticalAbsolute: 50, | ||
| consecutiveRequired: 2, | ||
| cooldownMs: 30_000, | ||
| }, | ||
| [MetricType.evicted_keys]: { | ||
| ...zScoreDefaults, | ||
| consecutiveRequired: 2, | ||
| cooldownMs: 30_000, | ||
| }, | ||
| [MetricType.blocked_clients]: { ...zScoreDefaults }, | ||
| [MetricType.keyspace_misses]: { ...zScoreDefaults }, | ||
| [MetricType.fragmentation_ratio]: { | ||
| ...zScoreDefaults, | ||
| warningAbsolute: 1.5, | ||
| criticalAbsolute: 2.0, | ||
| consecutiveRequired: 5, | ||
| cooldownMs: 120_000, | ||
| }, | ||
| }; | ||
|
|
||
| export function resolveDetectorConfig( | ||
| metric: MetricType, | ||
| overrides: DetectorConfigMap, | ||
| ): Required<DetectorConfig> { | ||
| const merged = { ...DETECTOR_DEFAULTS[metric], ...overrides[metric] } as Required<DetectorConfig>; | ||
| // Defensive: a null/undefined absolute (e.g. from a JSON round-trip) means "disabled". | ||
| for (const f of ABSOLUTE_FIELDS) { | ||
| const v = merged[f] as number | null | undefined; | ||
| if (v === null || v === undefined) merged[f] = Number.POSITIVE_INFINITY; | ||
| } | ||
| for (const f of FINITE_FIELDS) { | ||
| const v = merged[f] as unknown; | ||
| if (!(typeof v === 'number' && Number.isFinite(v))) merged[f] = DETECTOR_DEFAULTS[metric][f]; | ||
| } | ||
| return merged; | ||
| } | ||
|
|
||
| /** Strips Infinity sentinels before JSON serialization. Omits absolute fields | ||
| * when they represent "no threshold configured" (i.e., Infinity). */ | ||
| export function toApiDetectorConfig(config: Required<DetectorConfig>): DetectorConfig { | ||
| const out: DetectorConfig = { | ||
| warningZScore: config.warningZScore, | ||
| criticalZScore: config.criticalZScore, | ||
| consecutiveRequired: config.consecutiveRequired, | ||
| cooldownMs: config.cooldownMs, | ||
| }; | ||
| if (Number.isFinite(config.warningAbsolute)) out.warningAbsolute = config.warningAbsolute; | ||
| if (Number.isFinite(config.criticalAbsolute)) out.criticalAbsolute = config.criticalAbsolute; | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * Prepare a stored partial override for the API. Preserves the partial shape | ||
| * (fields that were not overridden stay absent). A disabled absolute threshold | ||
| * (stored as the Infinity sentinel, or null after a SQL JSON round-trip) is | ||
| * emitted as 0, the same value PATCH accepts to disable it, so an override | ||
| * that disables a default absolute threshold is distinguishable from no | ||
| * override and round-trips through GET → PATCH unchanged. Never emits null. | ||
| */ | ||
| export function sanitizeStoredOverride(override: DetectorConfig): DetectorConfig { | ||
| const out: DetectorConfig = { ...override }; | ||
| for (const f of ABSOLUTE_FIELDS) { | ||
| if (!(f in out)) continue; | ||
| const v = (out as Record<string, unknown>)[f]; | ||
| if (v === undefined) { | ||
| delete out[f]; | ||
| } else if (v === null || (typeof v === 'number' && !Number.isFinite(v))) { | ||
| out[f] = 0; | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| export function toSpikeDetectorConfig( | ||
| config: Required<DetectorConfig>, | ||
| ): Required<SpikeDetectorConfig> { | ||
| return { | ||
| warningZScore: config.warningZScore, | ||
| criticalZScore: config.criticalZScore, | ||
| warningThreshold: config.warningAbsolute, | ||
| criticalThreshold: config.criticalAbsolute, | ||
| consecutiveRequired: config.consecutiveRequired, | ||
| cooldownMs: config.cooldownMs, | ||
| detectDrops: false, | ||
| }; | ||
| } | ||
|
|
||
| /** Generic z-score defaults for metrics without API config (e.g. cpu_utilization). */ | ||
| export const DEFAULT_SPIKE_CONFIG: Required<SpikeDetectorConfig> = | ||
| toSpikeDetectorConfig(zScoreDefaults); | ||
|
|
||
| export const API_METRIC_TYPES = Object.values(MetricType); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.