mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
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"/>
This commit is contained in:
@@ -267,7 +267,7 @@ import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptio
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
@@ -280,6 +280,19 @@ const getCheckoutTier = (
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
interface BillingCycleOption {
|
||||
label: string
|
||||
value: BillingCycle
|
||||
@@ -415,7 +428,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
|
||||
|
||||
try {
|
||||
if (isActiveSubscription.value) {
|
||||
const checkoutAttribution = getCheckoutAttribution()
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
if (userId.value) {
|
||||
telemetry?.trackBeginCheckout({
|
||||
user_id: userId.value,
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
mockAccessBillingPortal,
|
||||
mockShowSubscriptionRequiredDialog,
|
||||
mockGetAuthHeader,
|
||||
mockGetCheckoutAttribution,
|
||||
mockTelemetry,
|
||||
mockUserId,
|
||||
mockIsCloud
|
||||
@@ -21,6 +22,10 @@ const {
|
||||
mockGetAuthHeader: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
),
|
||||
mockGetCheckoutAttribution: vi.fn(() => ({
|
||||
im_ref: 'impact-click-001',
|
||||
utm_source: 'impact'
|
||||
})),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
@@ -29,6 +34,13 @@ const {
|
||||
}))
|
||||
|
||||
let scope: ReturnType<typeof effectScope> | undefined
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
const setDistribution = (distribution: Distribution) => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
|
||||
).__DISTRIBUTION__ = distribution
|
||||
}
|
||||
|
||||
function useSubscriptionWithScope() {
|
||||
if (!scope) {
|
||||
@@ -84,6 +96,10 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
getCheckoutAttribution: mockGetCheckoutAttribution
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: vi.fn(() => ({
|
||||
showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog
|
||||
@@ -107,11 +123,13 @@ describe('useSubscription', () => {
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
scope?.stop()
|
||||
scope = effectScope()
|
||||
setDistribution('cloud')
|
||||
|
||||
vi.clearAllMocks()
|
||||
mockIsLoggedIn.value = false
|
||||
@@ -284,6 +302,10 @@ describe('useSubscription', () => {
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json'
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
im_ref: 'impact-click-001',
|
||||
utm_source: 'impact'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
@@ -98,6 +99,18 @@ function useSubscriptionInternal() {
|
||||
return `${getComfyApiBaseUrl()}${path}`
|
||||
}
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
const fetchStatus = wrapWithErrorHandlingAsync(
|
||||
fetchSubscriptionStatus,
|
||||
reportError
|
||||
@@ -231,6 +244,7 @@ function useSubscriptionInternal() {
|
||||
t('toastMessages.userNotAuthenticated')
|
||||
)
|
||||
}
|
||||
const checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
|
||||
const response = await fetch(
|
||||
buildApiUrl('/customers/cloud-subscription-checkout'),
|
||||
@@ -239,7 +253,8 @@ function useSubscriptionInternal() {
|
||||
headers: {
|
||||
...authHeader,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(checkoutAttribution)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ const {
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
@@ -54,6 +58,14 @@ vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
|
||||
|
||||
global.fetch = vi.fn()
|
||||
|
||||
type Distribution = 'desktop' | 'localhost' | 'cloud'
|
||||
|
||||
const setDistribution = (distribution: Distribution) => {
|
||||
;(
|
||||
globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution }
|
||||
).__DISTRIBUTION__ = distribution
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((res) => {
|
||||
@@ -65,6 +77,7 @@ function createDeferred<T>() {
|
||||
|
||||
describe('performSubscriptionCheckout', () => {
|
||||
beforeEach(() => {
|
||||
setDistribution('cloud')
|
||||
vi.clearAllMocks()
|
||||
mockIsCloud.value = true
|
||||
mockUserId.value = 'user-123'
|
||||
@@ -72,6 +85,7 @@ describe('performSubscriptionCheckout', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setDistribution('localhost')
|
||||
})
|
||||
|
||||
it('tracks begin_checkout with user id and tier metadata', async () => {
|
||||
@@ -93,6 +107,10 @@ describe('performSubscriptionCheckout', () => {
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
@@ -107,6 +125,10 @@ describe('performSubscriptionCheckout', () => {
|
||||
ga_client_id: 'ga-client-id',
|
||||
ga_session_id: 'ga-session-id',
|
||||
ga_session_number: 'ga-session-number',
|
||||
im_ref: 'impact-click-123',
|
||||
utm_source: 'impact',
|
||||
utm_medium: 'affiliate',
|
||||
utm_campaign: 'spring-launch',
|
||||
gclid: 'gclid-123',
|
||||
gbraid: 'gbraid-456',
|
||||
wbraid: 'wbraid-789'
|
||||
@@ -116,6 +138,41 @@ describe('performSubscriptionCheckout', () => {
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('continues checkout when attribution collection fails', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
mockGetCheckoutAttribution.mockRejectedValueOnce(
|
||||
new Error('Attribution failed')
|
||||
)
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ checkout_url: checkoutUrl })
|
||||
} as Response)
|
||||
|
||||
await performSubscriptionCheckout('pro', 'monthly', true)
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/customers/cloud-subscription-checkout/pro'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
)
|
||||
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
|
||||
user_id: 'user-123',
|
||||
tier: 'pro',
|
||||
cycle: 'monthly',
|
||||
checkout_type: 'new'
|
||||
})
|
||||
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
|
||||
})
|
||||
|
||||
it('uses the latest userId when it changes after checkout starts', async () => {
|
||||
const checkoutUrl = 'https://checkout.stripe.com/test'
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
@@ -4,11 +4,11 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
} from '@/stores/firebaseAuthStore'
|
||||
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from './subscriptionTierRank'
|
||||
|
||||
@@ -19,6 +19,18 @@ const getCheckoutTier = (
|
||||
billingCycle: BillingCycle
|
||||
): CheckoutTier => (billingCycle === 'yearly' ? `${tierKey}-yearly` : tierKey)
|
||||
|
||||
const getCheckoutAttributionForCloud =
|
||||
async (): Promise<CheckoutAttributionMetadata> => {
|
||||
if (__DISTRIBUTION__ !== 'cloud') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const { getCheckoutAttribution } =
|
||||
await import('@/platform/telemetry/utils/checkoutAttribution')
|
||||
|
||||
return getCheckoutAttribution()
|
||||
}
|
||||
|
||||
/**
|
||||
* Core subscription checkout logic shared between PricingTable and
|
||||
* SubscriptionRedirectView. Handles:
|
||||
@@ -49,7 +61,15 @@ export async function performSubscriptionCheckout(
|
||||
}
|
||||
|
||||
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
|
||||
const checkoutAttribution = getCheckoutAttribution()
|
||||
let checkoutAttribution: CheckoutAttributionMetadata = {}
|
||||
try {
|
||||
checkoutAttribution = await getCheckoutAttributionForCloud()
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[SubscriptionCheckout] Failed to collect checkout attribution',
|
||||
error
|
||||
)
|
||||
}
|
||||
const checkoutPayload = { ...checkoutAttribution }
|
||||
|
||||
const response = await fetch(
|
||||
|
||||
Reference in New Issue
Block a user