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
243fn kv_str(key: &str, value: &str) -> Value {
244    json!({ "key": key, "value": { "stringValue": value } })
245}
246fn kv_int(key: &str, value: u64) -> Value {
247    // OTLP-JSON encodes int64 as a string.
248    json!({ "key": key, "value": { "intValue": value.to_string() } })
249}
250fn kv_double(key: &str, value: f64) -> Value {
251    json!({ "key": key, "value": { "doubleValue": value } })
252}
253
254/// Parse a W3C `traceparent` (`VV-<32hex trace>-<16hex span>-FF`). Returns `(trace_id, span_id)` when
255/// well-formed with non-zero ids; else `None` (a malformed header just means "start a fresh trace").
256fn parse_traceparent(s: &str) -> Option<([u8; 16], [u8; 8])> {
257    let mut parts = s.trim().split('-');
258    let _version = parts.next()?;
259    let trace_hex = parts.next()?;
260    let span_hex = parts.next()?;
261    let _flags = parts.next()?;
262    if parts.next().is_some() || trace_hex.len() != 32 || span_hex.len() != 16 {
263        return None;
264    }
265    let trace: [u8; 16] = hex_to_bytes::<16>(trace_hex)?;
266    let span: [u8; 8] = hex_to_bytes::<8>(span_hex)?;
267    if trace == [0u8; 16] || span == [0u8; 8] {
268        return None; // all-zero ids are "invalid" per the spec
269    }
270    Some((trace, span))
271}
272
273/// Decode exactly `N` bytes from a `2N`-char lowercase/uppercase hex string; `None` on any non-hex.
274fn hex_to_bytes<const N: usize>(s: &str) -> Option<[u8; N]> {
275    if s.len() != N * 2 {
276        return None;
277    }
278    let mut out = [0u8; N];
279    let bytes = s.as_bytes();
280    for i in 0..N {
281        let hi = (bytes[i * 2] as char).to_digit(16)?;
282        let lo = (bytes[i * 2 + 1] as char).to_digit(16)?;
283        out[i] = (hi * 16 + lo) as u8;
284    }
285    Some(out)
286}
287
288/// Lowercase-hex-encode bytes (trace/span ids in the OTLP-JSON payload).
289fn hex(bytes: &[u8]) -> String {
290    let mut s = String::with_capacity(bytes.len() * 2);
291    for b in bytes {
292        s.push_str(&format!("{b:02x}"));
293    }
294    s
295}
296
297/// 16 random bytes (a fresh trace id), sourced from a v4 UUID.
298fn rand16() -> [u8; 16] {
299    uuid::Uuid::new_v4().into_bytes()
300}
301/// 8 random bytes (a fresh span id), the first half of a v4 UUID.
302fn rand8() -> [u8; 8] {
303    uuid::Uuid::new_v4().into_bytes()[..8]
304        .try_into()
305        .expect("8 bytes")
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    fn record() -> SpanRecord {
313        SpanRecord {
314            ctx: TraceContext {
315                trace_id: [0x11; 16],
316                span_id: [0x22; 8],
317                parent_span_id: None,
318            },
319            name: "llm.chat".into(),
320            model: "gpt-4o".into(),
321            provider: Some("openai".into()),
322            prompt_tokens: 100,
323            completion_tokens: 40,
324            cached_tokens: 30,
325            reasoning_tokens: 10,
326            cost_micros: Some(2_250_000),
327            start_unix_nano: 1_000,
328            end_unix_nano: 4_000,
329            ttft: Some(Duration::from_millis(120)),
330            tpot: Some(Duration::from_millis(25)),
331            status_ok: true,
332            input: None,
333            output: None,
334            session_id: Some("sess-1".into()),
335        }
336    }
337
338    /// The emitted attributes must use the exact OpenInference keys evald's normalizer reads.
339    #[test]
340    fn build_export_json_uses_openinference_keys() {
341        let v = build_export_json(&record(), "checkout");
342        let span = &v["resourceSpans"][0]["scopeSpans"][0]["spans"][0];
343        assert_eq!(span["traceId"], "11".repeat(16));
344        assert_eq!(span["spanId"], "22".repeat(8));
345        assert!(span.get("parentSpanId").is_none());
346        assert_eq!(span["startTimeUnixNano"], "1000");
347        assert_eq!(span["status"]["code"], 1);
348
349        let attrs = span["attributes"].as_array().unwrap();
350        let get = |key: &str| attrs.iter().find(|a| a["key"] == key).map(|a| &a["value"]);
351        assert_eq!(
352            get("openinference.span.kind").unwrap()["stringValue"],
353            "LLM"
354        );
355        assert_eq!(get("llm.model_name").unwrap()["stringValue"], "gpt-4o");
356        assert_eq!(get("llm.provider").unwrap()["stringValue"], "openai");
357        // OTLP-JSON int64 → string.
358        assert_eq!(get("llm.token_count.prompt").unwrap()["intValue"], "100");
359        assert_eq!(get("llm.token_count.completion").unwrap()["intValue"], "40");
360        assert_eq!(get("llm.token_count.total").unwrap()["intValue"], "140");
361        assert_eq!(
362            get("llm.token_count.prompt_details.cache_read").unwrap()["intValue"],
363            "30"
364        );
365        assert_eq!(
366            get("llm.token_count.completion_details.reasoning").unwrap()["intValue"],
367            "10"
368        );
369        assert_eq!(get("llm.cost.total").unwrap()["doubleValue"], 2.25);
370        assert_eq!(get("session.id").unwrap()["stringValue"], "sess-1");
371        assert_eq!(
372            v["resourceSpans"][0]["resource"]["attributes"][0]["value"]["stringValue"],
373            "checkout"
374        );
375    }
376
377    #[test]
378    fn zero_cache_and_reasoning_are_omitted() {
379        let mut r = record();
380        r.cached_tokens = 0;
381        r.reasoning_tokens = 0;
382        let v = build_export_json(&r, "svc");
383        let attrs = v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
384            .as_array()
385            .unwrap()
386            .clone();
387        assert!(!attrs
388            .iter()
389            .any(|a| a["key"] == "llm.token_count.prompt_details.cache_read"));
390        assert!(!attrs
391            .iter()
392            .any(|a| a["key"] == "llm.token_count.completion_details.reasoning"));
393    }
394
395    #[test]
396    fn content_is_attached_only_when_present() {
397        let mut r = record();
398        r.input = Some("hello?".into());
399        r.output = Some("hi!".into());
400        let v = build_export_json(&r, "svc");
401        let attrs = v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["attributes"]
402            .as_array()
403            .unwrap()
404            .clone();
405        let val = |k: &str| {
406            attrs
407                .iter()
408                .find(|a| a["key"] == k)
409                .map(|a| a["value"]["stringValue"].clone())
410        };
411        assert_eq!(val("input.value").unwrap(), "hello?");
412        assert_eq!(val("output.value").unwrap(), "hi!");
413    }
414
415    #[test]
416    fn error_status_maps_to_code_2() {
417        let mut r = record();
418        r.status_ok = false;
419        let v = build_export_json(&r, "svc");
420        assert_eq!(
421            v["resourceSpans"][0]["scopeSpans"][0]["spans"][0]["status"]["code"],
422            2
423        );
424    }
425
426    #[test]
427    fn traceparent_is_parsed_and_stitched_as_parent() {
428        let tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
429        let ctx = TraceContext::from_traceparent(Some(tp));
430        assert_eq!(hex(&ctx.trace_id), "4bf92f3577b34da6a3ce929d0e0e4736");
431        assert_eq!(
432            ctx.parent_span_id.map(|p| hex(&p)).as_deref(),
433            Some("00f067aa0ba902b7")
434        );
435        // A fresh 8-byte span id was minted (not the parent's).
436        assert_ne!(hex(&ctx.span_id), "00f067aa0ba902b7");
437    }
438
439    #[test]
440    fn missing_or_malformed_traceparent_starts_a_fresh_root_trace() {
441        for bad in [None, Some(""), Some("garbage"), Some("00-tooshort-x-01")] {
442            let ctx = TraceContext::from_traceparent(bad);
443            assert!(
444                ctx.parent_span_id.is_none(),
445                "bad traceparent {bad:?} must be a root"
446            );
447            assert_ne!(ctx.trace_id, [0u8; 16]);
448        }
449        // An all-zero trace id in an otherwise well-formed header is invalid → fresh trace.
450        let zero = "00-00000000000000000000000000000000-00f067aa0ba902b7-01";
451        assert!(TraceContext::from_traceparent(Some(zero))
452            .parent_span_id
453            .is_none());
454    }
455
456    #[test]
457    fn sampling_is_deterministic_and_bounded() {
458        let all = TelemetryRuntime::build(&TelemetryCfg {
459            enabled: true,
460            endpoint: "http://x/v1/traces".into(),
461            sample_rate: 1.0,
462            ..TelemetryCfg::default()
463        });
464        assert!(all.sampled(&[0xff; 16]));
465        let none = TelemetryRuntime::build(&TelemetryCfg {
466            enabled: true,
467            endpoint: "http://x/v1/traces".into(),
468            sample_rate: 0.0,
469            ..TelemetryCfg::default()
470        });
471        assert!(!none.sampled(&[0xff; 16]));
472        // Same trace id → same verdict, whatever the rate.
473        let half = TelemetryRuntime::build(&TelemetryCfg {
474            enabled: true,
475            endpoint: "http://x/v1/traces".into(),
476            sample_rate: 0.5,
477            ..TelemetryCfg::default()
478        });
479        let id = [0x40u8; 16];
480        assert_eq!(half.sampled(&id), half.sampled(&id));
481    }
482
483    #[test]
484    fn sampling_is_unbiased_for_real_uuidv4_trace_ids() {
485        // Regression: `sampled()` used to draw only from trace_id[8..16], but byte 8 of a
486        // UUIDv4 always has its top two bits fixed to `10` (the RFC 4122 variant), which
487        // capped that byte's range to [0x80, 0xbf] and skewed the draw to only ever cover
488        // roughly the [0.5, 0.75) slice of the [0,1) range — so at sample_rate=0.5, real
489        // trace ids would ~always sample, not ~half the time.
490        let half = TelemetryRuntime::build(&TelemetryCfg {
491            enabled: true,
492            endpoint: "http://x/v1/traces".into(),
493            sample_rate: 0.5,
494            ..TelemetryCfg::default()
495        });
496        let sampled_count = (0..2000)
497            .filter(|_| half.sampled(uuid::Uuid::new_v4().as_bytes()))
498            .count();
499        // Statistical, not exact — allow generous slack around the expected ~1000/2000.
500        assert!(
501            (700..=1300).contains(&sampled_count),
502            "expected roughly half of 2000 real UUIDv4 trace ids to sample at rate 0.5, got {sampled_count}"
503        );
504    }
505
506    #[test]
507    fn disabled_without_endpoint_even_if_enabled_flag_set() {
508        let rt = TelemetryRuntime::build(&TelemetryCfg {
509            enabled: true,
510            endpoint: "   ".into(), // whitespace-only → treated as unset
511            ..TelemetryCfg::default()
512        });
513        assert!(!rt.enabled);
514    }
515
516    #[test]
517    fn prepare_content_truncates_on_a_char_boundary() {
518        let s = prepare_content("abcdef".as_bytes(), 3);
519        assert!(s.starts_with("abc"));
520        assert!(s.contains("truncated"));
521        assert_eq!(prepare_content("hi".as_bytes(), 8), "hi");
522    }
523}