@intentius/chant-lexicon-prometheus 0.96.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 (237) hide show
  1. package/README.md +58 -0
  2. package/dist/alertmanager.d.ts +81 -0
  3. package/dist/alertmanager.d.ts.map +1 -0
  4. package/dist/build.d.ts +33 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/catalog.d.ts +17 -0
  7. package/dist/catalog.d.ts.map +1 -0
  8. package/dist/codegen/docs-cli.d.ts +3 -0
  9. package/dist/codegen/docs-cli.d.ts.map +1 -0
  10. package/dist/codegen/docs.d.ts +9 -0
  11. package/dist/codegen/docs.d.ts.map +1 -0
  12. package/dist/codegen/generate-cli.d.ts +3 -0
  13. package/dist/codegen/generate-cli.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +19 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +7 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/catalog.d.ts +3 -0
  19. package/dist/composites/catalog.d.ts.map +1 -0
  20. package/dist/composites/index.d.ts +6 -0
  21. package/dist/composites/index.d.ts.map +1 -0
  22. package/dist/composites/slo.d.ts +205 -0
  23. package/dist/composites/slo.d.ts.map +1 -0
  24. package/dist/detect.d.ts +2 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/duration.d.ts +16 -0
  27. package/dist/duration.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/init-templates.d.ts +13 -0
  31. package/dist/init-templates.d.ts.map +1 -0
  32. package/dist/integrity.json +33 -0
  33. package/dist/lint/audit-catalog.d.ts +13 -0
  34. package/dist/lint/audit-catalog.d.ts.map +1 -0
  35. package/dist/lint/post-synth/index.d.ts +3 -0
  36. package/dist/lint/post-synth/index.d.ts.map +1 -0
  37. package/dist/lint/post-synth/prom-helpers.d.ts +33 -0
  38. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -0
  39. package/dist/lint/post-synth/prom101.d.ts +8 -0
  40. package/dist/lint/post-synth/prom101.d.ts.map +1 -0
  41. package/dist/lint/post-synth/prom102.d.ts +8 -0
  42. package/dist/lint/post-synth/prom102.d.ts.map +1 -0
  43. package/dist/lint/post-synth/prom103.d.ts +8 -0
  44. package/dist/lint/post-synth/prom103.d.ts.map +1 -0
  45. package/dist/lint/post-synth/prom104.d.ts +8 -0
  46. package/dist/lint/post-synth/prom104.d.ts.map +1 -0
  47. package/dist/lint/post-synth/prom105.d.ts +8 -0
  48. package/dist/lint/post-synth/prom105.d.ts.map +1 -0
  49. package/dist/lint/post-synth/prom106.d.ts +8 -0
  50. package/dist/lint/post-synth/prom106.d.ts.map +1 -0
  51. package/dist/lint/post-synth/prom107.d.ts +8 -0
  52. package/dist/lint/post-synth/prom107.d.ts.map +1 -0
  53. package/dist/lint/post-synth/prom201.d.ts +8 -0
  54. package/dist/lint/post-synth/prom201.d.ts.map +1 -0
  55. package/dist/lint/post-synth/prom202.d.ts +8 -0
  56. package/dist/lint/post-synth/prom202.d.ts.map +1 -0
  57. package/dist/lint/post-synth/prom203.d.ts +8 -0
  58. package/dist/lint/post-synth/prom203.d.ts.map +1 -0
  59. package/dist/lint/post-synth/prom204.d.ts +8 -0
  60. package/dist/lint/post-synth/prom204.d.ts.map +1 -0
  61. package/dist/lint/post-synth/prom205.d.ts +8 -0
  62. package/dist/lint/post-synth/prom205.d.ts.map +1 -0
  63. package/dist/lint/post-synth/prom206.d.ts +8 -0
  64. package/dist/lint/post-synth/prom206.d.ts.map +1 -0
  65. package/dist/lint/post-synth/prom207.d.ts +8 -0
  66. package/dist/lint/post-synth/prom207.d.ts.map +1 -0
  67. package/dist/lint/post-synth/prom208.d.ts +8 -0
  68. package/dist/lint/post-synth/prom208.d.ts.map +1 -0
  69. package/dist/lint/post-synth/prom209.d.ts +8 -0
  70. package/dist/lint/post-synth/prom209.d.ts.map +1 -0
  71. package/dist/lint/rules/index.d.ts +7 -0
  72. package/dist/lint/rules/index.d.ts.map +1 -0
  73. package/dist/lint/rules/literal-credential.d.ts +18 -0
  74. package/dist/lint/rules/literal-credential.d.ts.map +1 -0
  75. package/dist/lint/rules/prom-ast.d.ts +14 -0
  76. package/dist/lint/rules/prom-ast.d.ts.map +1 -0
  77. package/dist/lint/rules/promql-literal.d.ts +11 -0
  78. package/dist/lint/rules/promql-literal.d.ts.map +1 -0
  79. package/dist/lint/rules/slo-literal.d.ts +13 -0
  80. package/dist/lint/rules/slo-literal.d.ts.map +1 -0
  81. package/dist/lsp/completions.d.ts +4 -0
  82. package/dist/lsp/completions.d.ts.map +1 -0
  83. package/dist/lsp/hover.d.ts +4 -0
  84. package/dist/lsp/hover.d.ts.map +1 -0
  85. package/dist/manifest.json +8 -0
  86. package/dist/matchers.d.ts +29 -0
  87. package/dist/matchers.d.ts.map +1 -0
  88. package/dist/meta.json +32 -0
  89. package/dist/model.d.ts +262 -0
  90. package/dist/model.d.ts.map +1 -0
  91. package/dist/okf/index.md +34 -0
  92. package/dist/okf/rules/PROM001.md +16 -0
  93. package/dist/okf/rules/PROM002.md +15 -0
  94. package/dist/okf/rules/PROM003.md +11 -0
  95. package/dist/okf/rules/PROM101.md +11 -0
  96. package/dist/okf/rules/PROM102.md +11 -0
  97. package/dist/okf/rules/PROM103.md +11 -0
  98. package/dist/okf/rules/PROM104.md +11 -0
  99. package/dist/okf/rules/PROM105.md +11 -0
  100. package/dist/okf/rules/PROM106.md +11 -0
  101. package/dist/okf/rules/PROM107.md +11 -0
  102. package/dist/okf/rules/PROM201.md +11 -0
  103. package/dist/okf/rules/PROM202.md +11 -0
  104. package/dist/okf/rules/PROM203.md +11 -0
  105. package/dist/okf/rules/PROM204.md +11 -0
  106. package/dist/okf/rules/PROM205.md +11 -0
  107. package/dist/okf/rules/PROM206.md +11 -0
  108. package/dist/okf/rules/PROM207.md +11 -0
  109. package/dist/okf/rules/PROM208.md +11 -0
  110. package/dist/okf/rules/PROM209.md +11 -0
  111. package/dist/okf/types/AlertmanagerSettings.md +13 -0
  112. package/dist/okf/types/InhibitRule.md +9 -0
  113. package/dist/okf/types/Receiver.md +13 -0
  114. package/dist/okf/types/Route.md +9 -0
  115. package/dist/okf/types/RuleGroup.md +13 -0
  116. package/dist/okf/types/TimeInterval.md +9 -0
  117. package/dist/package-cli.d.ts +3 -0
  118. package/dist/package-cli.d.ts.map +1 -0
  119. package/dist/pin.d.ts +20 -0
  120. package/dist/pin.d.ts.map +1 -0
  121. package/dist/plugin.d.ts +10 -0
  122. package/dist/plugin.d.ts.map +1 -0
  123. package/dist/promql.d.ts +25 -0
  124. package/dist/promql.d.ts.map +1 -0
  125. package/dist/rule-eval.d.ts +67 -0
  126. package/dist/rule-eval.d.ts.map +1 -0
  127. package/dist/rules/literal-credential.ts +84 -0
  128. package/dist/rules/prom-ast.ts +30 -0
  129. package/dist/rules/prom-helpers.ts +89 -0
  130. package/dist/rules/prom101.ts +17 -0
  131. package/dist/rules/prom102.ts +17 -0
  132. package/dist/rules/prom103.ts +17 -0
  133. package/dist/rules/prom104.ts +17 -0
  134. package/dist/rules/prom105.ts +17 -0
  135. package/dist/rules/prom106.ts +17 -0
  136. package/dist/rules/prom107.ts +17 -0
  137. package/dist/rules/prom201.ts +17 -0
  138. package/dist/rules/prom202.ts +17 -0
  139. package/dist/rules/prom203.ts +17 -0
  140. package/dist/rules/prom204.ts +17 -0
  141. package/dist/rules/prom205.ts +17 -0
  142. package/dist/rules/prom206.ts +17 -0
  143. package/dist/rules/prom207.ts +17 -0
  144. package/dist/rules/prom208.ts +17 -0
  145. package/dist/rules/prom209.ts +17 -0
  146. package/dist/rules/promql-literal.ts +55 -0
  147. package/dist/rules/slo-literal.ts +81 -0
  148. package/dist/rules.d.ts +59 -0
  149. package/dist/rules.d.ts.map +1 -0
  150. package/dist/serializer.d.ts +27 -0
  151. package/dist/serializer.d.ts.map +1 -0
  152. package/dist/skill-defs.d.ts +3 -0
  153. package/dist/skill-defs.d.ts.map +1 -0
  154. package/dist/skills/chant-prometheus-alertmanager.md +58 -0
  155. package/dist/skills/chant-prometheus-kubernetes.md +48 -0
  156. package/dist/skills/chant-prometheus.md +91 -0
  157. package/dist/tools.d.ts +30 -0
  158. package/dist/tools.d.ts.map +1 -0
  159. package/dist/types/index.d.ts +3 -0
  160. package/dist/validate-cli.d.ts +3 -0
  161. package/dist/validate-cli.d.ts.map +1 -0
  162. package/dist/validate-config.d.ts +35 -0
  163. package/dist/validate-config.d.ts.map +1 -0
  164. package/dist/validate.d.ts +11 -0
  165. package/dist/validate.d.ts.map +1 -0
  166. package/package.json +77 -0
  167. package/src/alertmanager.ts +143 -0
  168. package/src/build.ts +199 -0
  169. package/src/catalog.ts +65 -0
  170. package/src/codegen/docs-cli.ts +4 -0
  171. package/src/codegen/docs.ts +104 -0
  172. package/src/codegen/generate-cli.ts +8 -0
  173. package/src/codegen/generate.ts +49 -0
  174. package/src/codegen/package.ts +41 -0
  175. package/src/composites/catalog.test.ts +37 -0
  176. package/src/composites/catalog.ts +65 -0
  177. package/src/composites/composites.test.ts +256 -0
  178. package/src/composites/index.ts +23 -0
  179. package/src/composites/slo-burn.test.ts +289 -0
  180. package/src/composites/slo.ts +471 -0
  181. package/src/detect.ts +12 -0
  182. package/src/duration.ts +51 -0
  183. package/src/generated/lexicon-prometheus.json +32 -0
  184. package/src/index.ts +97 -0
  185. package/src/init-templates.test.ts +29 -0
  186. package/src/init-templates.ts +76 -0
  187. package/src/lint/audit-catalog.ts +123 -0
  188. package/src/lint/post-synth/index.ts +37 -0
  189. package/src/lint/post-synth/post-synth.test.ts +256 -0
  190. package/src/lint/post-synth/prom-helpers.ts +89 -0
  191. package/src/lint/post-synth/prom101.ts +17 -0
  192. package/src/lint/post-synth/prom102.ts +17 -0
  193. package/src/lint/post-synth/prom103.ts +17 -0
  194. package/src/lint/post-synth/prom104.ts +17 -0
  195. package/src/lint/post-synth/prom105.ts +17 -0
  196. package/src/lint/post-synth/prom106.ts +17 -0
  197. package/src/lint/post-synth/prom107.ts +17 -0
  198. package/src/lint/post-synth/prom201.ts +17 -0
  199. package/src/lint/post-synth/prom202.ts +17 -0
  200. package/src/lint/post-synth/prom203.ts +17 -0
  201. package/src/lint/post-synth/prom204.ts +17 -0
  202. package/src/lint/post-synth/prom205.ts +17 -0
  203. package/src/lint/post-synth/prom206.ts +17 -0
  204. package/src/lint/post-synth/prom207.ts +17 -0
  205. package/src/lint/post-synth/prom208.ts +17 -0
  206. package/src/lint/post-synth/prom209.ts +17 -0
  207. package/src/lint/rules/index.ts +11 -0
  208. package/src/lint/rules/literal-credential.ts +84 -0
  209. package/src/lint/rules/prom-ast.ts +30 -0
  210. package/src/lint/rules/promql-literal.ts +55 -0
  211. package/src/lint/rules/rules.test.ts +114 -0
  212. package/src/lint/rules/slo-literal.ts +81 -0
  213. package/src/lsp/completions.test.ts +23 -0
  214. package/src/lsp/completions.ts +15 -0
  215. package/src/lsp/hover.test.ts +27 -0
  216. package/src/lsp/hover.ts +26 -0
  217. package/src/matchers.ts +132 -0
  218. package/src/model.test.ts +104 -0
  219. package/src/model.ts +290 -0
  220. package/src/package-cli.ts +17 -0
  221. package/src/pin.ts +13 -0
  222. package/src/plugin.test.ts +73 -0
  223. package/src/plugin.ts +129 -0
  224. package/src/promql.ts +46 -0
  225. package/src/rule-eval.ts +397 -0
  226. package/src/rules.ts +127 -0
  227. package/src/serializer.test.ts +179 -0
  228. package/src/serializer.ts +55 -0
  229. package/src/skill-defs.ts +43 -0
  230. package/src/skills/chant-prometheus-alertmanager.md +58 -0
  231. package/src/skills/chant-prometheus-kubernetes.md +48 -0
  232. package/src/skills/chant-prometheus.md +91 -0
  233. package/src/tools.test.ts +119 -0
  234. package/src/tools.ts +70 -0
  235. package/src/validate-cli.ts +7 -0
  236. package/src/validate-config.ts +400 -0
  237. package/src/validate.ts +66 -0
