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:
Benjamin Lu
2026-02-17 02:43:34 -08:00
committed by GitHub
parent d06cc0819a
commit 821c1e74ff
6 changed files with 262 additions and 20 deletions

25
global.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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