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
243pub 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
259pub fn traceparent_header(ctx: &TraceContext) -> String {
265 format!("00-{}-{}-01", hex(&ctx.trace_id), hex(&ctx.span_id))
266}
267
268#[derive(Clone, Debug)]
275pub struct ServerSpan {
276 pub ctx: TraceContext,
277 pub method: String,
279 pub method_original: Option<String>,
281 pub url_path: String,
282 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 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 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
322pub 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 kv_str("http.request.method", &r.method),
344 kv_str("url.path", &r.url_path),
345 kv_str("url.scheme", &r.url_scheme),
346 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 let mut span = json!({
375 "traceId": hex(&r.ctx.trace_id),
376 "spanId": hex(&r.ctx.span_id),
377 "name": r.method, "kind": 2, "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 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
407fn 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; }
423 Some((trace, span))
424}
425
426fn 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
441fn 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
450fn rand16() -> [u8; 16] {
452 uuid::Uuid::new_v4().into_bytes()
453}
454fn 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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(), ..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#[derive(Clone)]
847pub struct SpanShipper {
848 tx: tokio::sync::mpsc::Sender<ServerSpan>,
849 stats: std::sync::Arc<SpanShipStats>,
850}
851
852#[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 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
876pub 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 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 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 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}