Compare commits

...

27 Commits

Author SHA1 Message Date
Deep Mehta
147c4f4044 refactor(telemetry): drop FE-inferred subscription-success event and tidy funnel call sites
- Stop emitting subscription_success from the FE inference in useSubscription;
  the backend now owns it via billing:subscription_created (Stripe webhook).
  Keep the checkout_returned funnel event and pending-attempt state-sync.
- Inline useTelemetry()?.trackOnboardingRouted in UserCheckView and remove the
  dead waitlist skeleton branch; relabel the user-not-found destination 'login'.
- Simplify the template-category watcher (redundant same-value guard).
2026-06-23 15:27:30 -07:00
Deep Mehta
2b27182c08 Merge remote-tracking branch 'origin/main' into deepme987/frontend/cloud-funnel-telemetry
# Conflicts:
#	src/stores/executionStore.test.ts
#	src/stores/executionStore.ts
2026-06-22 14:10:55 -07:00
Deep Mehta
fe9cb9d31e docs(telemetry): cut over-commenting to repo terse norm 2026-06-21 19:02:50 -07:00
Deep Mehta
f4b1c192ee fix(telemetry): correct auth-funnel + run-gate event accuracy (review)
Address review findings on the cloud funnel:
- Login-time subscription enforcement was mislabeled reason 'run_workflow' (it only fires on the isLoggedIn watch); tag 'subscription_required' so the run-gate cohort isn't polluted.
- Deliberate OAuth popup cancels no longer emit auth_failed (already captured as oauth_popup_result 'cancelled'); add auth-funnel tests locking in the cancel-vs-failure distinction.
- Correct the output_viewed doc (fires on output PRODUCED, not visibility).
- Drop stale auth-activation markers (>60s) so an unrelated reload can't report a bogus ms_since_auth.
- Replace the duplicated VIEWED_MEDIA_TYPES set with isPreviewableMediaType.
2026-06-21 18:04:03 -07:00
Deep Mehta
4b73a707ee Merge branch 'main' into deepme987/frontend/cloud-funnel-telemetry 2026-06-21 17:03:52 -07:00
Deep Mehta
b8db435d17 Merge origin/main into deepme987/frontend/cloud-funnel-telemetry
Restore trackEmailVerification + USER_EMAIL_VERIFY_* (main's new HostTelemetrySink sink consumes them; keeps PR additive). Gate first_execution_completed behind isCloud after Ben's executionStore refactor.
2026-06-19 11:58:32 -07:00
Deep Mehta
b70b862d7b Merge origin/main into deepme987/frontend/cloud-funnel-telemetry
Resolve conflicts with #12861 (navbar -> shadcn) and #12925 (decouple run telemetry): rewire website CTA telemetry into the new HeaderMain components, adopt trackRunButton(RunButtonProperties), keep is_app_mode super-property.
2026-06-19 11:34:25 -07:00
Deep Mehta
a2b2352778 chore: re-trigger CI 2026-06-18 19:43:00 -07:00
Deep Mehta
08a0136243 test(telemetry): cover new registry funnel-event dispatch methods 2026-06-18 19:13:03 -07:00
Deep Mehta
ecf30e08be chore: revert incidental Tailwind class reorders in website CTA components 2026-06-18 18:45:13 -07:00
Deep Mehta
bb4f31c758 Merge origin/main into deepme987/frontend/cloud-funnel-telemetry 2026-06-18 18:45:13 -07:00
Deep Mehta
e680a68c41 docs(telemetry): trim verbose comments to repo norm (1-2 lines) 2026-06-18 17:04:31 -07:00
GitHub Action
b7f0acc5c8 [automated] Apply ESLint and Oxfmt fixes 2026-06-18 06:41:59 +00:00
Deep Mehta
464f383e87 fix(telemetry): resolve consolidation follow-ups
- drop client-init heatmaps (RemoteConfig-only in posthog-js 1.358)
- un-export internal SubscribeClickSource (knip: unused export)
- sync provider/auth tests to the unioned config + trackAuthError mock
2026-06-17 23:37:15 -07:00
Deep Mehta
2338efc909 Merge remote-tracking branch 'origin/deepme987/feat/website-cta-telemetry' into deepme987/frontend/cloud-funnel-telemetry 2026-06-17 23:31:46 -07:00
Deep Mehta
631bab4d6e Merge remote-tracking branch 'origin/deepme987/feat/cloud-funnel-telemetry-gaps' into deepme987/frontend/cloud-funnel-telemetry
# Conflicts:
#	src/platform/telemetry/TelemetryRegistry.ts
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
#	src/stores/authStore.ts
2026-06-17 23:30:46 -07:00
Deep Mehta
cefe24a215 Merge remote-tracking branch 'origin/main' into deepme987/frontend/cloud-funnel-telemetry
# Conflicts:
#	src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
2026-06-17 22:46:49 -07:00
Deep Mehta
ffd37e82e2 feat(telemetry): add auth_error + template_category events; enable cloud web analytics 2026-06-17 17:39:53 -07:00
Deep Mehta
f303872748 feat(telemetry): track monthly/yearly billing-cycle toggle on pricing table 2026-06-17 17:28:24 -07:00
GitHub Action
5f789e17d2 [automated] Apply ESLint and Oxfmt fixes 2026-06-18 00:27:51 +00:00
Deep Mehta
ad6548690b feat(telemetry): fire subscribe-now click from real tier CTAs
app:subscribe_now_button_clicked was only fired by the legacy
SubscribeButton, never by the pricing table tier buttons or the
subscribe-to-run lock button - the surfaces users actually click. Add
the same trackSubscription('subscribe_clicked') capture to:

- PricingTable.handleSubscribe (both the new-subscriber and plan-change
  paths), before checkout opens, carrying { tier, cycle }
- SubscribeToRun.handleSubscribeToRun, alongside the existing run-button
  event

Each surface now tags a source ('pricing_table' | 'subscribe_to_run' |
'subscribe_button') so the subscribe-click funnel can be attributed by
CTA. Extend SubscriptionMetadata with tier/cycle/source; no new event
name.
2026-06-17 17:22:55 -07:00
Deep Mehta
8fe6159497 feat(website): add explicit homepage CTA telemetry
Add a captureCtaClick helper to the website PostHog wrapper and fire
website:cta_clicked from the real CTA elements (hero, nav buttons, nav
products links, product cards) instead of relying on autocapture or
i18n text matching. Each event carries { button, location }.
2026-06-17 17:20:59 -07:00
Deep Mehta
7da664a21b feat(telemetry): add funnel-completion events + context super-properties
Extends the cloud funnel instrumentation (all cloud-only):

- app:paywall_viewed — fired when a subscription/paywall dialog opens, with
  the reason (run-gate wired now; member_invite / upload_model entry points
  pending billing-context reason threading) and current_tier.
- app:checkout_viewed — Stripe checkout window opened, sharing the pending
  checkout attempt id with the success event.
- app:checkout_returned — user returned from checkout (success / cancelled /
  unknown), deduped once per attempt.
- app:first_execution_completed — once-per-user activation moment, guarded by
  a durable localStorage key (private-mode safe).
- Super-properties on every cloud event: is_app_mode (graph vs app mode, via
  a watcher) and customer_tier (event-level mirror of subscription_tier), plus
  first-touch attribution ($set_once initial_utm_* on the person at init).

The super-property registrations are wrapped so a failure (e.g. Pinia not
ready at init) degrades to "property absent" instead of disabling the provider
and skipping identify/logout wiring. media_type classification reuses the
canonical getMediaTypeFromFilename.
2026-06-16 22:01:36 -07:00
Deep Mehta
2cda397cb5 refactor(telemetry): address review on the cloud funnel events
- output_viewed: classify media_type via the canonical getMediaTypeFromFilename
  (reuse, not re-invent; now covers 3D), and evict-before-add in the dedup set
  so it never exceeds its bound.
- Move the output_viewed dedup state (run set + first-output flag) onto the
  store instance instead of module scope, so it resets with the store and
  tests isolate without resetModules.
- canvas_ready: stamp ms_since_auth at canvas-interactive time, before
  runWhenGlobalIdle defers, so the latency is auth->canvas not auth->idle.
- Tests: it.each -> it.for with canonical filename-based rows (+ 3D), drop a
  redundant typeof assertion, call trackCheckoutWindowBlocked with no args to
  match the real call site.
2026-06-16 21:37:02 -07:00
Deep Mehta
938609b844 test(telemetry): positive-path + behavioral tests for the cloud funnel events
- authActivationMarker: mark->consume round-trip, single-consume (clears the
  key), null on missing / corrupt / wrong-shape, and a swallowed
  sessionStorage failure.
- executionStore output_viewed: first media output of a run emits once,
  per-run dedup, is_first_output flips after the session's first, media_type
  mapping (image/video/gifs/audio), text-only skipped, isCloud-gated.
- PostHogTelemetryProvider: each new funnel method (canvas_ready,
  onboarding_routed, output_viewed, auth_method_selected, oauth_popup_result,
  auth_failed, checkout_initiate_failed, checkout_window_blocked) captures the
  right event name and forwards its metadata.
2026-06-16 19:53:46 -07:00
GitHub Action
68cd6c8819 [automated] Apply ESLint and Oxfmt fixes 2026-06-17 01:10:01 +00:00
Deep Mehta
151b6670ef feat(telemetry): instrument cloud funnel gaps (auth, onboarding, canvas, output)
Closes the dark sub-steps between landing and first output that the macro
funnel hid. New PostHog events (all cloud-only):

- app:auth_method_selected / app:oauth_popup_result / app:auth_failed —
  the OAuth popup void where users abandon before auth completes.
- app:canvas_ready — canvas first interactive, with is_new_user and
  ms_since_auth, anchoring new-user activation (user_logged_in carries
  neither). Bridged across the onboarding location.href reload via a
  per-tab sessionStorage marker set at auth completion.
- app:onboarding_routed — the UserCheckView post-auth fork
  (waitlist / survey / onboarded), lighting where users vanish before
  the canvas.
- app:output_viewed — first media output of a run becomes visible (the
  activation moment); deduped per run, is_first_output flags the
  session's first.
- Checkout-failure + paywall-reason events on the subscription path.

Re-enables app:workflow_opened / app:workflow_created (previously in the
default-disabled list) and removes the dead email-verification telemetry
path. GTM and Mixpanel providers ignore the new methods via optional
dispatch; PostHog carries them.
2026-06-16 18:05:32 -07:00
33 changed files with 2156 additions and 92 deletions

View File

@@ -2,6 +2,8 @@
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import { externalLinks, getRoutes } from '../../../config/routes.ts'
import type { CtaButton } from '../../../scripts/posthog'
import { captureCtaClick } from '../../../scripts/posthog'
import GitHubStarBadge from '../GitHubStarBadge.vue'
import HeaderMainDesktop from './HeaderMainDesktop.vue'
import HeaderMainMobile from './HeaderMainMobile.vue'
@@ -13,20 +15,29 @@ const { locale = 'en', githubStars = '' } = defineProps<{
}>()
const routes = getRoutes(locale)
const ctaButtons = [
const ctaButtons: {
prefix: string
core: string
ariaLabel: string
href: string
primary: boolean
ctaButton: CtaButton
}[] = [
{
prefix: t('nav.ctaDesktopPrefix', locale),
core: t('nav.ctaDesktopCore', locale),
ariaLabel: t('nav.downloadLocal', locale),
href: routes.download,
primary: false
primary: false,
ctaButton: 'download_desktop'
},
{
prefix: t('nav.ctaCloudPrefix', locale),
core: t('nav.ctaCloudCore', locale),
ariaLabel: t('nav.launchCloud', locale),
href: externalLinks.cloud,
primary: true
primary: true,
ctaButton: 'launch_cloud'
}
]
</script>
@@ -74,6 +85,7 @@ const ctaButtons = [
:href="cta.href"
:variant="cta.primary ? 'default' : 'outline'"
:aria-label="cta.ariaLabel"
@click="captureCtaClick(cta.ctaButton, 'nav')"
>
<span
><span class="hidden xl:inline-block">{{ cta.prefix }}&nbsp;</span

View File

@@ -14,6 +14,7 @@ import {
import { getMainNavigation } from '../../../data/mainNavigation'
import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { captureCtaClick } from '../../../scripts/posthog'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
@@ -66,9 +67,14 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
:active="isNavItemActive(navItem, currentPath)"
:class="navigationMenuTriggerStyle()"
>
<a :href="navItem.href" class="ppformula-text-center">{{
navItem.label
}}</a>
<a
:href="navItem.href"
class="ppformula-text-center"
@click="
navItem.ctaButton && captureCtaClick(navItem.ctaButton, 'nav')
"
>{{ navItem.label }}</a
>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>

View File

@@ -7,6 +7,7 @@ import { getRoutes } from '../../../config/routes.ts'
import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import { captureCtaClick } from '../../../scripts/posthog'
import NavLinkContent from './NavLinkContent.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
@@ -94,7 +95,11 @@ onUnmounted(() => {
variant="navMuted"
:type="item.columns ? 'button' : undefined"
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
@click="
item.columns
? (activeSection = item.label)
: item.ctaButton && captureCtaClick(item.ctaButton, 'nav')
"
>
{{ item.label }}
<template #append>
@@ -147,6 +152,9 @@ onUnmounted(() => {
as="a"
:target="link.external ? '_blank' : undefined"
:rel="link.external ? 'noopener noreferrer' : undefined"
@click="
link.ctaButton && captureCtaClick(link.ctaButton, 'nav')
"
>
<NavLinkContent :item="link" :locale="locale" />
</Button>

View File

@@ -4,6 +4,7 @@ import NavigationMenuLink from '@/components/ui/navigation-menu/NavigationMenuLi
import { isHrefActive } from '../../../composables/useCurrentPath'
import type { NavColumn } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { captureCtaClick } from '../../../scripts/posthog'
import NavLinkContent from './NavLinkContent.vue'
defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
@@ -26,6 +27,7 @@ defineProps<{ column: NavColumn; locale: Locale; currentPath: string }>()
:target="item.external ? '_blank' : undefined"
:rel="item.external ? 'noopener noreferrer' : undefined"
class="whitespace-nowrap"
@click="item.ctaButton && captureCtaClick(item.ctaButton, 'nav')"
>
<NavLinkContent :item="item" :locale="locale" />
</a>

View File

@@ -1,13 +1,21 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
const { title, description, cta, href, bg } = defineProps<{
import type { CtaButton } from '../../scripts/posthog'
import { captureCtaClick } from '../../scripts/posthog'
const { title, description, cta, href, bg, ctaButton } = defineProps<{
title: string
description: string
cta: string
href: string
bg: string
ctaButton?: CtaButton
}>()
function onClick() {
if (ctaButton) captureCtaClick(ctaButton, 'products_section')
}
</script>
<template>
@@ -19,6 +27,7 @@ const { title, description, cta, href, bg } = defineProps<{
bg
)
"
@click="onClick"
>
<h3 class="text-3xl font-light whitespace-pre-line text-white lg:text-4xl">
{{ title }}
@@ -29,7 +38,7 @@ const { title, description, cta, href, bg } = defineProps<{
{{ description }}
</p>
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
class="bg-primary-comfy-yellow mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide text-primary-comfy-ink"
>
{{ cta }}
</span>

View File

@@ -5,11 +5,19 @@ import { cn } from '@comfyorg/tailwind-utils'
import { getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import type { CtaButton } from '../../scripts/posthog'
import ProductCard from './ProductCard.vue'
import SectionLabel from './SectionLabel.vue'
type Product = 'local' | 'cloud' | 'api' | 'enterprise'
const ctaButtonByProduct: Record<Product, CtaButton> = {
local: 'download_desktop',
cloud: 'comfy_cloud',
api: 'products',
enterprise: 'products'
}
const {
locale = 'en',
excludeProduct,
@@ -29,7 +37,8 @@ function cardDef(product: Product, href: string, bg: string) {
description: t(`products.${product}.description`, locale),
cta: t(`products.${product}.cta`, locale),
href,
bg
bg,
ctaButton: ctaButtonByProduct[product]
}
}

View File

@@ -5,6 +5,7 @@ import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import { captureCtaClick } from '../../scripts/posthog'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -47,6 +48,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
variant="outline"
size="lg"
class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60"
@click="captureCtaClick('run_first_workflow', 'hero')"
>
{{ t('hero.runFirstWorkflow', locale) }}
</BrandButton>

View File

@@ -1,12 +1,14 @@
import { externalLinks, getRoutes } from '../config/routes'
import type { Locale } from '../i18n/translations'
import { t } from '../i18n/translations'
import type { CtaButton } from '../scripts/posthog'
export type NavColumnItem = {
label: string
href: string
badge?: 'new'
external?: boolean
ctaButton?: CtaButton
}
export type NavColumn = {
@@ -31,8 +33,15 @@ export type NavItem =
columns: NavColumn[]
featured?: NavFeatured
href?: never
ctaButton?: never
}
| {
label: string
href: string
columns?: never
featured?: never
ctaButton?: CtaButton
}
| { label: string; href: string; columns?: never; featured?: never }
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
@@ -53,8 +62,16 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.products', locale),
items: [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{
label: t('nav.comfyLocal', locale),
href: routes.download,
ctaButton: 'comfy_desktop'
},
{
label: t('nav.comfyCloud', locale),
href: routes.cloud,
ctaButton: 'comfy_cloud'
},
{
label: t('nav.comfyApi', locale),
href: routes.api,
@@ -82,7 +99,11 @@ export function getMainNavigation(locale: Locale): NavItem[] {
}
]
},
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.pricing', locale),
href: routes.cloudPricing,
ctaButton: 'pricing'
},
{
label: t('nav.community', locale),
featured: {

View File

@@ -78,3 +78,28 @@ describe('captureDownloadClick', () => {
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})
describe('captureCtaClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the CTA event with button and location', async () => {
const { initPostHog, captureCtaClick } = await import('./posthog')
initPostHog()
captureCtaClick('launch_cloud', 'nav')
expect(hoisted.mockCapture).toHaveBeenCalledWith('website:cta_clicked', {
button: 'launch_cloud',
location: 'nav'
})
})
it('does not capture before PostHog is initialized', async () => {
const { captureCtaClick } = await import('./posthog')
captureCtaClick('run_first_workflow', 'hero')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -49,3 +49,23 @@ export function captureDownloadClick(platform: Platform) {
console.error('PostHog download click capture failed', error)
}
}
export type CtaButton =
| 'launch_cloud'
| 'download_desktop'
| 'pricing'
| 'run_first_workflow'
| 'products'
| 'comfy_desktop'
| 'comfy_cloud'
export type CtaLocation = 'nav' | 'hero' | 'products_section'
export function captureCtaClick(button: CtaButton, location: CtaLocation) {
if (!initialized) return
try {
posthog.capture('website:cta_clicked', { button, location })
} catch (error) {
console.error('PostHog CTA click capture failed', error)
}
}

View File

@@ -544,6 +544,12 @@ const allTemplates = computed(() => {
// Navigation
const selectedNavItem = ref<string | null>(initialCategory)
// Track which curated category users browse before opening a template.
watch(selectedNavItem, (to) => {
if (!to) return
useTelemetry()?.trackTemplateCategorySelected({ category_id: to })
})
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {

View File

@@ -1,7 +1,6 @@
<template>
<CloudLoginViewSkeleton v-if="skeletonType === 'login'" />
<CloudSurveyViewSkeleton v-else-if="skeletonType === 'survey'" />
<CloudWaitlistViewSkeleton v-else-if="skeletonType === 'waitlist'" />
<div v-else-if="error" class="flex h-full items-center justify-center p-8">
<div class="max-w-[100vw] p-2 text-center lg:w-96">
<p class="mb-4 text-red-500">{{ errorMessage }}</p>
@@ -32,6 +31,7 @@ import {
getSurveyCompletedStatus,
getUserCloudStatus
} from '@/platform/cloud/onboarding/auth'
import { useTelemetry } from '@/platform/telemetry'
import CloudLoginViewSkeleton from './skeletons/CloudLoginViewSkeleton.vue'
import CloudSurveyViewSkeleton from './skeletons/CloudSurveyViewSkeleton.vue'
@@ -43,7 +43,7 @@ const onboardingSurveyEnabled = computed(
() => flags.onboardingSurveyEnabled ?? true
)
const skeletonType = ref<'login' | 'survey' | 'waitlist' | 'loading'>('loading')
const skeletonType = ref<'login' | 'survey' | 'loading'>('loading')
const {
isLoading,
@@ -54,6 +54,11 @@ const {
await nextTick()
if (!onboardingSurveyEnabled.value) {
useTelemetry()?.trackOnboardingRouted({
destination: 'onboarded',
survey_completed: false,
has_cloud_status: false
})
await router.replace({ path: '/' })
return
}
@@ -65,6 +70,11 @@ const {
// Navigate based on user status
if (!cloudUserStats) {
useTelemetry()?.trackOnboardingRouted({
destination: 'login',
survey_completed: !!surveyStatus,
has_cloud_status: false
})
skeletonType.value = 'login'
await router.replace({ name: 'cloud-login' })
return
@@ -72,12 +82,22 @@ const {
// Survey is required for all users when feature flag is enabled
if (!surveyStatus) {
useTelemetry()?.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)
useTelemetry()?.trackOnboardingRouted({
destination: 'onboarded',
survey_completed: true,
has_cloud_status: true
})
globalThis.location.href = '/'
}),
null,

View File

@@ -30,6 +30,8 @@ const mockIsYearlySubscription = ref(false)
const mockAccessBillingPortal = vi.fn()
const mockReportError = vi.fn()
const mockTrackBeginCheckout = vi.fn()
const mockTrackSubscription = vi.fn()
const mockTrackBillingCycleToggled = vi.fn()
const mockUserId = ref<string | undefined>('user-123')
const mockGetAuthHeader = vi.fn(() =>
Promise.resolve({ Authorization: 'Bearer test-token' })
@@ -111,7 +113,9 @@ vi.mock('@/stores/authStore', () => ({
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackBeginCheckout: mockTrackBeginCheckout
trackBeginCheckout: mockTrackBeginCheckout,
trackSubscription: mockTrackSubscription,
trackBillingCycleToggled: mockTrackBillingCycleToggled
})
}))
@@ -222,6 +226,8 @@ describe('PricingTable', () => {
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockTrackBeginCheckout.mockReset()
mockTrackSubscription.mockReset()
mockTrackBillingCycleToggled.mockReset()
mockLocalStorage.__reset()
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
@@ -439,4 +445,95 @@ describe('PricingTable', () => {
expect(onChooseTeamWorkspace).toHaveBeenCalledOnce()
})
})
describe('subscribe-now click telemetry', () => {
it('fires subscribe_clicked with tier/cycle/source on the new-subscriber path', async () => {
mockIsActiveSubscription.value = false
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
renderComponent()
await flushPromises()
const creatorButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Creator'))
await userEvent.click(creatorButton!)
await flushPromises()
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
current_tier: undefined,
tier: 'creator',
cycle: 'yearly',
source: 'pricing_table'
})
windowOpenSpy.mockRestore()
})
it('fires subscribe_clicked on the change path for an existing subscriber', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'STANDARD'
renderComponent()
await flushPromises()
const proButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Pro'))
await userEvent.click(proButton!)
await flushPromises()
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
current_tier: 'standard',
tier: 'pro',
cycle: 'yearly',
source: 'pricing_table'
})
})
it('does not fire subscribe_clicked when clicking the current plan', async () => {
mockIsActiveSubscription.value = true
mockSubscriptionTier.value = 'CREATOR'
renderComponent()
await flushPromises()
const currentPlanButton = screen
.getAllByRole('button')
.find((b) => b.textContent?.includes('Current Plan'))
await userEvent.click(currentPlanButton!)
await flushPromises()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('billing cycle toggle telemetry', () => {
it('fires billing_cycle_toggled with from/to when switching to monthly', async () => {
renderComponent()
await flushPromises()
const monthlyToggle = screen.getByRole('button', { name: 'Monthly' })
await userEvent.click(monthlyToggle)
await flushPromises()
expect(mockTrackBillingCycleToggled).toHaveBeenCalledWith({
from: 'yearly',
to: 'monthly'
})
})
it('does not fire on initial render when the cycle has not changed', async () => {
renderComponent()
await flushPromises()
expect(mockTrackBillingCycleToggled).not.toHaveBeenCalled()
})
})
})

View File

@@ -258,7 +258,7 @@ import { storeToRefs } from 'pinia'
import Popover from 'primevue/popover'
import SelectButton from 'primevue/selectbutton'
import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -374,6 +374,13 @@ const loadingTier = ref<CheckoutTierKey | null>(null)
const popover = ref()
const currentBillingCycle = ref<BillingCycle>('yearly')
// Track monthly/yearly toggles so we can see whether the annual-discount
// nudge actually moves the cycle selection before checkout.
watch(currentBillingCycle, (to, from) => {
if (!isCloud || to === from) return
telemetry?.trackBillingCycleToggled({ from, to })
})
const hasPaidSubscription = computed(
() => isActiveSubscription.value && !isFreeTier.value
)
@@ -448,6 +455,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
isLoading.value = true
loadingTier.value = tierKey
// Fire the subscribe-now click before opening checkout so the funnel
// captures intent even if the checkout window is blocked or abandoned.
// Covers both the 'change' (existing paid subscriber) and 'new' paths.
telemetry?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase(),
tier: tierKey,
cycle: currentBillingCycle.value,
source: 'pricing_table'
})
try {
if (hasPaidSubscription.value) {
const targetPlan = {

View File

@@ -55,7 +55,8 @@ watch(
const handleSubscribe = () => {
if (isCloud) {
useTelemetry()?.trackSubscription('subscribe_clicked', {
current_tier: subscriptionTier.value?.toLowerCase()
current_tier: subscriptionTier.value?.toLowerCase(),
source: 'subscribe_button'
})
}
isAwaitingStripeSubscription.value = true

View File

@@ -10,6 +10,8 @@ import SubscribeToRun from './SubscribeToRun.vue'
const mockShowSubscriptionDialog = vi.fn()
const mockCanManageSubscription = ref(true)
const mockIsMdOrLarger = ref(true)
const mockTrackRunButton = vi.fn()
const mockTrackSubscription = vi.fn()
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
@@ -29,8 +31,16 @@ vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
vi.mock('@/composables/useRunButtonTelemetry', () => ({
useRunButtonTelemetry: () => ({
trackRunButton: mockTrackRunButton
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
useTelemetry: () => ({
trackSubscription: mockTrackSubscription
})
}))
vi.mock('@vueuse/core', async (importOriginal) => {
@@ -111,4 +121,15 @@ describe('SubscribeToRun', () => {
expect(mockShowSubscriptionDialog).toHaveBeenCalledOnce()
})
it('tracks both the run button and a subscribe-now click on click', async () => {
const { user } = renderButton()
await user.click(screen.getByTestId('subscribe-to-run-button'))
expect(mockTrackRunButton).toHaveBeenCalledWith({ subscribe_to_run: true })
expect(mockTrackSubscription).toHaveBeenCalledWith('subscribe_clicked', {
source: 'subscribe_to_run'
})
})
})

View File

@@ -24,6 +24,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
const { t } = useI18n()
@@ -52,6 +53,11 @@ const buttonTooltip = computed(() =>
function handleSubscribeToRun() {
if (isCloud) {
trackRunButton({ subscribe_to_run: true })
// Also count this as a subscribe-now click so the lock-button CTA shows up
// in the subscribe-click funnel alongside the pricing table and SubscribeButton.
useTelemetry()?.trackSubscription('subscribe_clicked', {
source: 'subscribe_to_run'
})
}
showSubscriptionDialog()

View File

@@ -33,7 +33,11 @@ const {
mockTelemetry: {
trackSubscription: vi.fn(),
trackMonthlySubscriptionSucceeded: vi.fn(),
trackMonthlySubscriptionCancelled: vi.fn()
trackMonthlySubscriptionCancelled: vi.fn(),
trackCheckoutWindowBlocked: vi.fn(),
trackCheckoutInitiateFailed: vi.fn(),
trackCheckoutViewed: vi.fn(),
trackCheckoutReturned: vi.fn()
},
mockUserId: { value: 'user-123' },
mockLocalStorage: (() => {
@@ -176,6 +180,10 @@ describe('useSubscription', () => {
mockTelemetry.trackSubscription.mockReset()
mockTelemetry.trackMonthlySubscriptionSucceeded.mockReset()
mockTelemetry.trackMonthlySubscriptionCancelled.mockReset()
mockTelemetry.trackCheckoutWindowBlocked.mockReset()
mockTelemetry.trackCheckoutInitiateFailed.mockReset()
mockTelemetry.trackCheckoutViewed.mockReset()
mockTelemetry.trackCheckoutReturned.mockReset()
mockAccessBillingPortal.mockReset()
mockAccessBillingPortal.mockResolvedValue(true)
mockUserId.value = 'user-123'
@@ -355,17 +363,42 @@ describe('useSubscription', () => {
)
expect(windowOpenSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
expect(
JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ??
'{}'
)
).toMatchObject({
const storedAttempt = JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ?? '{}'
)
expect(storedAttempt).toMatchObject({
tier: 'standard',
cycle: 'monthly',
checkout_type: 'new'
})
expect(mockTelemetry.trackCheckoutViewed).toHaveBeenCalledTimes(1)
expect(mockTelemetry.trackCheckoutViewed).toHaveBeenCalledWith({
checkout_attempt_id: storedAttempt.attempt_id,
tier: 'standard',
cycle: 'monthly'
})
windowOpenSpy.mockRestore()
})
it('does not emit checkout_viewed when the checkout window is blocked', async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/test' })
} as Response)
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null)
const { subscribe } = useSubscriptionWithScope()
await subscribe()
expect(mockTelemetry.trackCheckoutWindowBlocked).toHaveBeenCalledTimes(1)
expect(mockTelemetry.trackCheckoutViewed).not.toHaveBeenCalled()
windowOpenSpy.mockRestore()
})
@@ -382,7 +415,7 @@ describe('useSubscription', () => {
})
describe('pending checkout recovery', () => {
it('emits subscription_success when a pending new subscription becomes active', async () => {
it('records a success return and clears the pending attempt when a new subscription becomes active', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
@@ -409,26 +442,22 @@ describe('useSubscription', () => {
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
user_id: 'user-123',
checkout_attempt_id: 'attempt-123',
tier: 'creator',
cycle: 'yearly',
checkout_type: 'new',
value: 336,
currency: 'USD'
})
)
expect(mockTelemetry.trackCheckoutReturned).toHaveBeenCalledWith({
checkout_attempt_id: 'attempt-123',
outcome: 'success'
})
})
expect(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
// Subscription-success conversion is emitted by the backend, not the FE.
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).not.toHaveBeenCalled()
})
it('emits subscription_success when a pending upgrade reaches the target tier', async () => {
it('records a success return when a pending upgrade reaches the target tier', async () => {
localStorage.setItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY,
JSON.stringify({
@@ -457,19 +486,14 @@ describe('useSubscription', () => {
useSubscriptionWithScope()
await vi.waitFor(() => {
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: 'attempt-456',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'change',
previous_tier: 'creator',
value: 100
})
)
expect(mockTelemetry.trackCheckoutReturned).toHaveBeenCalledWith({
checkout_attempt_id: 'attempt-456',
outcome: 'success'
})
})
expect(
mockTelemetry.trackMonthlySubscriptionSucceeded
).not.toHaveBeenCalled()
})
it('rechecks pending checkout attempts when the document becomes visible', async () => {
@@ -556,6 +580,95 @@ describe('useSubscription', () => {
})
})
describe('checkout_returned', () => {
const inactiveStatusResponse = {
ok: true,
json: async () => ({
is_active: false,
subscription_id: '',
renewal_date: ''
})
} as Response
const checkoutUrlResponse = {
ok: true,
json: async () => ({ checkout_url: 'https://checkout.stripe.com/x' })
} as Response
it('emits an unknown outcome on return while the attempt is still pending', async () => {
mockIsLoggedIn.value = true
// Bootstrap fetch (logged-in watcher) resolves to an inactive status.
vi.mocked(global.fetch).mockResolvedValue(inactiveStatusResponse)
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
const { subscribe } = useSubscriptionWithScope()
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalled()
})
// The checkout POST returns a Stripe URL; subsequent status fetches stay
// inactive so the attempt remains pending after the user returns.
vi.mocked(global.fetch)
.mockResolvedValueOnce(checkoutUrlResponse)
.mockResolvedValue(inactiveStatusResponse)
await subscribe()
const storedAttempt = JSON.parse(
localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY) ?? '{}'
)
// Simulate the user returning from the Stripe tab before the
// subscription is confirmed active.
window.dispatchEvent(new Event('pageshow'))
await vi.waitFor(() => {
expect(mockTelemetry.trackCheckoutReturned).toHaveBeenCalledWith({
checkout_attempt_id: storedAttempt.attempt_id,
outcome: 'unknown'
})
})
windowOpenSpy.mockRestore()
})
it('emits checkout_returned at most once per attempt across repeated returns', async () => {
mockIsLoggedIn.value = true
vi.mocked(global.fetch).mockResolvedValue(inactiveStatusResponse)
const windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
const { subscribe } = useSubscriptionWithScope()
await vi.waitFor(() => {
expect(global.fetch).toHaveBeenCalled()
})
vi.mocked(global.fetch)
.mockResolvedValueOnce(checkoutUrlResponse)
.mockResolvedValue(inactiveStatusResponse)
await subscribe()
window.dispatchEvent(new Event('pageshow'))
document.dispatchEvent(new Event('visibilitychange'))
window.dispatchEvent(new Event('pageshow'))
await vi.waitFor(() => {
expect(mockTelemetry.trackCheckoutReturned).toHaveBeenCalled()
})
expect(mockTelemetry.trackCheckoutReturned).toHaveBeenCalledTimes(1)
windowOpenSpy.mockRestore()
})
})
describe('requireActiveSubscription', () => {
it('should not show dialog when subscription is active', async () => {
vi.mocked(global.fetch).mockResolvedValue({
@@ -588,7 +701,10 @@ describe('useSubscription', () => {
await requireActiveSubscription()
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalled()
// Login-time enforcement, not a run gate — see useSubscription.ts.
expect(mockShowSubscriptionRequiredDialog).toHaveBeenCalledWith({
reason: 'subscription_required'
})
})
})

View File

@@ -130,6 +130,29 @@ function useSubscriptionInternal() {
let pendingCheckoutRecoveryAttempt = 0
let isRecoveringPendingCheckout = false
// Non-success returns attribute via this; success outcomes use the id from metadata (cross-session).
let lastCheckoutAttempt: {
attempt_id: string
tier: string
cycle: string
} | null = null
// Fire checkout_returned once per attempt despite pageshow/visibilitychange firing repeatedly.
const reportedReturnedAttemptIds = new Set<string>()
const reportCheckoutReturned = (
checkoutAttemptId: string,
outcome: 'success' | 'cancelled' | 'unknown'
) => {
if (reportedReturnedAttemptIds.has(checkoutAttemptId)) {
return
}
reportedReturnedAttemptIds.add(checkoutAttemptId)
telemetry?.trackCheckoutReturned({
checkout_attempt_id: checkoutAttemptId,
outcome
})
}
const stopPendingCheckoutRecovery = () => {
if (pendingCheckoutRecoveryTimeout !== null && defaultWindow) {
defaultWindow.clearTimeout(pendingCheckoutRecoveryTimeout)
@@ -172,17 +195,25 @@ function useSubscriptionInternal() {
if (!metadata) {
if (hasPendingSubscriptionCheckoutAttempt()) {
// Back but Stripe unconfirmed; recovery retries resolve to success later.
if (lastCheckoutAttempt) {
reportCheckoutReturned(lastCheckoutAttempt.attempt_id, 'unknown')
}
schedulePendingCheckoutRecovery()
} else {
// No pending attempt remains: abandoned/cleared, i.e. cancelled at Stripe.
if (lastCheckoutAttempt) {
reportCheckoutReturned(lastCheckoutAttempt.attempt_id, 'cancelled')
}
stopPendingCheckoutRecovery()
}
return
}
telemetry?.trackMonthlySubscriptionSucceeded({
...(authStore.userId ? { user_id: authStore.userId } : {}),
...metadata
})
// Subscription-success analytics now come from the backend
// billing:subscription_created event (Stripe webhook); the FE only records
// that the user returned and clears the pending attempt.
reportCheckoutReturned(metadata.checkout_attempt_id, 'success')
stopPendingCheckoutRecovery()
}
@@ -204,9 +235,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'
@@ -216,10 +257,11 @@ function useSubscriptionInternal() {
const checkoutWindow = window.open(response.checkout_url, '_blank')
if (!checkoutWindow) {
telemetry?.trackCheckoutWindowBlocked()
return
}
recordPendingSubscriptionCheckoutAttempt({
const attempt = recordPendingSubscriptionCheckoutAttempt({
tier: 'standard',
cycle: 'monthly',
checkout_type: isSubscribedOrIsNotCloud.value ? 'change' : 'new',
@@ -232,6 +274,18 @@ function useSubscriptionInternal() {
? { previous_cycle: 'monthly' as const }
: {})
})
lastCheckoutAttempt = {
attempt_id: attempt.attempt_id,
tier: attempt.tier,
cycle: attempt.cycle
}
reportedReturnedAttemptIds.delete(attempt.attempt_id)
telemetry?.trackCheckoutViewed({
checkout_attempt_id: attempt.attempt_id,
tier: attempt.tier,
cycle: attempt.cycle
})
}, reportError)
const showSubscriptionDialog = (options?: {
@@ -276,7 +330,8 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog()
// Login-time enforcement, not a run gate; reason keeps the run_button cohort clean.
showSubscriptionDialog({ reason: 'subscription_required' })
}
}

View File

@@ -7,8 +7,12 @@ const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockSubscriptionTier = vi.hoisted(() => ({
value: null as string | null
}))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockTrackPaywallViewed = vi.hoisted(() => vi.fn())
vi.mock('vue', async (importOriginal) => {
const actual = await importOriginal()
@@ -43,7 +47,14 @@ vi.mock('@/composables/useFeatureFlags', () => ({
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isFreeTier: mockIsFreeTier
isFreeTier: mockIsFreeTier,
subscriptionTier: mockSubscriptionTier
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackPaywallViewed: mockTrackPaywallViewed
})
}))
@@ -67,6 +78,7 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockSubscriptionTier.value = null
mockTeamWorkspacesEnabled.value = false
try {
@@ -96,6 +108,75 @@ describe('useSubscriptionDialog', () => {
})
})
describe('paywall_viewed telemetry', () => {
it('emits paywall_viewed with the reason when the pricing table opens', () => {
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'run_workflow' })
expect(mockTrackPaywallViewed).toHaveBeenCalledTimes(1)
expect(mockTrackPaywallViewed).toHaveBeenCalledWith({
reason: 'run_workflow'
})
})
it('defaults the reason to subscription_required when none is given', () => {
const { showPricingTable } = useSubscriptionDialog()
showPricingTable()
expect(mockTrackPaywallViewed).toHaveBeenCalledWith({
reason: 'subscription_required'
})
})
it('includes the lowercased current_tier when a tier is known', () => {
mockSubscriptionTier.value = 'CREATOR'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upload_model' })
expect(mockTrackPaywallViewed).toHaveBeenCalledWith({
reason: 'upload_model',
current_tier: 'creator'
})
})
it('does not emit paywall_viewed on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'member_invite' })
expect(mockTrackPaywallViewed).not.toHaveBeenCalled()
})
it('emits once for the free-tier dialog and does not double-count showPricingTable', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'member_invite' })
expect(mockTrackPaywallViewed).toHaveBeenCalledTimes(1)
expect(mockTrackPaywallViewed).toHaveBeenCalledWith({
reason: 'member_invite'
})
})
it('emits once when show falls through to the pricing table for non-free tier', () => {
mockIsFreeTier.value = false
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackPaywallViewed).toHaveBeenCalledTimes(1)
expect(mockTrackPaywallViewed).toHaveBeenCalledWith({
reason: 'out_of_credits'
})
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {
it('closes existing dialogs before opening team workspace dialog', () => {
mockShowTeamWorkspacesDialog.mockResolvedValue(undefined)

View File

@@ -4,6 +4,7 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -15,6 +16,10 @@ export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
// Non-activation cohort: the activation funnel must be able to exclude these.
| 'member_invite'
| 'upload_model'
| 'run_workflow'
export const useSubscriptionDialog = () => {
const { flags } = useFeatureFlags()
@@ -22,7 +27,17 @@ export const useSubscriptionDialog = () => {
const dialogStore = useDialogStore()
const workspaceStore = useTeamWorkspaceStore()
const { permissions } = useWorkspaceUI()
const { isFreeTier } = useSubscription()
const { isFreeTier, subscriptionTier } = useSubscription()
function trackPaywallViewed(reason?: SubscriptionDialogReason) {
if (!isCloud) return
useTelemetry()?.trackPaywallViewed({
reason: reason ?? 'subscription_required',
...(subscriptionTier.value
? { current_tier: subscriptionTier.value.toLowerCase() }
: {})
})
}
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -32,6 +47,8 @@ export const useSubscriptionDialog = () => {
function showPricingTable(options?: { reason?: SubscriptionDialogReason }) {
if (!isCloud) return
trackPaywallViewed(options?.reason)
// Members can't manage the workspace subscription, so a blocked run shows a
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
@@ -99,6 +116,8 @@ export const useSubscriptionDialog = () => {
function show(options?: { reason?: SubscriptionDialogReason }) {
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackPaywallViewed(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')

View File

@@ -45,4 +45,115 @@ describe('TelemetryRegistry', () => {
})
).not.toThrow()
})
it('forwards each cloud funnel event to registered providers', () => {
const provider: TelemetryProvider = {
trackAuthMethodSelected: vi.fn(),
trackOAuthPopupResult: vi.fn(),
trackAuthFailed: vi.fn(),
trackAuthError: vi.fn(),
trackCanvasReady: vi.fn(),
trackOnboardingRouted: vi.fn(),
trackPaywallViewed: vi.fn(),
trackCheckoutViewed: vi.fn(),
trackCheckoutReturned: vi.fn(),
trackCheckoutInitiateFailed: vi.fn(),
trackCheckoutWindowBlocked: vi.fn(),
trackBillingCycleToggled: vi.fn(),
trackTemplateCategorySelected: vi.fn(),
trackFirstExecutionCompleted: vi.fn(),
trackOutputViewed: vi.fn()
}
const registry = new TelemetryRegistry()
registry.registerProvider(provider)
registry.trackAuthMethodSelected({ method: 'email', view: 'login' })
registry.trackOAuthPopupResult({ provider: 'google', result: 'success' })
registry.trackAuthFailed({ method: 'email', stage: 'firebase' })
registry.trackAuthError({ method: 'email', is_sign_up: true })
registry.trackCanvasReady({ is_new_user: true })
registry.trackOnboardingRouted({
destination: 'survey',
survey_completed: true,
has_cloud_status: false
})
registry.trackPaywallViewed({ reason: 'run_button' })
registry.trackCheckoutViewed({
checkout_attempt_id: 'att_1',
tier: 'pro',
cycle: 'monthly'
})
registry.trackCheckoutReturned({
checkout_attempt_id: 'att_1',
outcome: 'success'
})
registry.trackCheckoutInitiateFailed({ stage: 'no_url' })
registry.trackCheckoutWindowBlocked({})
registry.trackBillingCycleToggled({ from: 'monthly', to: 'yearly' })
registry.trackTemplateCategorySelected({ category_id: 'image' })
registry.trackFirstExecutionCompleted({ workflow_run_id: 'run_1' })
registry.trackOutputViewed({
workflow_run_id: 'run_1',
media_type: 'image',
is_first_output: true
})
expect(provider.trackAuthMethodSelected).toHaveBeenCalledExactlyOnceWith({
method: 'email',
view: 'login'
})
expect(provider.trackOAuthPopupResult).toHaveBeenCalledExactlyOnceWith({
provider: 'google',
result: 'success'
})
expect(provider.trackAuthFailed).toHaveBeenCalledExactlyOnceWith({
method: 'email',
stage: 'firebase'
})
expect(provider.trackAuthError).toHaveBeenCalledExactlyOnceWith({
method: 'email',
is_sign_up: true
})
expect(provider.trackCanvasReady).toHaveBeenCalledExactlyOnceWith({
is_new_user: true
})
expect(provider.trackOnboardingRouted).toHaveBeenCalledExactlyOnceWith({
destination: 'survey',
survey_completed: true,
has_cloud_status: false
})
expect(provider.trackPaywallViewed).toHaveBeenCalledExactlyOnceWith({
reason: 'run_button'
})
expect(provider.trackCheckoutViewed).toHaveBeenCalledExactlyOnceWith({
checkout_attempt_id: 'att_1',
tier: 'pro',
cycle: 'monthly'
})
expect(provider.trackCheckoutReturned).toHaveBeenCalledExactlyOnceWith({
checkout_attempt_id: 'att_1',
outcome: 'success'
})
expect(
provider.trackCheckoutInitiateFailed
).toHaveBeenCalledExactlyOnceWith({ stage: 'no_url' })
expect(provider.trackCheckoutWindowBlocked).toHaveBeenCalledExactlyOnceWith(
{}
)
expect(provider.trackBillingCycleToggled).toHaveBeenCalledExactlyOnceWith({
from: 'monthly',
to: 'yearly'
})
expect(
provider.trackTemplateCategorySelected
).toHaveBeenCalledExactlyOnceWith({ category_id: 'image' })
expect(
provider.trackFirstExecutionCompleted
).toHaveBeenCalledExactlyOnceWith({ workflow_run_id: 'run_1' })
expect(provider.trackOutputViewed).toHaveBeenCalledExactlyOnceWith({
workflow_run_id: 'run_1',
media_type: 'image',
is_first_output: true
})
})
})

View File

@@ -1,20 +1,35 @@
import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthFailedMetadata,
AuthMethodSelectedMetadata,
AuthMetadata,
BeginCheckoutMetadata,
CanvasReadyMetadata,
CheckoutInitiateFailedMetadata,
CheckoutReturnedMetadata,
CheckoutViewedMetadata,
CheckoutWindowBlockedMetadata,
BillingCycleToggledMetadata,
AuthErrorMetadata,
TemplateCategorySelectedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
FirstExecutionCompletedMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OAuthPopupResultMetadata,
OnboardingRoutedMetadata,
OutputViewedMetadata,
PaywallViewedMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -67,6 +82,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))
}
@@ -75,6 +102,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
@@ -82,10 +117,48 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSubscription?.(event, metadata))
}
trackPaywallViewed(metadata: PaywallViewedMetadata): void {
this.dispatch((provider) => provider.trackPaywallViewed?.(metadata))
}
trackBeginCheckout(metadata: BeginCheckoutMetadata): void {
this.dispatch((provider) => provider.trackBeginCheckout?.(metadata))
}
trackCheckoutViewed(metadata: CheckoutViewedMetadata): void {
this.dispatch((provider) => provider.trackCheckoutViewed?.(metadata))
}
trackCheckoutReturned(metadata: CheckoutReturnedMetadata): void {
this.dispatch((provider) => provider.trackCheckoutReturned?.(metadata))
}
trackCheckoutInitiateFailed(metadata: CheckoutInitiateFailedMetadata): void {
this.dispatch((provider) =>
provider.trackCheckoutInitiateFailed?.(metadata)
)
}
trackCheckoutWindowBlocked(metadata?: CheckoutWindowBlockedMetadata): void {
this.dispatch((provider) => provider.trackCheckoutWindowBlocked?.(metadata))
}
trackBillingCycleToggled(metadata: BillingCycleToggledMetadata): void {
this.dispatch((provider) => provider.trackBillingCycleToggled?.(metadata))
}
trackAuthError(metadata: AuthErrorMetadata): void {
this.dispatch((provider) => provider.trackAuthError?.(metadata))
}
trackTemplateCategorySelected(
metadata: TemplateCategorySelectedMetadata
): void {
this.dispatch((provider) =>
provider.trackTemplateCategorySelected?.(metadata)
)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
@@ -248,6 +321,18 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackExecutionSuccess?.(metadata))
}
trackFirstExecutionCompleted(
metadata: FirstExecutionCompletedMetadata
): void {
this.dispatch((provider) =>
provider.trackFirstExecutionCompleted?.(metadata)
)
}
trackOutputViewed(metadata: OutputViewedMetadata): void {
this.dispatch((provider) => provider.trackOutputViewed?.(metadata))
}
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
this.dispatch((provider) => provider.trackSharedWorkflowRun?.(metadata))
}

View File

@@ -0,0 +1,105 @@
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')
})
it('ignores a marker older than the freshness window', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-16T00:00:00.000Z'))
markAuthForActivation(true)
// An unrelated reload minutes later must not report a bogus ms_since_auth.
vi.setSystemTime(new Date('2026-06-16T00:05:00.000Z'))
expect(consumeAuthActivation()).toBeNull()
})
})
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()
})
})
})

View File

@@ -0,0 +1,41 @@
// The onboarded path navigates via full location.href reload, wiping the auth
// store before the canvas mounts, so canvas_ready data is bridged through sessionStorage.
const MARKER_KEY = 'comfy:telemetry:auth-activation'
const MAX_MARKER_AGE_MS = 60_000
interface AuthActivationMarker {
at: number
isNewUser: boolean
}
export function markAuthForActivation(isNewUser: boolean): void {
try {
const marker: AuthActivationMarker = { at: Date.now(), isNewUser }
sessionStorage.setItem(MARKER_KEY, JSON.stringify(marker))
} catch {
// sessionStorage unavailable (private mode, SSR).
}
}
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'
) {
const marker = parsed as AuthActivationMarker
// Drop a stale marker so an unrelated later reload doesn't report a bogus ms_since_auth.
if (Date.now() - marker.at <= MAX_MARKER_AGE_MS) return marker
}
} catch {
// Corrupt or unavailable; treat as no marker.
}
return null
}

View File

@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { watch } from 'vue'
import { TelemetryEvents } from '../../types'
@@ -67,6 +68,12 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: { value: false }
})
}))
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
function createProvider(
@@ -107,9 +114,9 @@ describe('PostHogTelemetryProvider', () => {
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
autocapture: true,
capture_pageview: true,
capture_pageleave: true,
persistence: 'localStorage+cookie'
})
)
@@ -324,6 +331,18 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures billing cycle toggles with from/to', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackBillingCycleToggled({ from: 'yearly', to: 'monthly' })
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.BILLING_CYCLE_TOGGLED,
{ from: 'yearly', to: 'monthly' }
)
})
it('captures search queries with surface, query, length, and result count', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -450,10 +469,7 @@ describe('PostHogTelemetryProvider', () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackWorkflowOpened({
missing_node_count: 0,
missing_node_types: []
})
provider.trackPageVisibilityChanged({ visibility_state: 'hidden' })
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
@@ -686,4 +702,314 @@ describe('PostHogTelemetryProvider', () => {
expect(initConfig.person_profiles).toBe('identified_only')
})
})
describe('funnel events', () => {
it('captures canvas_ready with new-user flag and auth latency', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = { is_new_user: true, ms_since_auth: 1234 }
provider.trackCanvasReady(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CANVAS_READY,
metadata
)
})
it('captures onboarding_routed with destination and provisioning flags', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
destination: 'survey',
survey_completed: false,
has_cloud_status: true
} as const
provider.trackOnboardingRouted(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.ONBOARDING_ROUTED,
metadata
)
})
it('captures output_viewed with run id, media type, and first-output flag', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
workflow_run_id: 'run-1',
media_type: 'image',
is_first_output: true
}
provider.trackOutputViewed(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.OUTPUT_VIEWED,
metadata
)
})
it('captures auth_method_selected with method and view', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = { method: 'google', view: 'signup' } as const
provider.trackAuthMethodSelected(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.AUTH_METHOD_SELECTED,
metadata
)
})
it('captures oauth_popup_result with provider, result, and error code', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
provider: 'github',
result: 'error',
error_code: 'popup_closed_by_user'
} as const
provider.trackOAuthPopupResult(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.OAUTH_POPUP_RESULT,
metadata
)
})
it('captures auth_failed with method, stage, and error code', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
method: 'email',
stage: 'create_customer',
error_code: 'provisioning_timeout'
} as const
provider.trackAuthFailed(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.AUTH_FAILED,
metadata
)
})
it('captures checkout_initiate_failed with stage and error code', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
stage: 'no_url',
error_code: 'missing_checkout_url'
} as const
provider.trackCheckoutInitiateFailed(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CHECKOUT_INITIATE_FAILED,
metadata
)
})
it('captures checkout_window_blocked', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
// Matches the real call site (useSubscription) which passes no metadata.
provider.trackCheckoutWindowBlocked()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CHECKOUT_WINDOW_BLOCKED,
{}
)
})
it('captures paywall_viewed with reason and current tier', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
reason: 'run_gate',
current_tier: 'free'
} as const
provider.trackPaywallViewed(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAYWALL_VIEWED,
metadata
)
})
it('captures checkout_viewed with attempt id, tier, and cycle', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
checkout_attempt_id: 'attempt-1',
tier: 'creator',
cycle: 'monthly'
}
provider.trackCheckoutViewed(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CHECKOUT_VIEWED,
metadata
)
})
it('captures checkout_returned with attempt id and outcome', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
checkout_attempt_id: 'attempt-1',
outcome: 'success'
} as const
provider.trackCheckoutReturned(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.CHECKOUT_RETURNED,
metadata
)
})
it('captures first_execution_completed with run id and customer tier', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
workflow_run_id: 'run-1',
customer_tier: 'CREATOR'
}
provider.trackFirstExecutionCompleted(metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.FIRST_EXECUTION_COMPLETED,
metadata
)
})
})
describe('super-properties', () => {
function setLocation(search: string): void {
Object.defineProperty(window.location, 'search', {
configurable: true,
value: search,
writable: true
})
}
afterEach(() => {
setLocation('')
})
// The vue mock no-ops watch, so the immediate registration never auto-fires; replay the
// registered handler manually and locate it by the super-property key it registers.
type WatchHandler = (value: unknown) => void
function getWatchHandlers(): WatchHandler[] {
return (watch as unknown as ReturnType<typeof vi.fn>).mock.calls.map(
(call) => call[1] as WatchHandler
)
}
function findRegisterHandler(propertyKey: string): WatchHandler {
const handlers = getWatchHandlers()
for (const handler of handlers) {
hoisted.mockRegister.mockClear()
handler('__probe__')
const matched = hoisted.mockRegister.mock.calls.some(
([props]) => props && propertyKey in props
)
if (matched) {
hoisted.mockRegister.mockClear()
return handler
}
}
hoisted.mockRegister.mockClear()
throw new Error(`No watch handler registers "${propertyKey}"`)
}
it('registers is_app_mode as a super-property via register() when app mode changes', async () => {
createProvider()
await vi.dynamicImportSettled()
// registerAppModeSuperProperty() watches useAppMode().isAppMode and
// mirrors the current mode onto every event via register().
const handler = findRegisterHandler('is_app_mode')
handler(true)
expect(hoisted.mockRegister).toHaveBeenCalledWith({ is_app_mode: true })
})
it('registers customer_tier as a super-property via register() once tier is known', async () => {
createProvider()
await vi.dynamicImportSettled()
// registerCustomerTierSuperProperty() watches
// useSubscription().subscriptionTier and only registers a truthy tier.
const handler = findRegisterHandler('customer_tier')
handler('CREATOR')
expect(hoisted.mockRegister).toHaveBeenCalledWith({
customer_tier: 'CREATOR'
})
})
it('does not register customer_tier while tier is still null', async () => {
createProvider()
await vi.dynamicImportSettled()
const handler = findRegisterHandler('customer_tier')
handler(null)
const tierCall = hoisted.mockRegister.mock.calls.find(
([props]) => props && 'customer_tier' in props
)
expect(tierCall).toBeUndefined()
})
it('sets first-touch initial_utm_* on the person via set_once from the landing URL', async () => {
setLocation(
'?utm_source=newsletter&utm_medium=email&utm_campaign=spring_launch'
)
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockPeopleSetOnce).toHaveBeenCalledWith({
initial_utm_source: 'newsletter',
initial_utm_medium: 'email',
initial_utm_campaign: 'spring_launch'
})
})
it('only sets the first-touch params that are present in the URL', async () => {
setLocation('?utm_source=twitter')
createProvider()
await vi.dynamicImportSettled()
const firstTouchCall = hoisted.mockPeopleSetOnce.mock.calls.find(
([props]) => props && 'initial_utm_source' in props
)
expect(firstTouchCall?.[0]).toEqual({ initial_utm_source: 'twitter' })
})
it('does not set first-touch attribution when no utm params are present', async () => {
setLocation('')
createProvider()
await vi.dynamicImportSettled()
const firstTouchCall = hoisted.mockPeopleSetOnce.mock.calls.find(
([props]) =>
props &&
('initial_utm_source' in props ||
'initial_utm_medium' in props ||
'initial_utm_campaign' in props)
)
expect(firstTouchCall).toBeUndefined()
})
})
})

