Skip to main content

rpfm_telemetry/
actions.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Anonymous action telemetry, shared between the UI and the server.
12//!
13//! Tracks which actions (UI slots, server commands, ...) are used during a
14//! session. On graceful shutdown the per-session counts are POSTed to
15//! PostHog's `/batch/` capture endpoint as one event per distinct action,
16//! with the invocation count carried as the `count` property so PostHog
17//! insights can aggregate via `sum(properties.count)`.
18//!
19//! Telemetry is opt-out: the flag defaults to `true` so events captured
20//! during early startup (before settings have been loaded) aren't lost.
21//! Callers refresh it via [`set_usage_telemetry_enabled`] once they know
22//! the user's preference. While disabled, [`track_action`] still emits a
23//! log line for debugging, but no counters are kept.
24
25use std::collections::HashMap;
26use std::sync::atomic::{AtomicBool, Ordering};
27use std::sync::{Arc, LazyLock, RwLock};
28use std::time::{SystemTime, UNIX_EPOCH};
29
30use crate::{error, info, warn};
31
32/// PostHog host all events are sent to (PostHog Cloud EU).
33const DEFAULT_POSTHOG_HOST: &str = "https://eu.i.posthog.com";
34
35/// Whether usage telemetry counter updates are enabled. On by default so
36/// early-boot actions (before settings are loaded) are still counted;
37/// callers may opt out via [`set_usage_telemetry_enabled`].
38pub(crate) static USAGE_TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(true);
39
40/// Global action counter. Keys are action names, values are invocation counts.
41static ACTION_COUNTS: LazyLock<RwLock<HashMap<String, u64>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
42
43/// PostHog project API key.
44pub static POSTHOG_API_KEY: LazyLock<Arc<RwLock<String>>> = LazyLock::new(|| Arc::new(RwLock::new(String::new())));
45
46/// Identifier sent as `distinct_id` on every PostHog event.
47static DISTINCT_ID: LazyLock<RwLock<String>> = LazyLock::new(|| {
48    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
49    RwLock::new(format!("rpfm-ephemeral-{}-{}", std::process::id(), nanos))
50});
51
52/// Extra properties attached to every PostHog event in the flush (release,
53/// os, is_beta, ...). Callers populate this via [`set_event_property`]
54/// before the flush runs.
55static EVENT_PROPERTIES: LazyLock<RwLock<HashMap<String, serde_json::Value>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
56
57/// Enables or disables usage-telemetry counter updates.
58///
59/// Callers should set this once at startup (from the `enable_usage_telemetry`
60/// setting) and refresh it whenever the setting changes. Pending counts
61/// accumulated while enabled are preserved and will still be flushed.
62pub fn set_usage_telemetry_enabled(enabled: bool) {
63    USAGE_TELEMETRY_ENABLED.store(enabled, Ordering::Relaxed);
64}
65
66/// Returns the current usage-telemetry enabled state.
67pub fn is_usage_telemetry_enabled() -> bool {
68    USAGE_TELEMETRY_ENABLED.load(Ordering::Relaxed)
69}
70
71/// Sets the PostHog project API key used by [`flush`].
72///
73/// An empty string disables the PostHog flush entirely.
74pub fn set_posthog_api_key(key: &str) {
75    if let Ok(mut guard) = POSTHOG_API_KEY.write() {
76        *guard = key.to_string();
77    }
78}
79
80/// Sets the `distinct_id` attached to every PostHog event in the flush.
81pub fn set_distinct_id(id: &str) {
82    if let Ok(mut guard) = DISTINCT_ID.write() {
83        *guard = id.to_string();
84    }
85}
86
87/// Attaches a property to every event sent in the next [`flush`].
88pub fn set_event_property(key: &str, value: serde_json::Value) {
89    if let Ok(mut guard) = EVENT_PROPERTIES.write() {
90        guard.insert(key.to_string(), value);
91    }
92}
93
94/// Records a single action occurrence.
95///
96/// Also emits an `info!` log line for consistency with the existing logging.
97/// If telemetry is disabled, only the log line is emitted.
98pub fn track_action(action: &str) {
99    info!("Triggering `{}` By Slot", action);
100    record_action(action);
101}
102
103/// Records a single action occurrence without emitting a log line.
104///
105/// Useful for callers (like the server) that already log incoming actions
106/// elsewhere and don't want a duplicate log line. No-op when telemetry is
107/// disabled.
108pub fn record_action(action: &str) {
109    if USAGE_TELEMETRY_ENABLED.load(Ordering::Relaxed) {
110        if let Ok(mut counts) = ACTION_COUNTS.write() {
111            *counts.entry(action.to_string()).or_insert(0) += 1;
112        }
113    }
114}
115
116/// Sends accumulated action telemetry to PostHog and clears the counters.
117///
118/// No-op when telemetry is disabled, no counters are pending, or no
119/// PostHog API key has been configured.
120pub fn flush(source: &str) {
121    let counts = match ACTION_COUNTS.write() {
122        Ok(mut guard) => guard.drain().collect::<HashMap<String, u64>>(),
123        Err(_) => return,
124    };
125
126    if counts.is_empty() {
127        return;
128    }
129
130    let distinct_id = match DISTINCT_ID.read() {
131        Ok(guard) => guard.clone(),
132        Err(_) => return,
133    };
134
135    let shared_props = EVENT_PROPERTIES.read().map(|g| g.clone()).unwrap_or_default();
136
137    let source = source.to_string();
138    let batch = counts.into_iter().map(|(action, count)| {
139        let mut props = shared_props.clone();
140        props.insert("distinct_id".to_string(), serde_json::Value::from(distinct_id.clone()));
141        props.insert("count".to_string(), serde_json::Value::from(count));
142        props.insert("source".to_string(), serde_json::Value::from(source.clone()));
143        serde_json::json!({ "event": action, "properties": props })
144    }).collect::<Vec<_>>();
145
146    info!("Flushing action telemetry to PostHog ({} distinct actions)...", batch.len());
147    post_events(batch, "action telemetry");
148}
149
150/// Captures a single arbitrary PostHog event immediately.
151///
152/// Mainly for the user feedback dialog.
153///
154/// # Arguments
155///
156/// * `event` - The PostHog event name.
157/// * `properties` - Event-specific properties, merged over the shared ones.
158pub fn capture_event(event: &str, properties: HashMap<String, serde_json::Value>) {
159    let distinct_id = match DISTINCT_ID.read() {
160        Ok(guard) => guard.clone(),
161        Err(_) => return,
162    };
163
164    let mut props = EVENT_PROPERTIES.read().map(|g| g.clone()).unwrap_or_default();
165    props.insert("distinct_id".to_string(), serde_json::Value::from(distinct_id));
166    for (key, value) in properties {
167        props.insert(key, value);
168    }
169
170    let batch = vec![serde_json::json!({ "event": event, "properties": props })];
171
172    info!("Capturing PostHog event `{}`...", event);
173    post_events(batch, "event capture");
174}
175
176/// POSTs a batch of pre-built PostHog events to the `/batch/` capture endpoint.
177///
178/// The HTTP POST runs on a freshly spawned OS thread because some callers
179/// (the server's shutdown path) invoke this from inside a tokio runtime, and
180/// `reqwest::blocking` panics if used on a tokio worker thread.
181///
182/// # Arguments
183///
184/// * `batch` - The PostHog event objects to send (each `{event, properties}`).
185/// * `context` - Short label used in the skip/success/failure log lines.
186fn post_events(batch: Vec<serde_json::Value>, context: &str) {
187    let api_key = match POSTHOG_API_KEY.read() {
188        Ok(guard) => guard.clone(),
189        Err(_) => return,
190    };
191
192    if api_key.is_empty() {
193        info!("Skipping {context} send: PostHog API key not configured.");
194        return;
195    }
196
197    let payload = serde_json::json!({
198        "api_key": api_key,
199        "batch": batch,
200    });
201
202    let url = format!("{}/batch/", DEFAULT_POSTHOG_HOST);
203    let context = context.to_string();
204
205    let handle = std::thread::spawn(move || -> Result<(), String> {
206        let client = reqwest::blocking::Client::builder()
207            .timeout(std::time::Duration::from_secs(5))
208            .build()
209            .map_err(|err| format!("building client: {err}"))?;
210
211        let response = client.post(&url)
212            .json(&payload)
213            .send()
214            .map_err(|err| format!("posting batch: {err}"))?;
215
216        if !response.status().is_success() {
217            return Err(format!("PostHog responded {}: {}", response.status(), response.text().unwrap_or_default()));
218        }
219
220        Ok(())
221    });
222
223    match handle.join() {
224        Ok(Ok(())) => info!("PostHog {context} sent."),
225        Ok(Err(err)) => warn!("PostHog {context} send failed: {err}"),
226        Err(_) => error!("PostHog {context} send thread panicked."),
227    }
228}