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