rpfm_telemetry/
actions.rs1use 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
32const DEFAULT_POSTHOG_HOST: &str = "https://eu.i.posthog.com";
34
35pub(crate) static USAGE_TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(true);
39
40static ACTION_COUNTS: LazyLock<RwLock<HashMap<String, u64>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
42
43pub static POSTHOG_API_KEY: LazyLock<Arc<RwLock<String>>> = LazyLock::new(|| Arc::new(RwLock::new(String::new())));
45
46static 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
52static EVENT_PROPERTIES: LazyLock<RwLock<HashMap<String, serde_json::Value>>> = LazyLock::new(|| RwLock::new(HashMap::new()));
56
57pub fn set_usage_telemetry_enabled(enabled: bool) {
63 USAGE_TELEMETRY_ENABLED.store(enabled, Ordering::Relaxed);
64}
65
66pub fn is_usage_telemetry_enabled() -> bool {
68 USAGE_TELEMETRY_ENABLED.load(Ordering::Relaxed)
69}
70
71pub 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
80pub fn set_distinct_id(id: &str) {
82 if let Ok(mut guard) = DISTINCT_ID.write() {
83 *guard = id.to_string();
84 }
85}
86
87pub 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
94pub fn track_action(action: &str) {
99 info!("Triggering `{}` By Slot", action);
100 record_action(action);
101}
102
103pub 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
116pub 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
150pub 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
176fn 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}