Compare commits

...

9 Commits

Author SHA1 Message Date
Shibo Zhou
0ee77bdbc7 Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 15:53:47 -07:00
shibozhou
ae266f3040 Merge remote-tracking branch 'origin/main' into feat/app-mode-shared-link-telemetry
# Conflicts:
#	src/platform/telemetry/types.ts
#	src/platform/workflow/core/services/workflowService.ts
#	src/scripts/app.ts
2026-06-01 14:58:06 -07:00
shibozhou
9706bacabc fix: correct open_source normalization and mode-time-tracking scope
normalizeOpenSource compared against 'original', which is not a member
of WorkflowOpenSource (TS2367), breaking the build. Map shared_url to
shared_url, any other defined source to original, and undefined to
unknown.

Call useModeTimeTracking() in synchronous setup instead of inside
runWhenGlobalIdle so its scope-bound cleanup (tryOnScopeDispose/watch)
registers and the final duration flushes on unmount.
2026-06-01 14:40:16 -07:00
Shibo Zhou
219f8feaca Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 13:49:42 -07:00
Shibo Zhou
2eb14a1f8d Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 13:17:16 -07:00
Shibo Zhou
16877fec25 Merge branch 'main' into feat/app-mode-shared-link-telemetry 2026-06-01 12:14:03 -07:00
Shibo Zhou
8afbcdc72e feat: track active time spent per app mode (app:mode_time_spent) (#12567)
## Summary

Add an `app:mode_time_spent` telemetry event so we can measure how long
users spend in App Mode vs. graph/builder modes — today we only track
*entering* App Mode (`app:app_mode_opened`), with no exit or duration
signal.

## Changes

- **What**: New `trackModeTimeSpent` event emitting `{ mode,
duration_seconds }` across the registry and all three cloud providers
(PostHog/Mixpanel/Gtm). New `useModeTimeTracking` composable accumulates
*tab-visible* time for the current mode and flushes (emits + resets) on
mode change, tab-hide, and scope dispose. Wired into `GraphView` behind
the existing cloud gate; no-ops in OSS builds.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

- **Active-time semantics**: hidden-tab time is excluded (timer pauses
on `visibilitychange` → hidden). Flushing on tab-hide also means a
closing tab is still captured, since `visibilitychange` fires reliably
before unload — no separate `unload`/`pagehide` handler needed.
- **Fragmented durations**: one event is emitted per mode-segment
(between switches/tab-hides), so analysis should **sum**
`duration_seconds` grouped by `mode` rather than treating each event as
a full session.
- **Mode granularity**: emits the actual mode string (`graph` / `app` /
`builder:*`) so app-vs-graph and builder time are both sliceable.

> Stacked on `feat/app-mode-shared-link-telemetry` (unmerged, shares
`types.ts`/`GtmTelemetryProvider.ts`). Retarget to `main` once that
merges.
2026-06-01 12:05:48 -07:00
coderabbitai[bot]
82f51e373a fix: apply CodeRabbit auto-fixes
Fixed 2 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-06-01 18:07:49 +00:00
shibozhou
ea7bfc4919 feat: tag App Mode entry with shared-link provenance (app:app_mode_opened)
Distinguish App Mode sessions reached via a shared `?share=` link from
sessions where the user opened their own/original workflow.

The `shared_url` signal already exists at load time but was transient.
Thread the workflow's `openSource` through `afterLoadNewGraph` and emit it
as `open_source` on the `app:app_mode_opened` event (reusing the existing
WorkflowOpenSource enum). `'shared_url'` => opened via a shared link;
anything else (own file, template, unknown) => original.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:19:56 -07:00
12 changed files with 332 additions and 8 deletions

View File

@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Ref } from 'vue'
import { effectScope, nextTick, ref } from 'vue'
import type { AppMode } from './useAppMode'
const hoisted = vi.hoisted(() => ({
telemetry: null as { trackModeTimeSpent: ReturnType<typeof vi.fn> } | null,
mode: null as unknown as Ref<AppMode>
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => hoisted.telemetry
}))
vi.mock('./useAppMode', () => ({
useAppMode: () => ({ mode: hoisted.mode })
}))
import { useModeTimeTracking } from './useModeTimeTracking'
function setVisibility(state: 'visible' | 'hidden') {
Object.defineProperty(document, 'visibilityState', {
value: state,
configurable: true
})
document.dispatchEvent(new Event('visibilitychange'))
}
describe('useModeTimeTracking', () => {
let scope: ReturnType<typeof effectScope>
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(0)
hoisted.telemetry = { trackModeTimeSpent: vi.fn() }
hoisted.mode = ref<AppMode>('graph')
setVisibility('visible')
scope = effectScope()
})
afterEach(() => {
scope.stop()
vi.useRealTimers()
})
const track = () => hoisted.telemetry!.trackModeTimeSpent
it('emits the elapsed seconds for the previous mode on mode change', async () => {
scope.run(() => useModeTimeTracking())
vi.setSystemTime(5000)
hoisted.mode.value = 'app'
await nextTick()
expect(track()).toHaveBeenCalledWith({
mode: 'graph',
duration_seconds: 5
})
})
it('excludes time while the tab is hidden', async () => {
hoisted.mode = ref<AppMode>('app')
scope.run(() => useModeTimeTracking())
vi.setSystemTime(2000)
setVisibility('hidden')
expect(track()).toHaveBeenCalledWith({ mode: 'app', duration_seconds: 2 })
track().mockClear()
vi.setSystemTime(10_000)
setVisibility('visible')
vi.setSystemTime(11_000)
hoisted.mode.value = 'graph'
await nextTick()
expect(track()).toHaveBeenCalledTimes(1)
expect(track()).toHaveBeenCalledWith({ mode: 'app', duration_seconds: 1 })
})
it('does not emit sub-second durations', async () => {
scope.run(() => useModeTimeTracking())
vi.setSystemTime(300)
hoisted.mode.value = 'app'
await nextTick()
expect(track()).not.toHaveBeenCalled()
})
it('flushes the final mode when the scope is disposed', () => {
hoisted.mode = ref<AppMode>('app')
scope.run(() => useModeTimeTracking())
vi.setSystemTime(3000)
scope.stop()
expect(track()).toHaveBeenCalledWith({ mode: 'app', duration_seconds: 3 })
})
it('no-ops without a telemetry provider', async () => {
hoisted.telemetry = null
expect(() => scope.run(() => useModeTimeTracking())).not.toThrow()
vi.setSystemTime(5000)
hoisted.mode.value = 'app'
await expect(nextTick()).resolves.not.toThrow()
})
})

