Skip to content

Commit 13f1401

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 108425a commit 13f1401

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,11 +220,11 @@ export function useDowngradeToPersonal() {
207220
)
208221
}
209222
ensureCanDowngrade()
210-
targetTier =
223+
telemetryAttempt.targetTier =
211224
preview.new_plan?.tier && preview.new_plan.tier !== 'TEAM'
212225
? TIER_TO_KEY[preview.new_plan.tier]
213226
: undefined
214-
targetCycle = preview.new_plan
227+
telemetryAttempt.targetCycle = preview.new_plan
215228
? preview.new_plan.duration === 'ANNUAL'
216229
? 'yearly'
217230
: 'monthly'
@@ -245,7 +258,7 @@ export function useDowngradeToPersonal() {
245258
try {
246259
await workspaceStore.removeMember(member.id)
247260
} catch (error) {
248-
memberRemovalFailures += 1
261+
telemetryAttempt.memberRemovalFailures += 1
249262
telemetryFailure = {
250263
failure_category: categorizeBillingApiError(error),
251264
error_code: 'member_removal_failed'
@@ -260,24 +273,26 @@ export function useDowngradeToPersonal() {
260273
}
261274

262275
ensureCanDowngrade()
263-
checkoutStartedAt = Date.now()
264-
telemetry?.trackBillingEvent({
265-
operation: 'subscription_checkout',
266-
stage: 'started',
267-
outcome: 'pending',
268-
tier: targetTier,
269-
cycle: targetCycle,
270-
checkout_type: 'change'
271-
})
272-
telemetry?.trackBillingEvent({
273-
operation: 'operation',
274-
stage: 'started',
275-
outcome: 'pending',
276-
operation_type: 'subscription',
277-
tier: targetTier,
278-
cycle: targetCycle,
279-
checkout_type: 'change'
280-
})
276+
if (telemetryAttempt.checkoutStartedAt === undefined) {
277+
telemetryAttempt.checkoutStartedAt = Date.now()
278+
telemetry?.trackBillingEvent({
279+
operation: 'subscription_checkout',
280+
stage: 'started',
281+
outcome: 'pending',
282+
tier: telemetryAttempt.targetTier,
283+
cycle: telemetryAttempt.targetCycle,
284+
checkout_type: 'change'
285+
})
286+
telemetry?.trackBillingEvent({
287+
operation: 'operation',
288+
stage: 'started',
289+
outcome: 'pending',
290+
operation_type: 'subscription',
291+
tier: telemetryAttempt.targetTier,
292+
cycle: telemetryAttempt.targetCycle,
293+
checkout_type: 'change'
294+
})
295+
}
281296
let response: SubscribeResponse | void
282297
try {
283298
response = await subscribe(planSlug, {
@@ -331,18 +346,19 @@ export function useDowngradeToPersonal() {
331346
response.billing_op_id,
332347
'subscription',
333348
{
334-
tier: targetTier,
335-
cycle: targetCycle,
349+
tier: telemetryAttempt.targetTier,
350+
cycle: telemetryAttempt.targetCycle,
336351
checkoutType: 'change',
337352
downgradeToPersonal: {
338-
memberRemovalCount: membersToRemove.length,
339-
memberRemovalFailures,
340-
targetTier,
341-
startedAt: downgradeStartedAt
353+
memberRemovalCount: telemetryAttempt.memberRemovalCount,
354+
memberRemovalFailures: telemetryAttempt.memberRemovalFailures,
355+
targetTier: telemetryAttempt.targetTier,
356+
startedAt: telemetryAttempt.startedAt
342357
},
343-
attemptStartedAt: checkoutStartedAt
358+
attemptStartedAt: telemetryAttempt.checkoutStartedAt
344359
}
345360
)
361+
activeTelemetryAttempt = undefined
346362
return null
347363
}
348364

@@ -351,24 +367,29 @@ export function useDowngradeToPersonal() {
351367
response.billing_op_id,
352368
'subscription',
353369
{
354-
tier: targetTier,
355-
cycle: targetCycle,
370+
tier: telemetryAttempt.targetTier,
371+
cycle: telemetryAttempt.targetCycle,
356372
checkoutType: 'change',
357373
downgradeToPersonal: {
358-
memberRemovalCount: membersToRemove.length,
359-
memberRemovalFailures,
360-
targetTier,
361-
startedAt: downgradeStartedAt
374+
memberRemovalCount: telemetryAttempt.memberRemovalCount,
375+
memberRemovalFailures: telemetryAttempt.memberRemovalFailures,
376+
targetTier: telemetryAttempt.targetTier,
377+
startedAt: telemetryAttempt.startedAt
362378
},
363-
attemptStartedAt: checkoutStartedAt
379+
attemptStartedAt: telemetryAttempt.checkoutStartedAt
364380
}
365381
)
382+
activeTelemetryAttempt = undefined
366383
return null
367384
}
368385

369386
trackSucceeded()
387+
activeTelemetryAttempt = undefined
370388
return { preview, response }
371389
} catch (error) {
390+
if (error instanceof ReactivationConfirmationRequiredError) {
391+
throw error
392+
}
372393
const failure = telemetryFailure ?? {
373394
failure_category: categorizeBillingApiError(error)
374395
}
@@ -377,35 +398,36 @@ export function useDowngradeToPersonal() {
377398
operation: 'downgrade_to_personal',
378399
stage: 'failed',
379400
outcome: 'failure',
380-
member_removal_count: membersToRemove.length,
381-
member_removal_failures: memberRemovalFailures,
382-
target_tier: targetTier,
401+
member_removal_count: telemetryAttempt.memberRemovalCount,
402+
member_removal_failures: telemetryAttempt.memberRemovalFailures,
403+
target_tier: telemetryAttempt.targetTier,
383404
...failure,
384-
duration_ms: now - downgradeStartedAt
405+
duration_ms: now - telemetryAttempt.startedAt
385406
})
386-
if (checkoutStartedAt !== undefined) {
407+
if (telemetryAttempt.checkoutStartedAt !== undefined) {
387408
telemetry?.trackBillingEvent({
388409
operation: 'subscription_checkout',
389410
stage: 'failed',
390411
outcome: 'failure',
391-
tier: targetTier,
392-
cycle: targetCycle,
412+
tier: telemetryAttempt.targetTier,
413+
cycle: telemetryAttempt.targetCycle,
393414
checkout_type: 'change',
394415
...failure,
395-
duration_ms: now - checkoutStartedAt
416+
duration_ms: now - telemetryAttempt.checkoutStartedAt
396417
})
397418
telemetry?.trackBillingEvent({
398419
operation: 'operation',
399420
stage: 'failed',
400421
outcome: 'failure',
401422
operation_type: 'subscription',
402-
tier: targetTier,
403-
cycle: targetCycle,
423+
tier: telemetryAttempt.targetTier,
424+
cycle: telemetryAttempt.targetCycle,
404425
checkout_type: 'change',
405426
...failure,
406-
duration_ms: now - checkoutStartedAt
427+
duration_ms: now - telemetryAttempt.checkoutStartedAt
407428
})
408429
}
430+
activeTelemetryAttempt = undefined
409431
throw error
410432
}
411433
}

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)