use async_trait::async_trait;
use super::interceptor::{A2aDelegationContext, A2aError, A2aInterceptor, InterceptorDecision};
const AUDIT_START_KEY: &str = "__audit_start_ns";
#[derive(Debug, Clone)]
pub struct AuditLogInterceptor {
pub include_response: bool,
}
impl AuditLogInterceptor {
pub fn new(include_response: bool) -> Self {
Self { include_response }
}
fn caller_id_or_anonymous(ctx: &A2aDelegationContext) -> &str {
ctx.caller_id.as_deref().unwrap_or("anonymous")
}
}
#[async_trait]
impl A2aInterceptor for AuditLogInterceptor {
async fn before_delegation(
&self,
ctx: &mut A2aDelegationContext,
) -> Result<InterceptorDecision, A2aError> {
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
ctx.metadata.insert(AUDIT_START_KEY.to_string(), now_ns.to_string());
let caller = Self::caller_id_or_anonymous(ctx);
tracing::info!(
method = %ctx.method,
caller_id = %caller,
"a2a request started"
);
Ok(InterceptorDecision::Continue)
}
async fn after_delegation(
&self,
ctx: &A2aDelegationContext,
response: &mut serde_json::Value,
) -> Result<(), A2aError> {
let duration_ms = ctx
.metadata
.get(AUDIT_START_KEY)
.and_then(|s| s.parse::<u128>().ok())
.map(|start_ns| {
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
(now_ns.saturating_sub(start_ns)) as f64 / 1_000_000.0
})
.unwrap_or(0.0);
let caller = Self::caller_id_or_anonymous(ctx);
if self.include_response {
let response_str = response.to_string();
tracing::info!(
method = %ctx.method,
caller_id = %caller,
duration_ms = %format!("{duration_ms:.3}"),
response = %response_str,
"a2a request completed"
);
} else {
tracing::info!(
method = %ctx.method,
caller_id = %caller,
duration_ms = %format!("{duration_ms:.3}"),
"a2a request completed"
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_ctx(method: &str, caller_id: Option<&str>) -> A2aDelegationContext {
A2aDelegationContext {
method: method.to_string(),
params: serde_json::json!({}),
caller_id: caller_id.map(String::from),
metadata: HashMap::new(),
}
}
#[tokio::test]
async fn test_before_delegation_stores_start_time() {
let audit = AuditLogInterceptor::new(false);
let mut ctx = make_ctx("tasks/send", Some("agent-1"));
let decision = audit.before_delegation(&mut ctx).await.unwrap();
assert!(matches!(decision, InterceptorDecision::Continue));
assert!(ctx.metadata.contains_key(AUDIT_START_KEY));
let start_ns: u128 = ctx.metadata[AUDIT_START_KEY].parse().unwrap();
assert!(start_ns > 0);
}
#[tokio::test]
async fn test_before_delegation_always_continues() {
let audit = AuditLogInterceptor::new(true);
let mut ctx = make_ctx("tasks/get", None);
let decision = audit.before_delegation(&mut ctx).await.unwrap();
assert!(matches!(decision, InterceptorDecision::Continue));
}
#[tokio::test]
async fn test_after_delegation_succeeds_with_start_time() {
let audit = AuditLogInterceptor::new(false);
let mut ctx = make_ctx("tasks/send", Some("client-1"));
audit.before_delegation(&mut ctx).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
let mut response = serde_json::json!({"result": "ok"});
let result = audit.after_delegation(&ctx, &mut response).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_after_delegation_succeeds_without_start_time() {
let audit = AuditLogInterceptor::new(false);
let ctx = make_ctx("tasks/send", Some("client-1"));
let mut response = serde_json::json!({"result": "ok"});
let result = audit.after_delegation(&ctx, &mut response).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_anonymous_caller_when_no_caller_id() {
let audit = AuditLogInterceptor::new(false);
let mut ctx = make_ctx("tasks/send", None);
let decision = audit.before_delegation(&mut ctx).await.unwrap();
assert!(matches!(decision, InterceptorDecision::Continue));
assert_eq!(AuditLogInterceptor::caller_id_or_anonymous(&ctx), "anonymous");
}
#[tokio::test]
async fn test_include_response_false_does_not_modify_response() {
let audit = AuditLogInterceptor::new(false);
let mut ctx = make_ctx("tasks/send", Some("agent-1"));
audit.before_delegation(&mut ctx).await.unwrap();
let mut response = serde_json::json!({"result": "sensitive_data"});
let original = response.clone();
audit.after_delegation(&ctx, &mut response).await.unwrap();
assert_eq!(response, original);
}
#[tokio::test]
async fn test_include_response_true_does_not_modify_response() {
let audit = AuditLogInterceptor::new(true);
let mut ctx = make_ctx("tasks/send", Some("agent-1"));
audit.before_delegation(&mut ctx).await.unwrap();
let mut response = serde_json::json!({"result": "data", "nested": {"key": "value"}});
let original = response.clone();
audit.after_delegation(&ctx, &mut response).await.unwrap();
assert_eq!(response, original);
}
#[tokio::test]
async fn test_new_creates_with_include_response() {
let audit_no_response = AuditLogInterceptor::new(false);
assert!(!audit_no_response.include_response);
let audit_with_response = AuditLogInterceptor::new(true);
assert!(audit_with_response.include_response);
}
#[tokio::test]
async fn test_duration_is_non_negative() {
let audit = AuditLogInterceptor::new(false);
let mut ctx = make_ctx("tasks/send", Some("agent-1"));
audit.before_delegation(&mut ctx).await.unwrap();
let start_ns: u128 = ctx.metadata[AUDIT_START_KEY].parse().unwrap();
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
assert!(now_ns >= start_ns);
}
}