diff --git a/src/components/dialog/content/setting/UsageLogsTable.vue b/src/components/dialog/content/setting/UsageLogsTable.vue index 41b6e283c..f73158a93 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.vue +++ b/src/components/dialog/content/setting/UsageLogsTable.vue @@ -96,6 +96,7 @@ import Message from 'primevue/message' import ProgressSpinner from 'primevue/progressspinner' import { computed, ref } from 'vue' +import { useTelemetry } from '@/platform/telemetry' import type { AuditLog } from '@/services/customerEventsService' import { EventType, @@ -159,6 +160,9 @@ const loadEvents = async () => { if (response.totalPages) { pagination.value.totalPages = response.totalPages } + + // Check if a pending top-up has completed + useTelemetry()?.checkForCompletedTopup(response.events) } else { error.value = customerEventService.error.value || 'Failed to load events' } diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index d8dcc5466..a02532f56 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' +import { useTelemetry } from '@/platform/telemetry' import { useToastStore } from '@/platform/updates/common/toastStore' import { useDialogService } from '@/services/dialogService' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' @@ -99,7 +100,7 @@ export const useFirebaseAuthActions = () => { ) } - // Go to Stripe checkout page + useTelemetry()?.startTopupTracking() window.open(response.checkout_url, '_blank') }, reportError) @@ -116,7 +117,9 @@ export const useFirebaseAuthActions = () => { }, reportError) const fetchBalance = wrapWithErrorHandlingAsync(async () => { - return await authStore.fetchBalance() + const result = await authStore.fetchBalance() + // Top-up completion tracking happens in UsageLogsTable when events are fetched + return result }, reportError) const signInWithGoogle = wrapWithErrorHandlingAsync(async () => { diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 354854073..45e674aae 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -1,6 +1,11 @@ import type { OverridedMixpanel } from 'mixpanel-browser' import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { + checkForCompletedTopup as checkTopupUtil, + clearTopupTracking as clearTopupUtil, + startTopupTracking as startTopupUtil +} from '@/platform/telemetry/topupTracker' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import { app } from '@/scripts/app' @@ -172,6 +177,23 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { ) } + trackApiCreditTopupSucceeded(): void { + this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED) + } + + // Credit top-up tracking methods (composition with utility functions) + startTopupTracking(): void { + startTopupUtil() + } + + checkForCompletedTopup(events: any[] | undefined | null): boolean { + return checkTopupUtil(events) + } + + clearTopupTracking(): void { + clearTopupUtil() + } + trackRunButton(options?: { subscribe_to_run?: boolean }): void { const executionContext = this.getExecutionContext() diff --git a/src/platform/telemetry/topupTracker.ts b/src/platform/telemetry/topupTracker.ts new file mode 100644 index 000000000..ab8d40be8 --- /dev/null +++ b/src/platform/telemetry/topupTracker.ts @@ -0,0 +1,61 @@ +import { useTelemetry } from '@/platform/telemetry' +import type { AuditLog } from '@/services/customerEventsService' + +const STORAGE_KEY = 'pending_topup_timestamp' +const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours + +/** + * Start tracking a credit top-up purchase. + * Call this before opening the Stripe checkout window. + */ +export function startTopupTracking(): void { + localStorage.setItem(STORAGE_KEY, Date.now().toString()) +} + +/** + * Check if a pending top-up has completed by looking for a credit_added event + * that occurred after the tracking started. + * + * @param events - Array of audit log events to check + * @returns true if a completed top-up was detected and telemetry was sent + */ +export function checkForCompletedTopup( + events: AuditLog[] | undefined | null +): boolean { + const timestampStr = localStorage.getItem(STORAGE_KEY) + if (!timestampStr) return false + + const timestamp = parseInt(timestampStr, 10) + + // Auto-cleanup if expired (older than 24 hours) + if (Date.now() - timestamp > MAX_AGE_MS) { + localStorage.removeItem(STORAGE_KEY) + return false + } + + if (!events || events.length === 0) return false + + // Find credit_added event that occurred after our timestamp + const completedTopup = events.find( + (e) => + e.event_type === 'credit_added' && + e.createdAt && + new Date(e.createdAt).getTime() > timestamp + ) + + if (completedTopup) { + useTelemetry()?.trackApiCreditTopupSucceeded() + localStorage.removeItem(STORAGE_KEY) + return true + } + + return false +} + +/** + * Clear any pending top-up tracking. + * Useful for testing or manual cleanup. + */ +export function clearTopupTracking(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 78d14142f..4e9ed5172 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -247,10 +247,16 @@ export interface TelemetryProvider { trackMonthlySubscriptionSucceeded(): void trackAddApiCreditButtonClicked(): void trackApiCreditTopupButtonPurchaseClicked(amount: number): void + trackApiCreditTopupSucceeded(): void trackRunButton(options?: { subscribe_to_run?: boolean }): void trackRunTriggeredViaKeybinding(): void trackRunTriggeredViaMenu(): void + // Credit top-up tracking (composition with internal utilities) + startTopupTracking(): void + checkForCompletedTopup(events: any[] | undefined | null): boolean + clearTopupTracking(): void + // Survey flow events trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void @@ -315,6 +321,7 @@ export const TelemetryEvents = { ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked', API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED: 'app:api_credit_topup_button_purchase_clicked', + API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded', // Onboarding Survey USER_SURVEY_OPENED: 'app:user_survey_opened', diff --git a/src/services/customerEventsService.ts b/src/services/customerEventsService.ts index 0359f4c3a..04d0f1d66 100644 --- a/src/services/customerEventsService.ts +++ b/src/services/customerEventsService.ts @@ -179,7 +179,7 @@ export const useCustomerEventsService = () => { return null } - return executeRequest( + const result = await executeRequest( () => customerApiClient.get('/customers/events', { params: { page, limit }, @@ -187,6 +187,8 @@ export const useCustomerEventsService = () => { }), { errorContext, routeSpecificErrors } ) + + return result } return { diff --git a/tests-ui/tests/platform/telemetry/topupTracker.test.ts b/tests-ui/tests/platform/telemetry/topupTracker.test.ts new file mode 100644 index 000000000..668ed2db7 --- /dev/null +++ b/tests-ui/tests/platform/telemetry/topupTracker.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { AuditLog } from '@/services/customerEventsService' + +// Mock localStorage +const mockLocalStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +Object.defineProperty(window, 'localStorage', { + value: mockLocalStorage, + writable: true +}) + +// Mock telemetry +const mockTelemetry = vi.hoisted(() => ({ + trackApiCreditTopupSucceeded: vi.fn() +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: vi.fn(() => mockTelemetry) +})) + +describe('topupTracker', () => { + let topupTracker: typeof import('@/platform/telemetry/topupTracker') + + beforeEach(async () => { + vi.clearAllMocks() + // Dynamically import to ensure fresh module state + topupTracker = await import('@/platform/telemetry/topupTracker') + }) + + describe('startTopupTracking', () => { + it('should save current timestamp to localStorage', () => { + const beforeTimestamp = Date.now() + + topupTracker.startTopupTracking() + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + 'pending_topup_timestamp', + expect.any(String) + ) + + const savedTimestamp = parseInt( + mockLocalStorage.setItem.mock.calls[0][1], + 10 + ) + expect(savedTimestamp).toBeGreaterThanOrEqual(beforeTimestamp) + expect(savedTimestamp).toBeLessThanOrEqual(Date.now()) + }) + }) + + describe('checkForCompletedTopup', () => { + it('should return false if no pending topup exists', () => { + mockLocalStorage.getItem.mockReturnValue(null) + + const result = topupTracker.checkForCompletedTopup([]) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + + it('should return false if events array is empty', () => { + mockLocalStorage.getItem.mockReturnValue(Date.now().toString()) + + const result = topupTracker.checkForCompletedTopup([]) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + + it('should return false if events array is null', () => { + mockLocalStorage.getItem.mockReturnValue(Date.now().toString()) + + const result = topupTracker.checkForCompletedTopup(null) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + + it('should auto-cleanup if timestamp is older than 24 hours', () => { + const oldTimestamp = Date.now() - 25 * 60 * 60 * 1000 // 25 hours ago + mockLocalStorage.getItem.mockReturnValue(oldTimestamp.toString()) + + const events: AuditLog[] = [ + { + event_id: 'test-1', + event_type: 'credit_added', + createdAt: new Date().toISOString(), + params: { amount: 500 } + } + ] + + const result = topupTracker.checkForCompletedTopup(events) + + expect(result).toBe(false) + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'pending_topup_timestamp' + ) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + + it('should detect completed topup and fire telemetry', () => { + const startTimestamp = Date.now() - 5 * 60 * 1000 // 5 minutes ago + mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString()) + + const events: AuditLog[] = [ + { + event_id: 'test-1', + event_type: 'api_usage_completed', + createdAt: new Date(startTimestamp - 1000).toISOString(), + params: {} + }, + { + event_id: 'test-2', + event_type: 'credit_added', + createdAt: new Date(startTimestamp + 1000).toISOString(), + params: { amount: 500 } + } + ] + + const result = topupTracker.checkForCompletedTopup(events) + + expect(result).toBe(true) + expect(mockTelemetry.trackApiCreditTopupSucceeded).toHaveBeenCalledOnce() + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'pending_topup_timestamp' + ) + }) + + it('should not detect topup if credit_added event is before tracking started', () => { + const startTimestamp = Date.now() + mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString()) + + const events: AuditLog[] = [ + { + event_id: 'test-1', + event_type: 'credit_added', + createdAt: new Date(startTimestamp - 1000).toISOString(), // Before tracking + params: { amount: 500 } + } + ] + + const result = topupTracker.checkForCompletedTopup(events) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + expect(mockLocalStorage.removeItem).not.toHaveBeenCalled() + }) + + it('should ignore events without createdAt timestamp', () => { + const startTimestamp = Date.now() + mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString()) + + const events: AuditLog[] = [ + { + event_id: 'test-1', + event_type: 'credit_added', + createdAt: undefined, + params: { amount: 500 } + } + ] + + const result = topupTracker.checkForCompletedTopup(events) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + + it('should only match credit_added events, not other event types', () => { + const startTimestamp = Date.now() + mockLocalStorage.getItem.mockReturnValue(startTimestamp.toString()) + + const events: AuditLog[] = [ + { + event_id: 'test-1', + event_type: 'api_usage_completed', + createdAt: new Date(startTimestamp + 1000).toISOString(), + params: {} + }, + { + event_id: 'test-2', + event_type: 'account_created', + createdAt: new Date(startTimestamp + 2000).toISOString(), + params: {} + } + ] + + const result = topupTracker.checkForCompletedTopup(events) + + expect(result).toBe(false) + expect(mockTelemetry.trackApiCreditTopupSucceeded).not.toHaveBeenCalled() + }) + }) + + describe('clearTopupTracking', () => { + it('should remove pending topup from localStorage', () => { + topupTracker.clearTopupTracking() + + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'pending_topup_timestamp' + ) + }) + }) +})