View File

@@ -0,0 +1,65 @@
import { tryOnScopeDispose, useEventListener } from '@vueuse/core'
import { watch } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { AppMode } from './useAppMode'
import { useAppMode } from './useAppMode'
/**
* Tracks active (tab-visible) time spent in each {@link AppMode} and emits an
* `app:mode_time_spent` event whenever the user leaves a mode, backgrounds the
* tab, or tears down. Hidden-tab time is excluded, and flushing on tab-hide
* means a closing tab is still captured (`visibilitychange` fires before
* unload). Summing `duration_seconds` grouped by `mode` yields time per mode.
*
* Side-effecting composable: call once from a long-lived scope (GraphView).
*/
export function useModeTimeTracking() {
const dispatcher = useTelemetry()
if (!dispatcher) return
const trackModeTimeSpent = dispatcher.trackModeTimeSpent.bind(dispatcher)
const { mode } = useAppMode()
const isVisible = () => document.visibilityState === 'visible'
let trackedMode: AppMode = mode.value
let accumulatedMs = 0
let segmentStart: number | null = isVisible() ? Date.now() : null
function closeSegment() {
if (segmentStart !== null) {
accumulatedMs += Date.now() - segmentStart
segmentStart = null
}
}
function flush() {
closeSegment()
const durationSeconds = Math.round(accumulatedMs / 1000)
accumulatedMs = 0
if (durationSeconds >= 1) {
trackModeTimeSpent({
mode: trackedMode,
duration_seconds: durationSeconds
})
}
}
watch(mode, (newMode) => {
flush()
trackedMode = newMode
segmentStart = isVisible() ? Date.now() : null
})
useEventListener(document, 'visibilitychange', () => {
if (isVisible()) {
segmentStart = Date.now()
} else {
flush()
}
})
tryOnScopeDispose(flush)
}

View File

@@ -5,6 +5,7 @@ import type {
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ModeTimeSpentMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -176,6 +177,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}
trackModeTimeSpent(metadata: ModeTimeSpentMetadata): void {
this.dispatch((provider) => provider.trackModeTimeSpent?.(metadata))
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.dispatch((provider) => provider.trackShareFlow?.(metadata))
}

View File

