# Audit-log exporters (Datadog / Sentry / Honeycomb / Loki / …)
captchaforge ships three built-in `SolverTelemetry` sinks:
| `NoopTelemetry` | drops every event | default; no observability |
| `JsonTelemetry` | `tracing::event!` at target `captchaforge.solve` | when you have a `tracing-subscriber` JSON layer pointed at your log pipeline |
| `MetricsTelemetry` | Prometheus exposition + JSON snapshot | when you want in-process counters / histograms; served by `captchaforge serve` at `/metrics` |
For ANY other backend (Datadog / Sentry / Honeycomb / OpenTelemetry /
Splunk / Loki / your in-house store) the integration pattern is:
1. Implement `SolverTelemetry` against the backend's SDK.
2. Compose it with the built-ins via `FanoutTelemetry`.
The library deliberately does NOT vendor every backend's SDK —
those are large, churn fast, and would inflate captchaforge's
dependency graph. Each operator wires the one they actually use.
## Pattern: fan out to multiple sinks
```rust
use std::sync::Arc;
use captchaforge::telemetry::{FanoutTelemetry, JsonTelemetry, MetricsTelemetry, SolverTelemetry};
let metrics = Arc::new(MetricsTelemetry::new());
let fanout: Arc<dyn SolverTelemetry> = Arc::new(
FanoutTelemetry::new()
.with_sink(metrics.clone())
.with_sink(Arc::new(JsonTelemetry))
.with_sink(Arc::new(YourCustomExporter::new("https://api.datadoghq.com")))
);
let chain = captchaforge::solver::CaptchaSolverChain::default_chain()
.with_telemetry(fanout);
```
## Recipe: Datadog
```rust
use captchaforge::telemetry::{SolveEvent, SolverTelemetry};
pub struct DatadogExporter {
client: reqwest::blocking::Client,
api_key: String,
}
impl SolverTelemetry for DatadogExporter {
fn record(&self, evt: &SolveEvent<'_>) {
let payload = serde_json::json!({
"series": [{
"metric": "captchaforge.solve.attempts",
"type": "count",
"points": [[chrono::Utc::now().timestamp(), 1]],
"tags": [
format!("solver:{}", evt.solver),
format!("domain:{}", evt.domain),
format!("outcome:{:?}", evt.outcome),
],
}]
});
let _ = self.client
.post("https://api.datadoghq.com/api/v1/series")
.header("DD-API-KEY", &self.api_key)
.json(&payload)
.send();
}
}
```
> Always wrap network exporters in a non-blocking buffer
> (`tokio::sync::mpsc` + background flush) — `record()` runs in the
> solver hot path, and a blocking HTTP call here adds the network
> round-trip to every solve's latency.
## Recipe: Sentry breadcrumbs
```rust
impl SolverTelemetry for SentryBreadcrumbExporter {
fn record(&self, evt: &SolveEvent<'_>) {
sentry::add_breadcrumb(sentry::Breadcrumb {
ty: "captchaforge.solve".into(),
category: Some(evt.solver.to_string()),
level: match evt.outcome {
captchaforge::telemetry::SolveOutcome::Success => sentry::Level::Info,
captchaforge::telemetry::SolveOutcome::Failure => sentry::Level::Warning,
captchaforge::telemetry::SolveOutcome::Error => sentry::Level::Error,
captchaforge::telemetry::SolveOutcome::Timeout => sentry::Level::Warning,
},
message: Some(format!("captchaforge {:?} -> {:?}", evt.kind, evt.outcome)),
..Default::default()
});
}
}
```
## Recipe: Honeycomb / OpenTelemetry
Most operators wire OTLP via `tracing-subscriber` → `opentelemetry`
crate stack, which means `JsonTelemetry` already covers it — the
events emitted by `tracing::event!(target: "captchaforge.solve", ...)`
flow straight into the OTLP exporter when the global subscriber
is configured.
## Hot-path advice
- Buffer + flush in a background task. Never block the solve path.
- Sample for high-volume vendors (e.g. 10% of `success` events) if
ingestion cost matters.
- Use `MetricsTelemetry` for aggregates and your exporter for
per-event detail — most dashboards only need the aggregates.