Compare commits

...
Author SHA1 Message Date
Ludovic Marie 1a86761edf Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 20:46:34 +01:00
Ludovic Marie 320186c089 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 20:38:53 +01:00
Horizon agent 8d9b80c420 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	docs/layers.md
2026-11-09 20:06:36 +01:00
Horizon agent edeb6978b9 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:43:02 +01:00
Horizon agentandClaude Opus 5.5 afdfc8bfa1 rustfmt; Cargo.lock: horizon-official uses horizon-telemetry's game part
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:26:20 +01:00
Ludovic Marie 13c366a85b Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:26:19 +01:00
Ludovic Marie 74f52d0782 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:12:04 +01:00
Ludovic Marie 4958be7ce0 Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 19:11:19 +01:00
Horizon agentandClaude Opus 5.5 195ecc898e Telemetry test: the slow step's name is 'static
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:06:18 +01:00
Horizon agent 7aa6f0fda0 Merge branch 'split/telemetry-crash' into split/telemetry-game
# Conflicts:
#	crates/horizon-telemetry/src/game.rs
2026-11-09 18:59:06 +01:00
Horizon agent 5aca6c42ee Merge branch 'split/telemetry-crash' into split/telemetry-game 2026-11-09 18:39:07 +01:00
Horizon agentandClaude Opus 5.5 dd6ad03eea Telemetry's game part leaves the engine for horizon-telemetry (layer plan M5)
telemetry.rs moves to horizon_telemetry::game (feature "game"), using only
the platform's hooks, and horizon-official registers it among its PARTS;
horizon-client's entry no longer registers it. The benchmark upload
(keep_benchmark) stays with the in-game benchmark as benchmark.rs's
keep_for_upload, with its test, until the benchmark leaves too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:27:20 +01:00
11 changed files with 100 additions and 100 deletions

No files matched your search

Generated
+2
View File
@@ -1797,6 +1797,7 @@ dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
"horizon-telemetry",
"log",
"winresource",
]
@@ -1970,6 +1971,7 @@ dependencies = [
"getrandom 0.3.4",
"horizon-addresses",
"horizon-platform",
"log",
"ruzstd",
"serde",
"serde_json",
+56 -1
View File
@@ -311,7 +311,7 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
if let Ok(meta) = std::env::current_exe().and_then(std::fs::metadata) {
report.metric("game.binary_bytes", &[meta.len() as f64]);
}
crate::telemetry::keep_benchmark(&crate::logging::game_home(), &report);
keep_for_upload(&crate::logging::game_home(), &report);
report
.write_to(&setup.game_dir.join("benchmarks"))
.map_err(|e| e.to_string())
@@ -468,10 +468,65 @@ impl RssSampler {
}
}
/// Keeps the report for the launcher to send, if the player allows benchmark uploads: the report
/// as docs/benchmark-report.md defines it, dated to the day (docs/telemetry.md).
fn keep_for_upload(home: &Path, report: &Report) {
use horizon_telemetry::{Item, Payload, Source, outbox};
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
let item = Item::new(
Source::Game,
crate::crash::build(),
Payload::Benchmark(data),
);
if let Err(e) = outbox::push(home, &item) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_kept_for_upload_need_their_own_consent_and_go_dated_to_the_day() {
use horizon_telemetry::{Choices, Consent, outbox};
let home = std::env::temp_dir().join(format!(
"horizon-client-benchmark-upload-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let allow = |choices: Choices| {
let mut consent = Consent::load(&home);
consent.answer(choices);
consent.save(&home).unwrap();
};
let mut report = Report::new(Kind::Game, Default::default());
report.created = "2026-10-15T14:03:27Z".into();
report.label = Some("my machine".into());
keep_for_upload(&home, &report);
allow(Choices {
performance: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
assert!(outbox::pending(&home).is_empty());
allow(Choices {
benchmarks: true,
..Choices::NONE
});
keep_for_upload(&home, &report);
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
let text = serde_json::to_string(&pending[0].1).unwrap();
assert!(
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
"{text}"
);
}
#[test]
fn scenarios() {
assert_eq!(scenes("standard").unwrap().len(), 4);
-1
View File
@@ -140,7 +140,6 @@ pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
-6
View File
@@ -22,12 +22,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// Opt-in telemetry (the play session's summary, each crash report, whatever the mode) plugs
// in through the hooks until it moves to a Horizon crate in horizon-official's parts (layer
// plan M5). horizon-extras: telemetry is Horizon's (docs/layers.md); without it nothing is
// kept.
#[cfg(feature = "horizon-extras")]
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
+1
View File
@@ -15,6 +15,7 @@ horizon-game = { path = "../horizon-game" }
horizon-platform = { path = "../horizon-platform" }
horizon-heartbeat = { path = "../horizon-heartbeat" }
horizon-friends = { path = "../horizon-friends", features = ["game"] }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["game"] }
log = "0.4"
[build-dependencies]
+3 -1
View File
@@ -11,11 +11,13 @@
use horizon_platform::hooks::{self, Hooks};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 2] = [
const PARTS: [&dyn Hooks; 3] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
// Opt-in telemetry: the play session's summary and crash reports, kept for the launcher.
&horizon_telemetry::game::HOOKS,
];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
+4
View File
@@ -18,12 +18,16 @@ ruzstd = "0.9"
getrandom = "0.3"
# Sending, for the launcher (and the game's "I'm playing" ping); the ingest server builds without it.
ureq = { version = "2", default-features = false, features = ["tls"], optional = true }
log = { version = "0.4", optional = true }
[features]
default = ["upload", "live"]
upload = ["dep:ureq"]
# The "I'm playing" ping and reading the live count (`live`), for the game and the launcher.
live = ["dep:ureq"]
# The game's part (`game`): the play session's summary and crash reports, kept through the
# platform's hooks. The official executable turns it on.
game = ["dep:log"]
[[test]]
name = "consent_gating"
@@ -1,21 +1,21 @@
//! The game's part in opt-in data collection (`horizon_telemetry`, docs/telemetry.md). When the
//! player allows it, the game keeps a scrubbed crash report, or a play session's performance
//! The game's part in opt-in data collection (docs/telemetry.md), behind the `game` feature. When
//! the player allows it, the game keeps a scrubbed crash report, or a play session's performance
//! summary, in the outbox. It never sends those itself: the launcher does, at its next start (the
//! game's only sending is the anonymous "I'm playing" ping, `heartbeat.rs`).
//! Without the player's yes for that kind nothing is kept, and crash-reports/ stays local.
//! game's only sending is the anonymous "I'm playing" ping, `horizon-heartbeat`). Without the
//! player's yes for that kind nothing is kept, and crash-reports/ stays local.
//!
//! It hears the game only through its hooks ([`HOOKS`], `horizon_hooks`), as a Horizon crate does:
//! it leaves the engine for the Horizon layer next (layer plan M5, docs/layers.md).
//! It hears the game only through the platform's hooks ([`HOOKS`], `horizon_platform::hooks`);
//! the official executable registers it (`horizon_official::PARTS`).
use std::path::Path;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use horizon_hooks::{self as hooks, Frame, Hooks, Milestone, WorldLeft};
use horizon_platform::hooks::{self, Frame, Hooks, Milestone, WorldLeft};
use horizon_telemetry::payload::{Crash, Display, Perf, Settings, System, Timings};
use horizon_telemetry::scrub::Scrubber;
use horizon_telemetry::{Item, Payload, Source, outbox};
use crate::payload::{Crash, Display, Perf, Settings, System, Timings};
use crate::scrub::Scrubber;
use crate::{Item, Payload, Source, outbox};
/// A session shorter than this says too little to keep.
const MIN_SECONDS: f32 = 60.0;
@@ -29,7 +29,7 @@ static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
/// The screen the window opened on.
static DISPLAY: Mutex<Option<Display>> = Mutex::new(None);
/// The game's hooks, for `horizon_hooks::register`.
/// The game's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Telemetry = Telemetry {
session: LazyLock::new(|| Mutex::new(Session::default())),
};
@@ -57,7 +57,7 @@ impl Hooks for Telemetry {
/// A player who allows performance statistics also says what their slow frames spent their
/// time in.
fn wants_frame_steps(&self, home: &Path) -> bool {
horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
crate::Consent::load(home).allows(crate::Kind::Perf)
}
fn world_shown(&self, since_opening: Duration) {
@@ -114,7 +114,7 @@ pub fn system(gpu: Option<&hooks::Gpu>) -> System {
let mut system = System::detect_in(home.as_deref());
if let Some(gpu) = gpu {
system.vram_mb = match gpu.kind.as_str() {
"discrete" => horizon_telemetry::system::vram_mb(&gpu.vendor).unwrap_or(0),
"discrete" => crate::system::vram_mb(&gpu.vendor).unwrap_or(0),
_ => 0,
};
system.gpu = gpu.name.clone();
@@ -186,24 +186,6 @@ pub fn keep_crash(report: &hooks::Crash<'_>) {
}
}
/// Keeps a benchmark report (`--benchmark`) for the launcher to send, if the player allows
/// benchmark uploads: the report as docs/benchmark-report.md defines it, dated to the day.
pub fn keep_benchmark(home: &Path, report: &horizon_bench::Report) {
let Ok(data) = serde_json::to_value(report.for_upload()) else {
return;
};
if let Err(e) = outbox::push(
home,
&Item::new(
Source::Game,
crate::crash::build(),
Payload::Benchmark(data),
),
) {
log::warn!("Horizon: could not keep the benchmark report to send: {e}");
}
}
/// One play session's performance, from entering a world to leaving it: frame and tick
/// times as [`Timings`] (fixed size whatever the length), never the frames themselves.
#[derive(Default)]
@@ -215,7 +197,7 @@ pub struct Session {
/// The start was measured: only the first session carries it.
title_measured: bool,
to_world_ms: Option<u32>,
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
slow_frames: Vec<crate::payload::SlowFrames>,
}
impl Session {
@@ -239,7 +221,7 @@ impl Session {
/// more counts under the step it spent most of its time in (`slowest_step`, the game's
/// `spans::culprit`).
pub fn frame(&mut self, ms: f32, slowest_step: impl FnOnce() -> Option<(String, f32)>) {
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
use crate::payload::{SLOW_FRAME_MS, SlowFrames};
if ms >= SLOW_FRAME_MS {
let (mut path, span_ms) = slowest_step().unwrap_or_default();
// A path too long for the item keeps its last steps.
@@ -286,7 +268,7 @@ impl Session {
gpu: Option<&hooks::Gpu>,
settings: Settings,
) {
if horizon_telemetry::Consent::load(home).allows(horizon_telemetry::Kind::Perf)
if crate::Consent::load(home).allows(crate::Kind::Perf)
&& let Some(perf) = self.summary(gpu, settings)
{
let item = Item::new(Source::Game, version, Payload::Perf(perf));
@@ -334,7 +316,7 @@ fn peak_ram_mb() -> u32 {
#[cfg(test)]
mod tests {
use super::*;
use horizon_telemetry::{Choices, Consent, Kind};
use crate::{Choices, Consent, Kind};
fn home(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
@@ -433,39 +415,6 @@ mod tests {
assert!(crash.stack.lines().count() > 100);
}
#[test]
fn benchmark_reports_need_their_own_consent_and_go_dated_to_the_day() {
let home = home("benchmark");
let mut report = horizon_bench::Report::new(horizon_bench::Kind::Game, Default::default());
report.created = "2026-10-15T14:03:27Z".into();
report.label = Some("my machine".into());
keep_benchmark(&home, &report);
allow(
&home,
Choices {
performance: true,
..Choices::NONE
},
);
keep_benchmark(&home, &report);
assert!(outbox::pending(&home).is_empty());
allow(
&home,
Choices {
benchmarks: true,
..Choices::NONE
},
);
keep_benchmark(&home, &report);
let pending = outbox::pending(&home);
assert_eq!(pending.len(), 1);
let text = serde_json::to_string(&pending[0].1).unwrap();
assert!(
text.contains("2026-10-15T00:00:00Z") && !text.contains("my machine"),
"{text}"
);
}
#[test]
fn crashes_stay_local_without_consent() {
let home = home("crash");
@@ -560,22 +509,14 @@ mod tests {
/// span counts under none. Faster frames leave no entry.
#[test]
fn slow_frames_are_put_down_to_their_step() {
use crate::spans::Span;
let span = |name, ms, depth| Span {
name,
ms,
cpu_ms: ms,
depth,
};
let culprit = |spans: &[Span]| crate::spans::culprit(spans);
// What the game's `spans::culprit` names for each frame.
let step = |path: &'static str, ms: f32| move || Some((path.to_owned(), ms));
let mut session = Session::default();
let ticking = [span("tick", 10.0, 0)];
(0..4000).for_each(|_| session.frame(16.6, || culprit(&ticking)));
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
session.frame(320.0, || culprit(&frozen));
session.frame(900.0, || culprit(&frozen));
session.frame(260.0, || culprit(&[]));
session.frame(249.0, || culprit(&frozen));
(0..4000).for_each(|_| session.frame(16.6, step("tick", 10.0)));
session.frame(320.0, step("renderer/r_uploads", 280.0));
session.frame(900.0, step("renderer/r_uploads", 280.0));
session.frame(260.0, || None);
session.frame(249.0, step("renderer/r_uploads", 280.0));
let perf = session.summary(None, Settings::default()).unwrap();
let seen: Vec<_> = perf
.slow_frames
+2
View File
@@ -15,6 +15,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
pub mod batch;
pub mod consent;
#[cfg(feature = "game")]
pub mod game;
pub mod live;
pub mod outbox;
pub mod payload;
+1 -1
View File
@@ -65,7 +65,7 @@ command above to inspect the selection rather than relying on historical counts.
| 36 | Audio and music (38) | `R/audio.rs`, `R/sound_engine.rs`, `R/sound_events.rs`, `L/music.rs`, `G/music.rs`, `L/ambience.rs`, `G/subtitle_hud.rs` | sound_engine, sound_event, music, situational_music, ambient_sound, subtitle |
| 37 | Integrated server and levels (34) | `G/integrated.rs`, `G/levels.rs`, `G/server_travel.rs` | server_dimension_players, `lan_e2e` (server-thread by default; portals, levels, distance, lava) |
| 38 | Command parsing (34) | `C/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); `cargo test -p horizon-command` | command_parse (corpus `tools/command_parse_corpus.py`) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `L/telemetry.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 39 | Benchmarks and telemetry (34) | `L/benchmark.rs`, `crates/horizon-telemetry/src/game.rs`, `tools/perf_bench.py`, `crates/horizon-telemetry*/`, `crates/horizon-bench/` | `python3 -m unittest tests.test_perf_bench`; [benchmark.md](benchmark.md) |
| 40 | Explosions and TNT (31) | `G/explosion.rs`, `G/tnt.rs` | lan_e2e blast; no dedicated harness yet |
Controller glyphs (`R/pad_glyph.rs`, drawn by the gameplay guide, notices, splitscreen prompts and declared
+7 -7
View File
@@ -292,13 +292,13 @@ world saved), and the play session's: `startup` (core-ready, rest-ready, the tit
`wants_frame_steps` (asked once: time the render thread's steps), `world_shown` (a world's first frame), `frame`
(every frame played), `world_left` (the session's end, with its settings), `display` (the screen the window is on)
and `crashed` (each crash, before its report is saved); and `tunnel` (the resolver of `friend:` addresses, Play
with Friends'). Opt-in telemetry (`telemetry.rs`) hears the game through these only. **A hook you need and don't
find:** add a method with an empty default to `Hooks` and a function in `horizon_hooks::engine` that the engine
calls where vanilla does the matching thing; give it plain data or a type the engine owns, never one from above. A
hook that needs engine types the platform doesn't expose yet (a draw context for HUD and chat overlays, a settings
page) comes with that type's platform API (M6). An extension point that belongs to one subsystem (an input source,
the compositor) lives in the engine crate that owns it, with the same shape: registered before the game runs,
read-only after.
with Friends'). Opt-in telemetry (`horizon_telemetry::game`, registered by `horizon-official`) hears the game
through these only. **A hook you need and don't find:** add a method with an empty default to `Hooks` and a
function in `horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data
or a type the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a
draw context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension
point that belongs to one subsystem (an input source, the compositor) lives in the engine crate that owns it, with
the same shape: registered before the game runs, read-only after.
**What players get.** The release builds (`game-build.yml`, so the nightly and the release; `tools/ci/build-musl.sh`)
build `horizon-official` and ship it as `horizon-client`, the name the launcher and players know. The launcher's