Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/constants/toolkitNodes.ts
Original file line number Diff line number Diff line change
@@ -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',
Comment on lines 13 to 20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Confirm canonical casing for image compare.

Set membership is case-sensitive; if the node type is actually Image Compare (or similar), this entry won’t match and toolkit detection will miss it. Please verify the canonical node type and adjust or normalize casing in detection.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/constants/toolkitNodes.ts` around lines 13 - 20, The entry 'image
compare' in TOOLKIT_NODE_NAMES likely has incorrect casing and will fail
case-sensitive set membership checks; verify the canonical node type string
(e.g., "Image Compare") and update the TOOLKIT_NODE_NAMES set to the exact
canonical casing, or instead normalize case when checking membership (update the
detection code that references TOOLKIT_NODE_NAMES to compare using a consistent
transform such as .toLowerCase() against a lowercased set). Ensure you reference
the TOOLKIT_NODE_NAMES constant and the specific 'image compare' entry when
making the change.


// 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'
])
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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>(
Expand All @@ -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

Expand All @@ -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: []
}
)

Expand Down
5 changes: 5 additions & 0 deletions src/platform/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down