use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use crate::resolve::{CodeSource, MAX_ERROR_CODE, MIN_ERROR_CODE, is_usable_code};
pub const COUNTER_NAME: &str = "errors_pages_total";
const HELP_LINE: &str = "# HELP errors_pages_total Páginas de error servidas, por código.";
const TYPE_LINE: &str = "# TYPE errors_pages_total counter";
pub const CONTENT_TYPE: &str = "text/plain; version=0.0.4";
const CODES: usize = (MAX_ERROR_CODE - MIN_ERROR_CODE + 1) as usize;
const SOURCES: usize = 3;
const CELLS: usize = CODES * SOURCES;
const SOURCE_ORDER: [CodeSource; SOURCES] =
[CodeSource::Path, CodeSource::Header, CodeSource::Default];
#[derive(Clone)]
pub struct PageCounters {
counters: Arc<[AtomicU64; CELLS]>,
}
impl PageCounters {
pub fn new() -> Self {
Self {
counters: Arc::new([const { AtomicU64::new(0) }; CELLS]),
}
}
pub fn record(&self, code: u16, source: CodeSource) {
let Some(index) = index_of(code, source) else {
return;
};
self.counters[index].fetch_add(1, Ordering::Relaxed);
}
pub fn get(&self, code: u16, source: CodeSource) -> u64 {
match index_of(code, source) {
Some(index) => self.counters[index].load(Ordering::Relaxed),
None => 0,
}
}
pub fn render(&self) -> String {
let mut out = String::from(HELP_LINE);
out.push('\n');
out.push_str(TYPE_LINE);
out.push('\n');
for code in MIN_ERROR_CODE..=MAX_ERROR_CODE {
for source in SOURCE_ORDER {
let value = self.get(code, source);
if value > 0 {
out.push_str(&format!(
"{COUNTER_NAME}{{code=\"{code}\",source=\"{}\"}} {value}\n",
source_label(source)
));
}
}
}
out
}
}
impl Default for PageCounters {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for PageCounters {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut list = f.debug_map();
for code in MIN_ERROR_CODE..=MAX_ERROR_CODE {
for source in SOURCE_ORDER {
let value = self.get(code, source);
if value > 0 {
list.entry(&format!("{code}/{}", source_label(source)), &value);
}
}
}
list.finish()
}
}
fn index_of(code: u16, source: CodeSource) -> Option<usize> {
if !is_usable_code(code) {
return None;
}
Some(source_index(source) * CODES + usize::from(code - MIN_ERROR_CODE))
}
fn source_index(source: CodeSource) -> usize {
match source {
CodeSource::Path => 0,
CodeSource::Header => 1,
CodeSource::Default => 2,
}
}
fn source_label(source: CodeSource) -> &'static str {
match source {
CodeSource::Path => "path",
CodeSource::Header => "header",
CodeSource::Default => "default",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use std::path::{Path, PathBuf};
use tower::ServiceExt;
fn fixtures_config() -> Config {
let content_dir: PathBuf =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/content");
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
content_dir.to_string_lossy().into_owned(),
)])
.unwrap()
}
fn get(uri: &str) -> Request<Body> {
Request::builder().uri(uri).body(Body::empty()).unwrap()
}
async fn body_bytes(response: axum::response::Response) -> Vec<u8> {
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec()
}
fn serie(cuerpo: &str, code: u16, source: &str) -> Option<u64> {
let prefijo = format!("{COUNTER_NAME}{{code=\"{code}\",source=\"{source}\"}} ");
cuerpo
.lines()
.find_map(|linea| linea.strip_prefix(&prefijo))
.and_then(|valor| valor.trim().parse().ok())
}
#[tokio::test]
async fn el_contador_distingue_de_donde_salio_el_codigo() {
let router = crate::build_router(&fixtures_config());
for _ in 0..3 {
let response = router.clone().oneshot(get("/404")).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
let con_cabecera = Request::builder()
.uri("/")
.header("X-Code", "503")
.body(Body::empty())
.unwrap();
let response = router.clone().oneshot(con_cabecera).await.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let response = router.clone().oneshot(get("/")).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = router.oneshot(get("/metrics")).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cuerpo = String::from_utf8(body_bytes(response).await).unwrap();
assert_eq!(
serie(&cuerpo, 404, "path"),
Some(3),
"las páginas pedidas por su path se cuentan con origen `path`:\n{cuerpo}"
);
assert_eq!(
serie(&cuerpo, 503, "header"),
Some(1),
"una página con `X-Code: 503` se cuenta con origen `header`:\n{cuerpo}"
);
assert_eq!(
serie(&cuerpo, 503, "path"),
None,
"un código que vino de la cabecera no puede contarse como si viniera del path:\n{cuerpo}"
);
assert_eq!(
serie(&cuerpo, 404, "default"),
Some(1),
"una página sin pistas se cuenta con origen `default`:\n{cuerpo}"
);
}
}