返回 CodeWhale
schema-doc.test.ts
根目录 / telemetry-ingest / test / schema-doc.test.ts
1 /**
2 * The doc and the validator are welded.
3 *
4 * `docs/TELEMETRY.md` is a promise to users, and the Rust suite already welds it
5 * to the client's structs. This file welds it to the *server*: the field names
6 * and enum spellings are parsed back out of the markdown and compared for set
7 * equality against `src/schema.ts`. A field published in the doc that this
8 * endpoint would reject fails here; a field this endpoint would accept that the
9 * doc never published fails here too. Neither can be fixed by editing only one
10 * side.
11 *
12 * The parsers below mirror `crates/telemetry/src/tests.rs` on purpose — same
13 * fenced-block extraction, same "identifier followed by a colon is a key" rule.
14 */
15
16 import { describe, expect, it } from "vitest";
17
18 import {
19 ARCHES,
20 BATCH_MAX_BYTES,
21 BATCH_MAX_EVENTS,
22 COLD_START_BUCKETS,
23 COUNTER_FIELDS,
24 DURATION_BUCKETS,
25 ENVELOPE_FIELDS,
26 ERROR_FIELDS,
27 EVENT_FIELDS,
28 EVENT_NAMES,
29 EXIT_CLASSES,
30 INSTALL_KINDS,
31 LIBCS,
32 MAX_BODY_BYTES,
33 OSES,
34 PRODUCT_COUNTER_FIELDS,
35 OPERATIONS_FIELDS,
36 SCHEMA_VERSION,
37 SESSION_SOURCES,
38 SURFACES,
39 TURN_WALL_FIELDS,
40 } from "../src/schema";
41 import { DOC, goldenBatch } from "./support";
42
43 /** Every fenced ```jsonc block, in document order. */
44 function jsoncBlocks(doc: string): string[] {
45 const blocks: string[] = [];
46 let current: string[] | null = null;
47 for (const raw of doc.split("\n")) {
48 const line = raw.trimEnd();
49 if (current === null) {
50 if (line.trim() === "```jsonc") current = [];
51 continue;
52 }
53 if (line.trim() === "```") {
54 blocks.push(current.join("\n"));
55 current = null;
56 } else {
57 current.push(line);
58 }
59 }
60 return blocks;
61 }
62
63 /**
64 * Every `"name":` key in a block, nested objects included. Values are never
65 * matched: a key is an identifier-shaped string followed by a colon, and no
66 * value in these blocks has that shape.
67 */
68 function documentedKeys(block: string): Set<string> {
69 const keys = new Set<string>();
70 const pattern = /"([a-z0-9_]+)"\s*:/g;
71 for (const match of block.matchAll(pattern)) {
72 keys.add(match[1]);
73 }
74 return keys;
75 }
76
77 /** The rows of the first markdown table after `anchor`, as `[first, rest]`. */
78 function tableAfter(doc: string, anchor: string): Array<[string, string]> {
79 const lines = doc.split("\n");
80 const start = lines.findIndex((line) => line.includes(anchor));
81 expect(start, `anchor not found in docs/TELEMETRY.md: ${anchor}`).toBeGreaterThanOrEqual(0);
82
83 const rows: Array<[string, string]> = [];
84 let inTable = false;
85 for (const line of lines.slice(start + 1)) {
86 const trimmed = line.trim();
87 if (!trimmed.startsWith("|")) {
88 if (inTable) break;
89 continue;
90 }
91 inTable = true;
92 // Split on pipes that are not escaped as `\|` — escaped pipes are enum
93 // separators inside a cell, not cell boundaries.
94 const cells = trimmed
95 .replace(/^\|/, "")
96 .replace(/\|$/, "")
97 .split(/(?<!\\)\|/)
98 .map((cell) => cell.trim());
99 const first = (cells[0] ?? "").replace(/`/g, "").trim();
100 if (first === "" || /^[-:]+$/.test(first)) continue;
101 if (first.toLowerCase() === "field" || first.toLowerCase() === "file") continue;
102 rows.push([first, cells.slice(1).join(" | ")]);
103 }
104 return rows;
105 }
106
107 /**
108 * The enum list a table row publishes: the first backticked run in the row's
109 * remaining cells that contains a `\|` separator.
110 */
111 function enumFromRow(rows: Array<[string, string]>, field: string): string[] {
112 const row = rows.find(([name]) => name === field);
113 expect(row, `no table row for ${field}`).toBeDefined();
114 const rest = (row as [string, string])[1].replace(/\\\|/g, "|");
115 for (const match of rest.matchAll(/`([^`]+)`/g)) {
116 if (match[1].includes("|")) {
117 return match[1].split("|").map((value) => value.trim());
118 }
119 }
120 throw new Error(`no enum list published for ${field}`);
121 }
122
123 /** Every token the doc puts inside backticks, split on `|` and `,`. */
124 function documentedTokens(doc: string): Set<string> {
125 const tokens = new Set<string>();
126 const unescaped = doc.replace(/\\\|/g, "|");
127 for (const match of unescaped.matchAll(/`([^`\n]+)`/g)) {
128 for (const part of match[1].split(/[|,]/)) {
129 tokens.add(part.trim());
130 }
131 }
132 return tokens;
133 }
134
135 const BLOCKS = jsoncBlocks(DOC);
136 const TOKENS = documentedTokens(DOC);
137
138 describe("the doc and the validator publish the same fields", () => {
139 it("finds one jsonc block for the envelope and one per event", () => {
140 expect(BLOCKS).toHaveLength(1 + EVENT_NAMES.length);
141 });
142
143 it("agrees on the envelope field set", () => {
144 expect([...documentedKeys(BLOCKS[0])].sort()).toEqual(
145 [...ENVELOPE_FIELDS].sort(),
146 );
147 });
148
149 it("agrees on the install_or_upgrade field set", () => {
150 expect([...documentedKeys(BLOCKS[1])].sort()).toEqual(
151 [...EVENT_FIELDS.install_or_upgrade].sort(),
152 );
153 });
154
155 it("agrees on the session_start field set", () => {
156 expect([...documentedKeys(BLOCKS[2])].sort()).toEqual(
157 [...EVENT_FIELDS.session_start].sort(),
158 );
159 });
160
161 it("agrees on the session_end field set, nested objects included", () => {
162 const expected = [
163 ...EVENT_FIELDS.session_end,
164 ...COUNTER_FIELDS,
165 ...ERROR_FIELDS,
166 ...TURN_WALL_FIELDS,
167 ].sort();
168 expect([...documentedKeys(BLOCKS[3])].sort()).toEqual(expected);
169 });
170
171 it("agrees on the panic field set", () => {
172 expect([...documentedKeys(BLOCKS[4])].sort()).toEqual(
173 [...EVENT_FIELDS.panic].sort(),
174 );
175 });
176
177 it("agrees on aggregate product and operations fields", () => {
178 expect([...documentedKeys(BLOCKS[5])].sort()).toEqual(
179 [...EVENT_FIELDS.product_usage, ...PRODUCT_COUNTER_FIELDS].sort(),
180 );
181 expect([...documentedKeys(BLOCKS[6])].sort()).toEqual(
182 ["event", ...OPERATIONS_FIELDS].sort(),
183 );
184 });
185
186 it("agrees on the counters table, field for field", () => {
187 const rows = tableAfter(DOC, "**`counters`** — closed field set");
188 expect(rows.map(([name]) => name)).toEqual([...COUNTER_FIELDS]);
189 });
190
191 it("agrees on the errors table, field for field", () => {
192 const rows = tableAfter(DOC, "**`errors`** — closed field set");
193 expect(rows.map(([name]) => name)).toEqual([...ERROR_FIELDS]);
194 });
195
196 it("agrees on the envelope table, field for field", () => {
197 const rows = tableAfter(DOC, "### Batch envelope");
198 expect(rows.map(([name]) => name)).toEqual([...ENVELOPE_FIELDS]);
199 });
200 });
201
202 describe("the doc and the validator publish the same enums", () => {
203 const rows = tableAfter(DOC, "### Batch envelope");
204
205 it.each([
206 ["surface", SURFACES],
207 ["os", OSES],
208 ["arch", ARCHES],
209 ["libc", LIBCS],
210 ] as const)("agrees on the %s whitelist", (field, expected) => {
211 expect(enumFromRow(rows, field).sort()).toEqual([...expected].sort());
212 });
213
214 it.each([
215 ["install kind", INSTALL_KINDS],
216 ["session source", SESSION_SOURCES],
217 ["duration bucket", DURATION_BUCKETS],
218 ["exit class", EXIT_CLASSES],
219 ["cold start bucket", COLD_START_BUCKETS],
220 ] as const)("publishes every %s value it accepts", (_label, values) => {
221 for (const value of values) {
222 expect(TOKENS.has(value), `${value} is not in docs/TELEMETRY.md`).toBe(
223 true,
224 );
225 }
226 });
227 });
228
229 describe("the doc and the validator publish the same limits", () => {
230 it("agrees on SCHEMA_VERSION", () => {
231 const match = DOC.match(/`SCHEMA_VERSION\s*=\s*(\d+)`/);
232 expect(match).not.toBeNull();
233 expect(Number((match as RegExpMatchArray)[1])).toBe(SCHEMA_VERSION);
234 expect(goldenBatch().schema_version).toBe(1); // preserved legacy golden
235 });
236
237 it("agrees on the per-batch caps", () => {
238 const match = DOC.match(/Capped at (\d+) events or (\d+) KiB per batch/);
239 expect(match).not.toBeNull();
240 const [, events, kib] = match as RegExpMatchArray;
241 expect(Number(events)).toBe(BATCH_MAX_EVENTS);
242 expect(Number(kib) * 1024).toBe(BATCH_MAX_BYTES);
243 });
244
245 it("caps the body above what a conforming client can send", () => {
246 // 65536 event bytes + 199 commas + ~375 bytes of envelope keys and values.
247 const worstCaseBody = BATCH_MAX_BYTES + (BATCH_MAX_EVENTS - 1) + 375;
248 expect(MAX_BODY_BYTES).toBeGreaterThan(worstCaseBody);
249 // …and not so far above it that the cap has stopped meaning anything.
250 expect(MAX_BODY_BYTES).toBeLessThan(worstCaseBody * 2);
251 });
252
253 it("agrees that the disk rings are larger than one batch", () => {
254 expect(DOC).toContain("rings capped at 512 records or\n256 KiB");
255 expect(MAX_BODY_BYTES).toBeLessThan(256 * 1024);
256 });
257 });
258
259 describe("the red line the doc publishes", () => {
260 it("still says batches are IP-stripped at ingest", () => {
261 // If this line ever leaves the doc, the deploy of this Worker needs a
262 // second look before it ships.
263 expect(DOC).toContain("Batches are **IP-stripped at ingest**");
264 expect(DOC).toContain(
265 "No IP is stored, logged, or joined to `install_id`",
266 );
267 });
268 });
269
269 lines TYPESCRIPT