mirror of
https://github.com/supabase/supabase.git
synced 2026-06-28 19:39:19 -04:00
2d0bcd4714
## Summary The KPI-3 friction dashboard needs to know *why* users hit errors on the signup, project-creation, and org-creation funnels, not just that they did. The existing `dashboard_error_created` event already fires for these paths (10% sampled, with `$pathname`), but carries no reason: ~98.5% of events have no `errorType` and no property carries an error message. This adds PII-safe classification computed client-side from a controlled vocabulary, so raw error text never leaves the browser. Validation errors (previously invisible, since they are inline form errors that never raise a toast) are now captured on invalid submit. ## Changes - Extend `dashboard_error_created` with `origin`, `errorCategory`, `errorReason`, `errorCode`, and a `form` source value - Add a pure, unit-tested classifier (`funnel-errors.ts`) and a 10%-sampled tracking hook (`use-track-funnel-error.ts`); the classifier maps errors to stable slugs and emits only slugs + HTTP status, never raw message text - Classify signup errors (API failures + validation) in `SignUpForm` - Classify project-creation errors (API failures, OrioleDB guard, validation) in the new-project wizard - Classify org-creation errors (API failures, payment/card declines, confirm-subscription, validation) in `NewOrgForm` ## Testing 13 unit tests cover every classifier branch (validation / api / network / payment, status-code handling, message-pattern matching, and fallbacks). To verify on the Vercel preview (events are 10% sampled; set the sample rate to 1 locally to observe each fire): - Signup with a weak but non-empty password: `origin=signup, source=form, errorCategory=validation, errorReason=password_invalid` - Signup with an already-registered email: `origin=signup, source=toast, errorCategory=api, errorReason=email_already_registered` - New project with an empty name: `origin=project_creation, source=form, errorReason=project_name_invalid` - New org with an empty name: `origin=org_creation, source=form, errorReason=org_name_missing` - New org with a declined test card: `origin=org_creation, errorCategory=payment` PII: raw `error.message` is never sent; only controlled slugs and HTTP status. Dashboard consumers must filter `origin IS NOT NULL` so these do not collide with the generic toast events the global tracker still emits. ## Linear - fixes FE-3691 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added improved, categorized telemetry for signup, project creation, and organization creation errors, including payment, subscription-change, and validation failures. * Extended dashboard error events with optional structured diagnostics (origin, category, reason, and optional error code) and support for form-origin reporting. * **Bug Fixes** * Improved project-creation handling to record a validation telemetry event when an Oriole image is unavailable. * Ensured payment-related and subscription-change failures are captured consistently alongside existing user toasts. * **Tests** * Added unit tests covering API/network/validation/Stripe error classification and reason mapping. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
import type { FieldErrors } from 'react-hook-form'
|
|
|
|
export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation'
|
|
export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown'
|
|
|
|
export interface FunnelErrorClassification {
|
|
errorCategory: ErrorCategory
|
|
errorReason: FunnelErrorReason
|
|
errorCode?: number
|
|
}
|
|
|
|
const RATE_LIMIT_STATUS = 429
|
|
|
|
const API_REASON_PATTERNS = {
|
|
signup: [
|
|
[/already registered|already been registered|already exists/i, 'email_already_registered'],
|
|
[/rate limit|too many requests|after \d+ second/i, 'rate_limited'],
|
|
[/captcha/i, 'captcha_failed'],
|
|
[/password/i, 'password_rejected'],
|
|
[/valid email|invalid email|email address/i, 'email_invalid'],
|
|
],
|
|
project_creation: [
|
|
[/already exists/i, 'project_name_taken'],
|
|
[/free plan|free tier/i, 'free_tier_limit'],
|
|
[/limit|maximum number|can only have/i, 'project_limit_reached'],
|
|
[/payment|invoice|overdue|past due|billing/i, 'billing_issue'],
|
|
[/region/i, 'region_unavailable'],
|
|
[/db_pass|password/i, 'db_password_rejected'],
|
|
],
|
|
org_creation: [
|
|
[/already exists|name.*taken/i, 'org_name_taken'],
|
|
[/payment|card|invoice|billing/i, 'billing_issue'],
|
|
[/limit/i, 'org_limit_reached'],
|
|
],
|
|
} as const satisfies Record<FunnelOrigin, ReadonlyArray<readonly [RegExp, string]>>
|
|
|
|
const VALIDATION_FIELD_REASONS = {
|
|
signup: {
|
|
email: 'email_invalid',
|
|
password: 'password_invalid',
|
|
},
|
|
project_creation: {
|
|
organization: 'organization_missing',
|
|
projectName: 'project_name_invalid',
|
|
dbPass: 'db_password_weak',
|
|
dbPassStrength: 'db_password_weak',
|
|
dbRegion: 'region_missing',
|
|
cloudProvider: 'cloud_provider_invalid',
|
|
postgresVersion: 'postgres_version_missing',
|
|
highAvailability: 'incompatible_options',
|
|
useOrioleDb: 'incompatible_options',
|
|
},
|
|
org_creation: {
|
|
name: 'org_name_missing',
|
|
kind: 'org_kind_invalid',
|
|
size: 'org_size_invalid',
|
|
},
|
|
} as const satisfies Record<FunnelOrigin, Readonly<Record<string, string>>>
|
|
|
|
const STRIPE_DECLINE_REASONS = {
|
|
insufficient_funds: 'card_insufficient_funds',
|
|
card_declined: 'card_declined',
|
|
expired_card: 'card_expired',
|
|
incorrect_cvc: 'card_incorrect_cvc',
|
|
incorrect_number: 'card_incorrect_number',
|
|
processing_error: 'card_processing_error',
|
|
} as const satisfies Record<string, string>
|
|
|
|
const GENERIC_REASONS = [
|
|
'rate_limited',
|
|
'server_error',
|
|
'connection_timeout',
|
|
'network_error',
|
|
'payment_failed',
|
|
'payment_error',
|
|
'oriole_unavailable',
|
|
'other',
|
|
] as const
|
|
|
|
type ValuesOf<T> = T extends Readonly<Record<string, infer V extends string>> ? V : never
|
|
|
|
export type FunnelErrorReason =
|
|
| (typeof API_REASON_PATTERNS)[FunnelOrigin][number][1]
|
|
| ValuesOf<(typeof VALIDATION_FIELD_REASONS)[FunnelOrigin]>
|
|
| ValuesOf<typeof STRIPE_DECLINE_REASONS>
|
|
| (typeof GENERIC_REASONS)[number]
|
|
|
|
export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelErrorClassification {
|
|
const err = error as { code?: unknown; errorType?: unknown; message?: unknown }
|
|
const code = typeof err?.code === 'number' ? err.code : undefined
|
|
const message = typeof err?.message === 'string' ? err.message : ''
|
|
|
|
if (err?.errorType === 'connection-timeout') {
|
|
return { errorCategory: 'network', errorReason: 'connection_timeout' }
|
|
}
|
|
if (code === undefined) {
|
|
return { errorCategory: 'network', errorReason: 'network_error' }
|
|
}
|
|
if (code === RATE_LIMIT_STATUS) {
|
|
return { errorCategory: 'api', errorReason: 'rate_limited', errorCode: code }
|
|
}
|
|
if (code >= 500) {
|
|
return { errorCategory: 'api', errorReason: 'server_error', errorCode: code }
|
|
}
|
|
for (const [pattern, reason] of API_REASON_PATTERNS[origin]) {
|
|
if (pattern.test(message)) {
|
|
return { errorCategory: 'api', errorReason: reason, errorCode: code }
|
|
}
|
|
}
|
|
return { errorCategory: 'api', errorReason: 'other', errorCode: code }
|
|
}
|
|
|
|
export function classifyValidationError(
|
|
origin: FunnelOrigin,
|
|
errors: FieldErrors
|
|
): FunnelErrorClassification {
|
|
const fieldErrors = errors as Record<string, unknown>
|
|
const reasons = VALIDATION_FIELD_REASONS[origin] as Readonly<Record<string, FunnelErrorReason>>
|
|
for (const field of Object.keys(reasons)) {
|
|
if (fieldErrors[field]) {
|
|
return { errorCategory: 'validation', errorReason: reasons[field] }
|
|
}
|
|
}
|
|
return { errorCategory: 'validation', errorReason: 'other' }
|
|
}
|
|
|
|
export function classifyStripeError(error: unknown): FunnelErrorClassification {
|
|
const err = error as { code?: unknown; decline_code?: unknown }
|
|
const key =
|
|
typeof err?.decline_code === 'string'
|
|
? err.decline_code
|
|
: typeof err?.code === 'string'
|
|
? err.code
|
|
: undefined
|
|
const reason = key
|
|
? (STRIPE_DECLINE_REASONS as Readonly<Record<string, FunnelErrorReason>>)[key]
|
|
: undefined
|
|
if (reason) {
|
|
return { errorCategory: 'payment', errorReason: reason }
|
|
}
|
|
return { errorCategory: 'payment', errorReason: 'payment_failed' }
|
|
}
|