@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
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `metrics` alert signal: validation and evaluation of an
|
|
3
|
+
* `AlertQueryMetrics` against `metric_series` / `metric_point`.
|
|
4
|
+
*
|
|
5
|
+
* Evaluation, per selector (numerator, then the optional denominator):
|
|
6
|
+
*
|
|
7
|
+
* 1. Series: `metric_series` rows of the project whose name is one of the
|
|
8
|
+
* selector's names (and whose `service_name` matches, when given), kept
|
|
9
|
+
* when every listed data-point / resource attribute equals the rule's
|
|
10
|
+
* value. Attribute values are compared as strings, so `200` matches a
|
|
11
|
+
* stored `"200"` and vice versa.
|
|
12
|
+
* 2. Points: the series' points whose `received_at` (the collector's clock,
|
|
13
|
+
* as for every other alert signal) is inside the rule window, ordered by
|
|
14
|
+
* producer time (`ts_ns`). The number read is `field` (`value`, or a
|
|
15
|
+
* histogram/summary `count` / `sum`); points without it are skipped.
|
|
16
|
+
* 3. Staleness: for `last`, `min`, `max`, `avg` and `sum` a series takes
|
|
17
|
+
* part only when its newest point is at most `staleSecs` (default 300)
|
|
18
|
+
* old, so a series that stopped reporting (a renamed host, a replaced
|
|
19
|
+
* pod) no longer adds its last value. `increase` / `rate` keep every
|
|
20
|
+
* series with points in the window: growth that happened is real even
|
|
21
|
+
* if the series stopped afterwards.
|
|
22
|
+
* 4. Each series is reduced on its own (`reduceSeries`), and the per-series
|
|
23
|
+
* results are summed. A series with no usable point adds nothing.
|
|
24
|
+
*
|
|
25
|
+
* When no series takes part (in the numerator or the denominator) the
|
|
26
|
+
* result is "no data": the value is 0 and `noData` is true. The rule's
|
|
27
|
+
* `noData` policy decides what that means (`zero`: evaluate 0 as usual;
|
|
28
|
+
* `keep`: leave the alert state as it is).
|
|
29
|
+
*
|
|
30
|
+
* `age_seconds` ignores the window: it is the seconds since the newest
|
|
31
|
+
* `received_at` of any matching series. When no matching series has any
|
|
32
|
+
* stored point (never reported, or everything aged out of retention) it is
|
|
33
|
+
* the seconds since the rule was created, a finite number that grows until
|
|
34
|
+
* the threshold is crossed, so "never reported" fires after the same grace
|
|
35
|
+
* period as "stopped reporting".
|
|
36
|
+
*
|
|
37
|
+
* `age_seconds` is never "no data".
|
|
38
|
+
*
|
|
39
|
+
* Ratio: with a `denominator`, value = numerator / denominator when the
|
|
40
|
+
* denominator is > 0 and >= minDenominator (default 0), else 0.
|
|
41
|
+
*
|
|
42
|
+
* Cost bounds: at most `MAX_MATCHED_SERIES` series and `MAX_LOADED_POINTS`
|
|
43
|
+
* points per selector; past either the evaluation fails with an error (the
|
|
44
|
+
* evaluator records it as an `error` evaluation) rather than returning a
|
|
45
|
+
* value computed from part of the data. Metric rule windows are at most
|
|
46
|
+
* `MAX_METRIC_WINDOW_MINS`.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import type {
|
|
50
|
+
AlertMetricAggregation,
|
|
51
|
+
AlertMetricAttributeValue,
|
|
52
|
+
AlertMetricField,
|
|
53
|
+
AlertMetricSelector,
|
|
54
|
+
AlertQueryMetrics,
|
|
55
|
+
} from "@obsunified/types";
|
|
56
|
+
import type { SqlDb } from "./sql-db";
|
|
57
|
+
|
|
58
|
+
export const METRIC_AGGREGATIONS: readonly AlertMetricAggregation[] = [
|
|
59
|
+
"last",
|
|
60
|
+
"min",
|
|
61
|
+
"max",
|
|
62
|
+
"avg",
|
|
63
|
+
"sum",
|
|
64
|
+
"increase",
|
|
65
|
+
"rate",
|
|
66
|
+
"age_seconds",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export const METRIC_FIELDS: readonly AlertMetricField[] = [
|
|
70
|
+
"value",
|
|
71
|
+
"count",
|
|
72
|
+
"sum",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** OTLP `AggregationTemporality`. */
|
|
76
|
+
const TEMPORALITY_DELTA = 1;
|
|
77
|
+
|
|
78
|
+
const MAX_METRIC_NAMES = 20;
|
|
79
|
+
const MAX_ATTRIBUTE_KEYS = 32;
|
|
80
|
+
/** A rule matching more series than this is refused at evaluation time. */
|
|
81
|
+
export const MAX_MATCHED_SERIES = 1000;
|
|
82
|
+
/** Points one selector may load per evaluation before it is refused. */
|
|
83
|
+
export const MAX_LOADED_POINTS = 50_000;
|
|
84
|
+
/** Longest window (`windowMins`) a metrics rule may use: one day. */
|
|
85
|
+
export const MAX_METRIC_WINDOW_MINS = 1440;
|
|
86
|
+
export const DEFAULT_STALE_SECS = 300;
|
|
87
|
+
export const MAX_STALE_SECS = 86_400;
|
|
88
|
+
export const METRIC_NO_DATA_POLICIES = ["zero", "keep"] as const;
|
|
89
|
+
/** Keeps each statement under D1's ~100 bound-parameter limit. */
|
|
90
|
+
const ID_CHUNK = 50;
|
|
91
|
+
|
|
92
|
+
const SELECTOR_KEYS = [
|
|
93
|
+
"metric",
|
|
94
|
+
"attributes",
|
|
95
|
+
"resourceAttributes",
|
|
96
|
+
"serviceName",
|
|
97
|
+
"field",
|
|
98
|
+
];
|
|
99
|
+
const QUERY_KEYS = [
|
|
100
|
+
...SELECTOR_KEYS,
|
|
101
|
+
"aggregation",
|
|
102
|
+
"denominator",
|
|
103
|
+
"minDenominator",
|
|
104
|
+
"staleSecs",
|
|
105
|
+
"noData",
|
|
106
|
+
];
|
|
107
|
+
const DENOMINATOR_KEYS = [...SELECTOR_KEYS, "aggregation"];
|
|
108
|
+
|
|
109
|
+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
110
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
111
|
+
|
|
112
|
+
const validateAttributes = (value: unknown, path: string): void => {
|
|
113
|
+
if (value === undefined) return;
|
|
114
|
+
if (!isPlainObject(value))
|
|
115
|
+
throw new Error(`${path} must be an object of attribute: value pairs`);
|
|
116
|
+
const entries = Object.entries(value);
|
|
117
|
+
if (entries.length > MAX_ATTRIBUTE_KEYS)
|
|
118
|
+
throw new Error(`${path} may list at most ${MAX_ATTRIBUTE_KEYS} keys`);
|
|
119
|
+
for (const [key, v] of entries) {
|
|
120
|
+
if (!key.trim()) throw new Error(`${path} has an empty attribute name`);
|
|
121
|
+
const ok =
|
|
122
|
+
typeof v === "string" ||
|
|
123
|
+
typeof v === "boolean" ||
|
|
124
|
+
(typeof v === "number" && Number.isFinite(v));
|
|
125
|
+
if (!ok)
|
|
126
|
+
throw new Error(
|
|
127
|
+
`${path}.${key} must be a string, finite number or boolean`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const validateSelector = (
|
|
133
|
+
q: Record<string, unknown>,
|
|
134
|
+
path: string,
|
|
135
|
+
allowedKeys: string[],
|
|
136
|
+
): void => {
|
|
137
|
+
for (const key of Object.keys(q)) {
|
|
138
|
+
if (!allowedKeys.includes(key))
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${path}.${key} is not a metrics query field (allowed: ${allowedKeys.join(", ")})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const metric = q.metric;
|
|
144
|
+
if (typeof metric === "string") {
|
|
145
|
+
if (!metric.trim()) throw new Error(`${path}.metric must not be empty`);
|
|
146
|
+
} else if (Array.isArray(metric)) {
|
|
147
|
+
if (metric.length === 0)
|
|
148
|
+
throw new Error(`${path}.metric must name at least one metric`);
|
|
149
|
+
if (metric.length > MAX_METRIC_NAMES)
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${path}.metric may name at most ${MAX_METRIC_NAMES} metrics`,
|
|
152
|
+
);
|
|
153
|
+
for (const m of metric) {
|
|
154
|
+
if (typeof m !== "string" || !m.trim())
|
|
155
|
+
throw new Error(`${path}.metric entries must be non-empty strings`);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${path}.metric is required: a metric name or a list of names`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
validateAttributes(q.attributes, `${path}.attributes`);
|
|
163
|
+
validateAttributes(q.resourceAttributes, `${path}.resourceAttributes`);
|
|
164
|
+
if (
|
|
165
|
+
q.serviceName !== undefined &&
|
|
166
|
+
(typeof q.serviceName !== "string" || !q.serviceName.trim())
|
|
167
|
+
)
|
|
168
|
+
throw new Error(`${path}.serviceName must be a non-empty string`);
|
|
169
|
+
if (
|
|
170
|
+
q.field !== undefined &&
|
|
171
|
+
!METRIC_FIELDS.includes(q.field as AlertMetricField)
|
|
172
|
+
)
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${path}.field must be one of: ${METRIC_FIELDS.join(", ")}`,
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** Throws an Error with a user-facing message when `query` is malformed. */
|
|
179
|
+
export function validateMetricQuery(query: unknown): void {
|
|
180
|
+
if (!isPlainObject(query))
|
|
181
|
+
throw new Error("query must be an object for the metrics signal");
|
|
182
|
+
validateSelector(query, "query", QUERY_KEYS);
|
|
183
|
+
const aggregation = query.aggregation;
|
|
184
|
+
if (!METRIC_AGGREGATIONS.includes(aggregation as AlertMetricAggregation))
|
|
185
|
+
throw new Error(
|
|
186
|
+
`query.aggregation must be one of: ${METRIC_AGGREGATIONS.join(", ")}`,
|
|
187
|
+
);
|
|
188
|
+
if (
|
|
189
|
+
query.staleSecs !== undefined &&
|
|
190
|
+
(!Number.isInteger(query.staleSecs) ||
|
|
191
|
+
(query.staleSecs as number) < 1 ||
|
|
192
|
+
(query.staleSecs as number) > MAX_STALE_SECS)
|
|
193
|
+
)
|
|
194
|
+
throw new Error(
|
|
195
|
+
`query.staleSecs must be an integer from 1 to ${MAX_STALE_SECS}`,
|
|
196
|
+
);
|
|
197
|
+
if (
|
|
198
|
+
query.noData !== undefined &&
|
|
199
|
+
!METRIC_NO_DATA_POLICIES.includes(query.noData as "zero" | "keep")
|
|
200
|
+
)
|
|
201
|
+
throw new Error(
|
|
202
|
+
`query.noData must be one of: ${METRIC_NO_DATA_POLICIES.join(", ")}`,
|
|
203
|
+
);
|
|
204
|
+
if (query.denominator === undefined) {
|
|
205
|
+
if (query.minDenominator !== undefined)
|
|
206
|
+
throw new Error("query.minDenominator requires query.denominator");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (aggregation === "age_seconds")
|
|
210
|
+
throw new Error("query.denominator cannot be used with age_seconds");
|
|
211
|
+
if (!isPlainObject(query.denominator))
|
|
212
|
+
throw new Error("query.denominator must be an object");
|
|
213
|
+
validateSelector(query.denominator, "query.denominator", DENOMINATOR_KEYS);
|
|
214
|
+
const denAgg = query.denominator.aggregation;
|
|
215
|
+
if (denAgg !== undefined) {
|
|
216
|
+
if (
|
|
217
|
+
denAgg === "age_seconds" ||
|
|
218
|
+
!METRIC_AGGREGATIONS.includes(denAgg as AlertMetricAggregation)
|
|
219
|
+
)
|
|
220
|
+
throw new Error(
|
|
221
|
+
`query.denominator.aggregation must be one of: ${METRIC_AGGREGATIONS.filter((a) => a !== "age_seconds").join(", ")}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (
|
|
225
|
+
query.minDenominator !== undefined &&
|
|
226
|
+
(typeof query.minDenominator !== "number" ||
|
|
227
|
+
!Number.isFinite(query.minDenominator) ||
|
|
228
|
+
query.minDenominator < 0)
|
|
229
|
+
)
|
|
230
|
+
throw new Error("query.minDenominator must be a finite number >= 0");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Pure reduction ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
export interface MetricPointSample {
|
|
236
|
+
tsNs: string;
|
|
237
|
+
startTsNs: string | null;
|
|
238
|
+
value: number | null;
|
|
239
|
+
count: number | null;
|
|
240
|
+
sum: number | null;
|
|
241
|
+
receivedAt: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface SeriesReduceOptions {
|
|
245
|
+
/**
|
|
246
|
+
* The series has no history before the window (its `metric_series` row
|
|
247
|
+
* was created inside it): a cumulative counter's first point then counts
|
|
248
|
+
* as growth from 0 instead of being the baseline.
|
|
249
|
+
*/
|
|
250
|
+
bornInWindow?: boolean;
|
|
251
|
+
aggregation: Exclude<AlertMetricAggregation, "age_seconds">;
|
|
252
|
+
field: AlertMetricField;
|
|
253
|
+
/** `metric_series.temporality`: 1 delta, 2 cumulative, null for gauges. */
|
|
254
|
+
temporality: number | null;
|
|
255
|
+
/** Start of the window in Unix nanoseconds. */
|
|
256
|
+
windowStartNs: bigint;
|
|
257
|
+
windowSecs: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const toBigInt = (ns: string | null): bigint | null => {
|
|
261
|
+
if (!ns) return null;
|
|
262
|
+
try {
|
|
263
|
+
return BigInt(ns);
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const readField = (p: MetricPointSample, field: AlertMetricField) => {
|
|
270
|
+
const raw = field === "count" ? p.count : field === "sum" ? p.sum : p.value;
|
|
271
|
+
const n = typeof raw === "string" ? Number(raw) : raw;
|
|
272
|
+
return typeof n === "number" && Number.isFinite(n) ? n : null;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** Orders by producer time, then receive time (stable for equal ts_ns). */
|
|
276
|
+
export const sortPoints = (points: MetricPointSample[]): MetricPointSample[] =>
|
|
277
|
+
[...points].sort((a, b) => {
|
|
278
|
+
const ta = toBigInt(a.tsNs) ?? 0n;
|
|
279
|
+
const tb = toBigInt(b.tsNs) ?? 0n;
|
|
280
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
281
|
+
return a.receivedAt < b.receivedAt
|
|
282
|
+
? -1
|
|
283
|
+
: a.receivedAt > b.receivedAt
|
|
284
|
+
? 1
|
|
285
|
+
: 0;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Counter growth over consecutive cumulative points (Prometheus `increase`
|
|
290
|
+
* without extrapolation). A drop, or a change of the point's start time,
|
|
291
|
+
* is a counter reset: the new value counts in full, as growth from 0. The
|
|
292
|
+
* first point is decided by its OTLP start time when it has one: a start
|
|
293
|
+
* inside the window means the counter began there, so its value counts from
|
|
294
|
+
* 0; a start before the window means part of the value grew before the
|
|
295
|
+
* window, so the point is the baseline (even for a series obs-unified first
|
|
296
|
+
* sees now: a long-running counter reaching a fresh database must not read
|
|
297
|
+
* as a burst). Only without a start time does `bornInWindow` decide (no
|
|
298
|
+
* history before the window: count from 0).
|
|
299
|
+
*/
|
|
300
|
+
const cumulativeIncrease = (
|
|
301
|
+
points: Array<{ v: number; start: bigint | null }>,
|
|
302
|
+
windowStartNs: bigint,
|
|
303
|
+
bornInWindow: boolean,
|
|
304
|
+
): number => {
|
|
305
|
+
if (points.length === 0) return 0;
|
|
306
|
+
const first = points[0];
|
|
307
|
+
const countsFirst =
|
|
308
|
+
first.start !== null ? first.start >= windowStartNs : bornInWindow;
|
|
309
|
+
let total = countsFirst ? first.v : 0;
|
|
310
|
+
for (let i = 1; i < points.length; i++) {
|
|
311
|
+
const prev = points[i - 1];
|
|
312
|
+
const cur = points[i];
|
|
313
|
+
const restarted =
|
|
314
|
+
cur.start !== null && prev.start !== null && cur.start !== prev.start;
|
|
315
|
+
if (restarted || cur.v < prev.v) total += cur.v;
|
|
316
|
+
else total += cur.v - prev.v;
|
|
317
|
+
}
|
|
318
|
+
return total;
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reduces one series' window points to a number, or null when the series
|
|
323
|
+
* has no point carrying `field` in the window.
|
|
324
|
+
*/
|
|
325
|
+
export function reduceSeries(
|
|
326
|
+
points: MetricPointSample[],
|
|
327
|
+
opts: SeriesReduceOptions,
|
|
328
|
+
): number | null {
|
|
329
|
+
const usable: Array<{ v: number; start: bigint | null }> = [];
|
|
330
|
+
for (const p of sortPoints(points)) {
|
|
331
|
+
const v = readField(p, opts.field);
|
|
332
|
+
if (v !== null) usable.push({ v, start: toBigInt(p.startTsNs) });
|
|
333
|
+
}
|
|
334
|
+
if (usable.length === 0) return null;
|
|
335
|
+
const values = usable.map((u) => u.v);
|
|
336
|
+
switch (opts.aggregation) {
|
|
337
|
+
case "last":
|
|
338
|
+
return values[values.length - 1];
|
|
339
|
+
case "min":
|
|
340
|
+
return values.reduce((a, b) => (b < a ? b : a));
|
|
341
|
+
case "max":
|
|
342
|
+
return values.reduce((a, b) => (b > a ? b : a));
|
|
343
|
+
case "sum":
|
|
344
|
+
return values.reduce((a, b) => a + b, 0);
|
|
345
|
+
case "avg":
|
|
346
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
347
|
+
case "increase":
|
|
348
|
+
case "rate": {
|
|
349
|
+
const increase =
|
|
350
|
+
opts.temporality === TEMPORALITY_DELTA
|
|
351
|
+
? values.reduce((a, b) => a + b, 0)
|
|
352
|
+
: cumulativeIncrease(
|
|
353
|
+
usable,
|
|
354
|
+
opts.windowStartNs,
|
|
355
|
+
opts.bornInWindow ?? false,
|
|
356
|
+
);
|
|
357
|
+
return opts.aggregation === "rate"
|
|
358
|
+
? increase / opts.windowSecs
|
|
359
|
+
: increase;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** numerator / denominator when denominator > 0 and >= minDenominator, else 0. */
|
|
365
|
+
export const metricRatio = (
|
|
366
|
+
numerator: number,
|
|
367
|
+
denominator: number,
|
|
368
|
+
minDenominator: number,
|
|
369
|
+
): number =>
|
|
370
|
+
denominator > 0 && denominator >= minDenominator
|
|
371
|
+
? numerator / denominator
|
|
372
|
+
: 0;
|
|
373
|
+
|
|
374
|
+
// ── Database evaluation ────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
interface SeriesRow {
|
|
377
|
+
id: string;
|
|
378
|
+
temporality: number | string | null;
|
|
379
|
+
created_at: string;
|
|
380
|
+
attributes_json: unknown;
|
|
381
|
+
resource_attrs_json: unknown;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
interface PointRow {
|
|
385
|
+
series_id: string;
|
|
386
|
+
ts_ns: string;
|
|
387
|
+
start_ts_ns: string | null;
|
|
388
|
+
value: number | null;
|
|
389
|
+
count: number | null;
|
|
390
|
+
sum: number | null;
|
|
391
|
+
received_at: string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const parseAttrs = (raw: unknown): Record<string, unknown> => {
|
|
395
|
+
if (raw === null || raw === undefined) return {};
|
|
396
|
+
if (typeof raw === "string") {
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(raw);
|
|
399
|
+
return isPlainObject(parsed) ? parsed : {};
|
|
400
|
+
} catch {
|
|
401
|
+
return {};
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return isPlainObject(raw) ? raw : {};
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const attrsMatch = (
|
|
408
|
+
stored: Record<string, unknown>,
|
|
409
|
+
wanted: Record<string, AlertMetricAttributeValue> | undefined,
|
|
410
|
+
): boolean => {
|
|
411
|
+
if (!wanted) return true;
|
|
412
|
+
for (const [key, expected] of Object.entries(wanted)) {
|
|
413
|
+
const actual = stored[key];
|
|
414
|
+
if (actual === undefined || actual === null) return false;
|
|
415
|
+
if (typeof actual === "object") return false;
|
|
416
|
+
if (String(actual) !== String(expected)) return false;
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const metricNames = (selector: AlertMetricSelector): string[] => [
|
|
422
|
+
...new Set(
|
|
423
|
+
(Array.isArray(selector.metric) ? selector.metric : [selector.metric]).map(
|
|
424
|
+
(m) => m.trim(),
|
|
425
|
+
),
|
|
426
|
+
),
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
const chunks = <T>(items: T[], size: number): T[][] => {
|
|
430
|
+
const out: T[][] = [];
|
|
431
|
+
for (let i = 0; i < items.length; i += size)
|
|
432
|
+
out.push(items.slice(i, i + size));
|
|
433
|
+
return out;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
async function matchingSeries(
|
|
437
|
+
db: SqlDb,
|
|
438
|
+
projectId: string,
|
|
439
|
+
selector: AlertMetricSelector,
|
|
440
|
+
): Promise<
|
|
441
|
+
Array<{ id: string; temporality: number | null; createdAt: string }>
|
|
442
|
+
> {
|
|
443
|
+
const names = metricNames(selector);
|
|
444
|
+
let sql = `SELECT id, temporality, created_at, attributes_json, resource_attrs_json
|
|
445
|
+
FROM metric_series
|
|
446
|
+
WHERE project_id = ? AND name IN (${names.map(() => "?").join(", ")})`;
|
|
447
|
+
const binds: unknown[] = [projectId, ...names];
|
|
448
|
+
if (selector.serviceName) {
|
|
449
|
+
sql += ` AND service_name = ?`;
|
|
450
|
+
binds.push(selector.serviceName);
|
|
451
|
+
}
|
|
452
|
+
const rs = await db
|
|
453
|
+
.prepare(sql)
|
|
454
|
+
.bind(...binds)
|
|
455
|
+
.all<SeriesRow>();
|
|
456
|
+
const matched = (rs.results ?? []).filter(
|
|
457
|
+
(row) =>
|
|
458
|
+
attrsMatch(parseAttrs(row.attributes_json), selector.attributes) &&
|
|
459
|
+
attrsMatch(
|
|
460
|
+
parseAttrs(row.resource_attrs_json),
|
|
461
|
+
selector.resourceAttributes,
|
|
462
|
+
),
|
|
463
|
+
);
|
|
464
|
+
if (matched.length > MAX_MATCHED_SERIES)
|
|
465
|
+
throw new Error(
|
|
466
|
+
`metrics query matches ${matched.length} series (limit ${MAX_MATCHED_SERIES}); narrow it with attributes or serviceName`,
|
|
467
|
+
);
|
|
468
|
+
return matched.map((row) => ({
|
|
469
|
+
id: row.id,
|
|
470
|
+
createdAt: row.created_at,
|
|
471
|
+
temporality:
|
|
472
|
+
row.temporality === null || row.temporality === undefined
|
|
473
|
+
? null
|
|
474
|
+
: Number(row.temporality),
|
|
475
|
+
}));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export interface MetricEvalContext {
|
|
479
|
+
projectId: string;
|
|
480
|
+
windowMins: number;
|
|
481
|
+
/** The rule's `createdAt`; the reference for "never reported". */
|
|
482
|
+
ruleCreatedAt: string;
|
|
483
|
+
now: Date;
|
|
484
|
+
/** Staleness bound for gauge-style aggregations; default 300. */
|
|
485
|
+
staleSecs?: number;
|
|
486
|
+
/** Point cap per selector; default MAX_LOADED_POINTS (tests lower it). */
|
|
487
|
+
maxLoadedPoints?: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Aggregations whose series must be fresh (see step 3 above). */
|
|
491
|
+
const STALENESS_APPLIES = new Set<AlertMetricAggregation>([
|
|
492
|
+
"last",
|
|
493
|
+
"min",
|
|
494
|
+
"max",
|
|
495
|
+
"avg",
|
|
496
|
+
"sum",
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
export interface MetricEvaluation {
|
|
500
|
+
value: number;
|
|
501
|
+
/** No series took part; see the module comment. Never true for age_seconds. */
|
|
502
|
+
noData: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function evaluateSelector(
|
|
506
|
+
db: SqlDb,
|
|
507
|
+
selector: AlertMetricSelector,
|
|
508
|
+
aggregation: Exclude<AlertMetricAggregation, "age_seconds">,
|
|
509
|
+
ctx: MetricEvalContext,
|
|
510
|
+
): Promise<{ value: number; contributing: number }> {
|
|
511
|
+
const series = await matchingSeries(db, ctx.projectId, selector);
|
|
512
|
+
if (series.length === 0) return { value: 0, contributing: 0 };
|
|
513
|
+
const windowStartMs = ctx.now.getTime() - ctx.windowMins * 60_000;
|
|
514
|
+
const cutoff = new Date(windowStartMs).toISOString();
|
|
515
|
+
const bySeries = new Map<string, MetricPointSample[]>();
|
|
516
|
+
let loaded = 0;
|
|
517
|
+
for (const chunk of chunks(series, ID_CHUNK)) {
|
|
518
|
+
// One more row than the remaining budget tells "at the cap" from
|
|
519
|
+
// "over it" without loading the rest.
|
|
520
|
+
const cap = ctx.maxLoadedPoints ?? MAX_LOADED_POINTS;
|
|
521
|
+
const budget = cap - loaded;
|
|
522
|
+
const rs = await db
|
|
523
|
+
.prepare(
|
|
524
|
+
`SELECT series_id, ts_ns, start_ts_ns, value, count, sum, received_at
|
|
525
|
+
FROM metric_point
|
|
526
|
+
WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})
|
|
527
|
+
AND received_at >= ?
|
|
528
|
+
LIMIT ?`,
|
|
529
|
+
)
|
|
530
|
+
.bind(ctx.projectId, ...chunk.map((s) => s.id), cutoff, budget + 1)
|
|
531
|
+
.all<PointRow>();
|
|
532
|
+
const rows = rs.results ?? [];
|
|
533
|
+
loaded += rows.length;
|
|
534
|
+
if (loaded > cap)
|
|
535
|
+
throw new Error(
|
|
536
|
+
`metrics query loads more than ${cap} points in its window; shorten windowMins or narrow the query`,
|
|
537
|
+
);
|
|
538
|
+
for (const row of rows) {
|
|
539
|
+
const list = bySeries.get(row.series_id) ?? [];
|
|
540
|
+
list.push({
|
|
541
|
+
tsNs: String(row.ts_ns),
|
|
542
|
+
startTsNs: row.start_ts_ns === null ? null : String(row.start_ts_ns),
|
|
543
|
+
value: row.value,
|
|
544
|
+
count: row.count,
|
|
545
|
+
sum: row.sum,
|
|
546
|
+
receivedAt: row.received_at,
|
|
547
|
+
});
|
|
548
|
+
bySeries.set(row.series_id, list);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const field = selector.field ?? "value";
|
|
552
|
+
const windowStartNs = BigInt(windowStartMs) * 1_000_000n;
|
|
553
|
+
const freshCutoff = new Date(
|
|
554
|
+
ctx.now.getTime() - (ctx.staleSecs ?? DEFAULT_STALE_SECS) * 1000,
|
|
555
|
+
).toISOString();
|
|
556
|
+
let total = 0;
|
|
557
|
+
let contributing = 0;
|
|
558
|
+
for (const s of series) {
|
|
559
|
+
const points = bySeries.get(s.id) ?? [];
|
|
560
|
+
if (STALENESS_APPLIES.has(aggregation)) {
|
|
561
|
+
const newest = points.reduce<string | null>(
|
|
562
|
+
(max, p) => (max === null || p.receivedAt > max ? p.receivedAt : max),
|
|
563
|
+
null,
|
|
564
|
+
);
|
|
565
|
+
if (newest === null || newest < freshCutoff) continue;
|
|
566
|
+
}
|
|
567
|
+
const reduced = reduceSeries(points, {
|
|
568
|
+
aggregation,
|
|
569
|
+
field,
|
|
570
|
+
temporality: s.temporality,
|
|
571
|
+
windowStartNs,
|
|
572
|
+
windowSecs: ctx.windowMins * 60,
|
|
573
|
+
bornInWindow: s.createdAt >= cutoff,
|
|
574
|
+
});
|
|
575
|
+
if (reduced !== null) {
|
|
576
|
+
total += reduced;
|
|
577
|
+
contributing += 1;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { value: total, contributing };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function ageSeconds(
|
|
584
|
+
db: SqlDb,
|
|
585
|
+
selector: AlertMetricSelector,
|
|
586
|
+
ctx: MetricEvalContext,
|
|
587
|
+
): Promise<number> {
|
|
588
|
+
const series = await matchingSeries(db, ctx.projectId, selector);
|
|
589
|
+
let newest: string | null = null;
|
|
590
|
+
for (const chunk of chunks(series, ID_CHUNK)) {
|
|
591
|
+
const row = await db
|
|
592
|
+
.prepare(
|
|
593
|
+
`SELECT MAX(received_at) AS newest FROM metric_point
|
|
594
|
+
WHERE project_id = ? AND series_id IN (${chunk.map(() => "?").join(", ")})`,
|
|
595
|
+
)
|
|
596
|
+
.bind(ctx.projectId, ...chunk.map((s) => s.id))
|
|
597
|
+
.first<{ newest: string | null }>();
|
|
598
|
+
if (row?.newest && (newest === null || row.newest > newest))
|
|
599
|
+
newest = row.newest;
|
|
600
|
+
}
|
|
601
|
+
const reference = Date.parse(newest ?? ctx.ruleCreatedAt);
|
|
602
|
+
if (!Number.isFinite(reference)) return 0;
|
|
603
|
+
return Math.max(0, (ctx.now.getTime() - reference) / 1000);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Evaluates a validated metrics query to a finite number. */
|
|
607
|
+
export async function evaluateMetricQuery(
|
|
608
|
+
db: SqlDb,
|
|
609
|
+
query: AlertQueryMetrics,
|
|
610
|
+
ctx: MetricEvalContext,
|
|
611
|
+
): Promise<MetricEvaluation> {
|
|
612
|
+
if (query.aggregation === "age_seconds")
|
|
613
|
+
return { value: await ageSeconds(db, query, ctx), noData: false };
|
|
614
|
+
const withStale = { ...ctx, staleSecs: query.staleSecs ?? ctx.staleSecs };
|
|
615
|
+
const numerator = await evaluateSelector(
|
|
616
|
+
db,
|
|
617
|
+
query,
|
|
618
|
+
query.aggregation,
|
|
619
|
+
withStale,
|
|
620
|
+
);
|
|
621
|
+
if (!query.denominator)
|
|
622
|
+
return {
|
|
623
|
+
value: numerator.value,
|
|
624
|
+
noData: numerator.contributing === 0,
|
|
625
|
+
};
|
|
626
|
+
const denominator = await evaluateSelector(
|
|
627
|
+
db,
|
|
628
|
+
query.denominator,
|
|
629
|
+
query.denominator.aggregation ?? query.aggregation,
|
|
630
|
+
withStale,
|
|
631
|
+
);
|
|
632
|
+
return {
|
|
633
|
+
value: metricRatio(
|
|
634
|
+
numerator.value,
|
|
635
|
+
denominator.value,
|
|
636
|
+
query.minDenominator ?? 0,
|
|
637
|
+
),
|
|
638
|
+
noData: numerator.contributing === 0 && denominator.contributing === 0,
|
|
639
|
+
};
|
|
640
|
+
}
|
package/src/lib/metrics-store.ts
CHANGED
|
@@ -82,7 +82,11 @@ export class MetricsStore {
|
|
|
82
82
|
const { projectId, points, receivedAt, expiresAt } = opts;
|
|
83
83
|
if (points.length === 0) return;
|
|
84
84
|
|
|
85
|
-
const seriesByIdentity = await this.resolveSeries(
|
|
85
|
+
const seriesByIdentity = await this.resolveSeries(
|
|
86
|
+
projectId,
|
|
87
|
+
points,
|
|
88
|
+
receivedAt,
|
|
89
|
+
);
|
|
86
90
|
|
|
87
91
|
const pointStmt = this.db.prepare(`
|
|
88
92
|
INSERT INTO metric_point (
|
|
@@ -247,6 +251,7 @@ export class MetricsStore {
|
|
|
247
251
|
private async resolveSeries(
|
|
248
252
|
projectId: string,
|
|
249
253
|
points: DecodedMetricPoint[],
|
|
254
|
+
receivedAt: string,
|
|
250
255
|
): Promise<Map<string, SeriesRow>> {
|
|
251
256
|
const byIdentity = new Map<string, DecodedMetricPoint>();
|
|
252
257
|
for (const p of points) {
|
|
@@ -285,7 +290,9 @@ export class MetricsStore {
|
|
|
285
290
|
`);
|
|
286
291
|
const toInsert: SqlStatement[] = [];
|
|
287
292
|
const attemptedIds = new Set<string>();
|
|
288
|
-
|
|
293
|
+
// The batch's receive time: a series' created_at tells the metrics
|
|
294
|
+
// alert evaluator whether it has history before a window.
|
|
295
|
+
const createdAt = receivedAt;
|
|
289
296
|
for (const [identity, p] of byIdentity.entries()) {
|
|
290
297
|
if (existing.has(identity)) continue;
|
|
291
298
|
const id = crypto.randomUUID();
|