* The published Codewhale telemetry schema, transcribed from `docs/TELEMETRY.md`
* and enforced as a **closed** field set.
*
* The doc is the promise; this file is the enforcement. `test/schema-doc.test.ts`
* parses the field names and enum spellings back out of `docs/TELEMETRY.md` and
* asserts set equality against the constants below, so a doc that grows a field
* this validator does not know — or a validator that grows a field the doc does
* not publish — fails the build rather than quietly accepting new data.
*
* Why closed rather than "ignore what we don't recognise": a future client bug
* that starts attaching a path, a prompt, or a provider table name must be
* refused by the server, not stored. Unknown key anywhere in the batch rejects
* the whole batch. There is no sanitising path — a payload the schema cannot
* account for is not made safe by editing it, which is the same rule
* `Event::is_bounded` applies on the client's drain path.
*/
export const SCHEMA_VERSION = 3;
export const NOTICE_VERSION = 5;
export const CONSENT_VERSION = 4;
export const BATCH_MAX_EVENTS = 200;
export const BATCH_MAX_BYTES = 64 * 1024;
* Hard body cap, computed rather than guessed.
*
* The client assembles at most `BATCH_MAX_EVENTS` (200) buffered lines totalling
* at most `BATCH_MAX_BYTES` (65536) bytes — `actor::parse_events` breaks
* *before* appending a line that would cross either bound, so both are ceilings,
* and the batch is re-serialized by the same `serde` impl that wrote the lines,
* so the event bytes on the wire equal the bytes on disk. On top of that the
* envelope costs:
*
* - ~200 bytes of fixed keys and JSON punctuation,
* - ~175 bytes of envelope values (`sent_at` 20, `install_id` 36,
* `app_version` <= 64, `git_sha` 12, and four short enums),
* - 199 commas between the 200 events.
*
* 65536 + 200 + 175 + 199 = 66110 bytes is therefore the true maximum a
* conforming client can send. 72 KiB leaves ~11% headroom for a future envelope
* field without leaving room for a payload that is a different shape entirely.
*
* The disk rings (`buffer::MAX_EVENTS` = 512, `buffer::MAX_BYTES` = 256 KiB) are
* larger, but they are the *storage* cap: a 512-record ring drains as three
* batches, never as one 256 KiB POST.
*/
export const MAX_BODY_BYTES = 72 * 1024;
* Server-side length ceiling for the two version strings.
*
* `docs/TELEMETRY.md` pins their *shape*
* (`^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`) but not their length — a pre-release
* suffix is unbounded in the published regex. INFERRED, not published: 64 bytes
* is far past any real Cargo version and stops the one envelope field with an
* open tail from becoming a free-form string slot.
*/
export const MAX_VERSION_LEN = 64;
* Server-side length ceiling for a reduced panic site. INFERRED: the published
* rule is a charset, not a length. Real sites are well under 120 bytes.
*/
export const MAX_PANIC_SITE_LEN = 256;
* Server-side cardinality ceiling for `providers`. INFERRED: the published rule
* closes the *value* space via `ProviderKind::as_str()`, not the array length.
*/
export const MAX_PROVIDERS = 32;
export const SURFACES = [
"tui",
"exec",
"cli",
"app-server",
"mcp-server",
"serve",
"website",
"web-app",
"desktop",
"control-plane",
"vscode-extension",
] as const;
const LEGACY_SURFACES = SURFACES.slice(0, 6);
export const OSES = [
"linux",
"macos",
"windows",
"freebsd",
"android",
"other",
] as const;
export const ARCHES = ["x86_64", "aarch64", "other"] as const;
export const LIBCS = ["gnu", "musl", "none"] as const;
export const INSTALL_KINDS = ["install", "upgrade", "downgrade"] as const;
export const SESSION_SOURCES = [
"interactive",
"resume",
"fork",
"api",
"unknown",
] as const;
export const DURATION_BUCKETS = [
"lt_1m",
"1m_10m",
"10m_60m",
"gt_60m",
] as const;
export const EXIT_CLASSES = ["clean", "signal", "panic", "error"] as const;
export const COLD_START_BUCKETS = [
"lt_250",
"250_1000",
"1000_3000",
"gte_3000",
] as const;
export const ENVELOPE_FIELDS = [
"schema_version",
"notice_version",
"sent_at",
"install_id",
"app_version",
"git_sha",
"surface",
"os",
"arch",
"libc",
"tty",
"events",
] as const;
export const LEGACY_ENVELOPE_FIELDS = ENVELOPE_FIELDS.filter((field) => field !== "notice_version");
export const V2_ENVELOPE_FIELDS = [...LEGACY_ENVELOPE_FIELDS, "consent_version"];
export const PRODUCT_COUNTER_FIELDS = [
"page_view", "docs_view", "install_copy", "download", "signup", "login",
"session_create", "session_resume", "turn_submit", "turn_complete",
"settings_open", "integration_connect", "error_shown",
] as const;
export const OPERATIONS_FIELDS = [
"requests", "errors", "duration_ms_total", "duration_ms_max", "probes", "probes_failed",
] as const;
export const COUNTER_FIELDS = [
"turns",
"tool_calls",
"fleet_dispatch",
"workflow_run",
"subagent_spawn",
"mcp_server_connected",
"memory_search",
"approval_modal_shown",
"approval_auto_allowed",
"command_palette_open",
] as const;
export const ERROR_FIELDS = [
"auth_preflight_failed",
"provider_http_4xx",
"provider_http_5xx",
"tool_denied_by_policy",
"tool_timeout",
"network_error",
] as const;
export const TURN_WALL_FIELDS = [
"lt_5s",
"5_30s",
"30_120s",
"gte_120s",
] as const;
* Every event variant's complete key set, `event` tag included.
*
* `serde(tag = "event")` makes the wire form flat, so the tag is a key like any
* other and the variant set is closed.
*/
export const EVENT_FIELDS: Readonly<Record<string, readonly string[]>> = {
install_or_upgrade: ["event", "kind", "previous_version"],
session_start: ["event", "source"],
session_end: [
"event",
"duration_bucket",
"exit_class",
"cold_start_bucket",
"providers",
"counters",
"errors",
"turn_wall",
],
panic: ["event", "site"],
product_usage: ["event", "counters"],
operations_summary: ["event", ...OPERATIONS_FIELDS],
};
export const EVENT_NAMES = Object.keys(EVENT_FIELDS);
const SENT_AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
* A canonical lowercase v4 UUID.
*
* `docs/TELEMETRY.md` and `envelope.rs` both say v4, and `Uuid::new_v4()` only
* ever produces this form. The client's own read path accepts any parseable
* UUID, so this is marginally stricter than the client — deliberately: an
* `install_id.json` hand-written by something other than Codewhale is exactly
* the input this endpoint should refuse, and refusing costs the user nothing
* because the client drops rejected batches silently.
*/
const INSTALL_ID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const VERSION_RE = /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/;
const GIT_SHA_RE = /^[0-9a-f]{12}$/;
const PANIC_SITE_RE = /^crates\/[A-Za-z0-9_/.-]+\.rs:\d+:\d+$/;
* `ProviderKind::as_str()` shape.
*
* This is the one field whose *value* space the endpoint cannot close: the
* authoritative list is `codewhale_config::provider::all_providers()`, a Rust
* registry with no generated artifact to read, and hard-coding a copy here
* would drift into silently dropping a real user's route. The client closes it
* with `Event::is_bounded` -> `is_known_provider_id` before the POST is made;
* the server enforces the shape a closed `&'static str` enum can produce
* (lowercase, hyphen-joined, bounded) and the doc's sorted-and-deduplicated
* rule, which is what catches a client that started sending the customer's own
* `[providers.<name>]` table key.
*/
const PROVIDER_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
const U32_MAX = 4294967295;
export const CWC_PRODUCT_SCHEMA = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://codewhale.net/schemas/cwc-product-telemetry-v3.json",
title: "Codewhale CWC aggregate telemetry v3, notice v5",
description: "Generated from telemetry-ingest/src/schema.ts; do not edit the JSON artifact. No content or identity fields.",
type: "object",
additionalProperties: false,
required: ENVELOPE_FIELDS,
properties: {
schema_version: { const: SCHEMA_VERSION },
notice_version: { const: NOTICE_VERSION },
sent_at: { type: "string", pattern: SENT_AT_RE.source },
install_id: { type: "string", pattern: INSTALL_ID_RE.source },
app_version: { type: "string", maxLength: MAX_VERSION_LEN, pattern: VERSION_RE.source },
git_sha: { const: null },
surface: { enum: SURFACES.filter((surface) => surface === "web-app" || surface === "desktop") },
os: { const: "other" },
arch: { const: "other" },
libc: { const: "none" },
tty: { const: false },
events: {
type: "array",
minItems: 1,
maxItems: 1,
items: {
type: "object",
additionalProperties: false,
required: EVENT_FIELDS.product_usage,
properties: {
event: { const: "product_usage" },
counters: {
type: "object",
additionalProperties: false,
required: PRODUCT_COUNTER_FIELDS,
properties: Object.fromEntries(PRODUCT_COUNTER_FIELDS.map((field) => [
field, { type: "integer", minimum: 0, maximum: U32_MAX },
])),
},
},
},
},
},
} as const;
export type Rejection = { ok: false; reason: string };
export type Accepted = { ok: true; batch: Batch };
export type Counters = Record<(typeof COUNTER_FIELDS)[number], number>;
export type Errors = Record<(typeof ERROR_FIELDS)[number], number>;
export type TurnWall = Record<(typeof TURN_WALL_FIELDS)[number], number>;
export type ProductCounters = Record<(typeof PRODUCT_COUNTER_FIELDS)[number], number>;
export type Event =
| { event: "install_or_upgrade"; kind: string; previous_version: string | null }
| { event: "session_start"; source: string }
| {
event: "session_end";
duration_bucket: string;
exit_class: string;
cold_start_bucket: string | null;
providers: string[];
counters: Counters;
errors: Errors;
turn_wall: TurnWall;
}
| { event: "panic"; site: string }
| { event: "product_usage"; counters: ProductCounters }
| ({ event: "operations_summary" } & Record<(typeof OPERATIONS_FIELDS)[number], number>);
export interface Batch {
schema_version: number;
consent_version?: number;
notice_version?: number;
sent_at: string;
install_id: string;
app_version: string;
git_sha: string | null;
surface: string;
os: string;
arch: string;
libc: string;
tty: boolean;
events: Event[];
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" && value !== null && !Array.isArray(value)
);
}
function keysExactly(
value: Record<string, unknown>,
expected: readonly string[],
where: string,
): string | null {
const actual = Object.keys(value);
if (actual.length !== expected.length) {
const extra = actual.filter((key) => !expected.includes(key));
if (extra.length > 0) return `${where}: unexpected key ${extra[0]}`;
const missing = expected.filter((key) => !actual.includes(key));
return `${where}: missing key ${missing[0]}`;
}
for (const key of actual) {
if (!expected.includes(key)) return `${where}: unexpected key ${key}`;
}
return null;
}
function enumString(
value: unknown,
allowed: readonly string[],
where: string,
): string | null {
if (typeof value !== "string" || !allowed.includes(value)) {
return `${where}: not one of the documented values`;
}
return null;
}
function u32Map(
value: unknown,
fields: readonly string[],
where: string,
): string | null {
if (!isPlainObject(value)) return `${where}: not an object`;
const keyError = keysExactly(value, fields, where);
if (keyError) return keyError;
for (const field of fields) {
const item = value[field];
if (
typeof item !== "number" ||
!Number.isInteger(item) ||
item < 0 ||
item > U32_MAX
) {
return `${where}.${field}: not a u32`;
}
}
return null;
}
function validateEvent(value: unknown, where: string): string | null {
if (!isPlainObject(value)) return `${where}: not an object`;
const name = value.event;
if (typeof name !== "string" || !Object.hasOwn(EVENT_FIELDS, name)) {
return `${where}: unknown event discriminant`;
}
const keyError = keysExactly(value, EVENT_FIELDS[name], `${where}(${name})`);
if (keyError) return keyError;
switch (name) {
case "install_or_upgrade": {
const kindError = enumString(
value.kind,
INSTALL_KINDS,
`${where}.kind`,
);
if (kindError) return kindError;
const previous = value.previous_version;
if (previous !== null) {
if (
typeof previous !== "string" ||
previous.length > MAX_VERSION_LEN ||
!VERSION_RE.test(previous)
) {
return `${where}.previous_version: not a release version string`;
}
}
return null;
}
case "session_start":
return enumString(value.source, SESSION_SOURCES, `${where}.source`);
case "session_end": {
const bucketError = enumString(
value.duration_bucket,
DURATION_BUCKETS,
`${where}.duration_bucket`,
);
if (bucketError) return bucketError;
const exitError = enumString(
value.exit_class,
EXIT_CLASSES,
`${where}.exit_class`,
);
if (exitError) return exitError;
if (value.cold_start_bucket !== null) {
const coldError = enumString(
value.cold_start_bucket,
COLD_START_BUCKETS,
`${where}.cold_start_bucket`,
);
if (coldError) return coldError;
}
const providers = value.providers;
if (!Array.isArray(providers)) return `${where}.providers: not an array`;
if (providers.length > MAX_PROVIDERS) {
return `${where}.providers: too many entries`;
}
let previous: string | null = null;
for (const provider of providers) {
if (typeof provider !== "string" || !PROVIDER_RE.test(provider)) {
return `${where}.providers: not a provider id`;
}
if (previous !== null && provider <= previous) {
return `${where}.providers: not sorted and deduplicated`;
}
previous = provider;
}
return (
u32Map(value.counters, COUNTER_FIELDS, `${where}.counters`) ??
u32Map(value.errors, ERROR_FIELDS, `${where}.errors`) ??
u32Map(value.turn_wall, TURN_WALL_FIELDS, `${where}.turn_wall`)
);
}
case "operations_summary": {
const { event: _event, ...counts } = value;
return u32Map(counts, OPERATIONS_FIELDS, where);
}
case "product_usage":
return u32Map(value.counters, PRODUCT_COUNTER_FIELDS, `${where}.counters`);
case "panic": {
const site = value.site;
if (typeof site !== "string" || site.length > MAX_PANIC_SITE_LEN) {
return `${where}.site: not a string`;
}
if (site !== "<dep>" && !PANIC_SITE_RE.test(site)) {
return `${where}.site: not a reduced panic site`;
}
return null;
}
default:
return `${where}: unknown event discriminant`;
}
}
* Validate one decoded batch against the published schema.
*
* Returns the batch on success. On failure the reason is for tests and local
* reasoning only — the handler answers with a bare status and no body, because
* echoing a parse error back is a way to learn what the endpoint stores.
*/
export function validateBatch(value: unknown): Accepted | Rejection {
if (!isPlainObject(value)) return { ok: false, reason: "batch: not an object" };
const legacy = value.schema_version === 1;
const explicitConsent = value.schema_version === 2;
if (!legacy && !explicitConsent && value.schema_version !== SCHEMA_VERSION) {
return { ok: false, reason: "batch.schema_version: unsupported" };
}
const fields = legacy ? LEGACY_ENVELOPE_FIELDS : explicitConsent ? V2_ENVELOPE_FIELDS : ENVELOPE_FIELDS;
const keyError = keysExactly(value, fields, "batch");
if (keyError) return { ok: false, reason: keyError };
if (explicitConsent && value.consent_version !== CONSENT_VERSION) {
return { ok: false, reason: "batch.consent_version: explicit current consent required" };
}
if (!legacy && !explicitConsent && value.notice_version !== NOTICE_VERSION) {
return { ok: false, reason: "batch.notice_version: current policy required" };
}
if (typeof value.sent_at !== "string" || !SENT_AT_RE.test(value.sent_at)) {
return { ok: false, reason: "batch.sent_at: not RFC3339 UTC seconds" };
}
if (
typeof value.install_id !== "string" ||
!INSTALL_ID_RE.test(value.install_id)
) {
return { ok: false, reason: "batch.install_id: not a v4 uuid" };
}
if (
typeof value.app_version !== "string" ||
value.app_version.length > MAX_VERSION_LEN ||
!VERSION_RE.test(value.app_version)
) {
return { ok: false, reason: "batch.app_version: not a release version" };
}
if (value.git_sha !== null) {
if (typeof value.git_sha !== "string" || !GIT_SHA_RE.test(value.git_sha)) {
return { ok: false, reason: "batch.git_sha: not 12 hex chars or null" };
}
}
const enumErrors =
enumString(value.surface, legacy ? LEGACY_SURFACES : SURFACES, "batch.surface") ??
enumString(value.os, OSES, "batch.os") ??
enumString(value.arch, ARCHES, "batch.arch") ??
enumString(value.libc, LIBCS, "batch.libc");
if (enumErrors) return { ok: false, reason: enumErrors };
if (typeof value.tty !== "boolean") {
return { ok: false, reason: "batch.tty: not a boolean" };
}
if (!Array.isArray(value.events)) {
return { ok: false, reason: "batch.events: not an array" };
}
if (value.events.length > BATCH_MAX_EVENTS) {
return { ok: false, reason: "batch.events: over BATCH_MAX_EVENTS" };
}
for (let index = 0; index < value.events.length; index += 1) {
const event = value.events[index];
if (isPlainObject(event)) {
if (legacy && (event.event === "product_usage" || event.event === "operations_summary")) {
return { ok: false, reason: "events: aggregate product/operations events require schema v2 or v3" };
}
if (event.event === "operations_summary" && value.surface !== "control-plane") {
return { ok: false, reason: "events: operations_summary requires control-plane surface" };
}
}
const eventError = validateEvent(event, `events[${index}]`);
if (eventError) return { ok: false, reason: eventError };
}
return { ok: true, batch: value as unknown as Batch };
}