Skip to main content

devboy_skills/
trace.rs

1//! Session traces for the self-feedback loop (ADR-015).
2//!
3//! A session is the execution of one skill (or any caller that opts in
4//! through the `devboy trace` CLI). Events land as JSON Lines at
5//! `<target>/.devboy/sessions/<YYYY-MM-DD>/<skill>/<session_id>/trace.jsonl`,
6//! and a sibling `meta.json` in the same per-session directory carries
7//! session-level metadata. The `<session_id>` segment keeps concurrent
8//! or repeated invocations of the same skill on the same day isolated
9//! from each other (ADR-015 requires self-contained sessions).
10//!
11//! The [`SessionTracer`] writer is intentionally small — it serialises
12//! one event per line with no framing, no network I/O, and no reliance
13//! on the host logging stack. The companion [`devboy trace`] CLI
14//! subcommand family in `devboy-cli` lets shell-based skills write into
15//! the same format.
16//!
17//! The redaction pass in [`redact::sanitize`] strips values that match
18//! known credential shapes and values of environment variables named
19//! `*_TOKEN` / `*_SECRET` / `*_KEY` / `*_PASSWORD` / `*_PASSPHRASE` /
20//! `AUTHORIZATION` / `COOKIE` before anything is written to disk.
21
22use std::fs::{File, OpenOptions, create_dir_all};
23use std::io::Write;
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26
27use chrono::{DateTime, Utc};
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::error::{Result, SkillError};
32
33pub mod redact;
34
35// ---------------------------------------------------------------------------
36// Phase + Outcome enums
37// ---------------------------------------------------------------------------
38
39/// Event phases. Kept small; readers must silently ignore unknown values.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Phase {
43    /// First event of the session, marking the start of execution.
44    /// The tracer synthesises a `start` record with an empty payload;
45    /// any input / cwd / version metadata is caller-defined — pass it
46    /// via a regular `event` call after `begin` if you want it in the
47    /// stream, or record it in `meta.json` via a later `end` call.
48    Start,
49    /// Skill-level reasoning outcome.
50    Decision,
51    /// Immediately before invoking a tool.
52    ToolCall,
53    /// Immediately after a tool returns.
54    ToolResult,
55    /// A verification check ran (tests / clippy / dry-run etc.).
56    Verify,
57    /// Non-trivial file produced by the skill.
58    Artifact,
59    /// Free-form human-readable log entry.
60    Note,
61    /// Last event of the session.
62    End,
63}
64
65/// Session outcome — recorded on `End` in both the event stream and the
66/// per-session `meta.json`.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum Outcome {
70    /// Skill completed and achieved its objective.
71    Success,
72    /// Skill ran to completion but the objective was not met.
73    Failure,
74    /// Skill stopped before completing (cancelled, interrupted).
75    Aborted,
76}
77
78// ---------------------------------------------------------------------------
79// Target resolution
80// ---------------------------------------------------------------------------
81
82/// Where to write session traces.
83#[derive(Debug, Clone)]
84pub enum TraceTarget {
85    /// Repo-local at `<repo>/.devboy/sessions/`. Default.
86    RepoLocal,
87    /// Per-user at `~/.devboy/sessions/`.
88    Global,
89    /// Explicit directory — used by tests.
90    Custom(PathBuf),
91}
92
93impl TraceTarget {
94    /// Resolve the `sessions/` root directory for this target.
95    pub fn sessions_root(&self) -> Result<PathBuf> {
96        match self {
97            Self::RepoLocal => {
98                let cwd = std::env::current_dir().map_err(|source| SkillError::Io {
99                    path: PathBuf::from("."),
100                    source,
101                })?;
102                let repo = locate_repo_root(&cwd).ok_or_else(|| SkillError::Io {
103                    path: cwd,
104                    source: std::io::Error::other(
105                        "no git repository / .devboy.toml at the current path (pass --global to write traces under ~/.devboy/sessions/)",
106                    ),
107                })?;
108                Ok(repo.join(".devboy").join("sessions"))
109            }
110            Self::Global => {
111                let home = home_dir()?;
112                Ok(home.join(".devboy").join("sessions"))
113            }
114            Self::Custom(p) => Ok(p.clone()),
115        }
116    }
117}
118
119fn locate_repo_root(start: &Path) -> Option<PathBuf> {
120    let mut cur = start;
121    loop {
122        if cur.join(".git").exists() || cur.join(".devboy.toml").exists() {
123            return Some(cur.to_path_buf());
124        }
125        cur = cur.parent()?;
126    }
127}
128
129fn home_dir() -> Result<PathBuf> {
130    if let Some(p) = std::env::var_os("DEVBOY_HOME_OVERRIDE")
131        && !p.is_empty()
132    {
133        return Ok(PathBuf::from(p));
134    }
135    dirs::home_dir().ok_or_else(|| SkillError::Io {
136        path: PathBuf::from("~"),
137        source: std::io::Error::other("home directory is not set"),
138    })
139}
140
141// ---------------------------------------------------------------------------
142// SessionTracer
143// ---------------------------------------------------------------------------
144
145/// Appends events to a session's `trace.jsonl`.
146///
147/// A new instance is built via [`SessionTracer::begin`], events are
148/// appended with [`SessionTracer::event`], and the session is finalised
149/// with [`SessionTracer::end`]. Dropping a tracer without calling `end`
150/// leaves the `trace.jsonl` intact but does not write `meta.json` — the
151/// stream still round-trips, just without the convenience summary.
152pub struct SessionTracer {
153    session_id: String,
154    skill: String,
155    session_dir: PathBuf,
156    trace_path: PathBuf,
157    meta_path: PathBuf,
158    trace_file: Mutex<File>,
159    started_at: DateTime<Utc>,
160    tool_calls: std::sync::atomic::AtomicU64,
161    errors: std::sync::atomic::AtomicU64,
162    /// Env-var snapshot captured at `begin`, reused for every
163    /// `write_event` so the redactor does not rescan `std::env::vars()`
164    /// on each record (can be tens of thousands of events in a long
165    /// session).
166    redactor: redact::Redactor,
167}
168
169impl SessionTracer {
170    /// Start a new session. Creates a unique directory
171    /// `<root>/<date>/<skill>/<session_id>/` and opens `trace.jsonl`
172    /// for append. The per-session directory ensures that concurrent
173    /// or repeated invocations of the same skill on the same day do
174    /// not share any files (ADR-015 requires self-contained sessions).
175    pub fn begin(skill: &str, target: &TraceTarget) -> Result<Self> {
176        let root = target.sessions_root()?;
177        let started_at = Utc::now();
178        let date = started_at.format("%Y-%m-%d").to_string();
179        let session_id = new_session_id();
180        let session_dir = root.join(&date).join(skill).join(&session_id);
181        create_dir_all(&session_dir).map_err(|source| SkillError::Io {
182            path: session_dir.clone(),
183            source,
184        })?;
185
186        let trace_path = session_dir.join("trace.jsonl");
187        let meta_path = session_dir.join("meta.json");
188        let file = OpenOptions::new()
189            .create(true)
190            .append(true)
191            .open(&trace_path)
192            .map_err(|source| SkillError::Io {
193                path: trace_path.clone(),
194                source,
195            })?;
196
197        let tracer = Self {
198            session_id,
199            skill: skill.to_string(),
200            session_dir,
201            trace_path,
202            meta_path,
203            trace_file: Mutex::new(file),
204            started_at,
205            tool_calls: std::sync::atomic::AtomicU64::new(0),
206            errors: std::sync::atomic::AtomicU64::new(0),
207            redactor: redact::Redactor::snapshot(),
208        };
209
210        // Emit the `start` event synthesised from the call arguments;
211        // callers can decorate it further via a regular `event` call.
212        tracer.write_event(Phase::Start, json!({}))?;
213        Ok(tracer)
214    }
215
216    /// Append one event to the trace. Payload is redacted before
217    /// writing.
218    pub fn event(&self, phase: Phase, payload: Value) -> Result<()> {
219        if phase == Phase::ToolCall {
220            self.tool_calls
221                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
222        }
223        if phase == Phase::ToolResult
224            && payload
225                .get("ok")
226                .and_then(|v| v.as_bool())
227                .is_some_and(|ok| !ok)
228        {
229            self.errors
230                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
231        }
232        self.write_event(phase, payload)
233    }
234
235    /// Record the final `end` event and write the per-session
236    /// `meta.json`. Consumes the tracer.
237    pub fn end(self, outcome: Outcome, summary: &str) -> Result<()> {
238        let ended_at = Utc::now();
239        self.write_event(
240            Phase::End,
241            json!({ "outcome": outcome, "summary": summary }),
242        )?;
243
244        let meta = SessionMeta {
245            session_id: self.session_id.clone(),
246            skill: self.skill.clone(),
247            skill_version: None,
248            devboy_version: env!("CARGO_PKG_VERSION").to_string(),
249            started_at: self.started_at,
250            ended_at: Some(ended_at),
251            outcome: Some(outcome),
252            input_summary: None,
253            tool_calls: self.tool_calls.load(std::sync::atomic::Ordering::Relaxed),
254            errors: self.errors.load(std::sync::atomic::Ordering::Relaxed),
255            summary: Some(summary.to_string()),
256        };
257        let bytes = serde_json::to_vec_pretty(&meta).map_err(|source| SkillError::SerdeJson {
258            operation: "serialise session meta",
259            path: self.meta_path.clone(),
260            source,
261        })?;
262        std::fs::write(&self.meta_path, bytes).map_err(|source| SkillError::Io {
263            path: self.meta_path.clone(),
264            source,
265        })
266    }
267
268    /// The session directory on disk.
269    pub fn session_dir(&self) -> &Path {
270        &self.session_dir
271    }
272
273    /// The `trace.jsonl` path.
274    pub fn trace_path(&self) -> &Path {
275        &self.trace_path
276    }
277
278    /// The session id (ULID, or a randomly-generated fallback when the
279    /// `trace` Cargo feature is disabled).
280    pub fn session_id(&self) -> &str {
281        &self.session_id
282    }
283
284    fn write_event(&self, phase: Phase, payload: Value) -> Result<()> {
285        let redacted = self.redactor.sanitize(payload);
286        let record = TraceRecord {
287            ts: Utc::now(),
288            skill: self.skill.clone(),
289            session_id: self.session_id.clone(),
290            phase,
291            payload: redacted,
292        };
293        let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
294            operation: "serialise trace record",
295            path: self.trace_path.clone(),
296            source,
297        })?;
298
299        let mut guard = self.trace_file.lock().map_err(|_| SkillError::Io {
300            path: self.trace_path.clone(),
301            source: std::io::Error::other("trace mutex poisoned"),
302        })?;
303        guard
304            .write_all(line.as_bytes())
305            .and_then(|()| guard.write_all(b"\n"))
306            .map_err(|source| SkillError::Io {
307                path: self.trace_path.clone(),
308                source,
309            })
310    }
311}
312
313// ---------------------------------------------------------------------------
314// On-disk formats
315// ---------------------------------------------------------------------------
316
317/// One line of `trace.jsonl`.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct TraceRecord {
320    /// Timestamp of the event.
321    pub ts: DateTime<Utc>,
322    /// Skill name — duplicated on every event so that readers that
323    /// merge traces from different sessions can still attribute each
324    /// line.
325    pub skill: String,
326    /// Session id (ULID).
327    pub session_id: String,
328    /// Event phase.
329    pub phase: Phase,
330    /// Event payload. Redacted before writing.
331    pub payload: Value,
332}
333
334/// `meta.json` — written at session end and optionally touched during
335/// long sessions.
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct SessionMeta {
338    /// Session id matching every record in `trace.jsonl`.
339    pub session_id: String,
340    pub skill: String,
341    /// Skill version at run time, if the caller provided it.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub skill_version: Option<u32>,
344    /// devboy-tools version that emitted the session.
345    pub devboy_version: String,
346    /// Session start timestamp.
347    pub started_at: DateTime<Utc>,
348    /// Session end timestamp (missing on in-flight sessions).
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub ended_at: Option<DateTime<Utc>>,
351    /// Outcome, if the session ended cleanly.
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub outcome: Option<Outcome>,
354    /// One-line input summary recorded at start, if available.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub input_summary: Option<String>,
357    /// Number of tool calls observed.
358    pub tool_calls: u64,
359    /// Number of tool calls that reported `ok: false`.
360    pub errors: u64,
361    /// Closing summary string (mirrors the `end` event payload).
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub summary: Option<String>,
364}
365
366// ---------------------------------------------------------------------------
367// Stateless helpers (used by `devboy trace` CLI subcommands)
368// ---------------------------------------------------------------------------
369
370/// Create the session directory, write the opening `start` event, and
371/// return the session id + session directory. The returned path is the
372/// value the user passed through `TraceTarget` with the `<date>/<skill>
373/// /<session_id>` suffix appended — it is not canonicalised, so a
374/// relative `TraceTarget::Custom(".traces")` produces a relative path
375/// and an absolute target produces an absolute path. Callers pass both
376/// values back into [`append_event`] and [`finalise_session`] on
377/// subsequent CLI invocations.
378///
379/// The per-session directory level is required so that concurrent or
380/// repeated invocations of the same skill on the same day never share
381/// `trace.jsonl` or `meta.json` (ADR-015).
382pub fn create_session(skill: &str, target: &TraceTarget) -> Result<(String, PathBuf)> {
383    let root = target.sessions_root()?;
384    let started_at = Utc::now();
385    let date = started_at.format("%Y-%m-%d").to_string();
386    let session_id = new_session_id();
387    let session_dir = root.join(&date).join(skill).join(&session_id);
388    create_dir_all(&session_dir).map_err(|source| SkillError::Io {
389        path: session_dir.clone(),
390        source,
391    })?;
392    append_event_inner(
393        &session_dir,
394        &session_id,
395        skill,
396        Phase::Start,
397        Value::Object(Default::default()),
398    )?;
399    // Write a skeletal meta.json so long-running sessions are
400    // introspectable before `end` runs.
401    let skeleton = SessionMeta {
402        session_id: session_id.clone(),
403        skill: skill.to_string(),
404        skill_version: None,
405        devboy_version: env!("CARGO_PKG_VERSION").to_string(),
406        started_at,
407        ended_at: None,
408        outcome: None,
409        input_summary: None,
410        tool_calls: 0,
411        errors: 0,
412        summary: None,
413    };
414    write_meta_file(&session_dir.join("meta.json"), &skeleton)?;
415    Ok((session_id, session_dir))
416}
417
418/// Append one event to an existing session's `trace.jsonl`.
419pub fn append_event(
420    session_dir: &Path,
421    session_id: &str,
422    skill: &str,
423    phase: Phase,
424    payload: Value,
425) -> Result<()> {
426    append_event_inner(session_dir, session_id, skill, phase, payload)
427}
428
429fn append_event_inner(
430    session_dir: &Path,
431    session_id: &str,
432    skill: &str,
433    phase: Phase,
434    payload: Value,
435) -> Result<()> {
436    let redacted = redact::sanitize(payload);
437    let record = TraceRecord {
438        ts: Utc::now(),
439        skill: skill.to_string(),
440        session_id: session_id.to_string(),
441        phase,
442        payload: redacted,
443    };
444    let line = serde_json::to_string(&record).map_err(|source| SkillError::SerdeJson {
445        operation: "serialise trace record",
446        path: session_dir.join("trace.jsonl"),
447        source,
448    })?;
449
450    let trace_path = session_dir.join("trace.jsonl");
451    let mut file = OpenOptions::new()
452        .create(true)
453        .append(true)
454        .open(&trace_path)
455        .map_err(|source| SkillError::Io {
456            path: trace_path.clone(),
457            source,
458        })?;
459    file.write_all(line.as_bytes())
460        .and_then(|()| file.write_all(b"\n"))
461        .map_err(|source| SkillError::Io {
462            path: trace_path.clone(),
463            source,
464        })
465}
466
467/// Write the final `end` event and refresh `meta.json` with the
468/// outcome + aggregated counts derived from the existing trace.
469pub fn finalise_session(
470    session_dir: &Path,
471    session_id: &str,
472    skill: &str,
473    outcome: Outcome,
474    summary: &str,
475) -> Result<()> {
476    append_event_inner(
477        session_dir,
478        session_id,
479        skill,
480        Phase::End,
481        json!({ "outcome": outcome, "summary": summary }),
482    )?;
483
484    let trace_path = session_dir.join("trace.jsonl");
485    let (tool_calls, errors, started_at) = scan_counts(&trace_path)?;
486
487    let meta = SessionMeta {
488        session_id: session_id.to_string(),
489        skill: skill.to_string(),
490        skill_version: None,
491        devboy_version: env!("CARGO_PKG_VERSION").to_string(),
492        started_at,
493        ended_at: Some(Utc::now()),
494        outcome: Some(outcome),
495        input_summary: None,
496        tool_calls,
497        errors,
498        summary: Some(summary.to_string()),
499    };
500    write_meta_file(&session_dir.join("meta.json"), &meta)
501}
502
503fn write_meta_file(path: &Path, meta: &SessionMeta) -> Result<()> {
504    let bytes = serde_json::to_vec_pretty(meta).map_err(|source| SkillError::SerdeJson {
505        operation: "serialise session meta",
506        path: path.to_path_buf(),
507        source,
508    })?;
509    std::fs::write(path, bytes).map_err(|source| SkillError::Io {
510        path: path.to_path_buf(),
511        source,
512    })
513}
514
515fn scan_counts(trace_path: &Path) -> Result<(u64, u64, DateTime<Utc>)> {
516    use std::io::{BufRead, BufReader};
517
518    // Stream the file line-by-line rather than reading the whole
519    // `trace.jsonl` into memory. ADR-015 explicitly flags that long
520    // sessions can produce very large traces; keeping the memory
521    // footprint bounded matters.
522    let file = std::fs::File::open(trace_path).map_err(|source| SkillError::Io {
523        path: trace_path.to_path_buf(),
524        source,
525    })?;
526    let reader = BufReader::new(file);
527
528    let mut tool_calls = 0u64;
529    let mut errors = 0u64;
530    let mut started_at: Option<DateTime<Utc>> = None;
531    for line in reader.lines() {
532        let line = match line {
533            Ok(l) => l,
534            Err(source) => {
535                return Err(SkillError::Io {
536                    path: trace_path.to_path_buf(),
537                    source,
538                });
539            }
540        };
541        if line.trim().is_empty() {
542            continue;
543        }
544        let record: TraceRecord = match serde_json::from_str(&line) {
545            Ok(r) => r,
546            Err(_) => continue,
547        };
548        if started_at.is_none() && record.phase == Phase::Start {
549            started_at = Some(record.ts);
550        }
551        if record.phase == Phase::ToolCall {
552            tool_calls += 1;
553        }
554        if record.phase == Phase::ToolResult
555            && record
556                .payload
557                .get("ok")
558                .and_then(|v| v.as_bool())
559                .is_some_and(|ok| !ok)
560        {
561            errors += 1;
562        }
563    }
564    Ok((tool_calls, errors, started_at.unwrap_or_else(Utc::now)))
565}
566
567// ---------------------------------------------------------------------------
568// Session id
569// ---------------------------------------------------------------------------
570
571#[cfg(feature = "trace")]
572fn new_session_id() -> String {
573    ulid::Ulid::new().to_string()
574}
575
576#[cfg(not(feature = "trace"))]
577fn new_session_id() -> String {
578    // Deterministic-enough fallback when the `trace` feature is off —
579    // time-prefixed so logs stay sortable.
580    format!(
581        "{}-{:x}",
582        Utc::now().format("%Y%m%d%H%M%S%f"),
583        std::process::id()
584    )
585}
586
587// ---------------------------------------------------------------------------
588// Tests
589// ---------------------------------------------------------------------------
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594    use tempfile::tempdir;
595
596    fn events_in(path: &Path) -> Vec<TraceRecord> {
597        let text = std::fs::read_to_string(path).unwrap();
598        text.lines()
599            .filter(|l| !l.trim().is_empty())
600            .map(|l| serde_json::from_str(l).unwrap())
601            .collect()
602    }
603
604    #[test]
605    fn session_round_trip() {
606        let dir = tempdir().unwrap();
607        let target = TraceTarget::Custom(dir.path().to_path_buf());
608
609        let tracer = SessionTracer::begin("setup", &target).unwrap();
610        let trace_path = tracer.trace_path().to_path_buf();
611        let meta_path = tracer.session_dir().join("meta.json");
612
613        tracer
614            .event(
615                Phase::Decision,
616                json!({ "question": "provider?", "decision": "github" }),
617            )
618            .unwrap();
619        tracer
620            .event(
621                Phase::ToolCall,
622                json!({ "tool": "get_issues", "args": { "limit": 3 } }),
623            )
624            .unwrap();
625        tracer
626            .event(
627                Phase::ToolResult,
628                json!({ "tool": "get_issues", "ok": true, "duration_ms": 42 }),
629            )
630            .unwrap();
631        tracer.end(Outcome::Success, "configured github").unwrap();
632
633        let events = events_in(&trace_path);
634        // start + decision + tool_call + tool_result + end = 5
635        assert_eq!(events.len(), 5);
636        assert_eq!(events[0].phase, Phase::Start);
637        assert_eq!(events.last().unwrap().phase, Phase::End);
638        assert!(events.iter().all(|e| e.skill == "setup"));
639        assert!(events.iter().all(|e| e.session_id == events[0].session_id));
640
641        let meta_bytes = std::fs::read(&meta_path).unwrap();
642        let meta: SessionMeta = serde_json::from_slice(&meta_bytes).unwrap();
643        assert_eq!(meta.skill, "setup");
644        assert_eq!(meta.outcome, Some(Outcome::Success));
645        assert_eq!(meta.tool_calls, 1);
646        assert_eq!(meta.errors, 0);
647    }
648
649    #[test]
650    fn failed_tool_result_is_counted_as_error() {
651        let dir = tempdir().unwrap();
652        let target = TraceTarget::Custom(dir.path().to_path_buf());
653        let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
654        tracer
655            .event(Phase::ToolCall, json!({ "tool": "get_issues" }))
656            .unwrap();
657        tracer
658            .event(
659                Phase::ToolResult,
660                json!({ "tool": "get_issues", "ok": false, "error": "401 Unauthorized" }),
661            )
662            .unwrap();
663        let meta_path = tracer.session_dir().join("meta.json");
664        tracer.end(Outcome::Failure, "401").unwrap();
665
666        let meta: SessionMeta = serde_json::from_slice(&std::fs::read(meta_path).unwrap()).unwrap();
667        assert_eq!(meta.tool_calls, 1);
668        assert_eq!(meta.errors, 1);
669        assert_eq!(meta.outcome, Some(Outcome::Failure));
670    }
671
672    #[test]
673    fn events_are_redacted_before_writing() {
674        // Share the env-serialisation lock with `redact::tests`; without
675        // it a concurrent `DEVBOY_TRACE_REDACTION=off` in a sibling test
676        // can disable redaction for the window this test runs and let
677        // the raw token reach disk (observed as a hard failure on
678        // ubuntu-24.04-arm in CI). Strictly stronger than a bare
679        // `temp_env::with_var` reset because the mutex serialises against
680        // every redact-tests sibling that legitimately toggles the var.
681        super::redact::test_support::with_clean_env(|| {
682            let dir = tempdir().unwrap();
683            let target = TraceTarget::Custom(dir.path().to_path_buf());
684            let tracer = SessionTracer::begin("devboy-test", &target).unwrap();
685            let trace_path = tracer.trace_path().to_path_buf();
686            tracer
687                .event(
688                    Phase::ToolCall,
689                    json!({
690                        "tool": "create_issue",
691                        "args": { "token": "ghp_012345678901234567890123456789012345" }
692                    }),
693                )
694                .unwrap();
695            tracer.end(Outcome::Success, "").unwrap();
696
697            let text = std::fs::read_to_string(&trace_path).unwrap();
698            assert!(
699                !text.contains("ghp_0123456789"),
700                "trace contained raw GitHub token: {text}"
701            );
702            assert!(
703                text.contains("<redacted"),
704                "trace did not include redaction marker: {text}"
705            );
706        });
707    }
708
709    #[test]
710    fn global_target_respects_home_override() {
711        let home = tempdir().unwrap();
712        let home_path = home.path().to_path_buf();
713        temp_env::with_var("DEVBOY_HOME_OVERRIDE", Some(home.path()), || {
714            let root = TraceTarget::Global.sessions_root().unwrap();
715            assert!(root.starts_with(&home_path));
716        });
717    }
718
719    #[test]
720    fn custom_target_writes_exactly_where_asked() {
721        let dir = tempdir().unwrap();
722        let target = TraceTarget::Custom(dir.path().to_path_buf());
723        let tracer = SessionTracer::begin("x", &target).unwrap();
724        let trace_path = tracer.trace_path().to_path_buf();
725        assert!(trace_path.starts_with(dir.path()));
726        tracer.end(Outcome::Success, "").unwrap();
727    }
728}