fix: route gtm through telemetry entrypoint (#8354)

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.

<img width="1416" height="1230" alt="image" src="https://github.com/user-attachments/assets/b77cf0ed-be69-4497-a540-86e5beb7bfac" />

## Screenshots (if applicable)

<img width="991" height="385" alt="image" src="https://github.com/user-attachments/assets/8309cd9e-5ab5-4fba-addb-2d101aaae7e9"/>

Manual Testing:
<img width="3839" height="2020" alt="image" src="https://github.com/user-attachments/assets/36901dfd-08db-4c07-97b8-a71e6783c72f"/>
<img width="2141" height="851" alt="image" src="https://github.com/user-attachments/assets/2e9f7aa4-4716-40f7-b147-1c74b0ce8067"/>
<img width="2298" height="982" alt="image" src="https://github.com/user-attachments/assets/72cbaa53-9b92-458a-8539-c987cf753b02"/>
<img width="2125" height="999" alt="image" src="https://github.com/user-attachments/assets/4b22387e-8027-4f50-be49-a410282a1adc"/>

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)


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Benjamin Lu
2026-02-07 01:08:48 -08:00
committed by GitHub
parent 69c8c84aef
commit dd4d36d459
25 changed files with 1113 additions and 276 deletions

View File

@@ -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')
})

View File

@@ -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

View File

@@ -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<typeof effectScope> | 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()

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<CloudSubscriptionStatusResponse | null | void>
isActiveSubscription: ComputedRef<boolean>
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
telemetry: Pick<
TelemetryDispatcher,
'trackMonthlySubscriptionCancelled'
> | null
shouldWatchCancellation: () => boolean
}

View File

@@ -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')
})
})

View File

@@ -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<void> {
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 {