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