From a1c92274fba6dc922d8388df9aa6eee402447102 Mon Sep 17 00:00:00 2001 From: bymyself Date: Thu, 13 Nov 2025 09:50:54 -0800 Subject: [PATCH 1/2] refactor --- src/platform/telemetry/index.ts | 31 ++- .../telemetry/interfaces/TelemetryHooks.ts | 49 ++++ .../providers/TelemetryProviderBase.ts | 91 +++++++ .../providers/cloud/CloudAnalyticsProvider.ts | 210 +++++++++++++++ .../cloud/MixpanelTelemetryProvider.ts | 68 +++-- .../telemetry/services/TelemetryService.ts | 239 ++++++++++++++++++ src/platform/telemetry/types.ts | 2 +- src/scripts/api.ts | 22 ++ 8 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 src/platform/telemetry/interfaces/TelemetryHooks.ts create mode 100644 src/platform/telemetry/providers/TelemetryProviderBase.ts create mode 100644 src/platform/telemetry/providers/cloud/CloudAnalyticsProvider.ts create mode 100644 src/platform/telemetry/services/TelemetryService.ts diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts index 83d7f2c9f9..9fd2d99312 100644 --- a/src/platform/telemetry/index.ts +++ b/src/platform/telemetry/index.ts @@ -16,27 +16,40 @@ */ import { isCloud } from '@/platform/distribution/types' +import { CloudAnalyticsProvider } from './providers/cloud/CloudAnalyticsProvider' 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 useTelemetry(): TelemetryService | null { + if (_telemetryService === null) { // Use distribution check for tree-shaking if (isCloud) { - _telemetryProvider = new MixpanelTelemetryProvider() + _telemetryService = new TelemetryService() + + // Initialize and add both analytics providers + const mixpanelProvider = new MixpanelTelemetryProvider() + const cloudAnalyticsProvider = new CloudAnalyticsProvider() + + void mixpanelProvider.initialize().then(() => { + _telemetryService?.addProvider(mixpanelProvider) + }) + + void cloudAnalyticsProvider.initialize().then(() => { + _telemetryService?.addProvider(cloudAnalyticsProvider) + }) } - // For OSS builds, _telemetryProvider stays null + // For OSS builds, _telemetryService stays null } - return _telemetryProvider + return _telemetryService } 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..809a8aba61 --- /dev/null +++ b/src/platform/telemetry/providers/TelemetryProviderBase.ts @@ -0,0 +1,91 @@ +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 + } + + 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/CloudAnalyticsProvider.ts b/src/platform/telemetry/providers/cloud/CloudAnalyticsProvider.ts new file mode 100644 index 0000000000..1193d87d93 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/CloudAnalyticsProvider.ts @@ -0,0 +1,210 @@ +import { api } from '@/scripts/api' +import type { + AuthMetadata, + CreditTopupMetadata, + ExecutionContext, + ExecutionErrorMetadata, + ExecutionSuccessMetadata, + HelpCenterClosedMetadata, + HelpCenterOpenedMetadata, + HelpResourceClickedMetadata, + NodeSearchMetadata, + NodeSearchResultMetadata, + PageVisibilityMetadata, + RunButtonProperties, + SettingChangedMetadata, + SurveyResponses, + TabCountMetadata, + TelemetryEventName, + TemplateFilterMetadata, + TemplateLibraryClosedMetadata, + TemplateLibraryMetadata, + TemplateMetadata, + UiButtonClickMetadata, + WorkflowCreatedMetadata, + WorkflowImportMetadata +} from '../../types' +import { TelemetryEvents } from '../../types' +import { TelemetryProviderBase } from '../TelemetryProviderBase' + +/** + * Cloud Analytics Provider for server-side event tracking. + * Posts events to the backend's cloud analytics service (ClickHouse) via api.postCloudAnalytics. + * This complements client-side tracking (Mixpanel) with server-side data collection. + */ +export class CloudAnalyticsProvider extends TelemetryProviderBase { + async initialize(): Promise { + this.isInitialized = true + } + + private async postEvent( + eventName: TelemetryEventName, + eventData?: any + ): Promise { + if (!this.isEnabled || !this.isInitialized) { + return + } + + try { + await api.postCloudAnalytics(eventName, eventData || {}) + } catch (error) { + console.error('Failed to post cloud analytics event:', error) + } + } + + trackAuth(metadata: AuthMetadata): void { + void this.postEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata) + } + + trackUserLoggedIn(): void { + void this.postEvent(TelemetryEvents.USER_LOGGED_IN) + } + + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { + const eventName = + event === 'modal_opened' + ? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED + : TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED + void this.postEvent(eventName) + } + + trackMonthlySubscriptionSucceeded(): void { + void this.postEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED) + } + + trackAddApiCreditButtonClicked(): void { + void this.postEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + const metadata: CreditTopupMetadata = { credit_amount: amount } + void this.postEvent( + TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, + metadata + ) + } + + trackApiCreditTopupSucceeded(): void { + void this.postEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED) + } + + trackRunButton(properties: RunButtonProperties): void { + void this.postEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties) + } + + startTopupTracking(): void { + // Not applicable for server-side tracking + } + + checkForCompletedTopup(_events: any[] | undefined | null): boolean { + // Not applicable for server-side tracking + return false + } + + clearTopupTracking(): void { + // Not applicable for server-side tracking + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + const eventName = + stage === 'opened' + ? TelemetryEvents.USER_SURVEY_OPENED + : TelemetryEvents.USER_SURVEY_SUBMITTED + void this.postEvent(eventName, responses) + } + + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { + let eventName: TelemetryEventName + switch (stage) { + case 'opened': + eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED + break + case 'requested': + eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED + break + case 'completed': + eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED + break + } + void this.postEvent(eventName) + } + + trackTemplate(metadata: TemplateMetadata): void { + void this.postEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata) + } + + trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { + void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata) + } + + trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { + void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata) + } + + trackWorkflowImported(metadata: WorkflowImportMetadata): void { + void this.postEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata) + } + + trackWorkflowOpened(metadata: WorkflowImportMetadata): void { + void this.postEvent(TelemetryEvents.WORKFLOW_OPENED, metadata) + } + + trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { + void this.postEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata) + } + + trackTabCount(metadata: TabCountMetadata): void { + void this.postEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata) + } + + trackNodeSearch(metadata: NodeSearchMetadata): void { + void this.postEvent(TelemetryEvents.NODE_SEARCH, metadata) + } + + trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { + void this.postEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata) + } + + trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { + void this.postEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata) + } + + trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { + void this.postEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata) + } + + trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { + void this.postEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata) + } + + trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { + void this.postEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata) + } + + trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { + void this.postEvent(TelemetryEvents.WORKFLOW_CREATED, metadata) + } + + trackWorkflowExecution(context?: ExecutionContext): void { + void this.postEvent(TelemetryEvents.EXECUTION_START, context) + } + + trackExecutionError(metadata: ExecutionErrorMetadata): void { + void this.postEvent(TelemetryEvents.EXECUTION_ERROR, metadata) + } + + trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { + void this.postEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata) + } + + trackSettingChanged(metadata: SettingChangedMetadata): void { + void this.postEvent(TelemetryEvents.SETTING_CHANGED, metadata) + } + + trackUiButtonClicked(metadata: UiButtonClickMetadata): void { + void this.postEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata) + } +} diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 419ab6329d..608930b972 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -32,7 +32,6 @@ import type { TabCountMetadata, TelemetryEventName, TelemetryEventProperties, - TelemetryProvider, TemplateFilterMetadata, TemplateLibraryClosedMetadata, TemplateLibraryMetadata, @@ -43,6 +42,7 @@ import type { } from '../../types' import { TelemetryEvents } from '../../types' import { normalizeSurveyResponses } from '../../utils/surveyNormalization' +import { TelemetryProviderBase } from '../TelemetryProviderBase' interface QueuedEvent { eventName: TelemetryEventName @@ -61,50 +61,42 @@ 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() { + async initialize(): Promise { 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 + if (!token) { + this.setEnabled(false) + return + } + + 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', + loaded: () => { + this.isInitialized = true + this.flushEventQueue() + useCurrentUser().onUserResolved((user) => { + if (this.mixpanel && user.id) { + this.mixpanel.identify(user.id) + } }) - } 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 + } + }) + } catch (error) { + console.error('Failed to load Mixpanel:', error) + this.setEnabled(false) } } diff --git a/src/platform/telemetry/services/TelemetryService.ts b/src/platform/telemetry/services/TelemetryService.ts new file mode 100644 index 0000000000..966e7efbf2 --- /dev/null +++ b/src/platform/telemetry/services/TelemetryService.ts @@ -0,0 +1,239 @@ +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 coordinates multiple analytics providers. + * Uses registered hooks to gather context data from application stores + * without creating circular import dependencies. + */ +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 { + 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 { + 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..47456a789a 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -314,7 +314,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/scripts/api.ts b/src/scripts/api.ts index 84d491bde2..9658ad861f 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -1251,6 +1251,28 @@ export class ComfyApi extends EventTarget { getServerFeatures(): Record { return { ...this.serverFeatureFlags } } + + /** + * Posts analytics event to cloud analytics service + * @param eventName The name of the analytics event + * @param eventData The event data (any JSON-serializable object) + * @returns Promise resolving to the response + */ + async postCloudAnalytics( + eventName: string, + eventData: any + ): Promise { + return this.fetchApi(this.internalURL('/cloud_analytics'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + event_name: eventName, + event_data: eventData + }) + }) + } } export const api = new ComfyApi() From b3fe8da9b57e9a099a662b56c10a09658db8ff00 Mon Sep 17 00:00:00 2001 From: bymyself Date: Thu, 13 Nov 2025 15:29:22 -0800 Subject: [PATCH 2/2] fix --- .../cloud/MixpanelTelemetryProvider.ts | 134 +---------------- .../management/stores/workflowStore.ts | 136 ++++++++++++++++++ 2 files changed, 140 insertions(+), 130 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 608930b972..c1b02a4791 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -6,18 +6,11 @@ import { 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, CreditTopupMetadata, ExecutionContext, - ExecutionTriggerSource, ExecutionErrorMetadata, ExecutionSuccessMetadata, HelpCenterClosedMetadata, @@ -64,7 +57,6 @@ interface QueuedEvent { export class MixpanelTelemetryProvider extends TelemetryProviderBase { private mixpanel: OverridedMixpanel | null = null private eventQueue: QueuedEvent[] = [] - private lastTriggerSource: ExecutionTriggerSource | undefined async initialize(): Promise { const token = window.__CONFIG__?.mixpanel_token @@ -190,26 +182,8 @@ export class MixpanelTelemetryProvider extends TelemetryProviderBase { 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.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties) } trackSurvey( @@ -312,14 +286,8 @@ export class MixpanelTelemetryProvider extends TelemetryProviderBase { this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata) } - trackWorkflowExecution(): void { - const context = this.getExecutionContext() - const eventContext: ExecutionContext = { - ...context, - trigger_source: this.lastTriggerSource ?? 'unknown' - } - this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext) - this.lastTriggerSource = undefined + trackWorkflowExecution(context?: ExecutionContext): void { + this.trackEvent(TelemetryEvents.EXECUTION_START, context) } trackExecutionError(metadata: ExecutionErrorMetadata): void { @@ -337,98 +305,4 @@ export class MixpanelTelemetryProvider extends TelemetryProviderBase { 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/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts index 580bf793d5..a6ad0d24e0 100644 --- a/src/platform/workflow/management/stores/workflowStore.ts +++ b/src/platform/workflow/management/stores/workflowStore.ts @@ -20,6 +20,11 @@ import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' import { useDialogService } from '@/services/dialogService' import { UserFile } from '@/stores/userFileStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' +import { NodeSourceType } from '@/types/nodeSource' +import { reduceAllNodes } from '@/utils/graphTraversalUtil' +import { useTelemetry } from '@/platform/telemetry' +import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' import { createNodeExecutionId, @@ -743,6 +748,137 @@ export const useWorkflowStore = defineStore('workflow', () => { nodeLocatorIdToNodeId, nodeLocatorIdToNodeExecutionId } + + // Register telemetry hooks for execution context + const telemetryService = useTelemetry() + telemetryService?.registerHooks({ + 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: [] + } + ) + + const workflow = activeWorkflow.value + if (workflow?.filename) { + const isTemplate = templatesStore.knownTemplateNames.has( + workflow.filename + ) + + if (isTemplate) { + const template = templatesStore.getTemplateByName(workflow.filename) + + const englishMetadata = templatesStore.getEnglishMetadata( + workflow.filename + ) + + return { + is_template: true, + workflow_name: workflow.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: workflow.filename, + ...nodeCounts + } + } + + return { + is_template: false, + workflow_name: undefined, + ...nodeCounts + } + } + }) + + return { + activeWorkflow, + attachWorkflow, + isActive, + openWorkflows, + openedWorkflowIndexShift, + openWorkflow, + openWorkflowsInBackground, + isOpen, + isBusy, + closeWorkflow, + createTemporary, + renameWorkflow, + deleteWorkflow, + saveAs, + saveWorkflow, + reorderWorkflows, + + workflows, + bookmarkedWorkflows, + persistedWorkflows, + modifiedWorkflows, + getWorkflowByPath, + syncWorkflows, + + isSubgraphActive, + activeSubgraph, + updateActiveGraph, + executionIdToCurrentId, + nodeIdToNodeLocatorId, + nodeToNodeLocatorId, + nodeExecutionIdToNodeLocatorId, + nodeLocatorIdToNodeId, + nodeLocatorIdToNodeExecutionId + } }) satisfies () => WorkflowStore export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {