[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:
Christian Byrne
2025-10-19 20:04:57 -07:00
committed by GitHub
parent b210e63f3c
commit b708ebf540
20 changed files with 751 additions and 96 deletions

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

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