mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Live count: exact numbers, a history of it, and a link to playhorizon.dev/stats
- GET /v1/live serves exact playing, lan, versions and platforms (live::round is gone); rare versions are still summed under "other". - The telemetry server writes the live count down once a minute (totals only: playing, lan, per platform) into live_minute (7 days), live_hour (90 days) and live_day (kept). - GET /v1/live/history?range=24h|7d|30d|all serves the series ([start, average, peak]), the range's peak, the all-time peak and the average per platform, cached a minute, on the live count's rate limit. - The launcher's "N playing now" opens playhorizon.dev/stats. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
979aa64e51
commit
eccfe3d9b7
11 files changed
+699
-67
No files matched your search
@@ -16,7 +16,8 @@ It runs at **https://telemetry.playhorizon.dev** (`DEFAULT_ENDPOINT` in `crates/
|
||||
| `GET /v1/aggregate?days=30` | The totals the website reads. Cached 10 minutes, with `Access-Control-Allow-Origin` set to the website. |
|
||||
| `GET /v1/benchmarks/summary` | Players' benchmark uploads grouped by GPU, CPU, render distance and game, for the website's Benchmarks page (below). Cached 10 minutes, same `Access-Control-Allow-Origin`. |
|
||||
| `POST /v1/live` | A game's "I'm playing" ping (JSON, at most 512 bytes). Returns 204. Returns 400 for a bad ping, 413 for one too large, 429 over the address's limit, 503 when it holds `LIVE_MAX_SESSIONS` already. |
|
||||
| `GET /v1/live` | How many play now, rounded: `playing`, `lan`, `versions`, `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. |
|
||||
| `GET /v1/live` | How many play now, exactly: `playing`, `lan`, `versions` (the 12 most played, the rest under `other`), `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. |
|
||||
| `GET /v1/live/history?range=24h\|7d\|30d\|all` | The live count over time for playhorizon.dev/stats: `series` of `[start, average, peak]`, the range's `peak` and the `all_time` one with their minute, the average per platform ([docs/telemetry.md](../../docs/telemetry.md)). Made again at most every minute (`Cache-Control: max-age=60`), on the live count's rate limit. 400 for another range. |
|
||||
| `POST /hub/vote` | `{"item": "<roadmap id>", "voter": "<the browser's random id>", "up": true}`: a 👍 on a roadmap item, one per voter; `"up": false` takes it back. Returns `{"item", "votes"}`. 400 for a bad id, 429 over the address's limit. |
|
||||
| `GET /hub/votes` | `{"votes": {"<item>": n}}`. Made again at most every minute. |
|
||||
| `POST /hub/report` | A bug report from the hub's form: `{"title", "did", "happened", "expected", "version"?, "system", "name"?}`. Opens a GitHub issue at once (labels `bug` and `from-website`, the `#horizon-bugs` template, "Reported on playhorizon.dev") and returns 201 `{"id", "issue": "<url>"}`. If GitHub fails: 202 `{"id", "issue": null}`, and the upkeep tries again for a week. The same report twice in a day returns the first one's issue. 400 for an empty or too long field, 429 over the address's limit, 503 past the day's cap or without `GITHUB_TOKEN`. |
|
||||
@@ -57,6 +58,8 @@ Frame rates are whole numbers, milliseconds have one decimal under 10, megabytes
|
||||
|
||||
Measured: **about 190 bytes per play session** on disk, indexes included (`tests/server.rs`, 10 000 sessions). 1 000 players playing twice a day for 180 days is about 70 MB.
|
||||
|
||||
The live count's history (`src/history.rs`) is three small tables of totals, never sessions: `live_minute` (one row a minute, kept `LIVE_MINUTE_DAYS`, 7), `live_hour` (sum, sample count, peak and its minute, kept `LIVE_HOUR_DAYS`, 90) and `live_day` (the same per UTC day, kept for good). The server writes one sample a minute, starting a whole window (2 minutes) after it starts so a restart doesn't record a dip. About 1 MB a year.
|
||||
|
||||
The live count is a table in memory: a session ID, when it last pinged, its version, platform and LAN flag, about 100 bytes a session. A session counts while its last ping is under 2 minutes old and is forgotten after that. Nothing of it reaches the disk, so a restart empties it; it is whole again a minute later.
|
||||
|
||||
The day stored is the day the server received an item. The sender's IP address is never written down. The per-address rate limit keeps addresses in memory for an hour at most.
|
||||
@@ -80,6 +83,7 @@ All are environment variables:
|
||||
- `HORIZON_TELEMETRY_LIVE_PER_HOUR` per address, pings and reads (600, bursts of 60: ten players behind one address)
|
||||
- `HORIZON_TELEMETRY_LIVE_WINDOW_SECONDS` (120)
|
||||
- `HORIZON_TELEMETRY_LIVE_MAX_SESSIONS` (200 000)
|
||||
- `HORIZON_TELEMETRY_LIVE_MINUTE_DAYS` (7) and `…_LIVE_HOUR_DAYS` (90, at least 31): how long the history keeps minute and hourly samples; daily ones stay
|
||||
- `HORIZON_TELEMETRY_HUB_PER_HOUR` votes and vote reads per address (600, bursts of 60: a page load is one read, and several players can share an address)
|
||||
- `HORIZON_TELEMETRY_HUB_POSTS_PER_HOUR` bug reports per address (20, bursts of 2)
|
||||
- `HORIZON_TELEMETRY_HUB_REPORTS_PER_DAY` from everyone (30): each opens an issue
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
//! The live count over time, for playhorizon.dev/stats: `GET /v1/live/history`.
|
||||
//!
|
||||
//! Once a minute the server writes the live count (`live`) down: how many play, how many have a
|
||||
//! world open to LAN or friends, and how many per platform. Only those totals, never a session,
|
||||
//! a version or an address. Three tables, coarser as they age:
|
||||
//!
|
||||
//! - `live_minute`: one row a minute, kept [`crate::Config::live_minute_days`] (7 days).
|
||||
//! - `live_hour`: per hour, the minutes' sum and count (for the average) and the highest one with
|
||||
//! its minute, kept [`crate::Config::live_hour_days`] (90 days).
|
||||
//! - `live_day`: the same per day (UTC), kept forever.
|
||||
//!
|
||||
//! The hour and day rows are updated with each minute, so they hold the current hour and day too.
|
||||
//! Times are Unix seconds (UTC), each row's the start of its minute, hour or day.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use horizon_telemetry::live::{Count, PLATFORMS};
|
||||
use rusqlite::{OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::store::Store;
|
||||
|
||||
pub const SCHEMA: &str = "
|
||||
CREATE TABLE IF NOT EXISTS live_minute (
|
||||
t INTEGER PRIMARY KEY, playing INTEGER NOT NULL, lan INTEGER NOT NULL,
|
||||
linux INTEGER NOT NULL, macos INTEGER NOT NULL, windows INTEGER NOT NULL, other INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS live_hour (
|
||||
t INTEGER PRIMARY KEY, samples INTEGER NOT NULL, peak INTEGER NOT NULL, peak_at INTEGER NOT NULL,
|
||||
sum INTEGER NOT NULL, lan_peak INTEGER NOT NULL, lan_sum INTEGER NOT NULL,
|
||||
linux_sum INTEGER NOT NULL, macos_sum INTEGER NOT NULL, windows_sum INTEGER NOT NULL,
|
||||
other_sum INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS live_day (
|
||||
t INTEGER PRIMARY KEY, samples INTEGER NOT NULL, peak INTEGER NOT NULL, peak_at INTEGER NOT NULL,
|
||||
sum INTEGER NOT NULL, lan_peak INTEGER NOT NULL, lan_sum INTEGER NOT NULL,
|
||||
linux_sum INTEGER NOT NULL, macos_sum INTEGER NOT NULL, windows_sum INTEGER NOT NULL,
|
||||
other_sum INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
";
|
||||
|
||||
/// The per-platform columns, in [`PLATFORMS`]' order.
|
||||
const PLATFORM_COLUMNS: [&str; 4] = ["linux", "macos", "windows", "other"];
|
||||
|
||||
const HOUR: i64 = 3600;
|
||||
const DAY: i64 = 86_400;
|
||||
|
||||
/// One minute's count, as it is stored.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Sample {
|
||||
pub playing: u64,
|
||||
pub lan: u64,
|
||||
/// Per platform, in [`PLATFORMS`]' order.
|
||||
pub platforms: [u64; 4],
|
||||
}
|
||||
|
||||
impl Sample {
|
||||
pub fn of(count: &Count) -> Self {
|
||||
let mut platforms = [0; 4];
|
||||
for (slot, name) in platforms.iter_mut().zip(PLATFORMS) {
|
||||
*slot = count.platforms.get(name).copied().unwrap_or(0);
|
||||
}
|
||||
Self {
|
||||
playing: count.playing,
|
||||
lan: count.lan,
|
||||
platforms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `GET /v1/live/history?range=` shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Range {
|
||||
/// The last 24 hours, in 5-minute points.
|
||||
Day,
|
||||
/// The last 7 days, hourly.
|
||||
Week,
|
||||
/// The last 30 days, every 3 hours.
|
||||
Month,
|
||||
/// Every day since the first.
|
||||
All,
|
||||
}
|
||||
|
||||
impl Range {
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"24h" => Self::Day,
|
||||
"7d" => Self::Week,
|
||||
"30d" => Self::Month,
|
||||
"all" => Self::All,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Day => "24h",
|
||||
Self::Week => "7d",
|
||||
Self::Month => "30d",
|
||||
Self::All => "all",
|
||||
}
|
||||
}
|
||||
|
||||
/// The table it reads, its point's length, and how far back it goes (None: all of it).
|
||||
fn plan(self) -> (Table, i64, Option<i64>) {
|
||||
match self {
|
||||
Self::Day => (Table::Minute, 300, Some(DAY)),
|
||||
Self::Week => (Table::Hour, HOUR, Some(7 * DAY)),
|
||||
Self::Month => (Table::Hour, 3 * HOUR, Some(30 * DAY)),
|
||||
Self::All => (Table::Day, DAY, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Table {
|
||||
Minute,
|
||||
Hour,
|
||||
Day,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Minute => "live_minute",
|
||||
Self::Hour => "live_hour",
|
||||
Self::Day => "live_day",
|
||||
}
|
||||
}
|
||||
|
||||
/// The SQL for a row's samples, sum, peak and peak's time, and a platform's sum: a minute row
|
||||
/// is one sample.
|
||||
fn columns(
|
||||
self,
|
||||
) -> (
|
||||
&'static str,
|
||||
&'static str,
|
||||
&'static str,
|
||||
&'static str,
|
||||
&'static str,
|
||||
) {
|
||||
match self {
|
||||
Self::Minute => ("1", "playing", "playing", "t", ""),
|
||||
Self::Hour | Self::Day => ("samples", "sum", "peak", "peak_at", "_sum"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A count at a time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Peak {
|
||||
pub playing: u64,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
/// `GET /v1/live/history`'s answer.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct History {
|
||||
pub range: &'static str,
|
||||
/// Seconds per point.
|
||||
pub step: i64,
|
||||
/// The first point's start and now.
|
||||
pub from: i64,
|
||||
pub to: i64,
|
||||
/// `[start, average, highest]` per point that has samples, oldest first. A point with no
|
||||
/// sample (the server was down) is missing.
|
||||
pub series: Vec<(i64, f64, u64)>,
|
||||
/// The highest count in the range and its minute.
|
||||
pub peak: Option<Peak>,
|
||||
/// The highest count ever recorded and its minute.
|
||||
pub all_time: Option<Peak>,
|
||||
/// The average count per platform over the range.
|
||||
pub platforms: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
fn tenth(x: f64) -> f64 {
|
||||
(x * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Writes down `sample` as the count of the minute starting at `minute` (Unix seconds), and
|
||||
/// adds it to its hour and day. False, and nothing changes, when that minute has one already.
|
||||
pub fn record_live(&mut self, minute: i64, sample: &Sample) -> rusqlite::Result<bool> {
|
||||
let minute = minute - minute.rem_euclid(60);
|
||||
let tx = self.db.transaction()?;
|
||||
let [linux, macos, windows, other] = sample.platforms.map(|n| n as i64);
|
||||
let (playing, lan) = (sample.playing as i64, sample.lan as i64);
|
||||
let new = tx.execute(
|
||||
"INSERT OR IGNORE INTO live_minute (t, playing, lan, linux, macos, windows, other)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![minute, playing, lan, linux, macos, windows, other],
|
||||
)? == 1;
|
||||
if new {
|
||||
for (table, start) in [
|
||||
("live_hour", minute - minute.rem_euclid(HOUR)),
|
||||
("live_day", minute - minute.rem_euclid(DAY)),
|
||||
] {
|
||||
// SQLite evaluates every SET against the row as it was, so `peak_at` sees the old
|
||||
// peak. An equal count keeps the earlier minute.
|
||||
tx.execute(
|
||||
&format!(
|
||||
"INSERT INTO {table} (t, samples, peak, peak_at, sum, lan_peak, lan_sum,
|
||||
linux_sum, macos_sum, windows_sum, other_sum)
|
||||
VALUES (?1, 1, ?2, ?3, ?2, ?4, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT (t) DO UPDATE SET
|
||||
samples = samples + 1,
|
||||
peak = max(peak, excluded.peak),
|
||||
peak_at = CASE WHEN excluded.peak > peak THEN excluded.peak_at ELSE peak_at END,
|
||||
sum = sum + excluded.sum,
|
||||
lan_peak = max(lan_peak, excluded.lan_peak),
|
||||
lan_sum = lan_sum + excluded.lan_sum,
|
||||
linux_sum = linux_sum + excluded.linux_sum,
|
||||
macos_sum = macos_sum + excluded.macos_sum,
|
||||
windows_sum = windows_sum + excluded.windows_sum,
|
||||
other_sum = other_sum + excluded.other_sum"
|
||||
),
|
||||
params![start, playing, minute, lan, linux, macos, windows, other],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(new)
|
||||
}
|
||||
|
||||
/// Removes minutes older than `minute_days` and hours older than `hour_days` before `now`.
|
||||
/// Days stay.
|
||||
pub fn prune_live(
|
||||
&self,
|
||||
now: i64,
|
||||
minute_days: u32,
|
||||
hour_days: u32,
|
||||
) -> rusqlite::Result<usize> {
|
||||
let minutes = self.db.execute(
|
||||
"DELETE FROM live_minute WHERE t < ?1",
|
||||
[now - i64::from(minute_days) * DAY],
|
||||
)?;
|
||||
let hours = self.db.execute(
|
||||
"DELETE FROM live_hour WHERE t < ?1",
|
||||
[now - i64::from(hour_days) * DAY],
|
||||
)?;
|
||||
Ok(minutes + hours)
|
||||
}
|
||||
|
||||
/// The count over `range`, up to `now` (Unix seconds).
|
||||
pub fn live_history(&self, range: Range, now: i64) -> rusqlite::Result<History> {
|
||||
let (table, step, span) = range.plan();
|
||||
let (samples, sum, peak, peak_at, suffix) = table.columns();
|
||||
let name = table.name();
|
||||
let from = span.map_or(0, |span| {
|
||||
let from = now - span;
|
||||
from - from.rem_euclid(step)
|
||||
});
|
||||
let mut series = Vec::new();
|
||||
let mut rows = self.db.prepare(&format!(
|
||||
"SELECT t / ?2 * ?2 AS b, CAST(SUM({sum}) AS REAL) / SUM({samples}), MAX({peak})
|
||||
FROM {name} WHERE t >= ?1 GROUP BY b ORDER BY b"
|
||||
))?;
|
||||
for row in rows.query_map(params![from, step], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, f64>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
))
|
||||
})? {
|
||||
let (t, avg, max) = row?;
|
||||
series.push((t, tenth(avg), max.max(0) as u64));
|
||||
}
|
||||
let peak_of = |since: i64| {
|
||||
self.db
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT {peak}, {peak_at} FROM {name} WHERE t >= ?1
|
||||
ORDER BY {peak} DESC, {peak_at} ASC LIMIT 1"
|
||||
),
|
||||
[since],
|
||||
|r| {
|
||||
Ok(Peak {
|
||||
playing: r.get::<_, i64>(0)?.max(0) as u64,
|
||||
at: r.get(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
};
|
||||
let all_time = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT peak, peak_at FROM live_day ORDER BY peak DESC, peak_at ASC LIMIT 1",
|
||||
[],
|
||||
|r| {
|
||||
Ok(Peak {
|
||||
playing: r.get::<_, i64>(0)?.max(0) as u64,
|
||||
at: r.get(1)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let averages = PLATFORM_COLUMNS
|
||||
.iter()
|
||||
.map(|c| format!("CAST(SUM({c}{suffix}) AS REAL) / SUM({samples})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let platforms = self.db.query_row(
|
||||
&format!("SELECT {averages} FROM {name} WHERE t >= ?1"),
|
||||
[from],
|
||||
|r| {
|
||||
(0..PLATFORM_COLUMNS.len())
|
||||
.map(|i| r.get::<_, Option<f64>>(i))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
},
|
||||
)?;
|
||||
Ok(History {
|
||||
range: range.name(),
|
||||
step,
|
||||
from,
|
||||
to: now,
|
||||
series,
|
||||
peak: peak_of(from)?,
|
||||
all_time,
|
||||
platforms: PLATFORMS
|
||||
.iter()
|
||||
.zip(platforms)
|
||||
.filter_map(|(p, avg)| Some(((*p).to_owned(), tenth(avg?))))
|
||||
.filter(|(_, avg)| *avg > 0.0)
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(playing: u64, linux: u64) -> Sample {
|
||||
Sample {
|
||||
playing,
|
||||
lan: playing / 2,
|
||||
platforms: [linux, playing - linux, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_columns_follow_the_platforms() {
|
||||
assert_eq!(PLATFORM_COLUMNS, PLATFORMS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_minute_is_recorded_once_and_rolls_up() {
|
||||
let mut store = Store::open(":memory:").unwrap();
|
||||
let day = 20_000 * DAY;
|
||||
assert!(store.record_live(day + 10, &sample(4, 1)).unwrap());
|
||||
// The same minute again (a late tick) changes nothing.
|
||||
assert!(!store.record_live(day + 59, &sample(9, 9)).unwrap());
|
||||
store.record_live(day + 60, &sample(8, 2)).unwrap();
|
||||
store.record_live(day + 120, &sample(8, 8)).unwrap();
|
||||
store.record_live(day + HOUR, &sample(2, 0)).unwrap();
|
||||
|
||||
let now = day + HOUR + 30;
|
||||
let h = store.live_history(Range::Day, now).unwrap();
|
||||
assert_eq!(h.series, [(day, 6.7, 8), (day + HOUR, 2.0, 2)]);
|
||||
// The first minute that reached 8.
|
||||
assert_eq!(
|
||||
h.peak,
|
||||
Some(Peak {
|
||||
playing: 8,
|
||||
at: day + 60
|
||||
})
|
||||
);
|
||||
assert_eq!(h.all_time, h.peak);
|
||||
assert_eq!(h.platforms["linux"], 2.8);
|
||||
assert_eq!(h.platforms["macos"], 2.8);
|
||||
assert!(!h.platforms.contains_key("windows"));
|
||||
|
||||
let week = store.live_history(Range::Week, now).unwrap();
|
||||
assert_eq!(week.series, [(day, 6.7, 8), (day + HOUR, 2.0, 2)]);
|
||||
assert_eq!(week.peak, h.peak);
|
||||
let all = store.live_history(Range::All, now).unwrap();
|
||||
assert_eq!(all.series, [(day, 5.5, 8)]);
|
||||
assert_eq!(all.step, DAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_minutes_and_hours_go_and_days_stay() {
|
||||
let mut store = Store::open(":memory:").unwrap();
|
||||
let start = 20_000 * DAY;
|
||||
store.record_live(start, &sample(30, 0)).unwrap();
|
||||
let later = start + 100 * DAY;
|
||||
store.record_live(later, &sample(3, 0)).unwrap();
|
||||
assert_eq!(store.prune_live(later, 7, 90).unwrap(), 2);
|
||||
let all = store.live_history(Range::All, later).unwrap();
|
||||
assert_eq!(all.series, [(start, 30.0, 30), (later, 3.0, 3)]);
|
||||
assert_eq!(all.all_time.unwrap().playing, 30);
|
||||
// The 24 hours hold only today; the all-time peak is still the old one.
|
||||
let day = store.live_history(Range::Day, later + 60).unwrap();
|
||||
assert_eq!(day.series, [(later, 3.0, 3)]);
|
||||
assert_eq!(day.peak.unwrap().playing, 3);
|
||||
assert_eq!(day.all_time.unwrap().at, start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_history_has_no_peak() {
|
||||
let store = Store::open(":memory:").unwrap();
|
||||
let h = store.live_history(Range::Month, 20_000 * DAY).unwrap();
|
||||
assert!(h.series.is_empty());
|
||||
assert_eq!((h.peak, h.all_time), (None, None));
|
||||
assert!(h.platforms.is_empty());
|
||||
assert_eq!(Range::parse("30d"), Some(Range::Month));
|
||||
assert_eq!(Range::parse("1y"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
//! - `GET /v1/benchmarks/summary`: players' benchmark results grouped by GPU and CPU, groups of at
|
||||
//! least 3 installs only (`summary`), cached; the website's Benchmarks page reads it.
|
||||
//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only).
|
||||
//! - `GET /v1/live`: how many play now, rounded, cached briefly.
|
||||
//! - `GET /v1/live`: how many play now, exactly, cached briefly.
|
||||
//! - `GET /v1/live/history?range=24h|7d|30d|all`: that count over time, written down once a minute
|
||||
//! (`history`: totals only), cached a minute.
|
||||
//! - `/hub/…`: the hub's votes (`hub`) and bug reports (`intake`).
|
||||
//! - `GET /healthz`.
|
||||
//!
|
||||
@@ -30,6 +32,7 @@ use horizon_telemetry::batch;
|
||||
|
||||
pub mod classes;
|
||||
pub mod github;
|
||||
pub mod history;
|
||||
pub mod hub;
|
||||
pub mod intake;
|
||||
pub mod live;
|
||||
@@ -62,6 +65,10 @@ pub struct Config {
|
||||
pub live_window: Duration,
|
||||
/// Sessions the live count holds at most; a new one past that is refused.
|
||||
pub live_max_sessions: usize,
|
||||
/// Days the live count's minute samples are kept (`history`).
|
||||
pub live_minute_days: u32,
|
||||
/// Days its hourly totals are kept; at least 31, for the 30-day view. Daily ones stay.
|
||||
pub live_hour_days: u32,
|
||||
/// The hub's votes and reads of their counts an IP address may make an hour.
|
||||
pub hub_per_hour: f64,
|
||||
/// Bug reports an IP address may send an hour.
|
||||
@@ -88,6 +95,8 @@ impl Default for Config {
|
||||
live_per_hour: 600.0,
|
||||
live_window: horizon_telemetry::live::WINDOW,
|
||||
live_max_sessions: 200_000,
|
||||
live_minute_days: 7,
|
||||
live_hour_days: 90,
|
||||
hub_per_hour: 600.0,
|
||||
hub_posts_per_hour: 20.0,
|
||||
hub_reports_per_day: 30,
|
||||
@@ -101,7 +110,8 @@ impl Default for Config {
|
||||
impl Config {
|
||||
/// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`,
|
||||
/// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`,
|
||||
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_HUB_PER_HOUR`,
|
||||
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_LIVE_MINUTE_DAYS`,
|
||||
/// `…_LIVE_HOUR_DAYS`, `…_HUB_PER_HOUR`,
|
||||
/// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_KEEP_DAYS`, `…_GITHUB_TOKEN` and
|
||||
/// `…_GITHUB_REPO`. An empty token counts as unset.
|
||||
pub fn from_env() -> Self {
|
||||
@@ -136,6 +146,12 @@ impl Config {
|
||||
live_max_sessions: var("LIVE_MAX_SESSIONS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.live_max_sessions),
|
||||
live_minute_days: var("LIVE_MINUTE_DAYS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.live_minute_days),
|
||||
live_hour_days: var("LIVE_HOUR_DAYS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.live_hour_days),
|
||||
hub_per_hour: var("HUB_PER_HOUR")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.hub_per_hour),
|
||||
@@ -165,6 +181,10 @@ pub struct App {
|
||||
live: Mutex<Live>,
|
||||
/// `GET /v1/live`'s answer and when it was made.
|
||||
live_cache: Mutex<Option<(Instant, String)>>,
|
||||
/// `GET /v1/live/history`'s answers by range, with when they were made.
|
||||
history_cache: Mutex<HashMap<history::Range, (Instant, String)>>,
|
||||
/// When the server started: the live count is whole only a window later.
|
||||
started: Instant,
|
||||
/// `/v1/aggregate` answers by `days`, with when they were made.
|
||||
cache: Mutex<HashMap<u32, (Instant, String)>>,
|
||||
/// `/v1/benchmarks/summary`'s answer and when it was made.
|
||||
@@ -223,6 +243,8 @@ impl App {
|
||||
live_buckets: Mutex::default(),
|
||||
live: Mutex::new(live),
|
||||
live_cache: Mutex::default(),
|
||||
history_cache: Mutex::default(),
|
||||
started: Instant::now(),
|
||||
cache: Mutex::default(),
|
||||
summary_cache: Mutex::default(),
|
||||
today,
|
||||
@@ -292,6 +314,22 @@ impl App {
|
||||
*self.summary_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// Writes down the live count as this minute's (`history`), once the server has run a whole
|
||||
/// window: before that, games that play haven't all pinged it yet. True when it did.
|
||||
pub fn record_live(&self) -> rusqlite::Result<bool> {
|
||||
if self.started.elapsed() < self.config.live_window {
|
||||
return Ok(false);
|
||||
}
|
||||
let count = self.live().count(Instant::now());
|
||||
self.record_sample(unix_now(), &history::Sample::of(&count))
|
||||
}
|
||||
|
||||
/// Writes down `sample` as the count of the minute holding `unix` (seconds). False when that
|
||||
/// minute has one already.
|
||||
pub fn record_sample(&self, unix: i64, sample: &history::Sample) -> rusqlite::Result<bool> {
|
||||
self.store().record_live(unix, sample)
|
||||
}
|
||||
|
||||
/// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live
|
||||
/// sessions forgotten, the hub's old reports removed. Reports still waiting for
|
||||
/// their issue are [`Self::file_pending`]'s.
|
||||
@@ -307,6 +345,11 @@ impl App {
|
||||
.retain(|_, (_, at)| at.elapsed() < Duration::from_secs(3600));
|
||||
}
|
||||
self.live().forget_expired(Instant::now());
|
||||
self.store().prune_live(
|
||||
unix_now(),
|
||||
self.config.live_minute_days,
|
||||
self.config.live_hour_days.max(31),
|
||||
)?;
|
||||
if removed > 0 {
|
||||
self.forget_cached();
|
||||
}
|
||||
@@ -483,11 +526,16 @@ async fn benchmarks(State(app): State<Arc<App>>) -> Response {
|
||||
json_response(&app, json, CACHE_FOR.as_secs())
|
||||
}
|
||||
|
||||
/// Now in seconds since 1970.
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() as i64)
|
||||
}
|
||||
|
||||
/// Now as `YYYY-MM-DDTHH:MM:SSZ`.
|
||||
fn utc_now() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let secs = unix_now() as u64;
|
||||
let (days, rest) = (secs / 86_400, secs % 86_400);
|
||||
// Days since 1970 to a civil date (Howard Hinnant's algorithm).
|
||||
let z = days as i64 + 719_468;
|
||||
@@ -529,7 +577,7 @@ async fn ping(State(app): State<Arc<App>>, req: Request) -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/live`: the rounded live count (`horizon_telemetry::live::Count`), made again at
|
||||
/// `GET /v1/live`: the exact live count (`horizon_telemetry::live::Count`), made again at
|
||||
/// most every eighth of the window.
|
||||
async fn playing(State(app): State<Arc<App>>, req: Request) -> Response {
|
||||
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
||||
@@ -551,6 +599,54 @@ async fn playing(State(app): State<Arc<App>>, req: Request) -> Response {
|
||||
json_response(&app, json, fresh_for.as_secs())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct HistoryQuery {
|
||||
range: Option<String>,
|
||||
}
|
||||
|
||||
/// How long a history answer is reused: a new sample comes each minute.
|
||||
const HISTORY_FOR: Duration = Duration::from_secs(60);
|
||||
|
||||
/// `GET /v1/live/history?range=24h|7d|30d|all` (default 24h): the live count over time
|
||||
/// (`history::History`), made again at most every minute. 400 for another range.
|
||||
async fn live_history(
|
||||
State(app): State<Arc<App>>,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
if !app.allow_live(app.client(req.headers(), peer(&req))) {
|
||||
return status(StatusCode::TOO_MANY_REQUESTS, "too many requests");
|
||||
}
|
||||
let Some(range) = history::Range::parse(query.range.as_deref().unwrap_or("24h")) else {
|
||||
return status(StatusCode::BAD_REQUEST, "range: 24h, 7d, 30d or all");
|
||||
};
|
||||
let cached = app
|
||||
.history_cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&range)
|
||||
.filter(|(at, _)| at.elapsed() < HISTORY_FOR)
|
||||
.map(|(_, json)| json.clone());
|
||||
let json = match cached {
|
||||
Some(json) => json,
|
||||
None => match app.store().live_history(range, unix_now()) {
|
||||
Ok(history) => {
|
||||
let json = serde_json::to_string(&history).unwrap_or_default();
|
||||
app.history_cache
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(range, (Instant::now(), json.clone()));
|
||||
json
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("horizon-telemetry-server: could not read the live history: {e}");
|
||||
return status(StatusCode::SERVICE_UNAVAILABLE, "try again later");
|
||||
}
|
||||
},
|
||||
};
|
||||
json_response(&app, json, HISTORY_FOR.as_secs())
|
||||
}
|
||||
|
||||
fn json_response(app: &App, json: String, max_age: u64) -> Response {
|
||||
let origin =
|
||||
HeaderValue::from_str(&app.config.allow_origin).unwrap_or(HeaderValue::from_static("*"));
|
||||
@@ -574,6 +670,7 @@ pub fn router(app: Arc<App>) -> Router {
|
||||
Router::new()
|
||||
.route("/v1/batch", post(receive))
|
||||
.route("/v1/live", post(ping).get(playing))
|
||||
.route("/v1/live/history", get(live_history))
|
||||
.route("/v1/installs/{id}", delete(forget))
|
||||
.route("/v1/aggregate", get(aggregate))
|
||||
.route("/v1/benchmarks/summary", get(benchmarks))
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use horizon_telemetry::live::{Count, PLATFORMS, Ping, round};
|
||||
use horizon_telemetry::live::{Count, PLATFORMS, Ping};
|
||||
|
||||
/// Versions listed by name in a count; the rest are summed under `other`.
|
||||
const VERSIONS_SHOWN: usize = 12;
|
||||
@@ -71,7 +71,7 @@ impl Live {
|
||||
.retain(|_, s| now.saturating_duration_since(s.at) < window);
|
||||
}
|
||||
|
||||
/// Who is playing at `now`, rounded.
|
||||
/// Who is playing at `now`, exactly.
|
||||
pub fn count(&mut self, now: Instant) -> Count {
|
||||
self.forget_expired(now);
|
||||
let mut versions: HashMap<&str, u64> = HashMap::new();
|
||||
@@ -88,23 +88,23 @@ impl Live {
|
||||
let mut other = 0;
|
||||
for (i, (version, n)) in by_count.into_iter().enumerate() {
|
||||
if i < VERSIONS_SHOWN {
|
||||
shown.insert(version.to_owned(), round(n));
|
||||
shown.insert(version.to_owned(), n);
|
||||
} else {
|
||||
other += n;
|
||||
}
|
||||
}
|
||||
if other > 0 {
|
||||
*shown.entry("other".to_owned()).or_default() += round(other);
|
||||
*shown.entry("other".to_owned()).or_default() += other;
|
||||
}
|
||||
Count {
|
||||
playing: round(self.sessions.len() as u64),
|
||||
lan: round(lan),
|
||||
playing: self.sessions.len() as u64,
|
||||
lan,
|
||||
versions: shown,
|
||||
platforms: PLATFORMS
|
||||
.iter()
|
||||
.zip(platforms)
|
||||
.filter(|(_, n)| *n > 0)
|
||||
.map(|(p, n)| ((*p).to_owned(), round(n)))
|
||||
.map(|(p, n)| ((*p).to_owned(), n))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -178,10 +178,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rare_versions_are_summed_and_counts_rounded() {
|
||||
fn rare_versions_are_summed_and_counts_exact() {
|
||||
let t = Instant::now();
|
||||
let mut live = Live::new(Duration::from_secs(120), 1000);
|
||||
for n in 0..40 {
|
||||
for n in 0..41 {
|
||||
live.ping(&ping(n, "0.2.0", "windows", n % 4 == 0), t)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -190,12 +190,13 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
let count = live.count(t);
|
||||
assert_eq!(count.playing, 55);
|
||||
assert_eq!(count.lan, 10);
|
||||
assert_eq!(count.versions["0.2.0"], 40);
|
||||
// Exact to the unit: 41 and 56 stay 41 and 56.
|
||||
assert_eq!(count.playing, 56);
|
||||
assert_eq!(count.lan, 11);
|
||||
assert_eq!(count.versions["0.2.0"], 41);
|
||||
assert_eq!(count.versions["other"], 4);
|
||||
assert_eq!(count.versions.len(), VERSIONS_SHOWN + 1);
|
||||
assert_eq!(count.platforms["windows"], 40);
|
||||
assert_eq!(count.platforms["windows"], 41);
|
||||
assert_eq!(count.platforms["other"], 15);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `horizon-telemetry-server`: serves on `PORT` (default 8080), keeps its data in
|
||||
//! `HORIZON_TELEMETRY_DB` (default `telemetry.sqlite3`), and does its upkeep every ten minutes.
|
||||
//! `HORIZON_TELEMETRY_DB` (default `telemetry.sqlite3`), writes the live count down every minute
|
||||
//! and does its upkeep every ten minutes.
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -27,6 +28,23 @@ async fn main() {
|
||||
let _ = tokio::task::spawn_blocking(move || filing.file_pending()).await;
|
||||
}
|
||||
});
|
||||
// The live count's history: one sample a minute (`history`).
|
||||
let recorder = app.clone();
|
||||
tokio::spawn(async move {
|
||||
// A few seconds into each minute, so each tick lands in a minute of its own.
|
||||
let into_minute = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs() % 60);
|
||||
let first = tokio::time::Instant::now() + Duration::from_secs((65 - into_minute) % 60);
|
||||
let mut every = tokio::time::interval_at(first, Duration::from_secs(60));
|
||||
every.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
every.tick().await;
|
||||
if let Err(e) = recorder.record_live() {
|
||||
eprintln!("horizon-telemetry-server: could not record the live count: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("port {port}: {e}"));
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! that `/v1/aggregate` reads. Rebuilt from `perf` when a day changes (new sessions, a
|
||||
//! deletion), and kept after the raw rows expire.
|
||||
//!
|
||||
//! - `live_minute`, `live_hour`, `live_day`: the live count over time (`history`).
|
||||
//!
|
||||
//! Dates are the day the server received an item (days since 1970, UTC). The sender's IP
|
||||
//! address is never stored.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -132,6 +134,7 @@ impl Store {
|
||||
db.execute_batch(SCHEMA)?;
|
||||
db.execute_batch(crate::hub::SCHEMA)?;
|
||||
db.execute_batch(crate::intake::SCHEMA)?;
|
||||
db.execute_batch(crate::history::SCHEMA)?;
|
||||
migrate(&mut db)?;
|
||||
Ok(Self { db })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use horizon_telemetry::live::{self, Count, Ping};
|
||||
use horizon_telemetry_server::history::Sample;
|
||||
use horizon_telemetry_server::store::Store;
|
||||
use horizon_telemetry_server::{App, Config, router};
|
||||
use http_body_util::BodyExt;
|
||||
@@ -188,3 +189,88 @@ async fn the_games_ping_and_the_launchers_read_reach_it_end_to_end() {
|
||||
assert_eq!(count.versions.len(), 2);
|
||||
assert_eq!(count.platforms[live::platform()], 2);
|
||||
}
|
||||
|
||||
async fn history(app: &Arc<App>, range: &str) -> (StatusCode, axum::http::HeaderMap, String) {
|
||||
let req = Request::get(format!("/v1/live/history?range={range}"))
|
||||
.header("x-forwarded-for", "198.51.100.2")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
send(app, req).await
|
||||
}
|
||||
|
||||
fn unix_now() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_history_serves_the_recorded_minutes_and_peaks() {
|
||||
let app = app(quick());
|
||||
let now = unix_now();
|
||||
let minute = now - now % 60;
|
||||
let at = |playing: u64, linux: u64| Sample {
|
||||
playing,
|
||||
lan: 1,
|
||||
platforms: [linux, 0, playing - linux, 0],
|
||||
};
|
||||
// An old peak, three days ago, then today's minutes.
|
||||
assert!(
|
||||
app.record_sample(minute - 3 * 86_400, &at(1234, 4))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(app.record_sample(minute - 120, &at(7, 2)).unwrap());
|
||||
assert!(app.record_sample(minute - 60, &at(9, 3)).unwrap());
|
||||
assert!(!app.record_sample(minute - 30, &at(50, 0)).unwrap());
|
||||
|
||||
let (code, headers, body) = history(&app, "24h").await;
|
||||
assert_eq!(code, StatusCode::OK, "{body}");
|
||||
assert_eq!(headers[header::CACHE_CONTROL], "public, max-age=60");
|
||||
assert_eq!(
|
||||
headers[header::ACCESS_CONTROL_ALLOW_ORIGIN],
|
||||
"https://playhorizon.dev"
|
||||
);
|
||||
let day: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(day["range"], "24h");
|
||||
assert_eq!(day["step"], 300);
|
||||
assert_eq!(day["peak"]["playing"], 9);
|
||||
assert_eq!(day["peak"]["at"], minute - 60);
|
||||
assert_eq!(day["all_time"]["playing"], 1234);
|
||||
assert_eq!(day["all_time"]["at"], minute - 3 * 86_400);
|
||||
let points = day["series"].as_array().unwrap();
|
||||
let total: u64 = points.iter().map(|p| p[2].as_u64().unwrap()).max().unwrap();
|
||||
assert_eq!(total, 9);
|
||||
assert_eq!(day["platforms"]["linux"], 2.5);
|
||||
assert_eq!(day["platforms"]["windows"], 5.5);
|
||||
|
||||
let (_, _, body) = history(&app, "7d").await;
|
||||
let week: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(week["step"], 3600);
|
||||
assert_eq!(week["peak"]["playing"], 1234);
|
||||
let (_, _, body) = history(&app, "all").await;
|
||||
let all: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(all["step"], 86_400);
|
||||
assert!(!all["series"].as_array().unwrap().is_empty());
|
||||
|
||||
assert_eq!(history(&app, "1y").await.0, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_recorder_waits_a_window_then_writes_the_exact_count() {
|
||||
let app = app(Config {
|
||||
live_window: Duration::from_millis(400),
|
||||
..quick()
|
||||
});
|
||||
// Just started: games haven't all pinged yet.
|
||||
assert!(!app.record_live().unwrap());
|
||||
tokio::time::sleep(Duration::from_millis(450)).await;
|
||||
for n in 0..3 {
|
||||
post(&app, ping(n, "0.2.0", n == 0), "203.0.113.7").await;
|
||||
}
|
||||
assert!(app.record_live().unwrap());
|
||||
let (_, _, body) = history(&app, "24h").await;
|
||||
let day: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
assert_eq!(day["peak"]["playing"], 3);
|
||||
assert_eq!(day["platforms"]["linux"], 3.0);
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
//! made when the game starts and kept only in its memory (not the install ID), the Horizon
|
||||
//! version, the platform and whether a LAN or play-with-friends world is open. The game sends
|
||||
//! one every [`INTERVAL`]; the server counts a session while its last ping is under
|
||||
//! [`WINDOW`] old, keeps nothing on disk and serves the counts rounded ([`round`]).
|
||||
//! [`WINDOW`] old and serves the counts exactly. It writes down only the totals, once a minute,
|
||||
//! for the history on playhorizon.dev/stats: never a session.
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -86,7 +87,7 @@ pub fn platform() -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live count, as `GET /v1/live` serves it: every number already [`round`]ed.
|
||||
/// The live count, as `GET /v1/live` serves it: exact numbers.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Count {
|
||||
@@ -100,19 +101,6 @@ pub struct Count {
|
||||
pub platforms: BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
/// Rounds a count for showing: exact under 10, then to the nearest 5 under 100, the nearest 10
|
||||
/// under 1000 and the nearest 100 above. Small numbers stay true; large ones don't pretend to
|
||||
/// a precision a two-minute window doesn't have.
|
||||
pub fn round(n: u64) -> u64 {
|
||||
let step = match n {
|
||||
0..10 => return n,
|
||||
10..100 => 5,
|
||||
100..1000 => 10,
|
||||
_ => 100,
|
||||
};
|
||||
(n + step / 2) / step * step
|
||||
}
|
||||
|
||||
/// Sends `ping` to `endpoint` (`https://host`, without a trailing slash).
|
||||
#[cfg(feature = "live")]
|
||||
pub fn send(endpoint: &str, ping: &Ping) -> Result<(), String> {
|
||||
@@ -189,27 +177,4 @@ mod tests {
|
||||
let extra = r#"{"session":"00000000000000000000000000000000","version":"0.2.0","platform":"linux","lan":false,"name":"Steve"}"#;
|
||||
assert!(serde_json::from_str::<Ping>(extra).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_round_more_as_they_grow() {
|
||||
let cases = [
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(9, 9),
|
||||
(10, 10),
|
||||
(12, 10),
|
||||
(13, 15),
|
||||
(97, 95),
|
||||
(98, 100),
|
||||
(104, 100),
|
||||
(105, 110),
|
||||
(994, 990),
|
||||
(995, 1000),
|
||||
(1049, 1000),
|
||||
(1050, 1100),
|
||||
];
|
||||
for (n, shown) in cases {
|
||||
assert_eq!(round(n), shown, "{n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-3
@@ -80,13 +80,24 @@ While the game runs (not a dedicated server, a benchmark or a run that quits by
|
||||
- `version` is the release, `platform` is `linux`, `macos`, `windows` or `other`, and `lan` says whether a LAN or play-with-friends world is open.
|
||||
- Nothing else: no account, install ID, world, server, settings or hardware. The server checks each field (`live::Ping::check`) and refuses anything more.
|
||||
|
||||
The server keeps sessions in memory only, never on disk, and never with the sender's address. A session counts while its last ping is under 2 minutes old. `GET /v1/live` gives the count, rounded (`live::round`: exact under 10, then to 5, 10 and 100):
|
||||
The server keeps sessions in memory only, never on disk, and never with the sender's address. A session counts while its last ping is under 2 minutes old. `GET /v1/live` gives the exact count; versions past the 12 most played are summed under `other`:
|
||||
|
||||
```json
|
||||
{ "playing": 1240, "lan": 85, "versions": { "v2026.10.22": 1100, "other": 140 }, "platforms": { "linux": 300, "macos": 240, "windows": 700 } }
|
||||
{ "playing": 1243, "lan": 87, "versions": { "v2026.10.22": 1102, "other": 141 }, "platforms": { "linux": 301, "macos": 238, "windows": 704 } }
|
||||
```
|
||||
|
||||
The launcher reads it when it opens and every minute after, and shows "N playing now" on the Play page. It shows nothing when the server can't be reached. `HORIZON_TELEMETRY_URL=off` turns both off; `HORIZON_NO_HEARTBEAT=1` keeps a single automated run out of the count.
|
||||
Once a minute the server writes down only those totals (playing, LAN, per platform; no session, version or address) for the history at [playhorizon.dev/stats](https://playhorizon.dev/stats): every minute for 7 days, then per hour (average and peak) for 90 days, then per day (average and peak) for good. `GET /v1/live/history?range=24h|7d|30d|all` serves it:
|
||||
|
||||
```json
|
||||
{ "range": "24h", "step": 300, "from": 1793404800, "to": 1793491260,
|
||||
"series": [[1793404800, 41.2, 44], [1793405100, 43.0, 47]],
|
||||
"peak": { "playing": 1243, "at": 1793480400 }, "all_time": { "playing": 1243, "at": 1793480400 },
|
||||
"platforms": { "linux": 290.5, "macos": 230.1, "windows": 690.4 } }
|
||||
```
|
||||
|
||||
`series` holds `[start, average, peak]` per point (5 minutes for 24h, an hour for 7d, 3 hours for 30d, a day for all; UTC Unix seconds), `peak` the range's highest minute, `all_time` the highest ever, `platforms` the range's average per platform.
|
||||
|
||||
The launcher reads the count when it opens and every minute after, and shows "N playing now" on the Play page, exact, with thousands separators; clicking it opens playhorizon.dev/stats. It shows nothing when the server can't be reached. `HORIZON_TELEMETRY_URL=off` turns both off; `HORIZON_NO_HEARTBEAT=1` keeps a single automated run out of the count.
|
||||
|
||||
## Server
|
||||
|
||||
@@ -95,6 +106,7 @@ The launcher reads it when it opens and every minute after, and shows "N playing
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class.
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
- `GET /v1/live/history?range=24h|7d|30d|all`: the live count over time (above).
|
||||
|
||||
Public output never holds a raw hardware value, only these classes (`src/classes.rs`, the same in the website's `scripts/benchmarks.mjs`, horizon-rust/horizon-site), and a class is published only with at least 3 different installs:
|
||||
|
||||
|
||||
+19
-4
@@ -41,9 +41,21 @@ impl Launcher {
|
||||
));
|
||||
}
|
||||
what.push(tr(if prepared { "Ready" } else { "Needs preparing" }).to_string());
|
||||
if let Some(playing) = self.playing {
|
||||
what.push(format!("{} playing now", thousands(playing)));
|
||||
}
|
||||
// The exact count, a link to the page with its history and peaks.
|
||||
let ink = c(n.ink);
|
||||
let playing = self.playing.map(|count| {
|
||||
let label = format!("· {} playing now", thousands(count));
|
||||
match telemetry::stats_url(&self.links.website) {
|
||||
Some(url) => div()
|
||||
.id("playing-now")
|
||||
.cursor_pointer()
|
||||
.hover(move |s| s.text_color(ink))
|
||||
.child(label)
|
||||
.on_click(move |_: &ClickEvent, _, cx| cx.open_url(&url))
|
||||
.into_any_element(),
|
||||
None => div().child(label).into_any_element(),
|
||||
}
|
||||
});
|
||||
let state = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
@@ -57,7 +69,10 @@ impl Launcher {
|
||||
.child(
|
||||
shell::text(night::CAPTION, c(n.ink_soft))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.child(what.join(" · ")),
|
||||
.flex()
|
||||
.gap(px(4.))
|
||||
.child(what.join(" · "))
|
||||
.children(playing),
|
||||
);
|
||||
// Never squeezed by the rows below: the ping notice can make it tall.
|
||||
let mut hero = div()
|
||||
|
||||
@@ -45,7 +45,7 @@ pub const NEVER: &str = "Never sent: your account name or UUID, world names, ser
|
||||
pub const PING_NAME: &str = "\"I'm playing\" ping";
|
||||
pub const PING: &str = "While you play, the game sends an anonymous \"I'm playing\" ping once a minute, so the launcher can show how many people play Horizon right now. It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your OS (Linux, macOS or Windows), and whether you have a world open to LAN or friends. It doesn't hold: your account, your install ID, world or server names, or anything you do in the game, and the server doesn't keep your IP address. It is sent whatever the switches say.";
|
||||
|
||||
/// How many play Horizon now (rounded), if the server says.
|
||||
/// How many play Horizon now (exact), if the server says.
|
||||
pub fn playing_now() -> Option<u64> {
|
||||
let endpoint = horizon_telemetry::endpoint()?;
|
||||
horizon_telemetry::live::fetch(&endpoint)
|
||||
@@ -53,6 +53,14 @@ pub fn playing_now() -> Option<u64> {
|
||||
.map(|count| count.playing)
|
||||
}
|
||||
|
||||
/// The page with the count over time and its peaks, on the website `links.json` names; None when
|
||||
/// it names none.
|
||||
pub fn stats_url(website: &str) -> Option<String> {
|
||||
let site = website.trim().trim_end_matches('/');
|
||||
site.starts_with("https://")
|
||||
.then(|| format!("{site}/stats"))
|
||||
}
|
||||
|
||||
/// Sends [`playing_now`] to the window as `Msg::Playing` now and every minute, on its own
|
||||
/// thread. Nothing at all when sending is turned off.
|
||||
pub fn watch_playing(tx: std::sync::mpsc::Sender<backend::Msg>) {
|
||||
@@ -349,6 +357,20 @@ pub fn set(kinds: &[Kind], on: bool) -> Result<Consent, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_stats_page_is_on_the_website() {
|
||||
assert_eq!(
|
||||
stats_url("https://playhorizon.dev").as_deref(),
|
||||
Some("https://playhorizon.dev/stats")
|
||||
);
|
||||
assert_eq!(
|
||||
stats_url("https://example.org/horizon/").as_deref(),
|
||||
Some("https://example.org/horizon/stats")
|
||||
);
|
||||
assert_eq!(stats_url(""), None);
|
||||
assert_eq!(stats_url("javascript:alert(1)"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_are_named_in_plain_words_or_short() {
|
||||
let names = |n: &[&str]| parse_kinds(&n.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
|
||||
Reference in new issue
Block a user