From 08d1a78c13604e712ac529d29aeca4b81f863789 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 14:51:16 -0700 Subject: [PATCH] feat(telemetry): add workflow_opened with open_source and missing node metrics (#6476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../cloud/MixpanelTelemetryProvider.ts | 45 +++++- src/platform/telemetry/types.ts | 138 ++++++++++++++++++ .../composables/useTemplateWorkflows.ts | 8 +- src/scripts/app.ts | 76 +++++++--- src/scripts/ui.ts | 2 +- 5 files changed, 246 insertions(+), 23 deletions(-) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 296264757..601e7889c 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -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 diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index f19c0751a..1e3d654b8 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -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 diff --git a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts index f33582f80..d3bea7fb9 100644 --- a/src/platform/workflow/templates/composables/useTemplateWorkflows.ts +++ b/src/platform/workflow/templates/composables/useTemplateWorkflows.ts @@ -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) { diff --git a/src/scripts/app.ts b/src/scripts/app.ts index edabfb56a..ec8276593 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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) { diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index e72f3662c..0c2b3534c 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -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 = '' } })