Skip to content

Commit 595ea0f

Browse files
authored
Add a consistency check for the SDK metrics reference (#4986)
* fix(sdk-metrics): correct metric name, availability, and anchors Rename local_activity_succeeded_endtoend_latency to local_activity_succeed_endtoend_latency to match Core, Go, and Java. Fix the swapped resource_slots_cpu_usage and resource_slots_mem_usage anchors. Add Core to the four Nexus metric sections and to sticky_cache_total_forced_eviction, add Java to num_pollers, and drop the "Go and Java only" note from nexus_task, LocalActivityWorker, and NexusWorker. Remove the "in seconds" claims that contradict the units note, fix task-queue to task_queue, and drop two trailing commas. * fix(toc): wrap long identifiers before underscores Add break opportunities before underscores in table of contents entries, tighten the leading within an entry, and hang continuation lines. * Add consistency check for the SDK metrics reference Validate that the summary table and per-metric sections agree on anchor, type, and availability, that headings are backticked metric names, and that SDK names, metric types, emitters, and tags come from a known vocabulary. Runs on pull requests that touch the page.
1 parent 234aa3a commit 595ea0f

4 files changed

Lines changed: 526 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Check Metrics Reference
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "docs/references/sdk-metrics.mdx"
7+
- "bin/check-metrics-reference.js"
8+
- "bin/check-metrics-reference.test.js"
9+
- ".github/workflows/check-metrics-reference.yml"
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
check-metrics-reference:
16+
name: Validate metrics reference consistency
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Check out repository
20+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
21+
22+
- name: Set up Node.js
23+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
24+
with:
25+
node-version: "24"
26+
27+
- name: Test the checker
28+
run: node --test bin/check-metrics-reference.test.js
29+
30+
- name: Check the metrics reference
31+
run: node bin/check-metrics-reference.js

bin/check-metrics-reference.js

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
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

Comments
 (0)