Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
151b667
feat(telemetry): instrument cloud funnel gaps (auth, onboarding, canv…
deepme987 Jun 17, 2026
68cd6c8
[automated] Apply ESLint and Oxfmt fixes
actions-user Jun 17, 2026
938609b
test(telemetry): positive-path + behavioral tests for the cloud funne…
deepme987 Jun 17, 2026
2cda397
refactor(telemetry): address review on the cloud funnel events
deepme987 Jun 17, 2026
7da664a
feat(telemetry): add funnel-completion events + context super-properties
deepme987 Jun 17, 2026
8fe6159
feat(website): add explicit homepage CTA telemetry
deepme987 Jun 18, 2026
ad65486
feat(telemetry): fire subscribe-now click from real tier CTAs
deepme987 Jun 18, 2026
5f789e1
[automated] Apply ESLint and Oxfmt fixes
actions-user Jun 18, 2026
f303872
feat(telemetry): track monthly/yearly billing-cycle toggle on pricing…
deepme987 Jun 18, 2026
ffd37e8
feat(telemetry): add auth_error + template_category events; enable cl…
deepme987 Jun 18, 2026
cefe24a
Merge remote-tracking branch 'origin/main' into deepme987/frontend/cl…
deepme987 Jun 18, 2026
631bab4
Merge remote-tracking branch 'origin/deepme987/feat/cloud-funnel-tele…
deepme987 Jun 18, 2026
2338efc
Merge remote-tracking branch 'origin/deepme987/feat/website-cta-telem…
deepme987 Jun 18, 2026
464f383
fix(telemetry): resolve consolidation follow-ups
deepme987 Jun 18, 2026
b7f0acc
[automated] Apply ESLint and Oxfmt fixes
actions-user Jun 18, 2026
e680a68
docs(telemetry): trim verbose comments to repo norm (1-2 lines)
deepme987 Jun 19, 2026
bb4f31c
Merge origin/main into deepme987/frontend/cloud-funnel-telemetry
deepme987 Jun 19, 2026
ecf30e0
chore: revert incidental Tailwind class reorders in website CTA compo…
deepme987 Jun 19, 2026
08a0136
test(telemetry): cover new registry funnel-event dispatch methods
deepme987 Jun 19, 2026
a2b2352
chore: re-trigger CI
deepme987 Jun 19, 2026
b70b862
Merge origin/main into deepme987/frontend/cloud-funnel-telemetry
deepme987 Jun 19, 2026
b8db435
Merge origin/main into deepme987/frontend/cloud-funnel-telemetry
deepme987 Jun 19, 2026
4b73a70
Merge branch 'main' into deepme987/frontend/cloud-funnel-telemetry
deepme987 Jun 22, 2026
f4b1c19
fix(telemetry): correct auth-funnel + run-gate event accuracy (review)
deepme987 Jun 22, 2026
fe9cb9d
docs(telemetry): cut over-commenting to repo terse norm
deepme987 Jun 22, 2026
2b27182
Merge remote-tracking branch 'origin/main' into deepme987/frontend/cl…
deepme987 Jun 22, 2026
147c4f4
refactor(telemetry): drop FE-inferred subscription-success event and …
deepme987 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/platform/cloud/onboarding/UserCheckView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
getSurveyCompletedStatus,
getUserCloudStatus
} from '@/platform/cloud/onboarding/auth'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'

import CloudLoginViewSkeleton from './skeletons/CloudLoginViewSkeleton.vue'
import CloudSurveyViewSkeleton from './skeletons/CloudSurveyViewSkeleton.vue'
Expand All @@ -42,6 +44,7 @@ const { flags } = useFeatureFlags()
const onboardingSurveyEnabled = computed(
() => flags.onboardingSurveyEnabled ?? true
)
const telemetry = isCloud ? useTelemetry() : undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should stay consistent with the rest of the codebase and just inline all useTelemetry calls. The performance diff is not detectable and generally we should avoid eager import side effects.


const skeletonType = ref<'login' | 'survey' | 'waitlist' | 'loading'>('loading')

