@obsunified/collector 2.3.0 → 2.4.4

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.
Files changed (59) hide show
  1. package/README.md +7 -3
  2. package/dist/auth.d.ts +2 -1
  3. package/dist/auth.js +1 -1
  4. package/dist/{chunk-2FALTDCE.js → chunk-VSOZJO6Y.js} +12 -1
  5. package/dist/chunk-VSOZJO6Y.js.map +1 -0
  6. package/dist/index.d.ts +271 -62
  7. package/dist/index.js +1077 -137
  8. package/dist/index.js.map +1 -1
  9. package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
  10. package/package.json +3 -3
  11. package/src/framework/collector.ts +22 -0
  12. package/src/framework/env.ts +9 -1
  13. package/src/framework/ingest-auth-warning.test.ts +71 -0
  14. package/src/index.ts +15 -0
  15. package/src/lib/alert-email.ts +104 -0
  16. package/src/lib/alerts-store.test.ts +147 -2
  17. package/src/lib/alerts-store.ts +438 -63
  18. package/src/lib/metric-alerts.test.ts +769 -0
  19. package/src/lib/metric-alerts.ts +640 -0
  20. package/src/lib/metrics-store.ts +9 -2
  21. package/src/lib/projects-store.test.ts +44 -1
  22. package/src/lib/projects-store.ts +21 -0
  23. package/src/lib/sql-db-postgres.integration.test.ts +247 -0
  24. package/src/lib/store/trace-queries.ts +1 -1
  25. package/src/lib/webhook-url.test.ts +49 -0
  26. package/src/lib/webhook-url.ts +72 -14
  27. package/src/migrations/054_alert_rules_pending_secs.sql +11 -0
  28. package/src/migrations/055_alert_state_pending_since.sql +12 -0
  29. package/src/migrations/056_metric_point_series_received.sql +9 -0
  30. package/src/migrations/057_alert_evaluations_outcome.sql +11 -0
  31. package/src/migrations/058_alert_state_pending_seen_at.sql +12 -0
  32. package/src/migrations/059_alert_evaluations_error.sql +11 -0
  33. package/src/migrations-postgres/054_alert_rules_pending_secs.sql +7 -0
  34. package/src/migrations-postgres/055_alert_state_pending_since.sql +6 -0
  35. package/src/migrations-postgres/056_metric_point_series_received.sql +7 -0
  36. package/src/migrations-postgres/057_alert_evaluations_outcome.sql +6 -0
  37. package/src/migrations-postgres/058_alert_state_pending_seen_at.sql +6 -0
  38. package/src/migrations-postgres/059_alert_evaluations_error.sql +6 -0
  39. package/src/migrations-postgres/README.md +13 -0
  40. package/src/otlp/decode/logs.ts +30 -2
  41. package/src/otlp/decode/metrics.ts +67 -2
  42. package/src/otlp/decode/traces.ts +57 -4
  43. package/src/otlp/decode/values.ts +64 -10
  44. package/src/otlp/decode.test.ts +305 -0
  45. package/src/otlp/decode.ts +9 -3
  46. package/src/plugins/alerts-evaluator-pending.test.ts +322 -0
  47. package/src/plugins/alerts-evaluator-retention.test.ts +2 -0
  48. package/src/plugins/alerts-evaluator.test.ts +10 -2
  49. package/src/plugins/alerts-evaluator.ts +239 -40
  50. package/src/plugins/alerts-routes.ts +17 -2
  51. package/src/plugins/autosre-routes.ts +9 -0
  52. package/src/plugins/logs-receiver.ts +8 -8
  53. package/src/plugins/metrics-receiver.ts +10 -9
  54. package/src/plugins/otlp-receiver.ts +10 -26
  55. package/src/plugins/profile-routes.test.ts +22 -0
  56. package/src/plugins/profile-routes.ts +11 -2
  57. package/src/plugins/replay-query-routes.test.ts +63 -1
  58. package/src/plugins/replay-query-routes.ts +40 -9
  59. package/dist/chunk-2FALTDCE.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import { sha256Hex } from "./hash";
3
3
  import { displayPrefix, ProjectsStore } from "./projects-store";
4
4
  import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
@@ -60,3 +60,46 @@ describe("bootstrap key display prefix", () => {
60
60
  db.close();
61
61
  });
62
62
  });
