Skip to main content

bijux_cli/shared/
telemetry.rs

1#![forbid(unsafe_code)]
2//! Best-effort structured telemetry sink for local diagnostics and observability.
3
4#[cfg(test)]
5use std::cell::RefCell;
6use std::collections::HashSet;
7use std::fs::{self, OpenOptions};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process;
11use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12use std::sync::{Arc, Mutex, OnceLock};
13use std::time::{Instant, SystemTime, UNIX_EPOCH};
14
15use serde_json::{json, Value};
16
17/// Environment variable that enables telemetry writing when set to a file path.
18pub const TELEMETRY_FILE_ENV: &str = "BIJUX_TELEMETRY_FILE";
19/// Environment variable that allows raw argv capture in telemetry payloads.
20pub const TELEMETRY_INCLUDE_ARGS_ENV: &str = "BIJUX_TELEMETRY_INCLUDE_ARGS";
21
22static TELEMETRY_COUNTER: AtomicU64 = AtomicU64::new(1);
23static TELEMETRY_CONFIG_WARNING_EMITTED: AtomicBool = AtomicBool::new(false);
24static TELEMETRY_CONFIG_WARNING_KEYS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
25static TELEMETRY_WRITE_WARNING_EMITTED: AtomicBool = AtomicBool::new(false);
26static TELEMETRY_WRITE_WARNING_KEYS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
27#[cfg(test)]
28pub(crate) static TEST_ENV_LOCK: Mutex<()> = Mutex::new(());
29#[cfg(test)]
30thread_local! {
31    static TEST_TELEMETRY_CONFIG: RefCell<Option<TestTelemetryConfig>> = const { RefCell::new(None) };
32}
33const MAX_COMMAND_PREVIEW_CHARS: usize = 128;
34const MAX_ARG_CHARS: usize = 256;
35const MAX_CAPTURED_ARGS: usize = 64;
36const MAX_STAGE_FIELD_CHARS: usize = 128;
37const MAX_PAYLOAD_JSON_BYTES: usize = 32 * 1024;
38/// Max number of chars retained for telemetry message-like fields.
39pub const MAX_TEXT_FIELD_CHARS: usize = 2048;
40/// Max number of chars retained for telemetry command-like fields.
41pub const MAX_COMMAND_FIELD_CHARS: usize = 512;
42
43#[cfg(test)]
44#[derive(Debug, Clone)]
45struct TestTelemetryConfig {
46    sink_path: Option<PathBuf>,
47    include_args: bool,
48}
49
50#[cfg(test)]
51#[derive(Debug)]
52pub(crate) struct TestTelemetryConfigGuard(Option<TestTelemetryConfig>);
53
54#[cfg(test)]
55impl Drop for TestTelemetryConfigGuard {
56    fn drop(&mut self) {
57        let previous = self.0.take();
58        TEST_TELEMETRY_CONFIG.with(|slot| {
59            slot.replace(previous);
60        });
61    }
62}
63
64#[cfg(test)]
65pub(crate) fn install_test_telemetry_config(
66    sink_path: Option<PathBuf>,
67    include_args: bool,
68) -> TestTelemetryConfigGuard {
69    let previous = TEST_TELEMETRY_CONFIG
70        .with(|slot| slot.replace(Some(TestTelemetryConfig { sink_path, include_args })));
71    TestTelemetryConfigGuard(previous)
72}
73
74fn unix_timestamp_millis() -> u128 {
75    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()
76}
77
78fn append_json_line(path: &Path, value: &Value) -> std::io::Result<()> {
79    if let Some(parent) = path.parent() {
80        fs::create_dir_all(parent)?;
81    }
82
83    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
84    let mut line = serde_json::to_vec(value).map_err(std::io::Error::other)?;
85    line.push(b'\n');
86    file.write_all(&line)?;
87    Ok(())
88}
89
90fn emit_telemetry_config_warning_once(message: &str) {
91    let key = message.to_string();
92    let cache = TELEMETRY_CONFIG_WARNING_KEYS.get_or_init(|| Mutex::new(HashSet::new()));
93    let should_emit = match cache.lock() {
94        Ok(mut seen) => seen.insert(key),
95        Err(_) => !TELEMETRY_CONFIG_WARNING_EMITTED.swap(true, Ordering::Relaxed),
96    };
97    if should_emit {
98        eprintln!("{message}");
99    }
100}
101
102/// Truncate a text field to a stable char budget.
103#[must_use]
104pub fn truncate_chars(input: &str, limit: usize) -> (String, bool) {
105    let total = input.chars().count();
106    if total <= limit {
107        return (input.to_string(), false);
108    }
109    (input.chars().take(limit).collect(), true)
110}
111
112fn emit_telemetry_write_warning_once(path: &Path, error: &std::io::Error) {
113    let key = format!("{}|{:?}|{:?}", path.to_string_lossy(), error.kind(), error.raw_os_error());
114    let cache = TELEMETRY_WRITE_WARNING_KEYS.get_or_init(|| Mutex::new(HashSet::new()));
115    let should_emit = match cache.lock() {
116        Ok(mut seen) => seen.insert(key),
117        Err(_) => !TELEMETRY_WRITE_WARNING_EMITTED.swap(true, Ordering::Relaxed),
118    };
119    if should_emit {
120        eprintln!("telemetry write failed for {}: {error}", path.to_string_lossy());
121    }
122}
123
124fn sanitize_argv(argv: &[String]) -> Value {
125    let mut args = Vec::new();
126    let mut truncated_args = 0usize;
127    let mut clipped_by_count = 0usize;
128
129    for value in argv.iter().take(MAX_CAPTURED_ARGS) {
130        let (sanitized, truncated) = truncate_chars(value, MAX_ARG_CHARS);
131        args.push(sanitized);
132        if truncated {
133            truncated_args += 1;
134        }
135    }
136
137    if argv.len() > MAX_CAPTURED_ARGS {
138        clipped_by_count = argv.len() - MAX_CAPTURED_ARGS;
139    }
140
141    json!({
142        "argv": args,
143        "argv_total_count": argv.len(),
144        "argv_truncated_arg_count": truncated_args,
145        "argv_clipped_count": clipped_by_count,
146    })
147}
148
149fn global_flag_without_value(token: &str) -> bool {
150    matches!(
151        token,
152        "--help"
153            | "-h"
154            | "--version"
155            | "-V"
156            | "--quiet"
157            | "-q"
158            | "--pretty"
159            | "--no-pretty"
160            | "--json"
161            | "--text"
162    )
163}
164
165fn global_flag_with_value(token: &str) -> bool {
166    matches!(token, "--format" | "-f" | "--log-level" | "--color" | "--config-path")
167}
168
169fn global_flag_with_equals(token: &str) -> bool {
170    token.starts_with("--format=")
171        || token.starts_with("--log-level=")
172        || token.starts_with("--color=")
173        || token.starts_with("--config-path=")
174}
175
176fn command_preview(argv: &[String]) -> (String, bool, Option<usize>, &'static str) {
177    let mut consume_next = false;
178    for (idx, token) in argv.iter().enumerate().skip(1) {
179        if consume_next {
180            consume_next = false;
181            continue;
182        }
183        if token == "--" {
184            if let Some(next) = argv.get(idx + 1) {
185                let (preview, truncated) = truncate_chars(next, MAX_COMMAND_PREVIEW_CHARS);
186                return (preview, truncated, Some(idx + 1), "passthrough");
187            }
188            break;
189        }
190        if global_flag_with_value(token) {
191            consume_next = true;
192            continue;
193        }
194        if global_flag_without_value(token) || global_flag_with_equals(token) {
195            continue;
196        }
197        if token.starts_with('-') {
198            continue;
199        }
200        let (preview, truncated) = truncate_chars(token, MAX_COMMAND_PREVIEW_CHARS);
201        return (preview, truncated, Some(idx), "first_non_flag");
202    }
203
204    let fallback = argv.get(1).map_or("", String::as_str);
205    let (preview, truncated) = truncate_chars(fallback, MAX_COMMAND_PREVIEW_CHARS);
206    (
207        preview,
208        truncated,
209        if argv.len() > 1 { Some(1) } else { None },
210        if argv.len() > 1 { "fallback_first_arg" } else { "missing" },
211    )
212}
213
214fn bounded_cwd() -> (Option<String>, bool, Option<String>, bool) {
215    match std::env::current_dir() {
216        Ok(path) => {
217            let rendered = path.to_string_lossy().to_string();
218            let (cwd, cwd_truncated) = truncate_chars(&rendered, MAX_TEXT_FIELD_CHARS);
219            (Some(cwd), cwd_truncated, None, false)
220        }
221        Err(error) => {
222            let (message, message_truncated) =
223                truncate_chars(&error.to_string(), MAX_TEXT_FIELD_CHARS);
224            (None, false, Some(message), message_truncated)
225        }
226    }
227}
228
229fn normalized_stage_name(stage: &str) -> (String, bool, bool) {
230    let stage_was_empty = stage.trim().is_empty();
231    let source = if stage_was_empty { "unknown_stage" } else { stage };
232    let (name, truncated) = truncate_chars(source, MAX_STAGE_FIELD_CHARS);
233    (name, truncated, stage_was_empty)
234}
235
236fn normalize_payload(payload: Value) -> (Value, usize, bool) {
237    let payload_bytes = serde_json::to_vec(&payload).map_or(0, |bytes| bytes.len());
238    if payload_bytes <= MAX_PAYLOAD_JSON_BYTES {
239        return (payload, payload_bytes, false);
240    }
241
242    (
243        json!({
244            "truncated_payload": true,
245            "original_payload_bytes": payload_bytes,
246            "max_payload_bytes": MAX_PAYLOAD_JSON_BYTES,
247        }),
248        payload_bytes,
249        true,
250    )
251}
252
253fn resolve_sink_path() -> Option<PathBuf> {
254    #[cfg(test)]
255    if let Some(config) = TEST_TELEMETRY_CONFIG.with(|slot| slot.borrow().clone()) {
256        return config.sink_path.and_then(resolve_sink_path_value);
257    }
258
259    let raw = std::env::var_os(TELEMETRY_FILE_ENV)?;
260    resolve_sink_path_value(PathBuf::from(raw))
261}
262
263fn resolve_sink_path_value(raw_path: PathBuf) -> Option<PathBuf> {
264    let display = raw_path.to_string_lossy().to_string();
265
266    if display.trim().is_empty() {
267        emit_telemetry_config_warning_once(
268            "telemetry disabled: BIJUX_TELEMETRY_FILE is empty or whitespace",
269        );
270        return None;
271    }
272
273    let candidate = if raw_path.is_absolute() {
274        raw_path
275    } else {
276        match std::env::current_dir() {
277            Ok(cwd) => cwd.join(raw_path),
278            Err(error) => {
279                emit_telemetry_config_warning_once(&format!(
280                    "telemetry disabled: failed to resolve BIJUX_TELEMETRY_FILE against cwd: {error}"
281                ));
282                return None;
283            }
284        }
285    };
286
287    if candidate.is_dir() {
288        emit_telemetry_config_warning_once(&format!(
289            "telemetry disabled: BIJUX_TELEMETRY_FILE points to a directory ({})",
290            candidate.to_string_lossy()
291        ));
292        return None;
293    }
294    Some(candidate)
295}
296
297fn include_args_enabled() -> bool {
298    #[cfg(test)]
299    if let Some(config) = TEST_TELEMETRY_CONFIG.with(|slot| slot.borrow().clone()) {
300        return config.include_args;
301    }
302
303    std::env::var_os(TELEMETRY_INCLUDE_ARGS_ENV).is_some()
304}
305
306fn next_invocation_id(runtime: &str) -> String {
307    let seq = TELEMETRY_COUNTER.fetch_add(1, Ordering::Relaxed);
308    format!("{runtime}-{}-{}-{seq}", process::id(), unix_timestamp_millis())
309}
310
311/// Stable exit-kind mapping used in telemetry records.
312#[must_use]
313pub fn exit_code_kind(code: i32) -> &'static str {
314    match code {
315        0 => "success",
316        2 => "usage",
317        3 => "encoding",
318        130 => "aborted",
319        _ => "error",
320    }
321}
322
323/// In-memory telemetry span that writes line-delimited JSON events when enabled.
324#[derive(Debug, Clone)]
325pub struct TelemetrySpan {
326    runtime: String,
327    invocation_id: String,
328    sink_path: Option<PathBuf>,
329    started_at_ms: u128,
330    started_at_instant: Instant,
331    event_seq: Arc<AtomicU64>,
332}
333
334impl TelemetrySpan {
335    /// Start a telemetry span for one CLI invocation.
336    #[must_use]
337    pub fn start(runtime: &str, argv: &[String]) -> Self {
338        let sink_path = resolve_sink_path();
339        let span = Self {
340            runtime: runtime.to_string(),
341            invocation_id: next_invocation_id(runtime),
342            sink_path,
343            started_at_ms: unix_timestamp_millis(),
344            started_at_instant: Instant::now(),
345            event_seq: Arc::new(AtomicU64::new(1)),
346        };
347
348        let include_args = include_args_enabled();
349        let (program, program_truncated) =
350            truncate_chars(argv.first().map_or("", String::as_str), MAX_COMMAND_FIELD_CHARS);
351        let (preview, preview_truncated, preview_index, preview_source) = command_preview(argv);
352        let (cwd, cwd_truncated, cwd_error, cwd_error_truncated) = bounded_cwd();
353        let argv_payload = if include_args {
354            let mut payload = sanitize_argv(argv);
355            if let Some(map) = payload.as_object_mut() {
356                map.insert("arg_capture_mode".to_string(), json!("full"));
357                map.insert("program".to_string(), json!(program));
358                map.insert("program_truncated".to_string(), json!(program_truncated));
359                map.insert("command_preview".to_string(), json!(preview));
360                map.insert("command_preview_truncated".to_string(), json!(preview_truncated));
361                map.insert("command_preview_index".to_string(), json!(preview_index));
362                map.insert("command_preview_source".to_string(), json!(preview_source));
363                map.insert("runtime_version".to_string(), json!(super::version::runtime_version()));
364                map.insert("runtime_semver".to_string(), json!(super::version::runtime_semver()));
365                map.insert(
366                    "runtime_version_source".to_string(),
367                    json!(super::version::runtime_version_source()),
368                );
369                map.insert(
370                    "runtime_git_commit".to_string(),
371                    json!(super::version::runtime_git_commit()),
372                );
373                map.insert(
374                    "runtime_git_dirty".to_string(),
375                    json!(super::version::runtime_git_dirty()),
376                );
377                map.insert(
378                    "build_profile".to_string(),
379                    json!(super::version::runtime_build_profile()),
380                );
381                map.insert("cwd".to_string(), json!(cwd));
382                map.insert("cwd_truncated".to_string(), json!(cwd_truncated));
383                map.insert("cwd_error".to_string(), json!(cwd_error));
384                map.insert("cwd_error_truncated".to_string(), json!(cwd_error_truncated));
385            }
386            payload
387        } else {
388            json!({
389                "arg_capture_mode": "preview",
390                "program": program,
391                "program_truncated": program_truncated,
392                "argv_count": argv.len(),
393                "command_preview": preview,
394                "command_preview_truncated": preview_truncated,
395                "command_preview_index": preview_index,
396                "command_preview_source": preview_source,
397                "runtime_version": super::version::runtime_version(),
398                "runtime_semver": super::version::runtime_semver(),
399                "runtime_version_source": super::version::runtime_version_source(),
400                "runtime_git_commit": super::version::runtime_git_commit(),
401                "runtime_git_dirty": super::version::runtime_git_dirty(),
402                "build_profile": super::version::runtime_build_profile(),
403                "cwd": cwd,
404                "cwd_truncated": cwd_truncated,
405                "cwd_error": cwd_error,
406                "cwd_error_truncated": cwd_error_truncated,
407            })
408        };
409        span.record("invocation.start", argv_payload);
410        span
411    }
412
413    /// Record an intermediate telemetry event.
414    pub fn record(&self, stage: &str, payload: Value) {
415        let Some(path) = &self.sink_path else {
416            return;
417        };
418
419        let seq = self.event_seq.fetch_add(1, Ordering::Relaxed);
420        let (stage_name, stage_truncated, stage_was_empty) = normalized_stage_name(stage);
421        let (payload, payload_bytes, payload_truncated) = normalize_payload(payload);
422        let timestamp_ms = unix_timestamp_millis();
423        let elapsed_ms = timestamp_ms.saturating_sub(self.started_at_ms);
424        let elapsed_monotonic_ms = self.started_at_instant.elapsed().as_millis();
425        let event = json!({
426            "schema": "bijux-telemetry-event-v1",
427            "runtime": self.runtime,
428            "pid": process::id(),
429            "invocation_id": self.invocation_id,
430            "sequence": seq,
431            "stage": stage_name,
432            "stage_truncated": stage_truncated,
433            "stage_was_empty": stage_was_empty,
434            "timestamp_ms": timestamp_ms,
435            "elapsed_ms": elapsed_ms,
436            "elapsed_monotonic_ms": elapsed_monotonic_ms,
437            "payload_bytes": payload_bytes,
438            "payload_truncated": payload_truncated,
439            "payload": payload,
440        });
441
442        if let Err(error) = append_json_line(path, &event) {
443            emit_telemetry_write_warning_once(path, &error);
444        }
445    }
446
447    /// Record invocation completion based on final process exit.
448    pub fn finish_exit(&self, exit_code: i32, stdout_bytes: usize, stderr_bytes: usize) {
449        let duration_ms = self.started_at_instant.elapsed().as_millis();
450        let duration_ms_wall_clock = unix_timestamp_millis().saturating_sub(self.started_at_ms);
451        self.record(
452            "invocation.finish",
453            json!({
454                "result": if exit_code == 0 { "ok" } else { "nonzero_exit" },
455                "exit_code": exit_code,
456                "exit_kind": exit_code_kind(exit_code),
457                "stdout_bytes": stdout_bytes,
458                "stderr_bytes": stderr_bytes,
459                "duration_ms": duration_ms,
460                "duration_ms_wall_clock": duration_ms_wall_clock,
461            }),
462        );
463    }
464
465    /// Record invocation failure caused by internal runtime errors.
466    pub fn finish_internal_error(&self, error_message: &str, exit_code: i32) {
467        let (message, message_truncated) = truncate_chars(error_message, MAX_TEXT_FIELD_CHARS);
468        let duration_ms = self.started_at_instant.elapsed().as_millis();
469        let duration_ms_wall_clock = unix_timestamp_millis().saturating_sub(self.started_at_ms);
470        self.record(
471            "invocation.finish",
472            json!({
473                "result": "internal_error",
474                "exit_code": exit_code,
475                "exit_kind": exit_code_kind(exit_code),
476                "duration_ms": duration_ms,
477                "duration_ms_wall_clock": duration_ms_wall_clock,
478                "error_message": message,
479                "error_message_truncated": message_truncated,
480            }),
481        );
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::{
488        exit_code_kind, install_test_telemetry_config, truncate_chars, TelemetrySpan,
489        MAX_CAPTURED_ARGS, MAX_PAYLOAD_JSON_BYTES, MAX_STAGE_FIELD_CHARS, MAX_TEXT_FIELD_CHARS,
490    };
491    use serde_json::{json, Value};
492    use std::path::PathBuf;
493
494    #[test]
495    fn exit_code_kind_maps_stable_classes() {
496        assert_eq!(exit_code_kind(0), "success");
497        assert_eq!(exit_code_kind(2), "usage");
498        assert_eq!(exit_code_kind(3), "encoding");
499        assert_eq!(exit_code_kind(130), "aborted");
500        assert_eq!(exit_code_kind(1), "error");
501        assert_eq!(exit_code_kind(77), "error");
502    }
503
504    #[test]
505    fn span_writes_start_and_finish_events_when_enabled() {
506        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
507        let temp = tempfile::tempdir().expect("temp dir");
508        let sink = temp.path().join("telemetry").join("events.jsonl");
509        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
510
511        let argv = vec!["bijux".to_string(), "status".to_string()];
512        let span = TelemetrySpan::start("bijux-cli", &argv);
513        span.record("intent.parsed", serde_json::json!({"normalized_path":"status"}));
514        span.finish_exit(0, 11, 0);
515
516        let body = std::fs::read_to_string(&sink).expect("telemetry body");
517        let rows: Vec<Value> =
518            body.lines().map(|line| serde_json::from_str(line).expect("json line")).collect();
519        assert_eq!(rows.len(), 3);
520        assert_eq!(rows[0]["stage"], "invocation.start");
521        assert_eq!(rows[1]["stage"], "intent.parsed");
522        assert_eq!(rows[2]["stage"], "invocation.finish");
523        assert_eq!(rows[2]["payload"]["exit_kind"], "success");
524        assert_eq!(rows[2]["payload"]["result"], "ok");
525        assert_eq!(rows[0]["runtime"], "bijux-cli");
526        assert_eq!(rows[0]["sequence"], 1);
527        assert_eq!(rows[1]["sequence"], 2);
528        assert_eq!(rows[2]["sequence"], 3);
529        assert!(rows.iter().all(|row| row["elapsed_ms"].is_number()));
530        assert!(rows.iter().all(|row| row["elapsed_monotonic_ms"].is_number()));
531        assert!(rows.iter().all(|row| row["stage_truncated"].is_boolean()));
532        assert!(rows.iter().all(|row| row["payload_bytes"].is_u64()));
533        assert!(rows.iter().all(|row| row["payload_truncated"].is_boolean()));
534        assert_eq!(rows[0]["payload"]["arg_capture_mode"], "preview");
535        assert_eq!(rows[0]["payload"]["command_preview"], "status");
536        assert_eq!(rows[0]["payload"]["runtime_semver"], super::super::version::runtime_semver());
537        assert_eq!(rows[0]["payload"]["runtime_version"], super::super::version::runtime_version());
538        assert_eq!(
539            rows[0]["payload"]["runtime_version_source"],
540            super::super::version::runtime_version_source()
541        );
542        assert_eq!(
543            rows[0]["payload"]["runtime_git_commit"],
544            serde_json::json!(super::super::version::runtime_git_commit())
545        );
546        assert_eq!(
547            rows[0]["payload"]["runtime_git_dirty"],
548            serde_json::json!(super::super::version::runtime_git_dirty())
549        );
550        assert!(rows[0]["payload"]["build_profile"].is_string());
551    }
552
553    #[test]
554    fn span_marks_non_zero_exit_as_nonzero_result() {
555        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
556        let temp = tempfile::tempdir().expect("temp dir");
557        let sink = temp.path().join("events.jsonl");
558        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
559
560        let argv = vec!["bijux".to_string(), "status".to_string()];
561        let span = TelemetrySpan::start("bijux-cli", &argv);
562        span.finish_exit(2, 0, 42);
563
564        let body = std::fs::read_to_string(&sink).expect("telemetry body");
565        let rows: Vec<Value> =
566            body.lines().map(|line| serde_json::from_str(line).expect("json line")).collect();
567        assert_eq!(rows[1]["payload"]["result"], "nonzero_exit");
568        assert_eq!(rows[1]["payload"]["exit_kind"], "usage");
569    }
570
571    #[test]
572    fn span_can_include_raw_argv_when_opted_in() {
573        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
574        let temp = tempfile::tempdir().expect("temp dir");
575        let sink = temp.path().join("events.jsonl");
576        let _telemetry = install_test_telemetry_config(Some(sink.clone()), true);
577
578        let argv = vec!["bijux".to_string(), "config".to_string(), "list".to_string()];
579        let span = TelemetrySpan::start("bijux-cli", &argv);
580        span.finish_internal_error("boom", 1);
581
582        let body = std::fs::read_to_string(&sink).expect("telemetry body");
583        let first: Value =
584            serde_json::from_str(body.lines().next().expect("first line")).expect("json line");
585        assert_eq!(first["payload"]["argv"][0], "bijux");
586        assert_eq!(first["payload"]["argv"][2], "list");
587        assert_eq!(first["payload"]["argv_total_count"], 3);
588        assert_eq!(first["payload"]["argv_clipped_count"], 0);
589        assert_eq!(first["payload"]["arg_capture_mode"], "full");
590        assert_eq!(first["payload"]["command_preview"], "config");
591    }
592
593    #[test]
594    fn span_truncates_captured_argv_when_opted_in() {
595        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
596        let temp = tempfile::tempdir().expect("temp dir");
597        let sink = temp.path().join("events.jsonl");
598        let _telemetry = install_test_telemetry_config(Some(sink.clone()), true);
599
600        let mut argv = vec!["bijux".to_string()];
601        argv.extend((0..70).map(|idx| format!("arg-{idx}-{}", "x".repeat(300))));
602        let span = TelemetrySpan::start("bijux-cli", &argv);
603        span.finish_exit(0, 0, 0);
604
605        let body = std::fs::read_to_string(&sink).expect("telemetry body");
606        let first: Value =
607            serde_json::from_str(body.lines().next().expect("first line")).expect("json line");
608        let args = first["payload"]["argv"].as_array().expect("argv array");
609        assert_eq!(args.len(), MAX_CAPTURED_ARGS);
610        assert_eq!(first["payload"]["argv_total_count"], 71);
611        assert_eq!(first["payload"]["argv_clipped_count"], 7);
612        assert!(first["payload"]["argv_truncated_arg_count"].as_u64().unwrap_or_default() > 0);
613    }
614
615    #[test]
616    fn span_disables_sink_when_path_is_whitespace() {
617        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
618        let _telemetry = install_test_telemetry_config(Some(PathBuf::from("   ")), false);
619
620        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string()]);
621        span.finish_exit(0, 0, 0);
622    }
623
624    #[test]
625    fn span_disables_sink_when_path_points_to_directory() {
626        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
627        let temp = tempfile::tempdir().expect("temp dir");
628        let _telemetry = install_test_telemetry_config(Some(temp.path().to_path_buf()), false);
629
630        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string()]);
631        span.finish_exit(0, 0, 0);
632    }
633
634    #[test]
635    fn span_truncates_internal_error_message() {
636        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
637        let temp = tempfile::tempdir().expect("temp dir");
638        let sink = temp.path().join("events.jsonl");
639        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
640
641        let message = "e".repeat(MAX_TEXT_FIELD_CHARS + 100);
642        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string()]);
643        span.finish_internal_error(&message, 1);
644
645        let body = std::fs::read_to_string(&sink).expect("telemetry body");
646        let finish: Value =
647            serde_json::from_str(body.lines().nth(1).expect("finish line")).expect("json line");
648        let rendered = finish["payload"]["error_message"].as_str().unwrap_or_default();
649        assert_eq!(rendered.chars().count(), MAX_TEXT_FIELD_CHARS);
650        assert_eq!(finish["payload"]["error_message_truncated"], true);
651    }
652
653    #[test]
654    fn truncate_chars_reports_when_input_is_trimmed() {
655        let input = "abcde";
656        let (value, truncated) = truncate_chars(input, 3);
657        assert_eq!(value, "abc");
658        assert!(truncated);
659
660        let (value, truncated) = truncate_chars(input, 5);
661        assert_eq!(value, "abcde");
662        assert!(!truncated);
663    }
664
665    #[test]
666    fn span_truncates_oversized_stage_names() {
667        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
668        let temp = tempfile::tempdir().expect("temp dir");
669        let sink = temp.path().join("events.jsonl");
670        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
671
672        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string()]);
673        span.record(&"s".repeat(MAX_STAGE_FIELD_CHARS + 32), json!({"ok": true}));
674        span.finish_exit(0, 0, 0);
675
676        let body = std::fs::read_to_string(&sink).expect("telemetry body");
677        let rows: Vec<Value> =
678            body.lines().map(|line| serde_json::from_str(line).expect("json line")).collect();
679        let oversized =
680            rows.iter().find(|row| row["payload"]["ok"] == true).expect("oversized row");
681        let stage = oversized["stage"].as_str().expect("stage");
682        assert_eq!(stage.chars().count(), MAX_STAGE_FIELD_CHARS);
683        assert_eq!(oversized["stage_truncated"], true);
684    }
685
686    #[test]
687    fn start_preview_resolves_first_non_flag_command() {
688        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
689        let temp = tempfile::tempdir().expect("temp dir");
690        let sink = temp.path().join("events.jsonl");
691        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
692
693        let argv = vec![
694            "bijux".to_string(),
695            "--format".to_string(),
696            "json".to_string(),
697            "status".to_string(),
698        ];
699        let span = TelemetrySpan::start("bijux-cli", &argv);
700        span.finish_exit(0, 0, 0);
701
702        let body = std::fs::read_to_string(&sink).expect("telemetry body");
703        let first: Value =
704            serde_json::from_str(body.lines().next().expect("first line")).expect("json line");
705        assert_eq!(first["payload"]["command_preview"], "status");
706        assert_eq!(first["payload"]["command_preview_index"], 3);
707        assert_eq!(first["payload"]["command_preview_source"], "first_non_flag");
708    }
709
710    #[test]
711    fn start_preview_uses_passthrough_after_double_dash() {
712        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
713        let temp = tempfile::tempdir().expect("temp dir");
714        let sink = temp.path().join("events.jsonl");
715        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
716
717        let argv = vec![
718            "bijux".to_string(),
719            "--format=json".to_string(),
720            "--".to_string(),
721            "plugins".to_string(),
722            "list".to_string(),
723        ];
724        let span = TelemetrySpan::start("bijux-cli", &argv);
725        span.finish_exit(0, 0, 0);
726
727        let body = std::fs::read_to_string(&sink).expect("telemetry body");
728        let first: Value =
729            serde_json::from_str(body.lines().next().expect("first line")).expect("json line");
730        assert_eq!(first["payload"]["command_preview"], "plugins");
731        assert_eq!(first["payload"]["command_preview_index"], 3);
732        assert_eq!(first["payload"]["command_preview_source"], "passthrough");
733    }
734
735    #[test]
736    fn span_normalizes_empty_stage_names() {
737        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
738        let temp = tempfile::tempdir().expect("temp dir");
739        let sink = temp.path().join("events.jsonl");
740        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
741
742        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string(), "status".to_string()]);
743        span.record("   ", json!({"ok": true}));
744        span.finish_exit(0, 0, 0);
745
746        let body = std::fs::read_to_string(&sink).expect("telemetry body");
747        let rows: Vec<Value> =
748            body.lines().map(|line| serde_json::from_str(line).expect("json line")).collect();
749        let row =
750            rows.iter().find(|entry| entry["payload"]["ok"] == true).expect("normalized stage row");
751        assert_eq!(row["stage"], "unknown_stage");
752        assert_eq!(row["stage_was_empty"], true);
753    }
754
755    #[test]
756    fn span_truncates_oversized_payloads() {
757        let _guard = super::TEST_ENV_LOCK.lock().expect("env lock");
758        let temp = tempfile::tempdir().expect("temp dir");
759        let sink = temp.path().join("events.jsonl");
760        let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
761
762        let span = TelemetrySpan::start("bijux-cli", &["bijux".to_string(), "status".to_string()]);
763        let oversized = json!({
764            "blob": "x".repeat(MAX_PAYLOAD_JSON_BYTES + 1024),
765        });
766        span.record("oversized", oversized);
767        span.finish_exit(0, 0, 0);
768
769        let body = std::fs::read_to_string(&sink).expect("telemetry body");
770        let rows: Vec<Value> =
771            body.lines().map(|line| serde_json::from_str(line).expect("json line")).collect();
772        let row = rows.iter().find(|entry| entry["stage"] == "oversized").expect("oversized row");
773        assert_eq!(row["payload_truncated"], true);
774        assert!(row["payload"]["truncated_payload"].as_bool().unwrap_or(false));
775        assert!(
776            row["payload"]["original_payload_bytes"].as_u64().unwrap_or_default()
777                > MAX_PAYLOAD_JSON_BYTES as u64
778        );
779    }
780}