fix(telemetry): remove redundant run tracking; keep click analytics + single execution event (#6518)

## Summary
Deduplicates workflow run telemetry and keeps a single source of truth
for execution while retaining click analytics and attributing initiator
source.

- Keep execution tracking in one place via `trackWorkflowExecution()`
- Keep click analytics via `trackRunButton(...)`
- Attribute initiator with `trigger_source` = 'button' | 'keybinding' |
'legacy_ui'
- Remove pre‑tracking from keybindings to avoid double/triple counting
- Update legacy UI buttons to emit both click + execution events (they
bypass commands)

## Problem
PR #6499 added tracking at multiple layers:
1) Keybindings tracked via a dedicated method and then executed a
command
2) Menu items tracked via a dedicated method and then executed a command
3) Commands also tracked execution

Because these ultimately trigger the same command path, this produced
duplicate (sometimes triplicate) events per user action and made it hard
to attribute initiator precisely.

## Solution
- Remove redundant tracking from keybindings (and previous legacy menu
handler)
- Commands now emit both:
- `trackRunButton(...)` (click analytics, includes `trigger_source` when
provided)
- `trackWorkflowExecution()` (single execution start; includes the last
`trigger_source`)
- Legacy UI buttons (which call `app.queuePrompt(...)` directly) now
also emit both events with `trigger_source = 'legacy_ui'`
- Add `ExecutionTriggerSource` type and wire `trigger_source` through
provider so `EXECUTION_START` matches the most recent click intent

### Telemetry behavior after this change
- `RUN_BUTTON_CLICKED` (click analytics)
  - Emitted when a run is initiated via:
    - Button: `trigger_source = 'button'`
    - Keybinding: `trigger_source = 'keybinding'`
    - Legacy UI: `trigger_source = 'legacy_ui'`
- `EXECUTION_START` (execution lifecycle)
- Emitted once per run at start; includes `trigger_source` matched to
the click intent above
- Paired with `EXECUTION_SUCCESS` / `EXECUTION_ERROR` from execution
handlers

## Benefits
-  Accurate counts by removing duplicated run events
-  Clear initiator attribution (button vs keybinding vs legacy UI)
-  Separation of “intent” (click) vs. “lifecycle” (execution)
-  Simpler implementation and maintenance

## Files Changed (high level)
- `src/services/keybindingService.ts`: Route run commands with
`trigger_source = 'keybinding'`
- `src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue`: Send
`trigger_source = 'button'` to commands
- `src/scripts/ui.ts`: Legacy queue buttons emit `trackRunButton({
trigger_source: 'legacy_ui' })` and `trackWorkflowExecution()`
- `src/composables/useCoreCommands.ts`: Commands emit
`trackRunButton(...)` + `trackWorkflowExecution()`; accept telemetry
metadata
- `src/platform/telemetry/types.ts`: Add `ExecutionTriggerSource` and
optional `trigger_source` in click + execution payloads
- `src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts`:
Carry `trigger_source` from click → execution and reset after use
- `src/stores/commandStore.ts`: Allow commands to receive args (for
telemetry metadata)
- `src/extensions/core/groupNode.ts`: Adjust command function signatures
to new execute signature

## Related
- Reverts the multi‑event approach from #6499
- Keeps `trackWorkflowExecution()` as the canonical execution event
while preserving click analytics and initiator attribution with
`trackRunButton(...)`

┆Issue is synchronized with this Notion page by Unito

---------

Co-authored-by: Christian Byrne <c.byrne@comfy.org>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-11-02 19:48:21 -08:00
committed by GitHub
parent 8df0a3885d
commit 6fe88dba54
10 changed files with 90 additions and 55 deletions

View File

