1use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33pub const SERVICE_NAME: &str = "agent-trace-infra";
34pub const DEFAULT_PORT: u16 = 5140;
35pub const AUDIENCE: &str = "agent-trace-infra";
36
37pub const SERVICE_TOKEN_ENV: &str = "AGENT_TRACE_INFRA_SERVICE_TOKEN";
39
40pub const HEALTH_PATH: &str = "/health";
41pub const EVENTS_APPEND_PATH: &str = "/internal/v1/trace-infra/events/batch";
42pub const EVENTS_SEARCH_PATH: &str = "/internal/v1/trace-infra/events/search";
43
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct TraceScope {
52 pub conversation_id: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub run_id: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub user_id: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub agent_id: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub agent_space_id: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub runtime_type: Option<String>,
63}
64
65impl TraceScope {
66 pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
67 Self {
68 conversation_id: conversation_id.into(),
69 run_id: Some(run_id.into()),
70 ..Default::default()
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct RuntimeEventRecord {
79 pub id: String,
82 pub scope: TraceScope,
83 pub event_type: String,
85 #[serde(default)]
86 pub payload: Value,
87 #[serde(default)]
89 pub sequence: i64,
90 #[serde(default)]
91 pub created_at_ms: i64,
92 #[serde(default, skip_serializing_if = "Value::is_null")]
93 pub metadata: Value,
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct AppendEventsRequest {
99 pub events: Vec<RuntimeEventRecord>,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105#[serde(rename_all = "camelCase")]
106pub struct AppendResponse {
107 pub appended: usize,
108 pub skipped: usize,
109}
110
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct SearchEventsRequest {
114 pub scope: TraceScope,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub event_type: Option<String>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub limit: Option<usize>,
120}
121
122#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct EventsResponse {
129 pub items: Vec<RuntimeEventRecord>,
130}
131
132pub const MAX_PAGE_SIZE: usize = 1_000;
134
135pub const DEFAULT_PAGE_SIZE: usize = 100;
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ApiError {
141 pub status: u16,
142 pub message: String,
143}
144
145pub const REDACTED: &str = "<redacted>";
147
148pub fn redact_payload(value: &mut Value) {
161 match value {
162 Value::Object(fields) => {
163 for (key, field) in fields.iter_mut() {
164 if is_sensitive_key(key) {
165 *field = Value::String(REDACTED.to_string());
166 } else {
167 redact_payload(field);
168 }
169 }
170 }
171 Value::Array(items) => items.iter_mut().for_each(redact_payload),
172 _ => {}
173 }
174}
175
176fn is_sensitive_key(key: &str) -> bool {
181 let normalised = key
182 .chars()
183 .filter(|character| character.is_ascii_alphanumeric())
184 .flat_map(|character| character.to_lowercase())
185 .collect::<String>();
186
187 const EXACT: &[&str] = &[
188 "apikey",
189 "authorization",
190 "gatewaytoken",
191 "password",
192 "secret",
193 "token",
194 ];
195 if EXACT.contains(&normalised.as_str()) {
196 return true;
197 }
198
199 ["apikey", "secret", "token", "password"]
201 .iter()
202 .any(|needle| normalised.ends_with(needle))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use serde_json::json;
209
210 #[test]
211 fn scope_serialises_camel_case_and_omits_absent_provenance() {
212 let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
213 assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
214 }
215
216 #[test]
217 fn redaction_masks_credential_fields_at_any_depth() {
218 let mut payload = json!({
219 "model": "test-model",
220 "api_key": "sensitive-api-value",
221 "headers": {"Authorization": "sensitive-authorization-value"},
222 "calls": [
223 {"accessToken": "sensitive-access-value"},
224 {"clientSecret": "sensitive-client-value"}
225 ],
226 "nested": {"deep": {"apiKey": "sensitive-nested-value"}}
227 });
228
229 redact_payload(&mut payload);
230
231 assert_eq!(payload["model"], "test-model", "non-secrets must survive");
232 assert_eq!(payload["api_key"], REDACTED);
233 assert_eq!(payload["headers"]["Authorization"], REDACTED);
234 assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
235 assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
236 assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
237 }
238
239 #[test]
240 fn redaction_does_not_mask_lookalike_field_names() {
241 let mut payload = json!({
244 "tokenCount": 42,
245 "secretsManagerArn": "arn:aws:secretsmanager:...",
246 "passwordPolicy": {"minLength": 12}
247 });
248
249 redact_payload(&mut payload);
250
251 assert_eq!(payload["tokenCount"], 42);
252 assert_eq!(
253 payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
254 "a name that merely contains 'secret' is not a secret"
255 );
256 assert_eq!(payload["passwordPolicy"]["minLength"], 12);
257 }
258
259 #[test]
260 fn an_event_round_trips_through_json() {
261 let record = RuntimeEventRecord {
262 id: "e-1".into(),
263 scope: TraceScope::run("conv-1", "run-1"),
264 event_type: "tool_call".into(),
265 payload: json!({"name": "execute_cmd"}),
266 sequence: 3,
267 created_at_ms: 1_700_000_000_000,
268 metadata: Value::Null,
269 };
270 let encoded = serde_json::to_value(&record).expect("serialize");
271 assert_eq!(encoded["eventType"], "tool_call");
272 assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
273 let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
274 assert_eq!(decoded, record);
275 }
276}