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:
+10
-5
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tracedInvoke as invoke } from "./telemetry";
|
||||
|
||||
import type {
|
||||
AiReviewResult,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user