agent_first_http/sdk/fetch/artifacts/
network.rs1use std::collections::BTreeMap;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use crate::sdk::fetch::writer;
12use crate::shared::artifacts::{Artifact, ArtifactPaths};
13use crate::shared::error::{Error, ErrorCode};
14use crate::shared::redact;
15
16pub const NETWORK_SCHEMA_VERSION: u32 = 2;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NetworkLog {
20 pub schema_version: u32,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub main_request_id: Option<String>,
23 pub entries: Vec<NetworkEntry>,
24 pub summary: NetworkSummary,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct NetworkSummary {
29 pub requests_total: usize,
30 pub responses_total: usize,
31 pub finished_total: usize,
32 pub failed_total: usize,
33 pub incomplete_total: usize,
34 pub inflight_total_at_capture: usize,
35 pub pending_by_resource_type: BTreeMap<String, usize>,
36 pub captured_body_files: usize,
37 pub redacted: bool,
38}
39
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum NetworkEntryState {
43 #[default]
44 Pending,
45 Responded,
46 Finished,
47 Failed,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct NetworkEntry {
52 pub request_id: String,
53 pub state: NetworkEntryState,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub redirect_from_request_id: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub frame_id: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub loader_id: Option<String>,
60 pub resource_type: String,
61 #[serde(alias = "url")]
62 pub request_url: String,
63 pub method: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub initiator: Option<serde_json::Value>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub status: Option<u16>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub mime_type: Option<String>,
70 pub request_headers: BTreeMap<String, String>,
71 pub response_headers: BTreeMap<String, String>,
72 #[serde(default, skip_serializing_if = "is_false")]
73 pub request_post_data_present: bool,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub request_post_data_size_bytes: Option<usize>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub body_file: Option<PathBuf>,
78 pub timing: NetworkTiming,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub failure: Option<String>,
81 pub hints: BTreeMap<String, serde_json::Value>,
82}
83
84#[derive(Debug, Clone, Default, Serialize, Deserialize)]
85pub struct NetworkTiming {
86 #[serde(alias = "start_ms")]
87 pub start_monotonic_ms: u64,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 #[serde(alias = "end_ms")]
90 pub end_monotonic_ms: Option<u64>,
91}
92
93pub async fn write(paths: &ArtifactPaths, log: &NetworkLog) -> Result<PathBuf, Error> {
94 let target = paths.file_for(Artifact::Network);
95 let bytes = serde_json::to_vec_pretty(log).map_err(|e| {
96 Error::new(
97 ErrorCode::InternalError,
98 format!("serialize network log: {e}"),
99 )
100 })?;
101 writer::write_bytes(&target, &bytes).await?;
102 Ok(target)
103}
104
105pub fn redact_headers(map: &mut BTreeMap<String, String>, enabled: bool) {
108 if !enabled {
109 return;
110 }
111 for (name, value) in map.iter_mut() {
112 if redact::should_redact(name) {
113 *value = redact::REDACTED_VALUE.to_string();
114 }
115 }
116}
117
118#[must_use]
121pub fn redact_request_url(url: &str, enabled: bool) -> String {
122 if enabled {
123 redact::redact_url(url)
124 } else {
125 url.to_string()
126 }
127}
128
129fn is_false(value: &bool) -> bool {
130 !*value
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn redact_replaces_credentials_only() {
139 let mut map = BTreeMap::new();
140 map.insert("Authorization".to_string(), "Bearer xyz".to_string());
141 map.insert("Cookie".to_string(), "session=abc".to_string());
142 map.insert("X-Api-Token".to_string(), "tok".to_string());
143 map.insert("Content-Type".to_string(), "application/json".to_string());
144 redact_headers(&mut map, true);
145 assert_eq!(map["Authorization"], "[redacted]");
146 assert_eq!(map["Cookie"], "[redacted]");
147 assert_eq!(map["X-Api-Token"], "[redacted]");
148 assert_eq!(map["Content-Type"], "application/json");
149 }
150
151 #[test]
152 fn redact_disabled_passes_everything_through() {
153 let mut map = BTreeMap::new();
154 map.insert("Authorization".to_string(), "Bearer xyz".to_string());
155 redact_headers(&mut map, false);
156 assert_eq!(map["Authorization"], "Bearer xyz");
157 }
158
159 #[test]
160 fn request_url_redaction_honors_explicit_opt_out() {
161 let url = "https://user:pass@example.test/?token=abc&safe=ok";
162 assert_eq!(
163 redact_request_url(url, true),
164 "https://user:***@example.test/?token=***&safe=ok"
165 );
166 assert_eq!(redact_request_url(url, false), url);
167 }
168}