diff --git a/Cargo.lock b/Cargo.lock index 7872061..63e1fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ dependencies = [ "env_logger", "futures-util", "log", + "prometheus-client", "qrcode", "rand", "sailfish", @@ -466,6 +467,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + [[package]] name = "dyn-clone" version = "1.0.16" @@ -1416,6 +1423,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus-client" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "510c4f1c9d81d556458f94c98f857748130ea9737bbd6053da497503b26ea63c" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "qrcode" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 333a590..61c6dc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ rand = "0.8" qrcode = "0.12" tower = "0.4.13" tower-sessions = "0.4.1" +prometheus-client = "0.22.0" diff --git a/flake.lock b/flake.lock index 5dbd3d5..e09d9de 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1699030822, - "narHash": "sha256-a25bCHvTPJfAvK3qLoi5uI2pvwnOYhMQLRpJYNEt55o=", + "lastModified": 1699548976, + "narHash": "sha256-xnpxms0koM8mQpxIup9JnT0F7GrKdvv0QvtxvRuOYR4=", "owner": "ipetkov", "repo": "crane", - "rev": "2c89c36bffac32d8267e719f73b0d06e313ede30", + "rev": "6849911446e18e520970cc6b7a691e64ee90d649", "type": "github" }, "original": { @@ -40,11 +40,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1698924604, - "narHash": "sha256-GCFbkl2tj8fEZBZCw3Tc0AkGo0v+YrQlohhEGJ/X4s0=", + "lastModified": 1699099776, + "narHash": "sha256-X09iKJ27mGsGambGfkKzqvw5esP1L/Rf8H3u3fCqIiU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fa804edfb7869c9fb230e174182a8a1a7e512c40", + "rev": "85f1ba3e51676fa8cc604a3d863d729026a6b8eb", "type": "github" }, "original": { @@ -72,11 +72,11 @@ ] }, "locked": { - "lastModified": 1698977568, - "narHash": "sha256-bnbCqPDFdOUcSANJv9Br3q/b1LyK9vyB1I7os5T4jXI=", + "lastModified": 1699582387, + "narHash": "sha256-sPmUXPDl+cEi+zFtM5lnAs7dWOdRn0ptZ4a/qHwvNDk=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "321affd863e3e4e669990a1db5fdabef98387b95", + "rev": "41f7b0618052430d3a050e8f937030d00a2fcced", "type": "github" }, "original": { diff --git a/src/error.rs b/src/error.rs index a7bf6fc..85a879e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -46,6 +46,9 @@ pub enum Error { #[error("invalid input")] InvalidInput, + + #[error("Prometheus: {0:?}")] + Prometheus(std::fmt::Error), } impl IntoResponse for Error { diff --git a/src/game.rs b/src/game.rs index 8035abe..2d1a73b 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,10 +1,19 @@ use std::{ collections::{HashMap, HashSet}, + fmt::Debug, ops::Deref, sync::Arc, time::Duration, }; +use prometheus_client::{ + collector::Collector, + encoding::{EncodeLabelSet, EncodeMetric}, + metrics::{ + family::Family, + gauge::{ConstGauge, Gauge}, + }, +}; use qrcode::{render::svg, QrCode}; use rand::{distributions, Rng}; use sailfish::TemplateOnce; @@ -256,3 +265,64 @@ impl Game { .render_once()?) } } + +#[derive(EncodeLabelSet, PartialEq, Eq, Hash, Clone, Debug)] +pub struct GameLabels { + game: String, +} + +pub struct GameCollector { + games: Arc>>, +} + +impl GameCollector { + pub fn new(games: Arc>>) -> Self { + Self { games } + } +} + +impl Debug for GameCollector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GameCollector").finish() + } +} + +impl Collector for GameCollector { + fn encode( + &self, + mut encoder: prometheus_client::encoding::DescriptorEncoder, + ) -> Result<(), std::fmt::Error> { + let games = self.games.blocking_read(); + let running_games = ConstGauge::new(games.len() as i64); + + let participants = Family::::default(); + + games.iter().for_each(|(id, game)| { + participants + .get_or_create(&GameLabels { + game: id.0.to_string(), + }) + .set(game.players.len() as i64); + }); + + drop(games); + + let running_games_encoder = encoder.encode_descriptor( + "ars_running_games", + "number of running games", + None, + running_games.metric_type(), + )?; + running_games.encode(running_games_encoder)?; + + let participants_encoder = encoder.encode_descriptor( + "ars_game_participants", + "number of participants for a game", + None, + participants.metric_type(), + )?; + participants.encode(participants_encoder)?; + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 12d355e..d08b75b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use std::{ use axum::{ error_handling::HandleErrorLayer, extract::{Multipart, Path, Query, State}, - http::{StatusCode, Uri}, + http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, StatusCode, Uri}, response::{ sse::{Event, KeepAlive}, Html, IntoResponse, Redirect, Sse, @@ -23,8 +23,13 @@ use axum_oidc::{ error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, }; use futures_util::Stream; -use game::{Game, GameId, PlayerId}; +use game::{Game, GameCollector, GameId, PlayerId}; use garbage_collector::{start_gc, GarbageCollectorItem}; +use prometheus_client::{ + encoding::EncodeLabelSet, + metrics::{counter::Counter, family::Family, gauge::Gauge}, + registry::Registry, +}; use question::{single_choice::SingleChoiceQuestion, Question}; use sailfish::TemplateOnce; use serde::{Deserialize, Serialize}; @@ -50,6 +55,13 @@ pub struct AppState { games: Arc>>, game_expiry: Arc>>, application_base: &'static str, + prometheus_registry: Arc, + metrics: Arc, +} + +#[derive(Clone, Default)] +pub struct AppMetrics { + arc_games_total: Counter, } #[tokio::main] @@ -100,12 +112,25 @@ pub async fn main() { Arc::new(RwLock::new(BinaryHeap::new())); let games = Arc::new(RwLock::new(HashMap::new())); + let app_metrics = Arc::new(AppMetrics::default()); + let mut registry = Registry::default(); + + registry.register( + "arc_games_total", + "number of games created", + app_metrics.arc_games_total.clone(), + ); + + registry.register_collector(Box::new(GameCollector::new(games.clone()))); + start_gc(game_expiry.clone(), games.clone()); let app_state = AppState { games, game_expiry, application_base: Box::leak(application_base.into()), + prometheus_registry: Arc::new(registry), + metrics: app_metrics, }; let app = Router::new() @@ -115,6 +140,7 @@ pub async fn main() { .layer(oidc_login_service) .route("/:id", get(handle_player).post(handle_player_answer)) .route("/:id/events", get(sse_player)) + .route("/metrics", get(metrics)) .nest_service("/static", ServeDir::new("static")) .with_state(app_state) .layer(oidc_auth_service) @@ -157,6 +183,8 @@ pub async fn handle_create( let mut game_expiry = state.game_expiry.write().await; game_expiry.push(GarbageCollectorItem::new_in(game_id, 24 * 3600)); + state.metrics.arc_games_total.inc(); + Ok((HxRedirect(Uri::from_maybe_shared(url.clone())?), "Ok")) } @@ -284,6 +312,20 @@ pub async fn sse_player( Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } +async fn metrics(State(app_state): State) -> HandlerResult { + let mut buffer = String::new(); + prometheus_client::encoding::text::encode(&mut buffer, &app_state.prometheus_registry) + .map_err(Error::Prometheus)?; + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; version=0.0.4"), + ); + + Ok((headers, buffer)) +} + #[derive(TemplateOnce)] #[template(path = "index.stpl")] struct IndexTemplate {}