ai_agents_observability/
redaction.rs1use crate::config::PrivacyConfig;
2use crate::event::{EventType, ObservationError, ObservationEvent};
3use serde_json::Value;
4use std::collections::HashSet;
5
6#[derive(Debug, Clone)]
8pub struct Redactor {
9 config: PrivacyConfig,
10 keys: HashSet<String>,
11}
12
13impl Redactor {
14 pub fn new(config: PrivacyConfig) -> Self {
16 let keys = config
17 .redact_keys
18 .iter()
19 .map(|key| key.to_lowercase())
20 .collect();
21 Self { config, keys }
22 }
23
24 pub fn redact_value(&self, value: &Value) -> Value {
26 let mut value = value.clone();
27 self.redact_recursive(&mut value);
28 for path in &self.config.redact_paths {
29 redact_path(&mut value, path);
30 }
31 value
32 }
33
34 pub fn redact_event(&self, mut event: ObservationEvent) -> ObservationEvent {
36 if let Some(payload) = event.payload.take() {
37 event.payload = Some(self.redact_value(&payload));
38 }
39 if let Some(error) = event.error.take() {
40 event.error = Some(self.redact_error(error));
41 }
42 for value in event.tags.values_mut() {
43 *value = self.redact_text_to_string(value);
44 }
45 for (key, value) in event.dimensions.iter_mut() {
46 if !is_safe_dimension(key) {
47 *value = self.redact_text_to_string(value);
48 }
49 }
50 if let EventType::HitlApproval { trigger } = &mut event.event_type {
51 *trigger = self.redact_text_to_string(trigger);
52 }
53 event
54 }
55
56 pub fn redact_error(&self, mut error: ObservationError) -> ObservationError {
58 error.message = self.redact_text_to_string(&error.message);
59 error
60 }
61
62 pub fn redact_text_to_string(&self, text: &str) -> String {
64 if self.config.max_text_chars > 0 {
65 truncate_chars(text, self.config.max_text_chars)
66 } else if self.config.hash_inputs {
67 format!("length:{} hash:{}", text.chars().count(), stable_hash(text))
68 } else {
69 format!("length:{}", text.chars().count())
70 }
71 }
72
73 pub fn redact_text(&self, text: &str) -> Value {
75 let mut map = serde_json::Map::new();
76 map.insert(
77 "length".to_string(),
78 Value::from(text.chars().count() as u64),
79 );
80 if self.config.hash_inputs {
81 map.insert("hash".to_string(), Value::from(stable_hash(text)));
82 }
83 if self.config.max_text_chars > 0 {
84 map.insert(
85 "text".to_string(),
86 Value::from(truncate_chars(text, self.config.max_text_chars)),
87 );
88 }
89 Value::Object(map)
90 }
91
92 fn redact_recursive(&self, value: &mut Value) {
93 match value {
94 Value::Object(map) => {
95 let keys: Vec<String> = map.keys().cloned().collect();
96 for key in keys {
97 let key_lower = key.to_lowercase();
98 if self.keys.contains(&key_lower) {
99 map.insert(key, redacted_marker());
100 } else if key_lower == "hash"
101 || key_lower == "length"
102 || key_lower == "redacted"
103 {
104 continue;
105 } else if let Some(child) = map.get_mut(&key) {
106 self.redact_recursive(child);
107 }
108 }
109 }
110 Value::Array(items) => {
111 for item in items {
112 self.redact_recursive(item);
113 }
114 }
115 Value::String(text) => {
116 if self.config.max_text_chars == 0 {
117 let mut map = serde_json::Map::new();
118 map.insert(
119 "length".to_string(),
120 Value::from(text.chars().count() as u64),
121 );
122 if self.config.hash_inputs {
123 map.insert("hash".to_string(), Value::from(stable_hash(text)));
124 }
125 *value = Value::Object(map);
126 } else {
127 *text = truncate_chars(text, self.config.max_text_chars);
128 }
129 }
130 _ => {}
131 }
132 }
133}
134
135fn redacted_marker() -> Value {
136 serde_json::json!({"redacted": true})
137}
138
139fn is_safe_dimension(key: &str) -> bool {
140 matches!(
141 key,
142 "agent"
143 | "actor"
144 | "session"
145 | "purpose"
146 | "status"
147 | "provider"
148 | "model"
149 | "alias"
150 | "language"
151 | "state"
152 | "tool"
153 | "skill"
154 | "orchestration_pattern"
155 | "branch_status"
156 | "runtime.branch_status"
157 | "winner"
158 | "runtime.winner"
159 | "optimization"
160 | "runtime.optimization"
161 | "commit_behavior"
162 | "runtime.commit_behavior"
163 | "speculative"
164 | "runtime.speculative"
165 | "background"
166 | "runtime.background"
167 | "maintenance"
168 | "runtime.maintenance"
169 | "maintenance_stage"
170 | "runtime.maintenance_stage"
171 | "await_before_next_turn"
172 | "runtime.await_before_next_turn"
173 | "maintenance_mode"
174 | "runtime.maintenance_mode"
175 )
176}
177
178fn redact_path(value: &mut Value, path: &str) {
179 let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
180 if parts.is_empty() {
181 return;
182 }
183 redact_path_parts(value, &parts);
184}
185
186fn redact_path_parts(value: &mut Value, parts: &[&str]) {
187 if parts.is_empty() {
188 *value = redacted_marker();
189 return;
190 }
191 match value {
192 Value::Object(map) => {
193 if let Some(child) = map.get_mut(parts[0]) {
194 redact_path_parts(child, &parts[1..]);
195 }
196 }
197 Value::Array(items) => {
198 for item in items {
199 redact_path_parts(item, parts);
200 }
201 }
202 _ => {}
203 }
204}
205
206pub fn truncate_chars(text: &str, max_chars: usize) -> String {
208 if max_chars == 0 {
209 return String::new();
210 }
211 let mut chars = text.chars();
212 let truncated: String = chars.by_ref().take(max_chars).collect();
213 if chars.next().is_some() {
214 format!("{}...", truncated)
215 } else {
216 text.to_string()
217 }
218}
219
220pub fn stable_hash(text: &str) -> String {
222 let mut hash: u64 = 0xcbf29ce484222325;
223 for byte in text.as_bytes() {
224 hash ^= *byte as u64;
225 hash = hash.wrapping_mul(0x100000001b3);
226 }
227 format!("fnv1a64:{:016x}", hash)
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn redacts_nested_keys() {
236 let redactor = Redactor::new(PrivacyConfig::default());
237 let value = serde_json::json!({"headers": {"authorization": "Bearer secret"}});
238 let redacted = redactor.redact_value(&value);
239 assert_eq!(
240 redacted["headers"]["authorization"],
241 serde_json::json!({"redacted": true})
242 );
243 }
244
245 #[test]
246 fn truncates_on_char_boundaries() {
247 let text = "안녕하세요 world";
248 let truncated = truncate_chars(text, 3);
249 assert_eq!(truncated, "안녕하...");
250 }
251
252 #[test]
253 fn hash_is_stable() {
254 assert_eq!(stable_hash("abc"), stable_hash("abc"));
255 assert_ne!(stable_hash("abc"), stable_hash("abd"));
256 }
257
258 #[test]
259 fn redacts_event_tags_errors_and_payloads() {
260 use crate::event::{EventStatus, ObservationPurpose};
261 use chrono::Utc;
262 use std::collections::HashMap;
263
264 let redactor = Redactor::new(PrivacyConfig::default());
265 let mut tags = HashMap::new();
266 tags.insert("reason".to_string(), "사용자 secret token 값".to_string());
267 let event = ObservationEvent {
268 trace_id: "trace".to_string(),
269 span_id: "span".to_string(),
270 parent_span_id: None,
271 turn_id: "turn".to_string(),
272 agent_id: "agent".to_string(),
273 actor_id: None,
274 session_id: None,
275 event_type: EventType::HitlApproval {
276 trigger: "tool with private args".to_string(),
277 },
278 purpose: ObservationPurpose::HitlLocalization,
279 status: EventStatus::Error,
280 timestamp: Utc::now(),
281 duration_ms: 1,
282 tokens: None,
283 cost: None,
284 error: Some(ObservationError::new("tool", "비밀 응답 secret")),
285 dimensions: HashMap::new(),
286 tags,
287 payload: Some(
288 serde_json::json!({"authorization": "Bearer secret", "text": "こんにちはsecret"}),
289 ),
290 };
291
292 let redacted = redactor.redact_event(event);
293 assert!(!redacted.error.unwrap().message.contains("비밀"));
294 assert!(!redacted.tags["reason"].contains("사용자"));
295 assert_eq!(
296 redacted.payload.unwrap()["authorization"],
297 serde_json::json!({"redacted": true})
298 );
299 }
300}