use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const SERVICE_NAME: &str = "agent-trace-infra";
pub const DEFAULT_PORT: u16 = 5140;
pub const AUDIENCE: &str = "agent-trace-infra";
pub const SERVICE_TOKEN_ENV: &str = "AGENT_TRACE_INFRA_SERVICE_TOKEN";
pub const HEALTH_PATH: &str = "/health";
pub const EVENTS_APPEND_PATH: &str = "/internal/v1/trace-infra/events/batch";
pub const EVENTS_SEARCH_PATH: &str = "/internal/v1/trace-infra/events/search";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TraceScope {
pub conversation_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_space_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_type: Option<String>,
}
impl TraceScope {
pub fn run(conversation_id: impl Into<String>, run_id: impl Into<String>) -> Self {
Self {
conversation_id: conversation_id.into(),
run_id: Some(run_id.into()),
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEventRecord {
pub id: String,
pub scope: TraceScope,
pub event_type: String,
#[serde(default)]
pub payload: Value,
#[serde(default)]
pub sequence: i64,
#[serde(default)]
pub created_at_ms: i64,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub metadata: Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendEventsRequest {
pub events: Vec<RuntimeEventRecord>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppendResponse {
pub appended: usize,
pub skipped: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchEventsRequest {
pub scope: TraceScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventsResponse {
pub items: Vec<RuntimeEventRecord>,
}
pub const MAX_PAGE_SIZE: usize = 1_000;
pub const DEFAULT_PAGE_SIZE: usize = 100;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiError {
pub status: u16,
pub message: String,
}
pub const REDACTED: &str = "<redacted>";
pub fn redact_payload(value: &mut Value) {
match value {
Value::Object(fields) => {
for (key, field) in fields.iter_mut() {
if is_sensitive_key(key) {
*field = Value::String(REDACTED.to_string());
} else {
redact_payload(field);
}
}
}
Value::Array(items) => items.iter_mut().for_each(redact_payload),
_ => {}
}
}
fn is_sensitive_key(key: &str) -> bool {
let normalised = key
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(|character| character.to_lowercase())
.collect::<String>();
const EXACT: &[&str] = &[
"apikey",
"authorization",
"gatewaytoken",
"password",
"secret",
"token",
];
if EXACT.contains(&normalised.as_str()) {
return true;
}
["apikey", "secret", "token", "password"]
.iter()
.any(|needle| normalised.ends_with(needle))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scope_serialises_camel_case_and_omits_absent_provenance() {
let value = serde_json::to_value(TraceScope::run("conv-1", "run-1")).expect("serialize");
assert_eq!(value, json!({"conversationId": "conv-1", "runId": "run-1"}));
}
#[test]
fn redaction_masks_credential_fields_at_any_depth() {
let mut payload = json!({
"model": "test-model",
"api_key": "sensitive-api-value",
"headers": {"Authorization": "sensitive-authorization-value"},
"calls": [
{"accessToken": "sensitive-access-value"},
{"clientSecret": "sensitive-client-value"}
],
"nested": {"deep": {"apiKey": "sensitive-nested-value"}}
});
redact_payload(&mut payload);
assert_eq!(payload["model"], "test-model", "non-secrets must survive");
assert_eq!(payload["api_key"], REDACTED);
assert_eq!(payload["headers"]["Authorization"], REDACTED);
assert_eq!(payload["calls"][0]["accessToken"], REDACTED);
assert_eq!(payload["calls"][1]["clientSecret"], REDACTED);
assert_eq!(payload["nested"]["deep"]["apiKey"], REDACTED);
}
#[test]
fn redaction_does_not_mask_lookalike_field_names() {
let mut payload = json!({
"tokenCount": 42,
"secretsManagerArn": "arn:aws:secretsmanager:...",
"passwordPolicy": {"minLength": 12}
});
redact_payload(&mut payload);
assert_eq!(payload["tokenCount"], 42);
assert_eq!(
payload["secretsManagerArn"], "arn:aws:secretsmanager:...",
"a name that merely contains 'secret' is not a secret"
);
assert_eq!(payload["passwordPolicy"]["minLength"], 12);
}
#[test]
fn an_event_round_trips_through_json() {
let record = RuntimeEventRecord {
id: "e-1".into(),
scope: TraceScope::run("conv-1", "run-1"),
event_type: "tool_call".into(),
payload: json!({"name": "execute_cmd"}),
sequence: 3,
created_at_ms: 1_700_000_000_000,
metadata: Value::Null,
};
let encoded = serde_json::to_value(&record).expect("serialize");
assert_eq!(encoded["eventType"], "tool_call");
assert_eq!(encoded["createdAtMs"], 1_700_000_000_000_i64);
let decoded: RuntimeEventRecord = serde_json::from_value(encoded).expect("deserialize");
assert_eq!(decoded, record);
}
}