Skip to main content

edgeguard/
logship.rs

1//! Access-log shipping — the edge's half of the centralized log plane.
2//!
3//! # What was missing
4//!
5//! The edge emits one structured access-log line per request, to **stdout**, and that was the whole
6//! story. There was no sink, no transport and no destination: an operator running a fleet had to
7//! collect logs per box, out of band, with whatever their platform happened to provide. For a
8//! product whose pitch is centralized control of an edge fleet, that is the gap.
9//!
10//! # Why it ships to a collector rather than to the control plane
11//!
12//! Two shapes were possible. The control plane could grow a log-ingest endpoint and own the data;
13//! or the edge could ship to a collector the operator already runs. This is the second, and the
14//! reason is volume: access logs are three to five orders of magnitude more records than the usage
15//! deltas the control plane meters. Putting them through the same Postgres that holds invoices
16//! would make that store's dominant workload the one thing nobody bills for.
17//!
18//! Shipping to any NDJSON collector — Vector, Loki, Splunk HEC, Datadog, an S3 writer — means the
19//! operator keeps the data on their own retention and their own bill, which is also what a
20//! self-hosted enterprise customer wants. It reuses the wire shape the control plane's
21//! `[audit.ship]` already established: one POST of newline-delimited JSON, not an SDK per vendor.
22//!
23//! # This is NOT the audit trail, and the difference is deliberate
24//!
25//! `[audit.ship]` in the control plane is **at-least-once with a cursor**: the audit trail is
26//! evidence, and a lost record is a hole in it. Access logs are telemetry. Guaranteeing delivery
27//! here would mean an unbounded on-box buffer, and the failure mode of an unbounded buffer on a
28//! proxy is that a collector outage takes down the proxy — trading a real outage for a telemetry
29//! gap. So this is **best-effort and bounded**, and it counts precisely what it drops.
30//!
31//! # It must never slow down a request
32//!
33//! The request path does one `try_send` on a bounded channel and returns. It never awaits, never
34//! blocks, never allocates a batch and never touches the network. Everything else happens on a
35//! background task. If the channel is full — the collector is slow or gone — the record is dropped
36//! and counted, which is the same fail-static discipline the control-plane client uses: degrade the
37//! telemetry, never the traffic.
38
39use 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/// One access-log record on the wire.
51///
52/// The fields are exactly what `finish()` already logs to stdout — deliberately, so the shipped
53/// stream and the local one cannot disagree about what happened. `target` arrives already
54/// sanitised by [`crate::accesslog::sanitize_target`]: a proxy that advertises DLP must not be the
55/// component that writes credentials into a SIEM.
56#[derive(Debug, Clone, Serialize)]
57pub struct AccessRecord {
58    /// RFC3339 UTC, so a collector can order records across boxes without trusting arrival order.
59    pub ts: String,
60    pub request_id: String,
61    pub method: String,
62    /// Path plus sanitised query.
63    pub target: String,
64    pub client_ip: String,
65    pub status: u16,
66    pub outcome: String,
67    pub latency_ms: u64,
68    /// The edge that served it, so a fleet-wide stream stays attributable.
69    pub edge_id: String,
70}
71
72/// Counters for the shipper itself.
73///
74/// A log pipeline that silently drops is worse than no pipeline: the gap is invisible in the
75/// destination, and the absence of records reads as an absence of traffic. These are exported so
76/// the drop is a number someone can alert on.
77#[derive(Debug, Default)]
78pub struct ShipStats {
79    pub sent: AtomicU64,
80    /// Records the request path could not hand off because the queue was full.
81    pub dropped_queue_full: AtomicU64,
82    /// Records in batches the collector refused after the retry.
83    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/// The handle the request path holds. Cloneable, cheap, and non-blocking to use.
101#[derive(Clone)]
102pub struct LogShipper {
103    tx: mpsc::Sender<AccessRecord>,
104    stats: Arc<ShipStats>,
105    edge_id: String,
106}
107
108impl LogShipper {
109    /// This edge's identity, stamped onto every record.
110    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    /// Hand a record to the shipper. Never blocks and never fails the caller.
119    ///
120    /// `try_send`, not `send().await`: this is called from the response path of every request. An
121    /// await here would couple request latency to collector latency, which is the precise failure
122    /// this module exists to avoid — an access-log pipeline is not worth a millisecond of p99, let
123    /// alone the unbounded stall a wedged collector would cause.
124    ///
125    /// A full queue drops the NEWEST record rather than evicting the oldest. Both lose data; this
126    /// one is O(1) with no locking, and during a collector outage the older records are the ones
127    /// already batched and about to go out. Dropping is counted, never silent.
128    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
137/// Build the shipper and spawn its background task. `None` when shipping is disabled.
138pub 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        // A short timeout on purpose: this task is the only consumer of the queue, so a request
160        // that hangs for the client's default (no timeout at all) stops draining and every record
161        // behind it is dropped for queue-full. Failing fast and retrying loses less.
162        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        // `interval`'s FIRST tick completes immediately. Left in place it fires on the task's first
187        // pass through the select, which — if a record arrived before the task was scheduled —
188        // flushes a one-record batch at startup instead of after the configured interval. Harmless,
189        // but it makes `interval_secs` mean something other than what it says, and it turns the
190        // first batch of every process into a batch of one. Consume it here so the first real tick
191        // lands one full interval in.
192        tick.tick().await;
193
194        loop {
195            tokio::select! {
196                // Biased so the queue is drained before the timer fires. Without it, a busy edge
197                // can starve the drain under a hot timer and hold records longer than the interval
198                // it promises.
199                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                        // The senders are gone: the proxy is shutting down.
215                        None => break,
216                    }
217                }
218                _ = tick.tick() => {
219                    if !buf.is_empty() {
220                        self.flush(&mut buf).await;
221                    }
222                }
223            }
224        }
225
226        // Drain what is already queued on the way out. A graceful shutdown that discarded the last
227        // partial batch would lose exactly the records around a restart — the ones most likely to
228        // explain why it happened.
229        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    /// POST one batch as NDJSON. Empties `buf` either way — see the module docs on why this is
241    /// best-effort rather than at-least-once.
242    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            // A record that will not serialize is skipped rather than poisoning the batch: one bad
247            // line must not cost the other 499.
248            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        // One retry, then drop. More would build a backlog behind a collector that is down, which
256        // on a bounded queue just converts collector downtime into request-path drops.
257        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    /// The bodies a test collector received, in arrival order.
322    type Collected = Arc<std::sync::Mutex<Vec<String>>>;
323
324    /// State the test collector handler runs over.
325    #[derive(Clone)]
326    struct CollectorState {
327        hits: Arc<AtomicUsize>,
328        bodies: Collected,
329        status: u16,
330    }
331
332    /// A collector that records the NDJSON bodies it receives.
333    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")); // batch = 2, so this triggers a flush
392        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        // Otherwise a low-traffic edge holds its last records indefinitely, and the collector shows
417        // a gap that looks exactly like an outage.
418        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")); // below the batch size of 2
423        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        // The property the request path depends on. A collector that never answers must cost
439        // dropped telemetry, never request latency.
440        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        // Large enough that the task never flushes on size, so the queue genuinely fills.
445        c.batch = 10_000;
446        c.interval_secs = 3_600;
447        let s = spawn(&c, "edge-1".into(), rx).unwrap();
448
449        // Far more than the queue can hold. Every one of these must return immediately.
450        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        // The records around a restart are the ones most likely to explain it.
490        let (url, hits, _) = spawn_collector(200).await;
491        let (tx, rx) = watch::channel(false);
492        let mut c = cfg(&url);
493        c.batch = 100; // never flushes on size
494        c.interval_secs = 3_600; // never flushes on the timer
495        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        // How a collector's API key travels: Splunk HEC wants `Authorization: Splunk <token>`,
522        // Datadog wants `DD-API-KEY`. One header map rather than an integration per vendor.
523        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}