@intentius/chant-lexicon-prometheus 0.106.4 → 0.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/api/alertmanager.d.ts +56 -0
- package/dist/api/alertmanager.d.ts.map +1 -0
- package/dist/api/apply.d.ts +126 -0
- package/dist/api/apply.d.ts.map +1 -0
- package/dist/api/bind.d.ts +49 -0
- package/dist/api/bind.d.ts.map +1 -0
- package/dist/api/client.d.ts +76 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/evaluated.d.ts +36 -0
- package/dist/api/evaluated.d.ts.map +1 -0
- package/dist/api/fake-servers.d.ts +74 -0
- package/dist/api/fake-servers.d.ts.map +1 -0
- package/dist/api/index.d.ts +18 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/ruler.d.ts +87 -0
- package/dist/api/ruler.d.ts.map +1 -0
- package/dist/composites/alert-routing.d.ts +104 -0
- package/dist/composites/alert-routing.d.ts.map +1 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +6 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/red-alerts.d.ts +96 -0
- package/dist/composites/red-alerts.d.ts.map +1 -0
- package/dist/composites/watchdog.d.ts +69 -0
- package/dist/composites/watchdog.d.ts.map +1 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +48 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/export-resources.d.ts +41 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/import/alertmanager-live.d.ts +35 -0
- package/dist/import/alertmanager-live.d.ts.map +1 -0
- package/dist/import/parser.d.ts +12 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/prom-helpers.d.ts +11 -3
- package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +25 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/promtool.d.ts +78 -0
- package/dist/op/activities/promtool.d.ts.map +1 -0
- package/dist/op/activities/rule-audit.d.ts +65 -0
- package/dist/op/activities/rule-audit.d.ts.map +1 -0
- package/dist/op/activities/rules-loaded.d.ts +60 -0
- package/dist/op/activities/rules-loaded.d.ts.map +1 -0
- package/dist/op/activities/silence.d.ts +80 -0
- package/dist/op/activities/silence.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +113 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/audit-op.d.ts +38 -0
- package/dist/op/audit-op.d.ts.map +1 -0
- package/dist/op/builders.d.ts +57 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/op/slo-rule-tests.d.ts +44 -0
- package/dist/op/slo-rule-tests.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/prom-helpers.ts +38 -3
- package/package.json +19 -3
- package/src/api/alertmanager.ts +122 -0
- package/src/api/apply.test.ts +218 -0
- package/src/api/apply.ts +375 -0
- package/src/api/bind.ts +87 -0
- package/src/api/client.ts +138 -0
- package/src/api/evaluated.test.ts +78 -0
- package/src/api/evaluated.ts +106 -0
- package/src/api/fake-servers.ts +209 -0
- package/src/api/index.ts +29 -0
- package/src/api/ruler.ts +168 -0
- package/src/api/testdata/am-status-original.yml +85 -0
- package/src/composites/alert-routing.test.ts +163 -0
- package/src/composites/alert-routing.ts +277 -0
- package/src/composites/catalog.test.ts +10 -2
- package/src/composites/catalog.ts +214 -0
- package/src/composites/index.ts +14 -0
- package/src/composites/red-alerts.test.ts +182 -0
- package/src/composites/red-alerts.ts +214 -0
- package/src/composites/watchdog.test.ts +85 -0
- package/src/composites/watchdog.ts +142 -0
- package/src/config.test.ts +93 -0
- package/src/config.ts +288 -0
- package/src/describe-resources.test.ts +325 -0
- package/src/describe-resources.ts +328 -0
- package/src/export-resources.test.ts +253 -0
- package/src/export-resources.ts +193 -0
- package/src/import/alertmanager-live.test.ts +66 -0
- package/src/import/alertmanager-live.ts +291 -0
- package/src/import/parser.ts +18 -1
- package/src/import/roundtrip.test.ts +1 -0
- package/src/index.ts +37 -0
- package/src/lint/post-synth/prom-helpers.ts +38 -3
- package/src/op/activities/index.ts +31 -0
- package/src/op/activities/promtool.test.ts +107 -0
- package/src/op/activities/promtool.ts +149 -0
- package/src/op/activities/rule-audit.test.ts +89 -0
- package/src/op/activities/rule-audit.ts +168 -0
- package/src/op/activities/rules-loaded.test.ts +61 -0
- package/src/op/activities/rules-loaded.ts +98 -0
- package/src/op/activities/silence.test.ts +107 -0
- package/src/op/activities/silence.ts +175 -0
- package/src/op/activity-contracts.test.ts +141 -0
- package/src/op/activity-contracts.ts +96 -0
- package/src/op/audit-op.ts +54 -0
- package/src/op/builders.ts +82 -0
- package/src/op/slo-rule-tests.test.ts +84 -0
- package/src/op/slo-rule-tests.ts +173 -0
- package/src/plugin.ts +16 -0
package/src/api/ruler.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ruler APIs (#3371): Mimir's, Cortex's and Loki's rule group config
|
|
3
|
+
* API, and the Prometheus-compatible `/api/v1/rules` every one of them (and
|
|
4
|
+
* a plain Prometheus) serves with each group's evaluation state.
|
|
5
|
+
*
|
|
6
|
+
* | Kind | Config API (YAML) | Evaluated rules (JSON) |
|
|
7
|
+
* |---|---|---|
|
|
8
|
+
* | `mimir` | `<prefix>/config/v1/rules[/{namespace}[/{group}]]` | `<prefix>/api/v1/rules` |
|
|
9
|
+
* | `cortex` | `/api/v1/rules[/{namespace}[/{group}]]` | `<prefix>/api/v1/rules` |
|
|
10
|
+
* | `loki` | `/loki/api/v1/rules[/{namespace}[/{group}]]` | `/prometheus/api/v1/rules` |
|
|
11
|
+
* | `prometheus` | none: rule files on disk | `/api/v1/rules` |
|
|
12
|
+
*
|
|
13
|
+
* `<prefix>` is `-http.prometheus-http-prefix`, `/prometheus` unless the
|
|
14
|
+
* profile says otherwise. Mimir does not register Cortex's `/api/v1/rules`
|
|
15
|
+
* config path. Every request carries the target's `X-Scope-OrgID`.
|
|
16
|
+
*
|
|
17
|
+
* The writes (`setGroup`, `deleteGroup`) are here for the ruler apply
|
|
18
|
+
* target (#3372), which reuses this module through
|
|
19
|
+
* `@intentius/chant-lexicon-prometheus/api`. Nothing here deletes a
|
|
20
|
+
* namespace: an apply bounded by the declared namespaces deletes groups one
|
|
21
|
+
* by one, and only inside {@link declaredNamespaces}.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { emitYaml } from "../build";
|
|
25
|
+
import type { RuleGroupConfig } from "../model";
|
|
26
|
+
import { loadPrometheusYaml } from "../import/parser";
|
|
27
|
+
import type { RulerTarget } from "../config";
|
|
28
|
+
import { PromApiError, type PromClient } from "./client";
|
|
29
|
+
|
|
30
|
+
type Json = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
/** A rule group as the ruler's config API returns it: the rule-file shape, plus whatever the ruler adds (`source_tenants`, say). */
|
|
33
|
+
export type RawRuleGroup = Json & { name: string };
|
|
34
|
+
|
|
35
|
+
/** One rule as `/api/v1/rules` reports it. */
|
|
36
|
+
export interface EvaluatedRule {
|
|
37
|
+
type: "alerting" | "recording";
|
|
38
|
+
name: string;
|
|
39
|
+
query: string;
|
|
40
|
+
/** Seconds. Alerting rules only. */
|
|
41
|
+
duration?: number;
|
|
42
|
+
/** Seconds. Alerting rules only. */
|
|
43
|
+
keepFiringFor?: number;
|
|
44
|
+
labels?: Record<string, string>;
|
|
45
|
+
annotations?: Record<string, string>;
|
|
46
|
+
health?: "ok" | "err" | "unknown" | string;
|
|
47
|
+
lastError?: string;
|
|
48
|
+
/** `inactive`, `pending` or `firing`. Alerting rules only. */
|
|
49
|
+
state?: string;
|
|
50
|
+
lastEvaluation?: string;
|
|
51
|
+
evaluationTime?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One group as `/api/v1/rules` reports it. `file` is the rule file on a Prometheus, the namespace on a ruler. */
|
|
55
|
+
export interface EvaluatedGroup {
|
|
56
|
+
name: string;
|
|
57
|
+
file: string;
|
|
58
|
+
/** Seconds. */
|
|
59
|
+
interval?: number;
|
|
60
|
+
limit?: number;
|
|
61
|
+
rules: EvaluatedRule[];
|
|
62
|
+
lastEvaluation?: string;
|
|
63
|
+
evaluationTime?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isObject(v: unknown): v is Json {
|
|
67
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const enc = encodeURIComponent;
|
|
71
|
+
|
|
72
|
+
/** A bound ruler: the transport and the target it reaches. */
|
|
73
|
+
export class RulerApi {
|
|
74
|
+
constructor(
|
|
75
|
+
readonly client: PromClient,
|
|
76
|
+
readonly target: RulerTarget,
|
|
77
|
+
) {}
|
|
78
|
+
|
|
79
|
+
/** The base of the rule group config API, or undefined for a plain Prometheus, which has none. */
|
|
80
|
+
get configPath(): string | undefined {
|
|
81
|
+
switch (this.target.kind) {
|
|
82
|
+
case "mimir":
|
|
83
|
+
return `${this.target.prometheusPrefix}/config/v1/rules`;
|
|
84
|
+
case "cortex":
|
|
85
|
+
return "/api/v1/rules";
|
|
86
|
+
case "loki":
|
|
87
|
+
return "/loki/api/v1/rules";
|
|
88
|
+
case "prometheus":
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The Prometheus-compatible rules endpoint, with evaluation state. */
|
|
94
|
+
get evaluatedPath(): string {
|
|
95
|
+
return `${this.target.prometheusPrefix}/api/v1/rules`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private requireConfigPath(): string {
|
|
99
|
+
const p = this.configPath;
|
|
100
|
+
if (p === undefined) throw new Error(`a plain Prometheus (${this.target.source}) has no rule group API; its groups come from rule files`);
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private parseNamespaces(text: string, path: string): Record<string, RawRuleGroup[]> {
|
|
105
|
+
const doc = loadPrometheusYaml(text);
|
|
106
|
+
if (!isObject(doc)) throw new PromApiError(502, "GET", path, "the answer is not a YAML mapping of namespaces to rule groups");
|
|
107
|
+
const out: Record<string, RawRuleGroup[]> = {};
|
|
108
|
+
for (const [ns, groups] of Object.entries(doc)) {
|
|
109
|
+
out[ns] = (Array.isArray(groups) ? groups : []).filter((g): g is RawRuleGroup => isObject(g) && typeof g.name === "string");
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Every namespace the tenant has, with its groups. A 404 (Mimir: "no rule groups found") is none. */
|
|
115
|
+
async listNamespaces(): Promise<Record<string, RawRuleGroup[]>> {
|
|
116
|
+
const path = this.requireConfigPath();
|
|
117
|
+
const text = await this.client.getText(path);
|
|
118
|
+
return text === undefined ? {} : this.parseNamespaces(text, path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** One namespace's groups; undefined when the ruler has no such namespace. */
|
|
122
|
+
async readNamespace(namespace: string): Promise<RawRuleGroup[] | undefined> {
|
|
123
|
+
const path = `${this.requireConfigPath()}/${enc(namespace)}`;
|
|
124
|
+
const text = await this.client.getText(path);
|
|
125
|
+
if (text === undefined) return undefined;
|
|
126
|
+
return this.parseNamespaces(text, path)[namespace] ?? [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The path {@link readGroup} reads, for `queried`. */
|
|
130
|
+
groupPath(namespace: string, group: string): string {
|
|
131
|
+
return `${this.requireConfigPath()}/${enc(namespace)}/${enc(group)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One group; undefined when it is not there. */
|
|
135
|
+
async readGroup(namespace: string, group: string): Promise<RawRuleGroup | undefined> {
|
|
136
|
+
const path = this.groupPath(namespace, group);
|
|
137
|
+
const text = await this.client.getText(path);
|
|
138
|
+
if (text === undefined) return undefined;
|
|
139
|
+
const doc = loadPrometheusYaml(text);
|
|
140
|
+
if (!isObject(doc) || typeof doc.name !== "string") throw new PromApiError(502, "GET", path, "the answer is not a rule group");
|
|
141
|
+
return doc as RawRuleGroup;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Create or replace one group in `namespace` (202 on Mimir, Cortex and Loki). */
|
|
145
|
+
async setGroup(namespace: string, group: RuleGroupConfig | RawRuleGroup): Promise<void> {
|
|
146
|
+
await this.client.send("POST", `${this.requireConfigPath()}/${enc(namespace)}`, { text: emitYaml(group), contentType: "application/yaml" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Delete one group from `namespace`. */
|
|
150
|
+
async deleteGroup(namespace: string, group: string): Promise<void> {
|
|
151
|
+
await this.client.send("DELETE", this.groupPath(namespace, group));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Every group the ruler evaluates, with its health. Read once per client. */
|
|
155
|
+
async evaluated(): Promise<EvaluatedGroup[]> {
|
|
156
|
+
return this.client.once("evaluated", async () => {
|
|
157
|
+
const path = this.evaluatedPath;
|
|
158
|
+
const body = await this.client.getJson<{ status?: string; data?: { groups?: unknown[] } }>(path);
|
|
159
|
+
if (body === undefined) throw new PromApiError(404, "GET", path, "the rules API is not served here");
|
|
160
|
+
if (body.status !== undefined && body.status !== "success") throw new PromApiError(502, "GET", path, `status ${body.status}`);
|
|
161
|
+
return (body.data?.groups ?? []).filter((g): g is EvaluatedGroup => isObject(g) && typeof g.name === "string").map((g) => ({
|
|
162
|
+
...g,
|
|
163
|
+
file: typeof g.file === "string" ? g.file : "",
|
|
164
|
+
rules: Array.isArray(g.rules) ? g.rules.filter(isObject) as unknown as EvaluatedRule[] : [],
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
global:
|
|
2
|
+
resolve_timeout: 5m
|
|
3
|
+
http_config:
|
|
4
|
+
follow_redirects: true
|
|
5
|
+
enable_http2: true
|
|
6
|
+
smtp_from: am@example.com
|
|
7
|
+
smtp_hello: localhost
|
|
8
|
+
smtp_smarthost: smtp.example.com:587
|
|
9
|
+
smtp_require_tls: true
|
|
10
|
+
smtp_tls_config:
|
|
11
|
+
insecure_skip_verify: false
|
|
12
|
+
pagerduty_url: https://events.pagerduty.com/v2/enqueue
|
|
13
|
+
opsgenie_api_url: https://api.opsgenie.com/
|
|
14
|
+
wechat_api_url: https://qyapi.weixin.qq.com/cgi-bin/
|
|
15
|
+
victorops_api_url: https://alert.victorops.com/integrations/generic/20131114/alert/
|
|
16
|
+
telegram_api_url: https://api.telegram.org
|
|
17
|
+
webex_api_url: https://webexapis.com/v1/messages
|
|
18
|
+
rocketchat_api_url: https://open.rocket.chat/
|
|
19
|
+
slack_app_url: https://slack.com/api/chat.postMessage
|
|
20
|
+
route:
|
|
21
|
+
receiver: team
|
|
22
|
+
group_by:
|
|
23
|
+
- alertname
|
|
24
|
+
continue: false
|
|
25
|
+
routes:
|
|
26
|
+
- receiver: pager
|
|
27
|
+
matchers:
|
|
28
|
+
- severity="page"
|
|
29
|
+
continue: false
|
|
30
|
+
mute_time_intervals:
|
|
31
|
+
- weekends
|
|
32
|
+
inhibit_rules:
|
|
33
|
+
- source_matchers:
|
|
34
|
+
- severity="page"
|
|
35
|
+
target_matchers:
|
|
36
|
+
- severity="ticket"
|
|
37
|
+
equal:
|
|
38
|
+
- alertname
|
|
39
|
+
receivers:
|
|
40
|
+
- name: team
|
|
41
|
+
webhook_configs:
|
|
42
|
+
- send_resolved: true
|
|
43
|
+
http_config:
|
|
44
|
+
follow_redirects: true
|
|
45
|
+
enable_http2: true
|
|
46
|
+
url: <secret>
|
|
47
|
+
url_file: ""
|
|
48
|
+
max_alerts: 0
|
|
49
|
+
timeout: 0
|
|
50
|
+
- name: pager
|
|
51
|
+
email_configs:
|
|
52
|
+
- send_resolved: false
|
|
53
|
+
to: oncall@example.com
|
|
54
|
+
from: am@example.com
|
|
55
|
+
hello: localhost
|
|
56
|
+
smarthost: smtp.example.com:587
|
|
57
|
+
headers:
|
|
58
|
+
Subject: '{{ template "email.default.subject" . }}'
|
|
59
|
+
html: '{{ template "email.default.html" . }}'
|
|
60
|
+
require_tls: false
|
|
61
|
+
tls_config:
|
|
62
|
+
insecure_skip_verify: false
|
|
63
|
+
pagerduty_configs:
|
|
64
|
+
- send_resolved: true
|
|
65
|
+
http_config:
|
|
66
|
+
follow_redirects: true
|
|
67
|
+
enable_http2: true
|
|
68
|
+
routing_key: <secret>
|
|
69
|
+
url: https://events.pagerduty.com/v2/enqueue
|
|
70
|
+
client: '{{ template "pagerduty.default.client" . }}'
|
|
71
|
+
client_url: '{{ template "pagerduty.default.clientURL" . }}'
|
|
72
|
+
description: '{{ template "pagerduty.default.description" .}}'
|
|
73
|
+
details:
|
|
74
|
+
firing: '{{ .Alerts.Firing | toJson }}'
|
|
75
|
+
num_firing: '{{ .Alerts.Firing | len }}'
|
|
76
|
+
num_resolved: '{{ .Alerts.Resolved | len }}'
|
|
77
|
+
resolved: '{{ .Alerts.Resolved | toJson }}'
|
|
78
|
+
team: payments
|
|
79
|
+
templates: []
|
|
80
|
+
time_intervals:
|
|
81
|
+
- name: weekends
|
|
82
|
+
time_intervals:
|
|
83
|
+
- weekdays:
|
|
84
|
+
- saturday
|
|
85
|
+
- sunday
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AlertRouting`: the routing tree, the inhibit rules between levels and
|
|
3
|
+
* the receivers it declares, and that the severities `Slo`, `GenAiRules`,
|
|
4
|
+
* `RedAlerts` and `Watchdog` write all find a route (PROM202).
|
|
5
|
+
* `amtool check-config` runs when amtool is on PATH.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
9
|
+
import { genAiMetrics } from "@intentius/chant-lexicon-otel/genai";
|
|
10
|
+
import { spanMetricsNames } from "@intentius/chant-lexicon-otel/metric-names";
|
|
11
|
+
import { AlertRouting, ALERT_ROUTING_LEVELS, type AlertRoutingProps } from "./alert-routing";
|
|
12
|
+
import { Slo } from "./slo";
|
|
13
|
+
import { GenAiRules } from "./genai";
|
|
14
|
+
import { RedAlerts } from "./red-alerts";
|
|
15
|
+
import { Watchdog } from "./watchdog";
|
|
16
|
+
import { Receiver, isInhibitRule, isReceiver } from "../alertmanager";
|
|
17
|
+
import { alertmanagerYaml, buildAlertmanagerConfig, buildRuleFile } from "../build";
|
|
18
|
+
import { validateAlertmanagerConfig, validateSeverityRouting } from "../validate-config";
|
|
19
|
+
import { amtoolCheckConfig, hasTool } from "../tools";
|
|
20
|
+
|
|
21
|
+
const hasAmtool = hasTool(process.env.AMTOOL ?? "amtool");
|
|
22
|
+
|
|
23
|
+
const values = (...instances: Array<{ members: Record<string, unknown> }>) =>
|
|
24
|
+
instances.flatMap((i) => Object.values(i.members)) as Declarable[];
|
|
25
|
+
|
|
26
|
+
function built(props: AlertRoutingProps = {}, ...more: Array<{ members: Record<string, unknown> }>) {
|
|
27
|
+
return buildAlertmanagerConfig(values(AlertRouting(props), ...more));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pager = { name: "pager", pagerduty_configs: [{ routing_key_file: "/etc/alertmanager/secrets/pagerduty" }] };
|
|
31
|
+
const chat = { name: "chat", slack_configs: [{ api_url_file: "/etc/alertmanager/secrets/slack", channel: "#alerts" }] };
|
|
32
|
+
|
|
33
|
+
describe("AlertRouting defaults", () => {
|
|
34
|
+
const { config, warnings } = built();
|
|
35
|
+
|
|
36
|
+
test("a root route to a default receiver that drops, and one child route per level", () => {
|
|
37
|
+
expect(warnings).toEqual([]);
|
|
38
|
+
expect(config.receivers).toEqual([{ name: "default" }]);
|
|
39
|
+
expect(config.route).toMatchObject({ receiver: "default", group_by: ["alertname"], group_wait: "30s", group_interval: "5m", repeat_interval: "4h" });
|
|
40
|
+
expect(config.route?.routes).toEqual([
|
|
41
|
+
{ receiver: "default", matchers: ['severity=~"critical|page"'] },
|
|
42
|
+
{ receiver: "default", matchers: ['severity=~"warning|ticket"'] },
|
|
43
|
+
{ receiver: "default", matchers: ['severity="info"'] },
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("each level holds back the levels below it, for the same alert", () => {
|
|
48
|
+
const equal = ["alertname", "slo", "service_name", "team"];
|
|
49
|
+
expect(config.inhibit_rules).toEqual([
|
|
50
|
+
{ name: "critical holds back warning", source_matchers: ['severity=~"critical|page"'], target_matchers: ['severity=~"warning|ticket"'], equal },
|
|
51
|
+
{ name: "critical holds back info", source_matchers: ['severity=~"critical|page"'], target_matchers: ['severity="info"'], equal },
|
|
52
|
+
{ name: "warning holds back info", source_matchers: ['severity=~"warning|ticket"'], target_matchers: ['severity="info"'], equal },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("passes the Alertmanager checks", () => {
|
|
57
|
+
expect(validateAlertmanagerConfig(config)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the default levels are the severities the lexicon's composites write", () => {
|
|
61
|
+
expect(ALERT_ROUTING_LEVELS.map((l) => l.severities)).toEqual([["critical", "page"], ["warning", "ticket"], ["info"]]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("AlertRouting with the lexicon's alerting composites", () => {
|
|
66
|
+
const slo = Slo({
|
|
67
|
+
name: "checkout",
|
|
68
|
+
objective: 0.995,
|
|
69
|
+
window: "28d",
|
|
70
|
+
sli: {
|
|
71
|
+
good: 'sum(rate(traces_span_metrics_calls_total{span_name="checkout",status_code!="STATUS_CODE_ERROR"}[{{window}}]))',
|
|
72
|
+
total: 'sum(rate(traces_span_metrics_calls_total{span_name="checkout"}[{{window}}]))',
|
|
73
|
+
},
|
|
74
|
+
alerting: { page: { burnRates: "default" }, ticket: { burnRates: "default" } },
|
|
75
|
+
});
|
|
76
|
+
const genai = GenAiRules({ genAi: genAiMetrics(), alerts: { errorRatio: true, latency: { severity: "critical" } } });
|
|
77
|
+
const red = RedAlerts({ spanMetrics: spanMetricsNames({}) });
|
|
78
|
+
const watchdog = Watchdog({});
|
|
79
|
+
const rules = buildRuleFile([slo.rules, genai.rules, red.rules, watchdog.rules]).config;
|
|
80
|
+
|
|
81
|
+
test("every severity they write is routed (PROM202), with the watchdog first", () => {
|
|
82
|
+
const am = built({ receiver: chat, levels: [{ ...ALERT_ROUTING_LEVELS[0], receiver: pager }, ...ALERT_ROUTING_LEVELS.slice(1)], routes: [watchdog.route] }, watchdog);
|
|
83
|
+
expect(am.warnings).toEqual([]);
|
|
84
|
+
expect(validateSeverityRouting([rules], am.config)).toEqual([]);
|
|
85
|
+
expect(validateAlertmanagerConfig(am.config)).toEqual([]);
|
|
86
|
+
expect(am.config.route?.routes?.map((r) => r.receiver)).toEqual(["heartbeat", "pager", "chat", "chat"]);
|
|
87
|
+
expect(am.config.receivers?.map((r) => r.name)).toEqual(["chat", "heartbeat", "pager"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test.skipIf(!hasAmtool)("amtool check-config passes", () => {
|
|
91
|
+
const yaml = alertmanagerYaml(values(AlertRouting({ receiver: chat, routes: [watchdog.route] }), watchdog));
|
|
92
|
+
const r = amtoolCheckConfig(yaml);
|
|
93
|
+
expect(r.ran).toBe(true);
|
|
94
|
+
expect(r.ok, r.output).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("AlertRouting teams", () => {
|
|
99
|
+
test("a route per team before the levels, with a level of its own sent elsewhere", () => {
|
|
100
|
+
const dbPager = { name: "db-pager", pagerduty_configs: [{ routing_key_file: "/etc/alertmanager/secrets/db" }] };
|
|
101
|
+
const { config } = built({
|
|
102
|
+
receiver: chat,
|
|
103
|
+
teams: [{ team: "db", receiver: "db-chat", levels: { critical: dbPager }, repeatInterval: "1h" }],
|
|
104
|
+
});
|
|
105
|
+
expect(config.route?.routes?.[0]).toEqual({
|
|
106
|
+
receiver: "db-chat",
|
|
107
|
+
matchers: ['team="db"'],
|
|
108
|
+
repeat_interval: "1h",
|
|
109
|
+
routes: [{ receiver: "db-pager", matchers: ['severity=~"critical|page"'] }],
|
|
110
|
+
});
|
|
111
|
+
expect(config.route?.routes).toHaveLength(4);
|
|
112
|
+
expect(config.receivers?.map((r) => r.name)).toEqual(["chat", "db-pager"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("teamLabel changes the label team routes match on, and inhibit equality with it", () => {
|
|
116
|
+
const { config } = built({ teamLabel: "owner", teams: [{ team: "web", receiver: chat }] });
|
|
117
|
+
expect(config.route?.routes?.[0].matchers).toEqual(['owner="web"']);
|
|
118
|
+
expect(config.inhibit_rules?.[0].equal).toEqual(["alertname", "slo", "service_name", "owner"]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("AlertRouting options", () => {
|
|
123
|
+
test("levels of your own, timing, group_by, no inhibition", () => {
|
|
124
|
+
const routing = AlertRouting({
|
|
125
|
+
levels: [{ name: "sev1", severities: ["sev1"], receiver: pager, groupWait: "10s" }, { name: "sev2", severities: ["sev2"] }],
|
|
126
|
+
groupBy: ["alertname", "service_name"],
|
|
127
|
+
repeatInterval: "12h",
|
|
128
|
+
inhibit: false,
|
|
129
|
+
});
|
|
130
|
+
expect(Object.values(routing.members).some(isInhibitRule)).toBe(false);
|
|
131
|
+
expect(Object.keys(routing.members).sort()).toEqual(["receiver_default", "receiver_pager", "route"]);
|
|
132
|
+
const { config } = buildAlertmanagerConfig(values(routing));
|
|
133
|
+
expect(config.route).toMatchObject({ group_by: ["alertname", "service_name"], repeat_interval: "12h" });
|
|
134
|
+
expect(config.route?.routes).toEqual([
|
|
135
|
+
{ receiver: "pager", matchers: ['severity="sev1"'], group_wait: "10s" },
|
|
136
|
+
{ receiver: "default", matchers: ['severity="sev2"'] },
|
|
137
|
+
]);
|
|
138
|
+
expect(validateAlertmanagerConfig(config)).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a declared receiver is referenced, not declared again; one props object used twice is one receiver", () => {
|
|
142
|
+
const declared = new Receiver(chat);
|
|
143
|
+
const routing = AlertRouting({ receiver: declared, levels: [{ name: "a", severities: ["a"], receiver: pager }, { name: "b", severities: ["b"], receiver: pager }] });
|
|
144
|
+
const receivers = Object.values(routing.members).filter(isReceiver);
|
|
145
|
+
expect(receivers.map((r) => r.props.name)).toEqual(["pager"]);
|
|
146
|
+
expect(buildAlertmanagerConfig([declared, ...values(routing)]).config.receivers?.map((r) => r.name)).toEqual(["chat", "pager"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("bad props are refused", () => {
|
|
150
|
+
expect(() => AlertRouting({ levels: [{ name: "a", severities: ["x"] }, { name: "b", severities: ["x"] }] })).toThrow(
|
|
151
|
+
/AlertRouting: levels\[1\]: severity "x" is already in level "a"/,
|
|
152
|
+
);
|
|
153
|
+
expect(() => AlertRouting({ levels: [{ name: "a", severities: [] }] })).toThrow(/at least one severity/);
|
|
154
|
+
expect(() => AlertRouting({ levels: [{ name: "a", severities: ["x"] }, { name: "a", severities: ["y"] }] })).toThrow(/second level/);
|
|
155
|
+
expect(() => AlertRouting({ receiver: { name: "x" }, levels: [{ name: "a", severities: ["a"], receiver: { name: "x" } }] })).toThrow(
|
|
156
|
+
/two receivers are named "x"/,
|
|
157
|
+
);
|
|
158
|
+
expect(() => AlertRouting({ teams: [{ team: "db", receiver: chat, levels: { sev9: pager } }] })).toThrow(/"sev9", which is not a level/);
|
|
159
|
+
expect(() => AlertRouting({ teams: [{ team: "db", receiver: chat }, { team: "db", receiver: chat }] })).toThrow(/listed twice/);
|
|
160
|
+
expect(() => AlertRouting({ teamLabel: "team-name" })).toThrow(/teamLabel/);
|
|
161
|
+
expect(() => AlertRouting({ groupWait: "soon" })).toThrow(/groupWait/);
|
|
162
|
+
});
|
|
163
|
+
});
|