@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
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending duration (`pendingSecs`, Prometheus `for`) end to end: the real
|
|
3
|
+
* evaluator over the real `AlertsStore` on the migrated SQLite schema, with
|
|
4
|
+
* a `metrics` rule whose points come through `MetricsStore.ingestBatch`.
|
|
5
|
+
* Only `Date` is faked so each tick runs at a chosen time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AlertRule } from "@obsunified/types";
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import { AlertsStore } from "../lib/alerts-store";
|
|
11
|
+
import { MetricsStore } from "../lib/metrics-store";
|
|
12
|
+
import { ProjectsStore } from "../lib/projects-store";
|
|
13
|
+
import { createMigratedSqliteDb } from "../lib/test-utils/node-sqlite-db";
|
|
14
|
+
import type { DecodedMetricPoint } from "../otlp/decode";
|
|
15
|
+
import { evaluateRuleBatch } from "./alerts-evaluator";
|
|
16
|
+
|
|
17
|
+
const logger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
18
|
+
const T0 = Date.parse("2026-10-31T12:00:00.000Z");
|
|
19
|
+
|
|
20
|
+
const gauge = (atMs: number, value: number): DecodedMetricPoint => ({
|
|
21
|
+
name: "pg_up",
|
|
22
|
+
description: null,
|
|
23
|
+
unit: null,
|
|
24
|
+
type: "gauge",
|
|
25
|
+
isMonotonic: null,
|
|
26
|
+
temporality: null,
|
|
27
|
+
scopeName: "test",
|
|
28
|
+
scopeVersion: null,
|
|
29
|
+
serviceName: "vbox",
|
|
30
|
+
resourceAttrsJson: JSON.stringify({ "service.name": "vbox" }),
|
|
31
|
+
attributesJson: null,
|
|
32
|
+
identity: "pg_up|vbox",
|
|
33
|
+
tsNs: (BigInt(atMs) * 1_000_000n).toString(),
|
|
34
|
+
startTsNs: null,
|
|
35
|
+
value,
|
|
36
|
+
count: null,
|
|
37
|
+
sum: null,
|
|
38
|
+
min: null,
|
|
39
|
+
max: null,
|
|
40
|
+
boundsJson: null,
|
|
41
|
+
bucketCountsJson: null,
|
|
42
|
+
extraJson: null,
|
|
43
|
+
exemplarsJson: null,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("pendingSecs", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
49
|
+
vi.setSystemTime(T0 - 60_000);
|
|
50
|
+
});
|
|
51
|
+
afterEach(() => vi.useRealTimers());
|
|
52
|
+
|
|
53
|
+
const setup = async () => {
|
|
54
|
+
const db = createMigratedSqliteDb();
|
|
55
|
+
const project = await new ProjectsStore(db).createProject({
|
|
56
|
+
name: "P",
|
|
57
|
+
slug: "p",
|
|
58
|
+
});
|
|
59
|
+
const store = new AlertsStore(db);
|
|
60
|
+
const created = await store.createRule(project.id, {
|
|
61
|
+
name: "pg down for 30s",
|
|
62
|
+
signal: "metrics",
|
|
63
|
+
query: { metric: "pg_up", aggregation: "last" },
|
|
64
|
+
threshold: 0,
|
|
65
|
+
comparison: "==",
|
|
66
|
+
windowMins: 1,
|
|
67
|
+
pendingSecs: 30,
|
|
68
|
+
channels: [{ type: "webhook", url: "https://hooks.example.com/x" }],
|
|
69
|
+
});
|
|
70
|
+
// No channel: delivery trivially succeeds, no network.
|
|
71
|
+
const rule: AlertRule = { ...created, channels: [] };
|
|
72
|
+
const metrics = new MetricsStore(db);
|
|
73
|
+
/**
|
|
74
|
+
* Report `value` (null: report nothing) at `offsetSecs` after T0, then
|
|
75
|
+
* evaluate there.
|
|
76
|
+
*/
|
|
77
|
+
const tick = async (
|
|
78
|
+
offsetSecs: number,
|
|
79
|
+
value: number | null,
|
|
80
|
+
options: Parameters<typeof evaluateRuleBatch>[2] = {},
|
|
81
|
+
ruleOverride?: AlertRule,
|
|
82
|
+
) => {
|
|
83
|
+
const atMs = T0 + offsetSecs * 1000;
|
|
84
|
+
vi.setSystemTime(atMs);
|
|
85
|
+
if (value !== null) {
|
|
86
|
+
await metrics.ingestBatch({
|
|
87
|
+
projectId: project.id,
|
|
88
|
+
points: [gauge(atMs, value)],
|
|
89
|
+
receivedAt: new Date(atMs).toISOString(),
|
|
90
|
+
expiresAt: new Date(atMs + 86_400_000).toISOString(),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const evaluated = ruleOverride ?? {
|
|
94
|
+
...((await store.getRule(rule.id, project.id)) as AlertRule),
|
|
95
|
+
channels: [],
|
|
96
|
+
};
|
|
97
|
+
const result = await evaluateRuleBatch(store, [evaluated], {
|
|
98
|
+
logger,
|
|
99
|
+
...options,
|
|
100
|
+
});
|
|
101
|
+
const state = await store.getState(rule.id, project.id);
|
|
102
|
+
return { result, state };
|
|
103
|
+
};
|
|
104
|
+
return { db, store, rule, project, tick };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it("fires only after the condition held for pendingSecs, and resolves immediately", async () => {
|
|
108
|
+
const { db, store, rule, project, tick } = await setup();
|
|
109
|
+
|
|
110
|
+
let r = await tick(0, 0);
|
|
111
|
+
expect(r.result.fired).toBe(0);
|
|
112
|
+
expect(r.state).toMatchObject({
|
|
113
|
+
current_state: "ok",
|
|
114
|
+
pending_since: new Date(T0).toISOString(),
|
|
115
|
+
notified: 1,
|
|
116
|
+
});
|
|
117
|
+
expect((await store.getRule(rule.id, project.id))?.pendingSince).toBe(
|
|
118
|
+
new Date(T0).toISOString(),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
r = await tick(20, 0);
|
|
122
|
+
expect(r.result.fired).toBe(0);
|
|
123
|
+
expect(r.state?.current_state).toBe("ok");
|
|
124
|
+
|
|
125
|
+
r = await tick(31, 0);
|
|
126
|
+
expect(r.result.fired).toBe(1);
|
|
127
|
+
expect(r.state).toMatchObject({
|
|
128
|
+
current_state: "firing",
|
|
129
|
+
pending_since: null,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
r = await tick(40, 1);
|
|
133
|
+
expect(r.result.resolved).toBe(1);
|
|
134
|
+
expect(r.state).toMatchObject({ current_state: "ok", pending_since: null });
|
|
135
|
+
|
|
136
|
+
const evaluations = await store.listEvaluations({
|
|
137
|
+
ruleId: rule.id,
|
|
138
|
+
projectId: project.id,
|
|
139
|
+
hours: 24,
|
|
140
|
+
});
|
|
141
|
+
expect(evaluations.evaluations.map((e) => e.state).reverse()).toEqual([
|
|
142
|
+
"ok",
|
|
143
|
+
"ok",
|
|
144
|
+
"firing",
|
|
145
|
+
"ok",
|
|
146
|
+
]);
|
|
147
|
+
db.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("restarts the pending period when one evaluation does not match", async () => {
|
|
151
|
+
const { db, tick } = await setup();
|
|
152
|
+
await tick(0, 0);
|
|
153
|
+
let r = await tick(20, 1);
|
|
154
|
+
expect(r.state).toMatchObject({ current_state: "ok", pending_since: null });
|
|
155
|
+
r = await tick(35, 0);
|
|
156
|
+
expect(r.state?.pending_since).toBe(new Date(T0 + 35_000).toISOString());
|
|
157
|
+
r = await tick(60, 0);
|
|
158
|
+
expect(r.result.fired).toBe(0);
|
|
159
|
+
r = await tick(66, 0);
|
|
160
|
+
expect(r.result.fired).toBe(1);
|
|
161
|
+
db.close();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("fires on the first matching evaluation without pendingSecs", async () => {
|
|
165
|
+
const { db, store, rule, project, tick } = await setup();
|
|
166
|
+
await store.updateRule(rule.id, project.id, { pendingSecs: 0 });
|
|
167
|
+
const r = await tick(0, 0);
|
|
168
|
+
expect(r.result.fired).toBe(1);
|
|
169
|
+
expect(r.state?.pending_since ?? null).toBeNull();
|
|
170
|
+
db.close();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("starts the hold over after the rule is edited or disabled", async () => {
|
|
174
|
+
const { db, store, rule, project, tick } = await setup();
|
|
175
|
+
await tick(0, 0);
|
|
176
|
+
await tick(20, 0);
|
|
177
|
+
// Editing the threshold discards the 20 s already held.
|
|
178
|
+
await store.updateRule(rule.id, project.id, { threshold: 0 });
|
|
179
|
+
expect((await store.getState(rule.id, project.id))?.pending_since).toBe(
|
|
180
|
+
new Date(T0).toISOString(),
|
|
181
|
+
);
|
|
182
|
+
await store.updateRule(rule.id, project.id, { comparison: "<=" });
|
|
183
|
+
expect(
|
|
184
|
+
(await store.getState(rule.id, project.id))?.pending_since ?? null,
|
|
185
|
+
).toBeNull();
|
|
186
|
+
let r = await tick(31, 0);
|
|
187
|
+
expect(r.result.fired).toBe(0);
|
|
188
|
+
expect(r.state?.pending_since).toBe(new Date(T0 + 31_000).toISOString());
|
|
189
|
+
// Disable + re-enable also starts over.
|
|
190
|
+
await store.updateRule(rule.id, project.id, { enabled: false });
|
|
191
|
+
await store.updateRule(rule.id, project.id, { enabled: true });
|
|
192
|
+
r = await tick(62, 0);
|
|
193
|
+
expect(r.result.fired).toBe(0);
|
|
194
|
+
r = await tick(93, 0);
|
|
195
|
+
expect(r.result.fired).toBe(1);
|
|
196
|
+
// A rename alone keeps the state.
|
|
197
|
+
await store.updateRule(rule.id, project.id, { name: "renamed" });
|
|
198
|
+
expect((await store.getState(rule.id, project.id))?.current_state).toBe(
|
|
199
|
+
"firing",
|
|
200
|
+
);
|
|
201
|
+
db.close();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("starts the hold over after a gap longer than maxEvaluationGapMs or a restart", async () => {
|
|
205
|
+
const { db, tick } = await setup();
|
|
206
|
+
const gap = { maxEvaluationGapMs: 20_000 };
|
|
207
|
+
await tick(0, 0, gap);
|
|
208
|
+
await tick(15, 0, gap);
|
|
209
|
+
// 25 s without an evaluation: the hold does not survive it.
|
|
210
|
+
let r = await tick(40, 0, gap);
|
|
211
|
+
expect(r.result.fired).toBe(0);
|
|
212
|
+
expect(r.state?.pending_since).toBe(new Date(T0 + 40_000).toISOString());
|
|
213
|
+
r = await tick(55, 0, gap);
|
|
214
|
+
expect(r.result.fired).toBe(0);
|
|
215
|
+
// A collector process that started after the hold was last seen.
|
|
216
|
+
const restarted = {
|
|
217
|
+
...gap,
|
|
218
|
+
holdStartedAt: new Date(T0 + 60_000).toISOString(),
|
|
219
|
+
};
|
|
220
|
+
r = await tick(71, 0, restarted);
|
|
221
|
+
expect(r.result.fired).toBe(0);
|
|
222
|
+
expect(r.state?.pending_since).toBe(new Date(T0 + 71_000).toISOString());
|
|
223
|
+
r = await tick(86, 0, restarted);
|
|
224
|
+
expect(r.result.fired).toBe(0);
|
|
225
|
+
r = await tick(101, 0, restarted);
|
|
226
|
+
expect(r.result.fired).toBe(1);
|
|
227
|
+
db.close();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('noData "keep" leaves a firing rule firing when the source vanishes', async () => {
|
|
231
|
+
const { db, store, rule, project, tick } = await setup();
|
|
232
|
+
await store.updateRule(rule.id, project.id, {
|
|
233
|
+
pendingSecs: 0,
|
|
234
|
+
comparison: ">",
|
|
235
|
+
threshold: 5,
|
|
236
|
+
query: {
|
|
237
|
+
metric: "pg_up",
|
|
238
|
+
aggregation: "last",
|
|
239
|
+
noData: "keep",
|
|
240
|
+
staleSecs: 30,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
let r = await tick(0, 9);
|
|
244
|
+
expect(r.result.fired).toBe(1);
|
|
245
|
+
// The source stops; after 30 s its series is stale: no data.
|
|
246
|
+
r = await tick(45, null);
|
|
247
|
+
expect(r.result).toMatchObject({ fired: 0, resolved: 0 });
|
|
248
|
+
expect(r.state?.current_state).toBe("firing");
|
|
249
|
+
r = await tick(90, null);
|
|
250
|
+
expect(r.state?.current_state).toBe("firing");
|
|
251
|
+
// Data again, below the threshold: resolves.
|
|
252
|
+
r = await tick(120, 1);
|
|
253
|
+
expect(r.result.resolved).toBe(1);
|
|
254
|
+
const history = (
|
|
255
|
+
await store.listEvaluations({
|
|
256
|
+
ruleId: rule.id,
|
|
257
|
+
projectId: project.id,
|
|
258
|
+
hours: 24,
|
|
259
|
+
})
|
|
260
|
+
).evaluations
|
|
261
|
+
.reverse()
|
|
262
|
+
.map((e) => [e.state, e.outcome]);
|
|
263
|
+
expect(history).toEqual([
|
|
264
|
+
["firing", "value"],
|
|
265
|
+
["firing", "no_data"],
|
|
266
|
+
["firing", "no_data"],
|
|
267
|
+
["ok", "value"],
|
|
268
|
+
]);
|
|
269
|
+
db.close();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('noData "zero" (default) resolves when the source vanishes', async () => {
|
|
273
|
+
const { db, store, rule, project, tick } = await setup();
|
|
274
|
+
await store.updateRule(rule.id, project.id, {
|
|
275
|
+
pendingSecs: 0,
|
|
276
|
+
comparison: ">",
|
|
277
|
+
threshold: 5,
|
|
278
|
+
query: { metric: "pg_up", aggregation: "last", staleSecs: 30 },
|
|
279
|
+
});
|
|
280
|
+
expect((await tick(0, 9)).result.fired).toBe(1);
|
|
281
|
+
expect((await tick(45, null)).result.resolved).toBe(1);
|
|
282
|
+
db.close();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("records an error evaluation and keeps the state when evaluation fails", async () => {
|
|
286
|
+
const { db, store, rule, project, tick } = await setup();
|
|
287
|
+
await store.updateRule(rule.id, project.id, { pendingSecs: 0 });
|
|
288
|
+
expect((await tick(0, 0)).result.fired).toBe(1);
|
|
289
|
+
const broken: AlertRule = {
|
|
290
|
+
...((await store.getRule(rule.id, project.id)) as AlertRule),
|
|
291
|
+
channels: [],
|
|
292
|
+
// Unknown signal: evaluation throws.
|
|
293
|
+
signal: "bogus" as never,
|
|
294
|
+
};
|
|
295
|
+
const r = await tick(10, 1, {}, broken);
|
|
296
|
+
expect(r.result).toMatchObject({ fired: 0, resolved: 0 });
|
|
297
|
+
expect(r.state?.current_state).toBe("firing");
|
|
298
|
+
const latest = (
|
|
299
|
+
await store.listEvaluations({
|
|
300
|
+
ruleId: rule.id,
|
|
301
|
+
projectId: project.id,
|
|
302
|
+
hours: 24,
|
|
303
|
+
})
|
|
304
|
+
).evaluations[0];
|
|
305
|
+
expect(latest).toMatchObject({
|
|
306
|
+
outcome: "error",
|
|
307
|
+
state: "firing",
|
|
308
|
+
error: "unknown alert signal: bogus",
|
|
309
|
+
});
|
|
310
|
+
const listed = (await store.listRules(project.id))[0];
|
|
311
|
+
expect(listed).toMatchObject({
|
|
312
|
+
currentState: "firing",
|
|
313
|
+
notificationDelivered: true,
|
|
314
|
+
lastEvaluation: {
|
|
315
|
+
outcome: "error",
|
|
316
|
+
error: "unknown alert signal: bogus",
|
|
317
|
+
evaluatedAt: new Date(T0 + 10_000).toISOString(),
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
db.close();
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -37,6 +37,8 @@ const evaluatorOver = (
|
|
|
37
37
|
store.transitionState(id, project, next, now),
|
|
38
38
|
markNotified: (id, project, state, since) =>
|
|
39
39
|
store.markNotified(id, project, state, since),
|
|
40
|
+
setPending: (id, project, pending, createdAt) =>
|
|
41
|
+
store.setPending(id, project, pending, createdAt),
|
|
40
42
|
recordEvaluation: (id, project, v, state, notified) =>
|
|
41
43
|
store.recordEvaluation(id, project, v, state, notified),
|
|
42
44
|
});
|
|
@@ -31,6 +31,7 @@ const makeStore = (
|
|
|
31
31
|
markNotified: async () => true,
|
|
32
32
|
getAnalysisNarrative: async () => null,
|
|
33
33
|
transitionState: async () => {},
|
|
34
|
+
setPending: async () => {},
|
|
34
35
|
recordEvaluation: async () => {},
|
|
35
36
|
...overrides,
|
|
36
37
|
});
|
|
@@ -105,13 +106,15 @@ describe("evaluateRuleBatch", () => {
|
|
|
105
106
|
it("times out a stuck rule and continues the batch", async () => {
|
|
106
107
|
const errors: unknown[] = [];
|
|
107
108
|
let recorded = 0;
|
|
109
|
+
const outcomes: Array<[string, string]> = [];
|
|
108
110
|
const store = makeStore({
|
|
109
111
|
evaluateRule: async (rule) => {
|
|
110
112
|
if (rule.id === "stuck") return new Promise<number>(() => {});
|
|
111
113
|
return 0;
|
|
112
114
|
},
|
|
113
|
-
recordEvaluation: async () => {
|
|
115
|
+
recordEvaluation: async (id, _p, _v, _s, _n, outcome) => {
|
|
114
116
|
recorded += 1;
|
|
117
|
+
outcomes.push([id, outcome ?? "value"]);
|
|
115
118
|
},
|
|
116
119
|
});
|
|
117
120
|
|
|
@@ -136,7 +139,12 @@ describe("evaluateRuleBatch", () => {
|
|
|
136
139
|
resolved: 0,
|
|
137
140
|
redelivered: 0,
|
|
138
141
|
});
|
|
139
|
-
|
|
142
|
+
// The stuck rule leaves an `error` evaluation instead of nothing.
|
|
143
|
+
expect(recorded).toBe(2);
|
|
144
|
+
expect(outcomes.sort()).toEqual([
|
|
145
|
+
["ok", "value"],
|
|
146
|
+
["stuck", "error"],
|
|
147
|
+
]);
|
|
140
148
|
expect(errors).toHaveLength(1);
|
|
141
149
|
expect(errors[0]).toMatchObject({ rule_id: "stuck" });
|
|
142
150
|
});
|