From d02e837798180605461927c50e1273de80ebb5b5 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 18 Jun 2026 18:02:13 -0700 Subject: [PATCH 1/5] Move Comfy Desktop bridge types into frontend (#12857) Adds `@comfyorg/comfyui-desktop-bridge-types` as a workspace package in the frontend monorepo and changes the frontend app dependency to `workspace:*`. Adds a dedicated `Publish Desktop Bridge Types` workflow for publishing `packages/comfyui-desktop-bridge-types` by its own package version, without coupling it to the generated `@comfyorg/comfyui-frontend-types` release. The generated frontend types package still emits a concrete `@comfyorg/comfyui-desktop-bridge-types@0.1.2` dependency instead of leaking workspace/catalog protocol references. The Desktop2 missing-model path uses `window.__comfyDesktop2.isRemote()` when available, but falls back to the legacy `window.__comfyDesktop2Remote` marker so frontend rollout stays compatible with older Desktop builds. Paired Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1112 (cherry picked from commit 05efee07cef2432fcdd9a2434abda02a5047667c) --- .../publish-desktop-bridge-types.yaml | 142 ++++++++++++++++++ package.json | 1 + .../comfyDesktopBridge.d.ts | 91 +++++++++++ .../comfyui-desktop-bridge-types/index.d.ts | 1 + .../comfyui-desktop-bridge-types/index.js | 1 + .../comfyui-desktop-bridge-types/package.json | 27 ++++ pnpm-lock.yaml | 5 + scripts/prepare-types.js | 12 +- .../missingModel/missingModelDownload.test.ts | 22 ++- .../missingModel/missingModelDownload.ts | 20 +-- src/types/index.ts | 4 + 11 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/publish-desktop-bridge-types.yaml create mode 100644 packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts create mode 100644 packages/comfyui-desktop-bridge-types/index.d.ts create mode 100644 packages/comfyui-desktop-bridge-types/index.js create mode 100644 packages/comfyui-desktop-bridge-types/package.json diff --git a/.github/workflows/publish-desktop-bridge-types.yaml b/.github/workflows/publish-desktop-bridge-types.yaml new file mode 100644 index 00000000000..2116bbfad21 --- /dev/null +++ b/.github/workflows/publish-desktop-bridge-types.yaml @@ -0,0 +1,142 @@ +name: Publish Desktop Bridge Types + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to publish (e.g., 0.1.2)' + required: true + type: string + dist_tag: + description: 'npm dist-tag to use' + required: true + default: latest + type: string + ref: + description: 'Git ref to checkout (commit SHA, tag, or branch)' + required: false + type: string + workflow_call: + inputs: + version: + required: true + type: string + dist_tag: + required: false + type: string + default: latest + ref: + required: false + type: string + secrets: + NPM_TOKEN: + required: true + +concurrency: + group: publish-desktop-bridge-types-${{ github.workflow }}-${{ inputs.version }}-${{ inputs.dist_tag }} + cancel-in-progress: false + +jobs: + publish_desktop_bridge_types: + name: Publish @comfyorg/comfyui-desktop-bridge-types + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate inputs + env: + VERSION: ${{ inputs.version }} + shell: bash + run: | + set -euo pipefail + SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error title=Invalid version::Version '$VERSION' must follow semantic versioning (x.y.z[-suffix][+build])" >&2 + exit 1 + fi + + - name: Determine ref to checkout + id: resolve_ref + env: + REF: ${{ inputs.ref }} + DEFAULT_REF: ${{ github.ref_name }} + shell: bash + run: | + set -euo pipefail + if [ -z "$REF" ]; then + REF="$DEFAULT_REF" + fi + if ! git check-ref-format --allow-onelevel "$REF"; then + echo "::error title=Invalid ref::Ref '$REF' fails git check-ref-format validation." >&2 + exit 1 + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ steps.resolve_ref.outputs.ref }} + fetch-depth: 1 + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + - name: Verify package + id: pkg + env: + INPUT_VERSION: ${{ inputs.version }} + shell: bash + run: | + set -euo pipefail + PACKAGE_JSON=packages/comfyui-desktop-bridge-types/package.json + NAME=$(node -p "require('./${PACKAGE_JSON}').name") + VERSION=$(node -p "require('./${PACKAGE_JSON}').version") + if [ "$VERSION" != "$INPUT_VERSION" ]; then + echo "::error title=Version mismatch::${PACKAGE_JSON} version $VERSION does not match input $INPUT_VERSION" >&2 + exit 1 + fi + echo "name=$NAME" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if version already on npm + id: check_npm + env: + NAME: ${{ steps.pkg.outputs.name }} + VER: ${{ steps.pkg.outputs.version }} + shell: bash + run: | + set -euo pipefail + STATUS=0 + OUTPUT=$(npm view "${NAME}@${VER}" --json 2>&1) || STATUS=$? + if [ "$STATUS" -eq 0 ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "::warning title=Already published::${NAME}@${VER} already exists on npm. Skipping publish." + else + if echo "$OUTPUT" | grep -q "E404"; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error title=Registry lookup failed::$OUTPUT" >&2 + exit "$STATUS" + fi + fi + + - name: Publish package + if: steps.check_npm.outputs.exists == 'false' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + DIST_TAG: ${{ inputs.dist_tag }} + run: pnpm publish --access public --tag "$DIST_TAG" --no-git-checks --ignore-scripts + working-directory: packages/comfyui-desktop-bridge-types diff --git a/package.json b/package.json index f492b7224e8..44f09c95295 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dependencies": { "@alloc/quick-lru": "catalog:", "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", + "@comfyorg/comfyui-desktop-bridge-types": "workspace:*", "@comfyorg/comfyui-electron-types": "catalog:", "@comfyorg/design-system": "workspace:*", "@comfyorg/fbx-exporter-three": "^1.0.1", diff --git a/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts new file mode 100644 index 00000000000..a3c606c3a9c --- /dev/null +++ b/packages/comfyui-desktop-bridge-types/comfyDesktopBridge.d.ts @@ -0,0 +1,91 @@ +export interface ComfyDownloadProgress { + url: string + filename: string + directory?: string + progress: number + receivedBytes?: number + totalBytes?: number + speedBytesPerSec?: number + etaSeconds?: number + status: + | 'pending' + | 'downloading' + | 'paused' + | 'completed' + | 'error' + | 'cancelled' + error?: string + isImage?: boolean +} + +export interface TerminalRestore { + buffer: string[] + size: { cols: number; rows: number } + exited: boolean +} + +export interface LogsRestore { + installationId: string + buffer: string[] +} + +export interface LogsOutputMsg { + installationId: string + text: string +} + +export type ComfyDesktop2TelemetryValue = string | number | boolean | null +export type ComfyDesktop2TelemetryProperties = Record< + string, + ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[] +> + +export interface ComfyDesktop2TerminalBridge { + subscribe(installationId?: string): Promise + unsubscribe(installationId?: string): Promise + write(data: string, installationId?: string): Promise + resize(cols: number, rows: number, installationId?: string): Promise + restart(installationId?: string): Promise + openPopout(): Promise + onOutput(callback: (data: string) => void): () => void + onExited(callback: () => void): () => void +} + +export interface ComfyDesktop2LogsBridge { + subscribe(installationId?: string): Promise + unsubscribe(installationId?: string): Promise + openPopout(): Promise + onOutput(callback: (msg: LogsOutputMsg) => void): () => void +} + +export interface ComfyDesktop2TelemetryBridge { + capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void +} + +export interface ComfyDesktop2Bridge { + isRemote(): boolean + downloadModel?: ( + url: string, + filename: string, + directory: string + ) => Promise + downloadAsset?: ( + url: string, + filename: string, + authToken?: string + ) => Promise + pauseDownload?: (url: string) => Promise + resumeDownload?: (url: string) => Promise + cancelDownload?: (url: string) => Promise + onDownloadProgress?: ( + callback: (data: ComfyDownloadProgress) => void + ) => () => void + reportTheme?: (bg: string, text: string) => void + Terminal?: ComfyDesktop2TerminalBridge + Logs?: ComfyDesktop2LogsBridge + Telemetry?: ComfyDesktop2TelemetryBridge +} + +export type ComfyDesktop2BridgeImplementation = { + [K in keyof ComfyDesktop2Bridge]-?: NonNullable +} diff --git a/packages/comfyui-desktop-bridge-types/index.d.ts b/packages/comfyui-desktop-bridge-types/index.d.ts new file mode 100644 index 00000000000..2db482361b4 --- /dev/null +++ b/packages/comfyui-desktop-bridge-types/index.d.ts @@ -0,0 +1 @@ +export * from './comfyDesktopBridge.js' diff --git a/packages/comfyui-desktop-bridge-types/index.js b/packages/comfyui-desktop-bridge-types/index.js new file mode 100644 index 00000000000..336ce12bb91 --- /dev/null +++ b/packages/comfyui-desktop-bridge-types/index.js @@ -0,0 +1 @@ +export {} diff --git a/packages/comfyui-desktop-bridge-types/package.json b/packages/comfyui-desktop-bridge-types/package.json new file mode 100644 index 00000000000..d68019c2af3 --- /dev/null +++ b/packages/comfyui-desktop-bridge-types/package.json @@ -0,0 +1,27 @@ +{ + "name": "@comfyorg/comfyui-desktop-bridge-types", + "version": "0.1.2", + "description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge", + "homepage": "https://comfy.org", + "license": "MIT", + "author": { + "name": "Comfy Org", + "email": "support@comfy.org", + "url": "https://www.comfy.org" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Comfy-Org/ComfyUI_frontend.git" + }, + "files": [ + "comfyDesktopBridge.d.ts", + "index.d.ts", + "index.js" + ], + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "publishConfig": { + "access": "public" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 144d13e20b1..32216beedaf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@atlaskit/pragmatic-drag-and-drop': specifier: ^1.3.1 version: 1.3.1 + '@comfyorg/comfyui-desktop-bridge-types': + specifier: workspace:* + version: link:packages/comfyui-desktop-bridge-types '@comfyorg/comfyui-electron-types': specifier: 'catalog:' version: 0.6.2 @@ -986,6 +989,8 @@ importers: specifier: 'catalog:' version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + packages/comfyui-desktop-bridge-types: {} + packages/design-system: dependencies: '@iconify-json/lucide': diff --git a/scripts/prepare-types.js b/scripts/prepare-types.js index 625579dad5d..ff12f341d5f 100644 --- a/scripts/prepare-types.js +++ b/scripts/prepare-types.js @@ -2,6 +2,12 @@ import fs from 'fs' import path from 'path' const mainPackage = JSON.parse(fs.readFileSync('./package.json', 'utf8')) +const desktopBridgeTypesPackage = JSON.parse( + fs.readFileSync( + './packages/comfyui-desktop-bridge-types/package.json', + 'utf8' + ) +) // Create the types-only package.json const typesPackage = { @@ -16,7 +22,9 @@ const typesPackage = { homepage: mainPackage.homepage, description: `TypeScript definitions for ${mainPackage.name}`, license: mainPackage.license, - dependencies: {}, + dependencies: { + '@comfyorg/comfyui-desktop-bridge-types': desktopBridgeTypesPackage.version + }, peerDependencies: { vue: mainPackage.dependencies.vue, zod: mainPackage.dependencies.zod @@ -34,5 +42,3 @@ fs.writeFileSync( path.join(distDir, 'package.json'), JSON.stringify(typesPackage, null, 2) ) - -console.log('Types package.json have been prepared in the dist directory') diff --git a/src/platform/missingModel/missingModelDownload.test.ts b/src/platform/missingModel/missingModelDownload.test.ts index c1a8c0f3a1d..1991cadfe5b 100644 --- a/src/platform/missingModel/missingModelDownload.test.ts +++ b/src/platform/missingModel/missingModelDownload.test.ts @@ -39,7 +39,6 @@ beforeEach(() => { vi.restoreAllMocks() vi.resetAllMocks() delete window.__comfyDesktop2 - delete window.__comfyDesktop2Remote }) describe('fetchModelMetadata', () => { @@ -258,7 +257,10 @@ describe('downloadModel', () => { (url: string, filename: string, directory: string) => Promise >() .mockResolvedValue(true) - window.__comfyDesktop2 = { downloadModel: desktopDownloadModel } + window.__comfyDesktop2 = { + isRemote: () => false, + downloadModel: desktopDownloadModel + } downloadModel( { @@ -289,7 +291,10 @@ describe('downloadModel', () => { (url: string, filename: string, directory: string) => Promise >() .mockRejectedValue(bridgeError) - window.__comfyDesktop2 = { downloadModel: desktopDownloadModel } + window.__comfyDesktop2 = { + isRemote: () => false, + downloadModel: desktopDownloadModel + } downloadModel( { @@ -323,7 +328,10 @@ describe('downloadModel', () => { .mockImplementation(() => { throw bridgeError }) - window.__comfyDesktop2 = { downloadModel: desktopDownloadModel } + window.__comfyDesktop2 = { + isRemote: () => false, + downloadModel: desktopDownloadModel + } downloadModel( { @@ -353,8 +361,10 @@ describe('downloadModel', () => { (url: string, filename: string, directory: string) => Promise >() .mockResolvedValue(true) - window.__comfyDesktop2 = { downloadModel: desktopDownloadModel } - window.__comfyDesktop2Remote = true + window.__comfyDesktop2 = { + isRemote: () => true, + downloadModel: desktopDownloadModel + } downloadModel( { diff --git a/src/platform/missingModel/missingModelDownload.ts b/src/platform/missingModel/missingModelDownload.ts index d8816130008..dd8d8ddeeb2 100644 --- a/src/platform/missingModel/missingModelDownload.ts +++ b/src/platform/missingModel/missingModelDownload.ts @@ -2,21 +2,7 @@ import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil' import { isDesktop } from '@/platform/distribution/types' import { useElectronDownloadStore } from '@/stores/electronDownloadStore' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' - -interface ComfyDesktop2Bridge { - downloadModel: ( - url: string, - filename: string, - directory: string - ) => Promise -} - -declare global { - interface Window { - __comfyDesktop2?: ComfyDesktop2Bridge - __comfyDesktop2Remote?: boolean - } -} +import type { ComfyDesktop2Bridge } from '@/types' const ALLOWED_SOURCES = [ 'https://civitai.com/', @@ -55,7 +41,7 @@ async function startDesktop2ModelDownload( model: ModelWithUrl ): Promise { try { - await bridge.downloadModel(model.url, model.name, model.directory) + await bridge.downloadModel?.(model.url, model.name, model.directory) } catch (error: unknown) { console.error('Failed to start Desktop2 model download:', error) } @@ -90,7 +76,7 @@ export function downloadModel( paths: Record ): void { const desktop2Bridge = window.__comfyDesktop2 - if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) { + if (desktop2Bridge?.downloadModel && !desktop2Bridge.isRemote()) { void startDesktop2ModelDownload(desktop2Bridge, model) return } diff --git a/src/types/index.ts b/src/types/index.ts index 947c9b3ad24..dd54c46fec5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types' import type { DeviceStats, EmbeddingsResponse, @@ -25,6 +26,7 @@ import type { } from './extensionTypes' export type { ComfyExtension } from './comfy' +export type { ComfyDesktop2Bridge } from '@comfyorg/comfyui-desktop-bridge-types' export type { ComfyApi } from '@/scripts/api' export type { ComfyApp } from '@/scripts/app' export type { ComfyNodeDef } from '@/schemas/nodeDefSchema' @@ -88,5 +90,7 @@ declare global { /** For use in tests to track app initialization state */ __appReadiness?: AppReadiness + + __comfyDesktop2?: ComfyDesktop2Bridge } } From fcd56c9d5b8b5c67980f4ac877644dfbaade2e49 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 18 Jun 2026 19:26:17 -0700 Subject: [PATCH 2/5] Decouple run telemetry context from providers (#12925) Move run-button context assembly out of telemetry providers so telemetry can initialize without importing app-mode/workspace state. - **What**: Providers now accept completed `RunButtonProperties`; run-button call sites use a workspace composable to build that payload. - **Dependencies**: None. (cherry picked from commit bc885f383cfba1ce4739275db550932466ca70e4) --- .../builder/BuilderFooterToolbar.test.ts | 2 +- src/composables/useAppMode.ts | 20 +--- src/composables/useCoreCommands.ts | 8 +- src/composables/useRunButtonTelemetry.test.ts | 111 ++++++++++++++++++ src/composables/useRunButtonTelemetry.ts | 50 ++++++++ .../components/SubscribeToRun.vue | 5 +- src/platform/telemetry/TelemetryRegistry.ts | 9 +- .../cloud/GtmTelemetryProvider.test.ts | 16 ++- .../providers/cloud/GtmTelemetryProvider.ts | 13 +- .../cloud/MixpanelTelemetryProvider.test.ts | 67 +++++------ .../cloud/MixpanelTelemetryProvider.ts | 30 +---- .../cloud/PostHogTelemetryProvider.ts | 29 +---- src/platform/telemetry/types.ts | 10 +- .../core/services/workflowService.test.ts | 2 +- .../workflow/core/services/workflowService.ts | 2 +- .../management/stores/comfyWorkflow.ts | 2 +- src/scripts/ui.ts | 17 ++- src/utils/appMode.ts | 21 ++++ 18 files changed, 277 insertions(+), 137 deletions(-) create mode 100644 src/composables/useRunButtonTelemetry.test.ts create mode 100644 src/composables/useRunButtonTelemetry.ts create mode 100644 src/utils/appMode.ts diff --git a/src/components/builder/BuilderFooterToolbar.test.ts b/src/components/builder/BuilderFooterToolbar.test.ts index 4efe8ef10da..835c2ce7707 100644 --- a/src/components/builder/BuilderFooterToolbar.test.ts +++ b/src/components/builder/BuilderFooterToolbar.test.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, ref } from 'vue' import { createI18n } from 'vue-i18n' -import type { AppMode } from '@/composables/useAppMode' +import type { AppMode } from '@/utils/appMode' import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue' diff --git a/src/composables/useAppMode.ts b/src/composables/useAppMode.ts index e589e7c4efc..1ffb9a86411 100644 --- a/src/composables/useAppMode.ts +++ b/src/composables/useAppMode.ts @@ -1,24 +1,14 @@ import { computed, ref } from 'vue' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' - -export type AppMode = - | 'graph' - | 'app' - | 'builder:inputs' - | 'builder:outputs' - | 'builder:arrange' +import { getWorkflowMode, isAppModeValue } from '@/utils/appMode' +import type { AppMode } from '@/utils/appMode' const enableAppBuilder = ref(true) export function useAppMode() { const workflowStore = useWorkflowStore() - const mode = computed( - () => - workflowStore.activeWorkflow?.activeMode ?? - workflowStore.activeWorkflow?.initialMode ?? - 'graph' - ) + const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow)) const isBuilderMode = computed( () => isSelectMode.value || isArrangeMode.value @@ -29,9 +19,7 @@ export function useAppMode() { () => isSelectInputsMode.value || isSelectOutputsMode.value ) const isArrangeMode = computed(() => mode.value === 'builder:arrange') - const isAppMode = computed( - () => mode.value === 'app' || mode.value === 'builder:arrange' - ) + const isAppMode = computed(() => isAppModeValue(mode.value)) const isGraphMode = computed( () => mode.value === 'graph' || isSelectMode.value ) diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index bef7192942d..4853983d205 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -4,6 +4,7 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations' import { useExternalLink } from '@/composables/useExternalLink' import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog' +import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry' import { DEFAULT_DARK_COLOR_PALETTE, DEFAULT_LIGHT_COLOR_PALETTE @@ -85,6 +86,7 @@ export function useCoreCommands(): ComfyCommand[] { const executionStore = useExecutionStore() const modelStore = useModelStore() const telemetry = useTelemetry() + const { trackRunButton } = useRunButtonTelemetry() const { staticUrls, buildDocsUrl } = useExternalLink() const settingStore = useSettingStore() @@ -499,7 +501,7 @@ export function useCoreCommands(): ComfyCommand[] { subscribe_to_run?: boolean trigger_source?: ExecutionTriggerSource }) => { - useTelemetry()?.trackRunButton(metadata) + trackRunButton(metadata) if (!isActiveSubscription.value) { showSubscriptionDialog() return @@ -522,7 +524,7 @@ export function useCoreCommands(): ComfyCommand[] { subscribe_to_run?: boolean trigger_source?: ExecutionTriggerSource }) => { - useTelemetry()?.trackRunButton(metadata) + trackRunButton(metadata) if (!isActiveSubscription.value) { showSubscriptionDialog() return @@ -544,7 +546,7 @@ export function useCoreCommands(): ComfyCommand[] { subscribe_to_run?: boolean trigger_source?: ExecutionTriggerSource }) => { - useTelemetry()?.trackRunButton(metadata) + trackRunButton(metadata) if (!isActiveSubscription.value) { showSubscriptionDialog() return diff --git a/src/composables/useRunButtonTelemetry.test.ts b/src/composables/useRunButtonTelemetry.test.ts new file mode 100644 index 00000000000..5e3cf56c6b2 --- /dev/null +++ b/src/composables/useRunButtonTelemetry.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + mode: { value: 'graph' }, + isAppMode: { value: false }, + telemetry: { + trackRunButton: vi.fn() + }, + executionContext: { + is_template: false, + workflow_name: 'Desktop workflow', + custom_node_count: 2, + total_node_count: 4, + subgraph_count: 1, + has_api_nodes: true, + api_node_names: ['LoadImage'], + has_toolkit_nodes: false, + toolkit_node_names: [] + }, + executionContextError: null as Error | null +})) + +vi.mock('@/composables/useAppMode', () => ({ + useAppMode: () => ({ + mode: state.mode, + isAppMode: state.isAppMode + }) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => state.telemetry +})) + +vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({ + getExecutionContext: () => { + if (state.executionContextError) throw state.executionContextError + return state.executionContext + } +})) + +import { + getRunButtonTelemetryProperties, + useRunButtonTelemetry +} from './useRunButtonTelemetry' + +describe('useRunButtonTelemetry', () => { + beforeEach(() => { + localStorage.clear() + state.telemetry.trackRunButton.mockClear() + state.mode.value = 'graph' + state.isAppMode.value = false + state.executionContextError = null + }) + + it('builds run button properties from workspace state', () => { + localStorage.setItem('Comfy.MenuPosition.Docked', 'false') + + expect( + getRunButtonTelemetryProperties({ + subscribe_to_run: true, + trigger_source: 'button' + }) + ).toEqual({ + subscribe_to_run: true, + workflow_type: 'custom', + workflow_name: 'Desktop workflow', + custom_node_count: 2, + total_node_count: 4, + subgraph_count: 1, + has_api_nodes: true, + api_node_names: ['LoadImage'], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'button', + view_mode: 'graph', + is_app_mode: false + }) + }) + + it('tracks the completed run button payload', () => { + useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' }) + + expect(state.telemetry.trackRunButton).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + subscribe_to_run: false, + trigger_source: 'linear', + workflow_name: 'Desktop workflow' + }) + ) + }) + + it('does not throw when run button context collection fails', () => { + const error = new Error('Context unavailable') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + state.executionContextError = error + + try { + expect(() => + useRunButtonTelemetry().trackRunButton({ trigger_source: 'linear' }) + ).not.toThrow() + + expect(state.telemetry.trackRunButton).not.toHaveBeenCalled() + expect(consoleError).toHaveBeenCalledExactlyOnceWith( + '[Telemetry] Run button tracking failed', + error + ) + } finally { + consoleError.mockRestore() + } + }) +}) diff --git a/src/composables/useRunButtonTelemetry.ts b/src/composables/useRunButtonTelemetry.ts new file mode 100644 index 00000000000..a9bed225fd4 --- /dev/null +++ b/src/composables/useRunButtonTelemetry.ts @@ -0,0 +1,50 @@ +import { useAppMode } from '@/composables/useAppMode' +import { useTelemetry } from '@/platform/telemetry' +import type { + ExecutionTriggerSource, + RunButtonProperties +} from '@/platform/telemetry/types' +import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext' + +type RunButtonTelemetryOptions = { + subscribe_to_run?: boolean + trigger_source?: ExecutionTriggerSource +} + +export function getRunButtonTelemetryProperties( + options?: RunButtonTelemetryOptions +): RunButtonProperties { + const executionContext = getExecutionContext() + const { mode, isAppMode } = useAppMode() + + return { + subscribe_to_run: options?.subscribe_to_run ?? false, + workflow_type: executionContext.is_template ? 'template' : 'custom', + workflow_name: executionContext.workflow_name ?? 'untitled', + custom_node_count: executionContext.custom_node_count, + total_node_count: executionContext.total_node_count, + 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, + view_mode: mode.value, + is_app_mode: isAppMode.value + } +} + +export function useRunButtonTelemetry() { + function trackRunButton(options?: RunButtonTelemetryOptions): void { + const telemetry = useTelemetry() + if (!telemetry) return + + try { + telemetry.trackRunButton(getRunButtonTelemetryProperties(options)) + } catch (error) { + console.error('[Telemetry] Run button tracking failed', error) + } + } + + return { trackRunButton } +} diff --git a/src/platform/cloud/subscription/components/SubscribeToRun.vue b/src/platform/cloud/subscription/components/SubscribeToRun.vue index 80976c2e96e..61ec184806c 100644 --- a/src/platform/cloud/subscription/components/SubscribeToRun.vue +++ b/src/platform/cloud/subscription/components/SubscribeToRun.vue @@ -22,8 +22,8 @@ import { useI18n } from 'vue-i18n' import Button from '@/components/ui/button/Button.vue' import { useBillingContext } from '@/composables/billing/useBillingContext' +import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry' import { isCloud } from '@/platform/distribution/types' -import { useTelemetry } from '@/platform/telemetry' const { t } = useI18n() const breakpoints = useBreakpoints(breakpointsTailwind) @@ -36,10 +36,11 @@ const buttonLabel = computed(() => ) const { showSubscriptionDialog } = useBillingContext() +const { trackRunButton } = useRunButtonTelemetry() const handleSubscribeToRun = () => { if (isCloud) { - useTelemetry()?.trackRunButton({ subscribe_to_run: true }) + trackRunButton({ subscribe_to_run: true }) } showSubscriptionDialog() diff --git a/src/platform/telemetry/TelemetryRegistry.ts b/src/platform/telemetry/TelemetryRegistry.ts index 5323a1119b6..b196dcb4895 100644 --- a/src/platform/telemetry/TelemetryRegistry.ts +++ b/src/platform/telemetry/TelemetryRegistry.ts @@ -8,7 +8,6 @@ import type { ShareFlowMetadata, ExecutionErrorMetadata, ExecutionSuccessMetadata, - ExecutionTriggerSource, HelpCenterClosedMetadata, HelpCenterOpenedMetadata, HelpResourceClickedMetadata, @@ -16,6 +15,7 @@ import type { NodeSearchResultMetadata, PageViewMetadata, PageVisibilityMetadata, + RunButtonProperties, SettingChangedMetadata, SubscriptionMetadata, SubscriptionSuccessMetadata, @@ -107,11 +107,8 @@ export class TelemetryRegistry implements TelemetryDispatcher { this.dispatch((provider) => provider.trackApiCreditTopupSucceeded?.()) } - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void { - this.dispatch((provider) => provider.trackRunButton?.(options)) + trackRunButton(properties: RunButtonProperties): void { + this.dispatch((provider) => provider.trackRunButton?.(properties)) } startTopupTracking(): void { diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts index d98b0e803e4..68e910a0c42 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts @@ -184,7 +184,21 @@ describe('GtmTelemetryProvider', () => { it('pushes run_workflow with trigger_source', () => { const provider = createInitializedProvider() - provider.trackRunButton({ trigger_source: 'button' }) + provider.trackRunButton({ + subscribe_to_run: false, + workflow_type: 'custom', + workflow_name: 'untitled', + custom_node_count: 0, + total_node_count: 0, + subgraph_count: 0, + has_api_nodes: false, + api_node_names: [], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'button', + view_mode: 'app', + is_app_mode: true + }) expect(lastDataLayerEntry()).toMatchObject({ event: 'run_workflow', trigger_source: 'button', diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index bfc781ad21e..278a18028ac 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -5,7 +5,6 @@ import type { EnterLinearMetadata, ExecutionErrorMetadata, ExecutionSuccessMetadata, - ExecutionTriggerSource, HelpCenterClosedMetadata, HelpCenterOpenedMetadata, HelpResourceClickedMetadata, @@ -13,6 +12,7 @@ import type { NodeSearchResultMetadata, PageViewMetadata, PageVisibilityMetadata, + RunButtonProperties, SettingChangedMetadata, ShareFlowMetadata, SubscriptionMetadata, @@ -181,13 +181,12 @@ export class GtmTelemetryProvider implements TelemetryProvider { ) } - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void { + trackRunButton(properties: RunButtonProperties): void { this.pushEvent('run_workflow', { - subscribe_to_run: options?.subscribe_to_run ?? false, - trigger_source: options?.trigger_source ?? 'unknown' + subscribe_to_run: properties.subscribe_to_run, + trigger_source: properties.trigger_source ?? 'unknown', + view_mode: properties.view_mode, + is_app_mode: properties.is_app_mode }) } diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts index 16a1b0f7477..e0ce7d7e797 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts @@ -17,13 +17,6 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({ useCurrentUser: () => ({ onUserResolved: mockOnUserResolved }) })) -vi.mock('@/composables/useAppMode', () => ({ - useAppMode: () => ({ - mode: { value: 'workflow' }, - isAppMode: { value: false } - }) -})) - const topupMocks = vi.hoisted(() => ({ startTopupTracking: vi.fn(), clearTopupTracking: vi.fn(), @@ -31,20 +24,6 @@ const topupMocks = vi.hoisted(() => ({ })) vi.mock('@/platform/telemetry/topupTracker', () => topupMocks) -vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({ - getExecutionContext: () => ({ - is_template: false, - workflow_name: 'untitled', - custom_node_count: 0, - total_node_count: 0, - subgraph_count: 0, - has_api_nodes: false, - api_node_names: [], - has_toolkit_nodes: false, - toolkit_node_names: [] - }) -})) - const mockNormalizeSurveyResponses = vi.hoisted(() => vi.fn()) vi.mock('@/platform/telemetry/utils/surveyNormalization', () => ({ normalizeSurveyResponses: mockNormalizeSurveyResponses @@ -61,6 +40,7 @@ import type { EnterLinearMetadata, ExecutionErrorMetadata, ExecutionSuccessMetadata, + RunButtonProperties, ShareFlowMetadata, SurveyResponses, TemplateLibraryClosedMetadata, @@ -401,25 +381,32 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => { ) }) - it('trackRunButton populates RunButtonProperties from the execution context', async () => { + it('trackRunButton forwards RunButtonProperties', async () => { const provider = new MixpanelTelemetryProvider() await waitForMixpanelInit() mockMixpanel.track.mockClear() - provider.trackRunButton({ + const properties: RunButtonProperties = { subscribe_to_run: true, - trigger_source: 'button' - }) + workflow_type: 'custom', + workflow_name: 'untitled', + custom_node_count: 0, + total_node_count: 0, + subgraph_count: 0, + has_api_nodes: false, + api_node_names: [], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'button', + view_mode: 'graph', + is_app_mode: false + } + + provider.trackRunButton(properties) expect(mockMixpanel.track).toHaveBeenCalledWith( TelemetryEvents.RUN_BUTTON_CLICKED, - expect.objectContaining({ - subscribe_to_run: true, - workflow_type: 'custom', - trigger_source: 'button', - view_mode: 'workflow', - is_app_mode: false - }) + properties ) }) @@ -428,7 +415,21 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => { await waitForMixpanelInit() mockMixpanel.track.mockClear() - provider.trackRunButton({ trigger_source: 'keybinding' }) + provider.trackRunButton({ + subscribe_to_run: false, + workflow_type: 'custom', + workflow_name: 'untitled', + custom_node_count: 0, + total_node_count: 0, + subgraph_count: 0, + has_api_nodes: false, + api_node_names: [], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'keybinding', + view_mode: 'graph', + is_app_mode: false + }) provider.trackWorkflowExecution() expect(mockMixpanel.track).toHaveBeenCalledWith( diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts index 3a7fe9b20c5..9dba5fdee8d 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.ts @@ -1,7 +1,6 @@ import type { OverridedMixpanel } from 'mixpanel-browser' import { watch } from 'vue' -import { useAppMode } from '@/composables/useAppMode' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { checkForCompletedTopup as checkTopupUtil, @@ -11,7 +10,6 @@ import { import type { AuditLog } from '@/services/customerEventsService' import { getExecutionContext } from '../../utils/getExecutionContext' - import type { AuthMetadata, CreditTopupMetadata, @@ -277,31 +275,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider { clearTopupUtil() } - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void { - const executionContext = getExecutionContext() - const { mode, isAppMode } = useAppMode() - - const runButtonProperties: RunButtonProperties = { - subscribe_to_run: options?.subscribe_to_run || false, - workflow_type: executionContext.is_template ? 'template' : 'custom', - workflow_name: executionContext.workflow_name ?? 'untitled', - custom_node_count: executionContext.custom_node_count, - total_node_count: executionContext.total_node_count, - 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, - view_mode: mode.value, - is_app_mode: isAppMode.value - } - - this.lastTriggerSource = options?.trigger_source - this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties) + trackRunButton(properties: RunButtonProperties): void { + this.lastTriggerSource = properties.trigger_source + this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties) } trackSurvey( diff --git a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts index 5eeca29fa57..eb2bd053172 100644 --- a/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts @@ -1,7 +1,6 @@ import type { PostHog } from 'posthog-js' import { watch } from 'vue' -import { useAppMode } from '@/composables/useAppMode' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' @@ -276,31 +275,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider { this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED) } - trackRunButton(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void { - const executionContext = getExecutionContext() - const { mode, isAppMode } = useAppMode() - - const runButtonProperties: RunButtonProperties = { - subscribe_to_run: options?.subscribe_to_run || false, - workflow_type: executionContext.is_template ? 'template' : 'custom', - workflow_name: executionContext.workflow_name ?? 'untitled', - custom_node_count: executionContext.custom_node_count, - total_node_count: executionContext.total_node_count, - 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, - view_mode: mode.value, - is_app_mode: isAppMode.value - } - - this.lastTriggerSource = options?.trigger_source - this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties) + trackRunButton(properties: RunButtonProperties): void { + this.lastTriggerSource = properties.trigger_source + this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties) } trackSurvey( diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index e606379eb5f..ea4aa3a219b 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -16,6 +16,7 @@ import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/com import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing' import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank' import type { AuditLog } from '@/services/customerEventsService' +import type { AppMode } from '@/utils/appMode' /** * Authentication metadata for sign-up tracking @@ -69,8 +70,8 @@ export interface RunButtonProperties { has_toolkit_nodes: boolean toolkit_node_names: string[] trigger_source?: ExecutionTriggerSource - view_mode?: string - is_app_mode?: boolean + view_mode: AppMode + is_app_mode: boolean } /** @@ -398,10 +399,7 @@ export interface TelemetryProvider { trackAddApiCreditButtonClicked?(): void trackApiCreditTopupButtonPurchaseClicked?(amount: number): void trackApiCreditTopupSucceeded?(): void - trackRunButton?(options?: { - subscribe_to_run?: boolean - trigger_source?: ExecutionTriggerSource - }): void + trackRunButton?(properties: RunButtonProperties): void // Credit top-up tracking (composition with internal utilities) startTopupTracking?(): void diff --git a/src/platform/workflow/core/services/workflowService.test.ts b/src/platform/workflow/core/services/workflowService.test.ts index f621382d7a3..dd72a909fdc 100644 --- a/src/platform/workflow/core/services/workflowService.test.ts +++ b/src/platform/workflow/core/services/workflowService.test.ts @@ -18,9 +18,9 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore' import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore' import { app } from '@/scripts/app' import { useAppMode } from '@/composables/useAppMode' -import type { AppMode } from '@/composables/useAppMode' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils' +import type { AppMode } from '@/utils/appMode' import { t } from '@/i18n' function createModeTestWorkflow( diff --git a/src/platform/workflow/core/services/workflowService.ts b/src/platform/workflow/core/services/workflowService.ts index 7e04c68c721..476c2839f9a 100644 --- a/src/platform/workflow/core/services/workflowService.ts +++ b/src/platform/workflow/core/services/workflowService.ts @@ -23,7 +23,6 @@ import { app } from '@/scripts/app' import { blankGraph, defaultGraph } from '@/scripts/defaultGraph' import { useDialogService } from '@/services/dialogService' import { useAppMode } from '@/composables/useAppMode' -import type { AppMode } from '@/composables/useAppMode' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useAppModeStore } from '@/stores/appModeStore' import { useExecutionErrorStore } from '@/stores/executionErrorStore' @@ -37,6 +36,7 @@ import { appendWorkflowJsonExt, generateUUID } from '@/utils/formatUtil' +import type { AppMode } from '@/utils/appMode' function linearModeToAppMode(linearMode: unknown): AppMode | null { if (typeof linearMode !== 'boolean') return null diff --git a/src/platform/workflow/management/stores/comfyWorkflow.ts b/src/platform/workflow/management/stores/comfyWorkflow.ts index 331ab3dc2e8..032cd07a361 100644 --- a/src/platform/workflow/management/stores/comfyWorkflow.ts +++ b/src/platform/workflow/management/stores/comfyWorkflow.ts @@ -2,13 +2,13 @@ import { markRaw } from 'vue' import { t } from '@/i18n' import type { ChangeTracker } from '@/scripts/changeTracker' -import type { AppMode } from '@/composables/useAppMode' import type { NodeId } from '@/lib/litegraph/src/LGraphNode' import { UserFile } from '@/stores/userFileStore' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { MissingModelCandidate } from '@/platform/missingModel/types' import type { MissingMediaCandidate } from '@/platform/missingMedia/types' import type { MissingNodeType } from '@/types/comfy' +import type { AppMode } from '@/utils/appMode' export interface InputWidgetConfig { height?: number diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index 3f6f061e14c..bc62df86e76 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -1,11 +1,12 @@ -import { useSettingStore } from '@/platform/settings/settingStore' -import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats' -import { type StatusWsMessageStatus } from '@/schemas/apiSchema' -import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog' +import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry' import { isCloud } from '@/platform/distribution/types' import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog' +import { useSettingStore } from '@/platform/settings/settingStore' import { useTelemetry } from '@/platform/telemetry' +import { WORKFLOW_ACCEPT_STRING } from '@/platform/workflow/core/types/formats' +import { type StatusWsMessageStatus } from '@/schemas/apiSchema' import { useLitegraphService } from '@/services/litegraphService' import { useCommandStore } from '@/stores/commandStore' import { useWorkspaceStore } from '@/stores/workspaceStore' @@ -488,7 +489,9 @@ export class ComfyUI { textContent: 'Queue Prompt', onclick: () => { if (isCloud) { - useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' }) + useRunButtonTelemetry().trackRunButton({ + trigger_source: 'legacy_ui' + }) useTelemetry()?.trackWorkflowExecution() } app.queuePrompt(0, this.batchCount) @@ -596,7 +599,9 @@ export class ComfyUI { textContent: 'Queue Front', onclick: () => { if (isCloud) { - useTelemetry()?.trackRunButton({ trigger_source: 'legacy_ui' }) + useRunButtonTelemetry().trackRunButton({ + trigger_source: 'legacy_ui' + }) useTelemetry()?.trackWorkflowExecution() } app.queuePrompt(-1, this.batchCount) diff --git a/src/utils/appMode.ts b/src/utils/appMode.ts new file mode 100644 index 00000000000..16154ddea50 --- /dev/null +++ b/src/utils/appMode.ts @@ -0,0 +1,21 @@ +export type AppMode = + | 'graph' + | 'app' + | 'builder:inputs' + | 'builder:outputs' + | 'builder:arrange' + +type WorkflowModeSource = { + activeMode: AppMode | null + initialMode: AppMode | null | undefined +} + +export function getWorkflowMode( + workflow: WorkflowModeSource | null | undefined +): AppMode { + return workflow?.activeMode ?? workflow?.initialMode ?? 'graph' +} + +export function isAppModeValue(mode: AppMode): boolean { + return mode === 'app' || mode === 'builder:arrange' +} From a6e7fac7b6be4cdf12ba02d3f68450d9351fdf8a Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Thu, 18 Jun 2026 20:19:35 -0700 Subject: [PATCH 3/5] Add Desktop telemetry event sink (#12802) - initialize a Desktop-only telemetry provider in ComfyUI_frontend - forward existing typed telemetry events through `window.__comfyDesktop2.Telemetry.capture` using the existing event names - move the Desktop 2 bridge typing to the shared ambient types and let run/execution telemetry fire when any provider is registered - Desktop PR: https://github.com/Comfy-Org/Comfy-Desktop/pull/1069 - `pnpm typecheck` - `pnpm format:check` - `pnpm lint` - `pnpm knip` - `pnpm test:unit src/platform/missingModel/missingModelDownload.test.ts src/platform/telemetry/initDesktopTelemetry.test.ts src/platform/telemetry/providers/desktop/DesktopTelemetryProvider.test.ts` - YAML lint over tracked YAML files with `.yamllint` [MAR-240](https://linear.app/comfyorg/issue/MAR-240/frontend-telemetry-pipeline-for-desktop-app-eventsink-refactor) (cherry picked from commit 5acd76cb6da298a886310152ce1c76efacbb5f3d) --- src/composables/useRunButtonTelemetry.test.ts | 3 - src/main.ts | 16 +- .../remoteConfig/refreshRemoteConfig.ts | 13 +- src/platform/remoteConfig/types.ts | 1 + .../telemetry/initHostTelemetry.test.ts | 64 ++++ src/platform/telemetry/initHostTelemetry.ts | 23 ++ .../providers/cloud/GtmTelemetryProvider.ts | 3 +- .../providers/host/HostTelemetrySink.test.ts | 116 ++++++++ .../providers/host/HostTelemetrySink.ts | 275 ++++++++++++++++++ src/platform/telemetry/types.ts | 1 + src/scripts/ui.ts | 21 +- src/stores/executionStore.test.ts | 21 +- src/stores/executionStore.ts | 24 +- 13 files changed, 541 insertions(+), 40 deletions(-) create mode 100644 src/platform/telemetry/initHostTelemetry.test.ts create mode 100644 src/platform/telemetry/initHostTelemetry.ts create mode 100644 src/platform/telemetry/providers/host/HostTelemetrySink.test.ts create mode 100644 src/platform/telemetry/providers/host/HostTelemetrySink.ts diff --git a/src/composables/useRunButtonTelemetry.test.ts b/src/composables/useRunButtonTelemetry.test.ts index 5e3cf56c6b2..fb61f3b8f17 100644 --- a/src/composables/useRunButtonTelemetry.test.ts +++ b/src/composables/useRunButtonTelemetry.test.ts @@ -45,7 +45,6 @@ import { describe('useRunButtonTelemetry', () => { beforeEach(() => { - localStorage.clear() state.telemetry.trackRunButton.mockClear() state.mode.value = 'graph' state.isAppMode.value = false @@ -53,8 +52,6 @@ describe('useRunButtonTelemetry', () => { }) it('builds run button properties from workspace state', () => { - localStorage.setItem('Comfy.MenuPosition.Docked', 'false') - expect( getRunButtonTelemetryProperties({ subscribe_to_run: true, diff --git a/src/main.ts b/src/main.ts index 7898779cf5f..8cfda1b8cc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,21 +28,27 @@ import App from './App.vue' import './assets/css/style.css' import { i18n } from './i18n' -/** - * CRITICAL: Load remote config FIRST for cloud builds to ensure - * window.__CONFIG__is available for all modules during initialization - */ const isCloud = __DISTRIBUTION__ === 'cloud' +const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry) +const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge -if (isCloud) { +if (requiresRemoteConfigBootstrap) { const { refreshRemoteConfig } = await import('@/platform/remoteConfig/refreshRemoteConfig') await refreshRemoteConfig({ useAuth: false }) +} +if (isCloud) { const { initTelemetry } = await import('@/platform/telemetry/initTelemetry') await initTelemetry() } +if (hasHostTelemetryBridge) { + const { initHostTelemetry } = + await import('@/platform/telemetry/initHostTelemetry') + initHostTelemetry() +} + const ComfyUIPreset = definePreset(Aura, { semantic: { // @ts-expect-error fixme ts strict error diff --git a/src/platform/remoteConfig/refreshRemoteConfig.ts b/src/platform/remoteConfig/refreshRemoteConfig.ts index 86b49909025..78aa9b8290a 100644 --- a/src/platform/remoteConfig/refreshRemoteConfig.ts +++ b/src/platform/remoteConfig/refreshRemoteConfig.ts @@ -1,5 +1,3 @@ -import { api } from '@/scripts/api' - import { remoteConfig, remoteConfigState } from './remoteConfig' interface RefreshRemoteConfigOptions { @@ -10,6 +8,13 @@ interface RefreshRemoteConfigOptions { useAuth?: boolean } +async function fetchRemoteConfig(useAuth: boolean): Promise { + if (!useAuth) return fetch('/api/features', { cache: 'no-store' }) + + const { api } = await import('@/scripts/api') + return api.fetchApi('/features', { cache: 'no-store' }) +} + /** * Loads remote configuration from the backend /features endpoint * and updates the reactive remoteConfig ref. @@ -25,9 +30,7 @@ export async function refreshRemoteConfig( const { useAuth = true } = options try { - const response = useAuth - ? await api.fetchApi('/features', { cache: 'no-store' }) - : await fetch('/api/features', { cache: 'no-store' }) + const response = await fetchRemoteConfig(useAuth) if (response.ok) { const config = await response.json() diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index f2134aa513b..38f2ce654e8 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -89,6 +89,7 @@ export type RemoteConfig = { comfy_platform_base_url?: string firebase_config?: FirebaseRuntimeConfig telemetry_disabled_events?: TelemetryEventName[] + enable_telemetry?: boolean model_upload_button_enabled?: boolean asset_rename_enabled?: boolean private_models_enabled?: boolean diff --git a/src/platform/telemetry/initHostTelemetry.test.ts b/src/platform/telemetry/initHostTelemetry.test.ts new file mode 100644 index 00000000000..f5d68d4dd82 --- /dev/null +++ b/src/platform/telemetry/initHostTelemetry.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' +import { setTelemetryRegistry, useTelemetry } from '@/platform/telemetry' +import { initHostTelemetry } from '@/platform/telemetry/initHostTelemetry' +import { TelemetryEvents } from '@/platform/telemetry/types' + +const fetchMock = vi.fn() +const localStorageMock = { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn() +} + +describe('initHostTelemetry', () => { + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('localStorage', localStorageMock) + }) + + afterEach(() => { + remoteConfig.value = {} + setTelemetryRegistry(null) + delete window.__comfyDesktop2 + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('leaves the registry untouched when enable_telemetry is on but the host Telemetry bridge is absent', () => { + remoteConfig.value = { enable_telemetry: true } + + initHostTelemetry() + + expect(useTelemetry()).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('leaves the registry untouched when enable_telemetry is off', () => { + window.__comfyDesktop2 = { + isRemote: () => false, + Telemetry: { capture: vi.fn() } + } + remoteConfig.value = { enable_telemetry: false } + + initHostTelemetry() + + expect(useTelemetry()).toBeNull() + }) + + it('registers the host telemetry sink when enable_telemetry and the bridge are present', () => { + const capture = vi.fn() + window.__comfyDesktop2 = { isRemote: () => false, Telemetry: { capture } } + remoteConfig.value = { enable_telemetry: true } + + initHostTelemetry() + useTelemetry()?.trackSignupOpened() + + expect(capture).toHaveBeenCalledWith( + TelemetryEvents.USER_SIGN_UP_OPENED, + undefined + ) + }) +}) diff --git a/src/platform/telemetry/initHostTelemetry.ts b/src/platform/telemetry/initHostTelemetry.ts new file mode 100644 index 00000000000..76e3ba268f2 --- /dev/null +++ b/src/platform/telemetry/initHostTelemetry.ts @@ -0,0 +1,23 @@ +import { setTelemetryRegistry } from './index' +import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' +import { getDevOverride } from '@/utils/devFeatureFlagOverride' +import { TelemetryRegistry } from './TelemetryRegistry' +import { HostTelemetrySink } from './providers/host/HostTelemetrySink' + +const ENABLE_TELEMETRY_FEATURE = 'enable_telemetry' + +function isHostTelemetryEnabled(): boolean { + const override = getDevOverride(ENABLE_TELEMETRY_FEATURE) + if (override !== undefined) return override + + return remoteConfig.value.enable_telemetry === true +} + +export function initHostTelemetry(): void { + if (!isHostTelemetryEnabled()) return + if (!window.__comfyDesktop2?.Telemetry) return + + const registry = new TelemetryRegistry() + registry.registerProvider(new HostTelemetrySink()) + setTelemetryRegistry(registry) +} diff --git a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts index 278a18028ac..3fce02a71d9 100644 --- a/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts +++ b/src/platform/telemetry/providers/cloud/GtmTelemetryProvider.ts @@ -29,6 +29,7 @@ import type { WorkflowImportMetadata, WorkflowSavedMetadata } from '../../types' +import { TelemetryEvents } from '../../types' /** * Google Tag Manager telemetry provider. @@ -152,7 +153,7 @@ export class GtmTelemetryProvider implements TelemetryProvider { } trackBeginCheckout(metadata: BeginCheckoutMetadata): void { - this.pushEvent('begin_checkout', metadata) + this.pushEvent(TelemetryEvents.BEGIN_CHECKOUT, metadata) } trackSubscription( diff --git a/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts new file mode 100644 index 00000000000..e76d75705f1 --- /dev/null +++ b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { TelemetryEvents } from '@/platform/telemetry/types' + +import { HostTelemetrySink } from './HostTelemetrySink' + +const state = vi.hoisted(() => ({ + capture: vi.fn() +})) + +describe('HostTelemetrySink', () => { + beforeEach(() => { + state.capture.mockClear() + window.__comfyDesktop2 = { + isRemote: () => false, + Telemetry: { + capture: state.capture + } + } + }) + + afterEach(() => { + delete window.__comfyDesktop2 + }) + + it('forwards run button telemetry to the host bridge', () => { + new HostTelemetrySink().trackRunButton({ + subscribe_to_run: true, + workflow_type: 'custom', + workflow_name: 'Host workflow', + custom_node_count: 2, + total_node_count: 4, + subgraph_count: 1, + has_api_nodes: true, + api_node_names: ['LoadImage'], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'button', + view_mode: 'graph', + is_app_mode: false + }) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith( + TelemetryEvents.RUN_BUTTON_CLICKED, + { + subscribe_to_run: true, + workflow_type: 'custom', + workflow_name: 'Host workflow', + custom_node_count: 2, + total_node_count: 4, + subgraph_count: 1, + has_api_nodes: true, + api_node_names: ['LoadImage'], + has_toolkit_nodes: false, + toolkit_node_names: [], + trigger_source: 'button', + view_mode: 'graph', + is_app_mode: false + } + ) + }) + + it('keeps primitive arrays and drops nested payloads', () => { + new HostTelemetrySink().trackWorkflowImported({ + missing_node_count: 2, + missing_node_types: ['MissingA', 'MissingB'], + open_source: 'file_drop' + }) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith( + TelemetryEvents.WORKFLOW_IMPORTED, + { + missing_node_count: 2, + missing_node_types: ['MissingA', 'MissingB'], + open_source: 'file_drop' + } + ) + }) + + it('forwards begin checkout using the existing GA4 event name', () => { + new HostTelemetrySink().trackBeginCheckout({ + user_id: 'user-id', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + ecommerce: { + items: [ + { + item_name: 'Pro', + price: 100, + quantity: 1 + } + ] + } + }) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith( + TelemetryEvents.BEGIN_CHECKOUT, + { + user_id: 'user-id', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new' + } + ) + }) + + it('does nothing when the host bridge is absent', () => { + delete window.__comfyDesktop2 + + expect(() => + new HostTelemetrySink().trackNodeSearch({ query: 'k sampler' }) + ).not.toThrow() + expect(state.capture).not.toHaveBeenCalled() + }) +}) diff --git a/src/platform/telemetry/providers/host/HostTelemetrySink.ts b/src/platform/telemetry/providers/host/HostTelemetrySink.ts new file mode 100644 index 00000000000..12bb0575385 --- /dev/null +++ b/src/platform/telemetry/providers/host/HostTelemetrySink.ts @@ -0,0 +1,275 @@ +import type { + ComfyDesktop2TelemetryBridge, + ComfyDesktop2TelemetryValue +} from '@comfyorg/comfyui-desktop-bridge-types' +import { + checkForCompletedTopup as checkTopupUtil, + clearTopupTracking as clearTopupUtil, + startTopupTracking as startTopupUtil +} from '@/platform/telemetry/topupTracker' +import type { AuditLog } from '@/services/customerEventsService' + +import type { + AuthMetadata, + BeginCheckoutMetadata, + DefaultViewSetMetadata, + EnterLinearMetadata, + ExecutionErrorMetadata, + ExecutionSuccessMetadata, + HelpCenterClosedMetadata, + HelpCenterOpenedMetadata, + HelpResourceClickedMetadata, + NodeSearchMetadata, + NodeSearchResultMetadata, + PageViewMetadata, + PageVisibilityMetadata, + RunButtonProperties, + SettingChangedMetadata, + ShareFlowMetadata, + SubscriptionMetadata, + SubscriptionSuccessMetadata, + SurveyResponses, + TabCountMetadata, + TelemetryEventName, + TelemetryProvider, + TemplateFilterMetadata, + TemplateLibraryClosedMetadata, + TemplateLibraryMetadata, + TemplateMetadata, + UiButtonClickMetadata, + WorkflowCreatedMetadata, + WorkflowImportMetadata, + WorkflowSavedMetadata +} from '../../types' +import { TelemetryEvents } from '../../types' +import { normalizeSurveyResponses } from '../../utils/surveyNormalization' + +type HostTelemetryProperties = Parameters< + ComfyDesktop2TelemetryBridge['capture'] +>[1] + +function isHostTelemetryPrimitive( + value: unknown +): value is ComfyDesktop2TelemetryValue { + return ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) +} + +function toHostTelemetryProperties( + properties?: object +): HostTelemetryProperties { + if (!properties) return undefined + + const out: NonNullable = {} + for (const [key, value] of Object.entries(properties)) { + if (isHostTelemetryPrimitive(value)) { + out[key] = value + } else if (Array.isArray(value) && value.every(isHostTelemetryPrimitive)) { + out[key] = value + } + } + + return out +} + +export class HostTelemetrySink implements TelemetryProvider { + private capture(event: TelemetryEventName, properties?: object): void { + window.__comfyDesktop2?.Telemetry?.capture( + event, + toHostTelemetryProperties(properties) + ) + } + + trackSignupOpened(): void { + this.capture(TelemetryEvents.USER_SIGN_UP_OPENED) + } + + trackAuth(metadata: AuthMetadata): void { + this.capture(TelemetryEvents.USER_AUTH_COMPLETED, metadata) + } + + trackUserLoggedIn(): void { + this.capture(TelemetryEvents.USER_LOGGED_IN) + } + + trackSubscription( + event: 'modal_opened' | 'subscribe_clicked', + metadata?: SubscriptionMetadata + ): void { + this.capture( + event === 'modal_opened' + ? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED + : TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED, + metadata + ) + } + + trackBeginCheckout(metadata: BeginCheckoutMetadata): void { + this.capture(TelemetryEvents.BEGIN_CHECKOUT, metadata) + } + + trackMonthlySubscriptionSucceeded( + metadata?: SubscriptionSuccessMetadata + ): void { + this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, metadata) + } + + trackMonthlySubscriptionCancelled(): void { + this.capture(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED) + } + + trackAddApiCreditButtonClicked(): void { + this.capture(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED) + } + + trackApiCreditTopupButtonPurchaseClicked(amount: number): void { + this.capture(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, { + credit_amount: amount + }) + } + + trackApiCreditTopupSucceeded(): void { + this.capture(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED) + } + + trackRunButton(properties: RunButtonProperties): void { + this.capture(TelemetryEvents.RUN_BUTTON_CLICKED, properties) + } + + startTopupTracking(): void { + startTopupUtil() + } + + checkForCompletedTopup(events: AuditLog[] | undefined | null): boolean { + return checkTopupUtil(events) + } + + clearTopupTracking(): void { + clearTopupUtil() + } + + trackSurvey( + stage: 'opened' | 'submitted', + responses?: SurveyResponses + ): void { + this.capture( + stage === 'opened' + ? TelemetryEvents.USER_SURVEY_OPENED + : TelemetryEvents.USER_SURVEY_SUBMITTED, + responses ? normalizeSurveyResponses(responses) : undefined + ) + } + + trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void { + const event = + stage === 'opened' + ? TelemetryEvents.USER_EMAIL_VERIFY_OPENED + : stage === 'requested' + ? TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED + : TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED + this.capture(event) + } + + trackTemplate(metadata: TemplateMetadata): void { + this.capture(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata) + } + + trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void { + this.capture(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata) + } + + trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void { + this.capture(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata) + } + + trackWorkflowImported(metadata: WorkflowImportMetadata): void { + this.capture(TelemetryEvents.WORKFLOW_IMPORTED, metadata) + } + + trackWorkflowOpened(metadata: WorkflowImportMetadata): void { + this.capture(TelemetryEvents.WORKFLOW_OPENED, metadata) + } + + trackWorkflowSaved(metadata: WorkflowSavedMetadata): void { + this.capture(TelemetryEvents.WORKFLOW_SAVED, metadata) + } + + trackDefaultViewSet(metadata: DefaultViewSetMetadata): void { + this.capture(TelemetryEvents.DEFAULT_VIEW_SET, metadata) + } + + trackEnterLinear(metadata: EnterLinearMetadata): void { + this.capture(TelemetryEvents.ENTER_LINEAR_MODE, metadata) + } + + trackShareFlow(metadata: ShareFlowMetadata): void { + this.capture(TelemetryEvents.SHARE_FLOW, metadata) + } + + trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void { + this.capture(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata) + } + + trackTabCount(metadata: TabCountMetadata): void { + this.capture(TelemetryEvents.TAB_COUNT_TRACKING, metadata) + } + + trackNodeSearch(metadata: NodeSearchMetadata): void { + this.capture(TelemetryEvents.NODE_SEARCH, metadata) + } + + trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void { + this.capture(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata) + } + + trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void { + this.capture(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata) + } + + trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void { + this.capture(TelemetryEvents.HELP_CENTER_OPENED, metadata) + } + + trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void { + this.capture(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata) + } + + trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void { + this.capture(TelemetryEvents.HELP_CENTER_CLOSED, metadata) + } + + trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void { + this.capture(TelemetryEvents.WORKFLOW_CREATED, metadata) + } + + trackWorkflowExecution(): void { + this.capture(TelemetryEvents.EXECUTION_START) + } + + trackExecutionError(metadata: ExecutionErrorMetadata): void { + this.capture(TelemetryEvents.EXECUTION_ERROR, metadata) + } + + trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void { + this.capture(TelemetryEvents.EXECUTION_SUCCESS, metadata) + } + + trackSettingChanged(metadata: SettingChangedMetadata): void { + this.capture(TelemetryEvents.SETTING_CHANGED, metadata) + } + + trackUiButtonClicked(metadata: UiButtonClickMetadata): void { + this.capture(TelemetryEvents.UI_BUTTON_CLICKED, metadata) + } + + trackPageView(pageName: string, properties?: PageViewMetadata): void { + this.capture(TelemetryEvents.PAGE_VIEW, { + page_name: pageName, + ...properties + }) + } +} diff --git a/src/platform/telemetry/types.ts b/src/platform/telemetry/types.ts index ea4aa3a219b..309d7982f35 100644 --- a/src/platform/telemetry/types.ts +++ b/src/platform/telemetry/types.ts @@ -491,6 +491,7 @@ export const TelemetryEvents = { API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED: 'app:api_credit_topup_button_purchase_clicked', API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded', + BEGIN_CHECKOUT: 'begin_checkout', // Onboarding Survey USER_SURVEY_OPENED: 'app:user_survey_opened', diff --git a/src/scripts/ui.ts b/src/scripts/ui.ts index bc62df86e76..5e00d976797 100644 --- a/src/scripts/ui.ts +++ b/src/scripts/ui.ts @@ -1,5 +1,4 @@ import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry' -import { isCloud } from '@/platform/distribution/types' import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog' @@ -488,12 +487,10 @@ export class ComfyUI { id: 'queue-button', textContent: 'Queue Prompt', onclick: () => { - if (isCloud) { - useRunButtonTelemetry().trackRunButton({ - trigger_source: 'legacy_ui' - }) - useTelemetry()?.trackWorkflowExecution() - } + useRunButtonTelemetry().trackRunButton({ + trigger_source: 'legacy_ui' + }) + useTelemetry()?.trackWorkflowExecution() app.queuePrompt(0, this.batchCount) } }), @@ -598,12 +595,10 @@ export class ComfyUI { id: 'queue-front-button', textContent: 'Queue Front', onclick: () => { - if (isCloud) { - useRunButtonTelemetry().trackRunButton({ - trigger_source: 'legacy_ui' - }) - useTelemetry()?.trackWorkflowExecution() - } + useRunButtonTelemetry().trackRunButton({ + trigger_source: 'legacy_ui' + }) + useTelemetry()?.trackWorkflowExecution() app.queuePrompt(-1, this.batchCount) } }), diff --git a/src/stores/executionStore.test.ts b/src/stores/executionStore.test.ts index 8470f96dafe..5cdeb764bc3 100644 --- a/src/stores/executionStore.test.ts +++ b/src/stores/executionStore.test.ts @@ -15,12 +15,16 @@ const { mockNodeExecutionIdToNodeLocatorId, mockNodeIdToNodeLocatorId, mockNodeLocatorIdToNodeExecutionId, - mockShowTextPreview + mockShowTextPreview, + mockTrackExecutionError, + mockTrackExecutionSuccess } = vi.hoisted(() => ({ mockNodeExecutionIdToNodeLocatorId: vi.fn(), mockNodeIdToNodeLocatorId: vi.fn(), mockNodeLocatorIdToNodeExecutionId: vi.fn(), - mockShowTextPreview: vi.fn() + mockShowTextPreview: vi.fn(), + mockTrackExecutionError: vi.fn(), + mockTrackExecutionSuccess: vi.fn() })) import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' import { createTestingPinia } from '@pinia/testing' @@ -82,6 +86,13 @@ vi.mock('@/stores/jobPreviewStore', () => ({ }) })) +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ + trackExecutionError: mockTrackExecutionError, + trackExecutionSuccess: mockTrackExecutionSuccess + }) +})) + // Mock the app import with proper implementation vi.mock('@/scripts/app', () => ({ app: { @@ -1085,6 +1096,12 @@ describe('useExecutionStore - WebSocket event handlers', () => { expect(store.activeJobId).toBeNull() expect(store.queuedJobs['job-1']).toBeUndefined() }) + + it('does not track success for jobs this client did not queue', () => { + fire('execution_success', { prompt_id: 'foreign-job', timestamp: 0 }) + + expect(mockTrackExecutionSuccess).not.toHaveBeenCalled() + }) }) describe('executing', () => { diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index 4fc262730f2..0f84387a364 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -295,12 +295,14 @@ export const useExecutionStore = defineStore('execution', () => { } function handleExecutionSuccess(e: CustomEvent) { - if (isCloud && activeJobId.value) { - useTelemetry()?.trackExecutionSuccess({ - jobId: activeJobId.value + const jobId = e.detail.prompt_id + const queuedJob = queuedJobs.value[jobId] + const telemetry = useTelemetry() + if (queuedJob) { + telemetry?.trackExecutionSuccess({ + jobId }) } - const jobId = e.detail.prompt_id resetExecutionState(jobId) } @@ -398,14 +400,14 @@ export const useExecutionStore = defineStore('execution', () => { } function handleExecutionError(e: CustomEvent) { - if (isCloud) { - useTelemetry()?.trackExecutionError({ - jobId: e.detail.prompt_id, - nodeId: String(e.detail.node_id), - nodeType: e.detail.node_type, - error: e.detail.exception_message - }) + useTelemetry()?.trackExecutionError({ + jobId: e.detail.prompt_id, + nodeId: String(e.detail.node_id), + nodeType: e.detail.node_type, + error: e.detail.exception_message + }) + if (isCloud) { // Cloud wraps validation errors (400) in exception_message as embedded JSON. if (handleCloudValidationError(e.detail)) return } From e112b8915e7cecdb0a034f6f31c2691b7ba1750f Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Jun 2026 10:17:14 -0700 Subject: [PATCH 4/5] test: mock execution context in mixpanel telemetry test --- .../cloud/MixpanelTelemetryProvider.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts index e0ce7d7e797..bcd9acb9bb4 100644 --- a/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts +++ b/src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts @@ -33,6 +33,20 @@ vi.mock('@/platform/remoteConfig/remoteConfig', () => ({ remoteConfig: { value: null } })) +vi.mock('@/platform/telemetry/utils/getExecutionContext', () => ({ + getExecutionContext: () => ({ + is_template: false, + workflow_name: 'untitled', + custom_node_count: 0, + total_node_count: 0, + subgraph_count: 0, + has_api_nodes: false, + api_node_names: [], + has_toolkit_nodes: false, + toolkit_node_names: [] + }) +})) + import { MixpanelTelemetryProvider } from '@/platform/telemetry/providers/cloud/MixpanelTelemetryProvider' import type { AuthMetadata, From e249c13a38693d6d50ca4dabc679a230644ca830 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Jun 2026 10:32:36 -0700 Subject: [PATCH 5/5] test: cover host telemetry sink forwarding --- .../providers/host/HostTelemetrySink.test.ts | 441 ++++++++++++++++++ 1 file changed, 441 insertions(+) diff --git a/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts index e76d75705f1..d7c87ebb833 100644 --- a/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts +++ b/src/platform/telemetry/providers/host/HostTelemetrySink.test.ts @@ -1,16 +1,47 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TelemetryEvents } from '@/platform/telemetry/types' +import type { SurveyResponses } from '@/platform/telemetry/types' +import type { AuditLog } from '@/services/customerEventsService' import { HostTelemetrySink } from './HostTelemetrySink' +type ForwardCase = [ + string, + (sink: HostTelemetrySink) => void, + (typeof TelemetryEvents)[keyof typeof TelemetryEvents], + object | undefined +] + const state = vi.hoisted(() => ({ capture: vi.fn() })) +const topupMocks = vi.hoisted(() => ({ + startTopupTracking: vi.fn(), + checkForCompletedTopup: vi.fn().mockReturnValue(true), + clearTopupTracking: vi.fn() +})) +vi.mock('@/platform/telemetry/topupTracker', () => topupMocks) + +const mockNormalizeSurveyResponses = vi.hoisted(() => + vi.fn((responses: SurveyResponses) => ({ + ...responses, + industry_normalized: 'Software / IT / AI' + })) +) +vi.mock('../../utils/surveyNormalization', () => ({ + normalizeSurveyResponses: mockNormalizeSurveyResponses +})) + describe('HostTelemetrySink', () => { beforeEach(() => { state.capture.mockClear() + topupMocks.startTopupTracking.mockClear() + topupMocks.checkForCompletedTopup.mockClear() + topupMocks.checkForCompletedTopup.mockReturnValue(true) + topupMocks.clearTopupTracking.mockClear() + mockNormalizeSurveyResponses.mockClear() window.__comfyDesktop2 = { isRemote: () => false, Telemetry: { @@ -113,4 +144,414 @@ describe('HostTelemetrySink', () => { ).not.toThrow() expect(state.capture).not.toHaveBeenCalled() }) + + it.for([ + [ + 'trackSignupOpened', + (sink: HostTelemetrySink) => sink.trackSignupOpened(), + TelemetryEvents.USER_SIGN_UP_OPENED, + undefined + ], + [ + 'trackAuth', + (sink: HostTelemetrySink) => + sink.trackAuth({ + method: 'google', + is_new_user: true, + user_id: 'user-id' + }), + TelemetryEvents.USER_AUTH_COMPLETED, + { + method: 'google', + is_new_user: true, + user_id: 'user-id' + } + ], + [ + 'trackUserLoggedIn', + (sink: HostTelemetrySink) => sink.trackUserLoggedIn(), + TelemetryEvents.USER_LOGGED_IN, + undefined + ], + [ + 'trackSubscription modal', + (sink: HostTelemetrySink) => + sink.trackSubscription('modal_opened', { + current_tier: 'free', + reason: 'out_of_credits' + }), + TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED, + { + current_tier: 'free', + reason: 'out_of_credits' + } + ], + [ + 'trackSubscription subscribe', + (sink: HostTelemetrySink) => + sink.trackSubscription('subscribe_clicked', { current_tier: 'free' }), + TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED, + { current_tier: 'free' } + ], + [ + 'trackMonthlySubscriptionSucceeded', + (sink: HostTelemetrySink) => + sink.trackMonthlySubscriptionSucceeded({ + user_id: 'user-id', + checkout_attempt_id: 'attempt-id', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + value: 100, + currency: 'USD', + ecommerce: { + currency: 'USD', + value: 100, + items: [ + { + item_name: 'Pro', + item_category: 'subscription', + price: 100, + quantity: 1 + } + ] + } + }), + TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED, + { + user_id: 'user-id', + checkout_attempt_id: 'attempt-id', + tier: 'pro', + cycle: 'monthly', + checkout_type: 'new', + value: 100, + currency: 'USD' + } + ], + [ + 'trackMonthlySubscriptionCancelled', + (sink: HostTelemetrySink) => sink.trackMonthlySubscriptionCancelled(), + TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED, + undefined + ], + [ + 'trackAddApiCreditButtonClicked', + (sink: HostTelemetrySink) => sink.trackAddApiCreditButtonClicked(), + TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED, + undefined + ], + [ + 'trackApiCreditTopupButtonPurchaseClicked', + (sink: HostTelemetrySink) => + sink.trackApiCreditTopupButtonPurchaseClicked(25), + TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, + { credit_amount: 25 } + ], + [ + 'trackApiCreditTopupSucceeded', + (sink: HostTelemetrySink) => sink.trackApiCreditTopupSucceeded(), + TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED, + undefined + ], + [ + 'trackTemplate', + (sink: HostTelemetrySink) => + sink.trackTemplate({ + workflow_name: 'Template', + template_source: 'library' + }), + TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, + { + workflow_name: 'Template', + template_source: 'library' + } + ], + [ + 'trackTemplateLibraryOpened', + (sink: HostTelemetrySink) => + sink.trackTemplateLibraryOpened({ source: 'sidebar' }), + TelemetryEvents.TEMPLATE_LIBRARY_OPENED, + { source: 'sidebar' } + ], + [ + 'trackTemplateLibraryClosed', + (sink: HostTelemetrySink) => + sink.trackTemplateLibraryClosed({ + template_selected: true, + time_spent_seconds: 3 + }), + TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, + { + template_selected: true, + time_spent_seconds: 3 + } + ], + [ + 'trackWorkflowOpened', + (sink: HostTelemetrySink) => + sink.trackWorkflowOpened({ + missing_node_count: 1, + missing_node_types: ['MissingNode'], + open_source: 'file_button' + }), + TelemetryEvents.WORKFLOW_OPENED, + { + missing_node_count: 1, + missing_node_types: ['MissingNode'], + open_source: 'file_button' + } + ], + [ + 'trackWorkflowSaved', + (sink: HostTelemetrySink) => + sink.trackWorkflowSaved({ is_app: true, is_new: false }), + TelemetryEvents.WORKFLOW_SAVED, + { is_app: true, is_new: false } + ], + [ + 'trackDefaultViewSet', + (sink: HostTelemetrySink) => + sink.trackDefaultViewSet({ default_view: 'app' }), + TelemetryEvents.DEFAULT_VIEW_SET, + { default_view: 'app' } + ], + [ + 'trackEnterLinear', + (sink: HostTelemetrySink) => sink.trackEnterLinear({ source: 'toolbar' }), + TelemetryEvents.ENTER_LINEAR_MODE, + { source: 'toolbar' } + ], + [ + 'trackShareFlow', + (sink: HostTelemetrySink) => + sink.trackShareFlow({ step: 'link_created', source: 'app_mode' }), + TelemetryEvents.SHARE_FLOW, + { step: 'link_created', source: 'app_mode' } + ], + [ + 'trackPageVisibilityChanged', + (sink: HostTelemetrySink) => + sink.trackPageVisibilityChanged({ visibility_state: 'visible' }), + TelemetryEvents.PAGE_VISIBILITY_CHANGED, + { visibility_state: 'visible' } + ], + [ + 'trackTabCount', + (sink: HostTelemetrySink) => sink.trackTabCount({ tab_count: 2 }), + TelemetryEvents.TAB_COUNT_TRACKING, + { tab_count: 2 } + ], + [ + 'trackNodeSearch', + (sink: HostTelemetrySink) => sink.trackNodeSearch({ query: 'ksampler' }), + TelemetryEvents.NODE_SEARCH, + { query: 'ksampler' } + ], + [ + 'trackNodeSearchResultSelected', + (sink: HostTelemetrySink) => + sink.trackNodeSearchResultSelected({ + node_type: 'KSampler', + last_query: 'sampler' + }), + TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, + { node_type: 'KSampler', last_query: 'sampler' } + ], + [ + 'trackTemplateFilterChanged', + (sink: HostTelemetrySink) => + sink.trackTemplateFilterChanged({ + selected_models: ['flux'], + selected_use_cases: ['image'], + selected_runs_on: ['local'], + sort_by: 'popular', + filtered_count: 4, + total_count: 12 + }), + TelemetryEvents.TEMPLATE_FILTER_CHANGED, + { + selected_models: ['flux'], + selected_use_cases: ['image'], + selected_runs_on: ['local'], + sort_by: 'popular', + filtered_count: 4, + total_count: 12 + } + ], + [ + 'trackHelpCenterOpened', + (sink: HostTelemetrySink) => + sink.trackHelpCenterOpened({ source: 'menu' }), + TelemetryEvents.HELP_CENTER_OPENED, + { source: 'menu' } + ], + [ + 'trackHelpResourceClicked', + (sink: HostTelemetrySink) => + sink.trackHelpResourceClicked({ + resource_type: 'docs', + is_external: true, + source: 'help_center' + }), + TelemetryEvents.HELP_RESOURCE_CLICKED, + { + resource_type: 'docs', + is_external: true, + source: 'help_center' + } + ], + [ + 'trackHelpCenterClosed', + (sink: HostTelemetrySink) => + sink.trackHelpCenterClosed({ time_spent_seconds: 5 }), + TelemetryEvents.HELP_CENTER_CLOSED, + { time_spent_seconds: 5 } + ], + [ + 'trackWorkflowCreated', + (sink: HostTelemetrySink) => + sink.trackWorkflowCreated({ + workflow_type: 'blank', + previous_workflow_had_nodes: false + }), + TelemetryEvents.WORKFLOW_CREATED, + { + workflow_type: 'blank', + previous_workflow_had_nodes: false + } + ], + [ + 'trackWorkflowExecution', + (sink: HostTelemetrySink) => sink.trackWorkflowExecution(), + TelemetryEvents.EXECUTION_START, + undefined + ], + [ + 'trackExecutionError', + (sink: HostTelemetrySink) => + sink.trackExecutionError({ + jobId: 'job-id', + nodeId: 'node-id', + nodeType: 'KSampler', + error: 'failed' + }), + TelemetryEvents.EXECUTION_ERROR, + { + jobId: 'job-id', + nodeId: 'node-id', + nodeType: 'KSampler', + error: 'failed' + } + ], + [ + 'trackExecutionSuccess', + (sink: HostTelemetrySink) => + sink.trackExecutionSuccess({ jobId: 'job-id' }), + TelemetryEvents.EXECUTION_SUCCESS, + { jobId: 'job-id' } + ], + [ + 'trackSettingChanged', + (sink: HostTelemetrySink) => + sink.trackSettingChanged({ + setting_id: 'Comfy.Test', + previous_value: 'off', + new_value: 'on' + }), + TelemetryEvents.SETTING_CHANGED, + { + setting_id: 'Comfy.Test', + previous_value: 'off', + new_value: 'on' + } + ], + [ + 'trackUiButtonClicked', + (sink: HostTelemetrySink) => + sink.trackUiButtonClicked({ button_id: 'comfy_logo' }), + TelemetryEvents.UI_BUTTON_CLICKED, + { button_id: 'comfy_logo' } + ], + [ + 'trackPageView', + (sink: HostTelemetrySink) => + sink.trackPageView('Settings', { + path: '/settings', + title: 'Settings' + }), + TelemetryEvents.PAGE_VIEW, + { + page_name: 'Settings', + path: '/settings', + title: 'Settings' + } + ] + ])('forwards %s to the host bridge', ([_, track, event, properties]) => { + track(new HostTelemetrySink()) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, properties) + }) + + it.for< + [ + 'opened' | 'submitted', + (typeof TelemetryEvents)[keyof typeof TelemetryEvents] + ] + >([ + ['opened' as const, TelemetryEvents.USER_SURVEY_OPENED], + ['submitted' as const, TelemetryEvents.USER_SURVEY_SUBMITTED] + ])('normalizes survey responses for %s events', ([stage, event]) => { + const responses = { industry: 'software' } + + new HostTelemetrySink().trackSurvey(stage, responses) + + expect(mockNormalizeSurveyResponses).toHaveBeenCalledExactlyOnceWith( + responses + ) + expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, { + industry: 'software', + industry_normalized: 'Software / IT / AI' + }) + }) + + it('forwards survey events without responses', () => { + new HostTelemetrySink().trackSurvey('opened') + + expect(mockNormalizeSurveyResponses).not.toHaveBeenCalled() + expect(state.capture).toHaveBeenCalledExactlyOnceWith( + TelemetryEvents.USER_SURVEY_OPENED, + undefined + ) + }) + + it.for< + [ + 'opened' | 'requested' | 'completed', + (typeof TelemetryEvents)[keyof typeof TelemetryEvents] + ] + >([ + ['opened' as const, TelemetryEvents.USER_EMAIL_VERIFY_OPENED], + ['requested' as const, TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED], + ['completed' as const, TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED] + ])('forwards email verification %s events', ([stage, event]) => { + new HostTelemetrySink().trackEmailVerification(stage) + + expect(state.capture).toHaveBeenCalledExactlyOnceWith(event, undefined) + }) + + it('delegates topup tracking to the shared tracker', () => { + const events = [{ event_type: 'credit_added' }] as AuditLog[] + const sink = new HostTelemetrySink() + + sink.startTopupTracking() + const completed = sink.checkForCompletedTopup(events) + sink.clearTopupTracking() + + expect(topupMocks.startTopupTracking).toHaveBeenCalledOnce() + expect(topupMocks.checkForCompletedTopup).toHaveBeenCalledExactlyOnceWith( + events + ) + expect(completed).toBe(true) + expect(topupMocks.clearTopupTracking).toHaveBeenCalledOnce() + }) })