Skip to content

Commit 0c1ea6c

Browse files
dante01yoonampagent
andcommitted
fix: preserve billing telemetry lifecycle pairing
Amp-Thread-ID: https://ampcode.com/threads/T-019ff637-547e-7498-ac31-b59987908d93 Co-authored-by: Amp <amp@ampcode.com>
1 parent 97c5ce5 commit 0c1ea6c

7 files changed

Lines changed: 155 additions & 78 deletions

File tree

src/platform/telemetry/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,7 @@ type ResubscribeBillingEvent = {
810810
operation: 'resubscribe'
811811
source: ResubscribeClickMetadata['source']
812812
payment_intent_source?: PaymentIntentSource
813+
duration_ms?: number
813814
} & (BillingStarted | BillingSucceeded | BillingFailed)
814815

815816
type TopupBillingEvent = {

src/platform/workspace/composables/useDowngradeToPersonal.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,38 @@ describe('useDowngradeToPersonal', () => {
667667
})
668668

669669
describe('downgradeToPersonal telemetry', () => {
670+
it('keeps one telemetry attempt across an authoritative reactivation retry', async () => {
671+
mockMembers.value = teamWithOwnerAnd('m1')
672+
mockPreviewSubscribe.mockResolvedValue({
673+
allowed: true,
674+
transition_type: 'downgrade',
675+
cost_today_cents: 1500
676+
})
677+
mockSubscribe.mockRejectedValueOnce(
678+
Object.assign(new Error('reactivation confirmation required'), {
679+
code: 'REACTIVATION_CONFIRMATION_REQUIRED'
680+
})
681+
)
682+
const { downgradeToPersonal } = useDowngradeToPersonal()
683+
684+
await expect(downgradeToPersonal('founder-monthly')).rejects.toThrow(
685+
ReactivationConfirmationRequiredError
686+
)
687+
await downgradeToPersonal('founder-monthly', true, 1500)
688+
689+
const lifecycle = mockTrackBillingEvent.mock.calls.map(
690+
([event]) => `${event.operation}.${event.stage}`
691+
)
692+
expect(lifecycle).toEqual([
693+
'downgrade_to_personal.started',
694+
'subscription_checkout.started',
695+
'operation.started',
696+
'downgrade_to_personal.succeeded',
697+
'subscription_checkout.succeeded',
698+
'operation.succeeded'
699+
])
700+
})
701+
670702
it('tracks the start of the downgrade with the pending removal count', async () => {
671703
mockMembers.value = teamWithOwnerAnd('m1', 'm2')
672704
const { downgradeToPersonal } = useDowngradeToPersonal()

src/platform/workspace/composables/useDowngradeToPersonal.ts

Lines changed: 91 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export interface DowngradePreview {
3131
requiresReactivationConfirmation: boolean
3232
}
3333

34+
interface DowngradeTelemetryAttempt {
35+
startedAt: number
36+
memberRemovalCount: number
37+
memberRemovalFailures: number
38+
targetTier?: TierKey
39+
targetCycle?: BillingCycle
40+
checkoutStartedAt?: number
41+
}
42+
3443
/** Thrown by `downgradeToPersonal` when the billing authority requires
3544
* reactivation consent, so the still-open confirmation can collect it and
3645
* retry with `confirmReactivation: true`. */
@@ -66,6 +75,7 @@ export function useDowngradeToPersonal() {
6675
const { userEmail } = useCurrentUser()
6776
const { permissions } = useWorkspaceUI()
6877
const telemetry = useTelemetry()
78+
let activeTelemetryAttempt: DowngradeTelemetryAttempt | undefined
6979

7080
const removableMembers = computed(() => {
7181
const hasFlag = members.value.some((m) => m.isOriginalOwner)
@@ -147,51 +157,54 @@ export function useDowngradeToPersonal() {
147157
ensureCanDowngrade()
148158

149159
const membersToRemove = removableMembers.value
150-
let memberRemovalFailures = 0
151-
let targetTier: TierKey | undefined
152-
let targetCycle: BillingCycle | undefined
160+
const telemetryAttempt = activeTelemetryAttempt ?? {
161+
startedAt: Date.now(),
162+
memberRemovalCount: membersToRemove.length,
163+
memberRemovalFailures: 0
164+
}
153165
let telemetryFailure: BillingFailure | undefined
154-
let checkoutStartedAt: number | undefined
155166

156-
const downgradeStartedAt = Date.now()
157-
telemetry?.trackBillingEvent({
158-
operation: 'downgrade_to_personal',
159-
stage: 'started',
160-
outcome: 'pending',
161-
member_removal_count: membersToRemove.length,
162-
member_removal_failures: 0
163-
})
167+
if (!activeTelemetryAttempt) {
168+
activeTelemetryAttempt = telemetryAttempt
169+
telemetry?.trackBillingEvent({
170+
operation: 'downgrade_to_personal',
171+
stage: 'started',
172+
outcome: 'pending',
173+
member_removal_count: telemetryAttempt.memberRemovalCount,
174+
member_removal_failures: 0
175+
})
176+
}
164177

165178
function trackSucceeded() {
166179
const now = Date.now()
167180
telemetry?.trackBillingEvent({
168181
operation: 'downgrade_to_personal',
169182
stage: 'succeeded',
170183
outcome: 'success',
171-
member_removal_count: membersToRemove.length,
172-
member_removal_failures: memberRemovalFailures,
173-
target_tier: targetTier,
174-
duration_ms: now - downgradeStartedAt
184+
member_removal_count: telemetryAttempt.memberRemovalCount,
185+
member_removal_failures: telemetryAttempt.memberRemovalFailures,
186+
target_tier: telemetryAttempt.targetTier,
187+
duration_ms: now - telemetryAttempt.startedAt
175188
})
176-
if (checkoutStartedAt === undefined) return
189+
if (telemetryAttempt.checkoutStartedAt === undefined) return
177190
telemetry?.trackBillingEvent({
178191
operation: 'subscription_checkout',
179192
stage: 'succeeded',
180193
outcome: 'success',
181-
tier: targetTier,
182-
cycle: targetCycle,
194+
tier: telemetryAttempt.targetTier,
195+
cycle: telemetryAttempt.targetCycle,
183196
checkout_type: 'change',
184-
duration_ms: now - checkoutStartedAt
197+
duration_ms: now - telemetryAttempt.checkoutStartedAt
185198
})
186199
telemetry?.trackBillingEvent({
187200
operation: 'operation',
188201
stage: 'succeeded',
189202
outcome: 'success',
190203
operation_type: 'subscription',
191-
tier: targetTier,
192-
cycle: targetCycle,
204+
tier: telemetryAttempt.targetTier,
205+
cycle: telemetryAttempt.targetCycle,
193206
checkout_type: 'change',
194-
duration_ms: now - checkoutStartedAt
207+
duration_ms: now - telemetryAttempt.checkoutStartedAt
195208
})
196209
}
197210

@@ -207,10 +220,10 @@ export function useDowngradeToPersonal() {
207220
)
208221
}
209222
ensureCanDowngrade()
210-
targetTier = preview.new_plan?.tier
223+
telemetryAttempt.targetTier = preview.new_plan?.tier
211224
? (toTierKey(preview.new_plan.tier) ?? undefined)
212225
: undefined
213-
targetCycle = preview.new_plan
226+
telemetryAttempt.targetCycle = preview.new_plan
214227
? preview.new_plan.duration === 'ANNUAL'
215228
? 'yearly'
216229
: 'monthly'
@@ -244,7 +257,7 @@ export function useDowngradeToPersonal() {
244257
try {
245258
await workspaceStore.removeMember(member.id)
246259
} catch (error) {
247-
memberRemovalFailures += 1
260+
telemetryAttempt.memberRemovalFailures += 1
248261
telemetryFailure = {
249262
failure_category: categorizeBillingApiError(error),
250263
error_code: 'member_removal_failed'
@@ -259,24 +272,26 @@ export function useDowngradeToPersonal() {
259272
}
260273

261274
ensureCanDowngrade()
262-
checkoutStartedAt = Date.now()
263-
telemetry?.trackBillingEvent({
264-
operation: 'subscription_checkout',
265-
stage: 'started',
266-
outcome: 'pending',
267-
tier: targetTier,
268-
cycle: targetCycle,
269-
checkout_type: 'change'
270-
})
271-
telemetry?.trackBillingEvent({
272-
operation: 'operation',
273-
stage: 'started',
274-
outcome: 'pending',
275-
operation_type: 'subscription',
276-
tier: targetTier,
277-
cycle: targetCycle,
278-
checkout_type: 'change'
279-
})
275+
if (telemetryAttempt.checkoutStartedAt === undefined) {
276+
telemetryAttempt.checkoutStartedAt = Date.now()
277+
telemetry?.trackBillingEvent({
278+
operation: 'subscription_checkout',
279+
stage: 'started',
280+
outcome: 'pending',
281+
tier: telemetryAttempt.targetTier,
282+
cycle: telemetryAttempt.targetCycle,
283+
checkout_type: 'change'
284+
})
285+
telemetry?.trackBillingEvent({
286+
operation: 'operation',
287+
stage: 'started',
288+
outcome: 'pending',
289+
operation_type: 'subscription',
290+
tier: telemetryAttempt.targetTier,
291+
cycle: telemetryAttempt.targetCycle,
292+
checkout_type: 'change'
293+
})
294+
}
280295
let response: SubscribeResponse | void
281296
try {
282297
response = await subscribe(planSlug, {
@@ -330,18 +345,19 @@ export function useDowngradeToPersonal() {
330345
response.billing_op_id,
331346
'subscription',
332347
{
333-
tier: targetTier,
334-
cycle: targetCycle,
348+
tier: telemetryAttempt.targetTier,
349+
cycle: telemetryAttempt.targetCycle,
335350
checkoutType: 'change',
336351
downgradeToPersonal: {
337-
memberRemovalCount: membersToRemove.length,
338-
memberRemovalFailures,
339-
targetTier,
340-
startedAt: downgradeStartedAt
352+
memberRemovalCount: telemetryAttempt.memberRemovalCount,
353+
memberRemovalFailures: telemetryAttempt.memberRemovalFailures,
354+
targetTier: telemetryAttempt.targetTier,
355+
startedAt: telemetryAttempt.startedAt
341356
},
342-
attemptStartedAt: checkoutStartedAt
357+
attemptStartedAt: telemetryAttempt.checkoutStartedAt
343358
}
344359
)
360+
activeTelemetryAttempt = undefined
345361
return null
346362
}
347363

@@ -350,24 +366,29 @@ export function useDowngradeToPersonal() {
350366
response.billing_op_id,
351367
'subscription',
352368
{
353-
tier: targetTier,
354-
cycle: targetCycle,
369+
tier: telemetryAttempt.targetTier,
370+
cycle: telemetryAttempt.targetCycle,
355371
checkoutType: 'change',
356372
downgradeToPersonal: {
357-
memberRemovalCount: membersToRemove.length,
358-
memberRemovalFailures,
359-
targetTier,
360-
startedAt: downgradeStartedAt
373+
memberRemovalCount: telemetryAttempt.memberRemovalCount,
374+
memberRemovalFailures: telemetryAttempt.memberRemovalFailures,
375+
targetTier: telemetryAttempt.targetTier,
376+
startedAt: telemetryAttempt.startedAt
361377
},
362-
attemptStartedAt: checkoutStartedAt
378+
attemptStartedAt: telemetryAttempt.checkoutStartedAt
363379
}
364380
)
381+
activeTelemetryAttempt = undefined
365382
return null
366383
}
367384

368385
trackSucceeded()
386+
activeTelemetryAttempt = undefined
369387
return { preview, response }
370388
} catch (error) {
389+
if (error instanceof ReactivationConfirmationRequiredError) {
390+
throw error
391+
}
371392
const failure = telemetryFailure ?? {
372393
failure_category: categorizeBillingApiError(error)
373394
}
@@ -376,35 +397,36 @@ export function useDowngradeToPersonal() {
376397
operation: 'downgrade_to_personal',
377398
stage: 'failed',
378399
outcome: 'failure',
379-
member_removal_count: membersToRemove.length,
380-
member_removal_failures: memberRemovalFailures,
381-
target_tier: targetTier,
400+
member_removal_count: telemetryAttempt.memberRemovalCount,
401+
member_removal_failures: telemetryAttempt.memberRemovalFailures,
402+
target_tier: telemetryAttempt.targetTier,
382403
...failure,
383-
duration_ms: now - downgradeStartedAt
404+
duration_ms: now - telemetryAttempt.startedAt
384405
})
385-
if (checkoutStartedAt !== undefined) {
406+
if (telemetryAttempt.checkoutStartedAt !== undefined) {
386407
telemetry?.trackBillingEvent({
387408
operation: 'subscription_checkout',
388409
stage: 'failed',
389410
outcome: 'failure',
390-
tier: targetTier,
391-
cycle: targetCycle,
411+
tier: telemetryAttempt.targetTier,
412+
cycle: telemetryAttempt.targetCycle,
392413
checkout_type: 'change',
393414
...failure,
394-
duration_ms: now - checkoutStartedAt
415+
duration_ms: now - telemetryAttempt.checkoutStartedAt
395416
})
396417
telemetry?.trackBillingEvent({
397418
operation: 'operation',
398419
stage: 'failed',
399420
outcome: 'failure',
400421
operation_type: 'subscription',
401-
tier: targetTier,
402-
cycle: targetCycle,
422+
tier: telemetryAttempt.targetTier,
423+
cycle: telemetryAttempt.targetCycle,
403424
checkout_type: 'change',
404425
...failure,
405-
duration_ms: now - checkoutStartedAt
426+
duration_ms: now - telemetryAttempt.checkoutStartedAt
406427
})
407428
}
429+
activeTelemetryAttempt = undefined
408430
throw error
409431
}
410432
}

src/platform/workspace/composables/useResubscribe.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ describe('useResubscribe', () => {
9595
outcome: 'pending',
9696
source: 'settings_billing_panel'
9797
})
98+
expect(state.trackBillingEvent).toHaveBeenCalledWith({
99+
operation: 'resubscribe',
100+
stage: 'succeeded',
101+
outcome: 'success',
102+
source: 'settings_billing_panel',
103+
duration_ms: expect.any(Number)
104+
})
98105
})
99106

100107
it('does not report checkout launch as terminal legacy success', async () => {
@@ -143,7 +150,8 @@ describe('useResubscribe', () => {
143150
stage: 'failed',
144151
outcome: 'failure',
145152
source: 'settings_billing_panel',
146-
failure_category: 'unknown'
153+
failure_category: 'unknown',
154+
duration_ms: expect.any(Number)
147155
})
148156
expect(isResubscribing.value).toBe(false)
149157
})

src/platform/workspace/composables/useResubscribe.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export function useResubscribe() {
3030
}
3131

3232
const source = 'settings_billing_panel' as const
33+
const startedAt = Date.now()
34+
const isWorkspaceResubscribe = shouldUseWorkspaceBilling.value
3335

3436
useTelemetry()?.trackResubscribeClicked({ source })
3537
// Emitted before the awaited call so a failure always has a preceding
@@ -49,12 +51,13 @@ export function useResubscribe() {
4951
// tab, which isn't terminal — its `succeeded` is emitted later, from
5052
// useSubscription.ts's pending-checkout recovery, once a status poll
5153
// confirms the payment actually went through.
52-
if (shouldUseWorkspaceBilling.value) {
54+
if (isWorkspaceResubscribe) {
5355
useTelemetry()?.trackBillingEvent({
5456
operation: 'resubscribe',
5557
stage: 'succeeded',
5658
outcome: 'success',
57-
source
59+
source,
60+
duration_ms: Date.now() - startedAt
5861
})
5962
}
6063
toast.add({
@@ -72,7 +75,10 @@ export function useResubscribe() {
7275
stage: 'failed',
7376
outcome: 'failure',
7477
source,
75-
failure_category: categorizeBillingApiError(error)
78+
failure_category: categorizeBillingApiError(error),
79+
...(isWorkspaceResubscribe && {
80+
duration_ms: Date.now() - startedAt
81+
})
7682
})
7783
toast.add({
7884
severity: 'error',

0 commit comments

Comments
 (0)