Expand All @@ -54,6 +57,11 @@ const {
await nextTick()

if (!onboardingSurveyEnabled.value) {
telemetry?.trackOnboardingRouted({
destination: 'onboarded',
survey_completed: false,
has_cloud_status: false
})
await router.replace({ path: '/' })
return
}
Expand All @@ -65,19 +73,34 @@ const {

// Navigate based on user status
if (!cloudUserStats) {
telemetry?.trackOnboardingRouted({
destination: 'waitlist',
survey_completed: !!surveyStatus,
has_cloud_status: false
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessary as the waitlist doesn't exist anymore.

skeletonType.value = 'login'
await router.replace({ name: 'cloud-login' })
return
}

// Survey is required for all users when feature flag is enabled
if (!surveyStatus) {
telemetry?.trackOnboardingRouted({
destination: 'survey',
survey_completed: false,
has_cloud_status: true
})
skeletonType.value = 'survey'
await router.replace({ name: 'cloud-survey' })
return
}

// User is fully onboarded (active or whitelist check disabled)
telemetry?.trackOnboardingRouted({
destination: 'onboarded',
survey_completed: true,
has_cloud_status: true
})
globalThis.location.href = '/'
}),
null,
Expand Down
13 changes: 12 additions & 1 deletion src/platform/cloud/subscription/composables/useSubscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,19 @@ function useSubscriptionInternal() {
)

const subscribe = wrapWithErrorHandlingAsync(async () => {
const response = await initiateSubscriptionCheckout()
let response: CloudSubscriptionCheckoutResponse
try {
response = await initiateSubscriptionCheckout()
} catch (error) {
telemetry?.trackCheckoutInitiateFailed({
stage: 'server_error',
error_code: error instanceof Error ? error.message : undefined
})
throw error
}

if (!response.checkout_url) {
telemetry?.trackCheckoutInitiateFailed({ stage: 'no_url' })
throw new Error(
t('toastMessages.failedToInitiateSubscription', {
error: 'No checkout URL returned'
Expand All @@ -216,6 +226,7 @@ function useSubscriptionInternal() {

const checkoutWindow = window.open(response.checkout_url, '_blank')
if (!checkoutWindow) {
telemetry?.trackCheckoutWindowBlocked()
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
// Workspace member-invite upsell. A NON-activation cohort: the activation
// funnel must be able to EXCLUDE these so single-user activation is not
// diluted by team seat-expansion prompts.
| 'member_invite'
| 'upload_model'
| 'run_workflow'

export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
Expand Down
46 changes: 42 additions & 4 deletions src/platform/telemetry/TelemetryRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import type { AuditLog } from '@/services/customerEventsService'

import type {
AuthFailedMetadata,
AuthMethodSelectedMetadata,
AuthMetadata,
BeginCheckoutMetadata,
CanvasReadyMetadata,
CheckoutInitiateFailedMetadata,
CheckoutWindowBlockedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
Expand All @@ -16,6 +21,9 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OAuthPopupResultMetadata,
OnboardingRoutedMetadata,
OutputViewedMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
Expand Down Expand Up @@ -67,6 +75,18 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSignupOpened?.())
}

trackAuthMethodSelected(metadata: AuthMethodSelectedMetadata): void {
this.dispatch((provider) => provider.trackAuthMethodSelected?.(metadata))
}

trackOAuthPopupResult(metadata: OAuthPopupResultMetadata): void {
this.dispatch((provider) => provider.trackOAuthPopupResult?.(metadata))
}

trackAuthFailed(metadata: AuthFailedMetadata): void {
this.dispatch((provider) => provider.trackAuthFailed?.(metadata))
}

trackAuth(metadata: AuthMetadata): void {
this.dispatch((provider) => provider.trackAuth?.(metadata))
}
Expand All @@ -75,6 +95,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}

trackCanvasReady(metadata: CanvasReadyMetadata): void {
this.dispatch((provider) => provider.trackCanvasReady?.(metadata))
}

trackOnboardingRouted(metadata: OnboardingRoutedMetadata): void {
this.dispatch((provider) => provider.trackOnboardingRouted?.(metadata))
}

trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
Expand All @@ -86,6 +114,16 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}

trackCheckoutInitiateFailed(metadata: CheckoutInitiateFailedMetadata): void {
this.dispatch((provider) =>
provider.trackCheckoutInitiateFailed?.(metadata)
)
}

trackCheckoutWindowBlocked(metadata?: CheckoutWindowBlockedMetadata): void {
this.dispatch((provider) => provider.trackCheckoutWindowBlocked?.(metadata))
}

trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
Expand Down Expand Up @@ -145,10 +183,6 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
}

trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
}

trackTemplate(metadata: TemplateMetadata): void {
this.dispatch((provider) => provider.trackTemplate?.(metadata))
}
Expand Down Expand Up @@ -251,6 +285,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
}

trackOutputViewed(metadata: OutputViewedMetadata): void {
this.dispatch((provider) => provider.trackOutputViewed?.(metadata))
}

trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
this.dispatch((provider) => provider.trackSharedWorkflowRun?.(metadata))
}
Expand Down
95 changes: 95 additions & 0 deletions src/platform/telemetry/authActivationMarker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import {
consumeAuthActivation,
markAuthForActivation
} from '@/platform/telemetry/authActivationMarker'

const MARKER_KEY = 'comfy:telemetry:auth-activation'

describe('authActivationMarker', () => {
beforeEach(() => {
sessionStorage.clear()
})

afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})

describe('mark then consume', () => {
it('returns the stored marker once and then clears the key', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-16T00:00:00.000Z'))
const expectedAt = Date.now()

markAuthForActivation(true)

const marker = consumeAuthActivation()
expect(marker).toEqual({ at: expectedAt, isNewUser: true })

// The marker is single-use: a second consume finds nothing.
expect(consumeAuthActivation()).toBeNull()
expect(sessionStorage.getItem(MARKER_KEY)).toBeNull()
})

it('preserves isNewUser=false through a round trip', () => {
markAuthForActivation(false)

const marker = consumeAuthActivation()
expect(marker?.isNewUser).toBe(false)
expect(typeof marker?.at).toBe('number')
})
})

describe('consume without a valid marker', () => {
it('returns null when no marker is present', () => {
expect(consumeAuthActivation()).toBeNull()
})

it('returns null and clears the key when the value is garbage', () => {
sessionStorage.setItem(MARKER_KEY, 'not-json-{')

expect(consumeAuthActivation()).toBeNull()
expect(sessionStorage.getItem(MARKER_KEY)).toBeNull()
})

it('returns null for a wrong-shape object missing fields', () => {
sessionStorage.setItem(MARKER_KEY, JSON.stringify({ at: 123 }))

expect(consumeAuthActivation()).toBeNull()
// It still consumes (removes) the malformed marker so it is not retried.
expect(sessionStorage.getItem(MARKER_KEY)).toBeNull()
})

it('returns null when fields are present but wrong types', () => {
sessionStorage.setItem(
MARKER_KEY,
JSON.stringify({ at: 'soon', isNewUser: 'yes' })
)

expect(consumeAuthActivation()).toBeNull()
expect(sessionStorage.getItem(MARKER_KEY)).toBeNull()
})

it('returns null for a non-object JSON payload', () => {
sessionStorage.setItem(MARKER_KEY, JSON.stringify(42))

expect(consumeAuthActivation()).toBeNull()
expect(sessionStorage.getItem(MARKER_KEY)).toBeNull()
})
})

describe('markAuthForActivation error handling', () => {
it('swallows a sessionStorage failure without throwing', () => {
const setItemSpy = vi
.spyOn(sessionStorage, 'setItem')
.mockImplementation(() => {
throw new Error('QuotaExceededError')
})

expect(() => markAuthForActivation(true)).not.toThrow()
expect(setItemSpy).toHaveBeenCalledOnce()
})
})
})
51 changes: 51 additions & 0 deletions src/platform/telemetry/authActivationMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Bridges the auth -> canvas gap for the `canvas_ready` activation event.
*
* The onboarded path navigates with a full `location.href` reload, which wipes
* in-memory state (including the auth store) before the graph canvas mounts.
* `auth_completed` therefore cannot carry forward whether the user was new or
* when they authenticated. We stash a small marker in `sessionStorage` at auth
* completion and consume it exactly once when the canvas first becomes
* interactive. sessionStorage is per-tab and cleared on tab close, which is the
* correct scope for a single activation.
*/
const MARKER_KEY = 'comfy:telemetry:auth-activation'