View File

@@ -4,22 +4,38 @@ import { watch } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAppMode } from '@/composables/useAppMode'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthFailedMetadata,
AuthMethodSelectedMetadata,
AuthMetadata,
CanvasReadyMetadata,
CheckoutInitiateFailedMetadata,
CheckoutReturnedMetadata,
CheckoutViewedMetadata,
CheckoutWindowBlockedMetadata,
BillingCycleToggledMetadata,
AuthErrorMetadata,
TemplateCategorySelectedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShareLinkOpenedMetadata,
FirstExecutionCompletedMetadata,
HelpCenterClosedMetadata,
HelpCenterOpenedMetadata,
HelpResourceClickedMetadata,
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OAuthPopupResultMetadata,
OnboardingRoutedMetadata,
OutputViewedMetadata,
PaywallViewedMetadata,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -47,15 +63,13 @@ import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.WORKFLOW_OPENED,
TelemetryEvents.PAGE_VISIBILITY_CHANGED,
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED
TelemetryEvents.HELP_CENTER_CLOSED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -122,9 +136,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
autocapture: true,
capture_pageview: true,
capture_pageleave: true,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig,
@@ -138,6 +152,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.isInitialized = true
this.flushEventQueue()
this.registerDesktopEntryProps()
this.registerAppModeSuperProperty()
this.registerCustomerTierSuperProperty()
this.setFirstTouchAttribution()
const currentUser = useCurrentUser()
currentUser.onUserResolved((user) => {
@@ -319,10 +336,83 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
)
}
private registerAppModeSuperProperty(): void {
// Runs inside init's import .then(); an uncaught throw hits .catch and disables the provider.
try {
const { isAppMode } = useAppMode()
watch(
isAppMode,
(value) => {
if (this.posthog) {
this.posthog.register({ is_app_mode: value })
}
},
{ immediate: true }
)
} catch (error) {
console.error('Failed to register is_app_mode super-property:', error)
}
}
private registerCustomerTierSuperProperty(): void {
try {
const { subscriptionTier } = useSubscription()
watch(
subscriptionTier,
(tier) => {
if (tier && this.posthog) {
this.posthog.register({ customer_tier: tier })
}
},
{ immediate: true }
)
} catch (error) {
console.error('Failed to register customer_tier super-property:', error)
}
}
private setFirstTouchAttribution(): void {
if (!this.posthog) return
const params = new URLSearchParams(window.location.search)
const firstTouch: Record<string, string> = {}
const source = params.get('utm_source')
const medium = params.get('utm_medium')
const campaign = params.get('utm_campaign')
if (source) firstTouch.initial_utm_source = source
if (medium) firstTouch.initial_utm_medium = medium
if (campaign) firstTouch.initial_utm_campaign = campaign
if (Object.keys(firstTouch).length === 0) return
try {
this.posthog.people.set_once(firstTouch)
} catch (error) {
console.error('Failed to set first-touch attribution:', error)
}
}
trackSignupOpened(): void {
this.trackEvent(TelemetryEvents.USER_SIGN_UP_OPENED)
}
trackAuthMethodSelected(metadata: AuthMethodSelectedMetadata): void {
this.trackEvent(TelemetryEvents.AUTH_METHOD_SELECTED, metadata)
}
trackOAuthPopupResult(metadata: OAuthPopupResultMetadata): void {
this.trackEvent(TelemetryEvents.OAUTH_POPUP_RESULT, metadata)
}
trackAuthFailed(metadata: AuthFailedMetadata): void {
this.trackEvent(TelemetryEvents.AUTH_FAILED, metadata)
}
trackCanvasReady(metadata: CanvasReadyMetadata): void {
this.trackEvent(TelemetryEvents.CANVAS_READY, metadata)
}
trackOnboardingRouted(metadata: OnboardingRoutedMetadata): void {
this.trackEvent(TelemetryEvents.ONBOARDING_ROUTED, metadata)
}
trackAuth(metadata: AuthMetadata): void {
if (metadata.is_new_user && metadata.user_id) {
this.setFirstAuthAt(metadata.user_id)
@@ -346,10 +436,44 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName, metadata)
}
trackPaywallViewed(metadata: PaywallViewedMetadata): void {
this.trackEvent(TelemetryEvents.PAYWALL_VIEWED, metadata)
}
trackCheckoutViewed(metadata: CheckoutViewedMetadata): void {
this.trackEvent(TelemetryEvents.CHECKOUT_VIEWED, metadata)
}
trackCheckoutReturned(metadata: CheckoutReturnedMetadata): void {
this.trackEvent(TelemetryEvents.CHECKOUT_RETURNED, metadata)
}
trackBillingCycleToggled(metadata: BillingCycleToggledMetadata): void {
this.trackEvent(TelemetryEvents.BILLING_CYCLE_TOGGLED, metadata)
}
trackAuthError(metadata: AuthErrorMetadata): void {
this.trackEvent(TelemetryEvents.AUTH_ERROR, metadata)
}
trackTemplateCategorySelected(
metadata: TemplateCategorySelectedMetadata
): void {
this.trackEvent(TelemetryEvents.TEMPLATE_CATEGORY_SELECTED, metadata)
}
trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackCheckoutInitiateFailed(metadata: CheckoutInitiateFailedMetadata): void {
this.trackEvent(TelemetryEvents.CHECKOUT_INITIATE_FAILED, metadata)
}
trackCheckoutWindowBlocked(metadata?: CheckoutWindowBlockedMetadata): void {
this.trackEvent(TelemetryEvents.CHECKOUT_WINDOW_BLOCKED, metadata)
}
trackMonthlySubscriptionSucceeded(
metadata?: SubscriptionSuccessMetadata
): void {
@@ -510,6 +634,16 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_CREATED, metadata)
}
trackFirstExecutionCompleted(
metadata: FirstExecutionCompletedMetadata
): void {
this.trackEvent(TelemetryEvents.FIRST_EXECUTION_COMPLETED, metadata)
}
trackOutputViewed(metadata: OutputViewedMetadata): void {
this.trackEvent(TelemetryEvents.OUTPUT_VIEWED, metadata)
}
trackSharedWorkflowRun(metadata: SharedWorkflowRunMetadata): void {
this.trackEvent(TelemetryEvents.SHARED_WORKFLOW_RUN, metadata)
}

