diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 36dfbf16b50..8deba853795 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -298,7 +298,13 @@ export const NewOrgForm = ({ } const onOrganizationCreated = (org: { slug: string }) => { - track('organization_creation_completed', undefined, { organization: org.slug }) + if (submittedTier.current) { + track( + 'organization_creation_completed', + { tier: submittedTier.current }, + { organization: org.slug } + ) + } const prefilledProjectName = user.profile?.username ? user.profile.username + `'s Project` @@ -326,6 +332,8 @@ export const NewOrgForm = ({ } as StripeElementsOptions }, [paymentIntentSecret, resolvedTheme]) + const submittedTier = useRef<'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | null>(null) + async function createOrg( formValues: z.infer, paymentMethodId?: string, @@ -336,15 +344,17 @@ export const NewOrgForm = ({ } ) { const dbTier = formValues.plan === 'PRO' && !formValues.spend_cap ? 'PAYG' : formValues.plan + const tier = ('tier_' + dbTier.toLowerCase()) as + | 'tier_payg' + | 'tier_pro' + | 'tier_free' + | 'tier_team' + submittedTier.current = tier createOrganization({ name: formValues.name, kind: formValues.kind, - tier: ('tier_' + dbTier.toLowerCase()) as - | 'tier_payg' - | 'tier_pro' - | 'tier_free' - | 'tier_team', + tier, ...(formValues.kind == 'COMPANY' ? { size: formValues.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, diff --git a/apps/studio/lib/telemetry/funnel-errors.ts b/apps/studio/lib/telemetry/funnel-errors.ts index 579d0cc6bea..67c92592fed 100644 --- a/apps/studio/lib/telemetry/funnel-errors.ts +++ b/apps/studio/lib/telemetry/funnel-errors.ts @@ -74,6 +74,9 @@ const GENERIC_REASONS = [ 'payment_failed', 'payment_error', 'oriole_unavailable', + 'unauthorized', + 'forbidden', + 'not_found', 'other', ] as const @@ -85,6 +88,12 @@ export type FunnelErrorReason = | ValuesOf | (typeof GENERIC_REASONS)[number] +const STATUS_REASONS: Readonly>> = { + 401: 'unauthorized', + 403: 'forbidden', + 404: 'not_found', +} + 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 @@ -107,6 +116,10 @@ export function classifyApiError(origin: FunnelOrigin, error: unknown): FunnelEr return { errorCategory: 'api', errorReason: reason, errorCode: code } } } + const statusReason = STATUS_REASONS[code] + if (statusReason) { + return { errorCategory: 'api', errorReason: statusReason, errorCode: code } + } return { errorCategory: 'api', errorReason: 'other', errorCode: code } } diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 02a69b9ee89..1b55a32e4b9 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -117,6 +117,14 @@ const CreateProject = () => { const { data: organizationData } = useOrganizationsQuery() const organization = organizationData?.find((x) => x.slug === slug) + const hasTrackedFormExposed = useRef(false) + useEffect(() => { + if (hasTrackedFormExposed.current) return + if (!slug) return + hasTrackedFormExposed.current = true + track('project_creation_form_exposed', { surface: 'vercel' }, { organization: slug }) + }, [slug, track]) + /** * array of integrations installed */ diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index de486929d08..4d48913a696 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -480,6 +480,12 @@ export interface OrganizationCreationFormExposedEvent { */ export interface OrganizationCreationCompletedEvent { action: 'organization_creation_completed' + properties: { + /** + * Billing tier provisioned at creation. tier_payg is uncapped PRO. + */ + tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' + } groups: Omit } @@ -2930,7 +2936,14 @@ export interface DashboardErrorCreatedEvent { */ errorCategory?: 'validation' | 'api' | 'network' | 'payment' | 'unknown' /** - * Controlled-vocabulary slug describing the reason (no free text, no PII) + * Controlled-vocabulary slug describing the reason (no free text, no PII). + * + * Typed `string` rather than a literal union on purpose: the source-of-truth + * union `FunnelErrorReason` lives in `apps/studio/lib/telemetry/funnel-errors.ts`, + * and this `common` package cannot import from an app. The constraint is enforced + * at the only emit site instead: `useTrackFunnelError` accepts a classified + * `FunnelErrorReason`, so free text never reaches this field. Do not widen usage by + * setting `errorReason` from a raw error message. */ errorReason?: string /**