From da56c9e55457d4a00bd654362cadf66996edbb6d Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 10 Feb 2026 16:40:51 -0800 Subject: [PATCH] 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 image image image image --- global.d.ts | 7 + .../subscription/components/PricingTable.vue | 17 +- .../composables/useSubscription.test.ts | 22 ++ .../composables/useSubscription.ts | 17 +- .../utils/subscriptionCheckoutUtil.test.ts | 57 ++++ .../utils/subscriptionCheckoutUtil.ts | 24 +- src/platform/telemetry/initTelemetry.ts | 7 +- .../cloud/ImpactTelemetryProvider.test.ts | 260 ++++++++++++++++++ .../cloud/ImpactTelemetryProvider.ts | 174 ++++++++++++ src/platform/telemetry/types.ts | 24 +- .../__tests__/checkoutAttribution.test.ts | 161 ++++++++--- .../telemetry/utils/checkoutAttribution.ts | 129 +++++++-- 12 files changed, 822 insertions(+), 77 deletions(-) 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 7e37ab6e6e..20f4ea82a3 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 3e4e8a17d8..8af2f87dbd 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 = 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 6f2a158a64..48913f1dbd 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,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 | 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' }) }) ) diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 27297709fc..5f026ce782 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 type { CheckoutAttributionMetadata } from '@/platform/telemetry/types' import { FirebaseAuthStoreError, useFirebaseAuthStore @@ -98,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 @@ -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) } ) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 2f1d3ea876..06f769ff88 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -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() { let resolve: (value: T) => void = () => {} const promise = new Promise((res) => { @@ -65,6 +77,7 @@ function createDeferred() { 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) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 9cb2ad33e7..3494a8c4a9 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,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( diff --git a/src/platform/telemetry/initTelemetry.ts b/src/platform/telemetry/initTelemetry.ts index e9bec3065b..1456078df6 100644 --- a/src/platform/telemetry/initTelemetry.ts +++ b/src/platform/telemetry/initTelemetry.ts @@ -23,16 +23,19 @@ export async function initTelemetry(): Promise { const [ { TelemetryRegistry }, { MixpanelTelemetryProvider }, - { GtmTelemetryProvider } + { GtmTelemetryProvider }, + { ImpactTelemetryProvider } ] = await Promise.all([ import('./TelemetryRegistry'), import('./providers/cloud/MixpanelTelemetryProvider'), - import('./providers/cloud/GtmTelemetryProvider') + import('./providers/cloud/GtmTelemetryProvider'), + import('./providers/cloud/ImpactTelemetryProvider') ]) const registry = new TelemetryRegistry() registry.registerProvider(new MixpanelTelemetryProvider()) registry.registerProvider(new GtmTelemetryProvider()) + registry.registerProvider(new ImpactTelemetryProvider()) setTelemetryRegistry(registry) })() 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 0000000000..37339ccd04 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +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('@/stores/apiKeyAuthStore', () => ({ + useApiKeyAuthStore: mockUseApiKeyAuthStore +})) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: mockUseFirebaseAuthStore +})) + +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() +} + +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() + 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) + } + 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 () => { + mockFirebaseAuthStore.currentUser = { + uid: 'user-123', + email: ' User@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('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]).toEqual({ + customerId: 'user-123', + customerEmail: '63a710569261a24b3766275b7000ce8d7b32e2f7' + }) + }) + + it('falls back to current URL search and empty identify values when user is unresolved', async () => { + mockUseApiKeyAuthStore.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('invokes identify on each page view even with identical identity payloads', async () => { + mockFirebaseAuthStore.currentUser = { + uid: 'user-123', + email: '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(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' + }) + }) + + 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 new file mode 100644 index 0000000000..ff11d420a7 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.ts @@ -0,0 +1,174 @@ +import { captureCheckoutAttributionFromSearch } from '@/platform/telemetry/utils/checkoutAttribution' +import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' + +import type { PageViewMetadata, TelemetryProvider } from '../../types' + +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 stores: { + apiKeyAuthStore: ReturnType + firebaseAuthStore: ReturnType + } | null = null + + 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: NonNullable = (...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 + + document.head.insertBefore(script, document.head.firstChild) + + 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() + // Impact's Identify spec requires customerEmail to be sent as a SHA1 hash. + const hashedEmail = normalizedEmail + ? await this.hashSha1(normalizedEmail) + : EMPTY_CUSTOMER_VALUE + + window.ire?.('identify', { + customerId, + customerEmail: hashedEmail + }) + } + + private resolveCustomerIdentity(): { + customerId: string + customerEmail: string + } { + const stores = this.resolveAuthStores() + if (!stores) { + return { + customerId: EMPTY_CUSTOMER_VALUE, + customerEmail: EMPTY_CUSTOMER_VALUE + } + } + + if (stores.firebaseAuthStore.currentUser) { + return { + customerId: + stores.firebaseAuthStore.currentUser.uid ?? EMPTY_CUSTOMER_VALUE, + customerEmail: + stores.firebaseAuthStore.currentUser.email ?? EMPTY_CUSTOMER_VALUE + } + } + + if (stores.apiKeyAuthStore.isAuthenticated) { + return { + 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 { + 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/types.ts b/src/platform/telemetry/types.ts index ce3260a8e3..92a89c9c96 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -281,18 +281,28 @@ export interface PageViewMetadata { [key: string]: unknown } -export interface BeginCheckoutMetadata extends Record { +export interface CheckoutAttributionMetadata { + ga_client_id?: string + ga_session_id?: string + ga_session_number?: string + im_ref?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string + utm_term?: string + utm_content?: string + gclid?: string + gbraid?: string + wbraid?: string +} + +export interface BeginCheckoutMetadata + extends Record, CheckoutAttributionMetadata { user_id: string tier: TierKey cycle: BillingCycle checkout_type: 'new' | 'change' previous_tier?: TierKey - ga_client_id?: string - ga_session_id?: string - ga_session_number?: 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 9b6d6f809f..67ebe8cd1e 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -1,65 +1,156 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { 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 { + captureCheckoutAttributionFromSearch, + getCheckoutAttribution +} from '../checkoutAttribution' describe('getCheckoutAttribution', () => { beforeEach(() => { - storage.clear() vi.clearAllMocks() + window.localStorage.clear() window.__ga_identity__ = undefined + window.ire = undefined window.history.pushState({}, '', '/') }) - it('reads GA identity and persists click ids 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', session_number: '2' } - window.history.pushState({}, '', '/?gclid=gclid-123') + window.history.pushState( + {}, + '', + '/?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', ga_session_id: '1700000000', ga_session_number: '2', - gclid: 'gclid-123' + gclid: 'gclid-123', + utm_source: 'impact', + im_ref: 'generated-click-id' }) - expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - 'comfy_checkout_attribution', - JSON.stringify({ gclid: 'gclid-123' }) + expect(mockIreCall).toHaveBeenCalledWith( + 'generateClickId', + expect.any(Function) ) }) - it('uses stored click ids when URL is empty', () => { - storage.set( - 'comfy_checkout_attribution', - JSON.stringify({ gbraid: 'gbraid-1' }) + it('falls back to URL click id when generateClickId is unavailable', async () => { + window.history.pushState( + {}, + '', + '/?utm_campaign=launch&im_ref=fallback-from-url' ) - const attribution = getCheckoutAttribution() + const attribution = await getCheckoutAttribution() - expect(attribution.gbraid).toBe('gbraid-1') + expect(attribution).toMatchObject({ + utm_campaign: 'launch', + im_ref: 'fallback-from-url' + }) + }) + + 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(attribution).toMatchObject({ + utm_source: 'impact', + utm_medium: 'affiliate' + }) + expect(attribution.im_ref).toBeUndefined() + }) + + 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 = await getCheckoutAttribution() + + expect(attribution.im_ref).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 3c22457d1f..156a843abb 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -1,13 +1,7 @@ import { isPlainObject } from 'es-toolkit' +import { withTimeout } from 'es-toolkit/promise' -interface CheckoutAttribution { - ga_client_id?: string - ga_session_id?: string - ga_session_number?: string - gclid?: string - gbraid?: string - wbraid?: string -} +import type { CheckoutAttributionMetadata } from '../types' type GaIdentity = { client_id?: string @@ -15,22 +9,36 @@ 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' +const GENERATE_CLICK_ID_TIMEOUT_MS = 300 + +function readStoredAttribution(): Partial> { + if (typeof window === 'undefined') return {} -function readStoredClickIds(): 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 CLICK_ID_KEYS) { - const value = parsed[key] - if (typeof value === 'string' && value.length > 0) { + const result: Partial> = {} + + for (const key of ATTRIBUTION_QUERY_KEYS) { + const value = asNonEmptyString(parsed[key]) + if (value) { result[key] = value } } @@ -41,7 +49,11 @@ function readStoredClickIds(): Partial> { } } -function persistClickIds(payload: Partial>): void { +function persistAttribution( + payload: Partial> +): void { + if (typeof window === 'undefined') return + try { localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload)) } catch { @@ -49,14 +61,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 @@ -66,6 +78,20 @@ function readClickIdsFromUrl( return result } +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 asNonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.length > 0 ? value : undefined } @@ -83,24 +109,71 @@ function getGaIdentity(): GaIdentity | undefined { } } -export function getCheckoutAttribution(): CheckoutAttribution { +async function getGeneratedClickId(): Promise { + if (typeof window === 'undefined') { + return undefined + } + + const impactQueue = window.ire + if (typeof impactQueue !== 'function') { + return 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 storedAttribution = readStoredAttribution() + if (Object.keys(fromUrl).length === 0) return + + if (!hasAttributionChanges(storedAttribution, fromUrl)) return + + persistAttribution({ + ...storedAttribution, + ...fromUrl + }) +} + +export async function getCheckoutAttribution(): Promise { if (typeof window === 'undefined') return {} - const stored = readStoredClickIds() - const fromUrl = readClickIdsFromUrl(window.location.search) - const merged: Partial> = { - ...stored, + const storedAttribution = readStoredAttribution() + const fromUrl = readAttributionFromUrl(window.location.search) + const generatedClickId = await getGeneratedClickId() + const attribution: Partial> = { + ...storedAttribution, ...fromUrl } - if (Object.keys(fromUrl).length > 0) { - persistClickIds(merged) + if (generatedClickId) { + attribution.im_ref = generatedClickId + } + + if (hasAttributionChanges(storedAttribution, attribution)) { + persistAttribution(attribution) } const gaIdentity = getGaIdentity() return { - ...merged, + ...attribution, ga_client_id: gaIdentity?.client_id, ga_session_id: gaIdentity?.session_id, ga_session_number: gaIdentity?.session_number