Compare commits

...

4 Commits

Author SHA1 Message Date
huang47
b8f1c86a71 feat: attribute RUM workflow vitals to origin views 2026-07-17 13:32:39 -07:00
huang47
1f2f738585 feat: record RUM workflow execution vitals 2026-07-17 13:31:53 -07:00
huang47
3fdfc2ac3e feat: initialize Datadog RUM telemetry provider 2026-07-17 13:29:34 -07:00
huang47
4c3c25bb59 fix: initialize Cloud RUM before app bootstrap 2026-07-15 16:35:45 -07:00
10 changed files with 178 additions and 4 deletions

View File

@@ -7,9 +7,8 @@ import type {
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
ExecutionErrorMetadata,
ExecutionOutcomeMetadata,
ExecutionSuccessMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
@@ -22,6 +21,8 @@ import type {
PageVisibilityMetadata,
ResubscribeClickMetadata,
RunButtonProperties,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
@@ -268,6 +269,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowExecution?.())
}
trackExecutionOutcome(metadata: ExecutionOutcomeMetadata): void {
this.dispatch((provider) => provider.trackExecutionOutcome?.(metadata))
}
trackExecutionError(metadata: ExecutionErrorMetadata): void {
this.dispatch((provider) => provider.trackExecutionError?.(metadata))
}

View File

