mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 11:11:53 +00:00
feat(telemetry): track API credit top-up success via audit events (#6500)
Summary - Add TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED and provider method trackApiCreditTopupSucceeded - Introduce topupTrackerStore to persist pending top-ups per user (localStorage) and reconcile against recent audit logs - Hook purchase flow to start tracking before opening Stripe checkout - Reconcile after fetching audit events (UsageLogsTable) and after fetchBalance, then emit telemetry, refresh balance, and clear pending - Minor refactor in customerEventsService to return awaited result Implementation details - Matching strategy: - Event type: credit_added - Time window: createdAt between top-up start time and +24h - Amount: if known, e.params.amount must equal expected cents - Cross-tab/user changes: synchronize via storage event and userId watcher Limitations / Follow-up - Reconciliation fetches only page 1 (limit 10) of events; in high-volume cases, a recent credit_added could fall outside the first page - The window and pagination issue will be "resolved by a followup PR to core and cloud" Files touched - src/stores/topupTrackerStore.ts (new) - src/components/dialog/content/setting/UsageLogsTable.vue - src/composables/auth/useFirebaseAuthActions.ts - src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts - src/platform/telemetry/types.ts - src/services/customerEventsService.ts ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6500-feat-telemetry-track-API-credit-top-up-success-via-audit-events-29e6d73d365081169941efae70cf71fe) by [Unito](https://www.unito.io) --------- Co-authored-by: Christian Byrne <chrbyrne96@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,7 @@ import Message from 'primevue/message'
|
|||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import type { AuditLog } from '@/services/customerEventsService'
|
import type { AuditLog } from '@/services/customerEventsService'
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
@@ -159,6 +160,9 @@ const loadEvents = async () => {
|
|||||||
if (response.totalPages) {
|
if (response.totalPages) {
|
||||||
pagination.value.totalPages = response.totalPages
|
pagination.value.totalPages = response.totalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a pending top-up has completed
|
||||||
|
useTelemetry()?.checkForCompletedTopup(response.events)
|
||||||
} else {
|
} else {
|
||||||
error.value = customerEventService.error.value || 'Failed to load events'
|
error.value = customerEventService.error.value || 'Failed to load events'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||||
|
import { useTelemetry } from '@/platform/telemetry'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { useDialogService } from '@/services/dialogService'
|
import { useDialogService } from '@/services/dialogService'
|
||||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
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')
|
window.open(response.checkout_url, '_blank')
|
||||||
}, reportError)
|
}, reportError)
|
||||||
|
|
||||||
@@ -116,7 +117,9 @@ export const useFirebaseAuthActions = () => {
|
|||||||
}, reportError)
|
}, reportError)
|
||||||
|
|
||||||
const fetchBalance = wrapWithErrorHandlingAsync(async () => {
|
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)
|
}, reportError)
|
||||||
|
|
||||||
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { OverridedMixpanel } from 'mixpanel-browser'
|
import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||||
|
|
||||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
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 { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||||
import { app } from '@/scripts/app'
|
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 {
|
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
|
||||||
const executionContext = this.getExecutionContext()
|
const executionContext = this.getExecutionContext()
|
||||||
|
|
||||||
|
|||||||
61
src/platform/telemetry/topupTracker.ts
Normal file
61
src/platform/telemetry/topupTracker.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -247,10 +247,16 @@ export interface TelemetryProvider {
|
|||||||
trackMonthlySubscriptionSucceeded(): void
|
trackMonthlySubscriptionSucceeded(): void
|
||||||
trackAddApiCreditButtonClicked(): void
|
trackAddApiCreditButtonClicked(): void
|
||||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||||
|
trackApiCreditTopupSucceeded(): void
|
||||||
trackRunButton(options?: { subscribe_to_run?: boolean }): void
|
trackRunButton(options?: { subscribe_to_run?: boolean }): void
|
||||||
trackRunTriggeredViaKeybinding(): void
|
trackRunTriggeredViaKeybinding(): void
|
||||||
trackRunTriggeredViaMenu(): void
|
trackRunTriggeredViaMenu(): void
|
||||||
|
|
||||||
|
// Credit top-up tracking (composition with internal utilities)
|
||||||
|
startTopupTracking(): void
|
||||||
|
checkForCompletedTopup(events: any[] | undefined | null): boolean
|
||||||
|
clearTopupTracking(): void
|
||||||
|
|
||||||
// Survey flow events
|
// Survey flow events
|
||||||
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
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',
|
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||||
'app:api_credit_topup_button_purchase_clicked',
|
'app:api_credit_topup_button_purchase_clicked',
|
||||||
|
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',
|
||||||
|
|
||||||
// Onboarding Survey
|
// Onboarding Survey
|
||||||
USER_SURVEY_OPENED: 'app:user_survey_opened',
|
USER_SURVEY_OPENED: 'app:user_survey_opened',
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export const useCustomerEventsService = () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return executeRequest<CustomerEventsResponse>(
|
const result = await executeRequest<CustomerEventsResponse>(
|
||||||
() =>
|
() =>
|
||||||
customerApiClient.get('/customers/events', {
|
customerApiClient.get('/customers/events', {
|
||||||
params: { page, limit },
|
params: { page, limit },
|
||||||
@@ -187,6 +187,8 @@ export const useCustomerEventsService = () => {
|
|||||||
}),
|
}),
|
||||||
{ errorContext, routeSpecificErrors }
|
{ errorContext, routeSpecificErrors }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
207
tests-ui/tests/platform/telemetry/topupTracker.test.ts
Normal file
207
tests-ui/tests/platform/telemetry/topupTracker.test.ts
Normal file
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user