feat(telemetry): Implement structured telemetry logging and reporting

Adds comprehensive client-side telemetry capabilities for usage, errors, and performance metrics. This includes integrating OpenTelemetry standards into both the frontend (Svelte) and backend (Tauri/Rust) layers to capture events, spans, and system resource utilization.

The implementation ensures that all collected data is privacy-filtered by design, explicitly excluding sensitive information like repository paths, credentials, source code, or email addresses from being logged.

- Updates README with detailed SigNoz telemetry guide
- Adds process metrics collection (CPU/Memory) in Rust backend
- Exposes `setTelemetryEnabled` state management to the frontend
This commit is contained in:
Christoph Brandau
2026-07-21 23:40:39 +02:00
parent 5c9f67700b
commit cae8390b54
12 changed files with 728 additions and 135 deletions
+10 -5
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onDestroy, onMount, tick } from "svelte";
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
@@ -160,6 +159,7 @@
summarizeGitError,
} from "./lib/credentials";
import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
@@ -503,7 +503,7 @@
async function closeStartupSplashscreen() {
try {
await invoke("close_splashscreen");
await tracedInvoke("close_splashscreen");
} catch {
// Browser preview and failed startup paths should keep working without Tauri.
}
@@ -815,6 +815,7 @@
function initAnalytics() {
analyticsSettings = loadAnalyticsSettings();
setTelemetryEnabled(analyticsSettings.noticeSeen && analyticsSettings.enabled);
if (!analyticsSettings.noticeSeen) {
analyticsNoticeOpen = true;
return;
@@ -831,6 +832,7 @@
analyticsSettings = { enabled, noticeSeen: true };
persistAnalyticsSettings(analyticsSettings);
analyticsNoticeOpen = false;
setTelemetryEnabled(enabled);
trackEvent("app_started", { first_run: 1 });
}
@@ -900,6 +902,7 @@
persistThemePreference(nextTheme);
persistLanguagePreference(nextLanguage);
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
setTelemetryEnabled(next.enabled);
appSettingsOpen = false;
if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
@@ -1746,9 +1749,11 @@
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
let message: string;
if (error instanceof Error) message = error.message;
else if (typeof error === "string") message = error;
else try { message = JSON.stringify(error) ?? "Unknown error"; } catch { message = "Unknown error"; }
return message;
}
function isNonFastForwardPushError(message: string): boolean {
+3
View File
@@ -1,8 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import { telemetryLog } from "./telemetry";
export type AnalyticsEventProperties = Record<string, string | number>;
export function trackAnalyticsEvent(name: string, props?: AnalyticsEventProperties) {
const details = props && Object.keys(props).length > 0 ? ` ${JSON.stringify(props)}` : "";
telemetryLog("info", `${name}${details}`, `product.${name}`);
void invoke("plugin:aptabase|track_event", {
name,
props: props && Object.keys(props).length > 0 ? props : undefined,
@@ -30,10 +30,10 @@
</div>
<div class="analytics-notice-copy">
<p>
Gitty can send anonymous usage events to Aptabase so crashes, rough edges, and commonly used workflows are easier to improve.
Gitty can send anonymous usage events and technical error logs to Aptabase and the self-hosted SigNoz service 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.
Telemetry does not include repository paths, remote URLs, branch names, commit messages, diffs, file names, credentials, or source code.
</p>
</div>
@@ -47,7 +47,7 @@
</div>
<footer class="dialog-footer analytics-notice-footer">
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage events.</span>
<span class="dialog-footer-info">Privacy-friendly, optional, and limited to product usage and technical errors.</span>
<button class="btn-primary" type="button" onclick={() => onContinue(allowAnalytics)}>
<Check size={16} aria-hidden="true" />
Continue
+1 -1
View File
@@ -124,7 +124,7 @@
<label class="settings-toggle-row">
<input type="checkbox" bind:checked={analyticsEnabled} />
<span>
<strong>{isGerman ? "Anonyme Aptabase-Ereignisse erlauben" : "Allow anonymous Aptabase events"}</strong>
<strong>{isGerman ? "Anonyme Analytics und Fehlerberichte erlauben" : "Allow anonymous analytics and error reports"}</strong>
<small>{isGerman ? "Es werden keine Repository-Pfade, Remotes, Branches, Commit-Nachrichten, Dateinamen, Diffs, Zugangsdaten oder Code übertragen." : "No repository paths, remotes, branches, commit messages, file names, diffs, credentials, or code are sent."}</small>
</span>
</label>
+1 -1
View File
@@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { tracedInvoke as invoke } from "./telemetry";
import type {
AiReviewResult,
+72
View File
@@ -0,0 +1,72 @@
import { invoke } from "@tauri-apps/api/core";
type TelemetryLevel = "info" | "warn" | "error";
const MAX_MESSAGE_LENGTH = 2_048;
let telemetryEnabled = false;
let configuration: Promise<unknown> = Promise.resolve();
// Telemetry must never contain repository contents or identifying local data.
function sanitize(message: string): string {
return message
.replace(/https?:\/\/\S+/gi, "[url]")
.replace(/(?:[A-Za-z]:\\|\/)(?:[^\s<>:"|?*]+[\\/])*[^\s<>:"|?*]*/g, "[path]")
.replace(/(token|password|secret|authorization|credential)\s*[:=]\s*\S+/gi, "$1=[redacted]")
.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[email]")
.slice(0, MAX_MESSAGE_LENGTH);
}
export function setTelemetryEnabled(enabled: boolean) {
telemetryEnabled = enabled;
configuration = invoke("set_telemetry_enabled", { enabled }).catch(() => {});
}
export function telemetryLog(level: TelemetryLevel, message: string, eventName?: string) {
if (!telemetryEnabled) return;
void configuration.then(() => invoke("emit_frontend_log", {
log: {
level,
message: sanitize(message),
eventName,
},
}))
.catch(() => {
// Observability must never interrupt the Git workflow.
});
}
function randomHex(bytes: number): string {
const values = new Uint8Array(bytes);
crypto.getRandomValues(values);
return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function tracedInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
if (!telemetryEnabled) return invoke<T>(command, args);
const startedAtMs = Date.now();
const started = performance.now();
const traceId = randomHex(16);
const spanId = randomHex(8);
try {
const result = await invoke<T>(command, args);
void configuration.then(() => invoke("emit_frontend_span", {
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: true },
})).catch(() => {});
return result;
} catch (error) {
void configuration.then(() => invoke("emit_frontend_span", {
span: { name: command, traceId, spanId, startedAtMs, durationMs: performance.now() - started, success: false },
})).catch(() => {});
throw error;
}
}
export function installGlobalErrorTelemetry() {
window.addEventListener("error", (event) => {
telemetryLog("error", event.message || "Unhandled frontend error", "frontend.unhandled_error");
});
window.addEventListener("unhandledrejection", (event) => {
const message = event.reason instanceof Error ? event.reason.message : String(event.reason ?? "Unhandled promise rejection");
telemetryLog("error", message, "frontend.unhandled_rejection");
});
}
+3
View File
@@ -2,10 +2,13 @@ import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
import { installGlobalErrorTelemetry } from "./lib/telemetry";
const THEME_KEY = "gitlite.theme.v1";
const target = document.getElementById("app");
installGlobalErrorTelemetry();
if (!target) {
throw new Error("App target element was not found.");
}