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 FanoutTelemetry {
sinks: Vec<std::sync::Arc<dyn SolverTelemetry>>,
}
impl FanoutTelemetry {
pub fn new() -> Self {
Self { sinks: Vec::new() }
}
#[must_use = "FanoutTelemetry::with_sink returns Self; assign or chain it"]
pub fn with_sink(mut self, sink: std::sync::Arc<dyn SolverTelemetry>) -> Self {
self.sinks.push(sink);
self
}
pub fn len(&self) -> usize {
self.sinks.len()
}
pub fn is_empty(&self) -> bool {
self.sinks.is_empty()
}
}
impl Default for FanoutTelemetry {
fn default() -> Self {
Self::new()
}
}
impl SolverTelemetry for FanoutTelemetry {
fn record(&self, evt: &SolveEvent<'_>) {
for sink in &self.sinks {
sink.record(evt);
}
}
}
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 = %safe_kind_label(evt.kind),
method = ?evt.method,
time_ms = evt.time_ms,
confidence = evt.confidence.unwrap_or(f32::NAN),
);
}
}
fn safe_kind_label(kind: &crate::detect::DetectedCaptcha) -> String {
use crate::detect::DetectedCaptcha;
match kind {
DetectedCaptcha::Custom(name) => {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(name.as_bytes());
let d = h.finalize();
format!("custom:{:02x}{:02x}{:02x}{:02x}", d[0], d[1], d[2], d[3])
}
other => format!("{other:?}"),
}
}
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)]
#[path = "telemetry/tests.rs"]
mod tests;