From 0c121ac370afc30270591f12c7f8a22c9667cf03 Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 22 Jul 2026 21:57:32 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Sentry=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=ED=8C=90=EB=8B=A8=20=EA=B8=B0=EC=A4=80=20=EC=9C=A0=ED=8B=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/sentry.test.ts | 95 ++++++++++++++++++++ src/util/sentry.ts | 193 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 src/util/sentry.test.ts create mode 100644 src/util/sentry.ts diff --git a/src/util/sentry.test.ts b/src/util/sentry.test.ts new file mode 100644 index 00000000..e397c798 --- /dev/null +++ b/src/util/sentry.test.ts @@ -0,0 +1,95 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import { + buildSentryApiErrorMetadata, + createSentryApiError, + normalizeEndpoint, + resolveApiErrorLevel, + resolveFeatureFromPathname, + shouldSkipApiError, +} from './sentry'; + +describe('sentry 유틸', () => { + it('같은 API 장애가 하나의 알림 이슈로 묶이도록 가변 경로 값을 제거한다', () => { + expect(normalizeEndpoint('/api/tables/123/votes/456?tab=1')).toBe( + '/api/tables/:id/votes/:id', + ); + expect( + normalizeEndpoint( + 'https://example.com/api/tables/9e770d72-7a5d-4fd7-8c4e-7cf6115a1f6b', + ), + ).toBe('/api/tables/:uuid'); + }); + + it('알림만 보고 사용자에게 영향받은 기능을 판단할 수 있도록 pathname을 기능 단위로 분류한다', () => { + expect(resolveFeatureFromPathname('/ko/home')).toBe('landing'); + expect(resolveFeatureFromPathname('/en/table/customize/1')).toBe('timer'); + expect(resolveFeatureFromPathname('/table/customize/1/end')).toBe( + 'debate-end', + ); + expect(resolveFeatureFromPathname('/table/customize/1/end/vote/2')).toBe( + 'vote', + ); + expect(resolveFeatureFromPathname('/live/1')).toBe('live-share'); + expect(resolveFeatureFromPathname('/oauth')).toBe('auth'); + }); + + it('타이머, 투표, 실시간 공유의 5xx는 핵심 흐름 장애로 보고 즉시 대응 대상으로 분류한다', () => { + expect(resolveApiErrorLevel(500, 'timer')).toBe('fatal'); + expect(resolveApiErrorLevel(500, 'vote')).toBe('fatal'); + expect(resolveApiErrorLevel(500, 'live-share')).toBe('fatal'); + expect(resolveApiErrorLevel(500, 'landing')).toBe('error'); + }); + + it('사용자 입력이나 상태 충돌처럼 예상 가능한 에러는 즉시 대응 알림보다 낮은 우선순위로 분류한다', () => { + expect(resolveApiErrorLevel(400, 'timer')).toBe('warning'); + expect(resolveApiErrorLevel(404, 'vote')).toBe('warning'); + expect(resolveApiErrorLevel(409, 'live-share')).toBe('warning'); + expect(resolveApiErrorLevel(422, 'table-composition')).toBe('warning'); + }); + + it('토큰 재발급으로 복구될 수 있는 401은 즉시 대응 알림에서 제외한다', () => { + const unauthorizedError = new AxiosError( + 'unauthorized', + undefined, + undefined, + undefined, + { + status: 401, + statusText: 'Unauthorized', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: null, + }, + ); + expect(shouldSkipApiError(unauthorizedError)).toBe(true); + }); + + it('사용자 오프라인 상태의 네트워크 에러는 운영자가 바로 대응하기 어려워 수집하지 않는다', () => { + const offlineError = new AxiosError('Network Error', 'ERR_NETWORK'); + + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + + expect(shouldSkipApiError(offlineError)).toBe(true); + }); + + it('온라인 상태에서 발생한 네트워크 에러는 서버 연결 실패 가능성이 있어 수집한다', () => { + const onlineNetworkError = new AxiosError('Network Error', 'ERR_NETWORK'); + + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + + expect(shouldSkipApiError(onlineNetworkError)).toBe(false); + }); + + it('알림 제목만 보고 실패한 요청을 파악할 수 있도록 상태 코드, method, endpoint를 담는다', () => { + const error = new AxiosError('Request failed', undefined, { + method: 'post', + url: '/api/live/123', + headers: new AxiosHeaders(), + }); + const metadata = buildSentryApiErrorMetadata(error, '/ko/live/123'); + + const sentryError = createSentryApiError(error, metadata); + + expect(sentryError.name).toBe('[network-error] POST /api/live/:id'); + }); +}); diff --git a/src/util/sentry.ts b/src/util/sentry.ts new file mode 100644 index 00000000..d5cab8d0 --- /dev/null +++ b/src/util/sentry.ts @@ -0,0 +1,193 @@ +import { AxiosError } from 'axios'; +import type { SeverityLevel } from '@sentry/react'; + +export type DebateFeature = + | 'landing' + | 'table-list' + | 'table-composition' + | 'table-overview' + | 'timer' + | 'debate-end' + | 'vote' + | 'auth' + | 'share' + | 'live-share' + | 'unknown'; + +const criticalFeatures: DebateFeature[] = ['timer', 'vote', 'live-share']; + +type SentryCapturedError = { + __sentry_captured__?: boolean; +}; + +export type SentryApiErrorMetadata = { + status: number | undefined; + statusLabel: string; + method: string; + endpoint: string; + feature: DebateFeature; + level: SeverityLevel; + pathname: string; +}; + +export function normalizeEndpoint(url?: string) { + if (!url) { + return 'unknown'; + } + + const path = resolveUrlPath(url); + + return path + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, + ':uuid', + ) + .replace(/\/[0-9]+(?=\/|$)/g, '/:id'); +} + +export function resolveFeatureFromPathname(pathname: string): DebateFeature { + const path = removeLanguagePrefix(pathname); + + if (path === '/home') { + return 'landing'; + } + + if (path === '/') { + return 'table-list'; + } + + if (path.startsWith('/composition')) { + return 'table-composition'; + } + + if (path.startsWith('/overview')) { + return 'table-overview'; + } + + if (path.includes('/end/vote') || path.startsWith('/vote')) { + return 'vote'; + } + + if (path.includes('/end/feedback')) { + return 'timer'; + } + + if (path.includes('/end')) { + return 'debate-end'; + } + + if (path.startsWith('/table/customize')) { + return 'timer'; + } + + if (path.startsWith('/oauth')) { + return 'auth'; + } + + if (path.startsWith('/share')) { + return 'share'; + } + + if (path.startsWith('/live')) { + return 'live-share'; + } + + return 'unknown'; +} + +export function resolveApiErrorLevel( + status: number | undefined, + feature: DebateFeature, +): SeverityLevel { + if (status === 400 || status === 404 || status === 409 || status === 422) { + return 'warning'; + } + + if ( + status !== undefined && + status >= 500 && + criticalFeatures.includes(feature) + ) { + return 'fatal'; + } + + return 'error'; +} + +export function shouldSkipApiError(error: AxiosError) { + const status = error.response?.status; + + if (status === 401 || (status !== undefined && status < 400)) { + return true; + } + + return isOfflineNetworkError(error.code); +} + +export function buildSentryApiErrorMetadata( + error: AxiosError, + pathname: string, +): SentryApiErrorMetadata { + const status = error.response?.status; + const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN'; + const endpoint = normalizeEndpoint(error.config?.url); + const feature = resolveFeatureFromPathname(pathname); + + return { + status, + statusLabel: status ? String(status) : 'network-error', + method, + endpoint, + feature, + level: resolveApiErrorLevel(status, feature), + pathname, + }; +} + +export function createSentryApiError( + error: AxiosError, + metadata: SentryApiErrorMetadata, +) { + const sentryError = new Error(error.message); + sentryError.name = `[${metadata.statusLabel}] ${metadata.method} ${metadata.endpoint}`; + sentryError.stack = error.stack; + (sentryError as SentryCapturedError).__sentry_captured__ = true; + + return sentryError; +} + +export function markSentryCaptured(error: unknown) { + if (typeof error === 'object' && error !== null) { + (error as SentryCapturedError).__sentry_captured__ = true; + } +} + +export function isSentryCaptured(error: unknown) { + return ( + typeof error === 'object' && + error !== null && + (error as SentryCapturedError).__sentry_captured__ === true + ); +} + +function resolveUrlPath(url: string) { + try { + return new URL(url, window.location.origin).pathname; + } catch { + return url.split('?')[0] || 'unknown'; + } +} + +function removeLanguagePrefix(pathname: string) { + const path = pathname.replace(/^\/(ko|en)(?=\/|$)/, ''); + + return path === '' ? '/' : path; +} + +function isOfflineNetworkError(code?: string) { + return ( + typeof navigator !== 'undefined' && + navigator.onLine === false && + (code === 'ERR_NETWORK' || code === 'ECONNABORTED' || code === 'ETIMEDOUT') + ); +} From b42be4514f6951bf1ee6963914b79e7f693a625a Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 22 Jul 2026 22:05:47 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20API=20=EC=97=90=EB=9F=AC=20Sentry?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EB=A7=A5=EB=9D=BD=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/axiosInstance.ts | 93 +++++++++++++++------------------------ src/apis/primitives.ts | 9 +--- 2 files changed, 38 insertions(+), 64 deletions(-) diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index 6c8d64f0..e929449e 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -1,6 +1,5 @@ import axios from 'axios'; import * as Sentry from '@sentry/react'; -import type { SeverityLevel } from '@sentry/react'; import { getAccessToken, removeAccessToken, @@ -14,38 +13,17 @@ import { isSupportedLang, } from '../util/languageRouting'; import { analyticsManager } from '../util/analytics'; +import { + buildSentryApiErrorMetadata, + createSentryApiError, + markSentryCaptured, + shouldSkipApiError, +} from '../util/sentry'; // Get current mode (DEV, PROD or TEST) const currentMode = import.meta.env.MODE; const requestTimeoutMs = 5000; -type SentryCapturedError = { - __sentry_captured__?: boolean; -}; - -export function normalizeEndpoint(url?: string) { - if (!url) { - return 'unknown'; - } - - return url - .replace( - /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, - ':uuid', - ) - .replace(/[0-9]+/g, ':id'); -} - -function resolveApiErrorLevel(status: number | undefined) { - // 400/409/422는 사용자 입력·요청 상태 충돌 성격이 커서 warning으로 분리 - if (status === 400 || status === 409 || status === 422) { - return 'warning'; - } - - // 그 외(주로 5xx/네트워크 실패)는 운영 대응이 필요한 장애 신호로 처리 - return 'error'; -} - // Axios instance export const axiosInstance = axios.create({ baseURL: @@ -75,44 +53,45 @@ function captureClientApiError(error: unknown) { return; } - const { response, config, code } = error; - const status = response?.status; - const normalizedUrl = normalizeEndpoint(config?.url); - const requestMethod = config?.method?.toUpperCase() ?? 'UNKNOWN'; - - // 401은 토큰 재발급 후 원요청 재시도로 자동 복구되는 정상 인증 흐름이므로 수집에서 제외 - // 정상/리다이렉트 응답(<400)도 제외하고, 그 외 4xx/5xx/네트워크 실패/타임아웃은 수집 - if (status === 401 || (status !== undefined && status < 400)) { + if (shouldSkipApiError(error)) { return; } - const level: SeverityLevel = resolveApiErrorLevel(status); + const metadata = buildSentryApiErrorMetadata(error, window.location.pathname); + const sentryError = createSentryApiError(error, metadata); - Sentry.captureException(error, { - level, - tags: { + Sentry.withScope((scope) => { + scope.setLevel(metadata.level); + scope.setTags({ errorType: 'api-error', - httpStatus: status ? String(status) : 'network-error', - endpoint: `${requestMethod} ${normalizedUrl}`, - }, - extra: { - pathname: window.location.pathname, + httpStatus: metadata.statusLabel, + endpoint: `${metadata.method} ${metadata.endpoint}`, + feature: metadata.feature, + }); + scope.setContext('request', { + pathname: metadata.pathname, search: window.location.search, - url: config?.url, - method: config?.method, - params: config?.params, - timeout: config?.timeout ?? requestTimeoutMs, - errorCode: code, - }, - fingerprint: [ + url: error.config?.url, + method: error.config?.method, + params: error.config?.params, + timeout: error.config?.timeout ?? requestTimeoutMs, + errorCode: error.code, + }); + scope.setContext('response', { + status: metadata.status, + data: error.response?.data, + }); + scope.setFingerprint([ 'api-error', - String(status ?? 'network-error'), - requestMethod, - normalizedUrl, - ], + metadata.statusLabel, + metadata.method, + metadata.endpoint, + ]); + + Sentry.captureException(sentryError); }); - (error as SentryCapturedError).__sentry_captured__ = true; + markSentryCaptured(error); } // Response interceptor diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index 8dc4b715..42ee305b 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { AxiosResponse } from 'axios'; import axiosInstance from './axiosInstance'; +import { isSentryCaptured } from '../util/sentry'; // HTTP request methods export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -19,10 +20,6 @@ export class APIError extends Error { } } -type SentryCapturedError = { - __sentry_captured__?: boolean; -}; - // Low-level http request function export async function request( method: HttpMethod, @@ -59,9 +56,7 @@ export async function request( error.response?.status || 500, responseData, ); - apiError.__sentry_captured__ = ( - error as SentryCapturedError - ).__sentry_captured__; + apiError.__sentry_captured__ = isSentryCaptured(error); throw apiError; } From 7d9d167858ad1c530306da73a1ee373ba15b2492 Mon Sep 17 00:00:00 2001 From: useon Date: Wed, 22 Jul 2026 22:07:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20Sentry=20=EB=A7=A5=EB=9D=BD=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ErrorBoundary/ErrorBoundary.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index b3070b29..af503139 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,6 +1,10 @@ import { Component, ErrorInfo, ReactNode } from 'react'; import * as Sentry from '@sentry/react'; import ErrorPage from './ErrorPage'; +import { + isSentryCaptured, + resolveFeatureFromPathname, +} from '../../util/sentry'; interface ErrorBoundaryProps { children: ReactNode; @@ -15,10 +19,6 @@ interface ErrorBoundaryState { const defaultError = new Error('알 수 없는 오류'); const defaultStack = '스택 정보 없음'; -type SentryCapturedError = { - __sentry_captured__?: boolean; -}; - class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); @@ -37,16 +37,23 @@ class ErrorBoundary extends Component { componentDidCatch(error: Error, errorInfo: ErrorInfo): void { // 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송 - if (!(error as SentryCapturedError).__sentry_captured__) { - Sentry.captureException(error, { - tags: { + if (!isSentryCaptured(error)) { + const feature = resolveFeatureFromPathname(window.location.pathname); + + Sentry.withScope((scope) => { + scope.setLevel('fatal'); + scope.setTags({ errorType: 'render-error', - }, - extra: { + feature, + }); + scope.setContext('render', { pathname: window.location.pathname, search: window.location.search, componentStack: errorInfo.componentStack, - }, + }); + scope.setFingerprint(['render-error', feature]); + + Sentry.captureException(error); }); } From 3f76c302f2cfbf5fd4b8e721fbfdb250a9cbb529 Mon Sep 17 00:00:00 2001 From: useon Date: Thu, 23 Jul 2026 18:56:51 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20API=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A0=9C=EB=AA=A9=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=9C=EC=84=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/util/sentry.test.ts | 6 ++++-- src/util/sentry.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/util/sentry.test.ts b/src/util/sentry.test.ts index e397c798..bcf2e136 100644 --- a/src/util/sentry.test.ts +++ b/src/util/sentry.test.ts @@ -80,7 +80,7 @@ describe('sentry 유틸', () => { expect(shouldSkipApiError(onlineNetworkError)).toBe(false); }); - it('알림 제목만 보고 실패한 요청을 파악할 수 있도록 상태 코드, method, endpoint를 담는다', () => { + it('알림 제목을 level, error type, feature, status, endpoint 순서로 구성해 대응 판단 흐름을 만든다', () => { const error = new AxiosError('Request failed', undefined, { method: 'post', url: '/api/live/123', @@ -90,6 +90,8 @@ describe('sentry 유틸', () => { const sentryError = createSentryApiError(error, metadata); - expect(sentryError.name).toBe('[network-error] POST /api/live/:id'); + expect(sentryError.name).toBe( + 'error · api-error · live-share · [network-error] POST /api/live/:id', + ); }); }); diff --git a/src/util/sentry.ts b/src/util/sentry.ts index d5cab8d0..12b2e1fa 100644 --- a/src/util/sentry.ts +++ b/src/util/sentry.ts @@ -149,7 +149,7 @@ export function createSentryApiError( metadata: SentryApiErrorMetadata, ) { const sentryError = new Error(error.message); - sentryError.name = `[${metadata.statusLabel}] ${metadata.method} ${metadata.endpoint}`; + sentryError.name = `${metadata.level} · api-error · ${metadata.feature} · [${metadata.statusLabel}] ${metadata.method} ${metadata.endpoint}`; sentryError.stack = error.stack; (sentryError as SentryCapturedError).__sentry_captured__ = true;