Skip to content

Commit 1f2f738

Browse files
committed
feat: record RUM workflow execution vitals
1 parent 3fdfc2a commit 1f2f738

5 files changed

Lines changed: 121 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { DatadogRumTelemetryProvider } from './DatadogRumTelemetryProvider'
4+
5+
const addDurationVital = vi.fn()
6+
7+
function installDatadogRum(): void {
8+
Object.defineProperty(window, 'DD_RUM', {
9+
configurable: true,
10+
value: { addDurationVital }
11+
})
12+
}
13+
14+
afterEach(() => {
15+
vi.restoreAllMocks()
16+
vi.clearAllMocks()
17+
Reflect.deleteProperty(window, 'DD_RUM')
18+
})
19+
20+
describe('DatadogRumTelemetryProvider', () => {
21+
it('records a workflow vital with its duration and outcome', () => {
22+
installDatadogRum()
23+
vi.spyOn(performance, 'now').mockReturnValue(142)
24+
25+
new DatadogRumTelemetryProvider().trackExecutionOutcome({
26+
startTime: 42,
27+
outcome: 'success'
28+
})
29+
30+
expect(addDurationVital).toHaveBeenCalledWith('workflow_execution', {
31+
startTime: performance.timeOrigin + 42,
32+
duration: 100,
33+
context: {
34+
outcome: 'success',
35+
product: 'cloud_generation'
36+
}
37+
})
38+
})
39+
40+
it('does nothing when Datadog RUM is unavailable', () => {
41+
expect(() =>
42+
new DatadogRumTelemetryProvider().trackExecutionOutcome({
43+
startTime: 42,
44+
outcome: 'failure'
45+
})
46+
).not.toThrow()
47+
})
48+
})
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,35 @@
1-
import type { TelemetryProvider } from '../../types'
1+
import type { ExecutionOutcomeMetadata, TelemetryProvider } from '../../types'
22

3-
export class DatadogRumTelemetryProvider implements TelemetryProvider {}
3+
interface DatadogRumDurationVitalOptions {
4+
startTime: number
5+
duration: number
6+
context?: Record<string, unknown>
7+
}
8+
9+
interface DatadogRumClient {
10+
addDurationVital(name: string, options: DatadogRumDurationVitalOptions): void
11+
}
12+
13+
interface WindowWithDatadogRum extends Window {
14+
DD_RUM?: DatadogRumClient
15+
}
16+
17+
function getDatadogRum(): DatadogRumClient | undefined {
18+
return (window as WindowWithDatadogRum).DD_RUM
19+
}
20+
21+
export class DatadogRumTelemetryProvider implements TelemetryProvider {
22+
trackExecutionOutcome({
23+
startTime,
24+
outcome
25+
}: ExecutionOutcomeMetadata): void {
26+
getDatadogRum()?.addDurationVital('workflow_execution', {
27+
startTime: performance.timeOrigin + startTime,
28+
duration: performance.now() - startTime,
29+
context: {
30+
outcome,
31+
product: 'cloud_generation'
32+
}
33+
})
34+
}
35+
}

src/scripts/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1662,6 +1662,7 @@ export class ComfyApp {
16621662
// user switches tabs while the request is in flight.
16631663
const queuedWorkflow = useWorkspaceStore().workflow
16641664
.activeWorkflow as ComfyWorkflow
1665+
const startTime = performance.now()
16651666
const p = await this.graphToPrompt(this.rootGraph)
16661667
const queuedNodes = collectAllNodes(this.rootGraph)
16671668
try {
@@ -1685,6 +1686,7 @@ export class ComfyApp {
16851686
id: res.prompt_id,
16861687
nodes: Object.keys(p.output),
16871688
promptOutput: p.output,
1689+
startTime,
16881690
workflow: queuedWorkflow
16891691
})
16901692
}

