Files
ComfyUI_frontend/src/stores/commandStore.ts
Christian Byrne 6fe88dba54 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>
2025-11-02 19:48:21 -08:00

148 lines
4.2 KiB
TypeScript

import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ComfyExtension } from '@/types/comfy'
import { useKeybindingStore } from './keybindingStore'
import type { KeybindingImpl } from './keybindingStore'
export interface ComfyCommand {
id: string
function: (metadata?: Record<string, unknown>) => void | Promise<void>
label?: string | (() => string)
icon?: string | (() => string)
tooltip?: string | (() => string)
menubarLabel?: string | (() => string) // Menubar item label, if different from command label
versionAdded?: string
confirmation?: string // If non-nullish, this command will prompt for confirmation
source?: string
active?: () => boolean // Getter to check if the command is active/toggled on
category?: 'essentials' | 'view-controls' // For shortcuts panel organization
}
export class ComfyCommandImpl implements ComfyCommand {
id: string
function: (metadata?: Record<string, unknown>) => void | Promise<void>
_label?: string | (() => string)
_icon?: string | (() => string)
_tooltip?: string | (() => string)
_menubarLabel?: string | (() => string)
versionAdded?: string
confirmation?: string
source?: string
active?: () => boolean
category?: 'essentials' | 'view-controls'
constructor(command: ComfyCommand) {
this.id = command.id
this.function = command.function
this._label = command.label
this._icon = command.icon
this._tooltip = command.tooltip
this._menubarLabel = command.menubarLabel ?? command.label
this.versionAdded = command.versionAdded
this.confirmation = command.confirmation
this.source = command.source
this.active = command.active
this.category = command.category
}
get label() {
return typeof this._label === 'function' ? this._label() : this._label
}
get icon() {
return typeof this._icon === 'function' ? this._icon() : this._icon
}
get tooltip() {
return typeof this._tooltip === 'function' ? this._tooltip() : this._tooltip
}
get menubarLabel() {
return typeof this._menubarLabel === 'function'
? this._menubarLabel()
: this._menubarLabel
}
get keybinding(): KeybindingImpl | null {
return useKeybindingStore().getKeybindingByCommandId(this.id)
}
}
export const useCommandStore = defineStore('command', () => {
const commandsById = ref<Record<string, ComfyCommandImpl>>({})
const commands = computed(() => Object.values(commandsById.value))
const registerCommand = (command: ComfyCommand) => {
if (commandsById.value[command.id]) {
console.warn(`Command ${command.id} already registered`)
}
commandsById.value[command.id] = new ComfyCommandImpl(command)
}
const registerCommands = (commands: ComfyCommand[]) => {
for (const command of commands) {
registerCommand(command)
}
}
const getCommand = (command: string) => {
return commandsById.value[command]
}
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const execute = async (
commandId: string,
options?: {
errorHandler?: (error: unknown) => void
metadata?: Record<string, unknown>
}
) => {
const command = getCommand(commandId)
if (command) {
await wrapWithErrorHandlingAsync(
() => command.function(options?.metadata),
options?.errorHandler
)()
} else {
throw new Error(`Command ${commandId} not found`)
}
}
const isRegistered = (command: string) => {
return !!commandsById.value[command]
}
const loadExtensionCommands = (extension: ComfyExtension) => {
if (extension.commands) {
for (const command of extension.commands) {
registerCommand({
...command,
source: extension.name
})
}
}
}
const formatKeySequence = (command: ComfyCommandImpl): string => {
const sequences = command.keybinding?.combo.getKeySequences() || []
return sequences
.map((seq) => seq.replace(/Control/g, 'Ctrl').replace(/Shift/g, 'Shift'))
.join(' + ')
}
return {
commands,
execute,
getCommand,
registerCommand,
registerCommands,
isRegistered,
loadExtensionCommands,
formatKeySequence
}
})