use crate::{Analytics, ErrorRecord, RequestRecord};
use armature_core::{Error, HttpRequest, HttpResponse, Middleware, Next};
use async_trait::async_trait;
use std::time::Instant;
#[derive(Clone)]
pub struct AnalyticsMiddleware {
analytics: Analytics,
}
impl AnalyticsMiddleware {
pub fn new(analytics: Analytics) -> Self {
Self { analytics }
}
pub fn analytics(&self) -> &Analytics {
&self.analytics
}
}
#[derive(Clone)]
pub struct AnalyticsContext {
analytics: Analytics,
start_time: Instant,
method: String,
path: String,
}
impl AnalyticsContext {
pub fn new(analytics: Analytics, method: impl Into<String>, path: impl Into<String>) -> Self {
Self {
analytics,
start_time: Instant::now(),
method: method.into(),
path: path.into(),
}
}
pub fn elapsed(&self) -> std::time::Duration {
self.start_time.elapsed()
}
pub fn complete(self, status: u16, response_size: Option<u64>) {
let record = RequestRecord::new(
&self.method,
normalize_path(&self.path),
status,
self.start_time.elapsed(),
)
.with_response_size(response_size.unwrap_or(0));
self.analytics.record_request(record);
}
pub fn record_error(&self, error_type: &str, message: &str) {
let record = ErrorRecord::new(error_type, message)
.with_endpoint(format!("{} {}", self.method, self.path));
self.analytics.record_error(record);
}
}
fn extract_client_id(req: &HttpRequest) -> Option<String> {
if let Some(id) = req.headers.get("x-client-id") {
return Some(id.to_owned());
}
if let Some(fwd) = req.headers.get("x-forwarded-for") {
if let Some(first) = fwd.split(',').next() {
let trimmed = first.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
if let Some(ip) = req.headers.get("x-real-ip") {
return Some(ip.to_owned());
}
None
}
#[async_trait]
impl Middleware for AnalyticsMiddleware {
async fn handle(&self, req: HttpRequest, next: Next) -> Result<HttpResponse, Error> {
let config = self.analytics.config();
if !config.enabled {
return next(req).await;
}
let method = req.method_str().to_owned();
let excluded = config.should_exclude(req.path_only());
if excluded || !config.should_sample() {
return next(req).await;
}
let mut tracked_path = normalize_path(req.path_only());
if config.include_query_params && !req.query().is_empty() {
let mut pairs: Vec<(&str, &str)> = req.query().iter().collect();
pairs.sort_by(|a, b| a.0.cmp(b.0));
let query: Vec<String> = pairs.iter().map(|(k, v)| format!("{}={}", k, v)).collect();
tracked_path = format!("{}?{}", tracked_path, query.join("&"));
}
let client_id = if config.track_clients {
extract_client_id(&req)
} else {
None
};
let start = Instant::now();
let result = next(req).await;
let duration = start.elapsed();
match &result {
Ok(response) => {
let mut record =
RequestRecord::new(method, tracked_path, response.status, duration)
.with_response_size(response.body.len() as u64);
if let Some(cid) = client_id {
record = record.with_client_id(cid);
}
self.analytics.record_request(record);
}
Err(err) => {
let mut record = RequestRecord::new(&method, tracked_path.clone(), 500, duration);
if let Some(cid) = client_id {
record = record.with_client_id(cid);
}
self.analytics.record_request(record);
self.analytics.record_error(
ErrorRecord::new("middleware_error", err.to_string())
.with_status(500)
.with_endpoint(format!("{} {}", method, tracked_path)),
);
}
}
result
}
}
pub fn normalize_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for (i, segment) in path.split('/').enumerate() {
if i > 0 {
out.push('/');
}
if segment.is_empty() {
} else if is_likely_id(segment) {
out.push_str(":id");
} else {
out.push_str(segment);
}
}
out
}
fn is_likely_id(segment: &str) -> bool {
if segment.len() == 36 && segment.chars().filter(|c| *c == '-').count() == 4 {
return true;
}
if segment.chars().all(|c| c.is_ascii_digit()) && !segment.is_empty() {
return true;
}
if segment.len() == 24 && segment.chars().all(|c| c.is_ascii_hexdigit()) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AnalyticsConfig;
#[test]
fn test_normalize_path() {
assert_eq!(normalize_path("/users/123/posts"), "/users/:id/posts");
assert_eq!(normalize_path("/api/v1/users"), "/api/v1/users");
assert_eq!(
normalize_path("/users/550e8400-e29b-41d4-a716-446655440000"),
"/users/:id"
);
assert_eq!(
normalize_path("/items/507f1f77bcf86cd799439011"),
"/items/:id"
);
}
#[test]
fn test_is_likely_id() {
assert!(is_likely_id("123"));
assert!(is_likely_id("550e8400-e29b-41d4-a716-446655440000"));
assert!(is_likely_id("507f1f77bcf86cd799439011"));
assert!(!is_likely_id("users"));
assert!(!is_likely_id("api"));
}
#[test]
fn test_analytics_context() {
let analytics = Analytics::new(AnalyticsConfig::default());
let ctx = AnalyticsContext::new(analytics.clone(), "GET", "/api/users");
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(ctx.elapsed().as_millis() >= 10);
}
#[tokio::test]
async fn test_middleware_records_automatically() {
use std::future::Future;
use std::pin::Pin;
let analytics = Analytics::new(AnalyticsConfig::default());
let mw = AnalyticsMiddleware::new(analytics.clone());
let req = HttpRequest::new("GET", "/api/users/123".to_string());
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok().with_body(b"hello".to_vec())) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
let resp = mw.handle(req, next).await.unwrap();
assert_eq!(resp.status, 200);
let snapshot = analytics.snapshot();
assert_eq!(
snapshot.requests.total, 1,
"middleware must record the request"
);
assert_eq!(snapshot.requests.success, 1);
assert_eq!(snapshot.endpoints.len(), 1);
assert_eq!(snapshot.endpoints[0].path, "/api/users/:id");
assert_eq!(snapshot.throughput.total_bytes_transferred, 5);
}
#[tokio::test]
async fn test_middleware_respects_disabled_and_exclusions() {
use std::future::Future;
use std::pin::Pin;
let analytics = Analytics::new(AnalyticsConfig::builder().enabled(false).build());
let mw = AnalyticsMiddleware::new(analytics.clone());
let req = HttpRequest::new("GET", "/api/x".to_string());
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok()) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
let resp = mw.handle(req, next).await.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(analytics.snapshot().requests.total, 0);
let analytics = Analytics::new(AnalyticsConfig::default());
let mw = AnalyticsMiddleware::new(analytics.clone());
let req = HttpRequest::new("GET", "/health".to_string());
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok()) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
mw.handle(req, next).await.unwrap();
assert_eq!(analytics.snapshot().requests.total, 0);
}
#[tokio::test]
async fn test_middleware_strips_query_from_endpoint_key() {
use std::future::Future;
use std::pin::Pin;
let analytics = Analytics::new(AnalyticsConfig::default());
let mw = AnalyticsMiddleware::new(analytics.clone());
for query in ["?ref=x", "?ref=y", "?ref=z"] {
let req = HttpRequest::new("GET", format!("/users/123{}", query));
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok()) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
mw.handle(req, next).await.unwrap();
}
let snapshot = analytics.snapshot();
assert_eq!(snapshot.endpoints.len(), 1, "query must not fork the key");
assert_eq!(snapshot.endpoints[0].path, "/users/:id");
}
#[tokio::test]
async fn test_middleware_exclusion_ignores_query() {
use std::future::Future;
use std::pin::Pin;
let analytics = Analytics::new(AnalyticsConfig::default());
let mw = AnalyticsMiddleware::new(analytics.clone());
let req = HttpRequest::new("GET", "/health?cache-bust=1".to_string());
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok()) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
mw.handle(req, next).await.unwrap();
assert_eq!(analytics.snapshot().requests.total, 0);
}
#[tokio::test]
async fn test_middleware_include_query_params_appends_once() {
use std::future::Future;
use std::pin::Pin;
let analytics = Analytics::new(
AnalyticsConfig::builder()
.include_query_params(true)
.build(),
);
let mw = AnalyticsMiddleware::new(analytics.clone());
let req = HttpRequest::new("GET", "/users/123?b=2&a=1".to_string());
let next: Next = Box::new(|_req: HttpRequest| {
Box::pin(async { Ok(HttpResponse::ok()) })
as Pin<Box<dyn Future<Output = Result<HttpResponse, Error>> + Send>>
});
mw.handle(req, next).await.unwrap();
let snapshot = analytics.snapshot();
assert_eq!(snapshot.endpoints.len(), 1);
assert_eq!(snapshot.endpoints[0].path, "/users/:id?a=1&b=2");
}
}