use std::collections::BTreeMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};
const LOG_FILE_NAME: &str = "log.jsonl";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RecordKind {
#[default]
Invocation,
Http,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
#[default]
Cli,
Mcp,
Daemon,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LogRecord {
#[serde(default)]
pub id: String,
#[serde(default)]
pub invocation_id: String,
#[serde(default)]
pub kind: RecordKind,
#[serde(default)]
pub timestamp: String,
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub omni_dev_version: String,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub system_user: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_line: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<Source>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
#[serde(default, skip_serializing_if = "is_false")]
pub via_daemon: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_principal: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub request_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub response_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_body: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
!*b
}
impl LogRecord {
fn new(kind: RecordKind, invocation_id: String) -> Self {
Self {
id: new_id(),
invocation_id,
kind,
timestamp: now_rfc3339_millis(),
hostname: hostname(),
pid: std::process::id(),
omni_dev_version: crate::VERSION.to_string(),
cwd: cwd(),
system_user: system_user(),
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct RequestLogContext {
pub invocation_id: String,
pub source: Source,
pub mcp_tool: Option<String>,
}
impl Default for RequestLogContext {
fn default() -> Self {
Self {
invocation_id: new_id(),
source: Source::Cli,
mcp_tool: None,
}
}
}
impl RequestLogContext {
pub fn cli() -> Self {
Self {
invocation_id: new_id(),
source: Source::Cli,
mcp_tool: None,
}
}
pub fn mcp(tool: impl Into<String>) -> Self {
Self {
invocation_id: new_id(),
source: Source::Mcp,
mcp_tool: Some(tool.into()),
}
}
}
static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
tokio::task_local! {
pub static CTX: RequestLogContext;
}
pub fn set_global(ctx: RequestLogContext) {
let _ = GLOBAL.set(ctx);
}
pub fn current_context() -> RequestLogContext {
if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
return ctx;
}
if let Some(ctx) = GLOBAL.get() {
return ctx.clone();
}
RequestLogContext::default()
}
pub fn disabled() -> bool {
env_flag("OMNI_DEV_LOG_DISABLE")
}
pub fn bodies_enabled() -> bool {
env_flag("OMNI_DEV_LOG_BODIES")
}
pub fn headers_enabled() -> bool {
env_flag("OMNI_DEV_LOG_HEADERS")
}
fn env_flag(name: &str) -> bool {
std::env::var(name).is_ok_and(|v| {
let v = v.trim().to_ascii_lowercase();
v == "1" || v == "true" || v == "yes"
})
}
pub fn log_file_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
if !path.is_empty() {
return Some(PathBuf::from(path));
}
}
let base = dirs::state_dir().or_else(dirs::data_dir)?;
Some(base.join("omni-dev").join(LOG_FILE_NAME))
}
pub fn record(entry: &LogRecord) {
if disabled() {
return;
}
if let Err(e) = try_record(entry) {
tracing::debug!("request_log: failed to append record: {e}");
}
}
fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
use anyhow::Context;
let path = log_file_path().context("could not resolve the log file path")?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
crate::daemon::paths::ensure_dir_0700(parent)?;
}
}
let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
line.push('\n');
append_line(&path, &line)?;
Ok(())
}
#[cfg(unix)]
fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(path)?;
if bodies_enabled() {
match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
Ok(mut guard) => {
guard.write_all(line.as_bytes())?;
}
Err((mut file, _)) => {
file.write_all(line.as_bytes())?;
}
}
} else {
let mut file = file;
file.write_all(line.as_bytes())?;
}
Ok(())
}
#[cfg(not(unix))]
fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)?;
file.write_all(line.as_bytes())?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct InvocationOutcome {
pub command: Vec<String>,
pub command_line: Vec<String>,
pub exit_code: i32,
pub error: Option<String>,
pub duration: Duration,
}
pub fn record_invocation(outcome: InvocationOutcome) {
let ctx = current_context();
let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.command = outcome.command;
rec.command_line = outcome.command_line;
rec.exit_code = Some(outcome.exit_code);
rec.error = outcome.error;
rec.duration_ms = Some(outcome.duration.as_millis() as u64);
rec.env = whitelisted_env();
record(&rec);
}
#[derive(Debug, Clone, Default)]
pub struct HttpExtra {
pub via_daemon: bool,
pub daemon_session_id: Option<String>,
pub auth_principal: Option<String>,
pub request_headers: BTreeMap<String, String>,
pub response_headers: BTreeMap<String, String>,
pub request_body: Option<String>,
pub response_body: Option<String>,
pub context: BTreeMap<String, String>,
}
pub fn record_http(
service: &str,
method: &str,
url: &str,
started: Instant,
status: Option<u16>,
error: Option<&str>,
) {
record_http_with(
service,
method,
url,
started,
status,
error,
HttpExtra::default(),
);
}
#[allow(clippy::too_many_arguments)]
pub fn record_http_with(
service: &str,
method: &str,
url: &str,
started: Instant,
status: Option<u16>,
error: Option<&str>,
extra: HttpExtra,
) {
if disabled() {
return;
}
let ctx = current_context();
let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.service = Some(service.to_string());
rec.method = Some(method.to_string());
rec.url = Some(url.to_string());
rec.status_code = status;
rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
rec.error = error.map(str::to_string);
rec.via_daemon = extra.via_daemon;
rec.daemon_session_id = extra.daemon_session_id;
rec.auth_principal = extra.auth_principal;
rec.context = extra.context;
if headers_enabled() {
rec.request_headers = redact_headers(&extra.request_headers);
rec.response_headers = redact_headers(&extra.response_headers);
}
if bodies_enabled() {
rec.request_body = extra.request_body;
rec.response_body = extra.response_body;
}
record(&rec);
}
const SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
"dd-api-key",
"dd-application-key",
"x-datadog-api-key",
"x-datadog-application-key",
"x-omni-bridge",
"x-omni-bridge-target",
];
pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
headers
.iter()
.map(|(name, value)| {
let redacted = SENSITIVE_HEADERS.contains(&name.to_ascii_lowercase().as_str());
(
name.clone(),
if redacted {
"REDACTED".to_string()
} else {
value.clone()
},
)
})
.collect()
}
pub fn new_id() -> String {
let millis = chrono::Utc::now().timestamp_millis().max(0);
let suffix = rand::random::<u64>();
format!("{millis:013}-{suffix:016x}")
}
fn now_rfc3339_millis() -> String {
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn cwd() -> String {
std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default()
}
fn system_user() -> String {
if let Ok(user) = std::env::var("USER") {
if !user.is_empty() {
return user;
}
}
#[cfg(unix)]
{
if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
return user.name;
}
}
String::new()
}
fn hostname() -> String {
#[cfg(unix)]
{
if let Ok(name) = nix::unistd::gethostname() {
if let Some(name) = name.to_str() {
if !name.is_empty() {
return name.to_string();
}
}
}
}
std::env::var("HOSTNAME").unwrap_or_default()
}
const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
fn whitelisted_env() -> BTreeMap<String, String> {
std::env::vars()
.filter(|(k, _)| k.starts_with("OMNI_DEV_"))
.map(|(k, v)| {
let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
let value = if secretish { "REDACTED".to_string() } else { v };
(k, value)
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn record_round_trips_through_json() {
let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
rec.service = Some("jira".to_string());
rec.method = Some("GET".to_string());
rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
rec.status_code = Some(200);
rec.elapsed_ms = Some(42);
let line = serde_json::to_string(&rec).unwrap();
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.invocation_id, "inv-1");
assert_eq!(back.kind, RecordKind::Http);
assert_eq!(back.service.as_deref(), Some("jira"));
assert_eq!(back.status_code, Some(200));
}
#[test]
fn reader_tolerates_unknown_fields() {
let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
"future_field":{"nested":true},"another":42}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Http);
assert_eq!(rec.method.as_deref(), Some("GET"));
}
#[test]
fn reader_tolerates_missing_newer_fields() {
let line = r#"{"kind":"invocation","command":["git","view"]}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Invocation);
assert_eq!(rec.command, vec!["git", "view"]);
assert!(rec.status_code.is_none());
assert!(rec.id.is_empty());
}
#[test]
fn unknown_kind_and_source_do_not_fail() {
let line = r#"{"kind":"telemetry","source":"webhook"}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Unknown);
assert_eq!(rec.source, Some(Source::Unknown));
}
#[test]
fn optional_fields_are_skipped_when_empty() {
let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
let line = serde_json::to_string(&rec).unwrap();
assert!(!line.contains("status_code"));
assert!(!line.contains("request_headers"));
assert!(!line.contains("via_daemon"));
assert!(!line.contains("\"env\""));
}
#[test]
fn ids_are_time_sortable() {
let a = new_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = new_id();
assert!(a < b, "{a} should sort before {b}");
}
#[test]
fn sensitive_headers_are_redacted() {
let mut headers = BTreeMap::new();
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
headers.insert("X-Api-Key".to_string(), "abc123".to_string());
headers.insert("Content-Type".to_string(), "application/json".to_string());
let out = redact_headers(&headers);
assert_eq!(out["Authorization"], "REDACTED");
assert_eq!(out["X-Api-Key"], "REDACTED");
assert_eq!(out["Content-Type"], "application/json");
}
#[test]
fn env_flag_parses_truthy_values() {
std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
}
}