Skip to main content

rust_web_server/otel/
mod.rs

1//! OpenTelemetry-compatible distributed tracing.
2//!
3//! [`OtelLayer`] is a [`Middleware`] that:
4//! 1. Reads the W3C `traceparent` header from incoming requests and continues
5//!    an existing trace, or starts a fresh one.
6//! 2. Creates an HTTP server span with standard semantic attributes.
7//! 3. Stores the active span context in thread-local storage so downstream
8//!    middleware (e.g. [`crate::proxy::ReverseProxy`]) can propagate it.
9//! 4. Records the completed span to a configurable exporter.
10//!
11//! # Quick start
12//!
13//! ```rust,no_run
14//! use rust_web_server::app::App;
15//! use rust_web_server::core::New;
16//! use rust_web_server::otel::{OtelLayer, TracingConfig, ExporterConfig};
17//!
18//! // Dev: print spans to stdout.
19//! rust_web_server::otel::setup(TracingConfig {
20//!     service_name: "my-service".to_string(),
21//!     service_version: env!("CARGO_PKG_VERSION").to_string(),
22//!     exporter: ExporterConfig::Stdout,
23//!     sample_rate: 1.0,
24//!     batch_size: 128,
25//! });
26//!
27//! let app = App::new().wrap(OtelLayer);
28//! ```
29//!
30//! # Production: OTLP HTTP export
31//!
32//! ```rust,no_run
33//! use rust_web_server::otel::{ExporterConfig, TracingConfig};
34//!
35//! rust_web_server::otel::setup(TracingConfig {
36//!     service_name: "my-service".to_string(),
37//!     service_version: "1.0.0".to_string(),
38//!     exporter: ExporterConfig::Otlp {
39//!         endpoint: "http://localhost:4318".to_string(),
40//!     },
41//!     sample_rate: 0.1,
42//!     batch_size: 512,
43//! });
44//! ```
45//!
46//! Alternatively, set environment variables before calling [`setup_from_env`]:
47//!
48//! ```text
49//! OTEL_SERVICE_NAME=my-service
50//! OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
51//! OTEL_TRACES_SAMPLER_ARG=0.1    # sample rate 0.0–1.0 (default 1.0)
52//! ```
53
54#[cfg(test)]
55mod tests;
56
57pub mod baggage;
58pub mod metrics;
59
60pub use baggage::Baggage;
61
62use std::cell::{Cell, RefCell};
63use std::sync::atomic::{AtomicBool, Ordering};
64use std::sync::{Mutex, OnceLock};
65use std::time::{SystemTime, UNIX_EPOCH};
66
67use crate::application::Application;
68use crate::mcp::json_escape;
69use crate::middleware::Middleware;
70use crate::request::Request;
71use crate::response::Response;
72use crate::server::ConnectionInfo;
73
74// ── ID generation ─────────────────────────────────────────────────────────────
75
76/// 64 random bits: splitmix64 over a per-thread state seeded from the
77/// standard library's OS-random hash keys. Not for secrets — IDs only have to
78/// be unique across every service in a trace, which a counter wasn't (two rws
79/// instances minted the same span IDs).
80fn random_u64() -> u64 {
81    thread_local! {
82        static STATE: Cell<u64> = Cell::new({
83            use std::hash::{BuildHasher, Hasher};
84            let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
85            hasher.write_u128(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos());
86            hasher.finish()
87        });
88    }
89    STATE.with(|state| {
90        let x = state.get().wrapping_add(0x9E37_79B9_7F4A_7C15);
91        state.set(x);
92        let mut z = x;
93        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
94        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
95        z ^ (z >> 31)
96    })
97}
98
99/// The head-sampling decision for a new trace: `roll` (random) lands below
100/// `rate` in parts per ten thousand.
101fn sampled(roll: u64, rate: f64) -> bool {
102    if rate >= 1.0 {
103        return true;
104    }
105    if rate <= 0.0 {
106        return false;
107    }
108    roll % 10_000 < (rate * 10_000.0) as u64
109}
110
111/// Generate a new random 128-bit trace ID (never all zeros).
112pub fn new_trace_id() -> [u8; 16] {
113    let mut id = [0u8; 16];
114    id[..8].copy_from_slice(&random_u64().to_be_bytes());
115    id[8..].copy_from_slice(&(random_u64() | 1).to_be_bytes());
116    id
117}
118
119/// Generate a new random 64-bit span ID (never all zeros).
120pub fn new_span_id() -> [u8; 8] {
121    (random_u64() | 1).to_be_bytes()
122}
123
124fn hex16(b: &[u8; 16]) -> String {
125    b.iter().map(|x| format!("{:02x}", x)).collect()
126}
127
128fn hex8(b: &[u8; 8]) -> String {
129    b.iter().map(|x| format!("{:02x}", x)).collect()
130}
131
132// ── W3C Trace Context ─────────────────────────────────────────────────────────
133
134/// Parsed W3C `traceparent` header value.
135///
136/// Format: `00-{trace-id}-{parent-id}-{flags}`
137#[derive(Copy, Clone, Debug, PartialEq, Eq)]
138pub struct TraceContext {
139    pub trace_id: [u8; 16],
140    pub parent_span_id: [u8; 8],
141    pub sampled: bool,
142}
143
144impl TraceContext {
145    /// Parse a `traceparent` header value.
146    pub fn parse(header: &str) -> Option<Self> {
147        let parts: Vec<&str> = header.trim().splitn(4, '-').collect();
148        if parts.len() != 4 || parts[0] != "00" {
149            return None;
150        }
151        let trace_id = parse_hex16(parts[1])?;
152        let parent_span_id = parse_hex8(parts[2])?;
153        let flags = u8::from_str_radix(parts[3], 16).ok()?;
154        Some(TraceContext { trace_id, parent_span_id, sampled: flags & 0x01 != 0 })
155    }
156
157    /// Render as a `traceparent` header value for this context acting as parent.
158    pub fn as_header(&self, span_id: &[u8; 8]) -> String {
159        format!("00-{}-{}-{:02x}", hex16(&self.trace_id), hex8(span_id), self.sampled as u8)
160    }
161}
162
163fn parse_hex16(s: &str) -> Option<[u8; 16]> {
164    if s.len() != 32 { return None; }
165    let mut out = [0u8; 16];
166    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
167        out[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
168    }
169    Some(out)
170}
171
172fn parse_hex8(s: &str) -> Option<[u8; 8]> {
173    if s.len() != 16 { return None; }
174    let mut out = [0u8; 8];
175    for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
176        out[i] = u8::from_str_radix(std::str::from_utf8(chunk).ok()?, 16).ok()?;
177    }
178    Some(out)
179}
180
181// ── span kind / attributes ───────────────────────────────────────────────────
182
183/// OTLP `SpanKind`. Numbering matches the OTLP spec (`INTERNAL`=1, `SERVER`=2,
184/// `CLIENT`=3) so the numeric value can be cast directly with `as i32` when
185/// building OTLP JSON. `PRODUCER`/`CONSUMER` are intentionally not exposed —
186/// nothing in this crate does message-queue instrumentation yet.
187#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
188pub enum SpanKind {
189    /// Internal work with no remote counterpart — DB query, cache lookup,
190    /// business logic. The default for [`span`].
191    #[default]
192    Internal = 1,
193    /// The span created by [`OtelLayer`] for an incoming HTTP request.
194    Server = 2,
195    /// An outbound call to another service. The kind for [`client_span`].
196    Client = 3,
197}
198
199/// A span attribute value — matches OTLP `AnyValue`'s basic variants.
200#[derive(Clone, Debug, PartialEq)]
201pub enum AttributeValue {
202    String(String),
203    Int(i64),
204    Float(f64),
205    Bool(bool),
206}
207
208impl From<&str> for AttributeValue {
209    fn from(s: &str) -> Self { AttributeValue::String(s.to_string()) }
210}
211impl From<String> for AttributeValue {
212    fn from(s: String) -> Self { AttributeValue::String(s) }
213}
214impl From<i64> for AttributeValue {
215    fn from(v: i64) -> Self { AttributeValue::Int(v) }
216}
217impl From<i32> for AttributeValue {
218    fn from(v: i32) -> Self { AttributeValue::Int(v as i64) }
219}
220impl From<u32> for AttributeValue {
221    fn from(v: u32) -> Self { AttributeValue::Int(v as i64) }
222}
223impl From<f64> for AttributeValue {
224    fn from(v: f64) -> Self { AttributeValue::Float(v) }
225}
226impl From<bool> for AttributeValue {
227    fn from(v: bool) -> Self { AttributeValue::Bool(v) }
228}
229
230// ── span data ─────────────────────────────────────────────────────────────────
231
232/// A completed span ready for export.
233#[derive(Debug, Clone, Default)]
234pub struct SpanData {
235    pub trace_id: [u8; 16],
236    pub span_id: [u8; 8],
237    pub parent_span_id: Option<[u8; 8]>,
238    /// `"GET /api/users"` — method + path, query stripped.
239    pub name: String,
240    pub start_ns: u64,
241    pub end_ns: u64,
242    /// Empty for a non-HTTP span (e.g. a `db.query` child span) — exporters
243    /// omit the `http.*` attributes entirely in that case.
244    pub http_method: String,
245    pub http_target: String,
246    pub http_status: i16,
247    /// 0=Unset, 1=Ok, 2=Error
248    pub status_code: u8,
249    pub kind: SpanKind,
250    /// Extra key/value attributes beyond the first-class `http.*` fields —
251    /// e.g. `db.statement`, `cache.key`, or any custom attribute set via
252    /// [`Span::set_attribute`].
253    pub attributes: Vec<(String, AttributeValue)>,
254}
255
256impl SpanData {
257    fn duration_ms(&self) -> f64 {
258        (self.end_ns.saturating_sub(self.start_ns)) as f64 / 1_000_000.0
259    }
260}
261
262fn now_ns() -> u64 {
263    SystemTime::now()
264        .duration_since(UNIX_EPOCH)
265        .unwrap_or_default()
266        .as_nanos() as u64
267}
268
269fn strip_query(uri: &str) -> &str {
270    match uri.find('?') {
271        Some(i) => &uri[..i],
272        None => uri,
273    }
274}
275
276// ── thread-local span stack ─────────────────────────────────────────────────
277
278/// Compact span context stored on the thread-local stack for downstream
279/// propagation and parent/child nesting.
280#[derive(Clone)]
281struct ActiveSpanCtx {
282    trace_id: [u8; 16],
283    span_id: [u8; 8],
284    sampled: bool,
285    /// The request's W3C baggage, shared by its spans (children inherit it).
286    baggage: Option<std::sync::Arc<Baggage>>,
287}
288
289thread_local! {
290    /// One entry per currently-open [`Span`] on this thread, innermost last.
291    /// A single-slot cell can't represent nesting (span A active, starts
292    /// span B, B is now "current", B ends, A is "current" again) — this
293    /// stack is what makes multiple nested spans per request possible.
294    static ACTIVE_STACK: RefCell<Vec<ActiveSpanCtx>> = const { RefCell::new(Vec::new()) };
295}
296
297/// Return the W3C `traceparent` value for the innermost span currently being
298/// processed on this thread (the deepest active [`Span`], not necessarily the
299/// request root). Returns `None` when no span is active.
300///
301/// Use this to propagate trace context into outbound calls made from within
302/// a handler or a child span.
303pub fn current_traceparent() -> Option<String> {
304    ACTIVE_STACK.with(|stack| {
305        stack.borrow().last().map(|s| {
306            format!(
307                "00-{}-{}-{:02x}",
308                hex16(&s.trace_id),
309                hex8(&s.span_id),
310                s.sampled as u8,
311            )
312        })
313    })
314}
315
316// ── Span ─────────────────────────────────────────────────────────────────────
317
318/// A single open span. Create one with [`span`] or [`client_span`] (or
319/// [`Span::new`] for full control over [`SpanKind`]); it becomes the
320/// "current" span on this thread until it's dropped (or [`Span::end`] is
321/// called explicitly, which is equivalent).
322///
323/// Nesting: creating a `Span` while another is already active makes the new
324/// one its child — the child inherits the parent's `trace_id` and sampling
325/// decision, and `parent_span_id()` is the parent's `span_id()`. Dropping the
326/// child makes the parent "current" again.
327///
328/// Not `Send`: a `Span` must be dropped on the thread that created it, since
329/// dropping pops a thread-local stack — moving one to another thread and
330/// dropping it there would corrupt that thread's stack.
331///
332/// # Example
333///
334/// ```rust
335/// use rust_web_server::otel;
336///
337/// let span = otel::span("db.query");
338/// span.set_attribute("db.statement", "SELECT 1");
339/// // ... do the work ...
340/// span.end(); // or just let it drop at the end of scope
341/// ```
342pub struct Span {
343    trace_id: [u8; 16],
344    span_id: [u8; 8],
345    parent_span_id: Option<[u8; 8]>,
346    sampled: bool,
347    kind: SpanKind,
348    name: String,
349    start_ns: u64,
350    attributes: RefCell<Vec<(String, AttributeValue)>>,
351    status_code: Cell<u8>,
352    http_method: RefCell<Option<String>>,
353    http_target: RefCell<Option<String>>,
354    http_status: Cell<Option<i16>>,
355    // Makes `Span` `!Send` (a `Rc` is never `Send`) without requiring
356    // unstable negative impls. See the "Not `Send`" note above.
357    _not_send: std::marker::PhantomData<std::rc::Rc<()>>,
358}
359
360fn new_span(name: &str, kind: SpanKind, incoming: Option<TraceContext>) -> Span {
361    new_span_with_baggage(name, kind, incoming, None)
362}
363
364fn new_span_with_baggage(name: &str, kind: SpanKind, incoming: Option<TraceContext>, incoming_baggage: Option<Baggage>) -> Span {
365    let mut baggage = incoming_baggage.filter(|b| !b.is_empty()).map(std::sync::Arc::new);
366    let (trace_id, parent_span_id, sampled) = ACTIVE_STACK.with(|stack| {
367        let stack = stack.borrow();
368        if let Some(top) = stack.last() {
369            // Nested child: inherit the trace and the parent's sampling
370            // decision — resampling independently per child would produce
371            // broken/partial traces whenever a child's own coin-flip
372            // disagreed with its parent's.
373            if baggage.is_none() {
374                baggage = top.baggage.clone();
375            }
376            (top.trace_id, Some(top.span_id), top.sampled)
377        } else {
378            // Root-ish: no active parent on this thread, so make a fresh
379            // sampling decision, exactly like `OtelLayer` always has.
380            let sampled = tracer().map(|t| t.should_sample()).unwrap_or(false);
381            match incoming {
382                Some(ctx) => (ctx.trace_id, Some(ctx.parent_span_id), sampled),
383                None => (new_trace_id(), None, sampled),
384            }
385        }
386    });
387    let span_id = new_span_id();
388    ACTIVE_STACK.with(|stack| stack.borrow_mut().push(ActiveSpanCtx { trace_id, span_id, sampled, baggage }));
389
390    Span {
391        trace_id,
392        span_id,
393        parent_span_id,
394        sampled,
395        kind,
396        name: name.to_string(),
397        start_ns: now_ns(),
398        attributes: RefCell::new(Vec::new()),
399        status_code: Cell::new(0),
400        http_method: RefCell::new(None),
401        http_target: RefCell::new(None),
402        http_status: Cell::new(None),
403        _not_send: std::marker::PhantomData,
404    }
405}
406
407impl Span {
408    /// Start a new span with an explicit [`SpanKind`]. Prefer [`span`] or
409    /// [`client_span`] for the common cases.
410    pub fn new(name: &str, kind: SpanKind) -> Span {
411        new_span(name, kind, None)
412    }
413
414    /// Used only by [`OtelLayer`] to start the request's root span, honoring
415    /// an incoming W3C `traceparent` header if present.
416    pub(crate) fn start_root(name: &str, kind: SpanKind, incoming: Option<TraceContext>, baggage: Option<Baggage>) -> Span {
417        new_span_with_baggage(name, kind, incoming, baggage)
418    }
419
420    /// Attach a key/value attribute. Repeated keys are appended, not
421    /// deduplicated — matching this module's existing "loosely OTLP" style.
422    pub fn set_attribute(&self, key: &str, value: impl Into<AttributeValue>) {
423        self.attributes.borrow_mut().push((key.to_string(), value.into()));
424    }
425
426    /// Mark this span as failed (OTLP `Status.code = 2`, Error).
427    pub fn set_error(&self) {
428        self.status_code.set(2);
429    }
430
431    /// Mark this span as failed and attach an `error.message` attribute.
432    pub fn record_error(&self, message: &str) {
433        self.set_error();
434        self.set_attribute("error.message", message);
435    }
436
437    pub fn trace_id(&self) -> [u8; 16] {
438        self.trace_id
439    }
440
441    pub fn span_id(&self) -> [u8; 8] {
442        self.span_id
443    }
444
445    pub fn parent_span_id(&self) -> Option<[u8; 8]> {
446        self.parent_span_id
447    }
448
449    /// End the span now. Equivalent to letting it drop at the end of scope —
450    /// both run the exact same recording logic — but useful when you want
451    /// the span's duration to stop before other work continues in the same
452    /// scope.
453    pub fn end(self) {}
454
455    pub(crate) fn set_http(&self, method: &str, target: &str) {
456        *self.http_method.borrow_mut() = Some(method.to_string());
457        *self.http_target.borrow_mut() = Some(target.to_string());
458    }
459
460    /// Also marks the span as an error when `status >= 500`.
461    pub(crate) fn set_http_status(&self, status: i16) {
462        self.http_status.set(Some(status));
463        if status >= 500 {
464            self.set_error();
465        }
466    }
467
468    fn finish(&mut self, end_ns: u64) -> SpanData {
469        SpanData {
470            trace_id: self.trace_id,
471            span_id: self.span_id,
472            parent_span_id: self.parent_span_id,
473            name: std::mem::take(&mut self.name),
474            start_ns: self.start_ns,
475            end_ns,
476            http_method: self.http_method.get_mut().take().unwrap_or_default(),
477            http_target: self.http_target.get_mut().take().unwrap_or_default(),
478            http_status: self.http_status.get().unwrap_or(0),
479            status_code: self.status_code.get(),
480            kind: self.kind,
481            attributes: std::mem::take(self.attributes.get_mut()),
482        }
483    }
484}
485
486impl Drop for Span {
487    fn drop(&mut self) {
488        ACTIVE_STACK.with(|stack| {
489            let mut stack = stack.borrow_mut();
490            // Pop this span and (defensively) anything still above it, in
491            // case a descendant was somehow leaked without being dropped.
492            if let Some(pos) = stack.iter().rposition(|c| c.span_id == self.span_id) {
493                stack.truncate(pos);
494            }
495        });
496
497        if !self.sampled {
498            return;
499        }
500        let Some(t) = tracer() else { return };
501        let end_ns = now_ns();
502        t.record(self.finish(end_ns));
503    }
504}
505
506/// Start a new [`SpanKind::Internal`] child span nested under the currently
507/// active span (or a fresh trace if none is active). Use for internal work
508/// like a database query or cache lookup.
509///
510/// ```rust
511/// use rust_web_server::otel;
512///
513/// let span = otel::span("db.query");
514/// span.set_attribute("db.statement", "SELECT 1");
515/// ```
516pub fn span(name: &str) -> Span {
517    Span::new(name, SpanKind::Internal)
518}
519
520/// Start a new [`SpanKind::Client`] child span for an outbound call to
521/// another service (an HTTP request, a gRPC call, ...).
522pub fn client_span(name: &str) -> Span {
523    Span::new(name, SpanKind::Client)
524}
525
526/// Whether a span is active on this thread (and a tracer is set up).
527pub fn is_tracing() -> bool {
528    tracer().is_some() && ACTIVE_STACK.with(|stack| !stack.borrow().is_empty())
529}
530
531/// A client span for an outbound call rws makes itself (HTTP client, proxy
532/// forward) — only inside a traced request: it never starts a trace of its own.
533pub(crate) fn outbound_span(name: &str) -> Option<Span> {
534    is_tracing().then(|| client_span(name))
535}
536
537/// Semantic-convention attributes of an HTTP client span for `method` and
538/// `url` (`url.full` without query or user info, `server.address`,
539/// `server.port`).
540pub(crate) fn http_client_attributes(method: &str, url: &str) -> Vec<(&'static str, AttributeValue)> {
541    let (scheme, rest) = url.split_once("://").unwrap_or(("http", url));
542    let rest = rest.split(['?', '#']).next().unwrap_or("");
543    let rest = match rest.find('@') {
544        Some(at) if rest.find('/').is_none_or(|slash| at < slash) => &rest[at + 1..],
545        _ => rest,
546    };
547    let authority = rest.split('/').next().unwrap_or("");
548    let default_port = if scheme.eq_ignore_ascii_case("https") { 443 } else { 80 };
549    let (host, port) = match authority.rsplit_once(':') {
550        Some((host, port)) if !host.ends_with(']') || authority.starts_with('[') => match port.parse::<i64>() {
551            Ok(port) => (host, port),
552            Err(_) => (authority, default_port),
553        },
554        _ => (authority, default_port),
555    };
556    vec![
557        ("http.request.method", AttributeValue::from(method)),
558        ("url.full", AttributeValue::from(format!("{scheme}://{rest}"))),
559        ("server.address", AttributeValue::from(host.trim_start_matches('[').trim_end_matches(']'))),
560        ("server.port", AttributeValue::Int(port)),
561    ]
562}
563
564/// What an HTTP client span records about the outcome: the status, and an
565/// error for 4xx/5xx or no response (the client-span convention).
566pub(crate) fn http_client_outcome(status: Result<u16, &str>) -> (Option<i64>, Option<String>) {
567    match status {
568        Ok(status) => (Some(status as i64), (status >= 400).then(|| status.to_string())),
569        Err(message) => (None, Some(message.to_string())),
570    }
571}
572
573impl Span {
574    /// Record [`http_client_outcome`] on this span.
575    pub(crate) fn finish_http_client(&self, status: Result<u16, &str>) {
576        let (status, error) = http_client_outcome(status);
577        if let Some(status) = status {
578            self.set_attribute("http.response.status_code", status);
579        }
580        if let Some(error) = error {
581            self.set_attribute("error.type", error.as_str());
582            self.set_error();
583        }
584    }
585}
586
587// ── baggage and context ──────────────────────────────────────────────────────
588
589/// The current request's W3C baggage (empty outside a traced request).
590pub fn current_baggage() -> Baggage {
591    ACTIVE_STACK.with(|stack| stack.borrow().last().and_then(|top| top.baggage.as_deref().cloned()).unwrap_or_default())
592}
593
594/// Add `key=value` to the current request's baggage, so outbound calls made
595/// from here on carry it (and spans started from here on share it). Returns
596/// `false` outside a traced request or for a key that isn't a token.
597pub fn set_baggage(key: &str, value: &str) -> bool {
598    ACTIVE_STACK.with(|stack| {
599        let mut stack = stack.borrow_mut();
600        let Some(top) = stack.last_mut() else { return false };
601        let mut baggage = top.baggage.as_deref().cloned().unwrap_or_default();
602        if !baggage.set(key, value) {
603            return false;
604        }
605        top.baggage = Some(std::sync::Arc::new(baggage));
606        true
607    })
608}
609
610/// `traceparent` and `baggage` header values for an outbound request made
611/// under the current span; `None` outside a traced request.
612pub fn propagation_headers() -> Option<(String, Option<String>)> {
613    ACTIVE_STACK.with(|stack| {
614        let stack = stack.borrow();
615        let top = stack.last()?;
616        let traceparent = format!("00-{}-{}-{:02x}", hex16(&top.trace_id), hex8(&top.span_id), top.sampled as u8);
617        let baggage = top.baggage.as_deref().filter(|b| !b.is_empty()).map(Baggage::to_header);
618        Some((traceparent, baggage))
619    })
620}
621
622/// Add `traceparent` and `baggage` for the current span to `headers`, unless
623/// the caller set them already.
624pub(crate) fn inject(headers: &mut Vec<(String, String)>) {
625    let Some((traceparent, baggage)) = propagation_headers() else { return };
626    if !headers.iter().any(|(name, _)| name.eq_ignore_ascii_case("traceparent")) {
627        headers.push(("traceparent".to_string(), traceparent));
628    }
629    if let Some(baggage) = baggage {
630        if !headers.iter().any(|(name, _)| name.eq_ignore_ascii_case("baggage")) {
631            headers.push(("baggage".to_string(), baggage));
632        }
633    }
634}
635
636/// The current span context and baggage, captured to carry across threads:
637/// [`Context::attach`] it on another thread (or around an `async` block run
638/// elsewhere) and spans started there become children of the captured span.
639/// rws does this itself for `AsyncAppWithState` handlers.
640#[derive(Clone, Default)]
641pub struct Context {
642    active: Option<ActiveSpanCtx>,
643}
644
645impl Context {
646    /// The innermost active span on this thread (empty if none).
647    pub fn current() -> Context {
648        Context { active: ACTIVE_STACK.with(|stack| stack.borrow().last().cloned()) }
649    }
650
651    /// Whether there's a span in it.
652    pub fn is_empty(&self) -> bool {
653        self.active.is_none()
654    }
655
656    /// Make this context current on this thread until the guard drops.
657    pub fn attach(&self) -> ContextGuard {
658        let span_id = self.active.as_ref().map(|active| {
659            ACTIVE_STACK.with(|stack| stack.borrow_mut().push(active.clone()));
660            active.span_id
661        });
662        ContextGuard { span_id, _not_send: std::marker::PhantomData }
663    }
664}
665
666/// Returned by [`Context::attach`]; restores the previous context on drop.
667/// Must be dropped on the thread that attached it.
668pub struct ContextGuard {
669    span_id: Option<[u8; 8]>,
670    _not_send: std::marker::PhantomData<std::rc::Rc<()>>,
671}
672
673impl Drop for ContextGuard {
674    fn drop(&mut self) {
675        let Some(span_id) = self.span_id else { return };
676        ACTIVE_STACK.with(|stack| {
677            let mut stack = stack.borrow_mut();
678            if let Some(pos) = stack.iter().rposition(|c| c.span_id == span_id) {
679                stack.truncate(pos);
680            }
681        });
682    }
683}
684
685// ── detached spans (async code) ──────────────────────────────────────────────
686
687/// A span for `async` work, which may resume on another thread: it's `Send`,
688/// takes its parent from the context current where it starts, and is never
689/// on the thread-local stack (so spans can't nest under it implicitly —
690/// use [`DetachedSpan::enter`] for synchronous work that should). rws uses
691/// it for database queries and the async HTTP client.
692pub struct DetachedSpan {
693    data: SpanData,
694    sampled: bool,
695    baggage: Option<std::sync::Arc<Baggage>>,
696}
697
698/// Start a [`DetachedSpan`] under the current span; `None` outside a traced
699/// request (it never starts a trace of its own).
700pub fn detached_span(name: &str, kind: SpanKind) -> Option<DetachedSpan> {
701    tracer()?;
702    let parent = ACTIVE_STACK.with(|stack| stack.borrow().last().cloned())?;
703    Some(DetachedSpan {
704        data: SpanData {
705            trace_id: parent.trace_id,
706            span_id: new_span_id(),
707            parent_span_id: Some(parent.span_id),
708            name: name.to_string(),
709            start_ns: now_ns(),
710            kind,
711            ..SpanData::default()
712        },
713        sampled: parent.sampled,
714        baggage: parent.baggage,
715    })
716}
717
718impl DetachedSpan {
719    pub fn set_attribute(&mut self, key: &str, value: impl Into<AttributeValue>) {
720        self.data.attributes.push((key.to_string(), value.into()));
721    }
722
723    /// Mark the span failed with an `error.message`.
724    pub fn record_error(&mut self, message: &str) {
725        self.data.status_code = 2;
726        self.set_attribute("error.message", message);
727    }
728
729    /// Run `work` with this span current on this thread (for header
730    /// propagation or synchronous children).
731    pub fn enter<T>(&self, work: impl FnOnce() -> T) -> T {
732        let context = Context {
733            active: Some(ActiveSpanCtx {
734                trace_id: self.data.trace_id,
735                span_id: self.data.span_id,
736                sampled: self.sampled,
737                baggage: self.baggage.clone(),
738            }),
739        };
740        let _guard = context.attach();
741        work()
742    }
743
744    /// Record [`http_client_outcome`] on this span.
745    pub(crate) fn finish_http_client(&mut self, status: Result<u16, &str>) {
746        let (status, error) = http_client_outcome(status);
747        if let Some(status) = status {
748            self.set_attribute("http.response.status_code", status);
749        }
750        if let Some(error) = error {
751            self.set_attribute("error.type", error.as_str());
752            self.data.status_code = 2;
753        }
754    }
755
756    pub fn end(self) {}
757}
758
759impl Drop for DetachedSpan {
760    fn drop(&mut self) {
761        if !self.sampled {
762            return;
763        }
764        if let Some(tracer) = tracer() {
765            let mut data = std::mem::take(&mut self.data);
766            data.end_ns = now_ns();
767            tracer.record(data);
768        }
769    }
770}
771
772// ── exporter ──────────────────────────────────────────────────────────────────
773
774/// Destination for completed spans.
775pub trait Exporter: Send + Sync {
776    fn export(&self, spans: &[SpanData]);
777    fn shutdown(&self) {}
778}
779
780/// Renders one [`AttributeValue`] as an OTLP `AnyValue` JSON object.
781/// `Int` is string-encoded per the OTLP JSON mapping for `int64`.
782fn attr_value_json(v: &AttributeValue) -> String {
783    match v {
784        AttributeValue::String(s) => format!("{{\"stringValue\":\"{}\"}}", json_escape(s)),
785        AttributeValue::Int(i) => format!("{{\"intValue\":\"{i}\"}}"),
786        AttributeValue::Float(f) => format!("{{\"doubleValue\":{f}}}"),
787        AttributeValue::Bool(b) => format!("{{\"boolValue\":{b}}}"),
788    }
789}
790
791/// Renders one `(key, value)` pair as an OTLP `KeyValue` JSON object.
792fn attr_json(key: &str, value: &AttributeValue) -> String {
793    format!("{{\"key\":\"{}\",\"value\":{}}}", json_escape(key), attr_value_json(value))
794}
795
796/// Print one JSON line per span to stdout. Useful for development and for
797/// piping into `jq` or a log aggregator.
798pub struct StdoutExporter;
799
800impl StdoutExporter {
801    fn format_span(span: &SpanData) -> String {
802        let http_attrs = if span.http_method.is_empty() {
803            String::new()
804        } else {
805            format!(
806                ",\"httpMethod\":\"{}\",\"httpTarget\":\"{}\",\"httpStatus\":{}",
807                span.http_method, span.http_target, span.http_status,
808            )
809        };
810        let extra_attrs: String = span.attributes.iter()
811            .map(|(k, v)| format!(",\"{k}\":{}", attr_value_json(v)))
812            .collect();
813        format!(
814            "{{\"traceId\":\"{}\",\"spanId\":\"{}\",\"parentSpanId\":{},\
815             \"name\":\"{}\",\"kind\":{},\"startNs\":{},\"durationMs\":{:.3}{http_attrs}{extra_attrs}}}",
816            hex16(&span.trace_id),
817            hex8(&span.span_id),
818            span.parent_span_id
819                .as_ref()
820                .map(|p| format!("\"{}\"", hex8(p)))
821                .unwrap_or_else(|| "null".to_string()),
822            span.name,
823            span.kind as i32,
824            span.start_ns,
825            span.duration_ms(),
826        )
827    }
828}
829
830impl Exporter for StdoutExporter {
831    fn export(&self, spans: &[SpanData]) {
832        for span in spans {
833            println!("{}", Self::format_span(span));
834        }
835    }
836}
837
838/// Send spans to an OTLP-compatible collector over HTTP (JSON encoding).
839///
840/// Compatible with Jaeger ≥ 1.35, Grafana Tempo, and OpenTelemetry Collector.
841/// Point at `http://localhost:4318` (the default OTLP HTTP port).
842pub struct OtlpHttpExporter {
843    url: String,
844    headers: Vec<(String, String)>,
845    service_name: String,
846    service_version: String,
847}
848
849impl OtlpHttpExporter {
850    /// `endpoint` is the collector's base URL (`http://localhost:4318`, spans
851    /// go to `/v1/traces` under it) or the full traces URL ending in
852    /// `/v1/traces`. `https://` works too.
853    pub fn new(endpoint: &str, service_name: &str, service_version: &str) -> Self {
854        let endpoint = endpoint.trim_end_matches('/');
855        let url = if endpoint.ends_with("/v1/traces") {
856            endpoint.to_string()
857        } else if endpoint.contains("://") {
858            format!("{endpoint}/v1/traces")
859        } else {
860            format!("http://{endpoint}/v1/traces")
861        };
862        OtlpHttpExporter {
863            url,
864            headers: Vec::new(),
865            service_name: service_name.to_string(),
866            service_version: service_version.to_string(),
867        }
868    }
869
870    /// Send these headers with every export (an API key, for instance).
871    pub fn with_headers(mut self, headers: Vec<(String, String)>) -> Self {
872        self.headers = headers;
873        self
874    }
875
876    /// The URL spans are POSTed to.
877    pub fn url(&self) -> &str {
878        &self.url
879    }
880
881    pub fn build_body(&self, spans: &[SpanData]) -> String {
882        let span_jsons: Vec<String> = spans.iter().map(|s| {
883            let parent = s.parent_span_id
884                .as_ref()
885                .map(|p| format!(",\"parentSpanId\":\"{}\"", hex8(p)))
886                .unwrap_or_default();
887            let status_msg = if s.status_code == 2 { "Error" } else { "Unset" };
888
889            let mut attrs: Vec<String> = Vec::new();
890            if !s.http_method.is_empty() {
891                attrs.push(format!("{{\"key\":\"http.method\",\"value\":{{\"stringValue\":\"{}\"}} }}", json_escape(&s.http_method)));
892                attrs.push(format!("{{\"key\":\"http.target\",\"value\":{{\"stringValue\":\"{}\"}} }}", json_escape(&s.http_target)));
893                attrs.push(format!("{{\"key\":\"http.status_code\",\"value\":{{\"intValue\":\"{}\"}} }}", s.http_status));
894            }
895            attrs.extend(s.attributes.iter().map(|(k, v)| attr_json(k, v)));
896
897            format!(
898                "{{\"traceId\":\"{trace}\",\"spanId\":\"{span}\"{parent},\
899                 \"name\":\"{name}\",\"kind\":{kind},\
900                 \"startTimeUnixNano\":\"{start}\",\"endTimeUnixNano\":\"{end}\",\
901                 \"attributes\":[{attrs}],\
902                 \"status\":{{\"code\":{scode},\"message\":\"{smsg}\"}} }}",
903                trace = hex16(&s.trace_id),
904                span  = hex8(&s.span_id),
905                name  = json_escape(&s.name),
906                kind  = s.kind as i32,
907                start = s.start_ns,
908                end   = s.end_ns,
909                attrs = attrs.join(","),
910                scode = s.status_code,
911                smsg  = status_msg,
912            )
913        }).collect();
914
915        format!(
916            "{{\"resourceSpans\":[{{\"resource\":{{\"attributes\":[\
917               {{\"key\":\"service.name\",\"value\":{{\"stringValue\":\"{svc}\"}} }},\
918               {{\"key\":\"service.version\",\"value\":{{\"stringValue\":\"{ver}\"}} }}\
919             ]}},\"scopeSpans\":[{{\"scope\":{{\"name\":\"rws\"}},\"spans\":[{spans}]}}]}}]}}",
920            svc   = json_escape(&self.service_name),
921            ver   = json_escape(&self.service_version),
922            spans = span_jsons.join(","),
923        )
924    }
925
926    fn post(&self, body: &str) {
927        let client = crate::http_client::Client::new().timeout_ms(10_000);
928        let mut request = client.post(&self.url).body_json(body);
929        for (name, value) in &self.headers {
930            request = request.header(name, value);
931        }
932        match request.send() {
933            Ok(response) if response.is_success() => {}
934            Ok(response) => eprintln!("OTLP trace export to {}: HTTP {}", self.url, response.status()),
935            Err(e) => eprintln!("OTLP trace export to {}: {}", self.url, e),
936        }
937    }
938}
939
940impl Exporter for OtlpHttpExporter {
941    fn export(&self, spans: &[SpanData]) {
942        if spans.is_empty() { return; }
943        let body = self.build_body(spans);
944        self.post(&body);
945    }
946}
947
948// ── global tracer ─────────────────────────────────────────────────────────────
949
950struct GlobalTracer {
951    exporter: std::sync::Arc<dyn Exporter>,
952    /// Full batches go to the `rws-otel-export` thread, so a request never
953    /// waits for the collector.
954    full_batches: Mutex<Option<std::sync::mpsc::SyncSender<Vec<SpanData>>>>,
955    export_thread: Mutex<Option<std::thread::JoinHandle<()>>>,
956    batch: Mutex<Vec<SpanData>>,
957    batch_size: usize,
958    sample_rate: f64,
959    shutdown_flag: AtomicBool,
960}
961
962impl GlobalTracer {
963    fn should_sample(&self) -> bool {
964        sampled(random_u64(), self.sample_rate)
965    }
966
967    fn record(&self, span: SpanData) {
968        let mut guard = self.batch.lock().unwrap();
969        guard.push(span);
970        if guard.len() >= self.batch_size {
971            let spans = std::mem::take(&mut *guard);
972            drop(guard);
973            let sender = self.full_batches.lock().unwrap_or_else(|e| e.into_inner()).clone();
974            match sender {
975                // A collector that can't keep up loses batches, not requests.
976                Some(sender) => {
977                    if sender.try_send(spans).is_err() {
978                        eprintln!("OTLP trace export is behind: a batch of spans was dropped");
979                    }
980                }
981                None => self.exporter.export(&spans),
982            }
983        }
984    }
985
986    fn flush(&self) {
987        let spans = std::mem::take(&mut *self.batch.lock().unwrap());
988        if !spans.is_empty() {
989            self.exporter.export(&spans);
990        }
991    }
992}
993
994static TRACER: OnceLock<GlobalTracer> = OnceLock::new();
995
996/// Spans waiting in a partial batch are exported after this long (the
997/// OpenTelemetry batch span processor's default schedule delay).
998const SCHEDULE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
999
1000fn tracer() -> Option<&'static GlobalTracer> {
1001    TRACER.get()
1002}
1003
1004// ── public API ────────────────────────────────────────────────────────────────
1005
1006/// Which backend to export spans to.
1007#[derive(Clone, Debug)]
1008pub enum ExporterConfig {
1009    /// Print JSON-encoded spans to stdout. Suitable for development.
1010    Stdout,
1011    /// POST OTLP JSON to `{endpoint}/v1/traces`.
1012    ///
1013    /// Compatible with Jaeger ≥ 1.35, Grafana Tempo, OpenTelemetry Collector.
1014    /// Typical endpoint: `"http://localhost:4318"`.
1015    Otlp { endpoint: String },
1016    /// No-op — spans are discarded. Useful in tests to silence output.
1017    Discard,
1018}
1019
1020/// Configuration for the tracing subsystem.
1021#[derive(Clone, Debug)]
1022pub struct TracingConfig {
1023    /// Value of the `service.name` resource attribute (e.g. `"checkout-service"`).
1024    pub service_name: String,
1025    /// Value of the `service.version` resource attribute.
1026    pub service_version: String,
1027    /// Where to send completed spans.
1028    pub exporter: ExporterConfig,
1029    /// Fraction of requests to sample. `1.0` = 100%, `0.1` = 10%.
1030    pub sample_rate: f64,
1031    /// Maximum number of spans to accumulate before flushing to the exporter.
1032    pub batch_size: usize,
1033}
1034
1035impl Default for TracingConfig {
1036    fn default() -> Self {
1037        TracingConfig {
1038            service_name: "rws".to_string(),
1039            service_version: env!("CARGO_PKG_VERSION").to_string(),
1040            exporter: ExporterConfig::Stdout,
1041            sample_rate: 1.0,
1042            batch_size: 128,
1043        }
1044    }
1045}
1046
1047/// Initialize tracing with an explicit config. Call once at startup before
1048/// the server starts accepting requests.
1049///
1050/// Calling this more than once is a no-op (the first call wins).
1051pub fn setup(config: TracingConfig) {
1052    let exporter: Box<dyn Exporter> = match &config.exporter {
1053        ExporterConfig::Stdout => Box::new(StdoutExporter),
1054        ExporterConfig::Otlp { endpoint } => Box::new(OtlpHttpExporter::new(
1055            endpoint,
1056            &config.service_name,
1057            &config.service_version,
1058        )),
1059        ExporterConfig::Discard => Box::new(DiscardExporter),
1060    };
1061    setup_with_exporter(config, exporter);
1062}
1063
1064/// Like [`setup`], but takes the exporter directly instead of building one
1065/// from [`TracingConfig::exporter`]. Lets you wire in any [`Exporter`] —
1066/// including [`CapturingExporter`] — through the same code path production
1067/// code uses, rather than only [`ExporterConfig`]'s three built-in choices.
1068///
1069/// Calling this (or [`setup`]) more than once is a no-op (the first call wins).
1070pub fn setup_with_exporter(config: TracingConfig, exporter: Box<dyn Exporter>) {
1071    let exporter: std::sync::Arc<dyn Exporter> = exporter.into();
1072    let (sender, batches) = std::sync::mpsc::sync_channel::<Vec<SpanData>>(8);
1073    let background = std::sync::Arc::clone(&exporter);
1074    let spawned = std::thread::Builder::new().name("rws-otel-export".to_string()).spawn(move || loop {
1075        match batches.recv_timeout(SCHEDULE_DELAY) {
1076            Ok(spans) => background.export(&spans),
1077            // A quiet server's spans don't wait for a full batch.
1078            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
1079                if let Some(tracer) = tracer() {
1080                    let spans = std::mem::take(&mut *tracer.batch.lock().unwrap_or_else(|e| e.into_inner()));
1081                    if !spans.is_empty() {
1082                        background.export(&spans);
1083                    }
1084                }
1085            }
1086            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
1087        }
1088    });
1089    let _ = TRACER.set(GlobalTracer {
1090        exporter,
1091        full_batches: Mutex::new(spawned.as_ref().ok().map(|_| sender)),
1092        export_thread: Mutex::new(spawned.ok()),
1093        batch: Mutex::new(Vec::new()),
1094        batch_size: config.batch_size.max(1),
1095        sample_rate: config.sample_rate.clamp(0.0, 1.0),
1096        shutdown_flag: AtomicBool::new(false),
1097    });
1098}
1099
1100/// Initialize tracing from standard OpenTelemetry environment variables:
1101///
1102/// - `OTEL_SERVICE_NAME` — service name (default `"rws"`)
1103/// - `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` — full traces URL, used as is; else
1104///   `OTEL_EXPORTER_OTLP_ENDPOINT` (the collector's base URL, `/v1/traces`
1105///   appended); neither set → stdout
1106/// - `OTEL_EXPORTER_OTLP_HEADERS` — `key=value,…` sent with every export
1107///   (`OTEL_EXPORTER_OTLP_TRACES_HEADERS` wins)
1108/// - `OTEL_TRACES_SAMPLER_ARG` — sample rate `0.0`–`1.0` (default `1.0`)
1109///
1110/// Metrics export is separate: [`metrics::start_from_env`].
1111pub fn setup_from_env() {
1112    let var = |name: &str| std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
1113    let service_name = var("OTEL_SERVICE_NAME").unwrap_or_else(|| "rws".to_string());
1114    let sample_rate: f64 = var("OTEL_TRACES_SAMPLER_ARG").and_then(|v| v.parse().ok()).unwrap_or(1.0);
1115    let config = TracingConfig {
1116        service_name,
1117        service_version: env!("CARGO_PKG_VERSION").to_string(),
1118        exporter: ExporterConfig::Stdout,
1119        sample_rate,
1120        batch_size: 128,
1121    };
1122    let endpoint = var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
1123        .map(|url| if url.trim_end_matches('/').ends_with("/v1/traces") { url } else { format!("{}/v1/traces", url.trim_end_matches('/')) })
1124        .or_else(|| var("OTEL_EXPORTER_OTLP_ENDPOINT"));
1125    match endpoint {
1126        Some(endpoint) => {
1127            let headers = var("OTEL_EXPORTER_OTLP_TRACES_HEADERS").or_else(|| var("OTEL_EXPORTER_OTLP_HEADERS")).map(|h| parse_headers(&h)).unwrap_or_default();
1128            let exporter = OtlpHttpExporter::new(&endpoint, &config.service_name, &config.service_version).with_headers(headers);
1129            setup_with_exporter(config, Box::new(exporter));
1130        }
1131        None => setup(config),
1132    }
1133}
1134
1135/// Whether the environment asks for OTLP trace export: an endpoint is set
1136/// and `OTEL_TRACES_EXPORTER` isn't `none`. The `rws` binary traces requests
1137/// only then.
1138pub fn traces_configured() -> bool {
1139    let var = |name: &str| std::env::var(name).ok().filter(|v| !v.trim().is_empty());
1140    !var("OTEL_TRACES_EXPORTER").is_some_and(|v| v.trim().eq_ignore_ascii_case("none"))
1141        && (var("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT").is_some() || var("OTEL_EXPORTER_OTLP_ENDPOINT").is_some())
1142}
1143
1144/// `OTEL_EXPORTER_OTLP_HEADERS` syntax: `key=value` pairs separated by
1145/// commas, values percent-decoded.
1146pub fn parse_headers(value: &str) -> Vec<(String, String)> {
1147    value
1148        .split(',')
1149        .filter_map(|pair| {
1150            let (name, value) = pair.split_once('=')?;
1151            let name = name.trim();
1152            if name.is_empty() {
1153                return None;
1154            }
1155            let value = crate::url::URL::percent_decode(value.trim());
1156            Some((name.to_string(), value))
1157        })
1158        .collect()
1159}
1160
1161/// Flush all buffered spans to the exporter. Call before the process exits
1162/// to ensure no spans are lost.
1163pub fn shutdown() {
1164    metrics::shutdown();
1165    if let Some(t) = tracer() {
1166        t.shutdown_flag.store(true, Ordering::Relaxed);
1167        // Close the queue and wait for the batches already in it.
1168        t.full_batches.lock().unwrap_or_else(|e| e.into_inner()).take();
1169        if let Some(thread) = t.export_thread.lock().unwrap_or_else(|e| e.into_inner()).take() {
1170            let _ = thread.join();
1171        }
1172        t.flush();
1173        t.exporter.shutdown();
1174    }
1175}
1176
1177/// Flush buffered spans without shutting down. Useful in tests.
1178pub fn flush() {
1179    if let Some(t) = tracer() {
1180        t.flush();
1181    }
1182}
1183
1184// ── OtelLayer middleware ──────────────────────────────────────────────────────
1185
1186/// Middleware that creates an HTTP server span for each request.
1187///
1188/// - Reads W3C `traceparent` from the request to continue an existing trace.
1189/// - Creates a new trace when no `traceparent` is present.
1190/// - Stores the active span in thread-local storage so downstream middleware
1191///   (e.g. [`crate::proxy::ReverseProxy`]) can propagate it.
1192/// - Records the span on the way out with `http.method`, `http.target`, and
1193///   `http.status_code` attributes.
1194///
1195/// Requires [`setup`] or [`setup_from_env`] to be called at startup. Without
1196/// initialisation the layer is a no-op passthrough.
1197pub struct OtelLayer;
1198
1199impl Middleware for OtelLayer {
1200    fn handle(
1201        &self,
1202        request: &Request,
1203        connection: &ConnectionInfo,
1204        next: &dyn Application,
1205    ) -> Result<Response, String> {
1206        if tracer().is_none() {
1207            return next.execute(request, connection);
1208        }
1209
1210        // Extract an existing trace context from the request, if present.
1211        let incoming = request.headers.iter()
1212            .find(|h| h.name.eq_ignore_ascii_case("traceparent"))
1213            .and_then(|h| TraceContext::parse(&h.value));
1214
1215        let baggage = request.header("baggage").map(|h| Baggage::parse(&h.value));
1216        let path = strip_query(&request.request_uri).to_string();
1217        let root = Span::start_root(&format!("{} {}", request.method, path), SpanKind::Server, incoming, baggage);
1218        root.set_http(&request.method, &request.request_uri);
1219
1220        let result = next.execute(request, connection);
1221
1222        let status = match &result {
1223            Ok(r) => r.status_code,
1224            Err(_) => 500,
1225        };
1226        root.set_http_status(status);
1227
1228        result
1229        // `root` drops here: pops the thread-local stack and records the
1230        // span (if sampled) — the exact same path a child `Span` follows.
1231    }
1232    /// Spans are recorded in memory (export runs on its own thread): safe on
1233    /// the event loop.
1234    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
1235        true
1236    }
1237
1238    /// Headers only: a streamed body passes through untouched.
1239    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
1240        true
1241    }
1242}
1243
1244// ── internal no-op exporter (used for tests / Discard config) ─────────────────
1245
1246struct DiscardExporter;
1247impl Exporter for DiscardExporter {
1248    fn export(&self, _: &[SpanData]) {}
1249}
1250
1251// ── collect spans in tests ────────────────────────────────────────────────────
1252
1253/// Captures spans in memory instead of exporting them. Use in unit tests via
1254/// [`setup_with_exporter`] to prove that a span was actually recorded, not
1255/// just that its getters return the right values.
1256///
1257/// ```rust,no_run
1258/// use rust_web_server::otel::{self, CapturingExporter, TracingConfig, ExporterConfig};
1259/// use std::sync::Arc;
1260///
1261/// let captured = Arc::new(CapturingExporter::new());
1262/// otel::setup_with_exporter(
1263///     TracingConfig { exporter: ExporterConfig::Discard, ..Default::default() },
1264///     Box::new(captured.clone()),
1265/// );
1266///
1267/// otel::span("db.query").end();
1268/// otel::flush();
1269/// assert_eq!(1, captured.take().len());
1270/// ```
1271pub struct CapturingExporter {
1272    pub spans: Mutex<Vec<SpanData>>,
1273}
1274
1275impl CapturingExporter {
1276    pub fn new() -> Self {
1277        CapturingExporter { spans: Mutex::new(Vec::new()) }
1278    }
1279
1280    pub fn take(&self) -> Vec<SpanData> {
1281        std::mem::take(&mut *self.spans.lock().unwrap())
1282    }
1283}
1284
1285impl Default for CapturingExporter {
1286    fn default() -> Self { Self::new() }
1287}
1288
1289impl Exporter for CapturingExporter {
1290    fn export(&self, spans: &[SpanData]) {
1291        self.spans.lock().unwrap().extend_from_slice(spans);
1292    }
1293}
1294
1295/// Lets a shared, externally-held `Arc<CapturingExporter>` be handed to
1296/// [`setup_with_exporter`] (which takes ownership of a `Box<dyn Exporter>`)
1297/// while the caller keeps its own handle to call [`CapturingExporter::take`]
1298/// afterward.
1299impl Exporter for std::sync::Arc<CapturingExporter> {
1300    fn export(&self, spans: &[SpanData]) {
1301        self.spans.lock().unwrap().extend_from_slice(spans);
1302    }
1303}