1use 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
42pub const DEFAULT_MAX_RECORD_BYTES: usize = 1_048_576;
47
48enum Sink {
50 Sync(Arc<Mutex<dyn Write + Send>>),
52 Async {
55 tx: SyncSender<Vec<u8>>,
56 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
76pub type TraceContextProvider = Arc<dyn Fn() -> Option<(String, String)> + Send + Sync>;
82
83static TRACE_PROVIDER: RwLock<Option<TraceContextProvider>> = RwLock::new(None);
84static HAS_TRACE_PROVIDER: AtomicBool = AtomicBool::new(false);
87
88pub 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
101pub 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
109fn 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 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
125fn 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
135pub const REDACTED: &str = "[redacted]";
141
142pub type ClaimRedactor =
144 Arc<dyn Fn(&BTreeMap<String, String>) -> BTreeMap<String, String> + Send + Sync>;
145
146const SENSITIVE_CLAIM_FRAGMENTS: &[&str] = &[
149 "password",
152 "token",
153 "secret",
154 "key",
155 "authorization",
156 "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
172pub 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
205pub fn no_redaction(claims: &BTreeMap<String, String>) -> BTreeMap<String, String> {
207 claims.clone()
208}
209
210pub struct AccessLogHook {
221 sink: Sink,
222 server_version: String,
223 verbose: bool,
230 max_record_bytes: usize,
232 sample_rate: f64,
234 claim_redactor: ClaimRedactor,
235 starts: Mutex<std::collections::HashMap<HookToken, Instant>>,
239 next_token: std::sync::atomic::AtomicU64,
240}
241
242impl AccessLogHook {
243 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 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 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 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 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 pub fn with_claim_redactor(self: Arc<Self>, redactor: ClaimRedactor) -> Arc<Self> {
348 self.derive(|h| h.claim_redactor = redactor)
349 }
350
351 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 pub fn to_stderr(server_version: impl Into<String>) -> Arc<Self> {
399 Self::new(std::io::stderr(), server_version)
400 }
401
402 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 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 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 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
448fn 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 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 Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
477 *guard = pending + 1;
478 }
479 }
480 }
481 }
482}
483
484fn 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 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 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
534const 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 let sampled = error.is_some() || self.sampled_in(info, token);
581 if !sampled {
582 return;
583 }
584
585 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 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 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 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 let redactor = self.claim_redactor.clone();
673 let redacted = std::panic::catch_unwind(AssertUnwindSafe(|| redactor(&info.claims)))
674 .unwrap_or_else(|_| {
675 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 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 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 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
742pub(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 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
776fn 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
810pub(crate) fn random_stream_id() -> String {
813 use std::time::{SystemTime, UNIX_EPOCH};
814 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 #[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 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 #[test]
914 fn payload_omission_is_distinct_from_size_driven_shedding() {
915 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 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 .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 assert_eq!(rec["error_message"], "boom");
956 assert_eq!(rec["status"], "error");
957 assert!(rec.get("original_request_bytes").is_none());
958 }
959
960 #[test]
963 fn sample_rate_out_of_range_fails_at_construction() {
964 let (_, sink) = buffer();
965 let hook = AccessLogHook::new(sink, "v");
966 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 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 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 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 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 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 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 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 assert_eq!(logged.len(), 6);
1071 assert!(logged.contains_key("email"));
1072 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 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 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 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 #[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 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 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 #[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 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 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 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 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 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 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 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 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 {
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}