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

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