From dd4d36d459833f08913dddb07716a1fd4a58ba52 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sat, 7 Feb 2026 01:08:48 -0800 Subject: [PATCH] fix: route gtm through telemetry entrypoint (#8354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire checkout attribution into GTM events and checkout POST payloads. This updates the cloud telemetry flow so the backend team can correlate checkout events without relying on frontend cookie parsing. We now surface GA4 identity via a GTM-provided global and include attribution on both `begin_checkout` telemetry and the checkout POST body. The backend should continue to derive the Firebase UID from the auth header; the checkout POST body does not include a user ID. GTM events pushed (unchanged list, updated payloads): - `page_view` (page title/location/referrer as before) - `sign_up` / `login` - `begin_checkout` now includes: - `user_id`, `tier`, `cycle`, `checkout_type`, `previous_tier` (if change flow) - `ga_client_id`, `ga_session_id`, `ga_session_number` - `gclid`, `gbraid`, `wbraid` Backend-facing change: - `POST /customers/cloud-subscription-checkout/:tier` now includes a JSON body with attribution fields only: - `ga_client_id`, `ga_session_id`, `ga_session_number` - `gclid`, `gbraid`, `wbraid` - Backend should continue to derive the Firebase UID from the auth header. Required GTM setup: - Provide `window.__ga_identity__` via a GTM Custom HTML tag (after GA4/Google tag) with `{ client_id, session_id, session_number }`. The frontend reads this to populate the GA fields. image ## Screenshots (if applicable) image Manual Testing: image image image image To manually test, you will need to override api/features in devtools to also return this: ``` "gtm_container_id": "GTM-NP9JM6K7" ``` ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8354-fix-route-gtm-through-telemetry-entrypoint-2f66d73d36508138afacdeffe835f28a) by [Unito](https://www.unito.io) ## Summary by CodeRabbit * **New Features** * Analytics expanded: page view tracking, richer auth telemetry (includes user IDs), and checkout begin events with attribution. * Google Tag Manager support and persistent checkout attribution (GA/client/session IDs, gclid/gbraid/wbraid). * **Chores** * Telemetry reworked to support multiple providers via a registry with cloud-only initialization. * Workflow module refactored for clearer exports. * **Tests** * Added/updated tests for attribution, telemetry, and subscription flows. * **CI** * New check prevents telemetry from leaking into distribution artifacts. --- .github/workflows/ci-dist-telemetry-scan.yaml | 52 +++++ global.d.ts | 7 + src/main.ts | 5 +- .../CloudSubscriptionRedirectView.test.ts | 3 +- .../components/PricingTable.test.ts | 26 ++- .../subscription/components/PricingTable.vue | 24 +- .../composables/useSubscription.test.ts | 109 ++++++--- .../composables/useSubscription.ts | 8 +- ...useSubscriptionCancellationWatcher.test.ts | 4 +- .../useSubscriptionCancellationWatcher.ts | 7 +- .../utils/subscriptionCheckoutUtil.test.ts | 108 +++++++++ .../utils/subscriptionCheckoutUtil.ts | 19 +- src/platform/remoteConfig/types.ts | 1 + src/platform/telemetry/TelemetryRegistry.ts | 220 ++++++++++++++++++ src/platform/telemetry/index.ts | 49 ++-- src/platform/telemetry/initTelemetry.ts | 41 ++++ .../providers/cloud/GtmTelemetryProvider.ts | 77 ++++++ src/platform/telemetry/types.ts | 116 ++++++--- .../__tests__/checkoutAttribution.test.ts | 65 ++++++ .../telemetry/utils/checkoutAttribution.ts | 108 +++++++++ .../management/stores/comfyWorkflow.ts | 157 +++++++++++++ .../management/stores/workflowStore.ts | 150 +----------- src/router.ts | 13 ++ src/stores/firebaseAuthStore.ts | 12 +- src/stores/subgraphStore.ts | 8 +- 25 files changed, 1113 insertions(+), 276 deletions(-) create mode 100644 .github/workflows/ci-dist-telemetry-scan.yaml create mode 100644 src/platform/cloud/subscription/utils/subscriptionCheckoutUtil.test.ts create mode 100644 src/platform/telemetry/TelemetryRegistry.ts create mode 100644 src/platform/telemetry/initTelemetry.ts create mode 100644 src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts create mode 100644 src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts create mode 100644 src/platform/telemetry/utils/checkoutAttribution.ts create mode 100644 src/platform/workflow/management/stores/comfyWorkflow.ts 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,