Skip to main content

espresso_telemetry/
redact_exporter.rs

1//! Scrubs credential-bearing URLs out of OTLP log records before they leave the process.
2//!
3//! Applies to every log record, including URLs baked into the rendered `Debug`/`Display` of
4//! someone else's error type (`reqwest`, `alloy`, ...).
5
6use std::time::Duration;
7
8use espresso_utils::redact::scrub;
9use opentelemetry::{
10    InstrumentationScope,
11    logs::{AnyValue, LogRecord as _, Logger as _, LoggerProvider as _, Severity},
12};
13use opentelemetry_sdk::{
14    Resource,
15    error::OTelSdkResult,
16    logs::{LogBatch, LogExporter, SdkLogRecord, SdkLogger, SdkLoggerProvider},
17};
18
19#[derive(Debug)]
20pub(crate) struct RedactingLogExporter<T> {
21    inner: T,
22    // `SdkLogRecord` cannot be constructed directly, nor its attributes replaced once set, so a
23    // scrubbed record must be rebuilt field by field from a blank one.
24    blank_record_source: SdkLogger,
25}
26
27impl<T> RedactingLogExporter<T> {
28    pub(crate) fn new(inner: T) -> Self {
29        let blank_record_source = SdkLoggerProvider::builder()
30            .build()
31            .logger("redact-exporter");
32        Self {
33            inner,
34            blank_record_source,
35        }
36    }
37}
38
39impl<T: LogExporter> LogExporter for RedactingLogExporter<T> {
40    async fn export(&self, batch: LogBatch<'_>) -> OTelSdkResult {
41        let scrubbed: Vec<(SdkLogRecord, InstrumentationScope)> = batch
42            .iter()
43            .map(|(record, scope)| {
44                (
45                    scrub_record(&self.blank_record_source, record),
46                    scope.clone(),
47                )
48            })
49            .collect();
50        let refs: Vec<(&SdkLogRecord, &InstrumentationScope)> = scrubbed
51            .iter()
52            .map(|(record, scope)| (record, scope))
53            .collect();
54        self.inner.export(LogBatch::new(&refs)).await
55    }
56
57    fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
58        self.inner.shutdown_with_timeout(timeout)
59    }
60
61    fn event_enabled(&self, level: Severity, target: &str, name: Option<&str>) -> bool {
62        self.inner.event_enabled(level, target, name)
63    }
64
65    fn set_resource(&mut self, resource: &Resource) {
66        self.inner.set_resource(resource);
67    }
68}
69
70fn scrub_record(blank_record_source: &SdkLogger, record: &SdkLogRecord) -> SdkLogRecord {
71    let mut out = blank_record_source.create_log_record();
72    if let Some(name) = record.event_name() {
73        out.set_event_name(name);
74    }
75    if let Some(target) = record.target() {
76        out.set_target(target.clone());
77    }
78    if let Some(timestamp) = record.timestamp() {
79        out.set_timestamp(timestamp);
80    }
81    if let Some(timestamp) = record.observed_timestamp() {
82        out.set_observed_timestamp(timestamp);
83    }
84    if let Some(text) = record.severity_text() {
85        out.set_severity_text(text);
86    }
87    if let Some(number) = record.severity_number() {
88        out.set_severity_number(number);
89    }
90    if let Some(ctx) = record.trace_context() {
91        out.set_trace_context(ctx.trace_id, ctx.span_id, ctx.trace_flags);
92    }
93    if let Some(body) = record.body() {
94        out.set_body(scrub_any_value(body));
95    }
96    out.add_attributes(
97        record
98            .attributes_iter()
99            .map(|(key, value)| (key.clone(), scrub_any_value(value))),
100    );
101    out
102}
103
104/// Recurses through `ListAny`/`Map` so nested string leaves are reached, not just top-level ones.
105fn scrub_any_value(value: &AnyValue) -> AnyValue {
106    match value {
107        AnyValue::String(s) => AnyValue::String(scrub_str(s.as_str()).into()),
108        AnyValue::ListAny(items) => {
109            AnyValue::ListAny(Box::new(items.iter().map(scrub_any_value).collect()))
110        },
111        AnyValue::Map(entries) => AnyValue::Map(Box::new(
112            entries
113                .iter()
114                .map(|(k, v)| (k.clone(), scrub_any_value(v)))
115                .collect(),
116        )),
117        other => other.clone(),
118    }
119}
120
121fn scrub_str(s: &str) -> String {
122    if s.contains("://") || s.contains("Url {") {
123        scrub(s)
124    } else {
125        s.to_owned()
126    }
127}
128
129#[cfg(test)]
130mod test {
131    use std::time::SystemTime;
132
133    use opentelemetry::{
134        Key,
135        trace::{SpanId, TraceFlags, TraceId},
136    };
137
138    use super::*;
139
140    fn logger() -> SdkLogger {
141        SdkLoggerProvider::builder().build().logger("test")
142    }
143
144    #[test]
145    fn scrubs_body() {
146        let logger = logger();
147        let mut record = logger.create_log_record();
148        record.set_body(AnyValue::String("https://host/v1/FAKEKEY".into()));
149
150        let scrubbed = scrub_record(&logger, &record);
151
152        assert_eq!(
153            scrubbed.body(),
154            Some(&AnyValue::String("https://host/***".into()))
155        );
156    }
157
158    #[test]
159    fn scrubs_nested_attribute_leaf() {
160        let logger = logger();
161        let mut record = logger.create_log_record();
162        let leaf = AnyValue::String("https://host/v1/FAKEKEY".into());
163        let map = AnyValue::Map(Box::new([(Key::new("url"), leaf)].into_iter().collect()));
164        record.add_attribute("nested", AnyValue::ListAny(Box::new(vec![map])));
165
166        let scrubbed = scrub_record(&logger, &record);
167
168        let (_, value) = scrubbed
169            .attributes_iter()
170            .next()
171            .expect("attribute present");
172        let AnyValue::ListAny(items) = value else {
173            panic!("expected ListAny, got {value:?}");
174        };
175        let AnyValue::Map(entries) = &items[0] else {
176            panic!("expected Map, got {:?}", items[0]);
177        };
178        assert_eq!(
179            entries.get(&Key::new("url")),
180            Some(&AnyValue::String("https://host/***".into()))
181        );
182    }
183
184    /// Guards against `scrub_record` dropping a field: every field it copies is populated here, so
185    /// the equality fails if one goes missing after an SDK upgrade.
186    #[test]
187    fn passes_through_record_without_url_unchanged() {
188        let logger = logger();
189        let mut record = logger.create_log_record();
190        record.set_event_name("event");
191        record.set_target("target");
192        record.set_timestamp(SystemTime::UNIX_EPOCH + Duration::from_secs(1));
193        record.set_observed_timestamp(SystemTime::UNIX_EPOCH + Duration::from_secs(2));
194        record.set_severity_text("WARN");
195        record.set_severity_number(Severity::Warn);
196        record.set_trace_context(
197            TraceId::from(1u128),
198            SpanId::from(2u64),
199            Some(TraceFlags::SAMPLED),
200        );
201        record.set_body(AnyValue::String("no url here".into()));
202        record.add_attribute("k", "v");
203
204        assert_eq!(scrub_record(&logger, &record), record);
205    }
206}