返回 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 = 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")]
46 pub 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
61 impl 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")]
90 pub 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
105 impl 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")]
133 pub enum Arch {
134 /// 64-bit x86.
135 X86_64,
136 /// 64-bit ARM.
137 Aarch64,
138 /// Anything else.
139 Other,
140 }
141
142 impl 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")]
164 pub enum Libc {
165 /// glibc.
166 Gnu,
167 /// musl.
168 Musl,
169 /// Not a libc target (macOS, Windows, and anything else).
170 None,
171 }
172
173 impl 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")]
191 pub 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
200 impl 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")]
222 pub 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
235 impl 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")]
261 pub 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
276 impl 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")]
311 pub 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
322 impl 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")]
363 pub 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
378 impl 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)]
413 pub 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
436 impl 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)]
460 pub 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
475 impl 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)]
493 pub 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
506 impl 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")]
527 pub 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
570 impl 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]
620 pub 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]
643 pub 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]
671 pub 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)]
679 pub 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
706 impl 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 }
722
722 lines RUST