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:
Christian Byrne
2025-11-13 17:41:08 -08:00
committed by GitHub
parent ddbd26c062
commit f490b81be5
6 changed files with 432 additions and 2 deletions

View File

@@ -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 }

View File

@@ -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
}
}

View File

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

View File

@@ -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',

View File

@@ -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()
}
})
})
})

View File

@@ -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()
})
})