@intentius/chant-lexicon-prometheus 0.124.31 → 0.124.33

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 (114) hide show
  1. package/dist/alertmanager.js +61 -0
  2. package/dist/api/alertmanager.js +98 -0
  3. package/dist/api/apply.js +306 -0
  4. package/dist/api/bind.js +66 -0
  5. package/dist/api/client.js +110 -0
  6. package/dist/api/evaluated.js +86 -0
  7. package/dist/api/fake-servers.js +165 -0
  8. package/dist/api/index.js +17 -0
  9. package/dist/api/ruler.js +122 -0
  10. package/dist/build.js +240 -0
  11. package/dist/catalog.js +66 -0
  12. package/dist/codegen/docs-cli.js +3 -0
  13. package/dist/codegen/docs.js +105 -0
  14. package/dist/codegen/generate-cli.js +7 -0
  15. package/dist/codegen/generate.js +42 -0
  16. package/dist/codegen/package.js +28 -0
  17. package/dist/collector-metrics.js +225 -0
  18. package/dist/composites/alert-routing.js +210 -0
  19. package/dist/composites/catalog.js +344 -0
  20. package/dist/composites/genai.js +454 -0
  21. package/dist/composites/index.js +8 -0
  22. package/dist/composites/red-alerts.js +135 -0
  23. package/dist/composites/slo.js +353 -0
  24. package/dist/composites/watchdog.js +110 -0
  25. package/dist/config-model.js +52 -0
  26. package/dist/config.js +225 -0
  27. package/dist/describe-resources.js +299 -0
  28. package/dist/detect.js +12 -0
  29. package/dist/duration.js +54 -0
  30. package/dist/export-resources.js +163 -0
  31. package/dist/import/alertmanager-live.js +309 -0
  32. package/dist/import/embedded.js +100 -0
  33. package/dist/import/generator.js +683 -0
  34. package/dist/import/parser.js +428 -0
  35. package/dist/import/slo.js +207 -0
  36. package/dist/index.js +33 -0
  37. package/dist/init-templates.js +119 -0
  38. package/dist/integrations.js +33 -0
  39. package/dist/integrity.json +2 -2
  40. package/dist/lint/audit-catalog.js +60 -0
  41. package/dist/lint/audit-lineage.js +42 -0
  42. package/dist/lint/post-synth/index.js +68 -0
  43. package/dist/lint/post-synth/prom-helpers.js +157 -0
  44. package/dist/lint/post-synth/prom101.js +13 -0
  45. package/dist/lint/post-synth/prom102.js +13 -0
  46. package/dist/lint/post-synth/prom103.js +13 -0
  47. package/dist/lint/post-synth/prom104.js +13 -0
  48. package/dist/lint/post-synth/prom105.js +13 -0
  49. package/dist/lint/post-synth/prom106.js +13 -0
  50. package/dist/lint/post-synth/prom107.js +13 -0
  51. package/dist/lint/post-synth/prom201.js +13 -0
  52. package/dist/lint/post-synth/prom202.js +13 -0
  53. package/dist/lint/post-synth/prom203.js +13 -0
  54. package/dist/lint/post-synth/prom204.js +13 -0
  55. package/dist/lint/post-synth/prom205.js +13 -0
  56. package/dist/lint/post-synth/prom206.js +13 -0
  57. package/dist/lint/post-synth/prom207.js +13 -0
  58. package/dist/lint/post-synth/prom208.js +13 -0
  59. package/dist/lint/post-synth/prom209.js +13 -0
  60. package/dist/lint/post-synth/prom210.js +13 -0
  61. package/dist/lint/post-synth/prom211.js +17 -0
  62. package/dist/lint/post-synth/prom212.js +14 -0
  63. package/dist/lint/post-synth/prom213.js +16 -0
  64. package/dist/lint/post-synth/prom214.js +14 -0
  65. package/dist/lint/post-synth/prom215.js +14 -0
  66. package/dist/lint/post-synth/prom216.js +15 -0
  67. package/dist/lint/post-synth/prom217.js +14 -0
  68. package/dist/lint/post-synth/prom218.js +15 -0
  69. package/dist/lint/post-synth/prom219.js +14 -0
  70. package/dist/lint/post-synth/prom220.js +14 -0
  71. package/dist/lint/post-synth/prom221.js +15 -0
  72. package/dist/lint/post-synth/prom222.js +14 -0
  73. package/dist/lint/post-synth/prom223.js +15 -0
  74. package/dist/lint/post-synth/prom224.js +14 -0
  75. package/dist/lint/post-synth/prom225.js +15 -0
  76. package/dist/lint/post-synth/prom301.js +19 -0
  77. package/dist/lint/rules/index.js +8 -0
  78. package/dist/lint/rules/literal-credential.js +127 -0
  79. package/dist/lint/rules/prom-ast.js +65 -0
  80. package/dist/lint/rules/promql-literal.js +65 -0
  81. package/dist/lint/rules/slo-literal.js +79 -0
  82. package/dist/lsp/completions.js +11 -0
  83. package/dist/lsp/hover.js +21 -0
  84. package/dist/manifest.json +1 -1
  85. package/dist/matchers.js +131 -0
  86. package/dist/model.js +108 -0
  87. package/dist/op/activities/index.js +20 -0
  88. package/dist/op/activities/promtool.js +87 -0
  89. package/dist/op/activities/rule-audit.js +128 -0
  90. package/dist/op/activities/rules-loaded.js +73 -0
  91. package/dist/op/activities/silence.js +123 -0
  92. package/dist/op/activity-contracts.js +51 -0
  93. package/dist/op/audit-op.js +28 -0
  94. package/dist/op/builders.js +57 -0
  95. package/dist/op/slo-rule-tests.js +148 -0
  96. package/dist/package-cli.js +14 -0
  97. package/dist/pin.js +13 -0
  98. package/dist/plugin.js +143 -0
  99. package/dist/prometheus-config.js +38 -0
  100. package/dist/promql-analysis.js +343 -0
  101. package/dist/promql.js +44 -0
  102. package/dist/rule-eval.js +466 -0
  103. package/dist/rules.js +97 -0
  104. package/dist/serializer.js +59 -0
  105. package/dist/skill-defs.js +41 -0
  106. package/dist/tools.js +85 -0
  107. package/dist/validate-cli.js +7 -0
  108. package/dist/validate-config.js +556 -0
  109. package/dist/validate-integrations.js +388 -0
  110. package/dist/validate-relabel.js +52 -0
  111. package/dist/validate-security.js +135 -0
  112. package/dist/validate.js +69 -0
  113. package/package.json +10 -10
  114. package/src/codegen/package.ts +2 -2
