Files
AWatch-rus/adk-rust/crates/detmir-portal/src/production/metrics.rs
T

192 lines
6.0 KiB
Rust

//! In-process Prometheus-style metrics for the portal.
//!
//! CONTRACT: metric names and label keys are part of the operational contract
//! used by dashboards and smoke checks. Additive metrics are allowed; renaming
//! existing metrics requires synchronized dashboard/documentation changes.
use std::collections::BTreeMap;
use std::fmt::Write as FmtWrite;
use std::sync::{Mutex, OnceLock};
use tiny_http::StatusCode;
use super::readiness::build_readyz;
use super::request_context::HttpRequestMetadata;
use crate::Cli;
static PORTAL_METRICS: OnceLock<Mutex<PortalMetrics>> = OnceLock::new();
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
struct HttpMetricKey {
method: String,
route: String,
status: u16,
module: String,
}
#[derive(Clone, Debug, Default)]
struct HttpMetricValue {
requests_total: u64,
duration_seconds_sum: f64,
duration_seconds_count: u64,
}
#[derive(Clone, Debug, Default)]
struct PortalMetrics {
http: BTreeMap<HttpMetricKey, HttpMetricValue>,
reports_generated_total: u64,
ingestion_records_total: u64,
ingestion_rejected_total: u64,
role_denied_total: u64,
}
fn portal_metrics() -> &'static Mutex<PortalMetrics> {
PORTAL_METRICS.get_or_init(|| Mutex::new(PortalMetrics::default()))
}
pub(crate) fn record_http_metric(metadata: &HttpRequestMetadata, status: StatusCode) {
if let Ok(mut metrics) = portal_metrics().lock() {
let entry = metrics
.http
.entry(HttpMetricKey {
method: metadata.method.clone(),
route: metadata.route.clone(),
status: status.0,
module: metadata.module.clone(),
})
.or_default();
entry.requests_total = entry.requests_total.saturating_add(1);
entry.duration_seconds_sum += metadata.latency_ms as f64 / 1_000.0;
entry.duration_seconds_count = entry.duration_seconds_count.saturating_add(1);
if status.0 == 403 {
metrics.role_denied_total = metrics.role_denied_total.saturating_add(1);
}
}
}
pub(crate) fn record_report_generated() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.reports_generated_total = metrics.reports_generated_total.saturating_add(1);
}
}
pub(crate) fn record_ingestion_accepted() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1);
}
}
pub(crate) fn record_ingestion_rejected() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.ingestion_rejected_total = metrics.ingestion_rejected_total.saturating_add(1);
}
}
pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
let mut text = String::new();
let ready_value = if build_readyz(args)
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("not_ready")
== "ready"
{
1
} else {
0
};
writeln!(
&mut text,
"# HELP awatch_http_requests_total HTTP requests handled by AWatch-rus portal"
)
.ok();
writeln!(&mut text, "# TYPE awatch_http_requests_total counter").ok();
let metrics = portal_metrics()
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
for (key, value) in &metrics.http {
writeln!(
&mut text,
"awatch_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.requests_total
)
.ok();
}
writeln!(
&mut text,
"# HELP awatch_http_request_duration_seconds HTTP request duration in seconds"
)
.ok();
writeln!(
&mut text,
"# TYPE awatch_http_request_duration_seconds summary"
)
.ok();
for (key, value) in &metrics.http {
writeln!(
&mut text,
"awatch_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {:.6}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.duration_seconds_sum
)
.ok();
writeln!(
&mut text,
"awatch_http_request_duration_seconds_count{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.duration_seconds_count
)
.ok();
}
for (name, help, value) in [
(
"awatch_reports_generated_total",
"Reports generated by the portal",
metrics.reports_generated_total,
),
(
"awatch_ingestion_records_total",
"Telemetry ingestion records accepted",
metrics.ingestion_records_total,
),
(
"awatch_ingestion_rejected_total",
"Telemetry ingestion records rejected",
metrics.ingestion_rejected_total,
),
(
"awatch_role_denied_total",
"Requests denied by role gates",
metrics.role_denied_total,
),
] {
writeln!(&mut text, "# HELP {name} {help}").ok();
writeln!(&mut text, "# TYPE {name} counter").ok();
writeln!(&mut text, "{name} {value}").ok();
}
writeln!(
&mut text,
"# HELP awatch_readyz_status Portal readiness status, 1=ready, 0=not_ready"
)
.ok();
writeln!(&mut text, "# TYPE awatch_readyz_status gauge").ok();
writeln!(&mut text, "awatch_readyz_status {ready_value}").ok();
text
}
fn prom_escape(value: &str) -> String {
// SECURITY: metric label values are route/module tokens, but escaping keeps
// the endpoint safe if future callers pass proxy-derived values.
value.replace('\\', "\\\\").replace('"', "\\\"")
}