-
Notifications
You must be signed in to change notification settings - Fork 2
[FEAT] Sentry 기반 에러 모니터링 환경 구성 #439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
1abf075
751f55a
83f506d
85cbf15
ef0dde5
b26c44d
7530fdb
d4ad68b
a9e7356
0c4d044
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,5 @@ | ||||||||||||||||||
| import axios from 'axios'; | ||||||||||||||||||
| import * as Sentry from '@sentry/react'; | ||||||||||||||||||
| import { | ||||||||||||||||||
| getAccessToken, | ||||||||||||||||||
| removeAccessToken, | ||||||||||||||||||
|
|
@@ -13,12 +14,17 @@ import { | |||||||||||||||||
|
|
||||||||||||||||||
| // Get current mode (DEV, PROD or TEST) | ||||||||||||||||||
| const currentMode = import.meta.env.MODE; | ||||||||||||||||||
| const requestTimeoutMs = 5000; | ||||||||||||||||||
|
|
||||||||||||||||||
| type SentryCapturedError = { | ||||||||||||||||||
| __sentry_captured__?: boolean; | ||||||||||||||||||
| }; | ||||||||||||||||||
|
|
||||||||||||||||||
| // Axios instance | ||||||||||||||||||
| export const axiosInstance = axios.create({ | ||||||||||||||||||
| baseURL: | ||||||||||||||||||
| currentMode === 'test' ? undefined : import.meta.env.VITE_API_BASE_URL, | ||||||||||||||||||
| timeout: 5000, | ||||||||||||||||||
| timeout: requestTimeoutMs, | ||||||||||||||||||
| timeoutErrorMessage: | ||||||||||||||||||
| '시간 초과로 인해 요청을 처리하지 못했어요... 잠시 후 다시 시도해 주세요.', | ||||||||||||||||||
| headers: { | ||||||||||||||||||
|
|
@@ -38,6 +44,39 @@ axiosInstance.interceptors.request.use((config) => { | |||||||||||||||||
| return config; | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| function captureClientApiError(error: unknown) { | ||||||||||||||||||
| if (!axios.isAxiosError(error)) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const { response, config, code } = error; | ||||||||||||||||||
| const status = response?.status; | ||||||||||||||||||
|
|
||||||||||||||||||
| // 401 재발급 흐름과 정상/리다이렉트 응답만 제외하고, 4xx/5xx/네트워크 실패/타임아웃은 수집 | ||||||||||||||||||
| if (status === 401 || (status !== undefined && status < 400)) { | ||||||||||||||||||
| return; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+55
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PR 설명에서는 5xx 에러와 네트워크 에러/타임아웃을 수집 범위에서 제외한다고 하셨으나, 현재 구현된 로직은
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| Sentry.captureException(error, { | ||||||||||||||||||
| tags: { | ||||||||||||||||||
| errorType: 'api-error', | ||||||||||||||||||
| httpStatus: status ? String(status) : 'network-error', | ||||||||||||||||||
| }, | ||||||||||||||||||
| extra: { | ||||||||||||||||||
| pathname: window.location.pathname, | ||||||||||||||||||
| search: window.location.search, | ||||||||||||||||||
| url: config?.url, | ||||||||||||||||||
| method: config?.method, | ||||||||||||||||||
| baseURL: config?.baseURL, | ||||||||||||||||||
| params: config?.params, | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||
| timeout: config?.timeout ?? requestTimeoutMs, | ||||||||||||||||||
| errorCode: code, | ||||||||||||||||||
|
Comment on lines
+65
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Line 67의 최소한의 메타데이터만 남기는 예시 Sentry.captureException(error, {
tags: {
errorType: 'api-error',
httpStatus: status ? String(status) : 'network-error',
},
extra: {
pathname: window.location.pathname,
- search: window.location.search,
url: config?.url,
method: config?.method,
baseURL: config?.baseURL,
- params: config?.params,
timeout: config?.timeout ?? requestTimeoutMs,
errorCode: code,
},
});🤖 Prompt for AI Agents |
||||||||||||||||||
| }, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| (error as SentryCapturedError).__sentry_captured__ = true; | ||||||||||||||||||
| } | ||||||||||||||||||
useon marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| // Response interceptor | ||||||||||||||||||
| axiosInstance.interceptors.response.use( | ||||||||||||||||||
| (response) => response, | ||||||||||||||||||
|
|
@@ -70,15 +109,27 @@ axiosInstance.interceptors.response.use( | |||||||||||||||||
| originalRequest.headers.Authorization = `${newAccessToken}`; | ||||||||||||||||||
| return axiosInstance(originalRequest); | ||||||||||||||||||
| } catch (refreshError) { | ||||||||||||||||||
| if ( | ||||||||||||||||||
| axios.isAxiosError(refreshError) && | ||||||||||||||||||
| refreshError.response?.status !== 401 && | ||||||||||||||||||
| refreshError.response?.status !== 403 | ||||||||||||||||||
| ) { | ||||||||||||||||||
| captureClientApiError(refreshError); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| console.error('Refresh Token is invalid or expired', refreshError); | ||||||||||||||||||
| // 재발급도 실패하면 -> 로그인 페이지 이동 | ||||||||||||||||||
| const currentLang = i18n.resolvedLanguage ?? i18n.language; | ||||||||||||||||||
| const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG; | ||||||||||||||||||
| Sentry.setUser(null); | ||||||||||||||||||
| window.location.href = buildLangPath('/home', lang); | ||||||||||||||||||
| removeAccessToken(); | ||||||||||||||||||
| return Promise.reject(refreshError); | ||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [이슈 2] 토큰 만료로 인한 자동 로그아웃 시 Sentry user context가 정리되지 않습니다 refresh token 재발급 실패 시
해결 방안 (A) — 해당 위치에서 직접 처리 } catch (refreshError) {
console.error('Refresh Token is invalid or expired', refreshError);
const currentLang = i18n.resolvedLanguage ?? i18n.language;
const lang = isSupportedLang(currentLang) ? currentLang : DEFAULT_LANG;
window.location.href = buildLangPath('/home', lang);
removeAccessToken();
Sentry.setUser(null); // ← 추가: 세션 만료 시에도 user context 정리
return Promise.reject(refreshError);
}해결 방안 (B) —
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이 부분이 빠졌었네요. 제안해 주신 방안들 중 A안으로 간단하게 작업 완료했습니다! |
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| captureClientApiError(error); | ||||||||||||||||||
|
|
||||||||||||||||||
| return Promise.reject(error); | ||||||||||||||||||
| }, | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; | |
| export class APIError extends Error { | ||
| public readonly status: number; | ||
| public readonly data: unknown; | ||
| public __sentry_captured__?: boolean; | ||
|
|
||
| constructor(message: string, status: number, data: unknown) { | ||
| super(message); | ||
|
|
@@ -18,6 +19,10 @@ export class APIError extends Error { | |
| } | ||
| } | ||
|
|
||
| type SentryCapturedError = { | ||
| __sentry_captured__?: boolean; | ||
| }; | ||
|
Comment on lines
+22
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| // Low-level http request function | ||
| export async function request<T>( | ||
| method: HttpMethod, | ||
|
|
@@ -54,6 +59,9 @@ export async function request<T>( | |
| error.response?.status || 500, | ||
| responseData, | ||
| ); | ||
| apiError.__sentry_captured__ = ( | ||
| error as SentryCapturedError | ||
| ).__sentry_captured__; | ||
| throw apiError; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import * as Sentry from '@sentry/react'; | ||
|
|
||
| const dsn = import.meta.env.VITE_SENTRY_DSN; | ||
|
|
||
| if (import.meta.env.PROD && dsn) { | ||
| Sentry.init({ | ||
| dsn, | ||
| environment: import.meta.env.MODE, | ||
| integrations: [ | ||
| // 페이지 로드와 라우트 이동 등의 성능 흐름 추적 | ||
| Sentry.browserTracingIntegration(), | ||
| // 에러가 발생한 세션만 Replay로 남기고, 개인 정보 가림 | ||
| Sentry.replayIntegration({ | ||
| maskAllText: true, | ||
| blockAllMedia: true, | ||
| }), | ||
| ], | ||
| // 백엔드는 Datadog를 사용 중이므로, 프론트 단독 성능 추적만 낮은 비율로 수집 | ||
| tracesSampleRate: 0.1, | ||
| // 일반 세션 Replay는 수집 X | ||
| replaysSessionSampleRate: 0, | ||
| // 에러가 발생한 세션은 모두 Replay로 남김 | ||
| replaysOnErrorSampleRate: 1.0, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
4xx만 수집하려는 범위라면 이 필터는 너무 넓습니다.
Line 56 조건은
401과<400만 제외해서500/503같은 5xx와error.response가 없는 timeout/offline도 그대로 전송합니다. 이 함수가 Line 117과 Line 131에서 공통으로 재사용되기 때문에 실제 수집 범위가 많이 넓어집니다. 4xx만 보려는 의도라면status >= 400 && status < 500 && status !== 401처럼 양성 조건으로 좁히는 편이 안전합니다.수집 범위를 4xx로 제한하는 예시
📝 Committable suggestion
🤖 Prompt for AI Agents