1use std::time::Duration;
17
18use serde_json::{json, Value};
19
20use crate::config::TelemetryCfg;
21
22#[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 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#[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 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 pub status_ok: bool,
72 pub input: Option<String>,
73 pub output: Option<String>,
74 pub session_id: Option<String>,
75}
76
77#[derive(Clone)]
79pub struct TelemetryRuntime {
80 pub enabled: bool,
81 endpoint: String,
82 sample_rate: f64,
83 service_name: String,
84 pub capture_content: bool,
86 pub max_content_bytes: usize,
87 client: reqwest::Client,
88}
89
90impl TelemetryRuntime {
91 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 pub fn disabled() -> Self {
116 Self::build(&TelemetryCfg::default())
117 }
118
119 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 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 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
157pub 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
171pub 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, "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 } }, "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 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
254fn 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; }
270 Some((trace, span))
271}
272
273fn 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
288fn 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
297fn rand16() -> [u8; 16] {
299 uuid::Uuid::new_v4().into_bytes()
300}
301fn 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 #[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 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 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 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 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 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 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(), ..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}