Skip to main content

starweaver_runtime/trace/
policy.rs

1use 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/// Policy for debug-level trace capture.
16#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum TraceDebugPolicy {
19    /// Drop debug spans and events.
20    #[default]
21    Drop,
22    /// Keep debug spans and events, but redact raw payload-like attributes.
23    Redacted,
24    /// Keep debug spans, events, and payload-like attributes.
25    FullPayload,
26}
27
28/// Redaction and debug-capture policy applied before trace records reach an exporter.
29#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
30pub struct TraceRedactionPolicy {
31    /// Debug span/event handling.
32    #[serde(default)]
33    pub debug: TraceDebugPolicy,
34    /// Case-insensitive keys that should always be redacted.
35    #[serde(default = "default_sensitive_keys")]
36    pub sensitive_keys: BTreeSet<String>,
37    /// Case-insensitive raw payload keys redacted when debug policy is `Redacted`.
38    #[serde(default = "default_debug_payload_keys")]
39    pub debug_payload_keys: BTreeSet<String>,
40    /// Replacement value used for redacted content.
41    #[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    /// Default-safe policy: drop debug telemetry and redact sensitive keys.
58    #[must_use]
59    pub fn default_safe() -> Self {
60        Self::default()
61    }
62
63    /// Keep debug telemetry while redacting raw payload-like fields.
64    #[must_use]
65    pub fn debug_redacted() -> Self {
66        Self {
67            debug: TraceDebugPolicy::Redacted,
68            ..Self::default()
69        }
70    }
71
72    /// Keep debug telemetry and payload-like fields while still redacting secrets.
73    #[must_use]
74    pub fn debug_full_payloads() -> Self {
75        Self {
76            debug: TraceDebugPolicy::FullPayload,
77            ..Self::default()
78        }
79    }
80
81    /// Set debug capture behavior.
82    #[must_use]
83    pub const fn with_debug_policy(mut self, debug: TraceDebugPolicy) -> Self {
84        self.debug = debug;
85        self
86    }
87
88    /// Add a case-insensitive sensitive key.
89    #[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    /// Add a case-insensitive debug payload key.
96    #[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
123/// Trace recorder wrapper that applies [`TraceRedactionPolicy`] before forwarding records.
124pub struct PolicyTraceRecorder {
125    inner: DynTraceRecorder,
126    policy: TraceRedactionPolicy,
127    dropped_spans: Arc<Mutex<BTreeSet<String>>>,
128}
129
130impl PolicyTraceRecorder {
131    /// Wrap a recorder with the default-safe redaction policy.
132    #[must_use]
133    pub fn new(inner: DynTraceRecorder) -> Self {
134        Self::with_policy(inner, TraceRedactionPolicy::default_safe())
135    }
136
137    /// Wrap a recorder with an explicit redaction policy.
138    #[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    /// Return the active redaction policy.
148    #[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}