返回 CodeWhale
event.rs
根目录 / crates / telemetry / src / event.rs
1 //! The wire schema. This module is the whole of what may leave the machine.
2 //!
3 //! Every field below is an integer, a boolean, or a **closed enum string**,
4 //! except exactly three bounded strings: `app_version`, `git_sha`, and
5 //! `panic_site`. Each of those three has a written rule and a test pinning the
6 //! rule. There is no free-form string type here and no open-keyed map, which is
7 //! what makes "aggregates and events, never content" a property the compiler
8 //! and the test suite can enforce rather than a promise.
9 //!
10 //! Bounded is not the same as *built bounded*. These types are also a
11 //! **deserialization target**: `flush` reads `buffer.jsonl` back off disk and
12 //! hands the lines to `serde`, which will fill `site`, `previous_version`, and
13 //! `providers` with any string the file contains. Anything running as the user
14 //! can append to that file, `$CODEWHALE_HOME` is a predictable path, and this
15 //! product executes model-authored shell commands. [`Event::is_bounded`] is
16 //! therefore checked on the drain path, and it is the reason the guarantee
17 //! above survives contact with the filesystem.
18 //!
19 //! Three standing rules for anyone extending this file:
20 //!
21 //! 1. **Never `#[derive(Serialize)]` over an existing state type.**
22 //! `codewhale_state::Thread` carries `git_sha`, `git_branch`,
23 //! `git_origin_url`, `cwd`, and `path`. A payload builder that accepts one
24 //! and derives breaches the red lines in a single line. Every struct here is
25 //! built from scratch with explicit fields.
26 //! 2. **Bump [`SCHEMA_VERSION`] on any field add, remove, or retype**, and
27 //! never reuse a number. The golden snapshot test fails until you do.
28 //! 3. **A new string field needs a clause in [`Event::is_bounded`]**, not just
29 //! a doc comment naming its rule. A rule only the constructor honours is a
30 //! rule the drain path does not have.
31
32 use serde::{Deserialize, Serialize};
33
34 /// Wire schema version. Bumped on any field add, remove, or retype; never
35 /// reused. `crates/telemetry/tests/golden/v1.json` pins what v1 was.
36 pub const SCHEMA_VERSION: u32 = 3;
37
38 /// Default-on policy disclosure version. This is not human consent.
39 pub const NOTICE_VERSION: u32 = 5;
40
41 /// Which product surface produced a batch.
42 ///
43 /// Deliberately **not** derived from the executable: `codewhale-tui` serves at
44 /// least five surfaces, and app-server runs in-process inside `codewhale`, so
45 /// `current_exe()` would report every app-server session as CLI. Each
46 /// subcommand dispatch names its own surface.
47 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48 #[serde(rename_all = "kebab-case")]
49 pub enum Surface {
50 /// The interactive terminal UI.
51 Tui,
52 /// `codewhale exec` — one non-interactive run.
53 Exec,
54 /// Terminal `config` / `auth` / `update` subcommands.
55 Cli,
56 /// The app-server protocol surface (in-process inside `codewhale`).
57 AppServer,
58 /// The MCP server surface.
59 McpServer,
60 /// `codewhale serve`.
61 Serve,
62 /// Public website product interactions.
63 Website,
64 /// Browser application interactions.
65 WebApp,
66 /// Native desktop application interactions.
67 Desktop,
68 /// Control-plane application interactions.
69 ControlPlane,
70 }
71
72 impl Surface {
73 /// Every surface, for exhaustive iteration in tests and schema checks.
74 pub const ALL: &'static [Self] = &[
75 Self::Tui,
76 Self::Exec,
77 Self::Cli,
78 Self::AppServer,
79 Self::McpServer,
80 Self::Serve,
81 Self::Website,
82 Self::WebApp,
83 Self::Desktop,
84 Self::ControlPlane,
85 ];
86
87 /// The wire spelling.
88 #[must_use]
89 pub fn as_str(self) -> &'static str {
90 match self {
91 Self::Tui => "tui",
92 Self::Exec => "exec",
93 Self::Cli => "cli",
94 Self::AppServer => "app-server",
95 Self::McpServer => "mcp-server",
96 Self::Serve => "serve",
97 Self::Website => "website",
98 Self::WebApp => "web-app",
99 Self::Desktop => "desktop",
100 Self::ControlPlane => "control-plane",
101 }
102 }
103 }
104
105 /// Operating system family. A closed whitelist, so an unrecognised
106 /// `std::env::consts::OS` reports `other` rather than shipping a novel string.
107 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108 #[serde(rename_all = "snake_case")]
109 pub enum Os {
110 /// Linux.
111 Linux,
112 /// macOS.
113 Macos,
114 /// Windows.
115 Windows,
116 /// FreeBSD.
117 Freebsd,
118 /// Android.
119 Android,
120 /// Anything else.
121 Other,
122 }
123
124 impl Os {
125 /// Every value, for exhaustive iteration.
126 pub const ALL: &'static [Self] = &[
127 Self::Linux,
128 Self::Macos,
129 Self::Windows,
130 Self::Freebsd,
131 Self::Android,
132 Self::Other,
133 ];
134
135 /// The wire spelling.
136 #[must_use]
137 pub fn as_str(self) -> &'static str {
138 match self {
139 Self::Linux => "linux",
140 Self::Macos => "macos",
141 Self::Windows => "windows",
142 Self::Freebsd => "freebsd",
143 Self::Android => "android",
144 Self::Other => "other",
145 }
146 }
147 }
148
149 /// CPU family.
150 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151 #[serde(rename_all = "snake_case")]
152 pub enum Arch {
153 /// 64-bit x86.
154 X86_64,
155 /// 64-bit ARM.
156 Aarch64,
157 /// Anything else.
158 Other,
159 }
160
161 impl Arch {
162 /// Every value, for exhaustive iteration.
163 pub const ALL: &'static [Self] = &[Self::X86_64, Self::Aarch64, Self::Other];
164
165 /// The wire spelling.
166 #[must_use]
167 pub fn as_str(self) -> &'static str {
168 match self {
169 Self::X86_64 => "x86_64",
170 Self::Aarch64 => "aarch64",
171 Self::Other => "other",
172 }
173 }
174 }
175
176 /// C runtime the binary was **compiled** against.
177 ///
178 /// Compile-time (`cfg!(target_env)`), never runtime-detected: the only way to
179 /// read this at runtime is `/etc/os-release` or shelling to `ldd`, both of which
180 /// surface corporate golden-image vendor strings.
181 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182 #[serde(rename_all = "snake_case")]
183 pub enum Libc {
184 /// glibc.
185 Gnu,
186 /// musl.
187 Musl,
188 /// Not a libc target (macOS, Windows, and anything else).
189 None,
190 }
191
192 impl Libc {
193 /// Every value, for exhaustive iteration.
194 pub const ALL: &'static [Self] = &[Self::Gnu, Self::Musl, Self::None];
195
196 /// The wire spelling.
197 #[must_use]
198 pub fn as_str(self) -> &'static str {
199 match self {
200 Self::Gnu => "gnu",
201 Self::Musl => "musl",
202 Self::None => "none",
203 }
204 }
205 }
206
207 /// Whether this binary is newly installed, upgraded, or downgraded.
208 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209 #[serde(rename_all = "snake_case")]
210 pub enum InstallKind {
211 /// No prior version record on this machine.
212 Install,
213 /// The recorded version is older than this one.
214 Upgrade,
215 /// The recorded version is newer than this one.
216 Downgrade,
217 }
218
219 impl InstallKind {
220 /// Every value, for exhaustive iteration.
221 pub const ALL: &'static [Self] = &[Self::Install, Self::Upgrade, Self::Downgrade];
222
223 /// The wire spelling.
224 #[must_use]
225 pub fn as_str(self) -> &'static str {
226 match self {
227 Self::Install => "install",
228 Self::Upgrade => "upgrade",
229 Self::Downgrade => "downgrade",
230 }
231 }
232 }
233
234 /// How a session was started.
235 ///
236 /// Mirrors `codewhale_state::SessionSource` by value, deliberately re-declared
237 /// here rather than imported: this crate must not depend on the thread store,
238 /// which is the one crate whose types carry paths and git identity.
239 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240 #[serde(rename_all = "snake_case")]
241 pub enum SessionSource {
242 /// A user opened a session directly.
243 Interactive,
244 /// Resumed from a persisted session.
245 Resume,
246 /// Forked from an existing conversation.
247 Fork,
248 /// Started programmatically.
249 Api,
250 /// Not stated.
251 Unknown,
252 }
253
254 impl SessionSource {
255 /// Every value, for exhaustive iteration.
256 pub const ALL: &'static [Self] = &[
257 Self::Interactive,
258 Self::Resume,
259 Self::Fork,
260 Self::Api,
261 Self::Unknown,
262 ];
263
264 /// The wire spelling.
265 #[must_use]
266 pub fn as_str(self) -> &'static str {
267 match self {
268 Self::Interactive => "interactive",
269 Self::Resume => "resume",
270 Self::Fork => "fork",
271 Self::Api => "api",
272 Self::Unknown => "unknown",
273 }
274 }
275 }
276
277 /// How long a session lasted, bucketed. Half-open intervals, in seconds.
278 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279 #[serde(rename_all = "snake_case")]
280 pub enum DurationBucket {
281 /// `d < 60`
282 #[serde(rename = "lt_1m")]
283 Lt1m,
284 /// `60 <= d < 600`
285 #[serde(rename = "1m_10m")]
286 OneToTen,
287 /// `600 <= d < 3600`
288 #[serde(rename = "10m_60m")]
289 TenToSixty,
290 /// `d >= 3600`
291 #[serde(rename = "gt_60m")]
292 Gt60m,
293 }
294
295 impl DurationBucket {
296 /// Every value, for exhaustive iteration.
297 pub const ALL: &'static [Self] = &[Self::Lt1m, Self::OneToTen, Self::TenToSixty, Self::Gt60m];
298
299 /// Bucket a session duration in whole seconds.
300 #[must_use]
301 pub fn from_secs(secs: u64) -> Self {
302 match secs {
303 0..60 => Self::Lt1m,
304 60..600 => Self::OneToTen,
305 600..3600 => Self::TenToSixty,
306 _ => Self::Gt60m,
307 }
308 }
309
310 /// The wire spelling.
311 #[must_use]
312 pub fn as_str(self) -> &'static str {
313 match self {
314 Self::Lt1m => "lt_1m",
315 Self::OneToTen => "1m_10m",
316 Self::TenToSixty => "10m_60m",
317 Self::Gt60m => "gt_60m",
318 }
319 }
320 }
321
322 /// How the process ended.
323 ///
324 /// Derived from an explicit atomic set by the panic hook, the signal task, and
325 /// the clean path — **never from an exit code**. `RunTerminationReason::Canceled`
326 /// maps to exit 130, the same value the SIGINT path uses, so a code-based
327 /// derivation would report every Esc-cancelled turn as a signal.
328 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329 #[serde(rename_all = "snake_case")]
330 pub enum ExitClass {
331 /// Ordinary successful exit.
332 Clean,
333 /// Terminated by a signal.
334 Signal,
335 /// Terminated by a panic.
336 Panic,
337 /// Exited non-successfully without a signal or a panic.
338 Error,
339 }
340
341 impl ExitClass {
342 /// Every value, for exhaustive iteration.
343 pub const ALL: &'static [Self] = &[Self::Clean, Self::Signal, Self::Panic, Self::Error];
344
345 /// The wire spelling.
346 #[must_use]
347 pub fn as_str(self) -> &'static str {
348 match self {
349 Self::Clean => "clean",
350 Self::Signal => "signal",
351 Self::Panic => "panic",
352 Self::Error => "error",
353 }
354 }
355
356 /// Stable numeric encoding for the process-wide `AtomicU8`.
357 #[must_use]
358 pub fn as_u8(self) -> u8 {
359 match self {
360 Self::Clean => 0,
361 Self::Signal => 1,
362 Self::Panic => 2,
363 Self::Error => 3,
364 }
365 }
366
367 /// Inverse of [`Self::as_u8`]; anything unrecognised reads as `Clean`.
368 #[must_use]
369 pub fn from_u8(value: u8) -> Self {
370 match value {
371 1 => Self::Signal,
372 2 => Self::Panic,
373 3 => Self::Error,
374 _ => Self::Clean,
375 }
376 }
377 }
378
379 /// Cold-start time, bucketed, in milliseconds.
380 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
381 #[serde(rename_all = "snake_case")]
382 pub enum ColdStartBucket {
383 /// `ms < 250`
384 #[serde(rename = "lt_250")]
385 Lt250,
386 /// `250 <= ms < 1000`
387 #[serde(rename = "250_1000")]
388 Mid,
389 /// `1000 <= ms < 3000`
390 #[serde(rename = "1000_3000")]
391 Slow,
392 /// `ms >= 3000`
393 #[serde(rename = "gte_3000")]
394 Gte3000,
395 }
396
397 impl ColdStartBucket {
398 /// Every value, for exhaustive iteration.
399 pub const ALL: &'static [Self] = &[Self::Lt250, Self::Mid, Self::Slow, Self::Gte3000];
400
401 /// Bucket a cold-start measurement in milliseconds.
402 #[must_use]
403 pub fn from_millis(ms: u64) -> Self {
404 match ms {
405 0..250 => Self::Lt250,
406 250..1000 => Self::Mid,
407 1000..3000 => Self::Slow,
408 _ => Self::Gte3000,
409 }
410 }
411
412 /// The wire spelling.
413 #[must_use]
414 pub fn as_str(self) -> &'static str {
415 match self {
416 Self::Lt250 => "lt_250",
417 Self::Mid => "250_1000",
418 Self::Slow => "1000_3000",
419 Self::Gte3000 => "gte_3000",
420 }
421 }
422 }
423
424 /// Feature-use counts for one session.
425 ///
426 /// A struct of named `u32`s rather than a map, deliberately: a
427 /// `BTreeMap<&'static str, u32>` is an open key set the compiler cannot police,
428 /// so the doc-match test would be asserting that the doc matches a fixture
429 /// rather than the binary. Adding a counter now requires editing this file,
430 /// which is where that test lives. Every field serializes, including zeros.
431 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
432 pub struct Counters {
433 /// Model turns completed.
434 pub turns: u32,
435 /// Tool calls executed, across every surface.
436 pub tool_calls: u32,
437 /// Fleet dispatches started.
438 pub fleet_dispatch: u32,
439 /// `workflow_run` invocations, keyed off the parsed action discriminant.
440 pub workflow_run: u32,
441 /// Sub-agents spawned.
442 pub subagent_spawn: u32,
443 /// MCP servers that reached `connected`.
444 pub mcp_server_connected: u32,
445 /// Native-memory searches.
446 pub memory_search: u32,
447 /// Approval modals shown.
448 pub approval_modal_shown: u32,
449 /// Approvals granted by an auto-allow rule.
450 pub approval_auto_allowed: u32,
451 /// Command-palette opens.
452 pub command_palette_open: u32,
453 }
454
455 impl Counters {
456 /// Field names in declaration order, for the doc-match test.
457 pub const FIELDS: &'static [&'static str] = &[
458 "turns",
459 "tool_calls",
460 "fleet_dispatch",
461 "workflow_run",
462 "subagent_spawn",
463 "mcp_server_connected",
464 "memory_search",
465 "approval_modal_shown",
466 "approval_auto_allowed",
467 "command_palette_open",
468 ];
469 }
470
471 /// Closed aggregate product counters shared with browser and app clients.
472 /// Runtime sessions keep their existing counters and add no second collector.
473 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
474 #[serde(deny_unknown_fields)]
475 pub struct ProductCounters {
476 /// Aggregate page view count.
477 pub page_view: u32,
478 /// Aggregate docs view count.
479 pub docs_view: u32,
480 /// Aggregate install copy count.
481 pub install_copy: u32,
482 /// Aggregate download count.
483 pub download: u32,
484 /// Aggregate signup count.
485 pub signup: u32,
486 /// Aggregate login count.
487 pub login: u32,
488 /// Aggregate session create count.
489 pub session_create: u32,
490 /// Aggregate session resume count.
491 pub session_resume: u32,
492 /// Aggregate turn submit count.
493 pub turn_submit: u32,
494 /// Aggregate turn complete count.
495 pub turn_complete: u32,
496 /// Aggregate settings open count.
497 pub settings_open: u32,
498 /// Aggregate integration connect count.
499 pub integration_connect: u32,
500 /// Aggregate error shown count.
501 pub error_shown: u32,
502 }
503
504 impl ProductCounters {
505 /// Closed wire field names.
506 pub const FIELDS: &'static [&'static str] = &[
507 "page_view",
508 "docs_view",
509 "install_copy",
510 "download",
511 "signup",
512 "login",
513 "session_create",
514 "session_resume",
515 "turn_submit",
516 "turn_complete",
517 "settings_open",
518 "integration_connect",
519 "error_shown",
520 ];
521 }
522
523 /// Error counts for one session.
524 ///
525 /// Every value is a count of a **variant discriminant**, never of an
526 /// `err.to_string()`. `ToolError::PathEscape`'s `Display` *is* an absolute path;
527 /// the secret store's *is* the store's absolute path; every `LlmError` variant
528 /// carries the raw provider HTTP body verbatim, and a 400 from a content filter
529 /// routinely echoes the prompt.
530 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
531 pub struct Errors {
532 /// Credential preflight rejected the route.
533 pub auth_preflight_failed: u32,
534 /// Provider responded 4xx.
535 pub provider_http_4xx: u32,
536 /// Provider responded 5xx.
537 pub provider_http_5xx: u32,
538 /// A tool call was denied by policy.
539 pub tool_denied_by_policy: u32,
540 /// A tool call timed out.
541 pub tool_timeout: u32,
542 /// A request failed below HTTP — DNS, connect, TLS, or timeout.
543 pub network_error: u32,
544 }
545
546 impl Errors {
547 /// Field names in declaration order, for the doc-match test.
548 pub const FIELDS: &'static [&'static str] = &[
549 "auth_preflight_failed",
550 "provider_http_4xx",
551 "provider_http_5xx",
552 "tool_denied_by_policy",
553 "tool_timeout",
554 "network_error",
555 ];
556 }
557
558 /// Per-session histogram of turn wall-clock time.
559 ///
560 /// A histogram, never a per-turn series: a timestamped stream of turn durations
561 /// reconstructs a session's working rhythm, which is the same objection that
562 /// rules out per-tool-call phone-home.
563 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
564 pub struct TurnWall {
565 /// Turns under 5 seconds.
566 pub lt_5s: u32,
567 /// Turns in `[5s, 30s)`.
568 #[serde(rename = "5_30s")]
569 pub five_to_thirty: u32,
570 /// Turns in `[30s, 120s)`.
571 #[serde(rename = "30_120s")]
572 pub thirty_to_onetwenty: u32,
573 /// Turns at or over 120 seconds.
574 pub gte_120s: u32,
575 }
576
577 impl TurnWall {
578 /// Field names in wire spelling, in declaration order.
579 pub const FIELDS: &'static [&'static str] = &["lt_5s", "5_30s", "30_120s", "gte_120s"];
580
581 /// Record one turn of `secs` wall-clock seconds.
582 pub fn observe_secs(&mut self, secs: u64) {
583 match secs {
584 0..5 => self.lt_5s = self.lt_5s.saturating_add(1),
585 5..30 => self.five_to_thirty = self.five_to_thirty.saturating_add(1),
586 30..120 => self.thirty_to_onetwenty = self.thirty_to_onetwenty.saturating_add(1),
587 _ => self.gte_120s = self.gte_120s.saturating_add(1),
588 }
589 }
590 }
591
592 /// One telemetry event.
593 ///
594 /// The tag is the `event` key, so the wire form is flat and the variant set is
595 /// closed.
596 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597 #[serde(tag = "event", rename_all = "snake_case")]
598 pub enum Event {
599 /// This binary's version differs from the one last recorded on this machine.
600 InstallOrUpgrade {
601 /// Install, upgrade, or downgrade.
602 kind: InstallKind,
603 /// The previously recorded version, read from the telemetry state file
604 /// **only** — never from session history or config mtimes, which have a
605 /// different privacy contract.
606 previous_version: Option<String>,
607 },
608 /// A session began.
609 SessionStart {
610 /// How it was started.
611 source: SessionSource,
612 },
613 /// A session ended. Everything the session accumulated ships here, once.
614 SessionEnd {
615 /// Bucketed wall-clock session length.
616 duration_bucket: DurationBucket,
617 /// How the process ended.
618 exit_class: ExitClass,
619 /// Bucketed cold start. `null` on surfaces that do not measure it.
620 cold_start_bucket: Option<ColdStartBucket>,
621 /// Sorted, deduplicated `ProviderKind` names. A custom provider yields
622 /// the literal `"custom"`, never the customer's `[providers.<name>]`
623 /// table key, and no model id is sent for any provider.
624 providers: Vec<String>,
625 /// Feature-use counts.
626 counters: Counters,
627 /// Error counts.
628 errors: Errors,
629 /// Turn wall-clock histogram.
630 turn_wall: TurnWall,
631 },
632 /// The process panicked.
633 Panic {
634 /// Source location, reduced by the `crates/` allowlist. Never the panic
635 /// *message*: a slicing panic embeds the entire string being sliced, and
636 /// this tree slices user and model text in dozens of places.
637 site: String,
638 },
639 /// Anonymous service health aggregates under explicit operator consent.
640 OperationsSummary {
641 /// Aggregate requests.
642 requests: u32,
643 /// Aggregate errors.
644 errors: u32,
645 /// Aggregate duration ms total.
646 duration_ms_total: u32,
647 /// Aggregate duration ms max.
648 duration_ms_max: u32,
649 /// Aggregate probes.
650 probes: u32,
651 /// Aggregate probes failed.
652 probes_failed: u32,
653 },
654 /// Aggregate browser or application interactions without a timeline.
655 ProductUsage {
656 /// Closed counts; never a URL, user identity, or work content.
657 counters: ProductCounters,
658 },
659 }
660
661 impl Event {
662 /// The `event` discriminant, for the doc-match test.
663 #[must_use]
664 pub fn name(&self) -> &'static str {
665 match self {
666 Self::InstallOrUpgrade { .. } => "install_or_upgrade",
667 Self::SessionStart { .. } => "session_start",
668 Self::SessionEnd { .. } => "session_end",
669 Self::Panic { .. } => "panic",
670 Self::ProductUsage { .. } => "product_usage",
671 Self::OperationsSummary { .. } => "operations_summary",
672 }
673 }
674
675 /// Whether every string this event carries is inside its declared bound.
676 ///
677 /// The bounds above are enforced by the *constructors* — `Counters` is a
678 /// struct of `u32`s, `providers` comes from `ProviderKind::as_str()`, and
679 /// `site` comes from [`crate::reduce_panic_site`]. That holds only for an
680 /// event this process built. Events are also **read back from
681 /// `buffer.jsonl` and deserialized** before a batch is assembled, and
682 /// `serde` will happily fill `site`, `previous_version`, and `providers`
683 /// with any string the file contains. Anything running as this user can
684 /// append a line to that file — including a `Bash` tool call this session
685 /// made on the model's behalf — so the drain path must re-establish the
686 /// bound rather than inherit it.
687 ///
688 /// Failing this check drops the event. It is never *sanitized*: a payload
689 /// that the schema cannot account for is not made safe by editing it.
690 #[must_use]
691 pub fn is_bounded(&self) -> bool {
692 match self {
693 Self::SessionStart { .. }
694 | Self::ProductUsage { .. }
695 | Self::OperationsSummary { .. } => true,
696 Self::InstallOrUpgrade {
697 previous_version, ..
698 } => previous_version
699 .as_deref()
700 .is_none_or(is_release_version_string),
701 Self::SessionEnd { providers, .. } => {
702 providers.iter().all(|name| is_known_provider_id(name))
703 }
704 Self::Panic { site } => is_reduced_panic_site(site),
705 }
706 }
707 }
708
709 /// Whether `value` is a release version this schema may carry.
710 ///
711 /// `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$` — the rule already written on
712 /// [`Batch::app_version`], applied to `previous_version` as well because that
713 /// field is read back from `state.json` rather than built in this process.
714 #[must_use]
715 pub fn is_release_version_string(value: &str) -> bool {
716 let (core, pre) = match value.split_once('-') {
717 Some((core, pre)) => (core, Some(pre)),
718 None => (value, None),
719 };
720 let parts: Vec<&str> = core.split('.').collect();
721 if parts.len() != 3
722 || !parts
723 .iter()
724 .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
725 {
726 return false;
727 }
728 match pre {
729 None => true,
730 Some(pre) => !pre.is_empty() && pre.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.'),
731 }
732 }
733
734 /// Whether `value` is in the output space of [`crate::reduce_panic_site`]:
735 /// the literal `<dep>`, or `crates/…​.rs:<line>:<column>` over the allowlist
736 /// charset.
737 #[must_use]
738 pub fn is_reduced_panic_site(value: &str) -> bool {
739 if value == "<dep>" {
740 return true;
741 }
742 let Some((file, rest)) = value.split_once(".rs:") else {
743 return false;
744 };
745 let Some((line, column)) = rest.split_once(':') else {
746 return false;
747 };
748 file.starts_with("crates/")
749 && file
750 .bytes()
751 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'/' | b'.' | b'-'))
752 && !line.is_empty()
753 && line.bytes().all(|b| b.is_ascii_digit())
754 && !column.is_empty()
755 && column.bytes().all(|b| b.is_ascii_digit())
756 }
757
758 /// Whether `value` is a provider id this build knows.
759 ///
760 /// Checked against the **full** provider registry, not
761 /// `ProviderKind::all()`: that constant is the 36-row *catalog* subset, and
762 /// `ApiProvider::kind()` legitimately yields dialect kinds
763 /// (`deepseek-anthropic`, the Model Studio plan variants) that are absent from
764 /// it. Narrowing to the catalog would silently drop a real user's route.
765 #[must_use]
766 pub fn is_known_provider_id(value: &str) -> bool {
767 codewhale_config::provider::all_providers()
768 .iter()
769 .any(|provider| provider.id() == value)
770 }
771
772 /// The POST body. One per flush.
773 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774 pub struct Batch {
775 /// [`SCHEMA_VERSION`].
776 pub schema_version: u32,
777 /// [`NOTICE_VERSION`], policy metadata rather than proof of acceptance.
778 pub notice_version: u32,
779 /// RFC3339 UTC, second precision. Per-**batch** only — events carry no
780 /// timestamps at all.
781 pub sent_at: String,
782 /// Random v4 UUID stored on this machine, rotated every 90 days.
783 pub install_id: String,
784 /// `CARGO_PKG_VERSION`. Must match `^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$`.
785 pub app_version: String,
786 /// First 12 hex chars of the release-CI build sha, or `null` for every
787 /// locally built binary.
788 pub git_sha: Option<String>,
789 /// Which surface produced this batch.
790 pub surface: Surface,
791 /// OS family.
792 pub os: Os,
793 /// CPU family.
794 pub arch: Arch,
795 /// Compile-time libc.
796 pub libc: Libc,
797 /// Whether both stdin and stdout were terminals.
798 pub tty: bool,
799 /// The events.
800 pub events: Vec<Event>,
801 }
802
803 impl Batch {
804 /// Envelope field names in declaration order, for the doc-match test.
805 pub const FIELDS: &'static [&'static str] = &[
806 "schema_version",
807 "notice_version",
808 "sent_at",
809 "install_id",
810 "app_version",
811 "git_sha",
812 "surface",
813 "os",
814 "arch",
815 "libc",
816 "tty",
817 "events",
818 ];
819 }
820
820 lines RUST