Skip to main content

agent_engine/runtime/trace/
controls.rs

1//! Explicit trace controls (Task 12, spec §5.1 raw-capture clause + §6.1
2//! surfaces): `/trace next`, `/trace next content`, `/trace status`.
3//!
4//! ## One-shot semantics
5//!
6//! Arming (`/trace next`) enables tracing for **exactly the next logical
7//! outgoing provider request** — even when the `telemetry` config level is
8//! Off — and then auto-disarms. The armed ephemeral context carries a
9//! one-shot request gate consumed inside `RequestTracer::begin`: the first
10//! request wins, all of its retry attempts emit, and subsequent tool-loop
11//! requests through the same context are disabled. It never silently
12//! enables indefinite persistence: when telemetry is Off the one-shot
13//! record rides an ephemeral writer whose handle the runtime retains (in
14//! its one-shot state, off the request path) until it is replaced by a
15//! re-arm or drained by the `shutdown_observability*` exit epilogue — so
16//! the armed record flushes even when the session exits immediately after
17//! the request. The existing Basic/Full config rule is unchanged — arming
18//! on top of an already-enabled session only adds the (optional) content
19//! capture.
20//!
21//! ## Content capture (explicit, ephemeral, redacted)
22//!
23//! Persisted traces contain no content, so `synaps trace export
24//! --include-content` needs an explicit capture: `/trace next content` arms
25//! a **one-request, bounded, redacted** capture of the request body.
26//! Security decisions, deliberately conservative:
27//!
28//! - capture is redacted **at capture time** (recursive credential-key /
29//!   secret-pattern scrub, see `trace::export::redact_value`) — an
30//!   unredacted body never touches disk;
31//! - the bundle is written with the Phase 1 private-fs helpers (`0600`
32//!   file, `0700` parent, symlink-refusing, atomic) under
33//!   `<synaps base dir>/trace/capture/`;
34//! - the bundle carries its own schema tag (`synaps-trace-content-capture/1`)
35//!   so it can never masquerade as a `synaps-request-trace/1` record;
36//! - capture is bounded ([`CAPTURE_MAX_BYTES`]) and expires
37//!   ([`CAPTURE_TTL`]): `trace export --include-content` consumes (deletes)
38//!   it, and an expired bundle is refused and deleted instead of exported;
39//!
40//!   **Expiry semantics (documented guarantee):** expiry is *logical* —
41//!   an expired bundle can never be exported (the export path checks the
42//!   embedded `expires_unix_ms` and deletes stale bundles). Physical
43//!   deletion is a bounded, fail-soft sweep run at Runtime/session
44//!   startup, in both the sync and async shutdown epilogues, and on every
45//!   trace interaction (every new capture, `/trace status`, every
46//!   `synaps trace export` invocation). A bundle left behind by a killed
47//!   process may therefore sit (private `0600` file, already redacted) on
48//!   disk past its TTL until the next session startup/shutdown or trace
49//!   interaction — but it is unreadable through any supported export path
50//!   from the moment it expires;
51//! - only the request **body** is captured — headers, cookies, and
52//!   credentials never enter the capture path by construction (they are
53//!   attached by the HTTP client after the body is built).
54//!
55//! The capture file is how the arm crosses the process boundary to a later
56//! `synaps trace export` invocation without weakening privacy: the on-disk
57//! artifact is already redacted, private, bounded, and short-lived.
58
59use super::types::TraceId;
60use serde::{Deserialize, Serialize};
61use std::path::{Path, PathBuf};
62use std::sync::atomic::{AtomicBool, Ordering};
63use std::sync::Mutex;
64use std::time::{Duration, SystemTime, UNIX_EPOCH};
65
66/// Schema tag for the ephemeral content-capture bundle. Distinct from the
67/// request-trace schema by design.
68pub const CONTENT_CAPTURE_SCHEMA: &str = "synaps-trace-content-capture/1";
69
70/// Upper bound on captured (post-redaction) body bytes.
71pub const CAPTURE_MAX_BYTES: usize = 1024 * 1024;
72
73/// Retention window for a capture bundle.
74pub const CAPTURE_TTL: Duration = Duration::from_secs(15 * 60);
75
76/// Default capture directory: `<synaps base dir>/trace/capture/`.
77pub fn default_capture_dir() -> PathBuf {
78    agent_core::core::config::base_dir()
79        .join("trace")
80        .join("capture")
81}
82
83/// Current arm state.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum TraceArm {
86    Off,
87    /// Trace the next request (metadata only), then auto-disarm.
88    NextMetadata,
89    /// Trace the next request AND write a redacted content-capture bundle.
90    NextWithContent,
91}
92
93/// Session trace controls: one-shot arm consumed by the next request.
94#[derive(Debug, Default)]
95pub struct TraceControls {
96    state: Mutex<Option<ArmKind>>,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum ArmKind {
101    Metadata,
102    Content,
103}
104
105impl TraceControls {
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Arm for exactly the next request. Re-arming replaces the pending arm.
111    pub fn arm_next(&self, with_content: bool) {
112        *self.state.lock().expect("trace controls poisoned") = Some(if with_content {
113            ArmKind::Content
114        } else {
115            ArmKind::Metadata
116        });
117    }
118
119    pub fn disarm(&self) {
120        *self.state.lock().expect("trace controls poisoned") = None;
121    }
122
123    /// Non-consuming view for `/trace status`.
124    pub fn peek(&self) -> TraceArm {
125        match *self.state.lock().expect("trace controls poisoned") {
126            None => TraceArm::Off,
127            Some(ArmKind::Metadata) => TraceArm::NextMetadata,
128            Some(ArmKind::Content) => TraceArm::NextWithContent,
129        }
130    }
131
132    /// Consume the pending arm (auto-disable). Returns `Some(with_content)`
133    /// exactly once per arming.
134    pub fn consume(&self) -> Option<bool> {
135        self.state
136            .lock()
137            .expect("trace controls poisoned")
138            .take()
139            .map(|kind| kind == ArmKind::Content)
140    }
141}
142
143// --- Ephemeral content capture ---
144
145/// On-disk shape of the capture bundle. Clearly labeled non-request-trace
146/// schema; `redacted` is a hard marker checked again on export.
147#[derive(Debug, Serialize, Deserialize)]
148pub struct ContentCaptureBundle {
149    /// Always [`CONTENT_CAPTURE_SCHEMA`].
150    pub schema: String,
151    pub request_id: TraceId,
152    pub created_unix_ms: u64,
153    pub expires_unix_ms: u64,
154    /// Always true: the body was recursively redacted before writing.
155    pub redacted: bool,
156    /// True when the raw body exceeded [`CAPTURE_MAX_BYTES`] and was
157    /// therefore NOT captured (fail closed, never truncated mid-secret).
158    pub over_budget: bool,
159    /// Redacted request body (request content fields only — never headers
160    /// or credentials). `None` iff `over_budget`.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub body: Option<serde_json::Value>,
163}
164
165pub(crate) fn unix_ms_now() -> u64 {
166    SystemTime::now()
167        .duration_since(UNIX_EPOCH)
168        .map(|d| d.as_millis() as u64)
169        .unwrap_or(0)
170}
171
172/// One-request content capture arm, attached to a `TraceContext` when the
173/// user ran `/trace next content`. Fires at most once.
174#[derive(Debug)]
175pub struct ContentCapture {
176    dir: PathBuf,
177    consumed: AtomicBool,
178}
179
180impl ContentCapture {
181    pub fn new(dir: PathBuf) -> Self {
182        Self {
183            dir,
184            consumed: AtomicBool::new(false),
185        }
186    }
187
188    /// Capture one request body: parse, redact recursively, bound, write a
189    /// private bundle. Never fails the request — any error is logged as
190    /// metadata and the capture is simply lost. Fires at most once.
191    pub fn capture(&self, request_id: &TraceId, body_bytes: &[u8]) {
192        if self.consumed.swap(true, Ordering::SeqCst) {
193            return;
194        }
195        // Opportunistic retention sweep (see module docs): any bundle from
196        // an earlier session that outlived its TTL is removed before a new
197        // one is written — expiry is enforced logically at export, deleted
198        // physically on the next trace interaction.
199        let _ = super::export::sweep_expired_captures(&self.dir);
200        let over_budget = body_bytes.len() > CAPTURE_MAX_BYTES;
201        let body = if over_budget {
202            None
203        } else {
204            match serde_json::from_slice::<serde_json::Value>(body_bytes) {
205                Ok(mut value) => {
206                    super::export::redact_value(&mut value);
207                    Some(value)
208                }
209                Err(_) => {
210                    tracing::warn!(
211                        byte_len = body_bytes.len(),
212                        "trace content capture skipped: body is not JSON"
213                    );
214                    return;
215                }
216            }
217        };
218        let now = unix_ms_now();
219        let bundle = ContentCaptureBundle {
220            schema: CONTENT_CAPTURE_SCHEMA.to_string(),
221            request_id: request_id.clone(),
222            created_unix_ms: now,
223            expires_unix_ms: now + CAPTURE_TTL.as_millis() as u64,
224            redacted: true,
225            over_budget,
226            body,
227        };
228        if let Err(err) = write_bundle(&self.dir, &bundle) {
229            // Metadata-only: path/IO reason, never body content.
230            tracing::warn!(reason = %err, "trace content capture write failed");
231        }
232    }
233
234    /// Consume the arm on a provider path that cannot capture a request
235    /// body (no exact serialized body exists in this process). Returns
236    /// `true` exactly once so the caller can surface the failure; never
237    /// writes anything.
238    pub fn mark_unsupported(&self) -> bool {
239        !self.consumed.swap(true, Ordering::SeqCst)
240    }
241}
242
243/// Path of the capture bundle for a request ID.
244pub fn capture_path(dir: &Path, request_id: &TraceId) -> PathBuf {
245    // TraceId alphabet includes '/' and ':'; flatten to a safe file name.
246    let name: String = request_id
247        .as_str()
248        .chars()
249        .map(|c| if c == '/' || c == ':' { '_' } else { c })
250        .collect();
251    dir.join(format!("capture-{name}.json"))
252}
253
254fn write_bundle(dir: &Path, bundle: &ContentCaptureBundle) -> Result<(), String> {
255    agent_core::core::private_fs::ensure_private_dir(dir).map_err(|e| e.to_string())?;
256    let path = capture_path(dir, &bundle.request_id);
257    let data = serde_json::to_vec_pretty(bundle).map_err(|e| e.to_string())?;
258    agent_core::core::private_fs::write_atomic_private(&path, &data).map_err(|e| e.to_string())
259}
260
261// --- `/trace status` report ---
262
263/// Metadata-only status for `/trace status`: mode, persistence path,
264/// counters. Never secrets, never content.
265#[derive(Debug, Clone)]
266pub struct TraceStatusReport {
267    /// Session persistence via the telemetry Basic/Full rule.
268    pub persistent_enabled: bool,
269    pub arm: TraceArm,
270    /// Where trace records are persisted when enabled.
271    pub trace_path: Option<PathBuf>,
272    pub writer_stats: Option<crate::runtime::telemetry::WriterStats>,
273    pub degraded_records: u64,
274}
275
276impl TraceStatusReport {
277    pub fn render(&self) -> String {
278        use std::fmt::Write as _;
279        let mut out = String::new();
280        let mode = match (self.persistent_enabled, self.arm) {
281            (true, TraceArm::NextWithContent) => "enabled (next request adds content capture)",
282            (true, _) => "enabled (telemetry basic/full)",
283            (false, TraceArm::NextMetadata) => "armed for next request (metadata only)",
284            (false, TraceArm::NextWithContent) => {
285                "armed for next request (metadata + redacted content capture)"
286            }
287            (false, TraceArm::Off) => "disabled",
288        };
289        let _ = writeln!(out, "trace: {mode}");
290        match &self.trace_path {
291            Some(path) => {
292                let _ = writeln!(out, "  persistence: {}", path.display());
293            }
294            None => {
295                let _ = writeln!(out, "  persistence: (unresolved)");
296            }
297        }
298        match self.writer_stats {
299            Some(stats) => {
300                let _ = writeln!(
301                    out,
302                    "  writer: {} enqueued, {} written, {} dropped; {} degraded records",
303                    stats.enqueued, stats.written, stats.dropped, self.degraded_records,
304                );
305            }
306            None => {
307                let _ = writeln!(
308                    out,
309                    "  writer: not running; {} degraded records",
310                    self.degraded_records
311                );
312            }
313        }
314        out.trim_end().to_string()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn arm_is_consumed_exactly_once() {
324        let controls = TraceControls::new();
325        assert_eq!(controls.peek(), TraceArm::Off);
326        assert!(controls.consume().is_none());
327
328        controls.arm_next(false);
329        assert_eq!(controls.peek(), TraceArm::NextMetadata);
330        assert_eq!(controls.consume(), Some(false));
331        assert_eq!(controls.peek(), TraceArm::Off);
332        assert!(controls.consume().is_none(), "second consume must be empty");
333
334        controls.arm_next(true);
335        assert_eq!(controls.peek(), TraceArm::NextWithContent);
336        assert_eq!(controls.consume(), Some(true));
337        assert!(controls.consume().is_none());
338    }
339
340    #[test]
341    fn content_capture_fires_once_writes_private_redacted_bundle() {
342        let dir = tempfile::tempdir().expect("tempdir");
343        let capture = ContentCapture::new(dir.path().join("cap"));
344        let id = TraceId::new("req-1-0").unwrap();
345        let secret = "sk-SENTINEL1234567890abcd";
346        let body = serde_json::json!({
347            "model": "m",
348            "messages": [{"role": "user", "content": format!("my key is {secret}")}],
349            "api_key": "topsecret-value"
350        });
351        capture.capture(&id, serde_json::to_vec(&body).unwrap().as_slice());
352
353        let path = capture_path(&dir.path().join("cap"), &id);
354        let data = std::fs::read_to_string(&path).expect("bundle exists");
355        assert!(!data.contains(secret), "raw sentinel secret persisted");
356        assert!(!data.contains("topsecret-value"));
357        assert!(data.contains(CONTENT_CAPTURE_SCHEMA));
358        let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
359        assert!(bundle.redacted);
360        assert!(!bundle.over_budget);
361
362        #[cfg(unix)]
363        {
364            use std::os::unix::fs::PermissionsExt as _;
365            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
366            assert_eq!(mode, 0o600);
367        }
368
369        // Second capture on the same arm is a no-op.
370        std::fs::remove_file(&path).unwrap();
371        capture.capture(&id, b"{}");
372        assert!(!path.exists(), "capture fired twice");
373    }
374
375    #[test]
376    fn over_budget_body_is_refused_not_truncated() {
377        let dir = tempfile::tempdir().expect("tempdir");
378        let capture = ContentCapture::new(dir.path().to_path_buf());
379        let id = TraceId::new("req-big").unwrap();
380        let big = serde_json::json!({"content": "x".repeat(CAPTURE_MAX_BYTES + 10)});
381        capture.capture(&id, serde_json::to_vec(&big).unwrap().as_slice());
382        let path = capture_path(dir.path(), &id);
383        let bundle: ContentCaptureBundle =
384            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
385        assert!(bundle.over_budget);
386        assert!(bundle.body.is_none());
387    }
388
389    #[test]
390    fn new_capture_sweeps_a_previous_expired_bundle() {
391        let dir = tempfile::tempdir().expect("tempdir");
392        let cap_dir = dir.path().join("cap");
393        agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
394
395        // Capture A: expired long ago (as if left behind by a dead process).
396        let stale_id = TraceId::new("req-stale").unwrap();
397        let stale = ContentCaptureBundle {
398            schema: CONTENT_CAPTURE_SCHEMA.to_string(),
399            request_id: stale_id.clone(),
400            created_unix_ms: 1_000,
401            expires_unix_ms: 2_000,
402            redacted: true,
403            over_budget: false,
404            body: Some(serde_json::json!({"old": true})),
405        };
406        let stale_path = capture_path(&cap_dir, &stale_id);
407        std::fs::write(&stale_path, serde_json::to_vec(&stale).unwrap()).unwrap();
408
409        // Capture B removes A on its way in (opportunistic sweep).
410        let capture = ContentCapture::new(cap_dir.clone());
411        let fresh_id = TraceId::new("req-fresh").unwrap();
412        capture.capture(&fresh_id, b"{\"x\":1}");
413
414        assert!(
415            !stale_path.exists(),
416            "expired capture A must be swept when capture B is written"
417        );
418        assert!(capture_path(&cap_dir, &fresh_id).exists());
419    }
420
421    #[test]
422    fn mark_unsupported_consumes_the_arm_exactly_once_without_artifact() {
423        let dir = tempfile::tempdir().expect("tempdir");
424        let cap_dir = dir.path().join("cap");
425        let capture = ContentCapture::new(cap_dir.clone());
426        assert!(capture.mark_unsupported(), "first consume reports true");
427        assert!(!capture.mark_unsupported(), "second consume is a no-op");
428        // A later capture attempt on the consumed arm writes nothing.
429        let id = TraceId::new("req-after").unwrap();
430        capture.capture(&id, b"{}");
431        assert!(!capture_path(&cap_dir, &id).exists());
432    }
433
434    #[test]
435    fn status_render_mentions_mode_and_no_secrets() {
436        let report = TraceStatusReport {
437            persistent_enabled: false,
438            arm: TraceArm::NextMetadata,
439            trace_path: Some(PathBuf::from("/tmp/request-trace.jsonl")),
440            writer_stats: None,
441            degraded_records: 2,
442        };
443        let text = report.render();
444        assert!(text.contains("armed for next request (metadata only)"));
445        assert!(text.contains("request-trace.jsonl"));
446        assert!(text.contains("2 degraded"));
447    }
448}