use super::*;
use std::sync::{Arc, Mutex};
struct CapturingTelemetry {
events: Mutex<Vec<(SolveOutcome, String, u64)>>,
}
impl CapturingTelemetry {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
}
}
}
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);
}
#[test]
fn fanout_telemetry_dispatches_to_every_sink_in_order() {
use std::sync::Arc;
let a = Arc::new(CapturingTelemetry::new());
let b = Arc::new(CapturingTelemetry::new());
let c = Arc::new(CapturingTelemetry::new());
let fanout = FanoutTelemetry::new()
.with_sink(a.clone() as Arc<dyn SolverTelemetry>)
.with_sink(b.clone() as Arc<dyn SolverTelemetry>)
.with_sink(c.clone() as Arc<dyn SolverTelemetry>);
assert_eq!(fanout.len(), 3);
assert!(!fanout.is_empty());
let kind = crate::detect::DetectedCaptcha::Turnstile;
let captcha_type = crate::solver::CaptchaType::CloudflareTurnstile;
let method = crate::solver::SolveMethod::BehavioralBypass;
let evt = SolveEvent::new(
"S",
&captcha_type,
&kind,
"ex.com",
SolveOutcome::Success,
42,
Some(0.91),
&method,
);
fanout.record(&evt);
assert_eq!(a.events.lock().unwrap().len(), 1);
assert_eq!(b.events.lock().unwrap().len(), 1);
assert_eq!(c.events.lock().unwrap().len(), 1);
}
#[test]
fn fanout_telemetry_empty_default_is_noop() {
let fanout = FanoutTelemetry::default();
assert!(fanout.is_empty());
let kind = crate::detect::DetectedCaptcha::Turnstile;
let captcha_type = crate::solver::CaptchaType::CloudflareTurnstile;
let method = crate::solver::SolveMethod::BehavioralBypass;
let evt = SolveEvent::new(
"S",
&captcha_type,
&kind,
"ex.com",
SolveOutcome::Success,
0,
None,
&method,
);
fanout.record(&evt);
}