Files
tkmind_go/ui/desktop/src/utils/analytics.ts
T
2026-03-26 00:34:22 +00:00

732 lines
20 KiB
TypeScript

/**
* Frontend Analytics Module
*
* Provides privacy-respecting analytics by routing events through the backend.
* The backend uses posthog-rs which handles the PostHog API correctly.
*
* What we track:
* - Feature usage (which features users interact with)
* - Screen/view navigation
* - Onboarding funnel completion
* - Error types (without sensitive details)
*
* What we never track:
* - Conversation content
* - Code or file contents
* - API keys or credentials
* - Tool arguments or outputs
* - Personal identifiable information
*/
import { sendTelemetryEvent } from '../api';
let telemetryEnabled: boolean | null = null;
export function setTelemetryEnabled(enabled: boolean): void {
telemetryEnabled = enabled;
}
function canTrack(): boolean {
return telemetryEnabled === true;
}
async function sendEvent(
eventName: string,
properties: Record<string, unknown> = {}
): Promise<void> {
if (!canTrack()) return;
try {
await sendTelemetryEvent({
body: {
event_name: eventName,
properties: properties as Record<string, unknown>,
},
});
} catch (error) {
console.debug('[Analytics] Failed to send event:', error);
}
}
// ============================================================================
// Event Types
// ============================================================================
/**
* Frontend-specific analytics events.
*
* NOTE: The backend (posthog.rs) already tracks:
* - session_started (extensions, provider, model, tokens, session count, etc.)
* - error (provider errors like rate_limit, auth, etc.)
*
* Frontend events focus on what the backend can't see:
* - UI navigation patterns
* - Onboarding funnel (where users drop off during setup)
* - Frontend-only crashes (React errors, unhandled rejections)
*/
export type AnalyticsEvent =
| { name: 'page_view'; properties: { page: string; referrer?: string } }
| { name: 'onboarding_started'; properties: Record<string, never> }
| { name: 'onboarding_provider_selected'; properties: { provider?: string; method?: string } }
| {
name: 'onboarding_completed';
properties: { provider: string; model?: string; duration_seconds?: number };
}
| { name: 'onboarding_abandoned'; properties: { step: string; duration_seconds?: number } }
| {
name: 'onboarding_setup_failed';
properties: {
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local';
error_message?: string;
};
}
| {
name: 'error_occurred';
properties: {
error_type: string;
component?: string;
page?: string;
action?: string;
stack_summary?: string;
recoverable: boolean;
};
}
| { name: 'app_crashed'; properties: { error_type: string; component?: string; page?: string } }
| { name: 'app_reloaded'; properties: { reason?: string } }
| { name: 'model_changed'; properties: { provider: string; model: string } }
| { name: 'settings_tab_viewed'; properties: { tab: string } }
| { name: 'setting_toggled'; properties: { setting: string; enabled: boolean } }
| {
name: 'telemetry_preference_set';
properties: { enabled: boolean; location: 'settings' | 'onboarding' | 'modal' };
}
| {
name: 'schedule_created';
properties: { source_type: 'file' | 'deeplink'; success: boolean; error_details?: string };
}
| { name: 'schedule_deleted'; properties: { success: boolean; error_details?: string } }
| { name: 'schedule_run_now'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_created'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_imported'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_edited'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_deleted'; properties: { success: boolean; error_details?: string } }
| {
name: 'recipe_started';
properties: { success: boolean; error_details?: string; in_new_window?: boolean };
}
| { name: 'recipe_deeplink_copied'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_yaml_copied'; properties: { success: boolean; error_details?: string } }
| { name: 'recipe_exported_to_file'; properties: { success: boolean; error_details?: string } }
| {
name: 'recipe_scheduled';
properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' };
}
| {
name: 'recipe_slash_command_set';
properties: { success: boolean; error_details?: string; action: 'add' | 'edit' | 'remove' };
}
| {
name: 'extension_added';
properties: {
extension_name?: string;
is_builtin: boolean;
success: boolean;
error_details?: string;
};
}
| {
name: 'extension_enabled';
properties: {
extension_name?: string;
is_builtin: boolean;
success: boolean;
error_details?: string;
};
}
| {
name: 'extension_disabled';
properties: {
extension_name?: string;
is_builtin: boolean;
success: boolean;
error_details?: string;
};
}
| {
name: 'extension_deleted';
properties: {
extension_name?: string;
is_builtin: boolean;
success: boolean;
error_details?: string;
};
}
// Chat input bar features
| { name: 'input_file_attached'; properties: { file_type: 'file' | 'directory' } }
| {
name: 'input_voice_dictation';
properties: {
action: 'start' | 'stop' | 'transcribed' | 'error' | 'auto_submit';
duration_seconds?: number;
error_type?: string;
};
}
| { name: 'input_mode_changed'; properties: { from_mode: string; to_mode: string } }
| { name: 'input_diagnostics_opened'; properties: Record<string, never> }
| { name: 'input_create_recipe_opened'; properties: Record<string, never> }
| { name: 'input_edit_recipe_opened'; properties: Record<string, never> }
// Auto-update tracking events
| {
name: 'update_check_started';
properties: { trigger: 'startup' | 'manual'; current_version: string };
}
| {
name: 'update_check_completed';
properties: {
result: 'available' | 'not_available' | 'error';
current_version: string;
latest_version?: string;
using_fallback: boolean;
error_type?: string;
};
}
| {
name: 'update_download_started';
properties: {
version: string;
method: 'electron-updater' | 'github-fallback';
};
}
| {
name: 'update_download_progress';
properties: {
milestone: 25 | 50 | 75 | 100;
version: string;
method: 'electron-updater' | 'github-fallback';
};
}
| {
name: 'update_download_completed';
properties: {
success: boolean;
version: string;
method: 'electron-updater' | 'github-fallback';
duration_seconds?: number;
error_type?: string;
};
}
| {
name: 'update_install_initiated';
properties: {
version: string;
method: 'electron-updater' | 'github-fallback';
action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only';
};
};
// NOTE: slash_command_used is tracked by the backend (posthog.rs) with command_type info
export function trackEvent<T extends AnalyticsEvent>(event: T): void {
sendEvent(event.name, event.properties);
}
export function trackPageView(page: string, referrer?: string): void {
trackEvent({
name: 'page_view',
properties: { page, referrer },
});
}
export function trackError(
errorType: string,
options: {
component?: string;
page?: string;
action?: string;
stackSummary?: string;
recoverable?: boolean;
} = {}
): void {
trackEvent({
name: 'error_occurred',
properties: {
error_type: errorType,
component: options.component,
page: options.page,
action: options.action,
stack_summary: options.stackSummary,
recoverable: options.recoverable ?? false,
},
});
}
export function trackErrorWithContext(
error: unknown,
context: {
component?: string;
page?: string;
action?: string;
recoverable?: boolean;
} = {}
): void {
trackError(getErrorType(error), {
...context,
stackSummary: getStackSummary(error),
});
}
let onboardingStartTime: number | null = null;
export function trackOnboardingStarted(): void {
onboardingStartTime = Date.now();
trackEvent({ name: 'onboarding_started', properties: {} });
}
export function trackOnboardingProviderSelected(options: {
provider?: string;
method?: string;
}): void {
trackEvent({
name: 'onboarding_provider_selected',
properties: options,
});
}
export function trackOnboardingCompleted(provider: string, model?: string): void {
const durationSeconds = onboardingStartTime
? Math.round((Date.now() - onboardingStartTime) / 1000)
: undefined;
trackEvent({
name: 'onboarding_completed',
properties: { provider, model, duration_seconds: durationSeconds },
});
onboardingStartTime = null;
}
export function trackOnboardingSetupFailed(
provider: 'openrouter' | 'tetrate' | 'chatgpt_codex' | 'local',
errorMessage?: string
): void {
trackEvent({
name: 'onboarding_setup_failed',
properties: { provider, error_message: errorMessage },
});
}
export function trackModelChanged(provider: string, model: string): void {
trackEvent({
name: 'model_changed',
properties: { provider, model },
});
}
export function trackSettingsTabViewed(tab: string): void {
trackEvent({
name: 'settings_tab_viewed',
properties: { tab },
});
}
export function trackSettingToggled(setting: string, enabled: boolean): void {
trackEvent({
name: 'setting_toggled',
properties: { setting, enabled },
});
}
export function trackTelemetryPreference(
enabled: boolean,
location: 'settings' | 'onboarding' | 'modal'
): void {
// Always send this event, even if telemetry is disabled
// This is the one exception - we need to know opt-out rates
sendEvent('telemetry_preference_set', { enabled, location });
}
export function getErrorType(error: unknown): string {
if (error instanceof Error) {
const name = error.name || 'Error';
const message = error.message.split('\n')[0].slice(0, 200);
return `${name}: ${message}`;
}
return String(error).slice(0, 200);
}
export function getStackSummary(error: unknown): string | undefined {
if (!(error instanceof Error) || !error.stack) {
return undefined;
}
// Extract just the function/component names from the stack
// Skip error message, take top 4 frames
const lines = error.stack.split('\n').slice(1, 5);
const frames = lines
.map((line) => {
// Match function names like "at ComponentName" or "at Object.functionName"
const match = line.match(/at\s+([A-Za-z0-9_$.]+)/);
return match ? match[1] : null;
})
.filter(Boolean);
return frames.length > 0 ? frames.join(' > ') : undefined;
}
// ============================================================================
// Extension Tracking
// ============================================================================
// Only track names for builtin extensions (privacy protection for user-created extensions)
function getTrackableExtensionName(extensionName: string, isBuiltin: boolean): string | undefined {
return isBuiltin ? extensionName : undefined;
}
export function trackExtensionAdded(
extensionName: string,
success: boolean,
errorDetails?: string,
isBuiltin: boolean = false
): void {
trackEvent({
name: 'extension_added',
properties: {
extension_name: getTrackableExtensionName(extensionName, isBuiltin),
is_builtin: isBuiltin,
success,
error_details: errorDetails,
},
});
}
export function trackExtensionEnabled(
extensionName: string,
success: boolean,
errorDetails?: string,
isBuiltin: boolean = false
): void {
trackEvent({
name: 'extension_enabled',
properties: {
extension_name: getTrackableExtensionName(extensionName, isBuiltin),
is_builtin: isBuiltin,
success,
error_details: errorDetails,
},
});
}
export function trackExtensionDisabled(
extensionName: string,
success: boolean,
errorDetails?: string,
isBuiltin: boolean = false
): void {
trackEvent({
name: 'extension_disabled',
properties: {
extension_name: getTrackableExtensionName(extensionName, isBuiltin),
is_builtin: isBuiltin,
success,
error_details: errorDetails,
},
});
}
export function trackExtensionDeleted(
extensionName: string,
success: boolean,
errorDetails?: string,
isBuiltin: boolean = false
): void {
trackEvent({
name: 'extension_deleted',
properties: {
extension_name: getTrackableExtensionName(extensionName, isBuiltin),
is_builtin: isBuiltin,
success,
error_details: errorDetails,
},
});
}
// ============================================================================
// Schedule/Recipe Tracking
// ============================================================================
export function trackScheduleCreated(
sourceType: 'file' | 'deeplink',
success: boolean,
errorDetails?: string
): void {
trackEvent({
name: 'schedule_created',
properties: { source_type: sourceType, success, error_details: errorDetails },
});
}
export function trackScheduleDeleted(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'schedule_deleted',
properties: { success, error_details: errorDetails },
});
}
export function trackScheduleRunNow(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'schedule_run_now',
properties: { success, error_details: errorDetails },
});
}
// ============================================================================
// Recipe Tracking
// ============================================================================
export function trackRecipeCreated(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_created',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeImported(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_imported',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeEdited(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_edited',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeDeleted(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_deleted',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeStarted(
success: boolean,
errorDetails?: string,
inNewWindow?: boolean
): void {
trackEvent({
name: 'recipe_started',
properties: { success, error_details: errorDetails, in_new_window: inNewWindow },
});
}
export function trackRecipeDeeplinkCopied(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_deeplink_copied',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeYamlCopied(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_yaml_copied',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeExportedToFile(success: boolean, errorDetails?: string): void {
trackEvent({
name: 'recipe_exported_to_file',
properties: { success, error_details: errorDetails },
});
}
export function trackRecipeScheduled(
success: boolean,
action: 'add' | 'edit' | 'remove',
errorDetails?: string
): void {
trackEvent({
name: 'recipe_scheduled',
properties: { success, action, error_details: errorDetails },
});
}
export function trackRecipeSlashCommandSet(
success: boolean,
action: 'add' | 'edit' | 'remove',
errorDetails?: string
): void {
trackEvent({
name: 'recipe_slash_command_set',
properties: { success, action, error_details: errorDetails },
});
}
// NOTE: slash_command_used is tracked by the backend (posthog.rs) with richer info:
// - command_type: "builtin" | "recipe" | "unknown"
// - command_name: only for builtin commands (e.g., "compact", "summarize")
// - success: true for builtin/recipe, false for unknown
// ============================================================================
// Chat Input Bar Feature Tracking
// ============================================================================
export function trackFileAttached(fileType: 'file' | 'directory'): void {
trackEvent({
name: 'input_file_attached',
properties: { file_type: fileType },
});
}
export function trackVoiceDictation(
action: 'start' | 'stop' | 'transcribed' | 'error' | 'auto_submit',
durationSeconds?: number,
errorType?: string
): void {
trackEvent({
name: 'input_voice_dictation',
properties: { action, duration_seconds: durationSeconds, error_type: errorType },
});
}
export function trackModeChanged(fromMode: string, toMode: string): void {
trackEvent({
name: 'input_mode_changed',
properties: { from_mode: fromMode, to_mode: toMode },
});
}
export function trackDiagnosticsOpened(): void {
trackEvent({
name: 'input_diagnostics_opened',
properties: {},
});
}
export function trackCreateRecipeOpened(): void {
trackEvent({
name: 'input_create_recipe_opened',
properties: {},
});
}
export function trackEditRecipeOpened(): void {
trackEvent({
name: 'input_edit_recipe_opened',
properties: {},
});
}
// ============================================================================
// Auto-Update Tracking
// ============================================================================
type UpdateMethod = 'electron-updater' | 'github-fallback';
let updateDownloadStartTime: number | null = null;
let currentUpdateVersion: string | null = null;
let currentUpdateMethod: UpdateMethod | null = null;
let reportedMilestones: Set<25 | 50 | 75 | 100> = new Set();
export function trackUpdateCheckStarted(
trigger: 'startup' | 'manual',
currentVersion: string
): void {
trackEvent({
name: 'update_check_started',
properties: { trigger, current_version: currentVersion },
});
}
export function trackUpdateCheckCompleted(
result: 'available' | 'not_available' | 'error',
currentVersion: string,
options: {
latestVersion?: string;
usingFallback: boolean;
errorType?: string;
}
): void {
trackEvent({
name: 'update_check_completed',
properties: {
result,
current_version: currentVersion,
latest_version: options.latestVersion,
using_fallback: options.usingFallback,
error_type: options.errorType,
},
});
}
export function trackUpdateDownloadStarted(version: string, method: UpdateMethod): void {
updateDownloadStartTime = Date.now();
currentUpdateVersion = version;
currentUpdateMethod = method;
reportedMilestones = new Set();
trackEvent({
name: 'update_download_started',
properties: { version, method },
});
}
export function trackUpdateDownloadProgress(percent: number): void {
if (!currentUpdateVersion || !currentUpdateMethod) return;
const milestones: Array<25 | 50 | 75 | 100> = [25, 50, 75, 100];
for (const milestone of milestones) {
if (percent >= milestone && !reportedMilestones.has(milestone)) {
reportedMilestones.add(milestone);
trackEvent({
name: 'update_download_progress',
properties: {
milestone,
version: currentUpdateVersion,
method: currentUpdateMethod,
},
});
}
}
}
export function trackUpdateDownloadCompleted(
success: boolean,
version: string,
method: UpdateMethod,
errorType?: string
): void {
const durationSeconds = updateDownloadStartTime
? Math.round((Date.now() - updateDownloadStartTime) / 1000)
: undefined;
trackEvent({
name: 'update_download_completed',
properties: {
success,
version,
method,
duration_seconds: durationSeconds,
error_type: errorType,
},
});
// Reset state
updateDownloadStartTime = null;
currentUpdateVersion = null;
currentUpdateMethod = null;
reportedMilestones = new Set();
}
export function trackUpdateInstallInitiated(
version: string,
method: UpdateMethod,
action: 'quit_and_install' | 'open_folder_and_quit' | 'open_folder_only'
): void {
trackEvent({
name: 'update_install_initiated',
properties: { version, method, action },
});
}