use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::Duration;
use crate::request::Request;
use crate::response::Response;
use crate::server_config::LogPrivacy;
pub const MAX_PENDING: usize = 1 << 20;
pub const MAX_BATCH: usize = 4 << 20;
#[doc(hidden)]
pub const TRACE_HEADER: &str = "x-rws-log-trace";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TraceIds {
pub trace_id: [u8; 16],
pub span_id: [u8; 8],
pub sampled: bool,
}
static ENABLED: AtomicBool = AtomicBool::new(false);
static DROPPED: AtomicU64 = AtomicU64::new(0);
pub fn enabled() -> bool {
#[cfg(test)]
if TEST_ENABLED.with(|e| e.get()) {
return true;
}
ENABLED.load(Ordering::Relaxed)
}
pub fn dropped() -> u64 {
DROPPED.load(Ordering::Relaxed)
}
fn hex(bytes: &[u8], out: &mut String) {
const HEX: &[u8; 16] = b"0123456789abcdef";
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 15) as usize] as char);
}
}
fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
if text.len() != N * 2 {
return None;
}
let mut out = [0u8; N];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(text.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
Some(out)
}
#[doc(hidden)]
pub fn put_trace(response: &mut Response, ids: TraceIds) {
if !enabled() {
return;
}
let mut value = String::with_capacity(52);
hex(&ids.trace_id, &mut value);
value.push('-');
hex(&ids.span_id, &mut value);
value.push_str(if ids.sampled { "-01" } else { "-00" });
response.headers.push(crate::header::Header::new(TRACE_HEADER, value));
}
pub fn take_trace(response: &mut Response) -> Option<TraceIds> {
if !enabled() {
return None;
}
let at = response.headers.iter().position(|h| h.name.eq_ignore_ascii_case(TRACE_HEADER))?;
let header = response.headers.remove(at);
parse_trace(&header.value)
}
fn parse_trace(value: &str) -> Option<TraceIds> {
let mut parts = value.split('-');
let trace_id = unhex::<16>(parts.next()?)?;
let span_id = unhex::<8>(parts.next()?)?;
let sampled = parts.next()? == "01";
Some(TraceIds { trace_id, span_id, sampled })
}
type Shard = Arc<Mutex<Vec<u8>>>;
fn registry() -> &'static Mutex<Vec<Shard>> {
static REGISTRY: OnceLock<Mutex<Vec<Shard>>> = OnceLock::new();
REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
}
fn orphans() -> &'static Mutex<Vec<u8>> {
static ORPHANS: Mutex<Vec<u8>> = Mutex::new(Vec::new());
&ORPHANS
}
struct ShardHandle(Shard);
impl Drop for ShardHandle {
fn drop(&mut self) {
let pending = std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()));
append(&mut orphans().lock().unwrap_or_else(|e| e.into_inner()), &pending);
registry().lock().unwrap_or_else(|e| e.into_inner()).retain(|s| !Arc::ptr_eq(s, &self.0));
}
}
thread_local! {
static SHARD: ShardHandle = {
let shard: Shard = Arc::new(Mutex::new(Vec::with_capacity(4096)));
registry().lock().unwrap_or_else(|e| e.into_inner()).push(Arc::clone(&shard));
ShardHandle(shard)
};
static SCRATCH: std::cell::RefCell<Vec<u8>> = const { std::cell::RefCell::new(Vec::new()) };
}
fn append(buf: &mut Vec<u8>, records: &[u8]) {
if records.is_empty() {
return;
}
if buf.len() + records.len() > MAX_PENDING {
DROPPED.fetch_add(1 + memchr::memmem::find_iter(records, b"},{\"timeUnixNano\"").count() as u64, Ordering::Relaxed);
return;
}
if !buf.is_empty() {
buf.push(b',');
}
buf.extend_from_slice(records);
}
pub fn record(
request: &Request,
response: &Response,
client: std::net::IpAddr,
privacy: &LogPrivacy,
trace: Option<TraceIds>,
write_line: impl FnOnce(&mut Vec<u8>),
) {
if !enabled() {
return;
}
let mut write_line = Some(write_line);
let mut build = |out: &mut Vec<u8>, line: &mut Vec<u8>| {
line.clear();
if let Some(write_line) = write_line.take() {
write_line(line);
}
write_record(out, request, response, client, privacy, trace, line, super::now_ns());
};
let done = SHARD.try_with(|handle| {
SCRATCH.with(|scratch| {
let mut record = Vec::with_capacity(512);
build(&mut record, &mut scratch.borrow_mut());
let mut buf = handle.0.lock().unwrap_or_else(|e| e.into_inner());
append(&mut buf, &record);
})
});
if done.is_err() {
let (mut record, mut line) = (Vec::new(), Vec::new());
build(&mut record, &mut line);
append(&mut orphans().lock().unwrap_or_else(|e| e.into_inner()), &record);
}
}
#[allow(clippy::too_many_arguments)]
fn write_record(
out: &mut Vec<u8>,
request: &Request,
response: &Response,
client: std::net::IpAddr,
privacy: &LogPrivacy,
trace: Option<TraceIds>,
line: &[u8],
now_ns: u64,
) {
use std::io::Write;
let (number, text) = match response.status_code {
500.. => (17, "ERROR"),
400.. => (13, "WARN"),
_ => (9, "INFO"),
};
let _ = write!(
out,
"{{\"timeUnixNano\":\"{now_ns}\",\"observedTimeUnixNano\":\"{now_ns}\",\"severityNumber\":{number},\"severityText\":\"{text}\",\"body\":{{\"stringValue\":\""
);
escape_into(out, line);
out.extend_from_slice(b"\"},\"attributes\":[");
string_attr(out, "http.request.method", &request.method);
out.push(b',');
let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
string_attr(out, "url.path", path);
let _ = write!(out, ",{{\"key\":\"http.response.status_code\",\"value\":{{\"intValue\":\"{}\"}}}},", response.status_code);
string_attr(out, "client.address", &privacy.ip_string(client));
out.push(b',');
let version = request.http_version.strip_prefix("HTTP/").unwrap_or(&request.http_version);
string_attr(out, "network.protocol.version", version);
out.push(b']');
if let Some(ids) = trace {
let mut trace_hex = String::with_capacity(32);
hex(&ids.trace_id, &mut trace_hex);
let mut span_hex = String::with_capacity(16);
hex(&ids.span_id, &mut span_hex);
let _ = write!(out, ",\"traceId\":\"{trace_hex}\",\"spanId\":\"{span_hex}\",\"flags\":{}", ids.sampled as u8);
}
out.push(b'}');
}
fn string_attr(out: &mut Vec<u8>, key: &str, value: &str) {
out.extend_from_slice(b"{\"key\":\"");
out.extend_from_slice(key.as_bytes());
out.extend_from_slice(b"\",\"value\":{\"stringValue\":\"");
escape_into(out, value.as_bytes());
out.extend_from_slice(b"\"}}");
}
fn escape_into(out: &mut Vec<u8>, bytes: &[u8]) {
use std::io::Write;
for c in String::from_utf8_lossy(bytes).chars() {
match c {
'"' => out.extend_from_slice(b"\\\""),
'\\' => out.extend_from_slice(b"\\\\"),
'\n' => out.extend_from_slice(b"\\n"),
'\r' => out.extend_from_slice(b"\\r"),
'\t' => out.extend_from_slice(b"\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => {
let mut utf8 = [0u8; 4];
out.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes());
}
}
}
}
#[doc(hidden)]
pub fn drain() -> Vec<Vec<u8>> {
let shards: Vec<Shard> = registry().lock().unwrap_or_else(|e| e.into_inner()).clone();
let mut pieces = vec![std::mem::take(&mut *orphans().lock().unwrap_or_else(|e| e.into_inner()))];
for shard in shards {
pieces.push(std::mem::take(&mut *shard.lock().unwrap_or_else(|e| e.into_inner())));
}
let mut batches: Vec<Vec<u8>> = Vec::new();
for piece in pieces.into_iter().filter(|p| !p.is_empty()) {
match batches.last_mut() {
Some(batch) if batch.len() + piece.len() < MAX_BATCH => {
batch.push(b',');
batch.extend_from_slice(&piece);
}
_ => batches.push(piece),
}
}
batches
}
pub fn body(records: &[u8], service_name: &str, service_version: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(records.len() + 256);
out.extend_from_slice(b"{\"resourceLogs\":[{\"resource\":{\"attributes\":[");
string_attr(&mut out, "service.name", service_name);
out.push(b',');
string_attr(&mut out, "service.version", service_version);
out.extend_from_slice(b"]},\"scopeLogs\":[{\"scope\":{\"name\":\"rust-web-server\",\"version\":\"");
out.extend_from_slice(env!("CARGO_PKG_VERSION").as_bytes());
out.extend_from_slice(b"\"},\"logRecords\":[");
out.extend_from_slice(records);
out.extend_from_slice(b"]}]}]}");
out
}
#[derive(Clone, Debug)]
pub struct LogsExportConfig {
pub endpoint: String,
pub interval: Duration,
pub service_name: String,
pub service_version: String,
pub headers: Vec<(String, String)>,
}
struct Exporter {
config: LogsExportConfig,
stop: AtomicBool,
wake: (Mutex<()>, Condvar),
sending: Mutex<()>,
}
static EXPORTER: OnceLock<Exporter> = OnceLock::new();
pub fn start(config: LogsExportConfig) {
let interval = config.interval.max(Duration::from_millis(100));
let exporter = Exporter { config, stop: AtomicBool::new(false), wake: (Mutex::new(()), Condvar::new()), sending: Mutex::new(()) };
if EXPORTER.set(exporter).is_err() {
return;
}
ENABLED.store(true, Ordering::SeqCst);
let _ = std::thread::Builder::new().name("rws-otlp-logs".to_string()).spawn(move || {
let exporter = EXPORTER.get().expect("set above");
loop {
let guard = exporter.wake.0.lock().unwrap_or_else(|e| e.into_inner());
let _ = exporter.wake.1.wait_timeout(guard, interval);
if exporter.stop.load(Ordering::Relaxed) {
return;
}
export(exporter);
}
});
}
pub fn start_from_env() -> bool {
let var = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
let Some(config) = config_from(var) else {
return false;
};
start(config);
true
}
#[doc(hidden)]
pub fn config_from(var: impl Fn(&str) -> Option<String>) -> Option<LogsExportConfig> {
if !var("OTEL_LOGS_EXPORTER").is_some_and(|v| v.split(',').any(|e| e.trim().eq_ignore_ascii_case("otlp"))) {
return None;
}
let endpoint = var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
.or_else(|| var("OTEL_EXPORTER_OTLP_ENDPOINT").map(|base| format!("{}/v1/logs", base.trim_end_matches('/'))))
.unwrap_or_else(|| "http://localhost:4318/v1/logs".to_string());
let interval = var("OTEL_BLRP_SCHEDULE_DELAY").and_then(|v| v.parse().ok()).unwrap_or(1000);
let headers = var("OTEL_EXPORTER_OTLP_LOGS_HEADERS").or_else(|| var("OTEL_EXPORTER_OTLP_HEADERS")).map(|h| super::parse_headers(&h)).unwrap_or_default();
Some(LogsExportConfig {
endpoint,
interval: Duration::from_millis(interval),
service_name: var("OTEL_SERVICE_NAME").unwrap_or_else(|| "rws".to_string()),
service_version: env!("CARGO_PKG_VERSION").to_string(),
headers,
})
}
#[doc(hidden)]
pub fn shutdown() {
if let Some(exporter) = EXPORTER.get() {
if !exporter.stop.swap(true, Ordering::Relaxed) {
exporter.wake.1.notify_all();
export(exporter);
}
}
}
fn export(exporter: &Exporter) {
let _sending = exporter.sending.lock().unwrap_or_else(|e| e.into_inner());
for records in drain() {
let body = body(&records, &exporter.config.service_name, &exporter.config.service_version);
let client = crate::http_client::Client::new().timeout_ms(10_000);
let mut request = client.post(&exporter.config.endpoint).header("Content-Type", "application/json").body(body);
for (name, value) in &exporter.config.headers {
request = request.header(name, value);
}
match request.send() {
Ok(response) if response.is_success() => {}
Ok(response) => eprintln!("OTLP logs export to {}: HTTP {}", exporter.config.endpoint, response.status()),
Err(e) => eprintln!("OTLP logs export to {}: {}", exporter.config.endpoint, e),
}
}
}
#[cfg(test)]
thread_local! {
static TEST_ENABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
fn enable_on_this_thread(on: bool) {
TEST_ENABLED.with(|e| e.set(on));
}
#[cfg(test)]
mod tests;