|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +// Checks the internal consistency of the SDK metrics reference. |
| 4 | +// |
| 5 | +// The page states the same facts twice: once in the summary table and once in |
| 6 | +// the per-metric section below it. Nothing kept the two in sync, which is how |
| 7 | +// the table came to link two metrics to each other's anchors and to disagree |
| 8 | +// with five sections about which SDKs emit a metric. |
| 9 | +// |
| 10 | +// This check is deliberately self-contained. It reads no SDK source and cannot |
| 11 | +// tell you that a documented metric name is wrong, only that the page |
| 12 | +// contradicts itself or uses a value outside the vocabularies below. |
| 13 | +// |
| 14 | +// node bin/check-metrics-reference.js |
| 15 | + |
| 16 | +const fs = require('fs'); |
| 17 | +const path = require('path'); |
| 18 | + |
| 19 | +const PAGES = [path.join('docs', 'references', 'sdk-metrics.mdx')]; |
| 20 | + |
| 21 | +const METRIC_TYPES = ['Counter', 'Gauge', 'Histogram']; |
| 22 | +const EMITTERS = ['Worker', 'Service Client']; |
| 23 | +const SDKS = ['Core', 'Go', 'Java']; |
| 24 | + |
| 25 | +// Every tag any metric on the page is allowed to carry. Both the shared key |
| 26 | +// list near the top of the page and each metric's own "Tags" line are checked |
| 27 | +// against this, so a hyphen/underscore slip in either place is a failure. |
| 28 | +const TAGS = [ |
| 29 | + 'activity_type', |
| 30 | + 'failure_reason', |
| 31 | + 'namespace', |
| 32 | + 'nexus_operation', |
| 33 | + 'nexus_service', |
| 34 | + 'operation', |
| 35 | + 'poller_type', |
| 36 | + 'task_queue', |
| 37 | + 'worker_type', |
| 38 | + 'workflow_type', |
| 39 | +]; |
| 40 | + |
| 41 | +// The units admonition explains that histograms are milliseconds in Core-based |
| 42 | +// SDKs and seconds in Go and Java, so an individual metric must not claim one. |
| 43 | +const UNIT_CLAIM = /\bin (seconds|milliseconds)\b/i; |
| 44 | + |
| 45 | +const METRIC_NAME = '[a-z0-9_]+'; |
| 46 | + |
| 47 | +function parseTable(content) { |
| 48 | + const lines = content.split('\n'); |
| 49 | + const start = lines.findIndex((l) => l.startsWith('| Metric name')); |
| 50 | + if (start === -1) return []; |
| 51 | + |
| 52 | + const rows = []; |
| 53 | + for (let i = start + 2; i < lines.length; i++) { |
| 54 | + const line = lines[i]; |
| 55 | + if (!line.startsWith('|')) break; |
| 56 | + |
| 57 | + const cells = line |
| 58 | + .slice(1, line.lastIndexOf('|')) |
| 59 | + .split('|') |
| 60 | + .map((c) => c.trim()); |
| 61 | + |
| 62 | + const link = cells[0].match( |
| 63 | + new RegExp(`^\\[temporal_(${METRIC_NAME})\\]\\(#(${METRIC_NAME})\\)$`), |
| 64 | + ); |
| 65 | + |
| 66 | + rows.push({ |
| 67 | + line: i + 1, |
| 68 | + raw: cells[0], |
| 69 | + name: link ? link[1] : null, |
| 70 | + anchor: link ? link[2] : null, |
| 71 | + emitter: cells[1], |
| 72 | + type: cells[2], |
| 73 | + availability: cells[3], |
| 74 | + }); |
| 75 | + } |
| 76 | + return rows; |
| 77 | +} |
| 78 | + |
| 79 | +function parseSections(content) { |
| 80 | + const parts = content.split(new RegExp(`^### \`(${METRIC_NAME})\`$`, 'm')); |
| 81 | + const sections = []; |
| 82 | + |
| 83 | + for (let i = 1; i < parts.length; i += 2) { |
| 84 | + const body = parts[i + 1]; |
| 85 | + const field = (label) => { |
| 86 | + const m = body.match(new RegExp(`^- ${label}: (.*)$`, 'm')); |
| 87 | + return m ? m[1].trim() : null; |
| 88 | + }; |
| 89 | + |
| 90 | + sections.push({ |
| 91 | + name: parts[i], |
| 92 | + type: field('Type'), |
| 93 | + availability: field('Available in'), |
| 94 | + tags: field('Tags'), |
| 95 | + // Prose above the first bullet, which is where a stray unit claim lands. |
| 96 | + description: body.split('\n- ')[0], |
| 97 | + }); |
| 98 | + } |
| 99 | + return sections; |
| 100 | +} |
| 101 | + |
| 102 | +// Headings must be a backticked metric name so the anchors stay predictable |
| 103 | +// and match the sibling cluster-metrics reference. |
| 104 | +function findMalformedHeadings(content) { |
| 105 | + return content |
| 106 | + .split('\n') |
| 107 | + .map((line, i) => ({ line: i + 1, text: line })) |
| 108 | + .filter( |
| 109 | + ({ text }) => |
| 110 | + text.startsWith('### ') && |
| 111 | + !new RegExp(`^### \`${METRIC_NAME}\`$`).test(text), |
| 112 | + ); |
| 113 | +} |
| 114 | + |
| 115 | +// The shared "Each metric may have..." list, whose entries are backticked tag |
| 116 | +// names at one indent level. Nested entries are tag *values*, not names. |
| 117 | +function parseTagList(content) { |
| 118 | + const start = content.indexOf('Each metric may have'); |
| 119 | + if (start === -1) return []; |
| 120 | + const block = content.slice(start).split('\n\n').slice(0, 2).join('\n\n'); |
| 121 | + |
| 122 | + return [...block.matchAll(/^- `([a-z0-9_-]+)`/gm)].map((m) => m[1]); |
| 123 | +} |
| 124 | + |
| 125 | +function splitList(value) { |
| 126 | + return value.split(',').map((v) => v.trim()); |
| 127 | +} |
| 128 | + |
| 129 | +function backtickedNames(value) { |
| 130 | + return [...value.matchAll(/`([a-z0-9_-]+)`/g)].map((m) => m[1]); |
| 131 | +} |
| 132 | + |
| 133 | +function checkPage(content) { |
| 134 | + const problems = []; |
| 135 | + const add = (msg) => problems.push(msg); |
| 136 | + |
| 137 | + for (const { line, text } of findMalformedHeadings(content)) { |
| 138 | + add(`line ${line}: heading is not a backticked metric name: ${text}`); |
| 139 | + } |
| 140 | + |
| 141 | + for (const tag of parseTagList(content)) { |
| 142 | + if (!TAGS.includes(tag)) { |
| 143 | + add(`shared tag list: unknown tag \`${tag}\``); |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + const rows = parseTable(content); |
| 148 | + const sections = parseSections(content); |
| 149 | + |
| 150 | + if (rows.length === 0) add('summary table not found or empty'); |
| 151 | + if (sections.length === 0) add('no metric sections found'); |
| 152 | + |
| 153 | + const sectionsByName = new Map(sections.map((s) => [s.name, s])); |
| 154 | + |
| 155 | + for (const row of rows) { |
| 156 | + if (!row.name) { |
| 157 | + add(`line ${row.line}: table row is not a temporal_ metric link: ${row.raw}`); |
| 158 | + continue; |
| 159 | + } |
| 160 | + |
| 161 | + const label = row.name; |
| 162 | + |
| 163 | + if (row.anchor !== row.name) { |
| 164 | + add(`${label}: table links to #${row.anchor}`); |
| 165 | + } |
| 166 | + if (!EMITTERS.includes(row.emitter)) { |
| 167 | + add(`${label}: unknown "Emitted by" value "${row.emitter}"`); |
| 168 | + } |
| 169 | + if (!METRIC_TYPES.includes(row.type)) { |
| 170 | + add(`${label}: unknown metric type "${row.type}" in table`); |
| 171 | + } |
| 172 | + for (const sdk of splitList(row.availability)) { |
| 173 | + if (!SDKS.includes(sdk)) { |
| 174 | + add(`${label}: unknown SDK "${sdk}" in table availability`); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + const section = sectionsByName.get(row.anchor); |
| 179 | + if (!section) { |
| 180 | + add(`${label}: table links to #${row.anchor} but no such section exists`); |
| 181 | + continue; |
| 182 | + } |
| 183 | + |
| 184 | + if (section.type !== row.type) { |
| 185 | + add( |
| 186 | + `${label}: type disagrees (table "${row.type}", section "${section.type}")`, |
| 187 | + ); |
| 188 | + } |
| 189 | + if (section.availability !== row.availability) { |
| 190 | + add( |
| 191 | + `${label}: availability disagrees (table "${row.availability}", section "${section.availability}")`, |
| 192 | + ); |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + const rowsByAnchor = new Set(rows.map((r) => r.anchor)); |
| 197 | + |
| 198 | + for (const section of sections) { |
| 199 | + const label = section.name; |
| 200 | + |
| 201 | + if (!rowsByAnchor.has(section.name)) { |
| 202 | + add(`${label}: has a section but no summary table row`); |
| 203 | + } |
| 204 | + |
| 205 | + if (section.type === null) { |
| 206 | + add(`${label}: section is missing a "Type" line`); |
| 207 | + } else if (!METRIC_TYPES.includes(section.type)) { |
| 208 | + add(`${label}: unknown metric type "${section.type}" in section`); |
| 209 | + } |
| 210 | + |
| 211 | + if (section.availability === null) { |
| 212 | + add(`${label}: section is missing an "Available in" line`); |
| 213 | + } else { |
| 214 | + for (const sdk of splitList(section.availability)) { |
| 215 | + if (!SDKS.includes(sdk)) { |
| 216 | + add(`${label}: unknown SDK "${sdk}" in section availability`); |
| 217 | + } |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + if (section.tags !== null) { |
| 222 | + for (const tag of backtickedNames(section.tags)) { |
| 223 | + if (!TAGS.includes(tag)) { |
| 224 | + add(`${label}: unknown tag \`${tag}\``); |
| 225 | + } |
| 226 | + } |
| 227 | + } |
| 228 | + |
| 229 | + const unit = section.description.match(UNIT_CLAIM); |
| 230 | + if (unit) { |
| 231 | + add( |
| 232 | + `${label}: description says "${unit[0]}", which contradicts the units admonition`, |
| 233 | + ); |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + return problems; |
| 238 | +} |
| 239 | + |
| 240 | +function main() { |
| 241 | + let failed = false; |
| 242 | + |
| 243 | + for (const page of PAGES) { |
| 244 | + const content = fs.readFileSync(path.join(process.cwd(), page), 'utf8'); |
| 245 | + const problems = checkPage(content); |
| 246 | + |
| 247 | + if (problems.length === 0) { |
| 248 | + const count = parseTable(content).length; |
| 249 | + console.log(`${page}: ${count} metrics consistent.`); |
| 250 | + continue; |
| 251 | + } |
| 252 | + |
| 253 | + failed = true; |
| 254 | + console.error(`${page}: ${problems.length} problem(s)\n`); |
| 255 | + for (const problem of problems) { |
| 256 | + console.error(` ${problem}`); |
| 257 | + } |
| 258 | + console.error(''); |
| 259 | + } |
| 260 | + |
| 261 | + process.exit(failed ? 1 : 0); |
| 262 | +} |
| 263 | + |
| 264 | +module.exports = { |
| 265 | + PAGES, |
| 266 | + TAGS, |
| 267 | + parseTable, |
| 268 | + parseSections, |
| 269 | + parseTagList, |
| 270 | + findMalformedHeadings, |
| 271 | + checkPage, |
| 272 | +}; |
| 273 | + |
| 274 | +if (require.main === module) { |
| 275 | + main(); |
| 276 | +} |
0 commit comments