Skip to content

Commit ebc57e7

Browse files
committed
feat: integrate Impact telemetry with checkout attribution for subscriptions (#8688)
Implement Impact telemetry and checkout attribution through cloud subscription checkout flows. This PR adds Impact.com tracking support and carries attribution context from landing-page visits into subscription checkout requests so conversion attribution can be validated end-to-end. - Register a new `ImpactTelemetryProvider` during cloud telemetry initialization. - Initialize the Impact queue/runtime (`ire`) and load the Universal Tracking Tag script once. - Invoke `ire('identify', ...)` on page views with dynamic `customerId` and SHA-1 `customerEmail` (or empty strings when unknown). - Expand checkout attribution capture to include `im_ref`, UTM fields, and Google click IDs, with local persistence across navigation. - Attempt `ire('generateClickId')` with a timeout and fall back to URL/local attribution when unavailable. - Include attribution payloads in checkout creation requests for both: - `/customers/cloud-subscription-checkout` - `/customers/cloud-subscription-checkout/{tier}` - Extend begin-checkout telemetry metadata typing to include attribution fields. - Add focused unit coverage for provider behavior, attribution persistence/fallback logic, and checkout request payloads. Tradeoffs / constraints: - Attribution collection is treated as best-effort in tiered checkout flow to avoid blocking purchases. - Backend checkout handlers must accept and process the additional JSON attribution fields. ## Screenshots <img width="908" height="208" alt="image" src="https://github.com/user-attachments/assets/03c16d60-ffda-40c9-9bd6-8914d841be50"/> <img width="1144" height="460" alt="image" src="https://github.com/user-attachments/assets/74b97fde-ce0a-43e6-838e-9a4aba484488"/> <img width="1432" height="320" alt="image" src="https://github.com/user-attachments/assets/30c22a9f-7bd8-409f-b0ef-e4d02343780a"/> <img width="341" height="135" alt="image" src="https://github.com/user-attachments/assets/f6d918ae-5f80-45e0-855a-601abea61dec"/> (cherry picked from commit da56c9e)
1 parent d44924a commit ebc57e7

File tree

12 files changed

+820
-75
lines changed

12 files changed

+820
-75
lines changed

global.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string
55
declare const __ALGOLIA_API_KEY__: string
66
declare const __USE_PROD_CONFIG__: boolean
77

8+
interface ImpactQueueFunction {
9+
(...args: unknown[]): void
10+
a?: unknown[][]
11+
}
12+
813
interface Window {
914
__CONFIG__: {
1015
gtm_container_id?: string
@@ -37,6 +42,8 @@ interface Window {
3742
session_number?: string
3843
}
3944
dataLayer?: Array<Record<string, unknown>>
45+
ire_o?: string
46+
ire?: ImpactQueueFunction
4047
}
4148

4249
interface Navigator {

src/platform/cloud/subscription/components/PricingTable.vue

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptio
268268
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
269269
import { isCloud } from '@/platform/distribution/types'
270270
import { useTelemetry } from '@/platform/telemetry'
271-
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
271+
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
272272
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
273273
import type { components } from '@/types/comfyRegistryTypes'
274274
@@ -281,6 +281,19 @@ const getCheckoutTier = (
281281
billingCycle: BillingCycle
282282
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
283283
284+
const getCheckoutAttributionForCloud =
285+
async (): Promise<CheckoutAttributionMetadata> => {
286+
// eslint-disable-next-line no-undef
287+
if (__DISTRIBUTION__ !== 'cloud') {
288+
return {}
289+
}
290+
291+
const { getCheckoutAttribution } =
292+
await import('@/platform/telemetry/utils/checkoutAttribution')
293+
294+
return getCheckoutAttribution()
295+
}
296+
284297
interface BillingCycleOption {
285298
label: string
286299
value: BillingCycle
@@ -416,7 +429,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
416429
417430
try {
418431
if (isActiveSubscription.value) {
419-
const checkoutAttribution = getCheckoutAttribution()
432+
const checkoutAttribution = await getCheckoutAttributionForCloud()
420433
if (userId.value) {
421434
telemetry?.trackBeginCheckout({
422435
user_id: userId.value,

src/platform/cloud/subscription/composables/useSubscription.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const {
99
mockAccessBillingPortal,
1010
mockShowSubscriptionRequiredDialog,
1111
mockGetAuthHeader,
12+
mockGetCheckoutAttribution,
1213
mockTelemetry,
1314
mockUserId,
1415
mockIsCloud
@@ -21,6 +22,10 @@ const {
2122
mockGetAuthHeader: vi.fn(() =>
2223
Promise.resolve({ Authorization: 'Bearer test-token' })
2324
),
25+
mockGetCheckoutAttribution: vi.fn(() => ({
26+
im_ref: 'impact-click-001',
27+
utm_source: 'impact'
28+
})),
2429
mockTelemetry: {
2530
trackSubscription: vi.fn(),
2631
trackMonthlySubscriptionCancelled: vi.fn()
@@ -29,6 +34,13 @@ const {
2934
}))
3035

3136
let scope: ReturnType<typeof effectScope> | undefined
37+
type Distribution = 'desktop' | 'localhost' | 'cloud'
38+
39+
const setDistribution = (distribution: Distribution) => {
40+
;(
41+
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
42+
).__DISTRIBUTION__ = distribution
43+
}
3244

3345
function useSubscriptionWithScope() {
3446
if (!scope) {
@@ -84,6 +96,10 @@ vi.mock('@/platform/distribution/types', () => ({
8496
}
8597
}))
8698

99+
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
100+
getCheckoutAttribution: mockGetCheckoutAttribution
101+
}))
102+
87103
vi.mock('@/services/dialogService', () => ({
88104
useDialogService: vi.fn(() => ({
89105
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
@@ -107,11 +123,13 @@ describe('useSubscription', () => {
107123
afterEach(() => {
108124
scope?.stop()
109125
scope = undefined
126+
setDistribution('localhost')
110127
})
111128

112129
beforeEach(() => {
113130
scope?.stop()
114131
scope = effectScope()
132+
setDistribution('cloud')
115133

116134
vi.clearAllMocks()
117135
mockIsLoggedIn.value = false
@@ -284,6 +302,10 @@ describe('useSubscription', () => {
284302
headers: expect.objectContaining({
285303
Authorization: 'Bearer test-token',
286304
'Content-Type': 'application/json'
305+
}),
306+
body: JSON.stringify({
307+
im_ref: 'impact-click-001',
308+
utm_source: 'impact'
287309
})
288310
})
289311
)

src/platform/cloud/subscription/composables/useSubscription.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
88
import { t } from '@/i18n'
99
import { isCloud } from '@/platform/distribution/types'
1010
import { useTelemetry } from '@/platform/telemetry'
11+
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
1112
import {
1213
FirebaseAuthStoreError,
1314
useFirebaseAuthStore
@@ -98,6 +99,18 @@ function useSubscriptionInternal() {
9899
return `${getComfyApiBaseUrl()}${path}`
99100
}
100101

102+
const getCheckoutAttributionForCloud =
103+
async (): Promise<CheckoutAttributionMetadata> => {
104+
if (__DISTRIBUTION__ !== 'cloud') {
105+
return {}
106+
}
107+
108+
const { getCheckoutAttribution } =
109+
await import('@/platform/telemetry/utils/checkoutAttribution')
110+
111+
return getCheckoutAttribution()
112+
}
113+
101114
const fetchStatus = wrapWithErrorHandlingAsync(
102115
fetchSubscriptionStatus,
103116
reportError
@@ -231,6 +244,7 @@ function useSubscriptionInternal() {
231244
t('toastMessages.userNotAuthenticated')
232245
)
233246
}
247+
const checkoutAttribution = await getCheckoutAttributionForCloud()
234248

235249
const response = await fetch(
236250
buildApiUrl('/customers/cloud-subscription-checkout'),
@@ -239,7 +253,8 @@ function useSubscriptionInternal() {
239253
headers: {
240254
...authHeader,
241255
'Content-Type': 'application/json'
242-
}
256+
},
257+
body: JSON.stringify(checkoutAttribution)
243258
}
244259
)
245260

src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const {
2222
ga_client_id: 'ga-client-id',
2323
ga_session_id: 'ga-session-id',
2424
ga_session_number: 'ga-session-number',
25+
im_ref: 'impact-click-123',
26+
utm_source: 'impact',
27+
utm_medium: 'affiliate',
28+
utm_campaign: 'spring-launch',
2529
gclid: 'gclid-123',
2630
gbraid: 'gbraid-456',
2731
wbraid: 'wbraid-789'
@@ -54,6 +58,14 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
5458

5559
global.fetch = vi.fn()
5660

61+
type Distribution = 'desktop' | 'localhost' | 'cloud'
62+
63+
const setDistribution = (distribution: Distribution) => {
64+
;(
65+
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
66+
).__DISTRIBUTION__ = distribution
67+
}
68+
5769
function createDeferred<T>() {
5870
let resolve: (value: T) => void = () => {}
5971
const promise = new Promise<T>((res) => {
@@ -65,13 +77,15 @@ function createDeferred<T>() {
6577

6678
describe('performSubscriptionCheckout', () => {
6779
beforeEach(() => {
80+
setDistribution('cloud')
6881
vi.clearAllMocks()
6982
mockIsCloud.value = true
7083
mockUserId.value = 'user-123'
7184
})
7285

7386
afterEach(() => {
7487
vi.restoreAllMocks()
88+
setDistribution('localhost')
7589
})
7690

7791
it('tracks begin_checkout with user id and tier metadata', async () => {
@@ -93,6 +107,10 @@ describe('performSubscriptionCheckout', () => {
93107
ga_client_id: 'ga-client-id',
94108
ga_session_id: 'ga-session-id',
95109
ga_session_number: 'ga-session-number',
110+
im_ref: 'impact-click-123',
111+
utm_source: 'impact',
112+
utm_medium: 'affiliate',
113+
utm_campaign: 'spring-launch',
96114
gclid: 'gclid-123',
97115
gbraid: 'gbraid-456',
98116
wbraid: 'wbraid-789'
@@ -107,6 +125,10 @@ describe('performSubscriptionCheckout', () => {
107125
ga_client_id: 'ga-client-id',
108126
ga_session_id: 'ga-session-id',
109127
ga_session_number: 'ga-session-number',
128+
im_ref: 'impact-click-123',
129+
utm_source: 'impact',
130+
utm_medium: 'affiliate',
131+
utm_campaign: 'spring-launch',
110132
gclid: 'gclid-123',
111133
gbraid: 'gbraid-456',
112134
wbraid: 'wbraid-789'
@@ -116,6 +138,41 @@ describe('performSubscriptionCheckout', () => {
116138
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
117139
})
118140

141+
it('continues checkout when attribution collection fails', async () => {
142+
const checkoutUrl = 'https://checkout.stripe.com/test'
143+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
144+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
145+
146+
mockGetCheckoutAttribution.mockRejectedValueOnce(
147+
new Error('Attribution failed')
148+
)
149+
vi.mocked(global.fetch).mockResolvedValue({
150+
ok: true,
151+
json: async () => ({ checkout_url: checkoutUrl })
152+
} as Response)
153+
154+
await performSubscriptionCheckout('pro', 'monthly', true)
155+
156+
expect(warnSpy).toHaveBeenCalledWith(
157+
'[SubscriptionCheckout] Failed to collect checkout attribution',
158+
expect.any(Error)
159+
)
160+
expect(global.fetch).toHaveBeenCalledWith(
161+
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
162+
expect.objectContaining({
163+
method: 'POST',
164+
body: JSON.stringify({})
165+
})
166+
)
167+
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
168+
user_id: 'user-123',
169+
tier: 'pro',
170+
cycle: 'monthly',
171+
checkout_type: 'new'
172+
})
173+
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
174+
})
175+
119176
it('uses the latest userId when it changes after checkout starts', async () => {
120177
const checkoutUrl = 'https://checkout.stripe.com/test'
121178
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)

src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
44
import { t } from '@/i18n'
55
import { isCloud } from '@/platform/distribution/types'
66
import { useTelemetry } from '@/platform/telemetry'
7-
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
87
import {
98
FirebaseAuthStoreError,
109
useFirebaseAuthStore
1110
} from '@/stores/firebaseAuthStore'
11+
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
1212
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
1313
import type { BillingCycle } from './subscriptionTierRank'
1414

@@ -19,6 +19,18 @@ const getCheckoutTier = (
1919
billingCycle: BillingCycle
2020
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
2121

22+
const getCheckoutAttributionForCloud =
23+
async (): Promise<CheckoutAttributionMetadata> => {
24+
if (__DISTRIBUTION__ !== 'cloud') {
25+
return {}
26+
}
27+
28+
const { getCheckoutAttribution } =
29+
await import('@/platform/telemetry/utils/checkoutAttribution')
30+
31+
return getCheckoutAttribution()
32+
}
33+
2234
/**
2335
* Core subscription checkout logic shared between PricingTable and
2436
* SubscriptionRedirectView. Handles:
@@ -49,7 +61,15 @@ export async function performSubscriptionCheckout(
4961
}
5062

5163
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
52-
const checkoutAttribution = getCheckoutAttribution()
64+
let checkoutAttribution: CheckoutAttributionMetadata = {}
65+
try {
66+
checkoutAttribution = await getCheckoutAttributionForCloud()
67+
} catch (error) {
68+
console.warn(
69+
'[SubscriptionCheckout] Failed to collect checkout attribution',
70+
error
71+
)
72+
}
5373
const checkoutPayload = { ...checkoutAttribution }
5474

5575
const response = await fetch(

src/platform/telemetry/initTelemetry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,19 @@ export async function initTelemetry(): Promise<void> {
2323
const [
2424
{ TelemetryRegistry },
2525
{ MixpanelTelemetryProvider },
26-
{ GtmTelemetryProvider }
26+
{ GtmTelemetryProvider },
27+
{ ImpactTelemetryProvider }
2728
] = await Promise.all([
2829
import('./TelemetryRegistry'),
2930
import('./providers/cloud/MixpanelTelemetryProvider'),
30-
import('./providers/cloud/GtmTelemetryProvider')
31+
import('./providers/cloud/GtmTelemetryProvider'),
32+
import('./providers/cloud/ImpactTelemetryProvider')
3133
])
3234

3335
const registry = new TelemetryRegistry()
3436
registry.registerProvider(new MixpanelTelemetryProvider())
3537
registry.registerProvider(new GtmTelemetryProvider())
38+
registry.registerProvider(new ImpactTelemetryProvider())
3639

3740
setTelemetryRegistry(registry)
3841
})()

0 commit comments

Comments
 (0)