63
+
64
+ describe("ProjectsStore.bootstrapEnvKey rotation", () => {
65
+ it("warns (without revoking) when INGEST_KEY changes while the old bootstrap key is active", async () => {
66
+ const db = createMigratedSqliteDb();
67
+ const store = new ProjectsStore(db);
68
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
69
+ try {
70
+ await store.bootstrapEnvKey("first-bootstrap-key-0001");
71
+ await store.bootstrapEnvKey("first-bootstrap-key-0001"); // idempotent
72
+ expect(warn).not.toHaveBeenCalled();
73
+
74
+ await store.bootstrapEnvKey("second-bootstrap-key-0002");
75
+ expect(warn).toHaveBeenCalledTimes(1);
76
+ const message = String(warn.mock.calls[0]?.[0]);
77
+ expect(message).toContain("INGEST_KEY changed");
78
+ expect(message).toContain("revoke");
79
+ expect(message).not.toContain("first-bootstrap-key-0001");
80
+
81
+ // Nothing is auto-revoked: both keys still authenticate.
82
+ for (const key of [
83
+ "first-bootstrap-key-0001",
84
+ "second-bootstrap-key-0002",
85
+ ]) {
86
+ expect(
87
+ (await store.findByKeyHash(await sha256Hex(key)))?.projectId,
88
+ ).toBe("default");
89
+ }
90
+
91
+ // Once the old one is revoked, a restart with the new key is quiet.
92
+ const old = (await store.listKeys("default")).find(
93
+ (k) => k.name === "bootstrap" && k.keyPrefix.startsWith("first"),
94
+ );
95
+ expect(old).toBeDefined();
96
+ await store.revokeKey(old?.id ?? "", "default");
97
+ warn.mockClear();
98
+ await store.bootstrapEnvKey("second-bootstrap-key-0002");
99
+ expect(warn).not.toHaveBeenCalled();
100
+ } finally {
101
+ warn.mockRestore();
102
+ db.close();
103
+ }
104
+ });
105
+ });
@@ -215,6 +215,15 @@ export class ProjectsStore {
215
215
  return;
216
216
  }
217
217
 
218
+ // A different, still-active bootstrap key means INGEST_KEY was rotated.
219
+ // The old key is deliberately NOT revoked here: other instances (or
220
+ // clients mid-rollout) may still be using it. Tell the operator instead.
221
+ const previous = await this.db
222
+ .prepare(
223
+ `SELECT id, created_at FROM ingest_keys WHERE project_id = 'default' AND name = 'bootstrap' AND revoked_at IS NULL`,
224
+ )
225
+ .all<{ id: string; created_at: string }>();
226
+
218
227
  const id = randomHex(16);
219
228
  const createdAt = new Date().toISOString();
220
229
  await this.db
@@ -223,5 +232,17 @@ export class ProjectsStore {
223
232
  )
224
233
  .bind(id, keyHash, keyPrefix, createdAt)
225
234
  .run();
235
+
236
+ const stale = previous.results ?? [];
237
+ if (stale.length > 0) {
238
+ console.warn(
239
+ `[projects-store] INGEST_KEY changed: registered a new bootstrap ingest key (${keyPrefix}…) but ${stale.length} earlier bootstrap key(s) are still active and keep accepting ingest: ${stale
240
+ // Ids only: legacy rows may still hold a long key_prefix.
241
+ .map((row) => `id ${row.id} (created ${row.created_at})`)
242
+ .join(
243
+ ", ",
244
+ )}. Once every client uses the new key, revoke the old one in the dashboard (Projects → default → Keys) or with DELETE /internal/projects/default/keys/<keyId>.`,
245
+ );
246
+ }
226
247
  }
227
248
  }
@@ -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) }),
@@ -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
 
@@ -53,6 +53,41 @@ describe("assertSafeWebhookUrl", () => {
53
53
  }
54
54
  });
55
55
 
