732 lines
20 KiB
TypeScript
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 },
|
|
});
|
|
}
|