Skip to main content

ai_agents_observability/
redaction.rs

1use crate::config::PrivacyConfig;
2use crate::event::{EventType, ObservationError, ObservationEvent};
3use serde_json::Value;
4use std::collections::HashSet;
5
6/// Privacy helper that redacts keys, paths, errors, tags, and raw text.
7#[derive(Debug, Clone)]
8pub struct Redactor {
9    config: PrivacyConfig,
10    keys: HashSet<String>,
11}
12
13impl Redactor {
14    /// Creates a redactor with lowercase key lookup for configured redact keys.
15    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    /// Redacts a JSON value by configured keys, dotted paths, and text rules.
25    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    /// Redacts every event surface that can carry user or domain text.
35    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    /// Redacts the error message while preserving the error kind.
57    pub fn redact_error(&self, mut error: ObservationError) -> ObservationError {
58        error.message = self.redact_text_to_string(&error.message);
59        error
60    }
61
62    /// Converts text into a safe string summary for tags and errors.
63    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    /// Converts text into a structured safe summary with length and optional hash.
74    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
186/// Truncates by Unicode scalar values rather than byte offsets.
187pub 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
200/// Produces a stable non-cryptographic hash for correlation.
201pub 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}