Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Window {
badge?: string
}
}
dataLayer?: Array<Record<string, unknown>>
}

interface Navigator {
Expand Down
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ if (isCloud) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })

const { initGtm } = await import('@/platform/telemetry/gtm')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

initGtm()
}

const ComfyUIPreset = definePreset(Aura, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,45 @@ describe('useSubscription', () => {
)
})

it('pushes purchase event after a pending subscription completes', async () => {
window.dataLayer = []
localStorage.setItem(
'pending_subscription_purchase',
JSON.stringify({
tierKey: 'creator',
billingCycle: 'monthly',
timestamp: Date.now()
})
)

vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
is_active: true,
subscription_id: 'sub_123',
subscription_tier: 'CREATOR',
subscription_duration: 'MONTHLY'
})
} as Response)

mockIsLoggedIn.value = true
const { fetchStatus } = useSubscription()

await fetchStatus()

expect(window.dataLayer).toHaveLength(1)
expect(window.dataLayer?.[0]).toMatchObject({
event: 'purchase',
transaction_id: 'sub_123',
currency: 'USD',
item_id: 'monthly_creator',
item_variant: 'monthly',
item_category: 'subscription',
quantity: 1
})
expect(localStorage.getItem('pending_subscription_purchase')).toBeNull()
})

it('should handle fetch errors gracefully', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
Expand Down
53 changes: 51 additions & 2 deletions src/platform/cloud/subscription/composables/useSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import { useDialogService } from '@/services/dialogService'
import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
import {
getTierPrice,
TIER_TO_KEY
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
clearPendingSubscriptionPurchase,
getPendingSubscriptionPurchase
} from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'

Expand Down Expand Up @@ -93,7 +101,42 @@ function useSubscriptionInternal() {
: baseName
})

const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
function buildApiUrl(path: string): string {
return `${getComfyApiBaseUrl()}${path}`
}

function trackSubscriptionPurchase(
status: CloudSubscriptionStatusResponse | null
): void {
if (!status?.is_active || !status.subscription_id) return

const pendingPurchase = getPendingSubscriptionPurchase()
if (!pendingPurchase) return

const { tierKey, billingCycle } = pendingPurchase
const isYearly = billingCycle === 'yearly'
const baseName = t(`subscription.tiers.${tierKey}.name`)
const planName = isYearly
? t('subscription.tierNameYearly', { name: baseName })
: baseName
const unitPrice = getTierPrice(tierKey, isYearly)
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice

pushDataLayerEvent({
event: 'purchase',
transaction_id: status.subscription_id,
value,
currency: 'USD',
item_id: `${billingCycle}_${tierKey}`,
item_name: planName,
item_category: 'subscription',
item_variant: billingCycle,
price: value,
quantity: 1
})

clearPendingSubscriptionPurchase()
}