@@ -164,15 +164,18 @@ const queuePrompt = async (e: Event) => {
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
useTelemetry()?.trackRunButton({ subscribe_to_run: false })
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
})
}
await commandStore.execute(commandId)
await commandStore.execute(commandId, {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
}
</script>

View File

@@ -22,6 +22,7 @@ import { useSubscription } from '@/platform/cloud/subscription/composables/useSu
import { useSettingStore } from '@/platform/settings/settingStore'
import { SUPPORT_URL } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -466,7 +467,11 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Queue Prompt',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
function: async (metadata?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -485,7 +490,11 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Queue Prompt (Front)',
versionAdded: '1.3.7',
category: 'essentials' as const,
function: async () => {
function: async (metadata?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -503,7 +512,11 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-play',
label: 'Queue Selected Output Nodes',
versionAdded: '1.19.6',
function: async () => {
function: async (metadata?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}) => {
useTelemetry()?.trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog()
return
@@ -526,6 +539,7 @@ export function useCoreCommands(): ComfyCommand[] {
// Get execution IDs for all selected output nodes and their descendants
const executionIds =
getExecutionIdsForSelectedNodes(selectedOutputNodes)
if (executionIds.length === 0) {
toastStore.add({
severity: 'error',
@@ -535,6 +549,7 @@ export function useCoreCommands(): ComfyCommand[] {
})
return
}
useTelemetry()?.trackWorkflowExecution()
await app.queuePrompt(0, batchCount, executionIds)
}
},

View File

@@ -1694,21 +1694,22 @@ const ext: ComfyExtension = {
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: convertSelectedNodesToGroupNode
function: () => convertSelectedNodesToGroupNode()
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: ungroupSelectedGroupNodes
function: () => ungroupSelectedGroupNodes()
},
{
id: 'Comfy.GroupNode.ManageGroupNodes',
label: 'Manage group nodes',
icon: 'pi pi-cog',
versionAdded: '1.3.17',
function: manageGroupNodes
function: (...args: unknown[]) =>
manageGroupNodes(args[0] as string | undefined)
}
],
keybindings: [

View File

@@ -17,6 +17,7 @@ import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionTriggerSource,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
@@ -65,6 +66,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
constructor() {
const token = window.__CONFIG__?.mixpanel_token
@@ -196,7 +198,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
clearTopupUtil()
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = this.getExecutionContext()
const runButtonProperties: RunButtonProperties = {
@@ -207,20 +212,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
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
api_node_names: executionContext.api_node_names,
trigger_source: options?.trigger_source
}
this.lastTriggerSource = options?.trigger_source
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
@@ -323,7 +322,12 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
const eventContext: ExecutionContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
this.lastTriggerSource = undefined
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {

View File

@@ -47,6 +47,7 @@ export interface RunButtonProperties {
subgraph_count: number
has_api_nodes: boolean
api_node_names: string[]
trigger_source?: ExecutionTriggerSource
}
/**
@@ -69,6 +70,7 @@ export interface ExecutionContext {
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
trigger_source?: ExecutionTriggerSource
}
/**
@@ -265,9 +267,10 @@ export interface TelemetryProvider {
trackAddApiCreditButtonClicked(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackApiCreditTopupSucceeded(): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
trackRunTriggeredViaKeybinding(): void
trackRunTriggeredViaMenu(): void
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void
// Credit top-up tracking (composition with internal utilities)
startTopupTracking(): void
@@ -336,8 +339,6 @@ export const TelemetryEvents = {
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
RUN_TRIGGERED_KEYBINDING: 'app:run_triggered_keybinding',
RUN_TRIGGERED_MENU: 'app:run_triggered_menu',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
@@ -399,6 +400,12 @@ export const TelemetryEvents = {
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
export type ExecutionTriggerSource =
| 'button'
| 'keybinding'
| 'legacy_ui'
| 'unknown'
/**
* Union type for all possible telemetry event properties
*/

View File

@@ -476,7 +476,8 @@ export class ComfyUI {
textContent: 'Queue Prompt',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunTriggeredViaMenu()
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
app.queuePrompt(0, this.batchCount)
}
@@ -583,7 +584,8 @@ export class ComfyUI {
textContent: 'Queue Front',
onclick: () => {
if (isCloud) {
useTelemetry()?.trackRunTriggeredViaMenu()
useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' })
useTelemetry()?.trackWorkflowExecution()
}
app.queuePrompt(-1, this.batchCount)
}

View File

@@ -1,7 +1,5 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -66,15 +64,20 @@ export const useKeybindingService = () => {
// Prevent default browser behavior first, then execute the command
event.preventDefault()
if (
isCloud &&
(keybinding.commandId === 'Comfy.QueuePrompt' ||
keybinding.commandId === 'Comfy.QueuePromptFront' ||
keybinding.commandId === 'Comfy.QueueSelectedOutputNodes')
) {
useTelemetry()?.trackRunTriggeredViaKeybinding()
const runCommandIds = new Set([
'Comfy.QueuePrompt',
'Comfy.QueuePromptFront',
'Comfy.QueueSelectedOutputNodes'
])
if (runCommandIds.has(keybinding.commandId)) {
await commandStore.execute(keybinding.commandId, {
metadata: {
trigger_source: 'keybinding'
}
})
} else {
await commandStore.execute(keybinding.commandId)
}
await commandStore.execute(keybinding.commandId)
return
}

View File

@@ -9,7 +9,7 @@ import type { KeybindingImpl } from './keybindingStore'
export interface ComfyCommand {
id: string
function: () => void | Promise<void>
function: (metadata?: Record<string, unknown>) => void | Promise<void>
label?: string | (() => string)
icon?: string | (() => string)
@@ -24,7 +24,7 @@ export interface ComfyCommand {
export class ComfyCommandImpl implements ComfyCommand {
id: string
function: () => void | Promise<void>
function: (metadata?: Record<string, unknown>) => void | Promise<void>
_label?: string | (() => string)
_icon?: string | (() => string)
_tooltip?: string | (() => string)
@@ -96,11 +96,17 @@ export const useCommandStore = defineStore('command', () => {
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const execute = async (
commandId: string,
errorHandler?: (error: any) => void
options?: {
errorHandler?: (error: unknown) => void
metadata?: Record<string, unknown>
}
) => {
const command = getCommand(commandId)
if (command) {
await wrapWithErrorHandlingAsync(command.function, errorHandler)()
await wrapWithErrorHandlingAsync(
() => command.function(options?.metadata),
options?.errorHandler
)()
} else {
throw new Error(`Command ${commandId} not found`)
}

View File

@@ -3,8 +3,6 @@ import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'
@@ -64,17 +62,7 @@ export const useMenuItemStore = defineStore('menuItem', () => {
.map(
(command) =>
({
command: () => {
if (
isCloud &&
(command.id === 'Comfy.QueuePrompt' ||
command.id === 'Comfy.QueuePromptFront' ||
command.id === 'Comfy.QueueSelectedOutputNodes')
) {
useTelemetry()?.trackRunTriggeredViaMenu()
}
return commandStore.execute(command.id)
},
command: () => commandStore.execute(command.id),
label: command.menubarLabel,
icon: command.icon,
tooltip: command.tooltip,

View File

@@ -114,5 +114,11 @@ export interface ExtensionManager {
export interface CommandManager {
commands: ComfyCommand[]
execute(command: string, errorHandler?: (error: any) => void): void
execute(
command: string,
options?: {
errorHandler?: (error: unknown) => void
metadata?: Record<string, unknown>
}
): void
}