Files
ComfyUI_frontend/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts
Benjamin Lu 7bf8e5af38 feat(telemetry): add tracking for sidebar, run menu, dialogs, subgraphs, settings and credits (#6511)
Summary
- Add comprehensive telemetry for key UI interactions using existing
telemetry hooks (cloud-enabled, no-op in OSS):

Sidebar and top-level
- Node library button: `node_library`
- Model library button: `model_library`
- Workflows button: `workflows`
- Assets/Media button: `assets`
- Templates button: `templates`
- Keyboard Shortcuts: `keyboard_shortcuts`
- Console: `console`
- Help Center: `help_center`
- Settings (from Comfy logo menu): `settings_menu`

Floating canvas menu
- Minimap toggle: `minimap_toggle`
- Hide links toggle: `hide_links`

Run button and queue
- Run button handle (drag): `run_button_handle`
- Run mode selection: `run_instant`, `run_on_change`
- Queue multiple: `queue_multiple` fires on each run when batch count >
1 (moved from batch-count-change to run-time, per guidance)

Error dialogs
- Close (X/mask/ESC): `error_dialog_close` via dialog onClose
- Show report: `error_show_report`
- Help fix this: `error_help_fix_this`
- Find issues: `error_find_issues`

Nodes / Subgraphs
- Selection toolbox “Node info”: `node_info`
- Enter subgraph (node header enter): `open_subgraph`
- Subgraph breadcrumb navigation: `subgraph_breadcrumb_item` and
`subgraph_breadcrumb_root`

Settings / Credits / Search
- Settings menu button (under Comfy logo): `settings_menu`
- Purchase credits (Settings > Credits panel): tracked via existing
`trackAddApiCreditButtonClicked`
- Purchase credits (Avatar popover Top Up): tracked via existing
`trackAddApiCreditButtonClicked`
- Debounced search telemetry already present for node search and
template filters; left as-is

Notes and answers
- Error dialog onClose: only fires when the dialog actually closes (X,
mask, ESC, or programmatic close). “Show report” and “Help fix this” do
not close the dialog; they each emit their own events.
- Telemetry is behind the cloud provider; calls are optional
(`useTelemetry()?.…`). OSS builds send nothing.

Open questions / follow-ups
- Primary Run button click: today cloud-only `trackRunButton` exists; we
can also emit a UI-level `run` click (`UI_BUTTON_CLICKED` style)
alongside it if desired. Confirm preference and I can add it.
- Subgraph usage richness: if we want structured analytics (e.g.,
action, depth, subgraph id, node count), I can add a dedicated provider
method and include richer metadata at enter/breadcrumb.
- Optional parity: track the Comfy menu’s “Browse Templates” item in
addition to the sidebar Templates button.

Quality
- Ran `pnpm lint:fix` and `pnpm typecheck`; both pass locally.

Implementation details
- All handlers refactored to named functions where needed; used `void`
for intentionally unawaited async calls per lint rules.
- Event names kept consistent in `button_id` strings; happy to align to
a different naming scheme if you prefer.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6511-feat-telemetry-add-tracking-for-sidebar-run-menu-dialogs-subgraphs-settings-and-cre-29e6d73d365081a1b8b4fdfbbf40e18b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-01 23:09:41 -07:00

434 lines
13 KiB
TypeScript

import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryMetadata,
TemplateLibraryClosedMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} 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 = window.__CONFIG__?.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 in runtime config')
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)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
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)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackApiCreditTopupSucceeded(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
// Credit top-up tracking methods (composition with utility functions)
startTopupTracking(): void {
startTopupUtil()
}
checkForCompletedTopup(events: any[] | undefined | null): boolean {
return checkTopupUtil(events)
}
clearTopupTracking(): void {
clearTopupUtil()
}
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',
custom_node_count: executionContext.custom_node_count,
total_node_count: executionContext.total_node_count,
subgraph_count: executionContext.subgraph_count,
has_api_nodes: executionContext.has_api_nodes,
api_node_names: executionContext.api_node_names
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
}
trackRunTriggeredViaKeybinding(): void {
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_KEYBINDING)
}
trackRunTriggeredViaMenu(): void {
this.trackEvent(TelemetryEvents.RUN_TRIGGERED_MENU)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const eventName =
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
// Apply normalization to survey responses
const normalizedResponses = responses
? normalizeSurveyResponses(responses)
: undefined
this.trackEvent(eventName, normalizedResponses)
// If this is a survey submission, also set user properties with normalized data
if (stage === 'submitted' && normalizedResponses && this.mixpanel) {
try {
this.mixpanel.people.set(normalizedResponses)
} catch (error) {
console.error('Failed to set survey user properties:', error)
}
}
}
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)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
}