From ede4aa24f69ec02d3b7ae8af588b6f0bbbc888ec Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 27 Jan 2026 19:59:33 -0800 Subject: [PATCH 01/43] fix: route gtm through telemetry entrypoint --- src/main.ts | 3 + .../composables/useSubscription.ts | 57 ++++++++++++++++++- src/platform/telemetry/index.ts | 32 ++++++++++- src/router.ts | 15 +++++ src/stores/firebaseAuthStore.ts | 48 +++++++++++++++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/src/main.ts b/src/main.ts index ca80b87f891..966b380d2f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,9 @@ if (isCloud) { const { refreshRemoteConfig } = await import('@/platform/remoteConfig/refreshRemoteConfig') await refreshRemoteConfig({ useAuth: false }) + + const { initGtm } = await import('@/platform/telemetry') + initGtm() } const ComfyUIPreset = definePreset(Aura, { diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 7d8e8a4f894..e3ab00781e0 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -7,13 +7,20 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' -import { useTelemetry } from '@/platform/telemetry' +import { pushDataLayerEvent, useTelemetry } from '@/platform/telemetry' import { FirebaseAuthStoreError, useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useDialogService } from '@/services/dialogService' -import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing' +import { + getTierPrice, + TIER_TO_KEY +} from '@/platform/cloud/subscription/constants/tierPricing' +import { + clearPendingSubscriptionPurchase, + getPendingSubscriptionPurchase +} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' import type { operations } from '@/types/comfyRegistryTypes' import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher' @@ -93,7 +100,45 @@ function useSubscriptionInternal() { : baseName }) - const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` + function buildApiUrl(path: string): string { + return `${getComfyApiBaseUrl()}${path}` + } + + function trackSubscriptionPurchase( + status: CloudSubscriptionStatusResponse | null + ): void { + if (!status?.is_active || !status.subscription_id) return + + const pendingPurchase = getPendingSubscriptionPurchase() + if (!pendingPurchase) return + + const { tierKey, billingCycle } = pendingPurchase + const isYearly = billingCycle === 'yearly' + const baseName = t(`subscription.tiers.${tierKey}.name`) + const planName = isYearly + ? t('subscription.tierNameYearly', { name: baseName }) + : baseName + const unitPrice = getTierPrice(tierKey, isYearly) + const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice + pushDataLayerEvent({ + event: 'purchase', + transaction_id: status.subscription_id, + value, + currency: 'USD', + items: [ + { + item_id: `${billingCycle}_${tierKey}`, + item_name: planName, + item_category: 'subscription', + item_variant: billingCycle, + price: value, + quantity: 1 + } + ] + }) + + clearPendingSubscriptionPurchase() + } const fetchStatus = wrapWithErrorHandlingAsync( fetchSubscriptionStatus, @@ -194,6 +239,12 @@ function useSubscriptionInternal() { const statusData = await response.json() subscriptionStatus.value = statusData + + try { + await trackSubscriptionPurchase(statusData) + } catch (error) { + console.error('Failed to track subscription purchase', error) + } return statusData } diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 83d7f2c9f96..74bb7157d5f 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -14,13 +14,25 @@ * This approach maintains complete separation between cloud and OSS builds * while ensuring the open source version contains no telemetry dependencies. */ -import { isCloud } from '@/platform/distribution/types' - import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' import type { TelemetryProvider } from './types' +type GtmModule = { + initGtm: () => void + pushDataLayerEvent: (event: Record) => void +} + // Singleton instance let _telemetryProvider: TelemetryProvider | null = null +let gtmModulePromise: Promise | null = null +const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud' + +function loadGtmModule(): Promise { + if (!gtmModulePromise) { + gtmModulePromise = import('./gtm') + } + return gtmModulePromise +} /** * Telemetry factory - conditionally creates provider based on distribution @@ -32,7 +44,7 @@ let _telemetryProvider: TelemetryProvider | null = null export function useTelemetry(): TelemetryProvider | null { if (_telemetryProvider === null) { // Use distribution check for tree-shaking - if (isCloud) { + if (IS_CLOUD_BUILD) { _telemetryProvider = new MixpanelTelemetryProvider() } // For OSS builds, _telemetryProvider stays null @@ -40,3 +52,17 @@ export function useTelemetry(): TelemetryProvider | null { return _telemetryProvider } + +export function initGtm(): void { + if (!IS_CLOUD_BUILD || typeof window === 'undefined') return + void loadGtmModule().then(({ initGtm }) => { + initGtm() + }) +} + +export function pushDataLayerEvent(event: Record): void { + if (!IS_CLOUD_BUILD || typeof window === 'undefined') return + void loadGtmModule().then(({ pushDataLayerEvent }) => { + pushDataLayerEvent(event) + }) +} diff --git a/src/router.ts b/src/router.ts index b489d225733..adc6848af00 100644 --- a/src/router.ts +++ b/src/router.ts @@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { isCloud } from '@/platform/distribution/types' +import { pushDataLayerEvent } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useUserStore } from '@/stores/userStore' @@ -36,6 +37,16 @@ function getBasePath(): string { const basePath = getBasePath() +function pushPageView(): void { + if (!isCloud || typeof window === 'undefined') return + + pushDataLayerEvent({ + event: 'page_view', + page_location: window.location.href, + page_title: document.title + }) +} + const router = createRouter({ history: isFileProtocol ? createWebHashHistory() @@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [ } ]) +router.afterEach(() => { + pushPageView() +}) + if (isCloud) { const { flags } = useFeatureFlags() const PUBLIC_ROUTE_NAMES = new Set([ diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 41bae52a285..965ba3a8a0c 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -25,7 +25,10 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { isCloud } from '@/platform/distribution/types' -import { useTelemetry } from '@/platform/telemetry' +import { + pushDataLayerEvent as pushDataLayerEventBase, + useTelemetry +} from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -81,6 +84,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` + function pushDataLayerEvent(event: Record): void { + if (!isCloud || typeof window === 'undefined') return + + try { + pushDataLayerEventBase(event) + } catch (error) { + console.warn('Failed to push data layer event', error) + } + } + + async function hashSha256(value: string): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) return + if (typeof TextEncoder === 'undefined') return + const data = new TextEncoder().encode(value) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + } + + async function trackSignUp(method: 'email' | 'google' | 'github') { + if (!isCloud || typeof window === 'undefined') return + + try { + const userId = currentUser.value?.uid + const hashedUserId = userId ? await hashSha256(userId) : undefined + pushDataLayerEvent({ + event: 'sign_up', + method, + ...(hashedUserId ? { user_id: hashedUserId } : {}) + }) + } catch (error) { + console.warn('Failed to track sign up', error) + } + } + // Providers const googleProvider = new GoogleAuthProvider() googleProvider.addScope('email') @@ -372,6 +411,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { method: 'email', is_new_user: true }) + await trackSignUp('email') } return result @@ -390,6 +430,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { method: 'google', is_new_user: isNewUser }) + if (isNewUser) { + await trackSignUp('google') + } } return result @@ -408,6 +451,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { method: 'github', is_new_user: isNewUser }) + if (isNewUser) { + await trackSignUp('github') + } } return result From c247d7be523f508dc42f33bf93ae307be0c096c4 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 27 Jan 2026 20:01:59 -0800 Subject: [PATCH 02/43] fix: add gtm type import for knip --- src/platform/telemetry/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 74bb7157d5f..3e042b43728 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -15,11 +15,15 @@ * while ensuring the open source version contains no telemetry dependencies. */ import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' +import type { + initGtm as gtmInit, + pushDataLayerEvent as gtmPushDataLayerEvent +} from './gtm' import type { TelemetryProvider } from './types' type GtmModule = { - initGtm: () => void - pushDataLayerEvent: (event: Record) => void + initGtm: typeof gtmInit + pushDataLayerEvent: typeof gtmPushDataLayerEvent } // Singleton instance From 563cdbfd267a2736ded26b31d1675196bb04dab9 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 27 Jan 2026 22:51:22 -0800 Subject: [PATCH 03/43] fix: restore gtm purchase tracking --- global.d.ts | 1 + .../composables/useSubscription.test.ts | 46 +++++++++++ .../utils/subscriptionCheckoutUtil.ts | 2 + .../utils/subscriptionPurchaseTracker.ts | 78 +++++++++++++++++++ src/platform/telemetry/gtm.ts | 43 ++++++++++ 5 files changed, 170 insertions(+) create mode 100644 src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts create mode 100644 src/platform/telemetry/gtm.ts diff --git a/global.d.ts b/global.d.ts index 7f7dd832f89..ec455707f45 100644 --- a/global.d.ts +++ b/global.d.ts @@ -30,6 +30,7 @@ interface Window { badge?: string } } + dataLayer?: Array> } interface Navigator { diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index ffa370a903d..31a8dd79b28 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -11,6 +11,7 @@ const mockShowSubscriptionRequiredDialog = vi.fn() const mockGetAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) +const mockPushDataLayerEvent = vi.fn() const mockTelemetry = { trackSubscription: vi.fn(), trackMonthlySubscriptionCancelled: vi.fn() @@ -24,6 +25,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) vi.mock('@/platform/telemetry', () => ({ + pushDataLayerEvent: mockPushDataLayerEvent, useTelemetry: vi.fn(() => mockTelemetry) })) @@ -78,6 +80,7 @@ describe('useSubscription', () => { mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() + mockPushDataLayerEvent.mockReset() window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -206,6 +209,49 @@ describe('useSubscription', () => { ) }) + it('pushes purchase event after a pending subscription completes', async () => { + window.dataLayer = [] + localStorage.setItem( + 'pending_subscription_purchase', + JSON.stringify({ + tierKey: 'creator', + billingCycle: 'monthly', + timestamp: Date.now() + }) + ) + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + subscription_duration: 'MONTHLY' + }) + } as Response) + + mockIsLoggedIn.value = true + const { fetchStatus } = useSubscription() + + await fetchStatus() + + expect(window.dataLayer).toHaveLength(1) + expect(window.dataLayer?.[0]).toMatchObject({ + event: 'purchase', + transaction_id: 'sub_123', + currency: 'USD', + items: [ + { + item_id: 'monthly_creator', + item_variant: 'monthly', + item_category: 'subscription', + quantity: 1 + } + ] + }) + expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() + }) + it('should handle fetch errors gracefully', async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 746a7d61667..8be996ebc93 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -6,6 +6,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' import type { BillingCycle } from './subscriptionTierRank' type CheckoutTier = TierKey | `${TierKey}-yearly` @@ -78,6 +79,7 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { + startSubscriptionPurchaseTracking(tierKey, currentBillingCycle) if (openInNewTab) { window.open(data.checkout_url, '_blank') } else { diff --git a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts new file mode 100644 index 00000000000..41bbb348427 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts @@ -0,0 +1,78 @@ +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { BillingCycle } from './subscriptionTierRank' + +type PendingSubscriptionPurchase = { + tierKey: TierKey + billingCycle: BillingCycle + timestamp: number +} + +const STORAGE_KEY = 'pending_subscription_purchase' +const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours +const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder'] +const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly'] + +const safeRemove = (): void => { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + // Ignore storage errors (e.g. private browsing mode) + } +} + +export function startSubscriptionPurchaseTracking( + tierKey: TierKey, + billingCycle: BillingCycle +): void { + if (typeof window === 'undefined') return + try { + const payload: PendingSubscriptionPurchase = { + tierKey, + billingCycle, + timestamp: Date.now() + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + } catch { + // Ignore storage errors (e.g. private browsing mode) + } +} + +export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null { + if (typeof window === 'undefined') return null + + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + + const parsed = JSON.parse(raw) as PendingSubscriptionPurchase + if (!parsed || typeof parsed !== 'object') { + safeRemove() + return null + } + + const { tierKey, billingCycle, timestamp } = parsed + if ( + !VALID_TIERS.includes(tierKey) || + !VALID_CYCLES.includes(billingCycle) || + typeof timestamp !== 'number' + ) { + safeRemove() + return null + } + + if (Date.now() - timestamp > MAX_AGE_MS) { + safeRemove() + return null + } + + return parsed + } catch { + safeRemove() + return null + } +} + +export function clearPendingSubscriptionPurchase(): void { + if (typeof window === 'undefined') return + safeRemove() +} diff --git a/src/platform/telemetry/gtm.ts b/src/platform/telemetry/gtm.ts new file mode 100644 index 00000000000..d5c738ae4ac --- /dev/null +++ b/src/platform/telemetry/gtm.ts @@ -0,0 +1,43 @@ +import { isCloud } from '@/platform/distribution/types' + +const GTM_CONTAINER_ID = 'GTM-NP9JM6K7' + +let isInitialized = false +let initPromise: Promise | null = null + +export function initGtm(): void { + if (!isCloud || typeof window === 'undefined') return + if (typeof document === 'undefined') return + if (isInitialized) return + + if (!initPromise) { + initPromise = new Promise((resolve) => { + const dataLayer = window.dataLayer ?? (window.dataLayer = []) + dataLayer.push({ + 'gtm.start': Date.now(), + event: 'gtm.js' + }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}` + + const finalize = () => { + isInitialized = true + resolve() + } + + script.addEventListener('load', finalize, { once: true }) + script.addEventListener('error', finalize, { once: true }) + document.head?.appendChild(script) + }) + } + + void initPromise +} + +export function pushDataLayerEvent(event: Record): void { + if (!isCloud || typeof window === 'undefined') return + const dataLayer = window.dataLayer ?? (window.dataLayer = []) + dataLayer.push(event) +} From 2db667bde42a693a0cd111dc7f41d606ad04cdb7 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 28 Jan 2026 03:24:13 -0800 Subject: [PATCH 04/43] fix: stabilize useSubscription tests --- .../composables/useSubscription.test.ts | 103 ++++++++++++------ 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 31a8dd79b28..db6533d11fa 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -1,23 +1,46 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ref } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' -// Create mocks -const mockIsLoggedIn = ref(false) -const mockReportError = vi.fn() -const mockAccessBillingPortal = vi.fn() -const mockShowSubscriptionRequiredDialog = vi.fn() -const mockGetAuthHeader = vi.fn(() => - Promise.resolve({ Authorization: 'Bearer test-token' }) -) -const mockPushDataLayerEvent = vi.fn() -const mockTelemetry = { - trackSubscription: vi.fn(), - trackMonthlySubscriptionCancelled: vi.fn() +const { + mockIsLoggedIn, + mockReportError, + mockAccessBillingPortal, + mockShowSubscriptionRequiredDialog, + mockGetAuthHeader, + mockPushDataLayerEvent, + mockTelemetry +} = vi.hoisted(() => ({ + mockIsLoggedIn: { value: false }, + mockReportError: vi.fn(), + mockAccessBillingPortal: vi.fn(), + mockShowSubscriptionRequiredDialog: vi.fn(), + mockGetAuthHeader: vi.fn(() => + Promise.resolve({ Authorization: 'Bearer test-token' }) + ), + mockPushDataLayerEvent: vi.fn(), + mockTelemetry: { + trackSubscription: vi.fn(), + trackMonthlySubscriptionCancelled: vi.fn() + } +})) + +let scope: ReturnType | undefined + +function useSubscriptionWithScope() { + if (!scope) { + throw new Error('Test scope not initialized') + } + + const subscription = scope.run(() => useSubscription()) + if (!subscription) { + throw new Error('Failed to initialize subscription composable') + } + + return subscription } -// Mock dependencies vi.mock('@/composables/auth/useCurrentUser', () => ({ useCurrentUser: vi.fn(() => ({ isLoggedIn: mockIsLoggedIn @@ -75,12 +98,24 @@ vi.mock('@/stores/firebaseAuthStore', () => ({ global.fetch = vi.fn() describe('useSubscription', () => { + afterEach(() => { + scope?.stop() + scope = undefined + }) + beforeEach(() => { + scope?.stop() + scope = effectScope() + vi.clearAllMocks() mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() mockPushDataLayerEvent.mockReset() + mockPushDataLayerEvent.mockImplementation((event) => { + const dataLayer = window.dataLayer ?? (window.dataLayer = []) + dataLayer.push(event) + }) window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -106,7 +141,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { isActiveSubscription, fetchStatus } = useSubscription() + const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope() await fetchStatus() expect(isActiveSubscription.value).toBe(true) @@ -123,7 +158,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { isActiveSubscription, fetchStatus } = useSubscription() + const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope() await fetchStatus() expect(isActiveSubscription.value).toBe(false) @@ -140,7 +175,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { formattedRenewalDate, fetchStatus } = useSubscription() + const { formattedRenewalDate, fetchStatus } = useSubscriptionWithScope() await fetchStatus() // The date format may vary based on timezone, so we just check it's a valid date string @@ -150,7 +185,7 @@ describe('useSubscription', () => { }) it('should return empty string when renewal date is not available', () => { - const { formattedRenewalDate } = useSubscription() + const { formattedRenewalDate } = useSubscriptionWithScope() expect(formattedRenewalDate.value).toBe('') }) @@ -167,14 +202,14 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { subscriptionTier, fetchStatus } = useSubscription() + const { subscriptionTier, fetchStatus } = useSubscriptionWithScope() await fetchStatus() expect(subscriptionTier.value).toBe('CREATOR') }) it('should return null when subscription tier is not available', () => { - const { subscriptionTier } = useSubscription() + const { subscriptionTier } = useSubscriptionWithScope() expect(subscriptionTier.value).toBeNull() }) @@ -194,7 +229,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { fetchStatus } = useSubscription() + const { fetchStatus } = useSubscriptionWithScope() await fetchStatus() @@ -231,7 +266,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { fetchStatus } = useSubscription() + const { fetchStatus } = useSubscriptionWithScope() await fetchStatus() @@ -258,7 +293,7 @@ describe('useSubscription', () => { json: async () => ({ message: 'Subscription not found' }) } as Response) - const { fetchStatus } = useSubscription() + const { fetchStatus } = useSubscriptionWithScope() await expect(fetchStatus()).rejects.toThrow() }) @@ -278,7 +313,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { subscribe } = useSubscription() + const { subscribe } = useSubscriptionWithScope() await subscribe() @@ -304,7 +339,7 @@ describe('useSubscription', () => { json: async () => ({}) } as Response) - const { subscribe } = useSubscription() + const { subscribe } = useSubscriptionWithScope() await expect(subscribe()).rejects.toThrow() }) @@ -321,7 +356,7 @@ describe('useSubscription', () => { }) } as Response) - const { requireActiveSubscription } = useSubscription() + const { requireActiveSubscription } = useSubscriptionWithScope() await requireActiveSubscription() @@ -338,7 +373,7 @@ describe('useSubscription', () => { }) } as Response) - const { requireActiveSubscription } = useSubscription() + const { requireActiveSubscription } = useSubscriptionWithScope() await requireActiveSubscription() @@ -352,7 +387,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { handleViewUsageHistory } = useSubscription() + const { handleViewUsageHistory } = useSubscriptionWithScope() handleViewUsageHistory() expect(windowOpenSpy).toHaveBeenCalledWith( @@ -368,7 +403,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { handleLearnMore } = useSubscription() + const { handleLearnMore } = useSubscriptionWithScope() handleLearnMore() expect(windowOpenSpy).toHaveBeenCalledWith( @@ -380,7 +415,7 @@ describe('useSubscription', () => { }) it('should call accessBillingPortal for invoice history', async () => { - const { handleInvoiceHistory } = useSubscription() + const { handleInvoiceHistory } = useSubscriptionWithScope() await handleInvoiceHistory() @@ -388,7 +423,7 @@ describe('useSubscription', () => { }) it('should call accessBillingPortal for manage subscription', async () => { - const { manageSubscription } = useSubscription() + const { manageSubscription } = useSubscriptionWithScope() await manageSubscription() @@ -424,7 +459,7 @@ describe('useSubscription', () => { .mockResolvedValueOnce(cancelledResponse as Response) try { - const { fetchStatus, manageSubscription } = useSubscription() + const { fetchStatus, manageSubscription } = useSubscriptionWithScope() await fetchStatus() await manageSubscription() @@ -468,7 +503,7 @@ describe('useSubscription', () => { .mockResolvedValueOnce(cancelledResponse as Response) try { - const { fetchStatus, manageSubscription } = useSubscription() + const { fetchStatus, manageSubscription } = useSubscriptionWithScope() await fetchStatus() await manageSubscription() From 074ec623f07be0fba0766a8e5913f14922ff4757 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 28 Jan 2026 14:48:28 -0800 Subject: [PATCH 05/43] FirebaseUID gating pending purchases --- .../composables/useSubscription.test.ts | 43 +++++++++++++++++-- .../composables/useSubscription.ts | 6 ++- .../utils/subscriptionCheckoutUtil.ts | 6 ++- .../utils/subscriptionPurchaseTracker.ts | 14 ++++-- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index db6533d11fa..a9a2487a529 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -10,7 +10,8 @@ const { mockShowSubscriptionRequiredDialog, mockGetAuthHeader, mockPushDataLayerEvent, - mockTelemetry + mockTelemetry, + mockUserId } = vi.hoisted(() => ({ mockIsLoggedIn: { value: false }, mockReportError: vi.fn(), @@ -23,7 +24,8 @@ const { mockTelemetry: { trackSubscription: vi.fn(), trackMonthlySubscriptionCancelled: vi.fn() - } + }, + mockUserId: { value: 'user-123' } })) let scope: ReturnType | undefined @@ -89,7 +91,8 @@ vi.mock('@/services/dialogService', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: vi.fn(() => ({ - getFirebaseAuthHeader: mockGetAuthHeader + getFirebaseAuthHeader: mockGetAuthHeader, + userId: mockUserId.value })), FirebaseAuthStoreError: class extends Error {} })) @@ -112,6 +115,7 @@ describe('useSubscription', () => { mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() mockPushDataLayerEvent.mockReset() + mockUserId.value = 'user-123' mockPushDataLayerEvent.mockImplementation((event) => { const dataLayer = window.dataLayer ?? (window.dataLayer = []) dataLayer.push(event) @@ -249,6 +253,7 @@ describe('useSubscription', () => { localStorage.setItem( 'pending_subscription_purchase', JSON.stringify({ + firebaseUid: 'user-123', tierKey: 'creator', billingCycle: 'monthly', timestamp: Date.now() @@ -287,6 +292,38 @@ describe('useSubscription', () => { expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() }) + it('ignores pending purchase when user does not match', async () => { + window.dataLayer = [] + localStorage.setItem( + 'pending_subscription_purchase', + JSON.stringify({ + firebaseUid: 'user-123', + tierKey: 'creator', + billingCycle: 'monthly', + timestamp: Date.now() + }) + ) + + mockUserId.value = 'user-456' + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + subscription_duration: 'MONTHLY' + }) + } as Response) + + mockIsLoggedIn.value = true + const { fetchStatus } = useSubscriptionWithScope() + + await fetchStatus() + + expect(window.dataLayer).toHaveLength(0) + expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() + }) + it('should handle fetch errors gracefully', async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index e3ab00781e0..0f7c883d729 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -45,7 +45,7 @@ function useSubscriptionInternal() { const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { showSubscriptionRequiredDialog } = useDialogService() - const { getFirebaseAuthHeader } = useFirebaseAuthStore() + const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore() const { wrapWithErrorHandlingAsync } = useErrorHandling() const { isLoggedIn } = useCurrentUser() @@ -109,7 +109,9 @@ function useSubscriptionInternal() { ): void { if (!status?.is_active || !status.subscription_id) return - const pendingPurchase = getPendingSubscriptionPurchase() + if (!userId) return + + const pendingPurchase = getPendingSubscriptionPurchase(userId) if (!pendingPurchase) return const { tierKey, billingCycle } = pendingPurchase diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 8be996ebc93..7c9c0ec19cd 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -36,7 +36,7 @@ export async function performSubscriptionCheckout( ): Promise { if (!isCloud) return - const { getFirebaseAuthHeader } = useFirebaseAuthStore() + const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore() const authHeader = await getFirebaseAuthHeader() if (!authHeader) { @@ -79,7 +79,9 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { - startSubscriptionPurchaseTracking(tierKey, currentBillingCycle) + if (userId) { + startSubscriptionPurchaseTracking(tierKey, currentBillingCycle, userId) + } if (openInNewTab) { window.open(data.checkout_url, '_blank') } else { diff --git a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts index 41bbb348427..9f962fff677 100644 --- a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts @@ -2,6 +2,7 @@ import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricin import type { BillingCycle } from './subscriptionTierRank' type PendingSubscriptionPurchase = { + firebaseUid: string tierKey: TierKey billingCycle: BillingCycle timestamp: number @@ -22,11 +23,14 @@ const safeRemove = (): void => { export function startSubscriptionPurchaseTracking( tierKey: TierKey, - billingCycle: BillingCycle + billingCycle: BillingCycle, + firebaseUid: string ): void { if (typeof window === 'undefined') return + if (!firebaseUid) return try { const payload: PendingSubscriptionPurchase = { + firebaseUid, tierKey, billingCycle, timestamp: Date.now() @@ -37,8 +41,11 @@ export function startSubscriptionPurchaseTracking( } } -export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null { +export function getPendingSubscriptionPurchase( + firebaseUid: string +): PendingSubscriptionPurchase | null { if (typeof window === 'undefined') return null + if (!firebaseUid) return null try { const raw = localStorage.getItem(STORAGE_KEY) @@ -50,8 +57,9 @@ export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | return null } - const { tierKey, billingCycle, timestamp } = parsed + const { firebaseUid: storedUid, tierKey, billingCycle, timestamp } = parsed if ( + storedUid !== firebaseUid || !VALID_TIERS.includes(tierKey) || !VALID_CYCLES.includes(billingCycle) || typeof timestamp !== 'number' From bca2c6203f00e95416ef0ee79aca21d5b164f11b Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 28 Jan 2026 15:23:25 -0800 Subject: [PATCH 06/43] Add testing for telemetry in local dist assets --- .github/workflows/ci-dist-telemetry-scan.yaml | 36 ++++++++++++ package.json | 1 + scripts/verify-dist-no-telemetry.ts | 57 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 .github/workflows/ci-dist-telemetry-scan.yaml create mode 100644 scripts/verify-dist-no-telemetry.ts diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml new file mode 100644 index 00000000000..327cb5b6ccb --- /dev/null +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -0,0 +1,36 @@ +name: 'CI: Dist Telemetry Scan' + +on: + pull_request: + branches-ignore: [wip/*, draft/*, temp/*] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + scan: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build project + run: pnpm build + + - name: Scan dist for telemetry references + run: pnpm test:dist diff --git a/package.json b/package.json index e1b00ac124c..eb67c0ecf91 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'", "test:browser": "pnpm exec nx e2e", "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser", + "test:dist": "tsx scripts/verify-dist-no-telemetry.ts", "test:unit": "nx run test", "typecheck": "vue-tsc --noEmit", "typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck", diff --git a/scripts/verify-dist-no-telemetry.ts b/scripts/verify-dist-no-telemetry.ts new file mode 100644 index 00000000000..ca7f70ec43a --- /dev/null +++ b/scripts/verify-dist-no-telemetry.ts @@ -0,0 +1,57 @@ +import { readFile, stat } from 'node:fs/promises' +import { extname, resolve } from 'node:path' +import { glob } from 'glob' + +type Pattern = { + label: string + regex: RegExp +} + +const distDir = resolve('dist') +const ignoredExtensions = new Set(['.map', '.svg']) +const telemetryPatterns: Pattern[] = [ + { label: 'GTM container', regex: /GTM-[A-Z0-9]+/i }, + { label: 'GTM script', regex: /gtm\.js/i }, + { label: 'Google Tag Manager', regex: /googletagmanager/i }, + { label: 'dataLayer', regex: /\bdataLayer\b/ } +] + +const distStats = await stat(distDir).catch(() => null) +if (!distStats?.isDirectory()) { + console.error('dist directory not found. Run pnpm build first.') + process.exit(1) +} + +const files = await glob('dist/**/*', { nodir: true }) +const violations: Array<{ + file: string + hits: Array<{ label: string; match: string }> +}> = [] + +for (const file of files) { + const extension = extname(file).toLowerCase() + if (ignoredExtensions.has(extension)) continue + + const content = (await readFile(file)).toString('utf8') + const hits = telemetryPatterns + .map((pattern) => { + const match = content.match(pattern.regex) + return match ? { label: pattern.label, match: match[0] } : null + }) + .filter((hit): hit is { label: string; match: string } => hit !== null) + + if (hits.length > 0) { + violations.push({ file, hits }) + } +} + +if (violations.length > 0) { + console.error('Telemetry references found in dist assets:') + for (const violation of violations) { + const formattedHits = violation.hits + .map((hit) => `${hit.label} (${hit.match})`) + .join(', ') + console.error(`- ${violation.file}: ${formattedHits}`) + } + process.exit(1) +} From a3ccd92366fe1ace551b334f2b0aaa7d7137b577 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 28 Jan 2026 18:51:51 -0800 Subject: [PATCH 07/43] fix: simplify telemetry scan --- scripts/verify-dist-no-telemetry.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/verify-dist-no-telemetry.ts b/scripts/verify-dist-no-telemetry.ts index ca7f70ec43a..42d40b9c593 100644 --- a/scripts/verify-dist-no-telemetry.ts +++ b/scripts/verify-dist-no-telemetry.ts @@ -32,13 +32,11 @@ for (const file of files) { const extension = extname(file).toLowerCase() if (ignoredExtensions.has(extension)) continue - const content = (await readFile(file)).toString('utf8') - const hits = telemetryPatterns - .map((pattern) => { - const match = content.match(pattern.regex) - return match ? { label: pattern.label, match: match[0] } : null - }) - .filter((hit): hit is { label: string; match: string } => hit !== null) + const content = await readFile(file, 'utf8') + const hits = telemetryPatterns.flatMap((pattern) => { + const match = content.match(pattern.regex) + return match ? [{ label: pattern.label, match: match[0] }] : [] + }) if (hits.length > 0) { violations.push({ file, hits }) From c0ee82014dad29d674d565fc2f175bcc83771691 Mon Sep 17 00:00:00 2001 From: Subagent 5 Date: Wed, 28 Jan 2026 20:17:19 -0800 Subject: [PATCH 08/43] feat: add TelemetryRegistry for multi-provider dispatch - Add TelemetryRegistry that dispatches to multiple providers - Add GtmTelemetryProvider for GTM/GA4 integration - Convert TelemetryProvider methods to optional (providers implement what they need) - Add TelemetryDispatcher type (Required) for call sites - Dynamically import providers to ensure tree-shaking in OSS builds - Track page_view, sign_up, login, and purchase events via GTM - Remove gtm.ts in favor of GtmTelemetryProvider Amp-Thread-ID: https://ampcode.com/threads/T-019c07ca-7748-77c8-b15b-d79d42779f8f Co-authored-by: Amp --- global.d.ts | 1 + src/main.ts | 4 +- .../composables/useSubscription.test.ts | 9 +- .../composables/useSubscription.ts | 37 ++-- ...useSubscriptionCancellationWatcher.test.ts | 4 +- .../useSubscriptionCancellationWatcher.ts | 7 +- src/platform/remoteConfig/types.ts | 1 + src/platform/telemetry/TelemetryRegistry.ts | 198 ++++++++++++++++++ src/platform/telemetry/gtm.ts | 43 ---- src/platform/telemetry/index.ts | 92 ++++---- .../providers/cloud/GtmTelemetryProvider.ts | 93 ++++++++ src/platform/telemetry/types.ts | 98 +++++---- src/router.ts | 8 +- src/stores/firebaseAuthStore.ts | 8 +- 14 files changed, 429 insertions(+), 174 deletions(-) create mode 100644 src/platform/telemetry/TelemetryRegistry.ts delete mode 100644 src/platform/telemetry/gtm.ts create mode 100644 src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts diff --git a/global.d.ts b/global.d.ts index ec455707f45..71678702f9f 100644 --- a/global.d.ts +++ b/global.d.ts @@ -8,6 +8,7 @@ declare const __USE_PROD_CONFIG__: boolean interface Window { __CONFIG__: { mixpanel_token?: string + gtm_id?: string require_whitelist?: boolean subscription_required?: boolean max_upload_size?: number diff --git a/src/main.ts b/src/main.ts index 966b380d2f3..4af76693c8f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,8 +32,8 @@ if (isCloud) { await import('@/platform/remoteConfig/refreshRemoteConfig') await refreshRemoteConfig({ useAuth: false }) - const { initGtm } = await import('@/platform/telemetry') - initGtm() + const { initTelemetry } = await import('@/platform/telemetry') + await initTelemetry() } const ComfyUIPreset = definePreset(Aura, { diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index a9a2487a529..dafb4a4319e 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -9,7 +9,6 @@ const { mockAccessBillingPortal, mockShowSubscriptionRequiredDialog, mockGetAuthHeader, - mockPushDataLayerEvent, mockTelemetry, mockUserId } = vi.hoisted(() => ({ @@ -20,7 +19,6 @@ const { mockGetAuthHeader: vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ), - mockPushDataLayerEvent: vi.fn(), mockTelemetry: { trackSubscription: vi.fn(), trackMonthlySubscriptionCancelled: vi.fn() @@ -50,7 +48,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) vi.mock('@/platform/telemetry', () => ({ - pushDataLayerEvent: mockPushDataLayerEvent, useTelemetry: vi.fn(() => mockTelemetry) })) @@ -114,12 +111,8 @@ describe('useSubscription', () => { mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() - mockPushDataLayerEvent.mockReset() mockUserId.value = 'user-123' - mockPushDataLayerEvent.mockImplementation((event) => { - const dataLayer = window.dataLayer ?? (window.dataLayer = []) - dataLayer.push(event) - }) + window.dataLayer = [] window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 0f7c883d729..8916f941407 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -7,7 +7,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' -import { pushDataLayerEvent, useTelemetry } from '@/platform/telemetry' +import { useTelemetry } from '@/platform/telemetry' import { FirebaseAuthStoreError, useFirebaseAuthStore @@ -122,22 +122,25 @@ function useSubscriptionInternal() { : baseName const unitPrice = getTierPrice(tierKey, isYearly) const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice - pushDataLayerEvent({ - event: 'purchase', - transaction_id: status.subscription_id, - value, - currency: 'USD', - items: [ - { - item_id: `${billingCycle}_${tierKey}`, - item_name: planName, - item_category: 'subscription', - item_variant: billingCycle, - price: value, - quantity: 1 - } - ] - }) + if (typeof window !== 'undefined') { + window.dataLayer = window.dataLayer || [] + window.dataLayer.push({ + event: 'purchase', + transaction_id: status.subscription_id, + value, + currency: 'USD', + items: [ + { + item_id: `${billingCycle}_${tierKey}`, + item_name: planName, + item_category: 'subscription', + item_variant: billingCycle, + price: value, + quantity: 1 + } + ] + }) + } clearPendingSubscriptionPurchase() } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts index 12e114aed23..a44564801d9 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts @@ -4,12 +4,12 @@ import type { EffectScope } from 'vue' import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription' import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher' -import type { TelemetryProvider } from '@/platform/telemetry/types' +import type { TelemetryDispatcher } from '@/platform/telemetry/types' describe('useSubscriptionCancellationWatcher', () => { const trackMonthlySubscriptionCancelled = vi.fn() const telemetryMock: Pick< - TelemetryProvider, + TelemetryDispatcher, 'trackMonthlySubscriptionCancelled' > = { trackMonthlySubscriptionCancelled diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts index d841b02fcdb..01e16606937 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts @@ -2,7 +2,7 @@ import { onScopeDispose, ref } from 'vue' import type { ComputedRef, Ref } from 'vue' import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core' -import type { TelemetryProvider } from '@/platform/telemetry/types' +import type { TelemetryDispatcher } from '@/platform/telemetry/types' import type { CloudSubscriptionStatusResponse } from './useSubscription' @@ -14,7 +14,10 @@ type CancellationWatcherOptions = { fetchStatus: () => Promise isActiveSubscription: ComputedRef subscriptionStatus: Ref - telemetry: Pick | null + telemetry: Pick< + TelemetryDispatcher, + 'trackMonthlySubscriptionCancelled' + > | null shouldWatchCancellation: () => boolean } diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 7b8b1721cce..50744d6604f 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = { */ export type RemoteConfig = { mixpanel_token?: string + gtm_id?: string subscription_required?: boolean server_health_alert?: ServerHealthAlert max_upload_size?: number diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts new file mode 100644 index 00000000000..e7b4aacfa23 --- /dev/null +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -0,0 +1,198 @@ +import type { AuditLog } from '@/services/customerEventsService' + +import type { + AuthMetadata, + EnterLinearMetadata, + ExecutionErrorMetadata, + ExecutionSuccessMetadata, + ExecutionTriggerSource, + HelpCenterClosedMetadata, + HelpCenterOpenedMetadata, + HelpResourceClickedMetadata, + NodeSearchMetadata, + NodeSearchResultMetadata, + PageViewMetadata, + PageVisibilityMetadata, + SettingChangedMetadata, + SurveyResponses, + TabCountMetadata, + TelemetryDispatcher, + TelemetryProvider, + TemplateFilterMetadata, + TemplateLibraryClosedMetadata, + TemplateLibraryMetadata, + TemplateMetadata, + UiButtonClickMetadata, + WorkflowCreatedMetadata, + WorkflowImportMetadata +} from './types' + +/** + * Registry that holds multiple telemetry providers and dispatches + * all tracking calls to each registered provider. + * + * Implements TelemetryDispatcher (all methods required) while dispatching + * to TelemetryProvider instances using optional chaining since providers + * only implement the methods they care about. + */ +export class TelemetryRegistry implements TelemetryDispatcher { + private providers: TelemetryProvider[] = [] + + registerProvider(provider: TelemetryProvider): void { + this.providers.push(provider) + } + + trackSignupOpened(): void { + this.providers.forEach((p) => p.trackSignupOpened?.()) + } + + trackAuth(metadata: AuthMetadata): void { + this.providers.forEach((p) => p.trackAuth?.(metadata)) + } + + trackUserLoggedIn(): void { + this.providers.forEach((p) => p.trackUserLoggedIn?.()) + } + + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { + this.providers.forEach((p) => p.trackSubscription?.(event)) + } + + trackMonthlySubscriptionSucceeded(): void { + this.providers.forEach((p) => p.trackMonthlySubscriptionSucceeded?.()) + } + + trackMonthlySubscriptionCancelled(): void { + this.providers.forEach((p) => p.trackMonthlySubscriptionCancelled?.()) + } + + trackAddApiCreditButtonClicked(): void { + this.providers.forEach((p) => p.trackAddApiCreditButtonClicked?.()) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + this.providers.forEach((p) => + p.trackApiCreditTopupButtonPurchaseClicked?.(amount) + ) + } + + trackApiCreditTopupSucceeded(): void { + this.providers.forEach((p) => p.trackApiCreditTopupSucceeded?.()) + } + + trackRunButton(options?: { + subscribe_to_run?: boolean + trigger_source?: ExecutionTriggerSource + }): void { + this.providers.forEach((p) => p.trackRunButton?.(options)) + } + + startTopupTracking(): void { + this.providers.forEach((p) => p.startTopupTracking?.()) + } + + checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean { + return this.providers.some( + (p) => p.checkForCompletedTopup?.(events) ?? false + ) + } + + clearTopupTracking(): void { + this.providers.forEach((p) => p.clearTopupTracking?.()) + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + this.providers.forEach((p) => p.trackSurvey?.(stage, responses)) + } + + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { + this.providers.forEach((p) => p.trackEmailVerification?.(stage)) + } + + trackTemplate(metadata: TemplateMetadata): void { + this.providers.forEach((p) => p.trackTemplate?.(metadata)) + } + + trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { + this.providers.forEach((p) => p.trackTemplateLibraryOpened?.(metadata)) + } + + trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { + this.providers.forEach((p) => p.trackTemplateLibraryClosed?.(metadata)) + } + + trackWorkflowImported(metadata: WorkflowImportMetadata): void { + this.providers.forEach((p) => p.trackWorkflowImported?.(metadata)) + } + + trackWorkflowOpened(metadata: WorkflowImportMetadata): void { + this.providers.forEach((p) => p.trackWorkflowOpened?.(metadata)) + } + + trackEnterLinear(metadata: EnterLinearMetadata): void { + this.providers.forEach((p) => p.trackEnterLinear?.(metadata)) + } + + trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { + this.providers.forEach((p) => p.trackPageVisibilityChanged?.(metadata)) + } + + trackTabCount(metadata: TabCountMetadata): void { + this.providers.forEach((p) => p.trackTabCount?.(metadata)) + } + + trackNodeSearch(metadata: NodeSearchMetadata): void { + this.providers.forEach((p) => p.trackNodeSearch?.(metadata)) + } + + trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { + this.providers.forEach((p) => p.trackNodeSearchResultSelected?.(metadata)) + } + + trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { + this.providers.forEach((p) => p.trackTemplateFilterChanged?.(metadata)) + } + + trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { + this.providers.forEach((p) => p.trackHelpCenterOpened?.(metadata)) + } + + trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { + this.providers.forEach((p) => p.trackHelpResourceClicked?.(metadata)) + } + + trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { + this.providers.forEach((p) => p.trackHelpCenterClosed?.(metadata)) + } + + trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { + this.providers.forEach((p) => p.trackWorkflowCreated?.(metadata)) + } + + trackWorkflowExecution(): void { + this.providers.forEach((p) => p.trackWorkflowExecution?.()) + } + + trackExecutionError(metadata: ExecutionErrorMetadata): void { + this.providers.forEach((p) => p.trackExecutionError?.(metadata)) + } + + trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { + this.providers.forEach((p) => p.trackExecutionSuccess?.(metadata)) + } + + trackSettingChanged(metadata: SettingChangedMetadata): void { + this.providers.forEach((p) => p.trackSettingChanged?.(metadata)) + } + + trackUiButtonClicked(metadata: UiButtonClickMetadata): void { + this.providers.forEach((p) => p.trackUiButtonClicked?.(metadata)) + } + + trackPageView(pageName: string, properties?: PageViewMetadata): void { + this.providers.forEach((p) => p.trackPageView?.(pageName, properties)) + } +} diff --git a/src/platform/telemetry/gtm.ts b/src/platform/telemetry/gtm.ts deleted file mode 100644 index d5c738ae4ac..00000000000 --- a/src/platform/telemetry/gtm.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { isCloud } from '@/platform/distribution/types' - -const GTM_CONTAINER_ID = 'GTM-NP9JM6K7' - -let isInitialized = false -let initPromise: Promise | null = null - -export function initGtm(): void { - if (!isCloud || typeof window === 'undefined') return - if (typeof document === 'undefined') return - if (isInitialized) return - - if (!initPromise) { - initPromise = new Promise((resolve) => { - const dataLayer = window.dataLayer ?? (window.dataLayer = []) - dataLayer.push({ - 'gtm.start': Date.now(), - event: 'gtm.js' - }) - - const script = document.createElement('script') - script.async = true - script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}` - - const finalize = () => { - isInitialized = true - resolve() - } - - script.addEventListener('load', finalize, { once: true }) - script.addEventListener('error', finalize, { once: true }) - document.head?.appendChild(script) - }) - } - - void initPromise -} - -export function pushDataLayerEvent(event: Record): void { - if (!isCloud || typeof window === 'undefined') return - const dataLayer = window.dataLayer ?? (window.dataLayer = []) - dataLayer.push(event) -} diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 3e042b43728..358d6df1046 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -2,71 +2,57 @@ * Telemetry Provider - OSS Build Safety * * CRITICAL: OSS Build Safety - * This module is conditionally compiled based on distribution. When building - * the open source version (DISTRIBUTION unset), this entire module and its dependencies - * are excluded through via tree-shaking. + * This module uses dynamic imports to ensure all cloud telemetry code + * is tree-shaken from OSS builds. No top-level imports of provider code. * * To verify OSS builds exclude this code: * 1. `DISTRIBUTION= pnpm build` (OSS build) - * 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing) - * 3. Check dist/assets/*.js files contain no tracking code - * - * This approach maintains complete separation between cloud and OSS builds - * while ensuring the open source version contains no telemetry dependencies. + * 2. `grep -RinE --include='*.js' 'mixpanel|googletagmanager|dataLayer' dist/` + * 3. Should find nothing */ -import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' -import type { - initGtm as gtmInit, - pushDataLayerEvent as gtmPushDataLayerEvent -} from './gtm' -import type { TelemetryProvider } from './types' - -type GtmModule = { - initGtm: typeof gtmInit - pushDataLayerEvent: typeof gtmPushDataLayerEvent -} +import type { TelemetryDispatcher } from './types' -// Singleton instance -let _telemetryProvider: TelemetryProvider | null = null -let gtmModulePromise: Promise | null = null const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud' -function loadGtmModule(): Promise { - if (!gtmModulePromise) { - gtmModulePromise = import('./gtm') - } - return gtmModulePromise -} +let _telemetryRegistry: TelemetryDispatcher | null = null +let _initPromise: Promise | null = null /** - * Telemetry factory - conditionally creates provider based on distribution - * Returns singleton instance. - * - * CRITICAL: This returns undefined in OSS builds. There is no telemetry provider - * for OSS builds and all tracking calls are no-ops. + * Initialize telemetry providers for cloud builds. + * Must be called early in app startup (e.g., main.ts). + * Safe to call multiple times - only initializes once. */ -export function useTelemetry(): TelemetryProvider | null { - if (_telemetryProvider === null) { - // Use distribution check for tree-shaking - if (IS_CLOUD_BUILD) { - _telemetryProvider = new MixpanelTelemetryProvider() - } - // For OSS builds, _telemetryProvider stays null - } +export async function initTelemetry(): Promise { + if (!IS_CLOUD_BUILD) return + if (_initPromise) return _initPromise - return _telemetryProvider -} + _initPromise = (async () => { + const [ + { TelemetryRegistry }, + { MixpanelTelemetryProvider }, + { GtmTelemetryProvider } + ] = await Promise.all([ + import('./TelemetryRegistry'), + import('./providers/cloud/MixpanelTelemetryProvider'), + import('./providers/cloud/GtmTelemetryProvider') + ]) -export function initGtm(): void { - if (!IS_CLOUD_BUILD || typeof window === 'undefined') return - void loadGtmModule().then(({ initGtm }) => { - initGtm() - }) + const registry = new TelemetryRegistry() + registry.registerProvider(new MixpanelTelemetryProvider()) + registry.registerProvider(new GtmTelemetryProvider()) + + _telemetryRegistry = registry + })() + + return _initPromise } -export function pushDataLayerEvent(event: Record): void { - if (!IS_CLOUD_BUILD || typeof window === 'undefined') return - void loadGtmModule().then(({ pushDataLayerEvent }) => { - pushDataLayerEvent(event) - }) +/** + * Get the telemetry dispatcher for tracking events. + * Returns null in OSS builds - all tracking calls become no-ops. + * + * Usage: useTelemetry()?.trackAuth({ method: 'google' }) + */ +export function useTelemetry(): TelemetryDispatcher | null { + return _telemetryRegistry } diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts new file mode 100644 index 00000000000..fa83fc39f90 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -0,0 +1,93 @@ +import type { + AuthMetadata, + PageViewMetadata, + TelemetryProvider +} from '../../types' + +declare global { + interface Window { + dataLayer?: Record[] + } +} + +/** + * Google Tag Manager telemetry provider. + * Pushes events to the GTM dataLayer for GA4 and marketing integrations. + * + * Only implements events relevant to GTM/GA4 tracking. + * Other methods are no-ops (not implemented since interface is optional). + */ +export class GtmTelemetryProvider implements TelemetryProvider { + private dataLayer: Record[] = [] + private initialized = false + + constructor() { + this.initialize() + } + + private initialize(): void { + if (typeof window === 'undefined') return + + const gtmId = window.__CONFIG__?.gtm_id + if (!gtmId) { + if (import.meta.env.MODE === 'development') { + console.warn('[GTM] No GTM ID configured, skipping initialization') + } + return + } + + window.dataLayer = window.dataLayer || [] + this.dataLayer = window.dataLayer + + this.dataLayer.push({ + 'gtm.start': new Date().getTime(), + event: 'gtm.js' + }) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmId}` + document.head.insertBefore(script, document.head.firstChild) + + this.initialized = true + } + + private pushEvent(event: string, properties?: Record): void { + if (!this.initialized) return + this.dataLayer.push({ event, ...properties }) + } + + trackPageView(pageName: string, properties?: PageViewMetadata): void { + this.pushEvent('page_view', { + page_title: pageName, + page_location: properties?.path, + page_referrer: properties?.referrer + }) + } + + trackAuth(metadata: AuthMetadata): void { + if (metadata.is_new_user) { + this.pushEvent('sign_up', { + method: metadata.method + }) + } else { + this.pushEvent('login', { + method: metadata.method + }) + } + } + + trackMonthlySubscriptionSucceeded(): void { + this.pushEvent('purchase', { + currency: 'USD', + items: [{ item_name: 'Monthly Subscription' }] + }) + } + + trackApiCreditTopupSucceeded(): void { + this.pushEvent('purchase', { + currency: 'USD', + items: [{ item_name: 'API Credits' }] + }) + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 2ce9c7f0f9f..8a49e8d3db6 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -269,80 +269,101 @@ export interface WorkflowCreatedMetadata { } /** - * Core telemetry provider interface + * Page view metadata for route tracking + */ +export interface PageViewMetadata { + path?: string + referrer?: string + title?: string + [key: string]: unknown +} + +/** + * Telemetry provider interface for individual providers. + * All methods are optional - providers only implement what they need. */ export interface TelemetryProvider { // Authentication flow events - trackSignupOpened(): void - trackAuth(metadata: AuthMetadata): void - trackUserLoggedIn(): void + trackSignupOpened?(): void + trackAuth?(metadata: AuthMetadata): void + trackUserLoggedIn?(): void // Subscription flow events - trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void - trackMonthlySubscriptionSucceeded(): void - trackMonthlySubscriptionCancelled(): void - trackAddApiCreditButtonClicked(): void - trackApiCreditTopupButtonPurchaseClicked(amount: number): void - trackApiCreditTopupSucceeded(): void - trackRunButton(options?: { + trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void + trackMonthlySubscriptionSucceeded?(): void + trackMonthlySubscriptionCancelled?(): void + trackAddApiCreditButtonClicked?(): void + trackApiCreditTopupButtonPurchaseClicked?(amount: number): void + trackApiCreditTopupSucceeded?(): void + trackRunButton?(options?: { subscribe_to_run?: boolean trigger_source?: ExecutionTriggerSource }): void // Credit top-up tracking (composition with internal utilities) - startTopupTracking(): void - checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean - clearTopupTracking(): void + startTopupTracking?(): void + checkForCompletedTopup?(events: AuditLog[] | undefined | null): boolean + clearTopupTracking?(): void // Survey flow events - trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void + trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void // Email verification events - trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void + trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void // Template workflow events - trackTemplate(metadata: TemplateMetadata): void - trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void - trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void + trackTemplate?(metadata: TemplateMetadata): void + trackTemplateLibraryOpened?(metadata: TemplateLibraryMetadata): void + trackTemplateLibraryClosed?(metadata: TemplateLibraryClosedMetadata): void // Workflow management events - trackWorkflowImported(metadata: WorkflowImportMetadata): void - trackWorkflowOpened(metadata: WorkflowImportMetadata): void - trackEnterLinear(metadata: EnterLinearMetadata): void + trackWorkflowImported?(metadata: WorkflowImportMetadata): void + trackWorkflowOpened?(metadata: WorkflowImportMetadata): void + trackEnterLinear?(metadata: EnterLinearMetadata): void // Page visibility events - trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void + trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void // Tab tracking events - trackTabCount(metadata: TabCountMetadata): void + trackTabCount?(metadata: TabCountMetadata): void // Node search analytics events - trackNodeSearch(metadata: NodeSearchMetadata): void - trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void + trackNodeSearch?(metadata: NodeSearchMetadata): void + trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void // Template filter tracking events - trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void + trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void // Help center events - trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void - trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void - trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void + trackHelpCenterOpened?(metadata: HelpCenterOpenedMetadata): void + trackHelpResourceClicked?(metadata: HelpResourceClickedMetadata): void + trackHelpCenterClosed?(metadata: HelpCenterClosedMetadata): void // Workflow creation events - trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void + trackWorkflowCreated?(metadata: WorkflowCreatedMetadata): void // Workflow execution events - trackWorkflowExecution(): void - trackExecutionError(metadata: ExecutionErrorMetadata): void - trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void + trackWorkflowExecution?(): void + trackExecutionError?(metadata: ExecutionErrorMetadata): void + trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void // Settings events - trackSettingChanged(metadata: SettingChangedMetadata): void + trackSettingChanged?(metadata: SettingChangedMetadata): void // Generic UI button click events - trackUiButtonClicked(metadata: UiButtonClickMetadata): void + trackUiButtonClicked?(metadata: UiButtonClickMetadata): void + + // Page view tracking + trackPageView?(pageName: string, properties?: PageViewMetadata): void } +/** + * Telemetry dispatcher interface returned by useTelemetry(). + * All methods are required - the registry implements all methods and dispatches + * to registered providers using optional chaining. + */ +export type TelemetryDispatcher = Required + /** * Telemetry event constants * @@ -415,7 +436,10 @@ export const TelemetryEvents = { EXECUTION_ERROR: 'execution_error', EXECUTION_SUCCESS: 'execution_success', // Generic UI Button Click - UI_BUTTON_CLICKED: 'app:ui_button_clicked' + UI_BUTTON_CLICKED: 'app:ui_button_clicked', + + // Page View + PAGE_VIEW: 'app:page_view' } as const export type TelemetryEventName = diff --git a/src/router.ts b/src/router.ts index adc6848af00..f1edc45e997 100644 --- a/src/router.ts +++ b/src/router.ts @@ -9,7 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router' import { useFeatureFlags } from '@/composables/useFeatureFlags' import { isCloud } from '@/platform/distribution/types' -import { pushDataLayerEvent } from '@/platform/telemetry' +import { useTelemetry } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useUserStore } from '@/stores/userStore' @@ -40,10 +40,8 @@ const basePath = getBasePath() function pushPageView(): void { if (!isCloud || typeof window === 'undefined') return - pushDataLayerEvent({ - event: 'page_view', - page_location: window.location.href, - page_title: document.title + useTelemetry()?.trackPageView(document.title, { + path: window.location.href }) } diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 965ba3a8a0c..fae2adb565c 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -25,10 +25,7 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { isCloud } from '@/platform/distribution/types' -import { - pushDataLayerEvent as pushDataLayerEventBase, - useTelemetry -} from '@/platform/telemetry' +import { useTelemetry } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -88,7 +85,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (!isCloud || typeof window === 'undefined') return try { - pushDataLayerEventBase(event) + window.dataLayer = window.dataLayer || [] + window.dataLayer.push(event) } catch (error) { console.warn('Failed to push data layer event', error) } From acbaf04cbe10c8f027f2fe19d88c601f972eeb6a Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 15:28:33 -0800 Subject: [PATCH 09/43] fix: harden telemetry dispatch --- .../composables/useSubscription.test.ts | 39 +++---- .../composables/useSubscription.ts | 48 +++++---- src/platform/telemetry/TelemetryRegistry.ts | 100 +++++++++++------- .../providers/cloud/GtmTelemetryProvider.ts | 26 ++--- src/platform/telemetry/types.ts | 18 ++++ src/stores/firebaseAuthStore.ts | 74 ++++++------- 6 files changed, 171 insertions(+), 134 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index dafb4a4319e..5cbc7021ed7 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -21,7 +21,8 @@ const { ), mockTelemetry: { trackSubscription: vi.fn(), - trackMonthlySubscriptionCancelled: vi.fn() + trackMonthlySubscriptionCancelled: vi.fn(), + trackSubscriptionPurchase: vi.fn() }, mockUserId: { value: 'user-123' } })) @@ -111,8 +112,8 @@ describe('useSubscription', () => { mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() + mockTelemetry.trackSubscriptionPurchase.mockReset() mockUserId.value = 'user-123' - window.dataLayer = [] window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -242,7 +243,6 @@ describe('useSubscription', () => { }) it('pushes purchase event after a pending subscription completes', async () => { - window.dataLayer = [] localStorage.setItem( 'pending_subscription_purchase', JSON.stringify({ @@ -268,25 +268,26 @@ describe('useSubscription', () => { await fetchStatus() - expect(window.dataLayer).toHaveLength(1) - expect(window.dataLayer?.[0]).toMatchObject({ - event: 'purchase', - transaction_id: 'sub_123', - currency: 'USD', - items: [ - { - item_id: 'monthly_creator', - item_variant: 'monthly', - item_category: 'subscription', - quantity: 1 - } - ] - }) + expect(mockTelemetry.trackSubscriptionPurchase).toHaveBeenCalledTimes(1) + expect(mockTelemetry.trackSubscriptionPurchase).toHaveBeenCalledWith( + expect.objectContaining({ + transaction_id: 'sub_123', + currency: 'USD', + value: expect.any(Number), + items: [ + expect.objectContaining({ + item_id: 'monthly_creator', + item_variant: 'monthly', + item_category: 'subscription', + quantity: 1 + }) + ] + }) + ) expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() }) it('ignores pending purchase when user does not match', async () => { - window.dataLayer = [] localStorage.setItem( 'pending_subscription_purchase', JSON.stringify({ @@ -313,7 +314,7 @@ describe('useSubscription', () => { await fetchStatus() - expect(window.dataLayer).toHaveLength(0) + expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() }) diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 8916f941407..1d6cc986ba6 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 { SubscriptionPurchaseMetadata } from '@/platform/telemetry/types' import { FirebaseAuthStoreError, useFirebaseAuthStore @@ -45,7 +46,8 @@ function useSubscriptionInternal() { const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { showSubscriptionRequiredDialog } = useDialogService() - const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore() + const firebaseAuthStore = useFirebaseAuthStore() + const { getFirebaseAuthHeader } = firebaseAuthStore const { wrapWithErrorHandlingAsync } = useErrorHandling() const { isLoggedIn } = useCurrentUser() @@ -109,9 +111,11 @@ function useSubscriptionInternal() { ): void { if (!status?.is_active || !status.subscription_id) return - if (!userId) return + if (!firebaseAuthStore.userId) return - const pendingPurchase = getPendingSubscriptionPurchase(userId) + const pendingPurchase = getPendingSubscriptionPurchase( + firebaseAuthStore.userId + ) if (!pendingPurchase) return const { tierKey, billingCycle } = pendingPurchase @@ -122,24 +126,26 @@ function useSubscriptionInternal() { : baseName const unitPrice = getTierPrice(tierKey, isYearly) const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice - if (typeof window !== 'undefined') { - window.dataLayer = window.dataLayer || [] - window.dataLayer.push({ - event: 'purchase', - transaction_id: status.subscription_id, - value, - currency: 'USD', - items: [ - { - item_id: `${billingCycle}_${tierKey}`, - item_name: planName, - item_category: 'subscription', - item_variant: billingCycle, - price: value, - quantity: 1 - } - ] - }) + const metadata: SubscriptionPurchaseMetadata = { + transaction_id: status.subscription_id, + value, + currency: 'USD', + items: [ + { + item_id: `${billingCycle}_${tierKey}`, + item_name: planName, + item_category: 'subscription', + item_variant: billingCycle, + price: value, + quantity: 1 + } + ] + } + + try { + telemetry?.trackSubscriptionPurchase(metadata) + } catch (error) { + console.error('Failed to track subscription purchase', error) } clearPendingSubscriptionPurchase() diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts index e7b4aacfa23..13ee47171c6 100644 --- a/src/platform/telemetry/TelemetryRegistry.ts +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -14,6 +14,7 @@ import type { PageViewMetadata, PageVisibilityMetadata, SettingChangedMetadata, + SubscriptionPurchaseMetadata, SurveyResponses, TabCountMetadata, TelemetryDispatcher, @@ -42,157 +43,178 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.providers.push(provider) } + private dispatch(action: (provider: TelemetryProvider) => void): void { + this.providers.forEach((provider) => { + try { + action(provider) + } catch (error) { + console.error('[Telemetry] Provider dispatch failed', error) + } + }) + } + trackSignupOpened(): void { - this.providers.forEach((p) => p.trackSignupOpened?.()) + this.dispatch((provider) => provider.trackSignupOpened?.()) } trackAuth(metadata: AuthMetadata): void { - this.providers.forEach((p) => p.trackAuth?.(metadata)) + this.dispatch((provider) => provider.trackAuth?.(metadata)) } trackUserLoggedIn(): void { - this.providers.forEach((p) => p.trackUserLoggedIn?.()) + this.dispatch((provider) => provider.trackUserLoggedIn?.()) } trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { - this.providers.forEach((p) => p.trackSubscription?.(event)) + this.dispatch((provider) => provider.trackSubscription?.(event)) } trackMonthlySubscriptionSucceeded(): void { - this.providers.forEach((p) => p.trackMonthlySubscriptionSucceeded?.()) + this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.()) } trackMonthlySubscriptionCancelled(): void { - this.providers.forEach((p) => p.trackMonthlySubscriptionCancelled?.()) + this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.()) + } + + trackSubscriptionPurchase(metadata: SubscriptionPurchaseMetadata): void { + this.dispatch((provider) => provider.trackSubscriptionPurchase?.(metadata)) } trackAddApiCreditButtonClicked(): void { - this.providers.forEach((p) => p.trackAddApiCreditButtonClicked?.()) + this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.()) } trackApiCreditTopupButtonPurchaseClicked(amount: number): void { - this.providers.forEach((p) => - p.trackApiCreditTopupButtonPurchaseClicked?.(amount) + this.dispatch((provider) => + provider.trackApiCreditTopupButtonPurchaseClicked?.(amount) ) } trackApiCreditTopupSucceeded(): void { - this.providers.forEach((p) => p.trackApiCreditTopupSucceeded?.()) + this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.()) } trackRunButton(options?: { subscribe_to_run?: boolean trigger_source?: ExecutionTriggerSource }): void { - this.providers.forEach((p) => p.trackRunButton?.(options)) + this.dispatch((provider) => provider.trackRunButton?.(options)) } startTopupTracking(): void { - this.providers.forEach((p) => p.startTopupTracking?.()) + this.dispatch((provider) => provider.startTopupTracking?.()) } checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean { - return this.providers.some( - (p) => p.checkForCompletedTopup?.(events) ?? false - ) + return this.providers.some((provider) => { + try { + return provider.checkForCompletedTopup?.(events) ?? false + } catch (error) { + console.error('[Telemetry] Provider dispatch failed', error) + return false + } + }) } clearTopupTracking(): void { - this.providers.forEach((p) => p.clearTopupTracking?.()) + this.dispatch((provider) => provider.clearTopupTracking?.()) } trackSurvey( stage: 'opened' | 'submitted', responses?: SurveyResponses ): void { - this.providers.forEach((p) => p.trackSurvey?.(stage, responses)) + this.dispatch((provider) => provider.trackSurvey?.(stage, responses)) } trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { - this.providers.forEach((p) => p.trackEmailVerification?.(stage)) + this.dispatch((provider) => provider.trackEmailVerification?.(stage)) } trackTemplate(metadata: TemplateMetadata): void { - this.providers.forEach((p) => p.trackTemplate?.(metadata)) + this.dispatch((provider) => provider.trackTemplate?.(metadata)) } trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { - this.providers.forEach((p) => p.trackTemplateLibraryOpened?.(metadata)) + this.dispatch((provider) => provider.trackTemplateLibraryOpened?.(metadata)) } trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { - this.providers.forEach((p) => p.trackTemplateLibraryClosed?.(metadata)) + this.dispatch((provider) => provider.trackTemplateLibraryClosed?.(metadata)) } trackWorkflowImported(metadata: WorkflowImportMetadata): void { - this.providers.forEach((p) => p.trackWorkflowImported?.(metadata)) + this.dispatch((provider) => provider.trackWorkflowImported?.(metadata)) } trackWorkflowOpened(metadata: WorkflowImportMetadata): void { - this.providers.forEach((p) => p.trackWorkflowOpened?.(metadata)) + this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata)) } trackEnterLinear(metadata: EnterLinearMetadata): void { - this.providers.forEach((p) => p.trackEnterLinear?.(metadata)) + this.dispatch((provider) => provider.trackEnterLinear?.(metadata)) } trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { - this.providers.forEach((p) => p.trackPageVisibilityChanged?.(metadata)) + this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata)) } trackTabCount(metadata: TabCountMetadata): void { - this.providers.forEach((p) => p.trackTabCount?.(metadata)) + this.dispatch((provider) => provider.trackTabCount?.(metadata)) } trackNodeSearch(metadata: NodeSearchMetadata): void { - this.providers.forEach((p) => p.trackNodeSearch?.(metadata)) + this.dispatch((provider) => provider.trackNodeSearch?.(metadata)) } trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { - this.providers.forEach((p) => p.trackNodeSearchResultSelected?.(metadata)) + this.dispatch((provider) => + provider.trackNodeSearchResultSelected?.(metadata) + ) } trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { - this.providers.forEach((p) => p.trackTemplateFilterChanged?.(metadata)) + this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata)) } trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { - this.providers.forEach((p) => p.trackHelpCenterOpened?.(metadata)) + this.dispatch((provider) => provider.trackHelpCenterOpened?.(metadata)) } trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { - this.providers.forEach((p) => p.trackHelpResourceClicked?.(metadata)) + this.dispatch((provider) => provider.trackHelpResourceClicked?.(metadata)) } trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { - this.providers.forEach((p) => p.trackHelpCenterClosed?.(metadata)) + this.dispatch((provider) => provider.trackHelpCenterClosed?.(metadata)) } trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { - this.providers.forEach((p) => p.trackWorkflowCreated?.(metadata)) + this.dispatch((provider) => provider.trackWorkflowCreated?.(metadata)) } trackWorkflowExecution(): void { - this.providers.forEach((p) => p.trackWorkflowExecution?.()) + this.dispatch((provider) => provider.trackWorkflowExecution?.()) } trackExecutionError(metadata: ExecutionErrorMetadata): void { - this.providers.forEach((p) => p.trackExecutionError?.(metadata)) + this.dispatch((provider) => provider.trackExecutionError?.(metadata)) } trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { - this.providers.forEach((p) => p.trackExecutionSuccess?.(metadata)) + this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata)) } trackSettingChanged(metadata: SettingChangedMetadata): void { - this.providers.forEach((p) => p.trackSettingChanged?.(metadata)) + this.dispatch((provider) => provider.trackSettingChanged?.(metadata)) } trackUiButtonClicked(metadata: UiButtonClickMetadata): void { - this.providers.forEach((p) => p.trackUiButtonClicked?.(metadata)) + this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata)) } trackPageView(pageName: string, properties?: PageViewMetadata): void { - this.providers.forEach((p) => p.trackPageView?.(pageName, properties)) + this.dispatch((provider) => provider.trackPageView?.(pageName, properties)) } } diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index fa83fc39f90..3a9b169feea 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -1,6 +1,7 @@ import type { AuthMetadata, PageViewMetadata, + SubscriptionPurchaseMetadata, TelemetryProvider } from '../../types' @@ -66,22 +67,17 @@ export class GtmTelemetryProvider implements TelemetryProvider { } trackAuth(metadata: AuthMetadata): void { + const basePayload = { + method: metadata.method, + ...(metadata.user_id_hash ? { user_id: metadata.user_id_hash } : {}) + } + if (metadata.is_new_user) { - this.pushEvent('sign_up', { - method: metadata.method - }) - } else { - this.pushEvent('login', { - method: metadata.method - }) + this.pushEvent('sign_up', basePayload) + return } - } - trackMonthlySubscriptionSucceeded(): void { - this.pushEvent('purchase', { - currency: 'USD', - items: [{ item_name: 'Monthly Subscription' }] - }) + this.pushEvent('login', basePayload) } trackApiCreditTopupSucceeded(): void { @@ -90,4 +86,8 @@ export class GtmTelemetryProvider implements TelemetryProvider { items: [{ item_name: 'API Credits' }] }) } + + trackSubscriptionPurchase(metadata: SubscriptionPurchaseMetadata): void { + this.pushEvent('purchase', metadata) + } } diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 8a49e8d3db6..443abfa16f9 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -20,6 +20,7 @@ import type { AuditLog } from '@/services/customerEventsService' export interface AuthMetadata { method?: 'email' | 'google' | 'github' is_new_user?: boolean + user_id_hash?: string referrer_url?: string utm_source?: string utm_medium?: string @@ -278,6 +279,22 @@ export interface PageViewMetadata { [key: string]: unknown } +export interface SubscriptionPurchaseItem { + item_id: string + item_name: string + item_category: string + item_variant: string + price: number + quantity: number +} + +export interface SubscriptionPurchaseMetadata extends Record { + transaction_id: string + value: number + currency: string + items: SubscriptionPurchaseItem[] +} + /** * Telemetry provider interface for individual providers. * All methods are optional - providers only implement what they need. @@ -292,6 +309,7 @@ export interface TelemetryProvider { trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void trackMonthlySubscriptionSucceeded?(): void trackMonthlySubscriptionCancelled?(): void + trackSubscriptionPurchase?(metadata: SubscriptionPurchaseMetadata): void trackAddApiCreditButtonClicked?(): void trackApiCreditTopupButtonPurchaseClicked?(amount: number): void trackApiCreditTopupSucceeded?(): void diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index fae2adb565c..7b81e9d8e30 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -26,6 +26,7 @@ import { t } from '@/i18n' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' +import type { AuthMetadata } from '@/platform/telemetry/types' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -81,17 +82,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` - function pushDataLayerEvent(event: Record): void { - if (!isCloud || typeof window === 'undefined') return - - try { - window.dataLayer = window.dataLayer || [] - window.dataLayer.push(event) - } catch (error) { - console.warn('Failed to push data layer event', error) - } - } - async function hashSha256(value: string): Promise { if (typeof crypto === 'undefined' || !crypto.subtle) return if (typeof TextEncoder === 'undefined') return @@ -102,20 +92,25 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { .join('') } - async function trackSignUp(method: 'email' | 'google' | 'github') { - if (!isCloud || typeof window === 'undefined') return + async function buildAuthMetadata( + method: 'email' | 'google' | 'github', + isNewUser: boolean, + userId?: string + ): Promise { + const metadata: AuthMetadata = { method, is_new_user: isNewUser } - try { - const userId = currentUser.value?.uid - const hashedUserId = userId ? await hashSha256(userId) : undefined - pushDataLayerEvent({ - event: 'sign_up', - method, - ...(hashedUserId ? { user_id: hashedUserId } : {}) - }) - } catch (error) { - console.warn('Failed to track sign up', error) + if (isNewUser && userId) { + try { + const userIdHash = await hashSha256(userId) + if (userIdHash) { + metadata.user_id_hash = userIdHash + } + } catch (error) { + console.warn('Failed to hash user id for telemetry', error) + } } + + return metadata } // Providers @@ -405,11 +400,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { ) if (isCloud) { - useTelemetry()?.trackAuth({ - method: 'email', - is_new_user: true - }) - await trackSignUp('email') + const metadata = await buildAuthMetadata('email', true, result.user.uid) + useTelemetry()?.trackAuth(metadata) } return result @@ -424,13 +416,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { const additionalUserInfo = getAdditionalUserInfo(result) const isNewUser = additionalUserInfo?.isNewUser ?? false - useTelemetry()?.trackAuth({ - method: 'google', - is_new_user: isNewUser - }) - if (isNewUser) { - await trackSignUp('google') - } + const metadata = await buildAuthMetadata( + 'google', + isNewUser, + result.user.uid + ) + useTelemetry()?.trackAuth(metadata) } return result @@ -445,13 +436,12 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { const additionalUserInfo = getAdditionalUserInfo(result) const isNewUser = additionalUserInfo?.isNewUser ?? false - useTelemetry()?.trackAuth({ - method: 'github', - is_new_user: isNewUser - }) - if (isNewUser) { - await trackSignUp('github') - } + const metadata = await buildAuthMetadata( + 'github', + isNewUser, + result.user.uid + ) + useTelemetry()?.trackAuth(metadata) } return result From c375b53de97207aedb82529fd192ec309eb17576 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 15:29:35 -0800 Subject: [PATCH 10/43] fix: tidy telemetry types --- src/platform/telemetry/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 443abfa16f9..8f839a0e701 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -279,7 +279,7 @@ export interface PageViewMetadata { [key: string]: unknown } -export interface SubscriptionPurchaseItem { +interface SubscriptionPurchaseItem { item_id: string item_name: string item_category: string From f2cf5ab155e4c458b505154e42ea9627020f1d2b Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 16:26:19 -0800 Subject: [PATCH 11/43] fix: guard subscription purchase tracking --- .../CloudSubscriptionRedirectView.vue | 6 +- .../subscription/components/PricingTable.vue | 15 ++- .../composables/useSubscription.test.ts | 46 +++++++- .../composables/useSubscription.ts | 39 +++++++ .../utils/subscriptionCheckoutUtil.ts | 13 ++- .../utils/subscriptionPurchaseTracker.ts | 105 +++++++++++++++++- 6 files changed, 210 insertions(+), 14 deletions(-) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index d4da851ad3a..21881dda1c8 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -20,7 +20,8 @@ const router = useRouter() const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() -const { isActiveSubscription, isInitialized } = useSubscription() +const { isActiveSubscription, isInitialized, subscriptionStatus } = + useSubscription() const selectedTierKey = ref(null) @@ -83,7 +84,8 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { await performSubscriptionCheckout( tierKey, cycleParam as BillingCycle, - false + false, + subscriptionStatus.value ?? undefined ) } }, reportError) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 1520a94bfb6..3bf6a9c4b9b 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -328,8 +328,12 @@ const tiers: PricingTierConfig[] = [ ] const { n } = useI18n() -const { isActiveSubscription, subscriptionTier, isYearlySubscription } = - useSubscription() +const { + isActiveSubscription, + subscriptionStatus, + subscriptionTier, + isYearlySubscription +} = useSubscription() const { accessBillingPortal, reportError } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() @@ -430,7 +434,12 @@ const handleSubscribe = wrapWithErrorHandlingAsync( await accessBillingPortal(checkoutTier) } } else { - await performSubscriptionCheckout(tierKey, currentBillingCycle.value) + await performSubscriptionCheckout( + tierKey, + currentBillingCycle.value, + true, + subscriptionStatus.value ?? undefined + ) } } finally { isLoading.value = false diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 5cbc7021ed7..8c8606125af 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -249,7 +249,10 @@ describe('useSubscription', () => { firebaseUid: 'user-123', tierKey: 'creator', billingCycle: 'monthly', - timestamp: Date.now() + timestamp: Date.now(), + previous_status: { + is_active: false + } }) ) @@ -294,7 +297,10 @@ describe('useSubscription', () => { firebaseUid: 'user-123', tierKey: 'creator', billingCycle: 'monthly', - timestamp: Date.now() + timestamp: Date.now(), + previous_status: { + is_active: false + } }) ) @@ -318,6 +324,42 @@ describe('useSubscription', () => { expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() }) + it('skips purchase when subscription was already active and unchanged', async () => { + localStorage.setItem( + 'pending_subscription_purchase', + JSON.stringify({ + firebaseUid: 'user-123', + tierKey: 'creator', + billingCycle: 'monthly', + timestamp: Date.now(), + previous_status: { + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + subscription_duration: 'MONTHLY' + } + }) + ) + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + subscription_duration: 'MONTHLY' + }) + } as Response) + + mockIsLoggedIn.value = true + const { fetchStatus } = useSubscriptionWithScope() + + await fetchStatus() + + expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() + expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() + }) + it('should handle fetch errors gracefully', async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 1d6cc986ba6..7213b9a2209 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -118,6 +118,45 @@ function useSubscriptionInternal() { ) if (!pendingPurchase) return + const statusTierKey = status.subscription_tier + ? TIER_TO_KEY[status.subscription_tier] + : null + const statusBillingCycle = + status.subscription_duration === 'ANNUAL' + ? 'yearly' + : status.subscription_duration === 'MONTHLY' + ? 'monthly' + : null + + if ( + statusTierKey && + statusBillingCycle && + (statusTierKey !== pendingPurchase.tierKey || + statusBillingCycle !== pendingPurchase.billingCycle) + ) { + clearPendingSubscriptionPurchase() + return + } + + const previousStatus = pendingPurchase.previous_status + const wasActive = previousStatus?.is_active + const hasSubscriptionIdChange = + previousStatus?.subscription_id !== undefined && + previousStatus.subscription_id !== status.subscription_id + const hasTierChange = + previousStatus?.subscription_tier !== undefined && + previousStatus.subscription_tier !== status.subscription_tier + const hasDurationChange = + previousStatus?.subscription_duration !== undefined && + previousStatus.subscription_duration !== status.subscription_duration + const hasSubscriptionChange = + hasSubscriptionIdChange || hasTierChange || hasDurationChange + + if (wasActive !== false && !hasSubscriptionChange) { + clearPendingSubscriptionPurchase() + return + } + const { tierKey, billingCycle } = pendingPurchase const isYearly = billingCycle === 'yearly' const baseName = t(`subscription.tiers.${tierKey}.name`) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 7c9c0ec19cd..7786246cc98 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -6,7 +6,8 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' +import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'; +import type { SubscriptionStatusSnapshot } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'; import type { BillingCycle } from './subscriptionTierRank' type CheckoutTier = TierKey | `${TierKey}-yearly` @@ -32,7 +33,8 @@ const getCheckoutTier = ( export async function performSubscriptionCheckout( tierKey: TierKey, currentBillingCycle: BillingCycle, - openInNewTab: boolean = true + openInNewTab: boolean = true, + previousStatus?: SubscriptionStatusSnapshot ): Promise { if (!isCloud) return @@ -80,7 +82,12 @@ export async function performSubscriptionCheckout( if (data.checkout_url) { if (userId) { - startSubscriptionPurchaseTracking(tierKey, currentBillingCycle, userId) + startSubscriptionPurchaseTracking( + tierKey, + currentBillingCycle, + userId, + previousStatus + ) } if (openInNewTab) { window.open(data.checkout_url, '_blank') diff --git a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts index 9f962fff677..1e833f59bba 100644 --- a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts +++ b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts @@ -6,6 +6,14 @@ type PendingSubscriptionPurchase = { tierKey: TierKey billingCycle: BillingCycle timestamp: number + previous_status?: SubscriptionStatusSnapshot +} + +export type SubscriptionStatusSnapshot = { + is_active?: boolean + subscription_id?: string | null + subscription_tier?: string | null + subscription_duration?: string | null } const STORAGE_KEY = 'pending_subscription_purchase' @@ -24,16 +32,34 @@ const safeRemove = (): void => { export function startSubscriptionPurchaseTracking( tierKey: TierKey, billingCycle: BillingCycle, - firebaseUid: string + firebaseUid: string, + previousStatus?: SubscriptionStatusSnapshot ): void { if (typeof window === 'undefined') return if (!firebaseUid) return try { + const sanitizedStatus = previousStatus + ? { + ...(previousStatus.is_active !== undefined + ? { is_active: previousStatus.is_active } + : {}), + ...(previousStatus.subscription_id + ? { subscription_id: previousStatus.subscription_id } + : {}), + ...(previousStatus.subscription_tier + ? { subscription_tier: previousStatus.subscription_tier } + : {}), + ...(previousStatus.subscription_duration + ? { subscription_duration: previousStatus.subscription_duration } + : {}) + } + : undefined const payload: PendingSubscriptionPurchase = { firebaseUid, tierKey, billingCycle, - timestamp: Date.now() + timestamp: Date.now(), + ...(sanitizedStatus ? { previous_status: sanitizedStatus } : {}) } localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) } catch { @@ -57,7 +83,13 @@ export function getPendingSubscriptionPurchase( return null } - const { firebaseUid: storedUid, tierKey, billingCycle, timestamp } = parsed + const { + firebaseUid: storedUid, + tierKey, + billingCycle, + timestamp, + previous_status: previousStatus + } = parsed if ( storedUid !== firebaseUid || !VALID_TIERS.includes(tierKey) || @@ -68,12 +100,77 @@ export function getPendingSubscriptionPurchase( return null } + if ( + previousStatus && + (typeof previousStatus !== 'object' || Array.isArray(previousStatus)) + ) { + safeRemove() + return null + } + + if ( + previousStatus?.is_active !== undefined && + typeof previousStatus.is_active !== 'boolean' + ) { + safeRemove() + return null + } + + if ( + previousStatus?.subscription_id !== undefined && + previousStatus.subscription_id !== null && + typeof previousStatus.subscription_id !== 'string' + ) { + safeRemove() + return null + } + + if ( + previousStatus?.subscription_tier !== undefined && + previousStatus.subscription_tier !== null && + typeof previousStatus.subscription_tier !== 'string' + ) { + safeRemove() + return null + } + + if ( + previousStatus?.subscription_duration !== undefined && + previousStatus.subscription_duration !== null && + typeof previousStatus.subscription_duration !== 'string' + ) { + safeRemove() + return null + } + if (Date.now() - timestamp > MAX_AGE_MS) { safeRemove() return null } - return parsed + const normalizedPreviousStatus = previousStatus + ? { + ...(previousStatus.is_active !== undefined + ? { is_active: previousStatus.is_active } + : {}), + ...(previousStatus.subscription_id + ? { subscription_id: previousStatus.subscription_id } + : {}), + ...(previousStatus.subscription_tier + ? { subscription_tier: previousStatus.subscription_tier } + : {}), + ...(previousStatus.subscription_duration + ? { subscription_duration: previousStatus.subscription_duration } + : {}) + } + : undefined + + return { + ...parsed, + ...(normalizedPreviousStatus + ? { previous_status: normalizedPreviousStatus } + : {}) + } } catch { safeRemove() return null From a43f596854f1a58a0eeac60c51095f0486e6d24f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 30 Jan 2026 00:29:08 +0000 Subject: [PATCH 12/43] [automated] Apply ESLint and Oxfmt fixes --- .../cloud/subscription/utils/subscriptionCheckoutUtil.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 7786246cc98..3d6b4d3a70c 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -6,8 +6,8 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'; -import type { SubscriptionStatusSnapshot } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'; +import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' +import type { SubscriptionStatusSnapshot } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' import type { BillingCycle } from './subscriptionTierRank' type CheckoutTier = TierKey | `${TierKey}-yearly` From d864e7e1bdf5de23b3b741794b072d7cc117fcbe Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 16:30:35 -0800 Subject: [PATCH 13/43] fix: limit gtm purchase events --- .../telemetry/providers/cloud/GtmTelemetryProvider.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 3a9b169feea..6a95d857450 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -80,13 +80,6 @@ export class GtmTelemetryProvider implements TelemetryProvider { this.pushEvent('login', basePayload) } - trackApiCreditTopupSucceeded(): void { - this.pushEvent('purchase', { - currency: 'USD', - items: [{ item_name: 'API Credits' }] - }) - } - trackSubscriptionPurchase(metadata: SubscriptionPurchaseMetadata): void { this.pushEvent('purchase', metadata) } From 66cee7d6231cde19816e6f9905c451f35c496484 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 17:55:57 -0800 Subject: [PATCH 14/43] fix: remove gtm id from remote config --- src/platform/remoteConfig/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 50744d6604f..7b8b1721cce 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -27,7 +27,6 @@ type FirebaseRuntimeConfig = { */ export type RemoteConfig = { mixpanel_token?: string - gtm_id?: string subscription_required?: boolean server_health_alert?: ServerHealthAlert max_upload_size?: number From c439ab16e564347dc5b10d5a8a6517283f894256 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 18:46:11 -0800 Subject: [PATCH 15/43] fix: isolate cloud telemetry init --- src/main.ts | 2 +- src/platform/telemetry/index.ts | 51 +++---------------------- src/platform/telemetry/initTelemetry.ts | 41 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 46 deletions(-) create mode 100644 src/platform/telemetry/initTelemetry.ts diff --git a/src/main.ts b/src/main.ts index 4af76693c8f..d8bb8e59d38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,7 +32,7 @@ if (isCloud) { await import('@/platform/remoteConfig/refreshRemoteConfig') await refreshRemoteConfig({ useAuth: false }) - const { initTelemetry } = await import('@/platform/telemetry') + const { initTelemetry } = await import('@/platform/telemetry/initTelemetry') await initTelemetry() } diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 358d6df1046..293acc21fa0 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -1,51 +1,6 @@ -/** - * Telemetry Provider - OSS Build Safety - * - * CRITICAL: OSS Build Safety - * This module uses dynamic imports to ensure all cloud telemetry code - * is tree-shaken from OSS builds. No top-level imports of provider code. - * - * To verify OSS builds exclude this code: - * 1. `DISTRIBUTION= pnpm build` (OSS build) - * 2. `grep -RinE --include='*.js' 'mixpanel|googletagmanager|dataLayer' dist/` - * 3. Should find nothing - */ import type { TelemetryDispatcher } from './types' -const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud' - let _telemetryRegistry: TelemetryDispatcher | null = null -let _initPromise: Promise | null = null - -/** - * Initialize telemetry providers for cloud builds. - * Must be called early in app startup (e.g., main.ts). - * Safe to call multiple times - only initializes once. - */ -export async function initTelemetry(): Promise { - if (!IS_CLOUD_BUILD) return - if (_initPromise) return _initPromise - - _initPromise = (async () => { - const [ - { TelemetryRegistry }, - { MixpanelTelemetryProvider }, - { GtmTelemetryProvider } - ] = await Promise.all([ - import('./TelemetryRegistry'), - import('./providers/cloud/MixpanelTelemetryProvider'), - import('./providers/cloud/GtmTelemetryProvider') - ]) - - const registry = new TelemetryRegistry() - registry.registerProvider(new MixpanelTelemetryProvider()) - registry.registerProvider(new GtmTelemetryProvider()) - - _telemetryRegistry = registry - })() - - return _initPromise -} /** * Get the telemetry dispatcher for tracking events. @@ -56,3 +11,9 @@ export async function initTelemetry(): Promise { export function useTelemetry(): TelemetryDispatcher | null { return _telemetryRegistry } + +export function setTelemetryRegistry( + registry: TelemetryDispatcher | null +): void { + _telemetryRegistry = registry +} diff --git a/src/platform/telemetry/initTelemetry.ts b/src/platform/telemetry/initTelemetry.ts new file mode 100644 index 00000000000..e9bec3065b3 --- /dev/null +++ b/src/platform/telemetry/initTelemetry.ts @@ -0,0 +1,41 @@ +/** + * Telemetry Provider - Cloud Initialization + * + * This module is only imported in cloud builds to keep + * cloud telemetry code out of local/desktop bundles. + */ +import { setTelemetryRegistry } from './index' + +const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud' + +let _initPromise: Promise | null = null + +/** + * Initialize telemetry providers for cloud builds. + * Must be called early in app startup (e.g., main.ts). + * Safe to call multiple times - only initializes once. + */ +export async function initTelemetry(): Promise { + if (!IS_CLOUD_BUILD) return + if (_initPromise) return _initPromise + + _initPromise = (async () => { + const [ + { TelemetryRegistry }, + { MixpanelTelemetryProvider }, + { GtmTelemetryProvider } + ] = await Promise.all([ + import('./TelemetryRegistry'), + import('./providers/cloud/MixpanelTelemetryProvider'), + import('./providers/cloud/GtmTelemetryProvider') + ]) + + const registry = new TelemetryRegistry() + registry.registerProvider(new MixpanelTelemetryProvider()) + registry.registerProvider(new GtmTelemetryProvider()) + + setTelemetryRegistry(registry) + })() + + return _initPromise +} From a4fce641d9037ec3d04aaed0c2fce6096a43a164 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 18:50:30 -0800 Subject: [PATCH 16/43] fix: define gtm id for cloud builds --- global.d.ts | 2 +- src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts | 2 +- vite.config.mts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/global.d.ts b/global.d.ts index 71678702f9f..e5e27e26feb 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,6 +1,7 @@ declare const __COMFYUI_FRONTEND_VERSION__: string declare const __SENTRY_ENABLED__: boolean declare const __SENTRY_DSN__: string +declare const __GTM_ID__: string declare const __ALGOLIA_APP_ID__: string declare const __ALGOLIA_API_KEY__: string declare const __USE_PROD_CONFIG__: boolean @@ -8,7 +9,6 @@ declare const __USE_PROD_CONFIG__: boolean interface Window { __CONFIG__: { mixpanel_token?: string - gtm_id?: string require_whitelist?: boolean subscription_required?: boolean max_upload_size?: number diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 6a95d857450..ac544a33f79 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -29,7 +29,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { private initialize(): void { if (typeof window === 'undefined') return - const gtmId = window.__CONFIG__?.gtm_id + const gtmId = __GTM_ID__ if (!gtmId) { if (import.meta.env.MODE === 'development') { console.warn('[GTM] No GTM ID configured, skipping initialization') diff --git a/vite.config.mts b/vite.config.mts index 9769ec300ae..641a8ee1b48 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -507,6 +507,7 @@ export default defineConfig({ !(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN) ), __SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''), + __GTM_ID__: JSON.stringify(DISTRIBUTION === 'cloud' ? 'GTM-NP9JM6K7' : ''), __ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''), __ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''), __USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true', From dd2e18d297d4c0962af82cf35ba8d859e94f9430 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 19:23:24 -0800 Subject: [PATCH 17/43] fix: guard subscription purchase telemetry --- .../composables/useSubscription.test.ts | 43 ++++++++++++++++++- .../composables/useSubscription.ts | 10 +++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 8c8606125af..46693d00290 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -10,9 +10,11 @@ const { mockShowSubscriptionRequiredDialog, mockGetAuthHeader, mockTelemetry, - mockUserId + mockUserId, + mockIsCloud } = vi.hoisted(() => ({ mockIsLoggedIn: { value: false }, + mockIsCloud: { value: true }, mockReportError: vi.fn(), mockAccessBillingPortal: vi.fn(), mockShowSubscriptionRequiredDialog: vi.fn(), @@ -78,7 +80,9 @@ vi.mock('@/composables/useErrorHandling', () => ({ })) vi.mock('@/platform/distribution/types', () => ({ - isCloud: true + get isCloud() { + return mockIsCloud.value + } })) vi.mock('@/services/dialogService', () => ({ @@ -114,6 +118,7 @@ describe('useSubscription', () => { mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() mockTelemetry.trackSubscriptionPurchase.mockReset() mockUserId.value = 'user-123' + mockIsCloud.value = true window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -290,6 +295,40 @@ describe('useSubscription', () => { expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() }) + it('skips purchase tracking outside cloud distribution', async () => { + localStorage.setItem( + 'pending_subscription_purchase', + JSON.stringify({ + firebaseUid: 'user-123', + tierKey: 'creator', + billingCycle: 'monthly', + timestamp: Date.now(), + previous_status: { + is_active: false + } + }) + ) + + mockIsCloud.value = false + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + is_active: true, + subscription_id: 'sub_123', + subscription_tier: 'CREATOR', + subscription_duration: 'MONTHLY' + }) + } as Response) + + mockIsLoggedIn.value = true + const { fetchStatus } = useSubscriptionWithScope() + + await fetchStatus() + + expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() + expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() + }) + it('ignores pending purchase when user does not match', async () => { localStorage.setItem( 'pending_subscription_purchase', diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 7213b9a2209..d7ae23e5c60 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -181,10 +181,12 @@ function useSubscriptionInternal() { ] } - try { - telemetry?.trackSubscriptionPurchase(metadata) - } catch (error) { - console.error('Failed to track subscription purchase', error) + if (isCloud) { + try { + telemetry?.trackSubscriptionPurchase(metadata) + } catch (error) { + console.error('Failed to track subscription purchase', error) + } } clearPendingSubscriptionPurchase() From 921c1682e84edec0daa91d74be00c064f9fc336d Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 21:57:20 -0800 Subject: [PATCH 18/43] test: make firebase auth mock userId dynamic --- .../cloud/subscription/composables/useSubscription.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 46693d00290..8c6b3092af5 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -94,7 +94,9 @@ vi.mock('@/services/dialogService', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: vi.fn(() => ({ getFirebaseAuthHeader: mockGetAuthHeader, - userId: mockUserId.value + get userId() { + return mockUserId.value + } })), FirebaseAuthStoreError: class extends Error {} })) From 0d2dc2fa957bf3efd37daedb8f0a2dc20dda6645 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 22:13:01 -0800 Subject: [PATCH 19/43] Use allowlist --- scripts/verify-dist-no-telemetry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dist-no-telemetry.ts b/scripts/verify-dist-no-telemetry.ts index 42d40b9c593..f20ab947e77 100644 --- a/scripts/verify-dist-no-telemetry.ts +++ b/scripts/verify-dist-no-telemetry.ts @@ -8,7 +8,7 @@ type Pattern = { } const distDir = resolve('dist') -const ignoredExtensions = new Set(['.map', '.svg']) +const allowedExtensions = new Set(['.html', '.js', '.map', '.ts']) const telemetryPatterns: Pattern[] = [ { label: 'GTM container', regex: /GTM-[A-Z0-9]+/i }, { label: 'GTM script', regex: /gtm\.js/i }, @@ -30,7 +30,7 @@ const violations: Array<{ for (const file of files) { const extension = extname(file).toLowerCase() - if (ignoredExtensions.has(extension)) continue + if (!allowedExtensions.has(extension)) continue const content = await readFile(file, 'utf8') const hits = telemetryPatterns.flatMap((pattern) => { From 8c75f14cc13e3f0752b445fd10134715db0dbcb0 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 22:15:57 -0800 Subject: [PATCH 20/43] pin SHAs --- .github/workflows/ci-dist-telemetry-scan.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index 327cb5b6ccb..ea34ce02147 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -13,15 +13,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 - name: Install pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: version: 10 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 'lts/*' cache: 'pnpm' From 894fe540d9331923914b18f769ef9e76dbb0c84a Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 29 Jan 2026 22:18:44 -0800 Subject: [PATCH 21/43] chore: update ci dist telemetry action pins --- .github/workflows/ci-dist-telemetry-scan.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index ea34ce02147..c29e7e6a671 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install pnpm uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 @@ -21,7 +21,7 @@ jobs: version: 10 - name: Use Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: 'lts/*' cache: 'pnpm' From 20a951488bb7d1373e4da1980eae88dc587e3a41 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 30 Jan 2026 01:40:22 -0800 Subject: [PATCH 22/43] test: fix subscription mocks --- .../onboarding/CloudSubscriptionRedirectView.test.ts | 9 ++++++--- .../cloud/subscription/components/PricingTable.test.ts | 3 ++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index 68fec345d91..78aeacd8e5f 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -40,7 +40,8 @@ vi.mock('@/composables/useErrorHandling', () => ({ const subscriptionMocks = vi.hoisted(() => ({ isActiveSubscription: { value: false }, - isInitialized: { value: true } + isInitialized: { value: true }, + subscriptionStatus: { value: null } })) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ @@ -125,7 +126,8 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + false, + undefined ) // Shows loading affordances @@ -156,7 +158,8 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false + false, + undefined ) }) }) diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index 7e649e8fc0a..59cb78469e8 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -22,7 +22,8 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ useSubscription: () => ({ isActiveSubscription: computed(() => mockIsActiveSubscription.value), subscriptionTier: computed(() => mockSubscriptionTier.value), - isYearlySubscription: computed(() => mockIsYearlySubscription.value) + isYearlySubscription: computed(() => mockIsYearlySubscription.value), + subscriptionStatus: ref(null) }) })) From 6927c15416eed0eba069d6919a222d43e5733a01 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 30 Jan 2026 04:02:42 -0800 Subject: [PATCH 23/43] Fix circular import by extracting ComfyWorkflow Moves ComfyWorkflow to a dedicated file and uses dynamic imports for dependencies (ChangeTracker, WorkflowDraftStore) that indirectly import app.ts. This breaks the cycle app -> subgraphStore -> workflowStore -> app that was causing crashes during startup and tests. --- .../management/stores/comfyWorkflow.ts | 157 ++++++++++++++++++ .../management/stores/workflowStore.ts | 150 +---------------- src/stores/subgraphStore.ts | 8 +- 3 files changed, 163 insertions(+), 152 deletions(-) create mode 100644 src/platform/workflow/management/stores/comfyWorkflow.ts diff --git a/src/platform/workflow/management/stores/comfyWorkflow.ts b/src/platform/workflow/management/stores/comfyWorkflow.ts new file mode 100644 index 00000000000..ec3d9407f12 --- /dev/null +++ b/src/platform/workflow/management/stores/comfyWorkflow.ts @@ -0,0 +1,157 @@ +import { markRaw } from 'vue' + +import { t } from '@/i18n' +import type { ChangeTracker } from '@/scripts/changeTracker' +import { useDialogService } from '@/services/dialogService' +import { UserFile } from '@/stores/userFileStore' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' + +export class ComfyWorkflow extends UserFile { + static readonly basePath: string = 'workflows/' + readonly tintCanvasBg?: string + + /** + * The change tracker for the workflow. Non-reactive raw object. + */ + changeTracker: ChangeTracker | null = null + /** + * Whether the workflow has been modified comparing to the initial state. + */ + _isModified: boolean = false + + /** + * @param options The path, modified, and size of the workflow. + * Note: path is the full path, including the 'workflows/' prefix. + */ + constructor(options: { path: string; modified: number; size: number }) { + super(options.path, options.modified, options.size) + } + + override get key() { + return this.path.substring(ComfyWorkflow.basePath.length) + } + + get activeState(): ComfyWorkflowJSON | null { + return this.changeTracker?.activeState ?? null + } + + get initialState(): ComfyWorkflowJSON | null { + return this.changeTracker?.initialState ?? null + } + + override get isLoaded(): boolean { + return this.changeTracker !== null + } + + override get isModified(): boolean { + return this._isModified + } + + override set isModified(value: boolean) { + this._isModified = value + } + + /** + * Load the workflow content from remote storage. Directly returns the loaded + * workflow if the content is already loaded. + * + * @param force Whether to force loading the content even if it is already loaded. + * @returns this + */ + override async load({ force = false }: { force?: boolean } = {}): Promise< + this & LoadedComfyWorkflow + > { + const { useWorkflowDraftStore } = + await import('@/platform/workflow/persistence/stores/workflowDraftStore') + const draftStore = useWorkflowDraftStore() + let draft = !force ? draftStore.getDraft(this.path) : undefined + let draftState: ComfyWorkflowJSON | null = null + let draftContent: string | null = null + + if (draft) { + if (draft.updatedAt < this.lastModified) { + draftStore.removeDraft(this.path) + draft = undefined + } + } + + if (draft) { + try { + draftState = JSON.parse(draft.data) + draftContent = draft.data + } catch (err) { + console.warn('Failed to parse workflow draft, clearing it', err) + draftStore.removeDraft(this.path) + } + } + + await super.load({ force }) + if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow + + if (!this.originalContent) { + throw new Error('[ASSERT] Workflow content should be loaded') + } + + const initialState = JSON.parse(this.originalContent) + const { ChangeTracker } = await import('@/scripts/changeTracker') + this.changeTracker = markRaw(new ChangeTracker(this, initialState)) + if (draftState && draftContent) { + this.changeTracker.activeState = draftState + this.content = draftContent + this._isModified = true + draftStore.markDraftUsed(this.path) + } + return this as this & LoadedComfyWorkflow + } + + override unload(): void { + this.changeTracker = null + super.unload() + } + + override async save() { + const { useWorkflowDraftStore } = + await import('@/platform/workflow/persistence/stores/workflowDraftStore') + const draftStore = useWorkflowDraftStore() + this.content = JSON.stringify(this.activeState) + // Force save to ensure the content is updated in remote storage incase + // the isModified state is screwed by changeTracker. + const ret = await super.save({ force: true }) + this.changeTracker?.reset() + this.isModified = false + draftStore.removeDraft(this.path) + return ret + } + + /** + * Save the workflow as a new file. + * @param path The path to save the workflow to. Note: with 'workflows/' prefix. + * @returns this + */ + override async saveAs(path: string) { + const { useWorkflowDraftStore } = + await import('@/platform/workflow/persistence/stores/workflowDraftStore') + const draftStore = useWorkflowDraftStore() + this.content = JSON.stringify(this.activeState) + const result = await super.saveAs(path) + draftStore.removeDraft(path) + return result + } + + async promptSave(): Promise { + return await useDialogService().prompt({ + title: t('workflowService.saveWorkflow'), + message: t('workflowService.enterFilename') + ':', + defaultValue: this.filename + }) + } +} + +export interface LoadedComfyWorkflow extends ComfyWorkflow { + isLoaded: true + originalContent: string + content: string + changeTracker: ChangeTracker + initialState: ComfyWorkflowJSON + activeState: ComfyWorkflowJSON +} diff --git a/src/platform/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts index 364f952bb88..a4956b47b9c 100644 --- a/src/platform/workflow/management/stores/workflowStore.ts +++ b/src/platform/workflow/management/stores/workflowStore.ts @@ -4,7 +4,6 @@ import { defineStore } from 'pinia' import { computed, markRaw, ref, shallowRef, watch } from 'vue' import type { Raw } from 'vue' -import { t } from '@/i18n' import type { LGraph, LGraphNode, @@ -18,10 +17,7 @@ import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/wo import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' -import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' -import { useDialogService } from '@/services/dialogService' -import { UserFile } from '@/stores/userFileStore' import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' import { createNodeExecutionId, @@ -32,149 +28,9 @@ import { import { generateUUID, getPathDetails } from '@/utils/formatUtil' import { syncEntities } from '@/utils/syncUtil' import { isSubgraph } from '@/utils/typeGuardUtil' - -export class ComfyWorkflow extends UserFile { - static readonly basePath: string = 'workflows/' - readonly tintCanvasBg?: string - - /** - * The change tracker for the workflow. Non-reactive raw object. - */ - changeTracker: ChangeTracker | null = null - /** - * Whether the workflow has been modified comparing to the initial state. - */ - _isModified: boolean = false - - /** - * @param options The path, modified, and size of the workflow. - * Note: path is the full path, including the 'workflows/' prefix. - */ - constructor(options: { path: string; modified: number; size: number }) { - super(options.path, options.modified, options.size) - } - - override get key() { - return this.path.substring(ComfyWorkflow.basePath.length) - } - - get activeState(): ComfyWorkflowJSON | null { - return this.changeTracker?.activeState ?? null - } - - get initialState(): ComfyWorkflowJSON | null { - return this.changeTracker?.initialState ?? null - } - - override get isLoaded(): boolean { - return this.changeTracker !== null - } - - override get isModified(): boolean { - return this._isModified - } - - override set isModified(value: boolean) { - this._isModified = value - } - - /** - * Load the workflow content from remote storage. Directly returns the loaded - * workflow if the content is already loaded. - * - * @param force Whether to force loading the content even if it is already loaded. - * @returns this - */ - override async load({ force = false }: { force?: boolean } = {}): Promise< - this & LoadedComfyWorkflow - > { - const draftStore = useWorkflowDraftStore() - let draft = !force ? draftStore.getDraft(this.path) : undefined - let draftState: ComfyWorkflowJSON | null = null - let draftContent: string | null = null - - if (draft) { - if (draft.updatedAt < this.lastModified) { - draftStore.removeDraft(this.path) - draft = undefined - } - } - - if (draft) { - try { - draftState = JSON.parse(draft.data) - draftContent = draft.data - } catch (err) { - console.warn('Failed to parse workflow draft, clearing it', err) - draftStore.removeDraft(this.path) - } - } - - await super.load({ force }) - if (!force && this.isLoaded) return this as this & LoadedComfyWorkflow - - if (!this.originalContent) { - throw new Error('[ASSERT] Workflow content should be loaded') - } - - const initialState = JSON.parse(this.originalContent) - this.changeTracker = markRaw(new ChangeTracker(this, initialState)) - if (draftState && draftContent) { - this.changeTracker.activeState = draftState - this.content = draftContent - this._isModified = true - draftStore.markDraftUsed(this.path) - } - return this as this & LoadedComfyWorkflow - } - - override unload(): void { - this.changeTracker = null - super.unload() - } - - override async save() { - const draftStore = useWorkflowDraftStore() - this.content = JSON.stringify(this.activeState) - // Force save to ensure the content is updated in remote storage incase - // the isModified state is screwed by changeTracker. - const ret = await super.save({ force: true }) - this.changeTracker?.reset() - this.isModified = false - draftStore.removeDraft(this.path) - return ret - } - - /** - * Save the workflow as a new file. - * @param path The path to save the workflow to. Note: with 'workflows/' prefix. - * @returns this - */ - override async saveAs(path: string) { - const draftStore = useWorkflowDraftStore() - this.content = JSON.stringify(this.activeState) - const result = await super.saveAs(path) - draftStore.removeDraft(path) - return result - } - - async promptSave(): Promise { - return await useDialogService().prompt({ - title: t('workflowService.saveWorkflow'), - message: t('workflowService.enterFilename') + ':', - defaultValue: this.filename - }) - } -} - -export interface LoadedComfyWorkflow extends ComfyWorkflow { - isLoaded: true - originalContent: string - content: string - changeTracker: ChangeTracker - initialState: ComfyWorkflowJSON - activeState: ComfyWorkflowJSON -} +import { ComfyWorkflow } from './comfyWorkflow'; +import type { LoadedComfyWorkflow } from './comfyWorkflow'; +export { ComfyWorkflow, type LoadedComfyWorkflow } /** * Exposed store interface for the workflow store. diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 3c0456f2e37..29d6f41f2c8 100644 --- a/src/stores/subgraphStore.ts +++ b/src/stores/subgraphStore.ts @@ -6,11 +6,9 @@ import { SubgraphNode } from '@/lib/litegraph/src/litegraph' import { useSettingStore } from '@/platform/settings/settingStore' import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' -import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' -import { - ComfyWorkflow, - useWorkflowStore -} from '@/platform/workflow/management/stores/workflowStore' +import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow' +import { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { ComfyNode, ComfyWorkflowJSON, From 22c76c7b0f25f08baf22ac10855073cffd0823eb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 30 Jan 2026 12:05:58 +0000 Subject: [PATCH 24/43] [automated] Apply ESLint and Oxfmt fixes --- src/platform/workflow/management/stores/workflowStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts index a4956b47b9c..17d36f6b2c1 100644 --- a/src/platform/workflow/management/stores/workflowStore.ts +++ b/src/platform/workflow/management/stores/workflowStore.ts @@ -28,8 +28,8 @@ import { import { generateUUID, getPathDetails } from '@/utils/formatUtil' import { syncEntities } from '@/utils/syncUtil' import { isSubgraph } from '@/utils/typeGuardUtil' -import { ComfyWorkflow } from './comfyWorkflow'; -import type { LoadedComfyWorkflow } from './comfyWorkflow'; +import { ComfyWorkflow } from './comfyWorkflow' +import type { LoadedComfyWorkflow } from './comfyWorkflow' export { ComfyWorkflow, type LoadedComfyWorkflow } /** From 3899450adba8cd66291590ff471a900638a2fc3f Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 30 Jan 2026 04:20:52 -0800 Subject: [PATCH 25/43] Lazy import useDialogService --- src/platform/workflow/management/stores/comfyWorkflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/workflow/management/stores/comfyWorkflow.ts b/src/platform/workflow/management/stores/comfyWorkflow.ts index ec3d9407f12..0adfe177864 100644 --- a/src/platform/workflow/management/stores/comfyWorkflow.ts +++ b/src/platform/workflow/management/stores/comfyWorkflow.ts @@ -2,7 +2,6 @@ import { markRaw } from 'vue' import { t } from '@/i18n' import type { ChangeTracker } from '@/scripts/changeTracker' -import { useDialogService } from '@/services/dialogService' import { UserFile } from '@/stores/userFileStore' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' @@ -139,6 +138,7 @@ export class ComfyWorkflow extends UserFile { } async promptSave(): Promise { + const { useDialogService } = await import('@/services/dialogService') return await useDialogService().prompt({ title: t('workflowService.saveWorkflow'), message: t('workflowService.enterFilename') + ':', From 100d0ebb3bb6be02f4c4da104f0a08eb46db58d3 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 30 Jan 2026 12:33:53 -0800 Subject: [PATCH 26/43] Align with proper item name and item name --- .../subscription/composables/useSubscription.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index d7ae23e5c60..ec6c45af94e 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -159,10 +159,10 @@ function useSubscriptionInternal() { const { tierKey, billingCycle } = pendingPurchase const isYearly = billingCycle === 'yearly' - const baseName = t(`subscription.tiers.${tierKey}.name`) - const planName = isYearly - ? t('subscription.tierNameYearly', { name: baseName }) - : baseName + const itemId = `${billingCycle}_${tierKey}` + const itemName = `${billingCycle === 'yearly' ? 'Yearly' : 'Monthly'} ${ + tierKey.charAt(0).toUpperCase() + tierKey.slice(1) + }` const unitPrice = getTierPrice(tierKey, isYearly) const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice const metadata: SubscriptionPurchaseMetadata = { @@ -171,8 +171,8 @@ function useSubscriptionInternal() { currency: 'USD', items: [ { - item_id: `${billingCycle}_${tierKey}`, - item_name: planName, + item_id: itemId, + item_name: itemName, item_category: 'subscription', item_variant: billingCycle, price: value, From 50a2f16b7273ecdf7dc1c943af0032b3411e9df1 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 30 Jan 2026 12:54:48 -0800 Subject: [PATCH 27/43] Prefer Cloud Remote Config --- global.d.ts | 2 +- src/platform/remoteConfig/types.ts | 1 + src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts | 2 +- vite.config.mts | 1 - 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/global.d.ts b/global.d.ts index e5e27e26feb..5c4b6abab2b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,13 +1,13 @@ declare const __COMFYUI_FRONTEND_VERSION__: string declare const __SENTRY_ENABLED__: boolean declare const __SENTRY_DSN__: string -declare const __GTM_ID__: string declare const __ALGOLIA_APP_ID__: string declare const __ALGOLIA_API_KEY__: string declare const __USE_PROD_CONFIG__: boolean interface Window { __CONFIG__: { + gtm_container_id?: string mixpanel_token?: string require_whitelist?: boolean subscription_required?: boolean diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 7b8b1721cce..f81b7f23875 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -26,6 +26,7 @@ type FirebaseRuntimeConfig = { * Configuration fetched from the server at runtime */ export type RemoteConfig = { + gtm_container_id?: string mixpanel_token?: string subscription_required?: boolean server_health_alert?: ServerHealthAlert diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index ac544a33f79..17a92239562 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -29,7 +29,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { private initialize(): void { if (typeof window === 'undefined') return - const gtmId = __GTM_ID__ + const gtmId = window.__CONFIG__?.gtm_container_id if (!gtmId) { if (import.meta.env.MODE === 'development') { console.warn('[GTM] No GTM ID configured, skipping initialization') diff --git a/vite.config.mts b/vite.config.mts index 641a8ee1b48..9769ec300ae 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -507,7 +507,6 @@ export default defineConfig({ !(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN) ), __SENTRY_DSN__: JSON.stringify(process.env.SENTRY_DSN || ''), - __GTM_ID__: JSON.stringify(DISTRIBUTION === 'cloud' ? 'GTM-NP9JM6K7' : ''), __ALGOLIA_APP_ID__: JSON.stringify(process.env.ALGOLIA_APP_ID || ''), __ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''), __USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true', From 3ec99e8518aeb183226d0e9ed3a167c962a54607 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 15:54:09 -0800 Subject: [PATCH 28/43] fix: exclude ts files from dist scan --- scripts/verify-dist-no-telemetry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-dist-no-telemetry.ts b/scripts/verify-dist-no-telemetry.ts index f20ab947e77..651c421fc6f 100644 --- a/scripts/verify-dist-no-telemetry.ts +++ b/scripts/verify-dist-no-telemetry.ts @@ -8,7 +8,7 @@ type Pattern = { } const distDir = resolve('dist') -const allowedExtensions = new Set(['.html', '.js', '.map', '.ts']) +const allowedExtensions = new Set(['.html', '.js', '.map']) const telemetryPatterns: Pattern[] = [ { label: 'GTM container', regex: /GTM-[A-Z0-9]+/i }, { label: 'GTM script', regex: /gtm\.js/i }, From ea55302a407bb578373bf6888104632fce59c8dc Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 18:36:18 -0800 Subject: [PATCH 29/43] fix: rename page view tracking helper --- src/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/router.ts b/src/router.ts index f1edc45e997..16783008b1e 100644 --- a/src/router.ts +++ b/src/router.ts @@ -37,7 +37,7 @@ function getBasePath(): string { const basePath = getBasePath() -function pushPageView(): void { +function trackPageView(): void { if (!isCloud || typeof window === 'undefined') return useTelemetry()?.trackPageView(document.title, { @@ -103,7 +103,7 @@ installPreservedQueryTracker(router, [ ]) router.afterEach(() => { - pushPageView() + trackPageView() }) if (isCloud) { From 68944ca81e3105761aed29d760645c293161fa9f Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 18:51:41 -0800 Subject: [PATCH 30/43] fix: move auth telemetry metadata builder --- .../utils/__tests__/authMetadata.test.ts | 53 +++++++++++++++++++ src/platform/telemetry/utils/authMetadata.ts | 32 +++++++++++ src/stores/firebaseAuthStore.ts | 33 +----------- 3 files changed, 86 insertions(+), 32 deletions(-) create mode 100644 src/platform/telemetry/utils/__tests__/authMetadata.test.ts create mode 100644 src/platform/telemetry/utils/authMetadata.ts diff --git a/src/platform/telemetry/utils/__tests__/authMetadata.test.ts b/src/platform/telemetry/utils/__tests__/authMetadata.test.ts new file mode 100644 index 00000000000..6f1b6a67a02 --- /dev/null +++ b/src/platform/telemetry/utils/__tests__/authMetadata.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { buildAuthMetadata } from '../authMetadata' + +describe('buildAuthMetadata', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('hashes user id for new users', async () => { + const digestSpy = vi + .spyOn(globalThis.crypto.subtle, 'digest') + .mockResolvedValue(new Uint8Array([0, 1, 2, 255]).buffer) + + const metadata = await buildAuthMetadata('email', true, 'user-123') + + expect(digestSpy).toHaveBeenCalledWith( + 'SHA-256', + new TextEncoder().encode('user-123') + ) + expect(metadata).toMatchObject({ + method: 'email', + is_new_user: true, + user_id_hash: '000102ff' + }) + }) + + it('does not hash when user is not new', async () => { + const digestSpy = vi.spyOn(globalThis.crypto.subtle, 'digest') + + const metadata = await buildAuthMetadata('google', false, 'user-123') + + expect(digestSpy).not.toHaveBeenCalled() + expect(metadata).toMatchObject({ + method: 'google', + is_new_user: false + }) + expect(metadata.user_id_hash).toBeUndefined() + }) + + it('returns base metadata when crypto is unavailable', async () => { + vi.stubGlobal('crypto', undefined) + + const metadata = await buildAuthMetadata('github', true, 'user-123') + + expect(metadata).toMatchObject({ + method: 'github', + is_new_user: true + }) + expect(metadata.user_id_hash).toBeUndefined() + }) +}) diff --git a/src/platform/telemetry/utils/authMetadata.ts b/src/platform/telemetry/utils/authMetadata.ts new file mode 100644 index 00000000000..784bb771049 --- /dev/null +++ b/src/platform/telemetry/utils/authMetadata.ts @@ -0,0 +1,32 @@ +import type { AuthMetadata } from '../types' + +async function hashSha256(value: string): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) return + if (typeof TextEncoder === 'undefined') return + const data = new TextEncoder().encode(value) + const hash = await crypto.subtle.digest('SHA-256', data) + return Array.from(new Uint8Array(hash)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') +} + +export async function buildAuthMetadata( + method: 'email' | 'google' | 'github', + isNewUser: boolean, + userId?: string +): Promise { + const metadata: AuthMetadata = { method, is_new_user: isNewUser } + + if (isNewUser && userId) { + try { + const userIdHash = await hashSha256(userId) + if (userIdHash) { + metadata.user_id_hash = userIdHash + } + } catch (error) { + console.warn('Failed to hash user id for telemetry', error) + } + } + + return metadata +} diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 7b81e9d8e30..0e746238f73 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -26,7 +26,7 @@ import { t } from '@/i18n' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' -import type { AuthMetadata } from '@/platform/telemetry/types' +import { buildAuthMetadata } from '@/platform/telemetry/utils/authMetadata' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -82,37 +82,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` - async function hashSha256(value: string): Promise { - if (typeof crypto === 'undefined' || !crypto.subtle) return - if (typeof TextEncoder === 'undefined') return - const data = new TextEncoder().encode(value) - const hash = await crypto.subtle.digest('SHA-256', data) - return Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - } - - async function buildAuthMetadata( - method: 'email' | 'google' | 'github', - isNewUser: boolean, - userId?: string - ): Promise { - const metadata: AuthMetadata = { method, is_new_user: isNewUser } - - if (isNewUser && userId) { - try { - const userIdHash = await hashSha256(userId) - if (userIdHash) { - metadata.user_id_hash = userIdHash - } - } catch (error) { - console.warn('Failed to hash user id for telemetry', error) - } - } - - return metadata - } - // Providers const googleProvider = new GoogleAuthProvider() googleProvider.addScope('email') From 233a0c710f628861ca55644f8e373d500101918a Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 18:56:26 -0800 Subject: [PATCH 31/43] Rename test:dist:no-telemetry --- .github/workflows/ci-dist-telemetry-scan.yaml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index c29e7e6a671..ad84b0a18a6 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -33,4 +33,4 @@ jobs: run: pnpm build - name: Scan dist for telemetry references - run: pnpm test:dist + run: pnpm test:dist:no-telemetry diff --git a/package.json b/package.json index eb67c0ecf91..2cc9019d94a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'", "test:browser": "pnpm exec nx e2e", "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser", - "test:dist": "tsx scripts/verify-dist-no-telemetry.ts", + "test:dist:no-telemetry": "tsx scripts/verify-dist-no-telemetry.ts", "test:unit": "nx run test", "typecheck": "vue-tsc --noEmit", "typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck", From 294675b1e5de6630017fc977efe50418990fd7b6 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 19:40:42 -0800 Subject: [PATCH 32/43] Update src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts Co-authored-by: Christian Byrne --- src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 17a92239562..aa8c52aaceb 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -16,7 +16,6 @@ declare global { * Pushes events to the GTM dataLayer for GA4 and marketing integrations. * * Only implements events relevant to GTM/GA4 tracking. - * Other methods are no-ops (not implemented since interface is optional). */ export class GtmTelemetryProvider implements TelemetryProvider { private dataLayer: Record[] = [] From 3cd9de43f73d070af9cd5f2adb0176c2f68da3ff Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 19:49:58 -0800 Subject: [PATCH 33/43] fix: use window.dataLayer directly --- .../telemetry/providers/cloud/GtmTelemetryProvider.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 17a92239562..c24153ef794 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -19,7 +19,6 @@ declare global { * Other methods are no-ops (not implemented since interface is optional). */ export class GtmTelemetryProvider implements TelemetryProvider { - private dataLayer: Record[] = [] private initialized = false constructor() { @@ -38,9 +37,8 @@ export class GtmTelemetryProvider implements TelemetryProvider { } window.dataLayer = window.dataLayer || [] - this.dataLayer = window.dataLayer - this.dataLayer.push({ + window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }) @@ -55,7 +53,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { private pushEvent(event: string, properties?: Record): void { if (!this.initialized) return - this.dataLayer.push({ event, ...properties }) + window.dataLayer?.push({ event, ...properties }) } trackPageView(pageName: string, properties?: PageViewMetadata): void { From 2b2043d3bc771c9af9f25e6190a4dc9d04d492a1 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 20:00:12 -0800 Subject: [PATCH 34/43] fix: remove duplicate dataLayer declaration --- .../telemetry/providers/cloud/GtmTelemetryProvider.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index f66e6f866eb..bd629370a21 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -5,12 +5,6 @@ import type { TelemetryProvider } from '../../types' -declare global { - interface Window { - dataLayer?: Record[] - } -} - /** * Google Tag Manager telemetry provider. * Pushes events to the GTM dataLayer for GA4 and marketing integrations. From 320cafd9f2fd8acd2ec44401976ce4b87d9fddd3 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 20:03:23 -0800 Subject: [PATCH 35/43] fix: scope dist telemetry scan permissions --- .github/workflows/ci-dist-telemetry-scan.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index ad84b0a18a6..bd917d8f458 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -8,6 +8,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + jobs: scan: runs-on: ubuntu-latest From c29c6fc6fe0eaa8c4324ba799af1dc6394fc06a5 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Mon, 2 Feb 2026 20:06:13 -0800 Subject: [PATCH 36/43] test: stub crypto in auth metadata tests --- .../telemetry/utils/__tests__/authMetadata.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/platform/telemetry/utils/__tests__/authMetadata.test.ts b/src/platform/telemetry/utils/__tests__/authMetadata.test.ts index 6f1b6a67a02..450b6fd2782 100644 --- a/src/platform/telemetry/utils/__tests__/authMetadata.test.ts +++ b/src/platform/telemetry/utils/__tests__/authMetadata.test.ts @@ -9,13 +9,14 @@ describe('buildAuthMetadata', () => { }) it('hashes user id for new users', async () => { - const digestSpy = vi - .spyOn(globalThis.crypto.subtle, 'digest') + const digestMock = vi + .fn() .mockResolvedValue(new Uint8Array([0, 1, 2, 255]).buffer) + vi.stubGlobal('crypto', { subtle: { digest: digestMock } }) const metadata = await buildAuthMetadata('email', true, 'user-123') - expect(digestSpy).toHaveBeenCalledWith( + expect(digestMock).toHaveBeenCalledWith( 'SHA-256', new TextEncoder().encode('user-123') ) @@ -27,11 +28,12 @@ describe('buildAuthMetadata', () => { }) it('does not hash when user is not new', async () => { - const digestSpy = vi.spyOn(globalThis.crypto.subtle, 'digest') + const digestMock = vi.fn() + vi.stubGlobal('crypto', { subtle: { digest: digestMock } }) const metadata = await buildAuthMetadata('google', false, 'user-123') - expect(digestSpy).not.toHaveBeenCalled() + expect(digestMock).not.toHaveBeenCalled() expect(metadata).toMatchObject({ method: 'google', is_new_user: false From dfbbac8249830b7ed78d3384f12c4d951644bd69 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 3 Feb 2026 21:44:59 -0800 Subject: [PATCH 37/43] fix: track begin checkout in gtm --- .../CloudSubscriptionRedirectView.vue | 6 +- .../subscription/components/PricingTable.vue | 11 +- .../composables/useSubscription.test.ts | 156 +-------------- .../composables/useSubscription.ts | 101 +--------- .../utils/subscriptionCheckoutUtil.test.ts | 68 +++++++ .../utils/subscriptionCheckoutUtil.ts | 18 +- .../utils/subscriptionPurchaseTracker.ts | 183 ------------------ src/platform/telemetry/TelemetryRegistry.ts | 10 +- .../providers/cloud/GtmTelemetryProvider.ts | 8 +- src/platform/telemetry/types.ts | 24 +-- .../utils/__tests__/authMetadata.test.ts | 55 ------ src/platform/telemetry/utils/authMetadata.ts | 32 --- src/stores/firebaseAuthStore.ts | 33 ++-- 13 files changed, 117 insertions(+), 588 deletions(-) create mode 100644 src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts delete mode 100644 src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts delete mode 100644 src/platform/telemetry/utils/__tests__/authMetadata.test.ts delete mode 100644 src/platform/telemetry/utils/authMetadata.ts diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue index 21881dda1c8..d4da851ad3a 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.vue @@ -20,8 +20,7 @@ const router = useRouter() const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() -const { isActiveSubscription, isInitialized, subscriptionStatus } = - useSubscription() +const { isActiveSubscription, isInitialized } = useSubscription() const selectedTierKey = ref(null) @@ -84,8 +83,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => { await performSubscriptionCheckout( tierKey, cycleParam as BillingCycle, - false, - subscriptionStatus.value ?? undefined + false ) } }, reportError) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 3bf6a9c4b9b..049a6f36b8d 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -328,12 +328,8 @@ const tiers: PricingTierConfig[] = [ ] const { n } = useI18n() -const { - isActiveSubscription, - subscriptionStatus, - subscriptionTier, - isYearlySubscription -} = useSubscription() +const { isActiveSubscription, subscriptionTier, isYearlySubscription } = + useSubscription() const { accessBillingPortal, reportError } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() @@ -437,8 +433,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync( await performSubscriptionCheckout( tierKey, currentBillingCycle.value, - true, - subscriptionStatus.value ?? undefined + true ) } } finally { diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index 8c6b3092af5..b6b8851147a 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -23,8 +23,7 @@ const { ), mockTelemetry: { trackSubscription: vi.fn(), - trackMonthlySubscriptionCancelled: vi.fn(), - trackSubscriptionPurchase: vi.fn() + trackMonthlySubscriptionCancelled: vi.fn() }, mockUserId: { value: 'user-123' } })) @@ -118,7 +117,6 @@ describe('useSubscription', () => { mockIsLoggedIn.value = false mockTelemetry.trackSubscription.mockReset() mockTelemetry.trackMonthlySubscriptionCancelled.mockReset() - mockTelemetry.trackSubscriptionPurchase.mockReset() mockUserId.value = 'user-123' mockIsCloud.value = true window.__CONFIG__ = { @@ -249,158 +247,6 @@ describe('useSubscription', () => { ) }) - it('pushes purchase event after a pending subscription completes', async () => { - localStorage.setItem( - 'pending_subscription_purchase', - JSON.stringify({ - firebaseUid: 'user-123', - tierKey: 'creator', - billingCycle: 'monthly', - timestamp: Date.now(), - previous_status: { - is_active: false - } - }) - ) - - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - is_active: true, - subscription_id: 'sub_123', - subscription_tier: 'CREATOR', - subscription_duration: 'MONTHLY' - }) - } as Response) - - mockIsLoggedIn.value = true - const { fetchStatus } = useSubscriptionWithScope() - - await fetchStatus() - - expect(mockTelemetry.trackSubscriptionPurchase).toHaveBeenCalledTimes(1) - expect(mockTelemetry.trackSubscriptionPurchase).toHaveBeenCalledWith( - expect.objectContaining({ - transaction_id: 'sub_123', - currency: 'USD', - value: expect.any(Number), - items: [ - expect.objectContaining({ - item_id: 'monthly_creator', - item_variant: 'monthly', - item_category: 'subscription', - quantity: 1 - }) - ] - }) - ) - expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() - }) - - it('skips purchase tracking outside cloud distribution', async () => { - localStorage.setItem( - 'pending_subscription_purchase', - JSON.stringify({ - firebaseUid: 'user-123', - tierKey: 'creator', - billingCycle: 'monthly', - timestamp: Date.now(), - previous_status: { - is_active: false - } - }) - ) - - mockIsCloud.value = false - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - is_active: true, - subscription_id: 'sub_123', - subscription_tier: 'CREATOR', - subscription_duration: 'MONTHLY' - }) - } as Response) - - mockIsLoggedIn.value = true - const { fetchStatus } = useSubscriptionWithScope() - - await fetchStatus() - - expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() - expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() - }) - - it('ignores pending purchase when user does not match', async () => { - localStorage.setItem( - 'pending_subscription_purchase', - JSON.stringify({ - firebaseUid: 'user-123', - tierKey: 'creator', - billingCycle: 'monthly', - timestamp: Date.now(), - previous_status: { - is_active: false - } - }) - ) - - mockUserId.value = 'user-456' - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - is_active: true, - subscription_id: 'sub_123', - subscription_tier: 'CREATOR', - subscription_duration: 'MONTHLY' - }) - } as Response) - - mockIsLoggedIn.value = true - const { fetchStatus } = useSubscriptionWithScope() - - await fetchStatus() - - expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() - expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() - }) - - it('skips purchase when subscription was already active and unchanged', async () => { - localStorage.setItem( - 'pending_subscription_purchase', - JSON.stringify({ - firebaseUid: 'user-123', - tierKey: 'creator', - billingCycle: 'monthly', - timestamp: Date.now(), - previous_status: { - is_active: true, - subscription_id: 'sub_123', - subscription_tier: 'CREATOR', - subscription_duration: 'MONTHLY' - } - }) - ) - - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - is_active: true, - subscription_id: 'sub_123', - subscription_tier: 'CREATOR', - subscription_duration: 'MONTHLY' - }) - } as Response) - - mockIsLoggedIn.value = true - const { fetchStatus } = useSubscriptionWithScope() - - await fetchStatus() - - expect(mockTelemetry.trackSubscriptionPurchase).not.toHaveBeenCalled() - expect(localStorage.getItem('pending_subscription_purchase')).toBeNull() - }) - it('should handle fetch errors gracefully', async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index ec6c45af94e..27297709fc8 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -8,20 +8,12 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' -import type { SubscriptionPurchaseMetadata } from '@/platform/telemetry/types' import { FirebaseAuthStoreError, useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useDialogService } from '@/services/dialogService' -import { - getTierPrice, - TIER_TO_KEY -} from '@/platform/cloud/subscription/constants/tierPricing' -import { - clearPendingSubscriptionPurchase, - getPendingSubscriptionPurchase -} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' +import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing' import type { operations } from '@/types/comfyRegistryTypes' import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher' @@ -106,92 +98,6 @@ function useSubscriptionInternal() { return `${getComfyApiBaseUrl()}${path}` } - function trackSubscriptionPurchase( - status: CloudSubscriptionStatusResponse | null - ): void { - if (!status?.is_active || !status.subscription_id) return - - if (!firebaseAuthStore.userId) return - - const pendingPurchase = getPendingSubscriptionPurchase( - firebaseAuthStore.userId - ) - if (!pendingPurchase) return - - const statusTierKey = status.subscription_tier - ? TIER_TO_KEY[status.subscription_tier] - : null - const statusBillingCycle = - status.subscription_duration === 'ANNUAL' - ? 'yearly' - : status.subscription_duration === 'MONTHLY' - ? 'monthly' - : null - - if ( - statusTierKey && - statusBillingCycle && - (statusTierKey !== pendingPurchase.tierKey || - statusBillingCycle !== pendingPurchase.billingCycle) - ) { - clearPendingSubscriptionPurchase() - return - } - - const previousStatus = pendingPurchase.previous_status - const wasActive = previousStatus?.is_active - const hasSubscriptionIdChange = - previousStatus?.subscription_id !== undefined && - previousStatus.subscription_id !== status.subscription_id - const hasTierChange = - previousStatus?.subscription_tier !== undefined && - previousStatus.subscription_tier !== status.subscription_tier - const hasDurationChange = - previousStatus?.subscription_duration !== undefined && - previousStatus.subscription_duration !== status.subscription_duration - const hasSubscriptionChange = - hasSubscriptionIdChange || hasTierChange || hasDurationChange - - if (wasActive !== false && !hasSubscriptionChange) { - clearPendingSubscriptionPurchase() - return - } - - const { tierKey, billingCycle } = pendingPurchase - const isYearly = billingCycle === 'yearly' - const itemId = `${billingCycle}_${tierKey}` - const itemName = `${billingCycle === 'yearly' ? 'Yearly' : 'Monthly'} ${ - tierKey.charAt(0).toUpperCase() + tierKey.slice(1) - }` - const unitPrice = getTierPrice(tierKey, isYearly) - const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice - const metadata: SubscriptionPurchaseMetadata = { - transaction_id: status.subscription_id, - value, - currency: 'USD', - items: [ - { - item_id: itemId, - item_name: itemName, - item_category: 'subscription', - item_variant: billingCycle, - price: value, - quantity: 1 - } - ] - } - - if (isCloud) { - try { - telemetry?.trackSubscriptionPurchase(metadata) - } catch (error) { - console.error('Failed to track subscription purchase', error) - } - } - - clearPendingSubscriptionPurchase() - } - const fetchStatus = wrapWithErrorHandlingAsync( fetchSubscriptionStatus, reportError @@ -292,11 +198,6 @@ function useSubscriptionInternal() { const statusData = await response.json() subscriptionStatus.value = statusData - try { - await trackSubscriptionPurchase(statusData) - } catch (error) { - console.error('Failed to track subscription purchase', error) - } return statusData } diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts new file mode 100644 index 00000000000..e5a00cab194 --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { performSubscriptionCheckout } from './subscriptionCheckoutUtil' + +const { mockTelemetry, mockGetAuthHeader, mockUserId, mockIsCloud } = + vi.hoisted(() => ({ + mockTelemetry: { + trackBeginCheckout: vi.fn() + }, + mockGetAuthHeader: vi.fn(() => + Promise.resolve({ Authorization: 'Bearer test-token' }) + ), + mockUserId: { value: 'user-123' }, + mockIsCloud: { value: true } + })) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: vi.fn(() => mockTelemetry) +})) + +vi.mock('@/stores/firebaseAuthStore', () => ({ + useFirebaseAuthStore: vi.fn(() => ({ + getFirebaseAuthHeader: mockGetAuthHeader, + get userId() { + return mockUserId.value + } + })), + FirebaseAuthStoreError: class extends Error {} +})) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return mockIsCloud.value + } +})) + +global.fetch = vi.fn() + +describe('performSubscriptionCheckout', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCloud.value = true + mockUserId.value = 'user-123' + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('tracks begin_checkout with user id and tier metadata', async () => { + const checkoutUrl = 'https://checkout.stripe.com/test' + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ checkout_url: checkoutUrl }) + } as Response) + + await performSubscriptionCheckout('pro', 'yearly', true) + + expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({ + user_id: 'user-123', + tier: 'pro', + cycle: 'yearly' + }) + expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') + }) +}) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 3d6b4d3a70c..45c3b82a310 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -1,13 +1,12 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { FirebaseAuthStoreError, useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' -import type { SubscriptionStatusSnapshot } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker' import type { BillingCycle } from './subscriptionTierRank' type CheckoutTier = TierKey | `${TierKey}-yearly` @@ -33,12 +32,12 @@ const getCheckoutTier = ( export async function performSubscriptionCheckout( tierKey: TierKey, currentBillingCycle: BillingCycle, - openInNewTab: boolean = true, - previousStatus?: SubscriptionStatusSnapshot + openInNewTab: boolean = true ): Promise { if (!isCloud) return const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore() + const telemetry = useTelemetry() const authHeader = await getFirebaseAuthHeader() if (!authHeader) { @@ -82,12 +81,11 @@ export async function performSubscriptionCheckout( if (data.checkout_url) { if (userId) { - startSubscriptionPurchaseTracking( - tierKey, - currentBillingCycle, - userId, - previousStatus - ) + telemetry?.trackBeginCheckout({ + user_id: userId, + tier: tierKey, + cycle: currentBillingCycle + }) } if (openInNewTab) { window.open(data.checkout_url, '_blank') diff --git a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts b/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts deleted file mode 100644 index 1e833f59bba..00000000000 --- a/src/platform/cloud/subscription/utils/subscriptionPurchaseTracker.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' -import type { BillingCycle } from './subscriptionTierRank' - -type PendingSubscriptionPurchase = { - firebaseUid: string - tierKey: TierKey - billingCycle: BillingCycle - timestamp: number - previous_status?: SubscriptionStatusSnapshot -} - -export type SubscriptionStatusSnapshot = { - is_active?: boolean - subscription_id?: string | null - subscription_tier?: string | null - subscription_duration?: string | null -} - -const STORAGE_KEY = 'pending_subscription_purchase' -const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours -const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder'] -const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly'] - -const safeRemove = (): void => { - try { - localStorage.removeItem(STORAGE_KEY) - } catch { - // Ignore storage errors (e.g. private browsing mode) - } -} - -export function startSubscriptionPurchaseTracking( - tierKey: TierKey, - billingCycle: BillingCycle, - firebaseUid: string, - previousStatus?: SubscriptionStatusSnapshot -): void { - if (typeof window === 'undefined') return - if (!firebaseUid) return - try { - const sanitizedStatus = previousStatus - ? { - ...(previousStatus.is_active !== undefined - ? { is_active: previousStatus.is_active } - : {}), - ...(previousStatus.subscription_id - ? { subscription_id: previousStatus.subscription_id } - : {}), - ...(previousStatus.subscription_tier - ? { subscription_tier: previousStatus.subscription_tier } - : {}), - ...(previousStatus.subscription_duration - ? { subscription_duration: previousStatus.subscription_duration } - : {}) - } - : undefined - const payload: PendingSubscriptionPurchase = { - firebaseUid, - tierKey, - billingCycle, - timestamp: Date.now(), - ...(sanitizedStatus ? { previous_status: sanitizedStatus } : {}) - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) - } catch { - // Ignore storage errors (e.g. private browsing mode) - } -} - -export function getPendingSubscriptionPurchase( - firebaseUid: string -): PendingSubscriptionPurchase | null { - if (typeof window === 'undefined') return null - if (!firebaseUid) return null - - try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - - const parsed = JSON.parse(raw) as PendingSubscriptionPurchase - if (!parsed || typeof parsed !== 'object') { - safeRemove() - return null - } - - const { - firebaseUid: storedUid, - tierKey, - billingCycle, - timestamp, - previous_status: previousStatus - } = parsed - if ( - storedUid !== firebaseUid || - !VALID_TIERS.includes(tierKey) || - !VALID_CYCLES.includes(billingCycle) || - typeof timestamp !== 'number' - ) { - safeRemove() - return null - } - - if ( - previousStatus && - (typeof previousStatus !== 'object' || Array.isArray(previousStatus)) - ) { - safeRemove() - return null - } - - if ( - previousStatus?.is_active !== undefined && - typeof previousStatus.is_active !== 'boolean' - ) { - safeRemove() - return null - } - - if ( - previousStatus?.subscription_id !== undefined && - previousStatus.subscription_id !== null && - typeof previousStatus.subscription_id !== 'string' - ) { - safeRemove() - return null - } - - if ( - previousStatus?.subscription_tier !== undefined && - previousStatus.subscription_tier !== null && - typeof previousStatus.subscription_tier !== 'string' - ) { - safeRemove() - return null - } - - if ( - previousStatus?.subscription_duration !== undefined && - previousStatus.subscription_duration !== null && - typeof previousStatus.subscription_duration !== 'string' - ) { - safeRemove() - return null - } - - if (Date.now() - timestamp > MAX_AGE_MS) { - safeRemove() - return null - } - - const normalizedPreviousStatus = previousStatus - ? { - ...(previousStatus.is_active !== undefined - ? { is_active: previousStatus.is_active } - : {}), - ...(previousStatus.subscription_id - ? { subscription_id: previousStatus.subscription_id } - : {}), - ...(previousStatus.subscription_tier - ? { subscription_tier: previousStatus.subscription_tier } - : {}), - ...(previousStatus.subscription_duration - ? { subscription_duration: previousStatus.subscription_duration } - : {}) - } - : undefined - - return { - ...parsed, - ...(normalizedPreviousStatus - ? { previous_status: normalizedPreviousStatus } - : {}) - } - } catch { - safeRemove() - return null - } -} - -export function clearPendingSubscriptionPurchase(): void { - if (typeof window === 'undefined') return - safeRemove() -} diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts index 13ee47171c6..a4b55977f06 100644 --- a/src/platform/telemetry/TelemetryRegistry.ts +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -2,6 +2,7 @@ import type { AuditLog } from '@/services/customerEventsService' import type { AuthMetadata, + BeginCheckoutMetadata, EnterLinearMetadata, ExecutionErrorMetadata, ExecutionSuccessMetadata, @@ -14,7 +15,6 @@ import type { PageViewMetadata, PageVisibilityMetadata, SettingChangedMetadata, - SubscriptionPurchaseMetadata, SurveyResponses, TabCountMetadata, TelemetryDispatcher, @@ -69,6 +69,10 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.dispatch((provider) => provider.trackSubscription?.(event)) } + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.dispatch((provider) => provider.trackBeginCheckout?.(metadata)) + } + trackMonthlySubscriptionSucceeded(): void { this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.()) } @@ -77,10 +81,6 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.()) } - trackSubscriptionPurchase(metadata: SubscriptionPurchaseMetadata): void { - this.dispatch((provider) => provider.trackSubscriptionPurchase?.(metadata)) - } - trackAddApiCreditButtonClicked(): void { this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.()) } diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index bd629370a21..94caf87dec5 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -1,7 +1,7 @@ import type { AuthMetadata, + BeginCheckoutMetadata, PageViewMetadata, - SubscriptionPurchaseMetadata, TelemetryProvider } from '../../types' @@ -60,7 +60,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { trackAuth(metadata: AuthMetadata): void { const basePayload = { method: metadata.method, - ...(metadata.user_id_hash ? { user_id: metadata.user_id_hash } : {}) + ...(metadata.user_id ? { user_id: metadata.user_id } : {}) } if (metadata.is_new_user) { @@ -71,7 +71,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { this.pushEvent('login', basePayload) } - trackSubscriptionPurchase(metadata: SubscriptionPurchaseMetadata): void { - this.pushEvent('purchase', metadata) + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.pushEvent('begin_checkout', metadata) } } diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 8f839a0e701..daa7758c9d0 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -12,6 +12,8 @@ * 3. Check dist/assets/*.js files contain no tracking code */ +import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' +import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { AuditLog } from '@/services/customerEventsService' /** @@ -20,7 +22,7 @@ import type { AuditLog } from '@/services/customerEventsService' export interface AuthMetadata { method?: 'email' | 'google' | 'github' is_new_user?: boolean - user_id_hash?: string + user_id?: string referrer_url?: string utm_source?: string utm_medium?: string @@ -279,20 +281,10 @@ export interface PageViewMetadata { [key: string]: unknown } -interface SubscriptionPurchaseItem { - item_id: string - item_name: string - item_category: string - item_variant: string - price: number - quantity: number -} - -export interface SubscriptionPurchaseMetadata extends Record { - transaction_id: string - value: number - currency: string - items: SubscriptionPurchaseItem[] +export interface BeginCheckoutMetadata extends Record { + user_id: string + tier: TierKey + cycle: BillingCycle } /** @@ -307,9 +299,9 @@ export interface TelemetryProvider { // Subscription flow events trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void + trackBeginCheckout?(metadata: BeginCheckoutMetadata): void trackMonthlySubscriptionSucceeded?(): void trackMonthlySubscriptionCancelled?(): void - trackSubscriptionPurchase?(metadata: SubscriptionPurchaseMetadata): void trackAddApiCreditButtonClicked?(): void trackApiCreditTopupButtonPurchaseClicked?(amount: number): void trackApiCreditTopupSucceeded?(): void diff --git a/src/platform/telemetry/utils/__tests__/authMetadata.test.ts b/src/platform/telemetry/utils/__tests__/authMetadata.test.ts deleted file mode 100644 index 450b6fd2782..00000000000 --- a/src/platform/telemetry/utils/__tests__/authMetadata.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' - -import { buildAuthMetadata } from '../authMetadata' - -describe('buildAuthMetadata', () => { - afterEach(() => { - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('hashes user id for new users', async () => { - const digestMock = vi - .fn() - .mockResolvedValue(new Uint8Array([0, 1, 2, 255]).buffer) - vi.stubGlobal('crypto', { subtle: { digest: digestMock } }) - - const metadata = await buildAuthMetadata('email', true, 'user-123') - - expect(digestMock).toHaveBeenCalledWith( - 'SHA-256', - new TextEncoder().encode('user-123') - ) - expect(metadata).toMatchObject({ - method: 'email', - is_new_user: true, - user_id_hash: '000102ff' - }) - }) - - it('does not hash when user is not new', async () => { - const digestMock = vi.fn() - vi.stubGlobal('crypto', { subtle: { digest: digestMock } }) - - const metadata = await buildAuthMetadata('google', false, 'user-123') - - expect(digestMock).not.toHaveBeenCalled() - expect(metadata).toMatchObject({ - method: 'google', - is_new_user: false - }) - expect(metadata.user_id_hash).toBeUndefined() - }) - - it('returns base metadata when crypto is unavailable', async () => { - vi.stubGlobal('crypto', undefined) - - const metadata = await buildAuthMetadata('github', true, 'user-123') - - expect(metadata).toMatchObject({ - method: 'github', - is_new_user: true - }) - expect(metadata.user_id_hash).toBeUndefined() - }) -}) diff --git a/src/platform/telemetry/utils/authMetadata.ts b/src/platform/telemetry/utils/authMetadata.ts deleted file mode 100644 index 784bb771049..00000000000 --- a/src/platform/telemetry/utils/authMetadata.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AuthMetadata } from '../types' - -async function hashSha256(value: string): Promise { - if (typeof crypto === 'undefined' || !crypto.subtle) return - if (typeof TextEncoder === 'undefined') return - const data = new TextEncoder().encode(value) - const hash = await crypto.subtle.digest('SHA-256', data) - return Array.from(new Uint8Array(hash)) - .map((byte) => byte.toString(16).padStart(2, '0')) - .join('') -} - -export async function buildAuthMetadata( - method: 'email' | 'google' | 'github', - isNewUser: boolean, - userId?: string -): Promise { - const metadata: AuthMetadata = { method, is_new_user: isNewUser } - - if (isNewUser && userId) { - try { - const userIdHash = await hashSha256(userId) - if (userIdHash) { - metadata.user_id_hash = userIdHash - } - } catch (error) { - console.warn('Failed to hash user id for telemetry', error) - } - } - - return metadata -} diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 11a23921cf1..7807f0b0a48 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -25,7 +25,6 @@ import { t } from '@/i18n' import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants' import { isCloud } from '@/platform/distribution/types' import { useTelemetry } from '@/platform/telemetry' -import { buildAuthMetadata } from '@/platform/telemetry/utils/authMetadata' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -350,7 +349,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { useTelemetry()?.trackAuth({ method: 'email', - is_new_user: false + is_new_user: false, + user_id: result.user.uid }) } @@ -368,8 +368,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { ) if (isCloud) { - const metadata = await buildAuthMetadata('email', true, result.user.uid) - useTelemetry()?.trackAuth(metadata) + useTelemetry()?.trackAuth({ + method: 'email', + is_new_user: true, + user_id: result.user.uid + }) } return result @@ -384,12 +387,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { const additionalUserInfo = getAdditionalUserInfo(result) const isNewUser = additionalUserInfo?.isNewUser ?? false - const metadata = await buildAuthMetadata( - 'google', - isNewUser, - result.user.uid - ) - useTelemetry()?.trackAuth(metadata) + useTelemetry()?.trackAuth({ + method: 'google', + is_new_user: isNewUser, + user_id: result.user.uid + }) } return result @@ -404,12 +406,11 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { const additionalUserInfo = getAdditionalUserInfo(result) const isNewUser = additionalUserInfo?.isNewUser ?? false - const metadata = await buildAuthMetadata( - 'github', - isNewUser, - result.user.uid - ) - useTelemetry()?.trackAuth(metadata) + useTelemetry()?.trackAuth({ + method: 'github', + is_new_user: isNewUser, + user_id: result.user.uid + }) } return result From 6fa89b8bed7b721d49f08052c3d3afb8a284cc3d Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 3 Feb 2026 22:09:29 -0800 Subject: [PATCH 38/43] chore: move dist telemetry scan into workflow --- .github/workflows/ci-dist-telemetry-scan.yaml | 15 ++++- package.json | 1 - scripts/verify-dist-no-telemetry.ts | 55 ------------------- 3 files changed, 14 insertions(+), 57 deletions(-) delete mode 100644 scripts/verify-dist-no-telemetry.ts diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index bd917d8f458..cc4f1a2082a 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -36,4 +36,17 @@ jobs: run: pnpm build - name: Scan dist for telemetry references - run: pnpm test:dist:no-telemetry + run: | + set -euo pipefail + if rg --no-ignore -n \ + -g '*.html' \ + -g '*.js' \ + -e 'Google Tag Manager' \ + -e '(?i)\bgtm\.js\b' \ + -e '(?i)googletagmanager\.com/gtm\.js\\?id=' \ + -e '(?i)googletagmanager\.com/ns\.html\\?id=' \ + dist; then + echo 'Telemetry references found in dist assets.' + exit 1 + fi + echo 'No telemetry references found in dist assets.' diff --git a/package.json b/package.json index 4eaab33320a..d4243e2a657 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,6 @@ "stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'", "test:browser": "pnpm exec nx e2e", "test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 pnpm test:browser", - "test:dist:no-telemetry": "tsx scripts/verify-dist-no-telemetry.ts", "test:unit": "nx run test", "typecheck": "vue-tsc --noEmit", "typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck", diff --git a/scripts/verify-dist-no-telemetry.ts b/scripts/verify-dist-no-telemetry.ts deleted file mode 100644 index 651c421fc6f..00000000000 --- a/scripts/verify-dist-no-telemetry.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { readFile, stat } from 'node:fs/promises' -import { extname, resolve } from 'node:path' -import { glob } from 'glob' - -type Pattern = { - label: string - regex: RegExp -} - -const distDir = resolve('dist') -const allowedExtensions = new Set(['.html', '.js', '.map']) -const telemetryPatterns: Pattern[] = [ - { label: 'GTM container', regex: /GTM-[A-Z0-9]+/i }, - { label: 'GTM script', regex: /gtm\.js/i }, - { label: 'Google Tag Manager', regex: /googletagmanager/i }, - { label: 'dataLayer', regex: /\bdataLayer\b/ } -] - -const distStats = await stat(distDir).catch(() => null) -if (!distStats?.isDirectory()) { - console.error('dist directory not found. Run pnpm build first.') - process.exit(1) -} - -const files = await glob('dist/**/*', { nodir: true }) -const violations: Array<{ - file: string - hits: Array<{ label: string; match: string }> -}> = [] - -for (const file of files) { - const extension = extname(file).toLowerCase() - if (!allowedExtensions.has(extension)) continue - - const content = await readFile(file, 'utf8') - const hits = telemetryPatterns.flatMap((pattern) => { - const match = content.match(pattern.regex) - return match ? [{ label: pattern.label, match: match[0] }] : [] - }) - - if (hits.length > 0) { - violations.push({ file, hits }) - } -} - -if (violations.length > 0) { - console.error('Telemetry references found in dist assets:') - for (const violation of violations) { - const formattedHits = violation.hits - .map((hit) => `${hit.label} (${hit.match})`) - .join(', ') - console.error(`- ${violation.file}: ${formattedHits}`) - } - process.exit(1) -} From ea49807af5d345c82579b98ea1d2a7f851cd7af0 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 3 Feb 2026 23:21:24 -0800 Subject: [PATCH 39/43] feat: add checkout type to begin checkout --- .../components/PricingTable.test.ts | 18 +++++++++++++++++- .../subscription/components/PricingTable.vue | 15 +++++++++++++++ .../utils/subscriptionCheckoutUtil.test.ts | 3 ++- .../utils/subscriptionCheckoutUtil.ts | 3 ++- src/platform/telemetry/types.ts | 2 ++ 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index 59cb78469e8..da1e77a9f99 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -14,6 +14,7 @@ const mockSubscriptionTier = ref< const mockIsYearlySubscription = ref(false) const mockAccessBillingPortal = vi.fn() const mockReportError = vi.fn() +const mockTrackBeginCheckout = vi.fn() const mockGetFirebaseAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) @@ -54,11 +55,18 @@ vi.mock('@/composables/useErrorHandling', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: () => ({ - getFirebaseAuthHeader: mockGetFirebaseAuthHeader + getFirebaseAuthHeader: mockGetFirebaseAuthHeader, + userId: 'user-123' }), FirebaseAuthStoreError: class extends Error {} })) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ + trackBeginCheckout: mockTrackBeginCheckout + }) +})) + vi.mock('@/platform/distribution/types', () => ({ isCloud: true })) @@ -127,6 +135,7 @@ describe('PricingTable', () => { mockIsActiveSubscription.value = false mockSubscriptionTier.value = null mockIsYearlySubscription.value = false + mockTrackBeginCheckout.mockReset() vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' }) @@ -149,6 +158,13 @@ describe('PricingTable', () => { await creatorButton?.trigger('click') await flushPromises() + expect(mockTrackBeginCheckout).toHaveBeenCalledWith({ + user_id: 'user-123', + tier: 'creator', + cycle: 'yearly', + checkout_type: 'change', + previous_tier: 'standard' + }) expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly') }) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index 049a6f36b8d..b6e583573d5 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -266,6 +266,8 @@ import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components } from '@/types/comfyRegistryTypes' type SubscriptionTier = components['schemas']['SubscriptionTier'] @@ -330,6 +332,8 @@ const tiers: PricingTierConfig[] = [ const { n } = useI18n() const { isActiveSubscription, subscriptionTier, isYearlySubscription } = useSubscription() +const telemetry = useTelemetry() +const { userId } = useFirebaseAuthStore() const { accessBillingPortal, reportError } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() @@ -410,6 +414,17 @@ const handleSubscribe = wrapWithErrorHandlingAsync( try { if (isActiveSubscription.value) { + if (userId) { + telemetry?.trackBeginCheckout({ + user_id: userId, + tier: tierKey, + cycle: currentBillingCycle.value, + checkout_type: 'change', + ...(currentTierKey.value + ? { previous_tier: currentTierKey.value } + : {}) + }) + } // Pass the target tier to create a deep link to subscription update confirmation const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value) const targetPlan = { diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index e5a00cab194..15d56e44051 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -61,7 +61,8 @@ describe('performSubscriptionCheckout', () => { expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({ user_id: 'user-123', tier: 'pro', - cycle: 'yearly' + cycle: 'yearly', + 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 45c3b82a310..3baf0240189 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -84,7 +84,8 @@ export async function performSubscriptionCheckout( telemetry?.trackBeginCheckout({ user_id: userId, tier: tierKey, - cycle: currentBillingCycle + cycle: currentBillingCycle, + checkout_type: 'new' }) } if (openInNewTab) { diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index daa7758c9d0..e5b3507e87d 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -285,6 +285,8 @@ export interface BeginCheckoutMetadata extends Record { user_id: string tier: TierKey cycle: BillingCycle + checkout_type: 'new' | 'change' + previous_tier?: TierKey } /** From 6580b76aad018231a958333b09ce504242097490 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 4 Feb 2026 00:04:32 -0800 Subject: [PATCH 40/43] Fix unit test --- .../cloud/onboarding/CloudSubscriptionRedirectView.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index 78aeacd8e5f..fe1f992ad77 100644 --- a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts +++ b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts @@ -126,8 +126,7 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false, - undefined + false ) // Shows loading affordances @@ -158,8 +157,7 @@ describe('CloudSubscriptionRedirectView', () => { expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith( 'creator', 'monthly', - false, - undefined + false ) }) }) From 65b3089f34a31a1f7b425b3e91dab807a6b1288f Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 4 Feb 2026 18:16:50 -0800 Subject: [PATCH 41/43] fix: add checkout attribution to gtm --- global.d.ts | 5 + .../components/PricingTable.test.ts | 5 + .../subscription/components/PricingTable.vue | 3 + .../utils/subscriptionCheckoutUtil.test.ts | 61 ++++++++-- .../utils/subscriptionCheckoutUtil.ts | 9 +- src/platform/telemetry/types.ts | 6 + .../__tests__/checkoutAttribution.test.ts | 65 +++++++++++ .../telemetry/utils/checkoutAttribution.ts | 108 ++++++++++++++++++ 8 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts create mode 100644 src/platform/telemetry/utils/checkoutAttribution.ts diff --git a/global.d.ts b/global.d.ts index 5c4b6abab2b..7e37ab6e6ec 100644 --- a/global.d.ts +++ b/global.d.ts @@ -31,6 +31,11 @@ interface Window { badge?: string } } + __ga_identity__?: { + client_id?: string + session_id?: string + session_number?: string + } dataLayer?: Array> } diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index da1e77a9f99..f2f8984f59d 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -18,6 +18,7 @@ const mockTrackBeginCheckout = vi.fn() const mockGetFirebaseAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) +const mockGetCheckoutAttribution = vi.fn(() => ({})) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ useSubscription: () => ({ @@ -67,6 +68,10 @@ vi.mock('@/platform/telemetry', () => ({ }) })) +vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ + getCheckoutAttribution: mockGetCheckoutAttribution +})) + vi.mock('@/platform/distribution/types', () => ({ isCloud: true })) diff --git a/src/platform/cloud/subscription/components/PricingTable.vue b/src/platform/cloud/subscription/components/PricingTable.vue index b6e583573d5..1eeee1c65a8 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -267,6 +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 { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components } from '@/types/comfyRegistryTypes' @@ -414,12 +415,14 @@ const handleSubscribe = wrapWithErrorHandlingAsync( try { if (isActiveSubscription.value) { + const checkoutAttribution = getCheckoutAttribution() if (userId) { telemetry?.trackBeginCheckout({ user_id: userId, tier: tierKey, cycle: currentBillingCycle.value, checkout_type: 'change', + ...checkoutAttribution, ...(currentTierKey.value ? { previous_tier: currentTierKey.value } : {}) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts index 15d56e44051..355e230cef1 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -2,17 +2,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { performSubscriptionCheckout } from './subscriptionCheckoutUtil' -const { mockTelemetry, mockGetAuthHeader, mockUserId, mockIsCloud } = - vi.hoisted(() => ({ - mockTelemetry: { - trackBeginCheckout: vi.fn() - }, - mockGetAuthHeader: vi.fn(() => - Promise.resolve({ Authorization: 'Bearer test-token' }) - ), - mockUserId: { value: 'user-123' }, - mockIsCloud: { value: true } +const { + mockTelemetry, + mockGetAuthHeader, + mockUserId, + mockIsCloud, + mockGetCheckoutAttribution +} = vi.hoisted(() => ({ + mockTelemetry: { + trackBeginCheckout: vi.fn() + }, + mockGetAuthHeader: vi.fn(() => + Promise.resolve({ Authorization: 'Bearer test-token' }) + ), + mockUserId: { value: 'user-123' }, + mockIsCloud: { value: true }, + mockGetCheckoutAttribution: vi.fn(() => ({ + ga_client_id: 'ga-client-id', + ga_session_id: 'ga-session-id', + ga_session_number: 'ga-session-number', + gclid: 'gclid-123', + gbraid: 'gbraid-456', + wbraid: 'wbraid-789' })) +})) vi.mock('@/platform/telemetry', () => ({ useTelemetry: vi.fn(() => mockTelemetry) @@ -34,6 +47,10 @@ vi.mock('@/platform/distribution/types', () => ({ } })) +vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ + getCheckoutAttribution: mockGetCheckoutAttribution +})) + global.fetch = vi.fn() describe('performSubscriptionCheckout', () => { @@ -62,8 +79,30 @@ describe('performSubscriptionCheckout', () => { user_id: 'user-123', tier: 'pro', cycle: 'yearly', - checkout_type: 'new' + checkout_type: 'new', + ga_client_id: 'ga-client-id', + ga_session_id: 'ga-session-id', + ga_session_number: 'ga-session-number', + gclid: 'gclid-123', + gbraid: 'gbraid-456', + wbraid: 'wbraid-789' }) + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining( + '/customers/cloud-subscription-checkout/pro-yearly' + ), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + ga_client_id: 'ga-client-id', + ga_session_id: 'ga-session-id', + ga_session_number: 'ga-session-number', + gclid: 'gclid-123', + gbraid: 'gbraid-456', + wbraid: 'wbraid-789' + }) + }) + ) expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank') }) }) diff --git a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts index 3baf0240189..b50d827f745 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -2,6 +2,7 @@ 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 @@ -45,12 +46,15 @@ export async function performSubscriptionCheckout( } const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle) + const checkoutAttribution = getCheckoutAttribution() + const checkoutPayload = { ...checkoutAttribution } const response = await fetch( `${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`, { method: 'POST', - headers: { ...authHeader, 'Content-Type': 'application/json' } + headers: { ...authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(checkoutPayload) } ) @@ -85,7 +89,8 @@ export async function performSubscriptionCheckout( user_id: userId, tier: tierKey, cycle: currentBillingCycle, - checkout_type: 'new' + checkout_type: 'new', + ...checkoutAttribution }) } if (openInNewTab) { diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index e5b3507e87d..ce3260a8e36 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -287,6 +287,12 @@ export interface BeginCheckoutMetadata extends Record { 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 new file mode 100644 index 00000000000..9b6d6f809f5 --- /dev/null +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -0,0 +1,65 @@ +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 +}) + +describe('getCheckoutAttribution', () => { + beforeEach(() => { + storage.clear() + vi.clearAllMocks() + window.__ga_identity__ = undefined + window.history.pushState({}, '', '/') + }) + + it('reads GA identity and persists click ids from URL', () => { + window.__ga_identity__ = { + client_id: '123.456', + session_id: '1700000000', + session_number: '2' + } + window.history.pushState({}, '', '/?gclid=gclid-123') + + const attribution = getCheckoutAttribution() + + expect(attribution).toMatchObject({ + ga_client_id: '123.456', + ga_session_id: '1700000000', + ga_session_number: '2', + gclid: 'gclid-123' + }) + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + 'comfy_checkout_attribution', + JSON.stringify({ gclid: 'gclid-123' }) + ) + }) + + it('uses stored click ids when URL is empty', () => { + storage.set( + 'comfy_checkout_attribution', + JSON.stringify({ gbraid: 'gbraid-1' }) + ) + + const attribution = getCheckoutAttribution() + + expect(attribution.gbraid).toBe('gbraid-1') + }) +}) diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts new file mode 100644 index 00000000000..3c22457d1f7 --- /dev/null +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -0,0 +1,108 @@ +import { isPlainObject } from 'es-toolkit' + +interface CheckoutAttribution { + ga_client_id?: string + ga_session_id?: string + ga_session_number?: string + gclid?: string + gbraid?: string + wbraid?: string +} + +type GaIdentity = { + client_id?: string + session_id?: string + session_number?: string +} + +const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const +type ClickIdKey = (typeof CLICK_ID_KEYS)[number] +const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution' + +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) { + result[key] = value + } + } + + return result + } catch { + return {} + } +} + +function persistClickIds(payload: Partial>): void { + try { + localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload)) + } catch { + return + } +} + +function readClickIdsFromUrl( + search: string +): Partial> { + const params = new URLSearchParams(search) + + const result: Partial> = {} + + for (const key of CLICK_ID_KEYS) { + const value = params.get(key) + if (value) { + result[key] = value + } + } + + return result +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function getGaIdentity(): GaIdentity | undefined { + if (typeof window === 'undefined') return undefined + + const identity = window.__ga_identity__ + if (!isPlainObject(identity)) return undefined + + return { + client_id: asNonEmptyString(identity.client_id), + session_id: asNonEmptyString(identity.session_id), + session_number: asNonEmptyString(identity.session_number) + } +} + +export function getCheckoutAttribution(): CheckoutAttribution { + if (typeof window === 'undefined') return {} + + const stored = readStoredClickIds() + const fromUrl = readClickIdsFromUrl(window.location.search) + const merged: Partial> = { + ...stored, + ...fromUrl + } + + if (Object.keys(fromUrl).length > 0) { + persistClickIds(merged) + } + + const gaIdentity = getGaIdentity() + + return { + ...merged, + ga_client_id: gaIdentity?.client_id, + ga_session_id: gaIdentity?.session_id, + ga_session_number: gaIdentity?.session_number + } +} From 56df3eb4c3151aeae5edef7bc9ced6b520400811 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Wed, 4 Feb 2026 19:51:29 -0800 Subject: [PATCH 42/43] fix unit test by hoisting --- src/platform/cloud/subscription/components/PricingTable.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index f2f8984f59d..bd373e009cb 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -18,7 +18,7 @@ const mockTrackBeginCheckout = vi.fn() const mockGetFirebaseAuthHeader = vi.fn(() => Promise.resolve({ Authorization: 'Bearer test-token' }) ) -const mockGetCheckoutAttribution = vi.fn(() => ({})) +const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({}))) vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({ useSubscription: () => ({ From 4d9b0157bd1318c14e1f14661daa2db06d9387bd Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 5 Feb 2026 10:53:58 -0800 Subject: [PATCH 43/43] Switch to __DISTRIBUTION__ in main.ts --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index d8bb8e59d38..1ec83034534 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ import { i18n } from './i18n' * CRITICAL: Load remote config FIRST for cloud builds to ensure * window.__CONFIG__is available for all modules during initialization */ -import { isCloud } from '@/platform/distribution/types' +const isCloud = __DISTRIBUTION__ === 'cloud' if (isCloud) { const { refreshRemoteConfig } =