View File

@@ -33,6 +33,34 @@ export interface AuthMetadata {
utm_campaign?: string
}
export type AuthMethod = 'email' | 'google' | 'github'
export type AuthView = 'login' | 'signup'
export interface AuthMethodSelectedMetadata {
method: AuthMethod
view: AuthView
}
export type OAuthProvider = 'google' | 'github'
type OAuthPopupResult = 'success' | 'cancelled' | 'error'
export interface OAuthPopupResultMetadata {
provider: OAuthProvider
result: OAuthPopupResult
error_code?: string
}
// create_customer failure = authenticated in Firebase but never provisioned (no auth_completed).
type AuthFailureStage = 'firebase' | 'create_customer'
export interface AuthFailedMetadata {
method: AuthMethod
stage: AuthFailureStage
error_code?: string
}
/**
* Survey response data for user profiling
* Maps 1-to-1 with actual survey fields
@@ -423,9 +451,64 @@ export interface CheckoutAttributionMetadata {
wbraid?: string
}
type SubscribeClickSource =
| 'pricing_table'
| 'subscribe_to_run'
| 'subscribe_button'
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
tier?: TierKey
cycle?: BillingCycle
source?: SubscribeClickSource
}
export interface BillingCycleToggledMetadata {
from: BillingCycle
to: BillingCycle
}
export interface AuthErrorMetadata {
method: 'email' | 'google' | 'github'
is_sign_up: boolean
error_code?: string
error_message?: string
}
export interface TemplateCategorySelectedMetadata {
category_id: string
category_label?: string
}
// no_url = server returned no checkout_url; server_error = checkout request failed.
type CheckoutInitiateFailureStage = 'no_url' | 'server_error'
export interface CheckoutInitiateFailedMetadata {
stage: CheckoutInitiateFailureStage
error_code?: string
}
export type CheckoutWindowBlockedMetadata = Record<string, never>
// Activation proxy: fires on the `executed` message, not on actual output visibility.
export interface OutputViewedMetadata {
workflow_run_id: string
media_type: string
is_first_output: boolean
}
type OnboardingDestination = 'login' | 'survey' | 'onboarded'
export interface OnboardingRoutedMetadata {
destination: OnboardingDestination
survey_completed: boolean
has_cloud_status: boolean
}
export interface CanvasReadyMetadata {
is_new_user: boolean
ms_since_auth?: number
}
export interface BeginCheckoutMetadata
@@ -463,6 +546,30 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
ecommerce: EcommerceMetadata
}
export interface PaywallViewedMetadata {
reason: SubscriptionDialogReason | string
current_tier?: string
}
// checkout_attempt_id correlates this open with the matching checkout_returned.
export interface CheckoutViewedMetadata {
checkout_attempt_id: string
tier: string
cycle: string
}
type CheckoutReturnOutcome = 'success' | 'cancelled' | 'unknown'
export interface CheckoutReturnedMetadata {
checkout_attempt_id: string
outcome: CheckoutReturnOutcome
}
export interface FirstExecutionCompletedMetadata {
workflow_run_id: string
customer_tier?: string
}
/**
* Telemetry provider interface for individual providers.
* All methods are optional - providers only implement what they need.
@@ -470,15 +577,30 @@ export interface SubscriptionSuccessMetadata extends Record<string, unknown> {
export interface TelemetryProvider {
// Authentication flow events
trackSignupOpened?(): void
trackAuthMethodSelected?(metadata: AuthMethodSelectedMetadata): void
trackOAuthPopupResult?(metadata: OAuthPopupResultMetadata): void
trackAuthFailed?(metadata: AuthFailedMetadata): void
trackAuth?(metadata: AuthMetadata): void
trackUserLoggedIn?(): void
trackCanvasReady?(metadata: CanvasReadyMetadata): void
trackOnboardingRouted?(metadata: OnboardingRoutedMetadata): void
// Subscription flow events
trackSubscription?(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void
trackPaywallViewed?(metadata: PaywallViewedMetadata): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackCheckoutViewed?(metadata: CheckoutViewedMetadata): void
trackCheckoutReturned?(metadata: CheckoutReturnedMetadata): void
trackCheckoutInitiateFailed?(metadata: CheckoutInitiateFailedMetadata): void
trackCheckoutWindowBlocked?(metadata?: CheckoutWindowBlockedMetadata): void
trackBillingCycleToggled?(metadata: BillingCycleToggledMetadata): void
trackAuthError?(metadata: AuthErrorMetadata): void
trackTemplateCategorySelected?(
metadata: TemplateCategorySelectedMetadata
): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
@@ -547,6 +669,8 @@ export interface TelemetryProvider {
trackWorkflowExecution?(): void
trackExecutionError?(metadata: ExecutionErrorMetadata): void
trackExecutionSuccess?(metadata: ExecutionSuccessMetadata): void
trackFirstExecutionCompleted?(metadata: FirstExecutionCompletedMetadata): void
trackOutputViewed?(metadata: OutputViewedMetadata): void
trackSharedWorkflowRun?(metadata: SharedWorkflowRunMetadata): void
// Settings events
@@ -576,13 +700,26 @@ export type TelemetryDispatcher = Required<TelemetryProvider>
export const TelemetryEvents = {
// Authentication Flow
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
AUTH_METHOD_SELECTED: 'app:auth_method_selected',
OAUTH_POPUP_RESULT: 'app:oauth_popup_result',
AUTH_FAILED: 'app:auth_failed',
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_LOGGED_IN: 'app:user_logged_in',
CANVAS_READY: 'app:canvas_ready',
ONBOARDING_ROUTED: 'app:onboarding_routed',
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
PAYWALL_VIEWED: 'app:paywall_viewed',
CHECKOUT_VIEWED: 'app:checkout_viewed',
CHECKOUT_RETURNED: 'app:checkout_returned',
CHECKOUT_INITIATE_FAILED: 'app:checkout_initiate_failed',
CHECKOUT_WINDOW_BLOCKED: 'app:checkout_window_blocked',
BILLING_CYCLE_TOGGLED: 'app:billing_cycle_toggled',
AUTH_ERROR: 'app:auth_error',
TEMPLATE_CATEGORY_SELECTED: 'app:template_category_selected',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
MONTHLY_SUBSCRIPTION_CANCELLED: 'app:monthly_subscription_cancelled',
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
@@ -647,6 +784,8 @@ export const TelemetryEvents = {
EXECUTION_START: 'execution_start',
EXECUTION_ERROR: 'execution_error',
EXECUTION_SUCCESS: 'execution_success',
FIRST_EXECUTION_COMPLETED: 'app:first_execution_completed',
OUTPUT_VIEWED: 'app:output_viewed',
SHARED_WORKFLOW_RUN: 'app:shared_workflow_run',
// Generic UI Button Click
UI_BUTTON_CLICKED: 'app:ui_button_clicked',
@@ -670,6 +809,14 @@ export type ExecutionTriggerSource =
*/
export type TelemetryEventProperties =
| AuthMetadata
| AuthMethodSelectedMetadata
| OAuthPopupResultMetadata
| AuthFailedMetadata
| CanvasReadyMetadata
| OnboardingRoutedMetadata
| CheckoutInitiateFailedMetadata
| CheckoutWindowBlockedMetadata
| OutputViewedMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext
@@ -701,3 +848,10 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| PaywallViewedMetadata
| CheckoutViewedMetadata
| CheckoutReturnedMetadata
| FirstExecutionCompletedMetadata
| BillingCycleToggledMetadata
| AuthErrorMetadata
| TemplateCategorySelectedMetadata

