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 )
156}
157
158fn redact_path(value: &mut Value, path: &str) {
159 let parts: Vec<&str> = path.split('.').filter(|part| !part.is_empty()).collect();
160 if parts.is_empty() {
161 return;
162 }
163 redact_path_parts(value, &parts);
164}
165
166fn redact_path_parts(value: &mut Value, parts: &[&str]) {
167 if parts.is_empty() {
168 *value = redacted_marker();
169 return;
170 }
171 match value {
172 Value::Object(map) => {
173 if let Some(child) = map.get_mut(parts[0]) {
174 redact_path_parts(child, &parts[1..]);
175 }
176 }
177 Value::Array(items) => {
178 for item in items {
179 redact_path_parts(item, parts);
180 }
181 }
182 _ => {}
183 }
184}
185
186pub fn truncate_chars(text: &str, max_chars: usize) -> String {
188 if max_chars == 0 {
189 return String::new();
190 }
191 let mut chars = text.chars();
192 let truncated: String = chars.by_ref().take(max_chars).collect();
193 if chars.next().is_some() {
194 format!("{}...", truncated)
195 } else {
196 text.to_string()
197 }
198}
199
200pub fn stable_hash(text: &str) -> String {
202 let mut hash: u64 = 0xcbf29ce484222325;
203 for byte in text.as_bytes() {
204 hash ^= *byte as u64;
205 hash = hash.wrapping_mul(0x100000001b3);
206 }
207 format!("fnv1a64:{:016x}", hash)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn redacts_nested_keys() {
216 let redactor = Redactor::new(PrivacyConfig::default());
217 let value = serde_json::json!({"headers": {"authorization": "Bearer secret"}});
218 let redacted = redactor.redact_value(&value);
219 assert_eq!(
220 redacted["headers"]["authorization"],
221 serde_json::json!({"redacted": true})
222 );
223 }
224
225 #[test]
226 fn truncates_on_char_boundaries() {
227 let text = "안녕하세요 world";
228 let truncated = truncate_chars(text, 3);
229 assert_eq!(truncated, "안녕하...");
230 }
231
232 #[test]
233 fn hash_is_stable() {
234 assert_eq!(stable_hash("abc"), stable_hash("abc"));
235 assert_ne!(stable_hash("abc"), stable_hash("abd"));
236 }
237
238 #[test]
239 fn redacts_event_tags_errors_and_payloads() {
240 use crate::event::{EventStatus, ObservationPurpose};
241 use chrono::Utc;
242 use std::collections::HashMap;
243
244 let redactor = Redactor::new(PrivacyConfig::default());
245 let mut tags = HashMap::new();
246 tags.insert("reason".to_string(), "사용자 secret token 값".to_string());
247 let event = ObservationEvent {
248 trace_id: "trace".to_string(),
249 span_id: "span".to_string(),
250 parent_span_id: None,
251 turn_id: "turn".to_string(),
252 agent_id: "agent".to_string(),
253 actor_id: None,
254 session_id: None,
255 event_type: EventType::HitlApproval {
256 trigger: "tool with private args".to_string(),
257 },
258 purpose: ObservationPurpose::HitlLocalization,
259 status: EventStatus::Error,
260 timestamp: Utc::now(),
261 duration_ms: 1,
262 tokens: None,
263 cost: None,
264 error: Some(ObservationError::new("tool", "비밀 응답 secret")),
265 dimensions: HashMap::new(),
266 tags,
267 payload: Some(
268 serde_json::json!({"authorization": "Bearer secret", "text": "こんにちはsecret"}),
269 ),
270 };
271
272 let redacted = redactor.redact_event(event);
273 assert!(!redacted.error.unwrap().message.contains("비밀"));
274 assert!(!redacted.tags["reason"].contains("사용자"));
275 assert_eq!(
276 redacted.payload.unwrap()["authorization"],
277 serde_json::json!({"redacted": true})
278 );
279 }
280}