agent-trace-contract 0.1.0

Transport-neutral contracts for Agent Infra trace APIs
Documentation
//! HTTP API contract for the Trace Infra service.
//!
//! # Why trace is its own service and not part of History Infra
//!
//! Conversation messages and trace events look similar on the wire and behave
//! nothing alike:
//!
//! | | Conversation | Trace |
//! | --- | --- | --- |
//! | Replayed into a model prompt | yes | **never** |
//! | Losing one record | loses user work | acceptable |
//! | Volume | one per turn | many per turn |
//! | Retention | durable | short |
//!
//! Sharing one store would force the stricter guarantee onto high-volume
//! diagnostic data, and — worse — put events one filtering mistake away from
//! being replayed as conversation. Separate services keep that impossible by
//! construction.
//!
//! # Layering
//!
//! ```text
//! agent-trace-contract   paths + DTOs + redaction   (this crate)
//! agent-trace-runtime    store implementation
//! agent-trace-server     router + auth + bin
//! ```
//!
//! See [`api.md`](../api.md) for the full HTTP reference.

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";

/// Optional static service token. When unset, the server runs in open mode.
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";

/// Identifies what a trace event belongs to.
///
/// `run_id` is what makes a trace useful: it groups every event emitted by one
/// turn, so a single execution can be reconstructed without guessing from
/// timestamps.
#[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()
        }
    }
}

/// One runtime trace event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEventRecord {
    /// Caller supplied id. Enables idempotent append, so a retry after a lost
    /// response cannot duplicate a turn's trace.
    pub id: String,
    pub scope: TraceScope,
    /// Coarse kind, e.g. `turn_started`, `tool_call`, `guard_rail`, `turn_finished`.
    pub event_type: String,
    #[serde(default)]
    pub payload: Value,
    /// Monotonic order within a run. Ties break by `created_at_ms`.
    #[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>,
}

/// Result of an append. `skipped` counts ids that already existed, so a caller
/// can tell a fresh write from a deduplicated retry.
#[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,
    /// When set, only events of this type are returned.
    #[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>,
}

/// Events in chronological order, oldest first.
///
/// The order is part of the contract: a client that has to re-sort is a client
/// that will eventually get it wrong.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EventsResponse {
    pub items: Vec<RuntimeEventRecord>,
}

/// Upper bound the server enforces on any page size.
pub const MAX_PAGE_SIZE: usize = 1_000;

/// Default page size when a request omits `limit`.
pub const DEFAULT_PAGE_SIZE: usize = 100;

/// Error body shared by every non-2xx response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiError {
    pub status: u16,
    pub message: String,
}

/// Placeholder written over any value that looks like a credential.
pub const REDACTED: &str = "<redacted>";

/// Mask credential-shaped fields anywhere in a trace payload.
///
/// # Why this lives in the contract
///
/// Trace payloads carry tool arguments and model requests, which are exactly
/// where an API key or bearer token ends up. Traces are also the data most
/// likely to be shipped to a log aggregator and kept, so an unredacted secret
/// there outlives the request that leaked it.
///
/// Redaction is therefore part of the wire contract rather than a courtesy each
/// caller might forget: both the client and the server apply it, so a payload
/// cannot be stored unmasked even if a caller skips the step.
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),
        _ => {}
    }
}

/// True when a field name looks like it holds a credential.
///
/// Matching is done on a normalised key (lowercased, separators removed) so
/// `api_key`, `apiKey`, and `API-KEY` are all caught by one rule.
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;
    }

    // Compound names such as `accessToken`, `clientSecret`, `serviceApiKey`.
    ["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() {
        // These read like secrets but carry no credential; masking them would
        // quietly destroy useful trace data.
        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);
    }
}