@@ -3,6 +3,7 @@ import type {
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ModeTimeSpentMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ExecutionTriggerSource,
@@ -280,7 +281,15 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.pushEvent('app_mode_opened', {
source: metadata.source
source: metadata.source,
open_source: metadata.open_source
})
}
trackModeTimeSpent(metadata: ModeTimeSpentMetadata): void {
this.pushEvent('mode_time_spent', {
mode: metadata.mode,
duration_seconds: metadata.duration_seconds
})
}

View File

@@ -61,6 +61,7 @@ import type {
EnterLinearMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
ModeTimeSpentMetadata,
ShareFlowMetadata,
SurveyResponses,
TemplateLibraryClosedMetadata,
@@ -287,6 +288,10 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
default_view: 'graph'
}
const enterLinearMetadata: EnterLinearMetadata = {}
const modeTimeSpentMetadata: ModeTimeSpentMetadata = {
mode: 'app',
duration_seconds: 42
}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const executionErrorMetadata: ExecutionErrorMetadata = { jobId: 'job-1' }
const executionSuccessMetadata: ExecutionSuccessMetadata = { jobId: 'job-1' }
@@ -350,6 +355,11 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackEnterLinear(enterLinearMetadata),
TelemetryEvents.ENTER_LINEAR_MODE
],
[
'trackModeTimeSpent',
(p) => p.trackModeTimeSpent(modeTimeSpentMetadata),
TelemetryEvents.MODE_TIME_SPENT
],
[
'trackShareFlow',
(p) => p.trackShareFlow(shareFlowMetadata),

View File

@@ -17,6 +17,7 @@ import type {
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ModeTimeSpentMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionTriggerSource,
@@ -380,6 +381,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackModeTimeSpent(metadata: ModeTimeSpentMetadata): void {
this.trackEvent(TelemetryEvents.MODE_TIME_SPENT, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}

View File

@@ -13,6 +13,7 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ModeTimeSpentMetadata,
ShareFlowMetadata,
ExecutionContext,
ExecutionErrorMetadata,
@@ -391,6 +392,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}
trackModeTimeSpent(metadata: ModeTimeSpentMetadata): void {
this.trackEvent(TelemetryEvents.MODE_TIME_SPENT, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.trackEvent(TelemetryEvents.SHARE_FLOW, metadata)
}

View File

@@ -12,6 +12,7 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { AppMode } from '@/composables/useAppMode'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -165,6 +166,12 @@ export interface WorkflowImportMetadata {
export interface EnterLinearMetadata {
source?: string
/**
* The workflow's entry path for this session. `'shared_url'` means App Mode
* was reached via a link someone shared (`?share=`), as opposed to the user
* opening their own/original workflow.
*/
open_source?: AppModeOpenSource
workflow_id?: string
}
@@ -174,6 +181,16 @@ export interface WorkflowSavedMetadata {
workflow_id?: string
}
/**
* Mode time-spent metadata. Emitted when leaving a mode (or hiding the tab),
* reporting the active (tab-visible) seconds spent in that mode. Summing
* `duration_seconds` grouped by `mode` yields time spent per mode.
*/
export interface ModeTimeSpentMetadata {
mode: AppMode
duration_seconds: number
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
@@ -201,6 +218,12 @@ export type WorkflowOpenSource = NonNullable<
WorkflowImportMetadata['open_source']
>
/**
* Narrower type for app mode entry tracking.
* Maps WorkflowOpenSource values to allowed app mode sources.
*/
export type AppModeOpenSource = 'shared_url' | 'original' | 'unknown'
/**
* Template library metadata
*/
@@ -439,6 +462,7 @@ export interface TelemetryProvider {
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackModeTimeSpent?(metadata: ModeTimeSpentMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
// Page visibility events
@@ -526,6 +550,7 @@ export const TelemetryEvents = {
WORKFLOW_IMPORTED: 'app:workflow_imported',
WORKFLOW_OPENED: 'app:workflow_opened',
ENTER_LINEAR_MODE: 'app:app_mode_opened',
MODE_TIME_SPENT: 'app:mode_time_spent',
SHARE_FLOW: 'app:share_flow',
// Page Visibility
@@ -602,6 +627,7 @@ export type TelemetryEventProperties =
| HelpCenterClosedMetadata
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ModeTimeSpentMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata

View File

@@ -61,10 +61,12 @@ function makeWorkflowData(
}
}
const { mockConfirm, mockTrackWorkflowSaved } = vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockTrackWorkflowSaved: vi.fn()
}))
const { mockConfirm, mockTrackWorkflowSaved, mockTrackEnterLinear } =
vi.hoisted(() => ({
mockConfirm: vi.fn(),
mockTrackWorkflowSaved: vi.fn(),
mockTrackEnterLinear: vi.fn()
}))
const draftStoreMocks = vi.hoisted(() => ({
saveDraft: vi.fn(() => true),
@@ -108,7 +110,7 @@ vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackDefaultViewSet: vi.fn(),
trackWorkflowSaved: mockTrackWorkflowSaved,
trackEnterLinear: vi.fn()
trackEnterLinear: mockTrackEnterLinear
})
}))
@@ -844,6 +846,69 @@ describe('useWorkflowService', () => {
})
})
describe('app:app_mode_opened open_source', () => {
beforeEach(() => {
mockOpenWorkflow()
mockTrackEnterLinear.mockClear()
})
it('tags the entry as shared_url when loaded from a shared link', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true }),
'shared_url'
)
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
source: 'workflow',
open_source: 'shared_url'
})
})
it('defaults open_source to unknown when no source is provided', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
source: 'workflow',
open_source: 'unknown'
})
})
it('tags the entry as original when loaded from a non-shared source', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true }),
'template'
)
expect(mockTrackEnterLinear).toHaveBeenCalledWith({
source: 'workflow',
open_source: 'original'
})
})
it('does not fire when the workflow is not in app mode', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: false }),
'shared_url'
)
expect(mockTrackEnterLinear).not.toHaveBeenCalled()
})
})
describe('round-trip mode preservation', () => {
it('each workflow retains its own mode across tab switches', () => {
const workflow1 = createModeTestWorkflow({

View File

@@ -16,6 +16,10 @@ import {
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry'
import type {
WorkflowOpenSource,
AppModeOpenSource
} from '@/platform/telemetry/types'
import { workflowTelemetryId } from '@/platform/telemetry/utils/workflowTelemetryId'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
// eslint-disable-next-line import-x/no-restricted-paths
@@ -44,6 +48,13 @@ function linearModeToAppMode(linearMode: unknown): AppMode | null {
return linearMode ? 'app' : 'graph'
}
function normalizeOpenSource(
source: WorkflowOpenSource | undefined
): AppModeOpenSource {
if (source === 'shared_url') return 'shared_url'
return source ? 'original' : 'unknown'
}
export const useWorkflowService = () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
@@ -456,7 +467,8 @@ export const useWorkflowService = () => {
*/
const afterLoadNewGraph = async (
value: string | ComfyWorkflow | null,
workflowData: ComfyWorkflowJSON
workflowData: ComfyWorkflowJSON,
openSource?: WorkflowOpenSource
) => {
const workflowStore = useWorkspaceStore().workflow
const { isAppMode } = useAppMode()
@@ -471,6 +483,7 @@ export const useWorkflowService = () => {
if (!wasAppMode && workflow.initialMode === 'app') {
useTelemetry()?.trackEnterLinear({
source: 'workflow',
open_source: normalizeOpenSource(openSource),
workflow_id: workflowTelemetryId(workflow)
})
}

View File

@@ -1433,7 +1433,11 @@ export class ComfyApp {
}
useTelemetry()?.trackWorkflowOpened(telemetryPayload)
useTelemetry()?.trackWorkflowImported(telemetryPayload)
await useWorkflowService().afterLoadNewGraph(workflow, serializedGraph)
await useWorkflowService().afterLoadNewGraph(
workflow,
serializedGraph,
openSource
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)

View File

@@ -54,6 +54,7 @@ import InviteAcceptedToast from '@/platform/workspace/components/toasts/InviteAc
import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useModeTimeTracking } from '@/composables/useModeTimeTracking'
import { useQueuePolling } from '@/platform/remote/comfyui/useQueuePolling'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useReconnectQueueRefresh } from '@/composables/useReconnectQueueRefresh'
@@ -123,6 +124,12 @@ const telemetry = useTelemetry()
const authStore = useAuthStore()
let hasTrackedLogin = false
// Track active time spent in each mode (cloud only). Must run in the
// synchronous setup scope so the composable's scope-bound cleanup registers.
if (isCloud && telemetry) {
useModeTimeTracking()
}
watch(
() => colorPaletteStore.completedActivePalette,
(newTheme) => {