Compare commits

...

2 Commits

Author SHA1 Message Date
Benjamin Lu
ece2807f22 fix: track social signup auth failures 2026-07-07 20:58:08 -07:00
Benjamin Lu
5aa3ee9934 feat(telemetry): track signup/login flow errors in PostHog 2026-07-07 20:23:14 -07:00
7 changed files with 198 additions and 21 deletions

View File

@@ -8,6 +8,10 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
type ModifiedWorkflow = Pick<ComfyWorkflow, 'path' | 'isModified'>
const mockAuthStore = vi.hoisted(() => ({
login: vi.fn().mockResolvedValue(undefined),
loginWithGoogle: vi.fn().mockResolvedValue(undefined),
loginWithGithub: vi.fn().mockResolvedValue(undefined),
register: vi.fn().mockResolvedValue(undefined),
logout: vi.fn().mockResolvedValue(undefined)
}))
@@ -28,10 +32,12 @@ const mockDialogService = vi.hoisted(() => ({
}))
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockTrackAuthFailed = vi.hoisted(() => vi.fn())
const knownAuthErrorCodes = new Set([
'auth/invalid-credential',
'auth/email-already-in-use'
'auth/email-already-in-use',
'auth/user-not-found'
])
vi.mock('@/i18n', () => ({
@@ -48,7 +54,9 @@ vi.mock('@/platform/distribution/types', () => ({
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => undefined)
useTelemetry: vi.fn(() => ({
trackAuthFailed: mockTrackAuthFailed
}))
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -81,9 +89,19 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandlingAsync: <TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn
) => action,
wrapWithErrorHandlingAsync:
<TArgs extends unknown[], TReturn>(
action: (...args: TArgs) => Promise<TReturn> | TReturn,
errorHandler?: (error: unknown) => void
) =>
async (...args: TArgs) => {
try {
return await action(...args)
} catch (error) {
;(errorHandler ?? mockToastErrorHandler)(error)
return undefined
}
},
toastErrorHandler: mockToastErrorHandler
})
}))
@@ -156,10 +174,13 @@ describe('useAuthActions.logout', () => {
)
const { logout } = useAuthActions()
await expect(logout()).rejects.toThrow('auth.signOut.saveFailed:a.json')
await logout()
expect(mockWorkflowService.saveWorkflow).toHaveBeenCalledTimes(1)
expect(mockAuthStore.logout).not.toHaveBeenCalled()
expect(mockToastErrorHandler).toHaveBeenCalledExactlyOnceWith(
new Error('auth.signOut.saveFailed:a.json')
)
})
it('saves every modified workflow before signing out when user picks Save (true)', async () => {
@@ -206,6 +227,85 @@ describe('useAuthActions.logout', () => {
})
})
describe('useAuthActions auth flow error telemetry', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockWorkflowStore.modifiedWorkflows = []
})
it('tracks email sign-in Firebase failures and still shows the error toast', async () => {
const error = new FirebaseError('auth/user-not-found', 'msg')
mockAuthStore.login.mockRejectedValueOnce(error)
const { signInWithEmail } = useAuthActions()
await expect(
signInWithEmail('user@example.com', 'password')
).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
})
expect(mockToastStore.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'auth.errors.auth/user-not-found'
})
})
it('tracks unknown errors for email sign-up failures', async () => {
const error = new Error('network failed')
mockAuthStore.register.mockRejectedValueOnce(error)
const { signUpWithEmail } = useAuthActions()
await expect(
signUpWithEmail('user@example.com', 'password')
).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'unknown',
auth_action: 'email_sign_up'
})
})
it('tracks Google sign-up failures separately from sign-in failures', async () => {
const error = new FirebaseError('auth/popup-closed-by-user', 'msg')
mockAuthStore.loginWithGoogle.mockRejectedValueOnce(error)
const { signInWithGoogle } = useAuthActions()
await expect(signInWithGoogle({ isNewUser: true })).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/popup-closed-by-user',
auth_action: 'google_sign_up'
})
})
it('tracks GitHub sign-up failures separately from sign-in failures', async () => {
const error = new FirebaseError('auth/popup-closed-by-user', 'msg')
mockAuthStore.loginWithGithub.mockRejectedValueOnce(error)
const { signInWithGithub } = useAuthActions()
await expect(signInWithGithub({ isNewUser: true })).resolves.toBeUndefined()
expect(mockTrackAuthFailed).toHaveBeenCalledExactlyOnceWith({
error_code: 'auth/popup-closed-by-user',
auth_action: 'github_sign_up'
})
})
it('does not track auth failures for logout failures', async () => {
const error = new FirebaseError('auth/network-request-failed', 'msg')
mockAuthStore.logout.mockRejectedValueOnce(error)
const { logout } = useAuthActions()
await logout()
expect(mockTrackAuthFailed).not.toHaveBeenCalled()
})
})
describe('useAuthActions.reportError', () => {
beforeEach(() => {
setActivePinia(createPinia())

View File

@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { AuthFlowAction } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -28,6 +29,15 @@ export const useAuthActions = () => {
const accessError = ref(false)
const reportAuthFlowError =
(authAction: AuthFlowAction) => (error: unknown) => {
useTelemetry()?.trackAuthFailed({
error_code: error instanceof FirebaseError ? error.code : 'unknown',
auth_action: authAction
})
reportError(error)
}
const reportError = (error: unknown) => {
// Ref: https://firebase.google.com/docs/auth/admin/errors
if (
@@ -127,7 +137,7 @@ export const useAuthActions = () => {
life: 5000
})
},
reportError
reportAuthFlowError('password_reset')
)
const purchaseCredits = wrapWithErrorHandlingAsync(async (amount: number) => {
@@ -177,32 +187,34 @@ export const useAuthActions = () => {
return result
}, reportError)
const signInWithGoogle = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGoogle(options)
},
reportError
)
const signInWithGoogle = async (options?: { isNewUser?: boolean }) =>
await wrapWithErrorHandlingAsync(
async () => await authStore.loginWithGoogle(options),
reportAuthFlowError(
options?.isNewUser ? 'google_sign_up' : 'google_sign_in'
)
)()
const signInWithGithub = wrapWithErrorHandlingAsync(
async (options?: { isNewUser?: boolean }) => {
return await authStore.loginWithGithub(options)
},
reportError
)
const signInWithGithub = async (options?: { isNewUser?: boolean }) =>
await wrapWithErrorHandlingAsync(
async () => await authStore.loginWithGithub(options),
reportAuthFlowError(
options?.isNewUser ? 'github_sign_up' : 'github_sign_in'
)
)()
const signInWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string) => {
return await authStore.login(email, password)
},
reportError
reportAuthFlowError('email_sign_in')
)
const signUpWithEmail = wrapWithErrorHandlingAsync(
async (email: string, password: string, turnstileToken?: string) => {
return await authStore.register(email, password, turnstileToken)
},
reportError
reportAuthFlowError('email_sign_up')
)
/**

View File

@@ -49,6 +49,23 @@ describe('TelemetryRegistry', () => {
expect(a.trackBeginCheckout).toHaveBeenCalledExactlyOnceWith(metadata)
})
it('dispatches trackAuthFailed to every registered provider', () => {
const a: TelemetryProvider = { trackAuthFailed: vi.fn() }
const b: TelemetryProvider = { trackAuthFailed: vi.fn() }
const registry = new TelemetryRegistry()
registry.registerProvider(a)
registry.registerProvider(b)
const payload = {
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in' as const
}
registry.trackAuthFailed(payload)
expect(a.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload)
expect(b.trackAuthFailed).toHaveBeenCalledExactlyOnceWith(payload)
})
it('dispatches trackAddApiCreditButtonClicked with its source', () => {
const provider: TelemetryProvider = {
trackAddApiCreditButtonClicked: vi.fn()

View File

@@ -2,6 +2,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AddCreditsClickMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -75,6 +76,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackAuth?.(metadata))
}
trackAuthFailed(metadata: AuthErrorMetadata): void {
this.dispatch((provider) => provider.trackAuthFailed?.(metadata))
}
trackUserLoggedIn(): void {
this.dispatch((provider) => provider.trackUserLoggedIn?.())
}

View File

@@ -361,6 +361,24 @@ describe('PostHogTelemetryProvider', () => {
)
})
it('captures auth failure events with metadata', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackAuthFailed({
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.USER_AUTH_FAILED,
{
error_code: 'auth/user-not-found',
auth_action: 'email_sign_in'
}
)
})
it.for([
['flow_opened', TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED, {}],
['confirmed', TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED, {}],

View File

@@ -11,6 +11,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AddCreditsClickMetadata,
AuthErrorMetadata,
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
@@ -353,6 +354,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackAuthFailed(metadata: AuthErrorMetadata): void {
this.trackEvent(TelemetryEvents.USER_AUTH_FAILED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}

View File

@@ -52,6 +52,23 @@ export interface AuthMetadata {
utm_campaign?: string
}
export type AuthFlowAction =
| 'email_sign_in'
| 'email_sign_up'
| 'google_sign_in'
| 'google_sign_up'
| 'github_sign_in'
| 'github_sign_up'
| 'password_reset'
/**
* Metadata for failed authentication attempts
*/
export interface AuthErrorMetadata {
error_code: string
auth_action: AuthFlowAction
}
/**
* Survey response data for user profiling
* Maps 1-to-1 with actual survey fields
@@ -525,6 +542,7 @@ export interface TelemetryProvider {
// Authentication flow events
trackSignupOpened?(): void
trackAuth?(metadata: AuthMetadata): void
trackAuthFailed?(metadata: AuthErrorMetadata): void
trackUserLoggedIn?(): void
// Subscription flow events
@@ -637,6 +655,7 @@ export const TelemetryEvents = {
// Authentication Flow
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_AUTH_FAILED: 'app:user_auth_failed',
USER_LOGGED_IN: 'app:user_logged_in',
// Subscription Flow
@@ -743,6 +762,7 @@ export type ExecutionTriggerSource =
*/
export type TelemetryEventProperties =
| AuthMetadata
| AuthErrorMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext