Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
mockAccessBillingPortal,
mockShowSubscriptionRequiredDialog,
mockGetAuthHeader,
mockGetCheckoutAttribution,
mockTelemetry,
mockUserId,
mockIsCloud
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
})
})
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -231,6 +232,7 @@ function useSubscriptionInternal() {
t('toastMessages.userNotAuthenticated')
)
}
const checkoutAttribution = getCheckoutAttribution()

const response = await fetch(
buildApiUrl('/customers/cloud-subscription-checkout'),
Expand All @@ -239,7 +241,8 @@ function useSubscriptionInternal() {
headers: {
...authHeader,
'Content-Type': 'application/json'
}
},
body: JSON.stringify(checkoutAttribution)
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -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'
Expand Down
7 changes: 5 additions & 2 deletions src/platform/telemetry/initTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,17 @@ export async function initTelemetry(): Promise<void> {
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())

Expand Down
Original file line number Diff line number Diff line change
@@ -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) : ''
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SSR guard on Line 24 also blocks the window-independent fallback path.

When window is undefined but a valid path with query params is provided (e.g. "/checkout?utm_source=google"), the early return on Line 24 prevents reaching the manual indexOf('?') fallback (lines 30–31), which doesn't need window at all. This silently drops attribution params in SSR/test environments.

Move the window guard to only protect the new URL call:

Suggested fix
  private extractSearchFromPath(path?: string): string {
-   if (!path || typeof window === 'undefined') return ''
+   if (!path) return ''

    try {
+     if (typeof window === 'undefined') throw new Error('no window')
      const url = new URL(path, window.location.origin)
      return url.search
    } catch {
      const queryIndex = path.indexOf('?')
      return queryIndex >= 0 ? path.slice(queryIndex) : ''
    }
  }
🤖 Prompt for AI Agents
In
`@src/platform/telemetry/providers/cloud/CheckoutAttributionTelemetryProvider.ts`
around lines 23 - 33, The SSR guard currently returns early in
extractSearchFromPath and blocks the manual fallback; instead ensure you only
early-return when path is falsy, then attempt to construct new URL only if
typeof window !== 'undefined' (wrap new URL in try/catch), and if window is
undefined or URL construction fails fall back to using path.indexOf('?') to
slice and return the query string; update extractSearchFromPath to perform path
check first, then try the window-dependent URL parsing, and finally the manual
indexOf fallback.

}
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CheckoutAttributionTelemetryProvider lacks test coverage. While the underlying captureCheckoutAttributionFromSearch function is well-tested, the provider's specific behavior should also be tested, particularly:

  1. The extractSearchFromPath method with various path formats
  2. The fallback to window.location.search when no path is provided
  3. Integration with the trackPageView lifecycle

This is important because the provider has its own logic for extracting search parameters from paths and choosing between the provided path and window.location.search.

Copilot uses AI. Check for mistakes.
23 changes: 17 additions & 6 deletions src/platform/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,20 +281,31 @@ export interface PageViewMetadata {
[key: string]: unknown
}

export interface BeginCheckoutMetadata extends Record<string, unknown> {
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
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
}

export interface BeginCheckoutMetadata
extends Record<string, unknown>, 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>()

Expand Down Expand Up @@ -30,36 +33,103 @@ 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()

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: '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(
})

it('uses stored attribution when URL is empty', () => {
storage.set(
'comfy_checkout_attribution',
JSON.stringify({ gclid: 'gclid-123' })
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('uses stored click ids when URL is empty', () => {
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'
})
})

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({ gbraid: 'gbraid-1' })
JSON.stringify({ gclid: 'gclid-123', im_ref: 'impact-abc' })
)
window.history.pushState({}, '', '/?gclid=gclid-123&im_ref=impact-abc')

const attribution = getCheckoutAttribution()

expect(attribution.gbraid).toBe('gbraid-1')
expect(attribution).toMatchObject({
gclid: 'gclid-123',
im_ref: 'impact-abc',
impact_click_id: 'impact-abc'
})
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
})
})
Loading
Loading