Files
Oxicloud/src/infrastructure/services/session_liveness_gauges.rs
T
Edouard Vanbelle 20e6e05bb4 feat(sessions): identify online sessions (connected users)
identify online session by writing the `last_seen_at`
information is stored in a map and flush each 30s to prevent performance impact on pgsql
2026-08-20 10:40:27 +02:00

170 lines
6.5 KiB
Rust

//! Prometheus session-liveness gauges — periodic polling of
//! `auth.sessions` to publish three gauges the `/metrics` scraper
//! reads:
//!
//! - `oxicloud_sessions_online` — non-revoked rows observed in the
//! last [`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW).
//! **Per-session count**, not per-user — one user with three
//! devices contributes three.
//! - `oxicloud_sessions_online_users` — DISTINCT `user_id` behind
//! those online sessions. The multi-device factor is exactly
//! `sessions_online / sessions_online_users`.
//! - `oxicloud_sessions_total_non_revoked` — long-tail total,
//! including mobile clients still holding a refresh token they
//! haven't used in weeks. Useful sanity signal on the dashboard.
//!
//! **Naming — "online" vs "active".** The word "active" is already
//! spoken for by the session *lifecycle* (Active | Expired |
//! Revoked in the admin panel). Presence (recently-seen) is
//! orthogonal and uses "online" throughout the UI, DTO
//! (`SessionSummaryDto::is_online`), and these gauges — so a
//! dashboard graph and a per-row green-dot badge have the same
//! label root. Terminology decided 2026-08-18; see
//! `docs/plan/sessions.md`.
//!
//! **Cadence.** Poller ticks every [`POLL_INTERVAL`] (30 s). Three
//! `COUNT(*)` reads on the maintenance pool per tick — negligible
//! load on tens-of-thousands-of-rows tables thanks to the partial
//! index `idx_sessions_last_seen_at` (partial on `revoked = FALSE`,
//! which every query below filters on).
//!
//! **When it runs.** Spawned from DI only when auth is enabled AND
//! `OXICLOUD_METRICS_LISTEN` is set (recorder installed). Without
//! the recorder, `metrics::gauge!(...)` is a no-op — spawning
//! anyway would still hit PG every 30 s for values nobody reads.
//!
//! See `docs/plan/sessions.md` for the full design.
use std::sync::Arc;
use std::time::Duration;
use sqlx::PgPool;
use crate::application::dtos::session_dto::ONLINE_WINDOW;
/// Poll cadence. Matches the [`LastSeenTracker`](super::last_seen_tracker)
/// flush cadence so the gauges converge one tick after the tracker
/// flushes — no need to sync the two.
const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// Spawn the session-liveness poller. Detached — the task lives
/// for the runtime's lifetime; there's no mid-process reason to
/// stop reporting gauges.
///
/// Emits an initial poll on spawn so the very first `/metrics`
/// scrape after boot returns real values instead of the recorder's
/// zero-initialised default.
pub fn spawn(maintenance_pool: Arc<PgPool>) {
tokio::spawn(async move {
// Immediate first tick — a scraper hitting `/metrics` in
// the first 30 s otherwise sees `oxicloud_sessions_online
// 0` even on a busy server. Warmup query is cheap.
if let Err(err) = poll_once(&maintenance_pool).await {
tracing::warn!(
target: "oxicloud::sessions",
error = %err,
"initial session-liveness poll failed",
);
}
let mut ticker = tokio::time::interval(POLL_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Consume the first tick — `interval` fires immediately on
// creation and we've already done the warmup above.
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(err) = poll_once(&maintenance_pool).await {
tracing::warn!(
target: "oxicloud::sessions",
error = %err,
"session-liveness poll failed; keeping last-known gauge values",
);
}
}
});
tracing::info!(
target: "oxicloud::sessions",
poll_interval_secs = POLL_INTERVAL.as_secs(),
online_window_secs = ONLINE_WINDOW.as_secs(),
"📊 session-liveness gauges spawned",
);
}
/// One poll cycle. Three lightweight `COUNT` reads → three gauge
/// updates. Errors propagate to the caller (loop logs + retries
/// next tick; gauges keep their last-known value in the interim,
/// which is the honest thing to publish — a temporary PG blip is
/// not a "sessions dropped to zero" event).
async fn poll_once(pool: &PgPool) -> Result<(), sqlx::Error> {
// NOTE: `ONLINE_WINDOW` is a Duration; PG expects the interval
// in seconds via `make_interval` (portable across sqlx driver
// versions). Casting once at bind time is cheaper than an
// `INTERVAL '$1 seconds'` string interp and keeps the query
// parameterised.
let online_secs: f64 = ONLINE_WINDOW.as_secs_f64();
let online: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_secs)
.fetch_one(pool)
.await?;
let online_users: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(DISTINCT user_id) FROM auth.sessions
WHERE revoked = FALSE
AND last_seen_at > NOW() - make_interval(secs => $1)
"#,
)
.bind(online_secs)
.fetch_one(pool)
.await?;
let total_non_revoked: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM auth.sessions WHERE revoked = FALSE
"#,
)
.fetch_one(pool)
.await?;
// metrics-exporter-prometheus takes f64 gauges; the raw COUNT
// fits into f64 precisely up to 2^53, well past any realistic
// session-row count. `describe_gauge!` is called once at first
// emission and cached in the recorder — the second/third tick
// just updates the value.
metrics::describe_gauge!(
"oxicloud_sessions_online",
"Non-revoked sessions observed in the last ONLINE_WINDOW."
);
metrics::gauge!("oxicloud_sessions_online").set(online as f64);
metrics::describe_gauge!(
"oxicloud_sessions_online_users",
"Distinct users behind sessions observed in the last ONLINE_WINDOW."
);
metrics::gauge!("oxicloud_sessions_online_users").set(online_users as f64);
metrics::describe_gauge!(
"oxicloud_sessions_total_non_revoked",
"Total non-revoked sessions regardless of last-seen recency."
);
metrics::gauge!("oxicloud_sessions_total_non_revoked").set(total_non_revoked as f64);
tracing::debug!(
target: "oxicloud::sessions",
online,
online_users,
total_non_revoked,
"session-liveness gauges updated",
);
Ok(())
}