返回 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 = 3;
21
22 /** Disclosed default-on policy, not a record of human acceptance. */
23 export const NOTICE_VERSION = 5;
24
25 /** Explicit consent to the PostHog processor disclosure. */
26 export const CONSENT_VERSION = 4;
27
28 /** `BATCH_MAX_EVENTS` in `crates/telemetry/src/actor.rs`. */
29 export const BATCH_MAX_EVENTS = 200;
30
31 /** `BATCH_MAX_BYTES` in `crates/telemetry/src/actor.rs` — the event budget. */
32 export const BATCH_MAX_BYTES = 64 * 1024;
33
34 /**
35 * Hard body cap, computed rather than guessed.
36 *
37 * The client assembles at most `BATCH_MAX_EVENTS` (200) buffered lines totalling
38 * at most `BATCH_MAX_BYTES` (65536) bytes — `actor::parse_events` breaks
39 * *before* appending a line that would cross either bound, so both are ceilings,
40 * and the batch is re-serialized by the same `serde` impl that wrote the lines,
41 * so the event bytes on the wire equal the bytes on disk. On top of that the
42 * envelope costs:
43 *
44 * - ~200 bytes of fixed keys and JSON punctuation,
45 * - ~175 bytes of envelope values (`sent_at` 20, `install_id` 36,
46 * `app_version` <= 64, `git_sha` 12, and four short enums),
47 * - 199 commas between the 200 events.
48 *
49 * 65536 + 200 + 175 + 199 = 66110 bytes is therefore the true maximum a
50 * conforming client can send. 72 KiB leaves ~11% headroom for a future envelope
51 * field without leaving room for a payload that is a different shape entirely.
52 *
53 * The disk rings (`buffer::MAX_EVENTS` = 512, `buffer::MAX_BYTES` = 256 KiB) are
54 * larger, but they are the *storage* cap: a 512-record ring drains as three
55 * batches, never as one 256 KiB POST.
56 */
57 export const MAX_BODY_BYTES = 72 * 1024;
58
59 /**
60 * Server-side length ceiling for the two version strings.
61 *
62 * `docs/TELEMETRY.md` pins their *shape*
63 * (`^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`) but not their length — a pre-release
64 * suffix is unbounded in the published regex. INFERRED, not published: 64 bytes
65 * is far past any real Cargo version and stops the one envelope field with an
66 * open tail from becoming a free-form string slot.
67 */
68 export const MAX_VERSION_LEN = 64;
69
70 /**
71 * Server-side length ceiling for a reduced panic site. INFERRED: the published
72 * rule is a charset, not a length. Real sites are well under 120 bytes.
73 */
74 export const MAX_PANIC_SITE_LEN = 256;
75
76 /**
77 * Server-side cardinality ceiling for `providers`. INFERRED: the published rule
78 * closes the *value* space via `ProviderKind::as_str()`, not the array length.
79 */
80 export const MAX_PROVIDERS = 32;
81
82 // ------------------------------------------------------------- enum spellings
83
84 /** `Surface::as_str` — the surface that produced the batch. */
85 export const SURFACES = [
86 "tui",
87 "exec",
88 "cli",
89 "app-server",
90 "mcp-server",
91 "serve",
92 "website",
93 "web-app",
94 "desktop",
95 "control-plane",
96 ] as const;
97
98 const LEGACY_SURFACES = SURFACES.slice(0, 6);
99
100 /** `Os::as_str`. */
101 export const OSES = [
102 "linux",
103 "macos",
104 "windows",
105 "freebsd",
106 "android",
107 "other",
108 ] as const;
109
110 /** `Arch::as_str`. */
111 export const ARCHES = ["x86_64", "aarch64", "other"] as const;
112
113 /** `Libc::as_str`. */
114 export const LIBCS = ["gnu", "musl", "none"] as const;
115
116 /** `InstallKind::as_str`. */
117 export const INSTALL_KINDS = ["install", "upgrade", "downgrade"] as const;
118
119 /** `SessionSource::as_str`. */
120 export const SESSION_SOURCES = [
121 "interactive",
122 "resume",
123 "fork",
124 "api",
125 "unknown",
126 ] as const;
127
128 /** `DurationBucket` wire spellings. */
129 export const DURATION_BUCKETS = [
130 "lt_1m",
131 "1m_10m",
132 "10m_60m",
133 "gt_60m",
134 ] as const;
135
136 /** `ExitClass::as_str`. */
137 export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
138
139 /** `ColdStartBucket` wire spellings. */
140 export const COLD_START_BUCKETS = [
141 "lt_250",
142 "250_1000",
143 "1000_3000",
144 "gte_3000",
145 ] as const;
146
147 // ------------------------------------------------------------------ field sets
148
149 /** `Batch::FIELDS`, in declaration order. */
150 export const ENVELOPE_FIELDS = [
151 "schema_version",
152 "notice_version",
153 "sent_at",
154 "install_id",
155 "app_version",
156 "git_sha",
157 "surface",
158 "os",
159 "arch",
160 "libc",
161 "tty",
162 "events",
163 ] as const;
164
165 /** The unchanged v1 contract never establishes processor consent. */
166 export const LEGACY_ENVELOPE_FIELDS = ENVELOPE_FIELDS.filter((field) => field !== "notice_version");
167 /** V2 retains its original explicit-consent meaning and closed field set. */
168 export const V2_ENVELOPE_FIELDS = [...LEGACY_ENVELOPE_FIELDS, "consent_version"];
169
170 /** Aggregate product interactions; no page, account, session, or tool identifiers. */
171 export const PRODUCT_COUNTER_FIELDS = [
172 "page_view", "docs_view", "install_copy", "download", "signup", "login",
173 "session_create", "session_resume", "turn_submit", "turn_complete",
174 "settings_open", "integration_connect", "error_shown",
175 ] as const;
176
177 /** Anonymous operator-consented service health aggregates. */
178 export const OPERATIONS_FIELDS = [
179 "requests", "errors", "duration_ms_total", "duration_ms_max", "probes", "probes_failed",
180 ] as const;
181
182 /** `Counters::FIELDS`, in declaration order. */
183 export const COUNTER_FIELDS = [
184 "turns",
185 "tool_calls",
186 "fleet_dispatch",
187 "workflow_run",
188 "subagent_spawn",
189 "mcp_server_connected",
190 "memory_search",
191 "approval_modal_shown",
192 "approval_auto_allowed",
193 "command_palette_open",
194 ] as const;
195
196 /** `Errors::FIELDS`, in declaration order. */
197 export const ERROR_FIELDS = [
198 "auth_preflight_failed",
199 "provider_http_4xx",
200 "provider_http_5xx",
201 "tool_denied_by_policy",
202 "tool_timeout",
203 "network_error",
204 ] as const;
205
206 /** `TurnWall::FIELDS`, in wire spelling and declaration order. */
207 export const TURN_WALL_FIELDS = [
208 "lt_5s",
209 "5_30s",
210 "30_120s",
211 "gte_120s",
212 ] as const;
213
214 /**
215 * Every event variant's complete key set, `event` tag included.
216 *
217 * `serde(tag = "event")` makes the wire form flat, so the tag is a key like any
218 * other and the variant set is closed.
219 */
220 export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
221 install_or_upgrade: ["event", "kind", "previous_version"],
222 session_start: ["event", "source"],
223 session_end: [
224 "event",
225 "duration_bucket",
226 "exit_class",
227 "cold_start_bucket",
228 "providers",
229 "counters",
230 "errors",
231 "turn_wall",
232 ],
233 panic: ["event", "site"],
234 product_usage: ["event", "counters"],
235 operations_summary: ["event", ...OPERATIONS_FIELDS],
236 };
237
238 /** Every event discriminant, for the doc-match test. */
239 export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
240
241 // -------------------------------------------------------------------- matchers
242
243 /** RFC3339 UTC at second precision — exactly `to_rfc3339_opts(Secs, true)`. */
244 const SENT_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
245
246 /**
247 * A canonical lowercase v4 UUID.
248 *
249 * `docs/TELEMETRY.md` and `envelope.rs` both say v4, and `Uuid::new_v4()` only
250 * ever produces this form. The client's own read path accepts any parseable
251 * UUID, so this is marginally stricter than the client — deliberately: an
252 * `install_id.json` hand-written by something other than Codewhale is exactly
253 * the input this endpoint should refuse, and refusing costs the user nothing
254 * because the client drops rejected batches silently.
255 */
256 const INSTALL_ID_RE =
257 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
258
259 /** `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`, plus the inferred length ceiling. */
260 const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
261
262 /** First 12 lowercase hex chars — `envelope::short_hex_sha`. */
263 const GIT_SHA_RE = /^[0-9a-f]{12}$/;
264
265 /** `event::is_reduced_panic_site` — the literal `<dep>`, or a `crates/…` site. */
266 const PANIC_SITE_RE = /^crates\/[A-Za-z0-9_/.-]+\.rs:\d+:\d+$/;
267
268 /**
269 * `ProviderKind::as_str()` shape.
270 *
271 * This is the one field whose *value* space the endpoint cannot close: the
272 * authoritative list is `codewhale_config::provider::all_providers()`, a Rust
273 * registry with no generated artifact to read, and hard-coding a copy here
274 * would drift into silently dropping a real user's route. The client closes it
275 * with `Event::is_bounded` -> `is_known_provider_id` before the POST is made;
276 * the server enforces the shape a closed `&'static str` enum can produce
277 * (lowercase, hyphen-joined, bounded) and the doc's sorted-and-deduplicated
278 * rule, which is what catches a client that started sending the customer's own
279 * `[providers.<name>]` table key.
280 */
281 const PROVIDER_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
282
283 const U32_MAX = 4294967295;
284
285 /** Derived contract for CWC's product-only ingress; runtime events stay here. */
286 export const CWC_PRODUCT_SCHEMA = {
287 $schema: "http://json-schema.org/draft-07/schema#",
288 $id: "https://codewhale.net/schemas/cwc-product-telemetry-v3.json",
289 title: "Codewhale CWC aggregate telemetry v3, notice v5",
290 description: "Generated from telemetry-ingest/src/schema.ts; do not edit the JSON artifact. No content or identity fields.",
291 type: "object",
292 additionalProperties: false,
293 required: ENVELOPE_FIELDS,
294 properties: {
295 schema_version: { const: SCHEMA_VERSION },
296 notice_version: { const: NOTICE_VERSION },
297 sent_at: { type: "string", pattern: SENT_AT_RE.source },
298 install_id: { type: "string", pattern: INSTALL_ID_RE.source },
299 app_version: { type: "string", maxLength: MAX_VERSION_LEN, pattern: VERSION_RE.source },
300 git_sha: { const: null },
301 surface: { enum: SURFACES.filter((surface) => surface === "web-app" || surface === "desktop") },
302 os: { const: "other" },
303 arch: { const: "other" },
304 libc: { const: "none" },
305 tty: { const: false },
306 events: {
307 type: "array",
308 minItems: 1,
309 maxItems: 1,
310 items: {
311 type: "object",
312 additionalProperties: false,
313 required: EVENT_FIELDS.product_usage,
314 properties: {
315 event: { const: "product_usage" },
316 counters: {
317 type: "object",
318 additionalProperties: false,
319 required: PRODUCT_COUNTER_FIELDS,
320 properties: Object.fromEntries(PRODUCT_COUNTER_FIELDS.map((field) => [
321 field, { type: "integer", minimum: 0, maximum: U32_MAX },
322 ])),
323 },
324 },
325 },
326 },
327 },
328 } as const;
329
330 // ------------------------------------------------------------------ validation
331
332 /** A rejection carries a reason for the tests. It is never sent to a client. */
333 export type Rejection = { ok: false; reason: string };
334
335 /** A batch that satisfies every published rule. */
336 export type Accepted = { ok: true; batch: Batch };
337
338 export type Counters = Record<(typeof COUNTER_FIELDS)[number], number>;
339 export type Errors = Record<(typeof ERROR_FIELDS)[number], number>;
340 export type TurnWall = Record<(typeof TURN_WALL_FIELDS)[number], number>;
341
342 export type ProductCounters = Record<(typeof PRODUCT_COUNTER_FIELDS)[number], number>;
343
344 export type Event =
345 | { event: "install_or_upgrade"; kind: string; previous_version: string | null }
346 | { event: "session_start"; source: string }
347 | {
348 event: "session_end";
349 duration_bucket: string;
350 exit_class: string;
351 cold_start_bucket: string | null;
352 providers: string[];
353 counters: Counters;
354 errors: Errors;
355 turn_wall: TurnWall;
356 }
357 | { event: "panic"; site: string }
358 | { event: "product_usage"; counters: ProductCounters }
359 | ({ event: "operations_summary" } & Record<(typeof OPERATIONS_FIELDS)[number], number>);
360
361 export interface Batch {
362 schema_version: number;
363 consent_version?: number;
364 notice_version?: number;
365 sent_at: string;
366 install_id: string;
367 app_version: string;
368 git_sha: string | null;
369 surface: string;
370 os: string;
371 arch: string;
372 libc: string;
373 tty: boolean;
374 events: Event[];
375 }
376
377 function isPlainObject(value: unknown): value is Record<string, unknown> {
378 return (
379 typeof value === "object" && value !== null && !Array.isArray(value)
380 );
381 }
382
383 /** Exact key-set equality. Missing keys and extra keys are both fatal. */
384 function keysExactly(
385 value: Record<string, unknown>,
386 expected: readonly string[],
387 where: string,
388 ): string | null {
389 const actual = Object.keys(value);
390 if (actual.length !== expected.length) {
391 const extra = actual.filter((key) => !expected.includes(key));
392 if (extra.length > 0) return `${where}: unexpected key ${extra[0]}`;
393 const missing = expected.filter((key) => !actual.includes(key));
394 return `${where}: missing key ${missing[0]}`;
395 }
396 for (const key of actual) {
397 if (!expected.includes(key)) return `${where}: unexpected key ${key}`;
398 }
399 return null;
400 }
401
402 function enumString(
403 value: unknown,
404 allowed: readonly string[],
405 where: string,
406 ): string | null {
407 if (typeof value !== "string" || !allowed.includes(value)) {
408 return `${where}: not one of the documented values`;
409 }
410 return null;
411 }
412
413 function u32Map(
414 value: unknown,
415 fields: readonly string[],
416 where: string,
417 ): string | null {
418 if (!isPlainObject(value)) return `${where}: not an object`;
419 const keyError = keysExactly(value, fields, where);
420 if (keyError) return keyError;
421 for (const field of fields) {
422 const item = value[field];
423 if (
424 typeof item !== "number" ||
425 !Number.isInteger(item) ||
426 item < 0 ||
427 item > U32_MAX
428 ) {
429 return `${where}.${field}: not a u32`;
430 }
431 }
432 return null;
433 }
434
435 function validateEvent(value: unknown, where: string): string | null {
436 if (!isPlainObject(value)) return `${where}: not an object`;
437 const name = value.event;
438 if (typeof name !== "string" || !Object.hasOwn(EVENT_FIELDS, name)) {
439 return `${where}: unknown event discriminant`;
440 }
441 const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
442 if (keyError) return keyError;
443
444 switch (name) {
445 case "install_or_upgrade": {
446 const kindError = enumString(
447 value.kind,
448 INSTALL_KINDS,
449 `${where}.kind`,
450 );
451 if (kindError) return kindError;
452 const previous = value.previous_version;
453 if (previous !== null) {
454 if (
455 typeof previous !== "string" ||
456 previous.length > MAX_VERSION_LEN ||
457 !VERSION_RE.test(previous)
458 ) {
459 return `${where}.previous_version: not a release version string`;
460 }
461 }
462 return null;
463 }
464 case "session_start":
465 return enumString(value.source, SESSION_SOURCES, `${where}.source`);
466 case "session_end": {
467 const bucketError = enumString(
468 value.duration_bucket,
469 DURATION_BUCKETS,
470 `${where}.duration_bucket`,
471 );
472 if (bucketError) return bucketError;
473 const exitError = enumString(
474 value.exit_class,
475 EXIT_CLASSES,
476 `${where}.exit_class`,
477 );
478 if (exitError) return exitError;
479 if (value.cold_start_bucket !== null) {
480 const coldError = enumString(
481 value.cold_start_bucket,
482 COLD_START_BUCKETS,
483 `${where}.cold_start_bucket`,
484 );
485 if (coldError) return coldError;
486 }
487 const providers = value.providers;
488 if (!Array.isArray(providers)) return `${where}.providers: not an array`;
489 if (providers.length > MAX_PROVIDERS) {
490 return `${where}.providers: too many entries`;
491 }
492 let previous: string | null = null;
493 for (const provider of providers) {
494 if (typeof provider !== "string" || !PROVIDER_RE.test(provider)) {
495 return `${where}.providers: not a provider id`;
496 }
497 // The doc says "sorted, deduplicated". A client that started shipping
498 // the customer's own `[providers.<name>]` table key would land here
499 // first, because an unsorted or repeated list is the cheapest signal
500 // that this array stopped coming from `ProviderKind::as_str()`.
501 if (previous !== null && provider <= previous) {
502 return `${where}.providers: not sorted and deduplicated`;
503 }
504 previous = provider;
505 }
506 return (
507 u32Map(value.counters, COUNTER_FIELDS, `${where}.counters`) ??
508 u32Map(value.errors, ERROR_FIELDS, `${where}.errors`) ??
509 u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
510 );
511 }
512 case "operations_summary": {
513 const { event: _event, ...counts } = value;
514 return u32Map(counts, OPERATIONS_FIELDS, where);
515 }
516 case "product_usage":
517 return u32Map(value.counters, PRODUCT_COUNTER_FIELDS, `${where}.counters`);
518 case "panic": {
519 const site = value.site;
520 if (typeof site !== "string" || site.length > MAX_PANIC_SITE_LEN) {
521 return `${where}.site: not a string`;
522 }
523 if (site !== "<dep>" && !PANIC_SITE_RE.test(site)) {
524 return `${where}.site: not a reduced panic site`;
525 }
526 return null;
527 }
528 default:
529 return `${where}: unknown event discriminant`;
530 }
531 }
532
533 /**
534 * Validate one decoded batch against the published schema.
535 *
536 * Returns the batch on success. On failure the reason is for tests and local
537 * reasoning only — the handler answers with a bare status and no body, because
538 * echoing a parse error back is a way to learn what the endpoint stores.
539 */
540 export function validateBatch(value: unknown): Accepted | Rejection {
541 if (!isPlainObject(value)) return { ok: false, reason: "batch: not an object" };
542
543 const legacy = value.schema_version === 1;
544 const explicitConsent = value.schema_version === 2;
545 if (!legacy && !explicitConsent && value.schema_version !== SCHEMA_VERSION) {
546 return { ok: false, reason: "batch.schema_version: unsupported" };
547 }
548 const fields = legacy ? LEGACY_ENVELOPE_FIELDS : explicitConsent ? V2_ENVELOPE_FIELDS : ENVELOPE_FIELDS;
549 const keyError = keysExactly(value, fields, "batch");
550 if (keyError) return { ok: false, reason: keyError };
551 if (explicitConsent && value.consent_version !== CONSENT_VERSION) {
552 return { ok: false, reason: "batch.consent_version: explicit current consent required" };
553 }
554 if (!legacy && !explicitConsent && value.notice_version !== NOTICE_VERSION) {
555 return { ok: false, reason: "batch.notice_version: current policy required" };
556 }
557 if (typeof value.sent_at !== "string" || !SENT_AT_RE.test(value.sent_at)) {
558 return { ok: false, reason: "batch.sent_at: not RFC3339 UTC seconds" };
559 }
560 if (
561 typeof value.install_id !== "string" ||
562 !INSTALL_ID_RE.test(value.install_id)
563 ) {
564 return { ok: false, reason: "batch.install_id: not a v4 uuid" };
565 }
566 if (
567 typeof value.app_version !== "string" ||
568 value.app_version.length > MAX_VERSION_LEN ||
569 !VERSION_RE.test(value.app_version)
570 ) {
571 return { ok: false, reason: "batch.app_version: not a release version" };
572 }
573 if (value.git_sha !== null) {
574 if (typeof value.git_sha !== "string" || !GIT_SHA_RE.test(value.git_sha)) {
575 return { ok: false, reason: "batch.git_sha: not 12 hex chars or null" };
576 }
577 }
578 const enumErrors =
579 enumString(value.surface, legacy ? LEGACY_SURFACES : SURFACES, "batch.surface") ??
580 enumString(value.os, OSES, "batch.os") ??
581 enumString(value.arch, ARCHES, "batch.arch") ??
582 enumString(value.libc, LIBCS, "batch.libc");
583 if (enumErrors) return { ok: false, reason: enumErrors };
584
585 if (typeof value.tty !== "boolean") {
586 return { ok: false, reason: "batch.tty: not a boolean" };
587 }
588 if (!Array.isArray(value.events)) {
589 return { ok: false, reason: "batch.events: not an array" };
590 }
591 if (value.events.length > BATCH_MAX_EVENTS) {
592 return { ok: false, reason: "batch.events: over BATCH_MAX_EVENTS" };
593 }
594 for (let index = 0; index < value.events.length; index += 1) {
595 const event = value.events[index];
596 if (isPlainObject(event)) {
597 if (legacy && (event.event === "product_usage" || event.event === "operations_summary")) {
598 return { ok: false, reason: "events: aggregate product/operations events require schema v2 or v3" };
599 }
600 if (event.event === "operations_summary" && value.surface !== "control-plane") {
601 return { ok: false, reason: "events: operations_summary requires control-plane surface" };
602 }
603 }
604 const eventError = validateEvent(event, `events[${index}]`);
605 if (eventError) return { ok: false, reason: eventError };
606 }
607
608 return { ok: true, batch: value as unknown as Batch };
609 }
610
610 lines TYPESCRIPT