add telemetry provider for cloud distribution (#6154)

## 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 <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-10-19 19:47:35 -07:00
committed by GitHub
parent 522656a2dc
commit 7ad1112535
19 changed files with 641 additions and 14 deletions

1
global.d.ts vendored
View File

@@ -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

View File

@@ -89,6 +89,7 @@
"knip": "catalog:",
"lint-staged": "catalog:",
"markdown-table": "catalog:",
"mixpanel-browser": "catalog:",
"nx": "catalog:",
"picocolors": "catalog:",
"postcss-html": "catalog:",

67
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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

View File

@@ -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)
}
</script>

View File

@@ -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)
}
},

View File

@@ -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()

View File

@@ -10,7 +10,7 @@
severity="primary"
size="small"
data-testid="subscribe-to-run-button"
@click="showSubscriptionDialog"
@click="handleSubscribeToRun"
/>
</template>
@@ -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()
}
</script>

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -30,6 +30,11 @@ export const useWorkflowTemplatesStore = defineStore(
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
const knownTemplateNames = ref(new Set<string>())
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
}
}
)

View File

@@ -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<UserCredential> =>
executeAuthAction(
): Promise<UserCredential> => {
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<UserCredential> => {
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<UserCredential> =>
executeAuthAction(
const loginWithGoogle = async (): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
)
const loginWithGithub = async (): Promise<UserCredential> =>
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<UserCredential> => {
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<void> =>
executeAuthAction((authInstance) => signOut(authInstance))

View File

@@ -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
}
})
}))

View File

@@ -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,

View File

@@ -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()
})
})

View File

@@ -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: {