@@ -0,0 +1,353 @@
1
+ /**
2
+ * `Slo`: a service level objective that builds to Prometheus rules.
3
+ *
4
+ * One declaration expands to one `RuleGroup`, `slo-<name>`, holding:
5
+ *
6
+ * - recording rules: the SLI's error ratio over every window the alerts read
7
+ * (`slo:sli_error:ratio_rate<window>`), the error ratio over the whole SLO
8
+ * window, the objective (`slo:objective:ratio`) and the share of the error
9
+ * budget left (`slo:error_budget:remaining`). Every series carries an
10
+ * `slo` label with the SLO's name.
11
+ * - multiwindow, multi-burn-rate alerts from the Google SRE Workbook
12
+ * ("Alerting on SLOs", alert 6). Each alert pairs a long window with a
13
+ * short one and fires while both burn the error budget faster than the
14
+ * pair's factor: the long window keeps a short spike from paging, the
15
+ * short window stops the alert soon after the burn does.
16
+ *
17
+ * An SLI that sees no events in a window (a sparse one: a job that runs a few
18
+ * times a day) records no ratio for that window, where good/total would be
19
+ * 0/0 and record NaN. The SLO window's ratio is an average of the shortest
20
+ * recorded ratio, and one NaN in a range keeps `avg_over_time` NaN for the
21
+ * whole window, so the idle windows are left out and the average covers only
22
+ * the windows that had events. The SLI expressions are the caller's: `rate()`
23
+ * over a counter that first appears inside the range sees one sample and
24
+ * counts nothing, so a sparse SLI should count events as an increase that
25
+ * includes a series' first sample (`eventCount`, see the SLOs page).
26
+ *
27
+ * Both kinds share a group because Prometheus evaluates a group's rules in
28
+ * order: the alerts read the ratios recorded in the same evaluation. In
29
+ * separate groups they would read the previous evaluation's, and with an
30
+ * evaluation interval of 5m (the lookback) not at all.
31
+ *
32
+ * The Workbook's factors (14.4, 6, 3 and 1) assume a 30-day SLO window: each
33
+ * is the share of the budget a pair may burn over its long window, times the
34
+ * SLO window, over the long window (2% of the budget in 1h is
35
+ * 0.02 * 720h / 1h = 14.4). For any other window the lexicon keeps the
36
+ * windows and budget shares and recomputes the factor the same way, so a
37
+ * 28-day SLO pages at 0.02 * 672h / 1h = 13.44.
38
+ *
39
+ * `sloMetrics()` returns the recorded series names, the objective, the
40
+ * window and the burn-rate thresholds, so a dashboard or another rule reads
41
+ * them from the declaration instead of repeating them.
42
+ */
43
+ import { Composite } from "@intentius/chant/composite";
44
+ import { RuleGroup } from "../rules.js";
45
+ import { durationMs, formatDuration, isValidDuration } from "../duration.js";
46
+ import { checkPromql } from "../promql.js";
47
+ /** The placeholder an SLI expression writes where its range goes, e.g. `[{{window}}]`. */
48
+ export const SLO_WINDOW_PLACEHOLDER = "{{window}}";
49
+ /** The Workbook's pairs ("Alerting on SLOs", table 5-8), as shares of the budget. */
50
+ export const DEFAULT_BURN_RATES = Object.freeze({
51
+ page: Object.freeze([
52
+ Object.freeze({ long: "1h", short: "5m", budgetConsumed: 0.02 }),
53
+ Object.freeze({ long: "6h", short: "30m", budgetConsumed: 0.05 }),
54
+ ]),
55
+ ticket: Object.freeze([
56
+ Object.freeze({ long: "1d", short: "2h", budgetConsumed: 0.1 }),
57
+ Object.freeze({ long: "3d", short: "6h", budgetConsumed: 0.1 }),
58
+ ]),
59
+ });
60
+ const DEFAULT_ALERT_NAME = "ErrorBudgetBurn";
61
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
62
+ const ALERT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
63
+ const SLO_METRICS = Symbol.for("chant.prometheus.slo");
64
+ /** Record names. */
65
+ const errorRatioRecord = (window) => `slo:sli_error:ratio_rate${window}`;
66
+ const ERROR_BUDGET_REMAINING = "slo:error_budget:remaining";
67
+ const OBJECTIVE_RATIO = "slo:objective:ratio";
68
+ /** A number as PromQL writes it, without float noise: `1 - 0.995` is `0.005`. */
69
+ function num(n) {
70
+ return String(Number(n.toPrecision(10)));
71
+ }
72
+ function fail(name, message) {
73
+ throw new Error(`Slo "${name}": ${message}`);
74
+ }
75
+ /** Check the props and work out every pair's factor; throws on anything the rules could not be built from. */
76
+ function resolve(props) {
77
+ const name = typeof props?.name === "string" ? props.name : "";
78
+ if (!NAME.test(name))
79
+ fail(name, "name must be letters, digits, '.', '_' or '-', starting with a letter or digit");
80
+ const problem = sloPropsProblem(props);
81
+ if (problem)
82
+ fail(name, problem);
83
+ const windowMs = durationMs(props.window);
84
+ // Rounded, so 1 - 0.995 is 0.005 and not 0.0050000000000000044.
85
+ const budget = Number((1 - props.objective).toPrecision(12));
86
+ const alerting = props.alerting ?? {};
87
+ const alertName = alerting.alertName ?? DEFAULT_ALERT_NAME;
88
+ if (!ALERT_NAME.test(alertName))
89
+ fail(name, `alertName "${alertName}" is not a valid alert name`);
90
+ const pairs = [];
91
+ for (const tier of ["page", "ticket"]) {
92
+ const tierProps = alerting[tier];
93
+ if (tierProps === false)
94
+ continue;
95
+ const t = tierProps ?? {};
96
+ if (t.for !== undefined && !isValidDuration(t.for))
97
+ fail(name, `alerting.${tier}.for "${t.for}" is not a Prometheus duration`);
98
+ const windows = t.burnRates === undefined || t.burnRates === "default" ? DEFAULT_BURN_RATES[tier] : t.burnRates;
99
+ if (!Array.isArray(windows) || windows.length === 0)
100
+ fail(name, `alerting.${tier}.burnRates must be "default" or a non-empty list`);
101
+ windows.forEach((w, i) => {
102
+ const at = `alerting.${tier}.burnRates[${i}]`;
103
+ const longMs = isValidDuration(w.long) ? durationMs(w.long) : 0;
104
+ const shortMs = isValidDuration(w.short) ? durationMs(w.short) : 0;
105
+ if (longMs <= 0)
106
+ fail(name, `${at}.long "${w.long}" is not a positive Prometheus duration`);
107
+ if (shortMs <= 0)
108
+ fail(name, `${at}.short "${w.short}" is not a positive Prometheus duration`);
109
+ if (shortMs >= longMs)
110
+ fail(name, `${at}: the short window ${w.short} must be shorter than the long window ${w.long}`);
111
+ if (longMs > windowMs)
112
+ fail(name, `${at}: the long window ${w.long} is longer than the SLO window ${props.window}`);
113
+ const hasShare = w.budgetConsumed !== undefined;
114
+ const hasFactor = w.factor !== undefined;
115
+ if (hasShare === hasFactor)
116
+ fail(name, `${at} must set exactly one of budgetConsumed and factor`);
117
+ let factor;
118
+ if (hasShare) {
119
+ const s = w.budgetConsumed;
120
+ if (!(typeof s === "number" && s > 0 && s <= 1))
121
+ fail(name, `${at}.budgetConsumed must be above 0 and at most 1, got ${s}`);
122
+ factor = Number(((s * windowMs) / longMs).toPrecision(6));
123
+ }
124
+ else {
125
+ factor = w.factor;
126
+ if (!(typeof factor === "number" && Number.isFinite(factor) && factor > 0))
127
+ fail(name, `${at}.factor must be a positive number, got ${factor}`);
128
+ }
129
+ // An error ratio can't exceed 1, so a threshold at or above 1 never fires.
130
+ if (factor * budget >= 1) {
131
+ fail(name, `${at}: a burn rate of ${factor} on a budget of ${num(budget)} needs an error ratio of ${num(factor * budget)}, which can never happen`);
132
+ }
133
+ pairs.push({ tier, tierProps: t, long: w.long, short: w.short, factor });
134
+ });
135
+ }
136
+ const seen = new Set();
137
+ for (const p of pairs) {
138
+ const key = `${p.tier} ${p.long} ${p.short}`;
139
+ if (seen.has(key))
140
+ fail(name, `alerting.${p.tier} lists the pair ${p.long}/${p.short} twice`);
141
+ seen.add(key);
142
+ }
143
+ const byMs = new Map();
144
+ for (const p of pairs) {
145
+ for (const w of [p.long, p.short]) {
146
+ const ms = durationMs(w);
147
+ if (!byMs.has(ms))
148
+ byMs.set(ms, w);
149
+ }
150
+ }
151
+ byMs.delete(windowMs);
152
+ const windows = [...byMs.entries()].sort((a, b) => a[0] - b[0]).map(([, w]) => w);
153
+ windows.push(props.window);
154
+ return { props, budget, windowMs, pairs, alertName, windows };
155
+ }
156
+ /**
157
+ * What is wrong with an SLO's objective, window or SLI, or `undefined`.
158
+ * `Slo()` throws with this message; the PROM003 lint rule reports it at the
159
+ * literal in the source.
160
+ */
161
+ export function sloPropsProblem(props) {
162
+ const o = props.objective;
163
+ if (typeof o !== "number" || !Number.isFinite(o) || o <= 0 || o >= 1) {
164
+ return `objective must be strictly between 0 and 1 (e.g. 0.995 for 99.5%), got ${String(o)}`;
165
+ }
166
+ if (!isValidDuration(props.window) || durationMs(props.window) === 0) {
167
+ return `window must be a positive Prometheus duration (e.g. 28d or 30d), got ${JSON.stringify(props.window)}`;
168
+ }
169
+ const sli = props.sli;
170
+ if (typeof sli !== "object" || sli === null)
171
+ return "sli must set good and total, or errors and total";
172
+ const hasGood = "good" in sli;
173
+ const hasErrors = "errors" in sli;
174
+ if (hasGood === hasErrors || !("total" in sli))
175
+ return "sli must set good and total, or errors and total";
176
+ for (const key of [hasGood ? "good" : "errors", "total"]) {
177
+ const problem = sliExprProblem(sli[key]);
178
+ if (problem)
179
+ return `sli.${key} ${problem}`;
180
+ }
181
+ return undefined;
182
+ }
183
+ /** What is wrong with one SLI expression, or `undefined`. */
184
+ export function sliExprProblem(expr) {
185
+ if (typeof expr !== "string" || expr.trim() === "")
186
+ return "must be a PromQL expression";
187
+ if (!expr.includes(SLO_WINDOW_PLACEHOLDER)) {
188
+ return `must contain ${SLO_WINDOW_PLACEHOLDER} where the range goes, e.g. rate(x[${SLO_WINDOW_PLACEHOLDER}]), so it can be recorded per window`;
189
+ }
190
+ const checked = checkPromql(withWindow(expr, "5m"));
191
+ if (!checked.ok)
192
+ return `is not valid PromQL once ${SLO_WINDOW_PLACEHOLDER} is filled in: ${checked.message}`;
193
+ return undefined;
194
+ }
195
+ /**
196
+ * The number of events a counter saw over a window, counting a series' first
197
+ * sample. `rate()` and `increase()` need two samples in the range, so a counter
198
+ * that first appears inside the window reads as no events for the first one.
199
+ * This is the increase over the window where the series existed before it
200
+ * (never below 0, so a counter reset counts nothing), and the series' own
201
+ * value where it did not. Use it for `good`, `errors` and `total` of a sparse
202
+ * SLI; `window` defaults to the `{{window}}` placeholder an `Slo` fills in.
203
+ *
204
+ * `selector` is an instant vector selector, e.g. `jobs_total{result="ok"}`. It
205
+ * appears three times, so it must have no range and no `offset` of its own.
206
+ */
207
+ export function eventCount(selector, window = SLO_WINDOW_PLACEHOLDER) {
208
+ const sel = typeof selector === "string" ? selector.trim() : "";
209
+ if (sel === "")
210
+ throw new Error("eventCount: selector must be an instant vector selector, e.g. jobs_total{result=\"ok\"}");
211
+ if (/\boffset\b/.test(sel) || /\[[^\]]*\]\s*$/.test(sel) || /^(sum|rate|increase)\b/.test(sel)) {
212
+ throw new Error(`eventCount: "${sel}" must be a plain instant vector selector, with no range, offset or function`);
213
+ }
214
+ return `sum(clamp_min(${sel} - ${sel} offset ${window}, 0) or (${sel} unless ${sel} offset ${window}))`;
215
+ }
216
+ function withWindow(expr, window) {
217
+ return expr.split(SLO_WINDOW_PLACEHOLDER).join(window);
218
+ }
219
+ function metricsOf(r) {
220
+ const { props, budget, pairs, alertName, windows } = r;
221
+ const errorRatio = {};
222
+ for (const w of windows)
223
+ errorRatio[w] = errorRatioRecord(w);
224
+ const burnRates = pairs.map((p) => ({
225
+ tier: p.tier,
226
+ severity: p.tierProps.severity ?? p.tier,
227
+ long: p.long,
228
+ short: p.short,
229
+ factor: p.factor,
230
+ threshold: Number((p.factor * budget).toPrecision(10)),
231
+ longRecord: errorRatioRecord(p.long),
232
+ shortRecord: errorRatioRecord(p.short),
233
+ alert: alertName,
234
+ labels: { slo: props.name, severity: p.tierProps.severity ?? p.tier, long_window: p.long, short_window: p.short },
235
+ exhaustsIn: formatDuration(Math.round(r.windowMs / p.factor / 60000) * 60000),
236
+ }));
237
+ return {
238
+ name: props.name,
239
+ objective: props.objective,
240
+ errorBudget: budget,
241
+ window: props.window,
242
+ labels: { slo: props.name },
243
+ selector: `{slo="${props.name}"}`,
244
+ windows: [...windows],
245
+ errorRatio,
246
+ windowErrorRatio: errorRatioRecord(props.window),
247
+ errorBudgetRemaining: ERROR_BUDGET_REMAINING,
248
+ objectiveRatio: OBJECTIVE_RATIO,
249
+ burnRates,
250
+ alertName,
251
+ group: `slo-${props.name}`,
252
+ };
253
+ }
254
+ function recordingRules(r, m) {
255
+ const { props } = r;
256
+ const sli = props.sli;
257
+ // A window with no events records nothing: `total > 0` drops the idle
258
+ // window's 0/0 NaN, and the SLO window's average then covers only the
259
+ // windows that had events. The numerator falls back to `0 * total`, so a
260
+ // window whose events were all good (or all bad) still records 0 (or 1)
261
+ // where its own series is absent.
262
+ const ratio = (w) => {
263
+ const total = `(${withWindow(sli.total, w)}) > 0`;
264
+ const some = (expr) => `((${expr}) or 0 * (${total}))`;
265
+ return sli.errors !== undefined
266
+ ? `${some(withWindow(sli.errors, w))}\n/\n(${total})`
267
+ : `1 - (\n ${some(withWindow(sli.good, w))}\n /\n (${total})\n)`;
268
+ };
269
+ const labels = { slo: props.name };
270
+ const rules = [];
271
+ const alertWindows = m.windows.slice(0, -1);
272
+ for (const w of alertWindows)
273
+ rules.push({ record: m.errorRatio[w], expr: ratio(w), labels });
274
+ // The whole window as an average of the shortest recorded ratio, as Sloth
275
+ // does: a range over weeks of raw counters, every evaluation, is the most
276
+ // expensive query a rule file can hold. Without alert windows there is no
277
+ // shorter ratio to average, so the window is read from the SLI directly.
278
+ rules.push({
279
+ record: m.windowErrorRatio,
280
+ expr: alertWindows.length > 0 ? `avg_over_time(${m.errorRatio[alertWindows[0]]}${m.selector}[${props.window}])` : ratio(props.window),
281
+ labels,
282
+ });
283
+ rules.push({ record: OBJECTIVE_RATIO, expr: `vector(${num(props.objective)})`, labels });
284
+ rules.push({ record: ERROR_BUDGET_REMAINING, expr: `1 - (${m.windowErrorRatio}${m.selector} / ${num(r.budget)})`, labels });
285
+ return rules;
286
+ }
287
+ function alertRules(r, m) {
288
+ const { props } = r;
289
+ return r.pairs.map((p, i) => {
290
+ const b = m.burnRates[i];
291
+ const cond = (record) => `${record}${m.selector} > (${num(p.factor)} * ${num(r.budget)})`;
292
+ const rule = {
293
+ alert: b.alert,
294
+ expr: `(\n ${cond(b.longRecord)}\n)\nand\n(\n ${cond(b.shortRecord)}\n)`,
295
+ labels: { ...(p.tierProps.labels ?? {}), ...b.labels },
296
+ annotations: {
297
+ summary: `SLO ${props.name} is burning its error budget more than ${num(p.factor)}x too fast (${p.long} and ${p.short} windows)`,
298
+ description: `${props.description ? `${props.description} ` : ""}The error ratio over both the last ${p.long} and the last ${p.short} is above ` +
299
+ `${num(b.threshold)} (${num(p.factor)} times the ${num(r.budget)} budget of a ${num(props.objective)} objective). ` +
300
+ `At that rate the ${props.window} error budget is gone in ${b.exhaustsIn}.`,
301
+ ...(p.tierProps.annotations ?? {}),
302
+ },
303
+ };
304
+ if (p.tierProps.for !== undefined)
305
+ rule.for = p.tierProps.for;
306
+ return rule;
307
+ });
308
+ }
309
+ /**
310
+ * An SLO, built to recording rules and multiwindow multi-burn-rate alerts.
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * export const orderAck = Slo({
315
+ * name: "order-acknowledged",
316
+ * objective: 0.995,
317
+ * window: "28d",
318
+ * sli: {
319
+ * good: 'sum(rate(traces_span_metrics_calls_total{span_name="order.ack",status_code!="STATUS_CODE_ERROR"}[{{window}}]))',
320
+ * total: 'sum(rate(traces_span_metrics_calls_total{span_name="order.ack"}[{{window}}]))',
321
+ * },
322
+ * });
323
+ * // orderAck.rules is a RuleGroup; sloMetrics(orderAck) names its series.
324
+ * ```
325
+ */
326
+ export const Slo = Composite((props) => {
327
+ const r = resolve(props);
328
+ const m = metricsOf(r);
329
+ const rules = [...recordingRules(r, m), ...alertRules(r, m)];
330
+ const group = new RuleGroup({
331
+ name: m.group,
332
+ ...(props.interval !== undefined ? { interval: props.interval } : {}),
333
+ ...(props.labels !== undefined ? { labels: props.labels } : {}),
334
+ rules,
335
+ });
336
+ Object.defineProperty(group, SLO_METRICS, { value: m, enumerable: false });
337
+ return { rules: group };
338
+ }, "Slo");
339
+ /**
340
+ * The recorded series names, objective, window and burn-rate thresholds of
341
+ * an SLO. Pass the `Slo(...)` result, its rule group, or the props
342
+ * it was built from; a dashboard reads names from here, so renaming or
343
+ * re-windowing the SLO moves its panels with it.
344
+ */
345
+ export function sloMetrics(slo) {
346
+ const stashed = (x) => typeof x === "object" && x !== null ? x[SLO_METRICS] : undefined;
347
+ const direct = stashed(slo) ?? stashed(slo.rules);
348
+ if (direct)
349
+ return structuredClone(direct);
350
+ if (typeof slo === "object" && slo !== null && "objective" in slo && "sli" in slo)
351
+ return metricsOf(resolve(slo));
352
+ throw new Error("sloMetrics: pass an Slo(...) result, its rule group, or SLO props");
353
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `Watchdog`: an alert that always fires, routed to a heartbeat receiver.
3
+ *
4
+ * The alert's expression is `vector(1)`, so it fires from the first rule
5
+ * evaluation for as long as Prometheus evaluates rules and Alertmanager
6
+ * delivers notifications. The route sends it to a heartbeat service
7
+ * (healthchecks.io, Dead Man's Snitch, a PagerDuty or Opsgenie heartbeat),
8
+ * which raises its own alarm when the notifications stop: the whole alerting
9
+ * path, not just Prometheus, is down.
10
+ *
11
+ * The route is a child route, matched on the alert's name and severity. Put
12
+ * it first under the root route: `AlertRouting({ routes: [watchdog.route] })`,
13
+ * or the `routes` of a root `Route` of your own. Left unnested it is a second
14
+ * root, and the build warns.
15
+ *
16
+ * By default the receiver is a webhook that reads its URL from a file
17
+ * (`url_file`), since a heartbeat URL is a credential; mount the file into
18
+ * Alertmanager.
19
+ */
20
+ import { Composite } from "@intentius/chant/composite";
21
+ import { Receiver, Route, isReceiver } from "../alertmanager.js";
22
+ import { RuleGroup } from "../rules.js";
23
+ import { durationMs, isValidDuration } from "../duration.js";
24
+ const ALERT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
25
+ const GROUP_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
26
+ /** The default heartbeat URL file. */
27
+ export const WATCHDOG_URL_FILE = "/etc/alertmanager/secrets/heartbeat-url";
28
+ function fail(message) {
29
+ throw new Error(`Watchdog: ${message}`);
30
+ }
31
+ /**
32
+ * An always-firing alert and the route that sends it to a heartbeat receiver.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { AlertRouting, Watchdog } from "@intentius/chant-lexicon-prometheus";
37
+ *
38
+ * export const watchdog = Watchdog({ urlFile: "/etc/alertmanager/secrets/healthchecks-url" });
39
+ * export const routing = AlertRouting({ routes: [watchdog.route] });
40
+ * ```
41
+ */
42
+ export const Watchdog = Composite((input) => {
43
+ const props = input ?? {};
44
+ const alert = props.alert ?? "Watchdog";
45
+ if (!ALERT_NAME.test(alert))
46
+ fail(`alert "${alert}" is not a valid alert name`);
47
+ const severity = props.severity ?? "none";
48
+ if (typeof severity !== "string" || severity === "")
49
+ fail("severity must be a non-empty string");
50
+ const repeat = props.repeatInterval ?? "1m";
51
+ if (!isValidDuration(repeat) || !durationMs(repeat))
52
+ fail(`repeatInterval "${repeat}" is not a positive duration`);
53
+ // repeat_interval under group_interval is PROM223; a faster heartbeat brings the group interval down with it.
54
+ const groupInterval = (durationMs(repeat) ?? 0) < 60000 ? repeat : "1m";
55
+ const groupName = props.group ?? "watchdog";
56
+ if (!GROUP_NAME.test(groupName))
57
+ fail(`group "${groupName}" must be letters, digits, '.', '_' or '-'`);
58
+ if (props.urlFile !== undefined && props.receiver !== undefined)
59
+ fail("urlFile configures the default receiver; leave it out when receiver is set");
60
+ if (props.urlFile !== undefined && props.urlFile.trim() === "")
61
+ fail("urlFile must be a path");
62
+ let receiver;
63
+ let ref;
64
+ if (typeof props.receiver === "string") {
65
+ if (props.receiver === "")
66
+ fail("receiver must name a receiver");
67
+ ref = props.receiver;
68
+ }
69
+ else if (isReceiver(props.receiver)) {
70
+ ref = props.receiver;
71
+ }
72
+ else {
73
+ const p = props.receiver ?? {
74
+ name: "heartbeat",
75
+ webhook_configs: [{ url_file: props.urlFile ?? WATCHDOG_URL_FILE, send_resolved: false }],
76
+ };
77
+ if (!p.name)
78
+ fail("receiver.name is required");
79
+ receiver = new Receiver(p);
80
+ ref = receiver;
81
+ }
82
+ const rules = new RuleGroup({
83
+ name: groupName,
84
+ rules: [
85
+ {
86
+ alert,
87
+ expr: "vector(1)",
88
+ labels: { ...(props.labels ?? {}), severity },
89
+ annotations: {
90
+ summary: "Always firing, to show the alerting pipeline works",
91
+ description: "This alert fires all the time. A heartbeat service receives it every few minutes and raises its own alarm when it stops arriving, which means Prometheus, Alertmanager or the path between them is down.",
92
+ ...(props.annotations ?? {}),
93
+ },
94
+ },
95
+ ],
96
+ });
97
+ const route = new Route({
98
+ receiver: ref,
99
+ matchers: [`alertname=${JSON.stringify(alert)}`, `severity=${JSON.stringify(severity)}`],
100
+ group_by: ["alertname"],
101
+ group_wait: "0s",
102
+ group_interval: groupInterval,
103
+ repeat_interval: repeat,
104
+ continue: false,
105
+ });
106
+ const members = { rules, route };
107
+ if (receiver)
108
+ members.receiver = receiver;
109
+ return members;
110
+ }, "Watchdog");
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The plain-data model of `prometheus.yml`: global settings, scrape configs,
3
+ * relabelling, service discovery, Alertmanager targets and remote storage.
4
+ *
5
+ * Written by hand against Prometheus v3.15.0 (`config/config.go` and
6
+ * `discovery/*`), field names in the file's own snake_case. SchemaStore's
7
+ * `prometheus.json` is not used to generate these: it is strict about unknown
8
+ * keys and behind v3.15.0 (no `runtime`, `otlp` or `tracing`, 11 of about 20
9
+ * `global` keys, 24 of 32 discovery kinds), so types generated from it would
10
+ * reject configs Prometheus accepts.
11
+ *
12
+ * Six discovery kinds are typed: kubernetes, file, http, dns, ec2 and consul.
13
+ * Any other `<kind>_sd_configs` key is carried as untyped data.
14
+ */
15
+ /** The discovery kinds typed above. */
16
+ export const TYPED_SD_KINDS = Object.freeze([
17
+ "kubernetes_sd_configs",
18
+ "file_sd_configs",
19
+ "http_sd_configs",
20
+ "dns_sd_configs",
21
+ "ec2_sd_configs",
22
+ "consul_sd_configs",
23
+ ]);
24
+ /** Top-level keys of `prometheus.yml`, in the order Prometheus documents them. */
25
+ export const PROMETHEUS_CONFIG_SECTIONS = Object.freeze([
26
+ "global",
27
+ "alerting",
28
+ "rule_files",
29
+ "scrape_config_files",
30
+ "scrape_configs",
31
+ "remote_write",
32
+ "remote_read",
33
+ "otlp",
34
+ "storage",
35
+ "tracing",
36
+ "runtime",
37
+ ]);
38
+ const GLOBAL_ONLY_KEYS = ["scrape_interval", "scrape_timeout", "evaluation_interval", "external_labels", "rule_query_offset", "query_log_file"];
39
+ /** True when a parsed document has the shape of a `prometheus.yml`. */
40
+ export function looksLikePrometheusConfig(value) {
41
+ if (typeof value !== "object" || value === null || Array.isArray(value))
42
+ return false;
43
+ const v = value;
44
+ if ("apiVersion" in v || "kind" in v || "groups" in v || "route" in v || "receivers" in v)
45
+ return false;
46
+ if (PROMETHEUS_CONFIG_SECTIONS.some((k) => k !== "global" && k !== "runtime" && k !== "storage" && k !== "tracing" && k in v)) {
47
+ return true;
48
+ }
49
+ // `global:` alone is Alertmanager's too; only Prometheus's own keys settle it.
50
+ const g = v.global;
51
+ return typeof g === "object" && g !== null && GLOBAL_ONLY_KEYS.some((k) => k in g);
52
+ }