diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index cc5634cae0..9b0fec12a2 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -3,6 +3,7 @@ import { computed, watch } from 'vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { t } from '@/i18n' +import { useTelemetryService } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import { useCommandStore } from '@/stores/commandStore' @@ -128,6 +129,18 @@ export const useCurrentUser = () => { } } + // Register telemetry hooks + const telemetryService = useTelemetryService() + telemetryService?.registerHooks({ + getCurrentUser: () => + resolvedUserInfo.value + ? { + id: resolvedUserInfo.value.id, + tier: 'free' // This will be enhanced when we add subscription data + } + : null + }) + return { loading: authStore.loading, isLoggedIn, diff --git a/src/platform/settings/settingStore.ts b/src/platform/settings/settingStore.ts index 5a1573efbf..0b780dfcb5 100644 --- a/src/platform/settings/settingStore.ts +++ b/src/platform/settings/settingStore.ts @@ -4,6 +4,7 @@ import { compare, valid } from 'semver' import { ref } from 'vue' import type { SettingParams } from '@/platform/settings/types' +import { useTelemetryService } from '@/platform/telemetry' import type { Settings } from '@/schemas/apiSchema' import { api } from '@/scripts/api' import { app } from '@/scripts/app' @@ -237,6 +238,21 @@ export const useSettingStore = defineStore('setting', () => { } } + // Register telemetry hooks + const telemetryService = useTelemetryService() + telemetryService?.registerHooks({ + getSurveyData: () => { + // Use raw access to settings that may not be in the typed schema + return settingValues.value['onboarding_survey'] || null + }, + getFeatureFlags: () => ({ + // Use raw access to feature flags that might not be in the schema + darkMode: settingValues.value['Comfy.UseNewMenu'] === 'Top', + betaFeatures: settingValues.value['Comfy.Beta.Enabled'] === true, + advancedMode: settingValues.value['Comfy.Advanced.Mode'] === true + }) + }) + return { settingValues, settingsById, diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 83d7f2c9f9..8e976a2713 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -17,26 +17,39 @@ import { isCloud } from '@/platform/distribution/types' import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' -import type { TelemetryProvider } from './types' +import { TelemetryService } from './services/TelemetryService' // Singleton instance -let _telemetryProvider: TelemetryProvider | null = null +let _telemetryService: TelemetryService | null = null /** - * Telemetry factory - conditionally creates provider based on distribution + * Telemetry service factory - conditionally creates service with providers based on distribution * Returns singleton instance. * - * CRITICAL: This returns undefined in OSS builds. There is no telemetry provider + * CRITICAL: This returns null in OSS builds. There is no telemetry service * for OSS builds and all tracking calls are no-ops. */ -export function useTelemetry(): TelemetryProvider | null { - if (_telemetryProvider === null) { +export function useTelemetryService(): TelemetryService | null { + if (_telemetryService === null) { // Use distribution check for tree-shaking if (isCloud) { - _telemetryProvider = new MixpanelTelemetryProvider() + _telemetryService = new TelemetryService() + + // Initialize and add Mixpanel provider + const mixpanelProvider = new MixpanelTelemetryProvider() + void mixpanelProvider.initialize().then(() => { + _telemetryService?.addProvider(mixpanelProvider) + }) } - // For OSS builds, _telemetryProvider stays null + // For OSS builds, _telemetryService stays null } - return _telemetryProvider + return _telemetryService +} + +/** + * @deprecated Use useTelemetryService() instead + */ +export function useTelemetry() { + return useTelemetryService() } diff --git a/src/platform/telemetry/interfaces/TelemetryHooks.ts b/src/platform/telemetry/interfaces/TelemetryHooks.ts new file mode 100644 index 0000000000..e9b3a053bf --- /dev/null +++ b/src/platform/telemetry/interfaces/TelemetryHooks.ts @@ -0,0 +1,49 @@ +import type { + ExecutionContext, + SurveyResponses, + TemplateMetadata +} from '../types' + +/** + * Context types provided by domain stores to telemetry + */ +interface UserContext { + id: string + email?: string + tier?: 'free' | 'pro' | 'enterprise' +} + +interface WorkflowContext { + filename?: string + isTemplate: boolean + nodeCount?: number + hasCustomNodes?: boolean +} + +interface SubscriptionContext { + isSubscribed: boolean + plan?: string + creditsRemaining?: number +} + +/** + * Telemetry hooks interface for dependency inversion. + * Domain stores register these hooks to provide context to telemetry + * without creating circular dependencies. + */ +export interface TelemetryHooks { + // Context providers + getExecutionContext?(): ExecutionContext | null + getCurrentUser?(): UserContext | null + getActiveWorkflow?(): WorkflowContext | null + + // Setting providers + getSurveyData?(): SurveyResponses | null + getSubscriptionStatus?(): SubscriptionContext | null + + // Template metadata providers + getTemplateMetadata?(filename: string): TemplateMetadata | null + + // Feature flag providers + getFeatureFlags?(): Record +} diff --git a/src/platform/telemetry/providers/TelemetryProviderBase.ts b/src/platform/telemetry/providers/TelemetryProviderBase.ts new file mode 100644 index 0000000000..8a95c0a66e --- /dev/null +++ b/src/platform/telemetry/providers/TelemetryProviderBase.ts @@ -0,0 +1,92 @@ +import type { + AuthMetadata, + ExecutionContext, + ExecutionErrorMetadata, + ExecutionSuccessMetadata, + HelpCenterClosedMetadata, + HelpCenterOpenedMetadata, + HelpResourceClickedMetadata, + NodeSearchMetadata, + NodeSearchResultMetadata, + PageVisibilityMetadata, + RunButtonProperties, + SettingChangedMetadata, + SurveyResponses, + TabCountMetadata, + TelemetryProvider, + TemplateFilterMetadata, + TemplateLibraryClosedMetadata, + TemplateLibraryMetadata, + TemplateMetadata, + UiButtonClickMetadata, + WorkflowCreatedMetadata, + WorkflowImportMetadata +} from '../types' + +/** + * Abstract base class for telemetry providers with lifecycle management. + * Concrete providers should extend this class for consistent behavior. + */ +export abstract class TelemetryProviderBase implements TelemetryProvider { + protected isEnabled = true + protected isInitialized = false + + /** + * Initialize the provider (e.g., load external libraries) + */ + abstract initialize(): Promise + + /** + * Check if the provider is ready to track events + */ + isReady(): boolean { + return this.isEnabled && this.isInitialized + } + + setEnabled(enabled: boolean): void { + this.isEnabled = enabled + } + + // All abstract methods from TelemetryProvider interface with proper typing + abstract trackAuth(metadata: AuthMetadata): void + abstract trackUserLoggedIn(): void + abstract trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void + abstract trackMonthlySubscriptionSucceeded(): void + abstract trackAddApiCreditButtonClicked(): void + abstract trackApiCreditTopupButtonPurchaseClicked(amount: number): void + abstract trackApiCreditTopupSucceeded(): void + abstract trackRunButton(properties: RunButtonProperties): void + abstract startTopupTracking(): void + abstract checkForCompletedTopup(events: any[] | undefined | null): boolean + abstract clearTopupTracking(): void + abstract trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void + abstract trackEmailVerification( + stage: 'opened' | 'requested' | 'completed' + ): void + abstract trackTemplate(metadata: TemplateMetadata): void + abstract trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void + abstract trackTemplateLibraryClosed( + metadata: TemplateLibraryClosedMetadata + ): void + abstract trackWorkflowImported(metadata: WorkflowImportMetadata): void + abstract trackWorkflowOpened(metadata: WorkflowImportMetadata): void + abstract trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void + abstract trackTabCount(metadata: TabCountMetadata): void + abstract trackNodeSearch(metadata: NodeSearchMetadata): void + abstract trackNodeSearchResultSelected( + metadata: NodeSearchResultMetadata + ): void + abstract trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void + abstract trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void + abstract trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void + abstract trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void + abstract trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void + abstract trackWorkflowExecution(context?: ExecutionContext): void + abstract trackExecutionError(metadata: ExecutionErrorMetadata): void + abstract trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void + abstract trackSettingChanged(metadata: SettingChangedMetadata): void + abstract trackUiButtonClicked(metadata: UiButtonClickMetadata): void +} diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 419ab6329d..605ebc92cc 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -1,17 +1,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser' -import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { checkForCompletedTopup as checkTopupUtil, clearTopupTracking as clearTopupUtil, startTopupTracking as startTopupUtil } from '@/platform/telemetry/topupTracker' -import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' -import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' -import { app } from '@/scripts/app' -import { useNodeDefStore } from '@/stores/nodeDefStore' -import { NodeSourceType } from '@/types/nodeSource' -import { reduceAllNodes } from '@/utils/graphTraversalUtil' import type { AuthMetadata, @@ -32,7 +25,6 @@ import type { TabCountMetadata, TelemetryEventName, TelemetryEventProperties, - TelemetryProvider, TemplateFilterMetadata, TemplateLibraryClosedMetadata, TemplateLibraryMetadata, @@ -43,11 +35,7 @@ import type { } from '../../types' import { TelemetryEvents } from '../../types' import { normalizeSurveyResponses } from '../../utils/surveyNormalization' - -interface QueuedEvent { - eventName: TelemetryEventName - properties?: TelemetryEventProperties -} +import { TelemetryProviderBase } from '../TelemetryProviderBase' /** * Mixpanel Telemetry Provider - Cloud Build Implementation @@ -61,65 +49,38 @@ interface QueuedEvent { * 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing) * 3. Check dist/assets/*.js files contain no tracking code */ -export class MixpanelTelemetryProvider implements TelemetryProvider { - private isEnabled = true +export class MixpanelTelemetryProvider extends TelemetryProviderBase { private mixpanel: OverridedMixpanel | null = null - private eventQueue: QueuedEvent[] = [] - private isInitialized = false private lastTriggerSource: ExecutionTriggerSource | undefined constructor() { - const token = window.__CONFIG__?.mixpanel_token - - if (token) { - try { - // Dynamic import to avoid bundling mixpanel in OSS builds - void import('mixpanel-browser') - .then((mixpanelModule) => { - this.mixpanel = mixpanelModule.default - this.mixpanel.init(token, { - debug: import.meta.env.DEV, - track_pageview: true, - api_host: 'https://mp.comfy.org', - cross_subdomain_cookie: true, - persistence: 'cookie', - loaded: () => { - this.isInitialized = true - this.flushEventQueue() // flush events that were queued while initializing - useCurrentUser().onUserResolved((user) => { - if (this.mixpanel && user.id) { - this.mixpanel.identify(user.id) - } - }) - } - }) - }) - .catch((error) => { - console.error('Failed to load Mixpanel:', error) - this.isEnabled = false - }) - } catch (error) { - console.error('Failed to initialize Mixpanel:', error) - this.isEnabled = false - } - } else { - console.warn('Mixpanel token not provided in runtime config') - this.isEnabled = false - } + super() } - private flushEventQueue(): void { - if (!this.isInitialized || !this.mixpanel) { + async initialize(): Promise { + const token = window.__CONFIG__?.mixpanel_token + + if (!token) { + this.setEnabled(false) return } - while (this.eventQueue.length > 0) { - const event = this.eventQueue.shift()! - try { - this.mixpanel.track(event.eventName, event.properties || {}) - } catch (error) { - console.error('Failed to track queued event:', error) - } + try { + const mixpanelModule = await import('mixpanel-browser') + this.mixpanel = mixpanelModule.default + + this.mixpanel.init(token, { + debug: import.meta.env.DEV, + track_pageview: true, + api_host: 'https://mp.comfy.org', + cross_subdomain_cookie: true, + persistence: 'cookie' + }) + + this.isInitialized = true + } catch (error) { + console.error('Failed to load Mixpanel:', error) + this.setEnabled(false) } } @@ -127,22 +88,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { eventName: TelemetryEventName, properties?: TelemetryEventProperties ): void { - if (!this.isEnabled) { + if (!this.isEnabled || !this.isInitialized || !this.mixpanel) { return } - const event: QueuedEvent = { eventName, properties } - - if (this.isInitialized && this.mixpanel) { - // Mixpanel is ready, track immediately - try { - this.mixpanel.track(eventName, properties || {}) - } catch (error) { - console.error('Failed to track event:', error) - } - } else { - // Mixpanel not ready yet, queue the event - this.eventQueue.push(event) + try { + this.mixpanel.track(eventName, properties || {}) + } catch (error) { + console.error('Failed to track event:', error) } } @@ -198,26 +151,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { clearTopupUtil() } - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void { - const executionContext = this.getExecutionContext() - - const runButtonProperties: RunButtonProperties = { - subscribe_to_run: options?.subscribe_to_run || false, - workflow_type: executionContext.is_template ? 'template' : 'custom', - workflow_name: executionContext.workflow_name ?? 'untitled', - custom_node_count: executionContext.custom_node_count, - total_node_count: executionContext.total_node_count, - subgraph_count: executionContext.subgraph_count, - has_api_nodes: executionContext.has_api_nodes, - api_node_names: executionContext.api_node_names, - trigger_source: options?.trigger_source - } - - this.lastTriggerSource = options?.trigger_source - this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties) + trackRunButton(properties: RunButtonProperties): void { + this.lastTriggerSource = properties.trigger_source + this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties) } trackSurvey( @@ -320,9 +256,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata) } - trackWorkflowExecution(): void { - const context = this.getExecutionContext() - const eventContext: ExecutionContext = { + trackWorkflowExecution(context: ExecutionContext): void { + const eventContext = { ...context, trigger_source: this.lastTriggerSource ?? 'unknown' } @@ -345,98 +280,4 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { trackUiButtonClicked(metadata: UiButtonClickMetadata): void { this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata) } - - getExecutionContext(): ExecutionContext { - const workflowStore = useWorkflowStore() - const templatesStore = useWorkflowTemplatesStore() - const nodeDefStore = useNodeDefStore() - const activeWorkflow = workflowStore.activeWorkflow - - // Calculate node metrics in a single traversal - type NodeMetrics = { - custom_node_count: number - api_node_count: number - subgraph_count: number - total_node_count: number - has_api_nodes: boolean - api_node_names: string[] - } - - const nodeCounts = reduceAllNodes( - app.graph, - (metrics, node) => { - const nodeDef = nodeDefStore.nodeDefsByName[node.type] - const isCustomNode = - nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes - const isApiNode = nodeDef?.api_node === true - const isSubgraph = node.isSubgraphNode?.() === true - - if (isApiNode) { - metrics.has_api_nodes = true - const canonicalName = nodeDef?.name - if ( - canonicalName && - !metrics.api_node_names.includes(canonicalName) - ) { - metrics.api_node_names.push(canonicalName) - } - } - - metrics.custom_node_count += isCustomNode ? 1 : 0 - metrics.api_node_count += isApiNode ? 1 : 0 - metrics.subgraph_count += isSubgraph ? 1 : 0 - metrics.total_node_count += 1 - - return metrics - }, - { - custom_node_count: 0, - api_node_count: 0, - subgraph_count: 0, - total_node_count: 0, - has_api_nodes: false, - api_node_names: [] - } - ) - - if (activeWorkflow?.filename) { - const isTemplate = templatesStore.knownTemplateNames.has( - activeWorkflow.filename - ) - - if (isTemplate) { - const template = templatesStore.getTemplateByName( - activeWorkflow.filename - ) - - const englishMetadata = templatesStore.getEnglishMetadata( - activeWorkflow.filename - ) - - return { - is_template: true, - workflow_name: activeWorkflow.filename, - template_source: template?.sourceModule, - template_category: englishMetadata?.category ?? template?.category, - template_tags: englishMetadata?.tags ?? template?.tags, - template_models: englishMetadata?.models ?? template?.models, - template_use_case: englishMetadata?.useCase ?? template?.useCase, - template_license: englishMetadata?.license ?? template?.license, - ...nodeCounts - } - } - - return { - is_template: false, - workflow_name: activeWorkflow.filename, - ...nodeCounts - } - } - - return { - is_template: false, - workflow_name: undefined, - ...nodeCounts - } - } } diff --git a/src/platform/telemetry/services/TelemetryService.ts b/src/platform/telemetry/services/TelemetryService.ts new file mode 100644 index 0000000000..f2f4ff4a44 --- /dev/null +++ b/src/platform/telemetry/services/TelemetryService.ts @@ -0,0 +1,240 @@ +import type { + TelemetryProvider, + AuthMetadata, + ExecutionErrorMetadata, + ExecutionSuccessMetadata, + HelpCenterClosedMetadata, + HelpCenterOpenedMetadata, + HelpResourceClickedMetadata, + NodeSearchMetadata, + NodeSearchResultMetadata, + PageVisibilityMetadata, + RunButtonProperties, + SettingChangedMetadata, + SurveyResponses, + TabCountMetadata, + TemplateFilterMetadata, + TemplateLibraryClosedMetadata, + TemplateLibraryMetadata, + TemplateMetadata, + UiButtonClickMetadata, + WorkflowCreatedMetadata, + WorkflowImportMetadata, + ExecutionTriggerSource +} from '../types' +import type { TelemetryHooks } from '../interfaces/TelemetryHooks' + +/** + * Central telemetry service that manages multiple providers and resolves + * context through hook-based dependency inversion. + */ +export class TelemetryService { + private hooks: TelemetryHooks = {} + private providers: TelemetryProvider[] = [] + + /** + * Register hooks from domain stores to provide context + */ + registerHooks(hooks: Partial): void { + this.hooks = { ...this.hooks, ...hooks } + } + + /** + * Add analytics provider to receive events + */ + addProvider(provider: TelemetryProvider): void { + this.providers.push(provider) + } + + trackAuth(metadata: AuthMetadata): void { + this.providers.forEach((provider) => provider.trackAuth(metadata)) + } + + trackUserLoggedIn(): void { + this.providers.forEach((provider) => provider.trackUserLoggedIn()) + } + + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { + this.providers.forEach((provider) => provider.trackSubscription(event)) + } + + trackMonthlySubscriptionSucceeded(): void { + this.providers.forEach((provider) => + provider.trackMonthlySubscriptionSucceeded() + ) + } + + trackAddApiCreditButtonClicked(): void { + this.providers.forEach((provider) => + provider.trackAddApiCreditButtonClicked() + ) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + this.providers.forEach((provider) => + provider.trackApiCreditTopupButtonPurchaseClicked(amount) + ) + } + + trackApiCreditTopupSucceeded(): void { + this.providers.forEach((provider) => + provider.trackApiCreditTopupSucceeded() + ) + } + + trackRunButton(options?: { + subscribe_to_run?: boolean + trigger_source?: ExecutionTriggerSource + }): void { + // Resolve complete context through hooks + const context = this.hooks.getExecutionContext?.() + if (!context) return // Don't track if no context available + + const runButtonProperties: RunButtonProperties = { + subscribe_to_run: options?.subscribe_to_run || false, + workflow_type: context.is_template ? 'template' : 'custom', + workflow_name: context.workflow_name ?? 'untitled', + custom_node_count: context.custom_node_count, + total_node_count: context.total_node_count, + subgraph_count: context.subgraph_count, + has_api_nodes: context.has_api_nodes, + api_node_names: context.api_node_names, + trigger_source: options?.trigger_source + } + + this.providers.forEach((provider) => + provider.trackRunButton(runButtonProperties) + ) + } + + startTopupTracking(): void { + this.providers.forEach((provider) => provider.startTopupTracking()) + } + + checkForCompletedTopup(events: any[] | undefined | null): boolean { + return this.providers.some((provider) => + provider.checkForCompletedTopup(events) + ) + } + + clearTopupTracking(): void { + this.providers.forEach((provider) => provider.clearTopupTracking()) + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + this.providers.forEach((provider) => provider.trackSurvey(stage, responses)) + } + + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { + this.providers.forEach((provider) => provider.trackEmailVerification(stage)) + } + + trackTemplate(metadata: TemplateMetadata): void { + this.providers.forEach((provider) => provider.trackTemplate(metadata)) + } + + trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { + this.providers.forEach((provider) => + provider.trackTemplateLibraryOpened(metadata) + ) + } + + trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { + this.providers.forEach((provider) => + provider.trackTemplateLibraryClosed(metadata) + ) + } + + trackWorkflowImported(metadata: WorkflowImportMetadata): void { + this.providers.forEach((provider) => + provider.trackWorkflowImported(metadata) + ) + } + + trackWorkflowOpened(metadata: WorkflowImportMetadata): void { + this.providers.forEach((provider) => provider.trackWorkflowOpened(metadata)) + } + + trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { + this.providers.forEach((provider) => + provider.trackPageVisibilityChanged(metadata) + ) + } + + trackTabCount(metadata: TabCountMetadata): void { + this.providers.forEach((provider) => provider.trackTabCount(metadata)) + } + + trackNodeSearch(metadata: NodeSearchMetadata): void { + this.providers.forEach((provider) => provider.trackNodeSearch(metadata)) + } + + trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { + this.providers.forEach((provider) => + provider.trackNodeSearchResultSelected(metadata) + ) + } + + trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { + this.providers.forEach((provider) => + provider.trackTemplateFilterChanged(metadata) + ) + } + + trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { + this.providers.forEach((provider) => + provider.trackHelpCenterOpened(metadata) + ) + } + + trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { + this.providers.forEach((provider) => + provider.trackHelpResourceClicked(metadata) + ) + } + + trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { + this.providers.forEach((provider) => + provider.trackHelpCenterClosed(metadata) + ) + } + + trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { + this.providers.forEach((provider) => + provider.trackWorkflowCreated(metadata) + ) + } + + trackWorkflowExecution(): void { + // Resolve context through hooks and only track if context is available + const context = this.hooks.getExecutionContext?.() + if (!context) return // Don't track if no context available + + this.providers.forEach((provider) => + provider.trackWorkflowExecution(context) + ) + } + + trackExecutionError(metadata: ExecutionErrorMetadata): void { + this.providers.forEach((provider) => provider.trackExecutionError(metadata)) + } + + trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { + this.providers.forEach((provider) => + provider.trackExecutionSuccess(metadata) + ) + } + + trackSettingChanged(metadata: SettingChangedMetadata): void { + this.providers.forEach((provider) => provider.trackSettingChanged(metadata)) + } + + trackUiButtonClicked(metadata: UiButtonClickMetadata): void { + this.providers.forEach((provider) => + provider.trackUiButtonClicked(metadata) + ) + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index f16304e7c8..700e6da818 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -267,10 +267,7 @@ export interface TelemetryProvider { trackAddApiCreditButtonClicked(): void trackApiCreditTopupButtonPurchaseClicked(amount: number): void trackApiCreditTopupSucceeded(): void - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void + trackRunButton(properties: RunButtonProperties): void // Credit top-up tracking (composition with internal utilities) startTopupTracking(): void @@ -314,7 +311,7 @@ export interface TelemetryProvider { trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void // Workflow execution events - trackWorkflowExecution(): void + trackWorkflowExecution(context?: ExecutionContext): void trackExecutionError(metadata: ExecutionErrorMetadata): void trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void diff --git a/src/platform/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts index 580bf793d5..adfbe423f2 100644 --- a/src/platform/workflow/management/stores/workflowStore.ts +++ b/src/platform/workflow/management/stores/workflowStore.ts @@ -9,17 +9,22 @@ import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph' +import { useTelemetryService } from '@/platform/telemetry' import type { ComfyWorkflowJSON, NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' +import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' import { useDialogService } from '@/services/dialogService' +import { useNodeDefStore } from '@/stores/nodeDefStore' import { UserFile } from '@/stores/userFileStore' +import { NodeSourceType } from '@/types/nodeSource' +import { reduceAllNodes } from '@/utils/graphTraversalUtil' import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' import { createNodeExecutionId, @@ -708,6 +713,111 @@ export const useWorkflowStore = defineStore('workflow', () => { return createNodeExecutionId([...path, localNodeId]) } + // Register telemetry hooks + const telemetryService = useTelemetryService() + telemetryService?.registerHooks({ + getActiveWorkflow: () => + activeWorkflow.value + ? { + filename: activeWorkflow.value.filename, + isTemplate: false, // This will be enhanced when we add template detection + nodeCount: activeWorkflow.value.activeState?.nodes?.length + } + : null, + + getExecutionContext: () => { + const templatesStore = useWorkflowTemplatesStore() + const nodeDefStore = useNodeDefStore() + + // Calculate node metrics in a single traversal + type NodeMetrics = { + custom_node_count: number + api_node_count: number + subgraph_count: number + total_node_count: number + has_api_nodes: boolean + api_node_names: string[] + } + + const nodeCounts = reduceAllNodes( + comfyApp.graph, + (metrics, node) => { + const nodeDef = nodeDefStore.nodeDefsByName[node.type] + const isCustomNode = + nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes + const isApiNode = nodeDef?.api_node === true + const isSubgraph = node.isSubgraphNode?.() === true + + if (isApiNode) { + metrics.has_api_nodes = true + const canonicalName = nodeDef?.name + if ( + canonicalName && + !metrics.api_node_names.includes(canonicalName) + ) { + metrics.api_node_names.push(canonicalName) + } + } + + metrics.custom_node_count += isCustomNode ? 1 : 0 + metrics.api_node_count += isApiNode ? 1 : 0 + metrics.subgraph_count += isSubgraph ? 1 : 0 + metrics.total_node_count += 1 + + return metrics + }, + { + custom_node_count: 0, + api_node_count: 0, + subgraph_count: 0, + total_node_count: 0, + has_api_nodes: false, + api_node_names: [] + } + ) + + if (activeWorkflow.value?.filename) { + const isTemplate = templatesStore.knownTemplateNames.has( + activeWorkflow.value.filename + ) + + if (isTemplate) { + const template = templatesStore.getTemplateByName( + activeWorkflow.value.filename + ) + + const englishMetadata = templatesStore.getEnglishMetadata( + activeWorkflow.value.filename + ) + + return { + is_template: true, + workflow_name: activeWorkflow.value.filename, + template_source: template?.sourceModule, + template_category: englishMetadata?.category ?? template?.category, + template_tags: englishMetadata?.tags ?? template?.tags, + template_models: englishMetadata?.models ?? template?.models, + template_use_case: englishMetadata?.useCase ?? template?.useCase, + template_license: englishMetadata?.license ?? template?.license, + ...nodeCounts + } + } + + return { + is_template: false, + workflow_name: activeWorkflow.value.filename, + ...nodeCounts + } + } + + return { + is_template: false, + workflow_name: undefined, + ...nodeCounts + } + } + }) + return { activeWorkflow, attachWorkflow,