Files
ComfyUI_frontend/src/platform/telemetry/utils/checkoutAttribution.ts
Comfy Org PR Bot 4eb8148e81 [backport cloud/1.39] fix: use gtag get for checkout attribution (#8937)
Backport of #8930 to `cloud/1.39`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8937-backport-cloud-1-39-fix-use-gtag-get-for-checkout-attribution-30a6d73d365081b49d55d96912d9913b)
by [Unito](https://www.unito.io)

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-02-17 11:52:58 -08:00

224 lines
5.4 KiB
TypeScript

import { isPlainObject } from 'es-toolkit'
import { withTimeout } from 'es-toolkit/promise'
import type { CheckoutAttributionMetadata } from '../types'
type GaIdentity = {
client_id?: string
session_id?: string
session_number?: string
}
const GA_IDENTITY_FIELDS = [
'client_id',
'session_id',
'session_number'
] as const satisfies ReadonlyArray<GtagGetFieldName>
type GaIdentityField = GtagGetFieldName
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'gbraid',
'wbraid'
] as const
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isPlainObject(parsed)) return {}
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = asNonEmptyString(parsed[key])
if (value) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
function persistAttribution(
payload: Partial<Record<AttributionQueryKey, string>>
): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
} catch {
return
}
}
function readAttributionFromUrl(
search: string
): Partial<Record<AttributionQueryKey, string>> {
const params = new URLSearchParams(search)
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = params.get(key)
if (value) {
result[key] = value
}
}
return result
}
function hasAttributionChanges(
existing: Partial<Record<AttributionQueryKey, string>>,
incoming: Partial<Record<AttributionQueryKey, string>>
): boolean {
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = incoming[key]
if (value !== undefined && existing[key] !== value) {
return true
}
}
return false
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
): Promise<string | undefined> {
if (typeof window === 'undefined' || typeof window.gtag !== 'function') {
return undefined
}
const gtag = window.gtag
return withTimeout(
() =>
new Promise<string | undefined>((resolve) => {
gtag('get', measurementId, fieldName, (value) => {
resolve(asNonEmptyString(value))
})
}),
GET_GA_IDENTITY_TIMEOUT_MS
).catch(() => undefined)
}
async function getGaIdentity(): Promise<GaIdentity | undefined> {
const measurementId = asNonEmptyString(window.__CONFIG__?.ga_measurement_id)
if (!measurementId) {
return undefined
}
const [clientId, sessionId, sessionNumber] = await Promise.all(
GA_IDENTITY_FIELDS.map((fieldName) =>
getGaIdentityField(measurementId, fieldName)
)
)
if (!clientId && !sessionId && !sessionNumber) {
return undefined
}
return {
client_id: clientId,
session_id: sessionId,
session_number: sessionNumber
}
}
async function getGeneratedClickId(): Promise<string | undefined> {
if (typeof window === 'undefined') {
return undefined
}
const impactQueue = window.ire
if (typeof impactQueue !== 'function') {
return undefined
}
try {
return await withTimeout(
() =>
new Promise<string | undefined>((resolve, reject) => {
try {
impactQueue('generateClickId', (clickId: unknown) => {
resolve(asNonEmptyString(clickId))
})
} catch (error) {
reject(error)
}
}),
GENERATE_CLICK_ID_TIMEOUT_MS
)
} catch {
return undefined
}
}
export function captureCheckoutAttributionFromSearch(search: string): void {
const fromUrl = readAttributionFromUrl(search)
const storedAttribution = readStoredAttribution()
if (Object.keys(fromUrl).length === 0) return
if (!hasAttributionChanges(storedAttribution, fromUrl)) return
persistAttribution({
...storedAttribution,
...fromUrl
})
}
export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetadata> {
if (typeof window === 'undefined') return {}
const storedAttribution = readStoredAttribution()
const fromUrl = readAttributionFromUrl(window.location.search)
const generatedClickId = await getGeneratedClickId()
const attribution: Partial<Record<AttributionQueryKey, string>> = {
...storedAttribution,
...fromUrl
}
if (generatedClickId) {
attribution.im_ref = generatedClickId
}
if (hasAttributionChanges(storedAttribution, attribution)) {
persistAttribution(attribution)
}
const gaIdentity = await getGaIdentity()
return {
...attribution,
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number
}
}