@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.
- package/README.md +7 -3
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-2FALTDCE.js → chunk-VSOZJO6Y.js} +12 -1
- package/dist/chunk-VSOZJO6Y.js.map +1 -0
- package/dist/index.d.ts +271 -62
- package/dist/index.js +1077 -137
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-BmP5-f96.d.ts → ingest-auth-BTowieER.d.ts} +66 -2
- package/package.json +3 -3
- package/src/framework/collector.ts +22 -0
- package/src/framework/env.ts +9 -1
- package/src/framework/ingest-auth-warning.test.ts +71 -0
- package/src/index.ts +15 -0
- package/src/lib/alert-email.ts +104 -0
- package/src/lib/alerts-store.test.ts +147 -2
- package/src/lib/alerts-store.ts +438 -63
- 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/projects-store.test.ts +44 -1
- package/src/lib/projects-store.ts +21 -0
- package/src/lib/sql-db-postgres.integration.test.ts +247 -0
- package/src/lib/store/trace-queries.ts +1 -1
- package/src/lib/webhook-url.test.ts +49 -0
- package/src/lib/webhook-url.ts +72 -14
- 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-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/otlp/decode/logs.ts +30 -2
- package/src/otlp/decode/metrics.ts +67 -2
- package/src/otlp/decode/traces.ts +57 -4
- package/src/otlp/decode/values.ts +64 -10
- package/src/otlp/decode.test.ts +305 -0
- package/src/otlp/decode.ts +9 -3
- 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 +9 -0
- package/src/plugins/logs-receiver.ts +8 -8
- package/src/plugins/metrics-receiver.ts +10 -9
- package/src/plugins/otlp-receiver.ts +10 -26
- package/src/plugins/profile-routes.test.ts +22 -0
- package/src/plugins/profile-routes.ts +11 -2
- package/src/plugins/replay-query-routes.test.ts +63 -1
- package/src/plugins/replay-query-routes.ts +40 -9
- 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.
|
package/src/lib/webhook-url.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
if (
|
|
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,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
|