@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/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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }