Skip to main content

atomcode_telemetry/
event.rs

1//! Event and Envelope schema for AtomCode telemetry v2.
2//!
3//! The events are: open_atomcode, llm_chat, tool_call, use_command,
4//! mcp_connect, login_success, take_codingplan, panic, telemetry_disabled.
5//!
6//! Wire format: envelope fields + event-specific payload, both flattened
7//! into one JSON object. Event variant is tagged via `event_id`.
8
9use serde::Serialize;
10use uuid::Uuid;
11
12// ---------- SessionMode ----------
13
14#[derive(Debug, Clone, Copy, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum SessionMode {
17    Headless,
18    Tui,
19    Ide,
20    Vscode,
21    AtomcodeAir,
22}
23
24// ---------- Envelope (common to every event) ----------
25
26#[derive(Debug, Clone, Serialize)]
27pub struct Envelope {
28    pub device_id: Uuid,
29    pub launch_id: Uuid,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub account_id: Option<String>,
32    pub session_id: Uuid,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub turn_id: Option<Uuid>,
35    pub ts: i64,
36    pub schema_version: u32,
37    pub app_version: String,
38    pub os: String,
39    pub arch: String,
40    pub locale: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub provider: Option<String>,
43    /// Vendor host (e.g. `api.openai.com`). Derived from the configured
44    /// `base_url` host part — falls back to each vendor's official host
45    /// when missing/unparseable. See `resolve_provider_host`.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub provider_host: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub model: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub repo_origin: Option<RepoOrigin>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub mode: Option<SessionMode>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct RepoOrigin {
58    pub host: RepoHost,
59    pub has_git: bool,
60}
61
62#[derive(Debug, Clone, Copy, Serialize)]
63#[serde(rename_all = "snake_case")]
64pub enum RepoHost {
65    Gitcode,
66    Atomgit,
67    Github,
68    Gitlab,
69    Other,
70    None,
71}
72
73// ---------- Error kind enums ----------
74
75/// LLM 对话错误类型
76#[derive(Debug, Clone, Copy, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum LlmErrorKind {
79    NetworkError,
80    AuthError,
81    RateLimited,
82    ServerError,
83    StreamInterrupted,
84    StreamTimeout,
85    ContextOverflow,
86    Other,
87}
88
89/// 工具调用错误类型
90#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
91#[serde(rename_all = "snake_case")]
92pub enum ToolErrorKind {
93    NotFound,
94    InvalidArgs,
95    ExecutionFailed,
96    DeniedByUser,
97    BlockedByHook,
98    LoopDetected,
99    SkillNotFound,
100    SkillDisabled,
101    SkillEmptyTemplate,
102    /// Tool ran successfully (exit 0) but produced stderr output,
103    /// suggesting a partial failure or misleading success.
104    Warning,
105    Other,
106}
107
108/// MCP 连接错误类型
109#[derive(Debug, Clone, Copy, Serialize)]
110#[serde(rename_all = "snake_case")]
111pub enum McpErrorKind {
112    NetworkError,
113    AuthError,
114    ServerError,
115    ExecutionFailed,
116    Timeout,
117    Other,
118}
119
120/// CodingPlan 错误类型
121#[derive(Debug, Clone, Copy, Serialize)]
122#[serde(rename_all = "snake_case")]
123pub enum CodingplanErrorKind {
124    AuthError,
125    AuthExpired,
126    ExecutionFailed,
127    NetworkError,
128    ServerError,
129    Other,
130}
131
132/// use_command 错误类型
133#[derive(Debug, Clone, Copy, Serialize)]
134#[serde(rename_all = "snake_case")]
135pub enum UseCommandErrorKind {
136    ExecutionFailed,
137    InvalidArgs,
138    NotFound,
139    Other,
140}
141
142/// MCP 传输方式
143#[derive(Debug, Clone, Copy, Serialize)]
144#[serde(rename_all = "snake_case")]
145pub enum McpTransport {
146    Stdio,
147    Sse,
148    StreamableHttp,
149}
150
151// ---------- Event payloads ----------
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "snake_case")]
155pub enum CodingplanResult {
156    Success,
157    Fail,
158}
159
160// ---------- The Event enum (6 variants) ----------
161
162#[derive(Debug, Clone, Serialize)]
163#[serde(tag = "event_id", rename_all = "snake_case")]
164pub enum Event {
165    /// Fired when AtomCode is launched (interactive CLI, oneshot, or TUI entry).
166    /// Not fired for --version / --help / telemetry subcommands.
167    OpenAtomcode,
168
169    /// One LLM turn completed (success or failure).
170    LlmChat {
171        duration_ms: u32,
172        tool_calls_count: u32,
173        input_tokens: u32,
174        output_tokens: u32,
175        cached_tokens: u32,
176        had_error: bool,
177        context_window: u32,
178        system_tokens: u32,
179        tool_def_tokens: u32,
180        tool_result_tokens: u32,
181        message_tokens: u32,
182        messages_count: u32,
183        /// Error category (None when had_error=false or unclassifiable).
184        #[serde(skip_serializing_if = "Option::is_none")]
185        error_kind: Option<LlmErrorKind>,
186        /// Error detail JSON (None when had_error=false).
187        #[serde(skip_serializing_if = "Option::is_none")]
188        error_data: Option<String>,
189    },
190
191    /// Single tool call result (success or failure).
192    ToolCall {
193        /// Tool name (e.g. "bash", "edit_file", "mcp__xx__yy").
194        name: String,
195        /// Whether the call succeeded.
196        success: bool,
197        /// Execution duration in ms (0 if not executed).
198        duration_ms: u32,
199        /// Error category (None when success=true).
200        #[serde(skip_serializing_if = "Option::is_none")]
201        error_kind: Option<ToolErrorKind>,
202        /// Error/detail JSON (always present for diagnostics even on success).
203        #[serde(skip_serializing_if = "Option::is_none")]
204        error_data: Option<String>,
205    },
206
207    /// A slash command was executed in TUI or daemon.
208    /// `type_` is the literal command name (without the leading /).
209    UseCommand {
210        #[serde(rename = "type")]
211        type_: String,
212        /// Whether the command succeeded (None for legacy events).
213        #[serde(skip_serializing_if = "Option::is_none")]
214        success: Option<bool>,
215        /// Error category (None when success=true or legacy).
216        #[serde(skip_serializing_if = "Option::is_none")]
217        error_kind: Option<UseCommandErrorKind>,
218        /// Error/detail JSON.
219        #[serde(skip_serializing_if = "Option::is_none")]
220        error_data: Option<String>,
221    },
222
223    /// MCP server connection attempt (success or failure).
224    McpConnect {
225        /// MCP server name.
226        server_name: String,
227        /// Transport type.
228        transport: McpTransport,
229        /// Whether the connection succeeded.
230        success: bool,
231        /// Connection duration in ms.
232        #[serde(skip_serializing_if = "Option::is_none")]
233        duration_ms: Option<u32>,
234        /// Error category (None when success=true).
235        #[serde(skip_serializing_if = "Option::is_none")]
236        error_kind: Option<McpErrorKind>,
237        /// Error/detail JSON.
238        #[serde(skip_serializing_if = "Option::is_none")]
239        error_data: Option<String>,
240    },
241
242    /// OAuth login completed successfully.
243    LoginSuccess,
244
245    /// A coding plan run finished.
246    TakeCodingplan {
247        #[serde(rename = "type")]
248        type_: CodingplanResult,
249        /// Error category (None when type_=Success).
250        #[serde(skip_serializing_if = "Option::is_none")]
251        error_kind: Option<CodingplanErrorKind>,
252        /// Error/detail JSON.
253        #[serde(skip_serializing_if = "Option::is_none")]
254        error_data: Option<String>,
255    },
256
257    /// Panic captured by global hook.
258    Panic {
259        location: String,
260        message_head: String,
261        thread: String,
262        backtrace_top_5: Vec<String>,
263        /// Fixed to "panic".
264        #[serde(skip_serializing_if = "Option::is_none")]
265        error_kind: Option<String>,
266        /// Runtime context JSON (session_duration_secs, turns_completed, last_tool_name, last_event).
267        #[serde(skip_serializing_if = "Option::is_none")]
268        error_data: Option<String>,
269    },
270
271    /// Final event before user opts out via `atomcode telemetry disable`.
272    /// Only fired if telemetry was currently enabled at the time of the command.
273    TelemetryDisabled,
274
275    /// Reserved variant. Will be fired (in a future PR) when an
276    /// open-source build of AtomCode attempts to send a request to the
277    /// AtomGit LLM gateway. Locking the wire-format `event_id` here
278    /// keeps the firing-site PR small.
279    CodingplanOfficialBuildRequired,
280}
281
282// ---------- Record (wire format) ----------
283
284#[derive(Debug, Clone, Serialize)]
285pub struct Record {
286    #[serde(flatten)]
287    pub envelope: Envelope,
288    #[serde(flatten)]
289    pub event: Event,
290}
291
292// ---------- Tests ----------
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json;
298
299    fn sample_envelope() -> Envelope {
300        Envelope {
301            device_id: Uuid::nil(),
302            launch_id: Uuid::nil(),
303            account_id: None,
304            session_id: Uuid::nil(),
305            turn_id: None,
306            ts: 0,
307            schema_version: 1,
308            app_version: "0.0.0".into(),
309            os: "linux".into(),
310            arch: "x86_64".into(),
311            locale: "en-US".into(),
312            provider: None,
313            provider_host: None,
314            model: None,
315            repo_origin: None,
316            mode: None,
317        }
318    }
319
320    #[test]
321    fn envelope_omits_none_fields() {
322        let s = serde_json::to_string(&sample_envelope()).unwrap();
323        assert!(!s.contains("account_id"));
324        assert!(!s.contains("turn_id"));
325        assert!(!s.contains("provider"));
326        assert!(!s.contains("repo_origin"));
327        assert!(!s.contains("mode"));
328    }
329
330    #[test]
331    fn envelope_carries_session_mode() {
332        let mut env = sample_envelope();
333        env.mode = Some(SessionMode::Headless);
334        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
335        assert_eq!(v["mode"], "headless");
336
337        env.mode = Some(SessionMode::Tui);
338        let v: serde_json::Value = serde_json::to_value(&env).unwrap();
339        assert_eq!(v["mode"], "tui");
340    }
341
342    #[test]
343    fn record_flattens_envelope_and_event() {
344        let r = Record {
345            envelope: sample_envelope(),
346            event: Event::OpenAtomcode,
347        };
348        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
349        assert_eq!(v["event_id"], "open_atomcode");
350        assert_eq!(v["schema_version"], 1);
351        // Envelope flatten: device_id must be at the top level.
352        assert!(v.get("device_id").is_some());
353    }
354
355    #[test]
356    fn use_command_serializes_type_field() {
357        let r = Record {
358            envelope: sample_envelope(),
359            event: Event::UseCommand {
360                type_: "compact".into(),
361                success: None,
362                error_kind: None,
363                error_data: None,
364            },
365        };
366        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
367        assert_eq!(v["event_id"], "use_command");
368        assert_eq!(v["type"], "compact");
369        // skip_serializing_if: None fields must not appear
370        assert!(v.get("success").is_none());
371        assert!(v.get("error_kind").is_none());
372        assert!(v.get("error_data").is_none());
373    }
374
375    #[test]
376    fn use_command_with_error_fields() {
377        let r = Record {
378            envelope: sample_envelope(),
379            event: Event::UseCommand {
380                type_: "reload".into(),
381                success: Some(false),
382                error_kind: Some(UseCommandErrorKind::ExecutionFailed),
383                error_data: Some(r#"{"command":"reload","message":"parse error"}"#.into()),
384            },
385        };
386        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
387        assert_eq!(v["event_id"], "use_command");
388        assert_eq!(v["type"], "reload");
389        assert_eq!(v["success"], false);
390        assert_eq!(v["error_kind"], "execution_failed");
391    }
392
393    #[test]
394    fn take_codingplan_serializes_success_fail() {
395        let ok = Record {
396            envelope: sample_envelope(),
397            event: Event::TakeCodingplan {
398                type_: CodingplanResult::Success,
399                error_kind: None,
400                error_data: None,
401            },
402        };
403        let fail = Record {
404            envelope: sample_envelope(),
405            event: Event::TakeCodingplan {
406                type_: CodingplanResult::Fail,
407                error_kind: Some(CodingplanErrorKind::AuthError),
408                error_data: Some(r#"{"step":"login"}"#.into()),
409            },
410        };
411        let ov: serde_json::Value = serde_json::to_value(&ok).unwrap();
412        let fv: serde_json::Value = serde_json::to_value(&fail).unwrap();
413        assert_eq!(ov["event_id"], "take_codingplan");
414        assert_eq!(ov["type"], "success");
415        assert!(ov.get("error_kind").is_none());
416        assert_eq!(fv["type"], "fail");
417        assert_eq!(fv["error_kind"], "auth_error");
418    }
419
420    #[test]
421    fn llm_chat_payload_shape() {
422        let r = Record {
423            envelope: sample_envelope(),
424            event: Event::LlmChat {
425                duration_ms: 100,
426                tool_calls_count: 2,
427                input_tokens: 500,
428                output_tokens: 300,
429                cached_tokens: 0,
430                had_error: false,
431                context_window: 200000,
432                system_tokens: 100,
433                tool_def_tokens: 200,
434                tool_result_tokens: 0,
435                message_tokens: 50,
436                messages_count: 5,
437                error_kind: None,
438                error_data: None,
439            },
440        };
441        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
442        assert_eq!(v["event_id"], "llm_chat");
443        assert_eq!(v["duration_ms"], 100);
444        assert_eq!(v["tool_calls_count"], 2);
445        assert_eq!(v["had_error"], false);
446        assert_eq!(v["context_window"], 200000);
447        assert_eq!(v["system_tokens"], 100);
448        assert_eq!(v["tool_def_tokens"], 200);
449        assert_eq!(v["message_tokens"], 50);
450        assert_eq!(v["messages_count"], 5);
451        assert!(v.get("context_used").is_none());
452        // skip_serializing_if: None error fields must not appear
453        assert!(v.get("error_kind").is_none());
454        assert!(v.get("error_data").is_none());
455    }
456
457    #[test]
458    fn llm_chat_with_error() {
459        let r = Record {
460            envelope: sample_envelope(),
461            event: Event::LlmChat {
462                duration_ms: 5000,
463                tool_calls_count: 0,
464                input_tokens: 100,
465                output_tokens: 0,
466                cached_tokens: 0,
467                had_error: true,
468                context_window: 200000,
469                system_tokens: 100,
470                tool_def_tokens: 0,
471                tool_result_tokens: 0,
472                message_tokens: 0,
473                messages_count: 1,
474                error_kind: Some(LlmErrorKind::AuthError),
475                error_data: Some(r#"{"status_code":401,"message":"Invalid API key"}"#.into()),
476            },
477        };
478        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
479        assert_eq!(v["had_error"], true);
480        assert_eq!(v["error_kind"], "auth_error");
481        assert!(v["error_data"].is_string());
482    }
483
484    #[test]
485    fn tool_call_success_omits_error_fields() {
486        let r = Record {
487            envelope: sample_envelope(),
488            event: Event::ToolCall {
489                name: "bash".into(),
490                success: true,
491                duration_ms: 150,
492                error_kind: None,
493                error_data: None,
494            },
495        };
496        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
497        assert_eq!(v["event_id"], "tool_call");
498        assert_eq!(v["name"], "bash");
499        assert_eq!(v["success"], true);
500        assert_eq!(v["duration_ms"], 150);
501        assert!(v.get("error_kind").is_none());
502        assert!(v.get("error_data").is_none());
503    }
504
505    #[test]
506    fn tool_call_with_error() {
507        let r = Record {
508            envelope: sample_envelope(),
509            event: Event::ToolCall {
510                name: "edit_file".into(),
511                success: false,
512                duration_ms: 5,
513                error_kind: Some(ToolErrorKind::DeniedByUser),
514                error_data: Some(
515                    serde_json::json!({
516                        "tool_name": "edit_file",
517                        "reason": "User rejected file write",
518                        "resolution": "Confirm the edit when prompted"
519                    }).to_string(),
520                ),
521            },
522        };
523        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
524        assert_eq!(v["event_id"], "tool_call");
525        assert_eq!(v["name"], "edit_file");
526        assert_eq!(v["success"], false);
527        assert_eq!(v["error_kind"], "denied_by_user");
528        assert!(v["error_data"].is_string());
529        // Verify the error_data JSON contains reason + resolution
530        let ed: serde_json::Value =
531            serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
532        assert_eq!(ed["reason"], "User rejected file write");
533        assert_eq!(ed["resolution"], "Confirm the edit when prompted");
534    }
535
536    #[test]
537    fn tool_call_warning_with_stderr() {
538        let r = Record {
539            envelope: sample_envelope(),
540            event: Event::ToolCall {
541                name: "bash".into(),
542                success: true,
543                duration_ms: 43,
544                error_kind: Some(ToolErrorKind::Warning),
545                error_data: Some(
546                    serde_json::json!({
547                        "tool_name": "bash",
548                        "duration_ms": 43,
549                        "args_summary": "bash(command=rm -rf /tmp/test.txt)",
550                        "output_tail": "rm: /tmp/test.txt: No such file or directory\n[elapsed: 0.0s, exit: 0]",
551                        "reason": "Command succeeded (exit 0) but produced stderr output",
552                        "resolution": "Review stderr for potential issues; the command may not have had the intended effect",
553                    }).to_string(),
554                ),
555            },
556        };
557        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
558        assert_eq!(v["event_id"], "tool_call");
559        assert_eq!(v["success"], true);
560        assert_eq!(v["error_kind"], "warning");
561        let ed: serde_json::Value =
562            serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
563        assert_eq!(ed["reason"], "Command succeeded (exit 0) but produced stderr output");
564        assert_eq!(ed["resolution"], "Review stderr for potential issues; the command may not have had the intended effect");
565    }
566
567    #[test]
568    fn mcp_connect_success_omits_error_fields() {
569        let r = Record {
570            envelope: sample_envelope(),
571            event: Event::McpConnect {
572                server_name: "github".into(),
573                transport: McpTransport::Stdio,
574                success: true,
575                duration_ms: Some(320),
576                error_kind: None,
577                error_data: None,
578            },
579        };
580        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
581        assert_eq!(v["event_id"], "mcp_connect");
582        assert_eq!(v["server_name"], "github");
583        assert_eq!(v["transport"], "stdio");
584        assert_eq!(v["success"], true);
585        assert_eq!(v["duration_ms"], 320);
586        assert!(v.get("error_kind").is_none());
587        assert!(v.get("error_data").is_none());
588    }
589
590    #[test]
591    fn mcp_connect_with_error() {
592        let r = Record {
593            envelope: sample_envelope(),
594            event: Event::McpConnect {
595                server_name: "remote-api".into(),
596                transport: McpTransport::Sse,
597                success: false,
598                duration_ms: Some(5000),
599                error_kind: Some(McpErrorKind::Timeout),
600                error_data: Some(
601                    serde_json::json!({
602                        "server_name": "remote-api",
603                        "transport": "sse",
604                        "reason": "Connection timed out after 5s",
605                        "resolution": "Check MCP server URL and network connectivity"
606                    }).to_string(),
607                ),
608            },
609        };
610        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
611        assert_eq!(v["event_id"], "mcp_connect");
612        assert_eq!(v["server_name"], "remote-api");
613        assert_eq!(v["transport"], "sse");
614        assert_eq!(v["success"], false);
615        assert_eq!(v["error_kind"], "timeout");
616        let ed: serde_json::Value =
617            serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
618        assert_eq!(ed["reason"], "Connection timed out after 5s");
619        assert_eq!(ed["resolution"], "Check MCP server URL and network connectivity");
620    }
621
622    #[test]
623    fn panic_without_error_fields() {
624        let r = Record {
625            envelope: sample_envelope(),
626            event: Event::Panic {
627                location: "src/main.rs:42".into(),
628                message_head: "index out of bounds".into(),
629                thread: "main".into(),
630                backtrace_top_5: vec!["func_a".into(), "func_b".into()],
631                error_kind: None,
632                error_data: None,
633            },
634        };
635        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
636        assert_eq!(v["event_id"], "panic");
637        assert_eq!(v["location"], "src/main.rs:42");
638        assert_eq!(v["message_head"], "index out of bounds");
639        assert!(v.get("error_kind").is_none());
640        assert!(v.get("error_data").is_none());
641    }
642
643    #[test]
644    fn panic_with_error_context() {
645        let r = Record {
646            envelope: sample_envelope(),
647            event: Event::Panic {
648                location: "src/agent.rs:100".into(),
649                message_head: "assertion failed".into(),
650                thread: "tokio-runtime".into(),
651                backtrace_top_5: vec![],
652                error_kind: Some("panic".into()),
653                error_data: Some(
654                    serde_json::json!({
655                        "session_duration_secs": 300,
656                        "turns_completed": 5,
657                        "last_tool_name": "bash",
658                        "last_event": "llm_chat"
659                    }).to_string(),
660                ),
661            },
662        };
663        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
664        assert_eq!(v["event_id"], "panic");
665        assert_eq!(v["error_kind"], "panic");
666        let ed: serde_json::Value =
667            serde_json::from_str(v["error_data"].as_str().unwrap()).unwrap();
668        assert_eq!(ed["session_duration_secs"], 300);
669        assert_eq!(ed["turns_completed"], 5);
670    }
671
672    #[test]
673    fn all_variants_have_event_id_tag() {
674        let cases = [
675            Event::OpenAtomcode,
676            Event::LlmChat {
677                duration_ms: 0,
678                tool_calls_count: 0,
679                input_tokens: 0,
680                output_tokens: 0,
681                cached_tokens: 0,
682                had_error: false,
683                context_window: 0,
684                system_tokens: 0,
685                tool_def_tokens: 0,
686                tool_result_tokens: 0,
687                message_tokens: 0,
688                messages_count: 0,
689                error_kind: None,
690                error_data: None,
691            },
692            Event::ToolCall {
693                name: "bash".into(),
694                success: true,
695                duration_ms: 0,
696                error_kind: None,
697                error_data: None,
698            },
699            Event::UseCommand {
700                type_: "x".into(),
701                success: None,
702                error_kind: None,
703                error_data: None,
704            },
705            Event::McpConnect {
706                server_name: "test".into(),
707                transport: McpTransport::Stdio,
708                success: true,
709                duration_ms: None,
710                error_kind: None,
711                error_data: None,
712            },
713            Event::LoginSuccess,
714            Event::TakeCodingplan {
715                type_: CodingplanResult::Success,
716                error_kind: None,
717                error_data: None,
718            },
719            Event::Panic {
720                location: "x:1".into(),
721                message_head: "".into(),
722                thread: "main".into(),
723                backtrace_top_5: vec![],
724                error_kind: None,
725                error_data: None,
726            },
727            Event::TelemetryDisabled,
728        ];
729        for e in &cases {
730            let v = serde_json::to_value(e).unwrap();
731            assert!(v.get("event_id").is_some(), "missing event_id: {:?}", e);
732        }
733        assert_eq!(cases.len(), 9);
734    }
735
736    #[test]
737    fn telemetry_disabled_serializes_with_correct_event_id() {
738        let r = Record {
739            envelope: sample_envelope(),
740            event: Event::TelemetryDisabled,
741        };
742        let v: serde_json::Value = serde_json::to_value(&r).unwrap();
743        assert_eq!(v["event_id"], "telemetry_disabled");
744    }
745
746    #[test]
747    fn session_mode_ide_serializes_as_ide() {
748        assert_eq!(
749            serde_json::to_string(&SessionMode::Ide).unwrap(),
750            "\"ide\""
751        );
752    }
753}
754
755#[cfg(test)]
756mod codingplan_required_event_tests {
757    use super::*;
758
759    #[test]
760    fn codingplan_official_build_required_serialises_with_snake_case_event_id() {
761        let e = Event::CodingplanOfficialBuildRequired;
762        let v = serde_json::to_value(&e).expect("serialise");
763        assert_eq!(
764            v["event_id"], "codingplan_official_build_required",
765            "got: {v}"
766        );
767    }
768}