1#[cfg(feature = "decode")]
18use keyhog_core::ChunkMetadata;
19use serde::{Deserialize, Serialize};
20use std::borrow::Cow;
21use std::cell::RefCell;
22use std::collections::{BTreeMap, HashSet};
23use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
24use std::sync::{Arc, Mutex, OnceLock};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum DogfoodEvent {
32 ExampleSuppressed {
41 detector: String,
42 path: Option<String>,
43 credential_redacted: String,
44 reason: Cow<'static, str>,
45 },
46 ShapeSuppressed {
58 path: Option<String>,
59 credential_redacted: String,
60 reason: Cow<'static, str>,
61 },
62 StaticRecoveryRejected {
66 path: Option<String>,
67 expression_offset: usize,
68 decoder: Cow<'static, str>,
69 reason: Cow<'static, str>,
70 },
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub(crate) enum StaticRecoveryRejection {
77 LiteralByteArrayElement,
78 JsonBase64,
79 JsonUtf8,
80 JsonByteArray,
81 XorPlaintextUtf8,
82 StringJoinJson,
83 BufferBase64,
84 BufferHex,
85 AesKeyLength,
86 AesIvLength,
87 AesCiphertextBlockLength,
88 AesPadding,
89 AesPlaintextUtf8,
90 UnsupportedCall,
91 DynamicPropertyAccess,
92 MalformedExpression,
93 ResourceLimit,
94}
95
96impl StaticRecoveryRejection {
97 const ALL: [Self; 17] = [
98 Self::LiteralByteArrayElement,
99 Self::JsonBase64,
100 Self::JsonUtf8,
101 Self::JsonByteArray,
102 Self::XorPlaintextUtf8,
103 Self::StringJoinJson,
104 Self::BufferBase64,
105 Self::BufferHex,
106 Self::AesKeyLength,
107 Self::AesIvLength,
108 Self::AesCiphertextBlockLength,
109 Self::AesPadding,
110 Self::AesPlaintextUtf8,
111 Self::UnsupportedCall,
112 Self::DynamicPropertyAccess,
113 Self::MalformedExpression,
114 Self::ResourceLimit,
115 ];
116
117 const fn index(self) -> usize {
118 match self {
119 Self::LiteralByteArrayElement => 0,
120 Self::JsonBase64 => 1,
121 Self::JsonUtf8 => 2,
122 Self::JsonByteArray => 3,
123 Self::XorPlaintextUtf8 => 4,
124 Self::StringJoinJson => 5,
125 Self::BufferBase64 => 6,
126 Self::BufferHex => 7,
127 Self::AesKeyLength => 8,
128 Self::AesIvLength => 9,
129 Self::AesCiphertextBlockLength => 10,
130 Self::AesPadding => 11,
131 Self::AesPlaintextUtf8 => 12,
132 Self::UnsupportedCall => 13,
133 Self::DynamicPropertyAccess => 14,
134 Self::MalformedExpression => 15,
135 Self::ResourceLimit => 16,
136 }
137 }
138
139 const fn is_unsupported(self) -> bool {
140 matches!(self, Self::UnsupportedCall | Self::DynamicPropertyAccess)
141 }
142
143 pub(crate) const fn as_str(self) -> &'static str {
144 match self {
145 Self::LiteralByteArrayElement => "literal_byte_array_element",
146 Self::JsonBase64 => "json_base64",
147 Self::JsonUtf8 => "json_utf8",
148 Self::JsonByteArray => "json_byte_array",
149 Self::XorPlaintextUtf8 => "xor_plaintext_utf8",
150 Self::StringJoinJson => "string_join_json",
151 Self::BufferBase64 => "buffer_base64",
152 Self::BufferHex => "buffer_hex",
153 Self::AesKeyLength => "aes_key_length",
154 Self::AesIvLength => "aes_iv_length",
155 Self::AesCiphertextBlockLength => "aes_ciphertext_block_length",
156 Self::AesPadding => "aes_padding",
157 Self::AesPlaintextUtf8 => "aes_plaintext_utf8",
158 Self::UnsupportedCall => "unsupported_call",
159 Self::DynamicPropertyAccess => "dynamic_property_access",
160 Self::MalformedExpression => "malformed_expression",
161 Self::ResourceLimit => "resource_limit",
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
171pub struct StaticRecoveryStatus {
172 pub supported: u64,
173 pub unsupported: u64,
174 pub erroneous: u64,
175}
176
177pub const DOGFOOD_DETAIL_EVENT_LIMIT: usize = 1024;
180
181fn record_dropped_detail(counter: &AtomicUsize) {
182 let mut current = counter.load(Ordering::Relaxed);
183 while current != usize::MAX {
184 match counter.compare_exchange_weak(
185 current,
186 current + 1,
187 Ordering::Relaxed,
188 Ordering::Relaxed,
189 ) {
190 Ok(_) => return,
191 Err(observed) => current = observed,
192 }
193 }
194}
195
196fn push_dogfood_detail(
197 events: &Mutex<Vec<DogfoodEvent>>,
198 detail_events_dropped: &AtomicUsize,
199 event: DogfoodEvent,
200) -> bool {
201 match events.lock() {
202 Ok(mut events) if events.len() < DOGFOOD_DETAIL_EVENT_LIMIT => {
203 events.push(event);
204 true
205 }
206 Ok(_) | Err(_) => {
207 record_dropped_detail(detail_events_dropped);
209 false
210 }
211 }
212}
213
214fn recover_telemetry_lock<'a, T>(mutex: &'a Mutex<T>) -> std::sync::MutexGuard<'a, T> {
215 match mutex.lock() {
216 Ok(guard) => guard,
217 Err(poisoned) => {
218 let guard = poisoned.into_inner();
219 mutex.clear_poison();
220 guard
221 }
222 }
223}
224
225#[derive(Default)]
226struct StaticRecoveryTelemetry {
227 counts: [AtomicU64; StaticRecoveryRejection::ALL.len()],
228 supported: AtomicU64,
229 unsupported: AtomicU64,
230 erroneous: AtomicU64,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Hash)]
234enum EmittedDogfoodKey {
235 Suppression(String),
236 #[cfg(feature = "decode")]
237 StaticRecovery {
238 source_type: Arc<str>,
239 path: Option<Arc<str>>,
240 commit: Option<Arc<str>>,
241 expression_offset: usize,
242 reason: &'static str,
243 },
244}
245
246impl StaticRecoveryTelemetry {
247 fn record(&self, reason: StaticRecoveryRejection) {
248 self.add(reason, 1);
249 }
250
251 fn add(&self, reason: StaticRecoveryRejection, amount: u64) {
252 saturating_add_atomic(&self.counts[reason.index()], amount);
253 let disposition = if reason.is_unsupported() {
254 &self.unsupported
255 } else {
256 &self.erroneous
257 };
258 saturating_add_atomic(disposition, amount);
259 }
260
261 fn record_supported(&self, amount: u64) {
262 saturating_add_atomic(&self.supported, amount);
263 }
264
265 fn snapshot(&self) -> BTreeMap<String, u64> {
266 StaticRecoveryRejection::ALL
267 .iter()
268 .filter_map(|reason| {
269 let count = self.counts[reason.index()].load(Ordering::Relaxed);
270 (count != 0).then(|| (reason.as_str().to_owned(), count))
271 })
272 .collect()
273 }
274
275 fn status(&self) -> StaticRecoveryStatus {
276 StaticRecoveryStatus {
277 supported: self.supported.load(Ordering::Relaxed),
278 unsupported: self.unsupported.load(Ordering::Relaxed),
279 erroneous: self.erroneous.load(Ordering::Relaxed),
280 }
281 }
282
283 fn reset(&self) {
284 for count in &self.counts {
285 count.store(0, Ordering::Relaxed);
286 }
287 self.supported.store(0, Ordering::Relaxed);
288 self.unsupported.store(0, Ordering::Relaxed);
289 self.erroneous.store(0, Ordering::Relaxed);
290 }
291}
292
293fn saturating_add_atomic(counter: &AtomicU64, amount: u64) {
294 let mut current = counter.load(Ordering::Relaxed);
295 while current != u64::MAX {
296 let next = current.saturating_add(amount);
297 match counter.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
298 Ok(_) => return,
299 Err(observed) => current = observed,
300 }
301 }
302}
303
304#[derive(Default)]
305struct Telemetry {
306 dogfood_enabled: AtomicBool,
307 example_suppressions: AtomicUsize,
308 events: Mutex<Vec<DogfoodEvent>>,
309 emitted_suppression_events: Mutex<HashSet<EmittedDogfoodKey>>,
319 detail_events_dropped: AtomicUsize,
320 static_recovery: StaticRecoveryTelemetry,
321}
322
323#[derive(Default)]
331pub struct ScanTelemetry {
332 dogfood_enabled: AtomicBool,
333 example_suppressions: AtomicUsize,
334 events: Mutex<Vec<DogfoodEvent>>,
335 emitted_suppression_events: Mutex<HashSet<EmittedDogfoodKey>>,
336 detail_events_dropped: AtomicUsize,
337 static_recovery: StaticRecoveryTelemetry,
338}
339
340impl ScanTelemetry {
341 pub fn new() -> Self {
342 Self::default()
343 }
344
345 pub fn enable_dogfood(&self) {
346 self.dogfood_enabled.store(true, Ordering::Relaxed);
347 }
348
349 fn is_dogfood_enabled(&self) -> bool {
350 self.dogfood_enabled.load(Ordering::Relaxed)
351 }
352
353 fn example_suppression_count(&self) -> usize {
354 self.example_suppressions.load(Ordering::Relaxed)
355 }
356
357 fn drain_events(&self) -> Vec<DogfoodEvent> {
358 drain_event_buffers(&self.events, &self.emitted_suppression_events)
359 }
360
361 pub fn drain(&self) -> ScanTelemetrySnapshot {
362 ScanTelemetrySnapshot {
363 example_suppressions: self.example_suppression_count() as u64,
364 dogfood_events: self.drain_events(),
365 dogfood_detail_events_dropped: self.detail_events_dropped.load(Ordering::Relaxed)
366 as u64,
367 static_recovery_rejections: self.static_recovery.snapshot(),
368 static_recovery_status: self.static_recovery.status(),
369 }
370 }
371}
372
373pub struct ScanTelemetrySnapshot {
374 pub example_suppressions: u64,
375 pub dogfood_events: Vec<DogfoodEvent>,
376 pub dogfood_detail_events_dropped: u64,
377 pub static_recovery_rejections: BTreeMap<String, u64>,
378 pub static_recovery_status: StaticRecoveryStatus,
379}
380
381thread_local! {
382 static CURRENT_SCAN_TELEMETRY: RefCell<Option<Arc<ScanTelemetry>>> = RefCell::new(None);
383}
384
385struct ScanTelemetryRestore {
386 previous: Option<Arc<ScanTelemetry>>,
387}
388
389impl Drop for ScanTelemetryRestore {
390 fn drop(&mut self) {
391 let previous = self.previous.take();
392 CURRENT_SCAN_TELEMETRY.with(|slot| {
393 *slot.borrow_mut() = previous;
394 });
395 }
396}
397
398pub fn with_scan_telemetry<R>(telemetry: &Arc<ScanTelemetry>, f: impl FnOnce() -> R) -> R {
402 let previous = CURRENT_SCAN_TELEMETRY.with(|slot| {
403 let mut slot = slot.borrow_mut();
404 slot.replace(Arc::clone(telemetry))
405 });
406 let _restore = ScanTelemetryRestore { previous };
407 f()
408}
409
410fn current_scan_telemetry() -> Option<Arc<ScanTelemetry>> {
411 CURRENT_SCAN_TELEMETRY.with(|slot| slot.borrow().clone())
412}
413
414pub(crate) fn capture_scan_telemetry() -> Option<Arc<ScanTelemetry>> {
417 current_scan_telemetry()
418}
419
420pub(crate) fn with_captured_scan_telemetry<R>(
423 telemetry: Option<&Arc<ScanTelemetry>>,
424 f: impl FnOnce() -> R,
425) -> R {
426 match telemetry {
427 Some(telemetry) => with_scan_telemetry(telemetry, f),
428 None => f(),
429 }
430}
431
432fn current_scan_dogfood_enabled() -> Option<bool> {
433 CURRENT_SCAN_TELEMETRY.with(|slot| {
434 slot.borrow()
435 .as_ref()
436 .map(|telemetry| telemetry.is_dogfood_enabled())
437 })
438}
439
440static FILES_SCANNED: AtomicUsize = AtomicUsize::new(0);
442static BYTES_SCANNED: AtomicUsize = AtomicUsize::new(0);
443static SKIPPED_FILES: AtomicUsize = AtomicUsize::new(0);
444static TOTAL_MATCHES: AtomicUsize = AtomicUsize::new(0);
445static GPU_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
446static STRUCTURED_PARSE_FAILURES: AtomicUsize = AtomicUsize::new(0);
455static STRUCTURED_OVERSIZE_SKIPS: AtomicUsize = AtomicUsize::new(0);
464static DECODE_TRUNCATIONS: AtomicUsize = AtomicUsize::new(0);
468#[cfg(test)]
469thread_local! {
470 static THREAD_DECODE_TRUNCATIONS: std::cell::Cell<usize> =
471 const { std::cell::Cell::new(0) };
472}
473static INVALID_PATTERN_INDEX_SKIPS: AtomicUsize = AtomicUsize::new(0);
477static BOUNDARY_RESULT_CARDINALITY_MISMATCHES: AtomicUsize = AtomicUsize::new(0);
480static LINE_OFFSET_MAPPING_MISMATCHES: AtomicUsize = AtomicUsize::new(0);
483static CHUNK_DEADLINE_ABORTS: AtomicUsize = AtomicUsize::new(0);
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub(crate) enum ScannerCoverageGapEvent {
492 StructuredParseFailure,
493 StructuredOversizeSkip,
494 DecodeTruncation,
495 InvalidPatternIndexSkip,
496 BoundaryResultCardinalityMismatch,
497 LineOffsetMappingMismatch,
498 ChunkDeadlineAbort,
499}
500
501impl ScannerCoverageGapEvent {
502 pub(crate) const ALL: [Self; 7] = [
505 Self::StructuredParseFailure,
506 Self::StructuredOversizeSkip,
507 Self::DecodeTruncation,
508 Self::InvalidPatternIndexSkip,
509 Self::BoundaryResultCardinalityMismatch,
510 Self::LineOffsetMappingMismatch,
511 Self::ChunkDeadlineAbort,
512 ];
513
514 pub(crate) fn counter(self) -> &'static AtomicUsize {
515 match self {
516 Self::StructuredParseFailure => &STRUCTURED_PARSE_FAILURES,
517 Self::StructuredOversizeSkip => &STRUCTURED_OVERSIZE_SKIPS,
518 Self::DecodeTruncation => &DECODE_TRUNCATIONS,
519 Self::InvalidPatternIndexSkip => &INVALID_PATTERN_INDEX_SKIPS,
520 Self::BoundaryResultCardinalityMismatch => &BOUNDARY_RESULT_CARDINALITY_MISMATCHES,
521 Self::LineOffsetMappingMismatch => &LINE_OFFSET_MAPPING_MISMATCHES,
522 Self::ChunkDeadlineAbort => &CHUNK_DEADLINE_ABORTS,
523 }
524 }
525
526 const fn label(self) -> &'static str {
527 match self {
528 Self::StructuredParseFailure => "structured_parse_failures",
529 Self::StructuredOversizeSkip => "structured_oversize_skips",
530 Self::DecodeTruncation => "decode_truncations",
531 Self::InvalidPatternIndexSkip => "invalid_pattern_index_skips",
532 Self::BoundaryResultCardinalityMismatch => "boundary_result_cardinality_mismatches",
533 Self::LineOffsetMappingMismatch => "line_offset_mapping_mismatches",
534 Self::ChunkDeadlineAbort => "chunk_deadline_aborts",
535 }
536 }
537}
538
539#[derive(Clone, Copy, Default, Eq, PartialEq)]
544pub struct ScannerCoverageSnapshot {
545 counts: [usize; ScannerCoverageGapEvent::ALL.len()],
546}
547
548impl ScannerCoverageSnapshot {
549 #[must_use]
550 pub fn capture() -> Self {
551 Self {
552 counts: std::array::from_fn(|index| {
553 ScannerCoverageGapEvent::ALL[index]
554 .counter()
555 .load(Ordering::Relaxed)
556 }),
557 }
558 }
559
560 #[must_use]
561 pub fn saturating_delta(self, earlier: Self) -> Self {
562 Self {
563 counts: std::array::from_fn(|index| {
564 self.counts[index].saturating_sub(earlier.counts[index])
565 }),
566 }
567 }
568
569 #[must_use]
570 pub fn is_empty(self) -> bool {
571 self.counts.iter().all(|count| *count == 0)
572 }
573}
574
575impl std::fmt::Debug for ScannerCoverageSnapshot {
576 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
577 let mut gaps = formatter.debug_map();
578 for (event, count) in ScannerCoverageGapEvent::ALL.into_iter().zip(self.counts) {
579 if count > 0 {
580 gaps.entry(&event.label(), &count);
581 }
582 }
583 gaps.finish()
584 }
585}
586
587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589#[must_use = "scanner coverage gaps must be recorded through the typed recorder so partial coverage remains surfaced"]
590pub(crate) struct RecordedScannerCoverageGap {
591 event: ScannerCoverageGapEvent,
592 previous: usize,
593 delta: usize,
594}
595
596pub(crate) fn record_scanner_coverage_gap(
597 event: ScannerCoverageGapEvent,
598) -> RecordedScannerCoverageGap {
599 let previous = event.counter().fetch_add(1, Ordering::Relaxed);
600 RecordedScannerCoverageGap {
601 event,
602 previous,
603 delta: 1,
604 }
605}
606
607static DOGFOOD_ENABLED: AtomicBool = AtomicBool::new(false);
609
610fn cell() -> &'static Telemetry {
611 static CELL: OnceLock<Telemetry> = OnceLock::new();
612 CELL.get_or_init(Telemetry::default)
613}
614
615pub fn enable_dogfood() {
617 DOGFOOD_ENABLED.store(true, Ordering::Relaxed);
618 cell().dogfood_enabled.store(true, Ordering::Relaxed);
619}
620
621pub fn is_dogfood_enabled() -> bool {
622 if let Some(enabled) = current_scan_dogfood_enabled() {
623 return enabled;
624 }
625 DOGFOOD_ENABLED.load(Ordering::Relaxed)
626}
627
628pub fn record_example_suppression(
632 detector: &str,
633 path: Option<&str>,
634 credential: &str,
635 reason: &'static str,
636) {
637 if let Some(t) = current_scan_telemetry() {
638 record_example_suppression_in(
639 &t.example_suppressions,
640 &t.events,
641 &t.emitted_suppression_events,
642 &t.detail_events_dropped,
643 detector,
644 path,
645 credential,
646 reason,
647 );
648 return;
649 }
650
651 let t = cell();
652 record_example_suppression_in(
653 &t.example_suppressions,
654 &t.events,
655 &t.emitted_suppression_events,
656 &t.detail_events_dropped,
657 detector,
658 path,
659 credential,
660 reason,
661 );
662}
663
664fn record_example_suppression_in(
665 example_suppressions: &AtomicUsize,
666 events: &Mutex<Vec<DogfoodEvent>>,
667 emitted_suppression_events: &Mutex<HashSet<EmittedDogfoodKey>>,
668 detail_events_dropped: &AtomicUsize,
669 detector: &str,
670 path: Option<&str>,
671 credential: &str,
672 reason: &'static str,
673) {
674 example_suppressions.fetch_add(1, Ordering::Relaxed);
675
676 if !is_dogfood_enabled() {
678 return;
679 }
680
681 let credential_hash = keyhog_core::hex_encode(&keyhog_core::sha256_hash(credential));
682 if !mark_suppression_event_emitted(
686 emitted_suppression_events,
687 detail_events_dropped,
688 &credential_hash,
689 ) {
690 return;
691 }
692
693 let redacted = keyhog_core::redact(credential).into_owned();
697 push_dogfood_detail(
698 events,
699 detail_events_dropped,
700 DogfoodEvent::ExampleSuppressed {
701 detector: detector.to_string(),
702 path: path.map(str::to_string),
703 credential_redacted: redacted,
704 reason: Cow::Borrowed(reason),
705 },
706 );
707}
708
709fn mark_suppression_event_emitted(
721 emitted_suppression_events: &Mutex<HashSet<EmittedDogfoodKey>>,
722 detail_events_dropped: &AtomicUsize,
723 credential_hash: &str,
724) -> bool {
725 match emitted_suppression_events.lock() {
726 Ok(mut emitted) => {
727 let key = EmittedDogfoodKey::Suppression(credential_hash.to_owned());
728 if emitted.contains(&key) {
729 return false;
730 }
731 if emitted.len() >= DOGFOOD_DETAIL_EVENT_LIMIT {
732 record_dropped_detail(detail_events_dropped);
733 return false;
734 }
735 emitted.insert(key)
736 }
737 Err(_) => {
738 record_dropped_detail(detail_events_dropped);
740 false }
742 }
743}
744
745pub(crate) fn record_shape_suppression(path: Option<&str>, credential: &str, reason: &'static str) {
755 if !is_dogfood_enabled() {
757 return;
758 }
759 if let Some(t) = current_scan_telemetry() {
760 record_shape_suppression_in(
761 &t.events,
762 &t.emitted_suppression_events,
763 &t.detail_events_dropped,
764 path,
765 credential,
766 reason,
767 );
768 return;
769 }
770 let t = cell();
771 record_shape_suppression_in(
772 &t.events,
773 &t.emitted_suppression_events,
774 &t.detail_events_dropped,
775 path,
776 credential,
777 reason,
778 );
779}
780
781#[cfg(feature = "decode")]
784pub(crate) fn record_static_recovery_rejection(
785 metadata: &ChunkMetadata,
786 expression_offset: usize,
787 reason: StaticRecoveryRejection,
788) {
789 if let Some(t) = current_scan_telemetry() {
790 t.static_recovery.record(reason);
791 if !t.is_dogfood_enabled() {
792 return;
793 }
794 if !mark_static_recovery_event_emitted(
795 &t.emitted_suppression_events,
796 &t.detail_events_dropped,
797 metadata,
798 expression_offset,
799 reason,
800 ) {
801 return;
802 }
803 push_dogfood_detail(
804 &t.events,
805 &t.detail_events_dropped,
806 static_recovery_event(metadata, expression_offset, reason),
807 );
808 return;
809 }
810 let t = cell();
811 t.static_recovery.record(reason);
812 if !t.dogfood_enabled.load(Ordering::Relaxed) {
813 return;
814 }
815 if !mark_static_recovery_event_emitted(
816 &t.emitted_suppression_events,
817 &t.detail_events_dropped,
818 metadata,
819 expression_offset,
820 reason,
821 ) {
822 return;
823 }
824 push_dogfood_detail(
825 &t.events,
826 &t.detail_events_dropped,
827 static_recovery_event(metadata, expression_offset, reason),
828 );
829}
830
831#[cfg(feature = "decode")]
833pub(crate) fn record_static_recovery_supported(count: usize) {
834 if count == 0 {
835 return;
836 }
837 let amount = u64::try_from(count).unwrap_or(u64::MAX); if let Some(t) = current_scan_telemetry() {
839 t.static_recovery.record_supported(amount);
840 } else {
841 cell().static_recovery.record_supported(amount);
842 }
843}
844
845#[cfg(feature = "decode")]
846fn static_recovery_event(
847 metadata: &ChunkMetadata,
848 expression_offset: usize,
849 reason: StaticRecoveryRejection,
850) -> DogfoodEvent {
851 DogfoodEvent::StaticRecoveryRejected {
852 path: metadata.path.as_deref().map(str::to_owned),
853 expression_offset,
854 decoder: Cow::Borrowed("javascript-static"),
855 reason: Cow::Borrowed(reason.as_str()),
856 }
857}
858
859#[cfg(feature = "decode")]
860fn mark_static_recovery_event_emitted(
861 emitted_events: &Mutex<HashSet<EmittedDogfoodKey>>,
862 detail_events_dropped: &AtomicUsize,
863 metadata: &ChunkMetadata,
864 expression_offset: usize,
865 reason: StaticRecoveryRejection,
866) -> bool {
867 let key = EmittedDogfoodKey::StaticRecovery {
868 source_type: Arc::clone(&metadata.source_type),
869 path: metadata.path.clone(),
870 commit: metadata.commit.clone(),
871 expression_offset,
872 reason: reason.as_str(),
873 };
874 match emitted_events.lock() {
875 Ok(mut emitted) => {
876 if emitted.contains(&key) {
877 return false;
878 }
879 if emitted.len() >= DOGFOOD_DETAIL_EVENT_LIMIT {
880 record_dropped_detail(detail_events_dropped);
881 return false;
882 }
883 emitted.insert(key)
884 }
885 Err(_) => {
886 record_dropped_detail(detail_events_dropped);
888 false }
890 }
891}
892
893fn record_shape_suppression_in(
894 events: &Mutex<Vec<DogfoodEvent>>,
895 emitted_suppression_events: &Mutex<HashSet<EmittedDogfoodKey>>,
896 detail_events_dropped: &AtomicUsize,
897 path: Option<&str>,
898 credential: &str,
899 reason: &'static str,
900) {
901 let credential_hash = keyhog_core::hex_encode(&keyhog_core::sha256_hash(credential));
902 if !mark_suppression_event_emitted(
909 emitted_suppression_events,
910 detail_events_dropped,
911 &credential_hash,
912 ) {
913 return;
914 }
915 let redacted = keyhog_core::redact(credential).into_owned();
916 push_dogfood_detail(
917 events,
918 detail_events_dropped,
919 DogfoodEvent::ShapeSuppressed {
920 path: path.map(str::to_string),
921 credential_redacted: redacted,
922 reason: Cow::Borrowed(reason),
923 },
924 );
925}
926
927pub fn example_suppression_count() -> usize {
929 cell().example_suppressions.load(Ordering::Relaxed)
930}
931
932#[cfg(test)]
937pub(crate) fn reset_example_suppression_count() {
938 cell().example_suppressions.store(0, Ordering::Relaxed);
939}
940
941pub fn add_example_suppressions(n: usize) {
946 cell().example_suppressions.fetch_add(n, Ordering::Relaxed);
947}
948
949pub(crate) fn record_structured_parse_failure() {
955 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::StructuredParseFailure);
956}
957
958pub fn structured_parse_failure_count() -> usize {
960 STRUCTURED_PARSE_FAILURES.load(Ordering::Relaxed)
961}
962
963pub(crate) fn record_structured_oversize_skip() {
969 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::StructuredOversizeSkip);
970}
971
972pub fn structured_oversize_skip_count() -> usize {
975 STRUCTURED_OVERSIZE_SKIPS.load(Ordering::Relaxed)
976}
977
978pub(crate) fn record_decode_truncation() {
981 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::DecodeTruncation);
982 #[cfg(test)]
983 THREAD_DECODE_TRUNCATIONS.with(|count| count.set(count.get() + 1));
984}
985
986#[cfg(not(test))]
988pub fn decode_truncation_count() -> usize {
989 DECODE_TRUNCATIONS.load(Ordering::Relaxed)
990}
991
992#[cfg(test)]
996pub fn decode_truncation_count() -> usize {
997 THREAD_DECODE_TRUNCATIONS.with(|count| count.get())
998}
999
1000pub(crate) fn record_invalid_pattern_index_skip() {
1003 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::InvalidPatternIndexSkip);
1004}
1005
1006pub fn invalid_pattern_index_skip_count() -> usize {
1009 INVALID_PATTERN_INDEX_SKIPS.load(Ordering::Relaxed)
1010}
1011
1012pub(crate) fn record_boundary_result_cardinality_mismatch() {
1015 let _receipt =
1016 record_scanner_coverage_gap(ScannerCoverageGapEvent::BoundaryResultCardinalityMismatch);
1017}
1018
1019pub fn boundary_result_cardinality_mismatch_count() -> usize {
1022 BOUNDARY_RESULT_CARDINALITY_MISMATCHES.load(Ordering::Relaxed)
1023}
1024
1025#[cfg(feature = "multiline")]
1028pub(crate) fn record_line_offset_mapping_mismatch() {
1029 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::LineOffsetMappingMismatch);
1030}
1031
1032pub(crate) fn record_chunk_deadline_abort() {
1034 let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::ChunkDeadlineAbort);
1035}
1036
1037pub fn chunk_deadline_abort_count() -> usize {
1039 CHUNK_DEADLINE_ABORTS.load(Ordering::Relaxed)
1040}
1041
1042pub fn line_offset_mapping_mismatch_count() -> usize {
1045 LINE_OFFSET_MAPPING_MISMATCHES.load(Ordering::Relaxed)
1046}
1047
1048pub fn append_events<I: IntoIterator<Item = DogfoodEvent>>(events: I) {
1053 append_event_details(events, true);
1054}
1055
1056pub fn append_daemon_events<I: IntoIterator<Item = DogfoodEvent>>(events: I) {
1062 append_event_details(events, false);
1063}
1064
1065fn append_event_details<I: IntoIterator<Item = DogfoodEvent>>(
1066 events: I,
1067 infer_static_recovery_counts: bool,
1068) {
1069 let t = cell();
1070 for event in events {
1071 if infer_static_recovery_counts {
1072 let DogfoodEvent::StaticRecoveryRejected { reason, .. } = &event else {
1073 push_dogfood_detail(&t.events, &t.detail_events_dropped, event);
1074 continue;
1075 };
1076 if let Some(reason) = StaticRecoveryRejection::ALL
1077 .iter()
1078 .find(|candidate| candidate.as_str() == reason.as_ref())
1079 {
1080 t.static_recovery.record(*reason);
1081 }
1082 }
1083 push_dogfood_detail(&t.events, &t.detail_events_dropped, event);
1084 }
1085}
1086
1087pub fn merge_daemon_aggregates(
1094 static_recovery_rejections: &BTreeMap<String, u64>,
1095 static_recovery_status: StaticRecoveryStatus,
1096 detail_events_dropped: u64,
1097) -> Result<(), String> {
1098 let mut resolved = Vec::with_capacity(static_recovery_rejections.len());
1099 let mut unsupported = 0_u64;
1100 let mut erroneous = 0_u64;
1101 for (name, count) in static_recovery_rejections {
1102 let Some(reason) = StaticRecoveryRejection::ALL
1103 .iter()
1104 .copied()
1105 .find(|candidate| candidate.as_str() == name)
1106 else {
1107 return Err(format!(
1108 "daemon returned unknown static-recovery rejection reason {name:?}; restart it with this KeyHog build"
1109 ));
1110 };
1111 let disposition = if reason.is_unsupported() {
1112 &mut unsupported
1113 } else {
1114 &mut erroneous
1115 };
1116 *disposition = disposition.checked_add(*count).ok_or_else(|| {
1117 format!(
1118 "daemon static-recovery {kind} reason counts overflowed u64",
1119 kind = if reason.is_unsupported() {
1120 "unsupported"
1121 } else {
1122 "erroneous"
1123 }
1124 )
1125 })?;
1126 resolved.push((reason, *count));
1127 }
1128 if unsupported != static_recovery_status.unsupported
1129 || erroneous != static_recovery_status.erroneous
1130 {
1131 return Err(format!(
1132 "daemon static-recovery aggregate conservation failed: \
1133 reasons unsupported={unsupported}, erroneous={erroneous}; \
1134 status unsupported={}, erroneous={}",
1135 static_recovery_status.unsupported, static_recovery_status.erroneous
1136 ));
1137 }
1138
1139 let telemetry = cell();
1140 telemetry
1141 .static_recovery
1142 .record_supported(static_recovery_status.supported);
1143 for (reason, count) in resolved {
1144 telemetry.static_recovery.add(reason, count);
1145 }
1146 let dropped = usize::try_from(detail_events_dropped).unwrap_or(usize::MAX); let counter = &telemetry.detail_events_dropped;
1148 let mut current = counter.load(Ordering::Relaxed);
1149 while current != usize::MAX {
1150 match counter.compare_exchange_weak(
1151 current,
1152 current.saturating_add(dropped),
1153 Ordering::Relaxed,
1154 Ordering::Relaxed,
1155 ) {
1156 Ok(_) => break,
1157 Err(observed) => current = observed,
1158 }
1159 }
1160 Ok(())
1161}
1162
1163pub fn static_recovery_rejection_counts() -> BTreeMap<String, u64> {
1167 cell().static_recovery.snapshot()
1168}
1169
1170pub fn static_recovery_status() -> StaticRecoveryStatus {
1173 cell().static_recovery.status()
1174}
1175
1176pub fn dogfood_detail_events_dropped() -> usize {
1178 cell().detail_events_dropped.load(Ordering::Relaxed)
1179}
1180
1181pub fn drain_events() -> Vec<DogfoodEvent> {
1184 let t = cell();
1185 drain_event_buffers(&t.events, &t.emitted_suppression_events)
1186}
1187
1188fn drain_event_buffers(
1189 events: &Mutex<Vec<DogfoodEvent>>,
1190 emitted_suppression_events: &Mutex<HashSet<EmittedDogfoodKey>>,
1191) -> Vec<DogfoodEvent> {
1192 recover_telemetry_lock(emitted_suppression_events).clear();
1196 std::mem::take(&mut *recover_telemetry_lock(events))
1197}
1198
1199pub(crate) fn record_file_scanned(bytes: usize) {
1201 FILES_SCANNED.fetch_add(1, Ordering::Relaxed);
1202 BYTES_SCANNED.fetch_add(bytes, Ordering::Relaxed);
1203}
1204
1205pub(crate) fn global_scan_counts() -> (usize, usize) {
1206 (
1207 FILES_SCANNED.load(Ordering::Relaxed),
1208 BYTES_SCANNED.load(Ordering::Relaxed),
1209 )
1210}
1211
1212pub(crate) fn record_file_skipped() {
1213 SKIPPED_FILES.fetch_add(1, Ordering::Relaxed);
1214}
1215
1216pub(crate) fn record_match_found() {
1217 TOTAL_MATCHES.fetch_add(1, Ordering::Relaxed);
1218}
1219
1220pub(crate) fn record_gpu_dispatch() {
1221 GPU_DISPATCHES.fetch_add(1, Ordering::Relaxed);
1222}
1223
1224pub fn reset_for_scan() {
1231 let t = cell();
1232 DOGFOOD_ENABLED.store(false, Ordering::Relaxed);
1233 t.dogfood_enabled.store(false, Ordering::Relaxed);
1234 t.example_suppressions.store(0, Ordering::Relaxed);
1235 t.detail_events_dropped.store(0, Ordering::Relaxed);
1236 t.static_recovery.reset();
1237 FILES_SCANNED.store(0, Ordering::Relaxed);
1238 BYTES_SCANNED.store(0, Ordering::Relaxed);
1239 SKIPPED_FILES.store(0, Ordering::Relaxed);
1240 TOTAL_MATCHES.store(0, Ordering::Relaxed);
1241 GPU_DISPATCHES.store(0, Ordering::Relaxed);
1242 for gap in ScannerCoverageGapEvent::ALL {
1243 gap.counter().store(0, Ordering::Relaxed);
1244 }
1245 #[cfg(test)]
1246 THREAD_DECODE_TRUNCATIONS.with(|count| count.set(0));
1247 recover_telemetry_lock(&t.events).clear();
1248 recover_telemetry_lock(&t.emitted_suppression_events).clear();
1249 CURRENT_SCAN_TELEMETRY.with(|slot| {
1250 *slot.borrow_mut() = None;
1251 });
1252}
1253
1254#[cfg(test)]
1255#[doc(hidden)]
1256pub mod testing {
1257 use std::sync::Arc;
1258
1259 pub fn reset() {
1261 super::reset_for_scan();
1262 }
1263
1264 pub(crate) fn poison_events(telemetry: &Arc<super::ScanTelemetry>) {
1265 let telemetry = Arc::clone(telemetry);
1266 let _ = std::thread::spawn(move || {
1267 let Ok(_events) = telemetry.events.lock() else {
1269 panic!("fresh telemetry event buffer was already poisoned");
1270 };
1271 panic!("poison scoped telemetry event buffer");
1272 })
1273 .join();
1274 }
1275}