diff --git a/global.d.ts b/global.d.ts index 2134f9b49..5493cbb18 100644 --- a/global.d.ts +++ b/global.d.ts @@ -4,6 +4,7 @@ declare const __SENTRY_DSN__: string declare const __ALGOLIA_APP_ID__: string declare const __ALGOLIA_API_KEY__: string declare const __USE_PROD_CONFIG__: boolean +declare const __MIXPANEL_TOKEN__: string type BuildFeatureFlags = { REQUIRE_SUBSCRIPTION: boolean diff --git a/package.json b/package.json index cbecce22d..cfe9c4e18 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "knip": "catalog:", "lint-staged": "catalog:", "markdown-table": "catalog:", + "mixpanel-browser": "catalog:", "nx": "catalog:", "picocolors": "catalog:", "postcss-html": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d0b4fa8a..a9ec818d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ catalogs: markdown-table: specifier: ^3.0.4 version: 3.0.4 + mixpanel-browser: + specifier: ^2.71.0 + version: 2.71.0 nx: specifier: 21.4.1 version: 21.4.1 @@ -597,6 +600,9 @@ importers: markdown-table: specifier: 'catalog:' version: 3.0.4 + mixpanel-browser: + specifier: 'catalog:' + version: 2.71.0 nx: specifier: 'catalog:' version: 21.4.1 @@ -2201,6 +2207,21 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@mixpanel/rrdom@2.0.0-alpha.18.2': + resolution: {integrity: sha512-vX/tbnS14ZzzatC7vOyvAm9tOLU8tof0BuppBlphzEx1YHTSw8DQiAmyAc0AmXidchLV0W+cUHV/WsehPLh2hQ==} + + '@mixpanel/rrweb-snapshot@2.0.0-alpha.18.2': + resolution: {integrity: sha512-2kSnjZZ3QZ9zOz/isOt8s54mXUUDgXk/u0eEi/rE0xBWDeuA0NHrBcqiMc+w4F/yWWUpo5F5zcuPeYpc6ufAsw==} + + '@mixpanel/rrweb-types@2.0.0-alpha.18.2': + resolution: {integrity: sha512-ucIYe1mfJ2UksvXW+d3bOySTB2/0yUSqQJlUydvbBz6OO2Bhq3nJHyLXV9ExkgUMZm1ZyDcvvmNUd1+5tAXlpA==} + + '@mixpanel/rrweb-utils@2.0.0-alpha.18.2': + resolution: {integrity: sha512-OomKIB6GTx5xvCLJ7iic2khT/t/tnCJUex13aEqsbSqIT/UzUUsqf+LTrgUK5ex+f6odmkCNjre2y5jvpNqn+g==} + + '@mixpanel/rrweb@2.0.0-alpha.18.2': + resolution: {integrity: sha512-J3dVTEu6Z4p8di7y9KKvUooNuBjX97DdG6XGWoPEPi07A9512h9M8MEtvlY3mK0PGfuC0Mz5Pv/Ws6gjGYfKQg==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3020,6 +3041,9 @@ packages: '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/css-font-loading-module@0.0.7': + resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -3518,6 +3542,9 @@ packages: '@webgpu/types@0.1.51': resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==} + '@xstate/fsm@1.6.5': + resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} + '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: @@ -3800,6 +3827,10 @@ packages: balanced-match@2.0.0: resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -5987,6 +6018,9 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mixpanel-browser@2.71.0: + resolution: {integrity: sha512-jKmDXe68/oQFgk/9ns9Z36bA0CJ31PH8Y77XTLLGfJvhsUPbvu+7Se9e281NejZF6+OMqx7cE+zFxToozYyNrA==} + mkdirp@3.0.1: resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} engines: {node: '>=10'} @@ -9499,6 +9533,29 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@mixpanel/rrdom@2.0.0-alpha.18.2': + dependencies: + '@mixpanel/rrweb-snapshot': 2.0.0-alpha.18.2 + + '@mixpanel/rrweb-snapshot@2.0.0-alpha.18.2': + dependencies: + postcss: 8.5.6 + + '@mixpanel/rrweb-types@2.0.0-alpha.18.2': {} + + '@mixpanel/rrweb-utils@2.0.0-alpha.18.2': {} + + '@mixpanel/rrweb@2.0.0-alpha.18.2': + dependencies: + '@mixpanel/rrdom': 2.0.0-alpha.18.2 + '@mixpanel/rrweb-snapshot': 2.0.0-alpha.18.2 + '@mixpanel/rrweb-types': 2.0.0-alpha.18.2 + '@mixpanel/rrweb-utils': 2.0.0-alpha.18.2 + '@types/css-font-loading-module': 0.0.7 + '@xstate/fsm': 1.6.5 + base64-arraybuffer: 1.0.2 + mitt: 3.0.1 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.4.5 @@ -10387,6 +10444,8 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 + '@types/css-font-loading-module@0.0.7': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -10980,6 +11039,8 @@ snapshots: '@webgpu/types@0.1.51': {} + '@xstate/fsm@1.6.5': {} + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 @@ -11296,6 +11357,8 @@ snapshots: balanced-match@2.0.0: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} better-opn@3.0.2: @@ -13864,6 +13927,10 @@ snapshots: mitt@3.0.1: {} + mixpanel-browser@2.71.0: + dependencies: + '@mixpanel/rrweb': 2.0.0-alpha.18.2 + mkdirp@3.0.1: {} mlly@1.8.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5954445a7..bbcd55d81 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -98,6 +98,7 @@ catalog: zod: ^3.23.8 zod-to-json-schema: ^3.24.1 zod-validation-error: ^3.3.0 + mixpanel-browser: ^2.71.0 cleanupUnusedCatalogs: true diff --git a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue index aeaa18220..eddc9c3f7 100644 --- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue +++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue @@ -86,6 +86,8 @@ import SplitButton from 'primevue/splitbutton' import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { useCommandStore } from '@/stores/commandStore' import { useQueuePendingTaskCountStore, @@ -141,10 +143,15 @@ const hasPendingTasks = computed( const commandStore = useCommandStore() const queuePrompt = async (e: Event) => { - const commandId = - 'shiftKey' in e && e.shiftKey - ? 'Comfy.QueuePromptFront' - : 'Comfy.QueuePrompt' + const isShiftPressed = 'shiftKey' in e && e.shiftKey + const commandId = isShiftPressed + ? 'Comfy.QueuePromptFront' + : 'Comfy.QueuePrompt' + + if (isCloud) { + useTelemetry()?.trackRunButton({ subscribe_to_run: false }) + } + await commandStore.execute(commandId) } diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 2acf3fad5..ed1bc7fba 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -21,7 +21,9 @@ import { import type { Point } from '@/lib/litegraph/src/litegraph' import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog' import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset' +import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' +import { useTelemetry } from '@/platform/telemetry' import { useToastStore } from '@/platform/updates/common/toastStore' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' @@ -451,6 +453,11 @@ export function useCoreCommands(): ComfyCommand[] { category: 'essentials' as const, function: async () => { const batchCount = useQueueSettingsStore().batchCount + + if (isCloud) { + useTelemetry()?.trackWorkflowExecution() + } + await app.queuePrompt(0, batchCount) } }, @@ -462,6 +469,11 @@ export function useCoreCommands(): ComfyCommand[] { category: 'essentials' as const, function: async () => { const batchCount = useQueueSettingsStore().batchCount + + if (isCloud) { + useTelemetry()?.trackWorkflowExecution() + } + await app.queuePrompt(-1, batchCount) } }, diff --git a/src/platform/cloud/subscription/components/SubscribeButton.vue b/src/platform/cloud/subscription/components/SubscribeButton.vue index e9eb65fee..c50120f75 100644 --- a/src/platform/cloud/subscription/components/SubscribeButton.vue +++ b/src/platform/cloud/subscription/components/SubscribeButton.vue @@ -15,6 +15,8 @@ import Button from 'primevue/button' import { onBeforeUnmount, ref } from 'vue' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' +import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' withDefaults( defineProps<{ @@ -82,6 +84,10 @@ const stopPolling = () => { } const handleSubscribe = async () => { + if (isCloud) { + useTelemetry()?.trackSubscription('subscribe_clicked') + } + isLoading.value = true try { await subscribe() diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.vue b/src/platform/cloud/subscription/components/SubscribeToRun.vue index 75dec98ca..9048e20a7 100644 --- a/src/platform/cloud/subscription/components/SubscribeToRun.vue +++ b/src/platform/cloud/subscription/components/SubscribeToRun.vue @@ -10,7 +10,7 @@ severity="primary" size="small" data-testid="subscribe-to-run-button" - @click="showSubscriptionDialog" + @click="handleSubscribeToRun" /> @@ -18,6 +18,16 @@ import Button from 'primevue/button' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' +import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' const { showSubscriptionDialog } = useSubscription() + +const handleSubscribeToRun = () => { + if (isCloud) { + useTelemetry()?.trackRunButton({ subscribe_to_run: true }) + } + + showSubscriptionDialog() +} diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts index 694cbe239..b67c8086b 100644 --- a/src/platform/cloud/subscription/composables/useSubscription.ts +++ b/src/platform/cloud/subscription/composables/useSubscription.ts @@ -7,6 +7,7 @@ import { COMFY_API_BASE_URL } from '@/config/comfyApi' import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { FirebaseAuthStoreError, @@ -78,6 +79,10 @@ export function useSubscription() { }, reportError) const showSubscriptionDialog = () => { + if (isCloud) { + useTelemetry()?.trackSubscription('modal_opened') + } + dialogService.showSubscriptionRequiredDialog() } diff --git a/src/platform/telemetry/index.ts b/src/platform/telemetry/index.ts new file mode 100644 index 000000000..83d7f2c9f --- /dev/null +++ b/src/platform/telemetry/index.ts @@ -0,0 +1,42 @@ +/** + * Telemetry Provider - OSS Build Safety + * + * CRITICAL: OSS Build Safety + * This module is conditionally compiled based on distribution. When building + * the open source version (DISTRIBUTION unset), this entire module and its dependencies + * are excluded through via tree-shaking. + * + * To verify OSS builds exclude this code: + * 1. `DISTRIBUTION= pnpm build` (OSS build) + * 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing) + * 3. Check dist/assets/*.js files contain no tracking code + * + * This approach maintains complete separation between cloud and OSS builds + * while ensuring the open source version contains no telemetry dependencies. + */ +import { isCloud } from '@/platform/distribution/types' + +import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider' +import type { TelemetryProvider } from './types' + +// Singleton instance +let _telemetryProvider: TelemetryProvider | null = null + +/** + * Telemetry factory - conditionally creates provider based on distribution + * Returns singleton instance. + * + * CRITICAL: This returns undefined in OSS builds. There is no telemetry provider + * for OSS builds and all tracking calls are no-ops. + */ +export function useTelemetry(): TelemetryProvider | null { + if (_telemetryProvider === null) { + // Use distribution check for tree-shaking + if (isCloud) { + _telemetryProvider = new MixpanelTelemetryProvider() + } + // For OSS builds, _telemetryProvider stays null + } + + return _telemetryProvider +} diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts new file mode 100644 index 000000000..05866cfd0 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -0,0 +1,221 @@ +import type { OverridedMixpanel } from 'mixpanel-browser' + +import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' + +import type { + AuthMetadata, + ExecutionContext, + RunButtonProperties, + SurveyResponses, + TelemetryEventName, + TelemetryEventProperties, + TelemetryProvider, + TemplateMetadata +} from '../../types' +import { TelemetryEvents } from '../../types' + +interface QueuedEvent { + eventName: TelemetryEventName + properties?: TelemetryEventProperties +} + +/** + * Mixpanel Telemetry Provider - Cloud Build Implementation + * + * CRITICAL: OSS Build Safety + * This provider integrates with Mixpanel for cloud telemetry tracking. + * Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset). + * + * 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 + */ +export class MixpanelTelemetryProvider implements TelemetryProvider { + private isEnabled = true + private mixpanel: OverridedMixpanel | null = null + private eventQueue: QueuedEvent[] = [] + private isInitialized = false + + constructor() { + const token = __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') + this.isEnabled = false + } + } + + private flushEventQueue(): void { + if (!this.isInitialized || !this.mixpanel) { + 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) + } + } + } + + private trackEvent( + eventName: TelemetryEventName, + properties?: TelemetryEventProperties + ): void { + if (!this.isEnabled) { + 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) + } + } + + trackAuth(metadata: AuthMetadata): void { + this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata) + } + + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void { + const eventName = + event === 'modal_opened' + ? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED + : TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED + + this.trackEvent(eventName) + } + + trackRunButton(options?: { subscribe_to_run?: boolean }): 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' + } + + this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties) + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + const eventName = + stage === 'opened' + ? TelemetryEvents.USER_SURVEY_OPENED + : TelemetryEvents.USER_SURVEY_SUBMITTED + + this.trackEvent(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 + } + + this.trackEvent(eventName) + } + + trackTemplate(metadata: TemplateMetadata): void { + this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata) + } + + trackWorkflowExecution(): void { + const context = this.getExecutionContext() + this.trackEvent(TelemetryEvents.WORKFLOW_EXECUTION_STARTED, context) + } + + getExecutionContext(): ExecutionContext { + const workflowStore = useWorkflowStore() + const templatesStore = useWorkflowTemplatesStore() + const activeWorkflow = workflowStore.activeWorkflow + + if (activeWorkflow?.filename) { + const isTemplate = templatesStore.knownTemplateNames.has( + activeWorkflow.filename + ) + + if (isTemplate) { + const template = templatesStore.getTemplateByName( + activeWorkflow.filename + ) + return { + is_template: true, + workflow_name: activeWorkflow.filename, + template_source: template?.sourceModule, + template_category: template?.category, + template_tags: template?.tags, + template_models: template?.models, + template_use_case: template?.useCase, + template_license: template?.license + } + } + + return { + is_template: false, + workflow_name: activeWorkflow.filename + } + } + + return { + is_template: false, + workflow_name: undefined + } + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts new file mode 100644 index 000000000..c749ca569 --- /dev/null +++ b/src/platform/telemetry/types.ts @@ -0,0 +1,138 @@ +/** + * Telemetry Provider Interface + * + * CRITICAL: OSS Build Safety + * This module is excluded from OSS builds via conditional compilation. + * When DISTRIBUTION is unset (OSS builds), Vite's tree-shaking removes this code entirely, + * ensuring the open source build contains no telemetry dependencies. + * + * To verify OSS builds are clean: + * 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 + */ + +/** + * Authentication metadata for sign-up tracking + */ +export interface AuthMetadata { + method?: 'email' | 'google' | 'github' + is_new_user?: boolean + referrer_url?: string + utm_source?: string + utm_medium?: string + utm_campaign?: string +} + +/** + * Survey response data for user profiling + */ +export interface SurveyResponses { + industry?: string + team_size?: string + use_case?: string + familiarity?: string + intended_use?: 'personal' | 'client' | 'inhouse' +} + +/** + * Run button tracking properties + */ +export interface RunButtonProperties { + subscribe_to_run: boolean + workflow_type: 'template' | 'custom' + workflow_name: string +} + +/** + * Execution context for workflow tracking + */ +export interface ExecutionContext { + is_template: boolean + workflow_name?: string + // Template metadata (only present when is_template = true) + template_source?: string + template_category?: string + template_tags?: string[] + template_models?: string[] + template_use_case?: string + template_license?: string +} + +/** + * Template metadata for workflow tracking + */ +export interface TemplateMetadata { + workflow_name: string + template_source?: string + template_category?: string + template_tags?: string[] + template_models?: string[] + template_use_case?: string + template_license?: string +} + +/** + * Core telemetry provider interface + */ +export interface TelemetryProvider { + // Authentication flow events + trackAuth(metadata: AuthMetadata): void + + // Subscription flow events + trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void + trackRunButton(options?: { subscribe_to_run?: boolean }): void + + // Survey flow events + trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void + + // Email verification events + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void + + // Template workflow events + trackTemplate(metadata: TemplateMetadata): void + + // Workflow execution events + trackWorkflowExecution(): void +} + +/** + * Telemetry event constants + */ +export const TelemetryEvents = { + // Authentication Flow + USER_AUTH_COMPLETED: 'user_auth_completed', + + // Subscription Flow + RUN_BUTTON_CLICKED: 'run_button_clicked', + SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'subscription_required_modal_opened', + SUBSCRIBE_NOW_BUTTON_CLICKED: 'subscribe_now_button_clicked', + + // Onboarding Survey + USER_SURVEY_OPENED: 'user_survey_opened', + USER_SURVEY_SUBMITTED: 'user_survey_submitted', + + // Email Verification + USER_EMAIL_VERIFY_OPENED: 'user_email_verify_opened', + USER_EMAIL_VERIFY_REQUESTED: 'user_email_verify_requested', + USER_EMAIL_VERIFY_COMPLETED: 'user_email_verify_completed', + + // Template Tracking + TEMPLATE_WORKFLOW_OPENED: 'template_workflow_opened', + + // Workflow Execution Tracking + WORKFLOW_EXECUTION_STARTED: 'workflow_execution_started' +} as const + +export type TelemetryEventName = + (typeof TelemetryEvents)[keyof typeof TelemetryEvents] + +/** + * Union type for all possible telemetry event properties + */ +export type TelemetryEventProperties = + | AuthMetadata + | SurveyResponses + | TemplateMetadata + | ExecutionContext + | RunButtonProperties diff --git a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts index ea2bd926e..450ab9a47 100644 --- a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts +++ b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts @@ -1,6 +1,8 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore' import type { TemplateGroup, @@ -128,6 +130,13 @@ export function useTemplateWorkflows() { ? t(`templateWorkflows.template.${id}`, id) : id + if (isCloud) { + useTelemetry()?.trackTemplate({ + workflow_name: workflowName, + template_source: actualSourceModule + }) + } + dialogStore.closeDialog() await app.loadGraphData(json, true, true, workflowName) @@ -142,6 +151,13 @@ export function useTemplateWorkflows() { ? t(`templateWorkflows.template.${id}`, id) : id + if (isCloud) { + useTelemetry()?.trackTemplate({ + workflow_name: workflowName, + template_source: sourceModule + }) + } + dialogStore.closeDialog() await app.loadGraphData(json, true, true, workflowName) diff --git a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts index 87f9378c7..8d73911f1 100644 --- a/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts +++ b/src/platform/workflow/templates/repositories/workflowTemplatesStore.ts @@ -30,6 +30,11 @@ export const useWorkflowTemplatesStore = defineStore( const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({}) const coreTemplates = shallowRef([]) const isLoaded = ref(false) + const knownTemplateNames = ref(new Set()) + + const getTemplateByName = (name: string): EnhancedTemplate | undefined => { + return enhancedTemplates.value.find((template) => template.name === name) + } // Store filter mappings for dynamic categories type FilterData = { @@ -432,6 +437,13 @@ export const useWorkflowTemplatesStore = defineStore( customTemplates.value = await api.getWorkflowTemplates() const locale = i18n.global.locale.value coreTemplates.value = await api.getCoreWorkflowTemplates(locale) + + const coreNames = coreTemplates.value.flatMap((category) => + category.templates.map((template) => template.name) + ) + const customNames = Object.values(customTemplates.value).flat() + knownTemplateNames.value = new Set([...coreNames, ...customNames]) + isLoaded.value = true } } catch (error) { @@ -446,7 +458,9 @@ export const useWorkflowTemplatesStore = defineStore( templateFuse, filterTemplatesByCategory, isLoaded, - loadWorkflowTemplates + loadWorkflowTemplates, + knownTemplateNames, + getTemplateByName } } ) diff --git a/src/stores/firebaseAuthStore.ts b/src/stores/firebaseAuthStore.ts index 19512fd20..208ffe523 100644 --- a/src/stores/firebaseAuthStore.ts +++ b/src/stores/firebaseAuthStore.ts @@ -6,6 +6,7 @@ import { browserLocalPersistence, createUserWithEmailAndPassword, deleteUser, + getAdditionalUserInfo, onAuthStateChanged, sendPasswordResetEmail, setPersistence, @@ -21,6 +22,8 @@ import { useFirebaseAuth } from 'vuefire' import { COMFY_API_BASE_URL } from '@/config/comfyApi' import { t } from '@/i18n' +import { isCloud } from '@/platform/distribution/types' +import { useTelemetry } from '@/platform/telemetry' import { useDialogService } from '@/services/dialogService' import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore' import type { AuthHeader } from '@/types/authTypes' @@ -242,36 +245,79 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => { const login = async ( email: string, password: string - ): Promise => - executeAuthAction( + ): Promise => { + const result = await executeAuthAction( (authInstance) => signInWithEmailAndPassword(authInstance, email, password), { createCustomer: true } ) + if (isCloud) { + useTelemetry()?.trackAuth({ + method: 'email', + is_new_user: false + }) + } + + return result + } + const register = async ( email: string, password: string ): Promise => { - return executeAuthAction( + const result = await executeAuthAction( (authInstance) => createUserWithEmailAndPassword(authInstance, email, password), { createCustomer: true } ) + + if (isCloud) { + useTelemetry()?.trackAuth({ + method: 'email', + is_new_user: true + }) + } + + return result } - const loginWithGoogle = async (): Promise => - executeAuthAction( + const loginWithGoogle = async (): Promise => { + const result = await executeAuthAction( (authInstance) => signInWithPopup(authInstance, googleProvider), { createCustomer: true } ) - const loginWithGithub = async (): Promise => - executeAuthAction( + if (isCloud) { + const additionalUserInfo = getAdditionalUserInfo(result) + const isNewUser = additionalUserInfo?.isNewUser ?? false + useTelemetry()?.trackAuth({ + method: 'google', + is_new_user: isNewUser + }) + } + + return result + } + + const loginWithGithub = async (): Promise => { + const result = await executeAuthAction( (authInstance) => signInWithPopup(authInstance, githubProvider), { createCustomer: true } ) + if (isCloud) { + const additionalUserInfo = getAdditionalUserInfo(result) + const isNewUser = additionalUserInfo?.isNewUser ?? false + useTelemetry()?.trackAuth({ + method: 'github', + is_new_user: isNewUser + }) + } + + return result + } + const logout = async (): Promise => executeAuthAction((authInstance) => signOut(authInstance)) diff --git a/tests-ui/tests/composables/useTemplateWorkflows.test.ts b/tests-ui/tests/composables/useTemplateWorkflows.test.ts index 4b98fb4f1..8a78b5a4d 100644 --- a/tests-ui/tests/composables/useTemplateWorkflows.test.ts +++ b/tests-ui/tests/composables/useTemplateWorkflows.test.ts @@ -31,6 +31,11 @@ vi.mock('@/scripts/app', () => ({ vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: vi.fn((key, fallback) => fallback || key) + }), + createI18n: () => ({ + global: { + t: (key: string) => key + } }) })) diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts index a36471bf5..ec356cfc3 100644 --- a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts +++ b/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts @@ -19,6 +19,10 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ })) })) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: vi.fn(() => null) +})) + vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({ useFirebaseAuthActions: vi.fn(() => ({ reportError: mockReportError, diff --git a/tests-ui/tests/platform/telemetry/useTelemetry.test.ts b/tests-ui/tests/platform/telemetry/useTelemetry.test.ts new file mode 100644 index 000000000..fa3e584f2 --- /dev/null +++ b/tests-ui/tests/platform/telemetry/useTelemetry.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/platform/distribution/types', () => ({ + isCloud: false +})) + +describe('useTelemetry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should return null when not in cloud distribution', async () => { + const { useTelemetry } = await import('@/platform/telemetry') + const provider = useTelemetry() + + // Should return null for OSS builds + expect(provider).toBeNull() + }) + + it('should return null consistently for OSS builds', async () => { + const { useTelemetry } = await import('@/platform/telemetry') + + const provider1 = useTelemetry() + const provider2 = useTelemetry() + + // Both should be null for OSS builds + expect(provider1).toBeNull() + expect(provider2).toBeNull() + }) +}) diff --git a/vite.config.mts b/vite.config.mts index 99c8d47ae..dbd4f20c5 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -272,7 +272,8 @@ export default defineConfig({ __ALGOLIA_API_KEY__: JSON.stringify(process.env.ALGOLIA_API_KEY || ''), __USE_PROD_CONFIG__: process.env.USE_PROD_CONFIG === 'true', __DISTRIBUTION__: JSON.stringify(DISTRIBUTION), - __BUILD_FLAGS__: JSON.stringify(BUILD_FLAGS) + __BUILD_FLAGS__: JSON.stringify(BUILD_FLAGS), + __MIXPANEL_TOKEN__: JSON.stringify(process.env.MIXPANEL_TOKEN || '') }, resolve: {