forked from 1b-agent/fieldcraft-upstream
App: send usage batches to PostHog Cloud EU from a background thread (#238)
ureq over rustls (ring) with the OS certificate verifier. The project key is the build-time FIELDCRAFT_POSTHOG_KEY; without it nothing is asked or sent. FIELDCRAFT_TELEMETRY=0 turns it off; --control and CI runs only with FIELDCRAFT_TELEMETRY=1. A failed post drops its batch. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
79d583ea0f
commit
ed9ec7748d
5 files changed
+1586
-34
No files matched your search
@@ -18,6 +18,11 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Usage analytics (docs/privacy.md): an HTTPS POST with rustls on ring, checked against the
|
||||
# operating system's certificates (no bundled root list), and a random install id.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
getrandom = "0.3"
|
||||
|
||||
[build-dependencies]
|
||||
# The Windows executable's icon resource (build.rs).
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod control_server;
|
||||
mod usage;
|
||||
|
||||
use fieldcraft_engine::autosave::{self, Autosaver};
|
||||
use fieldcraft_engine::{Session, crash, logfile};
|
||||
@@ -49,6 +50,8 @@ struct App {
|
||||
autosaved_at: f64,
|
||||
/// The first frame was drawn (the crash journal says the app is up).
|
||||
running: bool,
|
||||
/// Sends the anonymous usage events, when this run may (docs/privacy.md).
|
||||
usage: Option<usage::Sender>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -82,6 +85,10 @@ impl eframe::App for App {
|
||||
{
|
||||
log::warn!("autosave: {e}");
|
||||
}
|
||||
self.app.usage_closing();
|
||||
if let Some(u) = self.usage.take() {
|
||||
u.finish();
|
||||
}
|
||||
log::info!("FieldCraft closed");
|
||||
crash::end_session();
|
||||
}
|
||||
@@ -103,7 +110,8 @@ fn services() -> Services {
|
||||
})),
|
||||
// Beside the recovery folder in the per-user data folder.
|
||||
recent_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("recent.json"))),
|
||||
prefs_path: fieldcraft_io::recovery::default_dir().and_then(|d| d.parent().map(|p| p.join("prefs.json"))),
|
||||
prefs: fieldcraft_io::recovery::default_dir()
|
||||
.and_then(|d| d.parent().map(|p| fieldcraft_ui_egui::files::PrefsStore::file(p.join("prefs.json")))),
|
||||
open_url: Some(Box::new(open_url)),
|
||||
}
|
||||
}
|
||||
@@ -236,6 +244,14 @@ fn main() -> eframe::Result {
|
||||
// The window's model: its commands go into the crash journal.
|
||||
session.crash_journal = crash::enabled();
|
||||
let mut app = ModelApp::new(session).with_services(services());
|
||||
// Anonymous usage: asked once, recorded only when the user agreed (docs/privacy.md).
|
||||
let usage = match usage::host(args.control.is_some()) {
|
||||
Some(host) => {
|
||||
app = app.with_usage(host);
|
||||
usage::start()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
@@ -261,7 +277,7 @@ fn main() -> eframe::Result {
|
||||
Autosaver::new(&dir).map_err(|e| log::warn!("autosave unavailable: {e}")).ok()
|
||||
});
|
||||
app.announce_crashes(&crashed);
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false }))
|
||||
Ok(Box::new(App { app, autosaver, autosaved_at: 0.0, running: false, usage }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Sending the anonymous usage events (docs/privacy.md) to PostHog Cloud EU: a background
|
||||
//! thread posts the queued events ([`fieldcraft_engine::usage`]) in batches every half minute and
|
||||
//! when the window closes. A failed post (offline, a slow network) drops its batch silently;
|
||||
//! nothing waits on it but the thread.
|
||||
//!
|
||||
//! The project key is a build setting, `FIELDCRAFT_POSTHOG_KEY`, which only the release workflow
|
||||
//! sets: local, development and fork builds have none and send nothing.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fieldcraft_engine::usage;
|
||||
use fieldcraft_ui_egui::privacy::UsageHost;
|
||||
|
||||
/// The PostHog project key the release build was made with.
|
||||
const KEY: Option<&str> = option_env!("FIELDCRAFT_POSTHOG_KEY");
|
||||
/// How often queued events are sent.
|
||||
const INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// The longest one post may take.
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How long closing the window waits for the last batch.
|
||||
const LAST_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
fn key() -> Option<&'static str> {
|
||||
KEY.map(str::trim).filter(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
/// What the window needs to ask and record, when this run may share usage: the build has a key,
|
||||
/// `FIELDCRAFT_TELEMETRY` is not `0`, and neither CI nor `--control` automation runs it (unless
|
||||
/// `FIELDCRAFT_TELEMETRY=1`).
|
||||
pub fn host(automation: bool) -> Option<UsageHost> {
|
||||
let telemetry = std::env::var("FIELDCRAFT_TELEMETRY").ok();
|
||||
let ci = std::env::var_os("CI").is_some_and(|v| !v.is_empty());
|
||||
usage::allowed(key().is_some(), telemetry.as_deref(), automation, ci).then(|| UsageHost {
|
||||
platform: "desktop",
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
new_id: Box::new(new_id),
|
||||
})
|
||||
}
|
||||
|
||||
/// A random (version 4) UUID from the operating system's random source.
|
||||
fn new_id() -> Option<String> {
|
||||
let mut b = [0u8; 16];
|
||||
getrandom::fill(&mut b).ok()?;
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
let hex: String = b.iter().map(|x| format!("{x:02x}")).collect();
|
||||
Some(format!("{}-{}-{}-{}-{}", &hex[..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..]))
|
||||
}
|
||||
|
||||
/// The sending thread; [`Sender::finish`] sends what is left and stops it.
|
||||
pub struct Sender {
|
||||
stop: mpsc::Sender<()>,
|
||||
finished: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
/// Start the sending thread (only when the build has a key). `FIELDCRAFT_TELEMETRY_HOST`
|
||||
/// replaces PostHog's host, for checking the payload against a local listener.
|
||||
pub fn start() -> Option<Sender> {
|
||||
let key = key()?;
|
||||
let host = std::env::var("FIELDCRAFT_TELEMETRY_HOST").ok().filter(|h| !h.trim().is_empty()).unwrap_or_else(|| usage::DEFAULT_HOST.into());
|
||||
let url = format!("{}/batch/", host.trim_end_matches('/'));
|
||||
// rustls's process-wide crypto (ring); another part of the app may have installed it first.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(TIMEOUT))
|
||||
.tls_config(ureq::tls::TlsConfig::builder().root_certs(ureq::tls::RootCerts::PlatformVerifier).build())
|
||||
.build()
|
||||
.into();
|
||||
let (stop, stopped) = mpsc::channel::<()>();
|
||||
let (done, finished) = mpsc::channel::<()>();
|
||||
let spawned = std::thread::Builder::new().name("fieldcraft usage".into()).spawn(move || {
|
||||
loop {
|
||||
let last = !matches!(stopped.recv_timeout(INTERVAL), Err(mpsc::RecvTimeoutError::Timeout));
|
||||
while let Some(batch) = usage::take_batch() {
|
||||
if let Err(e) = agent.post(&url).header("Content-Type", "application/json").send(batch.body(key)) {
|
||||
// Offline or refused: this batch is dropped, the rest waits for the next try.
|
||||
log::debug!("usage: {} event(s) not sent: {e}", batch.events.len());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if last {
|
||||
let _ = done.send(());
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => Some(Sender { stop, finished }),
|
||||
Err(e) => {
|
||||
log::warn!("usage: no sending thread: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sender {
|
||||
/// Send what is queued (waiting briefly) and stop.
|
||||
pub fn finish(self) {
|
||||
let _ = self.stop.send(());
|
||||
let _ = self.finished.recv_timeout(LAST_WAIT);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn install_ids_are_random_version_4_uuids() {
|
||||
let (a, b) = (super::new_id().unwrap(), super::new_id().unwrap());
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.len(), 36);
|
||||
assert_eq!(a.as_bytes()[14], b'4');
|
||||
assert!(matches!(a.as_bytes()[19], b'8' | b'9' | b'a' | b'b'), "{a}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user