use prometheus::{
Encoder, Histogram, HistogramVec, IntCounterVec, IntGauge, Registry, TextEncoder,
exponential_buckets, histogram_opts, register_histogram_vec_with_registry,
register_histogram_with_registry, register_int_counter_vec_with_registry,
register_int_gauge_with_registry,
};
use std::sync::OnceLock;
pub struct Metrics {
pub registry: Registry,
pub render_route_decision_total: IntCounterVec,
pub circuit_breaker_open_total: IntCounterVec,
pub host_preferences_promotions_total: IntCounterVec,
pub admin_preferences_reset_total: IntCounterVec,
pub user_pin_total: IntCounterVec,
pub host_preferences_size: IntGauge,
pub chrome_budget_truncated_total: IntCounterVec,
pub chrome_blocked_requests_total: IntCounterVec,
pub breaker_ignored_total: IntCounterVec,
pub cdp_pending_requests: IntGauge,
pub cdp_live_connections: IntGauge,
pub target_lifecycle_total: IntCounterVec,
pub renderer_recycle_total: IntCounterVec,
pub map_filter_dropped_total: IntCounterVec,
pub map_filter_stripped_total: IntCounterVec,
pub map_filter_preserved_total: IntCounterVec,
pub map_filter_rules_loaded: IntCounterVec,
pub chrome_connect_seconds: HistogramVec,
pub chrome_target_create_seconds: Histogram,
pub chrome_navigate_seconds: Histogram,
pub chrome_snapshot_seconds: Histogram,
pub chrome_pool_size: IntGauge,
pub chrome_pool_idle: IntGauge,
pub chrome_pool_inflight: IntGauge,
pub chrome_pool_acquire_seconds: Histogram,
pub chrome_pool_acquires_total: IntCounterVec,
pub chrome_pool_recycle_seconds: HistogramVec,
pub chrome_pool_recycle_total: IntCounterVec,
pub chrome_pool_recycle_failures_total: IntCounterVec,
pub chrome_pool_health_check_total: IntCounterVec,
pub chrome_context_lifetime_seconds: Histogram,
pub chrome_request_handshake_seconds: HistogramVec,
pub vendor_block_total: IntCounterVec,
pub antibot_escalation_total: IntCounterVec,
pub change_tracking_duration_seconds: HistogramVec,
pub change_tracking_snapshot_bytes: HistogramVec,
pub judge_calls_total: IntCounterVec,
pub judge_tokens_total: IntCounterVec,
}
static METRICS: OnceLock<Metrics> = OnceLock::new();
pub fn metrics() -> &'static Metrics {
METRICS.get_or_init(Metrics::new)
}
pub fn init() {
let _ = metrics();
}
impl Metrics {
fn new() -> Self {
let registry = Registry::new();
let render_route_decision_total = register_int_counter_vec_with_registry!(
"crw_render_route_decision_total",
"Routing decisions by chosen renderer and decision kind",
&["renderer", "decision"],
registry
)
.unwrap();
let circuit_breaker_open_total = register_int_counter_vec_with_registry!(
"crw_circuit_breaker_open_total",
"Circuit breaker transitions to Open, labeled by renderer and scope",
&["renderer", "scope"],
registry
)
.unwrap();
let host_preferences_promotions_total = register_int_counter_vec_with_registry!(
"crw_host_preferences_promotions_total",
"Host preference promotions to a heavier renderer",
&["from", "to"],
registry
)
.unwrap();
let admin_preferences_reset_total = register_int_counter_vec_with_registry!(
"crw_admin_preferences_reset_total",
"Admin resets of host preference state",
&["scope"],
registry
)
.unwrap();
let user_pin_total = register_int_counter_vec_with_registry!(
"crw_user_pin_total",
"User-pinned renderer requests",
&["renderer"],
registry
)
.unwrap();
let host_preferences_size = register_int_gauge_with_registry!(
"crw_host_preferences_size",
"Current size of the host preferences cache",
registry
)
.unwrap();
let chrome_budget_truncated_total = register_int_counter_vec_with_registry!(
"crw_chrome_budget_truncated_total",
"Chrome nav-budget truncations by snapshot outcome",
&["outcome"],
registry
)
.unwrap();
let chrome_blocked_requests_total = register_int_counter_vec_with_registry!(
"crw_chrome_blocked_requests_total",
"Chrome requests blocked by interception, labeled by reason",
&["reason"],
registry
)
.unwrap();
let breaker_ignored_total = register_int_counter_vec_with_registry!(
"crw_breaker_ignored_total",
"Renderer outcomes ignored by the circuit breaker (deadline-clamped, truncated, etc.)",
&["renderer", "reason"],
registry
)
.unwrap();
let cdp_pending_requests = register_int_gauge_with_registry!(
"crw_cdp_pending_requests",
"CDP pending request map size summed across all live connections (sampler tick)",
registry
)
.unwrap();
let cdp_live_connections = register_int_gauge_with_registry!(
"crw_cdp_live_connections",
"Number of CDP connections currently registered as live",
registry
)
.unwrap();
let target_lifecycle_total = register_int_counter_vec_with_registry!(
"crw_target_lifecycle_total",
"CDP target lifecycle events by renderer and phase (created/closed/leaked)",
&["renderer", "phase"],
registry
)
.unwrap();
let renderer_recycle_total = register_int_counter_vec_with_registry!(
"crw_renderer_recycle_total",
"Renderer recycle events by renderer and reason",
&["renderer", "reason"],
registry
)
.unwrap();
let map_filter_dropped_total = register_int_counter_vec_with_registry!(
"crw_map_filter_dropped_total",
"URLs dropped by /map filter (action-URL or parse-error pass-through)",
&["reason"],
registry
)
.unwrap();
let map_filter_stripped_total = register_int_counter_vec_with_registry!(
"crw_map_filter_stripped_total",
"Query params stripped by /map filter",
&["reason"],
registry
)
.unwrap();
let map_filter_preserved_total = register_int_counter_vec_with_registry!(
"crw_map_filter_preserved_total",
"Params/URLs preserved by /map filter rules (host override, gov TLD, always-preserve)",
&["reason"],
registry
)
.unwrap();
let map_filter_rules_loaded = register_int_counter_vec_with_registry!(
"crw_map_filter_rules_loaded",
"/map filter rules loaded at server startup",
&["kind"],
registry
)
.unwrap();
let lat_buckets = exponential_buckets(0.01, 2.0, 12).unwrap();
let chrome_connect_seconds = register_histogram_vec_with_registry!(
histogram_opts!(
"crw_chrome_connect_seconds",
"Chrome WS connect duration by outcome",
lat_buckets.clone()
),
&["outcome"],
registry
)
.unwrap();
let chrome_target_create_seconds = register_histogram_with_registry!(
histogram_opts!(
"crw_chrome_target_create_seconds",
"Chrome Target.createTarget round-trip duration",
lat_buckets.clone()
),
registry
)
.unwrap();
let chrome_navigate_seconds = register_histogram_with_registry!(
histogram_opts!(
"crw_chrome_navigate_seconds",
"Chrome navigation duration: Page.navigate send to loadEventFired",
lat_buckets.clone()
),
registry
)
.unwrap();
let chrome_snapshot_seconds = register_histogram_with_registry!(
histogram_opts!(
"crw_chrome_snapshot_seconds",
"Chrome HTML snapshot duration (Runtime.evaluate outerHTML)",
lat_buckets.clone()
),
registry
)
.unwrap();
let chrome_pool_size = register_int_gauge_with_registry!(
"crw_chrome_pool_size",
"Configured browser context pool size",
registry
)
.unwrap();
let chrome_pool_idle = register_int_gauge_with_registry!(
"crw_chrome_pool_idle",
"Idle slot count in the browser context pool (sampled)",
registry
)
.unwrap();
let chrome_pool_inflight = register_int_gauge_with_registry!(
"crw_chrome_pool_inflight",
"CheckedOut slot count in the browser context pool",
registry
)
.unwrap();
let chrome_pool_acquire_seconds = register_histogram_with_registry!(
histogram_opts!(
"crw_chrome_pool_acquire_seconds",
"Time spent in pool.acquire() — permit wait + slot bring-up",
lat_buckets.clone()
),
registry
)
.unwrap();
let chrome_pool_acquires_total = register_int_counter_vec_with_registry!(
"crw_chrome_pool_acquires_total",
"Pool acquire outcomes (hit_idle | created_new | errored | shutdown_refused)",
&["outcome"],
registry
)
.unwrap();
let chrome_pool_recycle_seconds = register_histogram_vec_with_registry!(
histogram_opts!(
"crw_chrome_pool_recycle_seconds",
"Per-phase release cost (close_target | dispose_ctx | create_ctx)",
lat_buckets.clone()
),
&["phase"],
registry
)
.unwrap();
let chrome_pool_recycle_total = register_int_counter_vec_with_registry!(
"crw_chrome_pool_recycle_total",
"Terminal recycle outcomes",
&["outcome"],
registry
)
.unwrap();
let chrome_pool_recycle_failures_total = register_int_counter_vec_with_registry!(
"crw_chrome_pool_recycle_failures_total",
"Recycle failures partitioned by failed stage",
&["stage"],
registry
)
.unwrap();
let chrome_pool_health_check_total = register_int_counter_vec_with_registry!(
"crw_chrome_pool_health_check_total",
"Pool idle-slot health probe outcomes (ok | failed)",
&["outcome"],
registry
)
.unwrap();
let chrome_context_lifetime_seconds = register_histogram_with_registry!(
histogram_opts!(
"crw_chrome_context_lifetime_seconds",
"Lifetime of each browser context, create to dispose",
lat_buckets.clone()
),
registry
)
.unwrap();
let chrome_request_handshake_seconds = register_histogram_vec_with_registry!(
histogram_opts!(
"crw_chrome_request_handshake_seconds",
"Pre-navigation overhead per Chrome request (B2 gate metric)",
lat_buckets
),
&["pool", "acquire_source"],
registry
)
.unwrap();
let vendor_block_total = register_int_counter_vec_with_registry!(
"crw_vendor_block_total",
"Vendor-specific anti-bot block detections by vendor name",
&["vendor"],
registry
)
.unwrap();
let antibot_escalation_total = register_int_counter_vec_with_registry!(
"crw_antibot_escalation_total",
"Anti-bot blocks flagged by the antibot classifier in the failover loop, by signal",
&["signal"],
registry
)
.unwrap();
let ct_lat_buckets = exponential_buckets(0.001, 2.0, 12).unwrap();
let change_tracking_duration_seconds = register_histogram_vec_with_registry!(
histogram_opts!(
"crw_change_tracking_duration_seconds",
"Duration of one compute_change_tracking call by mode",
ct_lat_buckets
),
&["mode"],
registry
)
.unwrap();
let snapshot_byte_buckets = exponential_buckets(256.0, 4.0, 10).unwrap();
let change_tracking_snapshot_bytes = register_histogram_vec_with_registry!(
histogram_opts!(
"crw_change_tracking_snapshot_bytes",
"Retained snapshot size in bytes per change-tracking call, by mode",
snapshot_byte_buckets
),
&["mode"],
registry
)
.unwrap();
let judge_calls_total = register_int_counter_vec_with_registry!(
"crw_judge_calls_total",
"LLM meaningful-change judge calls by outcome (ok | error | skipped)",
&["outcome"],
registry
)
.unwrap();
let judge_tokens_total = register_int_counter_vec_with_registry!(
"crw_judge_tokens_total",
"LLM judge token usage by kind (input | output)",
&["kind"],
registry
)
.unwrap();
Self {
registry,
render_route_decision_total,
circuit_breaker_open_total,
host_preferences_promotions_total,
admin_preferences_reset_total,
user_pin_total,
host_preferences_size,
chrome_budget_truncated_total,
chrome_blocked_requests_total,
breaker_ignored_total,
cdp_pending_requests,
cdp_live_connections,
target_lifecycle_total,
renderer_recycle_total,
map_filter_dropped_total,
map_filter_stripped_total,
map_filter_preserved_total,
map_filter_rules_loaded,
chrome_connect_seconds,
chrome_target_create_seconds,
chrome_navigate_seconds,
chrome_snapshot_seconds,
chrome_pool_size,
chrome_pool_idle,
chrome_pool_inflight,
chrome_pool_acquire_seconds,
chrome_pool_acquires_total,
chrome_pool_recycle_seconds,
chrome_pool_recycle_total,
chrome_pool_recycle_failures_total,
chrome_pool_health_check_total,
chrome_context_lifetime_seconds,
chrome_request_handshake_seconds,
vendor_block_total,
antibot_escalation_total,
change_tracking_duration_seconds,
change_tracking_snapshot_bytes,
judge_calls_total,
judge_tokens_total,
}
}
}
pub fn gather_text() -> String {
let metric_families = metrics().registry.gather();
let encoder = TextEncoder::new();
let mut buf = Vec::new();
encoder.encode(&metric_families, &mut buf).ok();
String::from_utf8(buf).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tier0_chrome_latency_histograms_registered() {
let m = metrics();
m.chrome_connect_seconds
.with_label_values(&["ok"])
.observe(0.05);
m.chrome_target_create_seconds.observe(0.02);
m.chrome_navigate_seconds.observe(0.4);
m.chrome_snapshot_seconds.observe(0.01);
let text = gather_text();
assert!(
text.contains("crw_chrome_connect_seconds"),
"missing connect_seconds; got: {text}"
);
assert!(text.contains("crw_chrome_target_create_seconds"));
assert!(text.contains("crw_chrome_navigate_seconds"));
assert!(text.contains("crw_chrome_snapshot_seconds"));
assert!(text.contains(r#"outcome="ok""#));
}
#[test]
fn change_tracking_metrics_registered() {
let m = metrics();
m.change_tracking_duration_seconds
.with_label_values(&["gitDiff"])
.observe(0.002);
m.change_tracking_snapshot_bytes
.with_label_values(&["json"])
.observe(4096.0);
m.judge_calls_total.with_label_values(&["ok"]).inc();
m.judge_tokens_total
.with_label_values(&["input"])
.inc_by(1234);
let text = gather_text();
assert!(text.contains("crw_change_tracking_duration_seconds"));
assert!(text.contains("crw_change_tracking_snapshot_bytes"));
assert!(text.contains("crw_judge_calls_total"));
assert!(text.contains("crw_judge_tokens_total"));
}
}