@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
@@ -21,3 +21,18 @@ export type {
21
21
  SloMetrics,
22
22
  SloBurnRate,
23
23
  } from "./slo";
24
+ export { GenAiRules, genAiRuleMetrics } from "./genai";
25
+ export type {
26
+ GenAiRulesProps,
27
+ GenAiRulesMembers,
28
+ GenAiRulesInstance,
29
+ GenAiRuleMetrics,
30
+ GenAiPrice,
31
+ GenAiAlerting,
32
+ GenAiAlertOptions,
33
+ GenAiRatioAlert,
34
+ GenAiLatencyAlert,
35
+ GenAiBudget,
36
+ GenAiAlertInfo,
37
+ GenAiQuantileSeries,
38
+ } from "./genai";
@@ -259,8 +259,32 @@ describe.skipIf(!hasPromtool)("each pair fires at its burn rate and not below it
259
259
 
260
260
  describe("rule-eval", () => {
261
261
  test("throws on PromQL it does not implement instead of guessing", () => {
262
- const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "histogram_quantile(0.9, rate(a[5m]))" }] }]);
262
+ const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "max_over_time(a[5m])" }] }]);
263
263
  expect(() => ev.step(0)).toThrow(/not supported|needs a range/);
264
+ const left = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "a / ignoring (t) group_left (u) b" }] }]);
265
+ expect(() => left.step(0)).toThrow(/group_left with labels is not supported/);
266
+ });
267
+
268
+ test("histogram_quantile interpolates inside the bucket the rank falls in", () => {
269
+ const ev = new RuleEvaluator([]);
270
+ for (const [le, n] of [["0.5", 20], ["1", 60], ["2", 100], ["+Inf", 100]] as const) ev.add({ __name__: "d_bucket", job: "a", le }, 0, n);
271
+ for (const [le, n] of [["1", 0], ["+Inf", 10]] as const) ev.add({ __name__: "d_bucket", job: "b", le }, 0, n);
272
+ const at = (q: number) => Object.fromEntries(ev.query(`histogram_quantile(${q}, d_bucket)`, 0).map((e) => [e.labels.job, e.value]));
273
+ // a: rank 50 is 30 of the 40 in (0.5, 1]; rank 95 is 35 of the 40 in (1, 2].
274
+ expect(at(0.5).a).toBeCloseTo(0.875, 9);
275
+ expect(at(0.95).a).toBeCloseTo(1.875, 9);
276
+ // b: every observation is above the highest finite bound, which is what Prometheus returns.
277
+ expect(at(0.5).b).toBe(1);
278
+ });
279
+
280
+ test("group_left matches many series on the left to one on the right", () => {
281
+ const ev = new RuleEvaluator([]);
282
+ ev.add({ __name__: "errs", m: "x", t: "timeout" }, 0, 2);
283
+ ev.add({ __name__: "errs", m: "x", t: "refused" }, 0, 3);
284
+ ev.add({ __name__: "reqs", m: "x" }, 0, 10);
285
+ const r = ev.query("errs / ignoring (t) group_left reqs", 0);
286
+ expect(r.map((e) => [e.labels.t, e.value]).sort()).toEqual([["refused", 0.3], ["timeout", 0.2]]);
287
+ expect(() => ev.query("errs / ignoring (t) reqs", 0)).toThrow(/needs group_left/);
264
288
  });
265
289
 
