feat(studio): instrument sign-in attempts and failures (#49853)

The /sign-in page emitted only a pageview on entry and the success-side
`sign_in` event on exit: failed or abandoned attempts were invisible, so
"never interacted" and "tried and failed silently" could not be told
apart in the sign-in funnel. I added an unsampled `sign_in_submitted`
event at every initiation point and classified failure capture via
`dashboard_error_created` with a new `signin` origin.

**Changed:**
- **Submit attempts observable**: `sign_in_submitted` (method: `email`,
provider id, `sso`, or partner) fires from the DOM submit handler on the
password and SSO forms (so submits that fail client-side validation
still count), and from the OAuth, custom-provider, and partner
initiation handlers.
- **Failures classified**: each sign-in error path feeds the existing
funnel-error pipe with origin `signin` and a controlled reason slug
(`invalid_credentials`, `email_not_confirmed`, `captcha_failed`,
`sso_provider_not_found`, ...). GoTrue auth errors now classify via
their numeric `status`, guarded so transport failures (`status: 0`) stay
`network_error`.
- **Attempt events survive the OAuth redirect**: the telemetry event
POST sends with `keepalive` (scoped to `sign_in_submitted`, since
keepalive requests share a per-page in-flight body quota), so a
dispatched request is no longer aborted by the provider navigation; send
rejections are caught centrally instead of surfacing as unhandled
rejections. The fetch still dispatches after an async token lookup, so
preview testing verifies the GitHub-path event actually lands on the
wire.
- **Captcha rejection is no longer silent**: a rejected hCaptcha
challenge resolves the stuck loading toast with an error message, emits
`captcha_challenge_failed` (distinct from `captcha_failed`, which stays
reserved for the auth server rejecting a submitted token), reports to
error monitoring, and resets the captcha widget (previously: unhandled
promise rejection and a spinner that never resolved).
- **Partner method validated**: the partner sign-in page resolves the
URL-hash value against the provider registry and forwards the canonical
provider id into `method` on both `sign_in_submitted` and `sign_in`;
anything unregistered records as `unregistered_partner`, so a crafted
link can't poison the breakdown on either event.

**Note:** failure events stay on the shared 10%
`dashboard_error_created` sampling rate (a per-origin carve-out would
break cross-source volume comparability); the unsampled attempt event
carries the tried-vs-never-interacted signal at full volume.

## To test

Tested on Vercel preview (studio-staging, wire-level network capture +
staging ingestion check):
- [x] On `/sign-in`, submit a bogus email + password: expect a `POST
*/platform/telemetry/event` request with `action: sign_in_submitted`,
`method: email` in the network tab, plus an error toast. Observed: 201,
auth returned 400 as expected.
- [x] Submit with an empty password: expect `sign_in_submitted` to still
fire (validation failures count as attempts). Observed: event fired with
201 and no auth call followed.
- [x] Click "Continue with GitHub": expect `sign_in_submitted` with
`method: github` on the wire before the provider redirect. Observed: the
POST completed (201) before the browser landed on github.com, so the
keepalive path holds.
- [x] Negative case: fresh page load with no interaction fires no
`sign_in_submitted`.
- [x] Ingestion: all fired events (methods `email`, `github`, plus
organic `sso` submits from a real login on the same preview) arrived in
the staging project with the expected properties.
- [x] Re-ran the email and GitHub paths on the scoped-keepalive build
(`129bf8d`): both `sign_in_submitted` POSTs returned 201 (the GitHub one
completed despite the provider redirect), and both events ingested into
the staging project with the expected `method`/`category` properties.

## Linear
- GROWTH-1165 (no `fixes` keyword on purpose: the evidence checks run on
prod data post-deploy, and the issue closes manually after they pass)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved sign-in protection with more reliable invisible CAPTCHA
handling.
* Added sign-in submission tracking across password, SSO, partner,
custom OAuth, and external-provider flows.
* Added detailed classification for authentication, validation, CAPTCHA,
provider, and network errors.

* **Bug Fixes**
  * Sign-in now stops safely and resets CAPTCHA when verification fails.
* Improved error reporting for failed sign-in attempts, including
redirects and OAuth flows.
* Ensured sign-in telemetry is delivered reliably during OAuth
redirects.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pamela Chia authored and GitHub committed 2026-09-02 19:02:37 +08:00
1 parent afeba62c7c
commit 5db8a0e960
11 files changed
+280 -17

No files matched your search

@@ -0,0 +1,32 @@
import type HCaptcha from '@hcaptcha/react-hcaptcha'
import type { RefObject } from 'react'
import { toast } from 'sonner'
import { captureCriticalError } from '@/lib/error-reporting'
import type { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
type TrackFunnelError = ReturnType<typeof useTrackFunnelError>
export async function resolveCaptchaToken(
captchaRef: RefObject<HCaptcha | null>,
trackFunnelError: TrackFunnelError,
toastId: string | number
): Promise<{ ok: true; token: string | null } | { ok: false }> {
try {
const captchaResponse = await captchaRef.current?.execute({ async: true })
return { ok: true, token: captchaResponse?.response ?? null }
} catch (error) {
toast.error('Could not complete the security check. Please try again.', { id: toastId })
trackFunnelError(
'signin',
{ errorCategory: 'unknown', errorReason: 'captcha_challenge_failed' },
'toast',
toastId
)
captureCriticalError(
error instanceof Error ? error : new Error(String(error)),
'sign in captcha challenge'
)
return { ok: false }
}
}
@@ -14,13 +14,16 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { LastSignInWrapper } from './LastSignInWrapper'
import { resolveCaptchaToken } from './SignIn.utils'
import { AlertError } from '@/components/ui/AlertError'
import { useAddLoginEvent } from '@/data/misc/audit-login-mutation'
import { getMfaAuthenticatorAssuranceLevel } from '@/data/profile/mfa-authenticator-assurance-level-query'
import { useLastSignIn } from '@/hooks/misc/useLastSignIn'
import { captureCriticalError } from '@/lib/error-reporting'
import { auth, buildPathWithParams, getReturnToPath } from '@/lib/gotrue'
import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors'
import { useTrack } from '@/lib/telemetry/track'
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'),
@@ -51,6 +54,7 @@ export const SignInForm = () => {
}, [])
const track = useTrack()
const trackFunnelError = useTrackFunnelError()
const { mutate: addLoginEvent } = useAddLoginEvent()
let forgotPasswordUrl = `/forgot-password`
@@ -64,8 +68,13 @@ export const SignInForm = () => {
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId)
if (!captcha.ok) {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
return
}
token = captcha.token
}
const { error } = await auth.signInWithPassword({
@@ -100,6 +109,7 @@ export const SignInForm = () => {
router.push(redirectPath)
} catch (error: any) {
toast.error(`Failed to sign in: ${(error as AuthError).message}`, { id: toastId })
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
captureCriticalError(error, 'sign in via EP')
}
} else {
@@ -107,13 +117,16 @@ export const SignInForm = () => {
captchaRef.current?.resetCaptcha()
if (error.message.toLowerCase() === 'email not confirmed') {
return toast.error(
toast.error(
'Your account has not been verified. Please check the verification link sent to your email. If you have not received the email or the link has expired, please sign up again to request a new verification link.',
{ id: toastId }
)
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
return
}
toast.error(error.message, { id: toastId })
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
}
}
@@ -125,7 +138,12 @@ export const SignInForm = () => {
id={formId}
method="POST"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={(e) => {
track('sign_in_submitted', { category: 'account', method: 'email' })
return form.handleSubmit(onSubmit, (errors) =>
trackFunnelError('signin', classifyValidationError('signin', errors), 'form')
)(e)
}}
>
{authError && <AlertError error={authError} subject="Error while signing in" />}
<FormField
@@ -3,10 +3,16 @@ import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { InlineLink } from '@/components/ui/InlineLink'
import { getIdentityProviderConfig } from '@/lib/external-identity-providers'
import { auth } from '@/lib/gotrue'
import { classifyApiError } from '@/lib/telemetry/funnel-errors'
import { useTrack } from '@/lib/telemetry/track'
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
export const SignInPartner = () => {
const router = useRouter()
const track = useTrack()
const trackFunnelError = useTrackFunnelError()
useEffect(() => {
;(async () => {
@@ -18,10 +24,21 @@ export const SignInPartner = () => {
const { data } = await auth.getSession()
if (!data.session && partner && token) {
// partner comes from the URL hash unauthenticated; only registry-known values may
// enter the method vocabulary, anything else would let a crafted link poison it
const knownPartner = getIdentityProviderConfig(partner)
const method = knownPartner?.id ?? 'unregistered_partner'
track('sign_in_submitted', {
category: 'account',
method,
})
try {
await auth.signInWithIdToken({ provider: partner, token })
const { error } = await auth.signInWithIdToken({ provider: partner, token })
if (error) {
trackFunnelError('signin', classifyApiError('signin', error), 'form')
}
} finally {
router.replace({ pathname: '/sign-in-mfa', query: { method: partner } })
router.replace({ pathname: '/sign-in-mfa', query: { method } })
}
} else {
router.replace({ pathname: '/sign-in' })
@@ -12,10 +12,14 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { LastSignInWrapper } from './LastSignInWrapper'
import { resolveCaptchaToken } from './SignIn.utils'
import { useLastSignIn } from '@/hooks/misc/useLastSignIn'
import { BASE_PATH } from '@/lib/constants'
import { captureCriticalError } from '@/lib/error-reporting'
import { auth, buildPathWithParams } from '@/lib/gotrue'
import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors'
import { useTrack } from '@/lib/telemetry/track'
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'),
@@ -28,6 +32,8 @@ export const SignInSSOForm = () => {
const captchaRef = useRef<HCaptcha>(null)
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [_, setLastSignInUsed] = useLastSignIn()
const track = useTrack()
const trackFunnelError = useTrackFunnelError()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { email: '' },
@@ -39,8 +45,13 @@ export const SignInSSOForm = () => {
let token = captchaToken
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
const captcha = await resolveCaptchaToken(captchaRef, trackFunnelError, toastId)
if (!captcha.ok) {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
return
}
token = captcha.token
}
// redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx)
@@ -71,6 +82,7 @@ export const SignInSSOForm = () => {
setCaptchaToken(null)
captchaRef.current?.resetCaptcha()
toast.error(`Failed to sign in: ${error.message}`, { id: toastId })
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
captureCriticalError(error, 'sign in via SSO')
}
}
@@ -81,7 +93,12 @@ export const SignInSSOForm = () => {
id={formId}
method="POST"
className="flex flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={(e) => {
track('sign_in_submitted', { category: 'account', method: 'sso' })
return form.handleSubmit(onSubmit, (errors) =>
trackFunnelError('signin', classifyValidationError('signin', errors), 'form')
)(e)
}}
>
<FormField
key="email"
@@ -6,6 +6,9 @@ import { BASE_PATH } from '@/lib/constants'
import { captureCriticalError } from '@/lib/error-reporting'
import { getProviderDisplay } from '@/lib/external-identity-providers'
import { auth, buildPathWithParams } from '@/lib/gotrue'
import { classifyApiError } from '@/lib/telemetry/funnel-errors'
import { useTrack } from '@/lib/telemetry/track'
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
interface SignInWithCustomProps {
providerName: string
@@ -14,9 +17,12 @@ interface SignInWithCustomProps {
export const SignInWithCustom = ({ providerName }: SignInWithCustomProps) => {
const [loading, setLoading] = useState(false)
const displayName = getProviderDisplay(providerName).displayName
const track = useTrack()
const trackFunnelError = useTrackFunnelError()
async function handleCustomSignIn() {
setLoading(true)
track('sign_in_submitted', { category: 'account', method: providerName.toLowerCase() })
try {
// redirects to /sign-in to check if the user has MFA setup (handled in SignInLayout.tsx)
@@ -36,7 +42,8 @@ export const SignInWithCustom = ({ providerName }: SignInWithCustomProps) => {
if (error) throw error
} catch (error: any) {
toast.error(`Failed to sign in via ${displayName}: ${error.message}`)
const toastId = toast.error(`Failed to sign in via ${displayName}: ${error.message}`)
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
captureCriticalError(error, `sign in via ${providerName}`)
setLoading(false)
}
@@ -14,6 +14,9 @@ import {
} from '@/lib/external-identity-providers'
import { getErrorMessage } from '@/lib/get-error-message'
import { auth, buildPathWithParams } from '@/lib/gotrue'
import { classifyApiError } from '@/lib/telemetry/funnel-errors'
import { useTrack } from '@/lib/telemetry/track'
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
interface SignInWithExternalProviderProps {
provider: ExternalIdentityProviderConfig
@@ -22,9 +25,12 @@ interface SignInWithExternalProviderProps {
export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProviderProps) => {
const [loading, setLoading] = useState(false)
const [, setLastSignInUsed] = useLastSignIn()
const track = useTrack()
const trackFunnelError = useTrackFunnelError()
async function handleSignIn() {
setLoading(true)
track('sign_in_submitted', { category: 'account', method: provider.id })
try {
// Redirects to /sign-in-mfa to check if the user has MFA set up before entering the dashboard
@@ -40,7 +46,8 @@ export const SignInWithExternalProvider = ({ provider }: SignInWithExternalProvi
setLastSignInUsed(provider.id)
} catch (error: unknown) {
const message = getErrorMessage(error) ?? 'Unknown error'
toast.error(`Failed to sign in via ${provider.displayName}: ${message}`)
const toastId = toast.error(`Failed to sign in via ${provider.displayName}: ${message}`)
trackFunnelError('signin', classifyApiError('signin', error), 'toast', toastId)
captureCriticalError(
error instanceof Error ? error : new Error(message),
`sign in via ${provider.displayName}`
@@ -58,6 +58,84 @@ describe('classifyApiError', () => {
errorCode: 400,
})
})
describe('signin', () => {
it('reads a GoTrue AuthError status as the code', () => {
expect(
classifyApiError('signin', { status: 400, message: 'Invalid login credentials' })
).toEqual({
errorCategory: 'api',
errorReason: 'invalid_credentials',
errorCode: 400,
})
})
it('classifies an unconfirmed email', () => {
expect(classifyApiError('signin', { status: 400, message: 'Email not confirmed' })).toEqual({
errorCategory: 'api',
errorReason: 'email_not_confirmed',
errorCode: 400,
})
})
it('classifies 429 via status as rate_limited', () => {
expect(classifyApiError('signin', { status: 429, message: 'Rate limit exceeded' })).toEqual({
errorCategory: 'api',
errorReason: 'rate_limited',
errorCode: 429,
})
})
it('classifies a captcha failure', () => {
expect(
classifyApiError('signin', { status: 400, message: 'captcha verification process failed' })
).toEqual({ errorCategory: 'api', errorReason: 'captcha_failed', errorCode: 400 })
})
it('matches the SSO pattern before the 404 status map', () => {
expect(
classifyApiError('signin', {
status: 404,
message: 'No SSO provider assigned for this domain',
})
).toEqual({ errorCategory: 'api', errorReason: 'sso_provider_not_found', errorCode: 404 })
})
it('classifies a redirect allow-list rejection', () => {
expect(classifyApiError('signin', { status: 400, message: 'Invalid redirect URL' })).toEqual({
errorCategory: 'api',
errorReason: 'redirect_not_allowed',
errorCode: 400,
})
})
it('classifies a disabled provider', () => {
expect(
classifyApiError('signin', {
status: 400,
message: 'Unsupported provider: provider is not enabled',
})
).toEqual({ errorCategory: 'api', errorReason: 'provider_not_enabled', errorCode: 400 })
})
it('classifies a GoTrue transport failure (status 0) as network_error, never api/other', () => {
expect(
classifyApiError('signin', {
name: 'AuthRetryableFetchError',
status: 0,
message: 'Failed to fetch',
})
).toEqual({ errorCategory: 'network', errorReason: 'network_error' })
})
it('classifies a retryable 5xx via status as server_error', () => {
expect(classifyApiError('signin', { status: 503, message: 'Service unavailable' })).toEqual({
errorCategory: 'api',
errorReason: 'server_error',
errorCode: 503,
})
})
})
})
describe('classifyValidationError', () => {
@@ -79,6 +157,15 @@ describe('classifyValidationError', () => {
).toEqual({ errorCategory: 'validation', errorReason: 'email_invalid' })
})
it('maps a signin password error to password_invalid', () => {
expect(
classifyValidationError('signin', { password: { type: 'too_small' } } as FieldErrors)
).toEqual({
errorCategory: 'validation',
errorReason: 'password_invalid',
})
})
it('maps an org name error to org_name_missing', () => {
expect(
classifyValidationError('org_creation', { name: { type: 'too_small' } } as FieldErrors)
+26 -3
View File
@@ -1,6 +1,6 @@
import type { FieldErrors } from 'react-hook-form'
export type FunnelOrigin = 'signup' | 'project_creation' | 'org_creation'
export type FunnelOrigin = 'signup' | 'signin' | 'project_creation' | 'org_creation'
export type ErrorCategory = 'validation' | 'api' | 'network' | 'payment' | 'unknown'
export interface FunnelErrorClassification {
@@ -19,6 +19,16 @@ const API_REASON_PATTERNS = {
[/password/i, 'password_rejected'],
[/valid email|invalid email|email address/i, 'email_invalid'],
],
signin: [
[/invalid login credentials/i, 'invalid_credentials'],
[/email not confirmed/i, 'email_not_confirmed'],
[/rate limit|too many requests|after \d+ second/i, 'rate_limited'],
[/captcha/i, 'captcha_failed'],
[/sso provider/i, 'sso_provider_not_found'],
[/redirect|requested path is invalid/i, 'redirect_not_allowed'],
[/provider is not enabled|unsupported provider/i, 'provider_not_enabled'],
[/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'],
@@ -39,6 +49,10 @@ const VALIDATION_FIELD_REASONS = {
email: 'email_invalid',
password: 'password_invalid',
},
signin: {
email: 'email_invalid',
password: 'password_invalid',
},
project_creation: {
organization: 'organization_missing',
projectName: 'project_name_invalid',
@@ -67,6 +81,7 @@ const STRIPE_DECLINE_REASONS = {
} as const satisfies Record<string, string>
const GENERIC_REASONS = [
'captcha_challenge_failed',
'rate_limited',
'server_error',
'connection_timeout',
@@ -95,8 +110,16 @@ const STATUS_REASONS: Readonly<Partial<Record<number, FunnelErrorReason>>> = {
}
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 err = error as { code?: unknown; status?: unknown; errorType?: unknown; message?: unknown }
// GoTrue AuthErrors carry a numeric `status` and a string `code` slug; auth-js uses
// status 0 for transport failures (AuthRetryableFetchError), which must classify as
// network_error, so the fallback only accepts positive statuses.
const code =
typeof err?.code === 'number'
? err.code
: typeof err?.status === 'number' && err.status > 0
? err.status
: undefined
const message = typeof err?.message === 'string' ? err.message : ''
if (err?.errorType === 'connection-timeout') {
+27
View File
@@ -76,6 +76,33 @@ describe('ToastErrorTracker', () => {
expect(mockTrack).toHaveBeenCalledTimes(1)
})
it('tracks a loading toast updated to an error exactly once (sign-in reuses the loading toast id)', async () => {
render(<ToastErrorTracker />)
let toastId: string | number
act(() => {
toastId = toast.loading('Signing in...')
})
act(() => {
toast.error('Invalid login credentials', { id: toastId })
registerFunnelErrorToast(toastId, {
origin: 'signin',
errorCategory: 'api',
errorReason: 'invalid_credentials',
errorCode: 400,
})
})
await waitFor(() =>
expect(mockTrack).toHaveBeenCalledWith('dashboard_error_created', {
source: 'toast',
origin: 'signin',
errorCategory: 'api',
errorReason: 'invalid_credentials',
errorCode: 400,
})
)
expect(mockTrack).toHaveBeenCalledTimes(1)
})
it('ignores non-error toasts', async () => {
render(<ToastErrorTracker />)
act(() => {
+23 -3
View File
@@ -50,8 +50,7 @@ export interface SignUpEvent {
*
* Some unintuitive behavior:
* - If signing up with GitHub the SignInEvent gets triggered first before the SignUpEvent.
* - Captured server-side; the distinct_id often resolves to the anonymous cookie because
* the event races identify, so don't use it as a funnel join key across the auth boundary.
* - distinct_id often resolves to the anonymous cookie (races identify); not a person-level join key.
*
* @group Events
* @source studio
@@ -68,6 +67,26 @@ export interface SignInEvent {
}
}
/**
* Triggered when a user initiates a sign-in (form submit including client-side validation
* failures, OAuth or custom-provider click, partner token exchange), before auth resolves.
* Pre-auth, so distinct_id is the anonymous cookie: not a person-level join key.
*
* @group Events
* @source studio
* @page /sign-in, /sign-in-sso, /sign-in-partner
*/
export interface SignInSubmittedEvent {
action: 'sign_in_submitted'
properties: {
category: 'account'
/**
* Matches the sign_in event's method vocabulary, e.g. email (password path), github, sso
*/
method: string
}
}
/**
* User copied the database connection string.
*
@@ -3176,7 +3195,7 @@ export interface DashboardErrorCreatedEvent {
/**
* Funnel the error occurred in (set only for instrumented funnel errors)
*/
origin?: 'signup' | 'project_creation' | 'org_creation'
origin?: 'signup' | 'signin' | 'project_creation' | 'org_creation'
/**
* Coarse classification of the funnel error
*/
@@ -3772,6 +3791,7 @@ export interface HeaderLocalVersionPopoverOpenedEvent {
export type TelemetryEvent =
| SignUpEvent
| SignInEvent
| SignInSubmittedEvent
| ConnectionStringCopiedEvent
| McpInstallButtonClickedEvent
| ApiDocsOpenedEvent
+8
View File
@@ -428,8 +428,16 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn
}
}
// keepalive lets the request survive the same-tick OAuth redirect after
// sign_in_submitted, but keepalive requests share a ~64KB in-flight quota
// page-wide, so it stays scoped to that event. Callers like useTrack
// fire-and-forget, so rejections are handled here rather than surfacing
// as unhandled promise rejections.
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
headers: { Version: '2' },
keepalive: event.action === 'sign_in_submitted',
}).catch((error) => {
console.error('Problem sending telemetry event:', error)
})
}