From 33b6df55a80a302569865f1d2d0b525e327822d2 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 19 Sep 2025 03:39:23 +0900 Subject: [PATCH] feat: Add Sentry error tracking to auth API functions (#5623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/api/auth.ts | 375 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 322 insertions(+), 53 deletions(-) diff --git a/src/api/auth.ts b/src/api/auth.ts index a46417a491..2eafdd0cfc 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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 { - 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 +) { + const tags: Record = { + 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 { + 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 { + 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 { - 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 { - 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 ): Promise { - 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 { - 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 } }