Skip to main content

chasm_cli/commands/
telemetry.rs

1// Copyright (c) 2024-2026 Nervosys LLC
2// SPDX-License-Identifier: Apache-2.0
3//! Telemetry command implementations
4
5use anyhow::Result;
6use colored::*;
7use std::collections::HashMap;
8use std::io::{self, Write};
9
10use crate::telemetry::{TelemetryConfig, TelemetryStore, TELEMETRY_INFO};
11
12/// Enable telemetry (opt-in)
13pub fn telemetry_opt_in() -> Result<()> {
14    let mut config = TelemetryConfig::load()?;
15
16    if config.enabled {
17        println!(
18            "{} Telemetry is already {}",
19            "[OK]".green().bold(),
20            "enabled".green()
21        );
22    } else {
23        config.opt_in()?;
24        println!(
25            "{} Telemetry {} - thank you for helping improve Chasm!",
26            "[OK]".green().bold(),
27            "enabled".green().bold()
28        );
29    }
30
31    println!();
32    println!(
33        "To see what data is collected, run: {}",
34        "chasm telemetry info".cyan()
35    );
36
37    Ok(())
38}
39
40/// Disable telemetry (opt-out)
41pub fn telemetry_opt_out() -> Result<()> {
42    let mut config = TelemetryConfig::load()?;
43
44    if !config.enabled {
45        println!(
46            "{} Telemetry is already {}",
47            "[OK]".green().bold(),
48            "disabled".yellow()
49        );
50    } else {
51        config.opt_out()?;
52        println!(
53            "{} Telemetry {} - no usage data will be collected",
54            "[OK]".green().bold(),
55            "disabled".yellow().bold()
56        );
57    }
58
59    println!();
60    println!(
61        "You can re-enable at any time with: {}",
62        "chasm telemetry opt-in".cyan()
63    );
64
65    Ok(())
66}
67
68/// Show telemetry status and what data is collected
69pub fn telemetry_info() -> Result<()> {
70    let config = TelemetryConfig::load()?;
71
72    let status = if config.enabled {
73        "ENABLED (collecting anonymous data)".green().to_string()
74    } else {
75        "DISABLED (not collecting data)".yellow().to_string()
76    };
77
78    let info = TELEMETRY_INFO
79        .replace("{installation_id}", &config.installation_id)
80        .replace("{status}", &status);
81
82    println!("{}", "[TELEMETRY INFO]".cyan().bold());
83    println!("{}", info);
84
85    // Show preference change timestamp if available
86    if let Some(changed_at) = config.preference_changed_at {
87        let dt = chrono::DateTime::from_timestamp(changed_at, 0)
88            .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string())
89            .unwrap_or_else(|| "Unknown".to_string());
90        println!("Preference last changed: {}", dt.dimmed());
91    }
92
93    println!();
94    println!(
95        "Use {} to change your preference",
96        "chasm telemetry --help".cyan()
97    );
98
99    Ok(())
100}
101
102/// Reset telemetry ID
103pub fn telemetry_reset() -> Result<()> {
104    let mut config = TelemetryConfig::load()?;
105    let old_id = config.installation_id.clone();
106
107    config.reset_id()?;
108
109    println!("{} Telemetry ID reset", "[OK]".green().bold());
110    println!();
111    println!("Old ID: {}", old_id.dimmed().strikethrough());
112    println!("New ID: {}", config.installation_id.green());
113
114    Ok(())
115}
116
117/// Record structured telemetry data for AI analysis
118pub fn telemetry_record(
119    category: &str,
120    event: &str,
121    data_json: Option<&str>,
122    kv_pairs: &[(String, String)],
123    tags: Vec<String>,
124    context: Option<&str>,
125    verbose: bool,
126) -> Result<()> {
127    let store = TelemetryStore::new()?;
128
129    // Build data HashMap from JSON and/or key-value pairs
130    let mut data: HashMap<String, serde_json::Value> = if let Some(json_str) = data_json {
131        serde_json::from_str(json_str).map_err(|e| {
132            anyhow::anyhow!(
133                "Invalid JSON data: {}. Use valid JSON or --kv key=value pairs",
134                e
135            )
136        })?
137    } else {
138        HashMap::new()
139    };
140
141    // Add key-value pairs (these override JSON values if keys conflict)
142    for (key, value) in kv_pairs {
143        // Try to parse value as JSON, fallback to string
144        let json_value =
145            serde_json::from_str(value).unwrap_or(serde_json::Value::String(value.clone()));
146        data.insert(key.clone(), json_value);
147    }
148
149    let record = store.record(category, event, data, tags, context.map(|s| s.to_string()))?;
150
151    println!(
152        "{} Recorded telemetry event: {}",
153        "[OK]".green().bold(),
154        format!("{}:{}", record.category, record.event).cyan()
155    );
156
157    if verbose {
158        println!();
159        println!("Record ID:   {}", record.id.dimmed());
160        println!("Timestamp:   {}", record.timestamp_iso);
161        println!("Category:    {}", record.category.cyan());
162        println!("Event:       {}", record.event);
163        if !record.tags.is_empty() {
164            println!("Tags:        {}", record.tags.join(", ").yellow());
165        }
166        if let Some(ctx) = &record.context {
167            println!("Context:     {}", ctx);
168        }
169        if !record.data.is_empty() {
170            println!("Data:");
171            let pretty = serde_json::to_string_pretty(&record.data).unwrap_or_default();
172            for line in pretty.lines() {
173                println!("  {}", line);
174            }
175        }
176    }
177
178    Ok(())
179}
180
181/// Show recorded telemetry data
182pub fn telemetry_show(
183    category: Option<&str>,
184    event: Option<&str>,
185    tag: Option<&str>,
186    limit: usize,
187    format: &str,
188    after: Option<&str>,
189    before: Option<&str>,
190) -> Result<()> {
191    let store = TelemetryStore::new()?;
192
193    // Parse date filters
194    let after_ts = after.and_then(|d| parse_date_to_timestamp(d));
195    let before_ts = before.and_then(|d| parse_date_to_timestamp(d));
196
197    let records = store.read_records(category, event, tag, after_ts, before_ts, Some(limit))?;
198
199    if records.is_empty() {
200        println!("{} No telemetry records found", "[INFO]".cyan());
201        return Ok(());
202    }
203
204    match format {
205        "json" => {
206            let json = serde_json::to_string_pretty(&records)?;
207            println!("{}", json);
208        }
209        "jsonl" => {
210            for record in &records {
211                println!("{}", serde_json::to_string(record)?);
212            }
213        }
214        _ => {
215            // Table format (default)
216            println!(
217                "{} Showing {} telemetry records",
218                "[TELEMETRY]".cyan().bold(),
219                records.len().to_string().green()
220            );
221            println!();
222
223            for record in &records {
224                let time_short = &record.timestamp_iso[..19];
225                let tags_str = if record.tags.is_empty() {
226                    String::new()
227                } else {
228                    format!(" [{}]", record.tags.join(", ").yellow())
229                };
230
231                println!(
232                    "{} {} {}{}",
233                    time_short.dimmed(),
234                    record.category.cyan(),
235                    record.event.white().bold(),
236                    tags_str
237                );
238
239                if !record.data.is_empty() {
240                    let data_str = serde_json::to_string(&record.data).unwrap_or_default();
241                    // Truncate long data
242                    let display = if data_str.len() > 80 {
243                        format!("{}...", &data_str[..77])
244                    } else {
245                        data_str
246                    };
247                    println!("           {}", display.dimmed());
248                }
249            }
250
251            let total = store.count_records()?;
252            if total > limit {
253                println!();
254                println!(
255                    "Showing {} of {} total records. Use {} to see more.",
256                    limit,
257                    total,
258                    "-n <limit>".cyan()
259                );
260            }
261        }
262    }
263
264    Ok(())
265}
266
267/// Export telemetry records
268pub fn telemetry_export(
269    output: &str,
270    format: &str,
271    category: Option<&str>,
272    with_metadata: bool,
273) -> Result<()> {
274    let store = TelemetryStore::new()?;
275
276    let count = store.export_records(output, format, category, with_metadata)?;
277
278    if count == 0 {
279        println!("{} No records to export", "[INFO]".yellow());
280    } else {
281        println!(
282            "{} Exported {} records to {}",
283            "[OK]".green().bold(),
284            count.to_string().cyan(),
285            output.green()
286        );
287
288        if with_metadata {
289            println!("   Installation ID included in export");
290        }
291    }
292
293    Ok(())
294}
295
296/// Clear telemetry records
297pub fn telemetry_clear(force: bool, older_than: Option<u32>) -> Result<()> {
298    let store = TelemetryStore::new()?;
299    let count = store.count_records()?;
300
301    if count == 0 {
302        println!("{} No telemetry records to clear", "[INFO]".cyan());
303        return Ok(());
304    }
305
306    let message = if let Some(days) = older_than {
307        format!(
308            "Clear {} telemetry records older than {} days?",
309            count, days
310        )
311    } else {
312        format!("Clear all {} telemetry records?", count)
313    };
314
315    if !force {
316        print!("{} [y/N] ", message.yellow());
317        io::stdout().flush()?;
318
319        let mut input = String::new();
320        io::stdin().read_line(&mut input)?;
321
322        if !input.trim().eq_ignore_ascii_case("y") {
323            println!("Cancelled");
324            return Ok(());
325        }
326    }
327
328    let removed = store.clear_records(older_than)?;
329
330    println!(
331        "{} Cleared {} telemetry records",
332        "[OK]".green().bold(),
333        removed.to_string().cyan()
334    );
335
336    Ok(())
337}
338
339/// Configure remote telemetry endpoint
340pub fn telemetry_config(
341    endpoint: Option<&str>,
342    api_key: Option<&str>,
343    enable_remote: bool,
344    disable_remote: bool,
345) -> Result<()> {
346    let mut config = TelemetryConfig::load()?;
347    let mut changed = false;
348
349    if let Some(ep) = endpoint {
350        config.set_remote_endpoint(Some(ep.to_string()))?;
351        println!(
352            "{} Remote endpoint set to: {}",
353            "[OK]".green().bold(),
354            ep.cyan()
355        );
356        changed = true;
357    }
358
359    if let Some(key) = api_key {
360        config.set_remote_api_key(Some(key.to_string()))?;
361        // Don't print the actual key for security
362        println!(
363            "{} API key configured ({})",
364            "[OK]".green().bold(),
365            format!("{}...", &key[..key.len().min(8)]).dimmed()
366        );
367        changed = true;
368    }
369
370    if enable_remote {
371        config.set_remote_enabled(true)?;
372        println!("{} Remote telemetry enabled", "[OK]".green().bold());
373        changed = true;
374    }
375
376    if disable_remote {
377        config.set_remote_enabled(false)?;
378        println!("{} Remote telemetry disabled", "[OK]".green().bold());
379        changed = true;
380    }
381
382    // Reload config to show current state
383    let config = TelemetryConfig::load()?;
384
385    if !changed {
386        // Just show current config
387        println!("{}", "[REMOTE TELEMETRY CONFIG]".cyan().bold());
388        println!();
389        println!(
390            "Endpoint:    {}",
391            config
392                .remote_endpoint
393                .as_deref()
394                .unwrap_or("(not configured)")
395                .cyan()
396        );
397        println!(
398            "API Key:     {}",
399            if config.remote_api_key.is_some() {
400                "(configured)".green().to_string()
401            } else {
402                "(not configured)".yellow().to_string()
403            }
404        );
405        println!(
406            "Remote Send: {}",
407            if config.remote_enabled {
408                "ENABLED".green().bold().to_string()
409            } else {
410                "DISABLED".yellow().to_string()
411            }
412        );
413
414        if config.is_remote_enabled() {
415            println!();
416            println!(
417                "{} Ready to sync. Use {}",
418                "[✓]".green(),
419                "chasm telemetry sync".cyan()
420            );
421        } else if config.remote_endpoint.is_some() && config.remote_api_key.is_some() {
422            println!();
423            println!(
424                "{} Configured but disabled. Use {}",
425                "[!]".yellow(),
426                "--enable-remote".cyan()
427            );
428        } else {
429            println!();
430            println!("To configure:");
431            println!(
432                "  {} {}",
433                "chasm telemetry config".cyan(),
434                "--endpoint <URL> --api-key <KEY> --enable-remote"
435            );
436        }
437    }
438
439    Ok(())
440}
441
442/// Sync telemetry records to remote server
443pub fn telemetry_sync(limit: Option<usize>, clear_after: bool) -> Result<()> {
444    let store = TelemetryStore::new()?;
445
446    let count = store.count_records()?;
447    if count == 0 {
448        println!("{} No telemetry records to sync", "[INFO]".cyan());
449        return Ok(());
450    }
451
452    println!(
453        "{} Syncing {} telemetry records to remote server...",
454        "[SYNC]".cyan().bold(),
455        limit.unwrap_or(count).min(count).to_string().green()
456    );
457
458    let result = store.sync_to_remote(limit)?;
459
460    if result.success {
461        println!(
462            "{} Successfully sent {} records",
463            "[OK]".green().bold(),
464            result.records_sent.to_string().cyan()
465        );
466
467        if clear_after && result.records_sent > 0 {
468            let cleared = store.clear_records(None)?;
469            println!("   Cleared {} local records", cleared.to_string().dimmed());
470        }
471    } else {
472        println!(
473            "{} Sync failed: {}",
474            "[ERROR]".red().bold(),
475            result.error.unwrap_or_else(|| "Unknown error".to_string())
476        );
477    }
478
479    Ok(())
480}
481
482/// Test connection to remote telemetry server
483pub fn telemetry_test() -> Result<()> {
484    let config = TelemetryConfig::load()?;
485
486    if config.remote_endpoint.is_none() {
487        println!("{} Remote endpoint not configured", "[ERROR]".red().bold());
488        println!(
489            "   Use: {}",
490            "chasm telemetry config --endpoint <URL>".cyan()
491        );
492        return Ok(());
493    }
494
495    let endpoint = config.remote_endpoint.as_ref().unwrap();
496    println!(
497        "{} Testing connection to {}",
498        "[TEST]".cyan().bold(),
499        endpoint
500    );
501
502    // Try health endpoint
503    let client = reqwest::blocking::Client::builder()
504        .timeout(std::time::Duration::from_secs(10))
505        .build()
506        .map_err(|e| anyhow::anyhow!("Failed to create HTTP client: {}", e))?;
507
508    let health_url = format!("{}/health", endpoint.trim_end_matches('/'));
509    let response = client.get(&health_url).send();
510
511    match response {
512        Ok(resp) => {
513            if resp.status().is_success() {
514                let body: serde_json::Value = resp.json().unwrap_or_default();
515                println!("{} Connection successful!", "[OK]".green().bold());
516                println!();
517                println!(
518                    "Server status: {}",
519                    body.get("status")
520                        .and_then(|v| v.as_str())
521                        .unwrap_or("unknown")
522                        .green()
523                );
524                if let Some(env) = body.get("environment").and_then(|v| v.as_str()) {
525                    println!("Environment:   {}", env);
526                }
527
528                // Test auth if API key is configured
529                if config.remote_api_key.is_some() {
530                    println!();
531                    println!("{} API key configured", "[✓]".green());
532                } else {
533                    println!();
534                    println!(
535                        "{} API key not configured (required for syncing)",
536                        "[!]".yellow()
537                    );
538                }
539            } else {
540                println!(
541                    "{} Server returned: HTTP {}",
542                    "[WARN]".yellow().bold(),
543                    resp.status()
544                );
545            }
546        }
547        Err(e) => {
548            println!("{} Connection failed: {}", "[ERROR]".red().bold(), e);
549            println!();
550            println!("Please check:");
551            println!("  • The endpoint URL is correct");
552            println!("  • The server is running");
553            println!("  • Your network connection");
554        }
555    }
556
557    Ok(())
558}
559
560/// Parse a date string (YYYY-MM-DD) to a Unix timestamp
561fn parse_date_to_timestamp(date_str: &str) -> Option<i64> {
562    chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
563        .ok()
564        .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp())
565}