mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 07:00:06 +00:00
[feat] Add comprehensive telemetry instrumentation for user behavior tracking
Implements unified telemetry service with fail-safe error handling: - Template browsing and usage tracking - Workflow creation method comparison - Node addition source tracking - UI interaction events (menus, sidebar, focus mode) - Queue management operations - Settings preference changes - Advanced gesture/shortcut tracking Features environment-variable sampling control and dual Electron/cloud support. All telemetry functions are completely fail-safe and will never break normal app functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,8 @@ const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl) {
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
pos: getNewNodeLocation(),
|
||||
telemetrySource: 'search-popover'
|
||||
})
|
||||
|
||||
if (disconnectOnReset && triggerEvent) {
|
||||
|
||||
@@ -265,7 +265,9 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
handleClick(e: MouseEvent) {
|
||||
if (this.leaf) {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useLitegraphService().addNodeOnGraph(this.data)
|
||||
useLitegraphService().addNodeOnGraph(this.data, {
|
||||
telemetrySource: 'sidebar-click'
|
||||
})
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLe
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
@@ -234,6 +235,13 @@ const renderTreeNode = (
|
||||
e: MouseEvent
|
||||
) {
|
||||
if (this.leaf) {
|
||||
// Track workflow opening from sidebar
|
||||
trackTypedEvent(TelemetryEvents.WORKFLOW_OPENED_FROM_SIDEBAR, {
|
||||
workflow_path: workflow.path,
|
||||
workflow_type: type,
|
||||
is_bookmarked: type === WorkflowTreeType.Bookmarks,
|
||||
is_open: type === WorkflowTreeType.Open
|
||||
})
|
||||
await workflowService.openWorkflow(workflow)
|
||||
} else {
|
||||
toggleNodeOnEvent(e, this)
|
||||
|
||||
@@ -31,6 +31,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import type { ComfyCommand } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
@@ -550,6 +551,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
trackTypedEvent(TelemetryEvents.NODE_MUTED, {
|
||||
node_count: selectedNodes.length,
|
||||
action_type: 'keyboard_shortcut'
|
||||
})
|
||||
toggleSelectedNodesMode(LGraphEventMode.NEVER)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
@@ -561,6 +567,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.3.11',
|
||||
category: 'essentials' as const,
|
||||
function: () => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
trackTypedEvent(TelemetryEvents.NODE_BYPASSED, {
|
||||
node_count: selectedNodes.length,
|
||||
action_type: 'keyboard_shortcut'
|
||||
})
|
||||
toggleSelectedNodesMode(LGraphEventMode.BYPASS)
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
@@ -896,6 +907,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const graph = canvas.subgraph ?? canvas.graph
|
||||
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
|
||||
|
||||
const selectedCount = canvas.selectedItems.size
|
||||
const res = graph.convertToSubgraph(canvas.selectedItems)
|
||||
if (!res) {
|
||||
toastStore.add({
|
||||
@@ -907,6 +919,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
return
|
||||
}
|
||||
|
||||
// Track subgraph creation
|
||||
trackTypedEvent(TelemetryEvents.SUBGRAPH_CREATED, {
|
||||
selected_item_count: selectedCount,
|
||||
action_type: 'keyboard_shortcut'
|
||||
})
|
||||
|
||||
const { node } = res
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SettingParams } from '@/platform/settings/types'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import type { TreeNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
export const getSettingInfo = (setting: SettingParams) => {
|
||||
@@ -73,6 +74,22 @@ export const useSettingStore = defineStore('setting', () => {
|
||||
onChange(settingsById.value[key], newValue, oldValue)
|
||||
settingValues.value[key] = newValue
|
||||
await api.storeSetting(key, newValue)
|
||||
|
||||
// Track setting changes (completely fail-safe)
|
||||
try {
|
||||
const setting = settingsById.value[key]
|
||||
trackTypedEvent(TelemetryEvents.SETTINGS_PREFERENCE_CHANGED, {
|
||||
setting_id: key as string,
|
||||
setting_category: setting?.category?.[0] || 'unknown',
|
||||
setting_type: String(setting?.type || 'unknown'),
|
||||
has_custom_value: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Absolutely silent failure - telemetry must never break settings
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] Settings tracking failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
@@ -139,6 +140,13 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const loadDefaultWorkflow = async () => {
|
||||
await app.loadGraphData(defaultGraph)
|
||||
|
||||
// Track workflow creation from default template
|
||||
trackTypedEvent(TelemetryEvents.WORKFLOW_CREATED_FROM_TEMPLATE, {
|
||||
template_name: 'default',
|
||||
creation_method: 'default_workflow',
|
||||
node_count: defaultGraph?.nodes?.length || 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +154,12 @@ export const useWorkflowService = () => {
|
||||
*/
|
||||
const loadBlankWorkflow = async () => {
|
||||
await app.loadGraphData(blankGraph)
|
||||
|
||||
// Track workflow creation from scratch
|
||||
trackTypedEvent(TelemetryEvents.WORKFLOW_CREATED_FROM_SCRATCH, {
|
||||
creation_method: 'blank_workflow',
|
||||
node_count: 0
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
@@ -51,6 +52,16 @@ export function useTemplateWorkflows() {
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
|
||||
// Track template category browsing
|
||||
if (category) {
|
||||
trackTypedEvent(TelemetryEvents.TEMPLATE_BROWSED, {
|
||||
category_name: category.title,
|
||||
category_module: category.moduleName,
|
||||
template_count: category.templates.length
|
||||
})
|
||||
}
|
||||
|
||||
return category !== null
|
||||
}
|
||||
|
||||
@@ -131,6 +142,15 @@ export function useTemplateWorkflows() {
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
// Track template usage
|
||||
trackTypedEvent(TelemetryEvents.TEMPLATE_USED, {
|
||||
template_id: id,
|
||||
template_name: workflowName,
|
||||
source_module: actualSourceModule,
|
||||
category: 'All',
|
||||
creation_method: 'template'
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -145,6 +165,15 @@ export function useTemplateWorkflows() {
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
// Track template usage for regular categories
|
||||
trackTypedEvent(TelemetryEvents.TEMPLATE_USED, {
|
||||
template_id: id,
|
||||
template_name: workflowName,
|
||||
source_module: sourceModule,
|
||||
category: selectedTemplate.value?.title || 'unknown',
|
||||
creation_method: 'template'
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
@@ -545,7 +546,7 @@ export class ComfyApp {
|
||||
event.dataTransfer.files.length &&
|
||||
event.dataTransfer.files[0].type !== 'image/bmp'
|
||||
) {
|
||||
await this.handleFile(event.dataTransfer.files[0])
|
||||
await this.handleFile(event.dataTransfer.files[0], 'drag_drop')
|
||||
} else {
|
||||
// Try loading the first URI in the transfer list
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
@@ -556,7 +557,10 @@ export class ComfyApp {
|
||||
const uri = event.dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (uri) {
|
||||
const blob = await (await fetch(uri)).blob()
|
||||
await this.handleFile(new File([blob], uri, { type: blob.type }))
|
||||
await this.handleFile(
|
||||
new File([blob], uri, { type: blob.type }),
|
||||
'drag_drop'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1450,7 +1454,10 @@ export class ComfyApp {
|
||||
* Loads workflow data from the specified file
|
||||
* @param {File} file
|
||||
*/
|
||||
async handleFile(file: File) {
|
||||
async handleFile(
|
||||
file: File,
|
||||
source: 'drag_drop' | 'file_dialog' = 'file_dialog'
|
||||
) {
|
||||
const removeExt = (f: string) => {
|
||||
if (!f) return f
|
||||
const p = f.lastIndexOf('.')
|
||||
@@ -1458,9 +1465,51 @@ export class ComfyApp {
|
||||
return f.substring(0, p)
|
||||
}
|
||||
const fileName = removeExt(file.name)
|
||||
|
||||
// Track workflow opening based on file type and source
|
||||
// Completely fail-safe - will never throw errors or break app flow
|
||||
const trackWorkflowOpening = (fileType: string, hasWorkflow: boolean) => {
|
||||
try {
|
||||
if (!hasWorkflow) return
|
||||
|
||||
if (fileType.startsWith('image/')) {
|
||||
trackTypedEvent(
|
||||
TelemetryEvents.WORKFLOW_OPENED_FROM_DRAG_DROP_IMAGE,
|
||||
{
|
||||
file_type: fileType,
|
||||
source_method: source,
|
||||
file_name: fileName
|
||||
}
|
||||
)
|
||||
} else if (
|
||||
fileType === 'application/json' ||
|
||||
fileName.endsWith('.json')
|
||||
) {
|
||||
trackTypedEvent(TelemetryEvents.WORKFLOW_OPENED_FROM_DRAG_DROP_JSON, {
|
||||
file_type: fileType,
|
||||
source_method: source,
|
||||
file_name: fileName
|
||||
})
|
||||
} else {
|
||||
// Other file types (audio, video, etc.)
|
||||
trackTypedEvent(TelemetryEvents.WORKFLOW_OPENED_FROM_FILE_DIALOG, {
|
||||
file_type: fileType,
|
||||
source_method: source,
|
||||
file_name: fileName
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Absolutely silent failure - telemetry must never break file loading
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] trackWorkflowOpening failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file.type === 'image/png') {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
if (pngInfo?.workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
await this.loadGraphData(
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
@@ -1468,10 +1517,12 @@ export class ComfyApp {
|
||||
fileName
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
} else if (pngInfo?.parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
trackWorkflowOpening(file.type, true)
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
useWorkflowService().afterLoadNewGraph(
|
||||
@@ -1485,8 +1536,10 @@ export class ComfyApp {
|
||||
const { workflow, prompt } = await getAvifMetadata(file)
|
||||
|
||||
if (workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1498,8 +1551,10 @@ export class ComfyApp {
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1507,8 +1562,10 @@ export class ComfyApp {
|
||||
} else if (file.type === 'audio/mpeg') {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
if (workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1516,8 +1573,10 @@ export class ComfyApp {
|
||||
} else if (file.type === 'audio/ogg') {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
if (workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1528,8 +1587,10 @@ export class ComfyApp {
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1537,8 +1598,10 @@ export class ComfyApp {
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
} else if (webmInfo.prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1553,15 +1616,19 @@ export class ComfyApp {
|
||||
) {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
if (mp4Info.workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName)
|
||||
} else if (mp4Info.prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(mp4Info.prompt, fileName)
|
||||
}
|
||||
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
if (svgInfo.workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName)
|
||||
} else if (svgInfo.prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(svgInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1572,8 +1639,10 @@ export class ComfyApp {
|
||||
) {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
if (gltfInfo.workflow) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
|
||||
} else if (gltfInfo.prompt) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(gltfInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
@@ -1587,10 +1656,13 @@ export class ComfyApp {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
// Template data, not a workflow
|
||||
this.loadTemplateData(jsonContent)
|
||||
} else if (this.isApiJson(jsonContent)) {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
this.loadApiJson(jsonContent, fileName)
|
||||
} else {
|
||||
trackWorkflowOpening(file.type, true)
|
||||
await this.loadGraphData(
|
||||
JSON.parse(readerResult),
|
||||
true,
|
||||
@@ -1608,6 +1680,7 @@ export class ComfyApp {
|
||||
// TODO define schema to LatentMetadata
|
||||
// @ts-expect-error
|
||||
if (info.workflow) {
|
||||
trackWorkflowOpening(file.type || 'application/octet-stream', true)
|
||||
await this.loadGraphData(
|
||||
// @ts-expect-error
|
||||
JSON.parse(info.workflow),
|
||||
@@ -1617,6 +1690,7 @@ export class ComfyApp {
|
||||
)
|
||||
// @ts-expect-error
|
||||
} else if (info.prompt) {
|
||||
trackWorkflowOpening(file.type || 'application/octet-stream', true)
|
||||
// @ts-expect-error
|
||||
this.loadApiJson(JSON.parse(info.prompt))
|
||||
} else {
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
|
||||
import { $el } from '@/scripts/ui'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
@@ -959,6 +960,7 @@ export const useLitegraphService = () => {
|
||||
nodeDef: ComfyNodeDefV1 | ComfyNodeDefV2,
|
||||
options: Record<string, any> = {}
|
||||
): LGraphNode {
|
||||
const source = options.telemetrySource || 'unknown'
|
||||
options.pos ??= getCanvasCenter()
|
||||
|
||||
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
|
||||
@@ -988,6 +990,23 @@ export const useLitegraphService = () => {
|
||||
|
||||
const graph = useWorkflowStore().activeSubgraph ?? app.graph
|
||||
|
||||
// Track node addition with source information
|
||||
const eventMap: Record<string, string> = {
|
||||
'sidebar-click': TelemetryEvents.NODE_ADDED_FROM_SIDEBAR,
|
||||
'sidebar-drag': TelemetryEvents.NODE_ADDED_FROM_SIDEBAR,
|
||||
'search-popover': TelemetryEvents.NODE_ADDED_FROM_SEARCH_POPOVER,
|
||||
'context-menu': TelemetryEvents.NODE_ADDED_FROM_CONTEXT_MENU,
|
||||
'drag-drop': TelemetryEvents.NODE_ADDED_FROM_DRAG_DROP
|
||||
}
|
||||
|
||||
const eventName = eventMap[source] || TelemetryEvents.WORKFLOW_NODE_ADDED
|
||||
trackTypedEvent(eventName as any, {
|
||||
node_type: nodeDef.name,
|
||||
node_display_name: nodeDef.display_name || nodeDef.name,
|
||||
source: source,
|
||||
is_subgraph: !!useWorkflowStore().activeSubgraph
|
||||
})
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
graph.add(node)
|
||||
// @ts-expect-error fixme ts strict error
|
||||
|
||||
360
src/services/telemetryService.ts
Normal file
360
src/services/telemetryService.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { electronAPI, isElectron } from '@/utils/envUtil'
|
||||
|
||||
export interface TelemetryEventProperties {
|
||||
[key: string]: string | number | boolean | undefined
|
||||
}
|
||||
|
||||
export interface TelemetryService {
|
||||
trackEvent(eventName: string, properties?: TelemetryEventProperties): void
|
||||
incrementUserProperty(propertyName: string, value: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified telemetry service that works in both electron and cloud environments
|
||||
*
|
||||
* - Electron: Uses existing electronAPI.Events for Mixpanel tracking
|
||||
* - Cloud: Placeholder for future Mixpanel web SDK integration
|
||||
*/
|
||||
class UnifiedTelemetryService implements TelemetryService {
|
||||
private isElectronEnv: boolean
|
||||
private isEnabled: boolean
|
||||
private samplingRate: number
|
||||
|
||||
constructor() {
|
||||
this.isElectronEnv = isElectron()
|
||||
this.isEnabled = true // TODO: Add user consent check
|
||||
|
||||
// Simple sampling rate control - can be adjusted via environment variable
|
||||
// Mixpanel will handle the actual user sampling and identity management
|
||||
this.samplingRate = this.getSamplingRate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sampling rate from environment or default to 100%
|
||||
* Set VITE_TELEMETRY_SAMPLING_RATE=0.1 for 10% sampling, etc.
|
||||
*/
|
||||
private getSamplingRate(): number {
|
||||
const envRate = import.meta.env.VITE_TELEMETRY_SAMPLING_RATE
|
||||
if (envRate) {
|
||||
const rate = parseFloat(envRate)
|
||||
return Math.max(0, Math.min(1, rate)) // Clamp between 0-1
|
||||
}
|
||||
return 1.0 // Default: 100% sampling
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if telemetry should be sent based on sampling rate
|
||||
*/
|
||||
private shouldSample(): boolean {
|
||||
if (this.samplingRate >= 1.0) return true
|
||||
if (this.samplingRate <= 0) return false
|
||||
return Math.random() < this.samplingRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a user event with optional properties
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
trackEvent(
|
||||
eventName: string,
|
||||
properties: TelemetryEventProperties = {}
|
||||
): void {
|
||||
// Fail silently if disabled, invalid input, or not in sample
|
||||
if (
|
||||
!this.isEnabled ||
|
||||
!eventName ||
|
||||
typeof eventName !== 'string' ||
|
||||
!this.shouldSample()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Additional input validation
|
||||
const safeProperties =
|
||||
properties && typeof properties === 'object' ? properties : {}
|
||||
|
||||
if (this.isElectronEnv) {
|
||||
// Use existing electron telemetry infrastructure
|
||||
electronAPI().Events.trackEvent(eventName, safeProperties)
|
||||
} else {
|
||||
// Cloud environment - placeholder for Mixpanel web SDK
|
||||
this.trackCloudEvent(eventName, safeProperties)
|
||||
}
|
||||
} catch (error) {
|
||||
// Absolutely silent failure in production - telemetry must never break the app
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] Service tracking failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment a user property (typically for counting behaviors)
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
incrementUserProperty(propertyName: string, value: number): void {
|
||||
// Fail silently if disabled or invalid input
|
||||
if (!this.isEnabled || !propertyName || typeof propertyName !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate and sanitize numeric value
|
||||
const safeValue = typeof value === 'number' && isFinite(value) ? value : 1
|
||||
|
||||
if (this.isElectronEnv) {
|
||||
electronAPI().Events.incrementUserProperty(propertyName, safeValue)
|
||||
} else {
|
||||
// Cloud environment - placeholder for user property updates
|
||||
this.incrementCloudUserProperty(propertyName, safeValue)
|
||||
}
|
||||
} catch (error) {
|
||||
// Absolutely silent failure in production - telemetry must never break the app
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[Telemetry] Service user property increment failed:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable telemetry tracking
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
try {
|
||||
this.isEnabled = Boolean(enabled)
|
||||
} catch (error) {
|
||||
// Absolutely silent failure in production
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] Failed to set enabled state:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if telemetry is currently enabled
|
||||
*/
|
||||
getEnabled(): boolean {
|
||||
return this.isEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current sampling rate (for monitoring/debugging)
|
||||
*/
|
||||
getSamplingInfo(): { rate: number; enabled: boolean } {
|
||||
return {
|
||||
rate: this.samplingRate,
|
||||
enabled: this.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud-specific event tracking (placeholder for future implementation)
|
||||
*/
|
||||
private trackCloudEvent(
|
||||
eventName: string,
|
||||
properties: TelemetryEventProperties
|
||||
): void {
|
||||
// TODO: Implement Mixpanel web SDK integration
|
||||
// For now, log to console in development
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Telemetry]', eventName, properties)
|
||||
}
|
||||
|
||||
// Future implementation:
|
||||
// mixpanel.track(eventName, {
|
||||
// ...properties,
|
||||
// platform: 'cloud',
|
||||
// timestamp: new Date().toISOString()
|
||||
// })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud-specific user property increment (placeholder)
|
||||
*/
|
||||
private incrementCloudUserProperty(
|
||||
propertyName: string,
|
||||
value: number
|
||||
): void {
|
||||
// TODO: Implement Mixpanel people.increment
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[Telemetry] Increment:', propertyName, value)
|
||||
}
|
||||
|
||||
// Future implementation:
|
||||
// mixpanel.people.increment(propertyName, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const telemetryService = new UnifiedTelemetryService()
|
||||
|
||||
/**
|
||||
* Convenience function for tracking events throughout the app
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
export function trackEvent(
|
||||
eventName: string,
|
||||
properties?: TelemetryEventProperties
|
||||
): void {
|
||||
try {
|
||||
// Validate inputs to prevent serialization errors
|
||||
if (!eventName || typeof eventName !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
// Safely serialize properties to avoid circular references or other issues
|
||||
const safeProperties: TelemetryEventProperties = {}
|
||||
if (properties && typeof properties === 'object') {
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
try {
|
||||
// Only include serializable primitive values
|
||||
if (value !== null && value !== undefined) {
|
||||
const valueType = typeof value
|
||||
if (
|
||||
valueType === 'string' ||
|
||||
valueType === 'number' ||
|
||||
valueType === 'boolean'
|
||||
) {
|
||||
safeProperties[key] = value
|
||||
} else {
|
||||
// Convert complex objects to strings safely
|
||||
safeProperties[key] = String(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip properties that can't be serialized
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
telemetryService.trackEvent(eventName, safeProperties)
|
||||
} catch (error) {
|
||||
// Absolutely silent failure - telemetry must never break the app
|
||||
// Only log in development to avoid production console noise
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] Silent failure in trackEvent:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function for incrementing user properties
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
export function incrementUserProperty(
|
||||
propertyName: string,
|
||||
value: number = 1
|
||||
): void {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!propertyName || typeof propertyName !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof value !== 'number' || !isFinite(value)) {
|
||||
value = 1
|
||||
}
|
||||
|
||||
telemetryService.incrementUserProperty(propertyName, value)
|
||||
} catch (error) {
|
||||
// Absolutely silent failure - telemetry must never break the app
|
||||
// Only log in development to avoid production console noise
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
'[Telemetry] Silent failure in incrementUserProperty:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event name constants for type safety and consistency
|
||||
export const TelemetryEvents = {
|
||||
// Template events
|
||||
TEMPLATE_BROWSED: 'template:browsed',
|
||||
TEMPLATE_USED: 'template:used',
|
||||
|
||||
// Workflow events
|
||||
WORKFLOW_CREATED_FROM_TEMPLATE: 'workflow:created_from_template',
|
||||
WORKFLOW_CREATED_FROM_SCRATCH: 'workflow:created_from_scratch',
|
||||
WORKFLOW_NODE_ADDED: 'workflow:node_added',
|
||||
WORKFLOW_SUBMITTED_FROM_UI: 'workflow:submitted_from_ui',
|
||||
WORKFLOW_CANCELLED_BY_USER: 'workflow:cancelled_by_user',
|
||||
|
||||
// UI interaction events
|
||||
WORKSPACE_FOCUS_MODE_ENABLED: 'workspace:focus_mode_enabled',
|
||||
MENU_ITEM_CLICKED: 'menu:item_clicked',
|
||||
NODE_TEMPLATE_USED: 'node_template:used',
|
||||
SETTINGS_PREFERENCE_CHANGED: 'settings:preference_changed',
|
||||
|
||||
// Sidebar and panel events
|
||||
SIDEBAR_PANEL_OPENED: 'sidebar:panel_opened',
|
||||
MENU_SUBMENU_ITEM_CLICKED: 'menu:submenu_item_clicked',
|
||||
TOOLBOX_ITEM_USED: 'toolbox:item_used',
|
||||
|
||||
// Queue management events
|
||||
QUEUE_ITEM_DELETED: 'queue:item_deleted',
|
||||
QUEUE_CLEARED: 'queue:cleared',
|
||||
QUEUE_EXECUTION_CANCELLED: 'queue:execution_cancelled',
|
||||
QUEUE_EXECUTION_STOPPED: 'queue:execution_stopped',
|
||||
|
||||
// Error tracking
|
||||
ERROR_MESSAGE_DISPLAYED: 'error:message_displayed',
|
||||
|
||||
// Node creation method comparison
|
||||
NODE_ADDED_FROM_SIDEBAR: 'node:added_from_sidebar',
|
||||
NODE_ADDED_FROM_SEARCH_POPOVER: 'node:added_from_search_popover',
|
||||
NODE_ADDED_FROM_CONTEXT_MENU: 'node:added_from_context_menu',
|
||||
NODE_ADDED_FROM_DRAG_DROP: 'node:added_from_drag_drop',
|
||||
|
||||
// Workflow opening method comparison
|
||||
WORKFLOW_OPENED_FROM_DRAG_DROP_IMAGE: 'workflow:opened_from_drag_drop_image',
|
||||
WORKFLOW_OPENED_FROM_DRAG_DROP_JSON: 'workflow:opened_from_drag_drop_json',
|
||||
WORKFLOW_OPENED_FROM_SIDEBAR: 'workflow:opened_from_sidebar',
|
||||
WORKFLOW_OPENED_FROM_TEMPLATE: 'workflow:opened_from_template',
|
||||
WORKFLOW_OPENED_FROM_FILE_DIALOG: 'workflow:opened_from_file_dialog',
|
||||
|
||||
// Advanced feature discovery
|
||||
NODE_COPIED_WITH_ALT_DRAG: 'node:copied_with_alt_drag',
|
||||
SLOT_CONNECTION_SHIFT_DRAG: 'slot:connection_shift_drag',
|
||||
SLOT_CONNECTION_REMOVED_CTRL_SHIFT: 'slot:connection_removed_ctrl_shift',
|
||||
NODE_MUTED: 'node:muted',
|
||||
NODE_BYPASSED: 'node:bypassed',
|
||||
SUBGRAPH_CREATED: 'subgraph:created',
|
||||
|
||||
// Canvas interactions
|
||||
CANVAS_PAN_GESTURE: 'canvas:pan_gesture',
|
||||
CANVAS_DOUBLE_CLICK: 'canvas:double_click'
|
||||
} as const
|
||||
|
||||
export type TelemetryEventName =
|
||||
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
|
||||
|
||||
/**
|
||||
* Type-safe event tracking with predefined event names
|
||||
* Completely fail-safe - will never throw errors or break app flow
|
||||
*/
|
||||
export function trackTypedEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
): void {
|
||||
try {
|
||||
// Extra safety layer even though trackEvent is already safe
|
||||
if (!eventName) {
|
||||
return
|
||||
}
|
||||
trackEvent(eventName, properties)
|
||||
} catch (error) {
|
||||
// Absolutely silent failure - telemetry must never break the app
|
||||
// Only log in development to avoid production console noise
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Telemetry] Silent failure in trackTypedEvent:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
|
||||
@@ -99,6 +100,15 @@ export const useCommandStore = defineStore('command', () => {
|
||||
) => {
|
||||
const command = getCommand(commandId)
|
||||
if (command) {
|
||||
// Track menu/command usage
|
||||
trackTypedEvent(TelemetryEvents.MENU_ITEM_CLICKED, {
|
||||
command_id: commandId,
|
||||
command_label: command.label || commandId,
|
||||
source: command.source || 'core',
|
||||
category: command.category || 'uncategorized',
|
||||
access_method: 'command_execute'
|
||||
})
|
||||
|
||||
await wrapWithErrorHandlingAsync(command.function, errorHandler)()
|
||||
} else {
|
||||
throw new Error(`Command ${commandId} not found`)
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
// Task type used in the API.
|
||||
@@ -521,11 +522,31 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track queue clearing
|
||||
trackTypedEvent(TelemetryEvents.QUEUE_CLEARED, {
|
||||
targets_count: targets.length,
|
||||
queue_length: pendingTasks.value.length + runningTasks.value.length,
|
||||
history_length: historyTasks.value.length,
|
||||
clear_type:
|
||||
targets.includes('queue') && targets.includes('history')
|
||||
? 'all'
|
||||
: targets[0]
|
||||
})
|
||||
|
||||
await Promise.all(targets.map((type) => api.clearItems(type)))
|
||||
await update()
|
||||
}
|
||||
|
||||
const deleteTask = async (task: TaskItemImpl) => {
|
||||
// Track individual task deletion
|
||||
trackTypedEvent(TelemetryEvents.QUEUE_ITEM_DELETED, {
|
||||
task_type: task.apiTaskType,
|
||||
task_status: task.status?.status_str || 'unknown',
|
||||
queue_position: task.queueIndex,
|
||||
is_running: runningTasks.value.some((t) => t.promptId === task.promptId)
|
||||
})
|
||||
|
||||
await api.deleteItem(task.apiTaskType, task.promptId)
|
||||
await update()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibra
|
||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||
import { t, te } from '@/i18n'
|
||||
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
@@ -22,7 +23,19 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
})
|
||||
|
||||
const toggleSidebarTab = (tabId: string) => {
|
||||
activeSidebarTabId.value = activeSidebarTabId.value === tabId ? null : tabId
|
||||
const wasActive = activeSidebarTabId.value === tabId
|
||||
const previousTab = activeSidebarTabId.value
|
||||
activeSidebarTabId.value = wasActive ? null : tabId
|
||||
|
||||
// Track sidebar panel interactions
|
||||
if (!wasActive && activeSidebarTabId.value) {
|
||||
// Panel was opened
|
||||
trackTypedEvent(TelemetryEvents.SIDEBAR_PANEL_OPENED, {
|
||||
panel_type: tabId,
|
||||
previous_panel: previousTab || 'none',
|
||||
action: 'opened'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const registerSidebarTab = (tab: SidebarTabExtension) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { TelemetryEvents, trackTypedEvent } from '@/services/telemetryService'
|
||||
import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
|
||||
|
||||
import { useApiKeyAuthStore } from './apiKeyAuthStore'
|
||||
@@ -89,7 +90,22 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
shiftDown,
|
||||
focusMode,
|
||||
toggleFocusMode: () => {
|
||||
const wasEnabled = focusMode.value
|
||||
focusMode.value = !focusMode.value
|
||||
|
||||
// Track focus mode toggle with context about workflow complexity
|
||||
trackTypedEvent(TelemetryEvents.WORKSPACE_FOCUS_MODE_ENABLED, {
|
||||
focus_enabled: focusMode.value,
|
||||
previous_state: wasEnabled,
|
||||
device_type:
|
||||
window.innerWidth < 768
|
||||
? 'mobile'
|
||||
: window.innerWidth < 1024
|
||||
? 'tablet'
|
||||
: 'desktop',
|
||||
screen_width: window.innerWidth,
|
||||
screen_height: window.innerHeight
|
||||
})
|
||||
},
|
||||
toast,
|
||||
queueSettings,
|
||||
|
||||
Reference in New Issue
Block a user