mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-19 22:34:15 +00:00
fix: use gtag get for checkout attribution (#8930)
## Summary
Replace checkout attribution GA identity sourcing from
`window.__ga_identity__` with GA4 `gtag('get', ...)` calls keyed by
remote config measurement ID.
## Changes
- **What**:
- Add typed global `gtag` get definitions and shared GA field types.
- Fetch `client_id`, `session_id`, and `session_number` via `gtag('get',
measurementId, field, callback)` with timeout-based fallback.
- Normalize numeric GA values to strings before emitting checkout
attribution metadata.
- Update checkout attribution tests to mock `gtag` retrieval and verify
requested fields + numeric normalization.
- Add `ga_measurement_id` to remote config typings.
## Review Focus
Validate the `gtag('get', ...)` retrieval path and failure handling
(`undefined` fallback on timeout/errors) and confirm analytics field
names match GA4 expectations.
## Screenshots (if applicable)
N/A
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8930-fix-use-gtag-get-for-checkout-attribution-30a6d73d365081dcb773da945daceee6)
by [Unito](https://www.unito.io)
This commit is contained in:
25
global.d.ts
vendored
25
global.d.ts
vendored
@@ -10,9 +10,28 @@ interface ImpactQueueFunction {
|
||||
a?: unknown[][]
|
||||
}
|
||||
|
||||
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
|
||||
|
||||
interface GtagGetFieldValueMap {
|
||||
client_id: string | number | undefined
|
||||
session_id: string | number | undefined
|
||||
session_number: string | number | undefined
|
||||
}
|
||||
|
||||
interface GtagFunction {
|
||||
<TField extends GtagGetFieldName>(
|
||||
command: 'get',
|
||||
targetId: string,
|
||||
fieldName: TField,
|
||||
callback: (value: GtagGetFieldValueMap[TField]) => void
|
||||
): void
|
||||
(...args: unknown[]): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
@@ -36,12 +55,8 @@ interface Window {
|
||||
badge?: string
|
||||
}
|
||||
}
|
||||
__ga_identity__?: {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
dataLayer?: Array<Record<string, unknown>>
|
||||
gtag?: GtagFunction
|
||||
ire_o?: string
|
||||
ire?: ImpactQueueFunction
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = {
|
||||
*/
|
||||
export type RemoteConfig = {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
|
||||
|
||||
describe('GtmTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.__CONFIG__ = {}
|
||||
window.dataLayer = undefined
|
||||
window.gtag = undefined
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('injects the GTM runtime script', () => {
|
||||
window.__CONFIG__ = {
|
||||
gtm_container_id: 'GTM-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtmScript = document.querySelector(
|
||||
'script[src="https://www.googletagmanager.com/gtm.js?id=GTM-TEST123"]'
|
||||
)
|
||||
|
||||
expect(gtmScript).not.toBeNull()
|
||||
expect(window.dataLayer?.[0]).toMatchObject({
|
||||
event: 'gtm.js'
|
||||
})
|
||||
})
|
||||
|
||||
it('bootstraps gtag when a GA measurement id exists', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtagScript = document.querySelector(
|
||||
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
|
||||
)
|
||||
const dataLayer = window.dataLayer as unknown[]
|
||||
|
||||
expect(gtagScript).not.toBeNull()
|
||||
expect(typeof window.gtag).toBe('function')
|
||||
expect(dataLayer).toHaveLength(2)
|
||||
expect(Array.from(dataLayer[0] as IArguments)[0]).toBe('js')
|
||||
expect(Array.from(dataLayer[1] as IArguments)).toEqual([
|
||||
'config',
|
||||
'G-TEST123',
|
||||
{
|
||||
send_page_view: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not inject duplicate gtag scripts across repeated init', () => {
|
||||
window.__CONFIG__ = {
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
|
||||
new GtmTelemetryProvider()
|
||||
new GtmTelemetryProvider()
|
||||
|
||||
const gtagScripts = document.querySelectorAll(
|
||||
'script[src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"]'
|
||||
)
|
||||
|
||||
expect(gtagScripts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -22,13 +22,21 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const gtmId = window.__CONFIG__?.gtm_container_id
|
||||
if (!gtmId) {
|
||||
if (gtmId) {
|
||||
this.initializeGtm(gtmId)
|
||||
} else {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.warn('[GTM] No GTM ID configured, skipping initialization')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const measurementId = window.__CONFIG__?.ga_measurement_id
|
||||
if (measurementId) {
|
||||
this.bootstrapGtag(measurementId)
|
||||
}
|
||||
}
|
||||
|
||||
private initializeGtm(gtmId: string): void {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.dataLayer.push({
|
||||
@@ -44,6 +52,38 @@ export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private bootstrapGtag(measurementId: string): void {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
if (typeof window.gtag !== 'function') {
|
||||
function gtag() {
|
||||
// gtag queue shape is dataLayer.push(arguments)
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
;(window.dataLayer as unknown[] | undefined)?.push(arguments)
|
||||
}
|
||||
|
||||
window.gtag = gtag as Window['gtag']
|
||||
}
|
||||
|
||||
const gtagScriptSrc = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`
|
||||
const existingGtagScript = document.querySelector(
|
||||
`script[src="${gtagScriptSrc}"]`
|
||||
)
|
||||
|
||||
if (!existingGtagScript) {
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = gtagScriptSrc
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
}
|
||||
|
||||
const gtag = window.gtag
|
||||
if (typeof gtag !== 'function') return
|
||||
|
||||
gtag('js', new Date())
|
||||
gtag('config', measurementId, { send_page_view: false })
|
||||
}
|
||||
|
||||
private pushEvent(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!this.initialized) return
|
||||
window.dataLayer?.push({ event, ...properties })
|
||||
|
||||
@@ -9,17 +9,37 @@ describe('getCheckoutAttribution', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
window.__ga_identity__ = undefined
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: undefined
|
||||
}
|
||||
window.gtag = undefined
|
||||
window.ire = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
|
||||
window.__ga_identity__ = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
const gtagSpy = vi.fn(
|
||||
(
|
||||
_command: 'get',
|
||||
_targetId: string,
|
||||
fieldName: GtagGetFieldName,
|
||||
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
|
||||
) => {
|
||||
const valueByField = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
}
|
||||
callback(valueByField[fieldName])
|
||||
}
|
||||
)
|
||||
window.gtag = gtagSpy as unknown as Window['gtag']
|
||||
|
||||
window.history.pushState(
|
||||
{},
|
||||
'',
|
||||
@@ -48,6 +68,61 @@ describe('getCheckoutAttribution', () => {
|
||||
'generateClickId',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'client_id',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_id',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_number',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('stringifies numeric GA values from gtag', async () => {
|
||||
window.__CONFIG__ = {
|
||||
...window.__CONFIG__,
|
||||
ga_measurement_id: 'G-TEST123'
|
||||
}
|
||||
const gtagSpy = vi.fn(
|
||||
(
|
||||
_command: 'get',
|
||||
_targetId: string,
|
||||
fieldName: GtagGetFieldName,
|
||||
callback: (value: GtagGetFieldValueMap[GtagGetFieldName]) => void
|
||||
) => {
|
||||
const valueByField = {
|
||||
client_id: '123.456',
|
||||
session_id: 1700000000,
|
||||
session_number: 2
|
||||
}
|
||||
callback(valueByField[fieldName])
|
||||
}
|
||||
)
|
||||
window.gtag = gtagSpy as unknown as Window['gtag']
|
||||
|
||||
const attribution = await getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
ga_client_id: '123.456',
|
||||
ga_session_id: '1700000000',
|
||||
ga_session_number: '2'
|
||||
})
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'get',
|
||||
'G-TEST123',
|
||||
'session_number',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to URL click id when generateClickId is unavailable', async () => {
|
||||
|
||||
@@ -9,6 +9,13 @@ type GaIdentity = {
|
||||
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',
|
||||
@@ -23,6 +30,7 @@ const ATTRIBUTION_QUERY_KEYS = [
|
||||
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 {}
|
||||
@@ -93,19 +101,53 @@ function hasAttributionChanges(
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getGaIdentity(): GaIdentity | undefined {
|
||||
if (typeof window === 'undefined') return 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
|
||||
|
||||
const identity = window.__ga_identity__
|
||||
if (!isPlainObject(identity)) return undefined
|
||||
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: asNonEmptyString(identity.client_id),
|
||||
session_id: asNonEmptyString(identity.session_id),
|
||||
session_number: asNonEmptyString(identity.session_number)
|
||||
client_id: clientId,
|
||||
session_id: sessionId,
|
||||
session_number: sessionNumber
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +212,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
|
||||
persistAttribution(attribution)
|
||||
}
|
||||
|
||||
const gaIdentity = getGaIdentity()
|
||||
const gaIdentity = await getGaIdentity()
|
||||
|
||||
return {
|
||||
...attribution,
|
||||
|
||||
Reference in New Issue
Block a user