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}
+117 -2
View File
@@ -439,7 +439,13 @@
/* --- App shell --- */
.shell { display: flex; flex-direction: column; height: 100%; background: transparent; }
.shell {
--app-titlebar-height: 42px;
display: flex;
flex-direction: column;
height: 100%;
background: transparent;
}
.shell-body {
display: flex;
@@ -453,10 +459,12 @@
/* --- Titlebar --- */
.titlebar {
position: relative;
z-index: 500;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
height: 42px;
height: var(--app-titlebar-height);
background:
linear-gradient(90deg, rgba(100,108,255,0.16), rgba(65,209,255,0.08), rgba(255,211,67,0.035)),
var(--color-bar);
@@ -2419,6 +2427,10 @@
background-image: none;
}
.app-chrome-backdrop {
top: var(--app-titlebar-height, 42px);
}
.dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -2485,6 +2497,14 @@
max-height: calc(100vh - 32px);
overflow: auto;
}
.app-settings-dialog,
.analytics-notice-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.clone-repository-dialog {
display: block;
width: min(620px, calc(100vw - 32px));
@@ -2539,6 +2559,101 @@
gap: 14px;
padding: 16px;
}
.app-settings-form {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.settings-section {
display: grid;
gap: 12px;
min-width: 0;
}
.settings-section > header {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.settings-section > header svg {
color: var(--color-accent);
flex: 0 0 auto;
}
.settings-section h3 {
margin: 1px 0 0;
color: var(--color-ink);
font-size: 15px;
font-weight: 750;
}
.settings-toggle-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
min-width: 0;
padding: 12px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: #151827;
color: var(--color-ink-muted);
cursor: pointer;
}
.settings-toggle-row input {
width: 16px;
height: 16px;
margin-top: 2px;
accent-color: var(--color-accent);
}
.settings-toggle-row span {
display: grid;
gap: 4px;
min-width: 0;
}
.settings-toggle-row strong {
color: var(--color-ink);
font-size: 13px;
}
.settings-toggle-row small {
color: var(--color-ink-dim);
font-size: 12px;
line-height: 1.45;
}
.analytics-notice-body {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 14px;
padding: 18px 16px 16px;
}
.analytics-notice-icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 1px solid rgba(65,209,255,0.28);
border-radius: 10px;
color: var(--color-accent);
background: rgba(65,209,255,0.08);
}
.analytics-notice-copy {
display: grid;
gap: 9px;
min-width: 0;
color: var(--color-ink-muted);
font-size: 13px;
line-height: 1.5;
}
.analytics-notice-copy p {
margin: 0;
}
.analytics-notice-toggle {
grid-column: 1 / -1;
}
.analytics-notice-footer {
align-items: center;
}
.ai-provider-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
+12 -1
View File
@@ -2,7 +2,7 @@
import { onDestroy, onMount } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { CloudDownload, Download, FolderOpen, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Settings, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -21,6 +21,7 @@
export let onCompare: () => void = () => {};
export let onOpenInExplorer: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
export let onOpenSettings: () => void = () => {};
const win = getCurrentWindow();
let isMaximized = false;
@@ -198,6 +199,16 @@
/>
<span class="tb-action-label">Auto</span>
</button>
<button
class="tb-action"
onclick={onOpenSettings}
title="Settings"
aria-label="Settings"
>
<Settings size={14} aria-hidden="true" />
<span class="tb-action-label">Settings</span>
</button>
</div>
<div class="titlebar-divider" aria-hidden="true"></div>
+12
View File
@@ -0,0 +1,12 @@
import { invoke } from "@tauri-apps/api/core";
export type AnalyticsEventProperties = Record<string, string | number>;
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
void invoke("plugin:aptabase|track_event", {
name,
props: props && Object.keys(props).length > 0 ? props : undefined,
}).catch(() => {
// Analytics must never interrupt the Git workflow.
});
}
@@ -0,0 +1,57 @@
<script lang="ts">
import { BarChart3, Check, ShieldCheck } from "@lucide/svelte";
interface Props {
enabled: boolean;
onContinue: (enabled: boolean) => void;
}
let { enabled = true, onContinue = () => {} }: Props = $props();
let allowAnalytics = $state(true);
$effect(() => {
allowAnalytics = enabled;
});
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog analytics-notice-dialog" role="dialog" aria-modal="true" aria-label="Analytics notice" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Privacy</span>
<h2 class="dialog-title">Anonymous usage analytics</h2>
</div>
<ShieldCheck size={20} aria-hidden="true" />
</header>
<div class="analytics-notice-body">
<div class="analytics-notice-icon">
<BarChart3 size={24} aria-hidden="true" />
</div>
<div class="analytics-notice-copy">
<p>
GitLite can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
</p>
<p>
Events do not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
</p>
</div>
<label class="settings-toggle-row analytics-notice-toggle">
<input type="checkbox" bind:checked={allowAnalytics} />
<span>
<strong>Allow anonymous analytics</strong>
<small>You can change this later in Settings.</small>
</span>
</label>
</div>
<footer class="dialog-footer analytics-notice-footer">
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
<Check size={16} aria-hidden="true" />
Continue
</button>
</footer>
</div>
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { Check, Settings, X } from "@lucide/svelte";
import type { AnalyticsSettings } from "../types";
interface Props {
analytics: AnalyticsSettings;
onSave: (settings: AnalyticsSettings) => void;
onClose: () => void;
}
let { analytics, onSave = () => {}, onClose = () => {} }: Props = $props();
let analyticsEnabled = $state(true);
$effect(() => {
analyticsEnabled = analytics.enabled;
});
function save() {
onSave({
...analytics,
enabled: analyticsEnabled,
noticeSeen: true,
});
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog app-settings-dialog" role="dialog" aria-modal="true" aria-label="Settings" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">GitLite</span>
<h2 class="dialog-title">Settings</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="app-settings-form" onsubmit={(event) => { event.preventDefault(); save(); }}>
<section class="settings-section">
<header>
<Settings size={16} aria-hidden="true" />
<div>
<span class="eyebrow">Analytics</span>
<h3>Anonymous usage analytics</h3>
</div>
</header>
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>Allow anonymous Aptabase events</strong>
<small>No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent.</small>
</span>
</label>
</section>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose}>Cancel</button>
<button class="btn-primary" type="submit">
<Check size={16} aria-hidden="true" />
Save
</button>
</div>
</form>
</div>
</div>
+4 -1
View File
@@ -43,7 +43,10 @@
<style>
.repo-loading {
position: fixed;
inset: 0;
top: var(--app-titlebar-height, 42px);
right: 0;
bottom: 0;
left: 0;
z-index: 400;
display: grid;
place-items: center;
+5
View File
@@ -33,6 +33,11 @@ export interface AiSettings {
customModel: string;
}
export interface AnalyticsSettings {
enabled: boolean;
noticeSeen: boolean;
}
export interface GitStatus {
repo_path: string;
current_branch: string | null;