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 -->
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
import type { FieldErrors } from 'react-hook-form'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import { classifyApiError, classifyStripeError, classifyValidationError } from './funnel-errors'
|
|
|
|
describe('classifyApiError', () => {
|
|
it('classifies connection timeout as network', () => {
|
|
expect(classifyApiError('signup', { errorType: 'connection-timeout' })).toEqual({
|
|
errorCategory: 'network',
|
|
errorReason: 'connection_timeout',
|
|
})
|
|
})
|
|
|
|
it('classifies a missing status code as network_error', () => {
|
|
expect(classifyApiError('project_creation', { message: 'Failed to fetch' })).toEqual({
|
|
errorCategory: 'network',
|
|
errorReason: 'network_error',
|
|
})
|
|
})
|
|
|
|
it('classifies 429 as rate_limited regardless of message', () => {
|
|
expect(classifyApiError('signup', { code: 429, message: 'whatever' })).toEqual({
|
|
errorCategory: 'api',
|
|
errorReason: 'rate_limited',
|
|
errorCode: 429,
|
|
})
|
|
})
|
|
|
|
it('classifies 5xx as server_error', () => {
|
|
expect(classifyApiError('org_creation', { code: 500, message: 'boom' })).toEqual({
|
|
errorCategory: 'api',
|
|
errorReason: 'server_error',
|
|
errorCode: 500,
|
|
})
|
|
})
|
|
|
|
it('matches a known 4xx signup message to a reason slug', () => {
|
|
expect(classifyApiError('signup', { code: 400, message: 'User already registered' })).toEqual({
|
|
errorCategory: 'api',
|
|
errorReason: 'email_already_registered',
|
|
errorCode: 400,
|
|
})
|
|
})
|
|
|
|
it('matches a known 4xx project message to a reason slug', () => {
|
|
expect(
|
|
classifyApiError('project_creation', {
|
|
code: 403,
|
|
message: 'Your organization can only have 2 projects',
|
|
})
|
|
).toEqual({ errorCategory: 'api', errorReason: 'project_limit_reached', errorCode: 403 })
|
|
})
|
|
|
|
it('falls back to other for an unmapped 4xx message', () => {
|
|
expect(classifyApiError('signup', { code: 400, message: 'totally novel error' })).toEqual({
|
|
errorCategory: 'api',
|
|
errorReason: 'other',
|
|
errorCode: 400,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('classifyValidationError', () => {
|
|
it('maps a signup password error to password_invalid', () => {
|
|
expect(
|
|
classifyValidationError('signup', { password: { type: 'too_small' } } as FieldErrors)
|
|
).toEqual({
|
|
errorCategory: 'validation',
|
|
errorReason: 'password_invalid',
|
|
})
|
|
})
|
|
|
|
it('respects field priority (email before password)', () => {
|
|
expect(
|
|
classifyValidationError('signup', {
|
|
email: { type: 'invalid' },
|
|
password: { type: 'too_small' },
|
|
} as FieldErrors)
|
|
).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' })
|
|
})
|
|
|
|
it('maps an org name error to org_name_missing', () => {
|
|
expect(
|
|
classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors)
|
|
).toEqual({
|
|
errorCategory: 'validation',
|
|
errorReason: 'org_name_missing',
|
|
})
|
|
})
|
|
|
|
it('falls back to other for an unmapped field', () => {
|
|
expect(
|
|
classifyValidationError('project_creation', { somethingNew: { type: 'x' } } as FieldErrors)
|
|
).toEqual({ errorCategory: 'validation', errorReason: 'other' })
|
|
})
|
|
})
|
|
|
|
describe('classifyStripeError', () => {
|
|
it('maps a decline_code to a card reason slug', () => {
|
|
expect(
|
|
classifyStripeError({ code: 'card_declined', decline_code: 'insufficient_funds' })
|
|
).toEqual({
|
|
errorCategory: 'payment',
|
|
errorReason: 'card_insufficient_funds',
|
|
})
|
|
})
|
|
|
|
it('falls back to payment_failed for an unknown code', () => {
|
|
expect(classifyStripeError({ code: 'mystery' })).toEqual({
|
|
errorCategory: 'payment',
|
|
errorReason: 'payment_failed',
|
|
})
|
|
})
|
|
})
|