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