Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
413 changes: 395 additions & 18 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"i18n:transform": "tsx scripts/i18nTransform.ts"
},
"dependencies": {
"@sentry/react": "^10.46.0",
"@tanstack/eslint-plugin-query": "^5.91.4",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
Expand All @@ -44,6 +45,7 @@
"@babel/types": "^7.28.4",
"@chromatic-com/storybook": "^3.2.2",
"@eslint/js": "^9.15.0",
"@sentry/vite-plugin": "^5.1.1",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/addon-interactions": "^8.6.15",
"@storybook/addon-onboarding": "^8.6.15",
Expand Down
5 changes: 5 additions & 0 deletions src/apis/apis/member.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { setAccessToken } from '../../util/accessToken';
import { ApiUrl } from '../endpoints';
import { request } from '../primitives';
Expand Down Expand Up @@ -40,6 +41,10 @@ export async function postUser(code: string): Promise<PostUserResponseType> {
throw new Error('Authorization 헤더가 존재하지 않습니다.');
}

Sentry.setUser({
id: String(response.data.id),
});

return response.data;
}

Expand Down
53 changes: 52 additions & 1 deletion src/apis/axiosInstance.ts
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,
Expand All @@ -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: {
Expand All @@ -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 +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

4xx만 수집하려는 범위라면 이 필터는 너무 넓습니다.

Line 56 조건은 401<400만 제외해서 500/503 같은 5xx와 error.response가 없는 timeout/offline도 그대로 전송합니다. 이 함수가 Line 117과 Line 131에서 공통으로 재사용되기 때문에 실제 수집 범위가 많이 넓어집니다. 4xx만 보려는 의도라면 status >= 400 && status < 500 && status !== 401처럼 양성 조건으로 좁히는 편이 안전합니다.

수집 범위를 4xx로 제한하는 예시
-  if (status === 401 || (status !== undefined && status < 400)) {
+  if (
+    status === undefined ||
+    status === 401 ||
+    status < 400 ||
+    status >= 500
+  ) {
     return;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 401 재발급 흐름과 정상/리다이렉트 응답만 제외하고, 4xx/5xx/네트워크 실패/타임아웃은 수집
if (status === 401 || (status !== undefined && status < 400)) {
return;
// 401 재발급 흐름과 정상/리다이렉트 응답만 제외하고, 4xx/5xx/네트워크 실패/타임아웃은 수집
if (
status === undefined ||
status === 401 ||
status < 400 ||
status >= 500
) {
return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/apis/axiosInstance.ts` around lines 55 - 57, Replace the current broad
exclusion check that uses "status === 401 || (status !== undefined && status <
400)" with a narrow positive filter that only selects 4xx (excluding 401): use
"status !== undefined && status >= 400 && status < 500 && status !== 401". Also
ensure you skip collection when there is no response (e.g., timeout/offline) by
guarding against undefined error.response before inspecting status; update the
condition in axiosInstance.ts where the "status" check is performed so both
reuse sites use the tightened 4xx-only logic.

}
Comment on lines +55 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

PR 설명에서는 5xx 에러와 네트워크 에러/타임아웃을 수집 범위에서 제외한다고 하셨으나, 현재 구현된 로직은 statusundefined인 경우(네트워크 에러)와 500 이상인 경우에도 Sentry로 전송하게 됩니다. 의도하신 대로 4xx 에러(401 제외)만 수집하려면 조건을 수정해야 합니다.

Suggested change
// 401 재발급 흐름과 정상/리다이렉트 응답만 제외하고, 4xx/5xx/네트워크 실패/타임아웃은 수집
if (status === 401 || (status !== undefined && status < 400)) {
return;
}
// 401, 정상 응답, 5xx, 네트워크 에러 등을 제외하고 4xx 에러만 수집
if (status === undefined || status < 400 || status >= 500 || status === 401) {
return;
}


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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

extra 데이터에 config?.params를 포함하면 URL 쿼리 파라미터에 담긴 사용자 입력값 등 민감한 정보(PII)가 Sentry로 전송될 수 있습니다. PR 본문에서 개인정보 범위를 최소화하겠다고 언급하신 만큼, 보안을 위해 params는 제외하거나 필요한 필드만 선택적으로 보내는 것이 안전합니다.

timeout: config?.timeout ?? requestTimeoutMs,
errorCode: code,
Comment on lines +65 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

searchparams를 원문 그대로 보내면 민감 값이 Sentry에 남을 수 있습니다.

Line 67의 window.location.search와 Line 71의 config.params에는 OAuth code, 이메일, 초대 토큰 같은 값이 실릴 수 있습니다. 요청/응답 바디를 뺀 뒤에도 여기로 같은 종류의 데이터가 계속 들어갈 수 있으니, 전체 값을 보내지 말고 allowlist된 키만 남기거나 마스킹하는 편이 안전합니다.

최소한의 메타데이터만 남기는 예시
   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
Verify each finding against the current code and only fix it if needed.

In `@src/apis/axiosInstance.ts` around lines 65 - 73, The extra object in
src/apis/axiosInstance.ts is sending raw window.location.search and
config.params (sensitive values like OAuth codes, emails, tokens); update the
code that builds the extra payload (the extra object used in your
request/response logging) to not include full search or params: instead parse
and either whitelist allowed keys or mask sensitive values before assigning to
extra.search and extra.params (reference the extra object, the pathname/search
fields, and config.params/config.url here), ensuring only non-sensitive metadata
or masked values are logged to Sentry.

},
});

(error as SentryCapturedError).__sentry_captured__ = true;
}

// Response interceptor
axiosInstance.interceptors.response.use(
(response) => response,
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[이슈 2] 토큰 만료로 인한 자동 로그아웃 시 Sentry user context가 정리되지 않습니다

refresh token 재발급 실패 시 removeAccessToken()은 호출되지만 Sentry.setUser(null)은 호출되지 않습니다. 이후 발생하는 에러 이벤트에 이전 사용자의 컨텍스트가 남아 잘못된 사용자에게 에러가 귀속될 수 있습니다.

useLogout.ts에서는 잘 처리하고 있지만, 세션 만료에 의한 강제 로그아웃 경로가 누락되어 있습니다.

해결 방안 (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) — removeAccessToken 유틸리티에 통합

removeAccessToken() 내부에서 Sentry.setUser(null)을 함께 호출하면, 명시적 로그아웃과 세션 만료 두 경로를 한 번에 처리할 수 있어 이후 실수를 방지할 수 있습니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분이 빠졌었네요. 제안해 주신 방안들 중 A안으로 간단하게 작업 완료했습니다!

}
}

captureClientApiError(error);

return Promise.reject(error);
},
);
Expand Down
8 changes: 8 additions & 0 deletions src/apis/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -18,6 +19,10 @@ export class APIError extends Error {
}
}

type SentryCapturedError = {
__sentry_captured__?: boolean;
};
Comment on lines +22 to +24
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

SentryCapturedError 타입 정의가 axiosInstance.ts, primitives.ts, ErrorBoundary.tsx 등 여러 파일에서 중복되고 있습니다. 유지보수를 위해 공통 타입 파일로 분리하여 관리하는 것을 권장합니다.


// Low-level http request function
export async function request<T>(
method: HttpMethod,
Expand Down Expand Up @@ -54,6 +59,9 @@ export async function request<T>(
error.response?.status || 500,
responseData,
);
apiError.__sentry_captured__ = (
error as SentryCapturedError
).__sentry_captured__;
throw apiError;
}

Expand Down
20 changes: 19 additions & 1 deletion src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import ErrorPage from './ErrorPage';

interface ErrorBoundaryProps {
Expand All @@ -14,6 +15,10 @@ interface ErrorBoundaryState {
const defaultError = new Error('알 수 없는 오류');
const defaultStack = '스택 정보 없음';

type SentryCapturedError = {
__sentry_captured__?: boolean;
};

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
Expand All @@ -31,7 +36,20 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// You can also log the error to an error reporting service
// 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송
if (!(error as SentryCapturedError).__sentry_captured__) {
Sentry.captureException(error, {
tags: {
errorType: 'render-error',
},
extra: {
pathname: window.location.pathname,
search: window.location.search,
componentStack: errorInfo.componentStack,
},
});
}

console.log(error, errorInfo);
}

Expand Down
2 changes: 2 additions & 0 deletions src/hooks/mutations/useLogout.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { useMutation } from '@tanstack/react-query';
import { logout } from '../../apis/apis/member';
import { removeAccessToken } from '../../util/accessToken';
Expand All @@ -10,6 +11,7 @@ export default function useLogout(onSuccess: () => void) {
},
onSuccess: () => {
removeAccessToken();
Sentry.setUser(null);
onSuccess();
},
});
Expand Down
25 changes: 25 additions & 0 deletions src/instrument.ts
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,
});
}
1 change: 1 addition & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './instrument';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
Expand Down
25 changes: 24 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
import { defineConfig as defineViteConfig, loadEnv, mergeConfig } from 'vite';
import { defineConfig as defineVitestConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { sentryVitePlugin } from '@sentry/vite-plugin';

const viteConfig = defineViteConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const isProductionBuild = mode === 'production';
const hasSentryAuth =
!!env.SENTRY_AUTH_TOKEN && !!env.SENTRY_ORG && !!env.SENTRY_PROJECT;
const shouldUploadSourcemaps = isProductionBuild && hasSentryAuth;
const plugins = [react()];

if (shouldUploadSourcemaps) {
plugins.push(
sentryVitePlugin({
authToken: env.SENTRY_AUTH_TOKEN,
org: env.SENTRY_ORG,
project: env.SENTRY_PROJECT,
telemetry: false,
sourcemaps: {
filesToDeleteAfterUpload: 'dist/**/*.map',
},
}),
);
}

return {
base: env.VITE_BASE_PATH || '/',
plugins: [react()],
plugins,
build: {
sourcemap: shouldUploadSourcemaps ? 'hidden' : false,
},
server: {
proxy: {
'/api': {
Expand Down
Loading