diff --git a/package-lock.json b/package-lock.json index df53bd54..7bbf7a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@sentry/react": "^10.50.0", "@stomp/stompjs": "^7.3.0", "@tanstack/eslint-plugin-query": "^5.91.4", "@tanstack/react-query": "^5.90.20", @@ -35,6 +36,7 @@ "@babel/types": "^7.28.4", "@chromatic-com/storybook": "^3.2.2", "@eslint/js": "^9.15.0", + "@sentry/vite-plugin": "^5.2.0", "@storybook/addon-essentials": "^8.6.15", "@storybook/addon-interactions": "^8.6.15", "@storybook/addon-onboarding": "^8.6.15", @@ -2365,6 +2367,370 @@ "dev": true, "license": "MIT" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.50.0.tgz", + "integrity": "sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.50.0.tgz", + "integrity": "sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.50.0.tgz", + "integrity": "sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.50.0.tgz", + "integrity": "sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.0.tgz", + "integrity": "sha512-8LbOI5Kzb5F0+7LVQPi2+zGz1iPiRRFhM+7uZ/ZQ33L9BmDOYNIy3xWxCfMw2JCuMXXaxF47XCjGmR22/B0WPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.50.0.tgz", + "integrity": "sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.50.0", + "@sentry-internal/feedback": "10.50.0", + "@sentry-internal/replay": "10.50.0", + "@sentry-internal/replay-canvas": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.2.0.tgz", + "integrity": "sha512-+C0x4gEIJRgoMwyRFGx+TFiJ1Po2BZlT1v61+PnouiaprKL5qtZG8n5PXx/5LPLDsVjSIcXjnDrTz9aSm8SJ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.2.0", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", + "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.5", + "@sentry/cli-linux-arm": "2.58.5", + "@sentry/cli-linux-arm64": "2.58.5", + "@sentry/cli-linux-i686": "2.58.5", + "@sentry/cli-linux-x64": "2.58.5", + "@sentry/cli-win32-arm64": "2.58.5", + "@sentry/cli-win32-i686": "2.58.5", + "@sentry/cli-win32-x64": "2.58.5" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", + "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", + "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", + "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", + "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", + "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", + "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", + "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.5", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", + "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/cli/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sentry/core": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.50.0.tgz", + "integrity": "sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.50.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.50.0.tgz", + "integrity": "sha512-MZHYjEZAtFIa4zPrWS4oXlo+gMppRvfETqUqF920Sj2jN2U7WjboU03lDmjfDqEcH7QiwjQyl13jHd2nwAyrrw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.50.0", + "@sentry/core": "10.50.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/rollup-plugin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.2.0.tgz", + "integrity": "sha512-a8LfpvcYMFtFSroro5MpCcOoS528LeLfUHzxWURnpofOnY+Aso9Si4y4dFlna+RKqxCXjmFbn6CLnfI+YrHysQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.2.0", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sentry/vite-plugin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.2.0.tgz", + "integrity": "sha512-4Jo3ixBspso5HY81PDvZdRXkH9wYGVmcw/0a2IX9ejbyKBdHqkYg4IhAtNqGUAyGuHwwRS9Y1S+sCMvrXv6htw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.2.0", + "@sentry/rollup-plugin": "5.2.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -5228,6 +5594,19 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -9441,6 +9820,16 @@ "node": ">= 0.6.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/package.json b/package.json index 0db7c3ae..3f5d74bc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "i18n:transform": "tsx scripts/i18nTransform.ts" }, "dependencies": { + "@sentry/react": "^10.50.0", "@stomp/stompjs": "^7.3.0", "@tanstack/eslint-plugin-query": "^5.91.4", "@tanstack/react-query": "^5.90.20", @@ -46,6 +47,7 @@ "@babel/types": "^7.28.4", "@chromatic-com/storybook": "^3.2.2", "@eslint/js": "^9.15.0", + "@sentry/vite-plugin": "^5.2.0", "@storybook/addon-essentials": "^8.6.15", "@storybook/addon-interactions": "^8.6.15", "@storybook/addon-onboarding": "^8.6.15", diff --git a/src/apis/apis/member.ts b/src/apis/apis/member.ts index 3b28e88c..fdbb21d8 100644 --- a/src/apis/apis/member.ts +++ b/src/apis/apis/member.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react'; import { setAccessToken } from '../../util/accessToken'; import { ApiUrl } from '../endpoints'; import { request } from '../primitives'; @@ -40,6 +41,10 @@ export async function postUser(code: string): Promise { throw new Error('Authorization 헤더가 존재하지 않습니다.'); } + Sentry.setUser({ + id: String(response.data.id), + }); + return response.data; } diff --git a/src/apis/axiosInstance.ts b/src/apis/axiosInstance.ts index d21d39ac..6e052ae7 100644 --- a/src/apis/axiosInstance.ts +++ b/src/apis/axiosInstance.ts @@ -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; + } + + 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, + }, + }); + + (error as SentryCapturedError).__sentry_captured__ = true; +} + // 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); } } + + captureClientApiError(error); + return Promise.reject(error); }, ); diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index e4bf1c13..8dc4b715 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -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; +}; + // Low-level http request function export async function request( method: HttpMethod, @@ -54,6 +59,9 @@ export async function request( error.response?.status || 500, responseData, ); + apiError.__sentry_captured__ = ( + error as SentryCapturedError + ).__sentry_captured__; throw apiError; } diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index 84a8709d..b3070b29 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import * as Sentry from '@sentry/react'; import ErrorPage from './ErrorPage'; interface ErrorBoundaryProps { @@ -14,6 +15,10 @@ interface ErrorBoundaryState { const defaultError = new Error('알 수 없는 오류'); const defaultStack = '스택 정보 없음'; +type SentryCapturedError = { + __sentry_captured__?: boolean; +}; + class ErrorBoundary extends Component { constructor(props: ErrorBoundaryProps) { super(props); @@ -31,7 +36,20 @@ class ErrorBoundary extends Component { } 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); } diff --git a/src/hooks/mutations/useLogout.ts b/src/hooks/mutations/useLogout.ts index 8ba7cc0f..cbcdfddf 100644 --- a/src/hooks/mutations/useLogout.ts +++ b/src/hooks/mutations/useLogout.ts @@ -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'; @@ -10,6 +11,7 @@ export default function useLogout(onSuccess: () => void) { }, onSuccess: () => { removeAccessToken(); + Sentry.setUser(null); onSuccess(); }, }); diff --git a/src/instrument.ts b/src/instrument.ts new file mode 100644 index 00000000..72579a0a --- /dev/null +++ b/src/instrument.ts @@ -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, + }); +} diff --git a/src/main.tsx b/src/main.tsx index fdbb8fd6..548dd07c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import './instrument'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; diff --git a/src/page/TableSharingPage/TableSharingPage.tsx b/src/page/TableSharingPage/TableSharingPage.tsx index e0642b8e..356d7b7e 100644 --- a/src/page/TableSharingPage/TableSharingPage.tsx +++ b/src/page/TableSharingPage/TableSharingPage.tsx @@ -116,7 +116,7 @@ export default function TableSharingPage() { }, ); } - }, [decodedData, navigate, openModal, closeModal, encodedData, lang]); + }, [decodedData, navigate, openModal, closeModal, encodedData, lang, t]); return ( <> diff --git a/vite.config.ts b/vite.config.ts index 4489551d..a37f05c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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': {