mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 13:29:55 +00:00
feat: add TelemetryRegistry for multi-provider dispatch
- Add TelemetryRegistry that dispatches to multiple providers - Add GtmTelemetryProvider for GTM/GA4 integration - Convert TelemetryProvider methods to optional (providers implement what they need) - Add TelemetryDispatcher type (Required<TelemetryProvider>) for call sites - Dynamically import providers to ensure tree-shaking in OSS builds - Track page_view, sign_up, login, and purchase events via GTM - Remove gtm.ts in favor of GtmTelemetryProvider Amp-Thread-ID: https://ampcode.com/threads/T-019c07ca-7748-77c8-b15b-d79d42779f8f Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -8,6 +8,7 @@ declare const __USE_PROD_CONFIG__: boolean
|
||||
interface Window {
|
||||
__CONFIG__: {
|
||||
mixpanel_token?: string
|
||||
gtm_id?: string
|
||||
require_whitelist?: boolean
|
||||
subscription_required?: boolean
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -32,8 +32,8 @@ if (isCloud) {
|
||||
await import('@/platform/remoteConfig/refreshRemoteConfig')
|
||||
await refreshRemoteConfig({ useAuth: false })
|
||||
|
||||
const { initGtm } = await import('@/platform/telemetry')
|
||||
initGtm()
|
||||
const { initTelemetry } = await import('@/platform/telemetry')
|
||||
await initTelemetry()
|
||||
}
|
||||
|
||||
const ComfyUIPreset = definePreset(Aura, {
|
||||
|
||||
@@ -9,7 +9,6 @@ const {
|
||||
mockAccessBillingPortal,
|
||||
mockShowSubscriptionRequiredDialog,
|
||||
mockGetAuthHeader,
|
||||
mockPushDataLayerEvent,
|
||||
mockTelemetry,
|
||||
mockUserId
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -20,7 +19,6 @@ const {
|
||||
mockGetAuthHeader: vi.fn(() =>
|
||||
Promise.resolve({ Authorization: 'Bearer test-token' })
|
||||
),
|
||||
mockPushDataLayerEvent: vi.fn(),
|
||||
mockTelemetry: {
|
||||
trackSubscription: vi.fn(),
|
||||
trackMonthlySubscriptionCancelled: vi.fn()
|
||||
@@ -50,7 +48,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
pushDataLayerEvent: mockPushDataLayerEvent,
|
||||
useTelemetry: vi.fn(() => mockTelemetry)
|
||||
}))
|
||||
|
||||
@@ -114,12 +111,8 @@ describe('useSubscription', () => {
|
||||
mockIsLoggedIn.value = false
|
||||
mockTelemetry.trackSubscription.mockReset()
|
||||
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
|
||||
mockPushDataLayerEvent.mockReset()
|
||||
mockUserId.value = 'user-123'
|
||||
mockPushDataLayerEvent.mockImplementation((event) => {
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push(event)
|
||||
})
|
||||
window.dataLayer = []
|
||||
window.__CONFIG__ = {
|
||||
subscription_required: true
|
||||
} as typeof window.__CONFIG__
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { pushDataLayerEvent, useTelemetry } from '@/platform/telemetry'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import {
|
||||
FirebaseAuthStoreError,
|
||||
useFirebaseAuthStore
|
||||
@@ -122,22 +122,25 @@ function useSubscriptionInternal() {
|
||||
: baseName
|
||||
const unitPrice = getTierPrice(tierKey, isYearly)
|
||||
const value = isYearly && tierKey !== 'founder' ? unitPrice * 12 : unitPrice
|
||||
pushDataLayerEvent({
|
||||
event: 'purchase',
|
||||
transaction_id: status.subscription_id,
|
||||
value,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
item_id: `${billingCycle}_${tierKey}`,
|
||||
item_name: planName,
|
||||
item_category: 'subscription',
|
||||
item_variant: billingCycle,
|
||||
price: value,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.dataLayer.push({
|
||||
event: 'purchase',
|
||||
transaction_id: status.subscription_id,
|
||||
value,
|
||||
currency: 'USD',
|
||||
items: [
|
||||
{
|
||||
item_id: `${billingCycle}_${tierKey}`,
|
||||
item_name: planName,
|
||||
item_category: 'subscription',
|
||||
item_variant: billingCycle,
|
||||
price: value,
|
||||
quantity: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
clearPendingSubscriptionPurchase()
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ import type { EffectScope } from 'vue'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
import type { TelemetryDispatcher } from '@/platform/telemetry/types'
|
||||
|
||||
describe('useSubscriptionCancellationWatcher', () => {
|
||||
const trackMonthlySubscriptionCancelled = vi.fn()
|
||||
const telemetryMock: Pick<
|
||||
TelemetryProvider,
|
||||
TelemetryDispatcher,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> = {
|
||||
trackMonthlySubscriptionCancelled
|
||||
|
||||
@@ -2,7 +2,7 @@ import { onScopeDispose, ref } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { defaultWindow, useEventListener, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
import type { TelemetryProvider } from '@/platform/telemetry/types'
|
||||
import type { TelemetryDispatcher } from '@/platform/telemetry/types'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from './useSubscription'
|
||||
|
||||
@@ -14,7 +14,10 @@ type CancellationWatcherOptions = {
|
||||
fetchStatus: () => Promise<CloudSubscriptionStatusResponse | null | void>
|
||||
isActiveSubscription: ComputedRef<boolean>
|
||||
subscriptionStatus: Ref<CloudSubscriptionStatusResponse | null>
|
||||
telemetry: Pick<TelemetryProvider, 'trackMonthlySubscriptionCancelled'> | null
|
||||
telemetry: Pick<
|
||||
TelemetryDispatcher,
|
||||
'trackMonthlySubscriptionCancelled'
|
||||
> | null
|
||||
shouldWatchCancellation: () => boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type FirebaseRuntimeConfig = {
|
||||
*/
|
||||
export type RemoteConfig = {
|
||||
mixpanel_token?: string
|
||||
gtm_id?: string
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
198
src/platform/telemetry/TelemetryRegistry.ts
Normal file
198
src/platform/telemetry/TelemetryRegistry.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
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)
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.providers.forEach((p) => p.trackSignupOpened?.())
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.providers.forEach((p) => p.trackAuth?.(metadata))
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
this.providers.forEach((p) => p.trackUserLoggedIn?.())
|
||||
}
|
||||
|
||||
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
|
||||
this.providers.forEach((p) => p.trackSubscription?.(event))
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.providers.forEach((p) => p.trackMonthlySubscriptionSucceeded?.())
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.providers.forEach((p) => p.trackMonthlySubscriptionCancelled?.())
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.providers.forEach((p) => p.trackAddApiCreditButtonClicked?.())
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
this.providers.forEach((p) =>
|
||||
p.trackApiCreditTopupButtonPurchaseClicked?.(amount)
|
||||
)
|
||||
}
|
||||
|
||||
trackApiCreditTopupSucceeded(): void {
|
||||
this.providers.forEach((p) => p.trackApiCreditTopupSucceeded?.())
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
this.providers.forEach((p) => p.trackRunButton?.(options))
|
||||
}
|
||||
|
||||
startTopupTracking(): void {
|
||||
this.providers.forEach((p) => p.startTopupTracking?.())
|
||||
}
|
||||
|
||||
checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean {
|
||||
return this.providers.some(
|
||||
(p) => p.checkForCompletedTopup?.(events) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
clearTopupTracking(): void {
|
||||
this.providers.forEach((p) => p.clearTopupTracking?.())
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
): void {
|
||||
this.providers.forEach((p) => p.trackSurvey?.(stage, responses))
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
this.providers.forEach((p) => p.trackEmailVerification?.(stage))
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.providers.forEach((p) => p.trackTemplate?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
|
||||
this.providers.forEach((p) => p.trackTemplateLibraryOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackTemplateLibraryClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.providers.forEach((p) => p.trackWorkflowImported?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.providers.forEach((p) => p.trackWorkflowOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.providers.forEach((p) => p.trackEnterLinear?.(metadata))
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.providers.forEach((p) => p.trackPageVisibilityChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackTabCount(metadata: TabCountMetadata): void {
|
||||
this.providers.forEach((p) => p.trackTabCount?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void {
|
||||
this.providers.forEach((p) => p.trackNodeSearch?.(metadata))
|
||||
}
|
||||
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
|
||||
this.providers.forEach((p) => p.trackNodeSearchResultSelected?.(metadata))
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.providers.forEach((p) => p.trackTemplateFilterChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackHelpCenterOpened?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackHelpResourceClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackHelpCenterClosed?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackWorkflowCreated?.(metadata))
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
this.providers.forEach((p) => p.trackWorkflowExecution?.())
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.providers.forEach((p) => p.trackExecutionError?.(metadata))
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.providers.forEach((p) => p.trackExecutionSuccess?.(metadata))
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.providers.forEach((p) => p.trackSettingChanged?.(metadata))
|
||||
}
|
||||
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
|
||||
this.providers.forEach((p) => p.trackUiButtonClicked?.(metadata))
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.providers.forEach((p) => p.trackPageView?.(pageName, properties))
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
const GTM_CONTAINER_ID = 'GTM-NP9JM6K7'
|
||||
|
||||
let isInitialized = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export function initGtm(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
if (typeof document === 'undefined') return
|
||||
if (isInitialized) return
|
||||
|
||||
if (!initPromise) {
|
||||
initPromise = new Promise((resolve) => {
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push({
|
||||
'gtm.start': Date.now(),
|
||||
event: 'gtm.js'
|
||||
})
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_CONTAINER_ID}`
|
||||
|
||||
const finalize = () => {
|
||||
isInitialized = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.addEventListener('load', finalize, { once: true })
|
||||
script.addEventListener('error', finalize, { once: true })
|
||||
document.head?.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
void initPromise
|
||||
}
|
||||
|
||||
export function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
const dataLayer = window.dataLayer ?? (window.dataLayer = [])
|
||||
dataLayer.push(event)
|
||||
}
|
||||
@@ -2,71 +2,57 @@
|
||||
* 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.
|
||||
* This module uses dynamic imports to ensure all cloud telemetry code
|
||||
* is tree-shaken from OSS builds. No top-level imports of provider code.
|
||||
*
|
||||
* 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.
|
||||
* 2. `grep -RinE --include='*.js' 'mixpanel|googletagmanager|dataLayer' dist/`
|
||||
* 3. Should find nothing
|
||||
*/
|
||||
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
|
||||
import type {
|
||||
initGtm as gtmInit,
|
||||
pushDataLayerEvent as gtmPushDataLayerEvent
|
||||
} from './gtm'
|
||||
import type { TelemetryProvider } from './types'
|
||||
import type { TelemetryDispatcher } from './types'
|
||||
|
||||
type GtmModule = {
|
||||
initGtm: typeof gtmInit
|
||||
pushDataLayerEvent: typeof gtmPushDataLayerEvent
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _telemetryProvider: TelemetryProvider | null = null
|
||||
let gtmModulePromise: Promise<GtmModule> | null = null
|
||||
const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud'
|
||||
|
||||
function loadGtmModule(): Promise<GtmModule> {
|
||||
if (!gtmModulePromise) {
|
||||
gtmModulePromise = import('./gtm')
|
||||
}
|
||||
return gtmModulePromise
|
||||
let _telemetryRegistry: TelemetryDispatcher | null = null
|
||||
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())
|
||||
|
||||
_telemetryRegistry = registry
|
||||
})()
|
||||
|
||||
return _initPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (IS_CLOUD_BUILD) {
|
||||
_telemetryProvider = new MixpanelTelemetryProvider()
|
||||
}
|
||||
// For OSS builds, _telemetryProvider stays null
|
||||
}
|
||||
|
||||
return _telemetryProvider
|
||||
}
|
||||
|
||||
export function initGtm(): void {
|
||||
if (!IS_CLOUD_BUILD || typeof window === 'undefined') return
|
||||
void loadGtmModule().then(({ initGtm }) => {
|
||||
initGtm()
|
||||
})
|
||||
}
|
||||
|
||||
export function pushDataLayerEvent(event: Record<string, unknown>): void {
|
||||
if (!IS_CLOUD_BUILD || typeof window === 'undefined') return
|
||||
void loadGtmModule().then(({ pushDataLayerEvent }) => {
|
||||
pushDataLayerEvent(event)
|
||||
})
|
||||
export function useTelemetry(): TelemetryDispatcher | null {
|
||||
return _telemetryRegistry
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type {
|
||||
AuthMetadata,
|
||||
PageViewMetadata,
|
||||
TelemetryProvider
|
||||
} from '../../types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
dataLayer?: Record<string, unknown>[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Tag Manager telemetry provider.
|
||||
* Pushes events to the GTM dataLayer for GA4 and marketing integrations.
|
||||
*
|
||||
* Only implements events relevant to GTM/GA4 tracking.
|
||||
* Other methods are no-ops (not implemented since interface is optional).
|
||||
*/
|
||||
export class GtmTelemetryProvider implements TelemetryProvider {
|
||||
private dataLayer: Record<string, unknown>[] = []
|
||||
private initialized = false
|
||||
|
||||
constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const gtmId = window.__CONFIG__?.gtm_id
|
||||
if (!gtmId) {
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
console.warn('[GTM] No GTM ID configured, skipping initialization')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
window.dataLayer = window.dataLayer || []
|
||||
this.dataLayer = window.dataLayer
|
||||
|
||||
this.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
|
||||
this.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 {
|
||||
if (metadata.is_new_user) {
|
||||
this.pushEvent('sign_up', {
|
||||
method: metadata.method
|
||||
})
|
||||
} else {
|
||||
this.pushEvent('login', {
|
||||
method: metadata.method
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.pushEvent('purchase', {
|
||||
currency: 'USD',
|
||||
items: [{ item_name: 'Monthly Subscription' }]
|
||||
})
|
||||
}
|
||||
|
||||
trackApiCreditTopupSucceeded(): void {
|
||||
this.pushEvent('purchase', {
|
||||
currency: 'USD',
|
||||
items: [{ item_name: 'API Credits' }]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -269,80 +269,101 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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 +436,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 =
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { pushDataLayerEvent } from '@/platform/telemetry'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
@@ -40,10 +40,8 @@ const basePath = getBasePath()
|
||||
function pushPageView(): void {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
pushDataLayerEvent({
|
||||
event: 'page_view',
|
||||
page_location: window.location.href,
|
||||
page_title: document.title
|
||||
useTelemetry()?.trackPageView(document.title, {
|
||||
path: window.location.href
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +25,7 @@ import { getComfyApiBaseUrl } from '@/config/comfyApi'
|
||||
import { t } from '@/i18n'
|
||||
import { WORKSPACE_STORAGE_KEYS } from '@/platform/auth/workspace/workspaceConstants'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
pushDataLayerEvent as pushDataLayerEventBase,
|
||||
useTelemetry
|
||||
} from '@/platform/telemetry'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import type { AuthHeader } from '@/types/authTypes'
|
||||
@@ -88,7 +85,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
if (!isCloud || typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
pushDataLayerEventBase(event)
|
||||
window.dataLayer = window.dataLayer || []
|
||||
window.dataLayer.push(event)
|
||||
} catch (error) {
|
||||
console.warn('Failed to push data layer event', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user