@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.
Files changed (80) hide show
  1. package/README.md +5 -2
  2. package/dist/alertmanager.d.ts.map +1 -1
  3. package/dist/codegen/docs.d.ts.map +1 -1
  4. package/dist/composites/catalog.d.ts.map +1 -1
  5. package/dist/composites/genai.d.ts +224 -0
  6. package/dist/composites/genai.d.ts.map +1 -0
  7. package/dist/composites/index.d.ts +2 -0
  8. package/dist/composites/index.d.ts.map +1 -1
  9. package/dist/composites/slo.d.ts +1 -1
  10. package/dist/composites/slo.d.ts.map +1 -1
  11. package/dist/import/embedded.d.ts +12 -3
  12. package/dist/import/embedded.d.ts.map +1 -1
  13. package/dist/import/generator.d.ts +14 -0
  14. package/dist/import/generator.d.ts.map +1 -1
  15. package/dist/import/parser.d.ts.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/init-templates.d.ts +11 -2
  19. package/dist/init-templates.d.ts.map +1 -1
  20. package/dist/integrations.d.ts.map +1 -1
  21. package/dist/integrity.json +8 -7
  22. package/dist/lint/audit-catalog.d.ts.map +1 -1
  23. package/dist/lint/audit-lineage.d.ts +33 -0
  24. package/dist/lint/audit-lineage.d.ts.map +1 -0
  25. package/dist/lint/post-synth/index.d.ts.map +1 -1
  26. package/dist/lint/post-synth/prom-helpers.d.ts +1 -1
  27. package/dist/lint/post-synth/prom208.d.ts +1 -1
  28. package/dist/lint/post-synth/prom209.d.ts +1 -1
  29. package/dist/lint/post-synth/prom210.d.ts +8 -0
  30. package/dist/lint/post-synth/prom210.d.ts.map +1 -0
  31. package/dist/manifest.json +1 -1
  32. package/dist/okf/index.md +1 -0
  33. package/dist/okf/rules/PROM210.md +11 -0
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/rule-eval.d.ts +10 -4
  36. package/dist/rule-eval.d.ts.map +1 -1
  37. package/dist/rules/prom-helpers.ts +1 -1
  38. package/dist/rules/prom208.ts +1 -1
  39. package/dist/rules/prom209.ts +1 -1
  40. package/dist/rules/prom210.ts +17 -0
  41. package/dist/skill-defs.d.ts +1 -1
  42. package/dist/skill-defs.d.ts.map +1 -1
  43. package/dist/skills/chant-prometheus-alertmanager.md +3 -1
  44. package/dist/skills/chant-prometheus.md +22 -0
  45. package/dist/validate-config.d.ts +2 -2
  46. package/dist/validate-config.d.ts.map +1 -1
  47. package/dist/validate-integrations.d.ts +37 -0
  48. package/dist/validate-integrations.d.ts.map +1 -0
  49. package/package.json +4 -3
  50. package/src/codegen/docs.ts +5 -0
  51. package/src/composites/catalog.test.ts +1 -1
  52. package/src/composites/catalog.ts +70 -0
  53. package/src/composites/genai.test.ts +307 -0
  54. package/src/composites/genai.ts +678 -0
  55. package/src/composites/index.ts +15 -0
  56. package/src/composites/slo-burn.test.ts +25 -1
  57. package/src/import/embedded.test.ts +52 -3
  58. package/src/import/embedded.ts +37 -5
  59. package/src/import/generator.ts +19 -1
  60. package/src/index.ts +14 -0
  61. package/src/init-templates.test.ts +37 -11
  62. package/src/init-templates.ts +75 -6
  63. package/src/lint/audit-catalog.ts +13 -2
  64. package/src/lint/audit-lineage.ts +69 -0
  65. package/src/lint/post-synth/index.ts +2 -0
  66. package/src/lint/post-synth/post-synth.test.ts +34 -0
  67. package/src/lint/post-synth/prom-helpers.ts +1 -1
  68. package/src/lint/post-synth/prom208.ts +1 -1
  69. package/src/lint/post-synth/prom209.ts +1 -1
  70. package/src/lint/post-synth/prom210.ts +17 -0
  71. package/src/plugin.test.ts +1 -0
  72. package/src/plugin.ts +8 -4
  73. package/src/rule-eval.ts +72 -7
  74. package/src/skills/chant-prometheus-alertmanager.md +3 -1
  75. package/src/skills/chant-prometheus.md +22 -0
  76. package/src/testdata/integration-cases.ts +96 -0
  77. package/src/tools.test.ts +6 -0
  78. package/src/typecheck.test.ts +88 -0
  79. package/src/validate-config.ts +8 -31
  80. 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 { initTemplates } from "./init-templates";
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
- return initTemplates(template);
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 `without`), the
11
- * arithmetic and comparison operators and `and`, `or` and `unless` (with
12
- * `on` or `ignoring`). Anything else throws, so a test never passes by
13
- * silently evaluating something it does not understand.
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 names = (modifier.getChild("GroupingLabels")?.getChildren("LabelName") ?? []).map((l) => this.text(l, src));
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("GroupLeft") || modifier.getChild("GroupRight")) throw new Error("rule-eval: group_left/right is not supported");
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 somewhere to send (PROM209): a webhook `url`, a Slack `api_url` (or `global.slack_api_url`), a PagerDuty key, an email `to`/`smarthost`/`from` (or the global SMTP defaults).
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
+ });
@@ -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-PROM209). */
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
- if (global.resolve_timeout !== undefined && !isValidDuration(global.resolve_timeout)) {
311
- issues.push({ code: "PROM208", severity: "error", subject: "global", message: `global.resolve_timeout "${str(global.resolve_timeout)}" is not a duration` });
312
- }
312
+ // PROM208, PROM210: global settings
313
+ issues.push(...validateGlobalSettings(global));
313
314
 
314
- // PROM207: unused receivers; PROM209: incomplete integrations
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
- for (const [i, w] of (r.webhook_configs ?? []).entries()) {
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