1#![forbid(unsafe_code)]
2#[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
17pub const TELEMETRY_FILE_ENV: &str = "BIJUX_TELEMETRY_FILE";
19pub 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;
38pub const MAX_TEXT_FIELD_CHARS: usize = 2048;
40pub 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#[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#[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#[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 #[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 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 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 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}