From 7ad1112535411e7d319adf3b2ccb5bacd4a9ce97 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sun, 19 Oct 2025 19:47:35 -0700 Subject: [PATCH] add telemetry provider for cloud distribution (#6154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This code is entirely excluded from open-source, local, and desktop builds. During minification and dead-code elimination, the Mixpanel library is fully tree-shaken -- meaning no telemetry code is ever included or downloaded in those builds. Even the inline callsites are removed during the build (because `isCloud` becomes false and the entire block becomes dead code and is removed). The code not only has no effect, is not even distributed in the first place. We’ve gone to great lengths to ensure this behavior. Verification proof: https://github.com/user-attachments/assets/b66c35f7-e233-447f-93da-4d70c433908d Telemetry is *enabled only in the ComfyUI Cloud environment*. Its goal is to help us understand and improve onboarding and new-user adoption. ComfyUI aims to be accessible to everyone, but we know the learning curve can be steep. Anonymous usage insights will help us identify where users struggle and guide us toward making the experience more intuitive and welcoming. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6154-add-telemetry-provider-for-cloud-distribution-2926d73d3650813cb9ccfb3a2733848b) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude --- global.d.ts | 1 + package.json | 1 + pnpm-lock.yaml | 67 ++++++ pnpm-workspace.yaml | 1 + .../ComfyRunButton/ComfyQueueButton.vue | 15 +- src/composables/useCoreCommands.ts | 12 + .../components/SubscribeButton.vue | 6 + .../components/SubscribeToRun.vue | 12 +- .../composables/useSubscription.ts | 5 + src/platform/telemetry/index.ts | 42 ++++ .../cloud/MixpanelTelemetryProvider.ts | 221 ++++++++++++++++++ src/platform/telemetry/types.ts | 138 +++++++++++ .../composables/useTemplateWorkflows.ts | 16 ++ .../repositories/workflowTemplatesStore.ts | 16 +- src/stores/firebaseAuthStore.ts | 60 ++++- .../composables/useTemplateWorkflows.test.ts | 5 + .../subscription/useSubscription.test.ts | 4 + .../platform/telemetry/useTelemetry.test.ts | 30 +++ vite.config.mts | 3 +- 19 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 src/platform/telemetry/index.ts create mode 100644 src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts create mode 100644 src/platform/telemetry/types.ts create mode 100644 tests-ui/tests/platform/telemetry/useTelemetry.test.ts 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: {