1use super::types::TraceId;
64use serde::{Deserialize, Serialize};
65use std::path::{Path, PathBuf};
66use std::sync::atomic::{AtomicBool, Ordering};
67use std::sync::Mutex;
68use std::time::{Duration, SystemTime, UNIX_EPOCH};
69
70pub const CONTENT_CAPTURE_SCHEMA: &str = "synaps-trace-content-capture/1";
73
74pub const CAPTURE_MAX_BYTES: usize = 1024 * 1024;
76
77pub const CAPTURE_TTL: Duration = Duration::from_secs(15 * 60);
79
80pub fn default_capture_dir() -> PathBuf {
82 agent_core::core::config::base_dir()
83 .join("trace")
84 .join("capture")
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TraceArm {
90 Off,
91 NextMetadata,
93 NextWithContent,
95}
96
97#[derive(Debug, Default)]
99pub struct TraceControls {
100 state: Mutex<Option<ArmKind>>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum ArmKind {
105 Metadata,
106 Content,
107}
108
109impl TraceControls {
110 pub fn new() -> Self {
111 Self::default()
112 }
113
114 pub fn arm_next(&self, with_content: bool) {
116 *self.state.lock().expect("trace controls poisoned") = Some(if with_content {
117 ArmKind::Content
118 } else {
119 ArmKind::Metadata
120 });
121 }
122
123 pub fn disarm(&self) {
124 *self.state.lock().expect("trace controls poisoned") = None;
125 }
126
127 pub fn peek(&self) -> TraceArm {
129 match *self.state.lock().expect("trace controls poisoned") {
130 None => TraceArm::Off,
131 Some(ArmKind::Metadata) => TraceArm::NextMetadata,
132 Some(ArmKind::Content) => TraceArm::NextWithContent,
133 }
134 }
135
136 pub fn consume(&self) -> Option<bool> {
139 self.state
140 .lock()
141 .expect("trace controls poisoned")
142 .take()
143 .map(|kind| kind == ArmKind::Content)
144 }
145}
146
147#[derive(Debug, Serialize, Deserialize)]
152pub struct ContentCaptureBundle {
153 pub schema: String,
155 pub request_id: TraceId,
156 pub created_unix_ms: u64,
157 pub expires_unix_ms: u64,
158 pub redacted: bool,
160 pub over_budget: bool,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub body: Option<serde_json::Value>,
167}
168
169pub(crate) fn unix_ms_now() -> u64 {
170 SystemTime::now()
171 .duration_since(UNIX_EPOCH)
172 .map(|d| d.as_millis() as u64)
173 .unwrap_or(0)
174}
175
176#[derive(Debug)]
179pub struct ContentCapture {
180 dir: PathBuf,
181 consumed: AtomicBool,
182}
183
184impl ContentCapture {
185 pub fn new(dir: PathBuf) -> Self {
186 Self {
187 dir,
188 consumed: AtomicBool::new(false),
189 }
190 }
191
192 pub fn capture(&self, request_id: &TraceId, body_bytes: &[u8]) {
196 if self.consumed.swap(true, Ordering::SeqCst) {
197 return;
198 }
199 let _ = super::export::sweep_expired_captures(&self.dir);
204 let over_budget = body_bytes.len() > CAPTURE_MAX_BYTES;
205 let body = if over_budget {
206 None
207 } else {
208 match serde_json::from_slice::<serde_json::Value>(body_bytes) {
209 Ok(mut value) => {
210 scrub_media_content(&mut value);
211 super::export::redact_value(&mut value);
212 Some(value)
213 }
214 Err(_) => {
215 tracing::warn!(
216 byte_len = body_bytes.len(),
217 "trace content capture skipped: body is not JSON"
218 );
219 return;
220 }
221 }
222 };
223 let now = unix_ms_now();
224 let bundle = ContentCaptureBundle {
225 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
226 request_id: request_id.clone(),
227 created_unix_ms: now,
228 expires_unix_ms: now + CAPTURE_TTL.as_millis() as u64,
229 redacted: true,
230 over_budget,
231 body,
232 };
233 if let Err(err) = write_bundle(&self.dir, &bundle) {
234 tracing::warn!(reason = %err, "trace content capture write failed");
236 }
237 }
238
239 pub fn mark_unsupported(&self) -> bool {
244 !self.consumed.swap(true, Ordering::SeqCst)
245 }
246}
247
248fn scrub_media_content(value: &mut serde_json::Value) {
254 use serde_json::Value;
255
256 fn image_url_is_data(value: &Value) -> bool {
257 value
258 .as_str()
259 .or_else(|| value["url"].as_str())
260 .is_some_and(|url| {
261 url.trim_start()
262 .get(..5)
263 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
264 })
265 }
266
267 let descriptor = match value["type"].as_str() {
268 Some("image" | "input_image") => Some("[redacted image]"),
269 Some("document" | "file" | "input_file") => Some("[redacted document]"),
270 Some("image_url") if image_url_is_data(&value["image_url"]) => Some("[redacted image]"),
271 _ => None,
272 };
273 if let Some(descriptor) = descriptor {
274 *value = Value::String(descriptor.to_string());
275 return;
276 }
277
278 match value {
279 Value::Array(items) => {
280 for item in items {
281 scrub_media_content(item);
282 }
283 }
284 Value::Object(object) => {
285 for (key, child) in object {
286 match key.as_str() {
287 "inlineData" | "inline_data" => {
290 *child = Value::String("[redacted media]".to_string());
291 }
292 "file_data" => {
294 *child = Value::String("[redacted document]".to_string());
295 }
296 "image_url" if image_url_is_data(child) => {
297 *child = Value::String("[redacted image]".to_string());
298 }
299 _ => scrub_media_content(child),
300 }
301 }
302 }
303 _ => {}
304 }
305}
306
307pub fn capture_path(dir: &Path, request_id: &TraceId) -> PathBuf {
309 let name: String = request_id
311 .as_str()
312 .chars()
313 .map(|c| if c == '/' || c == ':' { '_' } else { c })
314 .collect();
315 dir.join(format!("capture-{name}.json"))
316}
317
318fn write_bundle(dir: &Path, bundle: &ContentCaptureBundle) -> Result<(), String> {
319 agent_core::core::private_fs::ensure_private_dir(dir).map_err(|e| e.to_string())?;
320 let path = capture_path(dir, &bundle.request_id);
321 let data = serde_json::to_vec_pretty(bundle).map_err(|e| e.to_string())?;
322 agent_core::core::private_fs::write_atomic_private(&path, &data).map_err(|e| e.to_string())
323}
324
325#[derive(Debug, Clone)]
330pub struct TraceStatusReport {
331 pub persistent_enabled: bool,
333 pub arm: TraceArm,
334 pub trace_path: Option<PathBuf>,
336 pub writer_stats: Option<crate::runtime::telemetry::WriterStats>,
337 pub degraded_records: u64,
338}
339
340impl TraceStatusReport {
341 pub fn render(&self) -> String {
342 use std::fmt::Write as _;
343 let mut out = String::new();
344 let mode = match (self.persistent_enabled, self.arm) {
345 (true, TraceArm::NextWithContent) => "enabled (next request adds content capture)",
346 (true, _) => "enabled (telemetry basic/full)",
347 (false, TraceArm::NextMetadata) => "armed for next request (metadata only)",
348 (false, TraceArm::NextWithContent) => {
349 "armed for next request (metadata + redacted content capture)"
350 }
351 (false, TraceArm::Off) => "disabled",
352 };
353 let _ = writeln!(out, "trace: {mode}");
354 match &self.trace_path {
355 Some(path) => {
356 let _ = writeln!(out, " persistence: {}", path.display());
357 }
358 None => {
359 let _ = writeln!(out, " persistence: (unresolved)");
360 }
361 }
362 match self.writer_stats {
363 Some(stats) => {
364 let _ = writeln!(
365 out,
366 " writer: {} enqueued, {} written, {} dropped; {} degraded records",
367 stats.enqueued, stats.written, stats.dropped, self.degraded_records,
368 );
369 }
370 None => {
371 let _ = writeln!(
372 out,
373 " writer: not running; {} degraded records",
374 self.degraded_records
375 );
376 }
377 }
378 out.trim_end().to_string()
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn arm_is_consumed_exactly_once() {
388 let controls = TraceControls::new();
389 assert_eq!(controls.peek(), TraceArm::Off);
390 assert!(controls.consume().is_none());
391
392 controls.arm_next(false);
393 assert_eq!(controls.peek(), TraceArm::NextMetadata);
394 assert_eq!(controls.consume(), Some(false));
395 assert_eq!(controls.peek(), TraceArm::Off);
396 assert!(controls.consume().is_none(), "second consume must be empty");
397
398 controls.arm_next(true);
399 assert_eq!(controls.peek(), TraceArm::NextWithContent);
400 assert_eq!(controls.consume(), Some(true));
401 assert!(controls.consume().is_none());
402 }
403
404 #[test]
405 fn content_capture_fires_once_writes_private_redacted_bundle() {
406 let dir = tempfile::tempdir().expect("tempdir");
407 let capture = ContentCapture::new(dir.path().join("cap"));
408 let id = TraceId::new("req-1-0").unwrap();
409 let secret = "sk-SENTINEL1234567890abcd";
410 let body = serde_json::json!({
411 "model": "m",
412 "messages": [{"role": "user", "content": format!("my key is {secret}")}],
413 "api_key": "topsecret-value"
414 });
415 capture.capture(&id, serde_json::to_vec(&body).unwrap().as_slice());
416
417 let path = capture_path(&dir.path().join("cap"), &id);
418 let data = std::fs::read_to_string(&path).expect("bundle exists");
419 assert!(!data.contains(secret), "raw sentinel secret persisted");
420 assert!(!data.contains("topsecret-value"));
421 assert!(data.contains(CONTENT_CAPTURE_SCHEMA));
422 let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
423 assert!(bundle.redacted);
424 assert!(!bundle.over_budget);
425
426 #[cfg(unix)]
427 {
428 use std::os::unix::fs::PermissionsExt as _;
429 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
430 assert_eq!(mode, 0o600);
431 }
432
433 std::fs::remove_file(&path).unwrap();
435 capture.capture(&id, b"{}");
436 assert!(!path.exists(), "capture fired twice");
437 }
438
439 #[test]
440 fn content_capture_scrubs_canonical_and_wire_media_before_writing() {
441 use serde_json::json;
442 let dir = tempfile::tempdir().unwrap();
443 let id = TraceId::new("req-media").unwrap();
444 let media = json!([
445 {"type": "image", "source": {"type": "base64", "media_type": "image/png",
446 "data": "CANONICAL_IMAGE_SENTINEL"}},
447 {"type": "document", "title": "report.pdf",
448 "source": {"type": "base64", "data": "CANONICAL_PDF_SENTINEL"}},
449 {"type": "document", "title": "notes.txt",
450 "source": {"type": "text", "data": "CANONICAL_TEXT_SENTINEL"},
451 "text": "EXTRACTED_DOCUMENT_SENTINEL"},
452 {"type": "image_url", "image_url": {"url": "data:image/png;base64,CHAT_IMAGE_SENTINEL"}},
453 {"image_url": "data:image/png;base64,BARE_IMAGE_SENTINEL"},
454 {"type": "file", "file": {"filename": "report.pdf",
455 "file_data": "data:application/pdf;base64,CHAT_FILE_SENTINEL"}},
456 {"file": {"file_data": "UNTAGGED_FILE_SENTINEL"}},
457 {"type": "input_image", "image_url": "data:image/png;base64,RESPONSES_IMAGE_SENTINEL"},
458 {"type": "input_file", "filename": "report.pdf", "file_data": "RESPONSES_FILE_SENTINEL"},
459 {"inlineData": {"mimeType": "image/png", "data": "GEMINI_SENTINEL"}},
460 {"inline_data": {"mime_type": "application/pdf", "data": "PROTOBUF_SENTINEL"}},
461 {"image_url": {"url": " DATA:image/png;base64,CASE_INSENSITIVE_SENTINEL"}}
462 ]);
463 let body = json!({"messages": [{"role": "user", "content": [
464 {"type": "text", "text": "Keep the actual prompt"},
465 {"type": "tool_result", "tool_use_id": "t1", "content": [
466 {"type": "text", "text": "Keep authorized tool text"},
467 {"type": "tool_result", "tool_use_id": "nested", "content": media}
468 ]}
469 ]}], "input": media, "model": "m"});
470 let original = body.clone();
471 let capture = ContentCapture::new(dir.path().join("cap"));
472 capture.capture(&id, &serde_json::to_vec(&body).unwrap());
473 let data = std::fs::read_to_string(capture_path(&dir.path().join("cap"), &id)).unwrap();
476 assert!(!data.contains("SENTINEL"), "media payload persisted");
477 let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
478 assert!(bundle.redacted);
479 assert!(!bundle.over_budget);
480 let captured = bundle.body.unwrap();
481 assert_eq!(captured["model"], "m");
482 let content = &captured["messages"][0]["content"];
483 assert_eq!(content[0]["text"], "Keep the actual prompt");
484 assert_eq!(content[1]["type"], "tool_result");
485 assert_eq!(
486 content[1]["content"][0]["text"],
487 "Keep authorized tool text"
488 );
489 assert_eq!(
490 body, original,
491 "capture must not change the outgoing request"
492 );
493 }
494
495 #[test]
496 fn content_capture_preserves_authorized_non_media_including_lowered_text_documents() {
497 use serde_json::json;
498 let dir = tempfile::tempdir().unwrap();
499 let id = TraceId::new("req-authorized-text").unwrap();
500 let body = json!({
504 "input": [{"role": "user", "content": [
505 {"type": "input_text", "text": "[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
506 {"type": "input_text", "text": "AUTHORIZED_FILE_TEXT"},
507 {"type": "input_text", "text": "ordinary prompt"}
508 ]}],
509 "messages": [{"role": "user", "content": [
510 {"type": "text", "text": "[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
511 {"type": "text", "text": "AUTHORIZED_CHAT_FILE_TEXT"},
512 {"type": "image_url", "image_url": {"url": "https://example.org/image.png"}},
513 {"type": "tool_result", "tool_use_id": "t1", "content": "authorized tool text"}
514 ]}],
515 "data": "ordinary non-media data",
516 "tools": [{"name": "example", "input_schema": {"type": "object"}}]
517 });
518 ContentCapture::new(dir.path().to_path_buf())
519 .capture(&id, &serde_json::to_vec(&body).unwrap());
520 let data = std::fs::read_to_string(capture_path(dir.path(), &id)).unwrap();
521 let bundle: ContentCaptureBundle = serde_json::from_str(&data).unwrap();
522 assert_eq!(bundle.body, Some(body));
523 }
524
525 #[test]
526 fn over_budget_body_is_refused_not_truncated() {
527 let dir = tempfile::tempdir().expect("tempdir");
528 let capture = ContentCapture::new(dir.path().to_path_buf());
529 let id = TraceId::new("req-big").unwrap();
530 let big = serde_json::json!({"content": "x".repeat(CAPTURE_MAX_BYTES + 10)});
531 capture.capture(&id, serde_json::to_vec(&big).unwrap().as_slice());
532 let path = capture_path(dir.path(), &id);
533 let bundle: ContentCaptureBundle =
534 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
535 assert!(bundle.over_budget);
536 assert!(bundle.body.is_none());
537 }
538
539 #[test]
540 fn new_capture_sweeps_a_previous_expired_bundle() {
541 let dir = tempfile::tempdir().expect("tempdir");
542 let cap_dir = dir.path().join("cap");
543 agent_core::core::private_fs::ensure_private_dir(&cap_dir).unwrap();
544
545 let stale_id = TraceId::new("req-stale").unwrap();
547 let stale = ContentCaptureBundle {
548 schema: CONTENT_CAPTURE_SCHEMA.to_string(),
549 request_id: stale_id.clone(),
550 created_unix_ms: 1_000,
551 expires_unix_ms: 2_000,
552 redacted: true,
553 over_budget: false,
554 body: Some(serde_json::json!({"old": true})),
555 };
556 let stale_path = capture_path(&cap_dir, &stale_id);
557 std::fs::write(&stale_path, serde_json::to_vec(&stale).unwrap()).unwrap();
558
559 let capture = ContentCapture::new(cap_dir.clone());
561 let fresh_id = TraceId::new("req-fresh").unwrap();
562 capture.capture(&fresh_id, b"{\"x\":1}");
563
564 assert!(
565 !stale_path.exists(),
566 "expired capture A must be swept when capture B is written"
567 );
568 assert!(capture_path(&cap_dir, &fresh_id).exists());
569 }
570
571 #[test]
572 fn mark_unsupported_consumes_the_arm_exactly_once_without_artifact() {
573 let dir = tempfile::tempdir().expect("tempdir");
574 let cap_dir = dir.path().join("cap");
575 let capture = ContentCapture::new(cap_dir.clone());
576 assert!(capture.mark_unsupported(), "first consume reports true");
577 assert!(!capture.mark_unsupported(), "second consume is a no-op");
578 let id = TraceId::new("req-after").unwrap();
580 capture.capture(&id, b"{}");
581 assert!(!capture_path(&cap_dir, &id).exists());
582 }
583
584 #[test]
585 fn status_render_mentions_mode_and_no_secrets() {
586 let report = TraceStatusReport {
587 persistent_enabled: false,
588 arm: TraceArm::NextMetadata,
589 trace_path: Some(PathBuf::from("/tmp/request-trace.jsonl")),
590 writer_stats: None,
591 degraded_records: 2,
592 };
593 let text = report.render();
594 assert!(text.contains("armed for next request (metadata only)"));
595 assert!(text.contains("request-trace.jsonl"));
596 assert!(text.contains("2 degraded"));
597 }
598}