@@ -28,7 +28,8 @@ export async function initTelemetry(): Promise<void> {
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider },
{ SyftTelemetryProvider },
{ CustomerIoTelemetryProvider }
{ CustomerIoTelemetryProvider },
{ DatadogRumTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
@@ -37,7 +38,8 @@ export async function initTelemetry(): Promise<void> {
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/SyftTelemetryProvider'),
import('./providers/cloud/CustomerIoTelemetryProvider')
import('./providers/cloud/CustomerIoTelemetryProvider'),
import('./providers/cloud/DatadogRumTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -48,6 +50,7 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new SyftTelemetryProvider())
registry.registerProvider(new CustomerIoTelemetryProvider())
registry.registerProvider(new DatadogRumTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DatadogRumTelemetryProvider } from './DatadogRumTelemetryProvider'
const addDurationVital = vi.fn()
const getInternalContext = vi.fn()
function installDatadogRum(): void {
Object.defineProperty(window, 'DD_RUM', {
configurable: true,
value: { addDurationVital, getInternalContext }
})
}
afterEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
Reflect.deleteProperty(window, 'DD_RUM')
})
describe('DatadogRumTelemetryProvider', () => {
it('records a workflow vital with its duration and outcome', () => {
installDatadogRum()
getInternalContext.mockReturnValue({ view: { id: 'view-a' } })
vi.spyOn(performance, 'now').mockReturnValue(142)
new DatadogRumTelemetryProvider().trackExecutionOutcome({
startTime: 42,
outcome: 'success'
})
expect(getInternalContext).toHaveBeenCalledWith(42)
expect(addDurationVital).toHaveBeenCalledWith('workflow_execution', {
startTime: performance.timeOrigin + 42,
duration: 100,
context: {
origin_view_id: 'view-a',
outcome: 'success',
product: 'cloud_generation'
}
})
})
it('does nothing when Datadog RUM is unavailable', () => {
expect(() =>
new DatadogRumTelemetryProvider().trackExecutionOutcome({
startTime: 42,
outcome: 'failure'
})
).not.toThrow()
})
})

View File

@@ -0,0 +1,43 @@
import type { ExecutionOutcomeMetadata, TelemetryProvider } from '../../types'
interface DatadogRumDurationVitalOptions {
startTime: number
duration: number
context?: Record<string, unknown>
}
interface DatadogRumInternalContext {
view?: { id?: string }
}
interface DatadogRumClient {
addDurationVital(name: string, options: DatadogRumDurationVitalOptions): void
getInternalContext(startTime?: number): DatadogRumInternalContext | undefined
}
interface WindowWithDatadogRum extends Window {
DD_RUM?: DatadogRumClient
}
function getDatadogRum(): DatadogRumClient | undefined {
return (window as WindowWithDatadogRum).DD_RUM
}
export class DatadogRumTelemetryProvider implements TelemetryProvider {
trackExecutionOutcome({
startTime,
outcome
}: ExecutionOutcomeMetadata): void {
const rum = getDatadogRum()
const originViewId = rum?.getInternalContext(startTime)?.view?.id
rum?.addDurationVital('workflow_execution', {
startTime: performance.timeOrigin + startTime,
duration: performance.now() - startTime,
context: {
outcome,
product: 'cloud_generation',
...(originViewId && { origin_view_id: originViewId })
}
})
}
}

View File

@@ -156,6 +156,11 @@ export interface ExecutionErrorMetadata {
error?: string
}
export interface ExecutionOutcomeMetadata {
startTime: number
outcome: 'success' | 'failure'
}
/**
* Execution success metadata
*/
@@ -632,6 +637,7 @@ export interface TelemetryProvider {
// Workflow execution events
trackWorkflowExecution?(): void
trackExecutionOutcome?(metadata: ExecutionOutcomeMetadata): void
trackExecutionError?(metadata: ExecutionErrorMetadata): void
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
trackSharedWorkflowRun?(metadata: SharedWorkflowRunMetadata): void

View File

@@ -1662,6 +1662,7 @@ export class ComfyApp {
// user switches tabs while the request is in flight.
const queuedWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
const startTime = performance.now()
const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
try {
@@ -1685,6 +1686,7 @@ export class ComfyApp {
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
startTime,
workflow: queuedWorkflow
})
}

View File

@@ -42,6 +42,11 @@ export const useExtensionService = () => {
await Promise.all(
extensions
.filter((extension) => !extension.includes('extensions/core'))
.filter(
(extension) =>
__DISTRIBUTION__ !== 'cloud' ||
extension !== '/extensions/cloud/rum.js'
)
.map(async (ext) => {
try {
await import(/* @vite-ignore */ api.fileURL(ext))

View File

@@ -21,6 +21,7 @@ const {
mockOpenWorkflows,
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionOutcome,
mockTrackExecutionSuccess,
mockTrackSharedWorkflowRun
} = await vi.hoisted(async () => {
@@ -33,6 +34,7 @@ const {
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionOutcome: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}
@@ -92,6 +94,7 @@ vi.mock('@/platform/distribution/types', async () => ({
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackExecutionError: mockTrackExecutionError,
trackExecutionOutcome: mockTrackExecutionOutcome,
trackExecutionSuccess: mockTrackExecutionSuccess,
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
})
@@ -614,6 +617,7 @@ describe('useExecutionStore - workflowStatus', () => {
nodes: ['1'],
id: jobId,
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
startTime: 42,
workflow
})
}
@@ -631,6 +635,7 @@ describe('useExecutionStore - workflowStatus', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
expect(mockTrackExecutionOutcome).not.toHaveBeenCalled()
expect(store.getWorkflowStatus(workflowA)).toBe('running')
})
@@ -648,6 +653,11 @@ describe('useExecutionStore - workflowStatus', () => {
fireExecutionSuccess('job-1')
callStoreJob('job-1', workflowA)
expect(mockTrackExecutionOutcome).toHaveBeenCalledOnce()
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
startTime: 42,
outcome: 'success'
})
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
@@ -656,6 +666,10 @@ describe('useExecutionStore - workflowStatus', () => {
fireExecutionError('job-1')
callStoreJob('job-1', workflowA)
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
startTime: 42,
outcome: 'failure'
})
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
})
@@ -672,6 +686,10 @@ describe('useExecutionStore - workflowStatus', () => {
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
startTime: 42,
outcome: 'success'
})
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})

View File

@@ -51,6 +51,7 @@ interface QueuedJob {
* value is a boolean indicating if the node has been executed.
*/
nodes: Record<string, boolean>
startTime?: number
/**
* The workflow that is queued to be executed
*/
@@ -177,6 +178,19 @@ export const useExecutionStore = defineStore('execution', () => {
mutateStatus((m) => m.set(workflow, status))
}
function trackExecutionOutcome(
jobId: string,
status: WorkflowExecutionStatus
) {
if (status === 'running') return
const startTime = queuedJobs.value[jobId]?.startTime
if (startTime === undefined) return
useTelemetry()?.trackExecutionOutcome({
startTime,
outcome: status === 'completed' ? 'success' : 'failure'
})
}
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
const workflow = jobIdToWorkflow.get(jobId)
if (!workflow) {
@@ -184,6 +198,7 @@ export const useExecutionStore = defineStore('execution', () => {
return
}
applyWorkflowStatus(workflow, status)
trackExecutionOutcome(jobId, status)
}
function clearWorkflowStatus(workflow: ComfyWorkflow) {
@@ -718,11 +733,13 @@ export const useExecutionStore = defineStore('execution', () => {
nodes,
id,
promptOutput,
startTime,
workflow
}: {
nodes: string[]
id: JobId
promptOutput: ComfyApiWorkflow
startTime?: number
workflow: ComfyWorkflow
}) {
queuedJobs.value[id] ??= { nodes: {} }
@@ -735,6 +752,7 @@ export const useExecutionStore = defineStore('execution', () => {
...queuedJob.nodes
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.startTime = startTime
queuedJob.workflow = workflow
if (workflow) jobIdToWorkflow.set(String(id), workflow)
queuedJob.shareId = workflow?.shareId
@@ -761,6 +779,7 @@ export const useExecutionStore = defineStore('execution', () => {
// Don't let a stale 'running' overwrite a terminal status already set.
if (pending === 'running' && workflowStatus.value.has(workflow)) return
applyWorkflowStatus(workflow, pending)
trackExecutionOutcome(jobId, pending)
}
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)

View File

@@ -334,6 +334,27 @@ export default defineConfig({
tailwindcss(),
typegpuPlugin({}),
comfyAPIPlugin(IS_DEV),
{
name: 'inject-cloud-rum',
apply: 'build',
transformIndexHtml(html) {
if (DISTRIBUTION !== 'cloud') return html
return {
html,
tags: [
{
tag: 'script',
attrs: {
type: 'module',
src: '/extensions/cloud/rum.js'
},
injectTo: 'head-prepend'
}
]
}
}
},
// Exclude proprietary ABCROM fonts from non-cloud builds
{
name: 'exclude-proprietary-fonts',