1use std::collections::BTreeMap;
25use std::io::Write;
26use std::path::PathBuf;
27use std::sync::OnceLock;
28use std::time::{Duration, Instant};
29
30use chrono::SecondsFormat;
31use serde::{Deserialize, Serialize};
32
33const LOG_FILE_NAME: &str = "log.jsonl";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RecordKind {
41 #[default]
43 Invocation,
44 Http,
46 #[serde(other)]
48 Unknown,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Source {
56 #[default]
58 Cli,
59 Mcp,
61 Daemon,
63 #[serde(other)]
65 Unknown,
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct LogRecord {
73 #[serde(default)]
76 pub id: String,
77 #[serde(default)]
79 pub invocation_id: String,
80 #[serde(default)]
82 pub kind: RecordKind,
83 #[serde(default)]
85 pub timestamp: String,
86 #[serde(default)]
88 pub hostname: String,
89 #[serde(default)]
91 pub pid: u32,
92 #[serde(default)]
94 pub omni_dev_version: String,
95 #[serde(default)]
97 pub cwd: String,
98 #[serde(default)]
100 pub system_user: String,
101
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub command: Vec<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub command_line: Vec<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub exit_code: Option<i32>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub duration_ms: Option<u64>,
115 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub env: BTreeMap<String, String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub source: Option<Source>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub mcp_tool: Option<String>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub service: Option<String>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub method: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub url: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub status_code: Option<u16>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub elapsed_ms: Option<u64>,
141 #[serde(default, skip_serializing_if = "is_false")]
143 pub via_daemon: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub daemon_session_id: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub auth_principal: Option<String>,
151 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153 pub request_headers: BTreeMap<String, String>,
154 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
156 pub response_headers: BTreeMap<String, String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub request_body: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub response_body: Option<String>,
163 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165 pub context: BTreeMap<String, String>,
166
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub error: Option<String>,
171}
172
173#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
176 !*b
177}
178
179impl LogRecord {
180 fn new(kind: RecordKind, invocation_id: String) -> Self {
182 Self {
183 id: new_id(),
184 invocation_id,
185 kind,
186 timestamp: now_rfc3339_millis(),
187 hostname: hostname(),
188 pid: std::process::id(),
189 omni_dev_version: crate::VERSION.to_string(),
190 cwd: cwd(),
191 system_user: system_user(),
192 ..Self::default()
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
203pub struct RequestLogContext {
204 pub invocation_id: String,
206 pub source: Source,
208 pub mcp_tool: Option<String>,
210}
211
212impl Default for RequestLogContext {
213 fn default() -> Self {
214 Self {
215 invocation_id: new_id(),
216 source: Source::Cli,
217 mcp_tool: None,
218 }
219 }
220}
221
222impl RequestLogContext {
223 pub fn cli() -> Self {
225 Self {
226 invocation_id: new_id(),
227 source: Source::Cli,
228 mcp_tool: None,
229 }
230 }
231
232 pub fn mcp(tool: impl Into<String>) -> Self {
234 Self {
235 invocation_id: new_id(),
236 source: Source::Mcp,
237 mcp_tool: Some(tool.into()),
238 }
239 }
240}
241
242static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
243
244tokio::task_local! {
245 pub static CTX: RequestLogContext;
247}
248
249pub fn set_global(ctx: RequestLogContext) {
252 let _ = GLOBAL.set(ctx);
253}
254
255pub fn current_context() -> RequestLogContext {
258 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
259 return ctx;
260 }
261 if let Some(ctx) = GLOBAL.get() {
262 return ctx.clone();
263 }
264 RequestLogContext::default()
265}
266
267pub fn disabled() -> bool {
269 env_flag("OMNI_DEV_LOG_DISABLE")
270}
271
272pub fn bodies_enabled() -> bool {
274 env_flag("OMNI_DEV_LOG_BODIES")
275}
276
277pub fn headers_enabled() -> bool {
279 env_flag("OMNI_DEV_LOG_HEADERS")
280}
281
282fn env_flag(name: &str) -> bool {
284 std::env::var(name).is_ok_and(|v| {
285 let v = v.trim().to_ascii_lowercase();
286 v == "1" || v == "true" || v == "yes"
287 })
288}
289
290pub fn log_file_path() -> Option<PathBuf> {
293 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
294 if !path.is_empty() {
295 return Some(PathBuf::from(path));
296 }
297 }
298 let base = dirs::state_dir().or_else(dirs::data_dir)?;
299 Some(base.join("omni-dev").join(LOG_FILE_NAME))
300}
301
302pub fn record(entry: &LogRecord) {
305 if disabled() {
306 return;
307 }
308 if let Err(e) = try_record(entry) {
309 tracing::debug!("request_log: failed to append record: {e}");
310 }
311}
312
313fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
315 use anyhow::Context;
316
317 let path = log_file_path().context("could not resolve the log file path")?;
318 if let Some(parent) = path.parent() {
322 if !parent.as_os_str().is_empty() && !parent.exists() {
323 crate::daemon::paths::ensure_dir_0700(parent)?;
324 }
325 }
326 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
327 line.push('\n');
328 append_line(&path, &line)?;
329 Ok(())
330}
331
332#[cfg(unix)]
337fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
338 use std::os::unix::fs::OpenOptionsExt;
339
340 let file = std::fs::OpenOptions::new()
341 .append(true)
342 .create(true)
343 .mode(0o600)
344 .open(path)?;
345
346 if bodies_enabled() {
347 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
348 Ok(mut guard) => {
349 guard.write_all(line.as_bytes())?;
350 }
351 Err((mut file, _)) => {
352 file.write_all(line.as_bytes())?;
353 }
354 }
355 } else {
356 let mut file = file;
357 file.write_all(line.as_bytes())?;
358 }
359 Ok(())
360}
361
362#[cfg(not(unix))]
365fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
366 let mut file = std::fs::OpenOptions::new()
367 .append(true)
368 .create(true)
369 .open(path)?;
370 file.write_all(line.as_bytes())?;
371 Ok(())
372}
373
374#[derive(Debug, Clone)]
376pub struct InvocationOutcome {
377 pub command: Vec<String>,
379 pub command_line: Vec<String>,
381 pub exit_code: i32,
383 pub error: Option<String>,
385 pub duration: Duration,
387}
388
389pub fn record_invocation(outcome: InvocationOutcome) {
391 let ctx = current_context();
392 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
393 rec.source = Some(ctx.source);
394 rec.mcp_tool = ctx.mcp_tool;
395 rec.command = outcome.command;
396 rec.command_line = outcome.command_line;
397 rec.exit_code = Some(outcome.exit_code);
398 rec.error = outcome.error;
399 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
400 rec.env = whitelisted_env();
401 record(&rec);
402}
403
404#[derive(Debug, Clone, Default)]
407pub struct HttpExtra {
408 pub via_daemon: bool,
410 pub daemon_session_id: Option<String>,
412 pub auth_principal: Option<String>,
414 pub request_headers: BTreeMap<String, String>,
416 pub response_headers: BTreeMap<String, String>,
418 pub request_body: Option<String>,
420 pub response_body: Option<String>,
422 pub context: BTreeMap<String, String>,
424}
425
426pub fn record_http(
428 service: &str,
429 method: &str,
430 url: &str,
431 started: Instant,
432 status: Option<u16>,
433 error: Option<&str>,
434) {
435 record_http_with(
436 service,
437 method,
438 url,
439 started,
440 status,
441 error,
442 HttpExtra::default(),
443 );
444}
445
446#[allow(clippy::too_many_arguments)]
452pub fn record_http_with(
453 service: &str,
454 method: &str,
455 url: &str,
456 started: Instant,
457 status: Option<u16>,
458 error: Option<&str>,
459 extra: HttpExtra,
460) {
461 if disabled() {
462 return;
463 }
464 let ctx = current_context();
465 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
466 rec.source = Some(ctx.source);
467 rec.mcp_tool = ctx.mcp_tool;
468 rec.service = Some(service.to_string());
469 rec.method = Some(method.to_string());
470 rec.url = Some(url.to_string());
471 rec.status_code = status;
472 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
473 rec.error = error.map(str::to_string);
474 rec.via_daemon = extra.via_daemon;
475 rec.daemon_session_id = extra.daemon_session_id;
476 rec.auth_principal = extra.auth_principal;
477 rec.context = extra.context;
478 if headers_enabled() {
479 rec.request_headers = redact_headers(&extra.request_headers);
480 rec.response_headers = redact_headers(&extra.response_headers);
481 }
482 if bodies_enabled() {
483 rec.request_body = extra.request_body;
484 rec.response_body = extra.response_body;
485 }
486 record(&rec);
487}
488
489const SENSITIVE_HEADERS: &[&str] = &[
491 "authorization",
492 "proxy-authorization",
493 "cookie",
494 "set-cookie",
495 "x-api-key",
496 "api-key",
497 "dd-api-key",
498 "dd-application-key",
499 "x-datadog-api-key",
500 "x-datadog-application-key",
501 "x-omni-bridge",
502 "x-omni-bridge-target",
503];
504
505pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
507 headers
508 .iter()
509 .map(|(name, value)| {
510 let redacted = SENSITIVE_HEADERS.contains(&name.to_ascii_lowercase().as_str());
511 (
512 name.clone(),
513 if redacted {
514 "REDACTED".to_string()
515 } else {
516 value.clone()
517 },
518 )
519 })
520 .collect()
521}
522
523pub fn new_id() -> String {
529 let millis = chrono::Utc::now().timestamp_millis().max(0);
530 let suffix = rand::random::<u64>();
531 format!("{millis:013}-{suffix:016x}")
532}
533
534fn now_rfc3339_millis() -> String {
536 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
537}
538
539fn cwd() -> String {
541 std::env::current_dir()
542 .map(|p| p.display().to_string())
543 .unwrap_or_default()
544}
545
546fn system_user() -> String {
548 if let Ok(user) = std::env::var("USER") {
549 if !user.is_empty() {
550 return user;
551 }
552 }
553 #[cfg(unix)]
554 {
555 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
556 return user.name;
557 }
558 }
559 String::new()
560}
561
562fn hostname() -> String {
564 #[cfg(unix)]
565 {
566 if let Ok(name) = nix::unistd::gethostname() {
567 if let Some(name) = name.to_str() {
568 if !name.is_empty() {
569 return name.to_string();
570 }
571 }
572 }
573 }
574 std::env::var("HOSTNAME").unwrap_or_default()
575}
576
577const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
580
581fn whitelisted_env() -> BTreeMap<String, String> {
583 std::env::vars()
584 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
585 .map(|(k, v)| {
586 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
587 let value = if secretish { "REDACTED".to_string() } else { v };
588 (k, value)
589 })
590 .collect()
591}
592
593#[cfg(test)]
594#[allow(clippy::unwrap_used, clippy::expect_used)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn record_round_trips_through_json() {
600 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
601 rec.service = Some("jira".to_string());
602 rec.method = Some("GET".to_string());
603 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
604 rec.status_code = Some(200);
605 rec.elapsed_ms = Some(42);
606
607 let line = serde_json::to_string(&rec).unwrap();
608 let back: LogRecord = serde_json::from_str(&line).unwrap();
609 assert_eq!(back.invocation_id, "inv-1");
610 assert_eq!(back.kind, RecordKind::Http);
611 assert_eq!(back.service.as_deref(), Some("jira"));
612 assert_eq!(back.status_code, Some(200));
613 }
614
615 #[test]
616 fn reader_tolerates_unknown_fields() {
617 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
618 "future_field":{"nested":true},"another":42}"#;
619 let rec: LogRecord = serde_json::from_str(line).unwrap();
620 assert_eq!(rec.kind, RecordKind::Http);
621 assert_eq!(rec.method.as_deref(), Some("GET"));
622 }
623
624 #[test]
625 fn reader_tolerates_missing_newer_fields() {
626 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
628 let rec: LogRecord = serde_json::from_str(line).unwrap();
629 assert_eq!(rec.kind, RecordKind::Invocation);
630 assert_eq!(rec.command, vec!["git", "view"]);
631 assert!(rec.status_code.is_none());
632 assert!(rec.id.is_empty());
633 }
634
635 #[test]
636 fn unknown_kind_and_source_do_not_fail() {
637 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
638 let rec: LogRecord = serde_json::from_str(line).unwrap();
639 assert_eq!(rec.kind, RecordKind::Unknown);
640 assert_eq!(rec.source, Some(Source::Unknown));
641 }
642
643 #[test]
644 fn optional_fields_are_skipped_when_empty() {
645 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
646 let line = serde_json::to_string(&rec).unwrap();
647 assert!(!line.contains("status_code"));
649 assert!(!line.contains("request_headers"));
650 assert!(!line.contains("via_daemon"));
651 assert!(!line.contains("\"env\""));
652 }
653
654 #[test]
655 fn ids_are_time_sortable() {
656 let a = new_id();
657 std::thread::sleep(std::time::Duration::from_millis(2));
658 let b = new_id();
659 assert!(a < b, "{a} should sort before {b}");
660 }
661
662 #[test]
663 fn sensitive_headers_are_redacted() {
664 let mut headers = BTreeMap::new();
665 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
666 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
667 headers.insert("Content-Type".to_string(), "application/json".to_string());
668 let out = redact_headers(&headers);
669 assert_eq!(out["Authorization"], "REDACTED");
670 assert_eq!(out["X-Api-Key"], "REDACTED");
671 assert_eq!(out["Content-Type"], "application/json");
672 }
673
674 #[test]
675 fn env_flag_parses_truthy_values() {
676 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
677 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
678 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
679 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
680 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
681 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
682 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
683 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
684 }
685}