captchaforge 0.2.6

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Solver telemetry — structured event hooks for external analytics.
//!
//! The solver chain already logs to `tracing` and updates the
//! [`PatternStore`] for routing decisions, but neither is suitable for
//! durable metrics: tracing output is operator-facing text, and the
//! pattern store is a learned-routing cache that prunes old entries.
//! Production users want to know per-solver success rates, p50/p95
//! solve times, fallback rates per domain, and time-series trends.
//!
//! [`SolverTelemetry`] is a tiny trait the chain calls at every
//! solver-attempt outcome. The default implementation is a no-op, so
//! adding telemetry is opt-in: pass an `Arc<dyn SolverTelemetry>` via
//! [`CaptchaSolverChain::with_telemetry`] and your impl receives every
//! event.
//!
//! Implementations are free to: write to Prometheus, push to a
//! statsd/OTel collector, batch into SQLite, post to Sentry, mirror
//! into the pattern store, or ignore the events entirely. The trait
//! is intentionally allocation-light — events borrow strings where
//! possible — so a high-throughput impl can avoid heap churn.
use crate::captcha_detect::DetectedCaptcha;
use crate::solver::{CaptchaType, SolveMethod};

/// What happened during a single solver attempt.
///
/// `Success`/`Failure` correspond to the solver returning
/// `Ok(result)` with `result.success = true`/`false`. `Error` means
/// the solver returned `Err`. `Timeout` means the chain's per-solver
/// deadline elapsed before the solver returned anything.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SolveOutcome {
    Success,
    Failure,
    Error,
    Timeout,
}

/// One solver attempt's telemetry event. Borrowed where possible so
/// hot-path implementations can avoid allocation.
///
/// `confidence` is only present on `Success` outcomes — the other
/// outcomes have no meaningful confidence value.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SolveEvent<'a> {
    /// Stable solver identifier (e.g. `"VlmCaptchaSolver"`).
    pub solver: &'static str,
    /// Captcha type the chain mapped from the detection.
    pub captcha_type: &'a CaptchaType,
    /// Original detected captcha kind from the detector.
    pub kind: &'a DetectedCaptcha,
    /// Domain the solve targeted, parsed from `CaptchaInfo.page_url`.
    pub domain: &'a str,
    /// What happened.
    pub outcome: SolveOutcome,
    /// Wall-clock duration of the attempt, in milliseconds.
    pub time_ms: u64,
    /// Solve confidence in 0.0–1.0; `None` for non-Success outcomes.
    pub confidence: Option<f32>,
    /// Which solver strategy the attempt used.
    pub method: &'a SolveMethod,
}

/// Hook the solver chain calls at every attempt outcome.
///
/// Implementations MUST be cheap — the chain calls `record` from the
/// hot solve path. Slow implementations (network writes, blocking
/// I/O) should buffer or spawn so they don't extend solve latency.
pub trait SolverTelemetry: Send + Sync {
    /// Called once per solver attempt. The reference is short-lived;
    /// implementations that need to persist must clone.
    fn record(&self, evt: &SolveEvent<'_>);
}

/// Default zero-overhead implementation — drops every event.
///
/// Used as the chain's telemetry until a real implementation is
/// installed via [`crate::solver::CaptchaSolverChain::with_telemetry`].
pub struct NoopTelemetry;

impl SolverTelemetry for NoopTelemetry {
    fn record(&self, _evt: &SolveEvent<'_>) {}
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    /// In-memory telemetry sink for assertion in tests / examples.
    struct CapturingTelemetry {
        events: Mutex<Vec<(SolveOutcome, String, u64)>>,
    }

    impl SolverTelemetry for CapturingTelemetry {
        fn record(&self, evt: &SolveEvent<'_>) {
            self.events
                .lock()
                .unwrap()
                .push((evt.outcome, evt.domain.to_owned(), evt.time_ms));
        }
    }

    #[test]
    fn noop_telemetry_drops_events() {
        let t = NoopTelemetry;
        let kind = DetectedCaptcha::Turnstile;
        let captcha_type = CaptchaType::CloudflareTurnstile;
        let method = SolveMethod::BehavioralBypass;
        let evt = SolveEvent {
            solver: "TestSolver",
            captcha_type: &captcha_type,
            kind: &kind,
            domain: "example.com",
            outcome: SolveOutcome::Success,
            time_ms: 1234,
            confidence: Some(0.95),
            method: &method,
        };
        t.record(&evt);
        // No assertion needed; we're verifying it doesn't panic.
    }

    #[test]
    fn capturing_telemetry_round_trips_event_fields() {
        let cap = Arc::new(CapturingTelemetry {
            events: Mutex::new(Vec::new()),
        });
        let kind = DetectedCaptcha::HCaptcha;
        let captcha_type = CaptchaType::HCaptcha;
        let method = SolveMethod::VisionLLM;

        for (outcome, ms) in [
            (SolveOutcome::Success, 1000_u64),
            (SolveOutcome::Failure, 2000),
            (SolveOutcome::Error, 50),
            (SolveOutcome::Timeout, 30000),
        ] {
            let evt = SolveEvent {
                solver: "TestSolver",
                captcha_type: &captcha_type,
                kind: &kind,
                domain: "x.test",
                outcome,
                time_ms: ms,
                confidence: None,
                method: &method,
            };
            cap.record(&evt);
        }
        let events = cap.events.lock().unwrap();
        assert_eq!(events.len(), 4);
        assert_eq!(events[0].0, SolveOutcome::Success);
        assert_eq!(events[3].2, 30000);
    }
}