refactor: implement hook-based dependency inversion for telemetry system

- Create TelemetryService with multi-provider support and hook-based context resolution
- Add TelemetryProviderBase abstract class for consistent provider implementation
- Update MixpanelTelemetryProvider to use new architecture, remove legacy onboarding code
- Register execution context hooks in workflowStore with real node metrics calculation
- Eliminate circular dependencies through dependency inversion pattern
This commit is contained in:
bymyself
2025-11-06 19:34:18 -07:00
parent adecd258b6
commit 860afe2c14
9 changed files with 578 additions and 207 deletions

View File

@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { t } from '@/i18n'
import { useTelemetryService } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
@@ -128,6 +129,18 @@ export const useCurrentUser = () => {
}
}
// Register telemetry hooks
const telemetryService = useTelemetryService()
telemetryService?.registerHooks({
getCurrentUser: () =>
resolvedUserInfo.value
? {
id: resolvedUserInfo.value.id,
tier: 'free' // This will be enhanced when we add subscription data
}
: null
})
return {
loading: authStore.loading,
isLoggedIn,

View File

@@ -4,6 +4,7 @@ import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import { useTelemetryService } from '@/platform/telemetry'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -237,6 +238,21 @@ export const useSettingStore = defineStore('setting', () => {
}
}
// Register telemetry hooks
const telemetryService = useTelemetryService()
telemetryService?.registerHooks({
getSurveyData: () => {
// Use raw access to settings that may not be in the typed schema
return settingValues.value['onboarding_survey'] || null
},
getFeatureFlags: () => ({
// Use raw access to feature flags that might not be in the schema
darkMode: settingValues.value['Comfy.UseNewMenu'] === 'Top',
betaFeatures: settingValues.value['Comfy.Beta.Enabled'] === true,
advancedMode: settingValues.value['Comfy.Advanced.Mode'] === true
})
})
return {
settingValues,
settingsById,

View File

@@ -17,26 +17,39 @@
import { isCloud } from '@/platform/distribution/types'
import { MixpanelTelemetryProvider } from './providers/cloud/MixpanelTelemetryProvider'
import type { TelemetryProvider } from './types'
import { TelemetryService } from './services/TelemetryService'
// Singleton instance
let _telemetryProvider: TelemetryProvider | null = null
let _telemetryService: TelemetryService | null = null
/**
* Telemetry factory - conditionally creates provider based on distribution
* Telemetry service factory - conditionally creates service with providers based on distribution
* Returns singleton instance.
*
* CRITICAL: This returns undefined in OSS builds. There is no telemetry provider
* CRITICAL: This returns null in OSS builds. There is no telemetry service
* for OSS builds and all tracking calls are no-ops.
*/
export function useTelemetry(): TelemetryProvider | null {
if (_telemetryProvider === null) {
export function useTelemetryService(): TelemetryService | null {
if (_telemetryService === null) {
// Use distribution check for tree-shaking
if (isCloud) {
_telemetryProvider = new MixpanelTelemetryProvider()
_telemetryService = new TelemetryService()
// Initialize and add Mixpanel provider
const mixpanelProvider = new MixpanelTelemetryProvider()
void mixpanelProvider.initialize().then(() => {
_telemetryService?.addProvider(mixpanelProvider)
})
}
// For OSS builds, _telemetryProvider stays null
// For OSS builds, _telemetryService stays null
}
return _telemetryProvider
return _telemetryService
}
/**
* @deprecated Use useTelemetryService() instead
*/
export function useTelemetry() {
return useTelemetryService()
}

View File

@@ -0,0 +1,49 @@
import type {
ExecutionContext,
SurveyResponses,
TemplateMetadata
} from '../types'
/**
* Context types provided by domain stores to telemetry
*/
interface UserContext {
id: string
email?: string
tier?: 'free' | 'pro' | 'enterprise'
}
interface WorkflowContext {
filename?: string
isTemplate: boolean
nodeCount?: number
hasCustomNodes?: boolean
}
interface SubscriptionContext {
isSubscribed: boolean
plan?: string
creditsRemaining?: number
}
/**
* Telemetry hooks interface for dependency inversion.
* Domain stores register these hooks to provide context to telemetry
* without creating circular dependencies.
*/
export interface TelemetryHooks {
// Context providers
getExecutionContext?(): ExecutionContext | null
getCurrentUser?(): UserContext | null
getActiveWorkflow?(): WorkflowContext | null
// Setting providers
getSurveyData?(): SurveyResponses | null
getSubscriptionStatus?(): SubscriptionContext | null
// Template metadata providers
getTemplateMetadata?(filename: string): TemplateMetadata | null
// Feature flag providers
getFeatureFlags?(): Record<string, boolean>
}

View File

@@ -0,0 +1,92 @@
import type {
AuthMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../types'
/**
* Abstract base class for telemetry providers with lifecycle management.
* Concrete providers should extend this class for consistent behavior.
*/
export abstract class TelemetryProviderBase implements TelemetryProvider {
protected isEnabled = true
protected isInitialized = false
/**
* Initialize the provider (e.g., load external libraries)
*/
abstract initialize(): Promise<void>
/**
* Check if the provider is ready to track events
*/
isReady(): boolean {
return this.isEnabled && this.isInitialized
}
setEnabled(enabled: boolean): void {
this.isEnabled = enabled
}
// All abstract methods from TelemetryProvider interface with proper typing
abstract trackAuth(metadata: AuthMetadata): void
abstract trackUserLoggedIn(): void
abstract trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
abstract trackMonthlySubscriptionSucceeded(): void
abstract trackAddApiCreditButtonClicked(): void
abstract trackApiCreditTopupButtonPurchaseClicked(amount: number): void
abstract trackApiCreditTopupSucceeded(): void
abstract trackRunButton(properties: RunButtonProperties): void
abstract startTopupTracking(): void
abstract checkForCompletedTopup(events: any[] | undefined | null): boolean
abstract clearTopupTracking(): void
abstract trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void
abstract trackEmailVerification(
stage: 'opened' | 'requested' | 'completed'
): void
abstract trackTemplate(metadata: TemplateMetadata): void
abstract trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
abstract trackTemplateLibraryClosed(
metadata: TemplateLibraryClosedMetadata
): void
abstract trackWorkflowImported(metadata: WorkflowImportMetadata): void
abstract trackWorkflowOpened(metadata: WorkflowImportMetadata): void
abstract trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void
abstract trackTabCount(metadata: TabCountMetadata): void
abstract trackNodeSearch(metadata: NodeSearchMetadata): void
abstract trackNodeSearchResultSelected(
metadata: NodeSearchResultMetadata
): void
abstract trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void
abstract trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void
abstract trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void
abstract trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void
abstract trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
abstract trackWorkflowExecution(context?: ExecutionContext): void
abstract trackExecutionError(metadata: ExecutionErrorMetadata): void
abstract trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void
abstract trackSettingChanged(metadata: SettingChangedMetadata): void
abstract trackUiButtonClicked(metadata: UiButtonClickMetadata): void
}

View File

@@ -1,17 +1,10 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
clearTopupTracking as clearTopupUtil,
startTopupTracking as startTopupUtil
} from '@/platform/telemetry/topupTracker'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type {
AuthMetadata,
@@ -32,7 +25,6 @@ import type {
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
@@ -43,11 +35,7 @@ import type {
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
interface QueuedEvent {
eventName: TelemetryEventName
properties?: TelemetryEventProperties
}
import { TelemetryProviderBase } from '../TelemetryProviderBase'
/**
* Mixpanel Telemetry Provider - Cloud Build Implementation
@@ -61,65 +49,38 @@ interface QueuedEvent {
* 2. `grep -RinE --include='*.js' 'trackWorkflow|trackEvent|mixpanel' dist/` (should find nothing)
* 3. Check dist/assets/*.js files contain no tracking code
*/
export class MixpanelTelemetryProvider implements TelemetryProvider {
private isEnabled = true
export class MixpanelTelemetryProvider extends TelemetryProviderBase {
private mixpanel: OverridedMixpanel | null = null
private eventQueue: QueuedEvent[] = []
private isInitialized = false
private lastTriggerSource: ExecutionTriggerSource | undefined
constructor() {
const token = window.__CONFIG__?.mixpanel_token
if (token) {
try {
// Dynamic import to avoid bundling mixpanel in OSS builds
void import('mixpanel-browser')
.then((mixpanelModule) => {
this.mixpanel = mixpanelModule.default
this.mixpanel.init(token, {
debug: import.meta.env.DEV,
track_pageview: true,
api_host: 'https://mp.comfy.org',
cross_subdomain_cookie: true,
persistence: 'cookie',
loaded: () => {
this.isInitialized = true
this.flushEventQueue() // flush events that were queued while initializing
useCurrentUser().onUserResolved((user) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
}
})
}
})
})
.catch((error) => {
console.error('Failed to load Mixpanel:', error)
this.isEnabled = false
})
} catch (error) {
console.error('Failed to initialize Mixpanel:', error)
this.isEnabled = false
}
} else {
console.warn('Mixpanel token not provided in runtime config')
this.isEnabled = false
}
super()
}
private flushEventQueue(): void {
if (!this.isInitialized || !this.mixpanel) {
async initialize(): Promise<void> {
const token = window.__CONFIG__?.mixpanel_token
if (!token) {
this.setEnabled(false)
return
}
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift()!
try {
this.mixpanel.track(event.eventName, event.properties || {})
} catch (error) {
console.error('Failed to track queued event:', error)
}
try {
const mixpanelModule = await import('mixpanel-browser')
this.mixpanel = mixpanelModule.default
this.mixpanel.init(token, {
debug: import.meta.env.DEV,
track_pageview: true,
api_host: 'https://mp.comfy.org',
cross_subdomain_cookie: true,
persistence: 'cookie'
})
this.isInitialized = true
} catch (error) {
console.error('Failed to load Mixpanel:', error)
this.setEnabled(false)
}
}
@@ -127,22 +88,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
): void {
if (!this.isEnabled) {
if (!this.isEnabled || !this.isInitialized || !this.mixpanel) {
return
}
const event: QueuedEvent = { eventName, properties }
if (this.isInitialized && this.mixpanel) {
// Mixpanel is ready, track immediately
try {
this.mixpanel.track(eventName, properties || {})
} catch (error) {
console.error('Failed to track event:', error)
}
} else {
// Mixpanel not ready yet, queue the event
this.eventQueue.push(event)
try {
this.mixpanel.track(eventName, properties || {})
} catch (error) {
console.error('Failed to track event:', error)
}
}
@@ -198,26 +151,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
clearTopupUtil()
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = this.getExecutionContext()
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,
trigger_source: options?.trigger_source
}
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(
@@ -320,9 +256,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(): void {
const context = this.getExecutionContext()
const eventContext: ExecutionContext = {
trackWorkflowExecution(context: ExecutionContext): void {
const eventContext = {
...context,
trigger_source: this.lastTriggerSource ?? 'unknown'
}
@@ -345,98 +280,4 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
getExecutionContext(): ExecutionContext {
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
}

View File

@@ -0,0 +1,240 @@
import type {
TelemetryProvider,
AuthMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata,
ExecutionTriggerSource
} from '../types'
import type { TelemetryHooks } from '../interfaces/TelemetryHooks'
/**
* Central telemetry service that manages multiple providers and resolves
* context through hook-based dependency inversion.
*/
export class TelemetryService {
private hooks: TelemetryHooks = {}
private providers: TelemetryProvider[] = []
/**
* Register hooks from domain stores to provide context
*/
registerHooks(hooks: Partial<TelemetryHooks>): void {
this.hooks = { ...this.hooks, ...hooks }
}
/**
* Add analytics provider to receive events
*/
addProvider(provider: TelemetryProvider): void {
this.providers.push(provider)
}
trackAuth(metadata: AuthMetadata): void {
this.providers.forEach((provider) => provider.trackAuth(metadata))
}
trackUserLoggedIn(): void {
this.providers.forEach((provider) => provider.trackUserLoggedIn())
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
this.providers.forEach((provider) => provider.trackSubscription(event))
}
trackMonthlySubscriptionSucceeded(): void {
this.providers.forEach((provider) =>
provider.trackMonthlySubscriptionSucceeded()
)
}
trackAddApiCreditButtonClicked(): void {
this.providers.forEach((provider) =>
provider.trackAddApiCreditButtonClicked()
)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
this.providers.forEach((provider) =>
provider.trackApiCreditTopupButtonPurchaseClicked(amount)
)
}
trackApiCreditTopupSucceeded(): void {
this.providers.forEach((provider) =>
provider.trackApiCreditTopupSucceeded()
)
}
trackRunButton(options?: {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
// Resolve complete context through hooks
const context = this.hooks.getExecutionContext?.()
if (!context) return // Don't track if no context available
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: context.is_template ? 'template' : 'custom',
workflow_name: context.workflow_name ?? 'untitled',
custom_node_count: context.custom_node_count,
total_node_count: context.total_node_count,
subgraph_count: context.subgraph_count,
has_api_nodes: context.has_api_nodes,
api_node_names: context.api_node_names,
trigger_source: options?.trigger_source
}
this.providers.forEach((provider) =>
provider.trackRunButton(runButtonProperties)
)
}
startTopupTracking(): void {
this.providers.forEach((provider) => provider.startTopupTracking())
}
checkForCompletedTopup(events: any[] | undefined | null): boolean {
return this.providers.some((provider) =>
provider.checkForCompletedTopup(events)
)
}
clearTopupTracking(): void {
this.providers.forEach((provider) => provider.clearTopupTracking())
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
this.providers.forEach((provider) => provider.trackSurvey(stage, responses))
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.providers.forEach((provider) => provider.trackEmailVerification(stage))
}
trackTemplate(metadata: TemplateMetadata): void {
this.providers.forEach((provider) => provider.trackTemplate(metadata))
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateLibraryOpened(metadata)
)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateLibraryClosed(metadata)
)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
this.providers.forEach((provider) =>
provider.trackWorkflowImported(metadata)
)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.providers.forEach((provider) => provider.trackWorkflowOpened(metadata))
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
this.providers.forEach((provider) =>
provider.trackPageVisibilityChanged(metadata)
)
}
trackTabCount(metadata: TabCountMetadata): void {
this.providers.forEach((provider) => provider.trackTabCount(metadata))
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.providers.forEach((provider) => provider.trackNodeSearch(metadata))
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
this.providers.forEach((provider) =>
provider.trackNodeSearchResultSelected(metadata)
)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
this.providers.forEach((provider) =>
provider.trackTemplateFilterChanged(metadata)
)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpCenterOpened(metadata)
)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpResourceClicked(metadata)
)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
this.providers.forEach((provider) =>
provider.trackHelpCenterClosed(metadata)
)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
this.providers.forEach((provider) =>
provider.trackWorkflowCreated(metadata)
)
}
trackWorkflowExecution(): void {
// Resolve context through hooks and only track if context is available
const context = this.hooks.getExecutionContext?.()
if (!context) return // Don't track if no context available
this.providers.forEach((provider) =>
provider.trackWorkflowExecution(context)
)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.providers.forEach((provider) => provider.trackExecutionError(metadata))
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.providers.forEach((provider) =>
provider.trackExecutionSuccess(metadata)
)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
this.providers.forEach((provider) => provider.trackSettingChanged(metadata))
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.providers.forEach((provider) =>
provider.trackUiButtonClicked(metadata)
)
}
}

View File

@@ -267,10 +267,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
@@ -314,7 +311,7 @@ export interface TelemetryProvider {
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void
// Workflow execution events
trackWorkflowExecution(): void
trackWorkflowExecution(context?: ExecutionContext): void
trackExecutionError(metadata: ExecutionErrorMetadata): void
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void

View File

@@ -9,17 +9,22 @@ import type {
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { useTelemetryService } from '@/platform/telemetry'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { UserFile } from '@/stores/userFileStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
@@ -708,6 +713,111 @@ export const useWorkflowStore = defineStore('workflow', () => {
return createNodeExecutionId([...path, localNodeId])
}
// Register telemetry hooks
const telemetryService = useTelemetryService()
telemetryService?.registerHooks({
getActiveWorkflow: () =>
activeWorkflow.value
? {
filename: activeWorkflow.value.filename,
isTemplate: false, // This will be enhanced when we add template detection
nodeCount: activeWorkflow.value.activeState?.nodes?.length
}
: null,
getExecutionContext: () => {
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
comfyApp.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow.value?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.value.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.value.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.value.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.value.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: activeWorkflow.value.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
})
return {
activeWorkflow,
attachWorkflow,