espresso_telemetry/
redact_exporter.rs1use 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 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
104fn 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 #[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}