1use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::Arc;
41use std::time::Duration;
42
43use serde::Serialize;
44use tokio::sync::mpsc;
45use tokio::sync::watch;
46use tracing::{info, warn};
47
48use crate::config::LogShipCfg;
49
50#[derive(Debug, Clone, Serialize)]
57pub struct AccessRecord {
58 pub ts: String,
60 pub request_id: String,
61 pub method: String,
62 pub target: String,
64 pub client_ip: String,
65 pub status: u16,
66 pub outcome: String,
67 pub latency_ms: u64,
68 pub edge_id: String,
70}
71
72#[derive(Debug, Default)]
78pub struct ShipStats {
79 pub sent: AtomicU64,
80 pub dropped_queue_full: AtomicU64,
82 pub dropped_send_failed: AtomicU64,
84 pub batches_sent: AtomicU64,
85 pub batches_failed: AtomicU64,
86}
87
88impl ShipStats {
89 pub fn snapshot(&self) -> (u64, u64, u64, u64, u64) {
90 (
91 self.sent.load(Ordering::Relaxed),
92 self.dropped_queue_full.load(Ordering::Relaxed),
93 self.dropped_send_failed.load(Ordering::Relaxed),
94 self.batches_sent.load(Ordering::Relaxed),
95 self.batches_failed.load(Ordering::Relaxed),
96 )
97 }
98}
99
100#[derive(Clone)]
102pub struct LogShipper {
103 tx: mpsc::Sender<AccessRecord>,
104 stats: Arc<ShipStats>,
105 edge_id: String,
106}
107
108impl LogShipper {
109 pub fn edge_id(&self) -> &str {
111 &self.edge_id
112 }
113
114 pub fn stats(&self) -> &Arc<ShipStats> {
115 &self.stats
116 }
117
118 pub fn record(&self, rec: AccessRecord) {
129 if self.tx.try_send(rec).is_err() {
130 self.stats
131 .dropped_queue_full
132 .fetch_add(1, Ordering::Relaxed);
133 }
134 }
135}
136
137pub fn spawn(
139 cfg: &LogShipCfg,
140 edge_id: String,
141 shutdown: watch::Receiver<bool>,
142) -> Option<LogShipper> {
143 if !cfg.enabled || cfg.url.is_empty() {
144 return None;
145 }
146 let stats = Arc::new(ShipStats::default());
147 let (tx, rx) = mpsc::channel(cfg.queue_size.max(1));
148 let shipper = LogShipper {
149 tx,
150 stats: Arc::clone(&stats),
151 edge_id,
152 };
153 let task = ShipTask {
154 url: cfg.url.clone(),
155 headers: cfg.headers.clone(),
156 batch: cfg.batch.max(1),
157 interval: Duration::from_secs(cfg.interval_secs.max(1)),
158 stats,
159 http: reqwest::Client::builder()
163 .timeout(Duration::from_secs(10))
164 .build()
165 .ok()?,
166 };
167 info!(url = %cfg.url, batch = task.batch, ?task.interval, "access-log shipping enabled");
168 tokio::spawn(task.run(rx, shutdown));
169 Some(shipper)
170}
171
172struct ShipTask {
173 http: reqwest::Client,
174 url: String,
175 headers: Vec<(String, String)>,
176 batch: usize,
177 interval: Duration,
178 stats: Arc<ShipStats>,
179}
180
181impl ShipTask {
182 async fn run(self, mut rx: mpsc::Receiver<AccessRecord>, mut shutdown: watch::Receiver<bool>) {
183 let mut buf: Vec<AccessRecord> = Vec::with_capacity(self.batch);
184 let mut tick = tokio::time::interval(self.interval);
185 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
186 tick.tick().await;
193
194 loop {
195 tokio::select! {
196 biased;
200
201 _ = shutdown.changed() => {
202 if *shutdown.borrow() {
203 break;
204 }
205 }
206 got = rx.recv() => {
207 match got {
208 Some(rec) => {
209 buf.push(rec);
210 if buf.len() >= self.batch {
211 self.flush(&mut buf).await;
212 }
213 }
214 None => break,
216 }
217 }
218 _ = tick.tick() => {
219 if !buf.is_empty() {
220 self.flush(&mut buf).await;
221 }
222 }
223 }
224 }
225
226 while let Ok(rec) = rx.try_recv() {
230 buf.push(rec);
231 if buf.len() >= self.batch {
232 self.flush(&mut buf).await;
233 }
234 }
235 if !buf.is_empty() {
236 self.flush(&mut buf).await;
237 }
238 }
239
240 async fn flush(&self, buf: &mut Vec<AccessRecord>) {
243 let n = buf.len() as u64;
244 let mut body = String::with_capacity(n as usize * 256);
245 for rec in buf.iter() {
246 if let Ok(line) = serde_json::to_string(rec) {
249 body.push_str(&line);
250 body.push('\n');
251 }
252 }
253 buf.clear();
254
255 for attempt in 0..2 {
258 let mut req = self
259 .http
260 .post(&self.url)
261 .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
262 .body(body.clone());
263 for (k, v) in &self.headers {
264 req = req.header(k.as_str(), v.as_str());
265 }
266 match req.send().await {
267 Ok(r) if r.status().is_success() => {
268 self.stats.sent.fetch_add(n, Ordering::Relaxed);
269 self.stats.batches_sent.fetch_add(1, Ordering::Relaxed);
270 return;
271 }
272 Ok(r) => {
273 if attempt == 1 {
274 warn!(status = %r.status(), records = n, "log collector rejected a batch");
275 }
276 }
277 Err(e) => {
278 if attempt == 1 {
279 warn!(error = %e, records = n, "shipping a log batch failed");
280 }
281 }
282 }
283 }
284 self.stats
285 .dropped_send_failed
286 .fetch_add(n, Ordering::Relaxed);
287 self.stats.batches_failed.fetch_add(1, Ordering::Relaxed);
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::sync::atomic::AtomicUsize;
295
296 fn rec(id: &str) -> AccessRecord {
297 AccessRecord {
298 ts: "2026-09-06T12:00:00Z".into(),
299 request_id: id.into(),
300 method: "GET".into(),
301 target: "/x".into(),
302 client_ip: "1.2.3.4".into(),
303 status: 200,
304 outcome: "proxied".into(),
305 latency_ms: 3,
306 edge_id: "edge-1".into(),
307 }
308 }
309
310 fn cfg(url: &str) -> LogShipCfg {
311 LogShipCfg {
312 enabled: true,
313 url: url.into(),
314 headers: Vec::new(),
315 batch: 2,
316 interval_secs: 1,
317 queue_size: 8,
318 }
319 }
320
321 type Collected = Arc<std::sync::Mutex<Vec<String>>>;
323
324 #[derive(Clone)]
326 struct CollectorState {
327 hits: Arc<AtomicUsize>,
328 bodies: Collected,
329 status: u16,
330 }
331
332 async fn spawn_collector(status: u16) -> (String, Arc<AtomicUsize>, Collected) {
334 use axum::extract::State;
335 use axum::routing::post;
336
337 let hits = Arc::new(AtomicUsize::new(0));
338 let bodies: Collected = Arc::new(std::sync::Mutex::new(Vec::new()));
339 let st = CollectorState {
340 hits: Arc::clone(&hits),
341 bodies: Arc::clone(&bodies),
342 status,
343 };
344
345 async fn sink(
346 State(CollectorState {
347 hits,
348 bodies,
349 status,
350 }): State<CollectorState>,
351 body: String,
352 ) -> axum::http::StatusCode {
353 hits.fetch_add(1, Ordering::SeqCst);
354 bodies.lock().unwrap().push(body);
355 axum::http::StatusCode::from_u16(status).unwrap()
356 }
357
358 let app = axum::Router::new()
359 .route("/ingest", post(sink))
360 .with_state(st);
361 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
362 let addr = listener.local_addr().unwrap();
363 tokio::spawn(async move {
364 let _ = axum::serve(listener, app).await;
365 });
366 (format!("http://{addr}/ingest"), hits, bodies)
367 }
368
369 #[tokio::test]
370 async fn disabled_or_urlless_config_builds_nothing() {
371 let (_tx, rx) = watch::channel(false);
372 let mut c = cfg("http://example");
373 c.enabled = false;
374 assert!(spawn(&c, "e".into(), rx.clone()).is_none());
375
376 let mut c = cfg("");
377 c.enabled = true;
378 assert!(
379 spawn(&c, "e".into(), rx).is_none(),
380 "enabled with no URL must not spawn a task that can never deliver"
381 );
382 }
383
384 #[tokio::test]
385 async fn a_full_batch_is_posted_as_ndjson() {
386 let (url, hits, bodies) = spawn_collector(200).await;
387 let (_tx, rx) = watch::channel(false);
388 let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
389
390 s.record(rec("a"));
391 s.record(rec("b")); for _ in 0..50 {
393 if hits.load(Ordering::SeqCst) > 0 {
394 break;
395 }
396 tokio::time::sleep(Duration::from_millis(20)).await;
397 }
398 assert_eq!(
399 hits.load(Ordering::SeqCst),
400 1,
401 "one batch, not one POST per record"
402 );
403
404 let body = bodies.lock().unwrap()[0].clone();
405 let lines: Vec<&str> = body.trim_end().split('\n').collect();
406 assert_eq!(lines.len(), 2, "NDJSON: one record per line");
407 let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
408 assert_eq!(first["request_id"], "a");
409 assert_eq!(first["edge_id"], "edge-1");
410 assert_eq!(first["status"], 200);
411 assert_eq!(s.stats().sent.load(Ordering::Relaxed), 2);
412 }
413
414 #[tokio::test]
415 async fn a_partial_batch_flushes_on_the_interval() {
416 let (url, hits, _) = spawn_collector(200).await;
419 let (_tx, rx) = watch::channel(false);
420 let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
421
422 s.record(rec("only-one")); for _ in 0..100 {
424 if hits.load(Ordering::SeqCst) > 0 {
425 break;
426 }
427 tokio::time::sleep(Duration::from_millis(30)).await;
428 }
429 assert_eq!(
430 hits.load(Ordering::SeqCst),
431 1,
432 "the timer must flush a partial batch"
433 );
434 }
435
436 #[tokio::test]
437 async fn a_full_queue_drops_and_counts_rather_than_blocking() {
438 let (url, _, _) = spawn_collector(200).await;
441 let (_tx, rx) = watch::channel(false);
442 let mut c = cfg(&url);
443 c.queue_size = 1;
444 c.batch = 10_000;
446 c.interval_secs = 3_600;
447 let s = spawn(&c, "edge-1".into(), rx).unwrap();
448
449 let started = std::time::Instant::now();
451 for i in 0..500 {
452 s.record(rec(&format!("r{i}")));
453 }
454 assert!(
455 started.elapsed() < Duration::from_secs(1),
456 "recording must never block the request path"
457 );
458 assert!(
459 s.stats().dropped_queue_full.load(Ordering::Relaxed) > 0,
460 "a full queue must count its drops — a silent gap reads as an absence of traffic"
461 );
462 }
463
464 #[tokio::test]
465 async fn a_rejecting_collector_is_retried_once_then_the_batch_is_dropped() {
466 let (url, hits, _) = spawn_collector(503).await;
467 let (_tx, rx) = watch::channel(false);
468 let s = spawn(&cfg(&url), "edge-1".into(), rx).unwrap();
469
470 s.record(rec("a"));
471 s.record(rec("b"));
472 for _ in 0..100 {
473 if s.stats().batches_failed.load(Ordering::Relaxed) > 0 {
474 break;
475 }
476 tokio::time::sleep(Duration::from_millis(20)).await;
477 }
478 assert_eq!(
479 hits.load(Ordering::SeqCst),
480 2,
481 "one try plus exactly one retry"
482 );
483 assert_eq!(s.stats().dropped_send_failed.load(Ordering::Relaxed), 2);
484 assert_eq!(s.stats().sent.load(Ordering::Relaxed), 0);
485 }
486
487 #[tokio::test]
488 async fn queued_records_are_flushed_on_shutdown() {
489 let (url, hits, _) = spawn_collector(200).await;
491 let (tx, rx) = watch::channel(false);
492 let mut c = cfg(&url);
493 c.batch = 100; c.interval_secs = 3_600; let s = spawn(&c, "edge-1".into(), rx).unwrap();
496
497 s.record(rec("a"));
498 tokio::time::sleep(Duration::from_millis(50)).await;
499 assert_eq!(
500 hits.load(Ordering::SeqCst),
501 0,
502 "nothing should have flushed yet"
503 );
504
505 tx.send(true).unwrap();
506 for _ in 0..100 {
507 if hits.load(Ordering::SeqCst) > 0 {
508 break;
509 }
510 tokio::time::sleep(Duration::from_millis(20)).await;
511 }
512 assert_eq!(
513 hits.load(Ordering::SeqCst),
514 1,
515 "shutdown must drain the buffer"
516 );
517 }
518
519 #[tokio::test]
520 async fn configured_headers_are_sent() {
521 use std::sync::Mutex;
524 let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
525 let seen2 = Arc::clone(&seen);
526
527 let app = axum::Router::new().route(
528 "/ingest",
529 axum::routing::post(move |headers: axum::http::HeaderMap, _b: String| {
530 let seen = Arc::clone(&seen2);
531 async move {
532 *seen.lock().unwrap() = headers
533 .get("x-api-key")
534 .and_then(|v| v.to_str().ok())
535 .map(String::from);
536 axum::http::StatusCode::OK
537 }
538 }),
539 );
540 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
541 let addr = listener.local_addr().unwrap();
542 tokio::spawn(async move {
543 let _ = axum::serve(listener, app).await;
544 });
545
546 let (_tx, rx) = watch::channel(false);
547 let mut c = cfg(&format!("http://{addr}/ingest"));
548 c.headers = vec![("x-api-key".into(), "secret-token".into())];
549 let s = spawn(&c, "edge-1".into(), rx).unwrap();
550 s.record(rec("a"));
551 s.record(rec("b"));
552
553 for _ in 0..100 {
554 if seen.lock().unwrap().is_some() {
555 break;
556 }
557 tokio::time::sleep(Duration::from_millis(20)).await;
558 }
559 assert_eq!(seen.lock().unwrap().as_deref(), Some("secret-token"));
560 }
561}