diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6577d49e..8966854c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,10 @@ concurrency: # A new push to a pull request replaces its running checks; main's runs all finish (releases follow them). group: checks-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). + HORIZON_TELEMETRY_URL: "off" + jobs: verify: runs-on: ubuntu-latest diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 3346e1986..02c61cdd1 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -49,6 +49,10 @@ concurrency: group: launcher-${{ github.ref == 'refs/heads/main' && 'release' || github.ref }} cancel-in-progress: false +env: + # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). + HORIZON_TELEMETRY_URL: "off" + jobs: decide: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 689cdae59..51f9e89cd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,6 +23,10 @@ permissions: concurrency: group: nightly-audit cancel-in-progress: false +env: + # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). + HORIZON_TELEMETRY_URL: "off" + jobs: audit: runs-on: [self-hosted, horizon-game] diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 7289fe4de..a18fc5019 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -15,6 +15,10 @@ on: required: true permissions: contents: read +env: + # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). + HORIZON_TELEMETRY_URL: "off" + jobs: parity: runs-on: [self-hosted, horizon-game] diff --git a/.github/workflows/run-parity.yml b/.github/workflows/run-parity.yml index fd3cce6f8..a2b8e3291 100644 --- a/.github/workflows/run-parity.yml +++ b/.github/workflows/run-parity.yml @@ -11,6 +11,10 @@ permissions: concurrency: group: run-parity-${{ github.event.pull_request.number }} cancel-in-progress: true +env: + # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md). + HORIZON_TELEMETRY_URL: "off" + jobs: parity: if: github.event.label.name == 'run-parity' diff --git a/crates/horizon-client/src/heartbeat.rs b/crates/horizon-client/src/heartbeat.rs index f73c7131f..9f3f3a5a6 100644 --- a/crates/horizon-client/src/heartbeat.rs +++ b/crates/horizon-client/src/heartbeat.rs @@ -2,8 +2,8 @@ //! launcher's "N playing now". While the game runs, a thread of its own sends a random session //! ID made here and kept only in memory, the version, the platform and whether a LAN or //! play-with-friends world is open, every minute and at once when that last one changes. -//! It sends nothing when there is no server (`HORIZON_TELEMETRY_URL`, none by default) or -//! with `HORIZON_NO_HEARTBEAT=1` (automated runs). +//! It sends nothing when sending is turned off (`HORIZON_TELEMETRY_URL=off`) or with +//! `HORIZON_NO_HEARTBEAT=1` (automated runs). use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::Thread; diff --git a/crates/horizon-telemetry-server/README.md b/crates/horizon-telemetry-server/README.md index dc1f51248..ed54024a1 100644 --- a/crates/horizon-telemetry-server/README.md +++ b/crates/horizon-telemetry-server/README.md @@ -5,7 +5,7 @@ This server receives what players choose to send (performance summaries, crash a cargo run --release # in this folder: its own workspace, like the launcher's PORT=8080 HORIZON_TELEMETRY_DB=/data/telemetry.sqlite3 horizon-telemetry-server -The launcher sends to it when `HORIZON_TELEMETRY_URL` is set (and to `DEFAULT_ENDPOINT` in `crates/horizon-telemetry` once there is one). +It runs at **https://telemetry.playhorizon.dev** (`DEFAULT_ENDPOINT` in `crates/horizon-telemetry`). The game and the launcher use it unless `HORIZON_TELEMETRY_URL` names another server, or is `off` (tests and automated runs send nothing). ## Endpoints @@ -14,11 +14,34 @@ The launcher sends to it when `HORIZON_TELEMETRY_URL` is set (and to `DEFAULT_EN | `POST /v1/batch` | A zstd batch (`Content-Encoding: zstd`, at most 256 KiB, 1 MiB inflated, 64 items). Returns 204. Returns 400 for a bad batch, 413 for one too large, 415 if it is not zstd, 429 when over a limit. | | `DELETE /v1/installs/` | Deletes everything that install sent. Its share of the daily totals is rebuilt at once. | | `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 /healthz` | `ok` | -The totals are grouped per version, OS, GPU vendor and render distance. Each group has its sessions, install-days, hours played, the 14 frame-time buckets summed, and the mean p50 and p99 frame times. Groups with fewer than 5 install-days are left out. The totals also list crash counts per version and the newest 50 benchmark uploads. +The totals are grouped per version, OS, GPU vendor and render distance. Each group has its sessions, install-days, hours played, the 14 frame-time buckets summed, and the mean p50 and p99 frame times. Groups with fewer than 5 install-days are left out. The totals also list crash counts per version. Benchmark uploads are published only through the summary below, never one by one. + +## Benchmarks summary + +`src/summary.rs` makes it with the same rules as `website/scripts/benchmarks.mjs`, which bakes the page's fallback copy; both are tested against the same fixtures (`website/scripts/fixtures/benchmarks/`). + +- Only reports that compare: a version 1 `horizon-benchmark-report` of the standard flight over the four scenes, VSync off, no frame cap, on screen, from Horizon or plain vanilla, naming its GPU and CPU. +- A group (GPU, CPU, render distance, game) is published only with **at least 3 different installs**. Each install counts once: the median of its runs, then the median across installs. +- Out go the names, the counts, the newest three versions and the medians. Never an install ID, a day, an OS version, a driver, a memory size or a screen. + +```json +{ + "format": "horizon-benchmark-summary", "version": 1, "generated": "2026-10-22T20:00:00Z", "min_installs": 3, + "groups": [{ + "gpu": "NVIDIA GeForce RTX 4080", "cpu": "13th Gen Intel Core i7-13700KF", "render_distance": 12, + "game": "horizon", "source": "players", "installs": 3, "reports": 4, "versions": ["v0.2.1", "v0.2.0"], + "medians": { "avg_fps": 515, "low_1pct_fps": 120, "p95_ms": 3.5, "world_ms": 4500, + "chunk_load_ms": 1400, "rss_peak_mb": 1450, "rss_steady_mb": 1250 } + }] +} +``` + +Frame rates are whole numbers, milliseconds have one decimal under 10, megabytes are whole. Groups come most installs first. A deleted install leaves its groups at once. ## Storage @@ -45,7 +68,7 @@ All are environment variables: - `HORIZON_TELEMETRY_MIN_INSTALL_DAYS` (5) - `HORIZON_TELEMETRY_REQUESTS_PER_HOUR` per address (60, bursts of 6) - `HORIZON_TELEMETRY_ITEMS_PER_DAY` per install (100) -- `HORIZON_TELEMETRY_TRUST_PROXY=1` behind a proxy (the client's address is then the first of `X-Forwarded-For`) +- `HORIZON_TELEMETRY_TRUST_PROXY=1` behind a proxy (the client's address is then the proxy's `X-Real-IP`, else the last `X-Forwarded-For` entry; it is used only for the rate limits, in memory, and never stored or logged) - `HORIZON_TELEMETRY_ALLOW_ORIGIN` (`https://playhorizon.dev`) - `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) @@ -55,6 +78,14 @@ All are environment variables: `Dockerfile` builds it from the repository root (`docker build -f crates/horizon-telemetry-server/Dockerfile .`). The database goes on a volume mounted at `/data`. +On Railway it is a service of the same project as the website: + +- Source: this repository, root directory `/` (the Dockerfile needs the shared crate), config file `/crates/horizon-telemetry-server/railway.json` (Dockerfile build, health check `/healthz`, one replica in `europe-west4`, Amsterdam: one SQLite file can't be shared). +- A volume mounted at `/data`. The Dockerfile already sets `HORIZON_TELEMETRY_DB=/data/telemetry.sqlite3` and `HORIZON_TELEMETRY_TRUST_PROXY=1`; Railway sets `PORT`. +- The custom domain `telemetry.playhorizon.dev`: a `CNAME` at Porkbun to the target Railway gives. + +Check it with `curl https://telemetry.playhorizon.dev/healthz`. + At Railway's prices (RAM $10/GB-month, CPU $20/vCPU-month, volumes $0.15/GB-month, egress $0.05/GB), one more service with a 1 GB volume uses about $0.35 a month: - RAM about 15 MB: $0.15 diff --git a/crates/horizon-telemetry-server/railway.json b/crates/horizon-telemetry-server/railway.json new file mode 100644 index 000000000..30eb18c33 --- /dev/null +++ b/crates/horizon-telemetry-server/railway.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "crates/horizon-telemetry-server/Dockerfile", + "watchPatterns": [ + "/crates/horizon-telemetry/**", + "/crates/horizon-telemetry-server/**", + "/Cargo.toml" + ] + }, + "deploy": { + "healthcheckPath": "/healthz", + "healthcheckTimeout": 60, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5, + "multiRegionConfig": { + "europe-west4-drams3a": { "numReplicas": 1 } + } + } +} diff --git a/crates/horizon-telemetry-server/src/lib.rs b/crates/horizon-telemetry-server/src/lib.rs index 061a9ca37..c00002531 100644 --- a/crates/horizon-telemetry-server/src/lib.rs +++ b/crates/horizon-telemetry-server/src/lib.rs @@ -3,6 +3,8 @@ //! - `POST /v1/batch`: a zstd batch from the launcher, checked by `horizon_telemetry::batch`. //! - `DELETE /v1/installs/`: everything an install sent goes. //! - `GET /v1/aggregate?days=30`: the anonymous totals the website reads, cached. +//! - `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 /healthz`. @@ -26,6 +28,7 @@ use horizon_telemetry::batch; pub mod live; pub mod store; +pub mod summary; use horizon_telemetry::live::{MAX_PING_BYTES, Ping}; use live::Live; @@ -40,7 +43,8 @@ pub struct Config { pub requests_per_hour: f64, /// Items one install may send a day. pub items_per_day: u32, - /// Behind a proxy (Railway, Fly…): the client's address is the first of X-Forwarded-For. + /// Behind a proxy (Railway, Fly…): the client's address is the proxy's `X-Real-IP`, else the + /// last `X-Forwarded-For` entry. pub trust_proxy: bool, /// The website allowed to read the totals from a browser. pub allow_origin: String, @@ -117,6 +121,8 @@ pub struct App { live_cache: Mutex>, /// `/v1/aggregate` answers by `days`, with when they were made. cache: Mutex>, + /// `/v1/benchmarks/summary`'s answer and when it was made. + summary_cache: Mutex>, /// Today, in days since 1970 (a test moves it). today: Box u32 + Send + Sync>, } @@ -148,6 +154,7 @@ impl App { live: Mutex::new(live), live_cache: Mutex::default(), cache: Mutex::default(), + summary_cache: Mutex::default(), today, }) } @@ -170,20 +177,26 @@ impl App { self.live.lock().unwrap_or_else(|e| e.into_inner()) } + /// The address requests are counted under, for the rate limits only: kept in memory, never + /// stored or logged. Behind a proxy (Railway) the TCP peer is the proxy, so it is the proxy's + /// `X-Real-IP`, else the last `X-Forwarded-For` entry: the one the proxy added, which a client + /// can't choose (the first one it can). fn client(&self, headers: &HeaderMap, peer: Option) -> IpAddr { + let header = |name: &str| headers.get(name)?.to_str().ok(); let forwarded = self .config .trust_proxy .then(|| { - headers - .get("x-forwarded-for")? - .to_str() - .ok()? - .split(',') - .next()? - .trim() - .parse() - .ok() + header("x-real-ip") + .and_then(|ip| ip.trim().parse().ok()) + .or_else(|| { + header("x-forwarded-for")? + .rsplit(',') + .next()? + .trim() + .parse() + .ok() + }) }) .flatten(); forwarded @@ -196,6 +209,12 @@ impl App { self.store().newest_day(install).ok().flatten() } + /// Drops the cached public answers (after a deletion or expiry). + fn forget_cached(&self) { + self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear(); + *self.summary_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; + } + /// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live /// sessions forgotten. pub fn maintain(&self) -> rusqlite::Result { @@ -209,7 +228,7 @@ impl App { } self.live().forget_expired(Instant::now()); if removed > 0 { - self.cache.lock().unwrap_or_else(|e| e.into_inner()).clear(); + self.forget_cached(); } Ok(removed) } @@ -288,7 +307,7 @@ async fn forget(State(app): State>, Path(id): Path, req: Reques }; match app.store().delete_install(&install) { Ok(_) => { - app.cache.lock().unwrap_or_else(|e| e.into_inner()).clear(); + app.forget_cached(); StatusCode::NO_CONTENT.into_response() } Err(e) => { @@ -338,6 +357,60 @@ async fn aggregate(State(app): State>, Query(window): Query) -> json_response(&app, json, CACHE_FOR.as_secs()) } +/// `GET /v1/benchmarks/summary`: players' benchmark groups (`summary`), made again at most every +/// ten minutes. +async fn benchmarks(State(app): State>) -> Response { + let cached = app + .summary_cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .filter(|(at, _)| at.elapsed() < CACHE_FOR) + .map(|(_, json)| json.clone()); + let json = match cached { + Some(json) => json, + None => { + let oldest = (app.today)().saturating_sub(app.config.retention_days); + let uploads = match app.store().benchmark_uploads(oldest) { + Ok(uploads) => uploads, + Err(e) => { + eprintln!("horizon-telemetry-server: could not read the benchmarks: {e}"); + return status(StatusCode::SERVICE_UNAVAILABLE, "try again later"); + } + }; + let json = summary::summary(&uploads, summary::MIN_INSTALLS, &utc_now()).to_string(); + *app.summary_cache.lock().unwrap_or_else(|e| e.into_inner()) = + Some((Instant::now(), json.clone())); + json + } + }; + json_response(&app, json, CACHE_FOR.as_secs()) +} + +/// 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 (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; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rest / 3600, + rest / 60 % 60, + rest % 60 + ) +} + /// `POST /v1/live`: one "I'm playing". 204, or 400 for a bad ping, 413 for one too large, 429 /// over the address's limit, 503 when the table is full. async fn ping(State(app): State>, req: Request) -> Response { @@ -407,6 +480,7 @@ pub fn router(app: Arc) -> Router { .route("/v1/live", post(ping).get(playing)) .route("/v1/installs/{id}", delete(forget)) .route("/v1/aggregate", get(aggregate)) + .route("/v1/benchmarks/summary", get(benchmarks)) .route("/healthz", get(|| async { "ok" })) .with_state(app) } diff --git a/crates/horizon-telemetry-server/src/store.rs b/crates/horizon-telemetry-server/src/store.rs index 0bac20e50..5188657ee 100644 --- a/crates/horizon-telemetry-server/src/store.rs +++ b/crates/horizon-telemetry-server/src/store.rs @@ -357,27 +357,33 @@ impl Store { })? .collect::>()? }; - let benchmarks: Vec = { - let mut q = self.db.prepare( - "SELECT day, version, body FROM reports WHERE kind = ?1 AND day BETWEEN ?2 AND ?3 - ORDER BY rowid DESC LIMIT 50", - )?; - q.query_map(params![kind_code(Kind::Benchmark), from, to], |r| { - let body: Vec = r.get(2)?; - Ok(serde_json::json!({ - "day": r.get::<_, u32>(0)?, "version": r.get::<_, String>(1)?, "report": inflate(&body), - })) - })? - .collect::>()? - }; Ok(serde_json::json!({ "from_day": from, "to_day": to, "min_install_days": k, "bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0), "totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 }, - "groups": out, "crashes_by_version": crashes, "benchmarks": benchmarks, + "groups": out, "crashes_by_version": crashes, })) } + /// Every benchmark upload received since `from_day`, with its install (to count installs, never shown) + /// and release, for `summary`. + pub fn benchmark_uploads( + &self, + from_day: u32, + ) -> rusqlite::Result> { + let mut q = self + .db + .prepare("SELECT install, version, body FROM reports WHERE kind = ?1 AND day >= ?2")?; + q.query_map(params![kind_code(Kind::Benchmark), from_day], |r| { + Ok(crate::summary::Upload { + install: r.get(0)?, + version: r.get(1)?, + report: inflate(&r.get::<_, Vec>(2)?), + }) + })? + .collect() + } + /// The day an install's newest row came in, if any (tests, and checking a deletion). pub fn newest_day(&self, install: &[u8]) -> rusqlite::Result> { self.db diff --git a/crates/horizon-telemetry-server/src/summary.rs b/crates/horizon-telemetry-server/src/summary.rs new file mode 100644 index 000000000..6cec9200e --- /dev/null +++ b/crates/horizon-telemetry-server/src/summary.rs @@ -0,0 +1,266 @@ +//! `GET /v1/benchmarks/summary`: players' benchmark uploads, grouped for the website's Benchmarks page. +//! +//! The same rules as `website/scripts/benchmarks.mjs` (which builds the page's baked copy), checked against the +//! same fixtures (`website/scripts/fixtures/benchmarks/`): +//! +//! - Only reports that compare: `horizon-benchmark-report` version 1, kind `game`, Horizon or plain vanilla, the +//! standard flight over the four scenes, VSync off, no frame cap, drawn on a screen, with a GPU and CPU name. +//! - Groups by GPU, CPU, render distance and game. A group is published only with at least `min_installs` +//! different installs, so no one can be picked out. +//! - Each install counts once: the median of its runs first, then the median across installs. +//! - Out go the group's names, its counts, the newest three versions and the medians. Never the install ID, the +//! day, the OS version, the driver, the memory size or the screen. +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use serde_json::{Value, json}; + +pub const FORMAT: &str = "horizon-benchmark-summary"; +pub const MIN_INSTALLS: usize = 3; +pub const SCENES: [&str; 4] = ["village", "forest", "ocean", "mountain"]; +pub const FIELDS: [&str; 7] = [ + "avg_fps", + "low_1pct_fps", + "p95_ms", + "world_ms", + "chunk_load_ms", + "rss_peak_mb", + "rss_steady_mb", +]; + +/// One stored upload: who sent it (only to count installs), the release, and the report. +pub struct Upload { + pub install: Vec, + pub version: String, + pub report: Value, +} + +/// "Intel(R) Core(TM) i7" -> "Intel Core i7", spaces collapsed. +fn trim(s: &str) -> String { + s.replace("(R)", "") + .replace("(TM)", "") + .replace("(tm)", "") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn game(app: &Value) -> Option<&'static str> { + match app.get("name").and_then(Value::as_str) { + Some("horizon-client") => Some("horizon"), + Some("minecraft") => { + let mods = app.get("mods").and_then(Value::as_array); + let plain = mods.is_none_or(|m| { + m.iter() + .all(|m| m.get("name").and_then(Value::as_str) == Some("fabric-loader")) + }); + plain.then_some("vanilla") + } + _ => None, + } +} + +/// Why a report can't be compared with the others, or `None` when it can. +pub fn rejection(report: &Value) -> Option<&'static str> { + if report.get("format").and_then(Value::as_str) != Some("horizon-benchmark-report") + || report.get("version").and_then(Value::as_u64) != Some(1) + { + return Some("not a version 1 benchmark report"); + } + if report.get("kind").and_then(Value::as_str) != Some("game") { + return Some("not a game run"); + } + game(&report["app"])?; + let c = &report["conditions"]; + let scenario = c.get("scenario").and_then(Value::as_str).unwrap_or(""); + if !(scenario == "standard" || scenario == "flight" || scenario.starts_with("perf_bench:")) { + return Some("not the standard flight"); + } + if c.get("vsync") != Some(&Value::Bool(false)) { + return Some("VSync on or unknown"); + } + if c.get("fps_cap").is_some_and(|v| !v.is_null()) { + return Some("frame rate capped"); + } + if c.get("offscreen") == Some(&Value::Bool(true)) { + return Some("offscreen"); + } + if !c.get("render_distance").is_some_and(Value::is_number) { + return Some("no render distance"); + } + let phases: BTreeSet<&str> = report["frames"] + .as_array() + .map(|f| f.iter().filter_map(|f| f["phase"].as_str()).collect()) + .unwrap_or_default(); + if !SCENES.iter().all(|s| phases.contains(s)) { + return Some("not every scene"); + } + let named = |v: &Value| v.as_str().is_some_and(|s| !trim(s).is_empty()); + if !named(&report["hardware"]["gpu"]["name"]) || !named(&report["hardware"]["cpu"]) { + return Some("no GPU or CPU name"); + } + None +} + +fn mean(xs: &[f64]) -> Option { + (!xs.is_empty()).then(|| xs.iter().sum::() / xs.len() as f64) +} + +pub fn median(xs: &[f64]) -> Option { + let mut v: Vec = xs.iter().copied().filter(|x| x.is_finite()).collect(); + if v.is_empty() { + return None; + } + v.sort_by(f64::total_cmp); + let m = v.len() / 2; + Some(if v.len() % 2 == 1 { + v[m] + } else { + (v[m - 1] + v[m]) / 2.0 + }) +} + +/// fps to integers, milliseconds to a tenth under 10 and to integers above, megabytes to integers. +pub fn round(field: &str, x: f64) -> f64 { + if field.ends_with("_ms") && x < 10.0 { + (x * 10.0).round() / 10.0 + } else { + x.round() + } +} + +/// Whole numbers as integers (`515`, not `515.0`), as JavaScript writes them. +fn number(x: f64) -> Value { + if x.fract() == 0.0 && x.abs() < 9e15 { + json!(x as i64) + } else { + json!(x) + } +} + +/// A report's figures: frame figures averaged over every flight, the rest from its metrics. +pub fn figures(report: &Value) -> [Option; 7] { + let frames = report["frames"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]); + let avg = |key: &str| { + mean( + &frames + .iter() + .filter_map(|f| f[key].as_f64()) + .collect::>(), + ) + }; + let metrics = report["metrics"].as_object(); + let metric = |name: &str| metrics.and_then(|m| m.get(name)?.get("mean")?.as_f64()); + let loads: Vec = metrics + .map(|m| { + m.iter() + .filter(|(k, _)| { + k.strip_prefix("chunks.") + .and_then(|k| k.strip_suffix(".load_ms")) + .is_some_and(|scene| !scene.contains('.')) + }) + .filter_map(|(_, v)| v.get("mean")?.as_f64()) + .collect() + }) + .unwrap_or_default(); + [ + avg("avg_fps"), + avg("low_1pct_fps"), + avg("p95_ms"), + metric("startup.world_ms"), + mean(&loads), + metric("memory.rss_peak_mb"), + metric("memory.rss_steady_mb"), + ] +} + +/// "v0.2.10" after "v0.2.9", dated tags by their numbers: newest first. +fn newest_first(a: &str, b: &str) -> std::cmp::Ordering { + let n = |v: &str| -> Vec { + v.split(|c: char| !c.is_ascii_digit()) + .filter(|p| !p.is_empty()) + .filter_map(|p| p.parse().ok()) + .collect() + }; + n(b).cmp(&n(a)) +} + +#[derive(Default)] +struct Group { + installs: HashMap, Vec<[Option; 7]>>, + reports: usize, + versions: BTreeSet, +} + +/// Players' groups of at least `min_installs` installs, as `website/scripts/benchmarks.mjs` makes them. +pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec { + let mut by: BTreeMap<(String, String, i64, &str), Group> = BTreeMap::new(); + for u in uploads { + if u.install.is_empty() || rejection(&u.report).is_some() { + continue; + } + let r = &u.report; + let key = ( + trim(r["hardware"]["gpu"]["name"].as_str().unwrap_or_default()), + trim(r["hardware"]["cpu"].as_str().unwrap_or_default()), + r["conditions"]["render_distance"] + .as_f64() + .unwrap_or_default() as i64, + game(&r["app"]).unwrap_or_default(), + ); + let g = by.entry(key).or_default(); + g.reports += 1; + if !u.version.is_empty() { + g.versions.insert(u.version.clone()); + } + g.installs + .entry(u.install.clone()) + .or_default() + .push(figures(r)); + } + let mut out: Vec<(usize, Value)> = by + .into_iter() + .filter(|(_, g)| g.installs.len() >= min_installs) + .map(|((gpu, cpu, rd, game), g)| { + let per_install: Vec<[Option; 7]> = g + .installs + .values() + .map(|runs| { + std::array::from_fn(|i| median(&runs.iter().filter_map(|r| r[i]).collect::>())) + }) + .collect(); + let medians: serde_json::Map = FIELDS + .iter() + .enumerate() + .map(|(i, f)| { + let m = median(&per_install.iter().filter_map(|r| r[i]).collect::>()); + (f.to_string(), m.map_or(Value::Null, |m| number(round(f, m)))) + }) + .collect(); + let mut versions: Vec = g.versions.into_iter().collect(); + versions.sort_by(|a, b| newest_first(a, b)); + versions.truncate(3); + let installs = g.installs.len(); + ( + installs, + json!({ + "gpu": gpu, "cpu": cpu, "render_distance": rd, "game": game, "source": "players", + "installs": installs, "reports": g.reports, "versions": versions, "medians": medians, + }), + ) + }) + .collect(); + // Most installs first, then by GPU and render distance (the map's order). + out.sort_by_key(|g| std::cmp::Reverse(g.0)); + out.into_iter().map(|(_, v)| v).collect() +} + +/// The whole answer; `generated` is an RFC 3339 UTC time. +pub fn summary(uploads: &[Upload], min_installs: usize, generated: &str) -> Value { + json!({ + "format": FORMAT, "version": 1, "generated": generated, "min_installs": min_installs, + "groups": groups(uploads, min_installs), + }) +} diff --git a/crates/horizon-telemetry-server/tests/server.rs b/crates/horizon-telemetry-server/tests/server.rs index f8c78f085..5d5b17b6a 100644 --- a/crates/horizon-telemetry-server/tests/server.rs +++ b/crates/horizon-telemetry-server/tests/server.rs @@ -198,14 +198,30 @@ async fn an_address_is_rate_limited() { s.post(&install(9), vec![perf("x", 8)]).await, StatusCode::TOO_MANY_REQUESTS ); + let from = |forwarded: &str, real: Option<&str>| { + let body = batch::encode(&Batch::new(&install(9), vec![perf("x", 8)])); + let mut req = Request::post("/v1/batch") + .header("x-forwarded-for", forwarded) + .header("content-encoding", "zstd"); + if let Some(ip) = real { + req = req.header("x-real-ip", ip); + } + req.body(Body::from(body)).unwrap() + }; + // A client can't pick its address: the proxy adds the real one last, or sets X-Real-IP. + assert_eq!( + s.send(from("198.51.100.7, 203.0.113.9", None)).await.0, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + s.send(from("198.51.100.7", Some("203.0.113.9"))).await.0, + StatusCode::TOO_MANY_REQUESTS + ); // Another address has its own allowance. - let body = batch::encode(&Batch::new(&install(9), vec![perf("x", 8)])); - let req = Request::post("/v1/batch") - .header("x-forwarded-for", "198.51.100.7, 10.0.0.1") - .header("content-encoding", "zstd") - .body(Body::from(body)) - .unwrap(); - assert_eq!(s.send(req).await.0, StatusCode::NO_CONTENT); + assert_eq!( + s.send(from("203.0.113.9, 198.51.100.7", None)).await.0, + StatusCode::NO_CONTENT + ); } #[tokio::test] diff --git a/crates/horizon-telemetry-server/tests/summary.rs b/crates/horizon-telemetry-server/tests/summary.rs new file mode 100644 index 000000000..5a59aa8a1 --- /dev/null +++ b/crates/horizon-telemetry-server/tests/summary.rs @@ -0,0 +1,180 @@ +//! `GET /v1/benchmarks/summary` against the website's fixtures (website/scripts/fixtures/benchmarks/): the same +//! uploads must give the groups `website/scripts/benchmarks.mjs` gives. +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use horizon_telemetry::Item; +use horizon_telemetry::batch::{self, Batch}; +use horizon_telemetry::payload::{Payload, Source}; +use horizon_telemetry_server::store::Store; +use horizon_telemetry_server::summary::{self, Upload}; +use horizon_telemetry_server::{App, Config, router}; +use http_body_util::BodyExt; +use serde_json::Value; +use tower::ServiceExt; + +const FIXTURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../website/scripts/fixtures/benchmarks" +); + +fn items() -> Vec { + std::fs::read_to_string(format!("{FIXTURES}/items.jsonl")) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +fn expected() -> Value { + serde_json::from_str(&std::fs::read_to_string(format!("{FIXTURES}/expected.json")).unwrap()) + .unwrap() +} + +/// The fixtures' short install names as the 32 hex digits real installs have. +fn install_id(name: &str) -> String { + format!( + "{:032x}", + name.bytes().fold(0u128, |n, b| n * 257 + u128::from(b)) + ) +} + +#[test] +fn the_fixtures_give_the_websites_groups() { + let uploads: Vec = items() + .into_iter() + .map(|i| Upload { + install: i["install_id"] + .as_str() + .unwrap_or_default() + .as_bytes() + .to_vec(), + version: i["version"].as_str().unwrap_or_default().to_owned(), + report: i["data"].clone(), + }) + .collect(); + let s = summary::summary(&uploads, summary::MIN_INSTALLS, "2026-11-03T00:00:00Z"); + let want = expected(); + assert_eq!(s["format"], "horizon-benchmark-summary"); + assert_eq!(s["version"], 1); + assert_eq!(s["min_installs"], want["min_installs"]); + assert_eq!(s["groups"], want["groups"]); +} + +#[test] +fn only_comparable_runs_count() { + let reasons: Vec<&str> = items() + .iter() + .filter_map(|i| summary::rejection(&i["data"])) + .collect(); + assert_eq!( + reasons, + [ + "VSync on or unknown", + "offscreen", + "frame rate capped", + "not every scene", + "not the standard flight", + "not a version 1 benchmark report" + ] + ); +} + +#[test] +fn medians_and_rounding() { + assert_eq!(summary::median(&[3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(summary::median(&[4.0, 1.0, 2.0, 3.0]), Some(2.5)); + assert_eq!(summary::median(&[f64::NAN]), None); + assert_eq!(summary::round("p95_ms", 3.46), 3.5); + assert_eq!(summary::round("world_ms", 4512.4), 4512.0); + assert_eq!(summary::round("avg_fps", 99.5), 100.0); +} + +async fn get(app: &Arc, uri: &str) -> (StatusCode, String, Option) { + let res = router(app.clone()) + .oneshot(Request::get(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let code = res.status(); + let origin = res + .headers() + .get("access-control-allow-origin") + .map(|v| v.to_str().unwrap().to_owned()); + let body = res.into_body().collect().await.unwrap().to_bytes(); + (code, String::from_utf8_lossy(&body).into_owned(), origin) +} + +/// Uploaded through `POST /v1/batch` as the launcher sends them, then read back from the endpoint. +#[tokio::test] +async fn uploads_come_back_grouped_and_anonymous() { + let day = Arc::new(AtomicU32::new(20_396)); + let clock = day.clone(); + let config = Config { + requests_per_hour: 1e6, + ..Config::default() + }; + let app = App::with_clock( + Store::open(":memory:").unwrap(), + config, + Box::new(move || clock.load(std::sync::atomic::Ordering::SeqCst)), + ); + for i in items() { + let Some(name) = i["install_id"].as_str() else { + continue; // an item without an install can't be sent + }; + let item = Item::new( + Source::Game, + i["version"].as_str().unwrap(), + Payload::Benchmark(i["data"].clone()), + ); + let body = batch::encode(&Batch::new(&install_id(name), vec![item])); + let res = router(app.clone()) + .oneshot( + Request::post("/v1/batch") + .header("content-encoding", "zstd") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT, "{name}"); + } + + let (code, body, origin) = get(&app, "/v1/benchmarks/summary").await; + assert_eq!(code, StatusCode::OK); + assert_eq!(origin.as_deref(), Some("https://playhorizon.dev")); + let s: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(s["groups"], expected()["groups"]); + let generated = s["generated"].as_str().unwrap(); + assert!( + generated.len() == 20 && generated.ends_with('Z'), + "{generated}" + ); + for secret in [ + install_id("a1").as_str(), + "install_id", + "secret-os-version", + "secret-driver", + "20394", + "ram_mb", + ] { + assert!(!body.contains(secret), "{secret} in {body}"); + } + + // Deleting one of the three installs takes the group below three: it goes at once. + let res = router(app.clone()) + .oneshot( + Request::delete(format!("/v1/installs/{}", install_id("a3"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NO_CONTENT); + let (_, body, _) = get(&app, "/v1/benchmarks/summary").await; + let s: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(s["groups"], serde_json::json!([])); +} diff --git a/crates/horizon-telemetry/src/lib.rs b/crates/horizon-telemetry/src/lib.rs index 8d2840a08..018c13df8 100644 --- a/crates/horizon-telemetry/src/lib.rs +++ b/crates/horizon-telemetry/src/lib.rs @@ -33,16 +33,21 @@ pub const SCHEMA: u32 = 1; /// and until the player answers nothing new is kept or sent. pub const CONSENT_VERSION: u32 = 1; -/// The ingest server, or none while there isn't one: then nothing is ever sent (no ping either) -/// and the outbox stays within its caps. `HORIZON_TELEMETRY_URL` overrides it (tests, a local -/// server). -pub const DEFAULT_ENDPOINT: Option<&str> = None; +/// The ingest server (`crates/horizon-telemetry-server`, on Railway). `HORIZON_TELEMETRY_URL` +/// overrides it (a local server); `HORIZON_TELEMETRY_URL=off` sends nothing at all, no ping +/// either (tests and automated runs). What is sent still depends on the player's switches. +pub const DEFAULT_ENDPOINT: &str = "https://telemetry.playhorizon.dev"; /// Where items are sent, if anywhere. pub fn endpoint() -> Option { - match std::env::var("HORIZON_TELEMETRY_URL") { - Ok(url) if !url.trim().is_empty() => Some(url.trim().trim_end_matches('/').to_owned()), - _ => DEFAULT_ENDPOINT.map(str::to_owned), + resolve(std::env::var("HORIZON_TELEMETRY_URL").ok().as_deref()) +} + +fn resolve(var: Option<&str>) -> Option { + match var.map(str::trim) { + Some("off" | "none" | "0") => None, + Some(url) if !url.is_empty() => Some(url.trim_end_matches('/').to_owned()), + _ => Some(DEFAULT_ENDPOINT.to_owned()), } } @@ -100,3 +105,20 @@ pub(crate) mod test_home { dir } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_endpoint_defaults_to_the_server_and_can_be_moved_or_turned_off() { + assert_eq!(resolve(None).as_deref(), Some(DEFAULT_ENDPOINT)); + assert_eq!(resolve(Some(" ")).as_deref(), Some(DEFAULT_ENDPOINT)); + assert_eq!( + resolve(Some("http://127.0.0.1:8080/")).as_deref(), + Some("http://127.0.0.1:8080") + ); + assert_eq!(resolve(Some("off")), None); + assert!(DEFAULT_ENDPOINT.starts_with("https://") && !DEFAULT_ENDPOINT.ends_with('/')); + } +} diff --git a/docs/telemetry.md b/docs/telemetry.md index f701044f2..e2cb9b050 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -45,7 +45,8 @@ Never sent: account names or UUIDs, world names, server or LAN addresses, chat, - `telemetry/outbox/` holds one JSON file per item, `--.json`. It never holds more than 200 items or 2 MiB, and the oldest go first. `kept.json` counts today's items per kind against the caps above. - The launcher sends the outbox when it starts and when it closes. Items go as one JSON batch (`{"schema":1,"install_id":…,"items":[…]}`, up to 64 items and 1 MiB), compressed with zstd: `POST /v1/batch` with `Content-Encoding: zstd`. A day's perf summaries are under 2 KB on the wire. - After a failure the launcher waits a minute, doubling up to a day, and sends at most 8 batches a day. A batch the server refuses with a 4xx (not 429) is dropped. -- `HORIZON_TELEMETRY_URL` sets the server. There is none by default: until there is, nothing is sent and the outbox stays within its caps. +- The server is `https://telemetry.playhorizon.dev` (`DEFAULT_ENDPOINT`), on Railway in the EU (`europe-west4`, Amsterdam). `HORIZON_TELEMETRY_URL` names another one (a local server); `HORIZON_TELEMETRY_URL=off` sends nothing at all, no ping either. Tests, CI and the benchmark and test harnesses set `off`. +- What is sent still depends on the switches: with every switch off nothing but the ping leaves, and the outbox stays empty. - `SCHEMA` versions the format. New optional fields keep it. A field that changes meaning or goes bumps it, and the server keeps reading the older ones. ## The "I'm playing" ping @@ -68,13 +69,14 @@ The server keeps sessions in memory only, never on disk, and never with the send { "playing": 1240, "lan": 85, "versions": { "v2026.10.22": 1100, "other": 140 }, "platforms": { "linux": 300, "macos": 240, "windows": 700 } } ``` -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. Like the rest, there is no server by default (`HORIZON_TELEMETRY_URL`): until there is one, the game sends no ping and the launcher shows no count. `HORIZON_NO_HEARTBEAT=1` keeps automated runs out of the count. +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. ## Server - `POST /v1/batch` - `DELETE /v1/installs/`: deletes everything sent under that ID. - `GET /v1/aggregate`: the anonymous totals the website's results page reads. +- `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, memory or screen. Uploads are never published one by one. - `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count. -`crates/horizon-telemetry-server` is the server. Its [README](../crates/horizon-telemetry-server/README.md) covers storage (about 190 bytes per play session), retention (180 days of raw rows, anonymous daily totals kept), rate limits and hosting. +`crates/horizon-telemetry-server` is the server, at `https://telemetry.playhorizon.dev` on Railway (one SQLite file on a volume, region `europe-west4`). Its [README](../crates/horizon-telemetry-server/README.md) covers storage (about 190 bytes per play session), retention (180 days of raw rows, anonymous daily totals kept), rate limits and hosting. diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index bde878995..90eecd3f3 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -754,7 +754,7 @@ fn data(action: DataCmd, as_json: bool) -> Result<(), String> { println!("Waiting to be sent: {}", status["waiting"]); match horizon_telemetry::endpoint() { Some(url) => println!("Server: {url}"), - None => println!("Server: none yet, nothing is sent"), + None => println!("Server: turned off (HORIZON_TELEMETRY_URL=off), nothing is sent"), } } DataCmd::Allow { kinds } => { diff --git a/launcher/src/collection.rs b/launcher/src/collection.rs index 67745284e..995bce17c 100644 --- a/launcher/src/collection.rs +++ b/launcher/src/collection.rs @@ -307,7 +307,7 @@ impl Launcher { let server = if horizon_telemetry::endpoint().is_some() { format!("{waiting} waiting to be sent") } else { - "No data server yet: nothing is sent".into() + "Sending is turned off here (HORIZON_TELEMETRY_URL=off)".into() }; let confirm = self.collection.confirm_delete; let actions = div() diff --git a/launcher/src/telemetry.rs b/launcher/src/telemetry.rs index 26e8c0625..5cdd913f8 100644 --- a/launcher/src/telemetry.rs +++ b/launcher/src/telemetry.rs @@ -54,7 +54,7 @@ pub fn playing_now() -> Option { } /// Sends [`playing_now`] to the window as `Msg::Playing` now and every minute, on its own -/// thread. Nothing at all when there is no server. +/// thread. Nothing at all when sending is turned off. pub fn watch_playing(tx: std::sync::mpsc::Sender) { if horizon_telemetry::endpoint().is_none() { return; @@ -151,7 +151,9 @@ pub fn delete_my_data() -> Result { outbox::clear(&home).map_err(|e| e.to_string())?; let remote = match horizon_telemetry::endpoint() { _ if consent.install_id.is_empty() => "Nothing was ever sent from here.", - None => "Nothing was sent: there is no data server yet.", + None => { + "Sending is turned off here (HORIZON_TELEMETRY_URL=off), so the server was not asked." + } Some(url) => { upload::delete_remote(&url, &consent.install_id) .map_err(|e| format!("The server could not be reached ({e}). Try again later."))?; @@ -230,7 +232,7 @@ pub fn submit(item: &Item) -> Result { Ok(match send() { Flush::Sent { .. } => "Sent. Thank you.".into(), Flush::Nothing if horizon_telemetry::endpoint().is_none() => { - "Kept to be sent once the data server is up.".into() + "Kept; sending is turned off here (HORIZON_TELEMETRY_URL=off).".into() } _ => "Kept; it will be sent the next time the launcher starts.".into(), }) diff --git a/tools/agent_control_e2e.py b/tools/agent_control_e2e.py index 6f75b8a14..f4c7acaa9 100644 --- a/tools/agent_control_e2e.py +++ b/tools/agent_control_e2e.py @@ -64,7 +64,7 @@ class Failed(Exception): def game_env(screen): if sys.platform == 'darwin': - return dict(os.environ, HORIZON_NO_AUDIO='1') + return dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') env = dict(os.environ, VSCREEN=screen) subprocess.run(['vscreen', 'up'], env=env, check=False, capture_output=True) text = subprocess.run(['vscreen', 'env'], env=env, capture_output=True, text=True, check=True).stdout @@ -72,6 +72,7 @@ def game_env(screen): for match in re.finditer(r'(\w+)=(\S+)', text.split('unset')[0]): env[match.group(1)] = match.group(2) env['HORIZON_NO_AUDIO'] = '1' + env['HORIZON_TELEMETRY_URL'] = 'off' # no live-count ping from test games if LAVAPIPE.exists(): env['VK_ICD_FILENAMES'] = str(LAVAPIPE) return env diff --git a/tools/graphics_bench.py b/tools/graphics_bench.py index 202a5ea3c..0e42e94d5 100644 --- a/tools/graphics_bench.py +++ b/tools/graphics_bench.py @@ -61,6 +61,8 @@ def vscreen_env(): env.pop("DISPLAY", None) # Benchmark games never play sound on the machine's speakers. env["HORIZON_NO_AUDIO"] = "1" + # Nor reach the telemetry server (docs/telemetry.md). + env["HORIZON_TELEMETRY_URL"] = "off" return env diff --git a/tools/launcher_mcp_e2e.py b/tools/launcher_mcp_e2e.py index 8dd3dc1bc..955d141d4 100644 --- a/tools/launcher_mcp_e2e.py +++ b/tools/launcher_mcp_e2e.py @@ -116,7 +116,7 @@ def main(): os.symlink(resources, version / '.local/horizon-client' / lab.VERSION) (version / '.installed').write_text('') env = dict(os.environ, HORIZON_LAUNCHER_HOME=str(work / 'launcher'), HORIZON_HOME=str(work / 'home'), - HORIZON_NO_AUDIO='1') + HORIZON_NO_AUDIO='1', HORIZON_TELEMETRY_URL='off') m = Mcp(Path(args.launcher).resolve(), env) try: summary = scenario(m, out) diff --git a/tools/perf_bench.py b/tools/perf_bench.py index eb6b3fda2..c60533aa8 100644 --- a/tools/perf_bench.py +++ b/tools/perf_bench.py @@ -364,7 +364,8 @@ def run_horizon(args, distance): command.append("--offscreen") for carry in dev_mods(args): command += ["--dev-mod", str(carry)] - env = dict(os.environ, HORIZON_NO_AUDIO="1") + # Benchmark runs stay out of the live count and send nothing (docs/telemetry.md). + env = dict(os.environ, HORIZON_NO_AUDIO="1", HORIZON_TELEMETRY_URL="off") if args.warm: # A first start to the title screen builds what later starts reuse (the content-mod # root, the block behaviour snapshot). It is not measured: players see it once. diff --git a/website/scripts/fixtures/benchmarks/README.md b/website/scripts/fixtures/benchmarks/README.md new file mode 100644 index 000000000..716942893 --- /dev/null +++ b/website/scripts/fixtures/benchmarks/README.md @@ -0,0 +1,10 @@ +# Benchmark summary fixtures + +Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`) and the telemetry server's +`GET /v1/benchmarks/summary` test: the same `benchmark` items in, the same groups out. + +- `items.jsonl`: one stored item per line (`install_id`, `day`, `version`, `data`: a benchmark report). Group + RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32, + and the RTX 3060 group, have fewer than three and are left out. The other runs are rejected: VSync on, + offscreen, a frame cap, a missing scene, a one-scene scenario, a report of another version, no install ID. +- `expected.json`: `min_installs` and `groups` of the summary (`generated` varies). diff --git a/website/scripts/fixtures/benchmarks/expected.json b/website/scripts/fixtures/benchmarks/expected.json new file mode 100644 index 000000000..43470bd02 --- /dev/null +++ b/website/scripts/fixtures/benchmarks/expected.json @@ -0,0 +1,16 @@ +{ + "min_installs": 3, + "groups": [ + { + "gpu": "NVIDIA GeForce RTX 4080", + "cpu": "13th Gen Intel Core i7-13700KF", + "render_distance": 12, + "game": "horizon", + "source": "players", + "installs": 3, + "reports": 4, + "versions": ["v0.2.1", "v0.2.0"], + "medians": { "avg_fps": 515, "low_1pct_fps": 120, "p95_ms": 3.5, "world_ms": 4500, "chunk_load_ms": 1400, "rss_peak_mb": 1450, "rss_steady_mb": 1250 } + } + ] +} diff --git a/website/scripts/fixtures/benchmarks/items.jsonl b/website/scripts/fixtures/benchmarks/items.jsonl new file mode 100644 index 000000000..4796fb2d2 --- /dev/null +++ b/website/scripts/fixtures/benchmarks/items.jsonl @@ -0,0 +1,14 @@ +{"install_id":"a1","day":20394,"version":"v0.2.0","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":5000,"stdev":null,"min":5000,"max":5000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1400},"memory.rss_steady_mb":{"n":1,"mean":1200},"chunks.village.load_ms":{"n":1,"mean":1500},"chunks.forest.load_ms":{"n":1,"mean":1500},"chunks.ocean.load_ms":{"n":1,"mean":1500},"chunks.mountain.load_ms":{"n":1,"mean":1500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":400,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":410,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":420,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":430,"low_1pct_fps":100,"low_01pct_fps":50,"p50_ms":2,"p95_ms":4,"p99_ms":8,"p999_ms":12,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a1","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":4000,"stdev":null,"min":4000,"max":4000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1500},"memory.rss_steady_mb":{"n":1,"mean":1300},"chunks.village.load_ms":{"n":1,"mean":1300},"chunks.forest.load_ms":{"n":1,"mean":1300},"chunks.ocean.load_ms":{"n":1,"mean":1300},"chunks.mountain.load_ms":{"n":1,"mean":1300}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":600,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":610,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":620,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":630,"low_1pct_fps":140,"low_01pct_fps":70,"p50_ms":2,"p95_ms":3,"p99_ms":6,"p999_ms":9,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a2","day":20395,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":6000,"stdev":null,"min":6000,"max":6000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":1700},"chunks.forest.load_ms":{"n":1,"mean":1700},"chunks.ocean.load_ms":{"n":1,"mean":1700},"chunks.mountain.load_ms":{"n":1,"mean":1700}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":300,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":310,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":320,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":330,"low_1pct_fps":80,"low_01pct_fps":40,"p50_ms":2,"p95_ms":5,"p99_ms":10,"p999_ms":15,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":3000,"stdev":null,"min":3000,"max":3000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1600},"memory.rss_steady_mb":{"n":1,"mean":1400},"chunks.village.load_ms":{"n":1,"mean":1100},"chunks.forest.load_ms":{"n":1,"mean":1100},"chunks.ocean.load_ms":{"n":1,"mean":1100},"chunks.mountain.load_ms":{"n":1,"mean":1100}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":700,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":710,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":720,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":730,"low_1pct_fps":160,"low_01pct_fps":80,"p50_ms":2,"p95_ms":2.5,"p99_ms":5,"p999_ms":7.5,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a4","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a5","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"offscreen":true,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a6","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"fps_cap":60,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a7","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a8","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"village","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":2000,"stdev":null,"min":2000,"max":2000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1700},"memory.rss_steady_mb":{"n":1,"mean":1500},"chunks.village.load_ms":{"n":1,"mean":900},"chunks.forest.load_ms":{"n":1,"mean":900},"chunks.ocean.load_ms":{"n":1,"mean":900},"chunks.mountain.load_ms":{"n":1,"mean":900}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":900,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":910,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":920,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":930,"low_1pct_fps":200,"low_01pct_fps":100,"p50_ms":2,"p95_ms":2,"p99_ms":4,"p999_ms":6,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"a1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"13th Gen Intel(R) Core(TM) i7-13700KF","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 4080","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":32,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":9000,"stdev":null,"min":9000,"max":9000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":2600},"memory.rss_steady_mb":{"n":1,"mean":2300},"chunks.village.load_ms":{"n":1,"mean":6000},"chunks.forest.load_ms":{"n":1,"mean":6000},"chunks.ocean.load_ms":{"n":1,"mean":6000},"chunks.mountain.load_ms":{"n":1,"mean":6000}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":120,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":130,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":140,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":150,"low_1pct_fps":30,"low_01pct_fps":15,"p50_ms":2,"p95_ms":12,"p99_ms":24,"p999_ms":36,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"b1","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7000,"stdev":null,"min":7000,"max":7000,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2500},"chunks.forest.load_ms":{"n":1,"mean":2500},"chunks.ocean.load_ms":{"n":1,"mean":2500},"chunks.mountain.load_ms":{"n":1,"mean":2500}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":250,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":60,"low_01pct_fps":30,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"b2","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"install_id":"b3","day":20396,"version":"v0.2.1","source":"game","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":2,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":7100,"stdev":null,"min":7100,"max":7100,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1300},"memory.rss_steady_mb":{"n":1,"mean":1100},"chunks.village.load_ms":{"n":1,"mean":2400},"chunks.forest.load_ms":{"n":1,"mean":2400},"chunks.ocean.load_ms":{"n":1,"mean":2400},"chunks.mountain.load_ms":{"n":1,"mean":2400}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":260,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":270,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":280,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":290,"low_1pct_fps":70,"low_01pct_fps":35,"p50_ms":2,"p95_ms":6,"p99_ms":12,"p999_ms":18,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} +{"day":20396,"version":"v0.2.1","kind":"benchmark","data":{"format":"horizon-benchmark-report","version":1,"kind":"game","created":"2026-11-02","app":{"name":"horizon-client","version":"0.2.0"},"hardware":{"os":"linux","os_version":"secret-os-version","arch":"x86_64","cpu":"AMD Ryzen 5 5600X 6-Core Processor","cpu_threads":24,"ram_mb":32768,"gpu":{"name":"NVIDIA GeForce RTX 3060","vendor":"nvidia","driver":"secret-driver","backend":"vulkan","kind":"discrete"},"display":{"width":2560,"height":1440,"scale":1,"refresh_hz":165}},"conditions":{"scenario":"standard","seed":"1234","render_distance":12,"simulation_distance":12,"window":[1280,720],"vsync":false,"repeats":1,"warmup_s":2},"metrics":{"startup.title_ms":{"n":1,"mean":900,"stdev":null,"min":900,"max":900,"ci95":null},"startup.world_ms":{"n":1,"mean":1,"stdev":null,"min":1,"max":1,"ci95":null},"memory.rss_peak_mb":{"n":1,"mean":1},"memory.rss_steady_mb":{"n":1,"mean":1},"chunks.village.load_ms":{"n":1,"mean":1},"chunks.forest.load_ms":{"n":1,"mean":1},"chunks.ocean.load_ms":{"n":1,"mean":1},"chunks.mountain.load_ms":{"n":1,"mean":1}},"frames":[{"phase":"village","repeat":0,"frames":1000,"seconds":12,"avg_fps":1,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"forest","repeat":0,"frames":1000,"seconds":12,"avg_fps":11,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"ocean","repeat":0,"frames":1000,"seconds":12,"avg_fps":21,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"phase":"mountain","repeat":0,"frames":1000,"seconds":12,"avg_fps":31,"low_1pct_fps":1,"low_01pct_fps":0.5,"p50_ms":2,"p95_ms":1,"p99_ms":2,"p999_ms":3,"max_ms":50,"histogram":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}} diff --git a/website/src/privacy.html b/website/src/privacy.html index 6190b03c5..6b562a4cf 100644 --- a/website/src/privacy.html +++ b/website/src/privacy.html @@ -26,25 +26,25 @@
  • Performance statistics, after each play session of a minute or more. These are the frame-time and tick-time percentiles and a 14-bucket histogram (never a recording of each frame), the peak memory, how long the game took to start and to load the world, and the settings that change performance (render and simulation distance, frame limit, VSync, fullscreen, graphics mode).
  • Crash reports, when the game crashes. They hold the error, where it happened in Horizon's code, your graphics adapter and settings. They hold no log, no world, no server and no resource packs.
  • Bug reports that you write and send yourself. The end of the game's log goes with one only if you add it after reading it, and lines of chat are removed first.
  • -
  • Benchmark uploads of the benchmarks you run. These are shown publicly in the community results.
  • +
  • Benchmark uploads of the benchmarks you run. They are shown publicly on the Benchmarks page, only as medians of groups of at least 3 different installs with the same graphics card, processor and render distance; never one by one, and never with your install ID, the day, your OS version, driver, memory or screen.
  • Each one also carries the Horizon version, the day it was made (not the time), and a description of your machine: the operating system and version, the CPU model and thread count, the memory rounded to the gigabyte, and the GPU, its driver and the graphics backend. It also carries a random install ID. The launcher makes that ID the first time you allow something; it says nothing about you and exists only so that your data can be deleted.

    Never sent: your Microsoft or Minecraft account name or UUID, world names, server or LAN addresses, chat, and your user name or home folder in file paths. Before anything is kept, those are replaced with placeholders, along with e-mail and IP addresses. The server does not store the IP address requests come from.

    The launcher shows exactly what would be sent: Settings → Data collection → View what would be sent, or horizon-launcher data show. Every field is listed in docs/telemetry.md.

    The "I'm playing" ping

    -

    The game has an anonymous "I'm playing" ping, so the launcher can show how many people play Horizon right now. It is off today: no server receives it yet, so the game sends nothing. This is what it would hold, so you know before it is ever switched on. It would be sent once a minute while you play, whatever the switches say.

    -

    It would hold: a random number made when the game starts and forgotten when it closes, the Horizon version, your operating system (Linux, macOS or Windows), and whether you have a world open to LAN or to friends. It would not hold: your account, your install ID, world or server names, your hardware or settings, or anything you do in the game. The random number changes every time you start the game, so two of your sessions can't be linked to each other or to you.

    -

    The server would keep the pings in memory only, never on disk, and not with the address they came from (that is used only to stop abuse, as below). A session would count while it pinged in the last two minutes and is then forgotten. What it would show is the number playing now, per version and with a world open, rounded.

    +

    The game sends an anonymous "I'm playing" ping, so the launcher can show how many people play Horizon right now. It is sent once a minute while you play, whatever the switches say.

    +

    It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your operating system (Linux, macOS or Windows), and whether you have a world open to LAN or to friends. It does not hold: your account, your install ID, world or server names, your hardware or settings, or anything you do in the game. The random number changes every time you start the game, so two of your sessions can't be linked to each other or to you.

    +

    The server keeps the pings in memory only, never on disk, and not with the address they came from (that is used only to stop abuse, as below). A session counts while it pinged in the last two minutes and is then forgotten. What it shows is the number playing now, per version and with a world open, rounded.

    Why

    -

    To find what is slow or crashes on which machines, and fix it. If it is switched on, the ping shows players, and Horizon, how many people play right now. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.

    +

    To find what is slow or crashes on which machines, and fix it. The ping shows players, and Horizon, how many people play right now. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.

    On what basis

    -

    For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping would hold nothing that identifies you; the address it comes from would be used only to limit how often one address may send, kept in memory for an hour at most and never stored.

    +

    For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping holds nothing that identifies you; the address it comes from is used only to limit how often one address may send, kept in memory for an hour at most and never stored.

    Where and how long

    -

    Items wait on your computer (telemetry/ in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server at Railway. The server keeps them for 180 days, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown.

    +

    Items wait on your computer (telemetry/ in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server, telemetry.playhorizon.dev, hosted by Railway in the European Union (Amsterdam, the Netherlands). The server keeps them for 180 days, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown.

    Deleting your data

    Settings → Data collection → Delete my data, or horizon-launcher data delete. The server deletes everything sent under your install ID, your share of the totals is recomputed, anything waiting on your computer is removed, and you get a new install ID. You can also turn everything off, or delete the telemetry/ folder.