This commit is contained in:
bymyself
2025-11-13 09:50:54 -08:00
parent 2c4280a28d
commit a1c92274fb
8 changed files with 664 additions and 48 deletions

View File

@@ -16,27 +16,40 @@
*/
import { isCloud } from '@/platform/distribution/types'
import { CloudAnalyticsProvider } from './providers/cloud/CloudAnalyticsProvider'
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 useTelemetry(): TelemetryService | null {
if (_telemetryService === null) {
// Use distribution check for tree-shaking
if (isCloud) {
_telemetryProvider = new MixpanelTelemetryProvider()
_telemetryService = new TelemetryService()
// Initialize and add both analytics providers
const mixpanelProvider = new MixpanelTelemetryProvider()
const cloudAnalyticsProvider = new CloudAnalyticsProvider()
void mixpanelProvider.initialize().then(() => {
_telemetryService?.addProvider(mixpanelProvider)
})
void cloudAnalyticsProvider.initialize().then(() => {
_telemetryService?.addProvider(cloudAnalyticsProvider)
})
}
// For OSS builds, _telemetryProvider stays null
// For OSS builds, _telemetryService stays null
}
return _telemetryProvider
return _telemetryService
}

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

@@ -0,0 +1,210 @@
import { api } from '@/scripts/api'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
SurveyResponses,
TabCountMetadata,
TelemetryEventName,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { TelemetryProviderBase } from '../TelemetryProviderBase'
/**
* Cloud Analytics Provider for server-side event tracking.
* Posts events to the backend's cloud analytics service (ClickHouse) via api.postCloudAnalytics.
* This complements client-side tracking (Mixpanel) with server-side data collection.
*/
export class CloudAnalyticsProvider extends TelemetryProviderBase {
async initialize(): Promise<void> {
this.isInitialized = true
}
private async postEvent(
eventName: TelemetryEventName,
eventData?: any
): Promise<void> {
if (!this.isEnabled || !this.isInitialized) {
return
}
try {
await api.postCloudAnalytics(eventName, eventData || {})
} catch (error) {
console.error('Failed to post cloud analytics event:', error)
}
}
trackAuth(metadata: AuthMetadata): void {
void this.postEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
void this.postEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED
void this.postEvent(eventName)
}
trackMonthlySubscriptionSucceeded(): void {
void this.postEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackAddApiCreditButtonClicked(): void {
void this.postEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = { credit_amount: amount }
void this.postEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackApiCreditTopupSucceeded(): void {
void this.postEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(properties: RunButtonProperties): void {
void this.postEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
startTopupTracking(): void {
// Not applicable for server-side tracking
}
checkForCompletedTopup(_events: any[] | undefined | null): boolean {
// Not applicable for server-side tracking
return false
}
clearTopupTracking(): void {
// Not applicable for server-side tracking
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses
): void {
const eventName =
stage === 'opened'
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
void this.postEvent(eventName, responses)
}
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
}
void this.postEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackTemplateLibraryClosed(metadata: TemplateLibraryClosedMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_LIBRARY_CLOSED, metadata)
}
trackWorkflowImported(metadata: WorkflowImportMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_IMPORTED, metadata)
}
trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackPageVisibilityChanged(metadata: PageVisibilityMetadata): void {
void this.postEvent(TelemetryEvents.PAGE_VISIBILITY_CHANGED, metadata)
}
trackTabCount(metadata: TabCountMetadata): void {
void this.postEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
void this.postEvent(TelemetryEvents.NODE_SEARCH, metadata)
}
trackNodeSearchResultSelected(metadata: NodeSearchResultMetadata): void {
void this.postEvent(TelemetryEvents.NODE_SEARCH_RESULT_SELECTED, metadata)
}
trackTemplateFilterChanged(metadata: TemplateFilterMetadata): void {
void this.postEvent(TelemetryEvents.TEMPLATE_FILTER_CHANGED, metadata)
}
trackHelpCenterOpened(metadata: HelpCenterOpenedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_CENTER_OPENED, metadata)
}
trackHelpResourceClicked(metadata: HelpResourceClickedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_RESOURCE_CLICKED, metadata)
}
trackHelpCenterClosed(metadata: HelpCenterClosedMetadata): void {
void this.postEvent(TelemetryEvents.HELP_CENTER_CLOSED, metadata)
}
trackWorkflowCreated(metadata: WorkflowCreatedMetadata): void {
void this.postEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackWorkflowExecution(context?: ExecutionContext): void {
void this.postEvent(TelemetryEvents.EXECUTION_START, context)
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
void this.postEvent(TelemetryEvents.EXECUTION_ERROR, metadata)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
void this.postEvent(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackSettingChanged(metadata: SettingChangedMetadata): void {
void this.postEvent(TelemetryEvents.SETTING_CHANGED, metadata)
}
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
void this.postEvent(TelemetryEvents.UI_BUTTON_CLICKED, metadata)
}
}

View File

@@ -32,7 +32,6 @@ import type {
TabCountMetadata,
TelemetryEventName,
TelemetryEventProperties,
TelemetryProvider,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
@@ -43,6 +42,7 @@ import type {
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
import { TelemetryProviderBase } from '../TelemetryProviderBase'
interface QueuedEvent {
eventName: TelemetryEventName
@@ -61,50 +61,42 @@ 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() {
async initialize(): Promise<void> {
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)
}
})
}
})
if (!token) {
this.setEnabled(false)
return
}
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',
loaded: () => {
this.isInitialized = true
this.flushEventQueue()
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
}
})
} catch (error) {
console.error('Failed to load Mixpanel:', error)
this.setEnabled(false)
}
}

View File

@@ -0,0 +1,239 @@
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 coordinates multiple analytics providers.
* Uses registered hooks to gather context data from application stores
* without creating circular import dependencies.
*/
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 {
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 {
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

@@ -314,7 +314,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

@@ -1251,6 +1251,28 @@ export class ComfyApi extends EventTarget {
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
}
/**
* Posts analytics event to cloud analytics service
* @param eventName The name of the analytics event
* @param eventData The event data (any JSON-serializable object)
* @returns Promise resolving to the response
*/
async postCloudAnalytics(
eventName: string,
eventData: any
): Promise<Response> {
return this.fetchApi(this.internalURL('/cloud_analytics'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
event_name: eventName,
event_data: eventData
})
})
}
}
export const api = new ComfyApi()