Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions migrations/016_feature_usage_stats.sql
Original file line number Diff line number Diff line change
@@ -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)
);
127 changes: 127 additions & 0 deletions src/db/feature_usage.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<FeatureUsage>, 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());
}
}
9 changes: 9 additions & 0 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SqlitePool, sqlx::Error> {
std::fs::create_dir_all(app_data_dir).ok();
Expand Down Expand Up @@ -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(())
}

Expand Down
26 changes: 26 additions & 0 deletions src/vault_config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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";
Expand Down
Loading