Add more posthog analytics (privacy aware) (#6122)

This commit is contained in:
Zane
2025-12-16 08:59:14 -08:00
committed by GitHub
parent 036f4f74d9
commit 8a91f15731
34 changed files with 1339 additions and 48 deletions
+10 -1
View File
@@ -41,6 +41,13 @@ import { View, ViewOptions } from './utils/navigationUtils';
import { NoProviderOrModelError, useAgent } from './hooks/useAgent';
import { useNavigation } from './hooks/useNavigation';
import { errorMessage } from './utils/conversionUtils';
import { usePageViewTracking } from './hooks/useAnalytics';
import { trackOnboardingCompleted } from './utils/analytics';
function PageViewTracker() {
usePageViewTracking();
return null;
}
// Route Components
const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => {
@@ -264,7 +271,8 @@ const WelcomeRoute = ({ onSelectProvider }: WelcomeRouteProps) => {
navigate('/', { replace: true });
}}
isOnboarding={true}
onProviderLaunched={() => {
onProviderLaunched={(model?: string) => {
trackOnboardingCompleted('other', model);
onSelectProvider();
navigate('/', { replace: true });
}}
@@ -609,6 +617,7 @@ export function AppInner() {
return (
<>
<PageViewTracker />
<ToastContainer
aria-label="Toast notifications"
toastClassName={() =>
+10 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -406,6 +406,15 @@ export const updateSessionUserRecipeValues = <ThrowOnError extends boolean = fal
export const status = <ThrowOnError extends boolean = false>(options?: Options<StatusData, ThrowOnError>) => (options?.client ?? client).get<StatusResponses, unknown, ThrowOnError>({ url: '/status', ...options });
export const sendTelemetryEvent = <ThrowOnError extends boolean = false>(options: Options<SendTelemetryEventData, ThrowOnError>) => (options.client ?? client).post<SendTelemetryEventResponses, unknown, ThrowOnError>({
url: '/telemetry/event',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Start the tunnel
*/
+21
View File
@@ -887,6 +887,13 @@ export type SystemNotificationContent = {
export type SystemNotificationType = 'thinkingMessage' | 'inlineMessage';
export type TelemetryEventRequest = {
event_name: string;
properties?: {
[key: string]: unknown;
};
};
export type TextContent = {
_meta?: {
[key: string]: unknown;
@@ -2888,6 +2895,20 @@ export type StatusResponses = {
export type StatusResponse = StatusResponses[keyof StatusResponses];
export type SendTelemetryEventData = {
body: TelemetryEventRequest;
path?: never;
query?: never;
url: '/telemetry/event';
};
export type SendTelemetryEventResponses = {
/**
* Event accepted for processing
*/
202: unknown;
};
export type StartTunnelData = {
body?: never;
path?: never;
+21 -1
View File
@@ -30,6 +30,13 @@ import { DiagnosticsModal } from './ui/DownloadDiagnostics';
import { Message } from '../api';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import CreateEditRecipeModal from './recipes/CreateEditRecipeModal';
import {
trackFileAttached,
trackVoiceDictation,
trackDiagnosticsOpened,
trackCreateRecipeOpened,
trackEditRecipeOpened,
} from '../utils/analytics';
interface QueuedMessage {
id: string;
@@ -243,6 +250,7 @@ export default function ChatInput({
estimatedSize,
} = useWhisper({
onTranscription: (text) => {
trackVoiceDictation('transcribed');
// Append transcribed text to the current input
const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text;
setDisplayValue(newValue);
@@ -250,6 +258,8 @@ export default function ChatInput({
textAreaRef.current?.focus();
},
onError: (error) => {
const errorType = error.name || 'DictationError';
trackVoiceDictation('error', undefined, errorType);
toastError({
title: 'Dictation Error',
msg: error.message,
@@ -1052,6 +1062,9 @@ export default function ChatInput({
try {
const path = await window.electron.selectFileOrDirectory();
if (path) {
const isDirectory = !path.includes('.') || path.endsWith('/');
trackFileAttached(isDirectory ? 'directory' : 'file');
const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path;
setDisplayValue(newValue);
setValue(newValue);
@@ -1283,8 +1296,10 @@ export default function ChatInput({
variant="outline"
onClick={() => {
if (isRecording) {
trackVoiceDictation('stop', Math.floor(recordingDuration));
stopRecording();
} else {
trackVoiceDictation('start');
startRecording();
}
}}
@@ -1548,8 +1563,10 @@ export default function ChatInput({
<Button
onClick={() => {
if (recipe) {
trackEditRecipeOpened();
setShowEditRecipeModal(true);
} else {
trackCreateRecipeOpened();
setShowCreateRecipeModal(true);
}
}}
@@ -1572,7 +1589,10 @@ export default function ChatInput({
<TooltipTrigger asChild>
<Button
type="button"
onClick={() => setDiagnosticsOpen(true)}
onClick={() => {
trackDiagnosticsOpened();
setDiagnosticsOpen(true);
}}
variant="ghost"
size="sm"
className="flex items-center justify-center text-text-default/70 hover:text-text-default text-xs cursor-pointer transition-colors"
+38 -1
View File
@@ -2,10 +2,21 @@ import React from 'react';
import { Button } from './ui/button';
import { AlertTriangle } from 'lucide-react';
import { errorMessage } from '../utils/conversionUtils';
import { trackErrorWithContext, trackEvent, getErrorType } from '../utils/analytics';
function getCurrentPage(): string {
return window.location.hash.replace('#', '') || '/';
}
// Capture unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
window.electron.logInfo(`[UNHANDLED REJECTION] ${event.reason}`);
trackErrorWithContext(event.reason, {
component: 'global',
page: getCurrentPage(),
action: 'async_operation',
recoverable: false,
});
});
// Capture global errors
@@ -13,9 +24,23 @@ window.addEventListener('error', (event) => {
window.electron.logInfo(
`[GLOBAL ERROR] ${event.message} at ${event.filename}:${event.lineno}:${event.colno}`
);
trackErrorWithContext(event.error || event.message, {
component: event.filename ? event.filename.split('/').pop() : 'unknown',
page: getCurrentPage(),
action: 'script_execution',
recoverable: false,
});
});
export function ErrorUI({ error }: { error: string }) {
const handleReload = () => {
trackEvent({
name: 'app_reloaded',
properties: { reason: 'error_recovery' },
});
window.electron.reloadApp();
};
return (
<div className="fixed inset-0 w-full h-full flex flex-col items-center justify-center gap-6 bg-background">
<div className="flex flex-col items-center gap-4 max-w-[600px] text-center px-6">
@@ -39,7 +64,7 @@ export function ErrorUI({ error }: { error: string }) {
{error}
</pre>
<Button onClick={() => window.electron.reloadApp()}>Reload</Button>
<Button onClick={handleReload}>Reload</Button>
</div>
</div>
);
@@ -61,6 +86,18 @@ export class ErrorBoundary extends React.Component<
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Send error to main process
window.electron.logInfo(`[ERROR] ${error.toString()}\n${errorInfo.componentStack}`);
const componentMatch = errorInfo.componentStack?.match(/^\s*at\s+(\w+)/);
const componentName = componentMatch ? componentMatch[1] : undefined;
trackEvent({
name: 'app_crashed',
properties: {
error_type: getErrorType(error),
component: componentName,
page: getCurrentPage(),
},
});
}
render() {
+29 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useConfig } from './ConfigContext';
import { SetupModal } from './SetupModal';
@@ -11,6 +11,13 @@ import ApiKeyTester from './ApiKeyTester';
import { SwitchModelModal } from './settings/models/subcomponents/SwitchModelModal';
import { createNavigationHandler } from '../utils/navigationUtils';
import TelemetrySettings from './settings/app/TelemetrySettings';
import {
trackOnboardingStarted,
trackOnboardingProviderSelected,
trackOnboardingCompleted,
trackOnboardingAbandoned,
trackOnboardingSetupFailed,
} from '../utils/analytics';
import { Goose, OpenRouter, Tetrate } from './icons';
@@ -29,6 +36,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
const [userInActiveSetup, setUserInActiveSetup] = useState(false);
const [showSwitchModelModal, setShowSwitchModelModal] = useState(false);
const [switchModelProvider, setSwitchModelProvider] = useState<string | null>(null);
const onboardingTracked = useRef(false);
const setView = useMemo(() => createNavigationHandler(navigate), [navigate]);
@@ -49,12 +57,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
} | null>(null);
const handleTetrateSetup = async () => {
trackOnboardingProviderSelected('tetrate');
try {
const result = await startTetrateSetup();
if (result.success) {
setSwitchModelProvider('tetrate');
setShowSwitchModelModal(true);
} else {
trackOnboardingSetupFailed('tetrate', result.message);
setTetrateSetupState({
show: true,
title: 'Setup Failed',
@@ -64,6 +74,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}
} catch (error) {
console.error('Tetrate setup error:', error);
trackOnboardingSetupFailed('tetrate', 'unexpected_error');
setTetrateSetupState({
show: true,
title: 'Setup Error',
@@ -74,6 +85,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
};
const handleApiKeySuccess = async (provider: string, _model: string, apiKey: string) => {
trackOnboardingProviderSelected('api_key');
const keyName = `${provider.toUpperCase()}_API_KEY`;
await upsert(keyName, apiKey, true);
await upsert('GOOSE_PROVIDER', provider, false);
@@ -82,7 +94,10 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
setShowSwitchModelModal(true);
};
const handleModelSelected = () => {
const handleModelSelected = (model: string) => {
if (switchModelProvider) {
trackOnboardingCompleted(switchModelProvider, model);
}
setShowSwitchModelModal(false);
setUserInActiveSetup(false);
setShowFirstTimeSetup(false);
@@ -95,12 +110,14 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
};
const handleOpenRouterSetup = async () => {
trackOnboardingProviderSelected('openrouter');
try {
const result = await startOpenRouterSetup();
if (result.success) {
setSwitchModelProvider('openrouter');
setShowSwitchModelModal(true);
} else {
trackOnboardingSetupFailed('openrouter', result.message);
setOpenRouterSetupState({
show: true,
title: 'Setup Failed',
@@ -110,6 +127,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
}
} catch (error) {
console.error('OpenRouter setup error:', error);
trackOnboardingSetupFailed('openrouter', 'unexpected_error');
setOpenRouterSetupState({
show: true,
title: 'Setup Error',
@@ -120,6 +138,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
};
const handleOllamaComplete = () => {
trackOnboardingCompleted('ollama');
setShowOllamaSetup(false);
setShowFirstTimeSetup(false);
setHasProvider(true);
@@ -127,6 +146,7 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
};
const handleOllamaCancel = () => {
trackOnboardingAbandoned('ollama_setup');
setShowOllamaSetup(false);
};
@@ -182,6 +202,13 @@ export default function ProviderGuard({ didSelectProvider, children }: ProviderG
checkProvider();
}, [read, didSelectProvider, userInActiveSetup]);
useEffect(() => {
if (!isChecking && !hasProvider && showFirstTimeSetup && !onboardingTracked.current) {
trackOnboardingStarted();
onboardingTracked.current = true;
}
}, [isChecking, hasProvider, showFirstTimeSetup]);
if (isChecking) {
return (
<div className="h-screen w-full bg-background-default flex items-center justify-center">
@@ -5,6 +5,7 @@ import { Goose } from './icons/Goose';
import { TELEMETRY_UI_ENABLED } from '../updates';
import { toastService } from '../toasts';
import { useConfig } from './ConfigContext';
import { trackTelemetryPreference } from '../utils/analytics';
const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
@@ -54,6 +55,7 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) {
setIsLoading(true);
try {
await upsert(TELEMETRY_CONFIG_KEY, enabled, false);
trackTelemetryPreference(enabled, 'modal');
setShowModal(false);
onClose?.();
} catch (error) {
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Tornado } from 'lucide-react';
import { all_goose_modes, ModeSelectionItem } from '../settings/mode/ModeSelectionItem';
import { useConfig } from '../ConfigContext';
@@ -8,6 +8,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { trackModeChanged } from '../../utils/analytics';
export const BottomMenuModeSelection = () => {
const [gooseMode, setGooseMode] = useState('auto');
@@ -36,6 +37,7 @@ export const BottomMenuModeSelection = () => {
try {
await upsert('GOOSE_MODE', newMode, false);
setGooseMode(newMode);
trackModeChanged(gooseMode, newMode);
} catch (error) {
console.error('Error updating goose mode:', error);
throw new Error(`Failed to store new goose mode: ${newMode}`);
@@ -34,6 +34,14 @@ import { CronPicker } from '../schedule/CronPicker';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { SearchView } from '../conversation/SearchView';
import cronstrue from 'cronstrue';
import {
trackRecipeDeleted,
trackRecipeStarted,
trackRecipeDeeplinkCopied,
trackRecipeScheduled,
trackRecipeSlashCommandSet,
getErrorType,
} from '../../utils/analytics';
export default function RecipesView() {
const setView = useNavigation();
@@ -124,25 +132,34 @@ export default function RecipesView() {
throwOnError: true,
});
const session = newAgent.data;
trackRecipeStarted(true, undefined, false);
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
});
} catch (error) {
console.error('Failed to load recipe:', error);
setError(error instanceof Error ? error.message : 'Failed to load recipe');
const errorMsg = error instanceof Error ? error.message : 'Failed to load recipe';
trackRecipeStarted(false, getErrorType(error), false);
setError(errorMsg);
}
};
const handleStartRecipeChatInNewWindow = (recipeId: string) => {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string,
undefined,
undefined,
'pair',
recipeId
);
try {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string,
undefined,
undefined,
'pair',
recipeId
);
trackRecipeStarted(true, undefined, true);
} catch (error) {
console.error('Failed to open recipe in new window:', error);
trackRecipeStarted(false, getErrorType(error), true);
}
};
const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => {
@@ -161,6 +178,7 @@ export default function RecipesView() {
try {
await deleteRecipe({ body: { id: recipeManifest.id } });
trackRecipeDeleted(true);
await loadSavedRecipes();
toastSuccess({
title: recipeManifest.recipe.title,
@@ -168,7 +186,9 @@ export default function RecipesView() {
});
} catch (err) {
console.error('Failed to delete recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to delete recipe');
const errorMsg = err instanceof Error ? err.message : 'Failed to delete recipe';
trackRecipeDeleted(false, getErrorType(err));
setError(errorMsg);
}
};
@@ -189,12 +209,14 @@ export default function RecipesView() {
try {
const deeplink = await generateDeepLink(recipeManifest.recipe);
await navigator.clipboard.writeText(deeplink);
trackRecipeDeeplinkCopied(true);
toastSuccess({
title: 'Deeplink copied',
msg: 'Recipe deeplink has been copied to clipboard',
});
} catch (error) {
console.error('Failed to copy deeplink:', error);
trackRecipeDeeplinkCopied(false, getErrorType(error));
toastSuccess({
title: 'Copy failed',
msg: 'Failed to copy deeplink to clipboard',
@@ -211,6 +233,8 @@ export default function RecipesView() {
const handleSaveSchedule = async () => {
if (!scheduleRecipeManifest) return;
const action = scheduleRecipeManifest.schedule_cron ? 'edit' : 'add';
try {
await scheduleRecipe({
body: {
@@ -219,6 +243,7 @@ export default function RecipesView() {
},
});
trackRecipeScheduled(true, action);
toastSuccess({
title: 'Schedule saved',
msg: `Recipe will run ${getReadableCron(scheduleCron)}`,
@@ -229,7 +254,9 @@ export default function RecipesView() {
await loadSavedRecipes();
} catch (error) {
console.error('Failed to save schedule:', error);
setError(error instanceof Error ? error.message : 'Failed to save schedule');
const errorMsg = error instanceof Error ? error.message : 'Failed to save schedule';
trackRecipeScheduled(false, action, getErrorType(error));
setError(errorMsg);
}
};
@@ -244,6 +271,7 @@ export default function RecipesView() {
},
});
trackRecipeScheduled(true, 'remove');
toastSuccess({
title: 'Schedule removed',
msg: 'Recipe will no longer run automatically',
@@ -254,7 +282,9 @@ export default function RecipesView() {
await loadSavedRecipes();
} catch (error) {
console.error('Failed to remove schedule:', error);
setError(error instanceof Error ? error.message : 'Failed to remove schedule');
const errorMsg = error instanceof Error ? error.message : 'Failed to remove schedule';
trackRecipeScheduled(false, 'remove', getErrorType(error));
setError(errorMsg);
}
};
@@ -267,6 +297,12 @@ export default function RecipesView() {
const handleSaveSlashCommand = async () => {
if (!slashCommandRecipeManifest) return;
const action = slashCommand
? slashCommandRecipeManifest.slash_command
? 'edit'
: 'add'
: 'remove';
try {
await setRecipeSlashCommand({
body: {
@@ -275,6 +311,7 @@ export default function RecipesView() {
},
});
trackRecipeSlashCommandSet(true, action);
toastSuccess({
title: 'Slash command saved',
msg: slashCommand ? `Use /${slashCommand} to run this recipe` : 'Slash command removed',
@@ -285,7 +322,9 @@ export default function RecipesView() {
await loadSavedRecipes();
} catch (error) {
console.error('Failed to save slash command:', error);
setError(error instanceof Error ? error.message : 'Failed to save slash command');
const errorMsg = error instanceof Error ? error.message : 'Failed to save slash command';
trackRecipeSlashCommandSet(false, action, getErrorType(error));
setError(errorMsg);
}
};
@@ -300,6 +339,7 @@ export default function RecipesView() {
},
});
trackRecipeSlashCommandSet(true, 'remove');
toastSuccess({
title: 'Slash command removed',
msg: 'Recipe slash command has been removed',
@@ -310,7 +350,9 @@ export default function RecipesView() {
await loadSavedRecipes();
} catch (error) {
console.error('Failed to remove slash command:', error);
setError(error instanceof Error ? error.message : 'Failed to remove slash command');
const errorMsg = error instanceof Error ? error.message : 'Failed to remove slash command';
trackRecipeSlashCommandSet(false, 'remove', getErrorType(error));
setError(errorMsg);
}
};
@@ -21,6 +21,7 @@ import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
import cronstrue from 'cronstrue';
import { formatToLocalDateWithTimezone } from '../../utils/date';
import { getSession, Session } from '../../api';
import { trackScheduleRunNow, getErrorType } from '../../utils/analytics';
interface ScheduleSessionMeta {
id: string;
@@ -102,6 +103,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setIsActionLoading(true);
try {
const newSessionId = await runScheduleNow(scheduleId);
trackScheduleRunNow(true);
if (newSessionId === 'CANCELLED') {
toastSuccess({ title: 'Job Cancelled', msg: 'The job was cancelled while starting up.' });
} else {
@@ -110,9 +112,11 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
await fetchSessions(scheduleId);
await fetchSchedule(scheduleId);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule';
trackScheduleRunNow(false, getErrorType(err));
toastError({
title: 'Run Schedule Error',
msg: err instanceof Error ? err.message : 'Failed to trigger schedule',
msg: errorMsg,
});
} finally {
setIsActionLoading(false);
@@ -132,9 +136,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
}
await fetchSchedule(scheduleId);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Operation failed';
toastError({
title: 'Pause/Unpause Error',
msg: err instanceof Error ? err.message : 'Operation failed',
msg: errorMsg,
});
} finally {
setIsActionLoading(false);
@@ -149,9 +154,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
toastSuccess({ title: 'Job Killed', msg: result.message });
await fetchSchedule(scheduleId);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to kill job';
toastError({
title: 'Kill Job Error',
msg: err instanceof Error ? err.message : 'Failed to kill job',
msg: errorMsg,
});
} finally {
setIsActionLoading(false);
@@ -175,9 +181,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
toastSuccess({ title: 'Job Inspection', msg: 'No detailed information available' });
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to inspect job';
toastError({
title: 'Inspect Job Error',
msg: err instanceof Error ? err.message : 'Failed to inspect job',
msg: errorMsg,
});
} finally {
setIsActionLoading(false);
@@ -193,9 +200,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
await fetchSchedule(scheduleId);
setIsModalOpen(false);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update schedule';
toastError({
title: 'Update Schedule Error',
msg: err instanceof Error ? err.message : 'Failed to update schedule',
msg: errorMsg,
});
} finally {
setIsActionLoading(false);
@@ -23,6 +23,7 @@ import cronstrue from 'cronstrue';
import { formatToLocalDateWithTimezone } from '../../utils/date';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { ViewOptions } from '../../utils/navigationUtils';
import { trackScheduleCreated, trackScheduleDeleted, getErrorType } from '../../utils/analytics';
interface SchedulesViewProps {
onClose?: () => void;
@@ -259,14 +260,23 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
msg: `Successfully updated schedule "${editingSchedule.id}"`,
});
} else {
await createSchedule(payload as NewSchedulePayload);
const newPayload = payload as NewSchedulePayload;
await createSchedule(newPayload);
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
trackScheduleCreated(sourceType, true);
}
await fetchSchedules();
setIsModalOpen(false);
setEditingSchedule(null);
} catch (error) {
console.error('Failed to save schedule:', error);
setSubmitApiError(error instanceof Error ? error.message : 'Unknown error saving schedule.');
const errorMsg = error instanceof Error ? error.message : 'Unknown error saving schedule.';
setSubmitApiError(errorMsg);
if (!editingSchedule) {
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
trackScheduleCreated(sourceType, false, getErrorType(error));
}
} finally {
setIsSubmitting(false);
}
@@ -281,10 +291,13 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
try {
await deleteSchedule(id);
trackScheduleDeleted(true);
await fetchSchedules();
} catch (error) {
console.error(`Failed to delete schedule "${id}":`, error);
setApiError(error instanceof Error ? error.message : `Unknown error deleting "${id}".`);
const errorMsg = error instanceof Error ? error.message : `Unknown error deleting "${id}".`;
setApiError(errorMsg);
trackScheduleDeleted(false, getErrorType(error));
} finally {
setActionsInProgress((prev) => {
const newSet = new Set(prev);
@@ -417,6 +430,10 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
}
};
const handleNavigateToDetail = (id: string) => {
setViewingScheduleId(id);
};
if (viewingScheduleId) {
return (
<ScheduleDetailView
@@ -492,7 +509,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
<ScheduleCard
key={job.id}
job={job}
onNavigateToDetail={setViewingScheduleId}
onNavigateToDetail={handleNavigateToDetail}
onEdit={(schedule) => {
setEditingSchedule(schedule);
setSubmitApiError(null);
@@ -9,9 +9,10 @@ import ConfigSettings from './config/ConfigSettings';
import { ExtensionConfig } from '../../api';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { Bot, Share2, Monitor, MessageSquare } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import ChatSettingsSection from './chat/ChatSettingsSection';
import { CONFIGURATION_ENABLED } from '../../updates';
import { trackSettingsTabViewed } from '../../utils/analytics';
export type SettingsViewOptions = {
deepLinkConfig?: ExtensionConfig;
@@ -29,6 +30,12 @@ export default function SettingsView({
viewOptions: SettingsViewOptions;
}) {
const [activeTab, setActiveTab] = useState('models');
const hasTrackedInitialTab = useRef(false);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
trackSettingsTabViewed(tab);
};
// Determine initial tab based on section prop
useEffect(() => {
@@ -52,6 +59,13 @@ export default function SettingsView({
}
}, [viewOptions.section]);
useEffect(() => {
if (!hasTrackedInitialTab.current) {
trackSettingsTabViewed(activeTab);
hasTrackedInitialTab.current = true;
}
}, [activeTab]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -79,7 +93,11 @@ export default function SettingsView({
</div>
<div className="flex-1 min-h-0 relative px-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="h-full flex flex-col"
>
<div className="px-1">
<TabsList className="w-full mb-2 justify-start">
<TabsTrigger
@@ -13,6 +13,7 @@ import ThemeSelector from '../../GooseSidebar/ThemeSelector';
import BlockLogoBlack from './icons/block-lockup_black.png';
import BlockLogoWhite from './icons/block-lockup_white.png';
import TelemetrySettings from './TelemetrySettings';
import { trackSettingToggled } from '../../../utils/analytics';
interface AppSettingsSectionProps {
scrollToSection?: string;
@@ -170,6 +171,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setMenuBarIcon(newState);
if (success) {
setMenuBarIconEnabled(newState);
trackSettingToggled('menu_bar_icon', newState);
}
};
@@ -194,6 +196,7 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setDockIcon(newState);
if (success) {
setDockIconEnabled(newState);
trackSettingToggled('dock_icon', newState);
}
};
@@ -202,12 +205,14 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
const success = await window.electron.setWakelock(newState);
if (success) {
setWakelockEnabled(newState);
trackSettingToggled('prevent_sleep', newState);
}
};
const handleShowPricingToggle = (checked: boolean) => {
setShowPricing(checked);
localStorage.setItem('show_pricing', String(checked));
trackSettingToggled('cost_tracking', checked);
// Trigger storage event for other components
window.dispatchEvent(new CustomEvent('storage'));
};
@@ -5,6 +5,10 @@ import { useConfig } from '../../ConfigContext';
import { TELEMETRY_UI_ENABLED } from '../../../updates';
import TelemetryOptOutModal from '../../TelemetryOptOutModal';
import { toastService } from '../../../toasts';
import {
setTelemetryEnabled as setAnalyticsTelemetryEnabled,
trackTelemetryPreference,
} from '../../../utils/analytics';
const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
@@ -42,6 +46,8 @@ export default function TelemetrySettings({ isWelcome = false }: TelemetrySettin
try {
await upsert(TELEMETRY_CONFIG_KEY, checked, false);
setTelemetryEnabled(checked);
setAnalyticsTelemetryEnabled(checked);
trackTelemetryPreference(checked, isWelcome ? 'onboarding' : 'settings');
} catch (error) {
console.error('Failed to update telemetry status:', error);
toastService.error({
@@ -8,6 +8,7 @@ import {
import { useConfig } from '../../ConfigContext';
import { ProviderSelector } from './ProviderSelector';
import { VOICE_DICTATION_ELEVENLABS_ENABLED } from '../../../updates';
import { trackSettingToggled } from '../../../utils/analytics';
export const VoiceDictationToggle = () => {
const [settings, setSettings] = useState<DictationSettings>({
@@ -56,6 +57,7 @@ export const VoiceDictationToggle = () => {
enabled,
provider: settings.provider === null ? 'openai' : settings.provider,
});
trackSettingToggled('voice_dictation', enabled);
};
const handleProviderChange = (provider: DictationProvider) => {
@@ -195,11 +195,19 @@ export default function ExtensionsSection({
};
const handleDeleteExtension = async (name: string) => {
// Capture the selected extension before closing the modal
const extensionToDelete = selectedExtension;
// Close the modal immediately
handleModalClose();
try {
await deleteExtension({ name, removeFromConfig: removeExtension, sessionId: sessionId });
await deleteExtension({
name,
removeFromConfig: removeExtension,
sessionId: sessionId,
extensionConfig: extensionToDelete ?? undefined,
});
} catch (error) {
console.error('Failed to delete extension:', error);
// We don't reopen the modal on failure
@@ -1,6 +1,17 @@
import type { ExtensionConfig } from '../../../api/types.gen';
import { toastService, ToastServiceOptions } from '../../../toasts';
import { addToAgent, removeFromAgent, sanitizeName } from './agent-api';
import {
trackExtensionAdded,
trackExtensionEnabled,
trackExtensionDisabled,
trackExtensionDeleted,
getErrorType,
} from '../../../utils/analytics';
function isBuiltinExtension(config: ExtensionConfig): boolean {
return config.type === 'builtin';
}
interface ActivateExtensionProps {
addToConfig: (name: string, extensionConfig: ExtensionConfig, enabled: boolean) => Promise<void>;
@@ -58,17 +69,21 @@ export async function activateExtension({
extensionConfig,
sessionId,
}: ActivateExtensionProps): Promise<void> {
const isBuiltin = isBuiltinExtension(extensionConfig);
try {
// AddToAgent
await addToAgent(extensionConfig, sessionId, true);
} catch (error) {
console.error('Failed to add extension to agent:', error);
await addToConfig(extensionConfig.name, extensionConfig, false);
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
trackExtensionAdded(extensionConfig.name, true, undefined, isBuiltin);
} catch (error) {
console.error('Failed to add extension to config:', error);
// remove from Agent
@@ -77,6 +92,7 @@ export async function activateExtension({
} catch (removeError) {
console.error('Failed to remove extension from agent after config failure:', removeError);
}
trackExtensionAdded(extensionConfig.name, false, getErrorType(error), isBuiltin);
// Rethrow the error to inform the caller
throw error;
}
@@ -252,13 +268,16 @@ export async function toggleExtension({
toastOptions = {},
sessionId,
}: ToggleExtensionProps) {
const isBuiltin = isBuiltinExtension(extensionConfig);
// disabled to enabled
if (toggle == 'toggleOn') {
try {
// add to agent with toast options
await addToAgent(extensionConfig, sessionId, !toastOptions?.silent);
} catch (error) {
console.error('Error adding extension to agent. Will try to toggle back off.');
console.error('Error adding extension to agent. Attempting to toggle back off.');
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
try {
await toggleExtension({
toggle: 'toggleOff',
@@ -276,8 +295,10 @@ export async function toggleExtension({
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, true);
trackExtensionEnabled(extensionConfig.name, true, undefined, isBuiltin);
} catch (error) {
console.error('Failed to update config after enabling extension:', error);
trackExtensionEnabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
// remove from agent
try {
await removeFromAgent(extensionConfig.name, sessionId, !toastOptions?.silent);
@@ -300,8 +321,19 @@ export async function toggleExtension({
// update the config
try {
await addToConfig(extensionConfig.name, extensionConfig, false);
if (agentRemoveError) {
trackExtensionDisabled(
extensionConfig.name,
false,
getErrorType(agentRemoveError),
isBuiltin
);
} else {
trackExtensionDisabled(extensionConfig.name, true, undefined, isBuiltin);
}
} catch (error) {
console.error('Error removing extension from config', extensionConfig.name, 'Error:', error);
trackExtensionDisabled(extensionConfig.name, false, getErrorType(error), isBuiltin);
throw error;
}
@@ -316,13 +348,20 @@ interface DeleteExtensionProps {
name: string;
removeFromConfig: (name: string) => Promise<void>;
sessionId: string;
extensionConfig?: ExtensionConfig;
}
/**
* Deletes an extension completely from both agent and config
*/
export async function deleteExtension({ name, removeFromConfig, sessionId }: DeleteExtensionProps) {
// remove from agent
export async function deleteExtension({
name,
removeFromConfig,
sessionId,
extensionConfig,
}: DeleteExtensionProps) {
const isBuiltin = extensionConfig ? isBuiltinExtension(extensionConfig) : false;
let agentRemoveError = null;
try {
await removeFromAgent(name, sessionId, true);
@@ -333,16 +372,20 @@ export async function deleteExtension({ name, removeFromConfig, sessionId }: Del
try {
await removeFromConfig(name);
if (agentRemoveError) {
trackExtensionDeleted(name, false, getErrorType(agentRemoveError), isBuiltin);
} else {
trackExtensionDeleted(name, true, undefined, isBuiltin);
}
} catch (error) {
console.error(
'Failed to remove extension from config after removing from agent. Error:',
error
);
// If we also had an agent remove error, log it but throw the config error as it's more critical
trackExtensionDeleted(name, false, getErrorType(error), isBuiltin);
throw error;
}
// If we had an error removing from agent but succeeded removing from config, still throw the original error
if (agentRemoveError) {
throw agentRemoveError;
}
@@ -19,6 +19,7 @@ import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata, fetchModelsForProviders } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { ProviderType } from '../../../../api';
import { trackModelChanged } from '../../../../utils/analytics';
const PREFERRED_MODEL_PATTERNS = [
/claude-sonnet-4/i,
@@ -62,7 +63,7 @@ type SwitchModelModalProps = {
sessionId: string | null;
onClose: () => void;
setView: (view: View) => void;
onModelSelected?: () => void;
onModelSelected?: (model: string) => void;
initialProvider?: string | null;
titleOverride?: string;
};
@@ -144,8 +145,11 @@ export const SwitchModelModal = ({
}
await changeModel(sessionId, modelObj);
trackModelChanged(modelObj.provider || '', modelObj.name);
if (onModelSelected) {
onModelSelected();
onModelSelected(modelObj.name);
}
onClose();
}
@@ -59,7 +59,7 @@ function ProviderCards({
isOnboarding: boolean;
refreshProviders?: () => void;
setView?: (view: View) => void;
onModelSelected?: () => void;
onModelSelected?: (model?: string) => void;
}) {
const [configuringProvider, setConfiguringProvider] = useState<ProviderDetails | null>(null);
const [showCustomProviderModal, setShowCustomProviderModal] = useState(false);
@@ -256,7 +256,7 @@ export default function ProviderGrid({
isOnboarding: boolean;
refreshProviders?: () => void;
setView?: (view: View) => void;
onModelSelected?: () => void;
onModelSelected?: (model?: string) => void;
}) {
return (
<GridLayout>
@@ -10,7 +10,7 @@ import { createNavigationHandler } from '../../../utils/navigationUtils';
interface ProviderSettingsProps {
onClose: () => void;
isOnboarding: boolean;
onProviderLaunched?: () => void;
onProviderLaunched?: (model?: string) => void;
}
export default function ProviderSettings({
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Switch } from '../../ui/switch';
import { useConfig } from '../../ConfigContext';
import { trackSettingToggled } from '../../../utils/analytics';
interface SecurityConfig {
SECURITY_PROMPT_ENABLED?: boolean;
@@ -23,6 +24,7 @@ export const SecurityToggle = () => {
const handleToggle = async (enabled: boolean) => {
await upsert('SECURITY_PROMPT_ENABLED', enabled, false);
trackSettingToggled('prompt_injection_detection', enabled);
};
const handleThresholdChange = async (threshold: number) => {
@@ -4,6 +4,7 @@ import { Check, Lock, Loader2, AlertCircle } from 'lucide-react';
import { Switch } from '../../ui/switch';
import { Button } from '../../ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { trackSettingToggled } from '../../../utils/analytics';
export default function SessionSharingSection() {
const envBaseUrlShare = window.appConfig.get('GOOSE_BASE_URL_SHARE');
@@ -67,6 +68,7 @@ export default function SessionSharingSection() {
setSessionSharingConfig((prev) => {
const updated = { ...prev, enabled: !prev.enabled };
localStorage.setItem('session_sharing_config', JSON.stringify(updated));
trackSettingToggled('session_sharing', updated.enabled);
return updated;
});
};
+29
View File
@@ -0,0 +1,29 @@
/**
* React hooks for frontend-specific analytics tracking.
*
* NOTE: The backend (posthog.rs) already tracks:
* - session_started (extensions, provider, model, tokens, etc.)
* - error (provider errors like rate_limit, auth, etc.)
*
* These frontend hooks focus on UI-specific events that the backend can't see:
* - Page views and navigation patterns
* - Onboarding funnel (where users drop off)
* - Frontend crashes/errors (React errors, unhandled rejections)
*/
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { trackPageView } from '../utils/analytics';
export function usePageViewTracking(): void {
const location = useLocation();
const previousPath = useRef<string | null>(null);
useEffect(() => {
const currentPath = location.pathname;
if (currentPath !== previousPath.current) {
trackPageView(currentPath, previousPath.current || undefined);
previousPath.current = currentPath;
}
}, [location.pathname]);
}
+14
View File
@@ -4,9 +4,13 @@ import { ConfigProvider } from './components/ConfigContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import SuspenseLoader from './suspense-loader';
import { client } from './api/client.gen';
import { setTelemetryEnabled } from './utils/analytics';
import { readConfig } from './api';
const App = lazy(() => import('./App'));
const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
(async () => {
// Check if we're in the launcher view (doesn't need goosed connection)
const isLauncher = window.location.hash === '#/launcher';
@@ -26,6 +30,16 @@ const App = lazy(() => import('./App'));
'X-Secret-Key': await window.electron.getSecretKey(),
},
});
try {
const telemetryResponse = await readConfig({
body: { key: TELEMETRY_CONFIG_KEY, is_secret: false },
});
const isTelemetryEnabled = telemetryResponse.data !== false;
setTelemetryEnabled(isTelemetryEnabled);
} catch (error) {
console.warn('[Analytics] Failed to initialize analytics:', error);
}
}
ReactDOM.createRoot(document.getElementById('root')!).render(
+566
View File
@@ -0,0 +1,566 @@
/**
* 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: { method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other' };
}
| {
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'; 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_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';
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> };
// 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; // React component name
page?: string; // Current route/page
action?: string; // What user was doing
stackSummary?: string; // Use getStackSummary() to generate
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(
method: 'api_key' | 'openrouter' | 'tetrate' | 'ollama' | 'other'
): void {
trackEvent({
name: 'onboarding_provider_selected',
properties: { method },
});
}
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 trackOnboardingAbandoned(step: string): void {
const durationSeconds = onboardingStartTime
? Math.round((Date.now() - onboardingStartTime) / 1000)
: undefined;
trackEvent({
name: 'onboarding_abandoned',
properties: { step, duration_seconds: durationSeconds },
});
onboardingStartTime = null;
}
export function trackOnboardingSetupFailed(
provider: 'openrouter' | 'tetrate',
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 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',
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: {},
});
}