Skip to main content

vgi_rpc/
access_log.rs

1//! Structured access logging as a [`DispatchHook`].
2//!
3//! Emits one JSON record per RPC call via a writer the caller supplies.
4//! The schema matches the Python canonical (`vgi_rpc.access_log_conformance`
5//! validator) so logs are portable across implementations; the normative
6//! contract is `docs/access-log-spec.md` in the reference repo.
7//!
8//! # Trace correlation
9//!
10//! `request_id` only joins records within one service. `trace_id` / `span_id`
11//! join them to the surrounding distributed trace, and are read from whatever
12//! span is *current* rather than from anything this framework threads through,
13//! so a record correlates with an application-opened span as readily as with a
14//! framework-opened one. The crate carries no OpenTelemetry dependency (the
15//! `otel` feature is tracing-only), so the reader is pluggable — install one
16//! with [`set_trace_context_provider`]:
17//!
18//! ```no_run
19//! # fn my_current_span_ids() -> Option<(String, String)> { None }
20//! use std::sync::Arc;
21//! // e.g. via tracing_opentelemetry::OpenTelemetrySpanExt on Span::current()
22//! vgi_rpc::access_log::set_trace_context_provider(Arc::new(my_current_span_ids));
23//! ```
24//!
25//! Ids that are not 32 / 16 lowercase hex, or that are all zeroes (OTel's
26//! "invalid" sentinel), are dropped rather than emitted — and the pair is
27//! always emitted together or not at all.
28
29use std::collections::BTreeMap;
30use std::io::Write;
31use std::panic::AssertUnwindSafe;
32use std::sync::atomic::{AtomicBool, Ordering};
33use std::sync::mpsc::{SyncSender, TrySendError};
34use std::sync::{Arc, Mutex, RwLock};
35use std::time::Instant;
36
37use serde_json::json;
38
39use crate::errors::RpcError;
40use crate::hooks::{CallStatistics, DispatchHook, DispatchInfo, HookToken};
41
42/// Default per-record byte cap, matching the Python reference's 1 MiB.
43/// Log shippers impose a per-line ceiling (Vector 100 KiB, Fluent Bit
44/// 256 KiB by default) and silently drop longer lines, so a record that
45/// cannot be shipped is worse than a record that admits it shed a field.
46pub const DEFAULT_MAX_RECORD_BYTES: usize = 1_048_576;
47
48/// Where the hook sends formatted JSON lines.
49enum Sink {
50    /// Synchronous: dispatch thread holds the sink mutex during the write.
51    Sync(Arc<Mutex<dyn Write + Send>>),
52    /// Asynchronous: dispatch thread queues the line into a bounded
53    /// channel; a background writer thread drains it.
54    Async {
55        tx: SyncSender<Vec<u8>>,
56        /// Records lost since the last one that made it onto the queue.
57        /// Behind a mutex rather than an atomic so read-stamp-adjust is
58        /// one step: the count must reach the same file the losses would
59        /// have, exactly once.
60        dropped: Arc<Mutex<u64>>,
61    },
62}
63
64impl Clone for Sink {
65    fn clone(&self) -> Self {
66        match self {
67            Sink::Sync(m) => Sink::Sync(m.clone()),
68            Sink::Async { tx, dropped } => Sink::Async {
69                tx: tx.clone(),
70                dropped: dropped.clone(),
71            },
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Trace correlation
78// ---------------------------------------------------------------------------
79
80/// Returns the current span's `(trace_id, span_id)` as W3C hex, or `None`.
81pub type TraceContextProvider = Arc<dyn Fn() -> Option<(String, String)> + Send + Sync>;
82
83static TRACE_PROVIDER: RwLock<Option<TraceContextProvider>> = RwLock::new(None);
84/// Fast path for the overwhelmingly common case of no provider installed:
85/// one relaxed load instead of a lock on every record.
86static HAS_TRACE_PROVIDER: AtomicBool = AtomicBool::new(false);
87
88/// Install the reader used to correlate records with the surrounding trace.
89///
90/// Called once at startup. The provider runs on the dispatch thread while
91/// the call's span is still current, so it must be cheap; it must not be
92/// relied upon to be correct, either — a provider that panics or returns
93/// malformed ids costs the two correlation fields and nothing else.
94pub fn set_trace_context_provider(provider: TraceContextProvider) {
95    if let Ok(mut slot) = TRACE_PROVIDER.write() {
96        *slot = Some(provider);
97        HAS_TRACE_PROVIDER.store(true, Ordering::Relaxed);
98    }
99}
100
101/// Remove any installed provider, returning to trace-less records.
102pub fn clear_trace_context_provider() {
103    if let Ok(mut slot) = TRACE_PROVIDER.write() {
104        *slot = None;
105        HAS_TRACE_PROVIDER.store(false, Ordering::Relaxed);
106    }
107}
108
109/// Read `(trace_id, span_id)` from the current span, validated.
110///
111/// Returns `None` unless both ids are well-formed: the schema's
112/// `^[0-9a-f]{32}$` / `^[0-9a-f]{16}$` patterns are the cross-language
113/// enforcement, and emitting a dashed UUID would fail validation for every
114/// record rather than just skipping correlation on one.
115fn current_trace_context() -> Option<(String, String)> {
116    if !HAS_TRACE_PROVIDER.load(Ordering::Relaxed) {
117        return None;
118    }
119    let provider = TRACE_PROVIDER.read().ok()?.clone()?;
120    // Observability must never surface as a request failure.
121    let (trace_id, span_id) = std::panic::catch_unwind(AssertUnwindSafe(|| provider())).ok()??;
122    (is_trace_hex(&trace_id, 32) && is_trace_hex(&span_id, 16)).then_some((trace_id, span_id))
123}
124
125/// Lowercase hex of exactly `len` digits, and not all zeroes — an all-zero
126/// id is OTel's "no valid span" sentinel, not an identifier.
127fn is_trace_hex(value: &str, len: usize) -> bool {
128    value.len() == len
129        && value
130            .bytes()
131            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
132        && value.bytes().any(|b| b != b'0')
133}
134
135// ---------------------------------------------------------------------------
136// Claim redaction
137// ---------------------------------------------------------------------------
138
139/// Substituted for a sensitive claim value.
140pub const REDACTED: &str = "[redacted]";
141
142/// Applied to `claims` before they reach a record.
143pub type ClaimRedactor =
144    Arc<dyn Fn(&BTreeMap<String, String>) -> BTreeMap<String, String> + Send + Sync>;
145
146/// Claim names whose values never reach the log verbatim: credentials, plus
147/// the standard OIDC claims that are personal data.
148const SENSITIVE_CLAIM_FRAGMENTS: &[&str] = &[
149    // Credential-shaped. Same list `sentry_sdk` redacts, so the two
150    // observability paths do not disagree about what is sensitive.
151    "password",
152    "token",
153    "secret",
154    "key",
155    "authorization",
156    // Standard OIDC claims that are personal data.
157    "email",
158    "phone",
159    "address",
160    "birthdate",
161    "gender",
162    "given_name",
163    "family_name",
164    "middle_name",
165    "nickname",
166    "preferred_username",
167    "picture",
168    "profile",
169    "website",
170];
171
172/// Replace sensitive claim *values* with [`REDACTED`].
173///
174/// An access log outlives the token it describes by months or years and is
175/// shipped to systems chosen for searchability rather than for holding
176/// personal data, so `email` / `phone` / `*_token` reaching it verbatim is a
177/// retention problem rather than a debugging feature.
178///
179/// Matching is **key-based**: a value is judged by the name it arrived under,
180/// never by its content. A claim called `context` holding an email address is
181/// not caught, and cannot be without guessing at free text — a boundary worth
182/// stating rather than pretending to exceed.
183///
184/// Values are **replaced, not dropped**. "Did this credential carry an email
185/// claim?" is a question an audit log exists to answer; "what was it?" is not.
186pub fn redact_claims(claims: &BTreeMap<String, String>) -> BTreeMap<String, String> {
187    claims
188        .iter()
189        .map(|(key, value)| {
190            let lowered = key.to_ascii_lowercase();
191            let sensitive = lowered == "name"
192                || SENSITIVE_CLAIM_FRAGMENTS
193                    .iter()
194                    .any(|fragment| lowered.contains(fragment));
195            let value = if sensitive {
196                REDACTED.to_string()
197            } else {
198                value.clone()
199            };
200            (key.clone(), value)
201        })
202        .collect()
203}
204
205/// Pass claims through verbatim. Only for logs you own end to end.
206pub fn no_redaction(claims: &BTreeMap<String, String>) -> BTreeMap<String, String> {
207    claims.clone()
208}
209
210/// A `DispatchHook` that writes one JSON line per call to an arbitrary
211/// `Write` sink. Entries carry the `vgi_rpc.access` logger name so the
212/// Python validator's filter (`.logger == "vgi_rpc.access"`) matches.
213///
214/// Two modes:
215/// - [`AccessLogHook::new`] / [`AccessLogHook::to_stderr`] write synchronously on the
216///   dispatch thread (acceptable for stderr or in-memory test sinks).
217/// - [`AccessLogHook::buffered`] queues into a bounded mpsc channel and
218///   drains on a background thread; on overflow it drops the entry and
219///   bumps a counter rather than blocking dispatch.
220pub struct AccessLogHook {
221    sink: Sink,
222    server_version: String,
223    /// When true, emit the full base64-encoded request batch as
224    /// `request_data` (DEBUG-equivalent — see [`Self::with_verbose`]).
225    /// When false (default), emit `original_request_bytes` +
226    /// `truncated: "payload_omitted"` instead so the access-log schema's
227    /// "unary requires request_data unless truncated" invariant still
228    /// holds without ballooning every record by 8+ KiB.
229    verbose: bool,
230    /// Per-record byte cap; `0` disables it. See [`Self::with_max_record_bytes`].
231    max_record_bytes: usize,
232    /// Fraction of non-error calls kept; `1.0` keeps everything.
233    sample_rate: f64,
234    claim_redactor: ClaimRedactor,
235    /// Start instants keyed by request_id for duration tracking. For server
236    /// loads where request_id is always empty, a simple monotonically
237    /// increasing counter token is used instead.
238    starts: Mutex<std::collections::HashMap<HookToken, Instant>>,
239    next_token: std::sync::atomic::AtomicU64,
240}
241
242impl AccessLogHook {
243    /// Create an access log hook that writes synchronously to `sink`.
244    /// Suitable for stderr or in-memory sinks; for production file I/O
245    /// prefer [`AccessLogHook::buffered`] to keep dispatch threads off
246    /// the disk path.
247    pub fn new<W: Write + Send + 'static>(sink: W, server_version: impl Into<String>) -> Arc<Self> {
248        Arc::new(Self::with_sink(
249            Sink::Sync(Arc::new(Mutex::new(sink))),
250            server_version.into(),
251        ))
252    }
253
254    fn with_sink(sink: Sink, server_version: String) -> Self {
255        Self {
256            sink,
257            server_version,
258            verbose: false,
259            max_record_bytes: DEFAULT_MAX_RECORD_BYTES,
260            sample_rate: 1.0,
261            claim_redactor: Arc::new(redact_claims),
262            starts: Mutex::new(std::collections::HashMap::new()),
263            next_token: std::sync::atomic::AtomicU64::new(1),
264        }
265    }
266
267    /// Clone this hook's configuration, apply `mutate`, and return the result.
268    ///
269    /// The in-flight call table is deliberately not carried over: these are
270    /// startup knobs, and a hook reconfigured mid-serve would report a
271    /// duration of zero for every call already running.
272    fn derive(&self, mutate: impl FnOnce(&mut Self)) -> Arc<Self> {
273        let mut next = Self {
274            sink: self.sink.clone(),
275            server_version: self.server_version.clone(),
276            verbose: self.verbose,
277            max_record_bytes: self.max_record_bytes,
278            sample_rate: self.sample_rate,
279            claim_redactor: self.claim_redactor.clone(),
280            starts: Mutex::new(std::collections::HashMap::new()),
281            next_token: std::sync::atomic::AtomicU64::new(1),
282        };
283        mutate(&mut next);
284        Arc::new(next)
285    }
286
287    /// Return a new `Arc<AccessLogHook>` with verbose request-data
288    /// emission enabled. Mirrors Python's
289    /// `_access_logger.isEnabledFor(logging.DEBUG)` behaviour where
290    /// the full base64-encoded request batch is included verbatim
291    /// rather than being elided via `truncated: "payload_omitted"`.
292    pub fn with_verbose(self: Arc<Self>, verbose: bool) -> Arc<Self> {
293        if self.verbose == verbose {
294            return self;
295        }
296        self.derive(|h| h.verbose = verbose)
297    }
298
299    /// Cap each record at `max_bytes`, shedding optional fields to fit;
300    /// `0` disables the cap. Pair it with shipper configs that raise their
301    /// per-line limits to match (Vector's `max_line_bytes`, Fluent Bit's
302    /// `Buffer_Max_Size`) — a line above the shipper's ceiling is dropped
303    /// without a word.
304    pub fn with_max_record_bytes(self: Arc<Self>, max_bytes: usize) -> Arc<Self> {
305        self.derive(|h| h.max_record_bytes = max_bytes)
306    }
307
308    /// Keep only `rate` of the *successful* calls.
309    ///
310    /// Three properties separate a sampler that helps from one that quietly
311    /// costs someone an incident, and all three are enforced here:
312    ///
313    /// - **Errors are never sampled.** A rate below 1 exists because
314    ///   successful calls are repetitive, which is exactly what failures are
315    ///   not; a consumer has to be able to read a falling error count as a
316    ///   fix landing rather than as the dice going the other way.
317    /// - **The decision is deterministic, per call.** It is keyed on
318    ///   `stream_id` when present and `request_id` otherwise, so every record
319    ///   of one stream shares its init's fate. Random per-record sampling
320    ///   shreds a multi-record call into fragments indistinguishable from
321    ///   data loss, and the calls likeliest to be split are the long streams
322    ///   most worth studying.
323    /// - **The rate rides on every kept record** as `sample_rate`, because a
324    ///   consumer scaling counts must divide by it, and a rate discoverable
325    ///   only from a deployment's flags is one that gets guessed wrong.
326    ///
327    /// # Errors
328    ///
329    /// Returns a `ValueError` when `rate` is outside `0.0..=1.0`. Failing
330    /// here rather than at the first request is the point: `100` meaning
331    /// "100%" would otherwise silently log everything, and a negative rate
332    /// silently nothing.
333    pub fn with_sample_rate(self: Arc<Self>, rate: f64) -> crate::errors::Result<Arc<Self>> {
334        if !(0.0..=1.0).contains(&rate) {
335            return Err(RpcError::value_error(format!(
336                "access-log sample rate must be between 0.0 and 1.0, got {rate}"
337            )));
338        }
339        Ok(self.derive(|h| h.sample_rate = rate))
340    }
341
342    /// Replace the redaction policy applied to `claims`.
343    ///
344    /// Pass [`no_redaction`] to disable it — appropriate only for a service
345    /// that owns its logs end to end. A redactor that panics fails **closed**:
346    /// the claims are dropped from the record rather than emitted raw.
347    pub fn with_claim_redactor(self: Arc<Self>, redactor: ClaimRedactor) -> Arc<Self> {
348        self.derive(|h| h.claim_redactor = redactor)
349    }
350
351    /// Create a hook that writes asynchronously: the dispatch thread
352    /// pushes a formatted line into a bounded channel of `capacity`
353    /// entries and a background thread drains it into `sink`.
354    ///
355    /// The queue is bounded and a full queue **drops** rather than blocks:
356    /// an unbounded queue turns a stalled disk into an OOM, and a blocking
357    /// send reintroduces exactly the latency the thread was meant to remove.
358    /// What makes dropping acceptable rather than silent corruption is that
359    /// it is reported — the next record through carries `dropped_records`,
360    /// so the loss shows up in the log itself and not only in a counter
361    /// nobody exports.
362    ///
363    /// This trades durability. With a synchronous sink, a record on disk
364    /// means the call completed; here a crash loses whatever is still
365    /// queued. Right for high throughput, wrong for audit — hence opt-in.
366    ///
367    /// The writer thread exits when the hook is dropped (sender closes).
368    pub fn buffered<W: Write + Send + 'static>(
369        sink: W,
370        server_version: impl Into<String>,
371        capacity: usize,
372    ) -> Arc<Self> {
373        let (tx, rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(capacity.max(1));
374        let dropped = Arc::new(Mutex::new(0u64));
375        let mut sink = sink;
376        std::thread::Builder::new()
377            .name("vgi-rpc-access-log".into())
378            .spawn(move || {
379                while let Ok(line) = rx.recv() {
380                    if sink.write_all(&line).is_err() {
381                        return;
382                    }
383                    if sink.write_all(b"\n").is_err() {
384                        return;
385                    }
386                    let _ = sink.flush();
387                }
388            })
389            .expect("spawn access-log writer thread");
390        Arc::new(Self::with_sink(
391            Sink::Async { tx, dropped },
392            server_version.into(),
393        ))
394    }
395
396    /// Convenience: write access logs to stderr synchronously
397    /// (one JSON line per entry).
398    pub fn to_stderr(server_version: impl Into<String>) -> Arc<Self> {
399        Self::new(std::io::stderr(), server_version)
400    }
401
402    /// Records dropped since the last one that made it onto the queue.
403    /// Always zero for synchronous hooks; reset once the count has been
404    /// reported in-band as `dropped_records`.
405    pub fn dropped_count(&self) -> u64 {
406        match &self.sink {
407            Sink::Async { dropped, .. } => *dropped.lock().unwrap_or_else(|e| e.into_inner()),
408            Sink::Sync(_) => 0,
409        }
410    }
411
412    /// Decide whether a record for this call survives sampling.
413    ///
414    /// Keyed on a stable identifier for the *call*, not for the record, so a
415    /// stream's continuations share the fate of their init. `fallback` is
416    /// used only when the transport supplies neither id, which degrades to
417    /// per-record sampling rather than dropping the record on the floor.
418    fn sampled_in(&self, info: &DispatchInfo, fallback: HookToken) -> bool {
419        if self.sample_rate >= 1.0 {
420            return true;
421        }
422        if self.sample_rate <= 0.0 {
423            return false;
424        }
425        let key = if !info.stream_id.is_empty() {
426            info.stream_id.clone()
427        } else if !info.request_id.is_empty() {
428            info.request_id.clone()
429        } else {
430            format!("{}:{fallback}", info.server_id)
431        };
432        // A 32-bit hash prefix is exact enough for sampling and keeps the
433        // decision to one digest plus one integer compare.
434        use sha2::Digest;
435        let digest = sha2::Sha256::digest(key.as_bytes());
436        let prefix = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
437        u64::from(prefix) <= (self.sample_rate * f64::from(u32::MAX)) as u64
438    }
439
440    /// Serialize the record, apply the size cap, and hand it to the sink.
441    fn write_record(&self, rec: serde_json::Map<String, serde_json::Value>) {
442        write_record(&self.sink, self.max_record_bytes, rec);
443    }
444}
445
446type Record = serde_json::Map<String, serde_json::Value>;
447
448/// Serialize `rec`, apply the size cap, and hand it to `sink`.
449///
450/// Free-standing rather than a method because a deferred record outlives the
451/// dispatch call that built it and needs only these two pieces of the hook.
452fn write_record(sink: &Sink, max_record_bytes: usize, rec: Record) {
453    match sink {
454        Sink::Sync(m) => {
455            let line = render(max_record_bytes, rec);
456            if let Ok(mut w) = m.lock() {
457                let _ = writeln!(w, "{line}");
458                let _ = w.flush();
459            }
460        }
461        Sink::Async { tx, dropped } => {
462            // Read-stamp-adjust under one lock, so a drop count is attributed
463            // exactly once and to a record that actually reaches the file.
464            let mut guard = dropped.lock().unwrap_or_else(|e| e.into_inner());
465            let pending = *guard;
466            let mut rec = rec;
467            if pending > 0 {
468                rec.insert("dropped_records".into(), json!(pending));
469            }
470            let line = render(max_record_bytes, rec);
471            match tx.try_send(line.into_bytes()) {
472                Ok(()) => *guard = 0,
473                // Full means drop — never block dispatch behind a stalled
474                // disk. Disconnected means the writer thread is gone, which
475                // is the same loss by a different route.
476                Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
477                    *guard = pending + 1;
478                }
479            }
480        }
481    }
482}
483
484/// Render a record to one JSON line, shedding fields when it exceeds
485/// `max_record_bytes` (`0` disables the cap).
486///
487/// The shed order is the spec's: `request_data` first (it is almost always
488/// what blew the budget), then `claims`, then the sentinel form.
489/// `error_message` is never truncated — the full server-side message is what
490/// an operator is reading the record for.
491fn render(max_record_bytes: usize, mut rec: Record) -> String {
492    let mut line = serde_json::Value::Object(rec.clone()).to_string();
493    if max_record_bytes == 0 || line.len() <= max_record_bytes {
494        return line;
495    }
496
497    if let Some(serde_json::Value::String(payload)) = rec.remove("request_data") {
498        rec.insert("original_request_bytes".into(), json!(payload.len()));
499        // `true` here and nowhere else: this record genuinely lost data to a
500        // cap, as distinct from a deployment that simply never logs payloads
501        // (`"payload_omitted"`).
502        rec.insert("truncated".into(), json!(true));
503        line = serde_json::Value::Object(rec.clone()).to_string();
504        if line.len() <= max_record_bytes {
505            return line;
506        }
507    }
508
509    if rec.contains_key("claims") {
510        rec.insert("claims".into(), json!({}));
511        rec.insert("truncated".into(), json!(true));
512        line = serde_json::Value::Object(rec.clone()).to_string();
513        if line.len() <= max_record_bytes {
514            return line;
515        }
516    }
517
518    // Sentinel: everything the schema requires, plus the error message, and
519    // nothing else. A record too large to ship is worth less than one that
520    // says what it lost.
521    let mut sentinel = serde_json::Map::new();
522    for key in REQUIRED_RECORD_FIELDS {
523        if let Some(value) = rec.get(*key) {
524            sentinel.insert((*key).to_string(), value.clone());
525        }
526    }
527    if let Some(message) = rec.get("error_message") {
528        sentinel.insert("error_message".into(), message.clone());
529    }
530    sentinel.insert("truncated".into(), json!("record_too_large"));
531    serde_json::Value::Object(sentinel).to_string()
532}
533
534/// Fields every record must carry, and therefore the ones the sentinel form
535/// keeps. Mirrors the schema's `required` list.
536const REQUIRED_RECORD_FIELDS: &[&str] = &[
537    "timestamp",
538    "level",
539    "logger",
540    "message",
541    "server_id",
542    "protocol",
543    "protocol_hash",
544    "method",
545    "method_type",
546    "principal",
547    "auth_domain",
548    "authenticated",
549    "remote_addr",
550    "duration_ms",
551    "status",
552    "error_type",
553];
554
555impl DispatchHook for AccessLogHook {
556    fn on_dispatch_start(&self, _info: &DispatchInfo) -> HookToken {
557        let token = self
558            .next_token
559            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
560        self.starts.lock().unwrap().insert(token, Instant::now());
561        token
562    }
563
564    fn on_dispatch_end(
565        &self,
566        token: HookToken,
567        info: &DispatchInfo,
568        error: Option<&RpcError>,
569        stats: &CallStatistics,
570    ) {
571        let start = self.starts.lock().unwrap().remove(&token);
572        let duration_ms = start
573            .map(|t| t.elapsed().as_secs_f64() * 1000.0)
574            .unwrap_or(0.0);
575        let status = if error.is_some() { "error" } else { "ok" };
576
577        // Sampling decision first: everything below is work a sampled-out
578        // record does not need. Errors bypass it entirely — a rate below 1
579        // exists because successes repeat, which failures do not.
580        let sampled = error.is_some() || self.sampled_in(info, token);
581        if !sampled {
582            return;
583        }
584
585        // Build the record as a JSON object — schema-aligned with
586        // docs/access-log-spec.md in the Python reference repo.
587        let mut rec = serde_json::Map::new();
588        rec.insert("timestamp".into(), json!(rfc3339_utc_millis()));
589        rec.insert("level".into(), json!("INFO"));
590        rec.insert("logger".into(), json!("vgi_rpc.access"));
591        rec.insert(
592            "message".into(),
593            json!(format!("{}.{} {}", info.protocol, info.method, status)),
594        );
595        rec.insert("server_id".into(), json!(info.server_id));
596        rec.insert("protocol".into(), json!(info.protocol));
597        rec.insert("protocol_hash".into(), json!(info.protocol_hash));
598        rec.insert("method".into(), json!(info.method));
599        rec.insert("method_type".into(), json!(info.method_type));
600        rec.insert("principal".into(), json!(info.principal));
601        rec.insert("auth_domain".into(), json!(info.auth_domain));
602        rec.insert("authenticated".into(), json!(info.authenticated));
603        rec.insert("remote_addr".into(), json!(info.remote_addr));
604        rec.insert(
605            "duration_ms".into(),
606            json!((duration_ms * 100.0).round() / 100.0),
607        );
608        rec.insert("status".into(), json!(status));
609        rec.insert(
610            "error_type".into(),
611            json!(error.map(|e| e.error_type.clone()).unwrap_or_default()),
612        );
613
614        if let Some(err) = error {
615            rec.insert("error_message".into(), json!(err.message));
616        }
617        if !self.server_version.is_empty() {
618            rec.insert("server_version".into(), json!(self.server_version));
619        }
620        if !info.protocol_version.is_empty() {
621            rec.insert("protocol_version".into(), json!(info.protocol_version));
622        }
623        if !info.request_id.is_empty() {
624            rec.insert("request_id".into(), json!(info.request_id));
625        }
626        if info.http_status > 0 {
627            rec.insert("http_status".into(), json!(info.http_status));
628        }
629        // Trace correlation. `request_id` only joins records within this
630        // service; these join them to the surrounding distributed trace.
631        // Both or neither — a lone id correlates with nothing.
632        if let Some((trace_id, span_id)) = current_trace_context() {
633            rec.insert("trace_id".into(), json!(trace_id));
634            rec.insert("span_id".into(), json!(span_id));
635        }
636        // Payload capture. A record that would carry `request_data` but does
637        // not must say so, or the schema's "unary requires request_data"
638        // invariant fails.
639        let carries_payload = info.method_type == "unary" || !info.request_data.is_empty();
640        if self.verbose && !info.request_data.is_empty() {
641            rec.insert(
642                "request_data".into(),
643                json!(base64_encode(&info.request_data)),
644            );
645        } else if carries_payload {
646            // `"payload_omitted"`, not `true`: nothing was lost to a size
647            // cap here — this deployment simply does not log payloads at
648            // this level. Sharing one marker with genuine shedding made it
649            // fire on essentially every record and left a consumer looking
650            // for real data loss with nothing to filter on.
651            if !info.request_data.is_empty() {
652                let encoded_len = info.request_data.len().div_ceil(3) * 4;
653                rec.insert("original_request_bytes".into(), json!(encoded_len));
654            }
655            rec.insert("truncated".into(), json!("payload_omitted"));
656        }
657        if info.method_type == "stream" {
658            let sid = if info.stream_id.is_empty() {
659                random_stream_id()
660            } else {
661                info.stream_id.clone()
662            };
663            rec.insert("stream_id".into(), json!(sid));
664        }
665        if info.cancelled {
666            rec.insert("cancelled".into(), json!(true));
667        }
668        if !info.claims.is_empty() {
669            // Redacted by key before the record exists. Which claims a
670            // credential carried is what an audit log is for; what they
671            // contained is a retention problem.
672            let redactor = self.claim_redactor.clone();
673            let redacted = std::panic::catch_unwind(AssertUnwindSafe(|| redactor(&info.claims)))
674                .unwrap_or_else(|_| {
675                    // Fail closed. A broken redactor must not take the
676                    // request down, and it must not fail open either.
677                    tracing::warn!(
678                        target: "vgi_rpc.access",
679                        "claim redactor panicked; dropping claims from the record"
680                    );
681                    BTreeMap::new()
682                });
683            if !redacted.is_empty() {
684                rec.insert("claims".into(), json!(redacted));
685            }
686        }
687        // Egress accounting. The `input_bytes`/`output_bytes` pair below
688        // measures logical Arrow buffers — what the worker processed. These
689        // measure what crossed the network, which differs in both
690        // directions: compression shrinks the body, and externalised
691        // payloads leave it entirely. `response_bytes` is stamped later by
692        // the transport, since compression runs after this hook.
693        if let Some(request_bytes) = info.request_bytes {
694            rec.insert("request_bytes".into(), json!(request_bytes));
695        }
696        if info.externalized_bytes > 0 {
697            rec.insert("externalized_bytes".into(), json!(info.externalized_bytes));
698        }
699        if self.sample_rate < 1.0 && error.is_none() {
700            // Errors bypass the decision, so they carry no rate to divide by.
701            rec.insert("sample_rate".into(), json!(self.sample_rate));
702        }
703        if stats.input_batches
704            + stats.output_batches
705            + stats.input_rows
706            + stats.output_rows
707            + stats.input_bytes
708            + stats.output_bytes
709            != 0
710        {
711            rec.insert("input_batches".into(), json!(stats.input_batches));
712            rec.insert("output_batches".into(), json!(stats.output_batches));
713            rec.insert("input_rows".into(), json!(stats.input_rows));
714            rec.insert("output_rows".into(), json!(stats.output_rows));
715            rec.insert("input_bytes".into(), json!(stats.input_bytes));
716            rec.insert("output_bytes".into(), json!(stats.output_bytes));
717        }
718
719        // `response_bytes` cannot be measured here: the handler has finished
720        // but response compression has not run, so a record written now
721        // could only ever report the uncompressed body. When the transport
722        // offers a sink, hand the record over and let it emit once the final
723        // body exists. The cost is that a crash between handler and response
724        // loses the record; the alternative is a permanently wrong number.
725        match info.access_sink.as_ref() {
726            Some(sink) => {
727                let deferred_sink = self.sink.clone();
728                let max_record_bytes = self.max_record_bytes;
729                sink.defer(Box::new(move |response_bytes| {
730                    let mut rec = rec;
731                    if let Some(n) = response_bytes {
732                        rec.insert("response_bytes".into(), json!(n));
733                    }
734                    write_record(&deferred_sink, max_record_bytes, rec);
735                }));
736            }
737            None => self.write_record(rec),
738        }
739    }
740}
741
742/// Format the current wall-clock time as RFC 3339 UTC with millisecond
743/// precision, matching the access-log spec's `timestamp` regex.
744pub(crate) fn rfc3339_utc_millis() -> String {
745    use std::time::{SystemTime, UNIX_EPOCH};
746    let dur = SystemTime::now()
747        .duration_since(UNIX_EPOCH)
748        .unwrap_or_default();
749    let total_ms = dur.as_millis() as i64;
750    let secs = total_ms / 1000;
751    let millis = (total_ms % 1000) as u32;
752
753    // Civil time conversion using Howard Hinnant's algorithm.
754    let z = secs.div_euclid(86_400);
755    let sod = secs.rem_euclid(86_400) as u32;
756    let z = z + 719_468;
757    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
758    let doe = (z - era * 146_097) as u32;
759    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
760    let y = (yoe as i64) + era * 400;
761    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
762    let mp = (5 * doy + 2) / 153;
763    let d = doy - (153 * mp + 2) / 5 + 1;
764    let m = if mp < 10 { mp + 3 } else { mp - 9 };
765    let y = if m <= 2 { y + 1 } else { y };
766
767    let h = sod / 3600;
768    let mi = (sod / 60) % 60;
769    let s = sod % 60;
770    format!(
771        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
772        y, m, d, h, mi, s, millis
773    )
774}
775
776/// Standard base64 (RFC 4648, padded). Inlined here so the access-log module
777/// stays usable without the optional `base64` crate dependency.
778fn base64_encode(bytes: &[u8]) -> String {
779    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
780    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
781    let mut chunks = bytes.chunks_exact(3);
782    for chunk in chunks.by_ref() {
783        let n = ((chunk[0] as u32) << 16) | ((chunk[1] as u32) << 8) | (chunk[2] as u32);
784        out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
785        out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
786        out.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
787        out.push(ALPHABET[(n & 0x3F) as usize] as char);
788    }
789    let rem = chunks.remainder();
790    match rem.len() {
791        1 => {
792            let n = (rem[0] as u32) << 16;
793            out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
794            out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
795            out.push('=');
796            out.push('=');
797        }
798        2 => {
799            let n = ((rem[0] as u32) << 16) | ((rem[1] as u32) << 8);
800            out.push(ALPHABET[((n >> 18) & 0x3F) as usize] as char);
801            out.push(ALPHABET[((n >> 12) & 0x3F) as usize] as char);
802            out.push(ALPHABET[((n >> 6) & 0x3F) as usize] as char);
803            out.push('=');
804        }
805        _ => {}
806    }
807    out
808}
809
810/// Mint a 32-character lowercase hex stream_id. Use this at the start of a
811/// stream call and reuse the same value across init and continuations.
812pub(crate) fn random_stream_id() -> String {
813    use std::time::{SystemTime, UNIX_EPOCH};
814    // 128 bits drawn from time + a per-process atomic counter. Not
815    // cryptographic — adequate for log correlation.
816    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
817    let lo = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
818    let hi = SystemTime::now()
819        .duration_since(UNIX_EPOCH)
820        .map(|d| d.as_nanos() as u64)
821        .unwrap_or(0);
822    // wasm32-wasi has no process ids (`std::process::id()` aborts); the
823    // time+counter mix already disambiguates within the single wasm process.
824    #[cfg(not(target_arch = "wasm32"))]
825    let pid = std::process::id() as u64;
826    #[cfg(target_arch = "wasm32")]
827    let pid: u64 = 0;
828    format!("{:016x}{:016x}", hi ^ pid, lo)
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use crate::hooks::AccessSink;
835    use std::sync::Arc;
836
837    /// Sink that appends every write into a shared buffer.
838    struct BufSink(Arc<Mutex<Vec<u8>>>);
839    impl Write for BufSink {
840        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
841            self.0.lock().unwrap().extend_from_slice(b);
842            Ok(b.len())
843        }
844        fn flush(&mut self) -> std::io::Result<()> {
845            Ok(())
846        }
847    }
848
849    fn buffer() -> (Arc<Mutex<Vec<u8>>>, BufSink) {
850        let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
851        (buf.clone(), BufSink(buf))
852    }
853
854    fn lines(buf: &Arc<Mutex<Vec<u8>>>) -> Vec<serde_json::Value> {
855        String::from_utf8(buf.lock().unwrap().clone())
856            .unwrap()
857            .lines()
858            .filter(|l| !l.trim().is_empty())
859            .map(|l| serde_json::from_str(l).unwrap())
860            .collect()
861    }
862
863    fn info(method: &str) -> DispatchInfo {
864        DispatchInfo {
865            method: method.into(),
866            method_type: "unary",
867            server_id: "srv".into(),
868            protocol: "Test".into(),
869            request_id: "req-1".into(),
870            transport_metadata: Arc::new(Default::default()),
871            ..Default::default()
872        }
873    }
874
875    fn run(hook: &Arc<AccessLogHook>, info: &DispatchInfo, error: Option<&RpcError>) {
876        let dyn_hook: &dyn DispatchHook = hook.as_ref();
877        let token = dyn_hook.on_dispatch_start(info);
878        dyn_hook.on_dispatch_end(token, info, error, &CallStatistics::default());
879    }
880
881    #[test]
882    fn emits_json_line_per_call() {
883        let (buf, sink) = buffer();
884        let hook = AccessLogHook::new(sink, "1.2.3");
885        run(&hook, &info("echo_string"), None);
886
887        let rec = &lines(&buf)[0];
888        assert_eq!(rec["logger"], "vgi_rpc.access");
889        assert_eq!(rec["method"], "echo_string");
890        assert_eq!(rec["server_version"], "1.2.3");
891        assert_eq!(rec["status"], "ok");
892        assert_eq!(rec["authenticated"], false);
893    }
894
895    #[test]
896    fn error_entries_carry_error_message() {
897        let (buf, sink) = buffer();
898        let hook = AccessLogHook::new(sink, "1.2.3");
899        run(
900            &hook,
901            &info("raise_value_error"),
902            Some(&RpcError::value_error("boom")),
903        );
904
905        let rec = &lines(&buf)[0];
906        assert_eq!(rec["status"], "error");
907        assert_eq!(rec["error_type"], "ValueError");
908        assert_eq!(rec["error_message"], "boom");
909    }
910
911    // -- truncation ---------------------------------------------------------
912
913    #[test]
914    fn payload_omission_is_distinct_from_size_driven_shedding() {
915        // Not logging payloads at this level loses nothing, so it must not
916        // look like data loss to a consumer scanning for exactly that.
917        let (buf, sink) = buffer();
918        let hook = AccessLogHook::new(sink, "v");
919        let mut i = info("echo_string");
920        i.request_data = vec![7u8; 4096];
921        run(&hook, &i, None);
922        let rec = &lines(&buf)[0];
923        assert_eq!(rec["truncated"], "payload_omitted");
924        assert!(rec.get("request_data").is_none());
925        assert!(rec["original_request_bytes"].as_u64().unwrap() > 0);
926
927        // A cap that actually sheds the payload reports `true`.
928        let (buf, sink) = buffer();
929        let hook = AccessLogHook::new(sink, "v")
930            .with_verbose(true)
931            .with_max_record_bytes(1024);
932        run(&hook, &i, None);
933        let rec = &lines(&buf)[0];
934        assert_eq!(rec["truncated"], true);
935        assert!(rec.get("request_data").is_none());
936        assert_eq!(rec["original_request_bytes"].as_u64().unwrap(), 5464);
937        assert_eq!(rec["method"], "echo_string");
938    }
939
940    #[test]
941    fn unshippable_record_collapses_to_the_sentinel_form() {
942        let (buf, sink) = buffer();
943        let hook = AccessLogHook::new(sink, "v")
944            .with_verbose(true)
945            // Below even the envelope, so shedding the payload cannot save it.
946            .with_max_record_bytes(64);
947        let mut i = info("echo_string");
948        i.request_data = vec![7u8; 4096];
949        run(&hook, &i, Some(&RpcError::value_error("boom")));
950
951        let rec = &lines(&buf)[0];
952        assert_eq!(rec["truncated"], "record_too_large");
953        // The full server-side message survives: it is what an operator is
954        // reading the record for.
955        assert_eq!(rec["error_message"], "boom");
956        assert_eq!(rec["status"], "error");
957        assert!(rec.get("original_request_bytes").is_none());
958    }
959
960    // -- sampling -----------------------------------------------------------
961
962    #[test]
963    fn sample_rate_out_of_range_fails_at_construction() {
964        let (_, sink) = buffer();
965        let hook = AccessLogHook::new(sink, "v");
966        // 100 meaning "100%" must not silently log everything.
967        assert!(hook.clone().with_sample_rate(100.0).is_err());
968        assert!(hook.clone().with_sample_rate(-0.1).is_err());
969        assert!(hook.with_sample_rate(0.25).is_ok());
970    }
971
972    #[test]
973    fn sampling_decision_is_deterministic_per_stream() {
974        // Every record of one stream must share its init's fate; a split
975        // stream reads as data loss rather than as sampling.
976        let mut kept_by_stream: Vec<(String, usize)> = Vec::new();
977        for n in 0..40u32 {
978            let stream_id = format!("{n:032x}");
979            let (buf, sink) = buffer();
980            let hook = AccessLogHook::new(sink, "v")
981                .with_sample_rate(0.5)
982                .expect("valid rate");
983            let mut i = info("produce");
984            i.method_type = "stream";
985            i.stream_id = stream_id.clone();
986            // Init plus four continuations of the same call.
987            for _ in 0..5 {
988                run(&hook, &i, None);
989            }
990            kept_by_stream.push((stream_id, lines(&buf).len()));
991        }
992        for (stream_id, kept) in &kept_by_stream {
993            assert!(
994                *kept == 0 || *kept == 5,
995                "stream {stream_id} was shredded: {kept}/5 records kept"
996            );
997        }
998        // ...and the rate has to actually bite, or the assertion above is vacuous.
999        let sampled_out = kept_by_stream.iter().filter(|(_, k)| *k == 0).count();
1000        assert!(
1001            sampled_out > 0 && sampled_out < kept_by_stream.len(),
1002            "expected a mix at rate 0.5, got {sampled_out}/40 sampled out"
1003        );
1004    }
1005
1006    #[test]
1007    fn sampling_never_drops_errors() {
1008        let (buf, sink) = buffer();
1009        // Rate 0 keeps nothing that is allowed to be dropped.
1010        let hook = AccessLogHook::new(sink, "v")
1011            .with_sample_rate(0.0)
1012            .expect("valid rate");
1013        for n in 0..20 {
1014            let mut i = info("call");
1015            i.request_id = format!("req-{n}");
1016            run(&hook, &i, None);
1017        }
1018        assert!(lines(&buf).is_empty(), "rate 0.0 kept a successful call");
1019
1020        run(&hook, &info("boom"), Some(&RpcError::value_error("x")));
1021        let recs = lines(&buf);
1022        assert_eq!(recs.len(), 1);
1023        assert_eq!(recs[0]["status"], "error");
1024        // Errors bypass the decision, so they carry no rate to divide by.
1025        assert!(recs[0].get("sample_rate").is_none());
1026    }
1027
1028    #[test]
1029    fn kept_records_carry_the_rate() {
1030        let (buf, sink) = buffer();
1031        let hook = AccessLogHook::new(sink, "v")
1032            .with_sample_rate(1.0)
1033            .expect("valid rate");
1034        run(&hook, &info("call"), None);
1035        // A rate of 1 is not sampling, so nothing to divide by.
1036        assert!(lines(&buf)[0].get("sample_rate").is_none());
1037
1038        let (buf, sink) = buffer();
1039        let hook = AccessLogHook::new(sink, "v")
1040            .with_sample_rate(1.0 - f64::EPSILON)
1041            .expect("valid rate");
1042        run(&hook, &info("call"), None);
1043        assert!(lines(&buf)[0]["sample_rate"].as_f64().unwrap() < 1.0);
1044    }
1045
1046    // -- claim redaction ----------------------------------------------------
1047
1048    fn claims() -> BTreeMap<String, String> {
1049        BTreeMap::from([
1050            ("sub".to_string(), "user-42".to_string()),
1051            ("email".to_string(), "alice@example.com".to_string()),
1052            ("api_key".to_string(), "sk-live-abc".to_string()),
1053            ("Access_Token".to_string(), "eyJ...".to_string()),
1054            ("name".to_string(), "Alice".to_string()),
1055            ("tenant".to_string(), "acme".to_string()),
1056        ])
1057    }
1058
1059    #[test]
1060    fn claims_are_redacted_by_key_without_dropping_keys() {
1061        let (buf, sink) = buffer();
1062        let hook = AccessLogHook::new(sink, "v");
1063        let mut i = info("call");
1064        i.claims = claims();
1065        run(&hook, &i, None);
1066
1067        let rec = &lines(&buf)[0];
1068        let logged = rec["claims"].as_object().unwrap();
1069        // Which claims the credential carried stays answerable...
1070        assert_eq!(logged.len(), 6);
1071        assert!(logged.contains_key("email"));
1072        // ...while none of the sensitive values reach the log.
1073        assert_eq!(logged["email"], REDACTED);
1074        assert_eq!(logged["api_key"], REDACTED);
1075        assert_eq!(logged["Access_Token"], REDACTED);
1076        assert_eq!(logged["name"], REDACTED);
1077        // Key-based matching means non-credential keys pass through.
1078        assert_eq!(logged["sub"], "user-42");
1079        assert_eq!(logged["tenant"], "acme");
1080    }
1081
1082    #[test]
1083    fn redactor_that_panics_fails_closed() {
1084        let (buf, sink) = buffer();
1085        let hook = AccessLogHook::new(sink, "v")
1086            .with_claim_redactor(Arc::new(|_| panic!("redactor is broken")));
1087        let mut i = info("call");
1088        i.claims = claims();
1089        // Silence the default panic hook's stderr noise for the duration.
1090        let previous = std::panic::take_hook();
1091        std::panic::set_hook(Box::new(|_| {}));
1092        run(&hook, &i, None);
1093        std::panic::set_hook(previous);
1094
1095        let rec = &lines(&buf)[0];
1096        // Dropped entirely rather than emitted unredacted, and the call
1097        // itself still produced a record.
1098        assert!(rec.get("claims").is_none());
1099        assert_eq!(rec["status"], "ok");
1100    }
1101
1102    #[test]
1103    fn no_redaction_opts_out() {
1104        let (buf, sink) = buffer();
1105        let hook = AccessLogHook::new(sink, "v").with_claim_redactor(Arc::new(no_redaction));
1106        let mut i = info("call");
1107        i.claims = claims();
1108        run(&hook, &i, None);
1109        assert_eq!(lines(&buf)[0]["claims"]["email"], "alice@example.com");
1110    }
1111
1112    // -- egress accounting --------------------------------------------------
1113
1114    #[test]
1115    fn egress_fields_are_absent_when_unmeasured() {
1116        let (buf, sink) = buffer();
1117        let hook = AccessLogHook::new(sink, "v");
1118        run(&hook, &info("call"), None);
1119        let rec = &lines(&buf)[0];
1120        assert!(rec.get("request_bytes").is_none());
1121        assert!(rec.get("externalized_bytes").is_none());
1122        assert!(rec.get("response_bytes").is_none());
1123    }
1124
1125    #[test]
1126    fn deferred_records_wait_for_the_response_size() {
1127        let (buf, sink) = buffer();
1128        let hook = AccessLogHook::new(sink, "v");
1129        let access_sink = AccessSink::new();
1130        let mut i = info("call");
1131        i.access_sink = Some(access_sink.clone());
1132        i.request_bytes = Some(1234);
1133        i.externalized_bytes = 10_000_000;
1134        run(&hook, &i, None);
1135
1136        // Nothing written yet: the body it describes does not exist.
1137        assert!(lines(&buf).is_empty());
1138        access_sink.emit(Some(183));
1139
1140        let rec = &lines(&buf)[0];
1141        assert_eq!(rec["request_bytes"], 1234);
1142        assert_eq!(rec["response_bytes"], 183);
1143        assert_eq!(rec["externalized_bytes"], 10_000_000u64);
1144    }
1145
1146    #[test]
1147    fn undrained_sink_still_emits() {
1148        // A transport that forgets to drain loses the size, not the record.
1149        let (buf, sink) = buffer();
1150        let hook = AccessLogHook::new(sink, "v");
1151        let mut i = info("call");
1152        {
1153            let access_sink = AccessSink::new();
1154            i.access_sink = Some(access_sink.clone());
1155            run(&hook, &i, None);
1156            assert!(lines(&buf).is_empty());
1157            i.access_sink = None;
1158        }
1159        let rec = &lines(&buf)[0];
1160        assert_eq!(rec["method"], "call");
1161        assert!(rec.get("response_bytes").is_none());
1162    }
1163
1164    // -- asynchronous emission ----------------------------------------------
1165
1166    #[test]
1167    fn buffered_writes_via_background_thread() {
1168        struct ChanSink(std::sync::mpsc::Sender<Vec<u8>>);
1169        impl Write for ChanSink {
1170            fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
1171                let _ = self.0.send(b.to_vec());
1172                Ok(b.len())
1173            }
1174            fn flush(&mut self) -> std::io::Result<()> {
1175                Ok(())
1176            }
1177        }
1178        let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
1179        let hook = AccessLogHook::buffered(ChanSink(tx), "1.2.3", 128);
1180        run(&hook, &info("echo_string"), None);
1181
1182        // The writer thread writes the line and its newline separately.
1183        let mut acc = Vec::new();
1184        while let Ok(chunk) = rx.recv_timeout(std::time::Duration::from_millis(500)) {
1185            acc.extend(chunk);
1186            if acc.contains(&b'\n') {
1187                break;
1188            }
1189        }
1190        let line = String::from_utf8(acc).unwrap();
1191        assert!(line.contains("\"method\":\"echo_string\""), "got: {line}");
1192        assert!(line.contains("\"server_version\":\"1.2.3\""), "got: {line}");
1193    }
1194
1195    /// Sink whose first write blocks forever, leaving the queue saturated.
1196    struct WedgedSink(Arc<std::sync::Barrier>);
1197    impl Write for WedgedSink {
1198        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
1199            self.0.wait();
1200            Ok(b.len())
1201        }
1202        fn flush(&mut self) -> std::io::Result<()> {
1203            Ok(())
1204        }
1205    }
1206
1207    #[test]
1208    fn buffered_drops_when_channel_full_instead_of_blocking() {
1209        // Three parties would have to arrive for this barrier to release, so
1210        // the writer thread parks on the first line and never takes another.
1211        let hook =
1212            AccessLogHook::buffered(WedgedSink(Arc::new(std::sync::Barrier::new(3))), "v", 1);
1213        for _ in 0..50 {
1214            run(&hook, &info("m"), None);
1215        }
1216        assert!(
1217            hook.dropped_count() > 0,
1218            "expected drops on a saturated queue; dispatch must never block"
1219        );
1220    }
1221
1222    /// A sink whose writes park until the test opens the gate, so the queue
1223    /// overflows on demand rather than on timing.
1224    ///
1225    /// `entered` fires the first time the writer thread is *inside* `write`.
1226    /// The test waits for it before flooding: until the writer has parked,
1227    /// how many records the queue swallows is a scheduling question, and
1228    /// "did the queue overflow" is not yet a fact the test can assert.
1229    struct GatedSink {
1230        gate: Arc<(Mutex<bool>, std::sync::Condvar)>,
1231        entered: std::sync::mpsc::SyncSender<()>,
1232        out: Arc<Mutex<Vec<u8>>>,
1233    }
1234    impl Write for GatedSink {
1235        fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
1236            // Non-blocking: only the first send needs to land, and the
1237            // receiver may already be gone by a later write.
1238            let _ = self.entered.try_send(());
1239            let (lock, cv) = &*self.gate;
1240            let mut open = lock.lock().unwrap();
1241            while !*open {
1242                open = cv.wait(open).unwrap();
1243            }
1244            drop(open);
1245            self.out.lock().unwrap().extend_from_slice(b);
1246            Ok(b.len())
1247        }
1248        fn flush(&mut self) -> std::io::Result<()> {
1249            Ok(())
1250        }
1251    }
1252
1253    #[test]
1254    fn dropped_records_is_reported_on_the_next_record_through() {
1255        // A log that loses records without saying so is worse than a slow
1256        // one: a consumer cannot tell a quiet period from a lossy one.
1257        let gate = Arc::new((Mutex::new(false), std::sync::Condvar::new()));
1258        let buf: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
1259        let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel::<()>(1);
1260        let hook = AccessLogHook::buffered(
1261            GatedSink {
1262                gate: gate.clone(),
1263                entered: entered_tx,
1264                out: buf.clone(),
1265            },
1266            "v",
1267            1,
1268        );
1269        // Park the writer *before* asserting anything about overflow. This
1270        // record is the one it takes off the queue and blocks on; until it
1271        // is provably inside `write`, the queue has a consumer and how much
1272        // it swallows is up to the scheduler, not the test.
1273        run(&hook, &info("park"), None);
1274        entered_rx
1275            .recv_timeout(std::time::Duration::from_secs(10))
1276            .expect("access-log writer thread never reached the sink");
1277
1278        // Writer parked, queue holds one more, everything after that drops.
1279        for _ in 0..10 {
1280            run(&hook, &info("flood"), None);
1281        }
1282        let dropped = hook.dropped_count();
1283        assert!(dropped > 0, "queue never overflowed");
1284
1285        // Let the writer run, then keep offering records until one gets
1286        // through — that one has to carry the loss.
1287        {
1288            let (lock, cv) = &*gate;
1289            *lock.lock().unwrap() = true;
1290            cv.notify_all();
1291        }
1292        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
1293        while hook.dropped_count() > 0 && std::time::Instant::now() < deadline {
1294            run(&hook, &info("retry"), None);
1295            std::thread::sleep(std::time::Duration::from_millis(5));
1296        }
1297        assert_eq!(hook.dropped_count(), 0, "queue never drained");
1298
1299        let mut reported = 0u64;
1300        while reported == 0 && std::time::Instant::now() < deadline {
1301            reported = lines(&buf)
1302                .iter()
1303                .filter_map(|r| r.get("dropped_records").and_then(|v| v.as_u64()))
1304                .sum();
1305            std::thread::sleep(std::time::Duration::from_millis(5));
1306        }
1307        assert!(
1308            reported >= dropped,
1309            "{dropped} records were dropped but only {reported} were reported"
1310        );
1311    }
1312}