返回 CodeWhale
schema.ts
根目录 / telemetry-ingest / src / schema.ts
1 /**
2 * The published Codewhale telemetry schema, transcribed from `docs/TELEMETRY.md`
3 * and enforced as a **closed** field set.
4 *
5 * The doc is the promise; this file is the enforcement. `test/schema-doc.test.ts`
6 * parses the field names and enum spellings back out of `docs/TELEMETRY.md` and
7 * asserts set equality against the constants below, so a doc that grows a field
8 * this validator does not know — or a validator that grows a field the doc does
9 * not publish — fails the build rather than quietly accepting new data.
10 *
11 * Why closed rather than "ignore what we don't recognise": a future client bug
12 * that starts attaching a path, a prompt, or a provider table name must be
13 * refused by the server, not stored. Unknown key anywhere in the batch rejects
14 * the whole batch. There is no sanitising path — a payload the schema cannot
15 * account for is not made safe by editing it, which is the same rule
16 * `Event::is_bounded` applies on the client's drain path.
17 */
18
19 /** `SCHEMA_VERSION` in `crates/telemetry/src/event.rs`. */
20 export const SCHEMA_VERSION = 1;
21
22 /** `BATCH_MAX_EVENTS` in `crates/telemetry/src/actor.rs`. */
23 export const BATCH_MAX_EVENTS = 200;
24
25 /** `BATCH_MAX_BYTES` in `crates/telemetry/src/actor.rs` — the event budget. */
26 export const BATCH_MAX_BYTES = 64 * 1024;
27
28 /**
29 * Hard body cap, computed rather than guessed.
30 *
31 * The client assembles at most `BATCH_MAX_EVENTS` (200) buffered lines totalling
32 * at most `BATCH_MAX_BYTES` (65536) bytes — `actor::parse_events` breaks
33 * *before* appending a line that would cross either bound, so both are ceilings,
34 * and the batch is re-serialized by the same `serde` impl that wrote the lines,
35 * so the event bytes on the wire equal the bytes on disk. On top of that the
36 * envelope costs:
37 *
38 * - ~200 bytes of fixed keys and JSON punctuation,
39 * - ~175 bytes of envelope values (`sent_at` 20, `install_id` 36,
40 * `app_version` <= 64, `git_sha` 12, and four short enums),
41 * - 199 commas between the 200 events.
42 *
43 * 65536 + 200 + 175 + 199 = 66110 bytes is therefore the true maximum a
44 * conforming client can send. 72 KiB leaves ~11% headroom for a future envelope
45 * field without leaving room for a payload that is a different shape entirely.
46 *
47 * The disk rings (`buffer::MAX_EVENTS` = 512, `buffer::MAX_BYTES` = 256 KiB) are
48 * larger, but they are the *storage* cap: a 512-record ring drains as three
49 * batches, never as one 256 KiB POST.
50 */
51 export const MAX_BODY_BYTES = 72 * 1024;
52
53 /**
54 * Server-side length ceiling for the two version strings.
55 *
56 * `docs/TELEMETRY.md` pins their *shape*
57 * (`^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`) but not their length — a pre-release
58 * suffix is unbounded in the published regex. INFERRED, not published: 64 bytes
59 * is far past any real Cargo version and stops the one envelope field with an
60 * open tail from becoming a free-form string slot.
61 */
62 export const MAX_VERSION_LEN = 64;
63
64 /**
65 * Server-side length ceiling for a reduced panic site. INFERRED: the published
66 * rule is a charset, not a length. Real sites are well under 120 bytes.
67 */
68 export const MAX_PANIC_SITE_LEN = 256;
69
70 /**
71 * Server-side cardinality ceiling for `providers`. INFERRED: the published rule
72 * closes the *value* space via `ProviderKind::as_str()`, not the array length.
73 */
74 export const MAX_PROVIDERS = 32;
75
76 // ------------------------------------------------------------- enum spellings
77
78 /** `Surface::as_str` — the surface that produced the batch. */
79 export const SURFACES = [
80 "tui",
81 "exec",
82 "cli",
83 "app-server",
84 "mcp-server",
85 "serve",
86 ] as const;
87
88 /** `Os::as_str`. */
89 export const OSES = [
90 "linux",
91 "macos",
92 "windows",
93 "freebsd",
94 "android",
95 "other",
96 ] as const;
97
98 /** `Arch::as_str`. */
99 export const ARCHES = ["x86_64", "aarch64", "other"] as const;
100
101 /** `Libc::as_str`. */
102 export const LIBCS = ["gnu", "musl", "none"] as const;
103
104 /** `InstallKind::as_str`. */
105 export const INSTALL_KINDS = ["install", "upgrade", "downgrade"] as const;
106
107 /** `SessionSource::as_str`. */
108 export const SESSION_SOURCES = [
109 "interactive",
110 "resume",
111 "fork",
112 "api",
113 "unknown",
114 ] as const;
115
116 /** `DurationBucket` wire spellings. */
117 export const DURATION_BUCKETS = [
118 "lt_1m",
119 "1m_10m",
120 "10m_60m",
121 "gt_60m",
122 ] as const;
123
124 /** `ExitClass::as_str`. */
125 export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
126
127 /** `ColdStartBucket` wire spellings. */
128 export const COLD_START_BUCKETS = [
129 "lt_250",
130 "250_1000",
131 "1000_3000",
132 "gte_3000",
133 ] as const;
134
135 // ------------------------------------------------------------------ field sets
136
137 /** `Batch::FIELDS`, in declaration order. */
138 export const ENVELOPE_FIELDS = [
139 "schema_version",
140 "sent_at",
141 "install_id",
142 "app_version",
143 "git_sha",
144 "surface",
145 "os",
146 "arch",
147 "libc",
148 "tty",
149 "events",
150 ] as const;
151
152 /** `Counters::FIELDS`, in declaration order. */
153 export const COUNTER_FIELDS = [
154 "turns",
155 "tool_calls",
156 "fleet_dispatch",
157 "workflow_run",
158 "subagent_spawn",
159 "mcp_server_connected",
160 "memory_search",
161 "approval_modal_shown",
162 "approval_auto_allowed",
163 "command_palette_open",
164 ] as const;
165
166 /** `Errors::FIELDS`, in declaration order. */
167 export const ERROR_FIELDS = [
168 "auth_preflight_failed",
169 "provider_http_4xx",
170 "provider_http_5xx",
171 "tool_denied_by_policy",
172 "tool_timeout",
173 "network_error",
174 ] as const;
175
176 /** `TurnWall::FIELDS`, in wire spelling and declaration order. */
177 export const TURN_WALL_FIELDS = [
178 "lt_5s",
179 "5_30s",
180 "30_120s",
181 "gte_120s",
182 ] as const;
183
184 /**
185 * Every event variant's complete key set, `event` tag included.
186 *
187 * `serde(tag = "event")` makes the wire form flat, so the tag is a key like any
188 * other and the variant set is closed.
189 */
190 export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
191 install_or_upgrade: ["event", "kind", "previous_version"],
192 session_start: ["event", "source"],
193 session_end: [
194 "event",
195 "duration_bucket",
196 "exit_class",
197 "cold_start_bucket",
198 "providers",
199 "counters",
200 "errors",
201 "turn_wall",
202 ],
203 panic: ["event", "site"],
204 };
205
206 /** Every event discriminant, for the doc-match test. */
207 export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
208
209 // -------------------------------------------------------------------- matchers
210
211 /** RFC3339 UTC at second precision — exactly `to_rfc3339_opts(Secs, true)`. */
212 const SENT_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
213
214 /**
215 * A canonical lowercase v4 UUID.
216 *
217 * `docs/TELEMETRY.md` and `envelope.rs` both say v4, and `Uuid::new_v4()` only
218 * ever produces this form. The client's own read path accepts any parseable
219 * UUID, so this is marginally stricter than the client — deliberately: an
220 * `install_id.json` hand-written by something other than Codewhale is exactly
221 * the input this endpoint should refuse, and refusing costs the user nothing
222 * because the client drops rejected batches silently.
223 */
224 const INSTALL_ID_RE =
225 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
226
227 /** `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`, plus the inferred length ceiling. */
228 const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
229
230 /** First 12 lowercase hex chars — `envelope::short_hex_sha`. */
231 const GIT_SHA_RE = /^[0-9a-f]{12}$/;
232
233 /** `event::is_reduced_panic_site` — the literal `<dep>`, or a `crates/…` site. */
234 const PANIC_SITE_RE = /^crates\/[A-Za-z0-9_/.-]+\.rs:\d+:\d+$/;
235
236 /**
237 * `ProviderKind::as_str()` shape.
238 *
239 * This is the one field whose *value* space the endpoint cannot close: the
240 * authoritative list is `codewhale_config::provider::all_providers()`, a Rust
241 * registry with no generated artifact to read, and hard-coding a copy here
242 * would drift into silently dropping a real user's route. The client closes it
243 * with `Event::is_bounded` -> `is_known_provider_id` before the POST is made;
244 * the server enforces the shape a closed `&'static str` enum can produce
245 * (lowercase, hyphen-joined, bounded) and the doc's sorted-and-deduplicated
246 * rule, which is what catches a client that started sending the customer's own
247 * `[providers.<name>]` table key.
248 */
249 const PROVIDER_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
250
251 const U32_MAX = 4294967295;
252
253 // ------------------------------------------------------------------ validation
254
255 /** A rejection carries a reason for the tests. It is never sent to a client. */
256 export type Rejection = { ok: false; reason: string };
257
258 /** A batch that satisfies every published rule. */
259 export type Accepted = { ok: true; batch: Batch };
260
261 export type Counters = Record<(typeof COUNTER_FIELDS)[number], number>;
262 export type Errors = Record<(typeof ERROR_FIELDS)[number], number>;
263 export type TurnWall = Record<(typeof TURN_WALL_FIELDS)[number], number>;
264
265 export type Event =
266 | { event: "install_or_upgrade"; kind: string; previous_version: string | null }
267 | { event: "session_start"; source: string }
268 | {
269 event: "session_end";
270 duration_bucket: string;
271 exit_class: string;
272 cold_start_bucket: string | null;
273 providers: string[];
274 counters: Counters;
275 errors: Errors;
276 turn_wall: TurnWall;
277 }
278 | { event: "panic"; site: string };
279
280 export interface Batch {
281 schema_version: number;
282 sent_at: string;
283 install_id: string;
284 app_version: string;
285 git_sha: string | null;
286 surface: string;
287 os: string;
288 arch: string;
289 libc: string;
290 tty: boolean;
291 events: Event[];
292 }
293
294 function isPlainObject(value: unknown): value is Record<string, unknown> {
295 return (
296 typeof value === "object" && value !== null && !Array.isArray(value)
297 );
298 }
299
300 /** Exact key-set equality. Missing keys and extra keys are both fatal. */
301 function keysExactly(
302 value: Record<string, unknown>,
303 expected: readonly string[],
304 where: string,
305 ): string | null {
306 const actual = Object.keys(value);
307 if (actual.length !== expected.length) {
308 const extra = actual.filter((key) => !expected.includes(key));
309 if (extra.length > 0) return `${where}: unexpected key ${extra[0]}`;
310 const missing = expected.filter((key) => !actual.includes(key));
311 return `${where}: missing key ${missing[0]}`;
312 }
313 for (const key of actual) {
314 if (!expected.includes(key)) return `${where}: unexpected key ${key}`;
315 }
316 return null;
317 }
318
319 function enumString(
320 value: unknown,
321 allowed: readonly string[],
322 where: string,
323 ): string | null {
324 if (typeof value !== "string" || !allowed.includes(value)) {
325 return `${where}: not one of the documented values`;
326 }
327 return null;
328 }
329
330 function u32Map(
331 value: unknown,
332 fields: readonly string[],
333 where: string,
334 ): string | null {
335 if (!isPlainObject(value)) return `${where}: not an object`;
336 const keyError = keysExactly(value, fields, where);
337 if (keyError) return keyError;
338 for (const field of fields) {
339 const item = value[field];
340 if (
341 typeof item !== "number" ||
342 !Number.isInteger(item) ||
343 item < 0 ||
344 item > U32_MAX
345 ) {
346 return `${where}.${field}: not a u32`;
347 }
348 }
349 return null;
350 }
351
352 function validateEvent(value: unknown, where: string): string | null {
353 if (!isPlainObject(value)) return `${where}: not an object`;
354 const name = value.event;
355 if (typeof name !== "string" || !(name in EVENT_FIELDS)) {
356 return `${where}: unknown event discriminant`;
357 }
358 const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
359 if (keyError) return keyError;
360
361 switch (name) {
362 case "install_or_upgrade": {
363 const kindError = enumString(
364 value.kind,
365 INSTALL_KINDS,
366 `${where}.kind`,
367 );
368 if (kindError) return kindError;
369 const previous = value.previous_version;
370 if (previous !== null) {
371 if (
372 typeof previous !== "string" ||
373 previous.length > MAX_VERSION_LEN ||
374 !VERSION_RE.test(previous)
375 ) {
376 return `${where}.previous_version: not a release version string`;
377 }
378 }
379 return null;
380 }
381 case "session_start":
382 return enumString(value.source, SESSION_SOURCES, `${where}.source`);
383 case "session_end": {
384 const bucketError = enumString(
385 value.duration_bucket,
386 DURATION_BUCKETS,
387 `${where}.duration_bucket`,
388 );
389 if (bucketError) return bucketError;
390 const exitError = enumString(
391 value.exit_class,
392 EXIT_CLASSES,
393 `${where}.exit_class`,
394 );
395 if (exitError) return exitError;
396 if (value.cold_start_bucket !== null) {
397 const coldError = enumString(
398 value.cold_start_bucket,
399 COLD_START_BUCKETS,
400 `${where}.cold_start_bucket`,
401 );
402 if (coldError) return coldError;
403 }
404 const providers = value.providers;
405 if (!Array.isArray(providers)) return `${where}.providers: not an array`;
406 if (providers.length > MAX_PROVIDERS) {
407 return `${where}.providers: too many entries`;
408 }
409 let previous: string | null = null;
410 for (const provider of providers) {
411 if (typeof provider !== "string" || !PROVIDER_RE.test(provider)) {
412 return `${where}.providers: not a provider id`;
413 }
414 // The doc says "sorted, deduplicated". A client that started shipping
415 // the customer's own `[providers.<name>]` table key would land here
416 // first, because an unsorted or repeated list is the cheapest signal
417 // that this array stopped coming from `ProviderKind::as_str()`.
418 if (previous !== null && provider <= previous) {
419 return `${where}.providers: not sorted and deduplicated`;
420 }
421 previous = provider;
422 }
423 return (
424 u32Map(value.counters, COUNTER_FIELDS, `${where}.counters`) ??
425 u32Map(value.errors, ERROR_FIELDS, `${where}.errors`) ??
426 u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
427 );
428 }
429 case "panic": {
430 const site = value.site;
431 if (typeof site !== "string" || site.length > MAX_PANIC_SITE_LEN) {
432 return `${where}.site: not a string`;
433 }
434 if (site !== "<dep>" && !PANIC_SITE_RE.test(site)) {
435 return `${where}.site: not a reduced panic site`;
436 }
437 return null;
438 }
439 default:
440 return `${where}: unknown event discriminant`;
441 }
442 }
443
444 /**
445 * Validate one decoded batch against the published schema.
446 *
447 * Returns the batch on success. On failure the reason is for tests and local
448 * reasoning only — the handler answers with a bare status and no body, because
449 * echoing a parse error back is a way to learn what the endpoint stores.
450 */
451 export function validateBatch(value: unknown): Accepted | Rejection {
452 if (!isPlainObject(value)) return { ok: false, reason: "batch: not an object" };
453
454 const keyError = keysExactly(value, ENVELOPE_FIELDS, "batch");
455 if (keyError) return { ok: false, reason: keyError };
456
457 if (value.schema_version !== SCHEMA_VERSION) {
458 return { ok: false, reason: "batch.schema_version: unsupported" };
459 }
460 if (typeof value.sent_at !== "string" || !SENT_AT_RE.test(value.sent_at)) {
461 return { ok: false, reason: "batch.sent_at: not RFC3339 UTC seconds" };
462 }
463 if (
464 typeof value.install_id !== "string" ||
465 !INSTALL_ID_RE.test(value.install_id)
466 ) {
467 return { ok: false, reason: "batch.install_id: not a v4 uuid" };
468 }
469 if (
470 typeof value.app_version !== "string" ||
471 value.app_version.length > MAX_VERSION_LEN ||
472 !VERSION_RE.test(value.app_version)
473 ) {
474 return { ok: false, reason: "batch.app_version: not a release version" };
475 }
476 if (value.git_sha !== null) {
477 if (typeof value.git_sha !== "string" || !GIT_SHA_RE.test(value.git_sha)) {
478 return { ok: false, reason: "batch.git_sha: not 12 hex chars or null" };
479 }
480 }
481 const enumErrors =
482 enumString(value.surface, SURFACES, "batch.surface") ??
483 enumString(value.os, OSES, "batch.os") ??
484 enumString(value.arch, ARCHES, "batch.arch") ??
485 enumString(value.libc, LIBCS, "batch.libc");
486 if (enumErrors) return { ok: false, reason: enumErrors };
487
488 if (typeof value.tty !== "boolean") {
489 return { ok: false, reason: "batch.tty: not a boolean" };
490 }
491 if (!Array.isArray(value.events)) {
492 return { ok: false, reason: "batch.events: not an array" };
493 }
494 if (value.events.length > BATCH_MAX_EVENTS) {
495 return { ok: false, reason: "batch.events: over BATCH_MAX_EVENTS" };
496 }
497 for (let index = 0; index < value.events.length; index += 1) {
498 const eventError = validateEvent(value.events[index], `events[${index}]`);
499 if (eventError) return { ok: false, reason: eventError };
500 }
501
502 return { ok: true, batch: value as unknown as Batch };
503 }
504
504 lines TYPESCRIPT