#[cfg(feature = "tracing")]
pub(crate) fn sql_digest(sql: &str) -> String {
const MAX: usize = 120;
let mut collapsed = String::with_capacity(sql.len());
let mut last_was_space = false;
for ch in sql.trim().chars() {
if ch.is_whitespace() {
if !last_was_space {
collapsed.push(' ');
last_was_space = true;
}
} else {
collapsed.push(ch);
last_was_space = false;
}
}
let mut redacted = String::with_capacity(collapsed.len());
let mut chars = collapsed.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' => {
redacted.push('?');
while let Some(&inner) = chars.peek() {
chars.next();
if inner == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
continue;
}
break;
}
}
}
d if d.is_ascii_digit() && starts_numeric_literal(&redacted) => {
redacted.push('?');
while let Some(&inner) = chars.peek() {
if inner.is_ascii_digit()
|| inner == '.'
|| inner == 'e'
|| inner == 'E'
|| inner == '+'
|| inner == '-'
{
chars.next();
} else {
break;
}
}
}
other => redacted.push(other),
}
}
if redacted.chars().count() > MAX {
let mut capped: String = redacted.chars().take(MAX).collect();
capped.push('…');
capped
} else {
redacted
}
}
#[cfg(feature = "tracing")]
fn starts_numeric_literal(emitted_so_far: &str) -> bool {
match emitted_so_far.chars().last() {
None => true,
Some(c) if c.is_alphanumeric() || c == '_' => false,
Some(':') => false,
Some(_) => true,
}
}
#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! obs_span {
($name:expr $(,)?) => {{
$crate::__tracing::span!($crate::__tracing::Level::INFO, $name).entered()
}};
($name:expr, $($fields:tt)+) => {{
$crate::__tracing::span!($crate::__tracing::Level::INFO, $name, $($fields)+).entered()
}};
}
#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! obs_span {
($name:expr $(,)?) => {
$crate::ObsSpanGuard
};
($name:expr, $($fields:tt)+) => {
$crate::ObsSpanGuard
};
}
#[cfg(not(feature = "tracing"))]
#[doc(hidden)]
pub struct ObsSpanGuard;
#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! obs_record {
($guard:expr, $($field:ident).+ = $value:expr) => {{
let _ = $crate::__tracing::Span::record(
&*$guard,
::core::stringify!($($field).+),
$value,
);
}};
}
#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! obs_record {
($guard:expr, $($field:ident).+ = $value:expr) => {{}};
}
#[cfg(feature = "tracing")]
#[macro_export]
macro_rules! obs_warn {
($($args:tt)+) => {{
$crate::__tracing::event!($crate::__tracing::Level::WARN, $($args)+);
}};
}
#[cfg(not(feature = "tracing"))]
#[macro_export]
macro_rules! obs_warn {
($($args:tt)+) => {{}};
}
#[cfg(all(test, feature = "tracing"))]
mod tests {
use super::sql_digest;
#[test]
fn sql_digest_collapses_whitespace_and_keeps_shape() {
let digest = sql_digest(" select n\n from dual\t where 1=1 ");
assert_eq!(digest, "select n from dual where ?=?");
}
#[test]
fn sql_digest_is_length_capped() {
let long = format!("select {} from dual", "x".repeat(500));
let digest = sql_digest(&long);
assert!(
digest.chars().count() <= 121,
"digest must be length-capped, got {} chars",
digest.chars().count()
);
assert!(
digest.ends_with('…'),
"a truncated digest is marked with an ellipsis"
);
}
#[test]
fn sql_digest_preserves_bind_placeholders() {
assert_eq!(
sql_digest("select * from emp where id = :1"),
"select * from emp where id = :1"
);
assert_eq!(
sql_digest("select * from emp where name = :name and dept = :dept"),
"select * from emp where name = :name and dept = :dept"
);
}
#[test]
fn sql_digest_redacts_embedded_string_and_numeric_literals() {
let digest = sql_digest("select * from t where ssn = 'SSN-078-05-1120' and age = 42");
assert_eq!(digest, "select * from t where ssn = ? and age = ?");
assert!(
!digest.contains("SSN-078-05-1120") && !digest.contains("42"),
"embedded literals must be redacted, got {digest:?}"
);
let escaped = sql_digest("insert into t(name) values ('O''Brien')");
assert_eq!(escaped, "insert into t(name) values (?)");
assert!(
!escaped.contains("Brien"),
"an escaped-quote literal must still be fully redacted, got {escaped:?}"
);
assert_eq!(
sql_digest("select * from t where rate > 3.14e-2"),
"select * from t where rate > ?"
);
}
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Record};
use tracing::subscriber::with_default;
use tracing::{Id, Subscriber};
#[derive(Default)]
struct FieldCollector(BTreeMap<String, String>);
impl Visit for FieldCollector {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.0
.insert(field.name().to_string(), format!("{value:?}"));
}
fn record_str(&mut self, field: &Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.0.insert(field.name().to_string(), value.to_string());
}
}
#[derive(Clone, Debug)]
struct CapturedSpan {
name: String,
fields: BTreeMap<String, String>,
}
#[derive(Clone, Default)]
struct Capture {
spans: Arc<Mutex<Vec<CapturedSpan>>>,
}
impl Capture {
fn all_field_values(&self) -> Vec<String> {
self.spans
.lock()
.unwrap()
.iter()
.flat_map(|s| s.fields.values().cloned().collect::<Vec<_>>())
.collect()
}
fn all_field_names(&self) -> Vec<String> {
self.spans
.lock()
.unwrap()
.iter()
.flat_map(|s| s.fields.keys().cloned().collect::<Vec<_>>())
.collect()
}
}
impl Subscriber for Capture {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn new_span(&self, attrs: &Attributes<'_>) -> Id {
let mut collector = FieldCollector::default();
attrs.record(&mut collector);
let mut spans = self.spans.lock().unwrap();
spans.push(CapturedSpan {
name: attrs.metadata().name().to_string(),
fields: collector.0,
});
Id::from_u64(spans.len() as u64)
}
fn record(&self, span: &Id, values: &Record<'_>) {
let mut collector = FieldCollector::default();
values.record(&mut collector);
let idx = (span.into_u64() - 1) as usize;
let mut spans = self.spans.lock().unwrap();
if let Some(captured) = spans.get_mut(idx) {
captured.fields.extend(collector.0);
}
}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, _: &tracing::Event<'_>) {}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
#[test]
fn spans_redact_sql_binds_and_credentials_by_default() {
const PASSWORD: &str = "hunter2-super-secret-password";
const BIND_VALUE: &str = "4111111111111111"; const SQL_LITERAL_SECRET: &str = "SSN-078-05-1120";
let capture = Capture::default();
with_default(capture.clone(), || {
let host = "db.internal.example";
let port: u64 = 1521;
let service = "ORCLPDB1";
let _connect = obs_span!(
"oracledb.connect",
db.system = "oracle",
server.address = %host,
server.port = port,
db.name = %service,
);
let raw_sql_with_secret = format!(
"select * from accounts where ssn = '{SQL_LITERAL_SECRET}' and card = :card"
);
let bind_count: u64 = 1;
let bind_rows: u64 = 1;
let span = obs_span!(
"oracledb.execute",
db.statement = %sql_digest(&raw_sql_with_secret),
db.bind_count = bind_count,
db.bind_rows = bind_rows,
db.rows_fetched = tracing::field::Empty,
);
obs_record!(span, db.rows_fetched = 3u64);
let _bind_value_in_scope = (PASSWORD, BIND_VALUE);
});
let values = capture.all_field_values();
let names = capture.all_field_names();
assert!(
!capture.spans.lock().unwrap().is_empty(),
"the tracing feature must actually emit spans for this test to be meaningful"
);
for secret in [PASSWORD, BIND_VALUE, SQL_LITERAL_SECRET] {
for value in &values {
assert!(
!value.contains(secret),
"a secret ({secret}) leaked into an emitted span field value: {value:?}"
);
}
}
for name in &names {
let lower = name.to_ascii_lowercase();
assert!(
!lower.contains("password")
&& !lower.contains("secret")
&& !lower.contains("credential")
&& !lower.contains("bind_value")
&& !lower.contains("bind_values"),
"span field {name} looks like it leaks sensitive data"
);
}
let execute = capture
.spans
.lock()
.unwrap()
.iter()
.find(|s| s.name == "oracledb.execute")
.cloned()
.expect("an execute span was emitted");
let digest = execute
.fields
.get("db.statement")
.expect("execute span carries a db.statement digest");
assert!(
digest.to_uppercase().contains("SELECT") && digest.contains(":card"),
"the digest must be the parameterized shape, got {digest:?}"
);
assert!(
!digest.contains(SQL_LITERAL_SECRET),
"the digest must not echo the embedded SQL literal secret, got {digest:?}"
);
}
#[test]
fn sql_digest_never_lengthens_or_echoes_a_password_argument() {
let digest = sql_digest("update users set pw = :pw where id = :id");
assert!(digest.contains(":pw") && digest.contains(":id"));
assert!(
!digest.to_lowercase().contains("hunter2"),
"a parameterized digest cannot contain a value the caller never put in it"
);
}
}