From 9a3e387d369f6ce4a10bf8c9f0e42f8416336502 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 18:27:42 -0700 Subject: [PATCH 1/4] feat(telemetry): track API credit top-up succeeded via credit_added audit events\n\n- Add TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED and provider method\n- Emit on credit_added events in useCustomerEventsService with in-memory dedupe\n- No local storage, naive based on backend audit log (cherry picked from commit 0a50e43ae467f378f5f1c155679e818ed88b0aac) --- .../cloud/MixpanelTelemetryProvider.ts | 23 +++++++++++++ src/platform/telemetry/types.ts | 27 ++++++++++++++++ src/services/customerEventsService.ts | 32 ++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index c06f9fdcb8..e6798b32d6 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -2,6 +2,8 @@ import type { OverridedMixpanel } from 'mixpanel-browser' import type { AuthMetadata, + CreditTopupMetadata, + CreditTopupSucceededMetadata, ExecutionContext, ExecutionErrorMetadata, ExecutionSuccessMetadata, @@ -282,6 +284,27 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { this.trackEvent(eventName) } + trackAddApiCreditButtonClicked(): void { + this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED) + } + + trackMonthlySubscriptionSucceeded(): void { + this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + const metadata: CreditTopupMetadata = { + credit_amount: amount + } + this.trackEvent( + TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, + metadata + ) + } + + trackApiCreditTopupSucceeded(metadata: CreditTopupSucceededMetadata): void { + this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED, metadata) + } trackRunButton(options?: { subscribe_to_run?: boolean }): void { if (this.isOnboardingMode) { // During onboarding, track basic run button click without workflow context diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index ed5c28ecca..66f21537d5 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -89,6 +89,22 @@ export interface TemplateMetadata { template_license?: string } +/** + * Credit topup metadata + */ +export interface CreditTopupMetadata { + credit_amount: number +} + +/** + * Credit top-up succeeded metadata + */ +export interface CreditTopupSucceededMetadata { + credit_amount: number + payment_method?: string + transaction_id?: string +} + /** * Workflow import metadata */ @@ -169,6 +185,10 @@ export interface TelemetryProvider { // Subscription flow events trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void + trackMonthlySubscriptionSucceeded(): void + trackAddApiCreditButtonClicked(): void + trackApiCreditTopupButtonPurchaseClicked(amount: number): void + trackApiCreditTopupSucceeded(metadata: CreditTopupSucceededMetadata): void trackRunButton(options?: { subscribe_to_run?: boolean }): void // Survey flow events @@ -221,6 +241,11 @@ export const TelemetryEvents = { RUN_BUTTON_CLICKED: 'app:run_button_click', SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened', SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked', + MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded', + 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', @@ -267,6 +292,8 @@ export type TelemetryEventProperties = | RunButtonProperties | ExecutionErrorMetadata | ExecutionSuccessMetadata + | CreditTopupMetadata + | CreditTopupSucceededMetadata | WorkflowImportMetadata | TemplateLibraryMetadata | TemplateLibraryClosedMetadata diff --git a/src/services/customerEventsService.ts b/src/services/customerEventsService.ts index 0359f4c3af..7be2b6bbdd 100644 --- a/src/services/customerEventsService.ts +++ b/src/services/customerEventsService.ts @@ -4,6 +4,7 @@ import { ref } from 'vue' import { useI18n } from 'vue-i18n' import { COMFY_API_BASE_URL } from '@/config/comfyApi' +import { useTelemetry } from '@/platform/telemetry' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components, operations } from '@/types/comfyRegistryTypes' import { isAbortError } from '@/utils/typeGuardUtil' @@ -34,6 +35,8 @@ export const useCustomerEventsService = () => { const isLoading = ref(false) const error = ref(null) const { d } = useI18n() + const telemetry = useTelemetry() + const seenCreditAddedEventIds = new Set() const handleRequestError = ( err: unknown, @@ -179,7 +182,7 @@ export const useCustomerEventsService = () => { return null } - return executeRequest( + const result = await executeRequest( () => customerApiClient.get('/customers/events', { params: { page, limit }, @@ -187,6 +190,33 @@ export const useCustomerEventsService = () => { }), { errorContext, routeSpecificErrors } ) + + if (result?.events?.length) { + for (const evt of result.events) { + if (evt?.event_id && evt.event_type === EventType.CREDIT_ADDED) { + if (!seenCreditAddedEventIds.has(evt.event_id)) { + const amount = Number((evt as any)?.params?.amount) + if (!Number.isNaN(amount) && amount > 0) { + const creditAmountUsd = amount / 100 + const paymentMethod = (evt as any)?.params?.payment_method as + | string + | undefined + const transactionId = (evt as any)?.params?.transaction_id as + | string + | undefined + telemetry?.trackApiCreditTopupSucceeded({ + credit_amount: creditAmountUsd, + payment_method: paymentMethod, + transaction_id: transactionId + }) + } + seenCreditAddedEventIds.add(evt.event_id) + } + } + } + } + + return result } return { From d6ef19eedba000a3005959d8d510427e439c1881 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 21:08:08 -0700 Subject: [PATCH 2/4] cleanup (cherry picked from commit d5750dfe6c2536dac3c1824dcbbc8c6e3fb74582) --- .../cloud/MixpanelTelemetryProvider.ts | 5 ++-- src/platform/telemetry/types.ts | 12 +-------- src/services/customerEventsService.ts | 26 +++---------------- 3 files changed, 6 insertions(+), 37 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index e6798b32d6..0d77d17160 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -3,7 +3,6 @@ import type { OverridedMixpanel } from 'mixpanel-browser' import type { AuthMetadata, CreditTopupMetadata, - CreditTopupSucceededMetadata, ExecutionContext, ExecutionErrorMetadata, ExecutionSuccessMetadata, @@ -302,8 +301,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { ) } - trackApiCreditTopupSucceeded(metadata: CreditTopupSucceededMetadata): void { - this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED, metadata) + trackApiCreditTopupSucceeded(): void { + this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED) } trackRunButton(options?: { subscribe_to_run?: boolean }): void { if (this.isOnboardingMode) { diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 66f21537d5..25e1c3247e 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -96,15 +96,6 @@ export interface CreditTopupMetadata { credit_amount: number } -/** - * Credit top-up succeeded metadata - */ -export interface CreditTopupSucceededMetadata { - credit_amount: number - payment_method?: string - transaction_id?: string -} - /** * Workflow import metadata */ @@ -188,7 +179,7 @@ export interface TelemetryProvider { trackMonthlySubscriptionSucceeded(): void trackAddApiCreditButtonClicked(): void trackApiCreditTopupButtonPurchaseClicked(amount: number): void - trackApiCreditTopupSucceeded(metadata: CreditTopupSucceededMetadata): void + trackApiCreditTopupSucceeded(): void trackRunButton(options?: { subscribe_to_run?: boolean }): void // Survey flow events @@ -293,7 +284,6 @@ export type TelemetryEventProperties = | ExecutionErrorMetadata | ExecutionSuccessMetadata | CreditTopupMetadata - | CreditTopupSucceededMetadata | WorkflowImportMetadata | TemplateLibraryMetadata | TemplateLibraryClosedMetadata diff --git a/src/services/customerEventsService.ts b/src/services/customerEventsService.ts index 7be2b6bbdd..d31c87be44 100644 --- a/src/services/customerEventsService.ts +++ b/src/services/customerEventsService.ts @@ -36,7 +36,6 @@ export const useCustomerEventsService = () => { const error = ref(null) const { d } = useI18n() const telemetry = useTelemetry() - const seenCreditAddedEventIds = new Set() const handleRequestError = ( err: unknown, @@ -191,28 +190,9 @@ export const useCustomerEventsService = () => { { errorContext, routeSpecificErrors } ) - if (result?.events?.length) { - for (const evt of result.events) { - if (evt?.event_id && evt.event_type === EventType.CREDIT_ADDED) { - if (!seenCreditAddedEventIds.has(evt.event_id)) { - const amount = Number((evt as any)?.params?.amount) - if (!Number.isNaN(amount) && amount > 0) { - const creditAmountUsd = amount / 100 - const paymentMethod = (evt as any)?.params?.payment_method as - | string - | undefined - const transactionId = (evt as any)?.params?.transaction_id as - | string - | undefined - telemetry?.trackApiCreditTopupSucceeded({ - credit_amount: creditAmountUsd, - payment_method: paymentMethod, - transaction_id: transactionId - }) - } - seenCreditAddedEventIds.add(evt.event_id) - } - } + for (const event of result?.events ?? []) { + if (event?.event_type === EventType.CREDIT_ADDED) { + telemetry?.trackApiCreditTopupSucceeded() } } From cdc0bb591ae7c7da889d3124e892622ec01ce315 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 21:29:22 -0700 Subject: [PATCH 3/4] lobotomize (cherry picked from commit 37d8a53947fd2b80badb4330ef9c817ea8505cda) --- src/services/customerEventsService.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/services/customerEventsService.ts b/src/services/customerEventsService.ts index d31c87be44..04d0f1d66c 100644 --- a/src/services/customerEventsService.ts +++ b/src/services/customerEventsService.ts @@ -4,7 +4,6 @@ import { ref } from 'vue' import { useI18n } from 'vue-i18n' import { COMFY_API_BASE_URL } from '@/config/comfyApi' -import { useTelemetry } from '@/platform/telemetry' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components, operations } from '@/types/comfyRegistryTypes' import { isAbortError } from '@/utils/typeGuardUtil' @@ -35,7 +34,6 @@ export const useCustomerEventsService = () => { const isLoading = ref(false) const error = ref(null) const { d } = useI18n() - const telemetry = useTelemetry() const handleRequestError = ( err: unknown, @@ -190,12 +188,6 @@ export const useCustomerEventsService = () => { { errorContext, routeSpecificErrors } ) - for (const event of result?.events ?? []) { - if (event?.event_type === EventType.CREDIT_ADDED) { - telemetry?.trackApiCreditTopupSucceeded() - } - } - return result } From 4d9a566a1372086c89df2eb6eefc710621e007c6 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 21:57:59 -0700 Subject: [PATCH 4/4] Implement topupTrackerStore (cherry picked from commit fbf2aeb54f6c82afcdb4717f49bca28d9d869ab5) --- .../dialog/content/setting/UsageLogsTable.vue | 4 + .../auth/useFirebaseAuthActions.ts | 7 +- src/stores/topupTrackerStore.ts | 155 ++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/stores/topupTrackerStore.ts diff --git a/src/components/dialog/content/setting/UsageLogsTable.vue b/src/components/dialog/content/setting/UsageLogsTable.vue index ba70658700..4936bbf8d6 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.vue +++ b/src/components/dialog/content/setting/UsageLogsTable.vue @@ -101,12 +101,14 @@ import { EventType, useCustomerEventsService } from '@/services/customerEventsService' +import { useTopupTrackerStore } from '@/stores/topupTrackerStore' const events = ref([]) const loading = ref(true) const error = ref(null) const customerEventService = useCustomerEventsService() +const topupTracker = useTopupTrackerStore() const pagination = ref({ page: 1, @@ -159,6 +161,8 @@ const loadEvents = async () => { if (response.totalPages) { pagination.value.totalPages = response.totalPages } + + void topupTracker.reconcileWithEvents(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 1e80416332..61307ca983 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -6,6 +6,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { t } from '@/i18n' import { useToastStore } from '@/platform/updates/common/toastStore' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' +import { useTopupTrackerStore } from '@/stores/topupTrackerStore' import { usdToMicros } from '@/utils/formatUtil' /** @@ -98,7 +99,7 @@ export const useFirebaseAuthActions = () => { ) } - // Go to Stripe checkout page + useTopupTrackerStore().startTopup(amount) window.open(response.checkout_url, '_blank') }, reportError) @@ -115,7 +116,9 @@ export const useFirebaseAuthActions = () => { }, reportError) const fetchBalance = wrapWithErrorHandlingAsync(async () => { - return await authStore.fetchBalance() + const result = await authStore.fetchBalance() + void useTopupTrackerStore().reconcileByFetchingEvents() + return result }, reportError) const signInWithGoogle = (errorHandler = reportError) => diff --git a/src/stores/topupTrackerStore.ts b/src/stores/topupTrackerStore.ts new file mode 100644 index 0000000000..3094e9681e --- /dev/null +++ b/src/stores/topupTrackerStore.ts @@ -0,0 +1,155 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' + +import { useTelemetry } from '@/platform/telemetry' +import type { AuditLog } from '@/services/customerEventsService' +import { + EventType, + useCustomerEventsService +} from '@/services/customerEventsService' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' + +type PendingTopupRecord = { + startedAtIso: string + amountUsd?: number + expectedCents?: number +} + +const storageKeyForUser = (userId: string) => `topupTracker:pending:${userId}` + +export const useTopupTrackerStore = defineStore('topupTracker', () => { + const telemetry = useTelemetry() + const authStore = useFirebaseAuthStore() + const pendingTopup = ref(null) + const storageListenerInitialized = ref(false) + + const loadFromStorage = () => { + const userId = authStore.userId + if (!userId) return + try { + const rawValue = localStorage.getItem(storageKeyForUser(userId)) + if (!rawValue) return + const parsedValue = JSON.parse(rawValue) as PendingTopupRecord + pendingTopup.value = parsedValue + } catch { + pendingTopup.value = null + } + } + + const persistToStorage = () => { + const userId = authStore.userId + if (!userId) return + if (pendingTopup.value) { + localStorage.setItem( + storageKeyForUser(userId), + JSON.stringify(pendingTopup.value) + ) + } else { + localStorage.removeItem(storageKeyForUser(userId)) + } + } + + const initializeStorageSynchronization = () => { + if (storageListenerInitialized.value) return + storageListenerInitialized.value = true + loadFromStorage() + window.addEventListener('storage', (e: StorageEvent) => { + const userId = authStore.userId + if (!userId) return + if (e.key === storageKeyForUser(userId)) { + loadFromStorage() + } + }) + + watch( + () => authStore.userId, + (newUserId, oldUserId) => { + if (newUserId && newUserId !== oldUserId) { + loadFromStorage() + return + } + if (!newUserId && oldUserId) { + pendingTopup.value = null + } + } + ) + } + + const startTopup = (amountUsd: number) => { + const userId = authStore.userId + if (!userId) return + const expectedCents = Math.round(amountUsd * 100) + pendingTopup.value = { + startedAtIso: new Date().toISOString(), + amountUsd, + expectedCents + } + persistToStorage() + } + + const clearTopup = () => { + pendingTopup.value = null + persistToStorage() + } + + const reconcileWithEvents = async ( + events: AuditLog[] | undefined | null + ): Promise => { + if (!events || events.length === 0) return false + if (!pendingTopup.value) return false + + const startedAt = new Date(pendingTopup.value.startedAtIso) + if (Number.isNaN(+startedAt)) { + clearTopup() + return false + } + + const withinWindow = (createdAt: string) => { + const created = new Date(createdAt) + if (Number.isNaN(+created)) return false + const maxAgeMs = 1000 * 60 * 60 * 24 + return ( + created >= startedAt && + created.getTime() - startedAt.getTime() <= maxAgeMs + ) + } + + let matched = events.filter((e) => { + if (e.event_type !== EventType.CREDIT_ADDED) return false + if (!e.createdAt || !withinWindow(e.createdAt)) return false + return true + }) + + if (pendingTopup.value.expectedCents != null) { + matched = matched.filter((e) => + typeof e.params?.amount === 'number' + ? e.params.amount === pendingTopup.value?.expectedCents + : true + ) + } + + if (matched.length === 0) return false + + telemetry?.trackApiCreditTopupSucceeded() + await authStore.fetchBalance().catch(() => {}) + clearTopup() + return true + } + + const reconcileByFetchingEvents = async (): Promise => { + const service = useCustomerEventsService() + const response = await service.getMyEvents({ page: 1, limit: 10 }) + if (!response) return false + return await reconcileWithEvents(response.events) + } + + initializeStorageSynchronization() + + return { + pendingTopup, + startTopup, + clearTopup, + reconcileWithEvents, + reconcileByFetchingEvents + } +})