mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-26 01:34:07 +00:00
fix: route gtm through telemetry entrypoint (#8354)
Wire checkout attribution into GTM events and checkout POST payloads.
This updates the cloud telemetry flow so the backend team can correlate checkout events without relying on frontend cookie parsing. We now surface GA4 identity via a GTM-provided global and include attribution on both `begin_checkout` telemetry and the checkout POST body. The backend should continue to derive the Firebase UID from the auth header; the checkout POST body does not include a user ID.
GTM events pushed (unchanged list, updated payloads):
- `page_view` (page title/location/referrer as before)
- `sign_up` / `login`
- `begin_checkout` now includes:
- `user_id`, `tier`, `cycle`, `checkout_type`, `previous_tier` (if change flow)
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
Backend-facing change:
- `POST /customers/cloud-subscription-checkout/:tier` now includes a JSON body with attribution fields only:
- `ga_client_id`, `ga_session_id`, `ga_session_number`
- `gclid`, `gbraid`, `wbraid`
- Backend should continue to derive the Firebase UID from the auth header.
Required GTM setup:
- Provide `window.__ga_identity__` via a GTM Custom HTML tag (after GA4/Google tag) with `{ client_id, session_id, session_number }`. The frontend reads this to populate the GA fields.
<img width="1416" height="1230" alt="image" src="https://github.com/user-attachments/assets/b77cf0ed-be69-4497-a540-86e5beb7bfac" />
## Screenshots (if applicable)
<img width="991" height="385" alt="image" src="https://github.com/user-attachments/assets/8309cd9e-5ab5-4fba-addb-2d101aaae7e9"/>
Manual Testing:
<img width="3839" height="2020" alt="image" src="https://github.com/user-attachments/assets/36901dfd-08db-4c07-97b8-a71e6783c72f"/>
<img width="2141" height="851" alt="image" src="https://github.com/user-attachments/assets/2e9f7aa4-4716-40f7-b147-1c74b0ce8067"/>
<img width="2298" height="982" alt="image" src="https://github.com/user-attachments/assets/72cbaa53-9b92-458a-8539-c987cf753b02"/>
<img width="2125" height="999" alt="image" src="https://github.com/user-attachments/assets/4b22387e-8027-4f50-be49-a410282a1adc"/>
To manually test, you will need to override api/features in devtools to also return this:
```
"gtm_container_id": "GTM-NP9JM6K7"
```
┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8354-fix-route-gtm-through-telemetry-entrypoint-2f66d73d36508138afacdeffe835f28a) by [Unito](https://www.unito.io)
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit
* **New Features**
* Analytics expanded: page view tracking, richer auth telemetry (includes user IDs), and checkout begin events with attribution.
* Google Tag Manager support and persistent checkout attribution (GA/client/session IDs, gclid/gbraid/wbraid).
* **Chores**
* Telemetry reworked to support multiple providers via a registry with cloud-only initialization.
* Workflow module refactored for clearer exports.
* **Tests**
* Added/updated tests for attribution, telemetry, and subscription flows.
* **CI**
* New check prevents telemetry from leaking into distribution artifacts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
220
src/platform/telemetry/TelemetryRegistry.ts
Normal file
220
src/platform/telemetry/TelemetryRegistry.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
SettingChangedMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryDispatcher,
|
||||
TelemetryProvider,
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Registry that holds multiple telemetry providers and dispatches
|
||||
* all tracking calls to each registered provider.
|
||||
*
|
||||
* Implements TelemetryDispatcher (all methods required) while dispatching
|
||||
* to TelemetryProvider instances using optional chaining since providers
|
||||
* only implement the methods they care about.
|
||||
*/
|
||||
export class TelemetryRegistry implements TelemetryDispatcher {
|
||||
private providers: TelemetryProvider[] = []
|
||||
|
||||
registerProvider(provider: TelemetryProvider): void {
|
||||
this.providers.push(provider)
|
||||
}
|
||||
|
||||
private dispatch(action: (provider: TelemetryProvider) => void): void {
|
||||
this.providers.forEach((provider) => {
|
||||
try {
|
||||
action(provider)
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Provider dispatch failed', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.dispatch((provider) => provider.trackSignupOpened?.())
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.dispatch((provider) => provider.trackAuth?.(metadata))
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
this.dispatch((provider) => provider.trackUserLoggedIn?.())
|
||||
}
|
||||
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||
this.dispatch((provider) => provider.trackSubscription?.(event))
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionSucceeded?.())
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.dispatch((provider) => provider.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.dispatch((provider) => provider.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackApiCreditTopupButtonPurchaseClicked?.(amount)
|
||||
)
|
||||
}
|
||||
|
||||
trackApiCreditTopupSucceeded(): void {
|
||||
this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
this.dispatch((provider) => provider.trackRunButton?.(options))
|
||||
}
|
||||
|
||||
startTopupTracking(): void {
|
||||
this.dispatch((provider) => provider.startTopupTracking?.())
|
||||
}
|
||||
|
||||
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean {
|
||||
return this.providers.some((provider) => {
|
||||
try {
|
||||
return provider.checkForCompletedTopup?.(events) ?? false
|
||||
} catch (error) {
|
||||
console.error('[Telemetry] Provider dispatch failed', error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
clearTopupTracking(): void {
|
||||
this.dispatch((provider) => provider.clearTopupTracking?.())
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
): void {
|
||||
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplate?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateLibraryOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateLibraryClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowImported?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackTabCount(metadata: TabCountMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTabCount?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void {
|
||||
this.dispatch((provider) => provider.trackNodeSearch?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
|
||||
this.dispatch((provider) =>
|
||||
provider.trackNodeSearchResultSelected?.(metadata)
|
||||
)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.dispatch((provider) => provider.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpCenterOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpResourceClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackHelpCenterClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowCreated?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
this.dispatch((provider) => provider.trackWorkflowExecution?.())
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionError?.(metadata))
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.dispatch((provider) => provider.trackSettingChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
|
||||
this.dispatch((provider) => provider.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.dispatch((provider) => provider.trackPageView?.(pageName, properties))
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,19 @@
|
||||
/**
|
||||
* Telemetry Provider - OSS Build Safety
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* This module is conditionally compiled based on distribution. When building
|
||||
* the open source version (DISTRIBUTION unset), this entire module and its dependencies
|
||||
* are excluded through via tree-shaking.
|
||||
*
|
||||
* To verify OSS builds exclude this code:
|
||||
* 1. `DISTRIBUTION= pnpm build` (OSS build)
|
||||
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*
|
||||
* This approach maintains complete separation between cloud and OSS builds
|
||||
* while ensuring the open source version contains no telemetry dependencies.
|
||||
*/
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { TelemetryDispatcher } from './types'
|
||||
|
||||
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
|
||||
import type { TelemetryProvider } from './types'
|
||||
|
||||
// Singleton instance
|
||||
let _telemetryProvider: TelemetryProvider | null = null
|
||||
let _telemetryRegistry: TelemetryDispatcher | null = null
|
||||
|
||||
/**
|
||||
* Telemetry factory - conditionally creates provider based on distribution
|
||||
* Returns singleton instance.
|
||||
* Get the telemetry dispatcher for tracking events.
|
||||
* Returns null in OSS builds - all tracking calls become no-ops.
|
||||
*
|
||||
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
|
||||
* for OSS builds and all tracking calls are no-ops.
|
||||
* Usage: useTelemetry()?.trackAuth({ method: 'google' })
|
||||
*/
|
||||
export function useTelemetry(): TelemetryProvider | null {
|
||||
if (_telemetryProvider === null) {
|
||||
// Use distribution check for tree-shaking
|
||||
if (isCloud) {
|
||||
_telemetryProvider = new MixpanelTelemetryProvider()
|
||||
}
|
||||
// For OSS builds, _telemetryProvider stays null
|
||||
}
|
||||
|
||||
return _telemetryProvider
|
||||
export function useTelemetry(): TelemetryDispatcher | null {
|
||||
return _telemetryRegistry
|
||||
}
|
||||
|
||||
export function setTelemetryRegistry(
|
||||
registry: TelemetryDispatcher | null
|
||||
): void {
|
||||
_telemetryRegistry = registry
|
||||
}
|
||||
|
||||
41
src/platform/telemetry/initTelemetry.ts
Normal file
41
src/platform/telemetry/initTelemetry.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Telemetry Provider - Cloud Initialization
|
||||
*
|
||||
* This module is only imported in cloud builds to keep
|
||||
* cloud telemetry code out of local/desktop bundles.
|
||||
*/
|
||||
import { setTelemetryRegistry } from './index'
|
||||
|
||||
const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Initialize telemetry providers for cloud builds.
|
||||
* Must be called early in app startup (e.g., main.ts).
|
||||
* Safe to call multiple times - only initializes once.
|
||||
*/
|
||||
export async function initTelemetry(): Promise<void> {
|
||||
if (!IS_CLOUD_BUILD) return
|
||||
if (_initPromise) return _initPromise
|
||||
|
||||
_initPromise = (async () => {
|
||||
const [
|
||||
{ TelemetryRegistry },
|
||||
{ MixpanelTelemetryProvider },
|
||||
{ GtmTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(new MixpanelTelemetryProvider())
|
||||
registry.registerProvider(new GtmTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
return _initPromise
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
AuthMetadata,
|
||||
BeginCheckoutMetadata,
|
||||
PageViewMetadata,
|
||||
TelemetryProvider
|
||||
} from '../../types'
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
* Pushes events to the GTM dataLayer for GA4 and marketing integrations.
|
||||
*
|
||||
* Only implements events relevant to GTM/GA4 tracking.
|
||||
*/
|
||||
export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const gtmId = window.__CONFIG__?.gtm_container_id
|
||||
if (!gtmId) {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.warn('[GTM] No GTM ID configured, skipping initialization')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
|
||||
window.dataLayer.push({
|
||||
'gtm.start': new Date().getTime(),
|
||||
event: 'gtm.js'
|
||||
})
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmId}`
|
||||
document.head.insertBefore(script, document.head.firstChild)
|
||||
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
private pushEvent(event: string, properties?: Record<string, unknown>): void {
|
||||
if (!this.initialized) return
|
||||
window.dataLayer?.push({ event, ...properties })
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.pushEvent('page_view', {
|
||||
page_title: pageName,
|
||||
page_location: properties?.path,
|
||||
page_referrer: properties?.referrer
|
||||
})
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
const basePayload = {
|
||||
method: metadata.method,
|
||||
...(metadata.user_id ? { user_id: metadata.user_id } : {})
|
||||
}
|
||||
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', basePayload)
|
||||
return
|
||||
}
|
||||
|
||||
this.pushEvent('login', basePayload)
|
||||
}
|
||||
|
||||
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
|
||||
this.pushEvent('begin_checkout', metadata)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
* 3. Check dist/assets/*.js files contain no tracking code
|
||||
*/
|
||||
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
/**
|
||||
@@ -20,6 +22,7 @@ import type { AuditLog } from '@/services/customerEventsService'
|
||||
export interface AuthMetadata {
|
||||
method?: 'email' | 'google' | 'github'
|
||||
is_new_user?: boolean
|
||||
user_id?: string
|
||||
referrer_url?: string
|
||||
utm_source?: string
|
||||
utm_medium?: string
|
||||
@@ -269,80 +272,116 @@ export interface WorkflowCreatedMetadata {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core telemetry provider interface
|
||||
* Page view metadata for route tracking
|
||||
*/
|
||||
export interface PageViewMetadata {
|
||||
path?: string
|
||||
referrer?: string
|
||||
title?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface BeginCheckoutMetadata extends Record<string, unknown> {
|
||||
user_id: string
|
||||
tier: TierKey
|
||||
cycle: BillingCycle
|
||||
checkout_type: 'new' | 'change'
|
||||
previous_tier?: TierKey
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
gclid?: string
|
||||
gbraid?: string
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry provider interface for individual providers.
|
||||
* All methods are optional - providers only implement what they need.
|
||||
*/
|
||||
export interface TelemetryProvider {
|
||||
// Authentication flow events
|
||||
trackSignupOpened(): void
|
||||
trackAuth(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn(): void
|
||||
trackSignupOpened?(): void
|
||||
trackAuth?(metadata: AuthMetadata): void
|
||||
trackUserLoggedIn?(): void
|
||||
|
||||
// Subscription flow events
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackMonthlySubscriptionSucceeded(): void
|
||||
trackMonthlySubscriptionCancelled(): void
|
||||
trackAddApiCreditButtonClicked(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
|
||||
trackApiCreditTopupSucceeded(): void
|
||||
trackRunButton(options?: {
|
||||
trackSubscription?(event: 'modal_opened' | 'subscribe_clicked'): void
|
||||
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
|
||||
trackMonthlySubscriptionSucceeded?(): void
|
||||
trackMonthlySubscriptionCancelled?(): void
|
||||
trackAddApiCreditButtonClicked?(): void
|
||||
trackApiCreditTopupButtonPurchaseClicked?(amount: number): void
|
||||
trackApiCreditTopupSucceeded?(): void
|
||||
trackRunButton?(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void
|
||||
|
||||
// Credit top-up tracking (composition with internal utilities)
|
||||
startTopupTracking(): void
|
||||
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean
|
||||
clearTopupTracking(): void
|
||||
startTopupTracking?(): void
|
||||
checkForCompletedTopup?(events: AuditLog[] | undefined | null): boolean
|
||||
clearTopupTracking?(): void
|
||||
|
||||
// Survey flow events
|
||||
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
|
||||
|
||||
// Email verification events
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
|
||||
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
|
||||
|
||||
// Template workflow events
|
||||
trackTemplate(metadata: TemplateMetadata): void
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void
|
||||
trackTemplate?(metadata: TemplateMetadata): void
|
||||
trackTemplateLibraryOpened?(metadata: TemplateLibraryMetadata): void
|
||||
trackTemplateLibraryClosed?(metadata: TemplateLibraryClosedMetadata): void
|
||||
|
||||
// Workflow management events
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void
|
||||
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
|
||||
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
|
||||
trackEnterLinear?(metadata: EnterLinearMetadata): void
|
||||
|
||||
// Page visibility events
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
|
||||
trackPageVisibilityChanged?(metadata: PageVisibilityMetadata): void
|
||||
|
||||
// Tab tracking events
|
||||
trackTabCount(metadata: TabCountMetadata): void
|
||||
trackTabCount?(metadata: TabCountMetadata): void
|
||||
|
||||
// Node search analytics events
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
|
||||
trackNodeSearch?(metadata: NodeSearchMetadata): void
|
||||
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
|
||||
|
||||
// Template filter tracking events
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
|
||||
trackTemplateFilterChanged?(metadata: TemplateFilterMetadata): void
|
||||
|
||||
// Help center events
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
|
||||
trackHelpCenterOpened?(metadata: HelpCenterOpenedMetadata): void
|
||||
trackHelpResourceClicked?(metadata: HelpResourceClickedMetadata): void
|
||||
trackHelpCenterClosed?(metadata: HelpCenterClosedMetadata): void
|
||||
|
||||
// Workflow creation events
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
|
||||
trackWorkflowCreated?(metadata: WorkflowCreatedMetadata): void
|
||||
|
||||
// Workflow execution events
|
||||
trackWorkflowExecution(): void
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void
|
||||
trackWorkflowExecution?(): void
|
||||
trackExecutionError?(metadata: ExecutionErrorMetadata): void
|
||||
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
|
||||
|
||||
// Settings events
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void
|
||||
trackSettingChanged?(metadata: SettingChangedMetadata): void
|
||||
|
||||
// Generic UI button click events
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void
|
||||
trackUiButtonClicked?(metadata: UiButtonClickMetadata): void
|
||||
|
||||
// Page view tracking
|
||||
trackPageView?(pageName: string, properties?: PageViewMetadata): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Telemetry dispatcher interface returned by useTelemetry().
|
||||
* All methods are required - the registry implements all methods and dispatches
|
||||
* to registered providers using optional chaining.
|
||||
*/
|
||||
export type TelemetryDispatcher = Required<TelemetryProvider>
|
||||
|
||||
/**
|
||||
* Telemetry event constants
|
||||
*
|
||||
@@ -415,7 +454,10 @@ export const TelemetryEvents = {
|
||||
EXECUTION_ERROR: 'execution_error',
|
||||
EXECUTION_SUCCESS: 'execution_success',
|
||||
// Generic UI Button Click
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked'
|
||||
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
|
||||
|
||||
// Page View
|
||||
PAGE_VIEW: 'app:page_view'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getCheckoutAttribution } from '../checkoutAttribution'
|
||||
|
||||
const storage = new Map<string, string>()
|
||||
|
||||
const mockLocalStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage.set(key, value)
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
storage.delete(key)
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
storage.clear()
|
||||
})
|
||||
}))
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true
|
||||
})
|
||||
|
||||
describe('getCheckoutAttribution', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
vi.clearAllMocks()
|
||||
window.__ga_identity__ = undefined
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
it('reads GA identity and persists click ids from URL', () => {
|
||||
window.__ga_identity__ = {
|
||||
client_id: '123.456',
|
||||
session_id: '1700000000',
|
||||
session_number: '2'
|
||||
}
|
||||
window.history.pushState({}, '', '/?gclid=gclid-123')
|
||||
|
||||
const attribution = getCheckoutAttribution()
|
||||
|
||||
expect(attribution).toMatchObject({
|
||||
ga_client_id: '123.456',
|
||||
ga_session_id: '1700000000',
|
||||
ga_session_number: '2',
|
||||
gclid: 'gclid-123'
|
||||
})
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ gclid: 'gclid-123' })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses stored click ids when URL is empty', () => {
|
||||
storage.set(
|
||||
'comfy_checkout_attribution',
|
||||
JSON.stringify({ gbraid: 'gbraid-1' })
|
||||
)
|
||||
|
||||
const attribution = getCheckoutAttribution()
|
||||
|
||||
expect(attribution.gbraid).toBe('gbraid-1')
|
||||
})
|
||||
})
|
||||
108
src/platform/telemetry/utils/checkoutAttribution.ts
Normal file
108
src/platform/telemetry/utils/checkoutAttribution.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { isPlainObject } from 'es-toolkit'
|
||||
|
||||
interface CheckoutAttribution {
|
||||
ga_client_id?: string
|
||||
ga_session_id?: string
|
||||
ga_session_number?: string
|
||||
gclid?: string
|
||||
gbraid?: string
|
||||
wbraid?: string
|
||||
}
|
||||
|
||||
type GaIdentity = {
|
||||
client_id?: string
|
||||
session_id?: string
|
||||
session_number?: string
|
||||
}
|
||||
|
||||
const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const
|
||||
type ClickIdKey = (typeof CLICK_ID_KEYS)[number]
|
||||
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
|
||||
|
||||
function readStoredClickIds(): Partial<Record<ClickIdKey, string>> {
|
||||
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<ClickIdKey, string>> = {}
|
||||
|
||||
for (const key of CLICK_ID_KEYS) {
|
||||
const value = parsed[key]
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function persistClickIds(payload: Partial<Record<ClickIdKey, string>>): void {
|
||||
try {
|
||||
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function readClickIdsFromUrl(
|
||||
search: string
|
||||
): Partial<Record<ClickIdKey, string>> {
|
||||
const params = new URLSearchParams(search)
|
||||
|
||||
const result: Partial<Record<ClickIdKey, string>> = {}
|
||||
|
||||
for (const key of CLICK_ID_KEYS) {
|
||||
const value = params.get(key)
|
||||
if (value) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function asNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getGaIdentity(): GaIdentity | undefined {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
|
||||
const identity = window.__ga_identity__
|
||||
if (!isPlainObject(identity)) return undefined
|
||||
|
||||
return {
|
||||
client_id: asNonEmptyString(identity.client_id),
|
||||
session_id: asNonEmptyString(identity.session_id),
|
||||
session_number: asNonEmptyString(identity.session_number)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCheckoutAttribution(): CheckoutAttribution {
|
||||
if (typeof window === 'undefined') return {}
|
||||
|
||||
const stored = readStoredClickIds()
|
||||
const fromUrl = readClickIdsFromUrl(window.location.search)
|
||||
const merged: Partial<Record<ClickIdKey, string>> = {
|
||||
...stored,
|
||||
...fromUrl
|
||||
}
|
||||
|
||||
if (Object.keys(fromUrl).length > 0) {
|
||||
persistClickIds(merged)
|
||||
}
|
||||
|
||||
const gaIdentity = getGaIdentity()
|
||||
|
||||
return {
|
||||
...merged,
|
||||
ga_client_id: gaIdentity?.client_id,
|
||||
ga_session_id: gaIdentity?.session_id,
|
||||
ga_session_number: gaIdentity?.session_number
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user