20e6e05bb4
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
170 lines
6.5 KiB
Rust
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(())
|
|
}
|