View File

@@ -101,9 +101,17 @@ vi.mock('firebase/auth', async (importOriginal) => {
// Mock telemetry
const mockTrackAuth = vi.fn()
const mockTrackAuthMethodSelected = vi.fn()
const mockTrackOAuthPopupResult = vi.fn()
const mockTrackAuthFailed = vi.fn()
const mockTrackAuthError = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackAuth: mockTrackAuth
trackAuth: mockTrackAuth,
trackAuthMethodSelected: mockTrackAuthMethodSelected,
trackOAuthPopupResult: mockTrackOAuthPopupResult,
trackAuthFailed: mockTrackAuthFailed,
trackAuthError: mockTrackAuthError
})
}))
@@ -613,6 +621,78 @@ describe('useAuthStore', () => {
expect(store.loading).toBe(false)
})
describe('auth funnel telemetry', () => {
it('emits auth_method_selected on email login', async () => {
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockResolvedValue({
user: mockUser
} as Partial<UserCredential> as UserCredential)
await store.login('a@b.com', 'pw')
expect(mockTrackAuthMethodSelected).toHaveBeenCalledWith({
method: 'email',
view: 'login'
})
})
it('emits auth_failed (stage firebase) on a non-cancel login error', async () => {
const err = new FirebaseError(
firebaseAuth.AuthErrorCodes.USER_DISABLED,
'disabled'
)
vi.mocked(firebaseAuth.signInWithEmailAndPassword).mockRejectedValue(
err
)
await expect(store.login('a@b.com', 'pw')).rejects.toThrow()
expect(mockTrackAuthFailed).toHaveBeenCalledWith({
method: 'email',
stage: 'firebase',
error_code: firebaseAuth.AuthErrorCodes.USER_DISABLED
})
})
it('counts an OAuth popup cancel as cancelled, not auth_failed', async () => {
const cancel = new FirebaseError(
firebaseAuth.AuthErrorCodes.POPUP_CLOSED_BY_USER,
'cancelled'
)
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(cancel)
await expect(store.loginWithGoogle()).rejects.toThrow()
expect(mockTrackOAuthPopupResult).toHaveBeenCalledWith({
provider: 'google',
result: 'cancelled',
error_code: firebaseAuth.AuthErrorCodes.POPUP_CLOSED_BY_USER
})
// The fix: a deliberate cancel must not inflate auth_failed.
expect(mockTrackAuthFailed).not.toHaveBeenCalled()
})
it('counts a non-cancel OAuth error as both popup error and auth_failed', async () => {
const err = new FirebaseError(
firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED,
'network'
)
vi.mocked(firebaseAuth.signInWithPopup).mockRejectedValue(err)
await expect(store.loginWithGoogle()).rejects.toThrow()
expect(mockTrackOAuthPopupResult).toHaveBeenCalledWith({
provider: 'google',
result: 'error',
error_code: firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED
})
expect(mockTrackAuthFailed).toHaveBeenCalledWith({
method: 'google',
stage: 'firebase',
error_code: firebaseAuth.AuthErrorCodes.NETWORK_REQUEST_FAILED
})
})
})
describe('sign-up telemetry OR logic', () => {
const mockUserCredential = {
user: mockUser

View File

@@ -29,6 +29,12 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { markAuthForActivation } from '@/platform/telemetry/authActivationMarker'
import type {
AuthMethod,
AuthView,
OAuthProvider
} from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -325,27 +331,103 @@ export const useAuthStore = defineStore('auth', () => {
return createCustomerResJson
}
const errorCodeOf = (error: unknown): string | undefined =>
error instanceof FirebaseError ? error.code : undefined
interface AuthTelemetryContext {
method: AuthMethod
view: AuthView
oauthProvider?: OAuthProvider
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>,
options: {
createCustomer?: boolean
telemetry?: AuthTelemetryContext
authError?: {
method: 'email' | 'google' | 'github'
isSignUp: boolean
}
} = {}
): Promise<T> => {
loading.value = true
const telemetry = isCloud ? useTelemetry() : undefined
const telemetryContext = options.telemetry
if (telemetryContext) {
telemetry?.trackAuthMethodSelected({
method: telemetryContext.method,
view: telemetryContext.view
})
}
try {
const result = await action(auth)
let result: T
try {
result = await action(auth)
} catch (error) {
const errorCode = errorCodeOf(error)
const isUserCancel =
errorCode === AuthErrorCodes.POPUP_CLOSED_BY_USER ||
errorCode === AuthErrorCodes.EXPIRED_POPUP_REQUEST
if (telemetryContext?.oauthProvider) {
telemetry?.trackOAuthPopupResult({
provider: telemetryContext.oauthProvider,
result: isUserCancel ? 'cancelled' : 'error',
error_code: errorCode
})
}
// A deliberate popup cancel isn't an auth failure; counting it inflates auth_failed.
if (telemetryContext && !isUserCancel) {
telemetry?.trackAuthFailed({
method: telemetryContext.method,
stage: 'firebase',
error_code: errorCode
})
}
throw error
}
if (telemetryContext?.oauthProvider) {
telemetry?.trackOAuthPopupResult({
provider: telemetryContext.oauthProvider,
result: 'success'
})
}
// Create customer if needed
if (options?.createCustomer) {
const token = await getIdToken()
if (!token) {
throw new Error('Cannot create customer: User not authenticated')
try {
const token = await getIdToken()
if (!token) {
throw new Error('Cannot create customer: User not authenticated')
}
await createCustomer()
} catch (error) {
if (telemetryContext) {
telemetry?.trackAuthFailed({
method: telemetryContext.method,
stage: 'create_customer',
error_code: errorCodeOf(error)
})
}
throw error
}
await createCustomer()
}
return result
} catch (error) {
if (isCloud && options?.authError) {
useTelemetry()?.trackAuthError({
method: options.authError.method,
is_sign_up: options.authError.isSignUp,
error_code: (error as { code?: string })?.code,
error_message: error instanceof Error ? error.message : String(error)
})
}
throw error
} finally {
loading.value = false
}
@@ -358,7 +440,11 @@ export const useAuthStore = defineStore('auth', () => {
const result = await executeAuthAction(
(authInstance) =>
signInWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
{
createCustomer: true,
telemetry: { method: 'email', view: 'login' },
authError: { method: 'email', isSignUp: false }
}
)
if (isCloud) {
@@ -369,6 +455,7 @@ export const useAuthStore = defineStore('auth', () => {
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
markAuthForActivation(false)
}
return result
@@ -381,7 +468,11 @@ export const useAuthStore = defineStore('auth', () => {
const result = await executeAuthAction(
(authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
{
createCustomer: true,
telemetry: { method: 'email', view: 'signup' },
authError: { method: 'email', isSignUp: true }
}
)
if (isCloud) {
@@ -392,6 +483,7 @@ export const useAuthStore = defineStore('auth', () => {
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
markAuthForActivation(true)
}
return result
@@ -402,19 +494,29 @@ export const useAuthStore = defineStore('auth', () => {
}): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
{
createCustomer: true,
telemetry: {
method: 'google',
view: options?.isNewUser ? 'signup' : 'login',
oauthProvider: 'google'
},
authError: { method: 'google', isSignUp: options?.isNewUser ?? false }
}
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser =
options?.isNewUser || additionalUserInfo?.isNewUser || false
useTelemetry()?.trackAuth({
method: 'google',
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
is_new_user: isNewUser,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
markAuthForActivation(isNewUser)
}
return result
@@ -425,19 +527,29 @@ export const useAuthStore = defineStore('auth', () => {
}): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, githubProvider),
{ createCustomer: true }
{
createCustomer: true,
telemetry: {
method: 'github',
view: options?.isNewUser ? 'signup' : 'login',
oauthProvider: 'github'
},
authError: { method: 'github', isSignUp: options?.isNewUser ?? false }
}
)
if (isCloud) {
const additionalUserInfo = getAdditionalUserInfo(result)
const isNewUser =
options?.isNewUser || additionalUserInfo?.isNewUser || false
useTelemetry()?.trackAuth({
method: 'github',
is_new_user:
options?.isNewUser || additionalUserInfo?.isNewUser || false,
is_new_user: isNewUser,
user_id: result.user.uid,
email: result.user.email ?? undefined,
...getShareAuthMetadata()
})
markAuthForActivation(isNewUser)
}
return result

View File

@@ -21,6 +21,8 @@ const {
mockShowTextPreview,
mockTrackExecutionError,
mockTrackExecutionSuccess,
mockTrackFirstExecutionCompleted,
mockTrackOutputViewed,
mockTrackSharedWorkflowRun
} = await vi.hoisted(async () => {
const { shallowRef } = await import('vue')
@@ -33,6 +35,8 @@ const {
mockShowTextPreview: vi.fn(),
mockTrackExecutionError: vi.fn(),
mockTrackExecutionSuccess: vi.fn(),
mockTrackFirstExecutionCompleted: vi.fn(),
mockTrackOutputViewed: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}
})
@@ -80,17 +84,25 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
}
})
// Toggleable so the output_viewed cloud-gate test can flip isCloud to false
// without vi.doMock (banned) or a module reload.
const mockDistribution = vi.hoisted(() => ({ isCloud: true }))
vi.mock('@/platform/distribution/types', async () => ({
...(await vi.importActual<typeof DistributionTypes>(
'@/platform/distribution/types'
)),
isCloud: true
get isCloud() {
return mockDistribution.isCloud
}
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackExecutionError: mockTrackExecutionError,
trackExecutionSuccess: mockTrackExecutionSuccess,
trackFirstExecutionCompleted: mockTrackFirstExecutionCompleted,
trackOutputViewed: mockTrackOutputViewed,
trackSharedWorkflowRun: mockTrackSharedWorkflowRun
})
}))
@@ -1754,3 +1766,276 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
})
})
describe('useExecutionStore - output_viewed activation telemetry', () => {
/**
* `handleExecuted` dedups via per-store-instance state (`outputViewedRuns`
* + `sessionHasViewedOutput`), so a fresh pinia + store per test starts from
* a clean slate without reloading the module.
*/
type Store = ReturnType<typeof useExecutionStore>
type ExecutedOutput = Record<string, unknown>
async function freshStore(): Promise<Store> {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function fireExecuted(detail: {
node: string
prompt_id: string
output: ExecutedOutput
}) {
const handler = apiEventHandlers.get('executed')
if (!handler) throw new Error('executed handler not bound')
handler(
new CustomEvent('executed', {
detail: { display_node: detail.node, ...detail }
})
)
}
/** Seed an active job so `handleExecuted` runs its body (it early-returns
* when there is no active job). */
function startRun(store: Store, jobId: string) {
const startHandler = apiEventHandlers.get('execution_start')
if (!startHandler) throw new Error('execution_start handler not bound')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: jobId, timestamp: 0 }
})
)
expect(store.activeJobId).toBe(jobId)
}
const imageOutput: ExecutedOutput = {
images: [{ filename: 'out.png', type: 'output' }]
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
mockTrackOutputViewed.mockReset()
})
it('emits trackOutputViewed once for the first media output of a run', async () => {
const store = await freshStore()
startRun(store, 'run-1')
fireExecuted({ node: 'save-1', prompt_id: 'run-1', output: imageOutput })
expect(mockTrackOutputViewed).toHaveBeenCalledTimes(1)
expect(mockTrackOutputViewed).toHaveBeenCalledWith({
workflow_run_id: 'run-1',
media_type: 'image',
is_first_output: true
})
})
it('does not re-emit for a second media node in the same run', async () => {
const store = await freshStore()
startRun(store, 'run-1')
fireExecuted({ node: 'save-1', prompt_id: 'run-1', output: imageOutput })
fireExecuted({
node: 'save-2',
prompt_id: 'run-1',
output: { images: [{ filename: 'second.png', type: 'output' }] }
})
// Per-run dedup: only the first media output of run-1 fires.
expect(mockTrackOutputViewed).toHaveBeenCalledTimes(1)
expect(mockTrackOutputViewed).toHaveBeenCalledWith(
expect.objectContaining({ workflow_run_id: 'run-1' })
)
})
it('emits again for a second run with is_first_output false', async () => {
const store = await freshStore()
startRun(store, 'run-1')
fireExecuted({ node: 'save-1', prompt_id: 'run-1', output: imageOutput })
startRun(store, 'run-2')
fireExecuted({ node: 'save-1', prompt_id: 'run-2', output: imageOutput })
expect(mockTrackOutputViewed).toHaveBeenCalledTimes(2)
expect(mockTrackOutputViewed).toHaveBeenNthCalledWith(1, {
workflow_run_id: 'run-1',
media_type: 'image',
is_first_output: true
})
expect(mockTrackOutputViewed).toHaveBeenNthCalledWith(2, {
workflow_run_id: 'run-2',
media_type: 'image',
is_first_output: false
})
})
// media_type is classified by filename via getMediaTypeFromFilename, so the
// output bucket name is irrelevant (gif is an image extension; glb is 3D).
it.for([
{ filename: 'a.png', expected: 'image' },
{ filename: 'a.mp4', expected: 'video' },
{ filename: 'a.webm', expected: 'video' },
{ filename: 'a.gif', expected: 'image' },
{ filename: 'a.mp3', expected: 'audio' },
{ filename: 'a.glb', expected: '3D' }
])(
'classifies $filename as media_type $expected',
async ({ filename, expected }, { expect }) => {
const store = await freshStore()
startRun(store, 'run-media')
fireExecuted({
node: 'save-1',
prompt_id: 'run-media',
output: { images: [{ filename }] }
})
expect(mockTrackOutputViewed).toHaveBeenCalledTimes(1)
expect(mockTrackOutputViewed).toHaveBeenCalledWith(
expect.objectContaining({ media_type: expected })
)
}
)
it('does not emit for a non-media (text-only) output', async () => {
const store = await freshStore()
startRun(store, 'run-text')
fireExecuted({
node: 'show-text',
prompt_id: 'run-text',
output: { text: 'hello world' }
})
expect(mockTrackOutputViewed).not.toHaveBeenCalled()
})
it('does not emit anything when isCloud is false', async () => {
mockDistribution.isCloud = false
try {
const store = await freshStore()
startRun(store, 'run-1')
fireExecuted({ node: 'save-1', prompt_id: 'run-1', output: imageOutput })
expect(mockTrackOutputViewed).not.toHaveBeenCalled()
// The node is still marked executed regardless of the cloud gate.
expect(store.activeJob?.nodes['save-1']).toBe(true)
} finally {
mockDistribution.isCloud = true
}
})
})
describe('useExecutionStore - first_execution_completed activation telemetry', () => {
/**
* `first_execution_completed` fires once ever per browser profile, guarded by
* a durable localStorage key. Tests clear localStorage between cases so each
* starts as a "never executed" profile, and a single shared store instance
* verifies the within-profile dedup survives across successive runs.
*/
type Store = ReturnType<typeof useExecutionStore>
const FIRST_EXECUTION_COMPLETED_KEY =
'comfy:telemetry:first_execution_completed'
function freshStore(): Store {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useExecutionStore()
store.bindExecutionEvents()
return store
}
function queueAndStart(store: Store, jobId: string) {
store.storeJob({
nodes: ['a'],
id: jobId,
promptOutput: { a: createPromptNode('Node A', 'NodeA') },
workflow: createQueuedWorkflow()
})
const startHandler = apiEventHandlers.get('execution_start')
if (!startHandler) throw new Error('execution_start handler not bound')
startHandler(
new CustomEvent('execution_start', {
detail: { prompt_id: jobId, timestamp: 0 }
})
)
}
function fireSuccess(jobId: string) {
const handler = apiEventHandlers.get('execution_success')
if (!handler) throw new Error('execution_success handler not bound')
handler(
new CustomEvent('execution_success', {
detail: { prompt_id: jobId, timestamp: 0 }
})
)
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
localStorage.removeItem(FIRST_EXECUTION_COMPLETED_KEY)
mockTrackFirstExecutionCompleted.mockReset()
})
it('emits trackFirstExecutionCompleted on the first successful execution', () => {
const store = freshStore()
queueAndStart(store, 'run-1')
fireSuccess('run-1')
expect(mockTrackFirstExecutionCompleted).toHaveBeenCalledTimes(1)
expect(mockTrackFirstExecutionCompleted).toHaveBeenCalledWith({
workflow_run_id: 'run-1'
})
// The durable guard is now set for this browser profile.
expect(localStorage.getItem(FIRST_EXECUTION_COMPLETED_KEY)).toBeTruthy()
})
it('does not re-emit on subsequent successful executions in the same profile', () => {
const store = freshStore()
queueAndStart(store, 'run-1')
fireSuccess('run-1')
queueAndStart(store, 'run-2')
fireSuccess('run-2')
expect(mockTrackFirstExecutionCompleted).toHaveBeenCalledTimes(1)
expect(mockTrackFirstExecutionCompleted).toHaveBeenCalledWith({
workflow_run_id: 'run-1'
})
})
it('does not emit when the durable guard is already set (e.g. after reload)', () => {
// Simulate a prior session having already activated this profile.
localStorage.setItem(FIRST_EXECUTION_COMPLETED_KEY, '2026-01-01T00:00:00Z')
const store = freshStore()
queueAndStart(store, 'run-1')
fireSuccess('run-1')
expect(mockTrackFirstExecutionCompleted).not.toHaveBeenCalled()
})
it('does not emit when isCloud is false', () => {
mockDistribution.isCloud = false
try {
const store = freshStore()
queueAndStart(store, 'run-1')
fireSuccess('run-1')
expect(mockTrackFirstExecutionCompleted).not.toHaveBeenCalled()
// Cloud-gated event must not consume the durable first-run guard either.
expect(localStorage.getItem(FIRST_EXECUTION_COMPLETED_KEY)).toBeNull()
} finally {
mockDistribution.isCloud = true
}
})
})

