1use std::fmt;
40use std::process;
41use std::sync::atomic::{AtomicU64, Ordering};
42use std::time::{SystemTime, UNIX_EPOCH};
43
44use serde::{Deserialize, Serialize};
45use sha2::{Digest, Sha256};
46use thiserror::Error;
47
48static SPAN_COUNTER: AtomicU64 = AtomicU64::new(0);
49
50#[derive(Debug, Error, PartialEq, Eq)]
61pub enum TraceContextError {
62 #[error("invalid traceparent format: expected 4 dash-separated fields, got {field_count}")]
64 InvalidFormat {
65 field_count: usize,
67 },
68
69 #[error("invalid trace-id: expected 32 lowercase hex chars, got \"{value}\"")]
71 InvalidTraceId {
72 value: String,
74 },
75
76 #[error("invalid span-id: expected 16 lowercase hex chars, got \"{value}\"")]
78 InvalidSpanId {
79 value: String,
81 },
82
83 #[error("trace-id must not be all zeros")]
85 ZeroTraceId,
86
87 #[error("span-id must not be all zeros")]
89 ZeroSpanId,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct WorkflowTraceContext {
111 trace_id: String,
112 span_id: String,
113}
114
115impl WorkflowTraceContext {
116 pub fn new_root() -> Self {
131 let trace_id = generate_trace_id();
132 let span_id = generate_span_id();
133 Self { trace_id, span_id }
134 }
135
136 pub fn from_workflow_run_id(run_id: &str) -> Self {
159 assert!(!run_id.is_empty(), "run_id must not be empty");
160 let mut hasher = Sha256::new();
161 hasher.update(run_id.as_bytes());
162 let hash = hasher.finalize();
163 let trace_id = hex_encode(&hash[..16]);
164 let span_id = generate_span_id();
165 Self { trace_id, span_id }
166 }
167
168 pub fn child(&self) -> Self {
184 Self {
185 trace_id: self.trace_id.clone(),
186 span_id: generate_span_id(),
187 }
188 }
189
190 pub fn to_traceparent(&self) -> String {
209 format!("00-{}-{}-01", self.trace_id, self.span_id)
210 }
211
212 pub fn from_traceparent(header: &str) -> Result<Self, TraceContextError> {
238 let parts: Vec<&str> = header.split('-').collect();
239 if parts.len() < 4 {
240 return Err(TraceContextError::InvalidFormat {
241 field_count: parts.len(),
242 });
243 }
244
245 let trace_id = parts[1];
246 let span_id = parts[2];
247
248 if trace_id.len() != 32 || !trace_id.chars().all(|c| c.is_ascii_hexdigit()) {
249 return Err(TraceContextError::InvalidTraceId {
250 value: trace_id.to_string(),
251 });
252 }
253
254 if trace_id.chars().all(|c| c == '0') {
255 return Err(TraceContextError::ZeroTraceId);
256 }
257
258 if span_id.len() != 16 || !span_id.chars().all(|c| c.is_ascii_hexdigit()) {
259 return Err(TraceContextError::InvalidSpanId {
260 value: span_id.to_string(),
261 });
262 }
263
264 if span_id.chars().all(|c| c == '0') {
265 return Err(TraceContextError::ZeroSpanId);
266 }
267
268 Ok(Self {
269 trace_id: trace_id.to_lowercase(),
270 span_id: span_id.to_lowercase(),
271 })
272 }
273
274 pub fn trace_id(&self) -> &str {
276 &self.trace_id
277 }
278
279 pub fn span_id(&self) -> &str {
281 &self.span_id
282 }
283}
284
285impl fmt::Display for WorkflowTraceContext {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 write!(f, "{}", self.to_traceparent())
288 }
289}
290
291fn generate_trace_id() -> String {
292 let nanos = SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .unwrap_or_default()
295 .as_nanos();
296 let counter = SPAN_COUNTER.fetch_add(1, Ordering::Relaxed);
297 let pid = process::id();
298
299 let mut hasher = Sha256::new();
300 hasher.update(nanos.to_le_bytes());
301 hasher.update(counter.to_le_bytes());
302 hasher.update(pid.to_le_bytes());
303 hasher.update(b"trace");
304 let hash = hasher.finalize();
305 hex_encode(&hash[..16])
306}
307
308fn generate_span_id() -> String {
309 let nanos = SystemTime::now()
310 .duration_since(UNIX_EPOCH)
311 .unwrap_or_default()
312 .as_nanos();
313 let counter = SPAN_COUNTER.fetch_add(1, Ordering::Relaxed);
314 let pid = process::id();
315
316 let mut hasher = Sha256::new();
317 hasher.update(nanos.to_le_bytes());
318 hasher.update(counter.to_le_bytes());
319 hasher.update(pid.to_le_bytes());
320 hasher.update(b"span");
321 let hash = hasher.finalize();
322 hex_encode(&hash[..8])
323}
324
325fn hex_encode(bytes: &[u8]) -> String {
326 bytes.iter().map(|b| format!("{b:02x}")).collect()
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn new_root_produces_valid_ids() {
335 let ctx = WorkflowTraceContext::new_root();
336 assert_eq!(ctx.trace_id().len(), 32, "trace_id must be 32 hex chars");
337 assert_eq!(ctx.span_id().len(), 16, "span_id must be 16 hex chars");
338 assert!(
339 ctx.trace_id().chars().all(|c| c.is_ascii_hexdigit()),
340 "trace_id must be valid hex"
341 );
342 assert!(
343 ctx.span_id().chars().all(|c| c.is_ascii_hexdigit()),
344 "span_id must be valid hex"
345 );
346 }
347
348 #[test]
349 fn new_root_produces_unique_contexts() {
350 let a = WorkflowTraceContext::new_root();
351 let b = WorkflowTraceContext::new_root();
352 assert_ne!(a.trace_id(), b.trace_id());
353 }
354
355 #[test]
356 fn from_workflow_run_id_deterministic() {
357 let a = WorkflowTraceContext::from_workflow_run_id("run-abc-123");
358 let b = WorkflowTraceContext::from_workflow_run_id("run-abc-123");
359 assert_eq!(
360 a.trace_id(),
361 b.trace_id(),
362 "same run_id must produce same trace_id"
363 );
364 assert_eq!(a.trace_id().len(), 32);
365 assert!(a.trace_id().chars().all(|c| c.is_ascii_hexdigit()));
366 }
367
368 #[test]
369 fn from_workflow_run_id_different_inputs() {
370 let a = WorkflowTraceContext::from_workflow_run_id("run-1");
371 let b = WorkflowTraceContext::from_workflow_run_id("run-2");
372 assert_ne!(a.trace_id(), b.trace_id());
373 }
374
375 #[test]
376 #[should_panic(expected = "run_id must not be empty")]
377 fn from_workflow_run_id_empty_panics() {
378 WorkflowTraceContext::from_workflow_run_id("");
379 }
380
381 #[test]
382 fn child_preserves_trace_id() {
383 let parent = WorkflowTraceContext::new_root();
384 let child = parent.child();
385 assert_eq!(child.trace_id(), parent.trace_id());
386 assert_ne!(child.span_id(), parent.span_id());
387 assert_eq!(child.span_id().len(), 16);
388 }
389
390 #[test]
391 fn child_children_are_unique() {
392 let parent = WorkflowTraceContext::new_root();
393 let c1 = parent.child();
394 let c2 = parent.child();
395 assert_ne!(c1.span_id(), c2.span_id());
396 assert_eq!(c1.trace_id(), c2.trace_id());
397 }
398
399 #[test]
400 fn to_traceparent_format() {
401 let ctx = WorkflowTraceContext::new_root();
402 let header = ctx.to_traceparent();
403
404 assert!(header.starts_with("00-"), "must start with version 00");
405 assert!(header.ends_with("-01"), "must end with trace-flags 01");
406 assert_eq!(header.len(), 55, "00-<32>-<16>-01 = 55 chars");
407
408 let parts: Vec<&str> = header.split('-').collect();
409 assert_eq!(parts.len(), 4);
410 assert_eq!(parts[0], "00");
411 assert_eq!(parts[1], ctx.trace_id());
412 assert_eq!(parts[2], ctx.span_id());
413 assert_eq!(parts[3], "01");
414 }
415
416 #[test]
417 fn display_matches_to_traceparent() {
418 let ctx = WorkflowTraceContext::new_root();
419 assert_eq!(format!("{ctx}"), ctx.to_traceparent());
420 }
421
422 #[test]
423 fn from_traceparent_valid() {
424 let ctx = WorkflowTraceContext::from_traceparent(
425 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
426 )
427 .unwrap();
428 assert_eq!(ctx.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
429 assert_eq!(ctx.span_id(), "00f067aa0ba902b7");
430 }
431
432 #[test]
433 fn from_traceparent_accepts_other_versions() {
434 let ctx = WorkflowTraceContext::from_traceparent(
435 "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
436 )
437 .unwrap();
438 assert_eq!(ctx.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
439 }
440
441 #[test]
442 fn from_traceparent_ignores_extra_fields() {
443 let ctx = WorkflowTraceContext::from_traceparent(
444 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra-stuff",
445 )
446 .unwrap();
447 assert_eq!(ctx.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
448 assert_eq!(ctx.span_id(), "00f067aa0ba902b7");
449 }
450
451 #[test]
452 fn from_traceparent_uppercase_hex_normalized() {
453 let ctx = WorkflowTraceContext::from_traceparent(
454 "00-4BF92F3577B34DA6A3CE929D0E0E4736-00F067AA0BA902B7-01",
455 )
456 .unwrap();
457 assert_eq!(ctx.trace_id(), "4bf92f3577b34da6a3ce929d0e0e4736");
458 assert_eq!(ctx.span_id(), "00f067aa0ba902b7");
459 }
460
461 #[test]
462 fn from_traceparent_invalid_too_few_fields() {
463 let err = WorkflowTraceContext::from_traceparent("00-abc").unwrap_err();
464 assert!(matches!(
465 err,
466 TraceContextError::InvalidFormat { field_count: 2 }
467 ));
468 }
469
470 #[test]
471 fn from_traceparent_invalid_trace_id_length() {
472 let err = WorkflowTraceContext::from_traceparent("00-abc-00f067aa0ba902b7-01").unwrap_err();
473 assert!(matches!(err, TraceContextError::InvalidTraceId { .. }));
474 }
475
476 #[test]
477 fn from_traceparent_invalid_trace_id_hex() {
478 let err = WorkflowTraceContext::from_traceparent(
479 "00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-00f067aa0ba902b7-01",
480 )
481 .unwrap_err();
482 assert!(matches!(err, TraceContextError::InvalidTraceId { .. }));
483 }
484
485 #[test]
486 fn from_traceparent_invalid_span_id_length() {
487 let err =
488 WorkflowTraceContext::from_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-abc-01")
489 .unwrap_err();
490 assert!(matches!(err, TraceContextError::InvalidSpanId { .. }));
491 }
492
493 #[test]
494 fn from_traceparent_zero_trace_id() {
495 let err = WorkflowTraceContext::from_traceparent(
496 "00-00000000000000000000000000000000-00f067aa0ba902b7-01",
497 )
498 .unwrap_err();
499 assert!(matches!(err, TraceContextError::ZeroTraceId));
500 }
501
502 #[test]
503 fn from_traceparent_zero_span_id() {
504 let err = WorkflowTraceContext::from_traceparent(
505 "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01",
506 )
507 .unwrap_err();
508 assert!(matches!(err, TraceContextError::ZeroSpanId));
509 }
510
511 #[test]
512 fn from_traceparent_empty_string() {
513 let err = WorkflowTraceContext::from_traceparent("").unwrap_err();
514 assert!(matches!(err, TraceContextError::InvalidFormat { .. }));
515 }
516
517 #[test]
518 fn roundtrip_to_from_traceparent() {
519 let original = WorkflowTraceContext::new_root();
520 let header = original.to_traceparent();
521 let parsed = WorkflowTraceContext::from_traceparent(&header).unwrap();
522 assert_eq!(parsed.trace_id(), original.trace_id());
523 assert_eq!(parsed.span_id(), original.span_id());
524 assert_eq!(parsed, original);
525 }
526
527 #[test]
528 fn roundtrip_from_workflow_run_id() {
529 let ctx = WorkflowTraceContext::from_workflow_run_id("my-run-42");
530 let header = ctx.to_traceparent();
531 let parsed = WorkflowTraceContext::from_traceparent(&header).unwrap();
532 assert_eq!(parsed.trace_id(), ctx.trace_id());
533 assert_eq!(parsed.span_id(), ctx.span_id());
534 }
535
536 #[test]
537 fn serde_roundtrip() {
538 let ctx = WorkflowTraceContext::new_root();
539 let json = serde_json::to_string(&ctx).unwrap();
540 let back: WorkflowTraceContext = serde_json::from_str(&json).unwrap();
541 assert_eq!(back, ctx);
542 }
543
544 #[test]
545 fn serde_contains_expected_fields() {
546 let ctx = WorkflowTraceContext::from_traceparent(
547 "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
548 )
549 .unwrap();
550 let json: serde_json::Value = serde_json::to_value(&ctx).unwrap();
551 assert_eq!(json["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736");
552 assert_eq!(json["span_id"], "00f067aa0ba902b7");
553 }
554}