feat: Add Sentry error tracking to auth API functions (#5623)

## Summary
- Added comprehensive Sentry error tracking to all auth API functions
- Implemented helper functions to reduce code duplication  
- Properly distinguish between HTTP errors and network errors

## Changes
- Added `captureApiError` helper function for consistent error reporting
- Added `isHttpError` helper to prevent duplicate error capture
- Enhanced error tracking with:
  - Proper error type classification (`http_error` vs `network_error`)
  - HTTP status codes and response details
  - Operation names for better context
  - Route templates for better API endpoint tracking

## Test plan
- [ ] Verify auth functions work correctly in normal flow
- [ ] Test error scenarios (network failures, 4xx/5xx responses)
- [ ] Confirm Sentry receives proper error reports without duplicates
- [ ] Check that error messages are informative and actionable

🤖 Generated with [Claude Code](https://claude.ai/code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5623-feat-Add-Sentry-error-tracking-to-auth-API-functions-2716d73d3650819fbb15e73d19642235)
by [Unito](https://www.unito.io)
This commit is contained in:
Jin Yi
2025-09-19 03:39:23 +09:00
committed by GitHub
parent 16ebe33488
commit 33b6df55a8

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/vue'
import { isEmpty } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
@@ -8,84 +9,352 @@ export interface UserCloudStatus {
const ONBOARDING_SURVEY_KEY = 'onboarding_survey'
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
const response = await api.fetchApi('/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to get user: ${response.statusText}`)
/**
* Helper function to capture API errors with Sentry
*/
function captureApiError(
error: Error,
endpoint: string,
errorType: 'http_error' | 'network_error',
httpStatus?: number,
operation?: string,
extraContext?: Record<string, any>
) {
const tags: Record<string, any> = {
api_endpoint: endpoint,
error_type: errorType
}
return response.json()
if (httpStatus !== undefined) {
tags.http_status = httpStatus
}
if (operation) {
tags.operation = operation
}
const sentryOptions: any = {
tags,
extra: extraContext ? { ...extraContext } : undefined
}
Sentry.captureException(error, sentryOptions)
}
/**
* Helper function to check if error is already handled HTTP error
*/
function isHttpError(error: unknown, errorMessagePrefix: string): boolean {
return error instanceof Error && error.message.startsWith(errorMessagePrefix)
}
export async function getUserCloudStatus(): Promise<UserCloudStatus> {
try {
const response = await api.fetchApi('/user', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const error = new Error(`Failed to get user: ${response.statusText}`)
captureApiError(
error,
'/user',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: '/user',
status_code: response.status,
status_text: response.statusText
}
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get user:')) {
captureApiError(error as Error, '/user', 'network_error')
}
throw error
}
}
export async function getInviteCodeStatus(
inviteCode: string
): Promise<{ expired: boolean }> {
const response = await api.fetchApi(
`/invite/${encodeURIComponent(inviteCode)}/status`,
{
try {
const response = await api.fetchApi(
`/invite/${encodeURIComponent(inviteCode)}/status`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
)
if (!response.ok) {
const error = new Error(
`Failed to get invite code status: ${response.statusText}`
)
captureApiError(
error,
'/invite/{code}/status',
'http_error',
response.status,
undefined,
{
api: {
method: 'GET',
endpoint: `/invite/${inviteCode}/status`,
status_code: response.status,
status_text: response.statusText
},
extra: {
invite_code_length: inviteCode.length
},
route_template: '/invite/{code}/status',
route_actual: `/invite/${inviteCode}/status`
}
)
throw error
}
return response.json()
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to get invite code status:')) {
captureApiError(
error as Error,
'/invite/{code}/status',
'network_error',
undefined,
undefined,
{
route_template: '/invite/{code}/status',
route_actual: `/invite/${inviteCode}/status`
}
)
}
throw error
}
}
export async function getSurveyCompletedStatus(): Promise<boolean> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
// Not an error case - survey not completed is a valid state
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey status check returned non-ok response',
level: 'info',
data: {
status: response.status,
endpoint: `/settings/${ONBOARDING_SURVEY_KEY}`
}
})
return false
}
)
if (!response.ok) {
throw new Error(`Failed to get invite code status: ${response.statusText}`)
}
return response.json()
}
export async function getSurveyCompletedStatus(): Promise<boolean> {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
} catch (error) {
// Network error - still capture it as it's not thrown from above
Sentry.captureException(error, {
tags: {
api_endpoint: '/settings/{key}',
error_type: 'network_error'
},
extra: {
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
},
level: 'warning'
})
return false
}
const data = await response.json()
// Check if data exists and is not empty
return !isEmpty(data.value)
}
export async function postSurveyStatus(): Promise<void> {
await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {
const response = await api.fetchApi('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
})
if (!response.ok) {
throw new Error(`Failed to submit survey: ${response.statusText}`)
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Submitting survey',
level: 'info',
data: {
survey_fields: Object.keys(survey)
}
})
const response = await api.fetchApi('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: survey })
})
if (!response.ok) {
const error = new Error(`Failed to submit survey: ${response.statusText}`)
captureApiError(
error,
'/settings',
'http_error',
response.status,
'submit_survey',
{
survey: {
field_count: Object.keys(survey).length,
field_names: Object.keys(survey)
}
}
)
throw error
}
// Log successful survey submission
Sentry.addBreadcrumb({
category: 'auth',
message: 'Survey submitted successfully',
level: 'info'
})
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to submit survey:')) {
captureApiError(
error as Error,
'/settings',
'network_error',
undefined,
'submit_survey'
)
}
throw error
}
}
export async function claimInvite(code: string): Promise<void> {
const res = await api.fetchApi(`/invite/${encodeURIComponent(code)}/claim`, {
method: 'POST'
})
if (!res.ok) {
throw new Error(`Failed to claim invite: ${res.status} ${res.statusText}`)
try {
Sentry.addBreadcrumb({
category: 'auth',
message: 'Attempting to claim invite',
level: 'info',
data: {
code_length: code.length
}
})
const res = await api.fetchApi(
`/invite/${encodeURIComponent(code)}/claim`,
{
method: 'POST'
}
)
if (!res.ok) {
const error = new Error(
`Failed to claim invite: ${res.status} ${res.statusText}`
)
captureApiError(
error,
'/invite/{code}/claim',
'http_error',
res.status,
'claim_invite',
{
invite: {
code_length: code.length,
status_code: res.status,
status_text: res.statusText
},
route_template: '/invite/{code}/claim',
route_actual: `/invite/${encodeURIComponent(code)}/claim`
}
)
throw error
}
// Log successful invite claim
Sentry.addBreadcrumb({
category: 'auth',
message: 'Invite claimed successfully',
level: 'info'
})
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to claim invite:')) {
captureApiError(
error as Error,
'/invite/{code}/claim',
'network_error',
undefined,
'claim_invite',
{
route_template: '/invite/{code}/claim',
route_actual: `/invite/${encodeURIComponent(code)}/claim`
}
)
}
throw error
}
}