use std::fmt;
use std::sync::OnceLock;
use serde_json::{Map, Value, json};
use tracing::field::Visit;
use tracing::{Event, Subscriber};
use tracing_subscriber::Registry;
use tracing_subscriber::fmt as ts_fmt;
use tracing_subscriber::fmt::format::{DefaultFields, Writer};
use tracing_subscriber::fmt::{FmtContext, FormatEvent};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
static INSTALLED: OnceLock<()> = OnceLock::new();
pub fn install_tracing() {
INSTALLED.get_or_init(|| {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let fmt_layer = ts_fmt::Layer::default()
.event_format(RedactingJson)
.with_writer(std::io::stdout);
let subscriber = Registry::default().with(filter).with(fmt_layer);
let _ = tracing::subscriber::set_global_default(subscriber);
});
}
struct RedactingJson;
impl<S> FormatEvent<S, DefaultFields> for RedactingJson
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, DefaultFields>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let mut visitor = RedactingVisitor::default();
event.record(&mut visitor);
let metadata = event.metadata();
let mut fields = visitor.fields;
fields.insert("level".into(), json!(metadata.level().as_str()));
fields.insert("target".into(), json!(metadata.target()));
let timestamp = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "unknown".to_owned());
fields.insert("timestamp".into(), json!(timestamp));
writer.write_str(&Value::Object(fields).to_string())?;
writer.write_char('\n')
}
}
#[derive(Default)]
struct RedactingVisitor {
fields: Map<String, Value>,
}
impl RedactingVisitor {
fn record_field(&mut self, name: &str, value: &str) {
let redacted = cratefield_core::redacted_value(name, value);
if redacted == "[redacted]" {
self.fields.insert(name.to_owned(), json!("[redacted]"));
} else if let Some(hash) = redacted.strip_prefix("subject_hash:") {
self.fields
.insert(format!("{name}_hash"), json!(format!("sha256:{hash}")));
} else {
self.fields.insert(name.to_owned(), json!(redacted));
}
}
}
impl Visit for RedactingVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.record_field(field.name(), value);
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
self.record_field(field.name(), &format!("{value:?}"));
}
fn record_error(
&mut self,
field: &tracing::field::Field,
value: &(dyn std::error::Error + 'static),
) {
self.record_field(field.name(), &value.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visitor_redacts_like_core() {
let mut visitor = RedactingVisitor::default();
visitor.record_field("authorization", "Bearer x");
visitor.record_field("email", "nick@example.com");
visitor.record_field("outcome", "sent");
assert_eq!(visitor.fields["authorization"], "[redacted]");
assert!(
visitor.fields["email_hash"]
.as_str()
.is_some_and(|hash| hash.starts_with("sha256:"))
);
assert_eq!(visitor.fields["outcome"], "sent");
let mut visitor = RedactingVisitor::default();
visitor.record_field(
"error",
"DETAIL: Key (email)=(nick@example.com) exists; see /v1/waitlist/status?token=abc",
);
let error = visitor.fields["error"].as_str().expect("error field");
assert!(!error.contains('@'), "{error}");
assert!(!error.contains("token=abc"), "{error}");
assert!(error.contains("subject_hash:"), "{error}");
}
}