From feb8e16098d78ae1c8d52e32cdb5ea1a22b65b04 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 18 Jul 2026 18:53:07 +0800 Subject: [PATCH 1/2] refactor: add app event boundary --- .../InstallStep/OnboardingSteps.tsx | 72 +++--- src/lib/events/appEventClassifiers.ts | 80 ++++++ src/lib/events/appEvents.ts | 242 ++++++++++++++++++ src/lib/oauth.ts | 5 + .../Agents/components/SkillUploadDialog.tsx | 36 +-- src/pages/Connectors/components/MCPMarket.tsx | 49 ++-- src/pages/Login.tsx | 24 +- src/pages/SignUp.tsx | 26 +- src/service/triggerApi.ts | 10 + src/store/authStore.ts | 41 ++- src/store/chatStore.ts | 90 ++++++- src/store/installationStore.ts | 22 ++ test/unit/lib/appEvents.test.ts | 136 ++++++++++ 13 files changed, 745 insertions(+), 88 deletions(-) create mode 100644 src/lib/events/appEventClassifiers.ts create mode 100644 src/lib/events/appEvents.ts create mode 100644 test/unit/lib/appEvents.test.ts diff --git a/src/components/InstallStep/OnboardingSteps.tsx b/src/components/InstallStep/OnboardingSteps.tsx index 752e04e34..aacfd863b 100644 --- a/src/components/InstallStep/OnboardingSteps.tsx +++ b/src/components/InstallStep/OnboardingSteps.tsx @@ -21,6 +21,7 @@ import { } from '@/components/Background'; import { Button } from '@/components/ui/button'; import { LocaleEnum, switchLanguage } from '@/i18n'; +import { recordOnboardingStepCompleted } from '@/lib/events/appEvents'; import { cn } from '@/lib/utils'; import { useAuthStore, type WorkspaceMainBackground } from '@/store/authStore'; import { @@ -123,16 +124,16 @@ function StepLanguage({ }) { const { t } = useTranslation(); return ( -
-
+
+
{t('layout.onboarding-setup-language-title')} - + {t('layout.onboarding-setup-language-subtitle')}
-
+
{LANGUAGE_OPTIONS.map(({ key, nativeLabel }) => { const active = selected === key; const displayLabel = @@ -142,10 +143,10 @@ function StepLanguage({ key={key} onClick={() => onSelect(key)} className={cn( - 'rounded-xl px-6 py-3 text-body-sm font-medium transition-color flex items-center justify-between border border-solid duration-100', + 'transition-color flex items-center justify-between rounded-xl border border-solid px-6 py-3 text-body-sm font-medium duration-100', active ? 'border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default' - : 'bg-ds-bg-neutral-default-default text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-hover hover:border-ds-border-neutral-default-hover hover:text-ds-text-neutral-muted-hover border-transparent' + : 'border-transparent bg-ds-bg-neutral-default-default text-ds-text-neutral-muted-default hover:border-ds-border-neutral-default-hover hover:bg-ds-bg-neutral-default-hover hover:text-ds-text-neutral-muted-hover' )} > {displayLabel} @@ -194,16 +195,16 @@ function StepTheme({ ]; return ( -
+
{/* Mode selection */} -
+
{t('layout.onboarding-setup-appearance-title')} - + {t('layout.onboarding-setup-language-subtitle')} -
+
{MODES.map(({ id, label, Icon }) => { const active = appearanceMode === id; return ( @@ -211,10 +212,10 @@ function StepTheme({ key={id} onClick={() => onModeChange(id)} className={cn( - 'gap-2 rounded-xl py-4 flex flex-1 flex-col items-center border border-solid transition-colors duration-100', + 'flex flex-1 flex-col items-center gap-2 rounded-xl border border-solid py-4 transition-colors duration-100', active ? 'border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default text-ds-text-neutral-default-default' - : 'bg-ds-bg-neutral-default-default text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-default-hover hover:border-ds-border-neutral-default-hover hover:text-ds-text-neutral-muted-hover border-transparent' + : 'border-transparent bg-ds-bg-neutral-default-default text-ds-text-neutral-muted-default hover:border-ds-border-neutral-default-hover hover:bg-ds-bg-neutral-default-hover hover:text-ds-text-neutral-muted-hover' )} > @@ -226,13 +227,13 @@ function StepTheme({
{/* Color theme selection */} -
+
{t('layout.onboarding-setup-color-theme')}
-
+
{THEME_PRESETS.map(({ id, label, lightAccent, darkAccent }) => { const accent = appearance === 'dark' ? darkAccent : lightAccent; const active = activeThemeId === id; @@ -241,10 +242,10 @@ function StepTheme({ key={id} onClick={() => onThemeChange(id)} className={cn( - 'gap-3 rounded-xl p-4 flex flex-col items-center border border-solid transition-colors duration-100', + 'flex flex-col items-center gap-3 rounded-xl border border-solid p-4 transition-colors duration-100', active ? 'border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default' - : 'bg-ds-bg-neutral-default-default hover:bg-ds-bg-neutral-default-hover hover:border-ds-border-neutral-default-hover border-transparent' + : 'border-transparent bg-ds-bg-neutral-default-default hover:border-ds-border-neutral-default-hover hover:bg-ds-bg-neutral-default-hover' )} >
-
+
{Component && } {selected && ( -
-
+
+
-
+
+
{t('layout.onboarding-setup-workspace-title')} - + {t('layout.onboarding-setup-workspace-subtitle')}
-
+
{BG_PATTERN_DEFS.map(({ id, labelKey, Component }) => ( void }) { setColorThemeForMode('dark', themeId); }; + const stepName = (s: Step) => + s === 1 ? 'language' : s === 2 ? 'theme' : 'background'; + const handleComplete = () => { + recordOnboardingStepCompleted({ + step_id: 3, + step_name: 'background', + }); setOnboardingCompleted(true); setIsFirstLaunch(false); onComplete(); }; return ( -
+
{LivePatternComponent && } -
+
{/* Step indicator dots */} -
+
{([1, 2, 3] as Step[]).map((s) => (
void }) { textWeight="semibold" buttonContent="text" buttonRadius="lg" - onClick={() => setStep((s) => (s + 1) as Step)} + onClick={() => { + recordOnboardingStepCompleted({ + step_id: step, + step_name: stepName(step), + }); + setStep((s) => (s + 1) as Step); + }} > {t('layout.continue')} diff --git a/src/lib/events/appEventClassifiers.ts b/src/lib/events/appEventClassifiers.ts new file mode 100644 index 000000000..16f81d4d3 --- /dev/null +++ b/src/lib/events/appEventClassifiers.ts @@ -0,0 +1,80 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +/** + * Bucket raw runtime errors into a small, stable taxonomy. The raw message may + * include local paths, command output, or user content, so callers should emit + * only this low-cardinality label. + */ +export function classifyError(message?: string | null): string { + if (!message) return 'unknown'; + const m = message.toLowerCase(); + if (m.includes('backend') && (m.includes('ready') || m.includes('not'))) + return 'backend_unavailable'; + if (m.includes('already processing')) return 'single_agent_busy'; + if (m.includes('credit') || m.includes('usage limit') || m.includes('quota')) + return 'credits_or_limit'; + if (m.includes('model')) return 'model'; + if (m.includes('mcp') || m.includes('tool') || m.includes('toolkit')) + return 'tool_or_mcp'; + if (m.includes('network') || m.includes('timeout') || m.includes('fetch')) + return 'network'; + return 'unknown'; +} + +/** + * Classify "what job is Eigent hired for" entirely on-device. The raw project + * name / summary is read locally but never emitted; only the resulting enum can + * be forwarded to edition-specific analytics adapters. + */ +const TASK_CATEGORY_RULES: Array<[string, RegExp]> = [ + [ + 'coding', + /\b(code|coding|bug|debug|refactor|api|repo|git|deploy|compile|script|function|python|javascript|typescript)\b/, + ], + [ + 'data_analysis', + /\b(data|csv|excel|spreadsheet|dataset|sql|chart|graph|statistic|analy)/, + ], + [ + 'web_automation', + /\b(browse|browser|website|web page|navigate|crawl|scrape|fill.*form|automate)/, + ], + [ + 'document', + /\b(pdf|document|\bdoc\b|slide|presentation|ppt|word file|convert.*file)/, + ], + ['communication', /\b(email|slack|message|notify|calendar|meeting|invite)\b/], + [ + 'design', + /\b(design|logo|figma|mockup|diagram|wireframe|image|illustration)\b/, + ], + [ + 'writing', + /\b(write|blog|article|essay|draft|post|copywrit|content|translate|summari[sz]e)/, + ], + [ + 'research', + /\b(research|find|search|investigate|gather|compare|look up|report on)/, + ], +]; + +export function classifyTaskCategory(text?: string | null): string { + if (!text) return 'unknown'; + const t = text.toLowerCase(); + for (const [category, pattern] of TASK_CATEGORY_RULES) { + if (pattern.test(t)) return category; + } + return 'other'; +} diff --git a/src/lib/events/appEvents.ts b/src/lib/events/appEvents.ts new file mode 100644 index 000000000..ce2f6f0b4 --- /dev/null +++ b/src/lib/events/appEvents.ts @@ -0,0 +1,242 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +export interface UserIdentity { + id?: string | number | null; + email?: string | null; + username?: string | null; +} + +export interface TaskOutcomeEventProperties extends Record { + session_mode?: unknown; + agent_count?: number; + has_mcp?: boolean; + duration_seconds?: number; + tokens?: number; + task_category?: string; +} + +export interface AppEventMap { + app_launch_failed: { reason: string }; + onboarding_step_completed: { + step_id: string | number; + step_name?: string; + phase?: string; + }; + signin_failed: { reason: string; method: string }; + signup_failed: { reason: string }; + permission_resolved: { permission: string; granted: boolean }; + feature_used: { + feature: string; + action?: string; + [key: string]: unknown; + }; + mcp_installed: { mcp_id: number; mcp_key?: string }; + mcp_install_failed: { + mcp_id: number; + mcp_key?: string; + error_type: string; + error_name: string; + }; + scheduled_trigger_created: { + trigger_type?: unknown; + schedule?: unknown; + is_single_execution?: unknown; + }; + model_type_changed: { from: string; to: string }; + model_configured: { model_type: string; model_id?: string }; + task_submitted: { + session_mode?: unknown; + task_source?: string; + agent_count?: number; + has_mcp?: boolean; + }; + task_failed: { + error_type: string; + is_project_busy?: boolean; + session_mode?: unknown; + }; + task_stopped: TaskOutcomeEventProperties & { stop_reason: string }; + task_completed: TaskOutcomeEventProperties; + file_generated: { count: number }; + user_identity_available: UserIdentity; + user_session_cleared: Record; +} + +export type AppEventName = keyof AppEventMap; +export type AppEvent = { + [Name in AppEventName]: { + name: Name; + properties: AppEventMap[Name]; + timestamp: number; + }; +}[K]; +export type AppEventListener = (event: AppEvent) => void; + +const listeners = new Set(); +const bufferedEvents: AppEvent[] = []; +const MAX_BUFFERED_EVENTS = 100; +let shouldBufferEvents = true; + +export function emitAppEvent( + name: K, + properties: AppEventMap[K] +): void { + const event = { + name, + properties, + timestamp: Date.now(), + } as AppEvent; + + if (shouldBufferEvents) { + bufferedEvents.push(event as AppEvent); + if (bufferedEvents.length > MAX_BUFFERED_EVENTS) { + bufferedEvents.splice(0, bufferedEvents.length - MAX_BUFFERED_EVENTS); + } + } + + for (const listener of listeners) { + dispatchAppEvent(listener, event as AppEvent); + } +} + +export function subscribeAppEvents( + listener: AppEventListener, + options: { replayBuffered?: boolean; consumeBuffered?: boolean } = {} +): () => void { + listeners.add(listener); + + if (options.replayBuffered && bufferedEvents.length > 0) { + const replay = options.consumeBuffered + ? bufferedEvents.splice(0, bufferedEvents.length) + : [...bufferedEvents]; + if (options.consumeBuffered) { + shouldBufferEvents = false; + } + for (const event of replay) { + dispatchAppEvent(listener, event); + } + } else if (options.consumeBuffered) { + shouldBufferEvents = false; + } + + return () => { + listeners.delete(listener); + }; +} + +function dispatchAppEvent(listener: AppEventListener, event: AppEvent): void { + try { + listener(event); + } catch (error) { + console.error(`[appEvents] listener failed for ${event.name}:`, error); + } +} + +export function recordUserIdentityAvailable(identity: UserIdentity): void { + emitAppEvent('user_identity_available', identity); +} + +export function recordUserSessionCleared(): void { + emitAppEvent('user_session_cleared', {}); +} + +export function recordAppLaunchFailed(reason: string): void { + emitAppEvent('app_launch_failed', { reason }); +} + +export function recordOnboardingStepCompleted( + properties: AppEventMap['onboarding_step_completed'] +): void { + emitAppEvent('onboarding_step_completed', properties); +} + +export function recordSignInFailed( + properties: AppEventMap['signin_failed'] +): void { + emitAppEvent('signin_failed', properties); +} + +export function recordSignUpFailed(reason: string): void { + emitAppEvent('signup_failed', { reason }); +} + +export function recordPermissionResolved( + properties: AppEventMap['permission_resolved'] +): void { + emitAppEvent('permission_resolved', properties); +} + +export function recordFeatureUsed( + feature: string, + properties?: Omit +): void { + emitAppEvent('feature_used', { feature, ...properties }); +} + +export function recordMcpInstalled( + properties: AppEventMap['mcp_installed'] +): void { + emitAppEvent('mcp_installed', properties); +} + +export function recordMcpInstallFailed( + properties: AppEventMap['mcp_install_failed'] +): void { + emitAppEvent('mcp_install_failed', properties); +} + +export function recordScheduledTriggerCreated( + properties: AppEventMap['scheduled_trigger_created'] +): void { + emitAppEvent('scheduled_trigger_created', properties); +} + +export function recordModelTypeChanged( + properties: AppEventMap['model_type_changed'] +): void { + emitAppEvent('model_type_changed', properties); +} + +export function recordModelConfigured( + properties: AppEventMap['model_configured'] +): void { + emitAppEvent('model_configured', properties); +} + +export function recordTaskSubmitted( + properties: AppEventMap['task_submitted'] = {} +): void { + emitAppEvent('task_submitted', properties); +} + +export function recordTaskFailed(properties: AppEventMap['task_failed']): void { + emitAppEvent('task_failed', properties); +} + +export function recordTaskStopped( + properties: AppEventMap['task_stopped'] +): void { + emitAppEvent('task_stopped', properties); +} + +export function recordTaskCompleted( + properties: AppEventMap['task_completed'] = {} +): void { + emitAppEvent('task_completed', properties); +} + +export function recordFileGenerated(count: number): void { + emitAppEvent('file_generated', { count }); +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 089317ea1..a7fd725c8 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -13,6 +13,7 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { createHost } from '@/host'; +import { recordPermissionResolved } from '@/lib/events/appEvents'; const EnvOauthInfoMap = { notion: 'NOTION_TOKEN', @@ -136,6 +137,10 @@ export class OAuth { resourceMetadata: this.resourceMetadata, }, }); + recordPermissionResolved({ + permission: this.provider, + granted: Boolean(token?.access_token), + }); return token; } diff --git a/src/pages/Agents/components/SkillUploadDialog.tsx b/src/pages/Agents/components/SkillUploadDialog.tsx index 615a71d5a..c48dbfaec 100644 --- a/src/pages/Agents/components/SkillUploadDialog.tsx +++ b/src/pages/Agents/components/SkillUploadDialog.tsx @@ -22,6 +22,7 @@ import { DialogHeader, } from '@/components/ui/dialog'; import { Textarea } from '@/components/ui/textarea'; +import { recordFeatureUsed } from '@/lib/events/appEvents'; import { buildSkillMd, parseSkillMd } from '@/lib/skillToolkit'; import { useSkillsStore } from '@/store/skillsStore'; @@ -113,6 +114,7 @@ export default function SkillUploadDialog({ enabled: true, }); toast.success(t('agents.skill-added-success')); + recordFeatureUsed('skills', { action: 'create' }); handleClose(); } catch { toast.error(t('agents.skill-add-error')); @@ -306,6 +308,7 @@ export default function SkillUploadDialog({ await syncFromDisk(); toast.success(t('agents.skill-added-success')); + recordFeatureUsed('skills', { action: 'upload', format: 'zip' }); handleClose(); return; } @@ -343,6 +346,7 @@ export default function SkillUploadDialog({ }); toast.success(t('agents.skill-added-success')); + recordFeatureUsed('skills', { action: 'upload', format: 'md' }); handleClose(); } catch (_error) { toast.error(t('agents.skill-add-error')); @@ -486,7 +490,7 @@ export default function SkillUploadDialog({ } /> -
+
{mode === 'create' ? ( <>

@@ -496,10 +500,10 @@ export default function SkillUploadDialog({ variant="none" value={composeContent} onChange={(e) => setComposeContent(e.target.value)} - className="font-mono text-body-sm min-h-[220px] resize-y" + className="min-h-[220px] resize-y font-mono text-body-sm" spellCheck={false} /> -

+
{HAS_STACK_KEYS && ( -
+
)} {HAS_STACK_KEYS && ( -
+
{t('layout.or')}
)} -
+
{generalError && (

{generalError}

)} -
+
=> { try { const res = await proxyFetchPost(`/api/v1/trigger/`, triggerData); + recordScheduledTriggerCreated({ + trigger_type: triggerData.trigger_type, + schedule: triggerData.custom_cron_expression, + is_single_execution: triggerData.is_single_execution, + }); + recordFeatureUsed('triggers', { action: 'create' }); return res; } catch (error) { console.error('Failed to create trigger:', error); diff --git a/src/store/authStore.ts b/src/store/authStore.ts index ec41f243d..f049f30f6 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -13,6 +13,12 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { getAuthEnvironmentKey } from '@/lib/authEnvironment'; +import { + recordModelConfigured, + recordModelTypeChanged, + recordUserIdentityAvailable, + recordUserSessionCleared, +} from '@/lib/events/appEvents'; import { clearAllCachedProjects } from '@/lib/projectCache'; import { DEFAULT_COLOR_THEME_ID, @@ -244,6 +250,7 @@ const authStore = create()( authEnvironmentKey: getAuthEnvironmentKey(), }); hydrateSpacesForUser(resolvedUserId); + recordUserIdentityAvailable({ id: resolvedUserId, email, username }); }, logout: () => { @@ -263,6 +270,7 @@ const authStore = create()( localProxyValue: null, authEnvironmentKey: getAuthEnvironmentKey(), }); + recordUserSessionCleared(); useSpaceStore.getState().resetForUser(null); }, @@ -333,14 +341,38 @@ const authStore = create()( set({ initState }); }, - setModelType: (modelType) => set({ modelType }), + setModelType: (modelType) => + set((state) => { + if (modelType !== state.modelType) { + recordModelTypeChanged({ + from: state.modelType, + to: modelType, + }); + } + return { modelType }; + }), setCloudModelType: (cloud_model_type) => set({ cloud_model_type }), setCodexModelType: (codex_model_type) => set({ codex_model_type }), setHasModelConfigured: (hasModelConfigured) => - set({ hasModelConfigured }), + set((state) => { + // Fire `model_configured` once, on the false → true edge (Goal 1A). + if (hasModelConfigured && !state.hasModelConfigured) { + const modelId = + state.modelType === 'cloud' + ? state.cloud_model_type + : state.modelType === 'codex_subscription' + ? state.codex_model_type + : undefined; + recordModelConfigured({ + model_type: state.modelType, + model_id: modelId, + }); + } + return { hasModelConfigured }; + }), setIsFirstLaunch: (isFirstLaunch) => set({ isFirstLaunch }), @@ -538,8 +570,11 @@ export const getAuthStore = () => authStore.getState(); queueMicrotask(() => { if (!useSpaceStore?.getState) return; clearAuthForCurrentEnvironment(authStore.setState, authStore.getState); - const { user_id } = authStore.getState(); + const { token, user_id, email, username } = authStore.getState(); hydrateSpacesForUser(user_id); + if (token && user_id != null) { + recordUserIdentityAvailable({ id: user_id, email, username }); + } }); // constant definition diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 48de9ba13..98e443ae9 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -28,6 +28,18 @@ import { showCreditsToast } from '@/components/Toast/creditsToast'; import { showStorageToast } from '@/components/Toast/storageToast'; import type { AppHost } from '@/host/types'; import { generateUniqueId, uploadLog } from '@/lib'; +import { + classifyError, + classifyTaskCategory, +} from '@/lib/events/appEventClassifiers'; +import { + recordFeatureUsed, + recordFileGenerated, + recordTaskCompleted, + recordTaskFailed, + recordTaskStopped, + recordTaskSubmitted, +} from '@/lib/events/appEvents'; import { buildAgentModelConfigFromProvider, splitProviderConfig, @@ -1395,6 +1407,25 @@ const chatStore = (initial?: Partial) => } const sessionModeForRequest = sessionMode || project?.mode || SessionMode.SINGLE_AGENT; + // Track genuine, user-facing task starts (skip replay/share playback). + // Powers the "time to first task" lifecycle event. + if (isLiveTask) { + const submitWorkers = getWorkerList(); + const submitHasMcp = submitWorkers.some( + (w) => (w.workerInfo?.mcp_tools?.length ?? 0) > 0 + ); + recordTaskSubmitted({ + session_mode: sessionModeForRequest, + task_source: executionId ? 'trigger' : 'user', + agent_count: submitWorkers.length, + has_mcp: submitHasMcp, + }); + if (sessionModeForRequest === SessionMode.WORKFORCE) { + recordFeatureUsed('multi_agent', { + session_mode: sessionModeForRequest, + }); + } + } if (project_id && !project?.mode) { useSpaceStore .getState() @@ -1478,6 +1509,12 @@ const chatStore = (initial?: Partial) => if (!isBackendReady) { console.error('[startTask] Backend is not ready, cannot start task'); + // A task failure, not a launch failure — this can fire hours after + // a successful launch and would otherwise skew launch-failure rate. + recordTaskFailed({ + error_type: 'backend_unavailable', + session_mode: sessionModeForRequest, + }); const targetState = targetChatStore.getState(); targetState.addMessages(newTaskId, { id: generateUniqueId(), @@ -3582,6 +3619,14 @@ const chatStore = (initial?: Partial) => } } uploadLog(currentTaskId, type); + // Analytics: task failed — split breakage vs disinterest. + if (!type || type === 'normal') { + recordTaskFailed({ + error_type: classifyError(errorMessage), + is_project_busy: isProjectBusyError, + session_mode: tasks[currentTaskId]?.sessionMode, + }); + } // Update trigger execution status to Failed on error updateTriggerExecutionStatus( getCurrentChatStore(), @@ -3724,6 +3769,12 @@ const chatStore = (initial?: Partial) => } clearRequestUsageStepTokens(currentTaskId); if (!currentTaskId || !tasks[currentTaskId]) return; + // The Stop button hits backend's Action.skip_task, which also + // yields an `end` SSE event with this fixed sentinel. Do not count + // that as a successful completion for analytics metrics. + const wasStoppedByUser = endMessageText.startsWith( + 'Task stopped' + ); const endMessage = resolveEndMessageText( endMessageText, @@ -3745,6 +3796,39 @@ const chatStore = (initial?: Partial) => setStatus(currentTaskId, ChatTaskStatus.FINISHED); setUpdateCount(); + // Analytics: task outcome. Skip replay/share playback so only real + // runs are measured, and keep stopped runs out of completion metrics. + if (!type || type === 'normal') { + const completedTask = tasks[currentTaskId]; + const completedProjectName = ( + project_id ? projectStore.getProjectById(project_id) : null + )?.name; + const taskOutcomeProperties = { + session_mode: completedTask?.sessionMode, + agent_count: completedTask?.taskAssigning?.length ?? 0, + has_mcp: getWorkerList().some( + (w) => (w.workerInfo?.mcp_tools?.length ?? 0) > 0 + ), + duration_seconds: completedTask?.createdAt + ? Math.round((Date.now() - completedTask.createdAt) / 1000) + : undefined, + tokens: getTokens(currentTaskId), + // Classify the task on-device for low-cardinality reporting; + // the raw project name / summary (user content) is not sent. + task_category: classifyTaskCategory( + `${completedProjectName ?? ''} ${completedTask?.summaryTask ?? ''}` + ), + }; + if (wasStoppedByUser) { + recordTaskStopped({ + ...taskOutcomeProperties, + stop_reason: 'user_requested', + }); + } else { + recordTaskCompleted(taskOutcomeProperties); + } + } + // compute task time console.log( 'tasks[taskId].snapshotsTemp', @@ -3821,6 +3905,7 @@ const chatStore = (initial?: Partial) => action: 'file_generate_count', value: generatedSuccessCount, }); + recordFileGenerated(generatedSuccessCount); } } } catch (error) { @@ -3839,14 +3924,9 @@ const chatStore = (initial?: Partial) => const parts = st.split('|'); const rawEndPayload = endMessageText; const completionSummary = rawEndPayload || parts[1] || ''; - // The Stop button hits backend's Action.skip_task, which - // also yields an `end` SSE event with this fixed sentinel. // Treat the run as ongoing so chat_history.status accurately // reflects whether the project actually completed; the // history-replay polish keys off this flag. - const wasStoppedByUser = rawEndPayload.startsWith( - 'Task stopped' - ); const projectName = parts[0] || ''; const obj = { project_name: projectName, diff --git a/src/store/installationStore.ts b/src/store/installationStore.ts index 8e2a56ccc..b0e7eb895 100644 --- a/src/store/installationStore.ts +++ b/src/store/installationStore.ts @@ -13,6 +13,10 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import { createHost } from '@/host'; +import { + recordAppLaunchFailed, + recordOnboardingStepCompleted, +} from '@/lib/events/appEvents'; import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; @@ -241,6 +245,24 @@ export const useInstallationStore = create()( })) ); +// Analytics: one subscription tracks the install/setup lifecycle so we don't +// have to instrument every individual setter. Progress phases feed the +// onboarding funnel; an `error` phase is also reported as `app_launch_failed`. +useInstallationStore.subscribe( + (s) => s.state, + (state, prevState) => { + if (state === prevState || state === 'idle') return; + if (state === 'error') { + recordAppLaunchFailed('install_error'); + return; + } + recordOnboardingStepCompleted({ + step_id: `install:${state}`, + phase: 'installation', + }); + } +); + // Computed selectors export const useLatestLog = () => useInstallationStore((state) => state.logs[state.logs.length - 1]); diff --git a/test/unit/lib/appEvents.test.ts b/test/unit/lib/appEvents.test.ts new file mode 100644 index 000000000..a6bbe9fb5 --- /dev/null +++ b/test/unit/lib/appEvents.test.ts @@ -0,0 +1,136 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { describe, expect, it, vi } from 'vitest'; + +describe('app event bus', () => { + it('buffers startup events until the edition adapter subscribes', async () => { + vi.resetModules(); + const events = await import('@/lib/events/appEvents'); + const received: Array<{ name: string; properties: unknown }> = []; + + events.recordSignInFailed({ reason: 'startup_race', method: 'token' }); + const unsubscribe = events.subscribeAppEvents( + (event) => { + received.push({ + name: event.name, + properties: event.properties, + }); + }, + { replayBuffered: true, consumeBuffered: true } + ); + + expect(received).toEqual([ + { + name: 'signin_failed', + properties: { reason: 'startup_race', method: 'token' }, + }, + ]); + + unsubscribe(); + }); + + it('does not replay one buffered event to later subscribers twice', async () => { + vi.resetModules(); + const events = await import('@/lib/events/appEvents'); + const firstSubscriber = vi.fn(); + const secondSubscriber = vi.fn(); + + events.recordFeatureUsed('skills', { action: 'create' }); + const unsubscribeFirst = events.subscribeAppEvents(firstSubscriber, { + replayBuffered: true, + consumeBuffered: true, + }); + const unsubscribeSecond = events.subscribeAppEvents(secondSubscriber, { + replayBuffered: true, + }); + + expect(firstSubscriber).toHaveBeenCalledTimes(1); + expect(secondSubscriber).not.toHaveBeenCalled(); + + unsubscribeFirst(); + unsubscribeSecond(); + }); + + it('keeps buffering until a replay consumer drains the startup buffer', async () => { + vi.resetModules(); + const events = await import('@/lib/events/appEvents'); + const passiveSubscriber = vi.fn(); + const adapterSubscriber = vi.fn(); + + const unsubscribePassive = events.subscribeAppEvents(passiveSubscriber); + + events.recordUserIdentityAvailable({ + id: 42, + email: 'user@example.com', + username: 'user', + }); + const unsubscribeAdapter = events.subscribeAppEvents(adapterSubscriber, { + replayBuffered: true, + consumeBuffered: true, + }); + + expect(passiveSubscriber).toHaveBeenCalledTimes(1); + expect(adapterSubscriber).toHaveBeenCalledTimes(1); + expect(adapterSubscriber.mock.calls[0][0]).toMatchObject({ + name: 'user_identity_available', + properties: { + id: 42, + email: 'user@example.com', + username: 'user', + }, + }); + + unsubscribePassive(); + unsubscribeAdapter(); + }); + + it('keeps dispatching when one listener throws', async () => { + vi.resetModules(); + const events = await import('@/lib/events/appEvents'); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const healthySubscriber = vi.fn(); + + const unsubscribeBroken = events.subscribeAppEvents(() => { + throw new Error('listener failed'); + }); + const unsubscribeHealthy = events.subscribeAppEvents(healthySubscriber); + + events.recordFeatureUsed('triggers', { action: 'create' }); + + expect(healthySubscriber).toHaveBeenCalledTimes(1); + expect(healthySubscriber.mock.calls[0][0]).toMatchObject({ + name: 'feature_used', + properties: { feature: 'triggers', action: 'create' }, + }); + + unsubscribeBroken(); + unsubscribeHealthy(); + consoleError.mockRestore(); + }); +}); + +describe('app event classifiers', () => { + it('keeps error and task category labels low-cardinality', async () => { + const { classifyError, classifyTaskCategory } = + await import('@/lib/events/appEventClassifiers'); + + expect(classifyError('Backend is not ready')).toBe('backend_unavailable'); + expect(classifyError('fetch timeout')).toBe('network'); + expect(classifyTaskCategory('debug a TypeScript API')).toBe('coding'); + expect(classifyTaskCategory('compare market research')).toBe('research'); + }); +}); From 1755f17f2982ae1cc1a4b18e0d24f87ae78a8884 Mon Sep 17 00:00:00 2001 From: 4pmtong Date: Sat, 18 Jul 2026 21:47:20 +0800 Subject: [PATCH 2/2] docs: clarify app event identity scope --- src/lib/events/appEvents.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/events/appEvents.ts b/src/lib/events/appEvents.ts index ce2f6f0b4..1eb3644fe 100644 --- a/src/lib/events/appEvents.ts +++ b/src/lib/events/appEvents.ts @@ -13,6 +13,9 @@ // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= export interface UserIdentity { + // This may include an email address so edition adapters can decide how to + // identify a signed-in user. The OSS build only keeps it in this bounded + // in-memory event buffer; it is not sent anywhere without an adapter. id?: string | number | null; email?: string | null; username?: string | null;