use crate::captcha_detect::DetectedCaptcha;
use crate::solver::{CaptchaType, SolveMethod};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SolveOutcome {
Success,
Failure,
Error,
Timeout,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SolveEvent<'a> {
pub solver: &'static str,
pub captcha_type: &'a CaptchaType,
pub kind: &'a DetectedCaptcha,
pub domain: &'a str,
pub outcome: SolveOutcome,
pub time_ms: u64,
pub confidence: Option<f32>,
pub method: &'a SolveMethod,
}
impl<'a> SolveEvent<'a> {
#[allow(clippy::too_many_arguments)]
pub fn new(
solver: &'static str,
captcha_type: &'a CaptchaType,
kind: &'a DetectedCaptcha,
domain: &'a str,
outcome: SolveOutcome,
time_ms: u64,
confidence: Option<f32>,
method: &'a SolveMethod,
) -> Self {
Self {
solver,
captcha_type,
kind,
domain,
outcome,
time_ms,
confidence,
method,
}
}
}
pub trait SolverTelemetry: Send + Sync {
fn record(&self, evt: &SolveEvent<'_>);
}
pub struct NoopTelemetry;
impl SolverTelemetry for NoopTelemetry {
fn record(&self, _evt: &SolveEvent<'_>) {}
}
pub struct JsonTelemetry;
impl SolverTelemetry for JsonTelemetry {
fn record(&self, evt: &SolveEvent<'_>) {
tracing::event!(
target: "captchaforge.solve",
tracing::Level::INFO,
solver = %evt.solver,
outcome = %outcome_label(evt.outcome),
domain = %evt.domain,
captcha_type = ?evt.captcha_type,
kind = ?evt.kind,
method = ?evt.method,
time_ms = evt.time_ms,
confidence = evt.confidence.unwrap_or(f32::NAN),
);
}
}
fn outcome_label(o: SolveOutcome) -> &'static str {
match o {
SolveOutcome::Success => "success",
SolveOutcome::Failure => "failure",
SolveOutcome::Error => "error",
SolveOutcome::Timeout => "timeout",
}
}
pub struct MetricsTelemetry {
inner: std::sync::Mutex<MetricsState>,
}
#[derive(Default)]
struct MetricsState {
counts: std::collections::HashMap<(String, &'static str), u64>,
histograms: std::collections::HashMap<(String, &'static str), [u64; 9]>,
}
impl MetricsTelemetry {
pub fn new() -> Self {
Self {
inner: std::sync::Mutex::new(MetricsState::default()),
}
}
pub fn snapshot(&self) -> MetricsSnapshot {
let inner = self.inner.lock().expect("metrics mutex poisoned");
let counts = inner
.counts
.iter()
.map(|((solver, outcome), n)| MetricCount {
solver: solver.clone(),
outcome,
count: *n,
})
.collect();
let histograms = inner
.histograms
.iter()
.map(|((solver, outcome), buckets)| MetricHistogram {
solver: solver.clone(),
outcome,
bucket_upper_bounds_ms: HISTOGRAM_BUCKETS_MS.to_vec(),
bucket_counts: buckets.to_vec(),
})
.collect();
MetricsSnapshot { counts, histograms }
}
}
impl Default for MetricsTelemetry {
fn default() -> Self {
Self::new()
}
}
impl SolverTelemetry for MetricsTelemetry {
fn record(&self, evt: &SolveEvent<'_>) {
let mut inner = self.inner.lock().expect("metrics mutex poisoned");
let key = (evt.solver.to_string(), outcome_label(evt.outcome));
*inner.counts.entry(key.clone()).or_insert(0) += 1;
let buckets = inner.histograms.entry(key).or_insert([0u64; 9]);
let bucket_idx = histogram_bucket_index(evt.time_ms);
buckets[bucket_idx] += 1;
}
}
const HISTOGRAM_BUCKETS_MS: [u64; 9] = [10, 50, 100, 500, 1_000, 5_000, 10_000, 30_000, u64::MAX];
fn histogram_bucket_index(time_ms: u64) -> usize {
for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().enumerate() {
if time_ms <= *upper {
return i;
}
}
HISTOGRAM_BUCKETS_MS.len() - 1
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MetricsSnapshot {
pub counts: Vec<MetricCount>,
pub histograms: Vec<MetricHistogram>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MetricCount {
pub solver: String,
pub outcome: &'static str,
pub count: u64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MetricHistogram {
pub solver: String,
pub outcome: &'static str,
pub bucket_upper_bounds_ms: Vec<u64>,
pub bucket_counts: Vec<u64>,
}
impl MetricsSnapshot {
pub fn to_prometheus(&self) -> String {
use std::fmt::Write;
let mut out = String::with_capacity(self.counts.len() * 64 + self.histograms.len() * 256);
out.push_str("# HELP captchaforge_solve_total Total solver attempts by solver + outcome.\n");
out.push_str("# TYPE captchaforge_solve_total counter\n");
for c in &self.counts {
let _ = writeln!(
out,
r#"captchaforge_solve_total{{solver="{}",outcome="{}"}} {}"#,
escape_label(&c.solver),
c.outcome,
c.count
);
}
out.push_str(
"# HELP captchaforge_solve_duration_ms Solve duration in milliseconds.\n",
);
out.push_str("# TYPE captchaforge_solve_duration_ms histogram\n");
for h in &self.histograms {
let mut cumulative = 0u64;
for (upper, count) in h.bucket_upper_bounds_ms.iter().zip(h.bucket_counts.iter()) {
cumulative += count;
let upper_label = if *upper == u64::MAX {
"+Inf".to_string()
} else {
upper.to_string()
};
let _ = writeln!(
out,
r#"captchaforge_solve_duration_ms_bucket{{solver="{}",outcome="{}",le="{}"}} {}"#,
escape_label(&h.solver),
h.outcome,
upper_label,
cumulative
);
}
let total: u64 = h.bucket_counts.iter().sum();
let _ = writeln!(
out,
r#"captchaforge_solve_duration_ms_count{{solver="{}",outcome="{}"}} {}"#,
escape_label(&h.solver),
h.outcome,
total
);
}
out
}
}
fn escape_label(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for c in value.chars() {
match c {
'\\' => out.push_str(r"\\"),
'"' => out.push_str(r#"\""#),
'\n' => out.push_str(r"\n"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
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);
}
#[test]
fn metrics_telemetry_aggregates_per_solver_outcome_counts() {
let m = MetricsTelemetry::new();
let kind = DetectedCaptcha::Turnstile;
let captcha_type = CaptchaType::CloudflareTurnstile;
let method = SolveMethod::BehavioralBypass;
for (solver, outcome) in [
("BehavioralCaptchaSolver", SolveOutcome::Success),
("BehavioralCaptchaSolver", SolveOutcome::Success),
("BehavioralCaptchaSolver", SolveOutcome::Failure),
("VlmCaptchaSolver", SolveOutcome::Success),
] {
m.record(&SolveEvent {
solver,
captcha_type: &captcha_type,
kind: &kind,
domain: "ex.com",
outcome,
time_ms: 100,
confidence: None,
method: &method,
});
}
let snap = m.snapshot();
let beh_succ = snap
.counts
.iter()
.find(|c| c.solver == "BehavioralCaptchaSolver" && c.outcome == "success");
assert_eq!(beh_succ.map(|c| c.count), Some(2));
let beh_fail = snap
.counts
.iter()
.find(|c| c.solver == "BehavioralCaptchaSolver" && c.outcome == "failure");
assert_eq!(beh_fail.map(|c| c.count), Some(1));
}
#[test]
fn metrics_histogram_bucket_index_partitions_correctly() {
assert_eq!(histogram_bucket_index(0), 0);
assert_eq!(histogram_bucket_index(10), 0);
assert_eq!(histogram_bucket_index(11), 1);
assert_eq!(histogram_bucket_index(50), 1);
assert_eq!(histogram_bucket_index(100), 2);
assert_eq!(histogram_bucket_index(500), 3);
assert_eq!(histogram_bucket_index(1_000), 4);
assert_eq!(histogram_bucket_index(5_000), 5);
assert_eq!(histogram_bucket_index(10_000), 6);
assert_eq!(histogram_bucket_index(30_000), 7);
assert_eq!(histogram_bucket_index(31_000), 8);
assert_eq!(histogram_bucket_index(u64::MAX), 8);
}
#[test]
fn metrics_to_prometheus_renders_valid_exposition_format() {
let m = MetricsTelemetry::new();
let kind = DetectedCaptcha::Turnstile;
let captcha_type = CaptchaType::CloudflareTurnstile;
let method = SolveMethod::BehavioralBypass;
m.record(&SolveEvent {
solver: "TestSolver",
captcha_type: &captcha_type,
kind: &kind,
domain: "ex.com",
outcome: SolveOutcome::Success,
time_ms: 250,
confidence: Some(0.9),
method: &method,
});
let prom = m.snapshot().to_prometheus();
assert!(prom.contains("# HELP captchaforge_solve_total"));
assert!(prom.contains("# TYPE captchaforge_solve_total counter"));
assert!(prom.contains(r#"captchaforge_solve_total{solver="TestSolver",outcome="success"} 1"#));
assert!(prom.contains("# HELP captchaforge_solve_duration_ms"));
assert!(prom.contains("# TYPE captchaforge_solve_duration_ms histogram"));
assert!(prom.contains(r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="500"} 1"#));
assert!(prom.contains(r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="+Inf"} 1"#));
assert!(prom.contains(r#"captchaforge_solve_duration_ms_bucket{solver="TestSolver",outcome="success",le="10"} 0"#));
}
#[test]
fn metrics_to_prometheus_escapes_problematic_label_chars() {
let m = MetricsTelemetry::new();
let kind = DetectedCaptcha::Turnstile;
let captcha_type = CaptchaType::CloudflareTurnstile;
let method = SolveMethod::BehavioralBypass;
m.record(&SolveEvent {
solver: "Solver\"with\\quotes",
captcha_type: &captcha_type,
kind: &kind,
domain: "ex.com",
outcome: SolveOutcome::Success,
time_ms: 10,
confidence: None,
method: &method,
});
let prom = m.snapshot().to_prometheus();
assert!(prom.contains(r#"solver="Solver\"with\\quotes""#));
}
#[test]
fn outcome_label_returns_stable_strings() {
assert_eq!(outcome_label(SolveOutcome::Success), "success");
assert_eq!(outcome_label(SolveOutcome::Failure), "failure");
assert_eq!(outcome_label(SolveOutcome::Error), "error");
assert_eq!(outcome_label(SolveOutcome::Timeout), "timeout");
}
#[test]
fn json_telemetry_does_not_panic_on_record() {
let t = JsonTelemetry;
let kind = DetectedCaptcha::Turnstile;
let captcha_type = CaptchaType::CloudflareTurnstile;
let method = SolveMethod::BehavioralBypass;
for outcome in [
SolveOutcome::Success,
SolveOutcome::Failure,
SolveOutcome::Error,
SolveOutcome::Timeout,
] {
t.record(&SolveEvent {
solver: "TestSolver",
captcha_type: &captcha_type,
kind: &kind,
domain: "x.test",
outcome,
time_ms: 100,
confidence: None,
method: &method,
});
}
}
#[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);
}
}