Skip to main content

edgeguard/
telemetry.rs

1//! OTLP span emission (gateway L4 observability) — SDK-free tracing straight from the request path.
2//!
3//! When `[llm.telemetry]` is enabled, EdgeGuard emits one OpenInference/OTLP span per metered LLM
4//! request to an OTLP/HTTP `/v1/traces` receiver (e.g. evald). Because the proxy sits in the request
5//! path, the span carries the correct model, per-tier tokens, computed cost, and **server-side**
6//! TTFT/TPOT/latency with no client SDK, no import-order fragility, and no per-framework instrumentor
7//! drift — the exact failure class that plagues in-process instrumentation (nested usage, dropped
8//! spans, async nesting breakage). Emission is **fire-and-forget**: it never blocks or fails the
9//! client response.
10//!
11//! The wire format is **OTLP-JSON** posted with the crate's existing HTTP client, so the data plane
12//! stays a single static binary (no protobuf codegen, no OpenTelemetry SDK). The span attribute keys
13//! are exactly the OpenInference / `gen_ai.*` keys a downstream store normalizes (`llm.model_name`,
14//! `llm.token_count.*`, `input.value`, …), so a gateway span round-trips into evald unchanged.
15
16use std::time::Duration;
17
18use serde_json::{json, Value};
19
20use crate::config::TelemetryCfg;
21
22/// W3C trace context for one emitted span. `parent_span_id` is set when an inbound `traceparent`
23/// stitched this gateway span under an app-side span (so both land in one trace).
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct TraceContext {
26    pub trace_id: [u8; 16],
27    pub span_id: [u8; 8],
28    pub parent_span_id: Option<[u8; 8]>,
29}
30
31impl TraceContext {
32    /// Derive a context from an optional inbound W3C `traceparent`. If it is present and well-formed,
33    /// reuse its trace id and make the inbound span our parent (app-side spans + this gateway span
34    /// stitch into one trace); otherwise mint a fresh root trace. The span id is always freshly random.
35    pub fn from_traceparent(traceparent: Option<&str>) -> TraceContext {
36        let span_id = rand8();
37        match traceparent.and_then(parse_traceparent) {
38            Some((trace_id, parent)) => TraceContext {
39                trace_id,
40                span_id,
41                parent_span_id: Some(parent),
42            },
43            None => TraceContext {
44                trace_id: rand16(),
45                span_id,
46                parent_span_id: None,
47            },
48        }
49    }
50}
51
52/// A metered LLM request rendered into span form. `input`/`output` stay `None` unless content capture
53/// is enabled (they are populated, already DLP-redacted, at the wiring site).
54#[derive(Clone, Debug)]
55pub struct SpanRecord {
56    pub ctx: TraceContext,
57    pub name: String,
58    pub model: String,
59    pub provider: Option<String>,
60    pub prompt_tokens: u64,
61    pub completion_tokens: u64,
62    pub cached_tokens: u64,
63    pub reasoning_tokens: u64,
64    /// Cost in micro-dollars; `None` when the model is unpriced (tokens still emitted).
65    pub cost_micros: Option<u64>,
66    pub start_unix_nano: u64,
67    pub end_unix_nano: u64,
68    pub ttft: Option<Duration>,
69    pub tpot: Option<Duration>,
70    /// Upstream status was 2xx.
71    pub status_ok: bool,
72    pub input: Option<String>,
73    pub output: Option<String>,
74    pub session_id: Option<String>,
75}
76
77/// The compiled telemetry runtime carried on the proxy [`Runtime`](crate::proxy::Runtime).
78#[derive(Clone)]
79pub struct TelemetryRuntime {
80    pub enabled: bool,
81    endpoint: String,
82    sample_rate: f64,
83    service_name: String,
84    /// Whether to attach captured prompt/response content (populated at the wiring site).
85    pub capture_content: bool,
86    pub max_content_bytes: usize,
87    client: reqwest::Client,
88}
89
90impl TelemetryRuntime {
91    /// Compile from config. `enabled` folds in "has a non-empty endpoint" so a misconfigured switch
92    /// (on, but no endpoint) is inert rather than erroring on every request.
93    pub fn build(cfg: &TelemetryCfg) -> Self {
94        let client = reqwest::Client::builder()
95            .timeout(Duration::from_millis(cfg.timeout_ms.max(1)))
96            .build()
97            .unwrap_or_default();
98        let service_name = if cfg.service_name.trim().is_empty() {
99            "edgeguard".to_string()
100        } else {
101            cfg.service_name.trim().to_string()
102        };
103        TelemetryRuntime {
104            enabled: cfg.enabled && !cfg.endpoint.trim().is_empty(),
105            endpoint: cfg.endpoint.trim().to_string(),
106            sample_rate: cfg.sample_rate.clamp(0.0, 1.0),
107            service_name,
108            capture_content: cfg.capture_content,
109            max_content_bytes: cfg.max_content_bytes,
110            client,
111        }
112    }
113
114    /// An inert runtime (emission off) — the default when `[llm.telemetry]` is absent.
115    pub fn disabled() -> Self {
116        Self::build(&TelemetryCfg::default())
117    }
118
119    /// Whether a trace id falls in the sampled fraction. Deterministic per trace (folding
120    /// both 64-bit halves together is the uniform draw), so a trace is sampled consistently
121    /// and sampling needs no RNG.
122    fn sampled(&self, trace_id: &[u8; 16]) -> bool {
123        if self.sample_rate >= 1.0 {
124            return true;
125        }
126        if self.sample_rate <= 0.0 {
127            return false;
128        }
129        // XOR both halves rather than using bytes 8..16 alone: for a UUIDv4 trace id, byte 8
130        // carries the RFC 4122 variant bits (fixed to `10` in the top two bits), which would
131        // otherwise bias the draw to only ever cover half its intended range.
132        let lo = u64::from_be_bytes(trace_id[0..8].try_into().expect("8 bytes"));
133        let hi = u64::from_be_bytes(trace_id[8..16].try_into().expect("8 bytes"));
134        let draw = lo ^ hi;
135        (draw as f64 / u64::MAX as f64) < self.sample_rate
136    }
137
138    /// Fire-and-forget: build the OTLP-JSON and POST it on a background task. Never blocks, and any
139    /// error (endpoint down, non-2xx) is swallowed at debug level — telemetry must not affect traffic.
140    pub fn emit(&self, record: SpanRecord) {
141        if !self.enabled || !self.sampled(&record.ctx.trace_id) {
142            return;
143        }
144        let body = build_export_json(&record, &self.service_name);
145        let client = self.client.clone();
146        let endpoint = self.endpoint.clone();
147        tokio::spawn(async move {
148            match client.post(&endpoint).json(&body).send().await {
149                Ok(resp) if resp.status().is_success() => {}
150                Ok(resp) => tracing::debug!(status = %resp.status(), "otlp span emit rejected"),
151                Err(e) => tracing::debug!(error = %e, "otlp span emit failed"),
152            }
153        });
154    }
155}
156
157/// Lossy-UTF8 a captured body and truncate it to `max_bytes` on a char boundary (with a marker), so
158/// a large prompt/response can't bloat the emitted span. Used at the content-capture wiring site.
159pub fn prepare_content(bytes: &[u8], max_bytes: usize) -> String {
160    let s = String::from_utf8_lossy(bytes);
161    if s.len() <= max_bytes {
162        return s.into_owned();
163    }
164    let mut end = max_bytes;
165    while end > 0 && !s.is_char_boundary(end) {
166        end -= 1;
167    }
168    format!("{}…[truncated]", &s[..end])
169}
170
171/// Build the OTLP-JSON `ExportTraceServiceRequest` for one span. The attribute keys are the
172/// OpenInference / `gen_ai.*` keys a downstream OTel store normalizes, so the span round-trips.
173/// Ints are encoded as strings (the protobuf int64 → JSON mapping OTLP-JSON uses).
174pub fn build_export_json(r: &SpanRecord, service_name: &str) -> Value {
175    let mut attrs: Vec<Value> = Vec::new();
176    attrs.push(kv_str("openinference.span.kind", "LLM"));
177    attrs.push(kv_str("llm.model_name", &r.model));
178    if let Some(p) = &r.provider {
179        attrs.push(kv_str("llm.provider", p));
180    }
181    attrs.push(kv_int("llm.token_count.prompt", r.prompt_tokens));
182    attrs.push(kv_int("llm.token_count.completion", r.completion_tokens));
183    attrs.push(kv_int(
184        "llm.token_count.total",
185        r.prompt_tokens.saturating_add(r.completion_tokens),
186    ));
187    if r.cached_tokens > 0 {
188        attrs.push(kv_int(
189            "llm.token_count.prompt_details.cache_read",
190            r.cached_tokens,
191        ));
192    }
193    if r.reasoning_tokens > 0 {
194        attrs.push(kv_int(
195            "llm.token_count.completion_details.reasoning",
196            r.reasoning_tokens,
197        ));
198    }
199    if let Some(micros) = r.cost_micros {
200        attrs.push(kv_double("llm.cost.total", micros as f64 / 1_000_000.0));
201    }
202    if let Some(ttft) = r.ttft {
203        attrs.push(kv_double("edgeguard.ttft_seconds", ttft.as_secs_f64()));
204    }
205    if let Some(tpot) = r.tpot {
206        attrs.push(kv_double("edgeguard.tpot_seconds", tpot.as_secs_f64()));
207    }
208    if let Some(session) = &r.session_id {
209        attrs.push(kv_str("session.id", session));
210    }
211    if let Some(input) = &r.input {
212        attrs.push(kv_str("input.value", input));
213    }
214    if let Some(output) = &r.output {
215        attrs.push(kv_str("output.value", output));
216    }
217
218    let mut span = json!({
219        "traceId": hex(&r.ctx.trace_id),
220        "spanId": hex(&r.ctx.span_id),
221        "name": r.name,
222        "kind": 3, // CLIENT — an outbound model call
223        "startTimeUnixNano": r.start_unix_nano.to_string(),
224        "endTimeUnixNano": r.end_unix_nano.to_string(),
225        "status": { "code": if r.status_ok { 1 } else { 2 } }, // OK / ERROR
226        "attributes": attrs,
227    });
228    if let Some(parent) = &r.ctx.parent_span_id {
229        span["parentSpanId"] = Value::String(hex(parent));
230    }
231
232    json!({
233        "resourceSpans": [{
234            "resource": { "attributes": [ kv_str("service.name", service_name) ] },
235            "scopeSpans": [{
236                "scope": { "name": "edgeguard", "version": env!("CARGO_PKG_VERSION") },
237                "spans": [ span ],
238            }],
239        }],
240    })
241}
242
243/// Whether a trace id falls in the sampled fraction, as a free function so the HTTP server span and
244/// the LLM client span reach the SAME verdict for one request. Deterministic per trace: folding both
245/// 64-bit halves together is the uniform draw, so a trace is sampled consistently wherever it is
246/// evaluated and a trace is never half-recorded.
247pub fn trace_sampled(sample_rate: f64, trace_id: &[u8; 16]) -> bool {
248    if sample_rate >= 1.0 {
249        return true;
250    }
251    if sample_rate <= 0.0 {
252        return false;
253    }
254    let hi = u64::from_be_bytes(trace_id[0..8].try_into().unwrap_or([0; 8]));
255    let lo = u64::from_be_bytes(trace_id[8..16].try_into().unwrap_or([0; 8]));
256    ((hi ^ lo) as f64 / u64::MAX as f64) < sample_rate
257}
258
259/// Render a [`TraceContext`] as the W3C `traceparent` header value to send upstream.
260///
261/// `01` in the flags means sampled. This is only ever built for a span we are recording, so the
262/// upstream is told the trace is sampled — which is what makes the app's own spans join this trace
263/// instead of being dropped by its sampler.
264pub fn traceparent_header(ctx: &TraceContext) -> String {
265    format!("00-{}-{}-01", hex(&ctx.trace_id), hex(&ctx.span_id))
266}
267
268/// One proxied HTTP request, rendered as an OpenTelemetry **SERVER** span.
269///
270/// Attribute names follow the current stable HTTP semantic conventions, which renamed nearly all of
271/// them (`http.method` became `http.request.method`, `http.url` became `url.*`). Getting these wrong
272/// is not cosmetic: every backend normalizes on the stable names, so an old-name span is dropped
273/// from latency and error panels rather than being merely oddly labelled.
274#[derive(Clone, Debug)]
275pub struct ServerSpan {
276    pub ctx: TraceContext,
277    /// The method, or `_OTHER` when it is not one of the known set (semconv requires that).
278    pub method: String,
279    /// Set only when `method` was replaced by `_OTHER`.
280    pub method_original: Option<String>,
281    pub url_path: String,
282    /// The query string, **already sanitised** by [`crate::accesslog::sanitize_target`]. `None`
283    /// when the request carried none.
284    ///
285    /// This is the one place the HTTP semconv is deliberately not followed to the letter. The spec
286    /// wants the query as received; this proxy redacts credential-shaped values everywhere else it
287    /// writes them, and a span is shipped to the same class of destination as an access log. Sending
288    /// the raw query here would reintroduce, in traces, exactly the leak the access log was built to
289    /// prevent.
290    pub url_query: Option<String>,
291    pub url_scheme: String,
292    pub status_code: u16,
293    pub client_address: Option<String>,
294    pub server_address: Option<String>,
295    pub user_agent: Option<String>,
296    pub protocol_version: Option<String>,
297    /// EdgeGuard's own verdict (`proxied`, `rate_limited`, `waf_blocked`, …). Not a semconv
298    /// attribute, so it is namespaced — it says WHY a request ended as it did, which the status code
299    /// alone does not.
300    pub outcome: String,
301    pub request_id: String,
302    pub start_unix_nano: u128,
303    pub end_unix_nano: u128,
304}
305
306impl ServerSpan {
307    /// Methods the semantic conventions define. Anything else must be reported as `_OTHER` with the
308    /// original in `http.request.method_original`, so an attacker cannot create unbounded label
309    /// cardinality by inventing methods.
310    pub fn normalize_method(method: &str) -> (String, Option<String>) {
311        const KNOWN: [&str; 9] = [
312            "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",
313        ];
314        if KNOWN.contains(&method) {
315            (method.to_string(), None)
316        } else {
317            ("_OTHER".to_string(), Some(method.to_string()))
318        }
319    }
320}
321
322/// Build the OTLP-JSON for a batch of server spans — one POST body for many spans.
323///
324/// Batched because a proxy emits one span per request. One POST each would make the tracing backend
325/// the busiest thing the edge talks to and would put a network round trip in the path of every
326/// request's teardown.
327pub fn build_server_spans_json(spans: &[ServerSpan], service_name: &str) -> Value {
328    let rendered: Vec<Value> = spans.iter().map(render_server_span).collect();
329    json!({
330        "resourceSpans": [{
331            "resource": { "attributes": [ kv_str("service.name", service_name) ] },
332            "scopeSpans": [{
333                "scope": { "name": "edgeguard", "version": env!("CARGO_PKG_VERSION") },
334                "spans": rendered,
335            }],
336        }],
337    })
338}
339
340fn render_server_span(r: &ServerSpan) -> Value {
341    let mut attrs = vec![
342        // Required by the spec for a server span.
343        kv_str("http.request.method", &r.method),
344        kv_str("url.path", &r.url_path),
345        kv_str("url.scheme", &r.url_scheme),
346        // Conditionally required: a response was sent.
347        kv_int("http.response.status_code", r.status_code as u64),
348    ];
349    if let Some(orig) = &r.method_original {
350        attrs.push(kv_str("http.request.method_original", orig));
351    }
352    if let Some(q) = &r.url_query {
353        attrs.push(kv_str("url.query", q));
354    }
355    if let Some(c) = &r.client_address {
356        attrs.push(kv_str("client.address", c));
357    }
358    if let Some(sa) = &r.server_address {
359        attrs.push(kv_str("server.address", sa));
360    }
361    if let Some(ua) = &r.user_agent {
362        attrs.push(kv_str("user_agent.original", ua));
363    }
364    if let Some(v) = &r.protocol_version {
365        attrs.push(kv_str("network.protocol.version", v));
366    }
367    attrs.push(kv_str("edgeguard.outcome", &r.outcome));
368    attrs.push(kv_str("edgeguard.request_id", &r.request_id));
369
370    // Span status. The spec is explicit and counter-intuitive here: for a SERVER span a 4xx MUST be
371    // left unset, because the server handled the request correctly — the client sent a bad one.
372    // Only 5xx (and uninterpreted failures) are Error. Marking 4xx as Error is the common mistake
373    // and it makes every error-rate panel read a 404 storm as an outage.
374    let mut span = json!({
375        "traceId": hex(&r.ctx.trace_id),
376        "spanId": hex(&r.ctx.span_id),
377        "name": r.method,   // `{method}` — a proxy has no route template to name
378        "kind": 2,          // SERVER
379        "startTimeUnixNano": r.start_unix_nano.to_string(),
380        "endTimeUnixNano": r.end_unix_nano.to_string(),
381        "attributes": attrs,
382    });
383    if r.status_code >= 500 {
384        span["status"] = json!({ "code": 2 });
385        span["attributes"]
386            .as_array_mut()
387            .expect("attributes is an array")
388            .push(kv_str("error.type", &r.status_code.to_string()));
389    }
390    if let Some(parent) = &r.ctx.parent_span_id {
391        span["parentSpanId"] = Value::String(hex(parent));
392    }
393    span
394}
395
396fn kv_str(key: &str, value: &str) -> Value {
397    json!({ "key": key, "value": { "stringValue": value } })
398}
399fn kv_int(key: &str, value: u64) -> Value {
400    // OTLP-JSON encodes int64 as a string.
401    json!({ "key": key, "value": { "intValue": value.to_string() } })
402}
403fn kv_double(key: &str, value: f64) -> Value {
404    json!({ "key": key, "value": { "doubleValue": value } })
405}
406
407/// Parse a W3C `traceparent` (`VV-<32hex trace>-<16hex span>-FF`). Returns `(trace_id, span_id)` when
408/// well-formed with non-zero ids; else `None` (a malformed header just means "start a fresh trace").
409fn parse_traceparent(s: &str) -> Option<([u8; 16], [u8; 8])> {
410    let mut parts = s.trim().split('-');
411    let _version = parts.next()?;
412    let trace_hex = parts.next()?;
413    let span_hex = parts.next()?;
414    let _flags = parts.next()?;
415    if parts.next().is_some() || trace_hex.len() != 32 || span_hex.len() != 16 {
416        return None;
417    }
418    let trace: [u8; 16] = hex_to_bytes::<16>(trace_hex)?;
419    let span: [u8; 8] = hex_to_bytes::<8>(span_hex)?;
420    if trace == [0u8; 16] || span == [0u8; 8] {
421        return None; // all-zero ids are "invalid" per the spec
422    }
423    Some((trace, span))
424}
425
426/// Decode exactly `N` bytes from a `2N`-char lowercase/uppercase hex string; `None` on any non-hex.
427fn hex_to_bytes<const N: usize>(s: &str) -> Option<[u8; N]> {
428    if s.len() != N * 2 {
429        return None;
430    }
431    let mut out = [0u8; N];
432    let bytes = s.as_bytes();
433    for i in 0..N {
434        let hi = (bytes[i * 2] as char).to_digit(16)?;
435        let lo = (bytes[i * 2 + 1] as char).to_digit(16)?;
436        out[i] = (hi * 16 + lo) as u8;
437    }
438    Some(out)
439}
440
441/// Lowercase-hex-encode bytes (trace/span ids in the OTLP-JSON payload).
442fn hex(bytes: &[u8]) -> String {
443    let mut s = String::with_capacity(bytes.len() * 2);
444    for b in bytes {
445        s.push_str(&format!("{b:02x}"));
446    }
447    s
448}
449
450/// 16 random bytes (a fresh trace id), sourced from a v4 UUID.
451fn rand16() -> [u8; 16] {
452    uuid::Uuid::new_v4().into_bytes()
453}
454/// 8 random bytes (a fresh span id), the first half of a v4 UUID.
455fn rand8() -> [u8; 8] {
456    uuid::Uuid::new_v4().into_bytes()[..8]
457        .try_into()
458        .expect("8 bytes")
459}
460
461#[cfg(test)]
462mod tests {
463
464    fn srv(status: u16) -> ServerSpan {
465        ServerSpan {
466            ctx: TraceContext {
467                trace_id: [1u8; 16],
468                span_id: [2u8; 8],
469                parent_span_id: None,
470            },
471            method: "GET".into(),
472            method_original: None,
473            url_path: "/api/thing".into(),
474            url_query: Some("page=2".into()),
475            url_scheme: "https".into(),
476            status_code: status,
477            client_address: Some("203.0.113.7".into()),
478            server_address: Some("app.example.com".into()),
479            user_agent: Some("curl/8".into()),
480            protocol_version: Some("1.1".into()),
481            outcome: "proxied".into(),
482            request_id: "rid-1".into(),
483            start_unix_nano: 1_000,
484            end_unix_nano: 3_000,
485        }
486    }
487
488    fn attrs_of(v: &Value) -> std::collections::HashMap<String, Value> {
489        v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
490            .as_array()
491            .unwrap()
492            .iter()
493            .map(|a| (a["key"].as_str().unwrap().to_string(), a["value"].clone()))
494            .collect()
495    }
496
497    #[test]
498    fn server_span_uses_the_current_stable_semconv_names() {
499        // The conventions RENAMED nearly all of these (http.method -> http.request.method,
500        // http.url -> url.*). An old-name span is not merely oddly labelled: every backend
501        // normalizes on the stable names, so it drops out of latency and error panels entirely.
502        let v = build_server_spans_json(&[srv(200)], "edgeguard");
503        let a = attrs_of(&v);
504        assert_eq!(a["http.request.method"]["stringValue"], "GET");
505        assert_eq!(a["url.path"]["stringValue"], "/api/thing");
506        assert_eq!(a["url.scheme"]["stringValue"], "https");
507        assert_eq!(a["url.query"]["stringValue"], "page=2");
508        // Conditionally required and the single most-used attribute in any HTTP dashboard. It was
509        // missing from the first draft of this design.
510        assert_eq!(a["http.response.status_code"]["intValue"], "200");
511        assert_eq!(a["client.address"]["stringValue"], "203.0.113.7");
512        assert_eq!(a["user_agent.original"]["stringValue"], "curl/8");
513        assert_eq!(a["network.protocol.version"]["stringValue"], "1.1");
514        // The old names must not appear at all.
515        for dead in ["http.method", "http.url", "http.status_code", "http.target"] {
516            assert!(
517                !a.contains_key(dead),
518                "obsolete semconv attribute {dead} emitted"
519            );
520        }
521    }
522
523    #[test]
524    fn a_server_span_is_kind_server_and_named_for_the_method() {
525        // A proxy has no route template, and the spec says the span name is `{method}` alone when
526        // `http.route` is unavailable. Putting the PATH in the name is the common mistake and it
527        // makes span-name cardinality unbounded.
528        let v = build_server_spans_json(&[srv(200)], "edgeguard");
529        let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
530        assert_eq!(span["kind"], 2, "SERVER");
531        assert_eq!(span["name"], "GET");
532    }
533
534    #[test]
535    fn only_5xx_sets_span_status_to_error() {
536        // The spec is explicit and counter-intuitive: for a SERVER span a 4xx MUST be left unset,
537        // because the server handled a bad request correctly. Marking 4xx as Error makes every
538        // error-rate panel read a 404 storm as an outage.
539        for ok in [200u16, 301, 404, 429, 499] {
540            let v = build_server_spans_json(&[srv(ok)], "edgeguard");
541            let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
542            assert!(span.get("status").is_none(), "{ok} must leave status unset");
543            assert!(
544                !attrs_of(&v).contains_key("error.type"),
545                "{ok} is not an error"
546            );
547        }
548        for bad in [500u16, 502, 503] {
549            let v = build_server_spans_json(&[srv(bad)], "edgeguard");
550            let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
551            assert_eq!(span["status"]["code"], 2, "{bad} must be Error");
552            assert_eq!(attrs_of(&v)["error.type"]["stringValue"], bad.to_string());
553        }
554    }
555
556    #[test]
557    fn an_unknown_method_is_bucketed_rather_than_labelled() {
558        // Otherwise a caller invents methods and creates unbounded span-name cardinality.
559        let (m, orig) = ServerSpan::normalize_method("FROBNICATE");
560        assert_eq!(m, "_OTHER");
561        assert_eq!(orig.as_deref(), Some("FROBNICATE"));
562        let (m, orig) = ServerSpan::normalize_method("PATCH");
563        assert_eq!(m, "PATCH");
564        assert!(orig.is_none());
565    }
566
567    #[test]
568    fn one_batch_is_one_payload_with_many_spans() {
569        // A proxy emits a span per request; one POST each would make the trace backend the busiest
570        // thing the edge talks to.
571        let v = build_server_spans_json(&[srv(200), srv(500), srv(404)], "edgeguard");
572        let spans = v["resourceSpans"][0]["scopeSpans"][0]["spans"]
573            .as_array()
574            .unwrap();
575        assert_eq!(spans.len(), 3);
576        assert_eq!(
577            v["resourceSpans"][0]["resource"]["attributes"][0]["value"]["stringValue"],
578            "edgeguard"
579        );
580    }
581
582    #[test]
583    fn sampling_is_deterministic_per_trace_and_respects_the_bounds() {
584        let a = [7u8; 16];
585        let b = [9u8; 16];
586        assert!(trace_sampled(1.0, &a) && trace_sampled(1.0, &b));
587        assert!(!trace_sampled(0.0, &a) && !trace_sampled(0.0, &b));
588        // Same trace, same verdict, every time — this is what stops a half-recorded trace.
589        for _ in 0..100 {
590            assert_eq!(trace_sampled(0.5, &a), trace_sampled(0.5, &a));
591        }
592    }
593
594    #[test]
595    fn the_outbound_traceparent_names_our_span_and_says_sampled() {
596        // The upstream must become a CHILD of the edge's span, and must be told the trace is
597        // sampled — otherwise its own sampler drops the other half of the trace.
598        let ctx = TraceContext {
599            trace_id: [0xab; 16],
600            span_id: [0xcd; 8],
601            parent_span_id: None,
602        };
603        let h = traceparent_header(&ctx);
604        assert_eq!(h, format!("00-{}-{}-01", "ab".repeat(16), "cd".repeat(8)));
605        // And it round-trips: a downstream parsing it sees our trace and our span as its parent.
606        let back = TraceContext::from_traceparent(Some(&h));
607        assert_eq!(back.trace_id, ctx.trace_id);
608        assert_eq!(back.parent_span_id, Some(ctx.span_id));
609    }
610
611    #[test]
612    fn an_inbound_traceparent_makes_the_server_span_a_child() {
613        let inbound = format!("00-{}-{}-01", "11".repeat(16), "22".repeat(8));
614        let ctx = TraceContext::from_traceparent(Some(&inbound));
615        let mut s = srv(200);
616        s.ctx = ctx;
617        let v = build_server_spans_json(&[s], "edgeguard");
618        let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
619        assert_eq!(span["traceId"], "11".repeat(16));
620        assert_eq!(span["parentSpanId"], "22".repeat(8));
621    }
622    use super::*;
623
624    fn record() -> SpanRecord {
625        SpanRecord {
626            ctx: TraceContext {
627                trace_id: [0x11; 16],
628                span_id: [0x22; 8],
629                parent_span_id: None,
630            },
631            name: "llm.chat".into(),
632            model: "gpt-4o".into(),
633            provider: Some("openai".into()),
634            prompt_tokens: 100,
635            completion_tokens: 40,
636            cached_tokens: 30,
637            reasoning_tokens: 10,
638            cost_micros: Some(2_250_000),
639            start_unix_nano: 1_000,
640            end_unix_nano: 4_000,
641            ttft: Some(Duration::from_millis(120)),
642            tpot: Some(Duration::from_millis(25)),
643            status_ok: true,
644            input: None,
645            output: None,
646            session_id: Some("sess-1".into()),
647        }
648    }
649
650    /// The emitted attributes must use the exact OpenInference keys evald's normalizer reads.
651    #[test]
652    fn build_export_json_uses_openinference_keys() {
653        let v = build_export_json(&record(), "checkout");
654        let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
655        assert_eq!(span["traceId"], "11".repeat(16));
656        assert_eq!(span["spanId"], "22".repeat(8));
657        assert!(span.get("parentSpanId").is_none());
658        assert_eq!(span["startTimeUnixNano"], "1000");
659        assert_eq!(span["status"]["code"], 1);
660
661        let attrs = span["attributes"].as_array().unwrap();
662        let get = |key: &str| attrs.iter().find(|a| a["key"] == key).map(|a| &a["value"]);
663        assert_eq!(
664            get("openinference.span.kind").unwrap()["stringValue"],
665            "LLM"
666        );
667        assert_eq!(get("llm.model_name").unwrap()["stringValue"], "gpt-4o");
668        assert_eq!(get("llm.provider").unwrap()["stringValue"], "openai");
669        // OTLP-JSON int64 → string.
670        assert_eq!(get("llm.token_count.prompt").unwrap()["intValue"], "100");
671        assert_eq!(get("llm.token_count.completion").unwrap()["intValue"], "40");
672        assert_eq!(get("llm.token_count.total").unwrap()["intValue"], "140");
673        assert_eq!(
674            get("llm.token_count.prompt_details.cache_read").unwrap()["intValue"],
675            "30"
676        );
677        assert_eq!(
678            get("llm.token_count.completion_details.reasoning").unwrap()["intValue"],
679            "10"
680        );
681        assert_eq!(get("llm.cost.total").unwrap()["doubleValue"], 2.25);
682        assert_eq!(get("session.id").unwrap()["stringValue"], "sess-1");
683        assert_eq!(
684            v["resourceSpans"][0]["resource"]["attributes"][0]["value"]["stringValue"],
685            "checkout"
686        );
687    }
688
689    #[test]
690    fn zero_cache_and_reasoning_are_omitted() {
691        let mut r = record();
692        r.cached_tokens = 0;
693        r.reasoning_tokens = 0;
694        let v = build_export_json(&r, "svc");
695        let attrs = v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
696            .as_array()
697            .unwrap()
698            .clone();
699        assert!(!attrs
700            .iter()
701            .any(|a| a["key"] == "llm.token_count.prompt_details.cache_read"));
702        assert!(!attrs
703            .iter()
704            .any(|a| a["key"] == "llm.token_count.completion_details.reasoning"));
705    }
706
707    #[test]
708    fn content_is_attached_only_when_present() {
709        let mut r = record();
710        r.input = Some("hello?".into());
711        r.output = Some("hi!".into());
712        let v = build_export_json(&r, "svc");
713        let attrs = v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
714            .as_array()
715            .unwrap()
716            .clone();
717        let val = |k: &str| {
718            attrs
719                .iter()
720                .find(|a| a["key"] == k)
721                .map(|a| a["value"]["stringValue"].clone())
722        };
723        assert_eq!(val("input.value").unwrap(), "hello?");
724        assert_eq!(val("output.value").unwrap(), "hi!");
725    }
726
727    #[test]
728    fn error_status_maps_to_code_2() {
729        let mut r = record();
730        r.status_ok = false;
731        let v = build_export_json(&r, "svc");
732        assert_eq!(
733            v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["status"]["code"],
734            2
735        );
736    }
737
738    #[test]
739    fn traceparent_is_parsed_and_stitched_as_parent() {
740        let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
741        let ctx = TraceContext::from_traceparent(Some(tp));
742        assert_eq!(hex(&ctx.trace_id), "4bf92f3577b34da6a3ce929d0e0e4736");
743        assert_eq!(
744            ctx.parent_span_id.map(|p| hex(&p)).as_deref(),
745            Some("00f067aa0ba902b7")
746        );
747        // A fresh 8-byte span id was minted (not the parent's).
748        assert_ne!(hex(&ctx.span_id), "00f067aa0ba902b7");
749    }
750
751    #[test]
752    fn missing_or_malformed_traceparent_starts_a_fresh_root_trace() {
753        for bad in [None, Some(""), Some("garbage"), Some("00-tooshort-x-01")] {
754            let ctx = TraceContext::from_traceparent(bad);
755            assert!(
756                ctx.parent_span_id.is_none(),
757                "bad traceparent {bad:?} must be a root"
758            );
759            assert_ne!(ctx.trace_id, [0u8; 16]);
760        }
761        // An all-zero trace id in an otherwise well-formed header is invalid → fresh trace.
762        let zero = "00-00000000000000000000000000000000-00f067aa0ba902b7-01";
763        assert!(TraceContext::from_traceparent(Some(zero))
764            .parent_span_id
765            .is_none());
766    }
767
768    #[test]
769    fn sampling_is_deterministic_and_bounded() {
770        let all = TelemetryRuntime::build(&TelemetryCfg {
771            enabled: true,
772            endpoint: "http://x/v1/traces".into(),
773            sample_rate: 1.0,
774            ..TelemetryCfg::default()
775        });
776        assert!(all.sampled(&[0xff; 16]));
777        let none = TelemetryRuntime::build(&TelemetryCfg {
778            enabled: true,
779            endpoint: "http://x/v1/traces".into(),
780            sample_rate: 0.0,
781            ..TelemetryCfg::default()
782        });
783        assert!(!none.sampled(&[0xff; 16]));
784        // Same trace id → same verdict, whatever the rate.
785        let half = TelemetryRuntime::build(&TelemetryCfg {
786            enabled: true,
787            endpoint: "http://x/v1/traces".into(),
788            sample_rate: 0.5,
789            ..TelemetryCfg::default()
790        });
791        let id = [0x40u8; 16];
792        assert_eq!(half.sampled(&id), half.sampled(&id));
793    }
794
795    #[test]
796    fn sampling_is_unbiased_for_real_uuidv4_trace_ids() {
797        // Regression: `sampled()` used to draw only from trace_id[8..16], but byte 8 of a
798        // UUIDv4 always has its top two bits fixed to `10` (the RFC 4122 variant), which
799        // capped that byte's range to [0x80, 0xbf] and skewed the draw to only ever cover
800        // roughly the [0.5, 0.75) slice of the [0,1) range — so at sample_rate=0.5, real
801        // trace ids would ~always sample, not ~half the time.
802        let half = TelemetryRuntime::build(&TelemetryCfg {
803            enabled: true,
804            endpoint: "http://x/v1/traces".into(),
805            sample_rate: 0.5,
806            ..TelemetryCfg::default()
807        });
808        let sampled_count = (0..2000)
809            .filter(|_| half.sampled(uuid::Uuid::new_v4().as_bytes()))
810            .count();
811        // Statistical, not exact — allow generous slack around the expected ~1000/2000.
812        assert!(
813            (700..=1300).contains(&sampled_count),
814            "expected roughly half of 2000 real UUIDv4 trace ids to sample at rate 0.5, got {sampled_count}"
815        );
816    }
817
818    #[test]
819    fn disabled_without_endpoint_even_if_enabled_flag_set() {
820        let rt = TelemetryRuntime::build(&TelemetryCfg {
821            enabled: true,
822            endpoint: "   ".into(), // whitespace-only → treated as unset
823            ..TelemetryCfg::default()
824        });
825        assert!(!rt.enabled);
826    }
827
828    #[test]
829    fn prepare_content_truncates_on_a_char_boundary() {
830        let s = prepare_content("abcdef".as_bytes(), 3);
831        assert!(s.starts_with("abc"));
832        assert!(s.contains("truncated"));
833        assert_eq!(prepare_content("hi".as_bytes(), 8), "hi");
834    }
835}
836
837// ─── span shipping ────────────────────────────────────────────────────────────────────────────
838
839/// Bounded queue + background task that batches server spans into OTLP-JSON POSTs.
840///
841/// Same discipline as [`crate::logship`], for the same reason: this is fed from the response path of
842/// every request. `record` is one non-blocking `try_send` and returns; a slow or absent collector
843/// costs dropped spans, never request latency. Traces are telemetry, so the loss is bounded and
844/// counted rather than buffered without limit — an unbounded buffer on a proxy turns a collector
845/// outage into a proxy outage.
846#[derive(Clone)]
847pub struct SpanShipper {
848    tx: tokio::sync::mpsc::Sender<ServerSpan>,
849    stats: std::sync::Arc<SpanShipStats>,
850}
851
852/// Counters for the span shipper. A trace pipeline that drops silently reads, at the destination,
853/// as an absence of traffic.
854#[derive(Debug, Default)]
855pub struct SpanShipStats {
856    pub sent: std::sync::atomic::AtomicU64,
857    pub dropped_queue_full: std::sync::atomic::AtomicU64,
858    pub dropped_send_failed: std::sync::atomic::AtomicU64,
859}
860
861impl SpanShipper {
862    pub fn stats(&self) -> &std::sync::Arc<SpanShipStats> {
863        &self.stats
864    }
865
866    /// Hand a span to the shipper. Never blocks, never awaits, never fails the caller.
867    pub fn record(&self, span: ServerSpan) {
868        if self.tx.try_send(span).is_err() {
869            self.stats
870                .dropped_queue_full
871                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
872        }
873    }
874}
875
876/// Build the shipper and spawn its background task. `None` when tracing is off or unconfigured.
877pub fn spawn_span_shipper(
878    cfg: &crate::config::TracingCfg,
879    shutdown: tokio::sync::watch::Receiver<bool>,
880) -> Option<SpanShipper> {
881    if !cfg.enabled || cfg.endpoint.trim().is_empty() {
882        return None;
883    }
884    let http = reqwest::Client::builder()
885        .timeout(std::time::Duration::from_millis(cfg.timeout_ms.max(100)))
886        .build()
887        .ok()?;
888    let stats = std::sync::Arc::new(SpanShipStats::default());
889    let (tx, rx) = tokio::sync::mpsc::channel(cfg.queue_size.max(1));
890    let task = SpanShipTask {
891        http,
892        endpoint: cfg.endpoint.clone(),
893        service_name: cfg.service_name.clone(),
894        batch: cfg.batch.max(1),
895        interval: std::time::Duration::from_secs(cfg.interval_secs.max(1)),
896        stats: std::sync::Arc::clone(&stats),
897    };
898    tracing::info!(endpoint = %cfg.endpoint, batch = task.batch, "request tracing enabled");
899    tokio::spawn(task.run(rx, shutdown));
900    Some(SpanShipper { tx, stats })
901}
902
903struct SpanShipTask {
904    http: reqwest::Client,
905    endpoint: String,
906    service_name: String,
907    batch: usize,
908    interval: std::time::Duration,
909    stats: std::sync::Arc<SpanShipStats>,
910}
911
912impl SpanShipTask {
913    async fn run(
914        self,
915        mut rx: tokio::sync::mpsc::Receiver<ServerSpan>,
916        mut shutdown: tokio::sync::watch::Receiver<bool>,
917    ) {
918        let mut buf: Vec<ServerSpan> = Vec::with_capacity(self.batch);
919        let mut tick = tokio::time::interval(self.interval);
920        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
921        // `interval`'s first tick completes immediately; consume it so the configured interval means
922        // what it says and the first batch of a process is not a batch of one.
923        tick.tick().await;
924
925        loop {
926            tokio::select! {
927                biased;
928                _ = shutdown.changed() => { if *shutdown.borrow() { break } }
929                got = rx.recv() => match got {
930                    Some(s) => {
931                        buf.push(s);
932                        if buf.len() >= self.batch {
933                            self.flush(&mut buf).await;
934                        }
935                    }
936                    None => break,
937                },
938                _ = tick.tick() => {
939                    if !buf.is_empty() {
940                        self.flush(&mut buf).await;
941                    }
942                }
943            }
944        }
945        // Drain on the way out: the spans around a restart are the ones most likely to explain it.
946        while let Ok(s) = rx.try_recv() {
947            buf.push(s);
948            if buf.len() >= self.batch {
949                self.flush(&mut buf).await;
950            }
951        }
952        if !buf.is_empty() {
953            self.flush(&mut buf).await;
954        }
955    }
956
957    async fn flush(&self, buf: &mut Vec<ServerSpan>) {
958        let n = buf.len() as u64;
959        let body = build_server_spans_json(buf, &self.service_name);
960        buf.clear();
961        match self.http.post(&self.endpoint).json(&body).send().await {
962            Ok(r) if r.status().is_success() => {
963                self.stats
964                    .sent
965                    .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
966            }
967            // No retry, unlike the log shipper's single retry. Spans are the most disposable
968            // telemetry here and the highest volume; a retry queue behind a failing collector just
969            // converts collector downtime into request-path drops sooner.
970            Ok(r) => {
971                tracing::debug!(status = %r.status(), spans = n, "trace collector rejected a batch");
972                self.stats
973                    .dropped_send_failed
974                    .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
975            }
976            Err(e) => {
977                tracing::debug!(error = %e, spans = n, "shipping a span batch failed");
978                self.stats
979                    .dropped_send_failed
980                    .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
981            }
982        }
983    }
984}