[backport cloud/1.40] feat: Add PostHog telemetry provider (#9457)

Backport of #9409 to `cloud/1.40`.

Automatically created by manual backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9457-backport-cloud-1-40-feat-Add-PostHog-telemetry-provider-31b6d73d365081ac9eb4d596daafddf3)
by [Unito](https://www.unito.io)
This commit is contained in:
Robin Huang
2026-03-05 20:54:12 -08:00
committed by GitHub
parent 416f96649b
commit 10a680f690
14 changed files with 3188 additions and 2357 deletions

3
.github/license-clarifications.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"posthog-js@*": { "licenses": "Apache-2.0" }
}

View File

@@ -79,3 +79,22 @@ jobs:
exit 1
fi
echo '✅ No Mixpanel references found'
- name: Scan dist for PostHog telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for PostHog references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)posthog\.init' \
-e '(?i)posthog\.capture' \
-e 'PostHogTelemetryProvider' \
-e 'ph\.comfy\.org' \
-e 'posthog-js' \
dist; then
echo '❌ ERROR: PostHog references found in dist assets!'
echo 'PostHog must be properly tree-shaken from OSS builds.'
exit 1
fi
echo '✅ No PostHog references found'

View File

@@ -90,7 +90,6 @@ const preview: Preview = {
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' }
],
showName: true,
dynamicTitle: true
}
}

2
global.d.ts vendored
View File

@@ -33,6 +33,8 @@ interface Window {
gtm_container_id?: string
ga_measurement_id?: string
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -100,6 +100,7 @@
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "catalog:",
"posthog-js": "catalog:",
"primeicons": "catalog:",
"primevue": "catalog:",
"reka-ui": "catalog:",

4579
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -77,6 +77,7 @@ catalog:
picocolors: ^1.1.1
pinia: ^3.0.4
postcss-html: ^1.8.0
posthog-js: ^1.358.1
pretty-bytes: ^7.1.0
primeicons: ^7.0.0
primevue: ^4.2.5

View File

@@ -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

View File

@@ -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)
})()

View File

@@ -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'])

View File

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

View File

@@ -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' }
)
})
})
})

View File

@@ -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
})
}
}

View 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
}
}