diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml new file mode 100644 index 000000000..cc4f1a208 --- /dev/null +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -0,0 +1,52 @@ +name: 'CI: Dist Telemetry Scan' + +on: + pull_request: + branches-ignore: [wip/*, draft/*, temp/*] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + 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: | + 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/global.d.ts b/global.d.ts index 7f7dd832f..7e37ab6e6 100644 --- a/global.d.ts +++ b/global.d.ts @@ -7,6 +7,7 @@ declare const __USE_PROD_CONFIG__: boolean interface Window { __CONFIG__: { + gtm_container_id?: string mixpanel_token?: string require_whitelist?: boolean subscription_required?: boolean @@ -30,6 +31,12 @@ interface Window { badge?: string } } + __ga_identity__?: { + client_id?: string + session_id?: string + session_number?: string + } + dataLayer?: Array> } interface Navigator { diff --git a/src/main.ts b/src/main.ts index ca80b87f8..1ec830345 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,12 +25,15 @@ 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 } = await import('@/platform/remoteConfig/refreshRemoteConfig') await refreshRemoteConfig({ useAuth: false }) + + const { initTelemetry } = await import('@/platform/telemetry/initTelemetry') + await initTelemetry() } const ComfyUIPreset = definePreset(Aura, { diff --git a/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts b/src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts index 43e211cdc..de8627924 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', () => ({ diff --git a/src/platform/cloud/subscription/components/PricingTable.test.ts b/src/platform/cloud/subscription/components/PricingTable.test.ts index de0221f56..f7ac0aecd 100644 --- a/src/platform/cloud/subscription/components/PricingTable.test.ts +++ b/src/platform/cloud/subscription/components/PricingTable.test.ts @@ -14,15 +14,18 @@ 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' }) ) +const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({}))) 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) }) })) @@ -53,11 +56,22 @@ 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/telemetry/utils/checkoutAttribution', () => ({ + getCheckoutAttribution: mockGetCheckoutAttribution +})) + vi.mock('@/platform/distribution/types', () => ({ isCloud: true })) @@ -137,6 +151,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' }) @@ -159,6 +174,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 ec47c59b6..0e4df0a81 100644 --- a/src/platform/cloud/subscription/components/PricingTable.vue +++ b/src/platform/cloud/subscription/components/PricingTable.vue @@ -265,6 +265,9 @@ 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 { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution' +import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import type { components } from '@/types/comfyRegistryTypes' type SubscriptionTier = components['schemas']['SubscriptionTier'] @@ -329,6 +332,8 @@ const tiers: PricingTierConfig[] = [ ] const { isActiveSubscription, subscriptionTier, isYearlySubscription } = useSubscription() +const telemetry = useTelemetry() +const { userId } = useFirebaseAuthStore() const { accessBillingPortal, reportError } = useFirebaseAuthActions() const { wrapWithErrorHandlingAsync } = useErrorHandling() @@ -409,6 +414,19 @@ 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 } + : {}) + }) + } // Pass the target tier to create a deep link to subscription update confirmation const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value) const targetPlan = { @@ -429,7 +447,11 @@ const handleSubscribe = wrapWithErrorHandlingAsync( await accessBillingPortal(checkoutTier) } } else { - await performSubscriptionCheckout(tierKey, currentBillingCycle.value) + await performSubscriptionCheckout( + tierKey, + currentBillingCycle.value, + true + ) } } 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 30309cbdb..6f2a158a6 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -1,22 +1,48 @@ -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 mockTelemetry = { - trackSubscription: vi.fn(), - trackMonthlySubscriptionCancelled: vi.fn() +const { + mockIsLoggedIn, + mockReportError, + mockAccessBillingPortal, + mockShowSubscriptionRequiredDialog, + mockGetAuthHeader, + mockTelemetry, + mockUserId, + mockIsCloud +} = vi.hoisted(() => ({ + mockIsLoggedIn: { value: false }, + mockIsCloud: { value: true }, + mockReportError: vi.fn(), + mockAccessBillingPortal: vi.fn(), + mockShowSubscriptionRequiredDialog: vi.fn(), + mockGetAuthHeader: vi.fn(() => + Promise.resolve({ Authorization: 'Bearer test-token' }) + ), + mockTelemetry: { + trackSubscription: vi.fn(), + trackMonthlySubscriptionCancelled: vi.fn() + }, + mockUserId: { value: 'user-123' } +})) + +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 @@ -53,7 +79,9 @@ vi.mock('@/composables/useErrorHandling', () => ({ })) vi.mock('@/platform/distribution/types', () => ({ - isCloud: true + get isCloud() { + return mockIsCloud.value + } })) vi.mock('@/services/dialogService', () => ({ @@ -64,7 +92,10 @@ vi.mock('@/services/dialogService', () => ({ vi.mock('@/stores/firebaseAuthStore', () => ({ useFirebaseAuthStore: vi.fn(() => ({ - getFirebaseAuthHeader: mockGetAuthHeader + getFirebaseAuthHeader: mockGetAuthHeader, + get userId() { + return mockUserId.value + } })), FirebaseAuthStoreError: class extends Error {} })) @@ -73,11 +104,21 @@ 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() + mockUserId.value = 'user-123' + mockIsCloud.value = true window.__CONFIG__ = { subscription_required: true } as typeof window.__CONFIG__ @@ -103,7 +144,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { isActiveSubscription, fetchStatus } = useSubscription() + const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope() await fetchStatus() expect(isActiveSubscription.value).toBe(true) @@ -120,7 +161,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { isActiveSubscription, fetchStatus } = useSubscription() + const { isActiveSubscription, fetchStatus } = useSubscriptionWithScope() await fetchStatus() expect(isActiveSubscription.value).toBe(false) @@ -137,7 +178,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 @@ -147,7 +188,7 @@ describe('useSubscription', () => { }) it('should return empty string when renewal date is not available', () => { - const { formattedRenewalDate } = useSubscription() + const { formattedRenewalDate } = useSubscriptionWithScope() expect(formattedRenewalDate.value).toBe('') }) @@ -164,14 +205,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() }) @@ -191,7 +232,7 @@ describe('useSubscription', () => { } as Response) mockIsLoggedIn.value = true - const { fetchStatus } = useSubscription() + const { fetchStatus } = useSubscriptionWithScope() await fetchStatus() @@ -212,7 +253,7 @@ describe('useSubscription', () => { json: async () => ({ message: 'Subscription not found' }) } as Response) - const { fetchStatus } = useSubscription() + const { fetchStatus } = useSubscriptionWithScope() await expect(fetchStatus()).rejects.toThrow() }) @@ -232,7 +273,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { subscribe } = useSubscription() + const { subscribe } = useSubscriptionWithScope() await subscribe() @@ -258,7 +299,7 @@ describe('useSubscription', () => { json: async () => ({}) } as Response) - const { subscribe } = useSubscription() + const { subscribe } = useSubscriptionWithScope() await expect(subscribe()).rejects.toThrow() }) @@ -275,7 +316,7 @@ describe('useSubscription', () => { }) } as Response) - const { requireActiveSubscription } = useSubscription() + const { requireActiveSubscription } = useSubscriptionWithScope() await requireActiveSubscription() @@ -292,7 +333,7 @@ describe('useSubscription', () => { }) } as Response) - const { requireActiveSubscription } = useSubscription() + const { requireActiveSubscription } = useSubscriptionWithScope() await requireActiveSubscription() @@ -306,7 +347,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { handleViewUsageHistory } = useSubscription() + const { handleViewUsageHistory } = useSubscriptionWithScope() handleViewUsageHistory() expect(windowOpenSpy).toHaveBeenCalledWith( @@ -322,7 +363,7 @@ describe('useSubscription', () => { .spyOn(window, 'open') .mockImplementation(() => null) - const { handleLearnMore } = useSubscription() + const { handleLearnMore } = useSubscriptionWithScope() handleLearnMore() expect(windowOpenSpy).toHaveBeenCalledWith( @@ -334,7 +375,7 @@ describe('useSubscription', () => { }) it('should call accessBillingPortal for invoice history', async () => { - const { handleInvoiceHistory } = useSubscription() + const { handleInvoiceHistory } = useSubscriptionWithScope() await handleInvoiceHistory() @@ -342,7 +383,7 @@ describe('useSubscription', () => { }) it('should call accessBillingPortal for manage subscription', async () => { - const { manageSubscription } = useSubscription() + const { manageSubscription } = useSubscriptionWithScope() await manageSubscription() @@ -378,7 +419,7 @@ describe('useSubscription', () => { .mockResolvedValueOnce(cancelledResponse as Response) try { - const { fetchStatus, manageSubscription } = useSubscription() + const { fetchStatus, manageSubscription } = useSubscriptionWithScope() await fetchStatus() await manageSubscription() @@ -422,7 +463,7 @@ describe('useSubscription', () => { .mockResolvedValueOnce(cancelledResponse as Response) try { - const { fetchStatus, manageSubscription } = useSubscription() + const { fetchStatus, manageSubscription } = useSubscriptionWithScope() await fetchStatus() await manageSubscription() diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 7d8e8a4f8..27297709f 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -38,7 +38,8 @@ function useSubscriptionInternal() { const { reportError, accessBillingPortal } = useFirebaseAuthActions() const { showSubscriptionRequiredDialog } = useDialogService() - const { getFirebaseAuthHeader } = useFirebaseAuthStore() + const firebaseAuthStore = useFirebaseAuthStore() + const { getFirebaseAuthHeader } = firebaseAuthStore const { wrapWithErrorHandlingAsync } = useErrorHandling() const { isLoggedIn } = useCurrentUser() @@ -93,7 +94,9 @@ function useSubscriptionInternal() { : baseName }) - const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}` + function buildApiUrl(path: string): string { + return `${getComfyApiBaseUrl()}${path}` + } const fetchStatus = wrapWithErrorHandlingAsync( fetchSubscriptionStatus, @@ -194,6 +197,7 @@ function useSubscriptionInternal() { const statusData = await response.json() subscriptionStatus.value = statusData + return statusData } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts index 12e114aed..a44564801 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 d841b02fc..01e166069 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/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts new file mode 100644 index 000000000..355e230ce --- /dev/null +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { performSubscriptionCheckout } from './subscriptionCheckoutUtil' + +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) +})) + +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 + } +})) + +vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({ + getCheckoutAttribution: mockGetCheckoutAttribution +})) + +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', + 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 746a7d616..b50d827f7 100644 --- a/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts +++ b/src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.ts @@ -1,6 +1,8 @@ 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 @@ -35,7 +37,8 @@ export async function performSubscriptionCheckout( ): Promise { if (!isCloud) return - const { getFirebaseAuthHeader } = useFirebaseAuthStore() + const { getFirebaseAuthHeader, userId } = useFirebaseAuthStore() + const telemetry = useTelemetry() const authHeader = await getFirebaseAuthHeader() if (!authHeader) { @@ -43,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) } ) @@ -78,6 +84,15 @@ export async function performSubscriptionCheckout( const data = await response.json() if (data.checkout_url) { + if (userId) { + telemetry?.trackBeginCheckout({ + user_id: userId, + tier: tierKey, + cycle: currentBillingCycle, + checkout_type: 'new', + ...checkoutAttribution + }) + } if (openInNewTab) { window.open(data.checkout_url, '_blank') } else { diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 390a576a2..39eadcbf8 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/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts new file mode 100644 index 000000000..a4b55977f --- /dev/null +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -0,0 +1,220 @@ +import type { AuditLog } from '@/services/customerEventsService' + +import type { + AuthMetadata, + BeginCheckoutMetadata, + 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) + } + + 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.dispatch((provider) => provider.trackSignupOpened?.()) + } + + trackAuth(metadata: AuthMetadata): void { + this.dispatch((provider) => provider.trackAuth?.(metadata)) + } + + trackUserLoggedIn(): void { + this.dispatch((provider) => provider.trackUserLoggedIn?.()) + } + + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { + this.dispatch((provider) => provider.trackSubscription?.(event)) + } + + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.dispatch((provider) => provider.trackBeginCheckout?.(metadata)) + } + + trackMonthlySubscriptionSucceeded(): void { + this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.()) + } + + trackMonthlySubscriptionCancelled(): void { + this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.()) + } + + trackAddApiCreditButtonClicked(): void { + this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.()) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + this.dispatch((provider) => + provider.trackApiCreditTopupButtonPurchaseClicked?.(amount) + ) + } + + trackApiCreditTopupSucceeded(): void { + this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.()) + } + + trackRunButton(options?: { + subscribe_to_run?: boolean + trigger_source?: ExecutionTriggerSource + }): void { + this.dispatch((provider) => provider.trackRunButton?.(options)) + } + + startTopupTracking(): void { + this.dispatch((provider) => provider.startTopupTracking?.()) + } + + checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean { + 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.dispatch((provider) => provider.clearTopupTracking?.()) + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + this.dispatch((provider) => provider.trackSurvey?.(stage, responses)) + } + + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { + this.dispatch((provider) => provider.trackEmailVerification?.(stage)) + } + + trackTemplate(metadata: TemplateMetadata): void { + this.dispatch((provider) => provider.trackTemplate?.(metadata)) + } + + trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { + this.dispatch((provider) => provider.trackTemplateLibraryOpened?.(metadata)) + } + + trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { + this.dispatch((provider) => provider.trackTemplateLibraryClosed?.(metadata)) + } + + trackWorkflowImported(metadata: WorkflowImportMetadata): void { + this.dispatch((provider) => provider.trackWorkflowImported?.(metadata)) + } + + trackWorkflowOpened(metadata: WorkflowImportMetadata): void { + this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata)) + } + + trackEnterLinear(metadata: EnterLinearMetadata): void { + this.dispatch((provider) => provider.trackEnterLinear?.(metadata)) + } + + trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { + this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata)) + } + + trackTabCount(metadata: TabCountMetadata): void { + this.dispatch((provider) => provider.trackTabCount?.(metadata)) + } + + trackNodeSearch(metadata: NodeSearchMetadata): void { + this.dispatch((provider) => provider.trackNodeSearch?.(metadata)) + } + + trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { + this.dispatch((provider) => + provider.trackNodeSearchResultSelected?.(metadata) + ) + } + + trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { + this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata)) + } + + trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { + this.dispatch((provider) => provider.trackHelpCenterOpened?.(metadata)) + } + + trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { + this.dispatch((provider) => provider.trackHelpResourceClicked?.(metadata)) + } + + trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { + this.dispatch((provider) => provider.trackHelpCenterClosed?.(metadata)) + } + + trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { + this.dispatch((provider) => provider.trackWorkflowCreated?.(metadata)) + } + + trackWorkflowExecution(): void { + this.dispatch((provider) => provider.trackWorkflowExecution?.()) + } + + trackExecutionError(metadata: ExecutionErrorMetadata): void { + this.dispatch((provider) => provider.trackExecutionError?.(metadata)) + } + + trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { + this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata)) + } + + trackSettingChanged(metadata: SettingChangedMetadata): void { + this.dispatch((provider) => provider.trackSettingChanged?.(metadata)) + } + + trackUiButtonClicked(metadata: UiButtonClickMetadata): void { + this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata)) + } + + trackPageView(pageName: string, properties?: PageViewMetadata): void { + this.dispatch((provider) => provider.trackPageView?.(pageName, properties)) + } +} diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 83d7f2c9f..293acc21f 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -1,42 +1,19 @@ -/** - * 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. - * - * 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. - */ -import { isCloud } from '@/platform/distribution/types' +import type { TelemetryDispatcher } from './types' -import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' -import type { TelemetryProvider } from './types' - -// Singleton instance -let _telemetryProvider: TelemetryProvider | null = null +let _telemetryRegistry: TelemetryDispatcher | null = null /** - * Telemetry factory - conditionally creates provider based on distribution - * Returns singleton instance. + * Get the telemetry dispatcher for tracking events. + * Returns null in OSS builds - all tracking calls become no-ops. * - * CRITICAL: This returns undefined in OSS builds. There is no telemetry provider - * for OSS builds and all tracking calls are no-ops. + * Usage: useTelemetry()?.trackAuth({ method: 'google' }) */ -export function useTelemetry(): TelemetryProvider | null { - if (_telemetryProvider === null) { - // Use distribution check for tree-shaking - if (isCloud) { - _telemetryProvider = new MixpanelTelemetryProvider() - } - // For OSS builds, _telemetryProvider stays null - } - - return _telemetryProvider +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 000000000..e9bec3065 --- /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 +} diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts new file mode 100644 index 000000000..94caf87de --- /dev/null +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -0,0 +1,77 @@ +import type { + AuthMetadata, + BeginCheckoutMetadata, + PageViewMetadata, + TelemetryProvider +} from '../../types' + +/** + * Google Tag Manager telemetry provider. + * Pushes events to the GTM dataLayer for GA4 and marketing integrations. + * + * Only implements events relevant to GTM/GA4 tracking. + */ +export class GtmTelemetryProvider implements TelemetryProvider { + private initialized = false + + constructor() { + this.initialize() + } + + private initialize(): void { + if (typeof window === 'undefined') return + + const gtmId = window.__CONFIG__?.gtm_container_id + if (!gtmId) { + if (import.meta.env.MODE === 'development') { + console.warn('[GTM] No GTM ID configured, skipping initialization') + } + return + } + + window.dataLayer = window.dataLayer || [] + + window.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 + window.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 { + const basePayload = { + method: metadata.method, + ...(metadata.user_id ? { user_id: metadata.user_id } : {}) + } + + if (metadata.is_new_user) { + this.pushEvent('sign_up', basePayload) + return + } + + this.pushEvent('login', basePayload) + } + + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.pushEvent('begin_checkout', metadata) + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 2ce9c7f0f..ce3260a8e 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,6 +22,7 @@ import type { AuditLog } from '@/services/customerEventsService' export interface AuthMetadata { method?: 'email' | 'google' | 'github' is_new_user?: boolean + user_id?: string referrer_url?: string utm_source?: string utm_medium?: string @@ -269,80 +272,116 @@ 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 +} + +export interface BeginCheckoutMetadata extends Record { + user_id: string + tier: TierKey + cycle: BillingCycle + checkout_type: 'new' | 'change' + previous_tier?: TierKey + ga_client_id?: string + ga_session_id?: string + ga_session_number?: string + gclid?: string + gbraid?: string + wbraid?: string +} + +/** + * 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 + trackBeginCheckout?(metadata: BeginCheckoutMetadata): 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 +454,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/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts new file mode 100644 index 000000000..9b6d6f809 --- /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 000000000..3c22457d1 --- /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 + } +} diff --git a/src/platform/workflow/management/stores/comfyWorkflow.ts b/src/platform/workflow/management/stores/comfyWorkflow.ts new file mode 100644 index 000000000..656c1b542 --- /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 { 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 { + const { useDialogService } = await import('@/services/dialogService') + return await useDialogService().prompt({ + title: t('workflowService.saveWorkflow'), + message: t('workflowService.enterFilenamePrompt'), + 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 09d152ad3..fea16a993 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.enterFilenamePrompt'), - 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/router.ts b/src/router.ts index b489d2257..16783008b 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 { useTelemetry } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore' import { useUserStore } from '@/stores/userStore' @@ -36,6 +37,14 @@ function getBasePath(): string { const basePath = getBasePath() +function trackPageView(): void { + if (!isCloud || typeof window === 'undefined') return + + useTelemetry()?.trackPageView(document.title, { + path: window.location.href + }) +} + const router = createRouter({ history: isFileProtocol ? createWebHashHistory() @@ -93,6 +102,10 @@ installPreservedQueryTracker(router, [ } ]) +router.afterEach(() => { + trackPageView() +}) + if (isCloud) { const { flags } = useFeatureFlags() const PUBLIC_ROUTE_NAMES = new Set([ diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index b677026ff..225542ee2 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -349,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 }) } @@ -369,7 +370,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { if (isCloud) { useTelemetry()?.trackAuth({ method: 'email', - is_new_user: true + is_new_user: true, + user_id: result.user.uid }) } @@ -387,7 +389,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const isNewUser = additionalUserInfo?.isNewUser ?? false useTelemetry()?.trackAuth({ method: 'google', - is_new_user: isNewUser + is_new_user: isNewUser, + user_id: result.user.uid }) } @@ -405,7 +408,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const isNewUser = additionalUserInfo?.isNewUser ?? false useTelemetry()?.trackAuth({ method: 'github', - is_new_user: isNewUser + is_new_user: isNewUser, + user_id: result.user.uid }) } diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 2e1be2ab5..33aa49558 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,