mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat(telemetry): classify funnel creation errors (#47293)
## 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 -->
This commit is contained in:
1 parent
e0ba04caf4
commit
2d0bcd4714
7 files changed
+363
-4
No files matched your search
@@ -58,6 +58,12 @@ import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganizat
|
||||
import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from '@/lib/constants'
|
||||
import { validateReturnTo } from '@/lib/gotrue'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import {
|
||||
classifyApiError,
|
||||
classifyStripeError,
|
||||
classifyValidationError,
|
||||
} from '@/lib/telemetry/funnel-errors'
|
||||
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
|
||||
|
||||
interface NewOrgFormProps {
|
||||
onPaymentMethodReset: () => void
|
||||
@@ -226,6 +232,8 @@ export const NewOrgForm = ({
|
||||
[freeOrgs, projectsByOrg]
|
||||
)
|
||||
|
||||
const trackFunnelError = useTrackFunnelError()
|
||||
|
||||
const { mutate: createOrganization } = useOrganizationCreateMutation({
|
||||
onSuccess: async (org) => {
|
||||
if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) {
|
||||
@@ -236,6 +244,7 @@ export const NewOrgForm = ({
|
||||
},
|
||||
onError: (data) => {
|
||||
toast.error(data.message, { duration: 10_000 })
|
||||
trackFunnelError('org_creation', classifyApiError('org_creation', data), 'toast')
|
||||
setNewOrgLoading(false)
|
||||
},
|
||||
})
|
||||
@@ -246,6 +255,10 @@ export const NewOrgForm = ({
|
||||
onOrganizationCreated({ slug: data.slug })
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { dismissible: true, duration: 10_000 })
|
||||
trackFunnelError('org_creation', classifyApiError('org_creation', error), 'toast')
|
||||
},
|
||||
})
|
||||
|
||||
const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => {
|
||||
@@ -260,6 +273,11 @@ export const NewOrgForm = ({
|
||||
size: form.getValues('size'),
|
||||
})
|
||||
} else {
|
||||
trackFunnelError(
|
||||
'org_creation',
|
||||
classifyStripeError(paymentIntentConfirmation.error),
|
||||
'toast'
|
||||
)
|
||||
// If the payment intent is not successful, we reset the payment method and show an error
|
||||
toast.error(`Could not confirm payment. Please try again or use a different card.`, {
|
||||
duration: 10_000,
|
||||
@@ -354,7 +372,12 @@ export const NewOrgForm = ({
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} id={FORM_ID}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit, (errors) =>
|
||||
trackFunnelError('org_creation', classifyValidationError('org_creation', errors), 'form')
|
||||
)}
|
||||
id={FORM_ID}
|
||||
>
|
||||
<Panel
|
||||
title={
|
||||
<div key="panel-title">
|
||||
@@ -547,6 +570,11 @@ export const NewOrgForm = ({
|
||||
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
|
||||
onError={(err) => {
|
||||
toast.error(err.message, { duration: 10_000 })
|
||||
trackFunnelError(
|
||||
'org_creation',
|
||||
{ errorCategory: 'payment', errorReason: 'payment_error' },
|
||||
'toast'
|
||||
)
|
||||
setNewOrgLoading(false)
|
||||
resetPaymentMethod()
|
||||
}}
|
||||
|
||||
@@ -25,6 +25,8 @@ import PasswordConditionsHelper from './PasswordConditionsHelper'
|
||||
import { useSignUpMutation } from '@/data/misc/signup-mutation'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
import { buildPathWithParams } from '@/lib/gotrue'
|
||||
import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors'
|
||||
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().min(1, 'Email is required').email('Must be a valid email'),
|
||||
@@ -67,6 +69,8 @@ export const SignUpForm = () => {
|
||||
token: parseAsString.withDefault(''),
|
||||
})
|
||||
|
||||
const trackFunnelError = useTrackFunnelError()
|
||||
|
||||
const { mutate: signup, isPending: isSigningUp } = useSignUpMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Signed up successfully!`)
|
||||
@@ -76,6 +80,7 @@ export const SignUpForm = () => {
|
||||
setCaptchaToken(null)
|
||||
captchaRef.current?.resetCaptcha()
|
||||
toast.error(`Failed to sign up: ${error.message}`)
|
||||
trackFunnelError('signup', classifyApiError('signup', error), 'toast')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -144,7 +149,13 @@ export const SignUpForm = () => {
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form id={formId} className="flex flex-col gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit, (errors) =>
|
||||
trackFunnelError('signup', classifyValidationError('signup', errors), 'form')
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
key="email"
|
||||
name="email"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
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' }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { FunnelErrorClassification, FunnelOrigin } from '@/lib/telemetry/funnel-errors'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
// Matches the existing dashboard_error_created capture rate; keeps PostHog volume bounded.
|
||||
const SAMPLE_RATE = 0.1
|
||||
|
||||
export function useTrackFunnelError() {
|
||||
const track = useTrack()
|
||||
return useCallback(
|
||||
(
|
||||
origin: FunnelOrigin,
|
||||
classification: FunnelErrorClassification,
|
||||
source: 'toast' | 'form' = 'toast'
|
||||
) => {
|
||||
if (Math.random() >= SAMPLE_RATE) return
|
||||
track('dashboard_error_created', {
|
||||
source,
|
||||
origin,
|
||||
errorCategory: classification.errorCategory,
|
||||
errorReason: classification.errorReason,
|
||||
...(classification.errorCode !== undefined && { errorCode: classification.errorCode }),
|
||||
})
|
||||
},
|
||||
[track]
|
||||
)
|
||||
}
|
||||
@@ -71,13 +71,16 @@ import { usePHFlag } from '@/hooks/ui/useFlag'
|
||||
import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from '@/lib/constants'
|
||||
import { buildStudioPageTitle } from '@/lib/page-title'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small']
|
||||
|
||||
const Wizard: NextPageWithLayout = () => {
|
||||
const track = useTrack()
|
||||
const trackFunnelError = useTrackFunnelError()
|
||||
const router = useRouter()
|
||||
const { slug, projectName } = useParams()
|
||||
const { appTitle } = useCustomContent(['app:title'])
|
||||
@@ -317,6 +320,10 @@ const Wizard: NextPageWithLayout = () => {
|
||||
)
|
||||
router.push(`/project/${res.ref}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to create new project: ${error.message}`)
|
||||
trackFunnelError('project_creation', classifyApiError('project_creation', error), 'toast')
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmitWithComputeCostsConfirmation = async (values: z.infer<typeof FormSchema>) => {
|
||||
@@ -356,6 +363,11 @@ const Wizard: NextPageWithLayout = () => {
|
||||
} = values
|
||||
|
||||
if (useOrioleDb && !availableOrioleVersion) {
|
||||
trackFunnelError(
|
||||
'project_creation',
|
||||
{ errorCategory: 'validation', errorReason: 'oriole_unavailable' },
|
||||
'toast'
|
||||
)
|
||||
return toast.error('No available OrioleDB image found, only Postgres is available')
|
||||
}
|
||||
|
||||
@@ -501,7 +513,15 @@ const Wizard: NextPageWithLayout = () => {
|
||||
<meta name="description" content="Supabase Studio" />
|
||||
</Head>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation)}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation, (errors) =>
|
||||
trackFunnelError(
|
||||
'project_creation',
|
||||
classifyValidationError('project_creation', errors),
|
||||
'form'
|
||||
)
|
||||
)}
|
||||
>
|
||||
<Panel
|
||||
loading={!isOrganizationsSuccess}
|
||||
title={
|
||||
|
||||
@@ -2842,7 +2842,7 @@ export interface DashboardErrorCreatedEvent {
|
||||
/**
|
||||
* Source of the error
|
||||
*/
|
||||
source?: 'admonition' | 'toast' | 'error_display'
|
||||
source?: 'admonition' | 'toast' | 'error_display' | 'form'
|
||||
/**
|
||||
* Type of error matched (for error_display source)
|
||||
*/
|
||||
@@ -2851,6 +2851,22 @@ export interface DashboardErrorCreatedEvent {
|
||||
* Whether troubleshooting steps are available (for error_display source)
|
||||
*/
|
||||
hasTroubleshooting?: boolean
|
||||
/**
|
||||
* Funnel the error occurred in (set only for instrumented funnel errors)
|
||||
*/
|
||||
origin?: 'signup' | 'project_creation' | 'org_creation'
|
||||
/**
|
||||
* Coarse classification of the funnel error
|
||||
*/
|
||||
errorCategory?: 'validation' | 'api' | 'network' | 'payment' | 'unknown'
|
||||
/**
|
||||
* Controlled-vocabulary slug describing the reason (no free text, no PII)
|
||||
*/
|
||||
errorReason?: string
|
||||
/**
|
||||
* HTTP status code for api/network errors (absent for validation/payment)
|
||||
*/
|
||||
errorCode?: number
|
||||
}
|
||||
groups: TelemetryGroups
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user