src/stores/executionStore.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const {
2121
mockOpenWorkflows,
2222
mockShowTextPreview,
2323
mockTrackExecutionError,
24+
mockTrackExecutionOutcome,
2425
mockTrackExecutionSuccess,
2526
mockTrackSharedWorkflowRun
2627
} = await vi.hoisted(async () => {
@@ -33,6 +34,7 @@ const {
3334
mockOpenWorkflows: shallowRef<{ path: string }[]>([]),
3435
mockShowTextPreview: vi.fn(),
3536
mockTrackExecutionError: vi.fn(),
37+
mockTrackExecutionOutcome: vi.fn(),
3638
mockTrackExecutionSuccess: vi.fn(),
3739
mockTrackSharedWorkflowRun: vi.fn()
3840
}
@@ -92,6 +94,7 @@ vi.mock('@/platform/distribution/types', async () => ({
9294
vi.mock('@/platform/telemetry', () => ({
9395
useTelemetry: () => ({
9496
trackExecutionError: mockTrackExecutionError,
97+
trackExecutionOutcome: mockTrackExecutionOutcome,
9598
trackExecutionSuccess: mockTrackExecutionSuccess,
9699
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
97100
})
@@ -614,6 +617,7 @@ describe('useExecutionStore - workflowStatus', () => {
614617
nodes: ['1'],
615618
id: jobId,
616619
promptOutput: { '1': createPromptNode('Node', 'TestNode') },
620+
startTime: 42,
617621
workflow
618622
})
619623
}
@@ -631,6 +635,7 @@ describe('useExecutionStore - workflowStatus', () => {
631635
callStoreJob('job-1', workflowA)
632636
fireExecutionStart('job-1')
633637

638+
expect(mockTrackExecutionOutcome).not.toHaveBeenCalled()
634639
expect(store.getWorkflowStatus(workflowA)).toBe('running')
635640
})
636641

@@ -648,6 +653,11 @@ describe('useExecutionStore - workflowStatus', () => {
648653
fireExecutionSuccess('job-1')
649654

650655
callStoreJob('job-1', workflowA)
656+
expect(mockTrackExecutionOutcome).toHaveBeenCalledOnce()
657+
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
658+
startTime: 42,
659+
outcome: 'success'
660+
})
651661
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
652662
})
653663

@@ -656,6 +666,10 @@ describe('useExecutionStore - workflowStatus', () => {
656666
fireExecutionError('job-1')
657667

658668
callStoreJob('job-1', workflowA)
669+
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
670+
startTime: 42,
671+
outcome: 'failure'
672+
})
659673
expect(store.getWorkflowStatus(workflowA)).toBe('failed')
660674
})
661675

@@ -672,6 +686,10 @@ describe('useExecutionStore - workflowStatus', () => {
672686
fireExecutionStart('job-1')
673687
fireExecutionSuccess('job-1')
674688

689+
expect(mockTrackExecutionOutcome).toHaveBeenCalledWith({
690+
startTime: 42,
691+
outcome: 'success'
692+
})
675693
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
676694
})
677695

src/stores/executionStore.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ interface QueuedJob {
5151
* value is a boolean indicating if the node has been executed.
5252
*/
5353
nodes: Record<string, boolean>
54+
startTime?: number
5455
/**
5556
* The workflow that is queued to be executed
5657
*/
@@ -177,13 +178,27 @@ export const useExecutionStore = defineStore('execution', () => {
177178
mutateStatus((m) => m.set(workflow, status))
178179
}
179180

181+
function trackExecutionOutcome(
182+
jobId: string,
183+
status: WorkflowExecutionStatus
184+
) {
185+
if (status === 'running') return
186+
const startTime = queuedJobs.value[jobId]?.startTime
187+
if (startTime === undefined) return
188+
useTelemetry()?.trackExecutionOutcome({
189+
startTime,
190+
outcome: status === 'completed' ? 'success' : 'failure'
191+
})
192+
}
193+
180194
function setWorkflowStatus(jobId: string, status: WorkflowExecutionStatus) {
181195
const workflow = jobIdToWorkflow.get(jobId)
182196
if (!workflow) {
183197
bufferPendingWorkflowStatus(jobId, status)
184198
return
185199
}
186200
applyWorkflowStatus(workflow, status)
201+
trackExecutionOutcome(jobId, status)
187202
}
188203

189204
function clearWorkflowStatus(workflow: ComfyWorkflow) {
@@ -718,11 +733,13 @@ export const useExecutionStore = defineStore('execution', () => {
718733
nodes,
719734
id,
720735
promptOutput,
736+
startTime,
721737
workflow
722738
}: {
723739
nodes: string[]
724740
id: JobId
725741
promptOutput: ComfyApiWorkflow
742+
startTime?: number
726743
workflow: ComfyWorkflow
727744
}) {
728745
queuedJobs.value[id] ??= { nodes: {} }
@@ -735,6 +752,7 @@ export const useExecutionStore = defineStore('execution', () => {
735752
...queuedJob.nodes
736753
}
737754
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
755+
queuedJob.startTime = startTime
738756
queuedJob.workflow = workflow
739757
if (workflow) jobIdToWorkflow.set(String(id), workflow)
740758
queuedJob.shareId = workflow?.shareId
@@ -761,6 +779,7 @@ export const useExecutionStore = defineStore('execution', () => {
761779
// Don't let a stale 'running' overwrite a terminal status already set.
762780
if (pending === 'running' && workflowStatus.value.has(workflow)) return
763781
applyWorkflowStatus(workflow, pending)
782+
trackExecutionOutcome(jobId, pending)
764783
}
765784

766785
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)

0 commit comments

Comments
 (0)