mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 07:30:08 +00:00
[backport rh-test] add telemetry provider for cloud distribution (#6155)
## Summary This PR manually backports the telemetry provider implementation to the rh-test branch after the automated backport failed due to merge conflicts. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6155-Manual-backport-add-telemetry-provider-for-cloud-distribution-2926d73d3650812e94e2fe0bd5e9bc59) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
42
src/platform/telemetry/index.ts
Normal file
42
src/platform/telemetry/index.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
138
src/platform/telemetry/types.ts
Normal file
138
src/platform/telemetry/types.ts
Normal 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
|
||||
@@ -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,
|
||||
@@ -78,7 +80,7 @@ export function useTemplateWorkflows() {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
? (template.localizedTitle ?? fallback)
|
||||
: fallback
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user