agent_engine/runtime/trace/
controls.rs1use 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
66pub const CONTENT_CAPTURE_SCHEMA: &str = "synaps-trace-content-capture/1";
69
70pub const CAPTURE_MAX_BYTES: usize = 1024 * 1024;
72
73pub const CAPTURE_TTL: Duration = Duration::from_secs(15 * 60);
75
76pub fn default_capture_dir() -> PathBuf {
78 agent_core::core::config::base_dir()
79 .join("trace")
80 .join("capture")
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum TraceArm {
86 Off,
87 NextMetadata,
89 NextWithContent,
91}
92
93#[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 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 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 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#[derive(Debug, Serialize, Deserialize)]
148pub struct ContentCaptureBundle {
149 pub schema: String,
151 pub request_id: TraceId,
152 pub created_unix_ms: u64,
153 pub expires_unix_ms: u64,
154 pub redacted: bool,
156 pub over_budget: bool,
159 #[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#[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 pub fn capture(&self, request_id: &TraceId, body_bytes: &[u8]) {
192 if self.consumed.swap(true, Ordering::SeqCst) {
193 return;
194 }
195 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 tracing::warn!(reason = %err, "trace content capture write failed");
231 }
232 }
233
234 pub fn mark_unsupported(&self) -> bool {
239 !self.consumed.swap(true, Ordering::SeqCst)
240 }
241}
242
243pub fn capture_path(dir: &Path, request_id: &TraceId) -> PathBuf {
245 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#[derive(Debug, Clone)]
266pub struct TraceStatusReport {
267 pub persistent_enabled: bool,
269 pub arm: TraceArm,
270 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 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 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 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 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}