diff --git a/.github/scripts/sentry-daily-digest.sh b/.github/scripts/sentry-daily-digest.sh index 6e7fa458..5814f011 100644 --- a/.github/scripts/sentry-daily-digest.sh +++ b/.github/scripts/sentry-daily-digest.sh @@ -12,10 +12,11 @@ if [ "$DRY_RUN" != "1" ]; then fi case "$SENTRY_ENV" in - production) LABEL="[WEB·PROD]" ;; - staging) LABEL="[WEB·STG]" ;; - *) LABEL="[WEB·$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]')]" ;; + production) STAGE="PROD" ;; + staging) STAGE="STG" ;; + *) STAGE=$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]') ;; esac +LABEL="[${SENTRY_PLATFORM:-WEB}·$STAGE]" API="https://sentry.io/api/0" WORK=$(mktemp -d) diff --git a/.github/workflows/sentry-daily-digest.yml b/.github/workflows/sentry-daily-digest.yml index 0910edd6..0ce79ef6 100644 --- a/.github/workflows/sentry-daily-digest.yml +++ b/.github/workflows/sentry-daily-digest.yml @@ -24,11 +24,24 @@ jobs: sparse-checkout: .github/scripts persist-credentials: false - - name: Sentry 요약 전송 (허거덩 봇) + - name: 웹 Sentry 요약 전송 (허거덩 봇) env: SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }} SENTRY_ORG: piki-92 SENTRY_PROJECT: piki-web + SENTRY_PLATFORM: WEB + SENTRY_ENV: production + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }} + run: bash .github/scripts/sentry-daily-digest.sh + + - name: 앱 Sentry 요약 전송 (허거덩 봇) + if: ${{ !cancelled() }} + env: + SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }} + SENTRY_ORG: piki-92 + SENTRY_PROJECT: piki-app + SENTRY_PLATFORM: APP SENTRY_ENV: production DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }} diff --git a/apps/web/instrumentation.ts b/apps/web/instrumentation.ts index 21051d4a..d7434563 100644 --- a/apps/web/instrumentation.ts +++ b/apps/web/instrumentation.ts @@ -1,8 +1,12 @@ import * as Sentry from '@sentry/nextjs'; +import { isAxiosError } from 'axios'; export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') await import('./sentry.server.config'); if (process.env.NEXT_RUNTIME === 'edge') await import('./sentry.edge.config'); } -export const onRequestError = Sentry.captureRequestError; +export const onRequestError: typeof Sentry.captureRequestError = (error, request, errorContext) => { + if (isAxiosError(error)) return; + Sentry.captureRequestError(error, request, errorContext); +}; diff --git a/apps/web/src/apis/client.ts b/apps/web/src/apis/client.ts index 6396f10b..2c1edc75 100644 --- a/apps/web/src/apis/client.ts +++ b/apps/web/src/apis/client.ts @@ -8,7 +8,7 @@ import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; import { CLIENT_TYPE } from '@/consts/webBridge'; import type { ApiErrorResponseT } from '@/types/api'; -import { captureError } from '@/utils/captureError'; +import { captureApiError } from '@/utils/captureError'; import { deleteCookie, getCookie } from '@/utils/cookie'; import { handleSessionExpired } from '@/utils/handleSessionExpired'; import { getLoginPath } from '@/utils/loginRedirect'; @@ -82,31 +82,7 @@ clientApi.interceptors.response.use( return Promise.reject(error); } - /** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */ - const status = error.response?.status; - const shouldReport = - error.code !== 'ERR_CANCELED' && - (error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500)); - - if (shouldReport) { - const method = originalRequest?.method?.toUpperCase() ?? 'UNKNOWN'; - const path = originalRequest?.url?.split('?')[0] ?? 'unknown'; - - /** 디코/대시보드 제목을 알아보기 쉽게 (원본 axios 정보는 extra 유지) */ - const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`); - apiError.name = 'ApiError'; - - captureError(apiError, { - tags: { source: 'api-client' }, - extra: { - url: originalRequest?.url, - method: originalRequest?.method, - status, - code: error.code, - apiCode: error.response?.data?.code, - }, - }); - } + captureApiError(error, 'api-client'); return Promise.reject(error); } diff --git a/apps/web/src/apis/server.ts b/apps/web/src/apis/server.ts index f31a7c0a..2207a6f3 100644 --- a/apps/web/src/apis/server.ts +++ b/apps/web/src/apis/server.ts @@ -6,7 +6,7 @@ import { ENDPOINTS } from '@/consts/api'; import { QUERY_ACTION } from '@/consts/queryAction'; import { CLIENT_TYPE } from '@/consts/webBridge'; import type { ApiErrorResponseT } from '@/types/api'; -import { captureError } from '@/utils/captureError'; +import { captureApiError } from '@/utils/captureError'; import { getLoginPath } from '@/utils/loginRedirect'; import { isWebview } from '@/utils/webBridge'; @@ -59,30 +59,7 @@ serverApi.interceptors.response.use( redirect(getLoginPath(null, QUERY_ACTION.VALUE.WITHDRAWN_ACCOUNT)); } - /** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */ - const status = error.response?.status; - const shouldReport = - error.code !== 'ERR_CANCELED' && - (error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500)); - - if (shouldReport) { - const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; - const path = error.config?.url?.split('?')[0] ?? 'unknown'; - - const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`); - apiError.name = 'ApiError'; - - captureError(apiError, { - tags: { source: 'api-server' }, - extra: { - url: error.config?.url, - method: error.config?.method, - status, - code: error.code, - apiCode: error.response?.data?.code, - }, - }); - } + captureApiError(error, 'api-server'); return Promise.reject(error); } diff --git a/apps/web/src/app/error.tsx b/apps/web/src/app/error.tsx index c563c821..3a410b4a 100644 --- a/apps/web/src/app/error.tsx +++ b/apps/web/src/app/error.tsx @@ -1,6 +1,5 @@ 'use client'; -import * as Sentry from '@sentry/nextjs'; import Link from 'next/link'; import { useEffect } from 'react'; @@ -8,6 +7,7 @@ import { WarningIconFill } from '@/assets/icons'; import { buttonStyles } from '@/components/button/button.style'; import ErrorScreen from '@/components/common/error-screen'; import { ROUTES } from '@/consts/route'; +import { captureNonApiError } from '@/utils/captureError'; import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; type Props = { @@ -17,7 +17,7 @@ type Props = { function Error({ error, reset }: Props) { useEffect(() => { - Sentry.captureException(error); + captureNonApiError(error); }, [error]); const description = getApiErrorMessage(error); diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx index b2dd8e52..eb07ae99 100644 --- a/apps/web/src/app/global-error.tsx +++ b/apps/web/src/app/global-error.tsx @@ -1,6 +1,5 @@ 'use client'; -import * as Sentry from '@sentry/nextjs'; import Link from 'next/link'; import { useEffect } from 'react'; @@ -8,6 +7,7 @@ import { WarningIconFill } from '@/assets/icons'; import { buttonStyles } from '@/components/button/button.style'; import { ROUTES } from '@/consts/route'; import '@/styles/globals.css'; +import { captureNonApiError } from '@/utils/captureError'; type Props = { error: Error & { digest?: string }; @@ -16,7 +16,7 @@ type Props = { export default function GlobalError({ error, reset }: Props) { useEffect(() => { - Sentry.captureException(error); + captureNonApiError(error); }, [error]); return ( @@ -28,7 +28,7 @@ export default function GlobalError({ error, reset }: Props) {

오류가 발생했어요

-

+

잠시 후 다시 시도해 주세요

diff --git a/apps/web/src/hooks/useFcmTokenSync.ts b/apps/web/src/hooks/useFcmTokenSync.ts index 8cbdab0f..306a17bd 100644 --- a/apps/web/src/hooks/useFcmTokenSync.ts +++ b/apps/web/src/hooks/useFcmTokenSync.ts @@ -5,7 +5,7 @@ import { useCallback, useEffect } from 'react'; import { postFcmToken } from '@/apis/postFcmToken'; import { useWebBridgeMessage } from '@/hooks/useWebBridgeMessage'; -import { captureError } from '@/utils/captureError'; +import { captureNonApiError } from '@/utils/captureError'; import { getCookie } from '@/utils/cookie'; import { WebBridge, isWebview as isWebviewFn } from '@/utils/webBridge'; @@ -24,15 +24,11 @@ export const useFcmTokenSync = () => { message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_PUSH_PERMISSION_STATUS && message.payload.token ) { - postFcmToken(message.payload.token, message.payload.deviceId).catch(error => - captureError(error, { tags: { source: 'fcm-token-sync' } }) - ); + postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError); } if (message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_FCM_TOKEN) { - postFcmToken(message.payload.token, message.payload.deviceId).catch(error => - captureError(error, { tags: { source: 'fcm-token-sync' } }) - ); + postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError); } }, []) ); diff --git a/apps/web/src/utils/captureError.test.ts b/apps/web/src/utils/captureError.test.ts new file mode 100644 index 00000000..574748ff --- /dev/null +++ b/apps/web/src/utils/captureError.test.ts @@ -0,0 +1,61 @@ +import * as Sentry from '@sentry/nextjs'; +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; +import { AxiosError } from 'axios'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ApiErrorResponseT } from '@/types/api'; + +import { captureApiError } from './captureError'; + +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); + +const config = { method: 'post', url: '/api/v1/wishes/123?x=1' } as InternalAxiosRequestConfig; + +const axiosError = ({ code, status }: { code?: string; status?: number }) => { + const response = status + ? ({ status, data: { data: null, code: 'WISH-004' } } as AxiosResponse) + : void 0; + + return new AxiosError('failed', code, config, void 0, response); +}; + +const captured = () => vi.mocked(Sentry.captureException).mock.calls; + +describe('captureApiError', () => { + it('5xx 는 메서드·status·경로로 ApiError 를 보낸다', () => { + vi.mocked(Sentry.captureException).mockClear(); + + captureApiError(axiosError({ code: 'ERR_BAD_RESPONSE', status: 500 }), 'api-client'); + + const [error, context] = captured()[0] ?? []; + expect((error as Error).name).toBe('ApiError'); + expect((error as Error).message).toBe('API 500 POST /api/v1/wishes/123'); + expect(context).toMatchObject({ + tags: { source: 'api-client', errorType: 'server' }, + fingerprint: ['api', 'POST', '500', '/api/v1/wishes/:id'], + }); + }); + + it('응답 없는 서버 네트워크 오류는 ERR_NETWORK 가 아니어도 보낸다', () => { + vi.mocked(Sentry.captureException).mockClear(); + + captureApiError(axiosError({ code: 'ECONNREFUSED' }), 'api-server'); + captureApiError(axiosError({ code: 'ECONNABORTED' }), 'api-client'); + + expect(captured().map(([error]) => (error as Error).message)).toEqual([ + 'API ECONNREFUSED POST /api/v1/wishes/123', + 'API ECONNABORTED POST /api/v1/wishes/123', + ]); + expect(captured()[0]?.[1]).toMatchObject({ tags: { errorType: 'network' } }); + }); + + it('4xx 와 취소된 요청은 보내지 않는다', () => { + vi.mocked(Sentry.captureException).mockClear(); + + captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 401 }), 'api-client'); + captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 404 }), 'api-client'); + captureApiError(axiosError({ code: 'ERR_CANCELED' }), 'api-client'); + + expect(captured()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/utils/captureError.ts b/apps/web/src/utils/captureError.ts index 3f9799cd..4b13f9a5 100644 --- a/apps/web/src/utils/captureError.ts +++ b/apps/web/src/utils/captureError.ts @@ -1,10 +1,15 @@ import * as Sentry from '@sentry/nextjs'; +import type { AxiosError } from 'axios'; +import { isAxiosError } from 'axios'; + +import type { ApiErrorResponseT } from '@/types/api'; type CaptureErrorContextT = { /** 검색/그룹핑용 태그 (예: { source: 'api-client' }) */ tags?: Record; /** 디버깅용 부가 정보 (요청 URL, status 등) */ extra?: Record; + fingerprint?: string[]; }; /** 잡은(caught) 에러를 Sentry에 수동 리포트 — 태그/컨텍스트를 일관되게 부착 */ @@ -12,5 +17,44 @@ export const captureError = (error: unknown, context?: CaptureErrorContextT) => Sentry.captureException(error, { tags: context?.tags, extra: context?.extra, + fingerprint: context?.fingerprint, + }); +}; + +const isServerOriginError = (error: unknown) => + error instanceof Error && 'digest' in error && typeof error.digest === 'string'; + +/** API(axios) 에러는 인터셉터(apis/client.ts·server.ts)가 5xx·네트워크만 단독 수집 — 여기선 그 외 에러만 보낸다 */ +export const captureNonApiError = (error: unknown) => { + if (isAxiosError(error) || isServerOriginError(error)) return; + Sentry.captureException(error); +}; + +/** fingerprint 용 — 리소스 ID 가 달라도 같은 엔드포인트면 한 이슈로 묶는다 */ +export const normalizeApiPath = (path: string) => + path.replace(/\/\d+(?=\/|$)/g, '/:id').replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}(?=\/|$)/gi, '/:id'); + +/** 5xx 와 응답 없는 네트워크 오류만 수집 */ +export const captureApiError = (error: AxiosError, source: string) => { + const status = error.response?.status; + if (error.code === 'ERR_CANCELED' || (typeof status === 'number' && status < 500)) return; + + const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; + const path = error.config?.url?.split('?')[0] ?? 'unknown'; + const reason = String(status ?? error.code ?? 'NETWORK'); + + const apiError = new Error(`API ${reason} ${method} ${path}`); + apiError.name = 'ApiError'; + + captureError(apiError, { + tags: { source, errorType: status ? 'server' : 'network' }, + fingerprint: ['api', method, reason, normalizeApiPath(path)], + extra: { + url: error.config?.url, + method: error.config?.method, + status, + code: error.code, + apiCode: error.response?.data?.code, + }, }); }; diff --git a/apps/web/src/utils/queryClient.ts b/apps/web/src/utils/queryClient.ts index 233fdc5f..59d8c62e 100644 --- a/apps/web/src/utils/queryClient.ts +++ b/apps/web/src/utils/queryClient.ts @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/nextjs'; import { MutationCache, QueryCache, @@ -15,13 +14,14 @@ import { isServerOrNetworkError, isWithdrawnAccountError, } from '@/utils/apiError'; +import { captureNonApiError } from '@/utils/captureError'; import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; /** REF: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr */ const makeQueryClient = () => { return new QueryClient({ - /** mutation — 사용자 액션 실패: 토스트 + (Sentry) 로깅 */ + /** mutation — 사용자 액션 실패: 토스트 + (API 외 에러만) Sentry 로깅 */ mutationCache: new MutationCache({ onError: (error, _variables, _context, mutation) => { /** 401·탈퇴 계정은 전역 인터셉터(apis/client.ts)가 refresh·redirect로 처리 → 이중 토스트 방지 */ @@ -30,7 +30,7 @@ const makeQueryClient = () => { /** 5xx·네트워크는 전역이 단독 처리 (개별 onError는 5xx를 건드리지 않음) */ if (isServerOrNetworkError(error)) { toast.error(getApiErrorMessage(error)); - Sentry.captureException(error); + captureNonApiError(error); return; } @@ -39,11 +39,9 @@ const makeQueryClient = () => { toast.error(getApiErrorMessage(error)); }, }), - /** query — 데이터 fetch 실패: 토스트 X, 로깅만 (표시는 boundary/isError가 담당) */ + /** query — 데이터 fetch 실패: 토스트 X, API 외 에러만 로깅 (표시는 boundary/isError가 담당) */ queryCache: new QueryCache({ - onError: _error => { - Sentry.captureException(_error); - }, + onError: captureNonApiError, }), defaultOptions: { queries: { diff --git a/apps/web/src/utils/refreshClientToken.ts b/apps/web/src/utils/refreshClientToken.ts index df3f3782..04572cf3 100644 --- a/apps/web/src/utils/refreshClientToken.ts +++ b/apps/web/src/utils/refreshClientToken.ts @@ -1,8 +1,9 @@ import { type AuthTokensT, WEBBRIDGE_MESSAGE_TYPE } from '@piki/core'; -import axios from 'axios'; +import axios, { isAxiosError } from 'axios'; import { ENDPOINTS } from '@/consts/api'; import { CLIENT_TYPE } from '@/consts/webBridge'; +import { captureApiError } from '@/utils/captureError'; import { setCookie } from './cookie'; import { WebBridge, isWebview } from './webBridge'; @@ -60,9 +61,14 @@ const performRefresh = async (): Promise => { export const refreshClientToken = (): Promise => { if (inflightRefresh) return inflightRefresh; - inflightRefresh = performRefresh().finally(() => { - inflightRefresh = null; - }); + inflightRefresh = performRefresh() + .catch(error => { + if (isAxiosError(error)) captureApiError(error, 'api-client'); + throw error; + }) + .finally(() => { + inflightRefresh = null; + }); return inflightRefresh; }; diff --git a/docs/spec/error-handling-policy.md b/docs/spec/error-handling-policy.md index b20e2788..ac546db8 100644 --- a/docs/spec/error-handling-policy.md +++ b/docs/spec/error-handling-policy.md @@ -7,7 +7,7 @@ ## TL;DR - **4xx (클라이언트/비즈니스 오류)** → **개별(호출부) 처리.** 원인별로 사용자 행동이 달라지므로 맞춤 UX. -- **5xx · 네트워크 오류** → **전역 처리.** 사용자가 할 수 있는 게 없으므로 공통 "일시적 오류" 토스트 + Sentry. +- **5xx · 네트워크 오류** → **전역 처리.** 사용자가 할 수 있는 게 없으므로 공통 "일시적 오류" 토스트 + Sentry(인터셉터 단독 수집). - **401 (인증 만료)** → **전역 인터셉터**가 토큰 refresh 후 자동 재시도, 실패 시 로그인 redirect. - **인증/권한 게이팅** → **layout 가드(SSR)** 가 진입 시점에 차단. - **조용히 실패 금지** → 개별 onError가 없어도 **전역 안전망**이 최소 토스트를 보장. @@ -19,9 +19,10 @@ ```text ① 전역 인터셉터 apis/client.ts → 401 자동 refresh / 재시도 / 로그인 redirect + → 5xx·네트워크 Sentry 수집 (API 에러의 유일한 수집 창구) ② Layout 가드 app/**/layout.tsx (SSR) → 진입 시점 인증·권한 차단 (getMe 등) ③ 전역 MutationCache onError utils/queryClient.ts - → 5xx·네트워크 공통 처리 (+ Sentry) + → 5xx·네트워크 공통 토스트 → 4xx인데 개별 처리 없으면 generic 토스트 ④ 개별 onError 각 use*Mutation 훅 → 4xx 맞춤 UX (토스트/이동/롤백/전용 다이얼로그) ⑤ Error boundary app/error.tsx / global-error.tsx @@ -44,7 +45,7 @@ | **404** | 리소스 없음 | **layout ② or 개별 ④** | 진입 게이팅은 layout · 액션 실패는 개별 | | **409** | 충돌(만료/중복/이미 시작) | **개별** | 전용 다이얼로그/안내 (원인별 UX가 갈리면 `code` 2차 분기 — 아래 참고) | | **413** | 용량 초과 | **개별 (+ 사전검증)** | 업로드 전 클라 용량 체크 우선 | -| **5xx · 네트워크** | 서버/통신 오류 | **전역 MutationCache ③** | 공통 "일시적 오류" 토스트 + Sentry | +| **5xx · 네트워크** | 서버/통신 오류 | **전역 MutationCache ③** | 공통 "일시적 오류" 토스트 (Sentry 는 인터셉터 ①) | --- @@ -76,7 +77,7 @@ ### ③ 5xx · 네트워크 — 전역 MutationCache onError (`utils/queryClient.ts`) - **5xx(500/502/503…)와 네트워크 오류(`error.response` 없음)는 전역이 단독으로 처리한다.** - - `getApiErrorMessage` 토스트 + `Sentry.captureException` + - `getApiErrorMessage` 토스트. Sentry 수집은 인터셉터가 하므로 여기선 API 외 에러만 `captureNonApiError` 로 보낸다 - 5xx도 문구는 code로 갈린다 — `COMMON-RETRYABLE`(502) / `COMMON-SERVER-BUSY`(503) / `COMMON-SERVER-ERROR`(500). 에러 바운더리(`app/error.tsx`)도 같은 카탈로그를 쓴다 (code 없으면 `SERVER_ERROR_MESSAGE`) - **개별 onError는 5xx를 건드리지 않는다** (아래 개발 규칙 참고) → 토스트 중복 방지 @@ -121,7 +122,7 @@ | ---------------------------------------------- | ------------------------------------------------------------- | --------------- | | **suspense query** (`useSuspenseQuery` 등) | throw → `app/error.tsx` 에러바운더리 (이미 있음) | 추가 작업 없음 | | **일반 query** (`useQuery`/`useInfiniteQuery`) | 컴포넌트에서 **`isError` 분기** → "불러오기 실패 + 재시도" UI | 해당 컴포넌트만 | -| **전역 `QueryCache.onError`** | **Sentry 로깅만** (토스트 X) | 설정 1곳 | +| **전역 `QueryCache.onError`** | **API 외 에러만 Sentry 로깅** (토스트 X) | 설정 1곳 | - 일반 query는 **"빈 상태(데이터 없음)"와 "불러오기 실패"를 반드시 구분**해 표시할 것 (예: 알림 목록) - 예외: **react-query 밖 수동 재조회**(예: `useTournament` 라운드 동기화)는 배경 refetch 스팸이 없고 재시도 액션이 유일한 복구 수단이라 토스트 허용 @@ -286,11 +287,12 @@ import { isServerOrNetworkError, isWithdrawnAccountError, } from '@/utils/apiError'; +import { captureNonApiError } from '@/utils/captureError'; import { getApiErrorMessage } from '@/utils/getApiErrorMessage'; const makeQueryClient = () => new QueryClient({ - // mutation — 사용자 액션 실패: 토스트 + 로깅 + // mutation — 사용자 액션 실패: 토스트 + (API 외 에러만) 로깅 mutationCache: new MutationCache({ onError: (error, _vars, _ctx, mutation) => { // 401·탈퇴 계정은 인터셉터가 refresh·redirect 로 처리 → 이중 토스트 방지 @@ -298,7 +300,7 @@ const makeQueryClient = () => if (isServerOrNetworkError(error)) { toast.error(getApiErrorMessage(error)); - // Sentry.captureException(error); + captureNonApiError(error); // API 5xx 는 인터셉터가 이미 수집 return; // 5xx는 전역 독점 → 개별은 5xx 토스트 금지 } // 4xx: 개별 onError 있으면 양보, 없으면 generic fallback @@ -306,11 +308,9 @@ const makeQueryClient = () => toast.error(getApiErrorMessage(error)); }, }), - // query — 데이터 fetch 실패: 토스트 X, 로깅만 (표시는 boundary/isError) + // query — 데이터 fetch 실패: 토스트 X, API 외 에러만 로깅 (표시는 boundary/isError) queryCache: new QueryCache({ - onError: _error => { - // Sentry.captureException(_error); // 로깅만! 토스트 금지 - }, + onError: captureNonApiError, }), defaultOptions: { queries: { retry: 1, staleTime: 60_000, refetchOnWindowFocus: false }, @@ -323,12 +323,29 @@ const makeQueryClient = () => --- +## Sentry 수집 기준 + +한 에러는 한 곳에서만 수집한다. 여러 곳에서 보내면 제목이 다른 이슈로 갈라져 횟수가 이중 집계된다. + +| 에러 | 수집 지점 | Sentry 제목 예시 | +| ------------------------------------------ | ------------------------------------------------------------------------------------- | ----------------------------- | +| API 5xx · 응답 없는 네트워크 오류 | `captureApiError` — 인터셉터 ① (`apis/client.ts` · `apis/server.ts`) · 토큰 갱신 실패 | `ApiError: API 500 GET /경로` | +| API 4xx | 수집 안 함 — 화면에서 처리 (토스트·이동·인라인) | 없음 | +| API 외 에러 (렌더 오류, S3 업로드 실패 등) | `captureNonApiError` — QueryCache·MutationCache·에러 바운더리 ⑤ | 원래 에러 이름 | +| 서버(RSC·SSR)에서 난 API 외 에러 | `onRequestError` (`instrumentation.ts`) — axios 에러는 건너뜀 | 원래 에러 이름 | + +서버에서 난 에러는 Next 가 직렬화해 `digest` 를 붙여 브라우저 에러 바운더리로 넘긴다. axios 에러인지 알 수 없는 형태라 `captureNonApiError` 는 `digest` 가 있으면 건너뛴다 — 서버 `onRequestError` 가 이미 수집했기 때문이다. + +인터셉터는 모든 API 에러를 같은 줄에서 만들어 스택 트레이스가 같다. 그래서 `fingerprint: ['api', method, status, 정규화 경로]` 를 지정해 엔드포인트·status 별로 이슈를 나눈다. 경로의 숫자·UUID 세그먼트는 `:id` 로 바꿔 리소스 ID 별로 갈라지지 않게 한다. `errorType` 태그(`server` · `network`)로 서버 장애와 네트워크 단절을 구분해 검색한다. + +--- + ## "도달 불가" 케이스는 어떻게? 정상 UI 흐름에선 발생할 수 없는 status(예: 남의 위시 403)는: - **개별 전용 UX를 만들지 않는다** (죽은 코드 방지) -- 대신 **전역 안전망 + Sentry**로 덮어 "조용히 실패/크래시"만 막는다 -- 만약 Sentry에 실제로 찍히면 → "불가능하다던 가정이 깨졌다"는 신호로 삼아 원인 추적 +- 대신 **전역 안전망**(generic 토스트)으로 덮어 "조용히 실패/크래시"만 막는다 +- 4xx 는 Sentry 수집 대상이 아니다. 가정이 깨졌는지 꼭 알아야 하는 지점이면 그 자리에서 `captureError(error, { tags: { source: '...' } })` 로 명시적으로 보고한다 원칙 한 줄: **명시적 처리는 "도달 가능 + 사용자 행동이 바뀌는 것"만. 그 외 전부는 전역 안전망으로 "조용히 실패 금지".**