mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-08 06:30:04 +00:00
feat: Add PostHog telemetry provider (#9409)
Add PostHog as a telemetry provider for cloud builds so custom events can be correlated with session recordings. Follows the same pattern as MixpanelTelemetryProvider with dynamic import, event queuing, and disabled events from remote config. Tree-shaken away in OSS builds. The posthog-js package uses Apache-2.0 (verified from its LICENSE file) but declares it as "SEE LICENSE IN LICENSE" in package.json, which the license checker can't parse. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9409-feat-Add-PostHog-telemetry-provider-31a6d73d3650818b8e86c772c6551099) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -29,6 +29,8 @@ export type RemoteConfig = {
|
||||
gtm_container_id?: string
|
||||
ga_measurement_id?: string
|
||||
mixpanel_token?: string
|
||||
posthog_project_token?: string
|
||||
posthog_api_host?: string
|
||||
subscription_required?: boolean
|
||||
server_health_alert?: ServerHealthAlert
|
||||
max_upload_size?: number
|
||||
|
||||
@@ -24,18 +24,21 @@ export async function initTelemetry(): Promise<void> {
|
||||
{ TelemetryRegistry },
|
||||
{ MixpanelTelemetryProvider },
|
||||
{ GtmTelemetryProvider },
|
||||
{ ImpactTelemetryProvider }
|
||||
{ ImpactTelemetryProvider },
|
||||
{ PostHogTelemetryProvider }
|
||||
] = await Promise.all([
|
||||
import('./TelemetryRegistry'),
|
||||
import('./providers/cloud/MixpanelTelemetryProvider'),
|
||||
import('./providers/cloud/GtmTelemetryProvider'),
|
||||
import('./providers/cloud/ImpactTelemetryProvider')
|
||||
import('./providers/cloud/ImpactTelemetryProvider'),
|
||||
import('./providers/cloud/PostHogTelemetryProvider')
|
||||
])
|
||||
|
||||
const registry = new TelemetryRegistry()
|
||||
registry.registerProvider(new MixpanelTelemetryProvider())
|
||||
registry.registerProvider(new GtmTelemetryProvider())
|
||||
registry.registerProvider(new ImpactTelemetryProvider())
|
||||
registry.registerProvider(new PostHogTelemetryProvider())
|
||||
|
||||
setTelemetryRegistry(registry)
|
||||
})()
|
||||
|
||||
@@ -76,18 +76,15 @@ vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
}))
|
||||
|
||||
import { MixpanelTelemetryProvider } from './MixpanelTelemetryProvider'
|
||||
|
||||
describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
let provider: MixpanelTelemetryProvider
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
|
||||
describe('getExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.mockNodes.length = 0
|
||||
for (const key of Object.keys(hoisted.mockNodeDefsByName)) {
|
||||
delete hoisted.mockNodeDefsByName[key]
|
||||
}
|
||||
provider = new MixpanelTelemetryProvider()
|
||||
})
|
||||
|
||||
it('returns has_toolkit_nodes false when no toolkit nodes are present', () => {
|
||||
@@ -101,7 +98,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(false)
|
||||
expect(context.toolkit_node_names).toEqual([])
|
||||
@@ -119,7 +116,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
python_module: 'nodes'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
@@ -134,7 +131,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
python_module: 'comfy_essentials'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual([blueprintType])
|
||||
@@ -148,7 +145,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
python_module: 'comfy_extras.nodes_canny'
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.toolkit_node_names).toEqual(['Canny'])
|
||||
expect(context.toolkit_node_count).toBe(2)
|
||||
@@ -162,7 +159,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
api_node: true
|
||||
}
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_api_nodes).toBe(true)
|
||||
expect(context.api_node_names).toEqual(['RecraftRemoveBackgroundNode'])
|
||||
@@ -173,7 +170,7 @@ describe('MixpanelTelemetryProvider.getExecutionContext', () => {
|
||||
it('uses node.type as tracking name when nodeDef is missing', () => {
|
||||
hoisted.mockNodes.push(mockNode('ImageCrop'))
|
||||
|
||||
const context = provider.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
|
||||
expect(context.has_toolkit_nodes).toBe(true)
|
||||
expect(context.toolkit_node_names).toEqual(['ImageCrop'])
|
||||
|
||||
@@ -2,22 +2,14 @@ import type { OverridedMixpanel } from 'mixpanel-browser'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import {
|
||||
TOOLKIT_BLUEPRINT_MODULES,
|
||||
TOOLKIT_NODE_NAMES
|
||||
} from '@/constants/toolkitNodes'
|
||||
import {
|
||||
checkForCompletedTopup as checkTopupUtil,
|
||||
clearTopupTracking as clearTopupUtil,
|
||||
startTopupTracking as startTopupUtil
|
||||
} from '@/platform/telemetry/topupTracker'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
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 { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
@@ -282,7 +274,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = this.getExecutionContext()
|
||||
const executionContext = getExecutionContext()
|
||||
|
||||
const runButtonProperties: RunButtonProperties = {
|
||||
subscribe_to_run: options?.subscribe_to_run || false,
|
||||
@@ -407,7 +399,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = this.getExecutionContext()
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
@@ -431,117 +423,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
|
||||
toolkit_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
}
|
||||
|
||||
const nodeCounts = reduceAllNodes<NodeMetrics>(
|
||||
app.rootGraph,
|
||||
(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)
|
||||
}
|
||||
}
|
||||
|
||||
const isToolkitNode =
|
||||
TOOLKIT_NODE_NAMES.has(node.type) ||
|
||||
(nodeDef?.python_module !== undefined &&
|
||||
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
|
||||
if (isToolkitNode) {
|
||||
metrics.has_toolkit_nodes = true
|
||||
const trackingName = nodeDef?.name ?? node.type
|
||||
if (!metrics.toolkit_node_names.includes(trackingName)) {
|
||||
metrics.toolkit_node_names.push(trackingName)
|
||||
}
|
||||
}
|
||||
|
||||
metrics.custom_node_count += isCustomNode ? 1 : 0
|
||||
metrics.api_node_count += isApiNode ? 1 : 0
|
||||
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
|
||||
metrics.subgraph_count += isSubgraph ? 1 : 0
|
||||
metrics.total_node_count += 1
|
||||
|
||||
return metrics
|
||||
},
|
||||
{
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
toolkit_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const mockCapture = vi.fn()
|
||||
const mockInit = vi.fn()
|
||||
const mockIdentify = vi.fn()
|
||||
const mockPeopleSet = vi.fn()
|
||||
const mockOnUserResolved = vi.fn()
|
||||
|
||||
return {
|
||||
mockCapture,
|
||||
mockInit,
|
||||
mockIdentify,
|
||||
mockPeopleSet,
|
||||
mockOnUserResolved,
|
||||
mockPosthog: {
|
||||
default: {
|
||||
init: mockInit,
|
||||
capture: mockCapture,
|
||||
identify: mockIdentify,
|
||||
people: { set: mockPeopleSet }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
watch: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
onUserResolved: hoisted.mockOnUserResolved
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: { value: null }
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => hoisted.mockPosthog)
|
||||
|
||||
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
|
||||
|
||||
function createProvider(
|
||||
config: Partial<typeof window.__CONFIG__> = {}
|
||||
): PostHogTelemetryProvider {
|
||||
const original = window.__CONFIG__
|
||||
window.__CONFIG__ = { ...original, ...config }
|
||||
const provider = new PostHogTelemetryProvider()
|
||||
window.__CONFIG__ = original
|
||||
return provider
|
||||
}
|
||||
|
||||
describe('PostHogTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token'
|
||||
} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('disables itself when posthog_project_token is not provided', async () => {
|
||||
const provider = createProvider({ posthog_project_token: undefined })
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackSignupOpened()
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls posthog.init with the token and default api_host', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
|
||||
api_host: 'https://ph.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses custom api_host from config when provided', async () => {
|
||||
window.__CONFIG__ = {
|
||||
posthog_project_token: 'phc_test_token',
|
||||
posthog_api_host: 'https://custom.host.com'
|
||||
} as typeof window.__CONFIG__
|
||||
new PostHogTelemetryProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledWith(
|
||||
'phc_test_token',
|
||||
expect.objectContaining({ api_host: 'https://custom.host.com' })
|
||||
)
|
||||
})
|
||||
|
||||
it('registers onUserResolved callback after init', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockOnUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies user when onUserResolved fires', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const callback = hoisted.mockOnUserResolved.mock.calls[0][0]
|
||||
callback({ id: 'user-123' })
|
||||
|
||||
expect(hoisted.mockIdentify).toHaveBeenCalledWith('user-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('event tracking', () => {
|
||||
it('captures events after initialization', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackSignupOpened()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_SIGN_UP_OPENED,
|
||||
{}
|
||||
)
|
||||
})
|
||||
|
||||
it('captures events with metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ method: 'google' })
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
{ method: 'google' }
|
||||
)
|
||||
})
|
||||
|
||||
it('queues events before initialization and flushes after', async () => {
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackUserLoggedIn()
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_LOGGED_IN,
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled events', () => {
|
||||
it('does not capture default disabled events', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowOpened({
|
||||
missing_node_count: 0,
|
||||
missing_node_types: []
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('captures events not in the disabled list', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackMonthlySubscriptionSucceeded()
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED,
|
||||
{}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('survey tracking', () => {
|
||||
it('sets user properties on survey submission', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const responses = { familiarity: 'beginner', industry: 'tech' }
|
||||
provider.trackSurvey('submitted', responses)
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_SURVEY_SUBMITTED,
|
||||
expect.objectContaining({ familiarity: 'beginner' })
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not set user properties on survey opened', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackSurvey('opened')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.USER_SURVEY_OPENED,
|
||||
{}
|
||||
)
|
||||
expect(hoisted.mockPeopleSet).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('page view', () => {
|
||||
it('captures page view with page_name property', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackPageView('workflow_editor')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor' }
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards additional metadata', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackPageView('workflow_editor', {
|
||||
path: '/workflows/123'
|
||||
})
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
TelemetryEvents.PAGE_VIEW,
|
||||
{ page_name: 'workflow_editor', path: '/workflows/123' }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,417 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import type {
|
||||
AuthMetadata,
|
||||
EnterLinearMetadata,
|
||||
ExecutionContext,
|
||||
ExecutionErrorMetadata,
|
||||
ExecutionSuccessMetadata,
|
||||
ExecutionTriggerSource,
|
||||
HelpCenterClosedMetadata,
|
||||
HelpCenterOpenedMetadata,
|
||||
HelpResourceClickedMetadata,
|
||||
NodeSearchMetadata,
|
||||
NodeSearchResultMetadata,
|
||||
PageViewMetadata,
|
||||
PageVisibilityMetadata,
|
||||
RunButtonProperties,
|
||||
SettingChangedMetadata,
|
||||
SubscriptionMetadata,
|
||||
SurveyResponses,
|
||||
TabCountMetadata,
|
||||
TelemetryEventName,
|
||||
TelemetryEventProperties,
|
||||
TelemetryProvider,
|
||||
TemplateFilterMetadata,
|
||||
TemplateLibraryClosedMetadata,
|
||||
TemplateLibraryMetadata,
|
||||
TemplateMetadata,
|
||||
UiButtonClickMetadata,
|
||||
WorkflowCreatedMetadata,
|
||||
WorkflowImportMetadata
|
||||
} from '../../types'
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import { getExecutionContext } from '../../utils/getExecutionContext'
|
||||
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
|
||||
|
||||
const DEFAULT_DISABLED_EVENTS = [
|
||||
TelemetryEvents.WORKFLOW_OPENED,
|
||||
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
|
||||
TelemetryEvents.TAB_COUNT_TRACKING,
|
||||
TelemetryEvents.NODE_SEARCH,
|
||||
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
|
||||
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
|
||||
TelemetryEvents.SETTING_CHANGED,
|
||||
TelemetryEvents.HELP_CENTER_OPENED,
|
||||
TelemetryEvents.HELP_RESOURCE_CLICKED,
|
||||
TelemetryEvents.HELP_CENTER_CLOSED,
|
||||
TelemetryEvents.WORKFLOW_CREATED,
|
||||
TelemetryEvents.UI_BUTTON_CLICKED
|
||||
] as const satisfies TelemetryEventName[]
|
||||
|
||||
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
|
||||
Object.values(TelemetryEvents) as TelemetryEventName[]
|
||||
)
|
||||
|
||||
interface QueuedEvent {
|
||||
eventName: TelemetryEventName
|
||||
properties?: TelemetryEventProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog Telemetry Provider - Cloud Build Implementation
|
||||
*
|
||||
* Sends all telemetry events to PostHog so they can be correlated
|
||||
* with session recordings. Follows the same pattern as MixpanelTelemetryProvider.
|
||||
*
|
||||
* CRITICAL: OSS Build Safety
|
||||
* Entire file is tree-shaken away in OSS builds (DISTRIBUTION unset).
|
||||
*/
|
||||
export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
private isEnabled = true
|
||||
private posthog: PostHog | null = null
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private isInitialized = false
|
||||
private lastTriggerSource: ExecutionTriggerSource | undefined
|
||||
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
|
||||
|
||||
constructor() {
|
||||
this.configureDisabledEvents(
|
||||
(window.__CONFIG__ as Partial<RemoteConfig> | undefined) ?? null
|
||||
)
|
||||
watch(
|
||||
remoteConfig,
|
||||
(config) => {
|
||||
this.configureDisabledEvents(config)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const apiKey = window.__CONFIG__?.posthog_project_token
|
||||
if (apiKey) {
|
||||
try {
|
||||
void import('posthog-js')
|
||||
.then((posthogModule) => {
|
||||
this.posthog = posthogModule.default
|
||||
this.posthog!.init(apiKey, {
|
||||
api_host:
|
||||
window.__CONFIG__?.posthog_api_host || 'https://ph.comfy.org',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie'
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
|
||||
useCurrentUser().onUserResolved((user) => {
|
||||
if (this.posthog && user.id) {
|
||||
this.posthog.identify(user.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load PostHog:', error)
|
||||
this.isEnabled = false
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize PostHog:', error)
|
||||
this.isEnabled = false
|
||||
}
|
||||
} else {
|
||||
console.warn('PostHog API key not provided in runtime config')
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
private flushEventQueue(): void {
|
||||
if (!this.isInitialized || !this.posthog) return
|
||||
|
||||
while (this.eventQueue.length > 0) {
|
||||
const event = this.eventQueue.shift()!
|
||||
try {
|
||||
this.posthog.capture(event.eventName, event.properties || {})
|
||||
} catch (error) {
|
||||
console.error('Failed to track queued PostHog event:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private trackEvent(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: TelemetryEventProperties
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
if (this.disabledEvents.has(eventName)) return
|
||||
|
||||
const event: QueuedEvent = { eventName, properties }
|
||||
|
||||
if (this.isInitialized && this.posthog) {
|
||||
try {
|
||||
this.posthog.capture(eventName, properties || {})
|
||||
} catch (error) {
|
||||
console.error('Failed to track PostHog event:', error)
|
||||
}
|
||||
} else {
|
||||
this.eventQueue.push(event)
|
||||
}
|
||||
}
|
||||
|
||||
private captureRaw(
|
||||
eventName: TelemetryEventName,
|
||||
properties?: Record<string, unknown>
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
if (this.disabledEvents.has(eventName)) return
|
||||
|
||||
if (this.isInitialized && this.posthog) {
|
||||
try {
|
||||
this.posthog.capture(eventName, properties || {})
|
||||
} catch (error) {
|
||||
console.error('Failed to track PostHog event:', error)
|
||||
}
|
||||
} else {
|
||||
this.eventQueue.push({
|
||||
eventName,
|
||||
properties: properties as TelemetryEventProperties
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private configureDisabledEvents(config: Partial<RemoteConfig> | null): void {
|
||||
const disabledSource =
|
||||
config?.telemetry_disabled_events ?? DEFAULT_DISABLED_EVENTS
|
||||
|
||||
this.disabledEvents = this.buildEventSet(disabledSource)
|
||||
}
|
||||
|
||||
private buildEventSet(values: TelemetryEventName[]): Set<TelemetryEventName> {
|
||||
return new Set(
|
||||
values.filter((value) => {
|
||||
const isValid = TELEMETRY_EVENT_SET.has(value)
|
||||
if (!isValid && import.meta.env.DEV) {
|
||||
console.warn(
|
||||
`Unknown telemetry event name in disabled list: ${value}`
|
||||
)
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
trackSignupOpened(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
}
|
||||
|
||||
trackUserLoggedIn(): void {
|
||||
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
event: 'modal_opened' | 'subscribe_clicked',
|
||||
metadata?: SubscriptionMetadata
|
||||
): void {
|
||||
const eventName =
|
||||
event === 'modal_opened'
|
||||
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
|
||||
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
|
||||
|
||||
this.trackEvent(eventName, metadata)
|
||||
}
|
||||
|
||||
trackAddApiCreditButtonClicked(): void {
|
||||
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackMonthlySubscriptionCancelled(): void {
|
||||
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_CANCELLED)
|
||||
}
|
||||
|
||||
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED, {
|
||||
credit_amount: amount
|
||||
})
|
||||
}
|
||||
|
||||
trackApiCreditTopupSucceeded(): void {
|
||||
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
|
||||
}
|
||||
|
||||
trackRunButton(options?: {
|
||||
subscribe_to_run?: boolean
|
||||
trigger_source?: ExecutionTriggerSource
|
||||
}): void {
|
||||
const executionContext = 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,
|
||||
has_toolkit_nodes: executionContext.has_toolkit_nodes,
|
||||
toolkit_node_names: executionContext.toolkit_node_names,
|
||||
trigger_source: options?.trigger_source
|
||||
}
|
||||
|
||||
this.lastTriggerSource = options?.trigger_source
|
||||
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
|
||||
}
|
||||
|
||||
trackSurvey(
|
||||
stage: 'opened' | 'submitted',
|
||||
responses?: SurveyResponses
|
||||
): void {
|
||||
const eventName =
|
||||
stage === 'opened'
|
||||
? TelemetryEvents.USER_SURVEY_OPENED
|
||||
: TelemetryEvents.USER_SURVEY_SUBMITTED
|
||||
|
||||
const normalizedResponses = responses
|
||||
? normalizeSurveyResponses(responses)
|
||||
: undefined
|
||||
|
||||
this.trackEvent(eventName, normalizedResponses)
|
||||
|
||||
if (
|
||||
stage === 'submitted' &&
|
||||
normalizedResponses &&
|
||||
this.posthog &&
|
||||
this.isEnabled &&
|
||||
!this.disabledEvents.has(TelemetryEvents.USER_SURVEY_SUBMITTED)
|
||||
) {
|
||||
try {
|
||||
this.posthog.people.set(normalizedResponses)
|
||||
} catch (error) {
|
||||
console.error('Failed to set PostHog user properties:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
|
||||
let eventName: TelemetryEventName
|
||||
|
||||
switch (stage) {
|
||||
case 'opened':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
|
||||
break
|
||||
case 'requested':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
|
||||
break
|
||||
case 'completed':
|
||||
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
|
||||
break
|
||||
}
|
||||
|
||||
this.trackEvent(eventName)
|
||||
}
|
||||
|
||||
trackTemplate(metadata: TemplateMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackEnterLinear(metadata: EnterLinearMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
|
||||
}
|
||||
|
||||
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
|
||||
}
|
||||
|
||||
trackTabCount(metadata: TabCountMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
|
||||
}
|
||||
|
||||
trackNodeSearch(metadata: NodeSearchMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
|
||||
}
|
||||
|
||||
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
|
||||
}
|
||||
|
||||
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
|
||||
}
|
||||
|
||||
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
|
||||
}
|
||||
|
||||
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
|
||||
}
|
||||
|
||||
trackWorkflowExecution(): void {
|
||||
const context = getExecutionContext()
|
||||
const eventContext: ExecutionContext = {
|
||||
...context,
|
||||
trigger_source: this.lastTriggerSource ?? 'unknown'
|
||||
}
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_START, eventContext)
|
||||
this.lastTriggerSource = undefined
|
||||
}
|
||||
|
||||
trackExecutionError(metadata: ExecutionErrorMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
|
||||
}
|
||||
|
||||
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
|
||||
}
|
||||
|
||||
trackSettingChanged(metadata: SettingChangedMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.SETTING_CHANGED, metadata)
|
||||
}
|
||||
|
||||
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
|
||||
this.trackEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
|
||||
}
|
||||
|
||||
trackPageView(pageName: string, properties?: PageViewMetadata): void {
|
||||
this.captureRaw(TelemetryEvents.PAGE_VIEW, {
|
||||
page_name: pageName,
|
||||
...properties
|
||||
})
|
||||
}
|
||||
}
|
||||
119
src/platform/telemetry/utils/getExecutionContext.ts
Normal file
119
src/platform/telemetry/utils/getExecutionContext.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
TOOLKIT_BLUEPRINT_MODULES,
|
||||
TOOLKIT_NODE_NAMES
|
||||
} from '@/constants/toolkitNodes'
|
||||
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 { ExecutionContext } from '../types'
|
||||
|
||||
type NodeMetrics = {
|
||||
custom_node_count: number
|
||||
api_node_count: number
|
||||
toolkit_node_count: number
|
||||
subgraph_count: number
|
||||
total_node_count: number
|
||||
has_api_nodes: boolean
|
||||
api_node_names: string[]
|
||||
has_toolkit_nodes: boolean
|
||||
toolkit_node_names: string[]
|
||||
}
|
||||
|
||||
export function getExecutionContext(): ExecutionContext {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const templatesStore = useWorkflowTemplatesStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
|
||||
const nodeCounts = reduceAllNodes<NodeMetrics>(
|
||||
app.rootGraph,
|
||||
(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)
|
||||
}
|
||||
}
|
||||
|
||||
const isToolkitNode =
|
||||
TOOLKIT_NODE_NAMES.has(node.type) ||
|
||||
(nodeDef?.python_module !== undefined &&
|
||||
TOOLKIT_BLUEPRINT_MODULES.has(nodeDef.python_module))
|
||||
if (isToolkitNode) {
|
||||
metrics.has_toolkit_nodes = true
|
||||
const trackingName = nodeDef?.name ?? node.type
|
||||
if (!metrics.toolkit_node_names.includes(trackingName)) {
|
||||
metrics.toolkit_node_names.push(trackingName)
|
||||
}
|
||||
}
|
||||
|
||||
metrics.custom_node_count += isCustomNode ? 1 : 0
|
||||
metrics.api_node_count += isApiNode ? 1 : 0
|
||||
metrics.toolkit_node_count += isToolkitNode ? 1 : 0
|
||||
metrics.subgraph_count += isSubgraph ? 1 : 0
|
||||
metrics.total_node_count += 1
|
||||
|
||||
return metrics
|
||||
},
|
||||
{
|
||||
custom_node_count: 0,
|
||||
api_node_count: 0,
|
||||
toolkit_node_count: 0,
|
||||
subgraph_count: 0,
|
||||
total_node_count: 0,
|
||||
has_api_nodes: false,
|
||||
api_node_names: [],
|
||||
has_toolkit_nodes: false,
|
||||
toolkit_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user