feat(analytics): add optional Aptabase event tracking UI

Integrate Aptabase analytics via a Tauri plugin and add a privacy
notice flow plus a settings dialog to control whether anonymous
usage events are sent. The app now tracks key user actions while
ensuring analytics never blocks core Git operations.

- Add analytics tracking helper and Aptabase plugin wiring
- Persist analytics consent in localStorage and gate tracking
- Introduce notice and settings dialogs, plus new event calls
This commit is contained in:
Christoph Brandau
2026-07-07 17:35:20 +02:00
parent d4ac449074
commit e503786211
13 changed files with 548 additions and 12 deletions
+92 -1
View File
@@ -6,6 +6,8 @@
import TitleBar from "./lib/TitleBar.svelte";
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BlameDialog from "./lib/components/BlameDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte";
@@ -95,6 +97,7 @@
import type {
AiSettings,
AnalyticsSettings,
CommitAiPhase,
ConflictFile,
ExplorerNode,
@@ -123,6 +126,7 @@
isAuthError,
stripAuthPrefix,
} from "./lib/credentials";
import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
@@ -151,6 +155,7 @@
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
@@ -200,6 +205,9 @@
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let appSettingsOpen = false;
let analyticsNoticeOpen = false;
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
let localModelOptions: LocalModelOption[] = [];
let errorMessage = "";
let operation = "";
@@ -329,6 +337,7 @@
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
initAnalytics();
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
@@ -554,6 +563,57 @@
startCommitAiPolling();
}
function defaultAnalyticsSettings(): AnalyticsSettings {
return {
enabled: true,
noticeSeen: false,
};
}
function loadAnalyticsSettings(): AnalyticsSettings {
try {
const stored = JSON.parse(localStorage.getItem(ANALYTICS_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") {
return { ...defaultAnalyticsSettings(), ...(stored as Partial<AnalyticsSettings>) };
}
} catch { /* ignore malformed settings */ }
return defaultAnalyticsSettings();
}
function persistAnalyticsSettings(next: AnalyticsSettings) {
try {
localStorage.setItem(ANALYTICS_SETTINGS_KEY, JSON.stringify(next));
} catch { /* ignore storage quota/private-mode errors */ }
}
function initAnalytics() {
analyticsSettings = loadAnalyticsSettings();
if (!analyticsSettings.noticeSeen) {
analyticsNoticeOpen = true;
return;
}
trackEvent("app_started");
}
function trackEvent(name: string, props?: AnalyticsEventProperties) {
if (!analyticsSettings.enabled) return;
trackAnalyticsEvent(name, props);
}
function acceptAnalyticsNotice(enabled: boolean) {
analyticsSettings = { enabled, noticeSeen: true };
persistAnalyticsSettings(analyticsSettings);
analyticsNoticeOpen = false;
trackEvent("app_started", { first_run: 1 });
}
function saveAppSettings(next: AnalyticsSettings) {
analyticsSettings = next;
persistAnalyticsSettings(next);
appSettingsOpen = false;
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1 });
}
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
@@ -1210,6 +1270,10 @@
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
trackEvent("repository_opened", {
changed_files: bundle.status.files.length,
has_upstream: bundle.status.upstream ? 1 : 0,
});
void backgroundFetchRepo(activeRepoPath);
});
}
@@ -1940,6 +2004,7 @@
try {
const result = await getFileBlame(activeRepoPath, node.path);
blameLines = result.lines;
trackEvent("blame_opened", { lines: result.lines.length });
} catch (error) {
blameError = errorToMessage(error);
errorMessage = blameError;
@@ -2080,10 +2145,12 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_created", { amend: 1 });
});
return;
}
const trackedStagedCount = stagedCount;
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
@@ -2092,6 +2159,7 @@
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
});
}
@@ -2270,6 +2338,12 @@
function openCompareSelect() {
if (!hasRepository) return;
compareSelectOpen = true;
trackEvent("compare_opened");
}
function openGlobalSearchDialog() {
globalSearchOpen = true;
trackEvent("global_search_opened");
}
async function compareSelectedCommits() {
@@ -2337,6 +2411,7 @@
const results = await searchCodeIntroductions(activeRepoPath, query, caseSensitive, limit, searchId);
if (globalSearchId === searchId) {
globalSearchResults = results;
trackEvent("global_search_completed", { results: results.length, case_sensitive: caseSensitive ? 1 : 0 });
}
} catch (error) {
if (globalSearchId === searchId) {
@@ -2468,10 +2543,11 @@
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onSearch={openGlobalSearchDialog}
onCompare={openCompareSelect}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
onOpenSettings={() => { appSettingsOpen = true; }}
/>
<div class="shell-body">
@@ -2908,6 +2984,21 @@
/>
{/if}
{#if analyticsNoticeOpen}
<AnalyticsNoticeDialog
enabled={analyticsSettings.enabled}
onContinue={acceptAnalyticsNotice}
/>
{/if}
{#if appSettingsOpen}
<AppSettingsDialog
analytics={analyticsSettings}
onSave={saveAppSettings}
onClose={() => { appSettingsOpen = false; }}
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}