use std::hash::{BuildHasher, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub const HEADER: &str = "traceparent";
const SAMPLED_FLAGS: &str = "01";
const FIXED_LENGTH: usize = 55;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
pub trace_id: String,
pub parent_span_id: String,
}
pub fn parse(value: &str) -> Option<Context> {
let value = value.trim();
let bytes = value.as_bytes();
if bytes.len() < FIXED_LENGTH {
return None;
}
let lower_hex = |range: std::ops::Range<usize>| {
bytes[range]
.iter()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b))
};
if !(lower_hex(0..2)
&& bytes[2] == b'-'
&& lower_hex(3..35)
&& bytes[35] == b'-'
&& lower_hex(36..52)
&& bytes[52] == b'-'
&& lower_hex(53..55))
{
return None;
}
let version = &value[0..2];
let rest = &value[FIXED_LENGTH..];
if version == "ff" {
return None;
}
if !rest.is_empty() && (version == "00" || !rest.starts_with('-')) {
return None;
}
let trace_id = &value[3..35];
let parent_span_id = &value[36..52];
if all_zeros(trace_id) || all_zeros(parent_span_id) {
return None;
}
Some(Context {
trace_id: trace_id.to_string(),
parent_span_id: parent_span_id.to_string(),
})
}
pub fn build(trace_id: &str, span_id: &str) -> String {
format!("00-{trace_id}-{span_id}-{SAMPLED_FLAGS}")
}
pub fn generate_trace_id() -> String {
random_non_zero_hex(2)
}
pub fn generate_span_id() -> String {
random_non_zero_hex(1)
}
fn all_zeros(id: &str) -> bool {
id.bytes().all(|b| b == b'0')
}
fn random_non_zero_hex(words: usize) -> String {
loop {
let id = random_hex(words);
if !all_zeros(&id) {
return id;
}
}
}
fn random_hex(words: usize) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
(0..words)
.map(|_| {
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
hasher.write_u128(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
);
format!("{:016x}", hasher.finish())
})
.collect()
}
pub fn url_host(url: &str) -> Option<String> {
let (scheme, rest) = url.trim().split_once("://")?;
if scheme.is_empty() {
return None;
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let host_and_port = match authority.rsplit_once('@') {
Some((_, after)) => after,
None => authority,
};
let host = if host_and_port.starts_with('[') {
let end = host_and_port.find(']')?;
&host_and_port[..=end]
} else {
host_and_port.split(':').next().unwrap_or("")
};
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
#[cfg(test)]
mod tests {
use super::*;
const TRACE_ID: &str = "4bf92f3577b34da6a3ce929d0e0e4736";
const SPAN_ID: &str = "00f067aa0ba902b7";
#[test]
fn parses_a_valid_version_00_header() {
let context = parse(&format!("00-{TRACE_ID}-{SPAN_ID}-01")).unwrap();
assert_eq!(context.trace_id, TRACE_ID);
assert_eq!(context.parent_span_id, SPAN_ID);
assert_eq!(
parse(&format!(" 00-{TRACE_ID}-{SPAN_ID}-00 ")),
Some(context),
"surrounding whitespace and an unsampled flag are fine"
);
}
#[test]
fn rejects_anything_malformed() {
for value in [
String::new(),
"garbage".to_string(),
format!("00-{}-{SPAN_ID}-01", TRACE_ID.to_uppercase()),
format!("00-{TRACE_ID}-{}-01", SPAN_ID.to_uppercase()),
format!("ff-{TRACE_ID}-{SPAN_ID}-01"),
format!("00-{}-{SPAN_ID}-01", "0".repeat(32)),
format!("00-{TRACE_ID}-{}-01", "0".repeat(16)),
format!("00-{}-{SPAN_ID}-01", &TRACE_ID[..31]),
format!("00-{TRACE_ID}-{}-01", &SPAN_ID[..15]),
format!("00_{TRACE_ID}-{SPAN_ID}-01"),
format!("00-{TRACE_ID}-{SPAN_ID}-1"),
format!("00-{TRACE_ID}-{SPAN_ID}-01-extra"),
format!("0g-{TRACE_ID}-{SPAN_ID}-01"),
format!("00-{TRACE_ID}-{SPAN_ID}-01é"),
] {
assert_eq!(parse(&value), None, "value = {value:?}");
}
}
#[test]
fn accepts_a_future_version_with_extra_fields_but_not_glued_on_characters() {
let context = parse(&format!("01-{TRACE_ID}-{SPAN_ID}-01-what-comes-next")).unwrap();
assert_eq!(context.trace_id, TRACE_ID);
assert_eq!(parse(&format!("01-{TRACE_ID}-{SPAN_ID}-01")), Some(context));
assert_eq!(parse(&format!("01-{TRACE_ID}-{SPAN_ID}-01x")), None);
}
#[test]
fn builds_a_sampled_version_00_header_that_parses_back() {
let header = build(TRACE_ID, SPAN_ID);
assert_eq!(header, format!("00-{TRACE_ID}-{SPAN_ID}-01"));
assert_eq!(parse(&header).unwrap().parent_span_id, SPAN_ID);
}
#[test]
fn generated_ids_are_lowercase_hex_of_the_right_length_and_unique() {
let is_lower_hex = |id: &str| {
id.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
};
let trace_id = generate_trace_id();
assert_eq!(trace_id.len(), 32);
assert!(is_lower_hex(&trace_id));
let span_id = generate_span_id();
assert_eq!(span_id.len(), 16);
assert!(is_lower_hex(&span_id));
let ids: std::collections::HashSet<String> =
(0..1000).map(|_| generate_span_id()).collect();
assert_eq!(ids.len(), 1000);
}
#[test]
fn url_host_extracts_just_the_host() {
assert_eq!(
url_host("https://API.Example.com/orders/42?x=1").as_deref(),
Some("api.example.com")
);
assert_eq!(
url_host("http://user:pw@example.com:8080/x").as_deref(),
Some("example.com")
);
assert_eq!(
url_host("http://example.com?q=a@b").as_deref(),
Some("example.com")
);
assert_eq!(
url_host("http://example.com#frag").as_deref(),
Some("example.com")
);
assert_eq!(url_host("http://[::1]:3000/").as_deref(), Some("[::1]"));
assert_eq!(url_host("example.com/no-scheme"), None);
assert_eq!(url_host("http:///path-only"), None);
assert_eq!(url_host(""), None);
}
}