@@ -0,0 +1,65 @@
1
+ // Generated by scripts/generate-composite-catalogs.ts (#2662) from this
2
+ // lexicon's composite exports. Do not edit by hand: run
3
+ // `npm run generate:composite-catalogs`. catalog.test.ts holds it to the exports.
4
+ import type { CompositeEntry } from "@intentius/chant/lexicon";
5
+
6
+ export const compositeCatalog: CompositeEntry[] = [
7
+ {
8
+ "name": "Slo",
9
+ "lexicon": "prometheus",
10
+ "description": "An SLO, built to recording rules and multiwindow multi-burn-rate alerts.",
11
+ "bundles": [
12
+ "RuleGroup"
13
+ ],
14
+ "params": [
15
+ {
16
+ "name": "name",
17
+ "type": "string",
18
+ "required": true,
19
+ "description": "The SLO's name, the `slo` label on every series and alert it builds and part of its group names."
20
+ },
21
+ {
22
+ "name": "objective",
23
+ "type": "number",
24
+ "required": true,
25
+ "description": "The target share of good events, strictly between 0 and 1, e.g. `0.995`."
26
+ },
27
+ {
28
+ "name": "window",
29
+ "type": "string",
30
+ "required": true,
31
+ "description": "The rolling window the objective holds over, a Prometheus duration, e.g. `28d` or `30d`."
32
+ },
33
+ {
34
+ "name": "sli",
35
+ "type": "SloSli",
36
+ "required": true,
37
+ "description": "The SLI: good (or error) events over total events, with `{{window}}` for the range."
38
+ },
39
+ {
40
+ "name": "description",
41
+ "type": "string",
42
+ "required": false,
43
+ "description": "A sentence for the alerts' description, e.g. what the SLO measures."
44
+ },
45
+ {
46
+ "name": "alerting",
47
+ "type": "SloAlerting",
48
+ "required": false,
49
+ "description": "Burn-rate alerts (default: both tiers with the Workbook's windows)."
50
+ },
51
+ {
52
+ "name": "labels",
53
+ "type": "LabelSet",
54
+ "required": false,
55
+ "description": "Labels added to every rule, e.g. `team` or `service`."
56
+ },
57
+ {
58
+ "name": "interval",
59
+ "type": "string",
60
+ "required": false,
61
+ "description": "Evaluation interval of the group (default: Prometheus's `evaluation_interval`)."
62
+ }
63
+ ]
64
+ }
65
+ ];
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The `Slo` composite: validation, the rules it builds, and `sloMetrics()`.
3
+ * The burn-rate behaviour over synthetic series is in slo-burn.test.ts.
4
+ */
5
+ import { describe, expect, test } from "vitest";
6
+ import { load } from "js-yaml";
7
+ import { Slo, sloMetrics, sloPropsProblem, type SloProps } from "./slo";
8
+ import { ruleGroupConfig } from "../rules";
9
+ import { ruleFileYaml } from "../build";
10
+ import { validateRuleFile, validateSeverityRouting } from "../validate-config";
11
+ import { hasTool, promtoolCheckRules } from "../tools";
12
+ import type { AlertingRuleConfig, RecordingRuleConfig, RuleFileConfig } from "../model";
13
+
14
+ const PROMTOOL = process.env.PROMTOOL ?? "promtool";
15
+ const hasPromtool = hasTool(PROMTOOL);
16
+
17
+ const orderAck: SloProps = {
18
+ name: "order-acknowledged",
19
+ objective: 0.995,
20
+ window: "28d",
21
+ sli: {
22
+ good: 'sum(rate(traces_span_metrics_calls_total{span_name="order.ack",status_code!="STATUS_CODE_ERROR"}[{{window}}]))',
23
+ total: 'sum(rate(traces_span_metrics_calls_total{span_name="order.ack"}[{{window}}]))',
24
+ },
25
+ alerting: { page: { burnRates: "default" }, ticket: { burnRates: "default" } },
26
+ };
27
+
28
+ type SloResult = ReturnType<typeof Slo>;
29
+
30
+ function file(slo: SloResult): RuleFileConfig {
31
+ return load(ruleFileYaml([slo.rules])) as RuleFileConfig;
32
+ }
33
+
34
+ function recordings(slo: SloResult): RecordingRuleConfig[] {
35
+ return ruleGroupConfig(slo.rules).rules.filter((r): r is RecordingRuleConfig => "record" in r);
36
+ }
37
+
38
+ function alertsOf(slo: SloResult): AlertingRuleConfig[] {
39
+ return ruleGroupConfig(slo.rules).rules.filter((r): r is AlertingRuleConfig => "alert" in r);
40
+ }
41
+
42
+ describe("Slo validation", () => {
43
+ test.each([0, 1, -0.1, 1.5, Number.NaN])("rejects objective %s", (objective) => {
44
+ expect(() => Slo({ ...orderAck, objective })).toThrow(/objective must be strictly between 0 and 1/);
45
+ });
46
+
47
+ test.each(["", "28 days", "1.5d", "0", "30m1h"])("rejects window %j", (window) => {
48
+ expect(() => Slo({ ...orderAck, window })).toThrow(/window must be a positive Prometheus duration/);
49
+ });
50
+
51
+ test("rejects an SLI without the window placeholder, or one that does not parse", () => {
52
+ expect(() => Slo({ ...orderAck, sli: { good: "sum(rate(x[5m]))", total: "sum(rate(y[{{window}}]))" } })).toThrow(
53
+ /sli.good must contain \{\{window\}\}/,
54
+ );
55
+ expect(() => Slo({ ...orderAck, sli: { errors: "sum(rate(x[{{window}}])", total: "sum(rate(y[{{window}}]))" } })).toThrow(
56
+ /sli.errors is not valid PromQL/,
57
+ );
58
+ expect(() => Slo({ ...orderAck, sli: { total: "sum(rate(y[{{window}}]))" } as never })).toThrow(/good and total, or errors and total/);
59
+ });
60
+
61
+ test("rejects a long window longer than the SLO window, and a short window that is not shorter", () => {
62
+ expect(() => Slo({ ...orderAck, window: "2d" })).toThrow(/the long window 3d is longer than the SLO window 2d/);
63
+ expect(() =>
64
+ Slo({ ...orderAck, alerting: { ticket: false, page: { burnRates: [{ long: "1h", short: "1h", factor: 10 }] } } }),
65
+ ).toThrow(/must be shorter/);
66
+ expect(() =>
67
+ Slo({ ...orderAck, alerting: { ticket: false, page: { burnRates: [{ long: "1h", short: "5m", factor: 10, budgetConsumed: 0.02 }] } } }),
68
+ ).toThrow(/exactly one of budgetConsumed and factor/);
69
+ });
70
+
71
+ test("rejects a burn rate whose threshold is an error ratio of 1 or more", () => {
72
+ expect(() =>
73
+ Slo({ ...orderAck, objective: 0.9, alerting: { ticket: false, page: { burnRates: [{ long: "1h", short: "5m", factor: 10 }] } } }),
74
+ ).toThrow(/can never happen/);
75
+ });
76
+
77
+ test("rejects a name that cannot go in a label and a group name", () => {
78
+ expect(() => Slo({ ...orderAck, name: 'bad"name' })).toThrow(/name must be/);
79
+ });
80
+
81
+ test("sloPropsProblem accepts the issue's example", () => {
82
+ expect(sloPropsProblem(orderAck)).toBeUndefined();
83
+ });
84
+ });
85
+
86
+ describe("the rules an Slo builds", () => {
87
+ const slo = Slo(orderAck);
88
+
89
+ test("one group, clean under every PROM1xx check, and routable by severity", () => {
90
+ const f = file(slo);
91
+ expect(f.groups.map((g) => g.name)).toEqual(["slo-order-acknowledged"]);
92
+ expect(validateRuleFile(f)).toEqual([]);
93
+ const routes = { route: { receiver: "d", routes: [{ matchers: ['severity="page"'], receiver: "d" }, { matchers: ['severity="ticket"'], receiver: "d" }] }, receivers: [{ name: "d" }] };
94
+ expect(validateSeverityRouting([f], routes)).toEqual([]);
95
+ expect(validateSeverityRouting([f], { route: { receiver: "d", routes: [{ matchers: ['severity="page"'], receiver: "d" }] }, receivers: [{ name: "d" }] })).toHaveLength(1);
96
+ });
97
+
98
+ test("an error-ratio series per window, the window's own ratio, the objective and the budget left", () => {
99
+ const rec = recordings(slo);
100
+ expect(rec.map((r) => r.record)).toEqual([
101
+ "slo:sli_error:ratio_rate5m",
102
+ "slo:sli_error:ratio_rate30m",
103
+ "slo:sli_error:ratio_rate1h",
104
+ "slo:sli_error:ratio_rate2h",
105
+ "slo:sli_error:ratio_rate6h",
106
+ "slo:sli_error:ratio_rate1d",
107
+ "slo:sli_error:ratio_rate3d",
108
+ "slo:sli_error:ratio_rate28d",
109
+ "slo:objective:ratio",
110
+ "slo:error_budget:remaining",
111
+ ]);
112
+ for (const r of rec) expect(r.labels).toEqual({ slo: "order-acknowledged" });
113
+ expect(rec[2].expr).toContain('[1h]');
114
+ expect(rec[2].expr).not.toContain("{{window}}");
115
+ expect(rec[7].expr).toBe('avg_over_time(slo:sli_error:ratio_rate5m{slo="order-acknowledged"}[28d])');
116
+ expect(rec[9].expr).toBe('1 - (slo:sli_error:ratio_rate28d{slo="order-acknowledged"} / 0.005)');
117
+ });
118
+
119
+ test("one alert per window pair, factors scaled from 30 days to 28", () => {
120
+ const alerts = alertsOf(slo);
121
+ expect(alerts.map((a) => [a.alert, a.labels!.severity, a.labels!.long_window, a.labels!.short_window])).toEqual([
122
+ ["ErrorBudgetBurn", "page", "1h", "5m"],
123
+ ["ErrorBudgetBurn", "page", "6h", "30m"],
124
+ ["ErrorBudgetBurn", "ticket", "1d", "2h"],
125
+ ["ErrorBudgetBurn", "ticket", "3d", "6h"],
126
+ ]);
127
+ expect(alerts[0].expr).toContain('slo:sli_error:ratio_rate1h{slo="order-acknowledged"} > (13.44 * 0.005)');
128
+ expect(alerts[0].expr).toContain('slo:sli_error:ratio_rate5m{slo="order-acknowledged"} > (13.44 * 0.005)');
129
+ expect(alerts[0].annotations!.summary).toContain("13.44x");
130
+ expect(alerts.every((a) => a.for === undefined)).toBe(true);
131
+ });
132
+
133
+ test("a 30-day window keeps the Workbook's factors exactly", () => {
134
+ const m = sloMetrics({ ...orderAck, window: "30d" });
135
+ expect(m.burnRates.map((b) => b.factor)).toEqual([14.4, 6, 3, 1]);
136
+ expect(m.burnRates.map((b) => b.exhaustsIn)).toEqual(["2d2h", "5d", "1w3d", "4w2d"]);
137
+ });
138
+
139
+ test("tiers take their own severity, labels, annotations and for; alertName and group options apply", () => {
140
+ const custom = Slo({
141
+ ...orderAck,
142
+ name: "checkout",
143
+ labels: { team: "payments" },
144
+ interval: "30s",
145
+ alerting: {
146
+ alertName: "CheckoutBudgetBurn",
147
+ page: { severity: "critical", for: "2m", labels: { pager: "yes" }, annotations: { runbook_url: "https://runbooks/checkout" } },
148
+ ticket: false,
149
+ },
150
+ });
151
+ const g = ruleGroupConfig(custom.rules);
152
+ expect(g.interval).toBe("30s");
153
+ expect(g.labels).toEqual({ team: "payments" });
154
+ expect(alertsOf(custom)).toHaveLength(2);
155
+ const a = alertsOf(custom)[0];
156
+ expect(a.alert).toBe("CheckoutBudgetBurn");
157
+ expect(a.for).toBe("2m");
158
+ expect(a.labels).toMatchObject({ severity: "critical", pager: "yes", slo: "checkout" });
159
+ expect(a.annotations!.runbook_url).toBe("https://runbooks/checkout");
160
+ expect(sloMetrics(custom).windows).toEqual(["5m", "30m", "1h", "6h", "28d"]);
161
+ });
162
+
163
+ test("custom pairs: a factor is used as written, a budget share is scaled", () => {
164
+ const m = sloMetrics({
165
+ ...orderAck,
166
+ window: "7d",
167
+ alerting: { page: { burnRates: [{ long: "2h", short: "10m", factor: 10 }] }, ticket: { burnRates: [{ long: "1d", short: "2h", budgetConsumed: 0.2 }] } },
168
+ });
169
+ expect(m.burnRates.map((b) => b.factor)).toEqual([10, 1.4]);
170
+ });
171
+
172
+ test("errors/total SLIs record the ratio directly; no alerting records the window from the SLI", () => {
173
+ const direct = Slo({
174
+ ...orderAck,
175
+ name: "direct",
176
+ sli: { errors: "sum(rate(e_total[{{window}}]))", total: "sum(rate(t_total[{{window}}]))" },
177
+ alerting: { page: false, ticket: false },
178
+ });
179
+ expect(alertsOf(direct)).toEqual([]);
180
+ const rec = recordings(direct);
181
+ expect(rec.map((r) => r.record)).toEqual(["slo:sli_error:ratio_rate28d", "slo:objective:ratio", "slo:error_budget:remaining"]);
182
+ expect(rec[0].expr).toBe("(sum(rate(e_total[28d])))\n/\n(sum(rate(t_total[28d])))");
183
+ expect(sloMetrics(direct).group).toBe("slo-direct");
184
+ expect(sloMetrics(direct).burnRates).toEqual([]);
185
+ });
186
+
187
+ test("the same Slo renders inside the rule file promtool accepts", () => {
188
+ const r = promtoolCheckRules(ruleFileYaml([slo.rules]), PROMTOOL);
189
+ if (!hasPromtool) {
190
+ expect(r.ran).toBe(false);
191
+ return;
192
+ }
193
+ expect(r.output).toContain("SUCCESS");
194
+ expect(r.ok).toBe(true);
195
+ });
196
+ });
197
+
198
+ describe("sloMetrics", () => {
199
+ const slo = Slo(orderAck);
200
+
201
+ test("names every recorded series, the thresholds and the groups, for dashboards", () => {
202
+ const m = sloMetrics(slo);
203
+ expect(m).toMatchObject({
204
+ name: "order-acknowledged",
205
+ objective: 0.995,
206
+ errorBudget: 0.005,
207
+ window: "28d",
208
+ labels: { slo: "order-acknowledged" },
209
+ selector: '{slo="order-acknowledged"}',
210
+ windows: ["5m", "30m", "1h", "2h", "6h", "1d", "3d", "28d"],
211
+ windowErrorRatio: "slo:sli_error:ratio_rate28d",
212
+ errorBudgetRemaining: "slo:error_budget:remaining",
213
+ objectiveRatio: "slo:objective:ratio",
214
+ alertName: "ErrorBudgetBurn",
215
+ group: "slo-order-acknowledged",
216
+ });
217
+ expect(m.errorRatio["1h"]).toBe("slo:sli_error:ratio_rate1h");
218
+ expect(m.burnRates[0]).toEqual({
219
+ tier: "page",
220
+ severity: "page",
221
+ long: "1h",
222
+ short: "5m",
223
+ factor: 13.44,
224
+ threshold: 0.0672,
225
+ longRecord: "slo:sli_error:ratio_rate1h",
226
+ shortRecord: "slo:sli_error:ratio_rate5m",
227
+ alert: "ErrorBudgetBurn",
228
+ labels: { slo: "order-acknowledged", severity: "page", long_window: "1h", short_window: "5m" },
229
+ exhaustsIn: "2d2h",
230
+ });
231
+ });
232
+
233
+ test("every name it returns is recorded by the rules, so a dashboard never reads a missing series", () => {
234
+ const m = sloMetrics(slo);
235
+ const recorded = new Set(recordings(slo).map((r) => r.record));
236
+ for (const name of [...Object.values(m.errorRatio), m.windowErrorRatio, m.errorBudgetRemaining, m.objectiveRatio]) {
237
+ expect(recorded.has(name), name).toBe(true);
238
+ }
239
+ const alerts = alertsOf(slo);
240
+ m.burnRates.forEach((b, i) => {
241
+ expect(alerts[i].labels).toEqual(b.labels);
242
+ expect(alerts[i].expr).toContain(`${b.longRecord}${m.selector} >`);
243
+ });
244
+ });
245
+
246
+ test("reads the same from the instance, its group, or the props", () => {
247
+ expect(sloMetrics(slo.rules)).toEqual(sloMetrics(slo));
248
+ expect(sloMetrics(orderAck)).toEqual(sloMetrics(slo));
249
+ });
250
+
251
+ test("returns a copy, so a caller cannot change what the next caller reads", () => {
252
+ const m = sloMetrics(slo);
253
+ m.errorRatio["1h"] = "changed";
254
+ expect(sloMetrics(slo).errorRatio["1h"]).toBe("slo:sli_error:ratio_rate1h");
255
+ });
256
+ });
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Prometheus lexicon composites: declarations that expand to rule groups.
3
+ */
4
+
5
+ export {
6
+ Slo,
7
+ sloMetrics,
8
+ sloPropsProblem,
9
+ sliExprProblem,
10
+ DEFAULT_BURN_RATES,
11
+ SLO_WINDOW_PLACEHOLDER,
12
+ } from "./slo";
13
+ export type {
14
+ SloProps,
15
+ SloSli,
16
+ SloAlerting,
17
+ SloAlertTier,
18
+ BurnRateWindow,
19
+ SloMembers,
20
+ SloInstance,
21
+ SloMetrics,
22
+ SloBurnRate,
23
+ } from "./slo";
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Burn-rate alerts over synthetic series: each window pair fires at its
3
+ * burn rate and not below it.
4
+ *
5
+ * A scenario is a request counter split into good (`code="200"`) and bad
6
+ * (`code="500"`) series, healthy for as long as the pair's long window and
7
+ * then failing at a constant multiple of the error budget. The rule file an
8
+ * `Slo` builds is evaluated over it step by step:
9
+ *
10
+ * - by the small evaluator in `rule-eval.ts`, which always runs, and
11
+ * - by `promtool test rules`, when promtool is on PATH (or named by
12
+ * $PROMTOOL), against expectations the evaluator produced, so the two
13
+ * engines are held to the same answer.
14
+ */
15
+ import { describe, expect, test } from "vitest";
16
+ import { dump } from "js-yaml";
17
+ import { Slo, sloMetrics, type SloBurnRate, type SloProps } from "./slo";
18
+ import { ruleGroupConfig } from "../rules";
19
+ import { ruleFileYaml } from "../build";
20
+ import { RuleEvaluator, type FiringAlert } from "../rule-eval";
21
+ import { durationMs } from "../duration";
22
+ import { hasTool, promtoolTestRules } from "../tools";
23
+ import type { AlertingRuleConfig } from "../model";
24
+
25
+ const PROMTOOL = process.env.PROMTOOL ?? "promtool";
26
+ const hasPromtool = hasTool(PROMTOOL);
27
+ const MIN = 60_000;
28
+ /** Requests per minute. */
29
+ const RATE = 1000;
30
+
31
+ function props(window: string): SloProps {
32
+ return {
33
+ name: "checkout",
34
+ objective: 0.995,
35
+ window,
36
+ sli: {
37
+ good: 'sum(rate(requests_total{code!~"5.."}[{{window}}]))',
38
+ total: "sum(rate(requests_total[{{window}}]))",
39
+ },
40
+ };
41
+ }
42
+
43
+ interface Scenario {
44
+ /** Sample and evaluation interval, in minutes. */
45
+ step: number;
46
+ /** Healthy minutes before the burn starts. */
47
+ healthy: number;
48
+ /** Minutes of burn. */
49
+ burning: number;
50
+ /** Minutes of health after the burn. */
51
+ recovered?: number;
52
+ /** The burn rate: a multiple of the error budget. */
53
+ burn: number;
54
+ }
55
+
56
+ /** Bad requests in the minute ending at `minute`. */
57
+ function badPerStep(s: Scenario, stepEnd: number, budget: number): number {
58
+ const burning = stepEnd > s.healthy && stepEnd <= s.healthy + s.burning;
59
+ return burning ? RATE * s.step * s.burn * budget : 0;
60
+ }
61
+
62
+ /** Firing alerts for one pair's labels at each evaluation, by minute. */
63
+ function run(slo: ReturnType<typeof Slo>, s: Scenario): Map<number, FiringAlert[]> {
64
+ const m = sloMetrics(slo);
65
+ const ev = new RuleEvaluator([ruleGroupConfig(slo.rules)]);
66
+ const total = s.healthy + s.burning + (s.recovered ?? 0);
67
+ let good = 0;
68
+ let bad = 0;
69
+ const out = new Map<number, FiringAlert[]>();
70
+ for (let minute = 0; minute <= total; minute += s.step) {
71
+ if (minute > 0) {
72
+ const b = badPerStep(s, minute, m.errorBudget);
73
+ bad += b;
74
+ good += RATE * s.step - b;
75
+ }
76
+ ev.add({ __name__: "requests_total", code: "200" }, minute * MIN, good);
77
+ ev.add({ __name__: "requests_total", code: "500" }, minute * MIN, bad);
78
+ out.set(minute, ev.step(minute * MIN));
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function isPair(a: FiringAlert, b: SloBurnRate): boolean {
84
+ return a.labels.alertname === b.alert && a.labels.long_window === b.long && a.labels.short_window === b.short;
85
+ }
86
+
87
+ /** The first minute the pair fires, or undefined. */
88
+ function firstFiring(timeline: Map<number, FiringAlert[]>, b: SloBurnRate, from = 0): number | undefined {
89
+ for (const [minute, alerts] of timeline) if (minute >= from && alerts.some((a) => isPair(a, b))) return minute;
90
+ return undefined;
91
+ }
92
+
93
+ const minutes = (d: string) => durationMs(d)! / MIN;
94
+
95
+ describe("each pair fires at its burn rate and not below it (rule-eval)", () => {
96
+ for (const window of ["30d", "28d"]) {
97
+ const slo = Slo(props(window));
98
+ const m = sloMetrics(slo);
99
+ for (const b of m.burnRates) {
100
+ const long = minutes(b.long);
101
+ const short = minutes(b.short);
102
+ // Five-minute steps for the ticket pairs keep days of samples quick.
103
+ const step = long >= 24 * 60 ? 5 : 1;
104
+
105
+ test(`${window} ${b.severity} ${b.long}/${b.short}: fires once the long window has burnt ${b.factor}x, then stops within the short window`, () => {
106
+ const s: Scenario = { step, healthy: long, burning: long, recovered: long, burn: b.factor * 1.25 };
107
+ const timeline = run(slo, s);
108
+ const fired = firstFiring(timeline, b);
109
+ // The long window's error ratio climbs linearly from the onset and crosses
110
+ // the threshold after long * factor / burn of it.
111
+ const expected = s.healthy + ((long - step) * b.factor) / s.burn;
112
+ expect(fired, "never fired").toBeDefined();
113
+ expect(Math.abs(fired! - expected)).toBeLessThanOrEqual(2 * step);
114
+ // Nothing before the onset.
115
+ expect(firstFiring(timeline, b)).toBeGreaterThan(s.healthy);
116
+ // Still firing at the end of the burn.
117
+ expect(timeline.get(s.healthy + s.burning)!.some((a) => isPair(a, b))).toBe(true);
118
+ // After the burn stops the long window is still above the threshold, but
119
+ // the short window drops below it, so the alert resets.
120
+ let stopped: number | undefined;
121
+ for (const [minute, alerts] of timeline) {
122
+ if (minute > s.healthy + s.burning && !alerts.some((a) => isPair(a, b))) {
123
+ stopped = minute;
124
+ break;
125
+ }
126
+ }
127
+ expect(stopped, "kept firing").toBeDefined();
128
+ expect(stopped! - (s.healthy + s.burning)).toBeLessThanOrEqual(short + step);
129
+ });
130
+
131
+ test(`${window} ${b.severity} ${b.long}/${b.short}: never fires at 0.9x its burn rate`, () => {
132
+ const s: Scenario = { step, healthy: long, burning: 2 * long, burn: b.factor * 0.9 };
133
+ const timeline = run(slo, s);
134
+ expect(firstFiring(timeline, b)).toBeUndefined();
135
+ });
136
+ }
137
+ }
138
+
139
+ test("a 28-day SLO pages at 14x, where a 30-day one does not: the factors scale with the window", () => {
140
+ for (const [window, fires] of [["28d", true], ["30d", false]] as const) {
141
+ const slo = Slo(props(window));
142
+ const b = sloMetrics(slo).burnRates[0];
143
+ const timeline = run(slo, { step: 1, healthy: 60, burning: 120, burn: 14 });
144
+ expect(firstFiring(timeline, b) !== undefined, window).toBe(fires);
145
+ }
146
+ });
147
+
148
+ test("error budget remaining is 1 minus the burn rate after a whole window of steady burn", () => {
149
+ const slo = Slo({ ...props("1d"), alerting: { page: { burnRates: [{ long: "1h", short: "5m", factor: 14.4 }] }, ticket: false } });
150
+ const m = sloMetrics(slo);
151
+ const ev = new RuleEvaluator([ruleGroupConfig(slo.rules)]);
152
+ let good = 0;
153
+ let bad = 0;
154
+ const burn = 0.5;
155
+ for (let minute = 0; minute <= 26 * 60; minute++) {
156
+ if (minute > 0) {
157
+ bad += RATE * burn * m.errorBudget;
158
+ good += RATE * (1 - burn * m.errorBudget);
159
+ }
160
+ ev.add({ __name__: "requests_total", code: "200" }, minute * MIN, good);
161
+ ev.add({ __name__: "requests_total", code: "500" }, minute * MIN, bad);
162
+ ev.step(minute * MIN);
163
+ }
164
+ const [remaining] = ev.query(`${m.errorBudgetRemaining}${m.selector}`, 26 * 60 * MIN);
165
+ expect(remaining.value).toBeCloseTo(1 - burn, 6);
166
+ const [objective] = ev.query(`${m.objectiveRatio}${m.selector}`, 26 * 60 * MIN);
167
+ expect(objective.value).toBe(0.995);
168
+ });
169
+ });
170
+
171
+ /** `a+bxn` segments for one counter, sample by sample. */
172
+ function seriesValues(values: number[]): string {
173
+ // Written out as increments per segment of equal steps.
174
+ const parts: string[] = [];
175
+ let i = 0;
176
+ while (i < values.length) {
177
+ const start = values[i];
178
+ if (i + 1 >= values.length) {
179
+ parts.push(`${start}`);
180
+ break;
181
+ }
182
+ const inc = values[i + 1] - values[i];
183
+ let j = i + 1;
184
+ while (j + 1 < values.length && Math.abs(values[j + 1] - values[j] - inc) < 1e-9) j++;
185
+ parts.push(`${start}+${inc}x${j - i}`);
186
+ i = j + 1;
187
+ }
188
+ return parts.join(" ");
189
+ }
190
+
191
+ describe.skipIf(!hasPromtool)("each pair fires at its burn rate and not below it (promtool test rules)", () => {
192
+ const slo = Slo(props("30d"));
193
+ const m = sloMetrics(slo);
194
+ const alertRules = ruleGroupConfig(slo.rules).rules.filter((r): r is AlertingRuleConfig => "alert" in r);
195
+ const rulesYaml = ruleFileYaml([slo.rules]);
196
+
197
+ function testGroup(s: Scenario, checkAt: number[]) {
198
+ const good: number[] = [];
199
+ const bad: number[] = [];
200
+ let g = 0;
201
+ let e = 0;
202
+ const total = s.healthy + s.burning + (s.recovered ?? 0);
203
+ for (let minute = 0; minute <= total; minute += s.step) {
204
+ if (minute > 0) {
205
+ const b = badPerStep(s, minute, m.errorBudget);
206
+ e += b;
207
+ g += RATE * s.step - b;
208
+ }
209
+ good.push(g);
210
+ bad.push(e);
211
+ }
212
+ const timeline = run(slo, s);
213
+ return {
214
+ timeline,
215
+ group: {
216
+ interval: `${s.step}m`,
217
+ input_series: [
218
+ { series: 'requests_total{code="200"}', values: seriesValues(good) },
219
+ { series: 'requests_total{code="500"}', values: seriesValues(bad) },
220
+ ],
221
+ alert_rule_test: checkAt.map((minute) => ({
222
+ eval_time: `${minute}m`,
223
+ alertname: m.alertName,
224
+ exp_alerts: (timeline.get(minute) ?? []).map((a) => {
225
+ const rule = alertRules.find((r) => r.labels!.long_window === a.labels.long_window && r.labels!.short_window === a.labels.short_window)!;
226
+ const { alertname: _, ...labels } = a.labels;
227
+ return { exp_labels: labels, exp_annotations: rule.annotations };
228
+ }),
229
+ })),
230
+ },
231
+ };
232
+ }
233
+
234
+ for (const b of m.burnRates) {
235
+ const long = minutes(b.long);
236
+ const step = long >= 24 * 60 ? 5 : 1;
237
+ test(`${b.severity} ${b.long}/${b.short}`, () => {
238
+ const above: Scenario = { step, healthy: long, burning: long, burn: b.factor * 1.25 };
239
+ const fireAt = above.healthy + ((long - step) * b.factor) / above.burn;
240
+ const snap = (x: number) => Math.round(x / step) * step;
241
+ const aboveCheck = [snap(fireAt - 4 * step), snap(fireAt + 4 * step), above.healthy + long];
242
+ const upper = testGroup(above, aboveCheck);
243
+ // The evaluator's own claims, which promtool then confirms.
244
+ expect(upper.timeline.get(aboveCheck[0])!.some((a) => isPair(a, b))).toBe(false);
245
+ expect(upper.timeline.get(aboveCheck[1])!.some((a) => isPair(a, b))).toBe(true);
246
+ expect(upper.timeline.get(aboveCheck[2])!.some((a) => isPair(a, b))).toBe(true);
247
+
248
+ const below: Scenario = { step, healthy: long, burning: long, burn: b.factor * 0.9 };
249
+ const lower = testGroup(below, [below.healthy + long]);
250
+ expect(lower.timeline.get(below.healthy + long)!.some((a) => isPair(a, b))).toBe(false);
251
+
252
+ const testYaml = dump({ rule_files: ["rules.yml"], evaluation_interval: `${step}m`, tests: [upper.group, lower.group] }, { lineWidth: -1 });
253
+ const r = promtoolTestRules(rulesYaml, testYaml, PROMTOOL);
254
+ expect(r.ran).toBe(true);
255
+ expect(r.ok, r.output).toBe(true);
256
+ }, 120_000);
257
+ }
258
+ });
259
+
260
+ describe("rule-eval", () => {
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]))" }] }]);
263
+ expect(() => ev.step(0)).toThrow(/not supported|needs a range/);
264
+ });
265
+
266
+ test("evaluates sums, ratios and set operators the way Prometheus does", () => {
267
+ const ev = new RuleEvaluator([
268
+ {
269
+ name: "g",
270
+ labels: { team: "a" },
271
+ rules: [
272
+ { record: "job:req:rate1m", expr: "sum by (job) (rate(req_total[2m]))" },
273
+ { alert: "High", expr: 'job:req:rate1m > 1 and on (job) job:req:rate1m{job="api"}', labels: { severity: "page" } },
274
+ ],
275
+ },
276
+ ]);
277
+ for (let minute = 0; minute <= 2; minute++) {
278
+ ev.add({ __name__: "req_total", job: "api", i: "1" }, minute * MIN, minute * 60 * 3);
279
+ ev.add({ __name__: "req_total", job: "api", i: "2" }, minute * MIN, minute * 60);
280
+ ev.add({ __name__: "req_total", job: "web", i: "1" }, minute * MIN, minute * 60 * 5);
281
+ }
282
+ const firing = ev.step(2 * MIN);
283
+ expect(ev.query("job:req:rate1m", 2 * MIN).map((e) => [e.labels.job, e.value])).toEqual([
284
+ ["api", 4],
285
+ ["web", 5],
286
+ ]);
287
+ expect(firing).toEqual([{ labels: { job: "api", team: "a", severity: "page", alertname: "High" }, value: 4 }]);
288
+ });
289
+ });