Compare commits

...

3 Commits

Author SHA1 Message Date
Deep Mehta
ffd37e82e2 feat(telemetry): add auth_error + template_category events; enable cloud web analytics 2026-06-17 17:39:53 -07:00
Deep Mehta
f303872748 feat(telemetry): track monthly/yearly billing-cycle toggle on pricing table 2026-06-17 17:28:24 -07:00
Deep Mehta
ad6548690b feat(telemetry): fire subscribe-now click from real tier CTAs
app:subscribe_now_button_clicked was only fired by the legacy
SubscribeButton, never by the pricing table tier buttons or the
subscribe-to-run lock button - the surfaces users actually click. Add
the same trackSubscription('subscribe_clicked') capture to:

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

Each surface now tags a source ('pricing_table' | 'subscribe_to_run' |
'subscribe_button') so the subscribe-click funnel can be attributed by
CTA. Extend SubscriptionMetadata with tier/cycle/source; no new event
name.
2026-06-17 17:22:55 -07:00
11 changed files with 296 additions and 12 deletions

View File

@@ -544,6 +544,13 @@ const allTemplates = computed(() => {
// Navigation
const selectedNavItem = ref<string | null>(initialCategory)
// Track category/tab switches (e.g. "Getting Started" vs "All") so we can see
// which curated entry points users browse before opening a template.
watch(selectedNavItem, (to, from) => {
if (!to || to === from) return
useTelemetry()?.trackTemplateCategorySelected({ category_id: to })
})
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,14 @@ const buttonTooltip = computed(() =>
function handleSubscribeToRun() {
if (isCloud) {
useTelemetry()?.trackRunButton({ subscribe_to_run: true })
const telemetry = useTelemetry()
telemetry?.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 the
// legacy SubscribeButton (previously the only fired surface).
telemetry?.trackSubscription('subscribe_clicked', {
source: 'subscribe_to_run'
})
}
showSubscriptionDialog()

View File

@@ -3,6 +3,9 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
BillingCycleToggledMetadata,
AuthErrorMetadata,
TemplateCategorySelectedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -86,6 +89,22 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackBeginCheckout?.(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 {

View File

@@ -324,6 +324,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()

View File

@@ -11,6 +11,9 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
BillingCycleToggledMetadata,
AuthErrorMetadata,
TemplateCategorySelectedMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
@@ -126,9 +129,14 @@ 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,
// Web analytics enabled so cloud.comfy.org gets heatmaps + $pageview
// (the login/onboarding pages previously had zero coverage). autocapture
// does NOT record input *values* (posthog masks them), and these defaults
// remain overridable per-environment via `serverConfig` (spread below).
autocapture: true,
capture_pageview: true,
capture_pageleave: true,
heatmaps: true,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig,
@@ -350,6 +358,20 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName, 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)
}

View File

@@ -423,9 +423,56 @@ export interface CheckoutAttributionMetadata {
wbraid?: string
}
/**
* Surface that triggered a subscribe-now click. Lets us attribute the
* `app:subscribe_now_button_clicked` event to the specific CTA the user
* actually clicked, rather than only the legacy SubscribeButton.
*/
export type SubscribeClickSource =
| 'pricing_table'
| 'subscribe_to_run'
| 'subscribe_button'
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason
// Populated on subscribe-now clicks so the funnel can split intent by the
// tier/cycle selected and the CTA surface that fired the event.
tier?: TierKey
cycle?: BillingCycle
source?: SubscribeClickSource
}
/**
* Fired when the user toggles the monthly/yearly billing cycle on the
* pricing table. Lets us see whether the annual-discount nudge actually
* moves the cycle selection before checkout.
*/
export interface BillingCycleToggledMetadata {
from: BillingCycle
to: BillingCycle
}
/**
* Fired when an authentication attempt fails (sign-in or sign-up). Lets the
* signup funnel see the error/bounce leak that `app:user_auth_completed`
* (success-only) cannot.
*/
export interface AuthErrorMetadata {
method: 'email' | 'google' | 'github'
is_sign_up: boolean
error_code?: string
error_message?: string
}
/**
* Fired when the user switches category/tab in the template selector (e.g.
* "Getting Started" vs "All"), so we can see which curated entry points get
* used before a template is opened.
*/
export interface TemplateCategorySelectedMetadata {
category_id: string
category_label?: string
}
export interface BeginCheckoutMetadata
@@ -479,6 +526,11 @@ export interface TelemetryProvider {
metadata?: SubscriptionMetadata
): void
trackBeginCheckout?(metadata: BeginCheckoutMetadata): void
trackBillingCycleToggled?(metadata: BillingCycleToggledMetadata): void
trackAuthError?(metadata: AuthErrorMetadata): void
trackTemplateCategorySelected?(
metadata: TemplateCategorySelectedMetadata
): void
trackMonthlySubscriptionSucceeded?(
metadata?: SubscriptionSuccessMetadata
): void
@@ -586,6 +638,9 @@ export const TelemetryEvents = {
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',
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',
@@ -703,3 +758,6 @@ export type TelemetryEventProperties =
| DefaultViewSetMetadata
| SubscriptionMetadata
| SubscriptionSuccessMetadata
| BillingCycleToggledMetadata
| AuthErrorMetadata
| TemplateCategorySelectedMetadata

View File

@@ -329,6 +329,10 @@ export const useAuthStore = defineStore('auth', () => {
action: (auth: Auth) => Promise<T>,
options: {
createCustomer?: boolean
authError?: {
method: 'email' | 'google' | 'github'
isSignUp: boolean
}
} = {}
): Promise<T> => {
loading.value = true
@@ -346,6 +350,18 @@ export const useAuthStore = defineStore('auth', () => {
}
return result
} catch (error) {
// Surface the auth failure leak that trackAuth (success-only) misses.
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 +374,10 @@ export const useAuthStore = defineStore('auth', () => {
const result = await executeAuthAction(
(authInstance) =>
signInWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
{
createCustomer: true,
authError: { method: 'email', isSignUp: false }
}
)
if (isCloud) {
@@ -381,7 +400,10 @@ export const useAuthStore = defineStore('auth', () => {
const result = await executeAuthAction(
(authInstance) =>
createUserWithEmailAndPassword(authInstance, email, password),
{ createCustomer: true }
{
createCustomer: true,
authError: { method: 'email', isSignUp: true }
}
)
if (isCloud) {
@@ -402,7 +424,10 @@ export const useAuthStore = defineStore('auth', () => {
}): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, googleProvider),
{ createCustomer: true }
{
createCustomer: true,
authError: { method: 'google', isSignUp: options?.isNewUser ?? false }
}
)
if (isCloud) {
@@ -425,7 +450,10 @@ export const useAuthStore = defineStore('auth', () => {
}): Promise<UserCredential> => {
const result = await executeAuthAction(
(authInstance) => signInWithPopup(authInstance, githubProvider),
{ createCustomer: true }
{
createCustomer: true,
authError: { method: 'github', isSignUp: options?.isNewUser ?? false }
}
)
if (isCloud) {