@checkstack/anomaly-backend 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/drizzle/0000_soft_amphibian.sql +20 -0
- package/drizzle/0001_warm_spyke.sql +13 -0
- package/drizzle/0002_peaceful_krista_starr.sql +13 -0
- package/drizzle/0003_easy_maginty.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +152 -0
- package/drizzle/meta/0001_snapshot.json +232 -0
- package/drizzle/meta/0002_snapshot.json +307 -0
- package/drizzle/meta/0003_snapshot.json +323 -0
- package/drizzle/meta/_journal.json +34 -0
- package/drizzle.config.ts +7 -0
- package/package.json +39 -0
- package/src/config.ts +8 -0
- package/src/detector.test.ts +894 -0
- package/src/detector.ts +361 -0
- package/src/drift-evaluator.test.ts +383 -0
- package/src/drift-evaluator.ts +231 -0
- package/src/index.ts +4 -0
- package/src/jobs/baseline-analyzer.ts +269 -0
- package/src/notification.ts +139 -0
- package/src/plugin.ts +85 -0
- package/src/router-cache.ts +89 -0
- package/src/router.ts +74 -0
- package/src/schema.ts +87 -0
- package/src/service.ts +163 -0
- package/tsconfig.json +6 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
pgEnum,
|
|
4
|
+
text,
|
|
5
|
+
jsonb,
|
|
6
|
+
integer,
|
|
7
|
+
uuid,
|
|
8
|
+
timestamp,
|
|
9
|
+
doublePrecision,
|
|
10
|
+
unique,
|
|
11
|
+
} from "drizzle-orm/pg-core";
|
|
12
|
+
|
|
13
|
+
export const anomalyStateEnum = pgEnum("anomaly_state", [
|
|
14
|
+
"suspicious",
|
|
15
|
+
"anomaly",
|
|
16
|
+
"recovered",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
export type AnomalyState = (typeof anomalyStateEnum.enumValues)[number];
|
|
20
|
+
|
|
21
|
+
export const anomalyDirectionEnum = pgEnum("anomaly_direction", [
|
|
22
|
+
"above",
|
|
23
|
+
"below",
|
|
24
|
+
"changed",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export type AnomalyDirection = (typeof anomalyDirectionEnum.enumValues)[number];
|
|
28
|
+
|
|
29
|
+
export const anomalyKindEnum = pgEnum("anomaly_kind", ["spike", "drift"]);
|
|
30
|
+
export type AnomalyKind = (typeof anomalyKindEnum.enumValues)[number];
|
|
31
|
+
|
|
32
|
+
export const anomalies = pgTable("anomalies", {
|
|
33
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
34
|
+
systemId: text("system_id").notNull(),
|
|
35
|
+
/**
|
|
36
|
+
* Refers to the health check configuration ID that triggered this anomaly.
|
|
37
|
+
* Together with systemId, this maps to the specific health check assignment.
|
|
38
|
+
*/
|
|
39
|
+
configurationId: uuid("configuration_id").notNull(),
|
|
40
|
+
fieldPath: text("field_path").notNull(),
|
|
41
|
+
kind: anomalyKindEnum("kind").default("spike").notNull(),
|
|
42
|
+
state: anomalyStateEnum("state").notNull(),
|
|
43
|
+
direction: anomalyDirectionEnum("direction").notNull(),
|
|
44
|
+
baselineValue: doublePrecision("baseline_value"),
|
|
45
|
+
baselineStdDev: doublePrecision("baseline_std_dev"),
|
|
46
|
+
observedValue: text("observed_value").notNull(),
|
|
47
|
+
deviation: doublePrecision("deviation").notNull(),
|
|
48
|
+
suspiciousRunCount: integer("suspicious_run_count").default(0).notNull(),
|
|
49
|
+
confirmationThreshold: integer("confirmation_threshold").notNull(),
|
|
50
|
+
startedAt: timestamp("started_at").defaultNow().notNull(),
|
|
51
|
+
confirmedAt: timestamp("confirmed_at"),
|
|
52
|
+
recoveredAt: timestamp("recovered_at"),
|
|
53
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const anomalyBaselines = pgTable("anomaly_baselines", {
|
|
57
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
58
|
+
systemId: text("system_id").notNull(),
|
|
59
|
+
configurationId: uuid("configuration_id").notNull(),
|
|
60
|
+
fieldPath: text("field_path").notNull(),
|
|
61
|
+
mean: doublePrecision("mean").notNull(),
|
|
62
|
+
stdDev: doublePrecision("std_dev").notNull(),
|
|
63
|
+
trendSlope: doublePrecision("trend_slope").notNull(),
|
|
64
|
+
sampleCount: integer("sample_count").notNull(),
|
|
65
|
+
computedAt: timestamp("computed_at").notNull(),
|
|
66
|
+
dominantValue: text("dominant_value"),
|
|
67
|
+
dominantRatio: doublePrecision("dominant_ratio"),
|
|
68
|
+
}, (t) => ({
|
|
69
|
+
uniquePath: unique("anomaly_baselines_unique_path").on(
|
|
70
|
+
t.systemId,
|
|
71
|
+
t.configurationId,
|
|
72
|
+
t.fieldPath
|
|
73
|
+
)
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
export const anomalyConfigurations = pgTable("anomaly_configurations", {
|
|
77
|
+
configurationId: uuid("configuration_id").primaryKey(),
|
|
78
|
+
config: jsonb("config").notNull(), // VersionedRecord<AnomalySettings>
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const anomalyAssignments = pgTable("anomaly_assignments", {
|
|
82
|
+
systemId: text("system_id").notNull(),
|
|
83
|
+
configurationId: uuid("configuration_id").notNull(),
|
|
84
|
+
config: jsonb("config").notNull(), // VersionedRecord<Partial<AnomalySettings>>
|
|
85
|
+
}, (t) => ({
|
|
86
|
+
pk: unique("anomaly_assignments_pk").on(t.systemId, t.configurationId),
|
|
87
|
+
}));
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { eq, and, desc } from "drizzle-orm";
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import { anomalySettingsConfig } from "./config";
|
|
5
|
+
import type { VersionedRecord } from "@checkstack/backend-api";
|
|
6
|
+
import type { AnomalySettings } from "@checkstack/anomaly-common";
|
|
7
|
+
|
|
8
|
+
export class AnomalyService {
|
|
9
|
+
constructor(private readonly db: SafeDatabase<typeof schema>) {}
|
|
10
|
+
|
|
11
|
+
async getAnomalies(params: {
|
|
12
|
+
systemId?: string;
|
|
13
|
+
configurationId?: string;
|
|
14
|
+
state?: schema.AnomalyState;
|
|
15
|
+
kind?: schema.AnomalyKind;
|
|
16
|
+
limit?: number;
|
|
17
|
+
}) {
|
|
18
|
+
const conditions = [];
|
|
19
|
+
|
|
20
|
+
if (params.systemId) {
|
|
21
|
+
conditions.push(eq(schema.anomalies.systemId, params.systemId));
|
|
22
|
+
}
|
|
23
|
+
if (params.configurationId) {
|
|
24
|
+
conditions.push(
|
|
25
|
+
eq(schema.anomalies.configurationId, params.configurationId),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (params.state) {
|
|
29
|
+
conditions.push(eq(schema.anomalies.state, params.state));
|
|
30
|
+
}
|
|
31
|
+
if (params.kind) {
|
|
32
|
+
conditions.push(eq(schema.anomalies.kind, params.kind));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
36
|
+
|
|
37
|
+
const results = await this.db
|
|
38
|
+
.select()
|
|
39
|
+
.from(schema.anomalies)
|
|
40
|
+
.where(whereClause)
|
|
41
|
+
.orderBy(desc(schema.anomalies.startedAt))
|
|
42
|
+
.limit(params.limit ?? 50);
|
|
43
|
+
|
|
44
|
+
return results.map((r) => ({
|
|
45
|
+
...r,
|
|
46
|
+
startedAt: r.startedAt.toISOString(),
|
|
47
|
+
// eslint-disable-next-line unicorn/no-null
|
|
48
|
+
confirmedAt: r.confirmedAt?.toISOString() ?? null,
|
|
49
|
+
// eslint-disable-next-line unicorn/no-null
|
|
50
|
+
recoveredAt: r.recoveredAt?.toISOString() ?? null,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getAnomalyBaselines(params: {
|
|
55
|
+
systemId: string;
|
|
56
|
+
configurationId: string;
|
|
57
|
+
}) {
|
|
58
|
+
const results = await this.db
|
|
59
|
+
.select()
|
|
60
|
+
.from(schema.anomalyBaselines)
|
|
61
|
+
.where(
|
|
62
|
+
and(
|
|
63
|
+
eq(schema.anomalyBaselines.systemId, params.systemId),
|
|
64
|
+
eq(schema.anomalyBaselines.configurationId, params.configurationId),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return results.map((r) => ({
|
|
69
|
+
...r,
|
|
70
|
+
computedAt: r.computedAt.toISOString(),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getAnomalyConfig(
|
|
75
|
+
configurationId: string,
|
|
76
|
+
): Promise<VersionedRecord<AnomalySettings>> {
|
|
77
|
+
const [result] = await this.db
|
|
78
|
+
.select()
|
|
79
|
+
.from(schema.anomalyConfigurations)
|
|
80
|
+
.where(eq(schema.anomalyConfigurations.configurationId, configurationId));
|
|
81
|
+
|
|
82
|
+
if (!result) {
|
|
83
|
+
// Return default configuration wrapper
|
|
84
|
+
return anomalySettingsConfig.create({
|
|
85
|
+
enabled: true,
|
|
86
|
+
sensitivity: 1,
|
|
87
|
+
confirmationWindow: 3,
|
|
88
|
+
baselineWindow: "7d",
|
|
89
|
+
notify: true,
|
|
90
|
+
driftEnabled: true,
|
|
91
|
+
driftThreshold: 2,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result.config as VersionedRecord<AnomalySettings>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async updateAnomalyConfig(
|
|
99
|
+
configurationId: string,
|
|
100
|
+
configData: AnomalySettings,
|
|
101
|
+
) {
|
|
102
|
+
const newConfigRecord = anomalySettingsConfig.create(configData);
|
|
103
|
+
|
|
104
|
+
const [result] = await this.db
|
|
105
|
+
.insert(schema.anomalyConfigurations)
|
|
106
|
+
.values({
|
|
107
|
+
configurationId,
|
|
108
|
+
config: newConfigRecord,
|
|
109
|
+
})
|
|
110
|
+
.onConflictDoUpdate({
|
|
111
|
+
target: [schema.anomalyConfigurations.configurationId],
|
|
112
|
+
set: { config: newConfigRecord },
|
|
113
|
+
})
|
|
114
|
+
.returning();
|
|
115
|
+
|
|
116
|
+
return result!.config;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getAnomalyAssignmentConfig(systemId: string, configurationId: string) {
|
|
120
|
+
const [result] = await this.db
|
|
121
|
+
.select()
|
|
122
|
+
.from(schema.anomalyAssignments)
|
|
123
|
+
.where(
|
|
124
|
+
and(
|
|
125
|
+
eq(schema.anomalyAssignments.systemId, systemId),
|
|
126
|
+
eq(schema.anomalyAssignments.configurationId, configurationId),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
? (result.config as VersionedRecord<Partial<AnomalySettings>>)
|
|
132
|
+
: undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async updateAnomalyAssignmentConfig(
|
|
136
|
+
systemId: string,
|
|
137
|
+
configurationId: string,
|
|
138
|
+
configData: Partial<AnomalySettings>,
|
|
139
|
+
) {
|
|
140
|
+
const newConfigRecord = {
|
|
141
|
+
version: anomalySettingsConfig.version,
|
|
142
|
+
data: configData,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const [result] = await this.db
|
|
146
|
+
.insert(schema.anomalyAssignments)
|
|
147
|
+
.values({
|
|
148
|
+
systemId,
|
|
149
|
+
configurationId,
|
|
150
|
+
config: newConfigRecord,
|
|
151
|
+
})
|
|
152
|
+
.onConflictDoUpdate({
|
|
153
|
+
target: [
|
|
154
|
+
schema.anomalyAssignments.systemId,
|
|
155
|
+
schema.anomalyAssignments.configurationId,
|
|
156
|
+
],
|
|
157
|
+
set: { config: newConfigRecord },
|
|
158
|
+
})
|
|
159
|
+
.returning();
|
|
160
|
+
|
|
161
|
+
return result.config;
|
|
162
|
+
}
|
|
163
|
+
}
|