Files
VoiceStudio/tests/test_local_stats.py
0e2c00a403 feat(privacy): Settings → Usage — local-only insights instead of cloud analytics (#1114)
* feat(privacy): Settings → Usage — local-only insights, the answer to cloud analytics

A PostHog integration was proposed and rejected (PR #1110, closed): sending
usage events to a third-party endpoint would break the one promise this product
is built on — nothing leaves your machine — and local-first is the reason people
choose it over ElevenLabs. But the question analytics was meant to answer ("how
am I using this?") is a fair one, so answer it locally.

services/local_stats.py aggregates the history the app has ALREADY written to
the user's own SQLite DB: takes, audio produced, compute time, starred, active
days, voices/dubs/projects/exports, and distributions by mode and language.
GET /stats/usage serves it over loopback; Settings → Usage renders it.

The three properties that stop this becoming telemetry by accident:
  - READ-ONLY. No new table, column, or event stream. Delete the feature and not
    one byte of stored data changes.
  - NO CONTENT. Counts and totals only — the `text` column of a take is never
    read and never returned; no paths, no ids, no person. Pinned by a test that
    asserts the payload contains no take text, no /Users/ path, no row id.
  - NO NETWORK. There is no client, no endpoint, no token. It has no way to send
    anything anywhere.
The panel states the guarantee in the UI, because a privacy promise the user
can't see isn't worth much.

Route added to the API-surface snapshot (the inventory guard caught it, as
designed — one line: GET /stats/usage).

4 backend tests (aggregation / never-leaks-content / empty install / missing
table degrades to 0) + 4 frontend tests. Backend suite 2924 passed; lint,
format, typecheck clean.

Closes the analytics question opened by #1110.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): use the real --chrome-fg-dim token in UsageTab (css-token guard)

cssTokens.test.js is a frontend guard that every var(--…) a component
references actually exists — an undefined custom property with no fallback is an
invalid declaration, so the style silently does nothing. UsageTab referenced
--chrome-fg-subtle, which doesn't exist; the dim sub-label token is
--chrome-fg-dim (what the other settings panels use).

My miss: I ran the full BACKEND suite but only the two new frontend test files,
so this guard never ran locally. Full frontend suite now green (1211 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <nizam4103@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:15:10 +05:30

142 lines
5.0 KiB
Python

"""Local-only usage insights — the privacy-preserving alternative to cloud analytics.
PostHog was proposed and rejected (PR #1110) because a third-party telemetry
endpoint breaks the product's headline promise ("nothing leaves your machine").
This feature answers the same question from data the app has *already* written
locally. These tests pin the two properties that make that true:
1. it aggregates correctly from the user's own DB, and
2. it NEVER returns content — no take text, no paths, no identifiers.
(2) is the load-bearing one: it's what stops this from quietly becoming telemetry.
"""
from __future__ import annotations
import os
import sqlite3
import time
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import pytest
from core.db import _BASE_SCHEMA
from services import local_stats
SECRET_TEXT = "my private script about a confidential merger"
@pytest.fixture
def db(tmp_path, monkeypatch):
"""A throwaway DB with a couple of takes, wired into local_stats' db_conn."""
path = tmp_path / "stats.db"
conn = sqlite3.connect(str(path))
conn.executescript(_BASE_SCHEMA)
now = time.time()
conn.executemany(
"INSERT INTO generation_history "
"(id, text, mode, language, audio_path, duration_seconds, generation_time, starred, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?)",
[
("t1", SECRET_TEXT, "clone", "en", "/Users/someone/secret.wav", 10.0, 2.0, 1, now),
("t2", SECRET_TEXT, "clone", "en", "/Users/someone/secret2.wav", 5.0, 1.0, 0, now),
("t3", SECRET_TEXT, "design", "fr", "/Users/someone/secret3.wav", 2.5, 0.5, 0, now),
],
)
conn.execute(
"INSERT INTO voice_profiles (id, name, created_at) VALUES ('v1','Narrator',?)", (now,)
)
conn.commit()
conn.close()
def _connect():
c = sqlite3.connect(str(path))
c.row_factory = sqlite3.Row
return c
# local_stats uses core.db.db_conn, which calls core.db.get_db — patch at
# that seam (same idiom the takes suite uses against the module-purge leak).
monkeypatch.setitem(local_stats.db_conn.__wrapped__.__globals__, "get_db", _connect)
return path
def test_aggregates_the_users_own_history(db):
s = local_stats.usage_summary()
assert s["takes"] == 3
assert s["starred"] == 1
assert s["audio_seconds"] == 17.5 # 10 + 5 + 2.5
assert s["compute_seconds"] == 3.5 # 2 + 1 + 0.5
assert s["voices"] == 1
assert s["active_days"] == 1
# Distributions, biggest first.
assert s["by_mode"][0] == {"name": "clone", "count": 2}
assert {"name": "design", "count": 1} in s["by_mode"]
assert {"name": "en", "count": 2} in s["by_language"]
def test_never_returns_content_paths_or_identifiers(db):
"""The property that keeps this from becoming telemetry: aggregates only."""
s = local_stats.usage_summary()
blob = repr(s)
assert SECRET_TEXT not in blob # never the text of a take
assert "secret.wav" not in blob # never a file path
assert "/Users/" not in blob # never a home dir / username
assert "t1" not in s.get("by_mode", []) # never a row id
# And it says so on the tin, so any future consumer sees the guarantee.
assert s["local_only"] is True
def test_empty_install_returns_zeros_not_an_error(tmp_path, monkeypatch):
"""A fresh install has no takes — the panel must render, not 500."""
path = tmp_path / "empty.db"
conn = sqlite3.connect(str(path))
conn.executescript(_BASE_SCHEMA)
conn.commit()
conn.close()
def _connect():
c = sqlite3.connect(str(path))
c.row_factory = sqlite3.Row
return c
monkeypatch.setitem(local_stats.db_conn.__wrapped__.__globals__, "get_db", _connect)
s = local_stats.usage_summary()
assert s["takes"] == 0
assert s["audio_seconds"] == 0.0
assert s["by_mode"] == []
assert s["first_at"] is None
def test_a_missing_table_degrades_to_zero_rather_than_raising(tmp_path, monkeypatch):
"""An older DB predating a table must not break the panel."""
path = tmp_path / "old.db"
conn = sqlite3.connect(str(path))
# Only generation_history — no voice_profiles/dub_history/etc.
conn.execute(
"CREATE TABLE generation_history (id TEXT, text TEXT, mode TEXT, language TEXT, "
"audio_path TEXT, duration_seconds REAL, generation_time REAL, starred INTEGER, created_at REAL)"
)
conn.execute(
"INSERT INTO generation_history VALUES ('t1','x','clone','en','/p',3.0,1.0,0,?)",
(time.time(),),
)
conn.commit()
conn.close()
def _connect():
c = sqlite3.connect(str(path))
c.row_factory = sqlite3.Row
return c
monkeypatch.setitem(local_stats.db_conn.__wrapped__.__globals__, "get_db", _connect)
s = local_stats.usage_summary()
assert s["takes"] == 1
assert s["voices"] == 0 # missing table → 0, not a crash
assert s["dubs"] == 0