interface AuthActivationMarker {
at: number
isNewUser: boolean
}

/** Records that an authentication just completed, for the next canvas load. */
export function markAuthForActivation(isNewUser: boolean): void {
try {
const marker: AuthActivationMarker = { at: Date.now(), isNewUser }
sessionStorage.setItem(MARKER_KEY, JSON.stringify(marker))
} catch {
// sessionStorage may be unavailable (private mode, SSR); skip silently.
}
}

/**
* Reads and clears the activation marker. Returns null when no auth happened in
* this tab (e.g. a plain reload by a returning user).
*/
export function consumeAuthActivation(): AuthActivationMarker | null {
try {
const raw = sessionStorage.getItem(MARKER_KEY)
if (!raw) return null
sessionStorage.removeItem(MARKER_KEY)
const parsed: unknown = JSON.parse(raw)
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as AuthActivationMarker).at === 'number' &&
typeof (parsed as AuthActivationMarker).isNewUser === 'boolean'
) {
return parsed as AuthActivationMarker
}
} catch {
// Corrupt or unavailable; treat as no marker.
}
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,22 +270,6 @@ describe('GtmTelemetryProvider', () => {
})
})

it('pushes email_verify_opened for opened stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('opened')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_opened'
})
})

it('pushes email_verify_completed for completed stage', () => {
const provider = createInitializedProvider()
provider.trackEmailVerification('completed')
expect(lastDataLayerEntry()).toMatchObject({
event: 'email_verify_completed'
})
})

it('pushes search for node search (GA4 recommended)', () => {
const provider = createInitializedProvider()
provider.trackNodeSearch({ query: 'KSampler' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,15 +256,6 @@ export class GtmTelemetryProvider implements TelemetryProvider {
this.pushEvent(ga4EventName, responses ? { ...responses } : undefined)
}

trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
const eventMap = {
opened: 'email_verify_opened',
requested: 'email_verify_requested',
completed: 'email_verify_completed'
} as const
this.pushEvent(eventMap[stage])
}

trackWorkflowOpened(metadata: WorkflowImportMetadata): void {
this.pushEvent('workflow_opened', {
missing_node_count: metadata.missing_node_count,
Expand Down
Loading
Loading