mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
feat: add toolkit node tracking to execution telemetry (#9073)
## 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)
This commit is contained in:
38
src/constants/toolkitNodes.ts
Normal file
38
src/constants/toolkitNodes.ts
Normal file
@@ -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<string> = 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<string> = new Set([
|
||||
'comfy_essentials'
|
||||
])
|
||||
@@ -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<string, unknown>,
|
||||
mockNodes: [] as Pick<LGraphNode, 'type' | 'isSubgraphNode'>[]
|
||||
}))
|
||||
|
||||
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<LGraphNode, 'type' | 'isSubgraphNode'> {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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<NodeMetrics>(
|
||||
@@ -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: []
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user