Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +42,8 @@ interface Window {
session_number?: string
}
dataLayer?: Array<Record<string, unknown>>
ire_o?: string
ire?: ImpactQueueFunction
}

interface Navigator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(

try {
if (isActiveSubscription.value) {
const checkoutAttribution = getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttribution()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same risk: attribution failure surfaces as a user-facing error on subscription change.

wrapWithErrorHandlingAsync will catch and display the error, but users shouldn't see an error toast because a non-essential telemetry call failed. Wrap defensively, consistent with the fix suggested in subscriptionCheckoutUtil.ts.

🛡️ Proposed fix
-      const checkoutAttribution = await getCheckoutAttribution()
+      const checkoutAttribution = await getCheckoutAttribution().catch(
+        () => ({})
+      )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const checkoutAttribution = await getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttribution().catch(
() => ({})
)
🤖 Prompt for AI Agents
In `@src/platform/cloud/subscription/components/PricingTable.vue` at line 417, The
call to getCheckoutAttribution in PricingTable.vue can throw and currently
bubbles up through wrapWithErrorHandlingAsync causing a user-facing error; wrap
the call in a local try/catch (around the getCheckoutAttribution call that
assigns checkoutAttribution) so any failure is swallowed for UX purposes: on
catch set checkoutAttribution to undefined (or a safe default) and log/debug the
error internally (or send non-blocking telemetry), but do not rethrow or surface
a toast; mirror the defensive pattern used in subscriptionCheckoutUtil.ts to
keep this telemetry call non-fatal.

if (userId) {
telemetry?.trackBeginCheckout({
user_id: userId,
Expand Down
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 = await 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
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function performSubscriptionCheckout(
}

const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle)
const checkoutAttribution = getCheckoutAttribution()
const checkoutAttribution = await getCheckoutAttribution()
const checkoutPayload = { ...checkoutAttribution }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const response = await fetch(
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,16 +23,19 @@ export async function initTelemetry(): Promise<void> {
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)
})()
Expand Down
132 changes: 132 additions & 0 deletions src/platform/telemetry/providers/cloud/ImpactTelemetryProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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<Window['ire']> = (...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'
})
})
})
Loading