Compare commits

...

2 Commits

Author SHA1 Message Date
Comfy Org PR Bot
4fa7e25053 [backport cloud/1.44] feat(telemetry): capture Rewardful referral on checkout attribution (#12624)
Backport of #12311 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: nav-tej <36310614+nav-tej@users.noreply.github.com>
Co-authored-by: glary-bot <glary-bot@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-03 12:12:09 -07:00
Comfy Org PR Bot
9035f66df3 [backport cloud/1.44] Pr/12481 - fixed error (#12603)
Backport of #12574 to `cloud/1.44`

Automatically created by backport workflow.

Co-authored-by: Steven Tran <94876858+stevenltran@users.noreply.github.com>
Co-authored-by: Nav Singh <nav@comfy.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Tran <steventran@Stevens-MacBook-Air.local>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-06-02 11:01:36 -07:00
6 changed files with 246 additions and 4 deletions

14
global.d.ts vendored
View File

@@ -11,6 +11,18 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
interface RewardfulQueueFunction {
(method: 'ready', callback: () => void): void
(...args: unknown[]): void
q?: unknown[][]
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -63,6 +75,8 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -136,7 +136,7 @@ describe('PostHogTelemetryProvider', () => {
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
})
it('identifies user when onUserResolved fires', async () => {
it('identifies user without setting first_auth_at when onUserResolved fires', async () => {
createProvider()
await vi.dynamicImportSettled()
@@ -172,6 +172,88 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('sets first_auth_at on new-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
})
it('does not set first_auth_at on returning-user auth', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuth({
method: 'google',
is_new_user: false,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: false,
user_id: 'user-123'
}
)
})
it('flushes queued first_auth_at before queued auth event', async () => {
const provider = createProvider()
provider.trackAuth({
method: 'google',
is_new_user: true,
user_id: 'user-123'
})
expect(hoisted.mockIdentify).not.toHaveBeenCalled()
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockIdentify).toHaveBeenCalledWith(
'user-123',
undefined,
expect.objectContaining({
first_auth_at: expect.any(String)
})
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_COMPLETED,
{
method: 'google',
is_new_user: true,
user_id: 'user-123'
}
)
expect(hoisted.mockIdentify.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.mockCapture.mock.invocationCallOrder[0]
)
})
it('queues events before initialization and flushes after', async () => {
const provider = createProvider()

View File

@@ -84,6 +84,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private isEnabled = true
private posthog: PostHog | null = null
private eventQueue: QueuedEvent[] = []
private pendingFirstAuthAt = new Map<string, string>()
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
@@ -164,6 +165,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private flushEventQueue(): void {
if (!this.isInitialized || !this.posthog) return
this.flushPendingFirstAuthAt()
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
@@ -174,6 +177,33 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
}
private flushPendingFirstAuthAt(): void {
for (const [userId, firstAuthAt] of this.pendingFirstAuthAt) {
this.setFirstAuthAt(userId, firstAuthAt)
}
this.pendingFirstAuthAt.clear()
}
private setFirstAuthAt(
userId: string,
firstAuthAt = new Date().toISOString()
): void {
if (!this.isEnabled) return
if (this.isInitialized && this.posthog) {
try {
this.posthog.identify(userId, undefined, { first_auth_at: firstAuthAt })
} catch (error) {
console.error('Failed to set PostHog first auth timestamp:', error)
}
return
}
if (!this.pendingFirstAuthAt.has(userId)) {
this.pendingFirstAuthAt.set(userId, firstAuthAt)
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
@@ -254,6 +284,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
trackAuth(metadata: AuthMetadata): void {
if (metadata.is_new_user && metadata.user_id) {
this.setFirstAuthAt(metadata.user_id)
}
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}

View File

@@ -325,6 +325,7 @@ export interface CheckoutAttributionMetadata {
ga_session_id?: string
ga_session_number?: string
im_ref?: string
rewardful_referral?: string
utm_source?: string
utm_medium?: string
utm_campaign?: string

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
captureCheckoutAttributionFromSearch,
@@ -15,9 +15,15 @@ describe('getCheckoutAttribution', () => {
}
window.gtag = undefined
window.ire = undefined
window.rewardful = undefined
window.Rewardful = undefined
window.history.pushState({}, '', '/')
})
afterEach(() => {
vi.useRealTimers()
})
it('reads GA identity and URL attribution, and prefers generated click id', async () => {
window.__CONFIG__ = {
...window.__CONFIG__,
@@ -228,4 +234,80 @@ describe('getCheckoutAttribution', () => {
expect(attribution.im_ref).toBeUndefined()
})
it('captures Rewardful referral from window.Rewardful', async () => {
window.Rewardful = {
referral: 'rwd-abc-123'
}
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
})
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('waits for Rewardful ready before reading the referral', async () => {
let readyCallback: (() => void) | undefined
window.rewardful = vi.fn((_method: 'ready', callback: () => void) => {
readyCallback = callback
}) as Window['rewardful']
const attributionPromise = getCheckoutAttribution()
await Promise.resolve()
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
window.Rewardful = {
referral: 'rwd-ready-123'
}
readyCallback?.()
const attribution = await attributionPromise
expect(attribution.rewardful_referral).toBe('rwd-ready-123')
})
it('continues checkout attribution when Rewardful ready never runs', async () => {
vi.useFakeTimers()
window.rewardful = vi.fn() as Window['rewardful']
const attributionPromise = getCheckoutAttribution()
await vi.advanceTimersByTimeAsync(300)
const attribution = await attributionPromise
expect(window.rewardful).toHaveBeenCalledWith('ready', expect.any(Function))
expect(attribution.rewardful_referral).toBeUndefined()
})
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
window.Rewardful = { referral: '' }
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('captures Rewardful referral alongside Impact attribution', async () => {
window.history.pushState(
{},
'',
'/?im_ref=impact-url-id&utm_source=affiliate'
)
window.Rewardful = {
referral: 'rwd-xyz-789'
}
const attribution = await getCheckoutAttribution()
expect(attribution).toMatchObject({
im_ref: 'impact-url-id',
utm_source: 'affiliate',
rewardful_referral: 'rwd-xyz-789'
})
})
})

View File

@@ -31,6 +31,7 @@ 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
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
@@ -180,6 +181,30 @@ async function getGeneratedClickId(): Promise<string | undefined> {
}
}
async function getRewardfulReferral(): Promise<string | undefined> {
if (typeof window === 'undefined') return undefined
const referral = asNonEmptyString(window.Rewardful?.referral)
if (referral) return referral
const rewardful = window.rewardful
if (typeof rewardful !== 'function') return undefined
return withTimeout(
() =>
new Promise<string | undefined>((resolve, reject) => {
try {
rewardful('ready', () => {
resolve(asNonEmptyString(window.Rewardful?.referral))
})
} catch (error) {
reject(error)
}
}),
GET_REWARDFUL_REFERRAL_TIMEOUT_MS
).catch(() => undefined)
}
export function captureCheckoutAttributionFromSearch(search: string): void {
const fromUrl = readAttributionFromUrl(search)
const storedAttribution = readStoredAttribution()
@@ -198,6 +223,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
const storedAttribution = readStoredAttribution()
const fromUrl = readAttributionFromUrl(window.location.search)
const rewardfulReferralPromise = getRewardfulReferral()
const generatedClickId = await getGeneratedClickId()
const attribution: Partial<Record<AttributionQueryKey, string>> = {
...storedAttribution,
@@ -212,12 +238,16 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
persistAttribution(attribution)
}
const gaIdentity = await getGaIdentity()
const [gaIdentity, rewardfulReferral] = await Promise.all([
getGaIdentity(),
rewardfulReferralPromise
])
return {
...attribution,
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number
ga_session_number: gaIdentity?.session_number,
rewardful_referral: rewardfulReferral
}
}