From 16ddcfdbafa0542652de0be93f242bef9bc5458b Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 21 Feb 2026 21:31:09 -0800 Subject: [PATCH] feat: add toolkit node tracking to execution telemetry (#9073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add toolkit (Essentials) node tracking to execution telemetry, enabling measurement of toolkit node adoption and popularity. ## Changes - **What**: Add `has_toolkit_nodes`, `toolkit_node_names`, and `toolkit_node_count` fields to `ExecutionContext` and `RunButtonProperties`. Toolkit nodes are identified via a hardcoded set of node type names (10 novel Essentials nodes) and by `python_module === 'comfy_essentials'` for blueprint nodes. Detection runs inside the existing `reduceAllNodes()` traversal — no additional graph walks. ## Review Focus - Toolkit node identification is frontend-only (no backend flag) — uses two mechanisms: hardcoded `TOOLKIT_NODE_NAMES` set and `TOOLKIT_BLUEPRINT_MODULES` for blueprints - API node overlap is intentional — a node can appear in both `api_node_names` and `toolkit_node_names` - Blueprint detection via `python_module` automatically picks up new essentials blueprints without code changes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9073-feat-add-toolkit-node-tracking-to-execution-telemetry-30f6d73d365081b3ac91e697889c58b6) by [Unito](https://www.unito.io) --- src/constants/toolkitNodes.ts | 38 ++++ .../cloud/MixpanelTelemetryProvider.test.ts | 181 ++++++++++++++++++ .../cloud/MixpanelTelemetryProvider.ts | 27 ++- src/platform/telemetry/types.ts | 5 + 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/constants/toolkitNodes.ts create mode 100644 src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts diff --git a/src/constants/toolkitNodes.ts b/src/constants/toolkitNodes.ts new file mode 100644 index 000000000..8a030fc26 --- /dev/null +++ b/src/constants/toolkitNodes.ts @@ -0,0 +1,38 @@ +/** + * Toolkit (Essentials) node detection constants. + * + * Used by telemetry to track toolkit node adoption and popularity. + * Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded. + * + * Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778 + */ + +/** + * Canonical node type names for individual toolkit nodes. + */ +export const TOOLKIT_NODE_NAMES: ReadonlySet = new Set([ + // Image Tools + 'ImageCrop', + 'ImageRotate', + 'ImageBlur', + 'ImageInvert', + 'ImageCompare', + 'Canny', + + // Video Tools + 'Video Slice', + + // API Nodes + 'RecraftRemoveBackgroundNode', + 'RecraftVectorizeImageNode', + 'KlingOmniProEditVideoNode' +]) + +/** + * python_module values that identify toolkit blueprint nodes. + * Essentials blueprints are registered with node_pack 'comfy_essentials', + * which maps to python_module on the node def. + */ +export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet = new Set([ + 'comfy_essentials' +]) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts new file mode 100644 index 000000000..9eb4b2b52 --- /dev/null +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + watch: vi.fn() + } +}) + +vi.mock('@/composables/auth/useCurrentUser', () => ({ + useCurrentUser: () => ({ + onUserResolved: vi.fn() + }) +})) + +vi.mock('@/platform/telemetry/topupTracker', () => ({ + checkForCompletedTopup: vi.fn(), + clearTopupTracking: vi.fn(), + startTopupTracking: vi.fn() +})) + +const hoisted = vi.hoisted(() => ({ + mockNodeDefsByName: {} as Record, + mockNodes: [] as Pick[] +})) + +vi.mock('@/stores/nodeDefStore', () => ({ + useNodeDefStore: () => ({ + nodeDefsByName: hoisted.mockNodeDefsByName + }) +})) + +vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ + useWorkflowStore: () => ({ + activeWorkflow: null + }) +})) + +vi.mock( + '@/platform/workflow/templates/repositories/workflowTemplatesStore', + () => ({ + useWorkflowTemplatesStore: () => ({ + knownTemplateNames: new Set() + }) + }) +) + +function mockNode( + type: string, + isSubgraph = false +): Pick { + return { + type, + isSubgraphNode: (() => isSubgraph) as LGraphNode['isSubgraphNode'] + } +} + +vi.mock('@/utils/graphTraversalUtil', () => ({ + reduceAllNodes: vi.fn((_graph, reducer, initial) => { + let result = initial + for (const node of hoisted.mockNodes) { + result = reducer(result, node) + } + return result + }) +})) + +vi.mock('@/scripts/app', () => ({ + app: { rootGraph: {} } +})) + +vi.mock('@/platform/remoteConfig/remoteConfig', () => ({ + remoteConfig: { value: null } +})) + +import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider' + +describe('MixpanelTelemetryProvider.getExecutionContext', () => { + let provider: MixpanelTelemetryProvider + + beforeEach(() => { + vi.clearAllMocks() + hoisted.mockNodes.length = 0 + for (const key of Object.keys(hoisted.mockNodeDefsByName)) { + delete hoisted.mockNodeDefsByName[key] + } + provider = new MixpanelTelemetryProvider() + }) + + it('returns has_toolkit_nodes false when no toolkit nodes are present', () => { + hoisted.mockNodes.push(mockNode('KSampler'), mockNode('LoadImage')) + hoisted.mockNodeDefsByName['KSampler'] = { + name: 'KSampler', + python_module: 'nodes' + } + hoisted.mockNodeDefsByName['LoadImage'] = { + name: 'LoadImage', + python_module: 'nodes' + } + + const context = provider.getExecutionContext() + + expect(context.has_toolkit_nodes).toBe(false) + expect(context.toolkit_node_names).toEqual([]) + expect(context.toolkit_node_count).toBe(0) + }) + + it('detects individual toolkit nodes by type name', () => { + hoisted.mockNodes.push(mockNode('Canny'), mockNode('KSampler')) + hoisted.mockNodeDefsByName['Canny'] = { + name: 'Canny', + python_module: 'comfy_extras.nodes_canny' + } + hoisted.mockNodeDefsByName['KSampler'] = { + name: 'KSampler', + python_module: 'nodes' + } + + const context = provider.getExecutionContext() + + expect(context.has_toolkit_nodes).toBe(true) + expect(context.toolkit_node_names).toEqual(['Canny']) + expect(context.toolkit_node_count).toBe(1) + }) + + it('detects blueprint toolkit nodes via python_module', () => { + const blueprintType = 'SubgraphBlueprint.text_to_image' + hoisted.mockNodes.push(mockNode(blueprintType, true)) + hoisted.mockNodeDefsByName[blueprintType] = { + name: blueprintType, + python_module: 'comfy_essentials' + } + + const context = provider.getExecutionContext() + + expect(context.has_toolkit_nodes).toBe(true) + expect(context.toolkit_node_names).toEqual([blueprintType]) + expect(context.toolkit_node_count).toBe(1) + }) + + it('deduplicates toolkit_node_names when same type appears multiple times', () => { + hoisted.mockNodes.push(mockNode('Canny'), mockNode('Canny')) + hoisted.mockNodeDefsByName['Canny'] = { + name: 'Canny', + python_module: 'comfy_extras.nodes_canny' + } + + const context = provider.getExecutionContext() + + expect(context.toolkit_node_names).toEqual(['Canny']) + expect(context.toolkit_node_count).toBe(2) + }) + + it('allows a node to appear in both api_node_names and toolkit_node_names', () => { + hoisted.mockNodes.push(mockNode('RecraftRemoveBackgroundNode')) + hoisted.mockNodeDefsByName['RecraftRemoveBackgroundNode'] = { + name: 'RecraftRemoveBackgroundNode', + python_module: 'comfy_extras.nodes_api', + api_node: true + } + + const context = provider.getExecutionContext() + + expect(context.has_api_nodes).toBe(true) + expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode']) + expect(context.has_toolkit_nodes).toBe(true) + expect(context.toolkit_node_names).toEqual(['RecraftRemoveBackgroundNode']) + }) + + it('uses node.type as tracking name when nodeDef is missing', () => { + hoisted.mockNodes.push(mockNode('ImageCrop')) + + const context = provider.getExecutionContext() + + expect(context.has_toolkit_nodes).toBe(true) + expect(context.toolkit_node_names).toEqual(['ImageCrop']) + }) +}) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index c512d701b..5bf09a2fe 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -2,6 +2,10 @@ import type { OverridedMixpanel } from 'mixpanel-browser' import { watch } from 'vue' import { useCurrentUser } from '@/composables/auth/useCurrentUser' +import { + TOOLKIT_BLUEPRINT_MODULES, + TOOLKIT_NODE_NAMES +} from '@/constants/toolkitNodes' import { checkForCompletedTopup as checkTopupUtil, clearTopupTracking as clearTopupUtil, @@ -285,6 +289,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { subgraph_count: executionContext.subgraph_count, has_api_nodes: executionContext.has_api_nodes, api_node_names: executionContext.api_node_names, + has_toolkit_nodes: executionContext.has_toolkit_nodes, + toolkit_node_names: executionContext.toolkit_node_names, trigger_source: options?.trigger_source } @@ -432,10 +438,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { type NodeMetrics = { custom_node_count: number api_node_count: number + toolkit_node_count: number subgraph_count: number total_node_count: number has_api_nodes: boolean api_node_names: string[] + has_toolkit_nodes: boolean + toolkit_node_names: string[] } const nodeCounts = reduceAllNodes( @@ -458,8 +467,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { } } + const isToolkitNode = + TOOLKIT_NODE_NAMES.has(node.type) || + (nodeDef?.python_module !== undefined && + TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module)) + if (isToolkitNode) { + metrics.has_toolkit_nodes = true + const trackingName = nodeDef?.name ?? node.type + if (!metrics.toolkit_node_names.includes(trackingName)) { + metrics.toolkit_node_names.push(trackingName) + } + } + metrics.custom_node_count += isCustomNode ? 1 : 0 metrics.api_node_count += isApiNode ? 1 : 0 + metrics.toolkit_node_count += isToolkitNode ? 1 : 0 metrics.subgraph_count += isSubgraph ? 1 : 0 metrics.total_node_count += 1 @@ -468,10 +490,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { { custom_node_count: 0, api_node_count: 0, + toolkit_node_count: 0, subgraph_count: 0, total_node_count: 0, has_api_nodes: false, - api_node_names: [] + api_node_names: [], + has_toolkit_nodes: false, + toolkit_node_names: [] } ) diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index 92a89c9c9..5812e143a 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -59,6 +59,8 @@ export interface RunButtonProperties { subgraph_count: number has_api_nodes: boolean api_node_names: string[] + has_toolkit_nodes: boolean + toolkit_node_names: string[] trigger_source?: ExecutionTriggerSource } @@ -82,6 +84,9 @@ export interface ExecutionContext { total_node_count: number has_api_nodes: boolean api_node_names: string[] + has_toolkit_nodes: boolean + toolkit_node_names: string[] + toolkit_node_count: number trigger_source?: ExecutionTriggerSource }