mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 11:44:10 +00:00
Compare commits
27 Commits
codex/cove
...
deepme987/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
147c4f4044 | ||
|
|
2b27182c08 | ||
|
|
fe9cb9d31e | ||
|
|
f4b1c192ee | ||
|
|
4b73a707ee | ||
|
|
b8db435d17 | ||
|
|
b70b862d7b | ||
|
|
a2b2352778 | ||
|
|
08a0136243 | ||
|
|
ecf30e08be | ||
|
|
bb4f31c758 | ||
|
|
e680a68c41 | ||
|
|
b7f0acc5c8 | ||
|
|
464f383e87 | ||
|
|
2338efc909 | ||
|
|
631bab4d6e | ||
|
|
cefe24a215 | ||
|
|
ffd37e82e2 | ||
|
|
f303872748 | ||
|
|
5f789e17d2 | ||
|
|
ad6548690b | ||
|
|
8fe6159497 | ||
|
|
7da664a21b | ||
|
|
2cda397cb5 | ||
|
|
938609b844 | ||
|
|
68cd6c8819 | ||
|
|
151b6670ef |
@@ -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 }} </span
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
105
src/platform/telemetry/authActivationMarker.test.ts
Normal file
105
src/platform/telemetry/authActivationMarker.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/platform/telemetry/authActivationMarker.ts
Normal file
41
src/platform/telemetry/authActivationMarker.ts
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user