Skip to content

Commit dd4d36d

Browse files
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 -->
1 parent 69c8c84 commit dd4d36d

25 files changed

Lines changed: 1112 additions & 275 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: 'CI: Dist Telemetry Scan'
2+
3+
on:
4+
pull_request:
5+
branches-ignore: [wip/*, draft/*, temp/*]
6+
7+
concurrency:
8+
group: ${{ github.workflow }}-${{ github.ref }}
9+
cancel-in-progress: true
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
scan:
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
20+
21+
- name: Install pnpm
22+
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
23+
with:
24+
version: 10
25+
26+
- name: Use Node.js
27+
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
28+
with:
29+
node-version: 'lts/*'
30+
cache: 'pnpm'
31+
32+
- name: Install dependencies
33+
run: pnpm install --frozen-lockfile
34+
35+
- name: Build project
36+
run: pnpm build
37+
38+
- name: Scan dist for telemetry references
39+
run: |
40+
set -euo pipefail
41+
if rg --no-ignore -n \
42+
-g '*.html' \
43+
-g '*.js' \
44+
-e 'Google Tag Manager' \
45+
-e '(?i)\bgtm\.js\b' \
46+
-e '(?i)googletagmanager\.com/gtm\.js\\?id=' \
47+
-e '(?i)googletagmanager\.com/ns\.html\\?id=' \
48+
dist; then
49+
echo 'Telemetry references found in dist assets.'
50+
exit 1
51+
fi
52+
echo 'No telemetry references found in dist assets.'

global.d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ declare const __USE_PROD_CONFIG__: boolean
77

88
interface Window {
99
__CONFIG__: {
10+
gtm_container_id?: string
1011
mixpanel_token?: string
1112
require_whitelist?: boolean
1213
subscription_required?: boolean
@@ -30,6 +31,12 @@ interface Window {
3031
badge?: string
3132
}
3233
}
34+
__ga_identity__?: {
35+
client_id?: string
36+
session_id?: string
37+
session_number?: string
38+
}
39+
dataLayer?: Array<Record<string, unknown>>
3340
}
3441

3542
interface Navigator {

src/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import { i18n } from './i18n'
2525
* CRITICAL: Load remote config FIRST for cloud builds to ensure
2626
* window.__CONFIG__is available for all modules during initialization
2727
*/
28-
import { isCloud } from '@/platform/distribution/types'
28+
const isCloud = __DISTRIBUTION__ === 'cloud'
2929

3030
if (isCloud) {
3131
const { refreshRemoteConfig } =
3232
await import('@/platform/remoteConfig/refreshRemoteConfig')
3333
await refreshRemoteConfig({ useAuth: false })
34+
35+
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
36+
await initTelemetry()
3437
}
3538

3639
const ComfyUIPreset = definePreset(Aura, {

src/platform/cloud/onboarding/CloudSubscriptionRedirectView.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ vi.mock('@/composables/useErrorHandling', () => ({
4040

4141
const subscriptionMocks = vi.hoisted(() => ({
4242
isActiveSubscription: { value: false },
43-
isInitialized: { value: true }
43+
isInitialized: { value: true },
44+
subscriptionStatus: { value: null }
4445
}))
4546

4647
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({

src/platform/cloud/subscription/components/PricingTable.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,18 @@ const mockSubscriptionTier = ref<
1414
const mockIsYearlySubscription = ref(false)
1515
const mockAccessBillingPortal = vi.fn()
1616
const mockReportError = vi.fn()
17+
const mockTrackBeginCheckout = vi.fn()
1718
const mockGetFirebaseAuthHeader = vi.fn(() =>
1819
Promise.resolve({ Authorization: 'Bearer test-token' })
1920
)
21+
const mockGetCheckoutAttribution = vi.hoisted(() => vi.fn(() => ({})))
2022

2123
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
2224
useSubscription: () => ({
2325
isActiveSubscription: computed(() => mockIsActiveSubscription.value),
2426
subscriptionTier: computed(() => mockSubscriptionTier.value),
25-
isYearlySubscription: computed(() => mockIsYearlySubscription.value)
27+
isYearlySubscription: computed(() => mockIsYearlySubscription.value),
28+
subscriptionStatus: ref(null)
2629
})
2730
}))
2831

@@ -53,11 +56,22 @@ vi.mock('@/composables/useErrorHandling', () => ({
5356

5457
vi.mock('@/stores/firebaseAuthStore', () => ({
5558
useFirebaseAuthStore: () => ({
56-
getFirebaseAuthHeader: mockGetFirebaseAuthHeader
59+
getFirebaseAuthHeader: mockGetFirebaseAuthHeader,
60+
userId: 'user-123'
5761
}),
5862
FirebaseAuthStoreError: class extends Error {}
5963
}))
6064

65+
vi.mock('@/platform/telemetry', () => ({
66+
useTelemetry: () => ({
67+
trackBeginCheckout: mockTrackBeginCheckout
68+
})
69+
}))
70+
71+
vi.mock('@/platform/telemetry/utils/checkoutAttribution', () => ({
72+
getCheckoutAttribution: mockGetCheckoutAttribution
73+
}))
74+
6175
vi.mock('@/platform/distribution/types', () => ({
6276
isCloud: true
6377
}))
@@ -137,6 +151,7 @@ describe('PricingTable', () => {
137151
mockIsActiveSubscription.value = false
138152
mockSubscriptionTier.value = null
139153
mockIsYearlySubscription.value = false
154+
mockTrackBeginCheckout.mockReset()
140155
vi.mocked(global.fetch).mockResolvedValue({
141156
ok: true,
142157
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
@@ -159,6 +174,13 @@ describe('PricingTable', () => {
159174
await creatorButton?.trigger('click')
160175
await flushPromises()
161176

177+
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
178+
user_id: 'user-123',
179+
tier: 'creator',
180+
cycle: 'yearly',
181+
checkout_type: 'change',
182+
previous_tier: 'standard'
183+
})
162184
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
163185
})
164186

src/platform/cloud/subscription/components/PricingTable.vue

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils
265265
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
266266
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
267267
import { isCloud } from '@/platform/distribution/types'
268+
import { useTelemetry } from '@/platform/telemetry'
269+
import { getCheckoutAttribution } from '@/platform/telemetry/utils/checkoutAttribution'
270+
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
268271
import type { components } from '@/types/comfyRegistryTypes'
269272
270273
type SubscriptionTier = components['schemas']['SubscriptionTier']
@@ -329,6 +332,8 @@ const tiers: PricingTierConfig[] = [
329332
]
330333
const { isActiveSubscription, subscriptionTier, isYearlySubscription } =
331334
useSubscription()
335+
const telemetry = useTelemetry()
336+
const { userId } = useFirebaseAuthStore()
332337
const { accessBillingPortal, reportError } = useFirebaseAuthActions()
333338
const { wrapWithErrorHandlingAsync } = useErrorHandling()
334339
@@ -409,6 +414,19 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
409414
410415
try {
411416
if (isActiveSubscription.value) {
417+
const checkoutAttribution = getCheckoutAttribution()
418+
if (userId) {
419+
telemetry?.trackBeginCheckout({
420+
user_id: userId,
421+
tier: tierKey,
422+
cycle: currentBillingCycle.value,
423+
checkout_type: 'change',
424+
...checkoutAttribution,
425+
...(currentTierKey.value
426+
? { previous_tier: currentTierKey.value }
427+
: {})
428+
})
429+
}
412430
// Pass the target tier to create a deep link to subscription update confirmation
413431
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
414432
const targetPlan = {
@@ -429,7 +447,11 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
429447
await accessBillingPortal(checkoutTier)
430448
}
431449
} else {
432-
await performSubscriptionCheckout(tierKey, currentBillingCycle.value)
450+
await performSubscriptionCheckout(
451+
tierKey,
452+
currentBillingCycle.value,
453+
true
454+
)
433455
}
434456
} finally {
435457
isLoading.value = false

0 commit comments

Comments
 (0)