feat(telemetry): add workflow_opened with open_source and missing node metrics (#6476)

- Adds app:workflow_opened and plumbs open_source across drag/drop,
file-open button, workspace, and templates
- Tracks missing_node_count and missing_node_types for both
workflow_opened and workflow_imported
- Reuses WorkflowOpenSource type for consistency; no breaking changes to
loadGraphData callers (5th param remains options object; openSource
optional)

Validation
- pnpm lint:fix
- pnpm typecheck

Notes
- Telemetry only runs in cloud builds; OSS remains clean.
- loadGraphData telemetry is centralized where missing_node_types is
computed.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6476-feat-telemetry-add-workflow_opened-with-open_source-and-missing-node-metrics-29d6d73d365081f385c0da29958309da)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
This commit is contained in:
Benjamin Lu
2025-10-31 14:51:16 -07:00
committed by Benjamin Lu
parent 6dbe00d47c
commit 08d1a78c13
5 changed files with 246 additions and 23 deletions

View File

@@ -5,12 +5,20 @@ import type {
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateMetadata
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
@@ -351,6 +359,41 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
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)
}
trackWorkflowExecution(): void {
if (this.isOnboardingMode) {
// During onboarding, track basic execution without workflow context

View File

@@ -89,6 +89,97 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
export interface WorkflowImportMetadata {
missing_node_count: number
missing_node_types: string[]
/**
* The source of the workflow open/import action
*/
open_source?: 'file_button' | 'file_drop' | 'template' | 'unknown'
}
/**
* Workflow open metadata
*/
/**
* Enumerated sources for workflow open/import actions.
*/
export type WorkflowOpenSource = NonNullable<
WorkflowImportMetadata['open_source']
>
/**
* Template library metadata
*/
export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
}
/**
* Template library closed metadata
*/
export interface TemplateLibraryClosedMetadata {
template_selected: boolean
time_spent_seconds: number
}
/**
* Page visibility metadata
*/
export interface PageVisibilityMetadata {
visibility_state: 'visible' | 'hidden'
}
/**
* Tab count metadata
*/
export interface TabCountMetadata {
tab_count: number
}
/**
* Node search metadata
*/
export interface NodeSearchMetadata {
query: string
}
/**
* Node search result selection metadata
*/
export interface NodeSearchResultMetadata {
node_type: string
last_query: string
}
/**
* Template filter tracking metadata
*/
export interface TemplateFilterMetadata {
search_query?: string
selected_models: string[]
selected_use_cases: string[]
selected_licenses: string[]
sort_by:
| 'default'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
| 'model-size-low-to-high'
filtered_count: number
total_count: number
}
/**
* Core telemetry provider interface
*/
@@ -106,6 +197,25 @@ export interface TelemetryProvider {
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void
// Workflow management events
trackWorkflowImported(metadata: WorkflowImportMetadata): void
trackWorkflowOpened(metadata: WorkflowImportMetadata): void
// Page visibility events
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
// Tab tracking events
trackTabCount(metadata: TabCountMetadata): void
// Node search analytics events
trackNodeSearch(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void
// Template filter tracking events
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
@@ -140,6 +250,25 @@ export const TelemetryEvents = {
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
TEMPLATE_LIBRARY_CLOSED: 'app:template_library_closed',
// Workflow Management
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
// Page Visibility
PAGE_VISIBILITY_CHANGED: 'app:page_visibility_changed',
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
// Template Filter Analytics
TEMPLATE_FILTER_CHANGED: 'app:template_filter_changed',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -157,6 +286,15 @@ export type TelemetryEventProperties =
| AuthMetadata
| SurveyResponses
| TemplateMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata
| WorkflowImportMetadata
| PageVisibilityMetadata
| TabCountMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| TemplateFilterMetadata
| CreditTopupMetadata
| ExecutionContext
| RunButtonProperties
| ExecutionErrorMetadata

View File

@@ -138,7 +138,9 @@ export function useTemplateWorkflows() {
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
await app.loadGraphData(json, true, true, workflowName, {
openSource: 'template'
})
return true
}
@@ -159,7 +161,9 @@ export function useTemplateWorkflows() {
}
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
await app.loadGraphData(json, true, true, workflowName, {
openSource: 'template'
})
return true
} catch (error) {

View File

@@ -19,6 +19,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { WorkflowOpenSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -619,7 +620,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], 'file_drop')
} else {
// Try loading the first URI in the transfer list
const validTypes = ['text/uri-list', 'text/x-moz-url']
@@ -630,7 +631,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 }),
'file_drop'
)
}
}
}
@@ -1126,12 +1130,19 @@ export class ComfyApp {
clean: boolean = true,
restore_view: boolean = true,
workflow: string | null | ComfyWorkflow = null,
{
showMissingNodesDialog = true,
showMissingModelsDialog = true,
checkForRerouteMigration = false
options: {
showMissingNodesDialog?: boolean
showMissingModelsDialog?: boolean
checkForRerouteMigration?: boolean
openSource?: WorkflowOpenSource
} = {}
) {
const {
showMissingNodesDialog = true,
showMissingModelsDialog = true,
checkForRerouteMigration = false,
openSource
} = options
useWorkflowService().beforeLoadNewGraph()
if (clean !== false) {
@@ -1361,6 +1372,16 @@ export class ComfyApp {
'afterConfigureGraph',
missingNodeTypes
)
const telemetryPayload = {
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
),
open_source: openSource ?? 'unknown'
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
await useWorkflowService().afterLoadNewGraph(
workflow,
this.graph.serialize() as unknown as ComfyWorkflowJSON
@@ -1478,7 +1499,7 @@ export class ComfyApp {
* Loads workflow data from the specified file
* @param {File} file
*/
async handleFile(file: File) {
async handleFile(file: File, openSource?: WorkflowOpenSource) {
const removeExt = (f: string) => {
if (!f) return f
const p = f.lastIndexOf('.')
@@ -1493,7 +1514,8 @@ export class ComfyApp {
JSON.parse(pngInfo.workflow),
true,
true,
fileName
fileName,
{ openSource }
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
@@ -1513,7 +1535,9 @@ export class ComfyApp {
const { workflow, prompt } = await getAvifMetadata(file)
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1526,7 +1550,9 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1535,7 +1561,7 @@ export class ComfyApp {
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
this.loadGraphData(workflow, true, true, fileName, { openSource })
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
@@ -1544,7 +1570,7 @@ export class ComfyApp {
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
this.loadGraphData(workflow, true, true, fileName, { openSource })
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
@@ -1556,7 +1582,9 @@ export class ComfyApp {
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
this.loadGraphData(JSON.parse(workflow), true, true, fileName, {
openSource
})
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
@@ -1565,7 +1593,9 @@ export class ComfyApp {
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
if (webmInfo.workflow) {
this.loadGraphData(webmInfo.workflow, true, true, fileName)
this.loadGraphData(webmInfo.workflow, true, true, fileName, {
openSource
})
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
@@ -1581,14 +1611,18 @@ export class ComfyApp {
) {
const mp4Info = await getFromIsobmffFile(file)
if (mp4Info.workflow) {
this.loadGraphData(mp4Info.workflow, true, true, fileName)
this.loadGraphData(mp4Info.workflow, true, true, fileName, {
openSource
})
} else if (mp4Info.prompt) {
this.loadApiJson(mp4Info.prompt, fileName)
}
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
const svgInfo = await getSvgMetadata(file)
if (svgInfo.workflow) {
this.loadGraphData(svgInfo.workflow, true, true, fileName)
this.loadGraphData(svgInfo.workflow, true, true, fileName, {
openSource
})
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
@@ -1600,7 +1634,9 @@ export class ComfyApp {
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
this.loadGraphData(gltfInfo.workflow, true, true, fileName, {
openSource
})
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
@@ -1623,7 +1659,8 @@ export class ComfyApp {
JSON.parse(readerResult),
true,
true,
fileName
fileName,
{ openSource }
)
}
}
@@ -1641,7 +1678,8 @@ export class ComfyApp {
JSON.parse(info.workflow),
true,
true,
fileName
fileName,
{ openSource }
)
// @ts-expect-error
} else if (info.prompt) {

View File

@@ -398,7 +398,7 @@ export class ComfyUI {
parent: document.body,
onchange: async () => {
// @ts-expect-error fixme ts strict error
await app.handleFile(fileInput.files[0])
await app.handleFile(fileInput.files[0], 'file_button')
fileInput.value = ''
}
})