Skip to content
Merged
76 changes: 76 additions & 0 deletions apps/api/src/anomaly/__tests__/anomaly.types.spec.ts
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());
});
});
220 changes: 220 additions & 0 deletions apps/api/src/anomaly/anomaly.types.ts
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];
Comment thread
jamby77 marked this conversation as resolved.
}
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);
2 changes: 1 addition & 1 deletion apps/api/src/common/interfaces/storage-port.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ export interface StoragePort {
// Settings Methods (global, not connection-scoped)
getSettings(): Promise<AppSettings | null>;
saveSettings(settings: AppSettings): Promise<AppSettings>;
updateSettings(updates: SettingsUpdateRequest): Promise<AppSettings>;
updateSettings(updates: Partial<AppSettings>): Promise<AppSettings>;

// Webhook Methods - connectionId optional filter for scoping webhooks to connections
createWebhook(webhook: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>): Promise<Webhook>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function mockGlobalSettings(overrides?: Partial<AppSettings>): AppSettings {
metricForecastingDefaultRollingWindowMs: 21600000,
metricForecastingDefaultAlertThresholdMs: 7200000,
inferenceSlaConfig: {},
anomalyDetectorConfig: {},
localRetentionDays: null,
createdAt: Date.now(),
updatedAt: Date.now(),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/otel-telemetry/__tests__/semconv-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ describe('planSemconvInstruments', () => {
['betterdb_anomaly_by_severity', 'betterdb.anomaly.by_severity', 'gauge', '{event}'],
['betterdb_anomaly_by_metric', 'betterdb.anomaly.by_metric', 'gauge', '{event}'],
['betterdb_correlated_groups_total', 'betterdb.anomaly.correlated_groups', 'counter', '{group}'],
['betterdb_detector_config_updates_total', 'betterdb.anomaly.detector_config_updates', 'counter', '{update}'],
['betterdb_correlated_groups_by_severity', 'betterdb.anomaly.correlated_groups_by_severity', 'gauge', '{group}'],
['betterdb_correlated_groups_by_pattern', 'betterdb.anomaly.correlated_groups_by_pattern', 'gauge', '{group}'],
['betterdb_anomaly_buffer_ready', 'betterdb.anomaly.buffer.ready', 'gauge', '1'],
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/otel-telemetry/semconv-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ export const SEMCONV_RULES: Readonly<Record<string, SemconvRule>> = {
betterdb_anomaly_by_severity: gauge('betterdb.anomaly.by_severity', '{event}'),
betterdb_anomaly_by_metric: gauge('betterdb.anomaly.by_metric', '{event}'),
betterdb_correlated_groups_total: counter('betterdb.anomaly.correlated_groups', '{group}'),
betterdb_detector_config_updates_total: counter(
'betterdb.anomaly.detector_config_updates',
'{update}',
),
betterdb_correlated_groups_by_severity: gauge(
'betterdb.anomaly.correlated_groups_by_severity',
'{group}',
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/prometheus/prometheus.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export class PrometheusService extends MultiConnectionPoller implements OnModule
private anomalyBySeverity: Gauge;
private anomalyByMetric: Gauge;
private correlatedGroupsTotal: Counter;
private detectorConfigUpdatesTotal: Counter;
private correlatedGroupsBySeverity: Gauge;
private correlatedGroupsByPattern: Gauge;
private anomalyDetectionBufferReady: Gauge;
Expand Down Expand Up @@ -1003,6 +1004,11 @@ export class PrometheusService extends MultiConnectionPoller implements OnModule
labelNames: ['connection', 'pattern', 'severity'],
registers: [this.registry],
});
this.detectorConfigUpdatesTotal = new Counter({
name: 'betterdb_detector_config_updates_total',
help: 'Successful anomaly detector threshold config updates',
registers: [this.registry],
});
this.anomalyEventsCurrent = this.createGauge('anomaly_events_current', 'Unresolved anomalies', [
'severity',
]);
Expand Down Expand Up @@ -3302,6 +3308,10 @@ export class PrometheusService extends MultiConnectionPoller implements OnModule
this.pollsTotal.labels(connLabel).inc();
}

incrementDetectorConfigUpdates(): void {
this.detectorConfigUpdatesTotal.inc();
}

updateVectorIndexMetrics(
connectionId: string,
indexes: ReadonlyArray<{
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/settings/__tests__/settings.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function buildSettings(overrides: Partial<AppSettings> = {}): AppSettings {
metricForecastingDefaultRollingWindowMs: 21_600_000,
metricForecastingDefaultAlertThresholdMs: 7_200_000,
inferenceSlaConfig: {},
anomalyDetectorConfig: {},
localRetentionDays: null,
updatedAt: now,
createdAt: now,
Expand Down
Loading
Loading