1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::{Arc, Condvar, Mutex, OnceLock};
23use std::time::Duration;
24
25use crate::request::Request;
26use crate::response::Response;
27use crate::server_config::LogPrivacy;
28
29pub const MAX_PENDING: usize = 1 << 20;
31pub const MAX_BATCH: usize = 4 << 20;
33
34#[doc(hidden)]
38pub const TRACE_HEADER: &str = "x-rws-log-trace";
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct TraceIds {
43 pub trace_id: [u8; 16],
44 pub span_id: [u8; 8],
45 pub sampled: bool,
46}
47
48static ENABLED: AtomicBool = AtomicBool::new(false);
49static DROPPED: AtomicU64 = AtomicU64::new(0);
50
51pub fn enabled() -> bool {
53 #[cfg(test)]
54 if TEST_ENABLED.with(|e| e.get()) {
55 return true;
56 }
57 ENABLED.load(Ordering::Relaxed)
58}
59
60pub fn dropped() -> u64 {
62 DROPPED.load(Ordering::Relaxed)
63}
64
65fn hex(bytes: &[u8], out: &mut String) {
66 const HEX: &[u8; 16] = b"0123456789abcdef";
67 for b in bytes {
68 out.push(HEX[(b >> 4) as usize] as char);
69 out.push(HEX[(b & 15) as usize] as char);
70 }
71}
72
73fn unhex<const N: usize>(text: &str) -> Option<[u8; N]> {
74 if text.len() != N * 2 {
75 return None;
76 }
77 let mut out = [0u8; N];
78 for (i, byte) in out.iter_mut().enumerate() {
79 *byte = u8::from_str_radix(text.get(i * 2..i * 2 + 2)?, 16).ok()?;
80 }
81 Some(out)
82}
83
84#[doc(hidden)]
86pub fn put_trace(response: &mut Response, ids: TraceIds) {
87 if !enabled() {
88 return;
89 }
90 let mut value = String::with_capacity(52);
91 hex(&ids.trace_id, &mut value);
92 value.push('-');
93 hex(&ids.span_id, &mut value);
94 value.push_str(if ids.sampled { "-01" } else { "-00" });
95 response.headers.push(crate::header::Header::new(TRACE_HEADER, value));
96}
97
98pub fn take_trace(response: &mut Response) -> Option<TraceIds> {
101 if !enabled() {
102 return None;
103 }
104 let at = response.headers.iter().position(|h| h.name.eq_ignore_ascii_case(TRACE_HEADER))?;
105 let header = response.headers.remove(at);
106 parse_trace(&header.value)
107}
108
109fn parse_trace(value: &str) -> Option<TraceIds> {
110 let mut parts = value.split('-');
111 let trace_id = unhex::<16>(parts.next()?)?;
112 let span_id = unhex::<8>(parts.next()?)?;
113 let sampled = parts.next()? == "01";
114 Some(TraceIds { trace_id, span_id, sampled })
115}
116
117type Shard = Arc<Mutex<Vec<u8>>>;
120
121fn registry() -> &'static Mutex<Vec<Shard>> {
122 static REGISTRY: OnceLock<Mutex<Vec<Shard>>> = OnceLock::new();
123 REGISTRY.get_or_init(|| Mutex::new(Vec::new()))
124}
125
126fn orphans() -> &'static Mutex<Vec<u8>> {
128 static ORPHANS: Mutex<Vec<u8>> = Mutex::new(Vec::new());
129 &ORPHANS
130}
131
132struct ShardHandle(Shard);
133
134impl Drop for ShardHandle {
135 fn drop(&mut self) {
136 let pending = std::mem::take(&mut *self.0.lock().unwrap_or_else(|e| e.into_inner()));
137 append(&mut orphans().lock().unwrap_or_else(|e| e.into_inner()), &pending);
138 registry().lock().unwrap_or_else(|e| e.into_inner()).retain(|s| !Arc::ptr_eq(s, &self.0));
139 }
140}
141
142thread_local! {
143 static SHARD: ShardHandle = {
144 let shard: Shard = Arc::new(Mutex::new(Vec::with_capacity(4096)));
145 registry().lock().unwrap_or_else(|e| e.into_inner()).push(Arc::clone(&shard));
146 ShardHandle(shard)
147 };
148 static SCRATCH: std::cell::RefCell<Vec<u8>> = const { std::cell::RefCell::new(Vec::new()) };
149}
150
151fn append(buf: &mut Vec<u8>, records: &[u8]) {
154 if records.is_empty() {
155 return;
156 }
157 if buf.len() + records.len() > MAX_PENDING {
158 DROPPED.fetch_add(1 + memchr::memmem::find_iter(records, b"},{\"timeUnixNano\"").count() as u64, Ordering::Relaxed);
159 return;
160 }
161 if !buf.is_empty() {
162 buf.push(b',');
163 }
164 buf.extend_from_slice(records);
165}
166
167pub fn record(
170 request: &Request,
171 response: &Response,
172 client: std::net::IpAddr,
173 privacy: &LogPrivacy,
174 trace: Option<TraceIds>,
175 write_line: impl FnOnce(&mut Vec<u8>),
176) {
177 if !enabled() {
178 return;
179 }
180 let mut write_line = Some(write_line);
181 let mut build = |out: &mut Vec<u8>, line: &mut Vec<u8>| {
182 line.clear();
183 if let Some(write_line) = write_line.take() {
184 write_line(line);
185 }
186 write_record(out, request, response, client, privacy, trace, line, super::now_ns());
187 };
188 let done = SHARD.try_with(|handle| {
189 SCRATCH.with(|scratch| {
190 let mut record = Vec::with_capacity(512);
191 build(&mut record, &mut scratch.borrow_mut());
192 let mut buf = handle.0.lock().unwrap_or_else(|e| e.into_inner());
193 append(&mut buf, &record);
194 })
195 });
196 if done.is_err() {
197 let (mut record, mut line) = (Vec::new(), Vec::new());
199 build(&mut record, &mut line);
200 append(&mut orphans().lock().unwrap_or_else(|e| e.into_inner()), &record);
201 }
202}
203
204#[allow(clippy::too_many_arguments)]
206fn write_record(
207 out: &mut Vec<u8>,
208 request: &Request,
209 response: &Response,
210 client: std::net::IpAddr,
211 privacy: &LogPrivacy,
212 trace: Option<TraceIds>,
213 line: &[u8],
214 now_ns: u64,
215) {
216 use std::io::Write;
217 let (number, text) = match response.status_code {
218 500.. => (17, "ERROR"),
219 400.. => (13, "WARN"),
220 _ => (9, "INFO"),
221 };
222 let _ = write!(
223 out,
224 "{{\"timeUnixNano\":\"{now_ns}\",\"observedTimeUnixNano\":\"{now_ns}\",\"severityNumber\":{number},\"severityText\":\"{text}\",\"body\":{{\"stringValue\":\""
225 );
226 escape_into(out, line);
227 out.extend_from_slice(b"\"},\"attributes\":[");
228 string_attr(out, "http.request.method", &request.method);
229 out.push(b',');
230 let path = request.request_uri.split(['?', '#']).next().unwrap_or("");
231 string_attr(out, "url.path", path);
232 let _ = write!(out, ",{{\"key\":\"http.response.status_code\",\"value\":{{\"intValue\":\"{}\"}}}},", response.status_code);
233 string_attr(out, "client.address", &privacy.ip_string(client));
234 out.push(b',');
235 let version = request.http_version.strip_prefix("HTTP/").unwrap_or(&request.http_version);
236 string_attr(out, "network.protocol.version", version);
237 out.push(b']');
238 if let Some(ids) = trace {
239 let mut trace_hex = String::with_capacity(32);
240 hex(&ids.trace_id, &mut trace_hex);
241 let mut span_hex = String::with_capacity(16);
242 hex(&ids.span_id, &mut span_hex);
243 let _ = write!(out, ",\"traceId\":\"{trace_hex}\",\"spanId\":\"{span_hex}\",\"flags\":{}", ids.sampled as u8);
244 }
245 out.push(b'}');
246}
247
248fn string_attr(out: &mut Vec<u8>, key: &str, value: &str) {
249 out.extend_from_slice(b"{\"key\":\"");
250 out.extend_from_slice(key.as_bytes());
251 out.extend_from_slice(b"\",\"value\":{\"stringValue\":\"");
252 escape_into(out, value.as_bytes());
253 out.extend_from_slice(b"\"}}");
254}
255
256fn escape_into(out: &mut Vec<u8>, bytes: &[u8]) {
259 use std::io::Write;
260 for c in String::from_utf8_lossy(bytes).chars() {
261 match c {
262 '"' => out.extend_from_slice(b"\\\""),
263 '\\' => out.extend_from_slice(b"\\\\"),
264 '\n' => out.extend_from_slice(b"\\n"),
265 '\r' => out.extend_from_slice(b"\\r"),
266 '\t' => out.extend_from_slice(b"\\t"),
267 c if (c as u32) < 0x20 => {
268 let _ = write!(out, "\\u{:04x}", c as u32);
269 }
270 c => {
271 let mut utf8 = [0u8; 4];
272 out.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes());
273 }
274 }
275 }
276}
277
278#[doc(hidden)]
281pub fn drain() -> Vec<Vec<u8>> {
282 let shards: Vec<Shard> = registry().lock().unwrap_or_else(|e| e.into_inner()).clone();
283 let mut pieces = vec![std::mem::take(&mut *orphans().lock().unwrap_or_else(|e| e.into_inner()))];
284 for shard in shards {
285 pieces.push(std::mem::take(&mut *shard.lock().unwrap_or_else(|e| e.into_inner())));
286 }
287 let mut batches: Vec<Vec<u8>> = Vec::new();
288 for piece in pieces.into_iter().filter(|p| !p.is_empty()) {
289 match batches.last_mut() {
290 Some(batch) if batch.len() + piece.len() < MAX_BATCH => {
291 batch.push(b',');
292 batch.extend_from_slice(&piece);
293 }
294 _ => batches.push(piece),
295 }
296 }
297 batches
298}
299
300pub fn body(records: &[u8], service_name: &str, service_version: &str) -> Vec<u8> {
303 let mut out = Vec::with_capacity(records.len() + 256);
304 out.extend_from_slice(b"{\"resourceLogs\":[{\"resource\":{\"attributes\":[");
305 string_attr(&mut out, "service.name", service_name);
306 out.push(b',');
307 string_attr(&mut out, "service.version", service_version);
308 out.extend_from_slice(b"]},\"scopeLogs\":[{\"scope\":{\"name\":\"rust-web-server\",\"version\":\"");
309 out.extend_from_slice(env!("CARGO_PKG_VERSION").as_bytes());
310 out.extend_from_slice(b"\"},\"logRecords\":[");
311 out.extend_from_slice(records);
312 out.extend_from_slice(b"]}]}]}");
313 out
314}
315
316#[derive(Clone, Debug)]
320pub struct LogsExportConfig {
321 pub endpoint: String,
323 pub interval: Duration,
325 pub service_name: String,
327 pub service_version: String,
329 pub headers: Vec<(String, String)>,
331}
332
333struct Exporter {
334 config: LogsExportConfig,
335 stop: AtomicBool,
336 wake: (Mutex<()>, Condvar),
337 sending: Mutex<()>,
339}
340
341static EXPORTER: OnceLock<Exporter> = OnceLock::new();
342
343pub fn start(config: LogsExportConfig) {
345 let interval = config.interval.max(Duration::from_millis(100));
346 let exporter = Exporter { config, stop: AtomicBool::new(false), wake: (Mutex::new(()), Condvar::new()), sending: Mutex::new(()) };
347 if EXPORTER.set(exporter).is_err() {
348 return;
349 }
350 ENABLED.store(true, Ordering::SeqCst);
351 let _ = std::thread::Builder::new().name("rws-otlp-logs".to_string()).spawn(move || {
352 let exporter = EXPORTER.get().expect("set above");
353 loop {
354 let guard = exporter.wake.0.lock().unwrap_or_else(|e| e.into_inner());
355 let _ = exporter.wake.1.wait_timeout(guard, interval);
356 if exporter.stop.load(Ordering::Relaxed) {
357 return;
358 }
359 export(exporter);
360 }
361 });
362}
363
364pub fn start_from_env() -> bool {
376 let var = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
377 let Some(config) = config_from(var) else {
378 return false;
379 };
380 start(config);
381 true
382}
383
384#[doc(hidden)]
386pub fn config_from(var: impl Fn(&str) -> Option<String>) -> Option<LogsExportConfig> {
387 if !var("OTEL_LOGS_EXPORTER").is_some_and(|v| v.split(',').any(|e| e.trim().eq_ignore_ascii_case("otlp"))) {
388 return None;
389 }
390 let endpoint = var("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT")
391 .or_else(|| var("OTEL_EXPORTER_OTLP_ENDPOINT").map(|base| format!("{}/v1/logs", base.trim_end_matches('/'))))
392 .unwrap_or_else(|| "http://localhost:4318/v1/logs".to_string());
393 let interval = var("OTEL_BLRP_SCHEDULE_DELAY").and_then(|v| v.parse().ok()).unwrap_or(1000);
394 let headers = var("OTEL_EXPORTER_OTLP_LOGS_HEADERS").or_else(|| var("OTEL_EXPORTER_OTLP_HEADERS")).map(|h| super::parse_headers(&h)).unwrap_or_default();
395 Some(LogsExportConfig {
396 endpoint,
397 interval: Duration::from_millis(interval),
398 service_name: var("OTEL_SERVICE_NAME").unwrap_or_else(|| "rws".to_string()),
399 service_version: env!("CARGO_PKG_VERSION").to_string(),
400 headers,
401 })
402}
403
404#[doc(hidden)]
407pub fn shutdown() {
408 if let Some(exporter) = EXPORTER.get() {
409 if !exporter.stop.swap(true, Ordering::Relaxed) {
410 exporter.wake.1.notify_all();
411 export(exporter);
412 }
413 }
414}
415
416fn export(exporter: &Exporter) {
417 let _sending = exporter.sending.lock().unwrap_or_else(|e| e.into_inner());
418 for records in drain() {
419 let body = body(&records, &exporter.config.service_name, &exporter.config.service_version);
420 let client = crate::http_client::Client::new().timeout_ms(10_000);
421 let mut request = client.post(&exporter.config.endpoint).header("Content-Type", "application/json").body(body);
422 for (name, value) in &exporter.config.headers {
423 request = request.header(name, value);
424 }
425 match request.send() {
426 Ok(response) if response.is_success() => {}
427 Ok(response) => eprintln!("OTLP logs export to {}: HTTP {}", exporter.config.endpoint, response.status()),
428 Err(e) => eprintln!("OTLP logs export to {}: {}", exporter.config.endpoint, e),
429 }
430 }
431}
432
433#[cfg(test)]
434thread_local! {
435 static TEST_ENABLED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
438}
439
440#[cfg(test)]
441fn enable_on_this_thread(on: bool) {
442 TEST_ENABLED.with(|e| e.set(on));
443}
444
445#[cfg(test)]
446mod tests;