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 scope: keyhog runs one scan per process, so a
14//! process-global `OnceLock<Telemetry>` is the lightest container that
15//! doesn't drag every engine boundary into accepting a `&Telemetry`
16//! argument. Tests reset state via `reset()`.
17
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::borrow::Cow;
21use std::collections::HashSet;
22use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
23use std::sync::{Mutex, OnceLock};
24
25/// A single dogfood event. Variants are intentionally narrow - anything
26/// scanner-internal that would help a user understand a missed or
27/// suppressed credential should go here.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "kind", rename_all = "snake_case")]
30pub enum DogfoodEvent {
31    /// A credential was matched but suppressed as a known example /
32    /// placeholder (e.g. ends with `EXAMPLE`, is a sequential
33    /// placeholder, contains a `DUMMY`/`FAKE`/`MOCK` token).
34    ///
35    /// `reason` is `Cow<'static, str>` so callers can pass a literal
36    /// without allocating (`Cow::Borrowed("ends_with_EXAMPLE")`),
37    /// while the daemon-protocol deserialize path can also produce
38    /// owned values from over-the-wire JSON.
39    ExampleSuppressed {
40        detector: String,
41        path: Option<String>,
42        credential_redacted: String,
43        reason: Cow<'static, str>,
44    },
45}
46
47#[derive(Default)]
48struct Telemetry {
49    dogfood_enabled: AtomicBool,
50    example_suppressions: AtomicUsize,
51    events: Mutex<Vec<DogfoodEvent>>,
52    seen_example_suppressions: Mutex<HashSet<String>>,
53}
54
55// Global lock-free telemetry counters (KH-116)
56static FILES_SCANNED: AtomicUsize = AtomicUsize::new(0);
57static BYTES_SCANNED: AtomicUsize = AtomicUsize::new(0);
58static SKIPPED_FILES: AtomicUsize = AtomicUsize::new(0);
59static TOTAL_MATCHES: AtomicUsize = AtomicUsize::new(0);
60static GPU_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
61
62// Global static dogfood capability flag for fast opt-in checking (KH-120)
63static DOGFOOD_ENABLED: AtomicBool = AtomicBool::new(false);
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
66pub struct TelemetrySnapshot {
67    pub files_scanned: usize,
68    pub bytes_scanned: usize,
69    pub skipped_files: usize,
70    pub total_matches: usize,
71    pub gpu_dispatches: usize,
72    pub example_suppressions: usize,
73}
74
75fn cell() -> &'static Telemetry {
76    static CELL: OnceLock<Telemetry> = OnceLock::new();
77    CELL.get_or_init(Telemetry::default)
78}
79
80/// Enable dogfood event capture for the current process. Idempotent.
81pub fn enable_dogfood() {
82    DOGFOOD_ENABLED.store(true, Ordering::Relaxed);
83    cell().dogfood_enabled.store(true, Ordering::Relaxed);
84}
85
86pub fn is_dogfood_enabled() -> bool {
87    DOGFOOD_ENABLED.load(Ordering::Relaxed)
88}
89
90/// Record one example/placeholder suppression. Always increments the
91/// counter; only appends a full event record when `--dogfood` is on.
92pub fn record_example_suppression(
93    detector: &str,
94    path: Option<&str>,
95    credential: &str,
96    reason: &'static str,
97) {
98    let t = cell();
99    let credential_hash = {
100        let mut hasher = Sha256::new();
101        hasher.update(credential.as_bytes());
102        let digest = hasher.finalize();
103        let mut bytes = [0u8; 32];
104        bytes.copy_from_slice(&digest);
105        keyhog_core::hex_encode(&bytes)
106    };
107    let key = format!(
108        "{}\0{}\0{}\0{}",
109        detector,
110        path.unwrap_or(""),
111        credential_hash,
112        reason
113    );
114    if let Ok(mut seen) = t.seen_example_suppressions.lock() {
115        if !seen.insert(key) {
116            return;
117        }
118    }
119
120    t.example_suppressions.fetch_add(1, Ordering::Relaxed);
121
122    // KH-120: Wrap dogfood logging events behind static capability flags to eliminate overhead during silent scans.
123    if !is_dogfood_enabled() {
124        return;
125    }
126
127    // KH-disc: use the single canonical redaction policy (`keyhog_core::redact`)
128    // so dogfood output matches finding output - the bespoke 6-char-prefix
129    // helper leaked up to 6 of 8 bytes of short credentials.
130    let redacted = keyhog_core::redact(credential).into_owned();
131    if let Ok(mut events) = t.events.lock() {
132        events.push(DogfoodEvent::ExampleSuppressed {
133            detector: detector.to_string(),
134            path: path.map(str::to_string),
135            credential_redacted: redacted,
136            reason: Cow::Borrowed(reason),
137        });
138    }
139}
140
141/// Count of example/placeholder credentials suppressed during this scan.
142pub fn example_suppression_count() -> usize {
143    cell().example_suppressions.load(Ordering::Relaxed)
144}
145
146/// Zero the suppression counter without disturbing the dogfood
147/// enable-flag or any in-flight events. Used by the daemon between
148/// scan requests so per-request counts don't accumulate across
149/// clients - the count we ship over the wire belongs to one scan.
150pub fn reset_example_suppression_count() {
151    cell().example_suppressions.store(0, Ordering::Relaxed);
152}
153
154/// Add `n` to the suppression counter without recording an event.
155/// Used by the daemon client to merge a daemon-side count into the
156/// CLI's own counter so the reporter's empty-findings summary fires
157/// correctly across the IPC boundary.
158pub fn add_example_suppressions(n: usize) {
159    cell().example_suppressions.fetch_add(n, Ordering::Relaxed);
160}
161
162/// Append events into the per-process buffer without going through the
163/// `record_example_suppression` path (no counter bump, no dogfood
164/// enable-check). Used by the daemon client to replay events captured
165/// on the daemon side, so `--dogfood` output works in daemon mode.
166pub fn append_events<I: IntoIterator<Item = DogfoodEvent>>(events: I) {
167    let t = cell();
168    if let Ok(mut buf) = t.events.lock() {
169        buf.extend(events);
170    }
171}
172
173/// Drain and return all captured dogfood events. Returns empty when
174/// `enable_dogfood()` was never called.
175pub fn drain_events() -> Vec<DogfoodEvent> {
176    let t = cell();
177    if let Ok(mut events) = t.events.lock() {
178        std::mem::take(&mut *events)
179    } else {
180        Vec::new()
181    }
182}
183
184// Telemetry recording helpers (KH-116)
185pub fn record_file_scanned(bytes: usize) {
186    FILES_SCANNED.fetch_add(1, Ordering::Relaxed);
187    BYTES_SCANNED.fetch_add(bytes, Ordering::Relaxed);
188}
189
190pub fn record_file_skipped() {
191    SKIPPED_FILES.fetch_add(1, Ordering::Relaxed);
192}
193
194pub fn record_match_found() {
195    TOTAL_MATCHES.fetch_add(1, Ordering::Relaxed);
196}
197
198pub fn record_gpu_dispatch() {
199    GPU_DISPATCHES.fetch_add(1, Ordering::Relaxed);
200}
201
202// KH-122: Expose telemetry counters through static memory structures to avoid allocation during sweeps
203pub fn get_telemetry_snapshot() -> TelemetrySnapshot {
204    TelemetrySnapshot {
205        files_scanned: FILES_SCANNED.load(Ordering::Relaxed),
206        bytes_scanned: BYTES_SCANNED.load(Ordering::Relaxed),
207        skipped_files: SKIPPED_FILES.load(Ordering::Relaxed),
208        total_matches: TOTAL_MATCHES.load(Ordering::Relaxed),
209        gpu_dispatches: GPU_DISPATCHES.load(Ordering::Relaxed),
210        example_suppressions: example_suppression_count(),
211    }
212}
213
214/// Reset all state. For tests only.
215#[doc(hidden)]
216pub fn reset() {
217    let t = cell();
218    DOGFOOD_ENABLED.store(false, Ordering::Relaxed);
219    t.dogfood_enabled.store(false, Ordering::Relaxed);
220    t.example_suppressions.store(0, Ordering::Relaxed);
221    FILES_SCANNED.store(0, Ordering::Relaxed);
222    BYTES_SCANNED.store(0, Ordering::Relaxed);
223    SKIPPED_FILES.store(0, Ordering::Relaxed);
224    TOTAL_MATCHES.store(0, Ordering::Relaxed);
225    GPU_DISPATCHES.store(0, Ordering::Relaxed);
226    if let Ok(mut events) = t.events.lock() {
227        events.clear();
228    }
229    if let Ok(mut seen) = t.seen_example_suppressions.lock() {
230        seen.clear();
231    }
232}