use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tracing::{info, warn};
use crate::config::LogShipCfg;
#[derive(Debug, Clone, Serialize)]
pub struct AccessRecord {
pub ts: String,
pub request_id: String,
pub method: String,
pub target: String,
pub client_ip: String,
pub status: u16,
pub outcome: String,
pub latency_ms: u64,
pub edge_id: String,
}
#[derive(Debug, Default)]
pub struct ShipStats {
pub sent: AtomicU64,
pub dropped_queue_full: AtomicU64,
pub dropped_send_failed: AtomicU64,
pub batches_sent: AtomicU64,
pub batches_failed: AtomicU64,
}
impl ShipStats {
pub fn snapshot(&self) -> (u64, u64, u64, u64, u64) {
(
self.sent.load(Ordering::Relaxed),
self.dropped_queue_full.load(Ordering::Relaxed),
self.dropped_send_failed.load(Ordering::Relaxed),
self.batches_sent.load(Ordering::Relaxed),
self.batches_failed.load(Ordering::Relaxed),
)
}
}
#[derive(Clone)]
pub struct LogShipper {
tx: mpsc::Sender<AccessRecord>,
stats: Arc<ShipStats>,
edge_id: String,
}
impl LogShipper {
pub fn edge_id(&self) -> &str {
&self.edge_id
}
pub fn stats(&self) -> &Arc<ShipStats> {
&self.stats
}
pub fn record(&self, rec: AccessRecord) {
if self.tx.try_send(rec).is_err() {
self.stats
.dropped_queue_full
.fetch_add(1, Ordering::Relaxed);
}
}
}
pub fn spawn(
cfg: &LogShipCfg,
edge_id: String,
shutdown: watch::Receiver<bool>,
) -> Option<LogShipper> {
if !cfg.enabled || cfg.url.is_empty() {
return None;
}
let stats = Arc::new(ShipStats::default());
let (tx, rx) = mpsc::channel(cfg.queue_size.max(1));
let shipper = LogShipper {
tx,
stats: Arc::clone(&stats),
edge_id,
};
let task = ShipTask {
url: cfg.url.clone(),
headers: cfg.headers.clone(),
batch: cfg.batch.max(1),
interval: Duration::from_secs(cfg.interval_secs.max(1)),
stats,
http: reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.ok()?,
};
info!(url = %cfg.url, batch = task.batch, ?task.interval, "access-log shipping enabled");
tokio::spawn(task.run(rx, shutdown));
Some(shipper)
}
struct ShipTask {
http: reqwest::Client,
url: String,
headers: Vec<(String, String)>,
batch: usize,
interval: Duration,
stats: Arc<ShipStats>,
}
impl ShipTask {
async fn run(self, mut rx: mpsc::Receiver<AccessRecord>, mut shutdown: watch::Receiver<bool>) {
let mut buf: Vec<AccessRecord> = Vec::with_capacity(self.batch);
let mut tick = tokio::time::interval(self.interval);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
tick.tick().await;
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
if *shutdown.borrow() {
break;
}
}
got = rx.recv() => {
match got {
Some(rec) => {
buf.push(rec);
if buf.len() >= self.batch {
self.flush(&mut buf).await;
}
}
None => break,
}
}
_ = tick.tick() => {
if !buf.is_empty() {
self.flush(&mut buf).await;
}
}
}
}
while let Ok(rec) = rx.try_recv() {
buf.push(rec);
if buf.len() >= self.batch {
self.flush(&mut buf).await;
}
}
if !buf.is_empty() {
self.flush(&mut buf).await;
}
}
async fn flush(&self, buf: &mut Vec<AccessRecord>) {
let n = buf.len() as u64;
let mut body = String::with_capacity(n as usize * 256);
for rec in buf.iter() {
if let Ok(line) = serde_json::to_string(rec) {
body.push_str(&line);
body.push('\n');
}
}
buf.clear();
for attempt in 0..2 {
let mut req = self
.http
.post(&self.url)
.header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
.body(body.clone());
for (k, v) in &self.headers {
req = req.header(k.as_str(), v.as_str());
}
match req.send().await {
Ok(r) if r.status().is_success() => {
self.stats.sent.fetch_add(n, Ordering::Relaxed);
self.stats.batches_sent.fetch_add(1, Ordering::Relaxed);
return;
}
Ok(r) => {
if attempt == 1 {
warn!(status = %r.status(), records = n, "log collector rejected a batch");
}
}
Err(e) => {
if attempt == 1 {
warn!(error = %e, records = n, "shipping a log batch failed");
}
}
}
}
self.stats
.dropped_send_failed
.fetch_add(n, Ordering::Relaxed);
self.stats.batches_failed.fetch_add(1, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
fn rec(id: &str) -> AccessRecord {
AccessRecord {
ts: "2026-09-06T12:00:00Z".into(),
request_id: id.into(),
method: "GET".into(),
target: "/x".into(),
client_ip: "1.2.3.4".into(),
status: 200,
outcome: "proxied".into(),
latency_ms: 3,
edge_id: "edge-1".into(),
}
}
fn cfg(url: &str) -> LogShipCfg {
LogShipCfg {
enabled: true,
url: url.into(),
headers: Vec::new(),
batch: 2,
interval_secs: 1,
queue_size: 8,
}
}
type Collected = Arc<std::sync::Mutex<Vec<String>>>;
#[derive(Clone)]
struct CollectorState {
hits: Arc<AtomicUsize>,
bodies: Collected,
status: u16,
}
async fn spawn_collector(status: u16) -> (String, Arc<AtomicUsize>, Collected) {
use axum::extract::State;
use axum::routing::post;
let hits = Arc::new(AtomicUsize::new(0));
let bodies: Collected = Arc::new(std::sync::Mutex::new(Vec::new()));
let st = CollectorState {
hits: Arc::clone(&hits),
bodies: Arc::clone(&bodies),
status,
};
async fn sink(
State(CollectorState {
hits,
bodies,
status,
}): State<CollectorState>,
body: String,
) -> axum::http::StatusCode {
hits.fetch_add(1, Ordering::SeqCst);
bodies.lock().unwrap().push(body);
axum::http::StatusCode::from_u16(status).unwrap()
}
let app = axum::Router::new()
.route("/ingest", post(sink))
.with_state(st);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}/ingest"), hits, bodies)
}
#[tokio::test]
async fn disabled_or_urlless_config_builds_nothing() {
let (_tx, rx) = watch::channel(false);
let mut c = cfg("http://example");
c.enabled = false;
assert!(spawn(&c, "e".into(), rx.clone()).is_none());
let mut c = cfg("");
c.enabled = true;
assert!(
spawn(&c, "e".into(), rx).is_none(),
"enabled with no URL must not spawn a task that can never deliver"
);
}
#[tokio::test]
async fn a_full_batch_is_posted_as_ndjson() {
let (url, hits, bodies) = spawn_collector(200).await;
let (_tx, rx) = watch::channel(false);
let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
s.record(rec("a"));
s.record(rec("b")); for _ in 0..50 {
if hits.load(Ordering::SeqCst) > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"one batch, not one POST per record"
);
let body = bodies.lock().unwrap()[0].clone();
let lines: Vec<&str> = body.trim_end().split('\n').collect();
assert_eq!(lines.len(), 2, "NDJSON: one record per line");
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first["request_id"], "a");
assert_eq!(first["edge_id"], "edge-1");
assert_eq!(first["status"], 200);
assert_eq!(s.stats().sent.load(Ordering::Relaxed), 2);
}
#[tokio::test]
async fn a_partial_batch_flushes_on_the_interval() {
let (url, hits, _) = spawn_collector(200).await;
let (_tx, rx) = watch::channel(false);
let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
s.record(rec("only-one")); for _ in 0..100 {
if hits.load(Ordering::SeqCst) > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(30)).await;
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"the timer must flush a partial batch"
);
}
#[tokio::test]
async fn a_full_queue_drops_and_counts_rather_than_blocking() {
let (url, _, _) = spawn_collector(200).await;
let (_tx, rx) = watch::channel(false);
let mut c = cfg(&url);
c.queue_size = 1;
c.batch = 10_000;
c.interval_secs = 3_600;
let s = spawn(&c, "edge-1".into(), rx).unwrap();
let started = std::time::Instant::now();
for i in 0..500 {
s.record(rec(&format!("r{i}")));
}
assert!(
started.elapsed() < Duration::from_secs(1),
"recording must never block the request path"
);
assert!(
s.stats().dropped_queue_full.load(Ordering::Relaxed) > 0,
"a full queue must count its drops — a silent gap reads as an absence of traffic"
);
}
#[tokio::test]
async fn a_rejecting_collector_is_retried_once_then_the_batch_is_dropped() {
let (url, hits, _) = spawn_collector(503).await;
let (_tx, rx) = watch::channel(false);
let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
s.record(rec("a"));
s.record(rec("b"));
for _ in 0..100 {
if s.stats().batches_failed.load(Ordering::Relaxed) > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
hits.load(Ordering::SeqCst),
2,
"one try plus exactly one retry"
);
assert_eq!(s.stats().dropped_send_failed.load(Ordering::Relaxed), 2);
assert_eq!(s.stats().sent.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn queued_records_are_flushed_on_shutdown() {
let (url, hits, _) = spawn_collector(200).await;
let (tx, rx) = watch::channel(false);
let mut c = cfg(&url);
c.batch = 100; c.interval_secs = 3_600; let s = spawn(&c, "edge-1".into(), rx).unwrap();
s.record(rec("a"));
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
hits.load(Ordering::SeqCst),
0,
"nothing should have flushed yet"
);
tx.send(true).unwrap();
for _ in 0..100 {
if hits.load(Ordering::SeqCst) > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"shutdown must drain the buffer"
);
}
#[tokio::test]
async fn configured_headers_are_sent() {
use std::sync::Mutex;
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let seen2 = Arc::clone(&seen);
let app = axum::Router::new().route(
"/ingest",
axum::routing::post(move |headers: axum::http::HeaderMap, _b: String| {
let seen = Arc::clone(&seen2);
async move {
*seen.lock().unwrap() = headers
.get("x-api-key")
.and_then(|v| v.to_str().ok())
.map(String::from);
axum::http::StatusCode::OK
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let (_tx, rx) = watch::channel(false);
let mut c = cfg(&format!("http://{addr}/ingest"));
c.headers = vec![("x-api-key".into(), "secret-token".into())];
let s = spawn(&c, "edge-1".into(), rx).unwrap();
s.record(rec("a"));
s.record(rec("b"));
for _ in 0..100 {
if seen.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(seen.lock().unwrap().as_deref(), Some("secret-token"));
}
}