Skip to main content

agent_trace_contract/
lib.rs

1//! HTTP API contract for the Trace Infra service.
2//!
3//! # Why trace is its own service and not part of History Infra
4//!
5//! Conversation messages and trace events look similar on the wire and behave
6//! nothing alike:
7//!
8//! | | Conversation | Trace |
9//! | --- | --- | --- |
10//! | Replayed into a model prompt | yes | **never** |
11//! | Losing one record | loses user work | acceptable |
12//! | Volume | one per turn | many per turn |
13//! | Retention | durable | short |
14//!
15//! Sharing one store would force the stricter guarantee onto high-volume
16//! diagnostic data, and — worse — put events one filtering mistake away from
17//! being replayed as conversation. Separate services keep that impossible by
18//! construction.
19//!
20//! # Layering
21//!
22//! ```text
23//! agent-trace-contract   paths + DTOs + redaction   (this crate)
24//! agent-trace-runtime    store implementation
25//! agent-trace-server     router + auth + bin
26//! ```
27//!
28//! See [`api.md`](../api.md) for the full HTTP reference.
29
30use 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
37/// Optional static service token. When unset, the server runs in open mode.
38pub 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/// Identifies what a trace event belongs to.
45///
46/// `run_id` is what makes a trace useful: it groups every event emitted by one
47/// turn, so a single execution can be reconstructed without guessing from
48/// timestamps.
49#[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/// One runtime trace event.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct RuntimeEventRecord {
79    /// Caller supplied id. Enables idempotent append, so a retry after a lost
80    /// response cannot duplicate a turn's trace.
81    pub id: String,
82    pub scope: TraceScope,
83    /// Coarse kind, e.g. `turn_started`, `tool_call`, `guard_rail`, `turn_finished`.
84    pub event_type: String,
85    #[serde(default)]
86    pub payload: Value,
87    /// Monotonic order within a run. Ties break by `created_at_ms`.
88    #[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/// Result of an append. `skipped` counts ids that already existed, so a caller
103/// can tell a fresh write from a deduplicated retry.
104#[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    /// When set, only events of this type are returned.
116    #[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/// Events in chronological order, oldest first.
123///
124/// The order is part of the contract: a client that has to re-sort is a client
125/// that will eventually get it wrong.
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct EventsResponse {
129    pub items: Vec<RuntimeEventRecord>,
130}
131
132/// Upper bound the server enforces on any page size.
133pub const MAX_PAGE_SIZE: usize = 1_000;
134
135/// Default page size when a request omits `limit`.
136pub const DEFAULT_PAGE_SIZE: usize = 100;
137
138/// Error body shared by every non-2xx response.
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct ApiError {
141    pub status: u16,
142    pub message: String,
143}
144
145/// Placeholder written over any value that looks like a credential.
146pub const REDACTED: &str = "<redacted>";
147
148/// Mask credential-shaped fields anywhere in a trace payload.
149///
150/// # Why this lives in the contract
151///
152/// Trace payloads carry tool arguments and model requests, which are exactly
153/// where an API key or bearer token ends up. Traces are also the data most
154/// likely to be shipped to a log aggregator and kept, so an unredacted secret
155/// there outlives the request that leaked it.
156///
157/// Redaction is therefore part of the wire contract rather than a courtesy each
158/// caller might forget: both the client and the server apply it, so a payload
159/// cannot be stored unmasked even if a caller skips the step.
160pub 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
176/// True when a field name looks like it holds a credential.
177///
178/// Matching is done on a normalised key (lowercased, separators removed) so
179/// `api_key`, `apiKey`, and `API-KEY` are all caught by one rule.
180fn 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    // Compound names such as `accessToken`, `clientSecret`, `serviceApiKey`.
200    ["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        // These read like secrets but carry no credential; masking them would
242        // quietly destroy useful trace data.
243        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}