266
290
  test("evaluates sums, ratios and set operators the way Prometheus does", () => {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { embeddedDocument, type EmbeddedContent } from "@intentius/chant/import/embedded";
3
3
  import { prometheusPlugin } from "../plugin";
4
- import { ruleGroupsImporter } from "./embedded";
4
+ import { alertmanagerImporter, ruleGroupsImporter } from "./embedded";
5
5
 
6
6
  const GROUPS = [
7
7
  { name: "api", rules: [{ alert: "ApiDown", expr: "up{job=\"api\"} == 0", for: "5m" }] },
@@ -25,8 +25,8 @@ const site = (over: Partial<EmbeddedContent>): EmbeddedContent => ({
25
25
  });
26
26
 
27
27
  describe("rule groups embedded in another lexicon's resource (#2962)", () => {
28
- test("the plugin registers the importer", () => {
29
- expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter]);
28
+ test("the plugin registers the importers", () => {
29
+ expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter, alertmanagerImporter]);
30
30
  });
31
31
 
32
32
  test("matches a PrometheusRule's groups and a rule file held as text; not an alertmanager.yml", () => {
@@ -54,3 +54,52 @@ describe("rule groups embedded in another lexicon's resource (#2962)", () => {
54
54
  expect(out.value.bindings).toEqual([{ from: "rules.ts", name: "api" }]);
55
55
  });
56
56
  });
57
+
58
+ const ALERTMANAGER = `route:
59
+ receiver: team
60
+ routes:
61
+ - matchers: [severity="critical"]
62
+ receiver: pager
63
+ mute_time_intervals: [nights]
64
+ receivers:
65
+ - name: team
66
+ - name: pager
67
+ time_intervals:
68
+ - name: nights
69
+ time_intervals:
70
+ - times: [{ start_time: "22:00", end_time: "24:00" }]
71
+ inhibit_rules:
72
+ - source_matchers: [severity="critical"]
73
+ target_matchers: [severity="warning"]
74
+ templates: [/etc/alertmanager/*.tmpl]
75
+ `;
76
+
77
+ describe("an alertmanager.yml embedded in another lexicon's resource (#3031)", () => {
78
+ const configMap = (text: string) =>
79
+ site({ hostType: "K8s::Core::ConfigMap", location: 'ConfigMap am data["alertmanager.yml"]', text, document: embeddedDocument(text) });
80
+
81
+ test("matches an alertmanager.yml held as text; not a rule file, nor a selected member", () => {
82
+ expect(alertmanagerImporter.matches(configMap(ALERTMANAGER))).toBe(true);
83
+ expect(alertmanagerImporter.matches(configMap(RULE_FILE))).toBe(false);
84
+ expect(alertmanagerImporter.matches(site({ document: { groups: GROUPS }, select: "groups" }))).toBe(false);
85
+ expect(ruleGroupsImporter.matches(configMap(ALERTMANAGER))).toBe(false);
86
+ });
87
+
88
+ test("becomes alertmanagerYaml over every declaration the standalone import writes", () => {
89
+ const out = alertmanagerImporter.import(configMap(ALERTMANAGER));
90
+ expect(out.files.map((f) => f.path)).toEqual(["receivers.ts", "time-intervals.ts", "routes.ts", "inhibit-rules.ts", "settings.ts"]);
91
+ expect(out.value).toEqual({
92
+ bindings: [
93
+ { from: "receivers.ts", name: "team" },
94
+ { from: "receivers.ts", name: "pager" },
95
+ { from: "time-intervals.ts", name: "nights" },
96
+ { from: "routes.ts", name: "root" },
97
+ { from: "inhibit-rules.ts", name: "inhibitRule1" },
98
+ { from: "settings.ts", name: "settings" },
99
+ ],
100
+ shape: "list",
101
+ through: { from: "@intentius/chant-lexicon-prometheus", name: "alertmanagerYaml" },
102
+ });
103
+ expect(out.warnings).toEqual([]);
104
+ });
105
+ });
@@ -1,19 +1,27 @@
1
1
  /**
2
- * Rule groups embedded in another lexicon's resource, for `chant import`
3
- * (#2962): a k8s `PrometheusRule`'s `spec.groups`, or a rule file held as
4
- * text in a ConfigMap.
2
+ * Prometheus and Alertmanager content embedded in another lexicon's
3
+ * resource, for `chant import`: a k8s `PrometheusRule`'s `spec.groups`, or a
4
+ * rule file held as text in a ConfigMap (#2962), and an `alertmanager.yml`
5
+ * held as text in a ConfigMap (#3031).
5
6
  *
6
7
  * The groups are imported exactly as `chant import rules.yml` would import
7
8
  * them, into a directory of its own. `spec.groups` becomes the list of the
8
9
  * declared groups (an `Slo`'s by its `rules` member), which the k8s
9
10
  * serializer renders as the same groups; a rule file becomes
10
11
  * `ruleFileYaml([...])`, the text the prometheus serializer writes.
12
+ *
13
+ * An `alertmanager.yml` is imported exactly as `chant import alertmanager.yml`
14
+ * would import it, and becomes `alertmanagerYaml([...])` over every receiver,
15
+ * time interval, root route, inhibit rule and settings it declares. The
16
+ * ConfigMap then holds the config as the serializer writes it: the same
17
+ * config, with Alertmanager's deprecated spellings rewritten as the
18
+ * standalone import rewrites them.
11
19
  */
12
20
 
13
21
  import type { EmbeddedContentImporter, EmbeddedImport } from "@intentius/chant/import/embedded";
14
- import { looksLikeRuleFile } from "../model";
22
+ import { looksLikeAlertmanagerConfig, looksLikeRuleFile } from "../model";
15
23
  import { parsePrometheusYaml } from "./parser";
16
- import { generateRuleFile } from "./generator";
24
+ import { generateAlertmanager, generateRuleFile } from "./generator";
17
25
 
18
26
  const PACKAGE = "@intentius/chant-lexicon-prometheus";
19
27
 
@@ -42,3 +50,27 @@ export const ruleGroupsImporter: EmbeddedContentImporter = {
42
50
  };
43
51
  },
44
52
  };
53
+
54
+ export const alertmanagerImporter: EmbeddedContentImporter = {
55
+ what: "an Alertmanager config",
56
+
57
+ matches(content) {
58
+ return content.select === undefined && typeof content.text === "string" && looksLikeAlertmanagerConfig(content.document);
59
+ },
60
+
61
+ import(content): EmbeddedImport {
62
+ const parsed = parsePrometheusYaml(content.text!);
63
+ if (parsed.kind !== "alertmanager") throw new Error("this is not an alertmanager.yml");
64
+ const { files, declarations } = generateAlertmanager(parsed.config);
65
+ if (declarations.length === 0) throw new Error("the import declared nothing");
66
+ return {
67
+ files,
68
+ value: {
69
+ bindings: declarations.map((d) => ({ from: d.path, name: d.name })),
70
+ shape: "list",
71
+ through: { from: PACKAGE, name: "alertmanagerYaml" },
72
+ },
73
+ warnings: parsed.warnings,
74
+ };
75
+ },
76
+ };
@@ -381,6 +381,21 @@ function untypedComment(what: string, keys: string[]): string[] {
381
381
 
382
382
  /** Generate the TypeScript declaring one `alertmanager.yml`. */
383
383
  export function generateAlertmanagerFiles(config: AlertmanagerConfig): GeneratedFile[] {
384
+ return generateAlertmanager(config).files;
385
+ }
386
+
387
+ /** A declaration an `alertmanager.yml` import exports: its module and variable. */
388
+ export interface AlertmanagerDeclaration {
389
+ readonly path: string;
390
+ readonly name: string;
391
+ }
392
+
393
+ /**
394
+ * Generate the TypeScript declaring one `alertmanager.yml`, and list every
395
+ * declaration it exports, module by module (#3031: a ConfigMap holding the
396
+ * file becomes `alertmanagerYaml([...])` over them).
397
+ */
398
+ export function generateAlertmanager(config: AlertmanagerConfig): { files: GeneratedFile[]; declarations: AlertmanagerDeclaration[] } {
384
399
  const names = new Names([...LEXICON_NAMES, "global", "tracing"]);
385
400
  const modules: Module[] = [];
386
401
  const receiverVars = new Map<string, { v: string; mod: Module }>();
@@ -558,7 +573,10 @@ export function generateAlertmanagerFiles(config: AlertmanagerConfig): Generated
558
573
  mod.exports.push(v);
559
574
  }
560
575
 
561
- return modules.map((m) => ({ path: m.path, content: m.render() }));
576
+ return {
577
+ files: modules.map((m) => ({ path: m.path, content: m.render() })),
578
+ declarations: modules.flatMap((m) => m.exports.map((name) => ({ path: m.path, name }))),
579
+ };
562
580
  }
563
581
 
564
582
  /** The rule file and `alertmanager.yml` TypeScript generator `chant import` runs. */
package/src/index.ts CHANGED
@@ -94,4 +94,18 @@ export {
94
94
  type SloInstance,
95
95
  type SloMetrics,
96
96
  type SloBurnRate,
97
+ GenAiRules,
98
+ genAiRuleMetrics,
99
+ type GenAiRulesProps,
100
+ type GenAiRulesMembers,
101
+ type GenAiRulesInstance,
102
+ type GenAiRuleMetrics,
103
+ type GenAiPrice,
104
+ type GenAiAlerting,
105
+ type GenAiAlertOptions,
106
+ type GenAiRatioAlert,
107
+ type GenAiLatencyAlert,
108
+ type GenAiBudget,
109
+ type GenAiAlertInfo,
110
+ type GenAiQuantileSeries,
97
111
  } from "./composites";
@@ -1,29 +1,55 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
3
- import { tmpdir } from "os";
4
3
  import { join } from "path";
4
+ import { load } from "js-yaml";
5
5
  import { build } from "@intentius/chant/build";
6
6
  import { lintCommand } from "@intentius/chant/cli/commands/lint";
7
- import { initTemplates } from "./init-templates";
8
- import { prometheusSerializer } from "./serializer";
9
- import { postSynthChecks } from "./lint/post-synth";
10
7
  import { runPostSynthChecks } from "@intentius/chant/lint/post-synth";
8
+ import { prometheusPlugin } from "./plugin";
9
+ import { TEMPLATE_NAMES } from "./init-templates";
10
+ import { postSynthChecks } from "./lint/post-synth";
11
+
12
+ async function built(name: string | undefined) {
13
+ const dir = mkdtempSync(join(import.meta.dirname, "..", ".init-template-"));
14
+ mkdirSync(join(dir, "src"));
15
+ for (const [file, text] of Object.entries(prometheusPlugin.initTemplates!(name).src)) writeFileSync(join(dir, "src", file), text);
16
+ return { dir, result: await build(join(dir, "src"), [prometheusPlugin.serializer]) };
17
+ }
11
18
 
19
+ // Every template builds, passes every PROM check with nothing to report, and
20
+ // lints clean.
12
21
  describe("init templates", () => {
13
- test.each([undefined, "rules", "slo-style"])("%s builds, lints clean and passes every check", async (name) => {
14
- const dir = mkdtempSync(join(import.meta.dirname, "..", ".init-template-"));
22
+ test.each([undefined, ...TEMPLATE_NAMES])("%s builds, lints clean and passes every check", async (name) => {
23
+ const { dir, result } = await built(name);
15
24
  try {
16
- mkdirSync(join(dir, "src"));
17
- for (const [file, text] of Object.entries(initTemplates(name).src)) writeFileSync(join(dir, "src", file), text);
18
- const result = await build(join(dir, "src"), [prometheusSerializer]);
19
25
  expect(result.errors).toEqual([]);
20
26
  expect(result.outputs.get("prometheus")).toBeTruthy();
21
- const diags = runPostSynthChecks(postSynthChecks, result);
22
- expect(diags).toEqual([]);
27
+ expect(runPostSynthChecks(postSynthChecks, result)).toEqual([]);
23
28
  const lint = await lintCommand({ path: join(dir, "src"), format: "stylish", fix: false });
24
29
  expect(lint.errorCount + lint.warningCount, lint.output).toBe(0);
25
30
  } finally {
26
31
  rmSync(dir, { recursive: true, force: true });
27
32
  }
28
33
  });
34
+
35
+ test("the slo template builds the SLO's rules and routes both severities its alerts carry", async () => {
36
+ const { dir, result } = await built("slo");
37
+ try {
38
+ const out = result.outputs.get("prometheus") as { primary: string; files: Record<string, string> };
39
+ const rules = load(out.primary) as { groups: Array<{ name: string; rules: Array<{ alert?: string; labels?: Record<string, string> }> }> };
40
+ expect(rules.groups.map((g) => g.name)).toEqual(["slo-checkout"]);
41
+ const severities = new Set(rules.groups[0].rules.filter((r) => r.alert).map((r) => r.labels?.severity));
42
+ expect([...severities].sort()).toEqual(["page", "ticket"]);
43
+ const am = load(out.files["alertmanager.yml"]) as { route: { routes: Array<{ matchers: string[] }> }; inhibit_rules: unknown[] };
44
+ expect(am.route.routes.flatMap((r) => r.matchers)).toEqual(['severity="page"', 'severity="ticket"']);
45
+ expect(am.inhibit_rules).toEqual([{ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["slo"] }]);
46
+ } finally {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test("an unknown template name falls back to the default", () => {
52
+ expect(prometheusPlugin.initTemplates!("no-such-template")).toBe(prometheusPlugin.initTemplates!());
53
+ for (const name of TEMPLATE_NAMES) expect(prometheusPlugin.initTemplates!(name)).not.toBe(prometheusPlugin.initTemplates!());
54
+ });
29
55
  });
@@ -6,7 +6,11 @@
6
6
  * - `rules`: rule groups only, for a setup whose Alertmanager config lives
7
7
  * elsewhere.
8
8
  * - `slo-style`: a recording rule per window and alerts on two severities,
9
- * the shape SLO burn-rate rules take.
9
+ * the shape SLO burn-rate rules take, written out as plain rules.
10
+ * - `slo`: an SLO declared with the `Slo` composite, which builds its error
11
+ * ratios, error budget and multiwindow burn-rate alerts, and the
12
+ * Alertmanager routing for the page and ticket severities those alerts
13
+ * carry, with a page muting the same SLO's ticket.
10
14
  */
11
15
  import type { InitTemplateSet } from "@intentius/chant/lexicon";
12
16
 
@@ -69,8 +73,73 @@ const burnAlerts = new RuleGroup({ name: "burn-alerts", rules: alerting });
69
73
  export { errorRatios, burnAlerts };
70
74
  `;
71
75
 
72
- export function initTemplates(template?: string): InitTemplateSet {
73
- if (template === "rules") return { src: { "rules.ts": RULES } };
74
- if (template === "slo-style") return { src: { "rules.ts": SLO_STYLE } };
75
- return { src: { "rules.ts": RULES, "alertmanager.ts": ALERTMANAGER } };
76
- }
76
+ // ── slo ────────────────────────────────────────────────────────────────
77
+
78
+ const SLO_DECLARATION = `/**
79
+ * The SLO: 99.9% of checkout requests answer without a 5xx over 30 days.
80
+ * \`Slo\` builds one rule group: the error ratio over every window its alerts
81
+ * read, the error budget left, and burn-rate alerts that page on a fast burn
82
+ * (severity "page") and open a ticket on a slow one (severity "ticket").
83
+ * \`sloMetrics(checkout)\` returns the recorded series names, for a dashboard
84
+ * or another rule to read.
85
+ */
86
+ import { Slo } from "@intentius/chant-lexicon-prometheus";
87
+
88
+ const sli = {
89
+ errors: 'sum(rate(http_requests_total{job="checkout",code=~"5.."}[{{window}}]))',
90
+ total: 'sum(rate(http_requests_total{job="checkout"}[{{window}}]))',
91
+ };
92
+ const team = { team: "payments" };
93
+
94
+ const checkout = Slo({
95
+ name: "checkout",
96
+ objective: 0.999,
97
+ window: "30d",
98
+ description: "Checkout requests answer without a 5xx.",
99
+ sli,
100
+ labels: team,
101
+ });
102
+
103
+ export { checkout };
104
+ `;
105
+
106
+ const SLO_ALERTMANAGER = `/**
107
+ * Routing for the SLO's alerts, in the same build root so PROM202 checks that
108
+ * both severities they carry have a route.
109
+ */
110
+ import { InhibitRule, Receiver, Route, type RouteProps, type WebhookConfig } from "@intentius/chant-lexicon-prometheus";
111
+
112
+ const pager: WebhookConfig[] = [{ url: "http://pager-bridge:8080/alerts" }];
113
+ const oncall = new Receiver({ name: "oncall", webhook_configs: pager });
114
+
115
+ const ticketing: WebhookConfig[] = [{ url: "http://ticket-bridge:8080/alerts" }];
116
+ const tickets = new Receiver({ name: "tickets", webhook_configs: ticketing });
117
+
118
+ const fallback = new Receiver({ name: "default" });
119
+
120
+ const bySlo = ["alertname", "slo"];
121
+ const children: RouteProps[] = [
122
+ { matchers: ['severity="page"'], receiver: oncall },
123
+ { matchers: ['severity="ticket"'], receiver: tickets },
124
+ ];
125
+ const root = new Route({ receiver: fallback, group_by: bySlo, routes: children });
126
+
127
+ // A page for an SLO mutes its ticket: the fast burn already has someone on it.
128
+ const pageSource = ['severity="page"'];
129
+ const ticketTarget = ['severity="ticket"'];
130
+ const sameSlo = ["slo"];
131
+ const pageMutesTicket = new InhibitRule({ source_matchers: pageSource, target_matchers: ticketTarget, equal: sameSlo });
132
+
133
+ export { oncall, tickets, fallback, root, pageMutesTicket };
134
+ `;
135
+
136
+ export const DEFAULT_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES, "alertmanager.ts": ALERTMANAGER } };
137
+
138
+ export const RULES_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES } };
139
+
140
+ export const SLO_STYLE_TEMPLATE: InitTemplateSet = { src: { "rules.ts": SLO_STYLE } };
141
+
142
+ export const SLO_TEMPLATE: InitTemplateSet = { src: { "slo.ts": SLO_DECLARATION, "alertmanager.ts": SLO_ALERTMANAGER } };
143
+
144
+ /** The template names `chant init --lexicon prometheus --template <name>` takes, besides the default. */
145
+ export const TEMPLATE_NAMES = ["rules", "slo-style", "slo"] as const;
@@ -9,7 +9,8 @@
9
9
  * listed for a reader who meets them in a lint report.
10
10
  */
11
11
 
12
- import { auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
12
+ import { applyLineage, auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
13
+ import { prometheusAuditLineage } from "./audit-lineage";
13
14
 
14
15
  function sourceRule(id: string, category: RuleMeta["category"], title: string, remediation: string): RuleMeta {
15
16
  return { id, tier: "merge-worthy", fixKind: "guidance", category, title, remediation, yamlBased: false };
@@ -118,6 +119,16 @@ export const prometheusAuditCatalog: Record<string, RuleMeta> = {
118
119
  "merge-worthy",
119
120
  "correctness",
120
121
  "Receiver integration missing its destination or credential",
121
- "Set the integration's url, api_url, routing_key or to/smarthost/from (or their *_file and global equivalents).",
122
+ "Set the integration's destination and credential (url, webhook_url, api_key, routing_key, chat_id, room_id, to/smarthost/from, ...), through its *_file or global equivalent where there is one.",
123
+ ),
124
+ PROM210: outputRule(
125
+ "PROM210",
126
+ "merge-worthy",
127
+ "correctness",
128
+ "Integration or global setting Alertmanager rejects",
129
+ "Set one of each value and its *_file, and use a value Alertmanager allows (e.g. message_type text or markdown, parse_mode Markdown, MarkdownV2 or HTML).",
122
130
  ),
123
131
  };
132
+
133
+ // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
134
+ applyLineage(prometheusAuditCatalog, prometheusAuditLineage);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Prior art for the prometheus lexicon's audit rules: the tools whose checks
3
+ * cover the same condition, credited per rule. See
4
+ * packages/core/src/audit/prior-art.ts for the registry, the relation
5
+ * vocabulary, and why this is credit rather than authority. Kept by hand.
6
+ *
7
+ * Mapped against each tool's own check list (#2916):
8
+ *
9
+ * - promtool check rules parses a rule file with Prometheus's rulefmt
10
+ * package (model/rulefmt/rulefmt.go): repeated group names, record/alert
11
+ * exclusivity, annotations/for/keep_firing_for on recording rules, durations,
12
+ * and PromQL syntax. It has no per-rule ids, so those credits omit `rule`.
13
+ * - amtool check-config loads an Alertmanager config with the same code the
14
+ * server uses (config/config.go): undefined receivers and time intervals,
15
+ * non-unique names, a root route that is missing, has no receiver or has
16
+ * matchers, matcher syntax, durations, missing integration destinations and
17
+ * credentials, and "at most one of X and X_file". No per-rule ids either.
18
+ * - pint (cloudflare/pint, docs/checks/) is a rule linter with named checks.
19
+ * promql/syntax, rule/duplicate, rule/label, alerts/annotation and
20
+ * alerts/for map onto chant rules. rule/label and alerts/annotation are
21
+ * configurable "this label or annotation must exist" checks that pint ships
22
+ * with nothing enabled, so they overlap PROM106 and PROM107 and no more.
23
+ *
24
+ * Deliberately without a credit from these tools:
25
+ * - PROM001 and PROM003: a literal credential in TypeScript source, and the
26
+ * Slo objective/window model. Neither tool reads either.
27
+ * - PROM202 (severity not routed) and PROM207 (receiver never routed to):
28
+ * check-config accepts both, and `amtool config routes test` answers a
29
+ * different question (where one given label set goes).
30
+ */
31
+ import type { Lineage } from "@intentius/chant/audit/catalog";
32
+
33
+ const RULEFMT = "https://github.com/prometheus/prometheus/blob/main/model/rulefmt/rulefmt.go";
34
+ const AM_CONFIG = "https://github.com/prometheus/alertmanager/blob/main/config/config.go";
35
+ const PINT = "https://github.com/cloudflare/pint/blob/main/docs/checks";
36
+
37
+ const amtool = (relation: Lineage["relation"]): Lineage => ({
38
+ tool: "amtool",
39
+ url: AM_CONFIG,
40
+ relation,
41
+ });
42
+
43
+ export const prometheusAuditLineage: Record<string, Lineage[]> = {
44
+ PROM002: [
45
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
46
+ { tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
47
+ ],
48
+ PROM101: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
49
+ PROM102: [{ tool: "pint", rule: "rule/duplicate", url: `${PINT}/rule/duplicate.md`, relation: "overlaps" }],
50
+ PROM103: [
51
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
52
+ { tool: "pint", rule: "alerts/for", url: `${PINT}/alerts/for.md`, relation: "overlaps" },
53
+ ],
54
+ PROM104: [
55
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
56
+ { tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
57
+ ],
58
+ PROM105: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
59
+ PROM106: [{ tool: "pint", rule: "rule/label", url: `${PINT}/rule/label.md`, relation: "overlaps" }],
60
+ PROM107: [{ tool: "pint", rule: "alerts/annotation", url: `${PINT}/alerts/annotation.md`, relation: "overlaps" }],
61
+ PROM201: [amtool("equivalent")],
62
+ PROM203: [amtool("equivalent")],
63
+ PROM204: [amtool("equivalent")],
64
+ PROM205: [amtool("equivalent")],
65
+ PROM206: [amtool("equivalent")],
66
+ PROM208: [amtool("equivalent")],
67
+ PROM209: [amtool("overlaps")],
68
+ PROM210: [amtool("overlaps")],
69
+ };
@@ -16,6 +16,7 @@ import { prom206 } from "./prom206";
16
16
  import { prom207 } from "./prom207";
17
17
  import { prom208 } from "./prom208";
18
18
  import { prom209 } from "./prom209";
19
+ import { prom210 } from "./prom210";
19
20
 
20
21
  export const postSynthChecks: PostSynthCheck[] = [
21
22
  prom101,
@@ -34,4 +35,5 @@ export const postSynthChecks: PostSynthCheck[] = [
34
35
  prom207,
35
36
  prom208,
36
37
  prom209,
38
+ prom210,
37
39
  ];
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { makePostSynthCtx, makePostSynthCtxFromFiles } from "@intentius/chant-test-utils";
3
3
  import { postSynthChecks } from "./index";
4
+ import { RECEIVER_INTEGRATION_TYPES } from "../../integrations";
5
+ import { INTEGRATION_CASES, SLACK_APP_URL, amWith } from "../../testdata/integration-cases";
4
6
  import { prom101 } from "./prom101";
5
7
  import { prom102 } from "./prom102";
6
8
  import { prom103 } from "./prom103";
@@ -17,6 +19,7 @@ import { prom206 } from "./prom206";
17
19
  import { prom207 } from "./prom207";
18
20
  import { prom208 } from "./prom208";
19
21
  import { prom209 } from "./prom209";
22
+ import { prom210 } from "./prom210";
20
23
 
21
24
  const RULES = `groups:
22
25
  - name: api
@@ -245,6 +248,37 @@ receivers:
245
248
  });
246
249
  });
247
250
 
251
+ const integrationCodes = (text: string) =>
252
+ [prom208, prom209, prom210].flatMap((c) => c.check(amOnly(text))).map((d) => d.checkId).sort();
253
+
254
+ describe("PROM208-PROM210 on every Alertmanager integration", () => {
255
+ test.each(INTEGRATION_CASES)("%s", (_label, key, entry, global, codes) => {
256
+ expect(integrationCodes(amWith(key, entry, global))).toEqual(codes);
257
+ });
258
+
259
+ test("every integration has a passing and a failing case", () => {
260
+ for (const key of Object.keys(RECEIVER_INTEGRATION_TYPES)) {
261
+ const cases = INTEGRATION_CASES.filter((c) => c[1] === key);
262
+ expect(cases.some((c) => c[4].length === 0), `${key} passing`).toBe(true);
263
+ expect(cases.some((c) => c[4].length > 0), `${key} failing`).toBe(true);
264
+ }
265
+ });
266
+
267
+ test("findings name the receiver and the entry", () => {
268
+ const [d] = prom209.check(amOnly(amWith("opsgenie_configs", {})));
269
+ expect(d).toMatchObject({ checkId: "PROM209", severity: "error", entity: "r" });
270
+ expect(d.message).toContain('receiver "r" opsgenie_configs[0] has no api_key');
271
+ });
272
+
273
+ test("PROM210 and PROM208 in global", () => {
274
+ const am = (global: Record<string, unknown>) => JSON.stringify({ global, route: { receiver: "r" }, receivers: [{ name: "r" }] });
275
+ expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: "https://hooks.slack.com/x" }))).toEqual(["PROM210"]);
276
+ expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: SLACK_APP_URL }))).toEqual([]);
277
+ expect(integrationCodes(am({ smtp_auth_password: "x", smtp_auth_password_file: "/p", smtp_smarthost: "smtp" }))).toEqual(["PROM210", "PROM210"]);
278
+ expect(integrationCodes(am({ resolve_timeout: "1.5m" }))).toEqual(["PROM208"]);
279
+ });
280
+ });
281
+
248
282
  describe("documents from other lexicons", () => {
249
283
  test("a k8s manifest and a collector config are not read as ours", () => {
250
284
  const ctx = makePostSynthCtx(
@@ -71,7 +71,7 @@ export function ruleFileDiagnostics(ctx: PostSynthContext, code: PrometheusIssue
71
71
  );
72
72
  }
73
73
 
74
- /** Diagnostics for one Alertmanager code (PROM201, PROM203-PROM209) across every `alertmanager.yml` in the output. */
74
+ /** Diagnostics for one Alertmanager code (PROM201, PROM203-PROM210) across every `alertmanager.yml` in the output. */
75
75
  export function alertmanagerDiagnostics(ctx: PostSynthContext, code: PrometheusIssueCode): PostSynthDiagnostic[] {
76
76
  return prometheusDocs(ctx).alertmanager.flatMap(({ source, config }) =>
77
77
  validateAlertmanagerConfig(config)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PROM208: An Alertmanager duration is not a duration
3
3
  *
4
- * group_wait, group_interval, repeat_interval, resolve_timeout and webhook timeout take durations such as 30s or 4h.
4
+ * group_wait, group_interval, repeat_interval, resolve_timeout and Jira reopen_duration take Prometheus durations (30s, 4h, 1d); the timeout of webhook, Slack, PagerDuty and incident.io, and Pushover retry, expire and ttl take Go durations (10s, 1m30s, 500ms).
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PROM209: A receiver integration is missing its destination or credential
3
3
  *
4
- * A webhook needs url or url_file, Slack an api_url (or the global one), PagerDuty a routing_key or service_key, and email a to address, a smarthost and a from address (or their global defaults).
4
+ * Every one of Alertmanager's 18 integrations is checked for the destination, credential and required fields its config validation asks for, with the global defaults it falls back to: e.g. a webhook url, an Opsgenie api_key (or global.opsgenie_api_key), a Telegram chat_id and bot token, a Webex room_id and authorization, an SNS target, a Jira project and issue_type.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PROM210: A receiver integration or global setting is one Alertmanager rejects
3
+ *
4
+ * Two settings that exclude each other (a value and its *_file, a Slack api_url and app_token, Pushover html and monospace, two SNS targets), or a value outside the allowed set (WeChat message_type, Telegram parse_mode, Jira api_type, an Opsgenie responder type, a VictorOps reserved custom field, a smarthost that is not host:port, a Slack or Mattermost field without title and value).
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { alertmanagerDiagnostics } from "./prom-helpers";
9
+
10
+ export const prom210: PostSynthCheck = {
11
+ id: "PROM210",
12
+ description: "A receiver integration or global setting is one Alertmanager rejects",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return alertmanagerDiagnostics(ctx, "PROM210");
16
+ },
17
+ };
@@ -37,6 +37,7 @@ describe("prometheus plugin", () => {
37
37
  "PROM207",
38
38
  "PROM208",
39
39
  "PROM209",
40
+ "PROM210",
40
41
  ]);
41
42
  });
42
43