Skip to main content

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