1use std::collections::BTreeMap;
4use std::fmt;
5use std::str::FromStr;
6
7use crate::telemetry_core::propagation;
8use anyhow::{anyhow, Result};
9use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
10use opentelemetry::{Context as OtelContext, KeyValue};
11use serde_json::{Map, Number, Value};
12use uuid::Uuid;
13
14pub const ATTR_PREFIX: &str = "fleetforge";
16
17#[derive(Debug, Clone)]
19pub struct TraceContext {
20 trace_id: TraceId,
21 span_id: SpanId,
22 parent_span_id: Option<SpanId>,
23 trace_flags: TraceFlags,
24 tracestate: Option<String>,
25 pub run: RunScope,
26 pub step: Option<StepScope>,
27 pub tool_call: Option<ToolCallScope>,
28 pub policy_decision: Option<PolicyDecisionScope>,
29 pub budget: Option<BudgetScope>,
30 pub seed: Option<i64>,
31 pub baggage: Vec<(String, String)>,
32}
33
34impl TraceContext {
35 pub fn new(run: RunScope) -> Self {
37 Self {
38 trace_id: new_trace_id(),
39 span_id: new_span_id(),
40 parent_span_id: None,
41 trace_flags: TraceFlags::SAMPLED,
42 tracestate: None,
43 run,
44 step: None,
45 tool_call: None,
46 policy_decision: None,
47 budget: None,
48 seed: None,
49 baggage: Vec::new(),
50 }
51 }
52
53 pub fn with_trace_id(mut self, trace_id: TraceId) -> Self {
55 self.trace_id = trace_id;
56 self
57 }
58
59 pub fn with_span_id(mut self, span_id: SpanId) -> Self {
61 self.span_id = span_id;
62 self
63 }
64
65 pub fn with_parent_span_id(mut self, parent_span_id: impl Into<Option<SpanId>>) -> Self {
67 self.parent_span_id = parent_span_id.into();
68 self
69 }
70
71 pub fn from_parent(run: RunScope, trace_id: TraceId, parent_span_id: SpanId) -> Self {
74 Self {
75 trace_id,
76 span_id: new_span_id(),
77 parent_span_id: Some(parent_span_id),
78 trace_flags: TraceFlags::SAMPLED,
79 tracestate: None,
80 run,
81 step: None,
82 tool_call: None,
83 policy_decision: None,
84 budget: None,
85 seed: None,
86 baggage: Vec::new(),
87 }
88 }
89
90 pub fn with_seed(mut self, seed: impl Into<Option<i64>>) -> Self {
92 self.seed = seed.into();
93 self
94 }
95
96 pub fn with_trace_flags(mut self, flags: TraceFlags) -> Self {
98 self.trace_flags = flags;
99 self
100 }
101
102 pub fn with_tracestate(mut self, tracestate: impl Into<Option<String>>) -> Self {
104 self.tracestate = tracestate.into();
105 self
106 }
107
108 pub fn from_w3c(
110 run: RunScope,
111 traceparent: &str,
112 tracestate: Option<&str>,
113 baggage: Option<&str>,
114 ) -> Result<Self> {
115 let (trace_id, parent_span_id, trace_flags) = parse_traceparent(traceparent)?;
116 let mut ctx = Self::new(run)
117 .with_trace_id(trace_id)
118 .with_parent_span_id(parent_span_id)
119 .with_trace_flags(trace_flags);
120
121 if let Some(state) = tracestate {
122 let trimmed = state.trim();
123 if !trimmed.is_empty() {
124 ctx.tracestate = Some(trimmed.to_string());
125 }
126 }
127
128 if let Some(raw) = baggage {
129 ctx.baggage = parse_baggage(raw)?;
130 }
131
132 Ok(ctx)
133 }
134
135 pub fn with_baggage<I, K, V>(mut self, baggage: I) -> Self
137 where
138 I: IntoIterator<Item = (K, V)>,
139 K: Into<String>,
140 V: Into<String>,
141 {
142 self.baggage = baggage
143 .into_iter()
144 .map(|(k, v)| (k.into(), v.into()))
145 .collect();
146 self
147 }
148
149 pub fn with_budget(mut self, budget: BudgetScope) -> Self {
151 self.budget = Some(budget);
152 self
153 }
154
155 pub fn child_step(&self, scope: StepScope) -> Self {
157 let mut derived = self.derived();
158 derived.step = Some(scope);
159 derived.tool_call = None;
160 derived.policy_decision = None;
161 derived
162 }
163
164 pub fn child_tool_call(&self, scope: ToolCallScope) -> Self {
166 let mut derived = self.derived();
167 derived.step = self.step.clone();
168 derived.tool_call = Some(scope);
169 derived.policy_decision = None;
170 derived
171 }
172
173 pub fn child_policy_decision(&self, scope: PolicyDecisionScope) -> Self {
175 let mut derived = self.derived();
176 derived.step = self.step.clone();
177 derived.tool_call = self.tool_call.clone();
178 derived.policy_decision = Some(scope);
179 derived
180 }
181
182 pub fn trace_id(&self) -> TraceId {
184 self.trace_id
185 }
186
187 pub fn span_id(&self) -> SpanId {
189 self.span_id
190 }
191
192 pub fn trace_flags(&self) -> TraceFlags {
194 self.trace_flags
195 }
196
197 pub fn parent_span_id(&self) -> Option<SpanId> {
199 self.parent_span_id
200 }
201
202 pub fn traceparent(&self) -> String {
204 propagation::traceparent(self.trace_id, self.span_id, self.trace_flags)
205 }
206
207 pub fn tracestate(&self) -> Option<String> {
209 self.tracestate.clone()
210 }
211
212 pub fn baggage_header(&self) -> Option<String> {
214 propagation::baggage_header(&self.baggage)
215 }
216
217 pub fn w3c_headers(&self) -> Vec<(String, String)> {
219 propagation::w3c_headers(
220 self.trace_id,
221 self.span_id,
222 self.trace_flags,
223 self.tracestate.as_deref(),
224 &self.baggage,
225 )
226 }
227
228 pub fn as_parent_context(&self) -> OtelContext {
230 let trace_state = self
231 .tracestate
232 .as_ref()
233 .and_then(|state| TraceState::from_str(state).ok())
234 .unwrap_or_else(TraceState::default);
235
236 let span_context = SpanContext::new(
237 self.trace_id,
238 self.span_id,
239 self.trace_flags,
240 true,
241 trace_state,
242 );
243
244 OtelContext::current().with_remote_span_context(span_context)
245 }
246
247 pub fn from_span_context(
249 run: RunScope,
250 step: Option<StepScope>,
251 tool_call: Option<ToolCallScope>,
252 policy_decision: Option<PolicyDecisionScope>,
253 budget: Option<BudgetScope>,
254 parent_span_id: Option<SpanId>,
255 span_context: SpanContext,
256 seed: Option<i64>,
257 baggage: Vec<(String, String)>,
258 ) -> Self {
259 let tracestate = span_context.trace_state().header();
260 Self {
261 trace_id: span_context.trace_id(),
262 span_id: span_context.span_id(),
263 parent_span_id,
264 trace_flags: span_context.trace_flags(),
265 tracestate: if tracestate.is_empty() {
266 None
267 } else {
268 Some(tracestate)
269 },
270 run,
271 step,
272 tool_call,
273 policy_decision,
274 budget,
275 seed,
276 baggage,
277 }
278 }
279
280 pub fn attributes(&self) -> Vec<KeyValue> {
283 let mut attrs = Vec::with_capacity(24);
284
285 attrs.push(KeyValue::new(
286 format!("{ATTR_PREFIX}.trace.trace_id"),
287 self.trace_id.to_string(),
288 ));
289 attrs.push(KeyValue::new(
290 format!("{ATTR_PREFIX}.trace.span_id"),
291 self.span_id.to_string(),
292 ));
293 if let Some(parent) = self.parent_span_id {
294 attrs.push(KeyValue::new(
295 format!("{ATTR_PREFIX}.trace.parent_span_id"),
296 parent.to_string(),
297 ));
298 }
299
300 attrs.push(KeyValue::new(
301 format!("{ATTR_PREFIX}.run.id"),
302 self.run.run_id.clone(),
303 ));
304 if let Some(workspace) = &self.run.workspace_id {
305 attrs.push(KeyValue::new(
306 format!("{ATTR_PREFIX}.workspace.id"),
307 workspace.clone(),
308 ));
309 }
310 if let Some(app_id) = &self.run.app_id {
311 attrs.push(KeyValue::new(
312 format!("{ATTR_PREFIX}.app.id"),
313 app_id.clone(),
314 ));
315 }
316 if let Some(attempt_id) = &self.run.attempt_id {
317 attrs.push(KeyValue::new(
318 format!("{ATTR_PREFIX}.run.attempt_id"),
319 attempt_id.clone(),
320 ));
321 }
322 if let Some(seed) = self.seed {
323 attrs.push(KeyValue::new(
324 format!("{ATTR_PREFIX}.run.seed"),
325 seed as i64,
326 ));
327 }
328 for (key, value) in &self.run.labels {
329 attrs.push(KeyValue::new(
330 format!("{ATTR_PREFIX}.run.label.{key}"),
331 value.clone(),
332 ));
333 }
334
335 if let Some(step) = &self.step {
336 attrs.push(KeyValue::new(
337 format!("{ATTR_PREFIX}.step.id"),
338 step.step_id.clone(),
339 ));
340 if let Some(index) = step.index {
341 attrs.push(KeyValue::new(
342 format!("{ATTR_PREFIX}.step.index"),
343 index as i64,
344 ));
345 }
346 if let Some(attempt) = step.attempt {
347 attrs.push(KeyValue::new(
348 format!("{ATTR_PREFIX}.step.attempt"),
349 attempt as i64,
350 ));
351 }
352 if let Some(kind) = &step.kind {
353 attrs.push(KeyValue::new(
354 format!("{ATTR_PREFIX}.step.kind"),
355 kind.clone(),
356 ));
357 }
358 if let Some(scheduler) = &step.scheduler {
359 attrs.push(KeyValue::new(
360 format!("{ATTR_PREFIX}.step.scheduler"),
361 scheduler.clone(),
362 ));
363 }
364 }
365
366 if let Some(tool) = &self.tool_call {
367 attrs.push(KeyValue::new(
368 format!("{ATTR_PREFIX}.tool.id"),
369 tool.tool_call_id.clone(),
370 ));
371 if let Some(name) = &tool.tool_name {
372 attrs.push(KeyValue::new(
373 format!("{ATTR_PREFIX}.tool.name"),
374 name.clone(),
375 ));
376 }
377 if let Some(variant) = &tool.tool_variant {
378 attrs.push(KeyValue::new(
379 format!("{ATTR_PREFIX}.tool.variant"),
380 variant.clone(),
381 ));
382 }
383 if let Some(provider) = &tool.provider {
384 attrs.push(KeyValue::new(
385 format!("{ATTR_PREFIX}.tool.provider"),
386 provider.clone(),
387 ));
388 }
389 }
390
391 if let Some(policy) = &self.policy_decision {
392 attrs.push(KeyValue::new(
393 format!("{ATTR_PREFIX}.policy.decision_id"),
394 policy.policy_decision_id.clone(),
395 ));
396 if let Some(pack_id) = &policy.policy_pack_id {
397 attrs.push(KeyValue::new(
398 format!("{ATTR_PREFIX}.policy.pack_id"),
399 pack_id.clone(),
400 ));
401 }
402 if let Some(name) = &policy.policy_name {
403 attrs.push(KeyValue::new(
404 format!("{ATTR_PREFIX}.policy.name"),
405 name.clone(),
406 ));
407 }
408 if let Some(version) = &policy.policy_version {
409 attrs.push(KeyValue::new(
410 format!("{ATTR_PREFIX}.policy.version"),
411 version.clone(),
412 ));
413 }
414 }
415
416 if let Some(budget) = &self.budget {
417 attrs.push(KeyValue::new(
418 format!("{ATTR_PREFIX}.budget.id"),
419 budget.budget_id.clone(),
420 ));
421 if let Some(kind) = &budget.budget_kind {
422 attrs.push(KeyValue::new(
423 format!("{ATTR_PREFIX}.budget.kind"),
424 kind.clone(),
425 ));
426 }
427 if let Some(remaining) = budget.amount_remaining {
428 attrs.push(KeyValue::new(
429 format!("{ATTR_PREFIX}.budget.remaining"),
430 remaining,
431 ));
432 }
433 if let Some(allocated) = budget.amount_allocated {
434 attrs.push(KeyValue::new(
435 format!("{ATTR_PREFIX}.budget.allocated"),
436 allocated,
437 ));
438 }
439 }
440
441 attrs
442 }
443
444 pub fn to_json(&self) -> Value {
446 let mut root = Map::new();
447 root.insert(
448 "trace_id".to_string(),
449 Value::String(self.trace_id.to_string()),
450 );
451 root.insert(
452 "span_id".to_string(),
453 Value::String(self.span_id.to_string()),
454 );
455 if let Some(parent) = self.parent_span_id {
456 root.insert(
457 "parent_span_id".to_string(),
458 Value::String(parent.to_string()),
459 );
460 }
461 if !self.tracestate.as_deref().unwrap_or_default().is_empty() {
462 if let Some(state) = &self.tracestate {
463 root.insert("tracestate".to_string(), Value::String(state.clone()));
464 }
465 }
466 root.insert(
467 "trace_flags".to_string(),
468 Value::Number(Number::from(u64::from(self.trace_flags.to_u8()))),
469 );
470 if let Some(seed) = self.seed {
471 root.insert("seed".to_string(), Value::Number(Number::from(seed)));
472 }
473
474 let mut run_map = Map::new();
475 run_map.insert("run_id".to_string(), Value::String(self.run.run_id.clone()));
476 if let Some(workspace) = &self.run.workspace_id {
477 run_map.insert("workspace_id".to_string(), Value::String(workspace.clone()));
478 }
479 if let Some(app_id) = &self.run.app_id {
480 run_map.insert("app_id".to_string(), Value::String(app_id.clone()));
481 }
482 if let Some(attempt) = &self.run.attempt_id {
483 run_map.insert("attempt_id".to_string(), Value::String(attempt.clone()));
484 }
485 if !self.run.labels.is_empty() {
486 let labels = self
487 .run
488 .labels
489 .iter()
490 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
491 .collect::<Map<_, _>>();
492 run_map.insert("labels".to_string(), Value::Object(labels));
493 }
494 root.insert("run".to_string(), Value::Object(run_map));
495
496 if let Some(step) = &self.step {
497 let mut step_map = Map::new();
498 step_map.insert("step_id".to_string(), Value::String(step.step_id.clone()));
499 if let Some(index) = step.index {
500 step_map.insert("index".to_string(), Value::Number(Number::from(index)));
501 }
502 if let Some(attempt) = step.attempt {
503 step_map.insert("attempt".to_string(), Value::Number(Number::from(attempt)));
504 }
505 if let Some(kind) = &step.kind {
506 step_map.insert("kind".to_string(), Value::String(kind.clone()));
507 }
508 if let Some(scheduler) = &step.scheduler {
509 step_map.insert("scheduler".to_string(), Value::String(scheduler.clone()));
510 }
511 root.insert("step".to_string(), Value::Object(step_map));
512 }
513
514 if let Some(tool) = &self.tool_call {
515 let mut tool_map = Map::new();
516 tool_map.insert(
517 "tool_call_id".to_string(),
518 Value::String(tool.tool_call_id.clone()),
519 );
520 if let Some(name) = &tool.tool_name {
521 tool_map.insert("tool_name".to_string(), Value::String(name.clone()));
522 }
523 if let Some(variant) = &tool.tool_variant {
524 tool_map.insert("tool_variant".to_string(), Value::String(variant.clone()));
525 }
526 if let Some(provider) = &tool.provider {
527 tool_map.insert("provider".to_string(), Value::String(provider.clone()));
528 }
529 root.insert("tool_call".to_string(), Value::Object(tool_map));
530 }
531
532 if let Some(policy) = &self.policy_decision {
533 let mut policy_map = Map::new();
534 policy_map.insert(
535 "policy_decision_id".to_string(),
536 Value::String(policy.policy_decision_id.clone()),
537 );
538 if let Some(pack) = &policy.policy_pack_id {
539 policy_map.insert("policy_pack_id".to_string(), Value::String(pack.clone()));
540 }
541 if let Some(name) = &policy.policy_name {
542 policy_map.insert("policy_name".to_string(), Value::String(name.clone()));
543 }
544 if let Some(version) = &policy.policy_version {
545 policy_map.insert("policy_version".to_string(), Value::String(version.clone()));
546 }
547 root.insert("policy_decision".to_string(), Value::Object(policy_map));
548 }
549
550 if let Some(budget) = &self.budget {
551 let mut budget_map = Map::new();
552 budget_map.insert(
553 "budget_id".to_string(),
554 Value::String(budget.budget_id.clone()),
555 );
556 if let Some(kind) = &budget.budget_kind {
557 budget_map.insert("budget_kind".to_string(), Value::String(kind.clone()));
558 }
559 if let Some(remaining) = budget
560 .amount_remaining
561 .and_then(|value| Number::from_f64(value))
562 {
563 budget_map.insert("amount_remaining".to_string(), Value::Number(remaining));
564 }
565 if let Some(allocated) = budget
566 .amount_allocated
567 .and_then(|value| Number::from_f64(value))
568 {
569 budget_map.insert("amount_allocated".to_string(), Value::Number(allocated));
570 }
571 root.insert("budget".to_string(), Value::Object(budget_map));
572 }
573
574 if !self.baggage.is_empty() {
575 let baggage = self
576 .baggage
577 .iter()
578 .map(|(key, value)| {
579 let mut map = Map::new();
580 map.insert("key".to_string(), Value::String(key.clone()));
581 map.insert("value".to_string(), Value::String(value.clone()));
582 Value::Object(map)
583 })
584 .collect();
585 root.insert("baggage".to_string(), Value::Array(baggage));
586 }
587
588 Value::Object(root)
589 }
590
591 fn derived(&self) -> Self {
592 Self {
593 trace_id: self.trace_id,
594 span_id: new_span_id(),
595 parent_span_id: Some(self.span_id),
596 trace_flags: self.trace_flags,
597 tracestate: self.tracestate.clone(),
598 run: self.run.clone(),
599 step: None,
600 tool_call: None,
601 policy_decision: None,
602 budget: self.budget.clone(),
603 seed: self.seed,
604 baggage: self.baggage.clone(),
605 }
606 }
607}
608
609#[derive(Debug, Clone)]
611pub struct RunScope {
612 pub run_id: String,
613 pub workspace_id: Option<String>,
614 pub app_id: Option<String>,
615 pub attempt_id: Option<String>,
616 pub labels: BTreeMap<String, String>,
617}
618
619impl RunScope {
620 pub fn new(run_id: impl Into<String>) -> Self {
621 Self {
622 run_id: run_id.into(),
623 workspace_id: None,
624 app_id: None,
625 attempt_id: None,
626 labels: BTreeMap::new(),
627 }
628 }
629
630 pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {
631 self.workspace_id = Some(workspace_id.into());
632 self
633 }
634
635 pub fn with_app(mut self, app_id: impl Into<String>) -> Self {
636 self.app_id = Some(app_id.into());
637 self
638 }
639
640 pub fn with_attempt(mut self, attempt_id: impl Into<String>) -> Self {
641 self.attempt_id = Some(attempt_id.into());
642 self
643 }
644
645 pub fn with_labels<I, K, V>(mut self, labels: I) -> Self
646 where
647 I: IntoIterator<Item = (K, V)>,
648 K: Into<String>,
649 V: Into<String>,
650 {
651 for (key, value) in labels.into_iter() {
652 self.labels.insert(key.into(), value.into());
653 }
654 self
655 }
656}
657
658#[derive(Debug, Clone)]
660pub struct StepScope {
661 pub step_id: String,
662 pub index: Option<u32>,
663 pub attempt: Option<u32>,
664 pub kind: Option<String>,
665 pub scheduler: Option<String>,
666}
667
668impl StepScope {
669 pub fn new(step_id: impl Into<String>) -> Self {
670 Self {
671 step_id: step_id.into(),
672 index: None,
673 attempt: None,
674 kind: None,
675 scheduler: None,
676 }
677 }
678
679 pub fn with_index(mut self, index: u32) -> Self {
680 self.index = Some(index);
681 self
682 }
683
684 pub fn with_attempt(mut self, attempt: u32) -> Self {
685 self.attempt = Some(attempt);
686 self
687 }
688
689 pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
690 self.kind = Some(kind.into());
691 self
692 }
693
694 pub fn with_scheduler(mut self, scheduler: impl Into<String>) -> Self {
695 self.scheduler = Some(scheduler.into());
696 self
697 }
698}
699
700#[derive(Debug, Clone)]
702pub struct ToolCallScope {
703 pub tool_call_id: String,
704 pub tool_name: Option<String>,
705 pub tool_variant: Option<String>,
706 pub provider: Option<String>,
707}
708
709impl ToolCallScope {
710 pub fn new(tool_call_id: impl Into<String>) -> Self {
711 Self {
712 tool_call_id: tool_call_id.into(),
713 tool_name: None,
714 tool_variant: None,
715 provider: None,
716 }
717 }
718
719 pub fn with_name(mut self, name: impl Into<String>) -> Self {
720 self.tool_name = Some(name.into());
721 self
722 }
723
724 pub fn with_variant(mut self, variant: impl Into<String>) -> Self {
725 self.tool_variant = Some(variant.into());
726 self
727 }
728
729 pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
730 self.provider = Some(provider.into());
731 self
732 }
733}
734
735#[derive(Debug, Clone)]
737pub struct PolicyDecisionScope {
738 pub policy_decision_id: String,
739 pub policy_pack_id: Option<String>,
740 pub policy_name: Option<String>,
741 pub policy_version: Option<String>,
742}
743
744impl PolicyDecisionScope {
745 pub fn new(policy_decision_id: impl Into<String>) -> Self {
746 Self {
747 policy_decision_id: policy_decision_id.into(),
748 policy_pack_id: None,
749 policy_name: None,
750 policy_version: None,
751 }
752 }
753
754 pub fn with_pack(mut self, pack_id: impl Into<String>) -> Self {
755 self.policy_pack_id = Some(pack_id.into());
756 self
757 }
758
759 pub fn with_name(mut self, name: impl Into<String>) -> Self {
760 self.policy_name = Some(name.into());
761 self
762 }
763
764 pub fn with_version(mut self, version: impl Into<String>) -> Self {
765 self.policy_version = Some(version.into());
766 self
767 }
768}
769
770#[derive(Debug, Clone)]
772pub struct BudgetScope {
773 pub budget_id: String,
774 pub budget_kind: Option<String>,
775 pub amount_remaining: Option<f64>,
776 pub amount_allocated: Option<f64>,
777}
778
779impl BudgetScope {
780 pub fn new(budget_id: impl Into<String>) -> Self {
781 Self {
782 budget_id: budget_id.into(),
783 budget_kind: None,
784 amount_remaining: None,
785 amount_allocated: None,
786 }
787 }
788
789 pub fn with_kind(mut self, kind: impl Into<String>) -> Self {
790 self.budget_kind = Some(kind.into());
791 self
792 }
793
794 pub fn with_remaining(mut self, remaining: f64) -> Self {
795 self.amount_remaining = Some(remaining);
796 self
797 }
798
799 pub fn with_allocated(mut self, allocated: f64) -> Self {
800 self.amount_allocated = Some(allocated);
801 self
802 }
803}
804
805fn new_trace_id() -> TraceId {
806 TraceId::from_bytes(*Uuid::new_v4().as_bytes())
807}
808
809fn new_span_id() -> SpanId {
810 let raw = Uuid::new_v4().as_u128();
811 let high = (raw >> 64) as u64;
812 SpanId::from_bytes(high.to_be_bytes())
813}
814
815fn parse_traceparent(header: &str) -> Result<(TraceId, Option<SpanId>, TraceFlags)> {
816 let mut parts = header.split('-');
817 let version = parts
818 .next()
819 .ok_or_else(|| anyhow!("traceparent missing version"))?;
820 if version.len() != 2 {
821 return Err(anyhow!("traceparent version must be 2 hex chars"));
822 }
823
824 let trace_id_str = parts
825 .next()
826 .ok_or_else(|| anyhow!("traceparent missing trace id"))?;
827 if trace_id_str.len() != 32 {
828 return Err(anyhow!("traceparent trace id must be 32 hex chars"));
829 }
830 let trace_id = TraceId::from_hex(trace_id_str)
831 .map_err(|err| anyhow!("invalid trace id '{}': {err}", trace_id_str))?;
832
833 let span_id_str = parts
834 .next()
835 .ok_or_else(|| anyhow!("traceparent missing parent span id"))?;
836 if span_id_str.len() != 16 {
837 return Err(anyhow!("traceparent span id must be 16 hex chars"));
838 }
839 let parent_span_id = SpanId::from_hex(span_id_str)
840 .map_err(|err| anyhow!("invalid span id '{}': {err}", span_id_str))?;
841
842 let flags_str = parts
843 .next()
844 .ok_or_else(|| anyhow!("traceparent missing trace flags"))?;
845 if flags_str.len() != 2 {
846 return Err(anyhow!("traceparent trace flags must be 2 hex chars"));
847 }
848 let flags_byte = u8::from_str_radix(flags_str, 16)
849 .map_err(|err| anyhow!("invalid trace flags '{}': {err}", flags_str))?;
850 let trace_flags = TraceFlags::new(flags_byte);
851
852 Ok((trace_id, Some(parent_span_id), trace_flags))
853}
854
855fn parse_baggage(header: &str) -> Result<Vec<(String, String)>> {
856 let mut baggage = Vec::new();
857
858 for item in header.split(',') {
859 let trimmed = item.trim();
860 if trimmed.is_empty() {
861 continue;
862 }
863 let mut parts = trimmed.splitn(2, '=');
864 let raw_key = parts
865 .next()
866 .ok_or_else(|| anyhow!("baggage member missing key"))?
867 .trim();
868 let raw_value = parts
869 .next()
870 .ok_or_else(|| anyhow!("baggage member missing value"))?;
871
872 let value_without_props = raw_value
873 .split(';')
874 .next()
875 .ok_or_else(|| anyhow!("baggage member missing value component"))?
876 .trim();
877
878 let key = decode_baggage_token(raw_key)?;
879 let value = decode_baggage_token(value_without_props)?;
880
881 baggage.push((key, value));
882 }
883
884 Ok(baggage)
885}
886
887fn decode_baggage_token(input: &str) -> Result<String> {
888 let bytes = input.as_bytes();
889 let mut output = String::with_capacity(bytes.len());
890 let mut idx = 0;
891
892 while idx < bytes.len() {
893 match bytes[idx] {
894 b'%' => {
895 if idx + 2 >= bytes.len() {
896 return Err(anyhow!("invalid percent-encoding in baggage token"));
897 }
898 let hex = &bytes[idx + 1..idx + 3];
899 let hex_str = std::str::from_utf8(hex)
900 .map_err(|err| anyhow!("invalid utf8 in baggage escape: {err}"))?;
901 let value = u8::from_str_radix(hex_str, 16)
902 .map_err(|err| anyhow!("invalid baggage escape '%{}': {err}", hex_str))?;
903 output.push(value as char);
904 idx += 3;
905 }
906 b'+' => {
907 output.push(' ');
908 idx += 1;
909 }
910 byte => {
911 output.push(byte as char);
912 idx += 1;
913 }
914 }
915 }
916
917 Ok(output)
918}
919
920impl fmt::Display for TraceContext {
921 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
922 write!(
923 f,
924 "TraceContext(trace_id={}, span_id={}, run_id={})",
925 self.trace_id.to_string(),
926 self.span_id.to_string(),
927 self.run.run_id
928 )
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use opentelemetry::trace::TraceFlags;
936 use opentelemetry::Value;
937
938 #[test]
939 fn root_context_produces_traceparent() {
940 let ctx = TraceContext::new(RunScope::new("run-123"));
941 let traceparent = ctx.traceparent();
942 assert!(traceparent.starts_with("00-"));
943 assert_eq!(traceparent.len(), 55);
944 assert!(traceparent.ends_with("-01") || traceparent.ends_with("-00"));
945 }
946
947 #[test]
948 fn child_step_inherits_trace_id() {
949 let root = TraceContext::new(RunScope::new("run-123"));
950 let child = root.child_step(StepScope::new("step-1").with_index(1));
951
952 assert_eq!(root.trace_id(), child.trace_id());
953 assert_ne!(root.span_id(), child.span_id());
954 assert_eq!(child.parent_span_id(), Some(root.span_id()));
955 assert!(child.step.is_some());
956 assert!(child.tool_call.is_none());
957 }
958
959 #[test]
960 fn attributes_include_scope_metadata() {
961 let ctx = TraceContext::new(
962 RunScope::new("run-123")
963 .with_workspace("ws-1")
964 .with_app("app-9")
965 .with_labels([("env", "prod"), ("team", "mlops")]),
966 )
967 .with_seed(Some(42))
968 .child_step(
969 StepScope::new("step-1")
970 .with_index(3)
971 .with_attempt(2)
972 .with_kind("llm"),
973 )
974 .child_tool_call(
975 ToolCallScope::new("tool-1")
976 .with_name("search")
977 .with_provider("internal"),
978 );
979
980 let attrs = ctx.attributes();
981 assert!(attrs.iter().any(|kv| kv.key.as_str() == "fleetforge.run.id"
982 && matches!(kv.value, Value::String(ref v) if v == "run-123")));
983 assert!(attrs
984 .iter()
985 .any(|kv| kv.key.as_str() == "fleetforge.step.kind"
986 && matches!(kv.value, Value::String(ref v) if v == "llm")));
987 assert!(attrs
988 .iter()
989 .any(|kv| kv.key.as_str() == "fleetforge.tool.provider"
990 && matches!(kv.value, Value::String(ref v) if v == "internal")));
991 assert!(attrs.iter().any(
992 |kv| kv.key.as_str() == "fleetforge.run.seed" && matches!(kv.value, Value::I64(42))
993 ));
994 }
995
996 #[test]
997 fn baggage_header_is_percent_encoded() {
998 let ctx = TraceContext::new(RunScope::new("run-abc"))
999 .with_baggage([("Budget Tier", "gold;primary")]);
1000 let baggage = ctx.baggage_header().expect("baggage header");
1001 assert_eq!(baggage, "Budget%20Tier=gold%3Bprimary");
1002 }
1003
1004 #[test]
1005 fn from_w3c_parses_headers() {
1006 let traceparent = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
1007 let baggage = "tenant-id=acme,env=prod";
1008 let ctx = TraceContext::from_w3c(
1009 RunScope::new("run-xyz"),
1010 traceparent,
1011 Some("ro=1"),
1012 Some(baggage),
1013 )
1014 .expect("parse traceparent");
1015
1016 assert_eq!(
1017 ctx.trace_id().to_string(),
1018 "4bf92f3577b34da6a3ce929d0e0e4736"
1019 );
1020 assert_eq!(
1021 ctx.parent_span_id().unwrap().to_string(),
1022 "00f067aa0ba902b7"
1023 );
1024 assert_eq!(ctx.trace_flags(), TraceFlags::SAMPLED);
1025 assert_eq!(ctx.tracestate(), Some("ro=1".to_string()));
1026 assert_eq!(
1027 ctx.baggage,
1028 vec![
1029 ("tenant-id".to_string(), "acme".to_string()),
1030 ("env".to_string(), "prod".to_string()),
1031 ]
1032 );
1033 }
1034
1035 #[test]
1036 fn to_json_includes_basic_fields() {
1037 let ctx = TraceContext::new(RunScope::new("run-xyz"))
1038 .with_seed(Some(42))
1039 .child_step(
1040 StepScope::new("step-123")
1041 .with_index(3)
1042 .with_attempt(2)
1043 .with_kind("tool"),
1044 );
1045 let json = ctx.to_json();
1046 let run_id = json
1047 .get("run")
1048 .and_then(|value| value.as_object())
1049 .and_then(|map| map.get("run_id"))
1050 .and_then(|value| value.as_str())
1051 .unwrap();
1052 assert_eq!(run_id, "run-xyz");
1053 let step_kind = json
1054 .get("step")
1055 .and_then(|value| value.as_object())
1056 .and_then(|map| map.get("kind"))
1057 .and_then(|value| value.as_str())
1058 .unwrap();
1059 assert_eq!(step_kind, "tool");
1060 }
1061}