Skip to main content

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