@intentius/chant-lexicon-prometheus 0.99.0 → 0.101.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 +4 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/genai.d.ts +224 -0
- package/dist/composites/genai.d.ts.map +1 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +11 -2
- package/dist/init-templates.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-eval.d.ts +10 -4
- package/dist/rule-eval.d.ts.map +1 -1
- package/dist/skills/chant-prometheus.md +22 -0
- package/package.json +3 -2
- package/src/codegen/docs.ts +5 -0
- package/src/composites/catalog.test.ts +1 -1
- package/src/composites/catalog.ts +70 -0
- package/src/composites/genai.test.ts +307 -0
- package/src/composites/genai.ts +678 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/slo-burn.test.ts +25 -1
- package/src/index.ts +14 -0
- package/src/init-templates.test.ts +37 -11
- package/src/init-templates.ts +75 -6
- package/src/plugin.ts +6 -2
- package/src/rule-eval.ts +72 -7
- package/src/skills/chant-prometheus.md +22 -0
- package/src/typecheck.test.ts +88 -0
package/src/composites/index.ts
CHANGED
|
@@ -21,3 +21,18 @@ export type {
|
|
|
21
21
|
SloMetrics,
|
|
22
22
|
SloBurnRate,
|
|
23
23
|
} from "./slo";
|
|
24
|
+
export { GenAiRules, genAiRuleMetrics } from "./genai";
|
|
25
|
+
export type {
|
|
26
|
+
GenAiRulesProps,
|
|
27
|
+
GenAiRulesMembers,
|
|
28
|
+
GenAiRulesInstance,
|
|
29
|
+
GenAiRuleMetrics,
|
|
30
|
+
GenAiPrice,
|
|
31
|
+
GenAiAlerting,
|
|
32
|
+
GenAiAlertOptions,
|
|
33
|
+
GenAiRatioAlert,
|
|
34
|
+
GenAiLatencyAlert,
|
|
35
|
+
GenAiBudget,
|
|
36
|
+
GenAiAlertInfo,
|
|
37
|
+
GenAiQuantileSeries,
|
|
38
|
+
} from "./genai";
|
|
@@ -259,8 +259,32 @@ describe.skipIf(!hasPromtool)("each pair fires at its burn rate and not below it
|
|
|
259
259
|
|
|
260
260
|
describe("rule-eval", () => {
|
|
261
261
|
test("throws on PromQL it does not implement instead of guessing", () => {
|
|
262
|
-
const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "
|
|
262
|
+
const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "max_over_time(a[5m])" }] }]);
|
|
263
263
|
expect(() => ev.step(0)).toThrow(/not supported|needs a range/);
|
|
264
|
+
const left = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "a / ignoring (t) group_left (u) b" }] }]);
|
|
265
|
+
expect(() => left.step(0)).toThrow(/group_left with labels is not supported/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("histogram_quantile interpolates inside the bucket the rank falls in", () => {
|
|
269
|
+
const ev = new RuleEvaluator([]);
|
|
270
|
+
for (const [le, n] of [["0.5", 20], ["1", 60], ["2", 100], ["+Inf", 100]] as const) ev.add({ __name__: "d_bucket", job: "a", le }, 0, n);
|
|
271
|
+
for (const [le, n] of [["1", 0], ["+Inf", 10]] as const) ev.add({ __name__: "d_bucket", job: "b", le }, 0, n);
|
|
272
|
+
const at = (q: number) => Object.fromEntries(ev.query(`histogram_quantile(${q}, d_bucket)`, 0).map((e) => [e.labels.job, e.value]));
|
|
273
|
+
// a: rank 50 is 30 of the 40 in (0.5, 1]; rank 95 is 35 of the 40 in (1, 2].
|
|
274
|
+
expect(at(0.5).a).toBeCloseTo(0.875, 9);
|
|
275
|
+
expect(at(0.95).a).toBeCloseTo(1.875, 9);
|
|
276
|
+
// b: every observation is above the highest finite bound, which is what Prometheus returns.
|
|
277
|
+
expect(at(0.5).b).toBe(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("group_left matches many series on the left to one on the right", () => {
|
|
281
|
+
const ev = new RuleEvaluator([]);
|
|
282
|
+
ev.add({ __name__: "errs", m: "x", t: "timeout" }, 0, 2);
|
|
283
|
+
ev.add({ __name__: "errs", m: "x", t: "refused" }, 0, 3);
|
|
284
|
+
ev.add({ __name__: "reqs", m: "x" }, 0, 10);
|
|
285
|
+
const r = ev.query("errs / ignoring (t) group_left reqs", 0);
|
|
286
|
+
expect(r.map((e) => [e.labels.t, e.value]).sort()).toEqual([["refused", 0.3], ["timeout", 0.2]]);
|
|
287
|
+
expect(() => ev.query("errs / ignoring (t) reqs", 0)).toThrow(/needs group_left/);
|
|
264
288
|
});
|
|
265
289
|
|
|
266
290
|
test("evaluates sums, ratios and set operators the way Prometheus does", () => {
|
package/src/index.ts
CHANGED
|
@@ -94,4 +94,18 @@ export {
|
|
|
94
94
|
type SloInstance,
|
|
95
95
|
type SloMetrics,
|
|
96
96
|
type SloBurnRate,
|
|
97
|
+
GenAiRules,
|
|
98
|
+
genAiRuleMetrics,
|
|
99
|
+
type GenAiRulesProps,
|
|
100
|
+
type GenAiRulesMembers,
|
|
101
|
+
type GenAiRulesInstance,
|
|
102
|
+
type GenAiRuleMetrics,
|
|
103
|
+
type GenAiPrice,
|
|
104
|
+
type GenAiAlerting,
|
|
105
|
+
type GenAiAlertOptions,
|
|
106
|
+
type GenAiRatioAlert,
|
|
107
|
+
type GenAiLatencyAlert,
|
|
108
|
+
type GenAiBudget,
|
|
109
|
+
type GenAiAlertInfo,
|
|
110
|
+
type GenAiQuantileSeries,
|
|
97
111
|
} from "./composites";
|
|
@@ -1,29 +1,55 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
3
|
import { join } from "path";
|
|
4
|
+
import { load } from "js-yaml";
|
|
5
5
|
import { build } from "@intentius/chant/build";
|
|
6
6
|
import { lintCommand } from "@intentius/chant/cli/commands/lint";
|
|
7
|
-
import { initTemplates } from "./init-templates";
|
|
8
|
-
import { prometheusSerializer } from "./serializer";
|
|
9
|
-
import { postSynthChecks } from "./lint/post-synth";
|
|
10
7
|
import { runPostSynthChecks } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { prometheusPlugin } from "./plugin";
|
|
9
|
+
import { TEMPLATE_NAMES } from "./init-templates";
|
|
10
|
+
import { postSynthChecks } from "./lint/post-synth";
|
|
11
|
+
|
|
12
|
+
async function built(name: string | undefined) {
|
|
13
|
+
const dir = mkdtempSync(join(import.meta.dirname, "..", ".init-template-"));
|
|
14
|
+
mkdirSync(join(dir, "src"));
|
|
15
|
+
for (const [file, text] of Object.entries(prometheusPlugin.initTemplates!(name).src)) writeFileSync(join(dir, "src", file), text);
|
|
16
|
+
return { dir, result: await build(join(dir, "src"), [prometheusPlugin.serializer]) };
|
|
17
|
+
}
|
|
11
18
|
|
|
19
|
+
// Every template builds, passes every PROM check with nothing to report, and
|
|
20
|
+
// lints clean.
|
|
12
21
|
describe("init templates", () => {
|
|
13
|
-
test.each([undefined,
|
|
14
|
-
const dir =
|
|
22
|
+
test.each([undefined, ...TEMPLATE_NAMES])("%s builds, lints clean and passes every check", async (name) => {
|
|
23
|
+
const { dir, result } = await built(name);
|
|
15
24
|
try {
|
|
16
|
-
mkdirSync(join(dir, "src"));
|
|
17
|
-
for (const [file, text] of Object.entries(initTemplates(name).src)) writeFileSync(join(dir, "src", file), text);
|
|
18
|
-
const result = await build(join(dir, "src"), [prometheusSerializer]);
|
|
19
25
|
expect(result.errors).toEqual([]);
|
|
20
26
|
expect(result.outputs.get("prometheus")).toBeTruthy();
|
|
21
|
-
|
|
22
|
-
expect(diags).toEqual([]);
|
|
27
|
+
expect(runPostSynthChecks(postSynthChecks, result)).toEqual([]);
|
|
23
28
|
const lint = await lintCommand({ path: join(dir, "src"), format: "stylish", fix: false });
|
|
24
29
|
expect(lint.errorCount + lint.warningCount, lint.output).toBe(0);
|
|
25
30
|
} finally {
|
|
26
31
|
rmSync(dir, { recursive: true, force: true });
|
|
27
32
|
}
|
|
28
33
|
});
|
|
34
|
+
|
|
35
|
+
test("the slo template builds the SLO's rules and routes both severities its alerts carry", async () => {
|
|
36
|
+
const { dir, result } = await built("slo");
|
|
37
|
+
try {
|
|
38
|
+
const out = result.outputs.get("prometheus") as { primary: string; files: Record<string, string> };
|
|
39
|
+
const rules = load(out.primary) as { groups: Array<{ name: string; rules: Array<{ alert?: string; labels?: Record<string, string> }> }> };
|
|
40
|
+
expect(rules.groups.map((g) => g.name)).toEqual(["slo-checkout"]);
|
|
41
|
+
const severities = new Set(rules.groups[0].rules.filter((r) => r.alert).map((r) => r.labels?.severity));
|
|
42
|
+
expect([...severities].sort()).toEqual(["page", "ticket"]);
|
|
43
|
+
const am = load(out.files["alertmanager.yml"]) as { route: { routes: Array<{ matchers: string[] }> }; inhibit_rules: unknown[] };
|
|
44
|
+
expect(am.route.routes.flatMap((r) => r.matchers)).toEqual(['severity="page"', 'severity="ticket"']);
|
|
45
|
+
expect(am.inhibit_rules).toEqual([{ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["slo"] }]);
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an unknown template name falls back to the default", () => {
|
|
52
|
+
expect(prometheusPlugin.initTemplates!("no-such-template")).toBe(prometheusPlugin.initTemplates!());
|
|
53
|
+
for (const name of TEMPLATE_NAMES) expect(prometheusPlugin.initTemplates!(name)).not.toBe(prometheusPlugin.initTemplates!());
|
|
54
|
+
});
|
|
29
55
|
});
|
package/src/init-templates.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* - `rules`: rule groups only, for a setup whose Alertmanager config lives
|
|
7
7
|
* elsewhere.
|
|
8
8
|
* - `slo-style`: a recording rule per window and alerts on two severities,
|
|
9
|
-
* the shape SLO burn-rate rules take.
|
|
9
|
+
* the shape SLO burn-rate rules take, written out as plain rules.
|
|
10
|
+
* - `slo`: an SLO declared with the `Slo` composite, which builds its error
|
|
11
|
+
* ratios, error budget and multiwindow burn-rate alerts, and the
|
|
12
|
+
* Alertmanager routing for the page and ticket severities those alerts
|
|
13
|
+
* carry, with a page muting the same SLO's ticket.
|
|
10
14
|
*/
|
|
11
15
|
import type { InitTemplateSet } from "@intentius/chant/lexicon";
|
|
12
16
|
|
|
@@ -69,8 +73,73 @@ const burnAlerts = new RuleGroup({ name: "burn-alerts", rules: alerting });
|
|
|
69
73
|
export { errorRatios, burnAlerts };
|
|
70
74
|
`;
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// ── slo ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const SLO_DECLARATION = `/**
|
|
79
|
+
* The SLO: 99.9% of checkout requests answer without a 5xx over 30 days.
|
|
80
|
+
* \`Slo\` builds one rule group: the error ratio over every window its alerts
|
|
81
|
+
* read, the error budget left, and burn-rate alerts that page on a fast burn
|
|
82
|
+
* (severity "page") and open a ticket on a slow one (severity "ticket").
|
|
83
|
+
* \`sloMetrics(checkout)\` returns the recorded series names, for a dashboard
|
|
84
|
+
* or another rule to read.
|
|
85
|
+
*/
|
|
86
|
+
import { Slo } from "@intentius/chant-lexicon-prometheus";
|
|
87
|
+
|
|
88
|
+
const sli = {
|
|
89
|
+
errors: 'sum(rate(http_requests_total{job="checkout",code=~"5.."}[{{window}}]))',
|
|
90
|
+
total: 'sum(rate(http_requests_total{job="checkout"}[{{window}}]))',
|
|
91
|
+
};
|
|
92
|
+
const team = { team: "payments" };
|
|
93
|
+
|
|
94
|
+
const checkout = Slo({
|
|
95
|
+
name: "checkout",
|
|
96
|
+
objective: 0.999,
|
|
97
|
+
window: "30d",
|
|
98
|
+
description: "Checkout requests answer without a 5xx.",
|
|
99
|
+
sli,
|
|
100
|
+
labels: team,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export { checkout };
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const SLO_ALERTMANAGER = `/**
|
|
107
|
+
* Routing for the SLO's alerts, in the same build root so PROM202 checks that
|
|
108
|
+
* both severities they carry have a route.
|
|
109
|
+
*/
|
|
110
|
+
import { InhibitRule, Receiver, Route, type RouteProps, type WebhookConfig } from "@intentius/chant-lexicon-prometheus";
|
|
111
|
+
|
|
112
|
+
const pager: WebhookConfig[] = [{ url: "http://pager-bridge:8080/alerts" }];
|
|
113
|
+
const oncall = new Receiver({ name: "oncall", webhook_configs: pager });
|
|
114
|
+
|
|
115
|
+
const ticketing: WebhookConfig[] = [{ url: "http://ticket-bridge:8080/alerts" }];
|
|
116
|
+
const tickets = new Receiver({ name: "tickets", webhook_configs: ticketing });
|
|
117
|
+
|
|
118
|
+
const fallback = new Receiver({ name: "default" });
|
|
119
|
+
|
|
120
|
+
const bySlo = ["alertname", "slo"];
|
|
121
|
+
const children: RouteProps[] = [
|
|
122
|
+
{ matchers: ['severity="page"'], receiver: oncall },
|
|
123
|
+
{ matchers: ['severity="ticket"'], receiver: tickets },
|
|
124
|
+
];
|
|
125
|
+
const root = new Route({ receiver: fallback, group_by: bySlo, routes: children });
|
|
126
|
+
|
|
127
|
+
// A page for an SLO mutes its ticket: the fast burn already has someone on it.
|
|
128
|
+
const pageSource = ['severity="page"'];
|
|
129
|
+
const ticketTarget = ['severity="ticket"'];
|
|
130
|
+
const sameSlo = ["slo"];
|
|
131
|
+
const pageMutesTicket = new InhibitRule({ source_matchers: pageSource, target_matchers: ticketTarget, equal: sameSlo });
|
|
132
|
+
|
|
133
|
+
export { oncall, tickets, fallback, root, pageMutesTicket };
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
export const DEFAULT_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES, "alertmanager.ts": ALERTMANAGER } };
|
|
137
|
+
|
|
138
|
+
export const RULES_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES } };
|
|
139
|
+
|
|
140
|
+
export const SLO_STYLE_TEMPLATE: InitTemplateSet = { src: { "rules.ts": SLO_STYLE } };
|
|
141
|
+
|
|
142
|
+
export const SLO_TEMPLATE: InitTemplateSet = { src: { "slo.ts": SLO_DECLARATION, "alertmanager.ts": SLO_ALERTMANAGER } };
|
|
143
|
+
|
|
144
|
+
/** The template names `chant init --lexicon prometheus --template <name>` takes, besides the default. */
|
|
145
|
+
export const TEMPLATE_NAMES = ["rules", "slo-style", "slo"] as const;
|
package/src/plugin.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { detectTemplate } from "./detect";
|
|
|
12
12
|
import { PrometheusParser } from "./import/parser";
|
|
13
13
|
import { PrometheusGenerator } from "./import/generator";
|
|
14
14
|
import { ruleGroupsImporter } from "./import/embedded";
|
|
15
|
-
import {
|
|
15
|
+
import { DEFAULT_TEMPLATE, RULES_TEMPLATE, SLO_STYLE_TEMPLATE, SLO_TEMPLATE } from "./init-templates";
|
|
16
16
|
import { prometheusSkills } from "./skill-defs";
|
|
17
17
|
import { CATALOG } from "./catalog";
|
|
18
18
|
import { PROMETHEUS_PIN } from "./pin";
|
|
@@ -125,8 +125,12 @@ export const prometheusPlugin: LexiconPlugin = {
|
|
|
125
125
|
return [ruleGroupsImporter];
|
|
126
126
|
},
|
|
127
127
|
|
|
128
|
+
// `chant init --lexicon prometheus [--template rules|slo-style|slo]`; see ./init-templates.ts.
|
|
128
129
|
initTemplates(template?: string) {
|
|
129
|
-
|
|
130
|
+
if (template === "rules") return RULES_TEMPLATE;
|
|
131
|
+
if (template === "slo-style") return SLO_STYLE_TEMPLATE;
|
|
132
|
+
if (template === "slo") return SLO_TEMPLATE;
|
|
133
|
+
return DEFAULT_TEMPLATE;
|
|
130
134
|
},
|
|
131
135
|
|
|
132
136
|
completionProvider(ctx: CompletionContext) {
|
package/src/rule-eval.ts
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
* The PromQL it understands is the subset the lexicon's generated rules use:
|
|
8
8
|
* number literals, vector and range selectors with `=`, `!=`, `=~` and `!~`
|
|
9
9
|
* matchers, `rate`, `increase`, `avg_over_time`, `sum_over_time`,
|
|
10
|
-
* `count_over_time`, `vector`, `sum` (with `by` or
|
|
11
|
-
* arithmetic and comparison operators
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* `count_over_time`, `vector`, `histogram_quantile`, `sum` (with `by` or
|
|
11
|
+
* `without`), the arithmetic and comparison operators (one-to-one, or
|
|
12
|
+
* many-to-one with `group_left` and no extra labels) and `and`, `or` and
|
|
13
|
+
* `unless` (with `on` or `ignoring`). Anything else throws, so a test never
|
|
14
|
+
* passes by silently evaluating something it does not understand.
|
|
15
|
+
*
|
|
16
|
+
* `histogram_quantile` interpolates linearly inside the bucket the rank
|
|
17
|
+
* falls in, as Prometheus does for classic buckets, and returns the upper
|
|
18
|
+
* bound of the highest finite bucket when the rank falls in `+Inf`.
|
|
14
19
|
*
|
|
15
20
|
* `rate` is the increase between the first and last sample in the range
|
|
16
21
|
* over the time between them, without Prometheus's extrapolation to the
|
|
@@ -49,6 +54,7 @@ export interface FiringAlert {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const LOOKBACK_MS = 5 * 60_000;
|
|
57
|
+
const RANGE_FUNCTIONS = new Set(["rate", "increase", "avg_over_time", "sum_over_time", "count_over_time"]);
|
|
52
58
|
|
|
53
59
|
function key(labels: Labels): string {
|
|
54
60
|
return JSON.stringify(Object.entries(labels).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
@@ -217,12 +223,15 @@ export class RuleEvaluator {
|
|
|
217
223
|
|
|
218
224
|
private call(n: SyntaxNode, src: string, t: number): Value {
|
|
219
225
|
const fn = this.text(n.getChild("FunctionIdentifier")!, src);
|
|
220
|
-
const [arg] = this.args(n.getChild("FunctionCallBody")!);
|
|
226
|
+
const [arg, second] = this.args(n.getChild("FunctionCallBody")!);
|
|
227
|
+
if (fn === "histogram_quantile") return this.histogramQuantile(arg, second, src, t);
|
|
221
228
|
if (fn === "vector") {
|
|
222
229
|
const v = this.node(arg, src, t);
|
|
223
230
|
if (v.kind !== "scalar") throw new Error("rule-eval: vector() takes a scalar");
|
|
224
231
|
return { kind: "vector", elements: [{ labels: {}, value: v.value }] };
|
|
225
232
|
}
|
|
233
|
+
// Checked before any series is read, so an unsupported function throws even over no data.
|
|
234
|
+
if (!RANGE_FUNCTIONS.has(fn)) throw new Error(`rule-eval: function ${fn} is not supported`);
|
|
226
235
|
if (arg.name !== "MatrixSelector") throw new Error(`rule-eval: ${fn}() needs a range selector`);
|
|
227
236
|
const rangeMs = durationMs(this.text(arg.getChild("DurationExpr")!, src));
|
|
228
237
|
if (rangeMs === undefined) throw new Error(`rule-eval: bad range in ${this.text(arg, src)}`);
|
|
@@ -254,6 +263,45 @@ export class RuleEvaluator {
|
|
|
254
263
|
return { kind: "vector", elements };
|
|
255
264
|
}
|
|
256
265
|
|
|
266
|
+
private histogramQuantile(qNode: SyntaxNode, vNode: SyntaxNode, src: string, t: number): Value {
|
|
267
|
+
const q = this.node(qNode, src, t);
|
|
268
|
+
const v = this.node(vNode, src, t);
|
|
269
|
+
if (q.kind !== "scalar" || v.kind !== "vector") throw new Error("rule-eval: histogram_quantile takes a scalar and a vector");
|
|
270
|
+
const groups = new Map<string, { labels: Labels; buckets: { le: number; count: number }[] }>();
|
|
271
|
+
for (const e of v.elements) {
|
|
272
|
+
const { le, ...rest } = withoutName(e.labels);
|
|
273
|
+
if (le === undefined) continue;
|
|
274
|
+
const k = key(rest);
|
|
275
|
+
const g = groups.get(k) ?? { labels: rest, buckets: [] };
|
|
276
|
+
g.buckets.push({ le: le === "+Inf" ? Infinity : Number(le), count: e.value });
|
|
277
|
+
groups.set(k, g);
|
|
278
|
+
}
|
|
279
|
+
const elements: VectorElement[] = [];
|
|
280
|
+
for (const { labels, buckets } of groups.values()) {
|
|
281
|
+
buckets.sort((a, b) => a.le - b.le);
|
|
282
|
+
const last = buckets[buckets.length - 1];
|
|
283
|
+
if (!last || last.le !== Infinity) continue;
|
|
284
|
+
const total = last.count;
|
|
285
|
+
let value: number;
|
|
286
|
+
if (q.value < 0) value = -Infinity;
|
|
287
|
+
else if (q.value > 1) value = Infinity;
|
|
288
|
+
else if (!(total > 0)) value = NaN;
|
|
289
|
+
else {
|
|
290
|
+
const rank = q.value * total;
|
|
291
|
+
const i = buckets.findIndex((b) => b.count >= rank);
|
|
292
|
+
if (buckets[i].le === Infinity) value = buckets.length > 1 ? buckets[buckets.length - 2].le : NaN;
|
|
293
|
+
else {
|
|
294
|
+
const start = i === 0 ? 0 : buckets[i - 1].le;
|
|
295
|
+
const below = i === 0 ? 0 : buckets[i - 1].count;
|
|
296
|
+
const inBucket = buckets[i].count - below;
|
|
297
|
+
value = inBucket > 0 ? start + (buckets[i].le - start) * ((rank - below) / inBucket) : buckets[i].le;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
elements.push({ labels, value });
|
|
301
|
+
}
|
|
302
|
+
return { kind: "vector", elements };
|
|
303
|
+
}
|
|
304
|
+
|
|
257
305
|
private aggregate(n: SyntaxNode, src: string, t: number): Value {
|
|
258
306
|
const op = this.text(n.getChild("AggregateOp")!, src);
|
|
259
307
|
if (op !== "sum") throw new Error(`rule-eval: aggregation ${op} is not supported`);
|
|
@@ -293,11 +341,20 @@ export class RuleEvaluator {
|
|
|
293
341
|
if (parts.some((p) => p.name === "BoolModifier")) throw new Error("rule-eval: bool modifier is not supported");
|
|
294
342
|
let on: string[] | undefined;
|
|
295
343
|
let ignoring: string[] = [];
|
|
344
|
+
let groupLeft = false;
|
|
296
345
|
if (modifier) {
|
|
297
|
-
const
|
|
346
|
+
const grouping = modifier.getChild("GroupingLabels");
|
|
347
|
+
const names = (grouping?.getChildren("LabelName") ?? []).map((l) => this.text(l, src));
|
|
298
348
|
if (modifier.getChild("On")) on = names;
|
|
299
349
|
else ignoring = names;
|
|
300
|
-
if (modifier.getChild("
|
|
350
|
+
if (modifier.getChild("GroupRight")) throw new Error("rule-eval: group_right is not supported");
|
|
351
|
+
const left = modifier.getChild("GroupLeft");
|
|
352
|
+
if (left) {
|
|
353
|
+
// group_left(<labels>) copies labels from the one side; only the bare form is supported.
|
|
354
|
+
const after = left.nextSibling;
|
|
355
|
+
if (after && after.name === "GroupingLabels" && after !== grouping) throw new Error("rule-eval: group_left with labels is not supported");
|
|
356
|
+
groupLeft = true;
|
|
357
|
+
}
|
|
301
358
|
}
|
|
302
359
|
const lhs = this.node(lhsNode, src, t);
|
|
303
360
|
const rhs = this.node(rhsNode, src, t);
|
|
@@ -377,6 +434,14 @@ export class RuleEvaluator {
|
|
|
377
434
|
if (bySig.has(s)) throw new Error("rule-eval: many-to-many matching is not supported");
|
|
378
435
|
bySig.set(s, e);
|
|
379
436
|
}
|
|
437
|
+
if (!groupLeft) {
|
|
438
|
+
const seen = new Set<string>();
|
|
439
|
+
for (const e of l.elements) {
|
|
440
|
+
const s = signature(e.labels);
|
|
441
|
+
if (seen.has(s)) throw new Error("rule-eval: many-to-one matching needs group_left");
|
|
442
|
+
seen.add(s);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
380
445
|
const elements: VectorElement[] = [];
|
|
381
446
|
for (const e of l.elements) {
|
|
382
447
|
const other = bySig.get(signature(e.labels));
|
|
@@ -100,3 +100,25 @@ sloMetrics(checkout).errorRatio["1h"]; // "slo:sli_error:ratio_rate1h"
|
|
|
100
100
|
- Write the objective as a fraction strictly between 0 and 1, and put `{{window}}` where each SLI expression's range goes (PROM003).
|
|
101
101
|
- Alerts carry `severity` `page` or `ticket`; route both (PROM202).
|
|
102
102
|
- Read series names from `sloMetrics()` in dashboards instead of repeating them.
|
|
103
|
+
|
|
104
|
+
## GenAI rules
|
|
105
|
+
|
|
106
|
+
`GenAiRules` builds rules for the otel lexicon's GenAI collector preset from the same `genAiMetrics(options)` the collector was built with: request rate, error ratio (also by `error.type`) and p50/p95/p99 latency per provider, model and operation, token rates, spend per second from a price table the project declares, and per-tool call rate, error ratio and p95 latency. Alerts are off unless `alerts` names them.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { genAiMetrics } from "@intentius/chant-lexicon-otel";
|
|
110
|
+
import { GenAiRules, genAiRuleMetrics } from "@intentius/chant-lexicon-prometheus";
|
|
111
|
+
|
|
112
|
+
export const genai = GenAiRules({
|
|
113
|
+
genAi: genAiMetrics({ clientMetrics: "derive" }),
|
|
114
|
+
prices: [{ provider: "anthropic", model: "claude-x", inputPerMTok: 3, outputPerMTok: 15, currency: "USD", source: "https://example.com/pricing", asOf: "2026-09-29" }],
|
|
115
|
+
alerts: { errorRatio: true, latency: { thresholdSeconds: 20 }, budgets: [{ amount: 50, currency: "USD", per: "day" }] },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
genAiRuleMetrics(genai).cost; // "gen_ai:cost:rate5m"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- Pass the collector's own options to `genAiMetrics()`; never write a metric name by hand.
|
|
122
|
+
- Ship no prices in a lexicon or template. The project declares them, with `currency` and `source` on each.
|
|
123
|
+
- A model missing from `prices` gets no cost series, so a sum of the cost series covers priced models only.
|
|
124
|
+
- Read series names from `genAiRuleMetrics()` in dashboards.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level guarantees of the hand-written declarations. Each
|
|
3
|
+
* `@ts-expect-error` line must fail to compile: `tsconfig.typecheck.json`
|
|
4
|
+
* (scripts/typecheck.ts in CI) fails on an unused one, so a type that starts
|
|
5
|
+
* accepting what it used to reject breaks the build. The invalid declarations
|
|
6
|
+
* sit in functions that are never called, so nothing here throws at runtime.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expectTypeOf, test } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
Receiver,
|
|
11
|
+
Route,
|
|
12
|
+
RuleGroup,
|
|
13
|
+
Slo,
|
|
14
|
+
TimeInterval,
|
|
15
|
+
sloMetrics,
|
|
16
|
+
type ReceiverEntity,
|
|
17
|
+
type Rule,
|
|
18
|
+
type RuleGroupEntity,
|
|
19
|
+
type RuleGroupProps,
|
|
20
|
+
type SloMetrics,
|
|
21
|
+
type SloProps,
|
|
22
|
+
} from "./index";
|
|
23
|
+
|
|
24
|
+
describe("rule groups", () => {
|
|
25
|
+
test("a group needs a name and rules, and every rule an expr", () => {
|
|
26
|
+
expectTypeOf(RuleGroup).constructorParameters.toEqualTypeOf<[RuleGroupProps]>();
|
|
27
|
+
expectTypeOf<RuleGroupProps["rules"]>().toEqualTypeOf<Rule[]>();
|
|
28
|
+
expectTypeOf(new RuleGroup({ name: "api", rules: [] })).toEqualTypeOf<RuleGroupEntity>();
|
|
29
|
+
|
|
30
|
+
const rejected = () => [
|
|
31
|
+
// @ts-expect-error a group without a name
|
|
32
|
+
new RuleGroup({ rules: [] }),
|
|
33
|
+
// @ts-expect-error a group without rules
|
|
34
|
+
new RuleGroup({ name: "api" }),
|
|
35
|
+
// @ts-expect-error a recording rule without its expression
|
|
36
|
+
new RuleGroup({ name: "api", rules: [{ record: "job:up:sum" }] }),
|
|
37
|
+
// @ts-expect-error an alerting rule without its expression
|
|
38
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", for: "5m" }] }),
|
|
39
|
+
// @ts-expect-error label values are strings, as Prometheus stores them
|
|
40
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", expr: "up == 0", labels: { priority: 1 } }] }),
|
|
41
|
+
// @ts-expect-error `for` is a duration string, not a number of seconds
|
|
42
|
+
new RuleGroup({ name: "api", rules: [{ alert: "Down", expr: "up == 0", for: 300 }] }),
|
|
43
|
+
];
|
|
44
|
+
void rejected;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("alertmanager", () => {
|
|
49
|
+
test("a route names a receiver, and its time intervals by TimeInterval, never the other way round", () => {
|
|
50
|
+
const oncall = new Receiver({ name: "oncall" });
|
|
51
|
+
const weekend = new TimeInterval({ name: "weekend", time_intervals: [{ weekdays: ["saturday", "sunday"] }] });
|
|
52
|
+
expectTypeOf(oncall).toEqualTypeOf<ReceiverEntity>();
|
|
53
|
+
|
|
54
|
+
// By entity or by name, for a receiver declared outside the build.
|
|
55
|
+
new Route({ receiver: oncall, mute_time_intervals: [weekend] });
|
|
56
|
+
new Route({ receiver: "oncall", mute_time_intervals: ["weekend"] });
|
|
57
|
+
|
|
58
|
+
const rejected = () => [
|
|
59
|
+
// @ts-expect-error a time interval is not a receiver
|
|
60
|
+
new Route({ receiver: weekend }),
|
|
61
|
+
// @ts-expect-error a receiver is not a time interval
|
|
62
|
+
new Route({ receiver: oncall, mute_time_intervals: [oncall] }),
|
|
63
|
+
// @ts-expect-error a receiver needs a name
|
|
64
|
+
new Receiver({ webhook_configs: [{ url: "http://hook:8080/" }] }),
|
|
65
|
+
];
|
|
66
|
+
void rejected;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("Slo", () => {
|
|
71
|
+
test("an SLO needs a name, objective, window and an SLI over total events", () => {
|
|
72
|
+
const sli = { errors: "sum(rate(errors_total[{{window}}]))", total: "sum(rate(requests_total[{{window}}]))" };
|
|
73
|
+
const slo = Slo({ name: "checkout", objective: 0.999, window: "30d", sli });
|
|
74
|
+
expectTypeOf(slo.rules).toEqualTypeOf<RuleGroupEntity>();
|
|
75
|
+
expectTypeOf(sloMetrics(slo)).toEqualTypeOf<SloMetrics>();
|
|
76
|
+
expectTypeOf(Slo).parameter(0).toEqualTypeOf<SloProps>();
|
|
77
|
+
|
|
78
|
+
const rejected = () => [
|
|
79
|
+
// @ts-expect-error the objective is a fraction, not a string
|
|
80
|
+
Slo({ name: "checkout", objective: "99.9%", window: "30d", sli }),
|
|
81
|
+
// @ts-expect-error an SLO without a window
|
|
82
|
+
Slo({ name: "checkout", objective: 0.999, sli }),
|
|
83
|
+
// @ts-expect-error an SLI without total events has no ratio
|
|
84
|
+
Slo({ name: "checkout", objective: 0.999, window: "30d", sli: { errors: sli.errors } }),
|
|
85
|
+
];
|
|
86
|
+
void rejected;
|
|
87
|
+
});
|
|
88
|
+
});
|