From 821c1e74ff4b149e612e35df50ccae142f738d1e Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Tue, 17 Feb 2026 02:43:34 -0800 Subject: [PATCH] fix: use gtag get for checkout attribution (#8930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- global.d.ts | 25 ++++-- src/platform/remoteConfig/types.ts | 1 + .../cloud/GtmTelemetryProvider.test.ts | 69 +++++++++++++++ .../providers/cloud/GtmTelemetryProvider.ts | 44 +++++++++- .../__tests__/checkoutAttribution.test.ts | 85 +++++++++++++++++-- .../telemetry/utils/checkoutAttribution.ts | 58 +++++++++++-- 6 files changed, 262 insertions(+), 20 deletions(-) create mode 100644 src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts diff --git a/global.d.ts b/global.d.ts index 20f4ea82a3..4955744e6c 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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 { + ( + 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> + gtag?: GtagFunction ire_o?: string ire?: ImpactQueueFunction } diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 39eadcbf86..28316cf0c0 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -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 diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts new file mode 100644 index 0000000000..89b62ab1de --- /dev/null +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts @@ -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) + }) +}) diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 94caf87dec..d4eb467543 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -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): void { if (!this.initialized) return window.dataLayer?.push({ event, ...properties }) diff --git a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts index 67ebe8cd1e..eb9409318d 100644 --- a/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts +++ b/src/platform/telemetry/utils/__tests__/checkoutAttribution.test.ts @@ -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 () => { diff --git a/src/platform/telemetry/utils/checkoutAttribution.ts b/src/platform/telemetry/utils/checkoutAttribution.ts index 156a843abb..c525a35a10 100644 --- a/src/platform/telemetry/utils/checkoutAttribution.ts +++ b/src/platform/telemetry/utils/checkoutAttribution.ts @@ -9,6 +9,13 @@ type GaIdentity = { session_number?: string } +const GA_IDENTITY_FIELDS = [ + 'client_id', + 'session_id', + 'session_number' +] as const satisfies ReadonlyArray +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> { 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 { + 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((resolve) => { + gtag('get', measurementId, fieldName, (value) => { + resolve(asNonEmptyString(value)) + }) + }), + GET_GA_IDENTITY_TIMEOUT_MS + ).catch(() => undefined) +} + +async function getGaIdentity(): Promise { + 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