use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use apcore::context::Context;
use apcore::middleware::Middleware;
use apcore::{ErrorCode, ModuleError};
use async_trait::async_trait;
use serde_json::Value;
const STALE_SWEEP_THRESHOLD: usize = 1024;
const STALE_ENTRY_AGE: std::time::Duration = std::time::Duration::from_secs(3600);
fn module_records_its_own_outcome(code: ErrorCode) -> bool {
matches!(
code,
ErrorCode::ModuleTimeout | ErrorCode::ModuleExecuteError
)
}
#[derive(Debug, Default)]
pub struct FailureLogMiddleware {
start_times: Mutex<HashMap<String, Instant>>,
audit: Option<Arc<crate::governance::AuditManager>>,
emit_tracing_record: bool,
}
impl FailureLogMiddleware {
pub fn new() -> Self {
Self {
emit_tracing_record: true,
..Self::default()
}
}
pub fn with_audit(
audit: Option<Arc<crate::governance::AuditManager>>,
emit_tracing_record: bool,
) -> Self {
Self {
start_times: Mutex::new(HashMap::new()),
audit,
emit_tracing_record,
}
}
fn timing_key(module_id: &str, ctx: &Context<Value>) -> String {
format!("{}:{}", ctx.trace_id, module_id)
}
fn with_start_times<R>(&self, apply: impl FnOnce(&mut HashMap<String, Instant>) -> R) -> R {
let mut guard = self
.start_times
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
apply(&mut guard)
}
fn take_elapsed_ms(&self, module_id: &str, ctx: &Context<Value>) -> Option<f64> {
let key = Self::timing_key(module_id, ctx);
self.with_start_times(|times| times.remove(&key))
.map(|start| start.elapsed().as_secs_f64() * 1000.0)
}
}
#[async_trait]
impl Middleware for FailureLogMiddleware {
fn name(&self) -> &'static str {
"apexe_failure_log"
}
fn priority(&self) -> u16 {
700
}
async fn before(
&self,
module_id: &str,
_inputs: Value,
ctx: &Context<Value>,
) -> Result<Option<Value>, ModuleError> {
let key = Self::timing_key(module_id, ctx);
self.with_start_times(|times| {
if times.len() >= STALE_SWEEP_THRESHOLD {
times.retain(|_, started| started.elapsed() < STALE_ENTRY_AGE);
}
times.insert(key, Instant::now());
});
Ok(None)
}
async fn after(
&self,
module_id: &str,
_inputs: Value,
_output: Value,
ctx: &Context<Value>,
) -> Result<Option<Value>, ModuleError> {
self.take_elapsed_ms(module_id, ctx);
Ok(None)
}
async fn on_error(
&self,
module_id: &str,
_inputs: Value,
error: &ModuleError,
ctx: &Context<Value>,
) -> Result<Option<Value>, ModuleError> {
let duration_ms = self.take_elapsed_ms(module_id, ctx).unwrap_or(0.0);
if self.emit_tracing_record {
tracing::error!(
module_id = module_id,
trace_id = %ctx.trace_id,
caller_id = ?ctx.caller_id,
error_code = ?error.code,
duration_ms = duration_ms,
"Module call failed"
);
}
if let Some(ref audit) = self.audit {
if module_records_its_own_outcome(error.code) {
return Ok(None);
}
audit
.log_refusal(
module_id,
&ctx.trace_id,
ctx.identity.as_ref().map(|id| id.id()),
None,
error.code,
duration_ms as u64,
)
.await;
}
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
use apcore::ErrorCode;
use serde_json::json;
use std::time::Duration;
fn context() -> Context<Value> {
Context::anonymous()
}
#[tokio::test]
async fn test_failure_log_middleware_identity() {
let middleware = FailureLogMiddleware::new();
assert_eq!(middleware.name(), "apexe_failure_log");
assert_eq!(middleware.priority(), 700);
}
#[tokio::test]
async fn test_failure_log_middleware_passes_inputs_and_output_through() {
let middleware = FailureLogMiddleware::new();
let ctx = context();
assert!(middleware
.before("cli.curl", json!({"data": "password=hunter2"}), &ctx)
.await
.expect("before never fails")
.is_none());
assert!(middleware
.after("cli.curl", json!({}), json!({"stdout": "x"}), &ctx)
.await
.expect("after never fails")
.is_none());
}
#[tokio::test]
async fn test_failure_log_middleware_does_not_recover_the_error() {
let middleware = FailureLogMiddleware::new();
let error = ModuleError::new(ErrorCode::SchemaValidationError, "rejected".to_string());
let outcome = middleware
.on_error(
"cli.curl",
json!({"data": "password=hunter2"}),
&error,
&context(),
)
.await
.expect("on_error never fails");
assert!(outcome.is_none(), "the error must keep propagating");
}
#[tokio::test]
async fn test_abandoned_timing_entries_do_not_grow_without_bound() {
let middleware = FailureLogMiddleware::new();
for _ in 0..(STALE_SWEEP_THRESHOLD + 64) {
middleware
.before("cli.ls", json!({}), &context())
.await
.expect("before never fails");
}
middleware.with_start_times(|times| {
for started in times.values_mut() {
*started = Instant::now() - STALE_ENTRY_AGE - Duration::from_secs(1);
}
});
middleware
.before("cli.ls", json!({}), &context())
.await
.expect("before never fails");
assert_eq!(
middleware.with_start_times(|times| times.len()),
1,
"aged-out entries must be swept, leaving only the call just started"
);
}
#[tokio::test]
async fn test_failure_log_middleware_releases_timing_entries() {
let middleware = FailureLogMiddleware::new();
let ctx = context();
middleware
.before("cli.ls", json!({}), &ctx)
.await
.expect("before never fails");
middleware
.after("cli.ls", json!({}), json!({}), &ctx)
.await
.expect("after never fails");
assert!(middleware.with_start_times(|times| times.is_empty()));
middleware
.before("cli.ls", json!({}), &ctx)
.await
.expect("before never fails");
let error = ModuleError::new(ErrorCode::ModuleTimeout, "timed out".to_string());
middleware
.on_error("cli.ls", json!({}), &error, &ctx)
.await
.expect("on_error never fails");
assert!(middleware.with_start_times(|times| times.is_empty()));
}
#[tokio::test]
async fn test_failure_log_middleware_measures_a_duration_when_before_ran() {
let middleware = FailureLogMiddleware::new();
let ctx = context();
middleware
.before("cli.ls", json!({}), &ctx)
.await
.expect("before never fails");
let elapsed = middleware.take_elapsed_ms("cli.ls", &ctx);
assert!(elapsed.is_some(), "before must record a start instant");
}
#[tokio::test]
async fn test_failure_log_middleware_survives_a_failure_before_the_middleware_phase() {
let middleware = FailureLogMiddleware::new();
let error = ModuleError::new(ErrorCode::ACLDenied, "denied".to_string());
let outcome = middleware
.on_error("cli.rm", json!({}), &error, &context())
.await
.expect("on_error never fails without a matching before");
assert!(outcome.is_none());
}
#[tokio::test]
async fn test_failure_log_middleware_keys_concurrent_calls_separately() {
let middleware = FailureLogMiddleware::new();
let first = context();
let second = context();
assert_ne!(first.trace_id, second.trace_id);
middleware
.before("cli.ls", json!({}), &first)
.await
.expect("before never fails");
middleware
.before("cli.ls", json!({}), &second)
.await
.expect("before never fails");
assert!(middleware.take_elapsed_ms("cli.ls", &first).is_some());
assert!(
middleware.take_elapsed_ms("cli.ls", &second).is_some(),
"the second call's timing entry must survive the first call's release"
);
}
}