1use super::key::ComponentDigest;
8use agent_core::prompt::QualifiedModelId;
9use agent_core::TurnOutcome;
10use serde::{Deserialize, Serialize};
11
12pub const TRACE_SCHEMA: &str = "synaps-request-trace/1";
14
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(try_from = "String", into = "String")]
22pub struct TraceSchemaVersion;
23
24impl TryFrom<String> for TraceSchemaVersion {
25 type Error = String;
26 fn try_from(value: String) -> Result<Self, Self::Error> {
27 if value == TRACE_SCHEMA {
28 Ok(TraceSchemaVersion)
29 } else {
30 Err(format!(
31 "unsupported trace schema tag (expected {TRACE_SCHEMA})"
32 ))
33 }
34 }
35}
36
37impl From<TraceSchemaVersion> for String {
38 fn from(_: TraceSchemaVersion) -> Self {
39 TRACE_SCHEMA.to_string()
40 }
41}
42
43pub const TRACE_ID_MAX_BYTES: usize = 256;
47
48pub const WIRE_NAME_MAX_BYTES: usize = 128;
50
51fn validate_trace_id(s: &str) -> Result<(), String> {
52 if s.is_empty() {
53 return Err("trace id must be nonempty".to_string());
54 }
55 if s.len() > TRACE_ID_MAX_BYTES {
56 return Err(format!(
57 "trace id exceeds {TRACE_ID_MAX_BYTES} bytes ({} given)",
58 s.len()
59 ));
60 }
61 for b in s.bytes() {
62 let ok = b.is_ascii_alphanumeric()
63 || matches!(b, b'-' | b'_' | b'.' | b'/' | b':' | b'[' | b']');
64 if !ok {
65 return Err(format!(
66 "trace id contains forbidden byte 0x{b:02x} (allowed: [A-Za-z0-9._/:\\[\\]-])"
67 ));
68 }
69 }
70 Ok(())
71}
72
73fn validate_wire_name(s: &str) -> Result<(), String> {
74 if s.is_empty() {
75 return Err("wire name must be nonempty".to_string());
76 }
77 if s.len() > WIRE_NAME_MAX_BYTES {
78 return Err(format!(
79 "wire name exceeds {WIRE_NAME_MAX_BYTES} bytes ({} given)",
80 s.len()
81 ));
82 }
83 for b in s.bytes() {
84 if !(b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
85 return Err(format!(
86 "wire name contains forbidden byte 0x{b:02x} (allowed: [A-Za-z0-9_-])"
87 ));
88 }
89 }
90 Ok(())
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(try_from = "String", into = "String")]
101pub struct TraceId(String);
102
103impl TraceId {
104 pub fn new(value: impl Into<String>) -> Result<Self, String> {
106 let value = value.into();
107 validate_trace_id(&value)?;
108 Ok(TraceId(value))
109 }
110
111 pub fn as_str(&self) -> &str {
112 &self.0
113 }
114}
115
116impl std::fmt::Display for TraceId {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.write_str(&self.0)
119 }
120}
121
122impl TryFrom<String> for TraceId {
123 type Error = String;
124 fn try_from(value: String) -> Result<Self, Self::Error> {
125 TraceId::new(value)
126 }
127}
128
129impl TryFrom<&str> for TraceId {
130 type Error = String;
131 fn try_from(value: &str) -> Result<Self, Self::Error> {
132 TraceId::new(value)
133 }
134}
135
136impl From<TraceId> for String {
137 fn from(value: TraceId) -> Self {
138 value.0
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(try_from = "String", into = "String")]
147pub struct WireName(String);
148
149impl WireName {
150 pub fn new(value: impl Into<String>) -> Result<Self, String> {
152 let value = value.into();
153 validate_wire_name(&value)?;
154 Ok(WireName(value))
155 }
156
157 pub fn as_str(&self) -> &str {
158 &self.0
159 }
160}
161
162impl std::fmt::Display for WireName {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 f.write_str(&self.0)
165 }
166}
167
168impl TryFrom<String> for WireName {
169 type Error = String;
170 fn try_from(value: String) -> Result<Self, Self::Error> {
171 WireName::new(value)
172 }
173}
174
175impl TryFrom<&str> for WireName {
176 type Error = String;
177 fn try_from(value: &str) -> Result<Self, Self::Error> {
178 WireName::new(value)
179 }
180}
181
182impl From<WireName> for String {
183 fn from(value: WireName) -> Self {
184 value.0
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum TransportKind {
194 AnthropicMessages,
195 OpenAiChatCompletions,
196 OpenAiResponses,
197 GeminiGenerateContent,
198 VertexGenerateContent,
199 CloudProxy,
200 Extension,
201}
202
203const ENDPOINT_HOST_MAX_BYTES: usize = 262;
205
206const ENDPOINT_PATH_MAX_BYTES: usize = 1024;
208
209fn validate_endpoint_host(s: &str) -> Result<(), String> {
210 if s.is_empty() {
211 return Err("endpoint host must be nonempty".to_string());
212 }
213 if s.len() > ENDPOINT_HOST_MAX_BYTES {
214 return Err("endpoint host too long".to_string());
215 }
216 for b in s.bytes() {
219 if !(0x21..=0x7e).contains(&b) {
220 return Err("endpoint host contains whitespace/control/non-ASCII".to_string());
221 }
222 if matches!(b, b'@' | b'?' | b'#' | b'/' | b'\\' | b'"' | b'\'') {
223 return Err(format!(
224 "endpoint host contains forbidden char {:?}",
225 b as char
226 ));
227 }
228 }
229 fn validate_port(p: &str) -> Result<(), String> {
230 if p.is_empty() || p.len() > 5 || !p.bytes().all(|b| b.is_ascii_digit()) {
231 return Err("endpoint host port must be 1-5 digits".to_string());
232 }
233 Ok(())
234 }
235 if let Some(rest) = s.strip_prefix('[') {
236 let end = rest
238 .find(']')
239 .ok_or_else(|| "unterminated bracketed IPv6 host".to_string())?;
240 let inner = &rest[..end];
241 if inner.is_empty()
242 || !inner
243 .bytes()
244 .all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.')
245 {
246 return Err("invalid bracketed IPv6 host".to_string());
247 }
248 let after = &rest[end + 1..];
249 if !after.is_empty() {
250 let port = after
251 .strip_prefix(':')
252 .ok_or_else(|| "unexpected trailing bytes after IPv6 bracket".to_string())?;
253 validate_port(port)?;
254 }
255 return Ok(());
256 }
257 let (name, port) = match s.split_once(':') {
260 Some((name, port)) => {
261 if port.contains(':') {
262 return Err("endpoint host contains multiple colons".to_string());
263 }
264 (name, Some(port))
265 }
266 None => (s, None),
267 };
268 if name.is_empty()
269 || !name
270 .bytes()
271 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
272 {
273 return Err("endpoint host name must be [A-Za-z0-9.-]".to_string());
274 }
275 if let Some(port) = port {
276 validate_port(port)?;
277 }
278 Ok(())
279}
280
281fn validate_endpoint_path(s: &str) -> Result<(), String> {
282 if s.is_empty() {
283 return Err("endpoint path must be nonempty".to_string());
284 }
285 if !s.starts_with('/') {
286 return Err("endpoint path must begin with '/'".to_string());
287 }
288 if s.len() > ENDPOINT_PATH_MAX_BYTES {
289 return Err("endpoint path too long".to_string());
290 }
291 for b in s.bytes() {
292 let ok = b.is_ascii_alphanumeric()
293 || matches!(
294 b,
295 b'/' | b'-' | b'_' | b'.' | b'~' | b'%' | b':' | b'=' | b'+' | b','
296 );
297 if !ok {
298 return Err(format!(
299 "endpoint path contains forbidden byte 0x{b:02x} (no query/fragment/whitespace)"
300 ));
301 }
302 }
303 Ok(())
304}
305
306#[derive(Serialize, Deserialize)]
309struct RawEndpoint {
310 host: String,
311 path: String,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
319#[serde(try_from = "RawEndpoint", into = "RawEndpoint")]
320pub struct EndpointMeta {
321 host: String,
322 path: String,
323}
324
325impl EndpointMeta {
326 pub fn new(host: impl Into<String>, path: impl Into<String>) -> Result<Self, String> {
332 let host = host.into();
333 let path = path.into();
334 validate_endpoint_host(&host)?;
335 validate_endpoint_path(&path)?;
336 Ok(EndpointMeta { host, path })
337 }
338
339 pub fn host(&self) -> &str {
340 &self.host
341 }
342
343 pub fn path(&self) -> &str {
344 &self.path
345 }
346}
347
348impl TryFrom<RawEndpoint> for EndpointMeta {
349 type Error = String;
350 fn try_from(value: RawEndpoint) -> Result<Self, Self::Error> {
351 EndpointMeta::new(value.host, value.path)
352 }
353}
354
355impl From<EndpointMeta> for RawEndpoint {
356 fn from(value: EndpointMeta) -> Self {
357 RawEndpoint {
358 host: value.host,
359 path: value.path,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
366pub struct RequestAnatomy {
367 pub system_segment_count: u32,
368 pub message_count: u32,
369 pub block_count: u32,
370 pub tool_count: u32,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
376pub struct WireMeta {
377 pub byte_len: u64,
378 pub digest: ComponentDigest,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum SystemSegmentKind {
384 Primary,
385 Orchestration,
386 Memory,
387 Skill,
388 Other,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
393pub struct SystemSegmentMeta {
394 pub kind: SystemSegmentKind,
395 pub byte_len: u64,
396 pub digest: ComponentDigest,
397}
398
399#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(rename_all = "snake_case")]
402pub enum MessageRole {
403 User,
404 Assistant,
405 System,
406 Tool,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum BlockKind {
413 Text,
414 Thinking,
415 ToolUse,
416 ToolResult,
417 Media,
418 Unknown,
419}
420
421#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
423pub struct BlockMeta {
424 pub kind: BlockKind,
425 pub byte_len: u64,
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446pub struct MessageMeta {
447 pub role: MessageRole,
448 pub blocks: Vec<BlockMeta>,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453pub struct ToolMeta {
454 pub stable_id: TraceId,
455 pub wire_name: WireName,
456 pub schema_byte_len: u64,
457 pub schema_digest: ComponentDigest,
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(rename_all = "snake_case")]
463pub enum CacheBoundaryLocation {
464 Tools,
465 System,
466 Messages,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum CacheTtlClass {
473 FiveMinutes,
474 OneHour,
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
479pub struct CacheBoundaryMeta {
480 pub location: CacheBoundaryLocation,
481 pub index: u32,
484 pub ttl: CacheTtlClass,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
489pub struct PrefixMeta {
490 pub byte_len: u64,
491 pub digest: ComponentDigest,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(rename_all = "snake_case")]
497pub enum SegmentChange {
498 Unchanged,
501 Changed,
503 New,
505}
506
507#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
513pub struct CacheSegmentDelta {
514 #[serde(default, skip_serializing_if = "Option::is_none")]
515 pub tools: Option<SegmentChange>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub system: Option<SegmentChange>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub history_tail: Option<SegmentChange>,
520 #[serde(default, skip_serializing_if = "Vec::is_empty")]
523 pub changed_tool_ids: Vec<TraceId>,
524 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
527 pub tool_order_changed: bool,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub estimated_reused_bytes: Option<u64>,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub estimated_recomputed_bytes: Option<u64>,
537}
538
539#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
546pub struct CacheMeta {
547 pub boundaries: Vec<CacheBoundaryMeta>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
549 pub tools_prefix: Option<PrefixMeta>,
550 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub system_prefix: Option<PrefixMeta>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
555 pub history_tail: Option<PrefixMeta>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub delta: Option<CacheSegmentDelta>,
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case")]
565pub enum TranslationAction {
566 Dropped,
567 Merged,
568 Renamed,
569 Synthesized,
570 Downgraded,
571 Unsupported,
572}
573
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
576#[serde(rename_all = "snake_case")]
577pub enum TranslationElement {
578 SystemSegment,
579 MessageBlock,
580 Tool,
581 Parameter,
582 Other,
583}
584
585#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
588pub struct TranslationLoss {
589 pub action: TranslationAction,
590 pub element: TranslationElement,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub element_id: Option<TraceId>,
596}
597
598#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum RetryClass {
602 RateLimited,
603 Overloaded,
604 ServerError,
605 Network,
606 Timeout,
607 Auth,
608 Other,
609}
610
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613pub struct RetryMeta {
614 pub attempt: u32,
616 pub class: RetryClass,
617 pub delay_ms: u64,
619}
620
621#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
625pub struct TimingStages {
626 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub send_start_unix_ms: Option<u64>,
628 #[serde(default, skip_serializing_if = "Option::is_none")]
629 pub headers_ms: Option<u64>,
630 #[serde(default, skip_serializing_if = "Option::is_none")]
631 pub first_byte_ms: Option<u64>,
632 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub first_model_event_ms: Option<u64>,
634 #[serde(default, skip_serializing_if = "Option::is_none")]
635 pub stream_end_ms: Option<u64>,
636}
637
638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
640#[serde(rename_all = "snake_case")]
641pub enum UsageProvenance {
642 ProviderReported,
644 Estimated,
646}
647
648#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
651pub struct UsageMeta {
652 pub provenance: UsageProvenance,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub input_tokens: Option<u64>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub output_tokens: Option<u64>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub cache_read_tokens: Option<u64>,
659 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub cache_write_tokens: Option<u64>,
661}
662
663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
665#[serde(rename_all = "snake_case")]
666pub enum StopReason {
667 EndTurn,
668 MaxTokens,
669 StopSequence,
670 ToolUse,
671 PauseTurn,
672 Refusal,
673 ContentFilter,
674 Other,
675}
676
677#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
682pub struct TransportOutcome {
683 pub timings: TimingStages,
684 pub retries: Vec<RetryMeta>,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub provider_request_id: Option<TraceId>,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub http_status: Option<u16>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub stop_reason: Option<StopReason>,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
694 pub usage: Option<UsageMeta>,
695 pub terminal: TurnOutcome,
696}
697
698impl TransportOutcome {
699 pub fn unobserved(terminal: TurnOutcome) -> Self {
702 Self {
703 timings: TimingStages::default(),
704 retries: Vec::new(),
705 provider_request_id: None,
706 http_status: None,
707 stop_reason: None,
708 usage: None,
709 terminal,
710 }
711 }
712}
713
714#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum ExecutionPhase {
719 Planned,
720 Authorized,
721 Started,
722 Committed,
723 ResultRecorded,
724 Canceled,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
728#[serde(rename_all = "snake_case")]
729pub enum ExecutionEffect {
730 ReadOnly,
731 IdempotentWrite,
732 NonIdempotent,
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(rename_all = "snake_case")]
737pub enum ExecutionCommitStatus {
738 NotStarted,
739 Started,
740 Committed,
741 ResultRecorded,
742 UnknownAfterSideEffect,
743 CanceledBeforeCommit,
744}
745
746#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(tag = "kind", rename_all = "snake_case")]
748pub enum ActivationGrantRef {
749 Core,
750 Exact { catalog_generation: u64 },
751}
752
753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
757pub struct ToolExecutionEvent {
758 pub session_id: TraceId,
759 pub turn_id: TraceId,
760 pub request_id: TraceId,
761 pub tool_call_id: TraceId,
762 pub stable_tool_id: TraceId,
763 pub wire_name: WireName,
764 pub phase: ExecutionPhase,
765 pub elapsed_ms: u64,
766 pub result_bytes: u64,
767 pub truncated: bool,
768 pub preview_bytes: u64,
769 pub activation: ActivationGrantRef,
770 pub effect: ExecutionEffect,
771 pub commit_status: ExecutionCommitStatus,
772 pub model_order: u32,
775}
776
777#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
782pub struct RequestTrace {
783 pub schema: TraceSchemaVersion,
785 pub session_id: TraceId,
786 pub turn_id: TraceId,
787 pub request_id: TraceId,
788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
791 pub execution_events: Vec<ToolExecutionEvent>,
792 pub attempt: u32,
795 pub model: QualifiedModelId,
797 pub transport: TransportKind,
798 pub endpoint: EndpointMeta,
799 pub anatomy: RequestAnatomy,
800 #[serde(default, skip_serializing_if = "Option::is_none")]
803 pub wire: Option<WireMeta>,
804 pub system_segments: Vec<SystemSegmentMeta>,
805 pub messages: Vec<MessageMeta>,
806 pub tools: Vec<ToolMeta>,
807 pub cache: CacheMeta,
808 pub translation_losses: Vec<TranslationLoss>,
809 pub outcome: TransportOutcome,
810}
811
812impl RequestTrace {
813 pub fn provider(&self) -> &str {
815 self.model.provider()
816 }
817}