Skip to main content

omni_dev/
request_log.rs

1//! Append-only, local invocation + HTTP request log (`log.jsonl`).
2//!
3//! Every `omni-dev` invocation appends one `kind: "invocation"` line; every
4//! outbound HTTP request made by one of the integration clients appends one
5//! `kind: "http"` line correlated to it by a shared `invocation_id`. The log
6//! is **local-machine state** written under the platform state/data directory
7//! (`0700` dir / `0600` file, the same posture as [`crate::daemon::paths`]).
8//!
9//! Design invariants:
10//!
11//! - **Best effort.** [`record`] swallows every error (logging only at
12//!   `tracing::debug`); a logging failure can never change the program's exit
13//!   code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out.
14//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
15//!   `auth_principal` identity is kept. Headers are redacted centrally
16//!   ([`redact_headers`]) and request/response bodies are opt-in via
17//!   `OMNI_DEV_LOG_BODIES=1`.
18//! - **Forward compatible.** A single [`LogRecord`] is used for both writing
19//!   and reading: every field is `#[serde(default)]`, and every optional field
20//!   is `skip_serializing_if`, so a newer reader never chokes on an older line
21//!   and an older reader never chokes on a newer one — the same forward-rolling
22//!   contract the daemon wire types use.
23
24use 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
33/// Default log file name under the runtime directory.
34const LOG_FILE_NAME: &str = "log.jsonl";
35
36/// Which kind of record a line holds. Unknown future kinds deserialize to
37/// [`RecordKind::Unknown`] rather than failing the read.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
39#[serde(rename_all = "lowercase")]
40pub enum RecordKind {
41    /// One per process invocation (or per MCP tool call).
42    #[default]
43    Invocation,
44    /// One per outbound HTTP request.
45    Http,
46    /// A kind written by a newer version that this reader does not know.
47    #[serde(other)]
48    Unknown,
49}
50
51/// What drove an invocation. Unknown future sources deserialize to
52/// [`Source::Unknown`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum Source {
56    /// A direct `omni-dev` CLI invocation.
57    #[default]
58    Cli,
59    /// An `omni-dev-mcp` tool call.
60    Mcp,
61    /// Work performed inside the long-lived daemon process.
62    Daemon,
63    /// A source written by a newer version that this reader does not know.
64    #[serde(other)]
65    Unknown,
66}
67
68/// One line of the log. Used for both writing and reading; every field is
69/// `#[serde(default)]` (tolerant reads) and every optional field is
70/// `skip_serializing_if` (compact, forward-compatible writes).
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
72pub struct LogRecord {
73    // --- Core fields (present on every record) ---
74    /// Per-record, time-sortable id (see [`new_id`]).
75    #[serde(default)]
76    pub id: String,
77    /// Shared by an invocation record and every HTTP record it spawned.
78    #[serde(default)]
79    pub invocation_id: String,
80    /// Discriminates the record type.
81    #[serde(default)]
82    pub kind: RecordKind,
83    /// RFC3339 timestamp with milliseconds.
84    #[serde(default)]
85    pub timestamp: String,
86    /// Host the record was written on.
87    #[serde(default)]
88    pub hostname: String,
89    /// Writing process id.
90    #[serde(default)]
91    pub pid: u32,
92    /// `omni-dev` version that wrote the record.
93    #[serde(default)]
94    pub omni_dev_version: String,
95    /// Working directory at write time.
96    #[serde(default)]
97    pub cwd: String,
98    /// OS user that owns the process.
99    #[serde(default)]
100    pub system_user: String,
101
102    // --- `kind: "invocation"` fields ---
103    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub command: Vec<String>,
106    /// Full argv.
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub command_line: Vec<String>,
109    /// Process exit code (0 success, 1 error — matches `die`).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub exit_code: Option<i32>,
112    /// Wall time of the whole invocation.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub duration_ms: Option<u64>,
115    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
116    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117    pub env: BTreeMap<String, String>,
118    /// What drove the run (`cli`/`mcp`/`daemon`).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub source: Option<Source>,
121    /// When `source = mcp`, the tool name that drove the run.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub mcp_tool: Option<String>,
124
125    // --- `kind: "http"` fields ---
126    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub service: Option<String>,
129    /// HTTP method.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub method: Option<String>,
132    /// Request URL.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub url: Option<String>,
135    /// Response status; absent on a network/transport error.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub status_code: Option<u16>,
138    /// Elapsed time of the request.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub elapsed_ms: Option<u64>,
141    /// True when the request ran inside the daemon (bridge/Snowflake pool).
142    #[serde(default, skip_serializing_if = "is_false")]
143    pub via_daemon: bool,
144    /// Which pooled daemon session served the request.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub daemon_session_id: Option<String>,
147    /// Non-secret identity actually used (token id / OAuth principal) — never
148    /// the secret itself.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub auth_principal: Option<String>,
151    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
152    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
153    pub request_headers: BTreeMap<String, String>,
154    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
155    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
156    pub response_headers: BTreeMap<String, String>,
157    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub request_body: Option<String>,
160    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub response_body: Option<String>,
163    /// Free-form correlation tags.
164    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
165    pub context: BTreeMap<String, String>,
166
167    // --- shared optional ---
168    /// Top-level error chain (invocation) or per-request error (http).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub error: Option<String>,
171}
172
173/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
174#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
175fn is_false(b: &bool) -> bool {
176    !*b
177}
178
179impl LogRecord {
180    /// Builds a record carrying only the always-present core fields.
181    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/// The per-invocation context every record is stamped with.
198///
199/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
200/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
201/// invocation without threading state through every call site.
202#[derive(Debug, Clone)]
203pub struct RequestLogContext {
204    /// Shared id linking an invocation to the HTTP it spawned.
205    pub invocation_id: String,
206    /// What drove the run.
207    pub source: Source,
208    /// MCP tool name when `source = mcp`.
209    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    /// A CLI context with a freshly minted invocation id.
224    pub fn cli() -> Self {
225        Self {
226            invocation_id: new_id(),
227            source: Source::Cli,
228            mcp_tool: None,
229        }
230    }
231
232    /// An MCP context for a single tool call.
233    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    /// Per-task context override, set around each MCP tool dispatch.
246    pub static CTX: RequestLogContext;
247}
248
249/// Installs the process-global context. The first call wins (the CLI/daemon
250/// shell sets it once, very early); later calls are ignored.
251pub fn set_global(ctx: RequestLogContext) {
252    let _ = GLOBAL.set(ctx);
253}
254
255/// Resolves the active context: task-local override first, then the
256/// process-global default, then a synthesized fallback.
257pub 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
267/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
268pub fn disabled() -> bool {
269    env_flag("OMNI_DEV_LOG_DISABLE")
270}
271
272/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
273pub fn bodies_enabled() -> bool {
274    env_flag("OMNI_DEV_LOG_BODIES")
275}
276
277/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
278pub fn headers_enabled() -> bool {
279    env_flag("OMNI_DEV_LOG_HEADERS")
280}
281
282/// Reads a boolean-ish env var (`1`/`true`/`yes`, case-insensitive).
283fn 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
290/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
291/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
292pub 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
302/// Appends one record. Best effort: every error is swallowed (logged at
303/// `tracing::debug`) so logging can never affect the caller's exit code.
304pub 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
313/// The fallible append used by [`record`]; all errors flow back to be swallowed.
314fn 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    // Only create and tighten the parent when it's missing — re-`chmod`ing an
319    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
320    // temp dir) is both wrong and may fail; the file itself is always 0600.
321    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/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
333/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
334/// exclusive lock guards the write; the common no-body path relies on
335/// `O_APPEND` single-write atomicity and takes no lock.
336#[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/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
363/// no mode tightening (those are unix concepts).
364#[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/// The outcome of an invocation, recorded once after `cli.execute()` returns.
375#[derive(Debug, Clone)]
376pub struct InvocationOutcome {
377    /// Resolved clap subcommand path.
378    pub command: Vec<String>,
379    /// Full argv.
380    pub command_line: Vec<String>,
381    /// Process exit code.
382    pub exit_code: i32,
383    /// Rendered error chain, when the command failed.
384    pub error: Option<String>,
385    /// Wall time of the whole invocation.
386    pub duration: Duration,
387}
388
389/// Appends one `kind: "invocation"` record from the active context.
390pub 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/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
405/// redacted centrally in [`record_http_with`], so callers may pass them freely.
406#[derive(Debug, Clone, Default)]
407pub struct HttpExtra {
408    /// True when served inside the daemon.
409    pub via_daemon: bool,
410    /// Pooled daemon session id that served the request.
411    pub daemon_session_id: Option<String>,
412    /// Non-secret identity used (never the secret).
413    pub auth_principal: Option<String>,
414    /// Raw request headers (redacted + gated before writing).
415    pub request_headers: BTreeMap<String, String>,
416    /// Raw response headers (redacted + gated before writing).
417    pub response_headers: BTreeMap<String, String>,
418    /// Request body (gated before writing).
419    pub request_body: Option<String>,
420    /// Response body (gated before writing).
421    pub response_body: Option<String>,
422    /// Free-form correlation tags.
423    pub context: BTreeMap<String, String>,
424}
425
426/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
427pub 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/// Appends one `kind: "http"` record with extra, non-secret fields.
447///
448/// Headers and bodies are dropped unless their opt-in env var is set, and
449/// headers are always redacted — so no secret can be written here under any
450/// caller.
451#[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
489/// Header names whose values must never be written (compared lowercased).
490const 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
505/// Replaces sensitive header values with `REDACTED`, passing others through.
506pub 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
523/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
524///
525/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
526/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
527/// crate.
528pub 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
534/// Current time as RFC3339 with millisecond precision, in UTC.
535fn now_rfc3339_millis() -> String {
536    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
537}
538
539/// Best-effort current working directory.
540fn cwd() -> String {
541    std::env::current_dir()
542        .map(|p| p.display().to_string())
543        .unwrap_or_default()
544}
545
546/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
547fn 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
562/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
563fn 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
577/// Names matching these substrings have their env values redacted, guarding
578/// against any future secret-bearing `OMNI_DEV_*` var.
579const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
580
581/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
582fn 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        // An "old" line with only a couple of fields present.
627        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        // Empty collections / None options must not appear on the wire.
648        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}