Compare commits

...

4 Commits

Author SHA1 Message Date
Benjamin Lu
4d9a566a13 Implement topupTrackerStore
(cherry picked from commit fbf2aeb54f6c82afcdb4717f49bca28d9d869ab5)
2025-10-31 22:10:20 -07:00
Benjamin Lu
cdc0bb591a lobotomize
(cherry picked from commit 37d8a53947fd2b80badb4330ef9c817ea8505cda)
2025-10-31 22:10:20 -07:00
Benjamin Lu
d6ef19eedb cleanup
(cherry picked from commit d5750dfe6c2536dac3c1824dcbbc8c6e3fb74582)
2025-10-31 22:10:20 -07:00
Benjamin Lu
9a3e387d36 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)
2025-10-31 22:10:15 -07:00
6 changed files with 206 additions and 3 deletions

View File

@@ -101,12 +101,14 @@ import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { useTopupTrackerStore } from '@/stores/topupTrackerStore'
const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(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'
}

View File

@@ -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) =>

View File

@@ -2,6 +2,7 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -282,6 +283,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(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
if (this.isOnboardingMode) {
// During onboarding, track basic run button click without workflow context

View File

@@ -89,6 +89,13 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
@@ -169,6 +176,10 @@ export interface TelemetryProvider {
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackAddApiCreditButtonClicked(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackApiCreditTopupSucceeded(): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
@@ -221,6 +232,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 +283,7 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata

View File

@@ -179,7 +179,7 @@ export const useCustomerEventsService = () => {
return null
}
return executeRequest<CustomerEventsResponse>(
const result = await executeRequest<CustomerEventsResponse>(
() =>
customerApiClient.get('/customers/events', {
params: { page, limit },
@@ -187,6 +187,8 @@ export const useCustomerEventsService = () => {
}),
{ errorContext, routeSpecificErrors }
)
return result
}
return {

View File

@@ -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<PendingTopupRecord | null>(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<boolean> => {
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<boolean> => {
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
}
})