diff --git a/global.d.ts b/global.d.ts index ec455707f..71678702f 100644 --- a/global.d.ts +++ b/global.d.ts @@ -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 diff --git a/src/main.ts b/src/main.ts index 966b380d2..4af76693c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, { diff --git a/src/platform/cloud/subscription/composables/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts index a9a2487a5..dafb4a431 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts @@ -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__ diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 0f7c883d7..8916f9414 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -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() } diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts index 12e114aed..a44564801 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts @@ -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 diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts index d841b02fc..01e166069 100644 --- a/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts +++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.ts @@ -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 isActiveSubscription: ComputedRef subscriptionStatus: Ref - telemetry: Pick | null + telemetry: Pick< + TelemetryDispatcher, + 'trackMonthlySubscriptionCancelled' + > | null shouldWatchCancellation: () => boolean } diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 7b8b1721c..50744d660 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -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 diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts new file mode 100644 index 000000000..e7b4aacfa --- /dev/null +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -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)) + } +} diff --git a/src/platform/telemetry/gtm.ts b/src/platform/telemetry/gtm.ts deleted file mode 100644 index d5c738ae4..000000000 --- a/src/platform/telemetry/gtm.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { isCloud } from '@/platform/distribution/types' - -const GTM_CONTAINER_ID = 'GTM-NP9JM6K7' - -let isInitialized = false -let initPromise: Promise | 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): void { - if (!isCloud || typeof window === 'undefined') return - const dataLayer = window.dataLayer ?? (window.dataLayer = []) - dataLayer.push(event) -} diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 3e042b437..358d6df10 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -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 | null = null const IS_CLOUD_BUILD = __DISTRIBUTION__ === 'cloud' -function loadGtmModule(): Promise { - if (!gtmModulePromise) { - gtmModulePromise = import('./gtm') - } - return gtmModulePromise +let _telemetryRegistry: TelemetryDispatcher | null = null +let _initPromise: Promise | 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 { + 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): void { - if (!IS_CLOUD_BUILD || typeof window === 'undefined') return - void loadGtmModule().then(({ pushDataLayerEvent }) => { - pushDataLayerEvent(event) - }) +export function useTelemetry(): TelemetryDispatcher | null { + return _telemetryRegistry } diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts new file mode 100644 index 000000000..fa83fc39f --- /dev/null +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -0,0 +1,93 @@ +import type { + AuthMetadata, + PageViewMetadata, + TelemetryProvider +} from '../../types' + +declare global { + interface Window { + dataLayer?: Record[] + } +} + +/** + * 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[] = [] + 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): 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' }] + }) + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 2ce9c7f0f..8a49e8d3d 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -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 + /** * 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 = diff --git a/src/router.ts b/src/router.ts index adc6848af..f1edc45e9 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 }) } diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 965ba3a8a..fae2adb56 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -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) }