56
+ it("rejects IPv4 embedded in IPv6 (compatible, mapped, NAT64, 6to4)", () => {
57
+ for (const url of [
58
+ "http://[::7f00:1]/hook",
59
+ "http://[::127.0.0.1]/hook",
60
+ "http://[::a9fe:a9fe]/hook",
61
+ "http://[::ffff:7f00:1]/hook",
62
+ "http://[0:0:0:0:0:ffff:127.0.0.1]/hook",
63
+ "http://[64:ff9b::7f00:1]/hook",
64
+ "http://[64:ff9b::169.254.169.254]/hook",
65
+ "http://[2002:7f00:1::]/hook",
66
+ "http://[2002:a00:1::1]/hook",
67
+ "http://[::]/hook",
68
+ ]) {
69
+ expect(() => assertSafeWebhookUrl(url), url).toThrow(WebhookUrlError);
70
+ // The documented opt-out still unlocks every one of them.
71
+ expect(
72
+ () => assertSafeWebhookUrl(url, { allowPrivateNetworks: true }),
73
+ url,
74
+ ).not.toThrow();
75
+ }
76
+ });
77
+
78
+ it("keeps public IPv6 and public embedded IPv4 reachable", () => {
79
+ for (const url of [
80
+ "https://[2606:4700:4700::1111]/hook",
81
+ "https://[64:ff9b::808:808]/hook",
82
+ "https://[2002:808:808::1]/hook",
83
+ "https://[::ffff:8.8.8.8]/hook",
84
+ "https://198.20.0.1/hook",
85
+ "https://198.17.255.1/hook",
86
+ ]) {
87
+ expect(() => assertSafeWebhookUrl(url), url).not.toThrow();
88
+ }
89
+ });
90
+
56
91
  it("allows private hosts only with the explicit opt-in", () => {
57
92
  const url = "http://alertmanager.internal:9093/api/v2/alerts";
58
93
  expect(() => assertSafeWebhookUrl(url)).toThrow(WebhookUrlError);
@@ -107,6 +142,20 @@ describe("redactWebhookUrl", () => {
107
142
  expect(
108
143
  await resolveWebhookHost("v6.example", async () => ["fd00::1"]),
109
144
  ).toBe("private");
145
+ for (const answer of [
146
+ "::ffff:127.0.0.1",
147
+ "64:ff9b::a00:1",
148
+ "2002:a9fe:a9fe::",
149
+ "fe80::1%eth0",
150
+ ]) {
151
+ expect(
152
+ await resolveWebhookHost("v6.example", async () => [answer]),
153
+ answer,
154
+ ).toBe("private");
155
+ }
156
+ expect(
157
+ await resolveWebhookHost("v6.example", async () => ["2606:4700::1111"]),
158
+ ).toBe("public");
110
159
  // IP literals were judged already by the literal check.
111
160
  expect(await resolveWebhookHost("10.0.0.8", lookup)).toBe("public");
112
161
  // No answer is never "safe": the delivery fetch would resolve on its own.
@@ -38,25 +38,83 @@ const isPrivateIpv4 = (o: number[]): boolean =>
38
38
  (o[0] === 192 && o[1] === 168) ||
39
39
  o[0] >= 224; // multicast, reserved, broadcast
40
40
 
41
+ /**
42
+ * Expand an IPv6 literal (no brackets, optional `%zone`) into its eight
43
+ * 16-bit groups. Accepts a trailing dotted IPv4 tail (`::127.0.0.1`).
44
+ * Returns null for anything that is not a well-formed address.
45
+ */
46
+ const parseIpv6 = (literal: string): number[] | null => {
47
+ let text = literal.replace(/%.*$/, "");
48
+ const tail = /:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
49
+ let tailGroups: number[] = [];
50
+ if (tail) {
51
+ const v4 = parseIpv4(tail[1]);
52
+ if (!v4) return null;
53
+ tailGroups = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]];
54
+ text = text.slice(0, -tail[1].length);
55
+ // "::1.2.3.4" leaves "::", "64:ff9b::1.2.3.4" leaves "64:ff9b::",
56
+ // "::ffff:1.2.3.4" leaves "::ffff:"; drop the dangling separator
57
+ // unless it is part of "::".
58
+ if (text.endsWith(":") && !text.endsWith("::")) text = text.slice(0, -1);
59
+ }
60
+ const halves = text.split("::");
61
+ if (halves.length > 2) return null;
62
+ const toGroups = (part: string): number[] | null => {
63
+ if (part === "") return [];
64
+ const groups: number[] = [];
65
+ for (const g of part.split(":")) {
66
+ if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
67
+ groups.push(Number.parseInt(g, 16));
68
+ }
69
+ return groups;
70
+ };
71
+ const head = toGroups(halves[0]);
72
+ const rest = halves.length === 2 ? toGroups(halves[1]) : [];
73
+ if (!head || !rest) return null;
74
+ const explicit = head.length + rest.length + tailGroups.length;
75
+ if (halves.length === 1) {
76
+ return explicit === 8 ? [...head, ...tailGroups] : null;
77
+ }
78
+ if (explicit > 7) return null;
79
+ return [
80
+ ...head,
81
+ ...new Array<number>(8 - explicit).fill(0),
82
+ ...rest,
83
+ ...tailGroups,
84
+ ];
85
+ };
86
+
87
+ const embeddedIpv4 = (hi: number, lo: number): number[] => [
88
+ hi >> 8,
89
+ hi & 0xff,
90
+ lo >> 8,
91
+ lo & 0xff,
92
+ ];
93
+
41
94
  const isPrivateIpv6 = (host: string): boolean => {
42
95
  // URL.hostname keeps the brackets for IPv6 literals.
43
96
  const literal = host.replace(/^\[|\]$/g, "").toLowerCase();
44
- if (literal === "::" || literal === "::1") return true;
45
- // IPv4-mapped (::ffff:a.b.c.d). The WHATWG URL parser serializes the
46
- // embedded address as two hex groups (::ffff:a00:1), so accept both.
47
- const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(literal);
48
- if (dotted) {
49
- const v4 = parseIpv4(dotted[1]);
50
- return v4 ? isPrivateIpv4(v4) : true;
97
+ const g = parseIpv6(literal);
98
+ if (!g) return true; // not a parseable address: fail closed
99
+ const zeroPrefix = (n: number) => g.slice(0, n).every((x) => x === 0);
100
+ // :: (unspecified), ::1 (loopback) and the deprecated IPv4-compatible
101
+ // ::a.b.c.d form (which the WHATWG URL parser serializes as ::7f00:1).
102
+ if (zeroPrefix(6)) {
103
+ if (g[6] === 0 && (g[7] === 0 || g[7] === 1)) return true;
104
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
105
+ }
106
+ // IPv4-mapped ::ffff:a.b.c.d (serialized as ::ffff:a00:1).
107
+ if (zeroPrefix(5) && g[5] === 0xffff) {
108
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
51
109
  }
52
- const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(literal);
53
- if (hex) {
54
- const hi = Number.parseInt(hex[1], 16);
55
- const lo = Number.parseInt(hex[2], 16);
56
- return isPrivateIpv4([hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]);
110
+ // NAT64 well-known prefix 64:ff9b::/96 — the gateway forwards to the
111
+ // embedded IPv4 address.
112
+ if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every((x) => x === 0)) {
113
+ return isPrivateIpv4(embeddedIpv4(g[6], g[7]));
57
114
  }
58
- const first = Number.parseInt(literal.split(":")[0] || "0", 16);
59
- if (Number.isNaN(first)) return true;
115
+ // 6to4 2002::/16 carries the IPv4 relay address in groups 1-2.
116
+ if (g[0] === 0x2002) return isPrivateIpv4(embeddedIpv4(g[1], g[2]));
117
+ const first = g[0];
60
118
  if ((first & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
61
119
  if ((first & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
62
120
  if ((first & 0xff00) === 0xff00) return true; // ff00::/8 multicast
@@ -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;
@@ -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,6 @@
1
+ -- Migration 055: alert_state.pending_since
2
+ --
3
+ -- Mirrors ../migrations/055_alert_state_pending_since.sql. ISO TEXT like
4
+ -- alert_state.last_state_change. Idempotent.
5
+
6
+ ALTER TABLE alert_state ADD COLUMN IF NOT EXISTS pending_since TEXT;
@@ -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';
@@ -0,0 +1,6 @@
1
+ -- Migration 058: alert_state.pending_seen_at
2
+ --
3
+ -- Mirrors ../migrations/058_alert_state_pending_seen_at.sql. ISO TEXT.
4
+ -- Idempotent.
5
+
6
+ ALTER TABLE alert_state ADD COLUMN IF NOT EXISTS pending_seen_at TEXT;
@@ -0,0 +1,6 @@
1
+ -- Migration 059: alert_evaluations.error_message
2
+ --
3
+ -- Mirrors ../migrations/059_alert_evaluations_error.sql. TEXT, NULL unless
4
+ -- the outcome is 'error'. Idempotent.
5
+
6
+ ALTER TABLE alert_evaluations ADD COLUMN IF NOT EXISTS error_message TEXT;
@@ -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