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,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct MessageMeta {
431 pub role: MessageRole,
432 pub blocks: Vec<BlockMeta>,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
437pub struct ToolMeta {
438 pub stable_id: TraceId,
439 pub wire_name: WireName,
440 pub schema_byte_len: u64,
441 pub schema_digest: ComponentDigest,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(rename_all = "snake_case")]
447pub enum CacheBoundaryLocation {
448 Tools,
449 System,
450 Messages,
451}
452
453#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "snake_case")]
456pub enum CacheTtlClass {
457 FiveMinutes,
458 OneHour,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
463pub struct CacheBoundaryMeta {
464 pub location: CacheBoundaryLocation,
465 pub index: u32,
468 pub ttl: CacheTtlClass,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
473pub struct PrefixMeta {
474 pub byte_len: u64,
475 pub digest: ComponentDigest,
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481pub enum SegmentChange {
482 Unchanged,
485 Changed,
487 New,
489}
490
491#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
497pub struct CacheSegmentDelta {
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub tools: Option<SegmentChange>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub system: Option<SegmentChange>,
502 #[serde(default, skip_serializing_if = "Option::is_none")]
503 pub history_tail: Option<SegmentChange>,
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
507 pub changed_tool_ids: Vec<TraceId>,
508 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
511 pub tool_order_changed: bool,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub estimated_reused_bytes: Option<u64>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub estimated_recomputed_bytes: Option<u64>,
521}
522
523#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
530pub struct CacheMeta {
531 pub boundaries: Vec<CacheBoundaryMeta>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub tools_prefix: Option<PrefixMeta>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub system_prefix: Option<PrefixMeta>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub history_tail: Option<PrefixMeta>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub delta: Option<CacheSegmentDelta>,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum TranslationAction {
550 Dropped,
551 Merged,
552 Renamed,
553 Synthesized,
554 Downgraded,
555 Unsupported,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum TranslationElement {
562 SystemSegment,
563 MessageBlock,
564 Tool,
565 Parameter,
566 Other,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
572pub struct TranslationLoss {
573 pub action: TranslationAction,
574 pub element: TranslationElement,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub element_id: Option<TraceId>,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
584#[serde(rename_all = "snake_case")]
585pub enum RetryClass {
586 RateLimited,
587 Overloaded,
588 ServerError,
589 Network,
590 Timeout,
591 Auth,
592 Other,
593}
594
595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
597pub struct RetryMeta {
598 pub attempt: u32,
600 pub class: RetryClass,
601 pub delay_ms: u64,
603}
604
605#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
609pub struct TimingStages {
610 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub send_start_unix_ms: Option<u64>,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub headers_ms: Option<u64>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub first_byte_ms: Option<u64>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub first_model_event_ms: Option<u64>,
618 #[serde(default, skip_serializing_if = "Option::is_none")]
619 pub stream_end_ms: Option<u64>,
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum UsageProvenance {
626 ProviderReported,
628 Estimated,
630}
631
632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
635pub struct UsageMeta {
636 pub provenance: UsageProvenance,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub input_tokens: Option<u64>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub output_tokens: Option<u64>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub cache_read_tokens: Option<u64>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub cache_write_tokens: Option<u64>,
645}
646
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum StopReason {
651 EndTurn,
652 MaxTokens,
653 StopSequence,
654 ToolUse,
655 PauseTurn,
656 Refusal,
657 ContentFilter,
658 Other,
659}
660
661#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666pub struct TransportOutcome {
667 pub timings: TimingStages,
668 pub retries: Vec<RetryMeta>,
671 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub provider_request_id: Option<TraceId>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub http_status: Option<u16>,
675 #[serde(default, skip_serializing_if = "Option::is_none")]
676 pub stop_reason: Option<StopReason>,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub usage: Option<UsageMeta>,
679 pub terminal: TurnOutcome,
680}
681
682impl TransportOutcome {
683 pub fn unobserved(terminal: TurnOutcome) -> Self {
686 Self {
687 timings: TimingStages::default(),
688 retries: Vec::new(),
689 provider_request_id: None,
690 http_status: None,
691 stop_reason: None,
692 usage: None,
693 terminal,
694 }
695 }
696}
697
698#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701#[serde(rename_all = "snake_case")]
702pub enum ExecutionPhase {
703 Planned,
704 Authorized,
705 Started,
706 Committed,
707 ResultRecorded,
708 Canceled,
709}
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
712#[serde(rename_all = "snake_case")]
713pub enum ExecutionEffect {
714 ReadOnly,
715 IdempotentWrite,
716 NonIdempotent,
717}
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(rename_all = "snake_case")]
721pub enum ExecutionCommitStatus {
722 NotStarted,
723 Started,
724 Committed,
725 ResultRecorded,
726 UnknownAfterSideEffect,
727 CanceledBeforeCommit,
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(tag = "kind", rename_all = "snake_case")]
732pub enum ActivationGrantRef {
733 Core,
734 Exact { catalog_generation: u64 },
735}
736
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
741pub struct ToolExecutionEvent {
742 pub session_id: TraceId,
743 pub turn_id: TraceId,
744 pub request_id: TraceId,
745 pub tool_call_id: TraceId,
746 pub stable_tool_id: TraceId,
747 pub wire_name: WireName,
748 pub phase: ExecutionPhase,
749 pub elapsed_ms: u64,
750 pub result_bytes: u64,
751 pub truncated: bool,
752 pub preview_bytes: u64,
753 pub activation: ActivationGrantRef,
754 pub effect: ExecutionEffect,
755 pub commit_status: ExecutionCommitStatus,
756 pub model_order: u32,
759}
760
761#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct RequestTrace {
767 pub schema: TraceSchemaVersion,
769 pub session_id: TraceId,
770 pub turn_id: TraceId,
771 pub request_id: TraceId,
772 #[serde(default, skip_serializing_if = "Vec::is_empty")]
775 pub execution_events: Vec<ToolExecutionEvent>,
776 pub attempt: u32,
779 pub model: QualifiedModelId,
781 pub transport: TransportKind,
782 pub endpoint: EndpointMeta,
783 pub anatomy: RequestAnatomy,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub wire: Option<WireMeta>,
788 pub system_segments: Vec<SystemSegmentMeta>,
789 pub messages: Vec<MessageMeta>,
790 pub tools: Vec<ToolMeta>,
791 pub cache: CacheMeta,
792 pub translation_losses: Vec<TranslationLoss>,
793 pub outcome: TransportOutcome,
794}
795
796impl RequestTrace {
797 pub fn provider(&self) -> &str {
799 self.model.provider()
800 }
801}