@intentius/chant-lexicon-prometheus 0.100.0 → 0.102.0
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 +5 -2
- package/dist/alertmanager.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/genai.d.ts +224 -0
- package/dist/composites/genai.d.ts.map +1 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/slo.d.ts +1 -1
- package/dist/composites/slo.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +12 -3
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/import/generator.d.ts +14 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +11 -2
- package/dist/init-templates.d.ts.map +1 -1
- package/dist/integrations.d.ts.map +1 -1
- package/dist/integrity.json +8 -7
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +33 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/prom-helpers.d.ts +1 -1
- package/dist/lint/post-synth/prom208.d.ts +1 -1
- package/dist/lint/post-synth/prom209.d.ts +1 -1
- package/dist/lint/post-synth/prom210.d.ts +8 -0
- package/dist/lint/post-synth/prom210.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/PROM210.md +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-eval.d.ts +10 -4
- package/dist/rule-eval.d.ts.map +1 -1
- package/dist/rules/prom-helpers.ts +1 -1
- package/dist/rules/prom208.ts +1 -1
- package/dist/rules/prom209.ts +1 -1
- package/dist/rules/prom210.ts +17 -0
- package/dist/skill-defs.d.ts +1 -1
- package/dist/skill-defs.d.ts.map +1 -1
- package/dist/skills/chant-prometheus-alertmanager.md +3 -1
- package/dist/skills/chant-prometheus.md +22 -0
- package/dist/validate-config.d.ts +2 -2
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate-integrations.d.ts +37 -0
- package/dist/validate-integrations.d.ts.map +1 -0
- package/package.json +4 -3
- package/src/codegen/docs.ts +5 -0
- package/src/composites/catalog.test.ts +1 -1
- package/src/composites/catalog.ts +70 -0
- package/src/composites/genai.test.ts +307 -0
- package/src/composites/genai.ts +678 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/slo-burn.test.ts +25 -1
- package/src/import/embedded.test.ts +52 -3
- package/src/import/embedded.ts +37 -5
- package/src/import/generator.ts +19 -1
- package/src/index.ts +14 -0
- package/src/init-templates.test.ts +37 -11
- package/src/init-templates.ts +75 -6
- package/src/lint/audit-catalog.ts +13 -2
- package/src/lint/audit-lineage.ts +69 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/post-synth.test.ts +34 -0
- package/src/lint/post-synth/prom-helpers.ts +1 -1
- package/src/lint/post-synth/prom208.ts +1 -1
- package/src/lint/post-synth/prom209.ts +1 -1
- package/src/lint/post-synth/prom210.ts +17 -0
- package/src/plugin.test.ts +1 -0
- package/src/plugin.ts +8 -4
- package/src/rule-eval.ts +72 -7
- package/src/skills/chant-prometheus-alertmanager.md +3 -1
- package/src/skills/chant-prometheus.md +22 -0
- package/src/testdata/integration-cases.ts +96 -0
- package/src/tools.test.ts +6 -0
- package/src/typecheck.test.ts +88 -0
- package/src/validate-config.ts +8 -31
- package/src/validate-integrations.ts +389 -0
package/src/plugin.ts
CHANGED
|
@@ -11,8 +11,8 @@ import { hover } from "./lsp/hover";
|
|
|
11
11
|
import { detectTemplate } from "./detect";
|
|
12
12
|
import { PrometheusParser } from "./import/parser";
|
|
13
13
|
import { PrometheusGenerator } from "./import/generator";
|
|
14
|
-
import { ruleGroupsImporter } from "./import/embedded";
|
|
15
|
-
import {
|
|
14
|
+
import { alertmanagerImporter, ruleGroupsImporter } from "./import/embedded";
|
|
15
|
+
import { DEFAULT_TEMPLATE, RULES_TEMPLATE, SLO_STYLE_TEMPLATE, SLO_TEMPLATE } from "./init-templates";
|
|
16
16
|
import { prometheusSkills } from "./skill-defs";
|
|
17
17
|
import { CATALOG } from "./catalog";
|
|
18
18
|
import { PROMETHEUS_PIN } from "./pin";
|
|
@@ -122,11 +122,15 @@ export const prometheusPlugin: LexiconPlugin = {
|
|
|
122
122
|
},
|
|
123
123
|
|
|
124
124
|
embeddedImporters() {
|
|
125
|
-
return [ruleGroupsImporter];
|
|
125
|
+
return [ruleGroupsImporter, alertmanagerImporter];
|
|
126
126
|
},
|
|
127
127
|
|
|
128
|
+
// `chant init --lexicon prometheus [--template rules|slo-style|slo]`; see ./init-templates.ts.
|
|
128
129
|
initTemplates(template?: string) {
|
|
129
|
-
|
|
130
|
+
if (template === "rules") return RULES_TEMPLATE;
|
|
131
|
+
if (template === "slo-style") return SLO_STYLE_TEMPLATE;
|
|
132
|
+
if (template === "slo") return SLO_TEMPLATE;
|
|
133
|
+
return DEFAULT_TEMPLATE;
|
|
130
134
|
},
|
|
131
135
|
|
|
132
136
|
completionProvider(ctx: CompletionContext) {
|
package/src/rule-eval.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* The PromQL it understands is the subset the lexicon's generated rules use:
|
|
8
8
|
* number literals, vector and range selectors with `=`, `!=`, `=~` and `!~`
|
|
9
9
|
* matchers, `rate`, `increase`, `avg_over_time`, `sum_over_time`,
|
|
10
|
-
* `count_over_time`, `vector`, `sum` (with `by` or
|
|
11
|
-
* arithmetic and comparison operators
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* `count_over_time`, `vector`, `histogram_quantile`, `sum` (with `by` or
|
|
11
|
+
* `without`), the arithmetic and comparison operators (one-to-one, or
|
|
12
|
+
* many-to-one with `group_left` and no extra labels) and `and`, `or` and
|
|
13
|
+
* `unless` (with `on` or `ignoring`). Anything else throws, so a test never
|
|
14
|
+
* passes by silently evaluating something it does not understand.
|
|
15
|
+
*
|
|
16
|
+
* `histogram_quantile` interpolates linearly inside the bucket the rank
|
|
17
|
+
* falls in, as Prometheus does for classic buckets, and returns the upper
|
|
18
|
+
* bound of the highest finite bucket when the rank falls in `+Inf`.
|
|
14
19
|
*
|
|
15
20
|
* `rate` is the increase between the first and last sample in the range
|
|
16
21
|
* over the time between them, without Prometheus's extrapolation to the
|
|
@@ -49,6 +54,7 @@ export interface FiringAlert {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const LOOKBACK_MS = 5 * 60_000;
|
|
57
|
+
const RANGE_FUNCTIONS = new Set(["rate", "increase", "avg_over_time", "sum_over_time", "count_over_time"]);
|
|
52
58
|
|
|
53
59
|
function key(labels: Labels): string {
|
|
54
60
|
return JSON.stringify(Object.entries(labels).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
@@ -217,12 +223,15 @@ export class RuleEvaluator {
|
|
|
217
223
|
|
|
218
224
|
private call(n: SyntaxNode, src: string, t: number): Value {
|
|
219
225
|
const fn = this.text(n.getChild("FunctionIdentifier")!, src);
|
|
220
|
-
const [arg] = this.args(n.getChild("FunctionCallBody")!);
|
|
226
|
+
const [arg, second] = this.args(n.getChild("FunctionCallBody")!);
|
|
227
|
+
if (fn === "histogram_quantile") return this.histogramQuantile(arg, second, src, t);
|
|
221
228
|
if (fn === "vector") {
|
|
222
229
|
const v = this.node(arg, src, t);
|
|
223
230
|
if (v.kind !== "scalar") throw new Error("rule-eval: vector() takes a scalar");
|
|
224
231
|
return { kind: "vector", elements: [{ labels: {}, value: v.value }] };
|
|
225
232
|
}
|
|
233
|
+
// Checked before any series is read, so an unsupported function throws even over no data.
|
|
234
|
+
if (!RANGE_FUNCTIONS.has(fn)) throw new Error(`rule-eval: function ${fn} is not supported`);
|
|
226
235
|
if (arg.name !== "MatrixSelector") throw new Error(`rule-eval: ${fn}() needs a range selector`);
|
|
227
236
|
const rangeMs = durationMs(this.text(arg.getChild("DurationExpr")!, src));
|
|
228
237
|
if (rangeMs === undefined) throw new Error(`rule-eval: bad range in ${this.text(arg, src)}`);
|
|
@@ -254,6 +263,45 @@ export class RuleEvaluator {
|
|
|
254
263
|
return { kind: "vector", elements };
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
private histogramQuantile(qNode: SyntaxNode, vNode: SyntaxNode, src: string, t: number): Value {
|
|
267
|
+
const q = this.node(qNode, src, t);
|
|
268
|
+
const v = this.node(vNode, src, t);
|
|
269
|
+
if (q.kind !== "scalar" || v.kind !== "vector") throw new Error("rule-eval: histogram_quantile takes a scalar and a vector");
|
|
270
|
+
const groups = new Map<string, { labels: Labels; buckets: { le: number; count: number }[] }>();
|
|
271
|
+
for (const e of v.elements) {
|
|
272
|
+
const { le, ...rest } = withoutName(e.labels);
|
|
273
|
+
if (le === undefined) continue;
|
|
274
|
+
const k = key(rest);
|
|
275
|
+
const g = groups.get(k) ?? { labels: rest, buckets: [] };
|
|
276
|
+
g.buckets.push({ le: le === "+Inf" ? Infinity : Number(le), count: e.value });
|
|
277
|
+
groups.set(k, g);
|
|
278
|
+
}
|
|
279
|
+
const elements: VectorElement[] = [];
|
|
280
|
+
for (const { labels, buckets } of groups.values()) {
|
|
281
|
+
buckets.sort((a, b) => a.le - b.le);
|
|
282
|
+
const last = buckets[buckets.length - 1];
|
|
283
|
+
if (!last || last.le !== Infinity) continue;
|
|
284
|
+
const total = last.count;
|
|
285
|
+
let value: number;
|
|
286
|
+
if (q.value < 0) value = -Infinity;
|
|
287
|
+
else if (q.value > 1) value = Infinity;
|
|
288
|
+
else if (!(total > 0)) value = NaN;
|
|
289
|
+
else {
|
|
290
|
+
const rank = q.value * total;
|
|
291
|
+
const i = buckets.findIndex((b) => b.count >= rank);
|
|
292
|
+
if (buckets[i].le === Infinity) value = buckets.length > 1 ? buckets[buckets.length - 2].le : NaN;
|
|
293
|
+
else {
|
|
294
|
+
const start = i === 0 ? 0 : buckets[i - 1].le;
|
|
295
|
+
const below = i === 0 ? 0 : buckets[i - 1].count;
|
|
296
|
+
const inBucket = buckets[i].count - below;
|
|
297
|
+
value = inBucket > 0 ? start + (buckets[i].le - start) * ((rank - below) / inBucket) : buckets[i].le;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
elements.push({ labels, value });
|
|
301
|
+
}
|
|
302
|
+
return { kind: "vector", elements };
|
|
303
|
+
}
|
|
304
|
+
|
|
257
305
|
private aggregate(n: SyntaxNode, src: string, t: number): Value {
|
|
258
306
|
const op = this.text(n.getChild("AggregateOp")!, src);
|
|
259
307
|
if (op !== "sum") throw new Error(`rule-eval: aggregation ${op} is not supported`);
|
|
@@ -293,11 +341,20 @@ export class RuleEvaluator {
|
|
|
293
341
|
if (parts.some((p) => p.name === "BoolModifier")) throw new Error("rule-eval: bool modifier is not supported");
|
|
294
342
|
let on: string[] | undefined;
|
|
295
343
|
let ignoring: string[] = [];
|
|
344
|
+
let groupLeft = false;
|
|
296
345
|
if (modifier) {
|
|
297
|
-
const
|
|
346
|
+
const grouping = modifier.getChild("GroupingLabels");
|
|
347
|
+
const names = (grouping?.getChildren("LabelName") ?? []).map((l) => this.text(l, src));
|
|
298
348
|
if (modifier.getChild("On")) on = names;
|
|
299
349
|
else ignoring = names;
|
|
300
|
-
if (modifier.getChild("
|
|
350
|
+
if (modifier.getChild("GroupRight")) throw new Error("rule-eval: group_right is not supported");
|
|
351
|
+
const left = modifier.getChild("GroupLeft");
|
|
352
|
+
if (left) {
|
|
353
|
+
// group_left(<labels>) copies labels from the one side; only the bare form is supported.
|
|
354
|
+
const after = left.nextSibling;
|
|
355
|
+
if (after && after.name === "GroupingLabels" && after !== grouping) throw new Error("rule-eval: group_left with labels is not supported");
|
|
356
|
+
groupLeft = true;
|
|
357
|
+
}
|
|
301
358
|
}
|
|
302
359
|
const lhs = this.node(lhsNode, src, t);
|
|
303
360
|
const rhs = this.node(rhsNode, src, t);
|
|
@@ -377,6 +434,14 @@ export class RuleEvaluator {
|
|
|
377
434
|
if (bySig.has(s)) throw new Error("rule-eval: many-to-many matching is not supported");
|
|
378
435
|
bySig.set(s, e);
|
|
379
436
|
}
|
|
437
|
+
if (!groupLeft) {
|
|
438
|
+
const seen = new Set<string>();
|
|
439
|
+
for (const e of l.elements) {
|
|
440
|
+
const s = signature(e.labels);
|
|
441
|
+
if (seen.has(s)) throw new Error("rule-eval: many-to-one matching needs group_left");
|
|
442
|
+
seen.add(s);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
380
445
|
const elements: VectorElement[] = [];
|
|
381
446
|
for (const e of l.elements) {
|
|
382
447
|
const other = bySig.get(signature(e.labels));
|
|
@@ -57,6 +57,8 @@ Import it rather than retyping it: `chant import alertmanager.yml --output src`
|
|
|
57
57
|
- Routes name receivers and time intervals that exist (PROM201, PROM204). Reference the entity rather than a string and TypeScript does most of this.
|
|
58
58
|
- Every `severity` an alert in the same build root carries is matched by some route below the root (PROM202). The check only sees one build root (chant #1939): keep the rules and the Alertmanager config in the same one, or it goes quiet.
|
|
59
59
|
- Credentials go in `*_file` fields. Alertmanager does not expand environment variables in its config, and a literal `api_url`, `routing_key` or `auth_password` is flagged (PROM001).
|
|
60
|
-
- Each integration has
|
|
60
|
+
- Each integration has its destination, credential and required fields, or the `global` default it falls back to (PROM209): a webhook `url`, a Slack `api_url` or app token, an Opsgenie `api_key`, a Telegram `chat_id` and bot token, a Webex `room_id` and `http_config.authorization`, one SNS target, a Jira `project` and `issue_type`, an email `to`/`smarthost`/`from`, and so on.
|
|
61
|
+
- No setting Alertmanager rejects (PROM210): a value and its `*_file` both set, a Slack `api_url` with an app token, `update_message` without `api_url: https://slack.com/api/chat.postMessage`, a WeChat `message_type` other than `text`/`markdown`, a Telegram `parse_mode` other than `Markdown`/`MarkdownV2`/`HTML`.
|
|
62
|
+
- Durations parse (PROM208). Route timers and `resolve_timeout` take `1d`; the integration `timeout`s and Pushover `retry`/`expire`/`ttl` are Go durations and take `24h`, not `1d`.
|
|
61
63
|
|
|
62
64
|
Run `amtoolCheckConfig(alertmanagerYaml(entities))` in a test to have `amtool check-config` confirm it when installed.
|
|
@@ -100,3 +100,25 @@ sloMetrics(checkout).errorRatio["1h"]; // "slo:sli_error:ratio_rate1h"
|
|
|
100
100
|
- Write the objective as a fraction strictly between 0 and 1, and put `{{window}}` where each SLI expression's range goes (PROM003).
|
|
101
101
|
- Alerts carry `severity` `page` or `ticket`; route both (PROM202).
|
|
102
102
|
- Read series names from `sloMetrics()` in dashboards instead of repeating them.
|
|
103
|
+
|
|
104
|
+
## GenAI rules
|
|
105
|
+
|
|
106
|
+
`GenAiRules` builds rules for the otel lexicon's GenAI collector preset from the same `genAiMetrics(options)` the collector was built with: request rate, error ratio (also by `error.type`) and p50/p95/p99 latency per provider, model and operation, token rates, spend per second from a price table the project declares, and per-tool call rate, error ratio and p95 latency. Alerts are off unless `alerts` names them.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { genAiMetrics } from "@intentius/chant-lexicon-otel";
|
|
110
|
+
import { GenAiRules, genAiRuleMetrics } from "@intentius/chant-lexicon-prometheus";
|
|
111
|
+
|
|
112
|
+
export const genai = GenAiRules({
|
|
113
|
+
genAi: genAiMetrics({ clientMetrics: "derive" }),
|
|
114
|
+
prices: [{ provider: "anthropic", model: "claude-x", inputPerMTok: 3, outputPerMTok: 15, currency: "USD", source: "https://example.com/pricing", asOf: "2026-09-29" }],
|
|
115
|
+
alerts: { errorRatio: true, latency: { thresholdSeconds: 20 }, budgets: [{ amount: 50, currency: "USD", per: "day" }] },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
genAiRuleMetrics(genai).cost; // "gen_ai:cost:rate5m"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Pass the collector's own options to `genAiMetrics()`; never write a metric name by hand.
|
|
122
|
+
- Ship no prices in a lexicon or template. The project declares them, with `currency` and `source` on each.
|
|
123
|
+
- A model missing from `prices` gets no cost series, so a sum of the cost series covers priced models only.
|
|
124
|
+
- Read series names from `genAiRuleMetrics()` in dashboards.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alertmanager receiver integrations that pass and fail PROM208-PROM210: at
|
|
3
|
+
* least one of each per integration, after Alertmanager v0.34.1's
|
|
4
|
+
* UnmarshalYAML validations (sources in ../validate-integrations.ts).
|
|
5
|
+
* post-synth.test.ts checks the codes; tools.test.ts has amtool reject the
|
|
6
|
+
* failing ones when it is installed.
|
|
7
|
+
*
|
|
8
|
+
* Each case: a label, the receiver key, one entry, an optional `global:`,
|
|
9
|
+
* and the PROM208-PROM210 codes it produces, sorted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type IntegrationCase = [label: string, key: string, entry: Record<string, unknown>, global: Record<string, unknown> | undefined, codes: string[]];
|
|
13
|
+
const PD = "https://events.pagerduty.com/v2/enqueue";
|
|
14
|
+
export const SLACK_APP_URL = "https://slack.com/api/chat.postMessage";
|
|
15
|
+
export const INTEGRATION_CASES: IntegrationCase[] = [
|
|
16
|
+
["discord ok", "discord_configs", { webhook_url_file: "/s" }, undefined, []],
|
|
17
|
+
["discord without a webhook", "discord_configs", {}, undefined, ["PROM209"]],
|
|
18
|
+
["discord with url and file", "discord_configs", { webhook_url: "https://d/x", webhook_url_file: "/s" }, undefined, ["PROM210"]],
|
|
19
|
+
["email ok", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp:25" }, undefined, []],
|
|
20
|
+
["email on global SMTP", "email_configs", { to: "a@b.c" }, { smtp_smarthost: "smtp:25", smtp_from: "am@b.c" }, []],
|
|
21
|
+
["email with nothing", "email_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
|
|
22
|
+
["email smarthost without port", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp" }, undefined, ["PROM210"]],
|
|
23
|
+
["email duplicate header", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { subject: "a", Subject: "b" } }, undefined, ["PROM210"]],
|
|
24
|
+
["email threading without thread_by_date", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", threading: { enabled: true } }, undefined, ["PROM210"]],
|
|
25
|
+
["email threading with References", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { references: "x" }, threading: { enabled: true, thread_by_date: "daily" } }, undefined, ["PROM210"]],
|
|
26
|
+
["incident.io ok", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", timeout: "1.5s" }, undefined, []],
|
|
27
|
+
["incident.io without url", "incidentio_configs", {}, undefined, ["PROM209"]],
|
|
28
|
+
["incident.io http_config without a credential", "incidentio_configs", { url: "https://i/x", http_config: { follow_redirects: true } }, undefined, ["PROM209"]],
|
|
29
|
+
["incident.io token and authorization", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", http_config: { authorization: { credentials_file: "/c" } } }, undefined, ["PROM210"]],
|
|
30
|
+
["incident.io Prometheus-only duration", "incidentio_configs", { url_file: "/u", timeout: "1d" }, undefined, ["PROM208"]],
|
|
31
|
+
["jira ok", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1d" }, undefined, []],
|
|
32
|
+
["jira on global api_url", "jira_configs", { project: "OPS", issue_type: "Bug" }, { jira_api_url: "https://j/x" }, []],
|
|
33
|
+
["jira with nothing", "jira_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
|
|
34
|
+
["jira api_type", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", api_type: "server" }, undefined, ["PROM210"]],
|
|
35
|
+
["jira Go-only duration", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1.5h" }, undefined, ["PROM208"]],
|
|
36
|
+
["mattermost ok", "mattermost_configs", { webhook_url_file: "/m" }, undefined, []],
|
|
37
|
+
["mattermost on global webhook", "mattermost_configs", {}, { mattermost_webhook_url_file: "/m" }, []],
|
|
38
|
+
["mattermost without a webhook", "mattermost_configs", {}, undefined, ["PROM209"]],
|
|
39
|
+
["mattermost field without value", "mattermost_configs", { webhook_url_file: "/m", attachments: [{ fields: [{ title: "a" }] }] }, undefined, ["PROM210"]],
|
|
40
|
+
["msteams ok", "msteams_configs", { webhook_url_file: "/t" }, undefined, []],
|
|
41
|
+
["msteams without a webhook", "msteams_configs", {}, undefined, ["PROM209"]],
|
|
42
|
+
["msteamsv2 ok", "msteamsv2_configs", { webhook_url_file: "/t" }, undefined, []],
|
|
43
|
+
["msteamsv2 with url and file", "msteamsv2_configs", { webhook_url: "https://t/x", webhook_url_file: "/t" }, undefined, ["PROM210"]],
|
|
44
|
+
["opsgenie ok", "opsgenie_configs", { api_key_file: "/k", responders: [{ name: "ops", type: "Team" }, { id: "1", type: "{{ .CommonLabels.kind }}" }] }, undefined, []],
|
|
45
|
+
["opsgenie on global api_key", "opsgenie_configs", {}, { opsgenie_api_key_file: "/k" }, []],
|
|
46
|
+
["opsgenie without api_key", "opsgenie_configs", {}, undefined, ["PROM209"]],
|
|
47
|
+
["opsgenie responders", "opsgenie_configs", { api_key_file: "/k", responders: [{ type: "team" }, { name: "x", type: "group" }, { name: "y" }] }, undefined, ["PROM209", "PROM209", "PROM210"]],
|
|
48
|
+
["pagerduty ok", "pagerduty_configs", { routing_key_file: "/k", url: PD, timeout: "30s" }, undefined, []],
|
|
49
|
+
["pagerduty without a key", "pagerduty_configs", {}, undefined, ["PROM209"]],
|
|
50
|
+
["pagerduty key and file", "pagerduty_configs", { service_key: "x", service_key_file: "/k" }, undefined, ["PROM210"]],
|
|
51
|
+
["pagerduty timeout without unit", "pagerduty_configs", { routing_key_file: "/k", timeout: "10" }, undefined, ["PROM208"]],
|
|
52
|
+
["pushover ok", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "30s", expire: "1h", ttl: "1h30m" }, undefined, []],
|
|
53
|
+
["pushover without credentials", "pushover_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
54
|
+
["pushover html and monospace", "pushover_configs", { user_key_file: "/u", token_file: "/t", html: true, monospace: true }, undefined, ["PROM210"]],
|
|
55
|
+
["pushover retry in days", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "1d" }, undefined, ["PROM208"]],
|
|
56
|
+
["rocketchat ok", "rocketchat_configs", { token_id_file: "/i", token_file: "/t" }, undefined, []],
|
|
57
|
+
["rocketchat on global tokens", "rocketchat_configs", {}, { rocketchat_token_id_file: "/i", rocketchat_token_file: "/t" }, []],
|
|
58
|
+
["rocketchat without tokens", "rocketchat_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
59
|
+
["rocketchat token and file", "rocketchat_configs", { token_id_file: "/i", token: "x", token_file: "/t" }, undefined, ["PROM210"]],
|
|
60
|
+
["slack ok", "slack_configs", { api_url_file: "/s", timeout: "500ms" }, undefined, []],
|
|
61
|
+
["slack on global app token", "slack_configs", {}, { slack_app_token_file: "/t" }, []],
|
|
62
|
+
["slack update_message with the app URL", "slack_configs", { api_url: SLACK_APP_URL, update_message: true, http_config: { authorization: { credentials_file: "/t" } } }, undefined, []],
|
|
63
|
+
["slack without a url or token", "slack_configs", { channel: "#a" }, undefined, ["PROM209"]],
|
|
64
|
+
["slack local authorization blocks the global token", "slack_configs", { http_config: { authorization: { credentials_file: "/t" } } }, { slack_app_token_file: "/t" }, ["PROM209"]],
|
|
65
|
+
["slack url and token", "slack_configs", { api_url_file: "/s", app_token_file: "/t" }, undefined, ["PROM210"]],
|
|
66
|
+
["slack update_message on a webhook", "slack_configs", { api_url_file: "/s", update_message: true }, undefined, ["PROM210"]],
|
|
67
|
+
["slack app token and authorization", "slack_configs", { app_token_file: "/t", http_config: { bearer_token_file: "/b" } }, undefined, ["PROM210"]],
|
|
68
|
+
["slack fields and actions", "slack_configs", { api_url_file: "/s", fields: [{ title: "a" }], actions: [{ type: "button", text: "x" }, { type: "button", text: "y", name: "n", confirm: {} }] }, undefined, ["PROM210", "PROM210", "PROM210"]],
|
|
69
|
+
["slack timeout", "slack_configs", { api_url_file: "/s", timeout: "5 s" }, undefined, ["PROM208"]],
|
|
70
|
+
["sns ok", "sns_configs", { topic_arn: "arn:aws:sns:x" }, undefined, []],
|
|
71
|
+
["sns without a target", "sns_configs", {}, undefined, ["PROM209"]],
|
|
72
|
+
["sns with two targets", "sns_configs", { topic_arn: "arn:aws:sns:x", target_arn: "arn:aws:sns:y" }, undefined, ["PROM210"]],
|
|
73
|
+
["telegram ok", "telegram_configs", { bot_token_file: "/t", chat_id: 12, parse_mode: "MarkdownV2" }, undefined, []],
|
|
74
|
+
["telegram on global bot token", "telegram_configs", { chat_id_file: "/c" }, { telegram_bot_token_file: "/t" }, []],
|
|
75
|
+
["telegram with nothing", "telegram_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
76
|
+
["telegram chat_id and file, bad parse_mode", "telegram_configs", { bot_token_file: "/t", chat_id: 12, chat_id_file: "/c", parse_mode: "markdown" }, undefined, ["PROM210", "PROM210"]],
|
|
77
|
+
["victorops ok", "victorops_configs", { routing_key: "ops", api_key_file: "/k" }, undefined, []],
|
|
78
|
+
["victorops on global api_key", "victorops_configs", { routing_key: "ops" }, { victorops_api_key_file: "/k" }, []],
|
|
79
|
+
["victorops with nothing", "victorops_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
80
|
+
["victorops reserved custom field", "victorops_configs", { routing_key: "ops", api_key_file: "/k", custom_fields: { entity_id: "x" } }, undefined, ["PROM210"]],
|
|
81
|
+
["webex ok", "webex_configs", { room_id: "r", http_config: { bearer_token_file: "/t" } }, undefined, []],
|
|
82
|
+
["webex with nothing", "webex_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
83
|
+
["webex with only a global authorization", "webex_configs", { room_id: "r" }, { http_config: { authorization: { credentials_file: "/t" } } }, ["PROM209"]],
|
|
84
|
+
["webhook ok", "webhook_configs", { url: "http://sink/", timeout: "1m30s" }, undefined, []],
|
|
85
|
+
["webhook without url", "webhook_configs", {}, undefined, ["PROM209"]],
|
|
86
|
+
["webhook url and file", "webhook_configs", { url: "http://sink/", url_file: "/u" }, undefined, ["PROM210"]],
|
|
87
|
+
["webhook timeout in days", "webhook_configs", { url: "http://sink/", timeout: "1d" }, undefined, ["PROM208"]],
|
|
88
|
+
["wechat ok", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "markdown" }, undefined, []],
|
|
89
|
+
["wechat on global secret and corp", "wechat_configs", {}, { wechat_api_secret_file: "/s", wechat_api_corp_id: "c" }, []],
|
|
90
|
+
["wechat with nothing", "wechat_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
91
|
+
["wechat message_type", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "html" }, undefined, ["PROM210"]],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/** An `alertmanager.yml`, as JSON, with one receiver `r` holding `entry` under `key`. */
|
|
95
|
+
export const amWith = (key: string, entry: unknown, global?: Record<string, unknown>): string =>
|
|
96
|
+
JSON.stringify({ ...(global ? { global } : {}), route: { receiver: "r" }, receivers: [{ name: "r", [key]: [entry] }] });
|
package/src/tools.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
promtoolCheckRules,
|
|
18
18
|
ruleFileYaml,
|
|
19
19
|
} from "./index";
|
|
20
|
+
import { INTEGRATION_CASES, amWith } from "./testdata/integration-cases";
|
|
20
21
|
|
|
21
22
|
const PROMTOOL = process.env.PROMTOOL ?? "promtool";
|
|
22
23
|
const AMTOOL = process.env.AMTOOL ?? "amtool";
|
|
@@ -113,6 +114,11 @@ describe("amtool check-config", () => {
|
|
|
113
114
|
expect(r.ok).toBe(false);
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
test.skipIf(!hasAmtool).each(INTEGRATION_CASES.filter((c) => c[4].length > 0))("rejects what PROM208-PROM210 flag: %s", (_label, key, entry, global) => {
|
|
118
|
+
const r = amtoolCheckConfig(amWith(key, entry, global), AMTOOL);
|
|
119
|
+
expect(r.ok, r.output).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
116
122
|
test("reports ran: false when the binary is missing", () => {
|
|
117
123
|
expect(amtoolCheckConfig("route: {}\n", "/nonexistent/amtool").ran).toBe(false);
|
|
118
124
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level guarantees of the hand-written declarations. Each
|
|
3
|
+
* `@ts-expect-error` line must fail to compile: `tsconfig.typecheck.json`
|
|
4
|
+
* (scripts/typecheck.ts in CI) fails on an unused one, so a type that starts
|
|
5
|
+
* accepting what it used to reject breaks the build. The invalid declarations
|
|
6
|
+
* sit in functions that are never called, so nothing here throws at runtime.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expectTypeOf, test } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
Receiver,
|
|
11
|
+
Route,
|
|
12
|
+
RuleGroup,
|
|
13
|
+
Slo,
|
|
14
|
+
TimeInterval,
|
|
15
|
+
sloMetrics,
|
|
16
|
+
type ReceiverEntity,
|
|
17
|
+
type Rule,
|
|
18
|
+
type RuleGroupEntity,
|
|
19
|
+
type RuleGroupProps,
|
|
20
|
+
type SloMetrics,
|
|
21
|
+
type SloProps,
|
|
22
|
+
} from "./index";
|
|
23
|
+
|
|
24
|
+
describe("rule groups", () => {
|
|
25
|
+
test("a group needs a name and rules, and every rule an expr", () => {
|
|
26
|
+
expectTypeOf(RuleGroup).constructorParameters.toEqualTypeOf<[RuleGroupProps]>();
|
|
27
|
+
expectTypeOf<RuleGroupProps["rules"]>().toEqualTypeOf<Rule[]>();
|
|
28
|
+
expectTypeOf(new RuleGroup({ name: "api", rules: [] })).toEqualTypeOf<RuleGroupEntity>();
|
|
29
|
+
|
|
30
|
+
const rejected = () => [
|
|
31
|
+
// @ts-expect-error a group without a name
|
|
32
|
+
new RuleGroup({ rules: [] }),
|
|
33
|
+
// @ts-expect-error a group without rules
|
|
34
|
+
new RuleGroup({ name: "api" }),
|
|
35
|
+
// @ts-expect-error a recording rule without its expression
|
|
36
|
+
new RuleGroup({ name: "api", rules: [{ record: "job:up:sum" }] }),
|
|
37
|
+
// @ts-expect-error an alerting rule without its expression
|
|
38
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", for: "5m" }] }),
|
|
39
|
+
// @ts-expect-error label values are strings, as Prometheus stores them
|
|
40
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", expr: "up == 0", labels: { priority: 1 } }] }),
|
|
41
|
+
// @ts-expect-error `for` is a duration string, not a number of seconds
|
|
42
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", expr: "up == 0", for: 300 }] }),
|
|
43
|
+
];
|
|
44
|
+
void rejected;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("alertmanager", () => {
|
|
49
|
+
test("a route names a receiver, and its time intervals by TimeInterval, never the other way round", () => {
|
|
50
|
+
const oncall = new Receiver({ name: "oncall" });
|
|
51
|
+
const weekend = new TimeInterval({ name: "weekend", time_intervals: [{ weekdays: ["saturday", "sunday"] }] });
|
|
52
|
+
expectTypeOf(oncall).toEqualTypeOf<ReceiverEntity>();
|
|
53
|
+
|
|
54
|
+
// By entity or by name, for a receiver declared outside the build.
|
|
55
|
+
new Route({ receiver: oncall, mute_time_intervals: [weekend] });
|
|
56
|
+
new Route({ receiver: "oncall", mute_time_intervals: ["weekend"] });
|
|
57
|
+
|
|
58
|
+
const rejected = () => [
|
|
59
|
+
// @ts-expect-error a time interval is not a receiver
|
|
60
|
+
new Route({ receiver: weekend }),
|
|
61
|
+
// @ts-expect-error a receiver is not a time interval
|
|
62
|
+
new Route({ receiver: oncall, mute_time_intervals: [oncall] }),
|
|
63
|
+
// @ts-expect-error a receiver needs a name
|
|
64
|
+
new Receiver({ webhook_configs: [{ url: "http://hook:8080/" }] }),
|
|
65
|
+
];
|
|
66
|
+
void rejected;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Slo", () => {
|
|
71
|
+
test("an SLO needs a name, objective, window and an SLI over total events", () => {
|
|
72
|
+
const sli = { errors: "sum(rate(errors_total[{{window}}]))", total: "sum(rate(requests_total[{{window}}]))" };
|
|
73
|
+
const slo = Slo({ name: "checkout", objective: 0.999, window: "30d", sli });
|
|
74
|
+
expectTypeOf(slo.rules).toEqualTypeOf<RuleGroupEntity>();
|
|
75
|
+
expectTypeOf(sloMetrics(slo)).toEqualTypeOf<SloMetrics>();
|
|
76
|
+
expectTypeOf(Slo).parameter(0).toEqualTypeOf<SloProps>();
|
|
77
|
+
|
|
78
|
+
const rejected = () => [
|
|
79
|
+
// @ts-expect-error the objective is a fraction, not a string
|
|
80
|
+
Slo({ name: "checkout", objective: "99.9%", window: "30d", sli }),
|
|
81
|
+
// @ts-expect-error an SLO without a window
|
|
82
|
+
Slo({ name: "checkout", objective: 0.999, sli }),
|
|
83
|
+
// @ts-expect-error an SLI without total events has no ratio
|
|
84
|
+
Slo({ name: "checkout", objective: 0.999, window: "30d", sli: { errors: sli.errors } }),
|
|
85
|
+
];
|
|
86
|
+
void rejected;
|
|
87
|
+
});
|
|
88
|
+
});
|
package/src/validate-config.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type RuleGroupConfig,
|
|
21
21
|
} from "./model";
|
|
22
22
|
import { checkPromql } from "./promql";
|
|
23
|
+
import { validateGlobalSettings, validateReceiverIntegrations } from "./validate-integrations";
|
|
23
24
|
|
|
24
25
|
export type PrometheusIssueCode =
|
|
25
26
|
| "PROM101"
|
|
@@ -37,7 +38,8 @@ export type PrometheusIssueCode =
|
|
|
37
38
|
| "PROM206"
|
|
38
39
|
| "PROM207"
|
|
39
40
|
| "PROM208"
|
|
40
|
-
| "PROM209"
|
|
41
|
+
| "PROM209"
|
|
42
|
+
| "PROM210";
|
|
41
43
|
|
|
42
44
|
export interface PrometheusIssue {
|
|
43
45
|
code: PrometheusIssueCode;
|
|
@@ -221,7 +223,7 @@ function visitRoutes(root: RouteConfig | undefined): RouteVisit[] {
|
|
|
221
223
|
|
|
222
224
|
const AM_DURATIONS = ["group_wait", "group_interval", "repeat_interval"] as const;
|
|
223
225
|
|
|
224
|
-
/** Check an `alertmanager.yml` on its own (PROM201, PROM203-
|
|
226
|
+
/** Check an `alertmanager.yml` on its own (PROM201, PROM203-PROM210). */
|
|
225
227
|
export function validateAlertmanagerConfig(config: AlertmanagerConfig): PrometheusIssue[] {
|
|
226
228
|
const issues: PrometheusIssue[] = [];
|
|
227
229
|
const receivers = Array.isArray(config?.receivers) ? config.receivers : [];
|
|
@@ -307,41 +309,16 @@ export function validateAlertmanagerConfig(config: AlertmanagerConfig): Promethe
|
|
|
307
309
|
}
|
|
308
310
|
});
|
|
309
311
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
312
|
+
// PROM208, PROM210: global settings
|
|
313
|
+
issues.push(...validateGlobalSettings(global));
|
|
313
314
|
|
|
314
|
-
// PROM207: unused receivers;
|
|
315
|
+
// PROM207: unused receivers; PROM208-PROM210: integrations (./validate-integrations.ts)
|
|
315
316
|
for (const r of receivers) {
|
|
316
317
|
const name = str(r?.name ?? "");
|
|
317
318
|
if (!usedReceivers.has(name)) {
|
|
318
319
|
issues.push({ code: "PROM207", severity: "warning", subject: name, message: `receiver "${name}" is declared but no route sends to it` });
|
|
319
320
|
}
|
|
320
|
-
|
|
321
|
-
if (!w.url && !w.url_file) issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" webhook_configs[${i}] has no url or url_file` });
|
|
322
|
-
if (w.timeout !== undefined && !isValidDuration(w.timeout)) {
|
|
323
|
-
issues.push({ code: "PROM208", severity: "error", subject: name, message: `receiver "${name}" webhook_configs[${i}].timeout "${str(w.timeout)}" is not a duration` });
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
for (const [i, s] of (r.slack_configs ?? []).entries()) {
|
|
327
|
-
if (!s.api_url && !s.api_url_file && !global.slack_api_url && !global.slack_api_url_file) {
|
|
328
|
-
issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" slack_configs[${i}] has no api_url or api_url_file, and global sets no slack_api_url` });
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
for (const [i, p] of (r.pagerduty_configs ?? []).entries()) {
|
|
332
|
-
if (!p.routing_key && !p.routing_key_file && !p.service_key && !p.service_key_file) {
|
|
333
|
-
issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" pagerduty_configs[${i}] has no routing_key(_file) or service_key(_file)` });
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
for (const [i, e] of (r.email_configs ?? []).entries()) {
|
|
337
|
-
if (!e.to) issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no to address` });
|
|
338
|
-
if (!e.smarthost && !global.smtp_smarthost) {
|
|
339
|
-
issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no smarthost, and global sets no smtp_smarthost` });
|
|
340
|
-
}
|
|
341
|
-
if (!e.from && !global.smtp_from) {
|
|
342
|
-
issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no from address, and global sets no smtp_from` });
|
|
343
|
-
}
|
|
344
|
-
}
|
|
321
|
+
issues.push(...validateReceiverIntegrations(r, global));
|
|
345
322
|
// A receiver with no integrations at all is valid: it is how Alertmanager drops alerts.
|
|
346
323
|
}
|
|
347
324
|
|