mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
add telemetry event for subscription cancellation (#6684)
emits event after going to dashboard and returning to page and having subscription status change from subscribed to not subscribed. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6684-add-telemetry-event-for-subscription-cancellation-2aa6d73d365081009770de6d1db2b701) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -14,12 +14,13 @@ import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
|
||||
|
||||
type CloudSubscriptionCheckoutResponse = {
|
||||
checkout_url: string
|
||||
}
|
||||
|
||||
type CloudSubscriptionStatusResponse = {
|
||||
export type CloudSubscriptionStatusResponse = {
|
||||
is_active: boolean
|
||||
subscription_id: string
|
||||
renewal_date: string | null
|
||||
@@ -28,6 +29,7 @@ type CloudSubscriptionStatusResponse = {
|
||||
|
||||
function useSubscriptionInternal() {
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(null)
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const isSubscribedOrIsNotCloud = computed(() => {
|
||||
if (!isCloud || !window.__CONFIG__?.subscription_required) return true
|
||||
@@ -103,8 +105,21 @@ function useSubscriptionInternal() {
|
||||
void dialogService.showSubscriptionRequiredDialog()
|
||||
}
|
||||
|
||||
const shouldWatchCancellation = (): boolean =>
|
||||
Boolean(isCloud && window.__CONFIG__?.subscription_required)
|
||||
|
||||
const { startCancellationWatcher, stopCancellationWatcher } =
|
||||
useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription: isSubscribedOrIsNotCloud,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
const manageSubscription = async () => {
|
||||
await accessBillingPortal()
|
||||
startCancellationWatcher()
|
||||
}
|
||||
|
||||
const requireActiveSubscription = async (): Promise<void> => {
|
||||
@@ -168,6 +183,7 @@ function useSubscriptionInternal() {
|
||||
await fetchSubscriptionStatus()
|
||||
} else {
|
||||
subscriptionStatus.value = null
|
||||
stopCancellationWatcher()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from './useSubscription'
|
||||
|
||||
const MAX_CANCELLATION_ATTEMPTS = 4
|
||||
const CANCELLATION_BASE_DELAY_MS = 5000
|
||||
const CANCELLATION_BACKOFF_MULTIPLIER = 3 // 5s, 15s, 45s, 135s intervals
|
||||
|
||||
type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||
shouldWatchCancellation: () => boolean
|
||||
}
|
||||
|
||||
export function useSubscriptionCancellationWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry,
|
||||
shouldWatchCancellation
|
||||
}: CancellationWatcherOptions) {
|
||||
const watcherActive = ref(false)
|
||||
const cancellationAttempts = ref(0)
|
||||
const cancellationTracked = ref(false)
|
||||
const cancellationCheckInFlight = ref(false)
|
||||
const nextDelay = ref(CANCELLATION_BASE_DELAY_MS)
|
||||
let detachFocusListener: (() => void) | null = null
|
||||
|
||||
const { start: startTimer, stop: stopTimer } = useTimeoutFn(
|
||||
() => {
|
||||
void checkForCancellation()
|
||||
},
|
||||
nextDelay,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const stopCancellationWatcher = () => {
|
||||
watcherActive.value = false
|
||||
stopTimer()
|
||||
cancellationAttempts.value = 0
|
||||
cancellationCheckInFlight.value = false
|
||||
if (detachFocusListener) {
|
||||
detachFocusListener()
|
||||
detachFocusListener = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleNextCancellationCheck = () => {
|
||||
if (!watcherActive.value) return
|
||||
|
||||
if (cancellationAttempts.value >= MAX_CANCELLATION_ATTEMPTS) {
|
||||
stopCancellationWatcher()
|
||||
return
|
||||
}
|
||||
|
||||
nextDelay.value =
|
||||
CANCELLATION_BASE_DELAY_MS *
|
||||
CANCELLATION_BACKOFF_MULTIPLIER ** cancellationAttempts.value
|
||||
cancellationAttempts.value += 1
|
||||
startTimer()
|
||||
}
|
||||
|
||||
const checkForCancellation = async (triggeredFromFocus = false) => {
|
||||
if (!watcherActive.value || cancellationCheckInFlight.value) return
|
||||
|
||||
cancellationCheckInFlight.value = true
|
||||
try {
|
||||
await fetchStatus()
|
||||
|
||||
if (!isActiveSubscription.value) {
|
||||
if (!cancellationTracked.value) {
|
||||
cancellationTracked.value = true
|
||||
try {
|
||||
telemetry?.trackMonthlySubscriptionCancelled()
|
||||
} catch (telemetryError) {
|
||||
console.error(
|
||||
'[Subscription] Failed to track cancellation telemetry:',
|
||||
telemetryError
|
||||
)
|
||||
}
|
||||
}
|
||||
stopCancellationWatcher()
|
||||
return
|
||||
}
|
||||
|
||||
if (!triggeredFromFocus) {
|
||||
scheduleNextCancellationCheck()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Subscription] Error checking cancellation status:', error)
|
||||
scheduleNextCancellationCheck()
|
||||
} finally {
|
||||
cancellationCheckInFlight.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const startCancellationWatcher = () => {
|
||||
if (!shouldWatchCancellation() || !subscriptionStatus.value?.is_active) {
|
||||
return
|
||||
}
|
||||
|
||||
stopCancellationWatcher()
|
||||
watcherActive.value = true
|
||||
cancellationTracked.value = false
|
||||
cancellationAttempts.value = 0
|
||||
if (!detachFocusListener && defaultWindow) {
|
||||
detachFocusListener = useEventListener(defaultWindow, 'focus', () => {
|
||||
if (!watcherActive.value) return
|
||||
void checkForCancellation(true)
|
||||
})
|
||||
}
|
||||
scheduleNextCancellationCheck()
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopCancellationWatcher()
|
||||
})
|
||||
|
||||
return {
|
||||
startCancellationWatcher,
|
||||
stopCancellationWatcher
|
||||
}
|
||||
}
|
||||
@@ -175,6 +175,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Track when a user completes a subscription cancellation flow.
|
||||
* Fired after we detect the backend reports `is_active: false` and the UI stops polling.
|
||||
*/
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
const metadata: CreditTopupMetadata = {
|
||||
credit_amount: amount
|
||||
|
||||
@@ -265,6 +265,7 @@ export interface TelemetryProvider {
|
||||
// Subscription flow events
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackMonthlySubscriptionSucceeded(): void
|
||||
trackMonthlySubscriptionCancelled(): void
|
||||
trackAddApiCreditButtonClicked(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||
trackApiCreditTopupSucceeded(): void
|
||||
@@ -344,6 +345,7 @@ export const TelemetryEvents = {
|
||||
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',
|
||||
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
|
||||
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
|
||||
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
|
||||
'app:api_credit_topup_button_purchase_clicked',
|
||||
|
||||
@@ -11,6 +11,10 @@ const mockShowSubscriptionRequiredDialog = vi.fn()
|
||||
const mockGetAuthHeader = vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
)
|
||||
const mockTelemetry = {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
}
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
@@ -20,7 +24,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => null)
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
|
||||
@@ -72,6 +76,11 @@ describe('useSubscription', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
@@ -321,5 +330,94 @@ describe('useSubscription', () => {
|
||||
|
||||
expect(mockAccessBillingPortal).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks cancellation after manage subscription when status flips', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
const activeResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_active',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
}
|
||||
|
||||
const cancelledResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles rapid focus events during cancellation polling', async () => {
|
||||
vi.useFakeTimers()
|
||||
mockIsLoggedIn.value = true
|
||||
|
||||
const activeResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: true,
|
||||
subscription_id: 'sub_active',
|
||||
renewal_date: '2025-11-16'
|
||||
})
|
||||
}
|
||||
|
||||
const cancelledResponse = {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
})
|
||||
}
|
||||
|
||||
vi.mocked(global.fetch)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(activeResponse as Response)
|
||||
.mockResolvedValueOnce(cancelledResponse as Response)
|
||||
|
||||
try {
|
||||
const { fetchStatus, manageSubscription } = useSubscription()
|
||||
|
||||
await fetchStatus()
|
||||
await manageSubscription()
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, effectScope, ref } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
|
||||
|
||||
describe('useSubscriptionCancellationWatcher', () => {
|
||||
const trackMonthlySubscriptionCancelled = vi.fn()
|
||||
const telemetryMock: Pick<
|
||||
import('@/platform/telemetry/types').TelemetryProvider,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> = {
|
||||
trackMonthlySubscriptionCancelled
|
||||
}
|
||||
|
||||
const baseStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_123',
|
||||
renewal_date: '2025-11-16'
|
||||
}
|
||||
|
||||
const subscriptionStatus = ref<CloudSubscriptionStatusResponse | null>(
|
||||
baseStatus
|
||||
)
|
||||
const isActive = ref(true)
|
||||
const isActiveSubscription = computed(() => isActive.value)
|
||||
|
||||
let shouldWatch = true
|
||||
const shouldWatchCancellation = () => shouldWatch
|
||||
|
||||
const activeScopes: EffectScope[] = []
|
||||
|
||||
const initWatcher = (
|
||||
options: Parameters<typeof useSubscriptionCancellationWatcher>[0]
|
||||
): ReturnType<typeof useSubscriptionCancellationWatcher> => {
|
||||
const scope = effectScope()
|
||||
let result: ReturnType<typeof useSubscriptionCancellationWatcher> | null =
|
||||
null
|
||||
scope.run(() => {
|
||||
result = useSubscriptionCancellationWatcher(options)
|
||||
})
|
||||
if (!result) {
|
||||
throw new Error('Failed to initialize cancellation watcher')
|
||||
}
|
||||
activeScopes.push(scope)
|
||||
return result
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
trackMonthlySubscriptionCancelled.mockReset()
|
||||
subscriptionStatus.value = { ...baseStatus }
|
||||
isActive.value = true
|
||||
shouldWatch = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
activeScopes.forEach((scope) => scope.stop())
|
||||
activeScopes.length = 0
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('polls with exponential backoff and fires telemetry once cancellation detected', async () => {
|
||||
const fetchStatus = vi.fn(async () => {
|
||||
if (fetchStatus.mock.calls.length === 2) {
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
is_active: false,
|
||||
subscription_id: 'sub_cancelled',
|
||||
renewal_date: '2025-11-16',
|
||||
end_date: '2025-12-01'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(2)
|
||||
expect(
|
||||
telemetryMock.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('triggers a check immediately when window regains focus', async () => {
|
||||
const fetchStatus = vi.fn(async () => {
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
...baseStatus,
|
||||
is_active: false,
|
||||
end_date: '2025-12-01'
|
||||
}
|
||||
})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
telemetryMock.trackMonthlySubscriptionCancelled
|
||||
).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops after max attempts when subscription stays active', async () => {
|
||||
const fetchStatus = vi.fn(async () => {})
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
startCancellationWatcher()
|
||||
|
||||
const delays = [5000, 15000, 45000, 135000]
|
||||
for (const delay of delays) {
|
||||
await vi.advanceTimersByTimeAsync(delay)
|
||||
}
|
||||
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||
expect(trackMonthlySubscriptionCancelled).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200000)
|
||||
expect(fetchStatus).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('does not start watcher when guard fails or subscription inactive', async () => {
|
||||
const fetchStatus = vi.fn()
|
||||
|
||||
const { startCancellationWatcher } = initWatcher({
|
||||
fetchStatus,
|
||||
isActiveSubscription,
|
||||
subscriptionStatus,
|
||||
telemetry: telemetryMock,
|
||||
shouldWatchCancellation
|
||||
})
|
||||
|
||||
shouldWatch = false
|
||||
startCancellationWatcher()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(fetchStatus).not.toHaveBeenCalled()
|
||||
|
||||
shouldWatch = true
|
||||
isActive.value = false
|
||||
subscriptionStatus.value = {
|
||||
...baseStatus,
|
||||
is_active: false
|
||||
}
|
||||
startCancellationWatcher()
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
expect(fetchStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user