View File

@@ -35,6 +35,10 @@ import { useJobPreviewStore } from '@/stores/jobPreviewStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import {
getMediaTypeFromFilename,
isPreviewableMediaType
} from '@/utils/formatUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type { AppMode } from '@/utils/appMode'
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
@@ -93,6 +97,38 @@ function buildExecutionNodeLookup(
*/
export const MAX_PROGRESS_JOBS = 1000
const MAX_TRACKED_OUTPUT_RUNS = 256
const FIRST_EXECUTION_COMPLETED_KEY =
'comfy:telemetry:first_execution_completed'
// On storage throw, return false (treat as already emitted) so we never spam the event.
function claimFirstExecutionCompleted(): boolean {
try {
if (localStorage.getItem(FIRST_EXECUTION_COMPLETED_KEY)) return false
localStorage.setItem(
FIRST_EXECUTION_COMPLETED_KEY,
new Date().toISOString()
)
return true
} catch {
return false
}
}
function firstOutputMediaType(
output: ExecutedWsMessage['output']
): string | null {
for (const value of Object.values(output)) {
if (!Array.isArray(value) || value.length === 0) continue
const filename = (value[0] as { filename?: unknown })?.filename
if (typeof filename !== 'string') continue
const mediaType = getMediaTypeFromFilename(filename)
if (isPreviewableMediaType(mediaType)) return mediaType
}
return null
}
export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed'
export const WORKFLOW_STATUS_I18N_KEYS: Record<
@@ -110,6 +146,9 @@ export const useExecutionStore = defineStore('execution', () => {
const executionErrorStore = useExecutionErrorStore()
const { mode, isAppMode } = useAppMode()
const outputViewedRuns = new Set<string>()
let sessionHasViewedOutput = false
const clientId = ref<string | null>(null)
const activeJobId = ref<JobId | null>(null)
const queuedJobs = ref<Record<NodeId, QueuedJob>>({})
@@ -408,6 +447,26 @@ export const useExecutionStore = defineStore('execution', () => {
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
if (!activeJob.value) return
activeJob.value.nodes[e.detail.node] = true
if (isCloud) {
const runId = e.detail.prompt_id
const mediaType = firstOutputMediaType(e.detail.output)
if (mediaType && !outputViewedRuns.has(runId)) {
// Evict-before-add so we never evict the run we are about to record.
if (outputViewedRuns.size >= MAX_TRACKED_OUTPUT_RUNS) {
const oldest = outputViewedRuns.values().next().value
if (oldest !== undefined) outputViewedRuns.delete(oldest)
}
outputViewedRuns.add(runId)
const isFirstOutput = !sessionHasViewedOutput
sessionHasViewedOutput = true
useTelemetry()?.trackOutputViewed({
workflow_run_id: runId,
media_type: mediaType,
is_first_output: isFirstOutput
})
}
}
}
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
@@ -419,6 +478,12 @@ export const useExecutionStore = defineStore('execution', () => {
telemetry?.trackExecutionSuccess({
jobId
})
// isCloud short-circuits first so the durable claim isn't spent off-cloud.
if (isCloud && claimFirstExecutionCompleted()) {
telemetry?.trackFirstExecutionCompleted({
workflow_run_id: jobId
})
}
if (queuedJob.shareId) {
telemetry?.trackSharedWorkflowRun({
job_id: jobId,

View File

@@ -68,6 +68,7 @@ import DesktopCloudNotificationController from '@/platform/cloud/notification/co
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { consumeAuthActivation } from '@/platform/telemetry/authActivationMarker'
import { getShellLayoutSnapshot } from '@/platform/telemetry/utils/getShellLayoutSnapshot'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
@@ -294,11 +295,23 @@ void nextTick(() => {
})
const onGraphReady = () => {
// Stamp interactivity time before runWhenGlobalIdle defers the body, so
// canvas_ready's ms_since_auth measures auth -> canvas, not auth -> idle.
const canvasReadyAt = Date.now()
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && authStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
// Canvas is interactive: the activation anchor. The auth marker (when
// present) carries new-user status and auth time across the onboarding
// page reload; absent for returning users who just reloaded the app.
const activation = consumeAuthActivation()
telemetry?.trackCanvasReady({
is_new_user: activation?.isNewUser ?? false,
ms_since_auth: activation ? canvasReadyAt - activation.at : undefined
})
}
// Set up page visibility tracking (cloud only)