|
| 1 | +//! PostHog feature events: the one backend path for all product analytics events. |
| 2 | +//! |
| 3 | +//! See `analytics/CLAUDE.md` § "PostHog feature events" for the full model. In short: backend code |
| 4 | +//! calls [`capture`] directly, frontend code calls the `track_event` IPC command (which calls |
| 5 | +//! [`capture`]). Both ride the SAME consent gate and dev/CI suppression as the heartbeat, attach the |
| 6 | +//! `anal_` install id as the PostHog `distinct_id`, and mirror the PII-free config-shape as `$set` |
| 7 | +//! person properties. |
| 8 | +//! |
| 9 | +//! Events are an OPEN set: [`capture`] takes an arbitrary event name plus an arbitrary PII-free prop |
| 10 | +//! map, so adding an event later is a one-line call with whatever categorical props that event |
| 11 | +//! needs. The PII-free convention (enums, counts, bools only; never paths, names, queries, prompts) |
| 12 | +//! is enforced socially by review and backstopped in debug builds by [`sanitize_props`]. |
| 13 | +
|
| 14 | +use super::config_shape; |
| 15 | +use serde_json::{Map, Value}; |
| 16 | + |
| 17 | +/// PostHog capture endpoint (EU cloud, project `136072`). Same host the website uses. |
| 18 | +const CAPTURE_URL: &str = "https://eu.i.posthog.com/capture/"; |
| 19 | + |
| 20 | +/// Network timeout for one fire-and-forget capture. Mirrors the heartbeat sender. |
| 21 | +const CAPTURE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); |
| 22 | + |
| 23 | +/// The public PostHog project key (`phc_...`), baked at build time via the `CMDR_POSTHOG_KEY` env |
| 24 | +/// var (a GitHub secret for release builds). `None` for local dev builds, where `capture` is a |
| 25 | +/// no-op. The key is public by design (PostHog ingest keys are safe in client code). |
| 26 | +const POSTHOG_KEY: Option<&str> = option_env!("CMDR_POSTHOG_KEY"); |
| 27 | + |
| 28 | +/// Captures one PostHog feature event. Fire-and-forget: builds the payload and spawns the POST, then |
| 29 | +/// returns immediately so no call site ever blocks on the network. |
| 30 | +/// |
| 31 | +/// Gated identically to the heartbeat: a no-op in dev/CI builds (unless `CMDR_ANALYTICS_FORCE=1`), |
| 32 | +/// a no-op when the user opted out (`analytics.enabled == Some(false)`), and a no-op when no |
| 33 | +/// `CMDR_POSTHOG_KEY` was baked in (local dev). `props` is an arbitrary PII-free object; pass |
| 34 | +/// `serde_json::json!({})` for an event with no properties. |
| 35 | +pub fn capture(event: &str, props: Value) { |
| 36 | + if super::suppressed() { |
| 37 | + log::debug!(target: "analytics", "PostHog event '{event}' suppressed (dev or CI, no force override)"); |
| 38 | + return; |
| 39 | + } |
| 40 | + |
| 41 | + // Consent reuses the SAME tri-state gate the heartbeat uses, read through the shared settings |
| 42 | + // loader so consent resolution stays consistent app-wide. |
| 43 | + let Some(app) = super::APP_HANDLE.get() else { |
| 44 | + log::warn!(target: "analytics", "PostHog event '{event}' skipped: app handle not initialized"); |
| 45 | + return; |
| 46 | + }; |
| 47 | + let settings = crate::settings::load_settings(app); |
| 48 | + if !super::analytics_consent_granted(settings.analytics_enabled) { |
| 49 | + // Fully silent: an opted-out install sends nothing at all. |
| 50 | + return; |
| 51 | + } |
| 52 | + |
| 53 | + let Some(api_key) = POSTHOG_KEY else { |
| 54 | + // Local dev: no key baked in. Log once at debug so a dev sees why nothing ships. |
| 55 | + log_missing_key_once(); |
| 56 | + return; |
| 57 | + }; |
| 58 | + |
| 59 | + let fda_granted = !crate::fda_gate::is_fda_pending_runtime(); |
| 60 | + let config = config_shape::build_config_shape(&super::read_raw_settings(), fda_granted); |
| 61 | + let body = build_capture_body(api_key, event, props, &crate::install_id::analytics_id(), config); |
| 62 | + |
| 63 | + send_capture(body); |
| 64 | +} |
| 65 | + |
| 66 | +/// Builds the PostHog `/capture/` request body. Pure (no I/O, no gating), so it's directly |
| 67 | +/// unit-testable. Shape: |
| 68 | +/// |
| 69 | +/// ```json |
| 70 | +/// { |
| 71 | +/// "api_key": "phc_...", |
| 72 | +/// "event": "<name>", |
| 73 | +/// "distinct_id": "anal_<uuid>", |
| 74 | +/// "properties": { "source": "desktop", ...props }, |
| 75 | +/// "$set": <config-shape> |
| 76 | +/// } |
| 77 | +/// ``` |
| 78 | +/// |
| 79 | +/// `source: "desktop"` is injected first so a stray `source` in `props` can't shadow it (and so the |
| 80 | +/// dashboard can always split desktop events from the website's). The config-shape is the SAME |
| 81 | +/// allowlisted object the heartbeat ships, so there's exactly one source of truth for person |
| 82 | +/// properties. |
| 83 | +fn build_capture_body(api_key: &str, event: &str, props: Value, distinct_id: &str, config: Value) -> Value { |
| 84 | + let mut properties = Map::new(); |
| 85 | + properties.insert("source".to_string(), Value::String("desktop".to_string())); |
| 86 | + if let Value::Object(prop_map) = sanitize_props(event, props) { |
| 87 | + for (key, value) in prop_map { |
| 88 | + // `source` injected above wins: skip any caller-supplied `source`. |
| 89 | + properties.entry(key).or_insert(value); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + Value::Object(Map::from_iter([ |
| 94 | + ("api_key".to_string(), Value::String(api_key.to_string())), |
| 95 | + ("event".to_string(), Value::String(event.to_string())), |
| 96 | + ("distinct_id".to_string(), Value::String(distinct_id.to_string())), |
| 97 | + ("properties".to_string(), Value::Object(properties)), |
| 98 | + ("$set".to_string(), config), |
| 99 | + ])) |
| 100 | +} |
| 101 | + |
| 102 | +/// Dev-build PII backstop: scans string prop VALUES for PII shapes and logs a scoped warning if one |
| 103 | +/// slips through. This is a safety net for the open prop map, NOT a substitute for the PII-free |
| 104 | +/// convention (every event must pass only enums / counts / bools by design). Numbers, bools, and |
| 105 | +/// short enum strings pass freely; a string containing `/`, `\`, `@`, or a `~/` home prefix trips |
| 106 | +/// the guard. Returns `props` unchanged either way (it never strips, only warns) so production |
| 107 | +/// behavior is identical with the guard compiled out. |
| 108 | +fn sanitize_props(event: &str, props: Value) -> Value { |
| 109 | + #[cfg(debug_assertions)] |
| 110 | + if let Value::Object(map) = &props { |
| 111 | + for (key, value) in map { |
| 112 | + if let Value::String(s) = value |
| 113 | + && looks_pii_shaped(s) |
| 114 | + { |
| 115 | + log::warn!( |
| 116 | + target: "analytics", |
| 117 | + "PostHog event '{event}' prop '{key}' looks PII-shaped (contains a path / email / home-prefix). \ |
| 118 | + Analytics props must be PII-free enums/counts/bools only; never paths, names, queries, or prompts." |
| 119 | + ); |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + // Reference `event` on the release path so the param isn't flagged unused with the guard off. |
| 124 | + let _ = event; |
| 125 | + props |
| 126 | +} |
| 127 | + |
| 128 | +/// Whether a string value looks like PII (a path, email, or home-prefixed path). Heuristic, used |
| 129 | +/// only by the debug-build [`sanitize_props`] net. |
| 130 | +#[cfg(debug_assertions)] |
| 131 | +fn looks_pii_shaped(s: &str) -> bool { |
| 132 | + s.starts_with("~/") || s.contains('/') || s.contains('\\') || s.contains('@') |
| 133 | +} |
| 134 | + |
| 135 | +/// Logs the "no PostHog key baked in" notice once per process so a local dev sees why feature events |
| 136 | +/// don't ship, without spamming the log on every event. |
| 137 | +fn log_missing_key_once() { |
| 138 | + use std::sync::Once; |
| 139 | + static ONCE: Once = Once::new(); |
| 140 | + ONCE.call_once(|| { |
| 141 | + log::debug!( |
| 142 | + target: "analytics", |
| 143 | + "No CMDR_POSTHOG_KEY baked in (local dev build): PostHog feature events are a no-op" |
| 144 | + ); |
| 145 | + }); |
| 146 | +} |
| 147 | + |
| 148 | +/// Spawns the fire-and-forget POST. A failed capture is fine (the next event retries the channel); |
| 149 | +/// we never block or surface the error. |
| 150 | +fn send_capture(body: Value) { |
| 151 | + tauri::async_runtime::spawn(async move { |
| 152 | + let client = match reqwest::Client::builder().timeout(CAPTURE_TIMEOUT).build() { |
| 153 | + Ok(c) => c, |
| 154 | + Err(e) => { |
| 155 | + log::warn!(target: "analytics", "Couldn't build PostHog HTTP client: {e}"); |
| 156 | + return; |
| 157 | + } |
| 158 | + }; |
| 159 | + match client.post(CAPTURE_URL).json(&body).send().await { |
| 160 | + Ok(response) if response.status().is_success() => { |
| 161 | + log::debug!(target: "analytics", "PostHog event sent ({})", response.status()); |
| 162 | + } |
| 163 | + Ok(response) => { |
| 164 | + log::warn!(target: "analytics", "PostHog server returned {}", response.status()); |
| 165 | + } |
| 166 | + Err(e) => { |
| 167 | + log::debug!(target: "analytics", "PostHog send failed: {e}"); |
| 168 | + } |
| 169 | + } |
| 170 | + }); |
| 171 | +} |
| 172 | + |
| 173 | +#[cfg(test)] |
| 174 | +mod tests { |
| 175 | + use super::*; |
| 176 | + use serde_json::json; |
| 177 | + |
| 178 | + #[test] |
| 179 | + fn capture_body_has_expected_shape() { |
| 180 | + let config = json!({ "theme.mode": "dark", "fdaGranted": true }); |
| 181 | + let body = build_capture_body( |
| 182 | + "phc_test", |
| 183 | + "pane_navigated", |
| 184 | + json!({ "volume_kind": "local" }), |
| 185 | + "anal_178c8e27-511f-4f0e-a1fc-6a44f2ab7341", |
| 186 | + config.clone(), |
| 187 | + ); |
| 188 | + |
| 189 | + assert_eq!(body["api_key"], json!("phc_test")); |
| 190 | + assert_eq!(body["event"], json!("pane_navigated")); |
| 191 | + assert_eq!(body["distinct_id"], json!("anal_178c8e27-511f-4f0e-a1fc-6a44f2ab7341")); |
| 192 | + // `source: "desktop"` is always injected. |
| 193 | + assert_eq!(body["properties"]["source"], json!("desktop")); |
| 194 | + // Arbitrary props pass through. |
| 195 | + assert_eq!(body["properties"]["volume_kind"], json!("local")); |
| 196 | + // `$set` is the config-shape verbatim (one source of truth for person properties). |
| 197 | + assert_eq!(body["$set"], config); |
| 198 | + } |
| 199 | + |
| 200 | + #[test] |
| 201 | + fn distinct_id_is_the_anal_id() { |
| 202 | + let body = build_capture_body("phc_test", "app_launched", json!({}), "anal_abc", json!({})); |
| 203 | + let distinct = body["distinct_id"].as_str().expect("string"); |
| 204 | + assert!( |
| 205 | + distinct.starts_with("anal_"), |
| 206 | + "distinct_id must be the analytics id: {distinct}" |
| 207 | + ); |
| 208 | + } |
| 209 | + |
| 210 | + #[test] |
| 211 | + fn injected_source_cannot_be_shadowed_by_props() { |
| 212 | + // A caller passing `source: "sneaky"` must not override the injected `desktop` value. |
| 213 | + let body = build_capture_body("phc_test", "e", json!({ "source": "sneaky" }), "anal_x", json!({})); |
| 214 | + assert_eq!(body["properties"]["source"], json!("desktop")); |
| 215 | + } |
| 216 | + |
| 217 | + #[test] |
| 218 | + fn arbitrary_props_are_open_ended() { |
| 219 | + // The event API is open: any PII-free prop map passes through, no fixed schema. |
| 220 | + let body = build_capture_body( |
| 221 | + "phc_test", |
| 222 | + "file_transfer_completed", |
| 223 | + json!({ "op": "copy", "item_count": "11-100", "had_conflicts": false }), |
| 224 | + "anal_x", |
| 225 | + json!({}), |
| 226 | + ); |
| 227 | + assert_eq!(body["properties"]["op"], json!("copy")); |
| 228 | + assert_eq!(body["properties"]["item_count"], json!("11-100")); |
| 229 | + assert_eq!(body["properties"]["had_conflicts"], json!(false)); |
| 230 | + } |
| 231 | + |
| 232 | + // The PII backstop only runs in debug builds (where these tests run under `cargo nextest`). |
| 233 | + #[cfg(debug_assertions)] |
| 234 | + #[test] |
| 235 | + fn pii_guard_trips_on_pii_shaped_strings() { |
| 236 | + assert!(looks_pii_shaped("/Users/dave/secret"), "absolute path"); |
| 237 | + assert!(looks_pii_shaped("~/Documents"), "home prefix"); |
| 238 | + assert!(looks_pii_shaped("person@example.com"), "email"); |
| 239 | + assert!(looks_pii_shaped("C:\\Users\\dave"), "windows path"); |
| 240 | + assert!(looks_pii_shaped("photos/sunset.jpg"), "relative path"); |
| 241 | + } |
| 242 | + |
| 243 | + #[cfg(debug_assertions)] |
| 244 | + #[test] |
| 245 | + fn pii_guard_passes_plain_enums_and_values() { |
| 246 | + // Categorical enums, buckets, and plain words are not PII-shaped. |
| 247 | + assert!(!looks_pii_shaped("local")); |
| 248 | + assert!(!looks_pii_shaped("copy")); |
| 249 | + assert!(!looks_pii_shaped("11-100")); |
| 250 | + assert!(!looks_pii_shaped("disconnected")); |
| 251 | + assert!(!looks_pii_shaped("filename")); |
| 252 | + } |
| 253 | + |
| 254 | + #[cfg(debug_assertions)] |
| 255 | + #[test] |
| 256 | + fn sanitize_props_returns_props_unchanged() { |
| 257 | + // The guard only warns; it never strips. A PII-shaped value still passes through (so the |
| 258 | + // dev sees the warning AND the bug isn't silently masked). |
| 259 | + let props = json!({ "volume_kind": "local", "leaked": "/Users/dave" }); |
| 260 | + let out = sanitize_props("test_event", props.clone()); |
| 261 | + assert_eq!(out, props); |
| 262 | + } |
| 263 | +} |
0 commit comments