const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
Expand Down Expand Up @@ -194,6 +237,12 @@ function useSubscriptionInternal() {

const statusData = await response.json()
subscriptionStatus.value = statusData

try {
await trackSubscriptionPurchase(statusData)
} catch (error) {
console.error('Failed to track subscription purchase', error)
}
return statusData
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { startSubscriptionPurchaseTracking } from '@/platform/cloud/subscription/utils/subscriptionPurchaseTracker'
import type { BillingCycle } from './subscriptionTierRank'

type CheckoutTier = TierKey | `${TierKey}-yearly`
Expand Down Expand Up @@ -78,6 +79,7 @@ export async function performSubscriptionCheckout(
const data = await response.json()

if (data.checkout_url) {
startSubscriptionPurchaseTracking(tierKey, currentBillingCycle)
if (openInNewTab) {
window.open(data.checkout_url, '_blank')
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from './subscriptionTierRank'

type PendingSubscriptionPurchase = {
tierKey: TierKey
billingCycle: BillingCycle
timestamp: number
}

const STORAGE_KEY = 'pending_subscription_purchase'
const MAX_AGE_MS = 24 * 60 * 60 * 1000 // 24 hours
const VALID_TIERS: TierKey[] = ['standard', 'creator', 'pro', 'founder']
const VALID_CYCLES: BillingCycle[] = ['monthly', 'yearly']

const safeRemove = (): void => {
try {
localStorage.removeItem(STORAGE_KEY)
} catch {
// Ignore storage errors (e.g. private browsing mode)
}
}

export function startSubscriptionPurchaseTracking(
tierKey: TierKey,
billingCycle: BillingCycle
): void {
if (typeof window === 'undefined') return
try {
const payload: PendingSubscriptionPurchase = {
tierKey,
billingCycle,
timestamp: Date.now()
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
} catch {
// Ignore storage errors (e.g. private browsing mode)
}
}

export function getPendingSubscriptionPurchase(): PendingSubscriptionPurchase | null {
if (typeof window === 'undefined') return null

try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null

const parsed = JSON.parse(raw) as PendingSubscriptionPurchase
if (!parsed || typeof parsed !== 'object') {
safeRemove()
return null
}

const { tierKey, billingCycle, timestamp } = parsed
if (
!VALID_TIERS.includes(tierKey) ||
!VALID_CYCLES.includes(billingCycle) ||
typeof timestamp !== 'number'
) {
safeRemove()
return null
}

if (Date.now() - timestamp > MAX_AGE_MS) {
safeRemove()
return null
}

return parsed
} catch {
safeRemove()
return null
}
}

export function clearPendingSubscriptionPurchase(): void {
if (typeof window === 'undefined') return
safeRemove()
}
43 changes: 43 additions & 0 deletions src/platform/telemetry/gtm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { isCloud } from '@/platform/distribution/types'

const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'

let isInitialized = false
let initPromise: Promise<void> | null = null

export function initGtm(): void {
if (!isCloud || typeof window === 'undefined') return
if (typeof document === 'undefined') return
if (isInitialized) return

if (!initPromise) {
initPromise = new Promise((resolve) => {
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
'gtm.start': Date.now(),
event: 'gtm.js'
})

const script = document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`

const finalize = () => {
isInitialized = true
resolve()
}

script.addEventListener('load', finalize, { once: true })
script.addEventListener('error', finalize, { once: true })
document.head?.appendChild(script)
Comment on lines +25 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Script load errors are silently swallowed.

When GTM script fails to load (e.g., network error, blocked by ad-blocker), finalize still sets isInitialized = true. This silently succeeds, which is reasonable for best-effort analytics, but consider logging the error for observability.

♻️ Optional: Add error logging
       const finalize = () => {
         isInitialized = true
         resolve()
       }

       script.addEventListener('load', finalize, { once: true })
-      script.addEventListener('error', finalize, { once: true })
+      script.addEventListener('error', (e) => {
+        console.warn('GTM script failed to load:', e)
+        finalize()
+      }, { once: true })
       document.head?.appendChild(script)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const finalize = () => {
isInitialized = true
resolve()
}
script.addEventListener('load', finalize, { once: true })
script.addEventListener('error', finalize, { once: true })
document.head?.appendChild(script)
const finalize = () => {
isInitialized = true
resolve()
}
script.addEventListener('load', finalize, { once: true })
script.addEventListener('error', (e) => {
console.warn('GTM script failed to load:', e)
finalize()
}, { once: true })
document.head?.appendChild(script)
🤖 Prompt for AI Agents
In `@src/platform/telemetry/gtm.ts` around lines 25 - 32, The current finalize
handler marks isInitialized = true for both load and error events and swallows
failures; change this so the 'load' event sets isInitialized = true and
resolves, while the 'error' event uses a separate handler that logs the failure
(e.g., via console.warn or your telemetry/logger) including the Event/error
details and then resolves (still best-effort). Update the event wiring from
using finalize for both events to use finalize (or onLoad) for 'load' and
onError (or finalizeError) for 'error', ensuring you still resolve the
initialization promise but do not falsely mark success without logging;
reference isInitialized, finalize, and the script.addEventListener(...) calls to
locate where to change.

})
}

void initPromise
}

export function pushDataLayerEvent(event: Record<string, unknown>): void {
if (!isCloud || typeof window === 'undefined') return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push(event)
}
15 changes: 15 additions & 0 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'

import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { pushDataLayerEvent } from '@/platform/telemetry/gtm'
import { useDialogService } from '@/services/dialogService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useUserStore } from '@/stores/userStore'
Expand Down Expand Up @@ -36,6 +37,16 @@ function getBasePath(): string {

const basePath = getBasePath()

function pushPageView(): void {
if (!isCloud || typeof window === 'undefined') return

pushDataLayerEvent({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
Comment on lines +44 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid sending user workflow titles to GTM

pushPageView forwards document.title as page_title. In this app, document.title is derived from the active workflow filename and node execution text via useBrowserTabTitle (see src/composables/useBrowserTabTitle.ts), which are user-provided values. On cloud builds this will send workflow names and progress text to GTM/GA on every route change, which can leak sensitive user content into analytics. Consider omitting page_title or replacing it with a sanitized/static value such as the route name before pushing to dataLayer.

Useful? React with 👍 / 👎.

})
}
Comment on lines 40 to 48
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential timing issue with document.title.

When pushPageView is called in router.afterEach, document.title may still reflect the previous page's title since Vue Router's navigation completes before the new component is mounted and updates the title.

Consider using to.meta.title if available, or deferring the push with nextTick to allow the DOM to update:

🔧 Suggested fix using nextTick
+import { nextTick } from 'vue'
+
 function pushPageView(): void {
   if (!isCloud) return

-  const dataLayer = window.dataLayer ?? (window.dataLayer = [])
-  dataLayer.push({
-    event: 'page_view',
-    page_location: window.location.href,
-    page_title: document.title
+  nextTick(() => {
+    const dataLayer = window.dataLayer ?? (window.dataLayer = [])
+    dataLayer.push({
+      event: 'page_view',
+      page_location: window.location.href,
+      page_title: document.title
+    })
   })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function pushPageView(): void {
if (!isCloud) return
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
})
}
import { nextTick } from 'vue'
function pushPageView(): void {
if (!isCloud) return
nextTick(() => {
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
dataLayer.push({
event: 'page_view',
page_location: window.location.href,
page_title: document.title
})
})
}
🤖 Prompt for AI Agents
In `@src/router.ts` around lines 39 - 48, pushPageView may read document.title too
early when called from router.afterEach causing the old title to be pushed;
change pushPageView (and its caller in router.afterEach) to prefer a provided
title param (e.g., to.meta.title) and/or defer reading document.title via
Vue.nextTick before building the dataLayer object so the new page title is
available. Update the pushPageView signature to accept an optional title and in
router.afterEach pass to.meta.title if present, otherwise call pushPageView
inside nextTick to read document.title.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh, we'll see what marketing/gtm wants. we pagetitle is practically only ever going to be ComfyUI because we only change it for workflow name, unsaved indicator, and node/progress status.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benceruleanlu, understood! That makes sense—if the title is mostly static, the timing issue becomes less critical. It's good to verify with your marketing/GTM team whether they need route-specific titles or if the current approach works for their tracking needs. Feel free to follow up if they have specific requirements! 👍


✏️ Learnings added
Learnt from: benceruleanlu
Repo: Comfy-Org/ComfyUI_frontend PR: 8311
File: src/router.ts:39-48
Timestamp: 2026-01-26T20:01:11.087Z
Learning: In ComfyUI, the page title (document.title) is mostly static and remains "ComfyUI" throughout the application. It only changes for workflow name, unsaved indicator, and node/progress status, not for different routes or pages.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


const router = createRouter({
history: isFileProtocol
? createWebHashHistory()
Expand Down Expand Up @@ -93,6 +104,10 @@ installPreservedQueryTracker(router, [
}
])

router.afterEach(() => {
pushPageView()
})

if (isCloud) {
const { flags } = useFeatureFlags()
const PUBLIC_ROUTE_NAMES = new Set([
Expand Down
44 changes: 44 additions & 0 deletions src/stores/firebaseAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { pushDataLayerEvent as pushDataLayerEventBase } from '@/platform/telemetry/gtm'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
Expand Down Expand Up @@ -81,6 +82,42 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {

const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`

function pushDataLayerEvent(event: Record<string, unknown>): void {
if (!isCloud || typeof window === 'undefined') return

try {
pushDataLayerEventBase(event)
} catch (error) {
console.warn('Failed to push data layer event', error)
}
}

async function hashSha256(value: string): Promise<string | undefined> {
if (typeof crypto === 'undefined' || !crypto.subtle) return
if (typeof TextEncoder === 'undefined') return
const data = new TextEncoder().encode(value)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

async function trackSignUp(method: 'email' | 'google' | 'github') {
if (!isCloud || typeof window === 'undefined') return

try {
const userId = currentUser.value?.uid
const hashedUserId = userId ? await hashSha256(userId) : undefined
pushDataLayerEvent({
event: 'sign_up',
method,
...(hashedUserId ? { user_id: hashedUserId } : {})
})
} catch (error) {
console.warn('Failed to track sign up', error)
}
}

// Providers
const googleProvider = new GoogleAuthProvider()
googleProvider.addScope('email')
Expand Down Expand Up @@ -347,6 +384,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'email',
is_new_user: true
})
await trackSignUp('email')
}

return result
Expand All @@ -365,6 +403,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'google',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('google')
}
}

return result
Expand All @@ -383,6 +424,9 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
method: 'github',
is_new_user: isNewUser
})
if (isNewUser) {
await trackSignUp('github')
}
}

return result
Expand Down
Loading