Skip to main content

keyhog_scanner/
telemetry.rs

1//! Lightweight per-scan telemetry.
2//!
3//! Two purposes:
4//!
5//! 1. **Always-on counters** for things the reporter wants to surface
6//!    even on a default run (e.g. "no secrets, but 3 example/test keys
7//!    were suppressed - pass `--dogfood` to see them"). These are
8//!    cheap atomic increments.
9//! 2. **Opt-in event capture** (`enable_dogfood()`) - the engine logs
10//!    per-decision detail so a user can answer "why didn't keyhog fire
11//!    on my fixture?" without rebuilding with debug instrumentation.
12//!
13//! Single-process CLI scans use the process-global `OnceLock<Telemetry>` as
14//! the lightest container. Long-lived daemon workers use [`ScanTelemetry`]
15//! scopes so concurrent client scans do not share counts/events.
16
17#[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/// A single dogfood event. Variants are intentionally narrow - anything
27/// scanner-internal that would help a user understand a missed or
28/// suppressed credential should go here.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "kind", rename_all = "snake_case")]
31pub enum DogfoodEvent {
32    /// A credential was matched but suppressed as a known example /
33    /// placeholder (e.g. ends with `EXAMPLE`, is a sequential
34    /// placeholder, contains a `DUMMY`/`FAKE`/`MOCK` token).
35    ///
36    /// `reason` is `Cow<'static, str>` so callers can pass a literal
37    /// without allocating (`Cow::Borrowed("ends_with_EXAMPLE")`),
38    /// while the daemon-protocol deserialize path can also produce
39    /// owned values from over-the-wire JSON.
40    ExampleSuppressed {
41        detector: String,
42        path: Option<String>,
43        credential_redacted: String,
44        reason: Cow<'static, str>,
45    },
46    /// A credential was matched but suppressed by a SHAPE / heuristic / marker
47    /// gate in the suppression cascade (UUID-v4, bare-hex digest, base64 blob,
48    /// repetitive run, dashed serial, template placeholder, DUMMY/PLACEHOLDER
49    /// word, doc-marker substring, …) other than the example-token counter
50    /// path. These gates are recall-affecting: a real secret that happens to
51    /// wear a suppressed shape is dropped here, so `--dogfood` must report it
52    /// (the `--help` contract: "whether a match was made and silenced, or never
53    /// reached the engine"). `reason` is the gate name (e.g.
54    /// `Cow::Borrowed("uuid_v4_shape")`). No detector field: the suppression
55    /// cascade adjudicates on shape/markers, not detector identity, so naming a
56    /// detector here would be a guess.
57    ShapeSuppressed {
58        path: Option<String>,
59        credential_redacted: String,
60        reason: Cow<'static, str>,
61    },
62    /// A bounded static-recovery grammar recognized a candidate expression but
63    /// rejected malformed data, an unsafe construct, or a resource limit. The
64    /// original source is still scanned. No source bytes are retained.
65    StaticRecoveryRejected {
66        path: Option<String>,
67        expression_offset: usize,
68        decoder: Cow<'static, str>,
69        reason: Cow<'static, str>,
70    },
71}
72
73/// Typed reasons emitted when bounded static recovery cannot evaluate a
74/// recognized JavaScript expression.
75#[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/// Aggregate disposition of bounded JavaScript constant-recovery constructs.
167///
168/// Counts saturate rather than wrap. Retained dogfood details are independently
169/// bounded, so this status remains exact when the detail budget is exhausted.
170#[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
177/// Maximum retained detail events per scan. Aggregate counters continue past
178/// this limit and the omitted count is surfaced in the trace.
179pub 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            // LAW10: a full or poisoned detail buffer increments the operator-visible dropped-detail counter below.
208            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    /// Namespaced keys for events already emitted by this trace. Suppression
310    /// events key by credential hash. Static recovery keys by path and reason.
311    /// The same credential is adjudicated by several pipeline stages (the
312    /// example-token gate AND a shape/weak-anchor gate can both drop the same
313    /// `AKIA…EXAMPLE` key), so without this the `--dogfood` trace emitted one
314    /// event per STAGE (duplicate noise for one logical suppression (KH-GAP-091)).
315    /// Keyed without the reason/stage so the FIRST stage to record a credential
316    /// wins and later stages are deduped; the example counter keeps its own
317    /// (reason-keyed) dedup so per-stage COUNTS are unaffected.
318    emitted_suppression_events: Mutex<HashSet<EmittedDogfoodKey>>,
319    detail_events_dropped: AtomicUsize,
320    static_recovery: StaticRecoveryTelemetry,
321}
322
323/// Per-request scanner telemetry used by daemon scan workers.
324///
325/// The regular CLI process still uses the process-global telemetry cell because
326/// it runs one scan per process. A daemon serves many client requests in one
327/// process, so each request owns one `ScanTelemetry` and installs it with
328/// [`with_scan_telemetry`] for the duration of the scan. Recorders then route
329/// counts/events into that scope instead of the process-global cell.
330#[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
398/// Run `f` with `telemetry` installed for scanner telemetry recorders on this
399/// thread. Nested scopes restore the previous owner on drop, including during
400/// unwinding.
401pub 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
414/// Capture the request-scoped telemetry owner before dispatching work to a
415/// thread pool. Rayon workers do not inherit thread-local state automatically.
416pub(crate) fn capture_scan_telemetry() -> Option<Arc<ScanTelemetry>> {
417    current_scan_telemetry()
418}
419
420/// Install a captured request scope for one worker closure. When no request
421/// scope exists, execute directly so normal CLI scans retain the global path.
422pub(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
440// Global lock-free telemetry counters (KH-116)
441static 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);
446/// Files that MATCHED a structured-format heuristic (k8s Secret, Terraform
447/// state, Jupyter notebook, docker-compose) but FAILED to parse, so the
448/// structured decode-through (e.g. base64-encoded secrets inside a k8s `data:`
449/// block) was NOT applied. The raw text is still scanned, so this is not a total
450/// miss, but credentials only reachable via the structured decode are silently
451/// lost on the offending file. Counted (not just `tracing::debug!`-logged, which
452/// is filtered out at default verbosity) so the scan can surface the coverage
453/// gap loudly at completion (Law 10).
454static STRUCTURED_PARSE_FAILURES: AtomicUsize = AtomicUsize::new(0);
455/// A chunk matched a structured decode-through format (k8s Secret /
456/// docker-compose / tfstate / Jupyter notebook) but exceeded
457/// `MAX_STRUCTURED_PARSE_BYTES`, so its structured decode-through (base64
458/// `data:` decoding) was skipped. Distinct from a parse FAILURE: the file is
459/// well-formed, just too large for the structured pass. The raw bytes are still
460/// scanned, but the regular scan does not recover base64-encoded values, so this
461/// is a real recall gap the reporter must surface (Law 10) rather than the bare
462/// `return None` that previously dropped it silently.
463static STRUCTURED_OVERSIZE_SKIPS: AtomicUsize = AtomicUsize::new(0);
464/// Decode-through work was truncated by a safety budget/cap. The raw chunk is
465/// still scanned, but secrets only reachable after an omitted recursive decode
466/// layer may be missed, so the CLI must surface this as a coverage gap.
467static 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}
473/// A trigger bitmap or compiled pattern-index side table referenced a pattern
474/// outside the compiled pattern bitmap. That loses phase-2 admission/expansion
475/// coverage for the affected pattern, so the operator must see the partial scan.
476static INVALID_PATTERN_INDEX_SKIPS: AtomicUsize = AtomicUsize::new(0);
477/// Cross-chunk boundary reassembly could not run because the caller supplied a
478/// result vector with different cardinality than the chunk vector.
479static BOUNDARY_RESULT_CARDINALITY_MISMATCHES: AtomicUsize = AtomicUsize::new(0);
480/// Multiline/structured reassembly produced a synthetic finding mapping whose
481/// source line was not present in the caller-provided line-offset table.
482static LINE_OFFSET_MAPPING_MISMATCHES: AtomicUsize = AtomicUsize::new(0);
483/// A configured per-chunk deadline elapsed before the scanner completed every
484/// detection and post-processing stage for that chunk.
485static CHUNK_DEADLINE_ABORTS: AtomicUsize = AtomicUsize::new(0);
486
487/// Scanner coverage gap recorded when a scanner-owned transform did not run to
488/// full coverage. These are not source skips: raw bytes still flow through the
489/// scanner, but structured/decode-only secrets may be missed.
490#[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    /// Every variant, so the per-scan reset owner (`reset_for_scan`) can zero the
503    /// full coverage-gap counter set without a new gap counter ever being forgotten.
504    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/// Exact scanner-owned coverage-gap counters at one point in time.
540///
541/// Taking a saturating delta around a scan gives autoroute and other embedding
542/// surfaces a typed completeness receipt without resetting process-wide state.
543#[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/// Receipt proving a scanner coverage gap passed through the typed recorder.
588#[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
607// Global static dogfood capability flag for fast opt-in checking (KH-120)
608static 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
615/// Enable dogfood event capture for the current process. Idempotent.
616pub 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
628/// Record one example/placeholder suppression. The default path is only the
629/// per-scan atomic counter; hash/lock/redaction work is reserved for opt-in
630/// `--dogfood` event capture.
631pub 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    // KH-120: Wrap dogfood logging events behind static capability flags to eliminate overhead during silent scans.
677    if !is_dogfood_enabled() {
678        return;
679    }
680
681    let credential_hash = keyhog_core::hex_encode(&keyhog_core::sha256_hash(credential));
682    // One EVENT per credential across all stages (KH-GAP-091): if a later
683    // shape gate already recorded this same credential, or vice-versa, don't emit
684    // a duplicate. First stage to reach it wins.
685    if !mark_suppression_event_emitted(
686        emitted_suppression_events,
687        detail_events_dropped,
688        &credential_hash,
689    ) {
690        return;
691    }
692
693    // KH-disc: use the single canonical redaction policy (`keyhog_core::redact`)
694    // so dogfood output matches finding output - the bespoke 6-char-prefix
695    // helper leaked up to 6 of 8 bytes of short credentials.
696    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
709/// Insert `credential_hash` into the shared emitted-event set, returning `true`
710/// only the FIRST time a given credential VALUE is seen this scan. Both
711/// suppression recorders gate their `events.push` on this so the `--dogfood`
712/// trace carries one event per logical suppression rather than one per pipeline
713/// stage. The key is the credential hash ALONE, not `path\0hash`: because one
714/// logical drop of a credential can be recorded by several stages with
715/// INCONSISTENT path context (an early gate knows the file; a later
716/// entropy/fallback stage records `path=None`); keying on path would let those
717/// re-emit as duplicate events for the same logical suppression (KH-GAP-091).
718/// The shared detail budget bounds both this set and the event vector. Once it
719/// is exhausted, the exact dropped-detail counter remains operator-visible.
720fn 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            // LAW10: poisoned diagnostic dedup increments the surfaced omitted-detail counter; findings and exact aggregates remain intact.
739            record_dropped_detail(detail_events_dropped);
740            false // LAW10: poisoned diagnostic dedup is surfaced as one omitted detail; finding and exact aggregate counters are unchanged.
741        }
742    }
743}
744
745/// Record one SHAPE / heuristic suppression (UUID, bare-hex, base64 blob,
746/// repetitive run, …) for the `--dogfood` trace. Unlike
747/// [`record_example_suppression`] this is on the HOT suppression path (every
748/// candidate that hits a shape gate), so it is **zero-cost when dogfood is
749/// off**: the `is_dogfood_enabled()` atomic load short-circuits before any
750/// hashing / locking. It also does NOT bump the example-suppression counter -
751/// the reporter's "N example keys suppressed" summary stays example-only; shape
752/// drops are a `--dogfood`-only diagnostic. Dedup reuses the shared seen-set
753/// (keyed with a `shape\0` prefix so it can't collide with example keys).
754pub(crate) fn record_shape_suppression(path: Option<&str>, credential: &str, reason: &'static str) {
755    // Cheap atomic first - the common (no-dogfood) scan pays nothing beyond this.
756    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/// Record a static-recovery rejection in the dogfood trace. Deduplication keeps
782/// repeated references to the same rejected expression from producing noise.
783#[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/// Record successfully recovered static expressions in the bounded status.
832#[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); // LAW10: usize is at most 64 bits on every supported target, so this fallback is unreachable; saturation stays conservative if that target contract ever expands.
838    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            // LAW10: poisoned diagnostic dedup increments the surfaced omitted-detail counter; findings and exact aggregates remain intact.
887            record_dropped_detail(detail_events_dropped);
888            false // LAW10: poisoned diagnostic dedup is surfaced as one omitted detail; scan findings and exact rejection counters are unchanged.
889        }
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    // One EVENT per credential across ALL stages (KH-GAP-091): a credential
903    // the example-token gate already recorded (e.g. `AKIA…EXAMPLE`, which is also
904    // a weak-anchor shape) must not emit a second shape event for the same
905    // logical drop. The shared emitted-set also collapses the same shape gate
906    // firing twice for one credential, so this fully replaces the old
907    // reason-keyed dedup.
908    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
927/// Count of example/placeholder credentials suppressed during this scan.
928pub fn example_suppression_count() -> usize {
929    cell().example_suppressions.load(Ordering::Relaxed)
930}
931
932/// Zero the suppression counter without disturbing the dogfood
933/// enable-flag or any in-flight events. Used by the daemon between
934/// scan requests so per-request counts don't accumulate across
935/// clients - the count we ship over the wire belongs to one scan.
936#[cfg(test)]
937pub(crate) fn reset_example_suppression_count() {
938    cell().example_suppressions.store(0, Ordering::Relaxed);
939}
940
941/// Add `n` to the suppression counter without recording an event.
942/// Used by the daemon client to merge a daemon-side count into the
943/// CLI's own counter so the reporter's empty-findings summary fires
944/// correctly across the IPC boundary.
945pub fn add_example_suppressions(n: usize) {
946    cell().example_suppressions.fetch_add(n, Ordering::Relaxed);
947}
948
949/// Record that a file matched a structured-format heuristic but failed to parse,
950/// so its structured decode-through was not applied (see
951/// [`struct@STRUCTURED_PARSE_FAILURES`]). Always counts (not dogfood-gated): this
952/// is a recall-coverage fact the reporter surfaces unconditionally, like the
953/// walker skip counters.
954pub(crate) fn record_structured_parse_failure() {
955    let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::StructuredParseFailure);
956}
957
958/// Count of files that matched a structured format but failed to parse this scan.
959pub fn structured_parse_failure_count() -> usize {
960    STRUCTURED_PARSE_FAILURES.load(Ordering::Relaxed)
961}
962
963/// Record that a well-formed structured decode-through file (k8s Secret /
964/// docker-compose / tfstate / Jupyter notebook) exceeded
965/// `MAX_STRUCTURED_PARSE_BYTES`, so its base64 `data:` decode-through was
966/// skipped. Always counts: like a parse failure this is a recall-coverage fact
967/// the reporter surfaces unconditionally (Law 10), not a silent `return None`.
968pub(crate) fn record_structured_oversize_skip() {
969    let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::StructuredOversizeSkip);
970}
971
972/// Count of decode-through structured files skipped this scan for exceeding the
973/// structured-parse size cap.
974pub fn structured_oversize_skip_count() -> usize {
975    STRUCTURED_OVERSIZE_SKIPS.load(Ordering::Relaxed)
976}
977
978/// Record that recursive decode-through stopped before exhausting all available
979/// decoder output because a safety budget/cap fired.
980pub(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/// Count of decode roots truncated by safety budgets/caps this scan.
987#[cfg(not(test))]
988pub fn decode_truncation_count() -> usize {
989    DECODE_TRUNCATIONS.load(Ordering::Relaxed)
990}
991
992/// Count of decode roots truncated by safety budgets/caps on the current test
993/// thread. Production still records the global counter; tests read this local
994/// view so parallel decode-budget probes cannot pollute exact assertions.
995#[cfg(test)]
996pub fn decode_truncation_count() -> usize {
997    THREAD_DECODE_TRUNCATIONS.with(|count| count.get())
998}
999
1000/// Record that compiled pattern-index side data referenced an out-of-range
1001/// pattern and the affected expansion/admission edge had to be skipped.
1002pub(crate) fn record_invalid_pattern_index_skip() {
1003    let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::InvalidPatternIndexSkip);
1004}
1005
1006/// Count of compiled-pattern expansion/admission edges skipped by invalid
1007/// pattern indices this scan.
1008pub fn invalid_pattern_index_skip_count() -> usize {
1009    INVALID_PATTERN_INDEX_SKIPS.load(Ordering::Relaxed)
1010}
1011
1012/// Record that boundary reassembly was skipped because caller-provided chunk
1013/// and result slices no longer had the same cardinality.
1014pub(crate) fn record_boundary_result_cardinality_mismatch() {
1015    let _receipt =
1016        record_scanner_coverage_gap(ScannerCoverageGapEvent::BoundaryResultCardinalityMismatch);
1017}
1018
1019/// Count of boundary-reassembly passes skipped by chunk/result cardinality
1020/// mismatch this scan.
1021pub fn boundary_result_cardinality_mismatch_count() -> usize {
1022    BOUNDARY_RESULT_CARDINALITY_MISMATCHES.load(Ordering::Relaxed)
1023}
1024
1025/// Record that source line attribution fell back because a synthetic multiline
1026/// mapping could not find its line in the original line-offset table.
1027#[cfg(feature = "multiline")]
1028pub(crate) fn record_line_offset_mapping_mismatch() {
1029    let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::LineOffsetMappingMismatch);
1030}
1031
1032/// Record that a configured deadline stopped a chunk before full coverage.
1033pub(crate) fn record_chunk_deadline_abort() {
1034    let _receipt = record_scanner_coverage_gap(ScannerCoverageGapEvent::ChunkDeadlineAbort);
1035}
1036
1037/// Count of chunks that stopped before full coverage because their deadline elapsed.
1038pub fn chunk_deadline_abort_count() -> usize {
1039    CHUNK_DEADLINE_ABORTS.load(Ordering::Relaxed)
1040}
1041
1042/// Count of synthetic multiline/structured mapping attribution mismatches this
1043/// scan.
1044pub fn line_offset_mapping_mismatch_count() -> usize {
1045    LINE_OFFSET_MAPPING_MISMATCHES.load(Ordering::Relaxed)
1046}
1047
1048/// Append events into the per-process buffer without going through the
1049/// `record_example_suppression` path (no counter bump, no dogfood
1050/// enable-check). Used by the daemon client to replay events captured
1051/// on the daemon side, so `--dogfood` output works in daemon mode.
1052pub fn append_events<I: IntoIterator<Item = DogfoodEvent>>(events: I) {
1053    append_event_details(events, true);
1054}
1055
1056/// Append detail events transported with exact daemon aggregates.
1057///
1058/// Unlike [`append_events`], this does not infer static-recovery counts from
1059/// the bounded detail list. Call [`merge_daemon_aggregates`] once for the same
1060/// response so retained details and exact totals cannot be double-counted.
1061pub 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
1087/// Merge exact dogfood aggregates returned by a compatible daemon scan.
1088///
1089/// Detail events are transported separately through [`append_daemon_events`]. This
1090/// method validates every typed rejection reason and the disposition conservation
1091/// invariant before mutating process state, so a response from an incompatible
1092/// daemon fails instead of producing a plausible but incomplete trace.
1093pub 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); // LAW10: wire counts wider than this host can represent remain surfaced at the largest representable count; scan findings are unchanged.
1147    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
1163/// Exact per-reason static-recovery rejection counts for the current process
1164/// scan. Detail-event deduplication and retention limits never change these
1165/// aggregates.
1166pub fn static_recovery_rejection_counts() -> BTreeMap<String, u64> {
1167    cell().static_recovery.snapshot()
1168}
1169
1170/// Aggregate supported, unsupported, and erroneous static-recovery counts for
1171/// the current process scan.
1172pub fn static_recovery_status() -> StaticRecoveryStatus {
1173    cell().static_recovery.status()
1174}
1175
1176/// Number of dogfood detail events omitted after the bounded trace filled.
1177pub fn dogfood_detail_events_dropped() -> usize {
1178    cell().detail_events_dropped.load(Ordering::Relaxed)
1179}
1180
1181/// Drain and return all captured dogfood events. Returns empty when
1182/// `enable_dogfood()` was never called.
1183pub 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    // The drained batch is one complete trace; the next scan must be able to emit
1193    // its own events for the same credentials, so clear the per-credential
1194    // emitted-event dedup alongside the drain.
1195    recover_telemetry_lock(emitted_suppression_events).clear();
1196    std::mem::take(&mut *recover_telemetry_lock(events))
1197}
1198
1199// Telemetry recording helpers (KH-116)
1200pub(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
1224/// Reset process-global telemetry that is scoped to one scan.
1225///
1226/// Long-lived callers (CLI library use, daemon-style harnesses, and integration
1227/// tests) must not let a previous scan's suppression count, dogfood flag, or
1228/// coverage-gap counters change the next scan's report. Scoped daemon telemetry
1229/// (`with_scan_telemetry`) remains isolated by its caller-owned handle.
1230pub 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    /// Reset all telemetry state. Test-only facade for integration tests.
1260    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            // LAW10: this cfg(test) helper has no production runtime effect; it joins an expected panic to poison a disposable scoped buffer.
1268            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}