From 9714565e6111176c982f11c3458a6447a5ea4679 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Tue, 16 Jun 2026 08:28:41 -0300 Subject: [PATCH 1/2] feat(db): aggregated feature usage stats table and helpers --- migrations/016_feature_usage_stats.sql | 8 ++ src/db/feature_usage.rs | 127 +++++++++++++++++++++++++ src/db/mod.rs | 9 ++ 3 files changed, 144 insertions(+) create mode 100644 migrations/016_feature_usage_stats.sql create mode 100644 src/db/feature_usage.rs diff --git a/migrations/016_feature_usage_stats.sql b/migrations/016_feature_usage_stats.sql new file mode 100644 index 0000000..62052bd --- /dev/null +++ b/migrations/016_feature_usage_stats.sql @@ -0,0 +1,8 @@ +-- Local-only aggregated feature usage. One row per (day, feature) with a +-- running count — no payloads, no per-event rows, never leaves the machine. +CREATE TABLE IF NOT EXISTS feature_usage_stats ( + date TEXT NOT NULL, + feature TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, feature) +); diff --git a/src/db/feature_usage.rs b/src/db/feature_usage.rs new file mode 100644 index 0000000..5e9f191 --- /dev/null +++ b/src/db/feature_usage.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{Row, SqlitePool}; + +/// One aggregated row: how many times `feature` was used on `date`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FeatureUsage { + pub date: String, + pub feature: String, + pub count: i64, +} + +/// Increment today's counter for `feature`. Stores only a count — no +/// request/response data, timestamps, or any payload. Aggregation happens +/// in-place via UPSERT so the table stays one row per (day, feature). +pub async fn record_feature_usage(pool: &SqlitePool, feature: &str) -> Result<(), String> { + sqlx::query( + "INSERT INTO feature_usage_stats (date, feature, count) \ + VALUES (date('now'), ?, 1) \ + ON CONFLICT (date, feature) DO UPDATE SET count = count + 1", + ) + .bind(feature) + .execute(pool) + .await + .map_err(|e| format!("Failed to record feature usage: {e}"))?; + Ok(()) +} + +/// Daily per-feature counts within `[from, to]` (inclusive, ISO dates), +/// ordered by date then feature for stable rendering. +pub async fn get_feature_usage_by_date_range( + pool: &SqlitePool, + from: &str, + to: &str, +) -> Result, String> { + let rows = sqlx::query( + "SELECT date, feature, SUM(count) as count \ + FROM feature_usage_stats WHERE date >= ? AND date <= ? \ + GROUP BY date, feature ORDER BY date ASC, feature ASC", + ) + .bind(from) + .bind(to) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to get feature usage: {e}"))?; + + Ok(rows + .iter() + .map(|r| FeatureUsage { + date: r.get("date"), + feature: r.get("feature"), + count: r.get("count"), + }) + .collect()) +} + +/// Delete every recorded event. Backs the "clear usage data" control so a +/// user can reset the local dashboard at any time. +pub async fn clear_feature_usage(pool: &SqlitePool) -> Result<(), String> { + sqlx::query("DELETE FROM feature_usage_stats") + .execute(pool) + .await + .map_err(|e| format!("Failed to clear feature usage: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init_db; + use tempfile::TempDir; + + async fn setup() -> (SqlitePool, TempDir) { + let tmp = TempDir::new().unwrap(); + let pool = init_db(tmp.path()).await.unwrap(); + (pool, tmp) + } + + #[tokio::test] + async fn records_and_increments_count() { + let (pool, _tmp) = setup().await; + record_feature_usage(&pool, "http_block_run").await.unwrap(); + record_feature_usage(&pool, "http_block_run").await.unwrap(); + record_feature_usage(&pool, "db_block_run").await.unwrap(); + + let today: String = sqlx::query_scalar("SELECT date('now')") + .fetch_one(&pool) + .await + .unwrap(); + let rows = get_feature_usage_by_date_range(&pool, &today, &today) + .await + .unwrap(); + + assert_eq!(rows.len(), 2); + let http = rows.iter().find(|r| r.feature == "http_block_run").unwrap(); + assert_eq!(http.count, 2); + let db = rows.iter().find(|r| r.feature == "db_block_run").unwrap(); + assert_eq!(db.count, 1); + } + + #[tokio::test] + async fn range_excludes_dates_outside_window() { + let (pool, _tmp) = setup().await; + record_feature_usage(&pool, "http_block_run").await.unwrap(); + + // A window entirely in the past must return nothing. + let rows = get_feature_usage_by_date_range(&pool, "2000-01-01", "2000-01-02") + .await + .unwrap(); + assert!(rows.is_empty()); + } + + #[tokio::test] + async fn clear_removes_all_rows() { + let (pool, _tmp) = setup().await; + record_feature_usage(&pool, "http_block_run").await.unwrap(); + clear_feature_usage(&pool).await.unwrap(); + + let today: String = sqlx::query_scalar("SELECT date('now')") + .fetch_one(&pool) + .await + .unwrap(); + let rows = get_feature_usage_by_date_range(&pool, &today, &today) + .await + .unwrap(); + assert!(rows.is_empty()); + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 70cecff..5e9dfba 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,6 +2,7 @@ pub mod chat; pub mod connections; pub mod driver; pub mod environments; +pub mod feature_usage; pub mod keychain; pub mod lookup; pub mod pool; @@ -39,6 +40,7 @@ const MIGRATION_012_SQL: &str = include_str!("../../migrations/012_block_run_his const MIGRATION_013_SQL: &str = include_str!("../../migrations/013_schema_cache_drop_fk.sql"); const MIGRATION_014_SQL: &str = include_str!("../../migrations/014_block_results_alias.sql"); const MIGRATION_015_SQL: &str = include_str!("../../migrations/015_block_schema_cache.sql"); +const MIGRATION_016_SQL: &str = include_str!("../../migrations/016_feature_usage_stats.sql"); pub async fn init_db(app_data_dir: &Path) -> Result { std::fs::create_dir_all(app_data_dir).ok(); @@ -270,6 +272,13 @@ async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> { } } + for statement in MIGRATION_016_SQL.split(';') { + let trimmed = statement.trim(); + if !trimmed.is_empty() { + sqlx::query(trimmed).execute(pool).await?; + } + } + Ok(()) } From b36a57f1534b5f9373e5d481b5473441ddc336d1 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Tue, 16 Jun 2026 08:35:20 -0300 Subject: [PATCH 2/2] feat(config): telemetry_enabled opt-in ui pref --- src/vault_config/user.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/vault_config/user.rs b/src/vault_config/user.rs index 18a5f47..161d132 100644 --- a/src/vault_config/user.rs +++ b/src/vault_config/user.rs @@ -132,6 +132,11 @@ pub struct UiPrefs { /// this; `useAutoUpdate` reads it to gate the update prompt. #[serde(default)] pub auto_update_include_prereleases: bool, + /// Opt-in to local feature-usage tracking. Default `false`. When + /// off, the desktop never records block-run counts. Aggregated + /// counts stay on-machine in `notes.db` — nothing is ever uploaded. + #[serde(default)] + pub telemetry_enabled: bool, } impl Default for UiPrefs { @@ -153,6 +158,7 @@ impl Default for UiPrefs { hide_archived_in_quick_open: false, shortcut_profile: default_shortcut_profile(), auto_update_include_prereleases: false, + telemetry_enabled: false, } } } @@ -395,6 +401,26 @@ line_start = 0 assert!(!f.ui.auto_update_include_prereleases); } + #[test] + fn telemetry_enabled_round_trips() { + let raw = "version = \"1\"\n[ui]\ntelemetry_enabled = true\n"; + let f: UserFile = toml::from_str(raw).unwrap(); + assert!(f.ui.telemetry_enabled); + + let serialized = toml::to_string(&f).unwrap(); + assert!(serialized.contains("telemetry_enabled = true")); + + let back: UserFile = toml::from_str(&serialized).unwrap(); + assert!(back.ui.telemetry_enabled); + } + + #[test] + fn telemetry_enabled_defaults_to_false_when_omitted() { + let raw = "version = \"1\"\n[ui]\ntheme = \"dark\"\n"; + let f: UserFile = toml::from_str(raw).unwrap(); + assert!(!f.ui.telemetry_enabled); + } + #[test] fn hide_archived_in_quick_open_round_trips() { let raw = "version = \"1\"\n[ui]\nhide_archived_in_quick_open = true\n";