starweaver_runtime/trace/
policy.rs1use std::{
2 collections::{BTreeMap, BTreeSet},
3 sync::{Arc, Mutex},
4};
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use starweaver_core::{Metadata, TraceContext};
9use uuid::Uuid;
10
11use super::{
12 DynTraceRecorder, SpanEvent, SpanHandle, SpanSpec, SpanStatus, TraceLevel, TraceRecorder,
13};
14
15#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum TraceDebugPolicy {
19 #[default]
21 Drop,
22 Redacted,
24 FullPayload,
26}
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct TraceRedactionPolicy {
31 #[serde(default)]
33 pub debug: TraceDebugPolicy,
34 #[serde(default = "default_sensitive_keys")]
36 pub sensitive_keys: BTreeSet<String>,
37 #[serde(default = "default_debug_payload_keys")]
39 pub debug_payload_keys: BTreeSet<String>,
40 #[serde(default = "default_redaction_value")]
42 pub redaction_value: Value,
43}
44
45impl Default for TraceRedactionPolicy {
46 fn default() -> Self {
47 Self {
48 debug: TraceDebugPolicy::Drop,
49 sensitive_keys: default_sensitive_keys(),
50 debug_payload_keys: default_debug_payload_keys(),
51 redaction_value: default_redaction_value(),
52 }
53 }
54}
55
56impl TraceRedactionPolicy {
57 #[must_use]
59 pub fn default_safe() -> Self {
60 Self::default()
61 }
62
63 #[must_use]
65 pub fn debug_redacted() -> Self {
66 Self {
67 debug: TraceDebugPolicy::Redacted,
68 ..Self::default()
69 }
70 }
71
72 #[must_use]
74 pub fn debug_full_payloads() -> Self {
75 Self {
76 debug: TraceDebugPolicy::FullPayload,
77 ..Self::default()
78 }
79 }
80
81 #[must_use]
83 pub const fn with_debug_policy(mut self, debug: TraceDebugPolicy) -> Self {
84 self.debug = debug;
85 self
86 }
87
88 #[must_use]
90 pub fn with_sensitive_key(mut self, key: impl AsRef<str>) -> Self {
91 self.sensitive_keys.insert(normalize_key(key.as_ref()));
92 self
93 }
94
95 #[must_use]
97 pub fn with_debug_payload_key(mut self, key: impl AsRef<str>) -> Self {
98 self.debug_payload_keys.insert(normalize_key(key.as_ref()));
99 self
100 }
101
102 pub(super) fn sanitize_span_spec(&self, mut spec: SpanSpec) -> Option<SpanSpec> {
103 let redact_debug_payloads = match (spec.level, self.debug) {
104 (TraceLevel::Debug, TraceDebugPolicy::Drop) => return None,
105 (TraceLevel::Debug, TraceDebugPolicy::Redacted) => true,
106 (TraceLevel::Debug, TraceDebugPolicy::FullPayload) | (TraceLevel::Info, _) => false,
107 };
108 scrub_attributes(&mut spec.attributes, self, redact_debug_payloads);
109 Some(spec)
110 }
111
112 pub(super) fn sanitize_event(&self, mut event: SpanEvent) -> Option<SpanEvent> {
113 let redact_debug_payloads = match (event.level, self.debug) {
114 (TraceLevel::Debug, TraceDebugPolicy::Drop) => return None,
115 (TraceLevel::Debug, TraceDebugPolicy::Redacted) => true,
116 (TraceLevel::Debug, TraceDebugPolicy::FullPayload) | (TraceLevel::Info, _) => false,
117 };
118 scrub_attributes(&mut event.attributes, self, redact_debug_payloads);
119 Some(event)
120 }
121}
122
123pub struct PolicyTraceRecorder {
125 inner: DynTraceRecorder,
126 policy: TraceRedactionPolicy,
127 dropped_spans: Arc<Mutex<BTreeSet<String>>>,
128}
129
130impl PolicyTraceRecorder {
131 #[must_use]
133 pub fn new(inner: DynTraceRecorder) -> Self {
134 Self::with_policy(inner, TraceRedactionPolicy::default_safe())
135 }
136
137 #[must_use]
139 pub fn with_policy(inner: DynTraceRecorder, policy: TraceRedactionPolicy) -> Self {
140 Self {
141 inner,
142 policy,
143 dropped_spans: Arc::new(Mutex::new(BTreeSet::new())),
144 }
145 }
146
147 #[must_use]
149 pub const fn policy(&self) -> &TraceRedactionPolicy {
150 &self.policy
151 }
152
153 fn dropped_handle(&self, parent: &TraceContext) -> SpanHandle {
154 let span_id = format!("dropped_span_{}", Uuid::new_v4());
155 if let Ok(mut spans) = self.dropped_spans.lock() {
156 spans.insert(span_id.clone());
157 }
158 let mut metadata = Metadata::default();
159 metadata.insert("trace_dropped".to_string(), serde_json::json!(true));
160 let context = TraceContext {
161 trace_id: parent.trace_id.clone(),
162 span_id: Some(span_id.clone()),
163 parent_span_id: parent
164 .span_id
165 .clone()
166 .or_else(|| parent.parent_span_id.clone()),
167 trace_state: parent.trace_state.clone(),
168 metadata,
169 };
170 SpanHandle::new(context, span_id)
171 }
172
173 fn is_dropped_span(&self, span_id: &str) -> bool {
174 self.dropped_spans
175 .lock()
176 .is_ok_and(|spans| spans.contains(span_id))
177 }
178
179 fn remove_dropped_span(&self, span_id: &str) -> bool {
180 self.dropped_spans
181 .lock()
182 .is_ok_and(|mut spans| spans.remove(span_id))
183 }
184}
185
186impl TraceRecorder for PolicyTraceRecorder {
187 fn start_span(&self, spec: SpanSpec, parent: &TraceContext) -> SpanHandle {
188 let Some(spec) = self.policy.sanitize_span_spec(spec) else {
189 return self.dropped_handle(parent);
190 };
191 self.inner.start_span(spec, parent)
192 }
193
194 fn record_event(&self, span: &SpanHandle, event: SpanEvent) {
195 if self.is_dropped_span(span.span_id()) {
196 return;
197 }
198 if let Some(event) = self.policy.sanitize_event(event) {
199 self.inner.record_event(span, event);
200 }
201 }
202
203 fn close_span(&self, span: &SpanHandle, status: SpanStatus) {
204 if self.remove_dropped_span(span.span_id()) {
205 return;
206 }
207 self.inner.close_span(span, status);
208 }
209}
210
211fn scrub_attributes(
212 attributes: &mut BTreeMap<String, Value>,
213 policy: &TraceRedactionPolicy,
214 redact_debug_payloads: bool,
215) {
216 for (key, value) in attributes {
217 scrub_value_for_key(key, value, policy, redact_debug_payloads);
218 }
219}
220
221fn scrub_value_for_key(
222 key: &str,
223 value: &mut Value,
224 policy: &TraceRedactionPolicy,
225 redact_debug_payloads: bool,
226) {
227 if policy.should_redact_key(key, redact_debug_payloads) {
228 *value = policy.redaction_value.clone();
229 return;
230 }
231
232 match value {
233 Value::Object(object) => {
234 for (nested_key, nested_value) in object {
235 scrub_value_for_key(nested_key, nested_value, policy, redact_debug_payloads);
236 }
237 }
238 Value::Array(values) => {
239 for nested_value in values {
240 scrub_nested_value(nested_value, policy, redact_debug_payloads);
241 }
242 }
243 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
244 }
245}
246
247fn scrub_nested_value(
248 value: &mut Value,
249 policy: &TraceRedactionPolicy,
250 redact_debug_payloads: bool,
251) {
252 match value {
253 Value::Object(object) => {
254 for (nested_key, nested_value) in object {
255 scrub_value_for_key(nested_key, nested_value, policy, redact_debug_payloads);
256 }
257 }
258 Value::Array(values) => {
259 for nested_value in values {
260 scrub_nested_value(nested_value, policy, redact_debug_payloads);
261 }
262 }
263 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
264 }
265}
266
267impl TraceRedactionPolicy {
268 fn should_redact_key(&self, key: &str, redact_debug_payloads: bool) -> bool {
269 let key = normalize_key(key);
270 self.sensitive_keys.contains(&key)
271 || key.ends_with("_token")
272 || key_component_suffix(&key, '.', "token")
273 || key.contains("secret")
274 || key.contains("password")
275 || (redact_debug_payloads
276 && (self.debug_payload_keys.contains(&key)
277 || key.starts_with("raw_")
278 || key.ends_with("_payload")
279 || key_component_suffix(&key, '.', "payload")))
280 }
281}
282
283fn key_component_suffix(key: &str, separator: char, suffix: &str) -> bool {
284 key.rsplit_once(separator)
285 .is_some_and(|(_, component)| component == suffix)
286}
287
288fn normalize_key(key: &str) -> String {
289 key.trim().to_ascii_lowercase()
290}
291
292fn default_redaction_value() -> Value {
293 serde_json::json!({ "redacted": true })
294}
295
296fn default_sensitive_keys() -> BTreeSet<String> {
297 [
298 "api_key",
299 "apikey",
300 "authorization",
301 "cookie",
302 "id_token",
303 "password",
304 "proxy-authorization",
305 "refresh_token",
306 "secret",
307 "set-cookie",
308 "token",
309 ]
310 .into_iter()
311 .map(str::to_string)
312 .collect()
313}
314
315fn default_debug_payload_keys() -> BTreeSet<String> {
316 [
317 "arguments",
318 "body",
319 "content",
320 "headers",
321 "http_body",
322 "messages",
323 "payload",
324 "raw_event",
325 "raw_request",
326 "raw_response",
327 "request_body",
328 "response_body",
329 "result",
330 ]
331 .into_iter()
332 .map(str::to_string)
333 .collect()
334}