From 45b41ce5e248cb6d8e982ccd4d9f42bdf0de5f6c Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 5 Feb 2026 20:44:07 -0800 Subject: [PATCH 01/10] feat: add checkout attribution telemetry --- .../composables/useSubscription.test.ts | 15 +++++ .../composables/useSubscription.ts | 5 +- .../utils/subscriptionCheckoutUtil.test.ts | 15 +++++ src/platform/telemetry/initTelemetry.ts | 7 ++- .../CheckoutAttributionTelemetryProvider.ts | 34 ++++++++++ src/platform/telemetry/types.ts | 7 +++ .../__tests__/checkoutAttribution.test.ts | 62 ++++++++++++++++--- .../telemetry/utils/checkoutAttribution.ts | 60 +++++++++++++----- 8 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 6f2a158a648..8a43e779a5d 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -9,6 +9,7 @@ const { mockAccessBillingPortal, mockShowSubscriptionRequiredDialog, mockGetAuthHeader, + mockGetCheckoutAttribution, mockTelemetry, mockUserId, mockIsCloud @@ -21,6 +22,11 @@ const { mockGetAuthHeader: vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ), + mockGetCheckoutAttribution: vi.fn(() => ({ + im_ref: 'impact-click-001', + impact_click_id: 'impact-click-001', + utm_source: 'impact' + })), mockTelemetry: { trackSubscription: vi.fn(), trackMonthlySubscriptionCancelled: vi.fn() @@ -84,6 +90,10 @@ vi.mock('@/platform/distribution/types', () => ({ } })) +vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ + getCheckoutAttribution: mockGetCheckoutAttribution +})) + vi.mock('@/services/dialogService', () => ({ useDialogService: vi.fn(() => ({ showSubscriptionRequiredDialog: mockShowSubscriptionRequiredDialog @@ -284,6 +294,11 @@ describe('useSubscription', () => { headers: expect.objectContaining({ Authorization: 'Bearer test-token', 'Content-Type': 'application/json' + }), + body: JSON.stringify({ + im_ref: 'impact-click-001', + impact_click_id: 'impact-click-001', + utm_source: 'impact' }) }) ) diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 27297709fc8..83d299254ee 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -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 { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution' import { FirebaseAuthStoreError, useFirebaseAuthStore @@ -231,6 +232,7 @@ function useSubscriptionInternal() { t('toastMessages.userNotAuthenticated') ) } + const checkoutAttribution = getCheckoutAttribution() const response = await fetch( buildApiUrl('/customers/cloud-subscription-checkout'), @@ -239,7 +241,8 @@ function useSubscriptionInternal() { headers: { ...authHeader, 'Content-Type': 'application/json' - } + }, + body: JSON.stringify(checkoutAttribution) } ) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 355e230cef1..ee02f90b228 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -21,6 +21,11 @@ const { ga_client_id: 'ga-client-id', ga_session_id: 'ga-session-id', ga_session_number: 'ga-session-number', + im_ref: 'impact-click-123', + impact_click_id: 'impact-click-123', + utm_source: 'impact', + utm_medium: 'affiliate', + utm_campaign: 'spring-launch', gclid: 'gclid-123', gbraid: 'gbraid-456', wbraid: 'wbraid-789' @@ -83,6 +88,11 @@ 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', + impact_click_id: 'impact-click-123', + utm_source: 'impact', + utm_medium: 'affiliate', + utm_campaign: 'spring-launch', gclid: 'gclid-123', gbraid: 'gbraid-456', wbraid: 'wbraid-789' @@ -97,6 +107,11 @@ 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', + impact_click_id: 'impact-click-123', + utm_source: 'impact', + utm_medium: 'affiliate', + utm_campaign: 'spring-launch', gclid: 'gclid-123', gbraid: 'gbraid-456', wbraid: 'wbraid-789' diff --git a/src/platform/telemetry/initTelemetry.ts b/src/platform/telemetry/initTelemetry.ts index e9bec3065b3..94512203af3 100644 --- a/src/platform/telemetry/initTelemetry.ts +++ b/src/platform/telemetry/initTelemetry.ts @@ -23,14 +23,17 @@ export async function initTelemetry(): Promise { const [ { TelemetryRegistry }, { MixpanelTelemetryProvider }, - { GtmTelemetryProvider } + { GtmTelemetryProvider }, + { CheckoutAttributionTelemetryProvider } ] = await Promise.all([ import('./TelemetryRegistry'), import('./providers/cloud/MixpanelTelemetryProvider'), - import('./providers/cloud/GtmTelemetryProvider') + import('./providers/cloud/GtmTelemetryProvider'), + import('./providers/cloud/CheckoutAttributionTelemetryProvider') ]) const registry = new TelemetryRegistry() + registry.registerProvider(new CheckoutAttributionTelemetryProvider()) registry.registerProvider(new MixpanelTelemetryProvider()) registry.registerProvider(new GtmTelemetryProvider()) diff --git a/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts new file mode 100644 index 00000000000..ea3ce80cae0 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts @@ -0,0 +1,34 @@ +import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution' + +import type { PageViewMetadata, TelemetryProvider } from '../../types' + +/** + * Internal cloud telemetry provider used to persist checkout attribution + * from query parameters during page view tracking. + */ +export class CheckoutAttributionTelemetryProvider implements TelemetryProvider { + trackPageView(_pageName: string, properties?: PageViewMetadata): void { + const search = this.extractSearchFromPath(properties?.path) + + if (search) { + captureCheckoutAttributionFromSearch(search) + return + } + + if (typeof window !== 'undefined') { + captureCheckoutAttributionFromSearch(window.location.search) + } + } + + private extractSearchFromPath(path?: string): string { + if (!path || typeof window === 'undefined') return '' + + try { + const url = new URL(path, window.location.origin) + return url.search + } catch { + const queryIndex = path.indexOf('?') + return queryIndex >= 0 ? path.slice(queryIndex) : '' + } + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index ce3260a8e36..b8b520ccd1f 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -290,6 +290,13 @@ export interface BeginCheckoutMetadata extends Record { ga_client_id?: string ga_session_id?: string ga_session_number?: string + im_ref?: string + impact_click_id?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + utm_content?: string gclid?: string gbraid?: string wbraid?: string diff --git a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts index 9b6d6f809f5..5f85fd1b4bb 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getCheckoutAttribution } from '../checkoutAttribution' +import { + captureCheckoutAttributionFromSearch, + getCheckoutAttribution +} from '../checkoutAttribution' const storage = new Map() @@ -30,13 +33,17 @@ describe('getCheckoutAttribution', () => { window.history.pushState({}, '', '/') }) - it('reads GA identity and persists click ids from URL', () => { + it('reads GA identity and persists attribution from URL', () => { window.__ga_identity__ = { client_id: '123.456', session_id: '1700000000', session_number: '2' } - window.history.pushState({}, '', '/?gclid=gclid-123') + window.history.pushState( + {}, + '', + '/?gclid=gclid-123&utm_source=impact&im_ref=impact-123' + ) const attribution = getCheckoutAttribution() @@ -44,22 +51,57 @@ describe('getCheckoutAttribution', () => { ga_client_id: '123.456', ga_session_id: '1700000000', ga_session_number: '2', - gclid: 'gclid-123' + gclid: 'gclid-123', + utm_source: 'impact', + im_ref: 'impact-123', + impact_click_id: 'impact-123' + }) + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) + const firstPersistedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] + expect(JSON.parse(firstPersistedPayload)).toEqual({ + gclid: 'gclid-123', + utm_source: 'impact', + im_ref: 'impact-123' }) - expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'comfy_checkout_attribution', - JSON.stringify({ gclid: 'gclid-123' }) - ) }) - it('uses stored click ids when URL is empty', () => { + it('uses stored attribution when URL is empty', () => { storage.set( 'comfy_checkout_attribution', - JSON.stringify({ gbraid: 'gbraid-1' }) + JSON.stringify({ gbraid: 'gbraid-1', im_ref: 'impact-abc' }) ) const attribution = getCheckoutAttribution() expect(attribution.gbraid).toBe('gbraid-1') + expect(attribution.im_ref).toBe('impact-abc') + expect(attribution.impact_click_id).toBe('impact-abc') + }) + + it('captures attribution from current URL search string', () => { + window.history.pushState({}, '', '/?utm_campaign=launch&im_ref=impact-456') + + captureCheckoutAttributionFromSearch(window.location.search) + + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) + const capturedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] + expect(JSON.parse(capturedPayload)).toEqual({ + utm_campaign: 'launch', + im_ref: 'impact-456' + }) + }) + + it('captures attribution from an explicit search string', () => { + captureCheckoutAttributionFromSearch( + '?utm_source=impact&utm_medium=affiliate&im_ref=impact-789' + ) + + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) + const capturedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] + expect(JSON.parse(capturedPayload)).toEqual({ + utm_source: 'impact', + utm_medium: 'affiliate', + im_ref: 'impact-789' + }) }) }) diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts index 3c22457d1f7..e5cae39d49d 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -4,6 +4,13 @@ interface CheckoutAttribution { ga_client_id?: string ga_session_id?: string ga_session_number?: string + im_ref?: string + impact_click_id?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + utm_content?: string gclid?: string gbraid?: string wbraid?: string @@ -15,20 +22,30 @@ type GaIdentity = { session_number?: string } -const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const -type ClickIdKey = (typeof CLICK_ID_KEYS)[number] +const ATTRIBUTION_QUERY_KEYS = [ + 'im_ref', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'gclid', + 'gbraid', + 'wbraid' +] as const +type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number] const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution' -function readStoredClickIds(): Partial> { +function readStoredAttribution(): Partial> { try { const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY) if (!stored) return {} const parsed: unknown = JSON.parse(stored) if (!isPlainObject(parsed)) return {} - const result: Partial> = {} + const result: Partial> = {} - for (const key of CLICK_ID_KEYS) { + for (const key of ATTRIBUTION_QUERY_KEYS) { const value = parsed[key] if (typeof value === 'string' && value.length > 0) { result[key] = value @@ -41,7 +58,9 @@ function readStoredClickIds(): Partial> { } } -function persistClickIds(payload: Partial>): void { +function persistAttribution( + payload: Partial> +): void { try { localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload)) } catch { @@ -49,14 +68,14 @@ function persistClickIds(payload: Partial>): void { } } -function readClickIdsFromUrl( +function readAttributionFromUrl( search: string -): Partial> { +): Partial> { const params = new URLSearchParams(search) - const result: Partial> = {} + const result: Partial> = {} - for (const key of CLICK_ID_KEYS) { + for (const key of ATTRIBUTION_QUERY_KEYS) { const value = params.get(key) if (value) { result[key] = value @@ -83,24 +102,37 @@ function getGaIdentity(): GaIdentity | undefined { } } +export function captureCheckoutAttributionFromSearch(search: string): void { + const stored = readStoredAttribution() + const fromSearch = readAttributionFromUrl(search) + if (Object.keys(fromSearch).length === 0) return + + persistAttribution({ + ...stored, + ...fromSearch + }) +} + export function getCheckoutAttribution(): CheckoutAttribution { if (typeof window === 'undefined') return {} - const stored = readStoredClickIds() - const fromUrl = readClickIdsFromUrl(window.location.search) - const merged: Partial> = { + const stored = readStoredAttribution() + const fromUrl = readAttributionFromUrl(window.location.search) + const merged: Partial> = { ...stored, ...fromUrl } if (Object.keys(fromUrl).length > 0) { - persistClickIds(merged) + persistAttribution(merged) } const gaIdentity = getGaIdentity() + const impactClickId = merged.im_ref return { ...merged, + impact_click_id: impactClickId, ga_client_id: gaIdentity?.client_id, ga_session_id: gaIdentity?.session_id, ga_session_number: gaIdentity?.session_number From 73c045172387a899e27e45f90a0ab2ea27bf06bf Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 5 Feb 2026 20:44:56 -0800 Subject: [PATCH 02/10] fix: avoid redundant checkout attribution persistence --- src/platform/telemetry/types.ts | 16 +++++--- .../__tests__/checkoutAttribution.test.ts | 28 ++++++++++++++ .../telemetry/utils/checkoutAttribution.ts | 38 +++++++++++-------- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index b8b520ccd1f..eff691ef602 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -281,12 +281,7 @@ export interface PageViewMetadata { [key: string]: unknown } -export interface BeginCheckoutMetadata extends Record { - user_id: string - tier: TierKey - cycle: BillingCycle - checkout_type: 'new' | 'change' - previous_tier?: TierKey +export interface CheckoutAttributionMetadata { ga_client_id?: string ga_session_id?: string ga_session_number?: string @@ -302,6 +297,15 @@ export interface BeginCheckoutMetadata extends Record { wbraid?: string } +export interface BeginCheckoutMetadata + extends Record, CheckoutAttributionMetadata { + user_id: string + tier: TierKey + cycle: BillingCycle + checkout_type: 'new' | 'change' + previous_tier?: TierKey +} + /** * Telemetry provider interface for individual providers. * All methods are optional - providers only implement what they need. diff --git a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts index 5f85fd1b4bb..bc150361502 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -104,4 +104,32 @@ describe('getCheckoutAttribution', () => { im_ref: 'impact-789' }) }) + + it('does not persist when explicit search attribution matches stored values', () => { + storage.set( + 'comfy_checkout_attribution', + JSON.stringify({ utm_source: 'impact', im_ref: 'impact-789' }) + ) + + captureCheckoutAttributionFromSearch('?utm_source=impact&im_ref=impact-789') + + expect(mockLocalStorage.setItem).not.toHaveBeenCalled() + }) + + it('does not persist from URL when query attribution matches stored values', () => { + storage.set( + 'comfy_checkout_attribution', + JSON.stringify({ gclid: 'gclid-123', im_ref: 'impact-abc' }) + ) + window.history.pushState({}, '', '/?gclid=gclid-123&im_ref=impact-abc') + + const attribution = getCheckoutAttribution() + + expect(attribution).toMatchObject({ + gclid: 'gclid-123', + im_ref: 'impact-abc', + impact_click_id: 'impact-abc' + }) + expect(mockLocalStorage.setItem).not.toHaveBeenCalled() + }) }) diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts index e5cae39d49d..74f6377961e 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -1,20 +1,8 @@ import { isPlainObject } from 'es-toolkit' -interface CheckoutAttribution { - ga_client_id?: string - ga_session_id?: string - ga_session_number?: string - im_ref?: string - impact_click_id?: string - utm_source?: string - utm_medium?: string - utm_campaign?: string - utm_term?: string - utm_content?: string - gclid?: string - gbraid?: string - wbraid?: string -} +import type { CheckoutAttributionMetadata } from '../types' + +type CheckoutAttribution = CheckoutAttributionMetadata type GaIdentity = { client_id?: string @@ -68,6 +56,20 @@ function persistAttribution( } } +function hasAttributionChanges( + existing: Partial>, + incoming: Partial> +): boolean { + for (const key of ATTRIBUTION_QUERY_KEYS) { + const value = incoming[key] + if (value !== undefined && existing[key] !== value) { + return true + } + } + + return false +} + function readAttributionFromUrl( search: string ): Partial> { @@ -106,6 +108,7 @@ export function captureCheckoutAttributionFromSearch(search: string): void { const stored = readStoredAttribution() const fromSearch = readAttributionFromUrl(search) if (Object.keys(fromSearch).length === 0) return + if (!hasAttributionChanges(stored, fromSearch)) return persistAttribution({ ...stored, @@ -123,7 +126,10 @@ export function getCheckoutAttribution(): CheckoutAttribution { ...fromUrl } - if (Object.keys(fromUrl).length > 0) { + if ( + Object.keys(fromUrl).length > 0 && + hasAttributionChanges(stored, fromUrl) + ) { persistAttribution(merged) } From 33d25957d023c857060c2192e3ea4dc835b7bab0 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 9 Feb 2026 15:47:12 -0800 Subject: [PATCH 03/10] feat: integrate impact runtime telemetry and click id persistence --- global.d.ts | 7 + .../subscription/components/PricingTable.vue | 2 +- .../composables/useSubscription.ts | 2 +- .../utils/subscriptionCheckoutUtil.ts | 2 +- src/platform/telemetry/initTelemetry.ts | 6 +- .../CheckoutAttributionTelemetryProvider.ts | 34 ---- .../cloud/ImpactTelemetryProvider.test.ts | 132 +++++++++++++++ .../cloud/ImpactTelemetryProvider.ts | 146 ++++++++++++++++ .../__tests__/checkoutAttribution.test.ts | 133 +++++---------- .../telemetry/utils/checkoutAttribution.ts | 158 ++++++++++-------- 10 files changed, 421 insertions(+), 201 deletions(-) delete mode 100644 src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts create mode 100644 src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts create mode 100644 src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts diff --git a/global.d.ts b/global.d.ts index 7e37ab6e6ec..20f4ea82a39 100644 --- a/global.d.ts +++ b/global.d.ts @@ -5,6 +5,11 @@ declare const __ALGOLIA_APP_ID__: string declare const __ALGOLIA_API_KEY__: string declare const __USE_PROD_CONFIG__: boolean +interface ImpactQueueFunction { + (...args: unknown[]): void + a?: unknown[][] +} + interface Window { __CONFIG__: { gtm_container_id?: string @@ -37,6 +42,8 @@ interface Window { session_number?: string } dataLayer?: Array> + ire_o?: string + ire?: ImpactQueueFunction } interface Navigator { diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 0e4df0a81cf..c9648b464a6 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -414,7 +414,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( try { if (isActiveSubscription.value) { - const checkoutAttribution = getCheckoutAttribution() + const checkoutAttribution = await getCheckoutAttribution() if (userId) { telemetry?.trackBeginCheckout({ user_id: userId, diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 83d299254ee..52042785197 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -232,7 +232,7 @@ function useSubscriptionInternal() { t('toastMessages.userNotAuthenticated') ) } - const checkoutAttribution = getCheckoutAttribution() + const checkoutAttribution = await getCheckoutAttribution() const response = await fetch( buildApiUrl('/customers/cloud-subscription-checkout'), diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index b50d827f745..4dbba428ca9 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -46,7 +46,7 @@ export async function performSubscriptionCheckout( } const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle) - const checkoutAttribution = getCheckoutAttribution() + const checkoutAttribution = await getCheckoutAttribution() const checkoutPayload = { ...checkoutAttribution } const response = await fetch( diff --git a/src/platform/telemetry/initTelemetry.ts b/src/platform/telemetry/initTelemetry.ts index 94512203af3..1456078df6d 100644 --- a/src/platform/telemetry/initTelemetry.ts +++ b/src/platform/telemetry/initTelemetry.ts @@ -24,18 +24,18 @@ export async function initTelemetry(): Promise { { TelemetryRegistry }, { MixpanelTelemetryProvider }, { GtmTelemetryProvider }, - { CheckoutAttributionTelemetryProvider } + { ImpactTelemetryProvider } ] = await Promise.all([ import('./TelemetryRegistry'), import('./providers/cloud/MixpanelTelemetryProvider'), import('./providers/cloud/GtmTelemetryProvider'), - import('./providers/cloud/CheckoutAttributionTelemetryProvider') + import('./providers/cloud/ImpactTelemetryProvider') ]) const registry = new TelemetryRegistry() - registry.registerProvider(new CheckoutAttributionTelemetryProvider()) registry.registerProvider(new MixpanelTelemetryProvider()) registry.registerProvider(new GtmTelemetryProvider()) + registry.registerProvider(new ImpactTelemetryProvider()) setTelemetryRegistry(registry) })() diff --git a/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts deleted file mode 100644 index ea3ce80cae0..00000000000 --- a/src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution' - -import type { PageViewMetadata, TelemetryProvider } from '../../types' - -/** - * Internal cloud telemetry provider used to persist checkout attribution - * from query parameters during page view tracking. - */ -export class CheckoutAttributionTelemetryProvider implements TelemetryProvider { - trackPageView(_pageName: string, properties?: PageViewMetadata): void { - const search = this.extractSearchFromPath(properties?.path) - - if (search) { - captureCheckoutAttributionFromSearch(search) - return - } - - if (typeof window !== 'undefined') { - captureCheckoutAttributionFromSearch(window.location.search) - } - } - - private extractSearchFromPath(path?: string): string { - if (!path || typeof window === 'undefined') return '' - - try { - const url = new URL(path, window.location.origin) - return url.search - } catch { - const queryIndex = path.indexOf('?') - return queryIndex >= 0 ? path.slice(queryIndex) : '' - } - } -} diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts new file mode 100644 index 00000000000..164f1b1e543 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts @@ -0,0 +1,132 @@ +import { ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCaptureCheckoutAttributionFromSearch, mockUseCurrentUser } = + vi.hoisted(() => ({ + mockCaptureCheckoutAttributionFromSearch: vi.fn(), + mockUseCurrentUser: vi.fn() + })) + +vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ + captureCheckoutAttributionFromSearch: mockCaptureCheckoutAttributionFromSearch +})) + +vi.mock('@/composables/auth/useCurrentUser', () => ({ + useCurrentUser: mockUseCurrentUser +})) + +import { ImpactTelemetryProvider } from './ImpactTelemetryProvider' + +const IMPACT_SCRIPT_URL = + 'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js' + +async function flushAsyncWork() { + await Promise.resolve() + await Promise.resolve() +} + +describe('ImpactTelemetryProvider', () => { + beforeEach(() => { + mockCaptureCheckoutAttributionFromSearch.mockReset() + mockUseCurrentUser.mockReset() + vi.restoreAllMocks() + vi.unstubAllGlobals() + + const queueFn: NonNullable = (...args: unknown[]) => { + ;(queueFn.a ??= []).push(args) + } + window.ire = queueFn + window.ire_o = undefined + + vi.spyOn(document, 'querySelector').mockImplementation((selector) => { + if (selector === `script[src="${IMPACT_SCRIPT_URL}"]`) { + return document.createElement('script') + } + + return null + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('captures attribution and invokes identify with hashed email', async () => { + mockUseCurrentUser.mockReturnValue({ + resolvedUserInfo: ref({ id: 'user-123' }), + userEmail: ref('User@Example.com') + }) + vi.stubGlobal('crypto', { + subtle: { + digest: vi.fn(async () => new Uint8Array([0, 1, 2]).buffer) + } + }) + const provider = new ImpactTelemetryProvider() + provider.trackPageView('pricing', { + path: 'https://cloud.comfy.org/pricing?im_ref=impact-123' + }) + + await flushAsyncWork() + + expect(window.ire_o).toBe('ire') + expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith( + '?im_ref=impact-123' + ) + expect(window.ire?.a).toHaveLength(1) + expect(window.ire?.a?.[0]?.[0]).toBe('identify') + expect(window.ire?.a?.[0]?.[1]).toMatchObject({ + customerId: 'user-123' + }) + }) + + it('falls back to current URL search and empty identify values when user is unresolved', async () => { + mockUseCurrentUser.mockImplementation(() => { + throw new Error('No active pinia') + }) + window.history.pushState({}, '', '/?im_ref=fallback-123') + + const provider = new ImpactTelemetryProvider() + provider.trackPageView('home') + + await flushAsyncWork() + + expect(mockCaptureCheckoutAttributionFromSearch).toHaveBeenCalledWith( + '?im_ref=fallback-123' + ) + expect(window.ire?.a).toHaveLength(1) + expect(window.ire?.a?.[0]).toEqual([ + 'identify', + { + customerId: '', + customerEmail: '' + } + ]) + }) + + it('deduplicates repeated identify payloads', async () => { + mockUseCurrentUser.mockReturnValue({ + resolvedUserInfo: ref({ id: 'user-123' }), + userEmail: ref('user@example.com') + }) + vi.stubGlobal('crypto', { + subtle: { + digest: vi.fn(async () => new Uint8Array([16, 32, 48]).buffer) + } + }) + const provider = new ImpactTelemetryProvider() + provider.trackPageView('home', { + path: 'https://cloud.comfy.org/?im_ref=1' + }) + provider.trackPageView('pricing', { + path: 'https://cloud.comfy.org/pricing?im_ref=2' + }) + + await flushAsyncWork() + + expect(window.ire?.a).toHaveLength(1) + expect(window.ire?.a?.[0]?.[0]).toBe('identify') + expect(window.ire?.a?.[0]?.[1]).toMatchObject({ + customerId: 'user-123' + }) + }) +}) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts new file mode 100644 index 00000000000..57daf6b0343 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -0,0 +1,146 @@ +import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution' +import { useCurrentUser } from '@/composables/auth/useCurrentUser' + +import type { PageViewMetadata, TelemetryProvider } from '../../types' + +interface ImpactQueueFunction { + (...args: unknown[]): void + a?: unknown[][] +} + +const IMPACT_SCRIPT_URL = + 'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js' +const IMPACT_QUEUE_NAME = 'ire' +const EMPTY_CUSTOMER_VALUE = '' + +/** + * Impact telemetry provider. + * Initializes the Impact queue globals and loads the runtime script. + */ +export class ImpactTelemetryProvider implements TelemetryProvider { + private initialized = false + private lastIdentifySignature = '' + + constructor() { + this.initialize() + } + + trackPageView(_pageName: string, properties?: PageViewMetadata): void { + const search = this.extractSearchFromPath(properties?.path) + + if (search) { + captureCheckoutAttributionFromSearch(search) + } else if (typeof window !== 'undefined') { + captureCheckoutAttributionFromSearch(window.location.search) + } + + void this.identifyCurrentUser() + } + + private initialize(): void { + if (typeof window === 'undefined' || this.initialized) return + + window.ire_o = IMPACT_QUEUE_NAME + + if (!window.ire) { + const queueFn: ImpactQueueFunction = (...args: unknown[]) => { + ;(queueFn.a ??= []).push(args) + } + window.ire = queueFn + } + + const existingScript = document.querySelector( + `script[src="${IMPACT_SCRIPT_URL}"]` + ) + if (existingScript) { + this.initialized = true + return + } + + const script = document.createElement('script') + script.async = true + script.src = IMPACT_SCRIPT_URL + + const firstScript = document.getElementsByTagName('script')[0] + if (firstScript?.parentNode) { + firstScript.parentNode.insertBefore(script, firstScript) + } else { + document.head.append(script) + } + + this.initialized = true + } + + private extractSearchFromPath(path?: string): string { + if (!path) return '' + + if (typeof window !== 'undefined') { + try { + const url = new URL(path, window.location.origin) + return url.search + } catch { + // Fall through to manual parsing. + } + } + + const queryIndex = path.indexOf('?') + return queryIndex >= 0 ? path.slice(queryIndex) : '' + } + + private async identifyCurrentUser(): Promise { + if (typeof window === 'undefined') return + + const { customerId, customerEmail } = this.resolveCustomerIdentity() + const normalizedEmail = customerEmail.trim().toLowerCase() + const hashedEmail = normalizedEmail + ? await this.hashSha1(normalizedEmail) + : EMPTY_CUSTOMER_VALUE + const identifySignature = `${customerId}|${hashedEmail}` + + if (identifySignature === this.lastIdentifySignature) return + + window.ire?.('identify', { + customerId, + customerEmail: hashedEmail + }) + this.lastIdentifySignature = identifySignature + } + + private resolveCustomerIdentity(): { + customerId: string + customerEmail: string + } { + try { + const { resolvedUserInfo, userEmail } = useCurrentUser() + + return { + customerId: resolvedUserInfo.value?.id ?? EMPTY_CUSTOMER_VALUE, + customerEmail: userEmail.value ?? EMPTY_CUSTOMER_VALUE + } + } catch { + return { + customerId: EMPTY_CUSTOMER_VALUE, + customerEmail: EMPTY_CUSTOMER_VALUE + } + } + } + + private async hashSha1(value: string): Promise { + try { + if (!globalThis.crypto?.subtle || typeof TextEncoder === 'undefined') { + return EMPTY_CUSTOMER_VALUE + } + + const digestBuffer = await crypto.subtle.digest( + 'SHA-1', + new TextEncoder().encode(value) + ) + + return Array.from(new Uint8Array(digestBuffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + } catch { + return EMPTY_CUSTOMER_VALUE + } + } +} diff --git a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts index bc150361502..623b4536b53 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -1,39 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - captureCheckoutAttributionFromSearch, - getCheckoutAttribution -} from '../checkoutAttribution' - -const storage = new Map() - -const mockLocalStorage = vi.hoisted(() => ({ - getItem: vi.fn((key: string) => storage.get(key) ?? null), - setItem: vi.fn((key: string, value: string) => { - storage.set(key, value) - }), - removeItem: vi.fn((key: string) => { - storage.delete(key) - }), - clear: vi.fn(() => { - storage.clear() - }) -})) - -Object.defineProperty(window, 'localStorage', { - value: mockLocalStorage, - writable: true -}) +import { getCheckoutAttribution } from '../checkoutAttribution' describe('getCheckoutAttribution', () => { beforeEach(() => { - storage.clear() vi.clearAllMocks() window.__ga_identity__ = undefined + window.ire = undefined window.history.pushState({}, '', '/') }) - it('reads GA identity and persists attribution from URL', () => { + it('reads GA identity and URL attribution, and prefers generated click id', async () => { window.__ga_identity__ = { client_id: '123.456', session_id: '1700000000', @@ -42,10 +19,18 @@ describe('getCheckoutAttribution', () => { window.history.pushState( {}, '', - '/?gclid=gclid-123&utm_source=impact&im_ref=impact-123' + '/?gclid=gclid-123&utm_source=impact&im_ref=url-click-id' ) + const mockIreCall = vi.fn() + window.ire = (...args: unknown[]) => { + mockIreCall(...args) + const callback = args[1] + if (typeof callback === 'function') { + ;(callback as (value: string) => void)('generated-click-id') + } + } - const attribution = getCheckoutAttribution() + const attribution = await getCheckoutAttribution() expect(attribution).toMatchObject({ ga_client_id: '123.456', @@ -53,83 +38,53 @@ describe('getCheckoutAttribution', () => { ga_session_number: '2', gclid: 'gclid-123', utm_source: 'impact', - im_ref: 'impact-123', - impact_click_id: 'impact-123' - }) - expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) - const firstPersistedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] - expect(JSON.parse(firstPersistedPayload)).toEqual({ - gclid: 'gclid-123', - utm_source: 'impact', - im_ref: 'impact-123' + im_ref: 'generated-click-id', + impact_click_id: 'generated-click-id' }) - }) - - it('uses stored attribution when URL is empty', () => { - storage.set( - 'comfy_checkout_attribution', - JSON.stringify({ gbraid: 'gbraid-1', im_ref: 'impact-abc' }) + expect(mockIreCall).toHaveBeenCalledWith( + 'generateClickId', + expect.any(Function) ) - - const attribution = getCheckoutAttribution() - - expect(attribution.gbraid).toBe('gbraid-1') - expect(attribution.im_ref).toBe('impact-abc') - expect(attribution.impact_click_id).toBe('impact-abc') }) - it('captures attribution from current URL search string', () => { - window.history.pushState({}, '', '/?utm_campaign=launch&im_ref=impact-456') + it('falls back to URL click id when generateClickId is unavailable', async () => { + window.history.pushState( + {}, + '', + '/?utm_campaign=launch&im_ref=fallback-from-url' + ) - captureCheckoutAttributionFromSearch(window.location.search) + const attribution = await getCheckoutAttribution() - expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) - const capturedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] - expect(JSON.parse(capturedPayload)).toEqual({ + expect(attribution).toMatchObject({ utm_campaign: 'launch', - im_ref: 'impact-456' + im_ref: 'fallback-from-url', + impact_click_id: 'fallback-from-url' }) }) - it('captures attribution from an explicit search string', () => { - captureCheckoutAttributionFromSearch( - '?utm_source=impact&utm_medium=affiliate&im_ref=impact-789' - ) + it('returns URL attribution only when no click id is available', async () => { + window.history.pushState({}, '', '/?utm_source=impact&utm_medium=affiliate') + + const attribution = await getCheckoutAttribution() - expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1) - const capturedPayload = mockLocalStorage.setItem.mock.calls[0]?.[1] - expect(JSON.parse(capturedPayload)).toEqual({ + expect(attribution).toMatchObject({ utm_source: 'impact', - utm_medium: 'affiliate', - im_ref: 'impact-789' + utm_medium: 'affiliate' }) + expect(attribution.im_ref).toBeUndefined() + expect(attribution.impact_click_id).toBeUndefined() }) - it('does not persist when explicit search attribution matches stored values', () => { - storage.set( - 'comfy_checkout_attribution', - JSON.stringify({ utm_source: 'impact', im_ref: 'impact-789' }) - ) - - captureCheckoutAttributionFromSearch('?utm_source=impact&im_ref=impact-789') - - expect(mockLocalStorage.setItem).not.toHaveBeenCalled() - }) - - it('does not persist from URL when query attribution matches stored values', () => { - storage.set( - 'comfy_checkout_attribution', - JSON.stringify({ gclid: 'gclid-123', im_ref: 'impact-abc' }) - ) - window.history.pushState({}, '', '/?gclid=gclid-123&im_ref=impact-abc') + it('falls back to URL im_ref when generateClickId throws', async () => { + window.history.pushState({}, '', '/?im_ref=url-fallback') + window.ire = () => { + throw new Error('Impact unavailable') + } - const attribution = getCheckoutAttribution() + const attribution = await getCheckoutAttribution() - expect(attribution).toMatchObject({ - gclid: 'gclid-123', - im_ref: 'impact-abc', - impact_click_id: 'impact-abc' - }) - expect(mockLocalStorage.setItem).not.toHaveBeenCalled() + expect(attribution.im_ref).toBe('url-fallback') + expect(attribution.impact_click_id).toBe('url-fallback') }) }) diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts index 74f6377961e..417113a8e8e 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -12,6 +12,7 @@ type GaIdentity = { const ATTRIBUTION_QUERY_KEYS = [ 'im_ref', + 'impact_click_id', 'utm_source', 'utm_medium', 'utm_campaign', @@ -22,53 +23,8 @@ const ATTRIBUTION_QUERY_KEYS = [ 'wbraid' ] as const type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number] -const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution' - -function readStoredAttribution(): Partial> { - try { - const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY) - if (!stored) return {} - - const parsed: unknown = JSON.parse(stored) - if (!isPlainObject(parsed)) return {} - const result: Partial> = {} - - for (const key of ATTRIBUTION_QUERY_KEYS) { - const value = parsed[key] - if (typeof value === 'string' && value.length > 0) { - result[key] = value - } - } - - return result - } catch { - return {} - } -} - -function persistAttribution( - payload: Partial> -): void { - try { - localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload)) - } catch { - return - } -} - -function hasAttributionChanges( - existing: Partial>, - incoming: Partial> -): boolean { - for (const key of ATTRIBUTION_QUERY_KEYS) { - const value = incoming[key] - if (value !== undefined && existing[key] !== value) { - return true - } - } - - return false -} +const GENERATE_CLICK_ID_TIMEOUT_MS = 300 +const IMPACT_CLICK_ID_STORAGE_KEY = 'comfy_impact_click_id' function readAttributionFromUrl( search: string @@ -91,6 +47,32 @@ function asNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } +function getImpactClickId( + attribution: Partial> +): string | undefined { + return attribution.impact_click_id ?? attribution.im_ref +} + +function readStoredImpactClickId(): string | undefined { + if (typeof window === 'undefined') return undefined + + try { + return asNonEmptyString(localStorage.getItem(IMPACT_CLICK_ID_STORAGE_KEY)) + } catch { + return undefined + } +} + +function persistImpactClickId(clickId: string): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem(IMPACT_CLICK_ID_STORAGE_KEY, clickId) + } catch { + return + } +} + function getGaIdentity(): GaIdentity | undefined { if (typeof window === 'undefined') return undefined @@ -104,41 +86,73 @@ function getGaIdentity(): GaIdentity | undefined { } } -export function captureCheckoutAttributionFromSearch(search: string): void { - const stored = readStoredAttribution() - const fromSearch = readAttributionFromUrl(search) - if (Object.keys(fromSearch).length === 0) return - if (!hasAttributionChanges(stored, fromSearch)) return - - persistAttribution({ - ...stored, - ...fromSearch +async function getGeneratedClickId(): Promise { + if (typeof window === 'undefined') { + return undefined + } + + const impactQueue = window.ire + if (typeof impactQueue !== 'function') { + return undefined + } + + return await new Promise((resolve) => { + let settled = false + + const finish = (value: unknown): void => { + if (settled) return + settled = true + resolve(asNonEmptyString(value)) + } + + const timeoutHandle = window.setTimeout(() => { + finish(undefined) + }, GENERATE_CLICK_ID_TIMEOUT_MS) + + try { + impactQueue('generateClickId', (clickId: unknown) => { + window.clearTimeout(timeoutHandle) + finish(clickId) + }) + } catch { + window.clearTimeout(timeoutHandle) + finish(undefined) + } }) } -export function getCheckoutAttribution(): CheckoutAttribution { +export function captureCheckoutAttributionFromSearch(search: string): void { + const fromUrl = readAttributionFromUrl(search) + const clickId = getImpactClickId(fromUrl) + + if (!clickId) return + + persistImpactClickId(clickId) +} + +export async function getCheckoutAttribution(): Promise { if (typeof window === 'undefined') return {} - const stored = readStoredAttribution() const fromUrl = readAttributionFromUrl(window.location.search) - const merged: Partial> = { - ...stored, - ...fromUrl - } - - if ( - Object.keys(fromUrl).length > 0 && - hasAttributionChanges(stored, fromUrl) - ) { - persistAttribution(merged) - } + const generatedClickId = await getGeneratedClickId() + const storedClickId = readStoredImpactClickId() const gaIdentity = getGaIdentity() - const impactClickId = merged.im_ref + const impactClickId = + generatedClickId ?? getImpactClickId(fromUrl) ?? storedClickId + + if (impactClickId && impactClickId !== storedClickId) { + persistImpactClickId(impactClickId) + } return { - ...merged, - impact_click_id: impactClickId, + ...fromUrl, + ...(impactClickId + ? { + im_ref: impactClickId, + impact_click_id: impactClickId + } + : {}), ga_client_id: gaIdentity?.client_id, ga_session_id: gaIdentity?.session_id, ga_session_number: gaIdentity?.session_number From cdc9c5e753f850d06ce1aa3f31c6d7aa212f8489 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 9 Feb 2026 18:53:02 -0800 Subject: [PATCH 04/10] Add impact scripts and refactor --- .../composables/useSubscription.test.ts | 2 - .../utils/subscriptionCheckoutUtil.test.ts | 3 - .../cloud/ImpactTelemetryProvider.test.ts | 25 ++- .../cloud/ImpactTelemetryProvider.ts | 7 +- src/platform/telemetry/types.ts | 1 - .../__tests__/checkoutAttribution.test.ts | 80 ++++++++- .../telemetry/utils/checkoutAttribution.ts | 159 ++++++++++-------- 7 files changed, 185 insertions(+), 92 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 8a43e779a5d..559a51beeb0 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -24,7 +24,6 @@ const { ), mockGetCheckoutAttribution: vi.fn(() => ({ im_ref: 'impact-click-001', - impact_click_id: 'impact-click-001', utm_source: 'impact' })), mockTelemetry: { @@ -297,7 +296,6 @@ describe('useSubscription', () => { }), body: JSON.stringify({ im_ref: 'impact-click-001', - impact_click_id: 'impact-click-001', utm_source: 'impact' }) }) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index ee02f90b228..3f296522c7e 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -22,7 +22,6 @@ const { ga_session_id: 'ga-session-id', ga_session_number: 'ga-session-number', im_ref: 'impact-click-123', - impact_click_id: 'impact-click-123', utm_source: 'impact', utm_medium: 'affiliate', utm_campaign: 'spring-launch', @@ -89,7 +88,6 @@ describe('performSubscriptionCheckout', () => { ga_session_id: 'ga-session-id', ga_session_number: 'ga-session-number', im_ref: 'impact-click-123', - impact_click_id: 'impact-click-123', utm_source: 'impact', utm_medium: 'affiliate', utm_campaign: 'spring-launch', @@ -108,7 +106,6 @@ describe('performSubscriptionCheckout', () => { ga_session_id: 'ga-session-id', ga_session_number: 'ga-session-number', im_ref: 'impact-click-123', - impact_click_id: 'impact-click-123', utm_source: 'impact', utm_medium: 'affiliate', utm_campaign: 'spring-launch', diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts index 164f1b1e543..10dc793c5ec 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -25,6 +26,14 @@ async function flushAsyncWork() { await Promise.resolve() } +function toUint8Array(data: BufferSource): Uint8Array { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data) + } + + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) +} + describe('ImpactTelemetryProvider', () => { beforeEach(() => { mockCaptureCheckoutAttributionFromSearch.mockReset() @@ -54,11 +63,18 @@ describe('ImpactTelemetryProvider', () => { it('captures attribution and invokes identify with hashed email', async () => { mockUseCurrentUser.mockReturnValue({ resolvedUserInfo: ref({ id: 'user-123' }), - userEmail: ref('User@Example.com') + userEmail: ref(' User@Example.com ') }) vi.stubGlobal('crypto', { subtle: { - digest: vi.fn(async () => new Uint8Array([0, 1, 2]).buffer) + digest: vi.fn( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const digest = createHash('sha1') + .update(toUint8Array(data)) + .digest() + return Uint8Array.from(digest).buffer + } + ) } }) const provider = new ImpactTelemetryProvider() @@ -74,8 +90,9 @@ describe('ImpactTelemetryProvider', () => { ) expect(window.ire?.a).toHaveLength(1) expect(window.ire?.a?.[0]?.[0]).toBe('identify') - expect(window.ire?.a?.[0]?.[1]).toMatchObject({ - customerId: 'user-123' + expect(window.ire?.a?.[0]?.[1]).toEqual({ + customerId: 'user-123', + customerEmail: '63a710569261a24b3766275b7000ce8d7b32e2f7' }) }) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts index 57daf6b0343..094a37f7c95 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -3,11 +3,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser' import type { PageViewMetadata, TelemetryProvider } from '../../types' -interface ImpactQueueFunction { - (...args: unknown[]): void - a?: unknown[][] -} - const IMPACT_SCRIPT_URL = 'https://utt.impactcdn.com/A6951770-3747-434a-9ac7-4e582e67d91f1.js' const IMPACT_QUEUE_NAME = 'ire' @@ -43,7 +38,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider { window.ire_o = IMPACT_QUEUE_NAME if (!window.ire) { - const queueFn: ImpactQueueFunction = (...args: unknown[]) => { + const queueFn: NonNullable = (...args: unknown[]) => { ;(queueFn.a ??= []).push(args) } window.ire = queueFn diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index eff691ef602..92a89c9c963 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -286,7 +286,6 @@ export interface CheckoutAttributionMetadata { ga_session_id?: string ga_session_number?: string im_ref?: string - impact_click_id?: string utm_source?: string utm_medium?: string utm_campaign?: string diff --git a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts index 623b4536b53..67ebe8cd1ea 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -1,10 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getCheckoutAttribution } from '../checkoutAttribution' +import { + captureCheckoutAttributionFromSearch, + getCheckoutAttribution +} from '../checkoutAttribution' describe('getCheckoutAttribution', () => { beforeEach(() => { vi.clearAllMocks() + window.localStorage.clear() window.__ga_identity__ = undefined window.ire = undefined window.history.pushState({}, '', '/') @@ -38,8 +42,7 @@ describe('getCheckoutAttribution', () => { ga_session_number: '2', gclid: 'gclid-123', utm_source: 'impact', - im_ref: 'generated-click-id', - impact_click_id: 'generated-click-id' + im_ref: 'generated-click-id' }) expect(mockIreCall).toHaveBeenCalledWith( 'generateClickId', @@ -58,8 +61,7 @@ describe('getCheckoutAttribution', () => { expect(attribution).toMatchObject({ utm_campaign: 'launch', - im_ref: 'fallback-from-url', - impact_click_id: 'fallback-from-url' + im_ref: 'fallback-from-url' }) }) @@ -73,7 +75,6 @@ describe('getCheckoutAttribution', () => { utm_medium: 'affiliate' }) expect(attribution.im_ref).toBeUndefined() - expect(attribution.impact_click_id).toBeUndefined() }) it('falls back to URL im_ref when generateClickId throws', async () => { @@ -85,6 +86,71 @@ describe('getCheckoutAttribution', () => { const attribution = await getCheckoutAttribution() expect(attribution.im_ref).toBe('url-fallback') - expect(attribution.impact_click_id).toBe('url-fallback') + }) + + it('persists click and UTM attribution across navigation', async () => { + window.history.pushState( + {}, + '', + '/?gclid=gclid-123&utm_source=impact&utm_campaign=spring-launch' + ) + + await getCheckoutAttribution() + window.history.pushState({}, '', '/pricing') + + const attribution = await getCheckoutAttribution() + + expect(attribution).toMatchObject({ + gclid: 'gclid-123', + utm_source: 'impact', + utm_campaign: 'spring-launch' + }) + }) + + it('stores attribution from page-view capture for later checkout', async () => { + captureCheckoutAttributionFromSearch( + '?gbraid=gbraid-123&utm_medium=affiliate' + ) + window.history.pushState({}, '', '/pricing') + + const attribution = await getCheckoutAttribution() + + expect(attribution).toMatchObject({ + gbraid: 'gbraid-123', + utm_medium: 'affiliate' + }) + }) + + it('stores click id from page-view capture for later checkout', async () => { + captureCheckoutAttributionFromSearch('?im_ref=impact-123') + window.history.pushState({}, '', '/pricing') + + const attribution = await getCheckoutAttribution() + + expect(attribution).toMatchObject({ + im_ref: 'impact-123' + }) + }) + + it('does not rewrite click id when page-view capture value is unchanged', () => { + window.localStorage.setItem( + 'comfy_checkout_attribution', + JSON.stringify({ + im_ref: 'impact-123' + }) + ) + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem') + + captureCheckoutAttributionFromSearch('?im_ref=impact-123') + + expect(setItemSpy).not.toHaveBeenCalled() + }) + + it('ignores impact_click_id query param', async () => { + window.history.pushState({}, '', '/?impact_click_id=impact-query-id') + + const attribution = await getCheckoutAttribution() + + expect(attribution.im_ref).toBeUndefined() }) }) diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts index 417113a8e8e..156a843abb9 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -1,9 +1,8 @@ import { isPlainObject } from 'es-toolkit' +import { withTimeout } from 'es-toolkit/promise' import type { CheckoutAttributionMetadata } from '../types' -type CheckoutAttribution = CheckoutAttributionMetadata - type GaIdentity = { client_id?: string session_id?: string @@ -12,7 +11,6 @@ type GaIdentity = { const ATTRIBUTION_QUERY_KEYS = [ 'im_ref', - 'impact_click_id', 'utm_source', 'utm_medium', 'utm_campaign', @@ -23,8 +21,45 @@ const ATTRIBUTION_QUERY_KEYS = [ 'wbraid' ] as const type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number] +const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution' const GENERATE_CLICK_ID_TIMEOUT_MS = 300 -const IMPACT_CLICK_ID_STORAGE_KEY = 'comfy_impact_click_id' + +function readStoredAttribution(): Partial> { + if (typeof window === 'undefined') return {} + + try { + const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY) + if (!stored) return {} + + const parsed: unknown = JSON.parse(stored) + if (!isPlainObject(parsed)) return {} + + const result: Partial> = {} + + for (const key of ATTRIBUTION_QUERY_KEYS) { + const value = asNonEmptyString(parsed[key]) + if (value) { + result[key] = value + } + } + + return result + } catch { + return {} + } +} + +function persistAttribution( + payload: Partial> +): void { + if (typeof window === 'undefined') return + + try { + localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload)) + } catch { + return + } +} function readAttributionFromUrl( search: string @@ -43,34 +78,22 @@ function readAttributionFromUrl( return result } -function asNonEmptyString(value: unknown): string | undefined { - return typeof value === 'string' && value.length > 0 ? value : undefined -} - -function getImpactClickId( - attribution: Partial> -): string | undefined { - return attribution.impact_click_id ?? attribution.im_ref -} - -function readStoredImpactClickId(): string | undefined { - if (typeof window === 'undefined') return undefined - - try { - return asNonEmptyString(localStorage.getItem(IMPACT_CLICK_ID_STORAGE_KEY)) - } catch { - return undefined +function hasAttributionChanges( + existing: Partial>, + incoming: Partial> +): boolean { + for (const key of ATTRIBUTION_QUERY_KEYS) { + const value = incoming[key] + if (value !== undefined && existing[key] !== value) { + return true + } } -} -function persistImpactClickId(clickId: string): void { - if (typeof window === 'undefined') return + return false +} - try { - localStorage.setItem(IMPACT_CLICK_ID_STORAGE_KEY, clickId) - } catch { - return - } +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined } function getGaIdentity(): GaIdentity | undefined { @@ -96,63 +119,61 @@ async function getGeneratedClickId(): Promise { return undefined } - return await new Promise((resolve) => { - let settled = false - - const finish = (value: unknown): void => { - if (settled) return - settled = true - resolve(asNonEmptyString(value)) - } - - const timeoutHandle = window.setTimeout(() => { - finish(undefined) - }, GENERATE_CLICK_ID_TIMEOUT_MS) - - try { - impactQueue('generateClickId', (clickId: unknown) => { - window.clearTimeout(timeoutHandle) - finish(clickId) - }) - } catch { - window.clearTimeout(timeoutHandle) - finish(undefined) - } - }) + try { + return await withTimeout( + () => + new Promise((resolve, reject) => { + try { + impactQueue('generateClickId', (clickId: unknown) => { + resolve(asNonEmptyString(clickId)) + }) + } catch (error) { + reject(error) + } + }), + GENERATE_CLICK_ID_TIMEOUT_MS + ) + } catch { + return undefined + } } export function captureCheckoutAttributionFromSearch(search: string): void { const fromUrl = readAttributionFromUrl(search) - const clickId = getImpactClickId(fromUrl) + const storedAttribution = readStoredAttribution() + if (Object.keys(fromUrl).length === 0) return - if (!clickId) return + if (!hasAttributionChanges(storedAttribution, fromUrl)) return - persistImpactClickId(clickId) + persistAttribution({ + ...storedAttribution, + ...fromUrl + }) } -export async function getCheckoutAttribution(): Promise { +export async function getCheckoutAttribution(): Promise { if (typeof window === 'undefined') return {} + const storedAttribution = readStoredAttribution() const fromUrl = readAttributionFromUrl(window.location.search) const generatedClickId = await getGeneratedClickId() - const storedClickId = readStoredImpactClickId() + const attribution: Partial> = { + ...storedAttribution, + ...fromUrl + } - const gaIdentity = getGaIdentity() - const impactClickId = - generatedClickId ?? getImpactClickId(fromUrl) ?? storedClickId + if (generatedClickId) { + attribution.im_ref = generatedClickId + } - if (impactClickId && impactClickId !== storedClickId) { - persistImpactClickId(impactClickId) + if (hasAttributionChanges(storedAttribution, attribution)) { + persistAttribution(attribution) } + const gaIdentity = getGaIdentity() + return { - ...fromUrl, - ...(impactClickId - ? { - im_ref: impactClickId, - impact_click_id: impactClickId - } - : {}), + ...attribution, ga_client_id: gaIdentity?.client_id, ga_session_id: gaIdentity?.session_id, ga_session_number: gaIdentity?.session_number From d1ab86b93c0e9aee3ae397cf0a1b5a8888fff327 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 9 Feb 2026 19:01:55 -0800 Subject: [PATCH 05/10] Identify on each page --- .../providers/cloud/ImpactTelemetryProvider.test.ts | 8 ++++++-- .../telemetry/providers/cloud/ImpactTelemetryProvider.ts | 5 ----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts index 10dc793c5ec..69e71551f88 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts @@ -120,7 +120,7 @@ describe('ImpactTelemetryProvider', () => { ]) }) - it('deduplicates repeated identify payloads', async () => { + it('invokes identify on each page view even with identical identity payloads', async () => { mockUseCurrentUser.mockReturnValue({ resolvedUserInfo: ref({ id: 'user-123' }), userEmail: ref('user@example.com') @@ -140,10 +140,14 @@ describe('ImpactTelemetryProvider', () => { await flushAsyncWork() - expect(window.ire?.a).toHaveLength(1) + expect(window.ire?.a).toHaveLength(2) expect(window.ire?.a?.[0]?.[0]).toBe('identify') expect(window.ire?.a?.[0]?.[1]).toMatchObject({ customerId: 'user-123' }) + expect(window.ire?.a?.[1]?.[0]).toBe('identify') + expect(window.ire?.a?.[1]?.[1]).toMatchObject({ + customerId: 'user-123' + }) }) }) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts index 094a37f7c95..3a650a995c1 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -14,7 +14,6 @@ const EMPTY_CUSTOMER_VALUE = '' */ export class ImpactTelemetryProvider implements TelemetryProvider { private initialized = false - private lastIdentifySignature = '' constructor() { this.initialize() @@ -90,15 +89,11 @@ export class ImpactTelemetryProvider implements TelemetryProvider { const hashedEmail = normalizedEmail ? await this.hashSha1(normalizedEmail) : EMPTY_CUSTOMER_VALUE - const identifySignature = `${customerId}|${hashedEmail}` - - if (identifySignature === this.lastIdentifySignature) return window.ire?.('identify', { customerId, customerEmail: hashedEmail }) - this.lastIdentifySignature = identifySignature } private resolveCustomerIdentity(): { From 2fab0e8d2a1fb57c886cca082491932999293aad Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 03:28:40 -0800 Subject: [PATCH 06/10] Explain why we need SHA1 --- .../telemetry/providers/cloud/ImpactTelemetryProvider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts index 3a650a995c1..c62fe253503 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -86,6 +86,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider { const { customerId, customerEmail } = this.resolveCustomerIdentity() const normalizedEmail = customerEmail.trim().toLowerCase() + // Impact's Identify spec requires customerEmail to be sent as a SHA1 hash. const hashedEmail = normalizedEmail ? await this.hashSha1(normalizedEmail) : EMPTY_CUSTOMER_VALUE From 307aeb5e541cc6990467293ee568d9fa2529233a Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 03:39:54 -0800 Subject: [PATCH 07/10] Try catch telemetry block just for safety --- .../utils/subscriptionCheckoutUtil.test.ts | 35 +++++++++++++++++++ .../utils/subscriptionCheckoutUtil.ts | 11 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 3f296522c7e..76082019bbf 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -117,4 +117,39 @@ 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') + }) }) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 4dbba428ca9..c89fcb7c470 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -46,7 +46,16 @@ export async function performSubscriptionCheckout( } const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle) - const checkoutAttribution = await getCheckoutAttribution() + let checkoutAttribution: Awaited> = + {} + try { + checkoutAttribution = await getCheckoutAttribution() + } catch (error) { + console.warn( + '[SubscriptionCheckout] Failed to collect checkout attribution', + error + ) + } const checkoutPayload = { ...checkoutAttribution } const response = await fetch( From 64d5f6985626cb6a0cab3e542b1a3073b4c54f7e Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 03:47:49 -0800 Subject: [PATCH 08/10] Simplify impact script appending --- .../telemetry/providers/cloud/ImpactTelemetryProvider.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts index c62fe253503..5b231b91793 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -55,12 +55,7 @@ export class ImpactTelemetryProvider implements TelemetryProvider { script.async = true script.src = IMPACT_SCRIPT_URL - const firstScript = document.getElementsByTagName('script')[0] - if (firstScript?.parentNode) { - firstScript.parentNode.insertBefore(script, firstScript) - } else { - document.head.append(script) - } + document.head.insertBefore(script, document.head.firstChild) this.initialized = true } From c6b2434c2570ede62ce0e222bb54ceab500781ca Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 04:23:41 -0800 Subject: [PATCH 09/10] fix: prioritize firebase identity in impact identify --- .../cloud/ImpactTelemetryProvider.test.ts | 143 +++++++++++++++--- .../cloud/ImpactTelemetryProvider.ts | 58 ++++++- 2 files changed, 175 insertions(+), 26 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts index 69e71551f88..37339ccd044 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts @@ -1,19 +1,45 @@ import { createHash } from 'node:crypto' -import { ref } from 'vue' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCaptureCheckoutAttributionFromSearch, mockUseCurrentUser } = - vi.hoisted(() => ({ - mockCaptureCheckoutAttributionFromSearch: vi.fn(), - mockUseCurrentUser: vi.fn() - })) +type MockApiKeyUser = { + id: string + email?: string +} | null + +type MockFirebaseUser = { + uid: string + email?: string | null +} | null + +const { + mockCaptureCheckoutAttributionFromSearch, + mockUseApiKeyAuthStore, + mockUseFirebaseAuthStore, + mockApiKeyAuthStore, + mockFirebaseAuthStore +} = vi.hoisted(() => ({ + mockCaptureCheckoutAttributionFromSearch: vi.fn(), + mockUseApiKeyAuthStore: vi.fn(), + mockUseFirebaseAuthStore: vi.fn(), + mockApiKeyAuthStore: { + isAuthenticated: false, + currentUser: null as MockApiKeyUser + }, + mockFirebaseAuthStore: { + currentUser: null as MockFirebaseUser + } +})) vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ captureCheckoutAttributionFromSearch: mockCaptureCheckoutAttributionFromSearch })) -vi.mock('@/composables/auth/useCurrentUser', () => ({ - useCurrentUser: mockUseCurrentUser +vi.mock('@/stores/apiKeyAuthStore', () => ({ + useApiKeyAuthStore: mockUseApiKeyAuthStore +})) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: mockUseFirebaseAuthStore })) import { ImpactTelemetryProvider } from './ImpactTelemetryProvider' @@ -37,9 +63,15 @@ function toUint8Array(data: BufferSource): Uint8Array { describe('ImpactTelemetryProvider', () => { beforeEach(() => { mockCaptureCheckoutAttributionFromSearch.mockReset() - mockUseCurrentUser.mockReset() + mockUseApiKeyAuthStore.mockReset() + mockUseFirebaseAuthStore.mockReset() + mockApiKeyAuthStore.isAuthenticated = false + mockApiKeyAuthStore.currentUser = null + mockFirebaseAuthStore.currentUser = null vi.restoreAllMocks() vi.unstubAllGlobals() + mockUseApiKeyAuthStore.mockReturnValue(mockApiKeyAuthStore) + mockUseFirebaseAuthStore.mockReturnValue(mockFirebaseAuthStore) const queueFn: NonNullable = (...args: unknown[]) => { ;(queueFn.a ??= []).push(args) @@ -61,10 +93,10 @@ describe('ImpactTelemetryProvider', () => { }) it('captures attribution and invokes identify with hashed email', async () => { - mockUseCurrentUser.mockReturnValue({ - resolvedUserInfo: ref({ id: 'user-123' }), - userEmail: ref(' User@Example.com ') - }) + mockFirebaseAuthStore.currentUser = { + uid: 'user-123', + email: ' User@Example.com ' + } vi.stubGlobal('crypto', { subtle: { digest: vi.fn( @@ -97,7 +129,7 @@ describe('ImpactTelemetryProvider', () => { }) it('falls back to current URL search and empty identify values when user is unresolved', async () => { - mockUseCurrentUser.mockImplementation(() => { + mockUseApiKeyAuthStore.mockImplementation(() => { throw new Error('No active pinia') }) window.history.pushState({}, '', '/?im_ref=fallback-123') @@ -121,10 +153,10 @@ describe('ImpactTelemetryProvider', () => { }) it('invokes identify on each page view even with identical identity payloads', async () => { - mockUseCurrentUser.mockReturnValue({ - resolvedUserInfo: ref({ id: 'user-123' }), - userEmail: ref('user@example.com') - }) + mockFirebaseAuthStore.currentUser = { + uid: 'user-123', + email: 'user@example.com' + } vi.stubGlobal('crypto', { subtle: { digest: vi.fn(async () => new Uint8Array([16, 32, 48]).buffer) @@ -150,4 +182,79 @@ describe('ImpactTelemetryProvider', () => { customerId: 'user-123' }) }) + + it('prefers firebase identity when both firebase and API key identity are available', async () => { + mockApiKeyAuthStore.isAuthenticated = true + mockApiKeyAuthStore.currentUser = { + id: 'api-key-user-123', + email: 'apikey@example.com' + } + mockFirebaseAuthStore.currentUser = { + uid: 'firebase-user-123', + email: 'firebase@example.com' + } + vi.stubGlobal('crypto', { + subtle: { + digest: vi.fn( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const digest = createHash('sha1') + .update(toUint8Array(data)) + .digest() + return Uint8Array.from(digest).buffer + } + ) + } + }) + + const provider = new ImpactTelemetryProvider() + provider.trackPageView('home', { + path: 'https://cloud.comfy.org/?im_ref=impact-123' + }) + + await flushAsyncWork() + + expect(window.ire?.a?.[0]).toEqual([ + 'identify', + { + customerId: 'firebase-user-123', + customerEmail: '2a2f2883bb1c5dd4ec5d18d95630834744609a7e' + } + ]) + }) + + it('falls back to API key identity when firebase user is unavailable', async () => { + mockApiKeyAuthStore.isAuthenticated = true + mockApiKeyAuthStore.currentUser = { + id: 'api-key-user-123', + email: 'apikey@example.com' + } + mockFirebaseAuthStore.currentUser = null + vi.stubGlobal('crypto', { + subtle: { + digest: vi.fn( + async (_algorithm: AlgorithmIdentifier, data: BufferSource) => { + const digest = createHash('sha1') + .update(toUint8Array(data)) + .digest() + return Uint8Array.from(digest).buffer + } + ) + } + }) + + const provider = new ImpactTelemetryProvider() + provider.trackPageView('home', { + path: 'https://cloud.comfy.org/?im_ref=impact-123' + }) + + await flushAsyncWork() + + expect(window.ire?.a?.[0]).toEqual([ + 'identify', + { + customerId: 'api-key-user-123', + customerEmail: '76ce7ed8519b3ab66d7520bbc3c4efcdff657028' + } + ]) + }) }) diff --git a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts index 5b231b91793..ff11d420a76 100644 --- a/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -1,5 +1,6 @@ import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution' -import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { PageViewMetadata, TelemetryProvider } from '../../types' @@ -14,6 +15,10 @@ const EMPTY_CUSTOMER_VALUE = '' */ export class ImpactTelemetryProvider implements TelemetryProvider { private initialized = false + private stores: { + apiKeyAuthStore: ReturnType + firebaseAuthStore: ReturnType + } | null = null constructor() { this.initialize() @@ -96,19 +101,56 @@ export class ImpactTelemetryProvider implements TelemetryProvider { customerId: string customerEmail: string } { - try { - const { resolvedUserInfo, userEmail } = useCurrentUser() + const stores = this.resolveAuthStores() + if (!stores) { + return { + customerId: EMPTY_CUSTOMER_VALUE, + customerEmail: EMPTY_CUSTOMER_VALUE + } + } + if (stores.firebaseAuthStore.currentUser) { return { - customerId: resolvedUserInfo.value?.id ?? EMPTY_CUSTOMER_VALUE, - customerEmail: userEmail.value ?? EMPTY_CUSTOMER_VALUE + customerId: + stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE, + customerEmail: + stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE } - } catch { + } + + if (stores.apiKeyAuthStore.isAuthenticated) { return { - customerId: EMPTY_CUSTOMER_VALUE, - customerEmail: EMPTY_CUSTOMER_VALUE + customerId: + stores.apiKeyAuthStore.currentUser?.id ?? EMPTY_CUSTOMER_VALUE, + customerEmail: + stores.apiKeyAuthStore.currentUser?.email ?? EMPTY_CUSTOMER_VALUE } } + + return { + customerId: EMPTY_CUSTOMER_VALUE, + customerEmail: EMPTY_CUSTOMER_VALUE + } + } + + private resolveAuthStores(): { + apiKeyAuthStore: ReturnType + firebaseAuthStore: ReturnType + } | null { + if (this.stores) { + return this.stores + } + + try { + const stores = { + apiKeyAuthStore: useApiKeyAuthStore(), + firebaseAuthStore: useFirebaseAuthStore() + } + this.stores = stores + return stores + } catch { + return null + } } private async hashSha1(value: string): Promise { From ae6b05a3f3b2d98e19e3ef5b9ef4ff8df99518ba Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 05:01:05 -0800 Subject: [PATCH 10/10] fix: tree-shake checkout attribution in non-cloud builds --- .../subscription/components/PricingTable.vue | 17 +++++++++++++++-- .../composables/useSubscription.test.ts | 9 +++++++++ .../composables/useSubscription.ts | 16 ++++++++++++++-- .../utils/subscriptionCheckoutUtil.test.ts | 10 ++++++++++ .../utils/subscriptionCheckoutUtil.ts | 19 +++++++++++++++---- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 2df6ec0aada..8af2f87dbda 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -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 => { + // 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 = await getCheckoutAttribution() + const checkoutAttribution = await getCheckoutAttributionForCloud() if (userId.value) { telemetry?.trackBeginCheckout({ user_id: userId.value, diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 559a51beeb0..48913f1dbd0 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -34,6 +34,13 @@ const { })) let scope: ReturnType | undefined +type Distribution = 'desktop' | 'localhost' | 'cloud' + +const setDistribution = (distribution: Distribution) => { + ;( + globalThis as typeof globalThis & { __DISTRIBUTION__: Distribution } + ).__DISTRIBUTION__ = distribution +} function useSubscriptionWithScope() { if (!scope) { @@ -116,11 +123,13 @@ describe('useSubscription', () => { afterEach(() => { scope?.stop() scope = undefined + setDistribution('localhost') }) beforeEach(() => { scope?.stop() scope = effectScope() + setDistribution('cloud') vi.clearAllMocks() mockIsLoggedIn.value = false diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 52042785197..5f026ce7828 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -8,7 +8,7 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } 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 type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' import { FirebaseAuthStoreError, useFirebaseAuthStore @@ -99,6 +99,18 @@ function useSubscriptionInternal() { return `${getComfyApiBaseUrl()}${path}` } + const getCheckoutAttributionForCloud = + async (): Promise => { + if (__DISTRIBUTION__ !== 'cloud') { + return {} + } + + const { getCheckoutAttribution } = + await import('@/platform/telemetry/utils/checkoutAttribution') + + return getCheckoutAttribution() + } + const fetchStatus = wrapWithErrorHandlingAsync( fetchSubscriptionStatus, reportError @@ -232,7 +244,7 @@ function useSubscriptionInternal() { t('toastMessages.userNotAuthenticated') ) } - const checkoutAttribution = await getCheckoutAttribution() + const checkoutAttribution = await getCheckoutAttributionForCloud() const response = await fetch( buildApiUrl('/customers/cloud-subscription-checkout'), diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 4deb4dc16d0..06f769ff888 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -58,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() { let resolve: (value: T) => void = () => {} const promise = new Promise((res) => { @@ -69,6 +77,7 @@ function createDeferred() { describe('performSubscriptionCheckout', () => { beforeEach(() => { + setDistribution('cloud') vi.clearAllMocks() mockIsCloud.value = true mockUserId.value = 'user-123' @@ -76,6 +85,7 @@ describe('performSubscriptionCheckout', () => { afterEach(() => { vi.restoreAllMocks() + setDistribution('localhost') }) it('tracks begin_checkout with user id and tier metadata', async () => { diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index b09cd778c31..3494a8c4a9b 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -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 => { + 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,10 +61,9 @@ export async function performSubscriptionCheckout( } const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle) - let checkoutAttribution: Awaited> = - {} + let checkoutAttribution: CheckoutAttributionMetadata = {} try { - checkoutAttribution = await getCheckoutAttribution() + checkoutAttribution = await getCheckoutAttributionForCloud() } catch (error) { console.warn( '[SubscriptionCheckout] Failed to collect checkout attribution',