@obsunified/collector 2.2.3 → 2.4.3
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/dist/auth.d.ts +2 -1
- package/dist/{chunk-ZSF5XP3B.js → chunk-RASSTGNB.js} +30 -22
- package/dist/chunk-RASSTGNB.js.map +1 -0
- package/dist/index.d.ts +264 -62
- package/dist/index.js +3390 -2350
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-C5-8Mly5.d.ts → ingest-auth-BTowieER.d.ts} +96 -1
- package/dist/{openai-GSJZGFGL.js → openai-VDKVVZAV.js} +2 -2
- package/package.json +4 -2
- package/src/__tests__/autosre-closed-loop.test.ts +110 -0
- package/src/__tests__/db-parity.integration.test.ts +961 -0
- package/src/__tests__/internal-contracts-routes.test.ts +451 -0
- package/src/framework/env.ts +113 -0
- package/src/index.ts +27 -4
- package/src/lib/ai-store/evaluations.test.ts +3 -3
- package/src/lib/alert-email.ts +104 -0
- package/src/lib/alerts-store.test.ts +116 -2
- package/src/lib/alerts-store.ts +421 -57
- package/src/lib/analyses-store.test.ts +6 -6
- package/src/lib/db-parity.ts +883 -0
- package/src/lib/eval-cases-store.test.ts +1 -1
- package/src/lib/eval-cases-store.ts +16 -4
- package/src/lib/evidence-references.ts +30 -22
- package/src/lib/metric-alerts.test.ts +769 -0
- package/src/lib/metric-alerts.ts +640 -0
- package/src/lib/metrics-store.ts +9 -2
- package/src/lib/sql-db-postgres.integration.test.ts +247 -0
- package/src/lib/stage6.test.ts +1 -1
- package/src/lib/store/trace-queries.ts +1 -1
- package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
- package/src/migrations/055_alert_state_pending_since.sql +12 -0
- package/src/migrations/056_metric_point_series_received.sql +9 -0
- package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
- package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
- package/src/migrations/059_alert_evaluations_error.sql +11 -0
- package/src/migrations/parity.test.ts +105 -3
- package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
- package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
- package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
- package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
- package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
- package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
- package/src/migrations-postgres/README.md +13 -0
- package/src/plugins/ai-receiver.ts +6 -1
- package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
- package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
- package/src/plugins/alerts-evaluator.test.ts +10 -2
- package/src/plugins/alerts-evaluator.ts +239 -40
- package/src/plugins/alerts-routes.ts +17 -2
- package/src/plugins/autosre-routes.ts +251 -140
- package/src/plugins/connected-routes/manifest.test.ts +114 -32
- package/src/plugins/connected-routes/manifest.ts +59 -76
- package/src/plugins/connected-routes.test.ts +10 -8
- package/src/plugins/connected-routes.ts +42 -12
- package/src/plugins/eval-cases-routes.ts +33 -10
- package/src/plugins/evidence-retrieval-routes.ts +60 -25
- package/dist/chunk-ZSF5XP3B.js.map +0 -1
- /package/dist/{openai-GSJZGFGL.js.map → openai-VDKVVZAV.js.map} +0 -0
|
@@ -1112,6 +1112,253 @@ describe.skipIf(!databaseUrl)("PostgresAdapter against a live Postgres", () => {
|
|
|
1112
1112
|
);
|
|
1113
1113
|
});
|
|
1114
1114
|
|
|
1115
|
+
it("evaluates metrics alert rules and drives pendingSecs state (054-059)", async () => {
|
|
1116
|
+
const columns = await db
|
|
1117
|
+
.prepare(
|
|
1118
|
+
`SELECT table_name, column_name FROM information_schema.columns
|
|
1119
|
+
WHERE table_schema = current_schema()
|
|
1120
|
+
AND ((table_name = 'alert_rules' AND column_name = 'pending_secs')
|
|
1121
|
+
OR (table_name = 'alert_state' AND column_name IN ('pending_since', 'pending_seen_at'))
|
|
1122
|
+
OR (table_name = 'alert_evaluations' AND column_name IN ('outcome', 'error_message')))
|
|
1123
|
+
ORDER BY table_name, column_name`,
|
|
1124
|
+
)
|
|
1125
|
+
.all<{ table_name: string; column_name: string }>();
|
|
1126
|
+
expect(columns.results.map((c) => c.column_name)).toEqual([
|
|
1127
|
+
"error_message",
|
|
1128
|
+
"outcome",
|
|
1129
|
+
"pending_secs",
|
|
1130
|
+
"pending_seen_at",
|
|
1131
|
+
"pending_since",
|
|
1132
|
+
]);
|
|
1133
|
+
const index = await db
|
|
1134
|
+
.prepare(
|
|
1135
|
+
`SELECT indexname FROM pg_indexes
|
|
1136
|
+
WHERE schemaname = current_schema() AND indexname = 'idx_metric_point_series_received'`,
|
|
1137
|
+
)
|
|
1138
|
+
.first<{ indexname: string }>();
|
|
1139
|
+
expect(index?.indexname).toBe("idx_metric_point_series_received");
|
|
1140
|
+
|
|
1141
|
+
const project = await new ProjectsStore(db).createProject({
|
|
1142
|
+
name: "Metric alerts",
|
|
1143
|
+
slug: `metric-alerts-${Date.now().toString(36)}`,
|
|
1144
|
+
});
|
|
1145
|
+
const metrics = new MetricsStore(db);
|
|
1146
|
+
const now = new Date();
|
|
1147
|
+
const ago = (secs: number) => now.getTime() - secs * 1000;
|
|
1148
|
+
const ingest = (secsAgo: number, points: DecodedMetricPoint[]) =>
|
|
1149
|
+
metrics.ingestBatch({
|
|
1150
|
+
projectId: project.id,
|
|
1151
|
+
points,
|
|
1152
|
+
receivedAt: new Date(ago(secsAgo)).toISOString(),
|
|
1153
|
+
expiresAt: isoIn(60 * 60 * 1000),
|
|
1154
|
+
});
|
|
1155
|
+
const counter = (
|
|
1156
|
+
name: string,
|
|
1157
|
+
code: number,
|
|
1158
|
+
secsAgo: number,
|
|
1159
|
+
value: number,
|
|
1160
|
+
) =>
|
|
1161
|
+
metricPoint({
|
|
1162
|
+
name,
|
|
1163
|
+
type: "sum",
|
|
1164
|
+
isMonotonic: true,
|
|
1165
|
+
temporality: 2,
|
|
1166
|
+
serviceName: "vbox",
|
|
1167
|
+
resourceAttrsJson: JSON.stringify({ "service.name": "vbox" }),
|
|
1168
|
+
attributesJson: JSON.stringify({ code }),
|
|
1169
|
+
identity: `${name}|${code}`,
|
|
1170
|
+
tsNs: (BigInt(ago(secsAgo)) * 1_000_000n).toString(),
|
|
1171
|
+
startTsNs: (BigInt(ago(3600)) * 1_000_000n).toString(),
|
|
1172
|
+
value,
|
|
1173
|
+
count: null,
|
|
1174
|
+
sum: null,
|
|
1175
|
+
boundsJson: null,
|
|
1176
|
+
bucketCountsJson: null,
|
|
1177
|
+
});
|
|
1178
|
+
// History before the 5-minute window for all three series, plus a
|
|
1179
|
+
// series first seen inside it (code=503) whose first value counts.
|
|
1180
|
+
await ingest(400, [
|
|
1181
|
+
counter("calls_total", 200, 400, 90),
|
|
1182
|
+
counter("calls_total", 500, 400, 1),
|
|
1183
|
+
counter("calls_failed_total", 500, 400, 1),
|
|
1184
|
+
]);
|
|
1185
|
+
await ingest(240, [
|
|
1186
|
+
counter("calls_total", 200, 240, 100),
|
|
1187
|
+
counter("calls_total", 500, 240, 1),
|
|
1188
|
+
counter("calls_failed_total", 500, 240, 1),
|
|
1189
|
+
counter("calls_failed_total", 503, 240, 2),
|
|
1190
|
+
]);
|
|
1191
|
+
await ingest(60, [
|
|
1192
|
+
// Counter reset on the 200 series: 100 → 4 counts 4.
|
|
1193
|
+
counter("calls_total", 200, 60, 4),
|
|
1194
|
+
counter("calls_total", 500, 60, 3),
|
|
1195
|
+
counter("calls_failed_total", 500, 60, 3),
|
|
1196
|
+
]);
|
|
1197
|
+
|
|
1198
|
+
const alerts = new AlertsStore(db);
|
|
1199
|
+
const ratio = await alerts.createRule(project.id, {
|
|
1200
|
+
name: "call error ratio",
|
|
1201
|
+
signal: "metrics",
|
|
1202
|
+
query: {
|
|
1203
|
+
metric: "calls_failed_total",
|
|
1204
|
+
attributes: { code: "500" },
|
|
1205
|
+
aggregation: "increase",
|
|
1206
|
+
denominator: { metric: "calls_total" },
|
|
1207
|
+
minDenominator: 5,
|
|
1208
|
+
},
|
|
1209
|
+
threshold: 0.05,
|
|
1210
|
+
windowMins: 5,
|
|
1211
|
+
comparison: ">",
|
|
1212
|
+
pendingSecs: 0,
|
|
1213
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1214
|
+
});
|
|
1215
|
+
// errors +2; attempts +4 (reset) +2 = 6 >= 5 → 2/6
|
|
1216
|
+
expect(await alerts.evaluateRule(ratio, now)).toBeCloseTo(2 / 6);
|
|
1217
|
+
// The new 503 series started an hour ago (before the window): its
|
|
1218
|
+
// first point is a baseline, so only the 500 series' +2 counts.
|
|
1219
|
+
const allErrors = await alerts.createRule(project.id, {
|
|
1220
|
+
name: "all call errors",
|
|
1221
|
+
signal: "metrics",
|
|
1222
|
+
query: { metric: "calls_failed_total", aggregation: "increase" },
|
|
1223
|
+
threshold: 0,
|
|
1224
|
+
windowMins: 5,
|
|
1225
|
+
comparison: ">",
|
|
1226
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1227
|
+
});
|
|
1228
|
+
expect(await alerts.evaluateRuleWithStatus(allErrors, now)).toEqual({
|
|
1229
|
+
value: 2,
|
|
1230
|
+
noData: false,
|
|
1231
|
+
});
|
|
1232
|
+
// `last` with a 30 s staleness bound: the newest points are 60 s old.
|
|
1233
|
+
const gone = await alerts.createRule(project.id, {
|
|
1234
|
+
name: "stale gauge",
|
|
1235
|
+
signal: "metrics",
|
|
1236
|
+
query: {
|
|
1237
|
+
metric: "calls_total",
|
|
1238
|
+
aggregation: "last",
|
|
1239
|
+
staleSecs: 30,
|
|
1240
|
+
noData: "keep",
|
|
1241
|
+
},
|
|
1242
|
+
threshold: 0,
|
|
1243
|
+
windowMins: 5,
|
|
1244
|
+
comparison: ">",
|
|
1245
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1246
|
+
});
|
|
1247
|
+
expect(await alerts.evaluateRuleWithStatus(gone, now)).toEqual({
|
|
1248
|
+
value: 0,
|
|
1249
|
+
noData: true,
|
|
1250
|
+
});
|
|
1251
|
+
await alerts.recordEvaluation(
|
|
1252
|
+
gone.id,
|
|
1253
|
+
project.id,
|
|
1254
|
+
0,
|
|
1255
|
+
"ok",
|
|
1256
|
+
false,
|
|
1257
|
+
"no_data",
|
|
1258
|
+
);
|
|
1259
|
+
expect(
|
|
1260
|
+
(
|
|
1261
|
+
await alerts.listEvaluations({
|
|
1262
|
+
ruleId: gone.id,
|
|
1263
|
+
projectId: project.id,
|
|
1264
|
+
hours: 1,
|
|
1265
|
+
})
|
|
1266
|
+
).evaluations[0],
|
|
1267
|
+
).toMatchObject({ outcome: "no_data", state: "ok" });
|
|
1268
|
+
await alerts.recordEvaluation(
|
|
1269
|
+
ratio.id,
|
|
1270
|
+
project.id,
|
|
1271
|
+
0,
|
|
1272
|
+
"ok",
|
|
1273
|
+
false,
|
|
1274
|
+
"error",
|
|
1275
|
+
"statement timeout on postgres://obs:secret@db/obs",
|
|
1276
|
+
);
|
|
1277
|
+
await alerts.transitionState(gone.id, project.id, "firing", isoNow());
|
|
1278
|
+
const listed = new Map(
|
|
1279
|
+
(await alerts.listRules(project.id)).map((r) => [r.id, r]),
|
|
1280
|
+
);
|
|
1281
|
+
expect(listed.get(ratio.id)).toMatchObject({
|
|
1282
|
+
notificationDelivered: null,
|
|
1283
|
+
lastEvaluation: {
|
|
1284
|
+
outcome: "error",
|
|
1285
|
+
value: 0,
|
|
1286
|
+
error: "statement timeout on <url>",
|
|
1287
|
+
},
|
|
1288
|
+
});
|
|
1289
|
+
expect(listed.get(gone.id)).toMatchObject({
|
|
1290
|
+
currentState: "firing",
|
|
1291
|
+
notificationDelivered: false,
|
|
1292
|
+
lastEvaluation: { outcome: "no_data", error: null },
|
|
1293
|
+
});
|
|
1294
|
+
expect(listed.get(allErrors.id)?.lastEvaluation).toBeNull();
|
|
1295
|
+
|
|
1296
|
+
const stale = await alerts.createRule(project.id, {
|
|
1297
|
+
name: "calls stopped",
|
|
1298
|
+
signal: "metrics",
|
|
1299
|
+
query: { metric: ["calls_total"], aggregation: "age_seconds" },
|
|
1300
|
+
threshold: 60,
|
|
1301
|
+
windowMins: 1,
|
|
1302
|
+
comparison: ">=",
|
|
1303
|
+
pendingSecs: 30,
|
|
1304
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1305
|
+
});
|
|
1306
|
+
expect(await alerts.evaluateRule(stale, now)).toBeCloseTo(60, 0);
|
|
1307
|
+
const never = await alerts.createRule(project.id, {
|
|
1308
|
+
name: "never reported",
|
|
1309
|
+
signal: "metrics",
|
|
1310
|
+
query: { metric: "not_a_metric", aggregation: "age_seconds" },
|
|
1311
|
+
threshold: 60,
|
|
1312
|
+
windowMins: 1,
|
|
1313
|
+
comparison: ">=",
|
|
1314
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/pg" }],
|
|
1315
|
+
});
|
|
1316
|
+
const neverValue = await alerts.evaluateRule(
|
|
1317
|
+
never,
|
|
1318
|
+
new Date(Date.parse(never.createdAt) + 90_000),
|
|
1319
|
+
);
|
|
1320
|
+
expect(neverValue).toBeCloseTo(90, 0);
|
|
1321
|
+
|
|
1322
|
+
// pending_since round-trips; a fresh state row is ok + delivered.
|
|
1323
|
+
expect(stale.pendingSecs).toBe(30);
|
|
1324
|
+
const since = new Date().toISOString();
|
|
1325
|
+
await alerts.setPending(
|
|
1326
|
+
stale.id,
|
|
1327
|
+
project.id,
|
|
1328
|
+
{ since, seenAt: since },
|
|
1329
|
+
stale.createdAt,
|
|
1330
|
+
);
|
|
1331
|
+
expect(await alerts.getState(stale.id, project.id)).toMatchObject({
|
|
1332
|
+
current_state: "ok",
|
|
1333
|
+
last_state_change: stale.createdAt,
|
|
1334
|
+
notified: 1,
|
|
1335
|
+
pending_since: since,
|
|
1336
|
+
pending_seen_at: since,
|
|
1337
|
+
});
|
|
1338
|
+
expect(
|
|
1339
|
+
(await alerts.listRules(project.id)).find((r) => r.id === stale.id),
|
|
1340
|
+
).toMatchObject({ pendingSecs: 30, pendingSince: since });
|
|
1341
|
+
// An edit that changes evaluation resets the hold.
|
|
1342
|
+
await alerts.updateRule(stale.id, project.id, { threshold: 61 });
|
|
1343
|
+
expect(await alerts.getState(stale.id, project.id)).toMatchObject({
|
|
1344
|
+
pending_since: null,
|
|
1345
|
+
pending_seen_at: null,
|
|
1346
|
+
});
|
|
1347
|
+
await alerts.setPending(
|
|
1348
|
+
stale.id,
|
|
1349
|
+
project.id,
|
|
1350
|
+
{ since, seenAt: since },
|
|
1351
|
+
stale.createdAt,
|
|
1352
|
+
);
|
|
1353
|
+
await alerts.transitionState(stale.id, project.id, "firing", since);
|
|
1354
|
+
await alerts.setPending(stale.id, project.id, null, stale.createdAt);
|
|
1355
|
+
expect(await alerts.getState(stale.id, project.id)).toMatchObject({
|
|
1356
|
+
current_state: "firing",
|
|
1357
|
+
pending_since: null,
|
|
1358
|
+
notified: 0,
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1115
1362
|
it("purges expired AI data using the dialect's text-rendered now()", async () => {
|
|
1116
1363
|
await ingestAICallBatch(db, [
|
|
1117
1364
|
aiCall({ callId: "ai-expired", expiresAt: isoIn(-60_000) }),
|
package/src/lib/stage6.test.ts
CHANGED
|
@@ -201,7 +201,7 @@ describe("AlertsStore.listEvaluations evidence references", () => {
|
|
|
201
201
|
evidenceId: "alert:rule-1:rule",
|
|
202
202
|
entityKind: "alert",
|
|
203
203
|
entityId: "rule-1",
|
|
204
|
-
route: "#/alerts?
|
|
204
|
+
route: "#/alerts?id=rule-1",
|
|
205
205
|
source: "alerts.rule_evaluation_preview",
|
|
206
206
|
}),
|
|
207
207
|
expect.objectContaining({
|
|
@@ -33,7 +33,7 @@ export async function selectTraceCandidates(
|
|
|
33
33
|
if (options.search) {
|
|
34
34
|
const term = `%${options.search}%`;
|
|
35
35
|
whereClause +=
|
|
36
|
-
" AND (span_name LIKE ? OR status_message LIKE ? OR attributes_json LIKE ? OR events_json LIKE ?)";
|
|
36
|
+
" AND (span_name LIKE ? OR status_message LIKE ? OR CAST(attributes_json AS TEXT) LIKE ? OR CAST(events_json AS TEXT) LIKE ?)";
|
|
37
37
|
binds.push(term, term, term, term);
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Migration 054: alert_rules.pending_secs
|
|
2
|
+
--
|
|
3
|
+
-- How long a rule's condition must hold on consecutive evaluations before
|
|
4
|
+
-- the rule fires (Prometheus `for`). 0 keeps the existing behaviour: fire on
|
|
5
|
+
-- the first evaluation whose value matches. The start of the pending period
|
|
6
|
+
-- lives on alert_state (055).
|
|
7
|
+
--
|
|
8
|
+
-- Re-running fails with "duplicate column name", which the D1 runner treats
|
|
9
|
+
-- as already applied.
|
|
10
|
+
|
|
11
|
+
ALTER TABLE alert_rules ADD COLUMN pending_secs INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Migration 055: alert_state.pending_since
|
|
2
|
+
--
|
|
3
|
+
-- ISO timestamp of the first evaluation, in the current unbroken run, whose
|
|
4
|
+
-- value matched a rule that has a pending duration (054) and is not firing
|
|
5
|
+
-- yet. NULL when the rule is not pending. The evaluator fires once
|
|
6
|
+
-- `now - pending_since >= pending_secs` and clears it when the condition
|
|
7
|
+
-- stops holding or the rule fires.
|
|
8
|
+
--
|
|
9
|
+
-- Re-running fails with "duplicate column name", which the D1 runner treats
|
|
10
|
+
-- as already applied.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE alert_state ADD COLUMN pending_since TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Migration 056: metric_point (series_id, received_at) index
|
|
2
|
+
--
|
|
3
|
+
-- `metrics` alert rules read each matching series' points by receive time:
|
|
4
|
+
-- the points inside the rule window, and the newest point of the series for
|
|
5
|
+
-- `age_seconds`. The existing (series_id, ts_ns) index orders by producer
|
|
6
|
+
-- time; this one serves both reads without scanning the series' history.
|
|
7
|
+
|
|
8
|
+
CREATE INDEX IF NOT EXISTS idx_metric_point_series_received
|
|
9
|
+
ON metric_point (series_id, received_at);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Migration 057: alert_evaluations.outcome
|
|
2
|
+
--
|
|
3
|
+
-- What an evaluation produced: 'value' (a value was compared), 'no_data'
|
|
4
|
+
-- (a metrics rule with noData "keep" found no fresh series and left the
|
|
5
|
+
-- state alone) or 'error' (the evaluation failed; the state was left alone).
|
|
6
|
+
-- Existing rows are 'value'.
|
|
7
|
+
--
|
|
8
|
+
-- Re-running fails with "duplicate column name", which the D1 runner treats
|
|
9
|
+
-- as already applied.
|
|
10
|
+
|
|
11
|
+
ALTER TABLE alert_evaluations ADD COLUMN outcome TEXT NOT NULL DEFAULT 'value';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Migration 058: alert_state.pending_seen_at
|
|
2
|
+
--
|
|
3
|
+
-- ISO time of the last evaluation that saw a pending rule's condition hold.
|
|
4
|
+
-- The evaluator continues a pending period only when the next matching
|
|
5
|
+
-- evaluation comes within the allowed gap of this one (and after the
|
|
6
|
+
-- current collector process started); otherwise the hold starts over, as
|
|
7
|
+
-- Prometheus resets `for` when evaluations stop.
|
|
8
|
+
--
|
|
9
|
+
-- Re-running fails with "duplicate column name", which the D1 runner treats
|
|
10
|
+
-- as already applied.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE alert_state ADD COLUMN pending_seen_at TEXT;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Migration 059: alert_evaluations.error_message
|
|
2
|
+
--
|
|
3
|
+
-- Why an evaluation with outcome 'error' (057) failed: a timeout, a metrics
|
|
4
|
+
-- query over its series or point cap, a database error. Truncated and with
|
|
5
|
+
-- URLs replaced by the store, so no credentials or hook paths are kept.
|
|
6
|
+
-- NULL for every other outcome.
|
|
7
|
+
--
|
|
8
|
+
-- Re-running fails with "duplicate column name", which the D1 runner treats
|
|
9
|
+
-- as already applied.
|
|
10
|
+
|
|
11
|
+
ALTER TABLE alert_evaluations ADD COLUMN error_message TEXT;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { readdirSync, readFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
|
+
import { DatabaseSync } from "node:sqlite";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { describe, expect, it } from "vitest";
|
|
13
14
|
|
|
@@ -34,13 +35,74 @@ const RETENTION_TABLES = [
|
|
|
34
35
|
"remediation_proposals",
|
|
35
36
|
];
|
|
36
37
|
|
|
38
|
+
export function checkMigrationFilePairings(
|
|
39
|
+
sqliteFiles: readonly string[],
|
|
40
|
+
postgresFiles: readonly string[],
|
|
41
|
+
): string[] {
|
|
42
|
+
const errors: string[] = [];
|
|
43
|
+
const sqliteSet = new Set(sqliteFiles);
|
|
44
|
+
const pgSet = new Set(postgresFiles);
|
|
45
|
+
for (const f of sqliteFiles) {
|
|
46
|
+
if (!pgSet.has(f)) {
|
|
47
|
+
errors.push(`Missing Postgres migration counterpart for ${f}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const f of postgresFiles) {
|
|
51
|
+
if (!sqliteSet.has(f)) {
|
|
52
|
+
errors.push(`Missing SQLite migration counterpart for ${f}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const seenPrefixes = new Set<string>();
|
|
56
|
+
for (const f of sqliteFiles) {
|
|
57
|
+
const prefix = f.slice(0, 3);
|
|
58
|
+
if (seenPrefixes.has(prefix)) {
|
|
59
|
+
errors.push(`Duplicate migration prefix ${prefix} in ${f}`);
|
|
60
|
+
}
|
|
61
|
+
seenPrefixes.add(prefix);
|
|
62
|
+
}
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const extractCreatedTables = (sql: string): string[] => {
|
|
67
|
+
const names: string[] = [];
|
|
68
|
+
for (const m of sql.matchAll(
|
|
69
|
+
/CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
|
|
70
|
+
)) {
|
|
71
|
+
const table = m[1].toLowerCase();
|
|
72
|
+
if (!table.endsWith("_new")) {
|
|
73
|
+
names.push(table);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return [...new Set(names)].sort();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const extractAddedColumns = (sql: string): string[] => {
|
|
80
|
+
const cols: string[] = [];
|
|
81
|
+
for (const m of sql.matchAll(
|
|
82
|
+
/ALTER\s+TABLE\s+([a-zA-Z0-9_]+)\s+ADD\s+COLUMN(?:\s+IF\s+NOT\s+EXISTS)?\s+([a-zA-Z0-9_]+)/gi,
|
|
83
|
+
)) {
|
|
84
|
+
cols.push(`${m[1].toLowerCase()}.${m[2].toLowerCase()}`);
|
|
85
|
+
}
|
|
86
|
+
return [...new Set(cols)].sort();
|
|
87
|
+
};
|
|
88
|
+
|
|
37
89
|
describe("migration parity", () => {
|
|
38
|
-
it("has the same numbered files on SQLite/D1 and Postgres", () => {
|
|
90
|
+
it("has the same numbered files on SQLite/D1 and Postgres and catches missing pairs", () => {
|
|
39
91
|
const sqlite = sqlFiles(sqliteDir);
|
|
40
92
|
const postgres = sqlFiles(postgresDir);
|
|
41
93
|
expect(postgres).toEqual(sqlite);
|
|
42
|
-
|
|
43
|
-
expect(
|
|
94
|
+
expect(sqlite.length).toBeGreaterThanOrEqual(53);
|
|
95
|
+
expect(checkMigrationFilePairings(sqlite, postgres)).toEqual([]);
|
|
96
|
+
|
|
97
|
+
// Regression check: missing or duplicate migration files must be flagged.
|
|
98
|
+
expect(
|
|
99
|
+
checkMigrationFilePairings([...sqlite, "099_new_feature.sql"], postgres),
|
|
100
|
+
).toEqual([
|
|
101
|
+
"Missing Postgres migration counterpart for 099_new_feature.sql",
|
|
102
|
+
]);
|
|
103
|
+
expect(
|
|
104
|
+
checkMigrationFilePairings(sqlite, [...postgres, "099_pg_only.sql"]),
|
|
105
|
+
).toEqual(["Missing SQLite migration counterpart for 099_pg_only.sql"]);
|
|
44
106
|
});
|
|
45
107
|
|
|
46
108
|
it("declares retention timestamps as TEXT in every Postgres CREATE TABLE", () => {
|
|
@@ -65,4 +127,44 @@ describe("migration parity", () => {
|
|
|
65
127
|
}
|
|
66
128
|
expect(offenders).toEqual([]);
|
|
67
129
|
});
|
|
130
|
+
|
|
131
|
+
it("creates and alters the same tables and columns per migration file", () => {
|
|
132
|
+
const mismatches: string[] = [];
|
|
133
|
+
for (const file of sqlFiles(sqliteDir)) {
|
|
134
|
+
const sqliteSql = readFileSync(path.join(sqliteDir, file), "utf8");
|
|
135
|
+
const pgSql = readFileSync(path.join(postgresDir, file), "utf8");
|
|
136
|
+
const sTables = extractCreatedTables(sqliteSql);
|
|
137
|
+
const pTables = extractCreatedTables(pgSql);
|
|
138
|
+
if (JSON.stringify(sTables) !== JSON.stringify(pTables)) {
|
|
139
|
+
mismatches.push(
|
|
140
|
+
`${file} CREATE TABLE mismatch: sqlite=${JSON.stringify(sTables)} pg=${JSON.stringify(pTables)}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const sCols = extractAddedColumns(sqliteSql);
|
|
144
|
+
const pCols = extractAddedColumns(pgSql);
|
|
145
|
+
if (JSON.stringify(sCols) !== JSON.stringify(pCols)) {
|
|
146
|
+
mismatches.push(
|
|
147
|
+
`${file} ALTER TABLE ADD COLUMN mismatch: sqlite=${JSON.stringify(sCols)} pg=${JSON.stringify(pCols)}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
expect(mismatches).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("applies all SQLite migrations cleanly in-memory without leftover staging tables", () => {
|
|
155
|
+
const db = new DatabaseSync(":memory:");
|
|
156
|
+
try {
|
|
157
|
+
for (const file of sqlFiles(sqliteDir)) {
|
|
158
|
+
db.exec(readFileSync(path.join(sqliteDir, file), "utf8"));
|
|
159
|
+
}
|
|
160
|
+
const leftover = db
|
|
161
|
+
.prepare(
|
|
162
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%_new'",
|
|
163
|
+
)
|
|
164
|
+
.all();
|
|
165
|
+
expect(leftover).toEqual([]);
|
|
166
|
+
} finally {
|
|
167
|
+
db.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
68
170
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Migration 054: alert_rules.pending_secs
|
|
2
|
+
--
|
|
3
|
+
-- Mirrors ../migrations/054_alert_rules_pending_secs.sql. INTEGER like the
|
|
4
|
+
-- sibling alert_rules.window_mins. Existing rules default to 0 (fire on the
|
|
5
|
+
-- first matching evaluation, as before). Idempotent.
|
|
6
|
+
|
|
7
|
+
ALTER TABLE alert_rules ADD COLUMN IF NOT EXISTS pending_secs INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Migration 056: metric_point (series_id, received_at) index
|
|
2
|
+
--
|
|
3
|
+
-- Mirrors ../migrations/056_metric_point_series_received.sql. Serves the
|
|
4
|
+
-- `metrics` alert evaluator's window and newest-point reads. Idempotent.
|
|
5
|
+
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_metric_point_series_received
|
|
7
|
+
ON metric_point (series_id, received_at);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Migration 057: alert_evaluations.outcome
|
|
2
|
+
--
|
|
3
|
+
-- Mirrors ../migrations/057_alert_evaluations_outcome.sql ('value',
|
|
4
|
+
-- 'no_data' or 'error'; existing rows 'value'). Idempotent.
|
|
5
|
+
|
|
6
|
+
ALTER TABLE alert_evaluations ADD COLUMN IF NOT EXISTS outcome TEXT NOT NULL DEFAULT 'value';
|
|
@@ -74,6 +74,19 @@ land alongside the first hosted-Postgres deployment.
|
|
|
74
74
|
store falls back to the pre-049 statement when the column is missing, so
|
|
75
75
|
the collector keeps evaluating during a rolling deploy, but it cannot
|
|
76
76
|
retry undelivered notifications until 049 is applied.
|
|
77
|
+
- `054` adds `alert_rules.pending_secs INTEGER NOT NULL DEFAULT 0` and `055`
|
|
78
|
+
`alert_state.pending_since TEXT` (ISO, NULL when not pending) for the
|
|
79
|
+
rule-level pending duration. Before 054 the store still writes rules
|
|
80
|
+
without a pending duration (legacy statement) and refuses `pendingSecs > 0`
|
|
81
|
+
with a message naming the migration. `056` adds
|
|
82
|
+
`idx_metric_point_series_received (series_id, received_at)` for the
|
|
83
|
+
`metrics` alert signal's window and newest-point reads.
|
|
84
|
+
- `057` adds `alert_evaluations.outcome TEXT NOT NULL DEFAULT 'value'`
|
|
85
|
+
(`value`, `no_data`, `error`; the store writes the legacy row without it
|
|
86
|
+
before 057) and `058` `alert_state.pending_seen_at TEXT`, the last
|
|
87
|
+
evaluation that saw a pending condition hold (required by pending rules).
|
|
88
|
+
- `059` adds `alert_evaluations.error_message TEXT` (sanitized reason for an
|
|
89
|
+
`error` outcome; the rules list reads it as `lastEvaluation.error`).
|
|
77
90
|
- Flags (`is_error`, `is_bot`, `payload_capture_enabled`) are `BOOLEAN` here
|
|
78
91
|
and `INTEGER 0/1` on D1. Shared SQL must test them as bare booleans
|
|
79
92
|
(`CASE WHEN is_error THEN 1 ELSE 0 END`, never `is_error = 1` or
|
|
@@ -16,6 +16,7 @@ import { AIStore, type IngestEvaluation } from "../lib/ai-store";
|
|
|
16
16
|
import { sqlDbFor } from "../lib/sql-db";
|
|
17
17
|
import { getProjectId } from "./_context";
|
|
18
18
|
import { readJsonObject } from "./_json";
|
|
19
|
+
import { respondWithContract } from "./autosre-routes";
|
|
19
20
|
|
|
20
21
|
export const aiReceiverPlugin: CollectorPlugin = {
|
|
21
22
|
name: "ai-receiver",
|
|
@@ -104,7 +105,11 @@ export const aiReceiverPlugin: CollectorPlugin = {
|
|
|
104
105
|
const store = new AIStore(sqlDbFor(c.env));
|
|
105
106
|
const call = await store.getAICall(projectId, c.req.param("callId"));
|
|
106
107
|
if (!call) return c.json({ error: "AI call not found" }, 404);
|
|
107
|
-
return c
|
|
108
|
+
return respondWithContract(c, "GET /internal/ai/calls/:callId", {
|
|
109
|
+
call,
|
|
110
|
+
traceId: call.traceId,
|
|
111
|
+
spanId: call.spanId,
|
|
112
|
+
});
|
|
108
113
|
});
|
|
109
114
|
|
|
110
115
|
// OpenInference-kind spans (LLM/TOOL/RETRIEVER/CHAIN/AGENT/...) joined
|