Engine: anonymous usage events, counted in Session::execute (#238)

fieldcraft_engine::usage holds the events docs/privacy.md lists, typed so
that only registry names (command ids, sample names, requirements) and
durations can go in them, a bounded queue that records nothing until the
host enables it, and the PostHog batch body. Session::execute counts each
top-level command of a session with an origin (ui, palette, script, mcp):
its id, kind, duration and error class, and the funnel's milestones and
opened samples. Nothing here touches the network.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 01:07:28 -04:00
1 parent 54f50f9518
commit 162bb421f1
3 files changed
+615 -2

No files matched your search

+2 -1
View File
@@ -236,12 +236,13 @@ impl Session {
}
/// Replace this session with `opened`, keeping its undo memory limit, its work caps and its
/// file access; the history starts empty.
/// file access (and where its commands come from); the history starts empty.
fn replace_with(&mut self, mut opened: Session) {
opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.file_access = std::mem::take(&mut self.file_access);
opened.crash_journal = self.crash_journal;
opened.origin = self.origin;
opened.clear_history();
opened.mark_opened();
*self = opened;
+20 -1
View File
@@ -28,6 +28,7 @@ pub mod requirement;
pub mod results;
pub mod sample;
pub mod schema;
pub mod usage;
pub mod view;
use std::collections::BTreeMap;
@@ -250,6 +251,10 @@ pub struct Session {
/// This is the app's own model: its top-level commands go into the crash journal
/// ([`crash`]), so an unclean exit can be replayed. Off for scratch sessions and tests.
pub crash_journal: bool,
/// Where this session's commands come from, for the usage analytics ([`usage`]): the app
/// sets it on its own model (and the control channel and the command search while they
/// run); `None` (scratch sessions, the CLI, tests) counts nothing.
pub origin: Option<usage::Origin>,
}
impl Session {
@@ -258,7 +263,21 @@ impl Session {
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
// An id from hostile input is not echoed in full.
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?;
self.availability(spec).map_err(EngineError::Unavailable)?;
let timer = usage::Timer::start(self.origin);
let elements = self.model.elements().len();
let result = self.availability(spec).map_err(EngineError::Unavailable).and_then(|()| self.run_command(spec, params));
if timer.counting() {
let milestone = usage::milestone(spec.id, (elements, self.model.elements().len()));
let sample =
(spec.id == "file.sample").then(|| params.get("name").and_then(Value::as_str).and_then(sample::find)).flatten().map(|s| s.name);
timer.done(spec.id, spec.kind, result.as_ref().err(), milestone, sample);
}
result
}
/// [`Session::execute`] once the command is found and can run.
fn run_command(&mut self, spec: &'static CommandSpec, params: &Value) -> Result<Value> {
let id = spec.id;
// Session commands (open, save, undo, redo) change the session only in their last step,
// after everything that can fail, so they need no copy of the model to restore.
let before = (spec.kind == CommandKind::Model).then(|| (self.snapshot(), self.document()));
+593
View File
@@ -0,0 +1,593 @@
//! Anonymous usage analytics, off until the user agrees (docs/privacy.md).
//!
//! The app records [`Event`]s here; a host (the desktop app's sender thread, the web page's
//! `fetch`) takes them in batches ([`take_batch`]) and posts [`Batch::body`] to PostHog's batch
//! API. Nothing here touches the network.
//!
//! What an event can hold is fixed by its type: command ids, sample names and requirements come
//! from the registries (`&'static str`), never from parameters, paths, file names or messages.
//! Recording does nothing until [`enable`] (the user agreed, and the host may send); [`disable`]
//! drops what is queued. The queue is bounded ([`MAX_QUEUED`]): when full, new events are dropped.
//!
//! [`Session::execute`](crate::Session::execute) counts every top-level command of a session
//! whose [`Session::origin`](crate::Session::origin) is set (the app's own model): its id, kind,
//! duration, whether it failed and how, and where it came from ([`Origin`]); the funnel's
//! [`Milestone`]s and [`Event::SampleOpened`] follow from the same commands.
use std::cell::Cell;
use std::collections::VecDeque;
use std::sync::Mutex;
use serde_json::{Map, Value, json};
use crate::requirement::Requirement;
use crate::{CommandKind, EngineError};
/// PostHog Cloud's EU ingestion host.
pub const DEFAULT_HOST: &str = "https://eu.i.posthog.com";
/// Events kept waiting to be sent; more are dropped.
pub const MAX_QUEUED: usize = 1000;
/// Events in one request.
pub const MAX_BATCH: usize = 100;
/// Where a command came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Origin {
/// A click: the ribbon, a menu, a form, the browser.
Ui,
/// The command search or the command line.
Palette,
/// A script or the control channel.
Script,
/// An AI agent through the MCP server.
Mcp,
}
impl Origin {
pub fn as_str(self) -> &'static str {
match self {
Origin::Ui => "ui",
Origin::Palette => "palette",
Origin::Script => "script",
Origin::Mcp => "mcp",
}
}
/// Scripts and agents, which the user can leave out on their own.
pub fn automated(self) -> bool {
matches!(self, Origin::Script | Origin::Mcp)
}
}
/// A step of the way from a design to results, counted once per run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Milestone {
DesignOpened,
Meshed,
Solved,
ResultsViewed,
}
impl Milestone {
pub fn as_str(self) -> &'static str {
match self {
Milestone::DesignOpened => "design_opened",
Milestone::Meshed => "meshed",
Milestone::Solved => "solved",
Milestone::ResultsViewed => "results_viewed",
}
}
fn bit(self) -> u8 {
1 << self as u8
}
}
/// Something that happened, with only what docs/privacy.md lists.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
AppStarted,
/// The window closed after `seconds`.
AppClosed {
seconds: u64,
},
CommandRun {
command: &'static str,
kind: CommandKind,
duration_ms: u64,
error: Option<&'static str>,
from: Origin,
},
Milestone(Milestone),
SampleOpened {
name: &'static str,
},
/// A command's form closed without running it.
FormCancelled {
command: &'static str,
},
/// A command clicked before the model had what it needs.
UnavailableClicked {
command: &'static str,
missing: Vec<Requirement>,
},
}
impl Event {
pub fn name(&self) -> &'static str {
match self {
Event::AppStarted => "app_started",
Event::AppClosed { .. } => "app_closed",
Event::CommandRun { .. } => "command_run",
Event::Milestone(_) => "milestone",
Event::SampleOpened { .. } => "sample_opened",
Event::FormCancelled { .. } => "form_cancelled",
Event::UnavailableClicked { .. } => "unavailable_clicked",
}
}
/// The event's own properties.
pub fn properties(&self) -> Map<String, Value> {
let v = match self {
Event::AppStarted => json!({}),
Event::AppClosed { seconds } => json!({"duration_s": seconds}),
Event::CommandRun { command, kind, duration_ms, error, from } => {
let kind = match kind {
CommandKind::Model => "model",
CommandKind::Session => "session",
};
let mut v = json!({"command": command, "kind": kind, "duration_ms": duration_ms, "ok": error.is_none(), "from": from.as_str()});
if let Some(e) = error {
v["error"] = json!(e);
}
v
}
Event::Milestone(m) => json!({"name": m.as_str()}),
Event::SampleOpened { name } => json!({"name": name}),
Event::FormCancelled { command } => json!({"command": command}),
Event::UnavailableClicked { command, missing } => json!({"command": command, "missing": missing}),
};
match v {
Value::Object(m) => m,
_ => Map::new(),
}
}
}
/// The class of an error, without its message (which can name the user's files and values).
pub fn error_class(e: &EngineError) -> &'static str {
match e {
EngineError::UnknownCommand(_) => "unknown_command",
EngineError::Params(_) | EngineError::Param(_) => "params",
EngineError::Model(_) => "model",
EngineError::Geometry(_) => "geometry",
EngineError::Mesh(_) => "mesh",
EngineError::File(_) => "file",
EngineError::Deck(_) => "deck",
EngineError::Solver(_) => "solver",
EngineError::Explicit(_) => "explicit",
EngineError::Fluid(_) => "fluid",
EngineError::Unavailable(_) => "unavailable",
EngineError::Internal { .. } => "internal",
}
}
/// Who is sending: the anonymous install and the build. The same for every event of a run.
#[derive(Clone, Debug, PartialEq)]
pub struct Identity {
/// A random id made for this install (not derived from the machine or the user).
pub install_id: String,
/// `desktop` or `web`.
pub platform: &'static str,
/// The operating system (`macos`, `linux`, `windows`; `web` in a browser).
pub os: &'static str,
pub arch: &'static str,
pub app_version: &'static str,
}
/// What [`take_batch`] hands the sender.
#[derive(Clone, Debug, PartialEq)]
pub struct Batch {
pub identity: Identity,
pub events: Vec<Event>,
}
impl Batch {
/// The body of a POST to PostHog's `/batch/` endpoint with the project's API key: each event
/// with the anonymous install id, the build, no person profile and no IP-based location.
pub fn body(&self, api_key: &str) -> String {
let id = &self.identity;
let batch: Vec<Value> = self
.events
.iter()
.map(|e| {
let mut p = e.properties();
p.insert("app_version".into(), json!(id.app_version));
p.insert("platform".into(), json!(id.platform));
p.insert("os".into(), json!(id.os));
p.insert("arch".into(), json!(id.arch));
p.insert("$process_person_profile".into(), json!(false));
p.insert("$geoip_disable".into(), json!(true));
json!({"event": e.name(), "distinct_id": id.install_id, "properties": p})
})
.collect();
json!({"api_key": api_key, "batch": batch}).to_string()
}
}
/// Whether this run may record at all, before asking the user: the build has a project key,
/// `FIELDCRAFT_TELEMETRY` (`telemetry`) is not `0`, and a run driven by automation (`--control`)
/// or CI records only when `FIELDCRAFT_TELEMETRY=1` says so.
pub fn allowed(has_key: bool, telemetry: Option<&str>, automation: bool, ci: bool) -> bool {
let set = telemetry.map(|v| v.trim().to_ascii_lowercase());
match set.as_deref() {
Some("0" | "false" | "off" | "no") => false,
Some("1" | "true" | "on" | "yes") => has_key,
_ => has_key && !automation && !ci,
}
}
struct State {
identity: Identity,
/// Count scripts and MCP runs too.
automation: bool,
queue: VecDeque<Event>,
/// Milestones already sent this run.
reached: u8,
/// When recording started ([`now_ms`]), for the session's length.
started: Option<f64>,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
fn with_state<R>(f: impl FnOnce(&mut Option<State>) -> R) -> R {
// A poisoned lock still holds a usable queue.
let mut g = STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut g)
}
/// Start recording, as `identity`, with [`Event::AppStarted`]; `automation` counts scripts and
/// MCP runs too. Already recording, only the settings change.
pub fn enable(identity: Identity, automation: bool) {
let now = now_ms();
with_state(|s| match s {
Some(s) => {
s.identity = identity;
s.automation = automation;
}
None => *s = Some(State { identity, automation, queue: VecDeque::from([Event::AppStarted]), reached: 0, started: now }),
});
}
/// The app is closing: [`Event::AppClosed`] with how long it recorded.
pub fn closing() {
let now = now_ms();
let started = with_state(|s| s.as_ref().map(|s| s.started));
if let Some(started) = started {
let seconds = match (started, now) {
(Some(a), Some(b)) if b >= a => ((b - a) / 1000.0).round() as u64,
_ => 0,
};
record(Event::AppClosed { seconds });
}
}
/// Stop recording and drop everything queued.
pub fn disable() {
with_state(|s| *s = None);
}
pub fn enabled() -> bool {
with_state(|s| s.is_some())
}
/// Queue an event, when recording (and when it is no script or MCP run the user left out).
pub fn record(event: Event) {
with_state(|s| {
let Some(s) = s else { return };
match &event {
Event::CommandRun { from, .. } if from.automated() && !s.automation => return,
Event::Milestone(m) if s.reached & m.bit() != 0 => return,
Event::Milestone(m) => s.reached |= m.bit(),
_ => {}
}
if s.queue.len() < MAX_QUEUED {
s.queue.push_back(event);
}
});
}
/// Up to [`MAX_BATCH`] queued events to send, oldest first; `None` when there are none.
pub fn take_batch() -> Option<Batch> {
with_state(|s| {
let s = s.as_mut()?;
if s.queue.is_empty() {
return None;
}
let n = s.queue.len().min(MAX_BATCH);
Some(Batch { identity: s.identity.clone(), events: s.queue.drain(..n).collect() })
})
}
/// Events waiting to be sent.
pub fn queued() -> usize {
with_state(|s| s.as_ref().map_or(0, |s| s.queue.len()))
}
/// A clock in milliseconds for command durations. The plain wasm target has none: the web page
/// sets one ([`set_clock`]).
static CLOCK: Mutex<Option<fn() -> f64>> = Mutex::new(None);
/// Use `clock` (milliseconds, any origin) for command durations.
pub fn set_clock(clock: fn() -> f64) {
*CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(clock);
}
fn now_ms() -> Option<f64> {
if let Some(c) = *CLOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
return Some(c());
}
#[cfg(not(target_arch = "wasm32"))]
{
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
Some(START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0)
}
#[cfg(target_arch = "wasm32")]
None
}
thread_local! {
/// Commands running on this thread: only the outermost is counted.
static DEPTH: Cell<u32> = const { Cell::new(0) };
}
/// A command being counted, from [`Session::execute`](crate::Session::execute): `None` when it
/// is not (not recording, no origin, or a command inside another).
pub(crate) struct Timer {
started: Option<f64>,
from: Option<Origin>,
}
impl Timer {
pub(crate) fn start(from: Option<Origin>) -> Timer {
let outer = DEPTH.with(|d| {
d.set(d.get() + 1);
d.get() == 1
});
let from = from.filter(|_| outer && enabled());
Timer { started: from.and_then(|_| now_ms()), from }
}
/// Whether this command is counted.
pub(crate) fn counting(&self) -> bool {
self.from.is_some()
}
/// The command ended: count it, and the milestone it reached.
pub(crate) fn done(
&self,
command: &'static str,
kind: CommandKind,
error: Option<&EngineError>,
milestone: Option<Milestone>,
sample: Option<&'static str>,
) {
let Some(from) = self.from else { return };
let duration_ms = match (self.started, now_ms()) {
(Some(a), Some(b)) if b >= a => (b - a).round() as u64,
_ => 0,
};
record(Event::CommandRun { command, kind, duration_ms, error: error.map(error_class), from });
if error.is_none() {
if let Some(m) = milestone {
record(Event::Milestone(m));
}
if let Some(name) = sample {
record(Event::SampleOpened { name });
}
}
}
}
impl Drop for Timer {
fn drop(&mut self) {
DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
}
}
/// The milestone a successful `command` reaches; `elements` is the element count before and
/// after it.
pub(crate) fn milestone(command: &str, elements: (usize, usize)) -> Option<Milestone> {
match command {
"geometry.open" | "geometry.import.mesh" | "file.open" => Some(Milestone::DesignOpened),
c if c.starts_with("mesh.") && elements.1 > elements.0 => Some(Milestone::Meshed),
// Solvers, not the queries on a solved step.
c if c.starts_with("solve.") && !matches!(c, "solve.probe" | "solve.history") => Some(Milestone::Solved),
c if c.starts_with("results.") && !matches!(c, "results.reactions" | "results.clear") => Some(Milestone::ResultsViewed),
_ => None,
}
}
/// Serialises the tests (and other code) that turn the process-wide recording on.
#[doc(hidden)]
pub static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
fn identity() -> Identity {
Identity { install_id: "00000000-0000-4000-8000-000000000000".into(), platform: "desktop", os: "test", arch: "test", app_version: "0.0.0" }
}
fn take_all() -> Vec<Event> {
std::iter::from_fn(take_batch).flat_map(|b| b.events).collect()
}
/// Every string in a payload is a key, a registry name or the identity, and every number a
/// duration: no path, file name, parameter value or message can be in it.
pub(crate) fn assert_clean(body: &str) {
let v: Value = serde_json::from_str(body).unwrap();
let mut allowed: Vec<String> = crate::commands().iter().map(|c| c.id.to_string()).collect();
allowed.extend(crate::sample::SAMPLES.iter().map(|s| s.name.to_string()));
for e in [
"app_started",
"app_closed",
"command_run",
"milestone",
"sample_opened",
"form_cancelled",
"unavailable_clicked",
"model",
"session",
"ui",
"palette",
"script",
"mcp",
"design_opened",
"meshed",
"solved",
"results_viewed",
"unknown_command",
"params",
"geometry",
"mesh",
"file",
"deck",
"solver",
"explicit",
"fluid",
"unavailable",
"internal",
"KEY",
] {
allowed.push(e.into());
}
let id = identity();
allowed.extend([id.install_id, id.platform.into(), id.os.into(), id.arch.into(), id.app_version.into()]);
fn walk(key: &str, v: &Value, allowed: &[String]) {
match v {
Value::String(s) => {
// A requirement's snake_case name.
let requirement = key == "missing" && s.chars().all(|c| c.is_ascii_lowercase() || c == '_');
assert!(allowed.contains(s) || requirement, "`{key}`: `{s}` is not a name FieldCraft defines");
}
Value::Number(_) => assert!(["duration_ms", "duration_s"].contains(&key), "`{key}`: a number that is not a duration"),
Value::Array(a) => a.iter().for_each(|x| walk(key, x, allowed)),
Value::Object(m) => m.iter().for_each(|(k, x)| walk(k, x, allowed)),
Value::Bool(_) | Value::Null => {}
}
}
walk("", &v, &allowed);
}
#[test]
fn nothing_is_queued_without_consent() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
let mut s = Session { origin: Some(Origin::Ui), ..Default::default() };
s.run_script(&crate::sample::cantilever()).unwrap();
record(Event::AppStarted);
assert_eq!(queued(), 0);
assert!(take_batch().is_none());
}
#[test]
fn a_scripted_session_sends_only_names_and_durations() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
enable(identity(), true);
let dir = std::env::temp_dir().join(format!("fieldcraft-usage-secret-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("my-secret-bracket.solidcraft");
std::fs::write(&design, fieldcraft_geometry::sample::plate_design(&fieldcraft_geometry::sample::Plate::default()).unwrap()).unwrap();
let mut s = Session { origin: Some(Origin::Script), ..Default::default() };
s.execute("geometry.open", &json!({"path": design.to_string_lossy()})).unwrap();
s.run_script(&crate::sample::cantilever()).unwrap();
s.execute("model.material.create", &json!({"name": "Secret alloy 7", "elastic": {"young": 123456.75, "poisson": 0.3125}})).ok();
s.execute("solve.run", &json!({})).unwrap();
s.execute("results.contour", &json!({"field": "von_mises"})).unwrap();
// A failure whose message names a path, and a sample.
assert!(s.execute("file.open", &json!({"path": dir.join("missing-secret.fieldcraft").to_string_lossy()})).is_err());
s.execute("file.sample", &json!({"name": "cantilever"})).unwrap();
// An unknown id is user text: not counted.
assert!(s.execute("secret.command", &json!({})).is_err());
let events = take_all();
let body = Batch { identity: identity(), events: events.clone() }.body("KEY");
assert_clean(&body);
for secret in ["secret", "Secret", "123456", "0.3125", "1000", dir.to_string_lossy().as_ref()] {
assert!(!body.contains(secret), "{secret} in {body}");
}
let names: Vec<&str> = events.iter().map(Event::name).collect();
assert!(names.contains(&"command_run") && names.contains(&"sample_opened"), "{names:?}");
// Inner commands (the script's steps run by execute, a sample's own script) count once.
let runs = events.iter().filter(|e| matches!(e, Event::CommandRun { command: "file.sample", .. })).count();
assert_eq!(runs, 1);
let reached: Vec<_> = events.iter().filter_map(|e| if let Event::Milestone(m) = e { Some(*m) } else { None }).collect();
for m in [Milestone::DesignOpened, Milestone::Meshed, Milestone::Solved, Milestone::ResultsViewed] {
assert_eq!(reached.iter().filter(|r| **r == m).count(), 1, "{m:?} once: {reached:?}");
}
assert!(
events.iter().any(|e| matches!(e, Event::CommandRun { command: "file.open", error: Some("file"), from: Origin::Script, .. })),
"{events:?}"
);
disable();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn scripts_and_agents_can_be_left_out_and_the_queue_is_bounded() {
let _g = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
disable();
enable(identity(), false);
let run = |from| Event::CommandRun { command: "model.info", kind: CommandKind::Model, duration_ms: 1, error: None, from };
record(run(Origin::Mcp));
record(run(Origin::Script));
record(run(Origin::Palette));
assert_eq!(take_all(), [Event::AppStarted, run(Origin::Palette)]);
for _ in 0..MAX_QUEUED + 50 {
record(Event::AppStarted);
}
assert_eq!(queued(), MAX_QUEUED);
let first = take_batch().unwrap();
assert_eq!(first.events.len(), MAX_BATCH);
assert_eq!(queued(), MAX_QUEUED - MAX_BATCH);
// Turning it off drops what waits.
disable();
assert_eq!(queued(), 0);
enable(identity(), true);
closing();
assert!(matches!(take_all()[..], [Event::AppStarted, Event::AppClosed { .. }]));
disable();
}
#[test]
fn the_body_is_a_posthog_batch() {
let b = Batch {
identity: identity(),
events: vec![Event::UnavailableClicked { command: "solve.run", missing: vec![Requirement::Step, Requirement::Material] }],
};
let v: Value = serde_json::from_str(&b.body("phc_test")).unwrap();
assert_eq!(v["api_key"], "phc_test");
let e = &v["batch"][0];
assert_eq!((e["event"].as_str(), e["distinct_id"].as_str()), (Some("unavailable_clicked"), Some(identity().install_id.as_str())));
assert_eq!(e["properties"]["missing"], json!(["step", "material"]));
assert_eq!(e["properties"]["$process_person_profile"], false);
assert_eq!(e["properties"]["$geoip_disable"], true);
assert_clean(&b.body("KEY"));
}
#[test]
fn telemetry_is_off_without_a_key_in_automation_and_ci_and_when_turned_off() {
assert!(allowed(true, None, false, false));
assert!(!allowed(false, None, false, false));
assert!(!allowed(false, Some("1"), false, false));
assert!(!allowed(true, Some("0"), false, false));
assert!(!allowed(true, Some(" off "), false, false));
assert!(!allowed(true, None, true, false));
assert!(!allowed(true, None, false, true));
assert!(!allowed(true, Some(""), true, false));
assert!(allowed(true, Some("1"), true, true));
}
}