Skip to content

Commit 78b5270

Browse files
authored
fix(analytics): count daily active usage more accurately (#294)
Track app starts once per UTC day regardless of app version and keep checking at day rollover so long-running sessions and update days do not skew active-user counts. Made-with: Cursor
1 parent 2aaadf0 commit 78b5270

1 file changed

Lines changed: 103 additions & 36 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,8 @@ use uuid::Uuid;
2121
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
2222

2323
const GLOBAL_SHORTCUT_STORE_KEY: &str = "globalShortcut";
24-
const APP_STARTED_TRACKED_DAY_KEY_PREFIX: &str = "analytics.app_started_day.";
25-
26-
fn app_started_day_key(version: &str) -> String {
27-
format!("{}{}", APP_STARTED_TRACKED_DAY_KEY_PREFIX, version)
28-
}
24+
const DAILY_ACTIVE_TRACKED_DAY_KEY: &str = "analytics.daily_active_day";
25+
const DAILY_ACTIVE_EVENT_NAME: &str = "app_started";
2926

3027
fn today_utc_ymd() -> String {
3128
let date = time::OffsetDateTime::now_utc().date();
@@ -37,48 +34,82 @@ fn today_utc_ymd() -> String {
3734
)
3835
}
3936

40-
fn should_track_app_started(last_tracked_day: Option<&str>, today: &str) -> bool {
37+
fn should_track_daily_active(last_tracked_day: Option<&str>, today: &str) -> bool {
4138
match last_tracked_day {
4239
Some(day) => day != today,
4340
None => true,
4441
}
4542
}
4643

4744
#[cfg(desktop)]
48-
fn track_app_started_once_per_day_per_version(app: &tauri::App) {
45+
fn track_daily_active_if_needed(app_handle: &tauri::AppHandle) {
4946
use tauri_plugin_store::StoreExt;
5047

51-
let version = app.package_info().version.to_string();
52-
let key = app_started_day_key(&version);
5348
let today = today_utc_ymd();
5449

55-
let store = match app.handle().store("settings.json") {
50+
let store = match app_handle.store("settings.json") {
5651
Ok(store) => store,
5752
Err(error) => {
58-
log::warn!("Failed to access settings store for app_started gate: {}", error);
53+
log::warn!(
54+
"Failed to access settings store for daily analytics gate: {}",
55+
error
56+
);
5957
return;
6058
}
6159
};
6260

6361
let last_tracked_day = store
64-
.get(&key)
62+
.get(DAILY_ACTIVE_TRACKED_DAY_KEY)
6563
.and_then(|value| value.as_str().map(|value| value.to_string()));
6664

67-
if !should_track_app_started(last_tracked_day.as_deref(), &today) {
65+
if !should_track_daily_active(last_tracked_day.as_deref(), &today) {
6866
return;
6967
}
7068

71-
let _ = app.track_event("app_started", None);
69+
if let Err(error) = app_handle.track_event(DAILY_ACTIVE_EVENT_NAME, None) {
70+
log::warn!("Failed to track daily analytics event: {}", error);
71+
return;
72+
}
7273

73-
store.set(&key, serde_json::Value::String(today));
74+
store.set(
75+
DAILY_ACTIVE_TRACKED_DAY_KEY,
76+
serde_json::Value::String(today),
77+
);
7478
if let Err(error) = store.save() {
75-
log::warn!("Failed to save app_started tracked day: {}", error);
79+
log::warn!("Failed to save daily analytics tracked day: {}", error);
7680
}
7781
}
7882

7983
#[cfg(not(desktop))]
80-
fn track_app_started_once_per_day_per_version(app: &tauri::App) {
81-
let _ = app.track_event("app_started", None);
84+
fn track_daily_active_if_needed(app_handle: &tauri::AppHandle) {
85+
let _ = app_handle.track_event(DAILY_ACTIVE_EVENT_NAME, None);
86+
}
87+
88+
#[cfg(desktop)]
89+
fn seconds_until_next_utc_day(now: time::OffsetDateTime) -> u64 {
90+
let now_time = now.time();
91+
let seconds_since_midnight = u64::from(now_time.hour()) * 60 * 60
92+
+ u64::from(now_time.minute()) * 60
93+
+ u64::from(now_time.second());
94+
let seconds_until_next_day = 86_400_u64.saturating_sub(seconds_since_midnight);
95+
if seconds_until_next_day == 0 {
96+
86_400
97+
} else {
98+
seconds_until_next_day
99+
}
100+
}
101+
102+
#[cfg(desktop)]
103+
fn spawn_daily_active_rollover_tracker(app_handle: tauri::AppHandle) {
104+
std::thread::spawn(move || {
105+
loop {
106+
let sleep_for = std::time::Duration::from_secs(seconds_until_next_utc_day(
107+
time::OffsetDateTime::now_utc(),
108+
));
109+
std::thread::sleep(sleep_for);
110+
track_daily_active_if_needed(&app_handle);
111+
}
112+
});
82113
}
83114

84115
#[cfg(desktop)]
@@ -89,7 +120,10 @@ fn managed_shortcut_slot() -> &'static Mutex<Option<String>> {
89120

90121
/// Shared shortcut handler that toggles the panel when the shortcut is pressed.
91122
#[cfg(desktop)]
92-
fn handle_global_shortcut(app: &tauri::AppHandle, event: tauri_plugin_global_shortcut::ShortcutEvent) {
123+
fn handle_global_shortcut(
124+
app: &tauri::AppHandle,
125+
event: tauri_plugin_global_shortcut::ShortcutEvent,
126+
) {
93127
if event.state == ShortcutState::Pressed {
94128
log::debug!("Global shortcut triggered");
95129
panel::toggle_panel(app);
@@ -270,9 +304,19 @@ async fn start_probe_batch(
270304
if has_error {
271305
log::warn!("probe {} completed with error", plugin_id);
272306
} else {
273-
log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len());
307+
log::info!(
308+
"probe {} completed ok ({} lines)",
309+
plugin_id,
310+
output.lines.len()
311+
);
274312
}
275-
let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output });
313+
let _ = handle.emit(
314+
"probe:result",
315+
ProbeResult {
316+
batch_id: bid,
317+
output,
318+
},
319+
);
276320
}
277321
Err(_) => {
278322
log::error!("probe {} panicked", plugin_id);
@@ -311,7 +355,10 @@ fn get_log_path(app_handle: tauri::AppHandle) -> Result<String, String> {
311355
/// Pass `null` to disable the shortcut, or a shortcut string like "CommandOrControl+Shift+U".
312356
#[cfg(desktop)]
313357
#[tauri::command]
314-
fn update_global_shortcut(app_handle: tauri::AppHandle, shortcut: Option<String>) -> Result<(), String> {
358+
fn update_global_shortcut(
359+
app_handle: tauri::AppHandle,
360+
shortcut: Option<String>,
361+
) -> Result<(), String> {
315362
let global_shortcut = app_handle.global_shortcut();
316363
let normalized_shortcut = shortcut.and_then(|value| {
317364
let trimmed = value.trim().to_string();
@@ -338,7 +385,11 @@ fn update_global_shortcut(app_handle: tauri::AppHandle, shortcut: Option<String>
338385
*managed_shortcut = None;
339386
}
340387
Err(e) => {
341-
log::warn!("Failed to unregister existing shortcut '{}': {}", existing, e);
388+
log::warn!(
389+
"Failed to unregister existing shortcut '{}': {}",
390+
existing,
391+
e
392+
);
342393
}
343394
}
344395
}
@@ -462,7 +513,9 @@ pub fn run() {
462513
let version = app.package_info().version.to_string();
463514
log::info!("OpenUsage v{} starting", version);
464515

465-
track_app_started_once_per_day_per_version(app);
516+
track_daily_active_if_needed(app.handle());
517+
#[cfg(desktop)]
518+
spawn_daily_active_rollover_tracker(app.handle().clone());
466519

467520
let app_data_dir = app.path().app_data_dir().expect("no app data dir");
468521
let resource_dir = app.path().resource_dir().expect("no resource dir");
@@ -477,7 +530,8 @@ pub fn run() {
477530

478531
tray::create(app.handle())?;
479532

480-
app.handle().plugin(tauri_plugin_updater::Builder::new().build())?;
533+
app.handle()
534+
.plugin(tauri_plugin_updater::Builder::new().build())?;
481535

482536
// Register global shortcut from stored settings
483537
#[cfg(desktop)]
@@ -498,7 +552,8 @@ pub fn run() {
498552
},
499553
) {
500554
log::warn!("Failed to register initial global shortcut: {}", e);
501-
} else if let Ok(mut managed_shortcut) = managed_shortcut_slot().lock()
555+
} else if let Ok(mut managed_shortcut) =
556+
managed_shortcut_slot().lock()
502557
{
503558
*managed_shortcut = Some(shortcut.to_string());
504559
} else {
@@ -519,29 +574,41 @@ pub fn run() {
519574

520575
#[cfg(test)]
521576
mod tests {
522-
use super::{app_started_day_key, should_track_app_started};
577+
use super::{
578+
DAILY_ACTIVE_TRACKED_DAY_KEY, seconds_until_next_utc_day, should_track_daily_active,
579+
};
580+
use time::{Date, Month, PrimitiveDateTime, Time};
523581

524582
#[test]
525583
fn should_track_when_no_previous_day() {
526-
assert!(should_track_app_started(None, "2026-02-12"));
584+
assert!(should_track_daily_active(None, "2026-02-12"));
527585
}
528586

529587
#[test]
530588
fn should_not_track_when_same_day() {
531-
assert!(!should_track_app_started(Some("2026-02-12"), "2026-02-12"));
589+
assert!(!should_track_daily_active(Some("2026-02-12"), "2026-02-12"));
532590
}
533591

534592
#[test]
535593
fn should_track_when_day_changes() {
536-
assert!(should_track_app_started(Some("2026-02-11"), "2026-02-12"));
594+
assert!(should_track_daily_active(Some("2026-02-11"), "2026-02-12"));
595+
}
596+
597+
#[test]
598+
fn daily_active_key_is_not_version_scoped() {
599+
assert_eq!(DAILY_ACTIVE_TRACKED_DAY_KEY, "analytics.daily_active_day");
600+
assert!(!DAILY_ACTIVE_TRACKED_DAY_KEY.contains("0.6.2"));
601+
assert!(!DAILY_ACTIVE_TRACKED_DAY_KEY.contains("0.6.3"));
537602
}
538603

539604
#[test]
540-
fn key_is_version_scoped() {
541-
let v1_key = app_started_day_key("0.6.2");
542-
let v2_key = app_started_day_key("0.6.3");
543-
assert_ne!(v1_key, v2_key);
544-
assert!(v1_key.ends_with("0.6.2"));
545-
assert!(v2_key.ends_with("0.6.3"));
605+
fn rollover_sleep_waits_for_next_utc_day_boundary() {
606+
let now = PrimitiveDateTime::new(
607+
Date::from_calendar_date(2026, Month::February, 12).unwrap(),
608+
Time::from_hms(23, 59, 50).unwrap(),
609+
)
610+
.assume_utc();
611+
612+
assert_eq!(seconds_until_next_utc_day(now), 10);
546613
}
547614
}

0 commit comments

Comments
 (0)