diff --git a/src/apis/primitives.ts b/src/apis/primitives.ts index 42ee305b..2535100c 100644 --- a/src/apis/primitives.ts +++ b/src/apis/primitives.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { AxiosResponse } from 'axios'; import axiosInstance from './axiosInstance'; -import { isSentryCaptured } from '../util/sentry'; +import { isSentryCaptured, markSentryCaptured } from '../util/sentry'; // HTTP request methods export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -10,7 +10,6 @@ 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); @@ -56,7 +55,11 @@ export async function request( error.response?.status || 500, responseData, ); - apiError.__sentry_captured__ = isSentryCaptured(error); + // Axios 인터셉터에서 이미 보낸 에러는 APIError로 감싼 뒤에도 + // ErrorBoundary/전역 핸들러에서 중복 수집되지 않도록 캡처 상태를 전달한다. + if (isSentryCaptured(error)) { + markSentryCaptured(apiError); + } throw apiError; } diff --git a/src/components/ErrorBoundary/ErrorBoundary.tsx b/src/components/ErrorBoundary/ErrorBoundary.tsx index 73c6659e..d3269e1d 100644 --- a/src/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -38,7 +38,8 @@ class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo): void { - // 이미 API 인터셉터 등에서 캡처된 에러가 아니라면 전송 + // API 인터셉터에서 이미 커스텀 이벤트로 전송한 에러는 전역 beforeSend에서도 drop된다. + // ErrorBoundary에서는 아직 수집되지 않은 렌더링 에러만 render-error로 전송한다. if (!isSentryCaptured(error)) { const feature = resolveFeatureFromPathname(window.location.pathname); const sentryError = createSentryRenderError(error, feature); diff --git a/src/instrument.ts b/src/instrument.ts index 72655524..f130628b 100644 --- a/src/instrument.ts +++ b/src/instrument.ts @@ -1,8 +1,11 @@ import * as Sentry from '@sentry/react'; +import { shouldSkipSentryEvent } from './util/sentry'; const dsn = import.meta.env.VITE_SENTRY_DSN; +const isSentryEnabled = + import.meta.env.PROD || import.meta.env.VITE_ENABLE_SENTRY === 'true'; -if (import.meta.env.PROD && dsn) { +if (isSentryEnabled && dsn) { Sentry.init({ dsn, environment: import.meta.env.MODE, @@ -24,22 +27,13 @@ if (import.meta.env.PROD && dsn) { beforeSend(event, hint) { const originalException = hint?.originalException; - // 정상 사용자 흐름(이동/언마운트)에서 자주 생기는 취소성 에러는 노이즈로 간주 - if (originalException instanceof Error) { - const normalizedMessage = originalException.message.toLowerCase(); - const isCanceledRequest = - normalizedMessage.includes('cancel') || - normalizedMessage.includes('aborted') || - originalException.name === 'AbortError' || - originalException.name === 'CanceledError'; - - if (isCanceledRequest) { - return null; - } - } - - // 크로스 오리진 스크립트 에러는 재현 단서가 부족해 운영 액션 가능성이 낮음 - if (event.exception?.values?.[0]?.value === 'Script error.') { + // 이미 수집한 API 에러, 취소성 에러, 원인 추적이 어려운 Script error는 전송하지 않음 + if ( + shouldSkipSentryEvent( + originalException, + event.exception?.values?.[0]?.value, + ) + ) { return null; } diff --git a/src/util/sentry.test.ts b/src/util/sentry.test.ts index 1aec7964..b61ecb1c 100644 --- a/src/util/sentry.test.ts +++ b/src/util/sentry.test.ts @@ -3,12 +3,15 @@ import { buildSentryApiErrorMetadata, createSentryApiError, createSentryRenderError, + isSentryCaptured, + markSentryCaptured, normalizeEndpoint, resolveApiErrorLevel, resolveFeatureFromPathname, sanitizeSentryContext, sanitizeSentrySearch, sanitizeSentryUrl, + shouldSkipSentryEvent, shouldSkipApiError, } from './sentry'; @@ -99,6 +102,56 @@ describe('sentry 유틸', () => { expect(shouldSkipApiError(onlineNetworkError)).toBe(false); }); + it('SDK 내부 플래그와 앱 전용 플래그를 분리해 중복 캡처를 판단한다', () => { + const originalError = new AxiosError('Request failed', undefined, { + method: 'get', + url: '/api/polls/123/votes', + headers: new AxiosHeaders(), + }); + const metadata = buildSentryApiErrorMetadata(originalError, '/vote/123'); + + const sentryError = createSentryApiError(originalError, metadata); + const sdkCapturedError = Object.assign(new Error('sdk captured'), { + __sentry_captured__: true, + }); + + expect('__sentry_captured__' in sentryError).toBe(false); + expect(isSentryCaptured(sentryError)).toBe(false); + expect(isSentryCaptured(sdkCapturedError)).toBe(false); + + markSentryCaptured(originalError); + markSentryCaptured(sdkCapturedError); + + expect(isSentryCaptured(originalError)).toBe(true); + expect(isSentryCaptured(sdkCapturedError)).toBe(true); + }); + + it('이미 커스텀 이벤트로 전송한 에러는 전역 Sentry 이벤트에서 제외한다', () => { + const error = new Error('already captured'); + + markSentryCaptured(error); + + expect(shouldSkipSentryEvent(error)).toBe(true); + }); + + it('사용자 이동이나 요청 취소로 발생한 에러와 Script error는 Sentry 이벤트에서 제외한다', () => { + expect(shouldSkipSentryEvent(new DOMException('', 'AbortError'))).toBe( + true, + ); + expect(shouldSkipSentryEvent({ name: 'CanceledError' })).toBe(true); + expect(shouldSkipSentryEvent({ code: 'ERR_CANCELED' })).toBe(true); + expect(shouldSkipSentryEvent(undefined, 'Script error.')).toBe(true); + }); + + it('서비스 에러 메시지에 cancel이나 aborted가 포함되어도 취소성 에러로 오분류하지 않는다', () => { + expect( + shouldSkipSentryEvent(new Error('Debate live session was cancelled')), + ).toBe(false); + expect(shouldSkipSentryEvent(new Error('Request was aborted by server'))).toBe( + false, + ); + }); + it('Sentry context에 원본 요청/응답 데이터가 그대로 전송되지 않도록 민감 필드를 마스킹한다', () => { expect( sanitizeSentryContext({ @@ -144,7 +197,7 @@ describe('sentry 유틸', () => { ); }); - it('알림 제목을 level, error type, feature, status, endpoint 순서로 구성해 대응 판단 흐름을 만든다', () => { + it('API 에러 알림 제목은 level, error type, feature, status, endpoint 순서로 구성한다', () => { const error = new AxiosError('Request failed', undefined, { method: 'post', url: '/api/live/123', @@ -159,7 +212,7 @@ describe('sentry 유틸', () => { ); }); - it('렌더링 에러 알림 제목도 level, error type, feature, 원본 에러 순서로 구성한다', () => { + it('렌더링 에러 알림 제목은 level, error type, feature, 원본 에러 순서로 구성한다', () => { const error = new TypeError('Cannot read properties of undefined'); const sentryError = createSentryRenderError(error, 'timer'); diff --git a/src/util/sentry.ts b/src/util/sentry.ts index a516c89a..f7a079e3 100644 --- a/src/util/sentry.ts +++ b/src/util/sentry.ts @@ -33,9 +33,10 @@ const sensitiveKeys = [ ]; type SentryCapturedError = { - __sentry_captured__?: boolean; + __debate_timer_sentry_captured__?: boolean; }; +// API 에러 알림 메타데이터 export type SentryApiErrorMetadata = { status: number | undefined; statusLabel: string; @@ -46,6 +47,7 @@ export type SentryApiErrorMetadata = { pathname: string; }; +// 엔드포인트 그룹핑 export function normalizeEndpoint(url?: string) { if (!url) { return 'unknown'; @@ -61,6 +63,7 @@ export function normalizeEndpoint(url?: string) { .replace(/\/[0-9]+(?=\/|$)/g, '/:id'); } +// 기능 태그 export function resolveFeatureFromPathname(pathname: string): DebateFeature { const path = removeLanguagePrefix(pathname); @@ -123,6 +126,7 @@ export function resolveFeatureFromPathname(pathname: string): DebateFeature { return 'unknown'; } +// API 에러 심각도 export function resolveApiErrorLevel( status: number | undefined, feature: DebateFeature, @@ -142,6 +146,7 @@ export function resolveApiErrorLevel( return 'error'; } +// API 에러 수집 제외 export function shouldSkipApiError(error: AxiosError) { const status = error.response?.status; @@ -152,6 +157,7 @@ export function shouldSkipApiError(error: AxiosError) { return isOfflineNetworkError(error.code); } +// API 에러 메타데이터 export function buildSentryApiErrorMetadata( error: AxiosError, pathname: string, @@ -172,6 +178,7 @@ export function buildSentryApiErrorMetadata( }; } +// API 이슈 제목 export function createSentryApiError( error: AxiosError, metadata: SentryApiErrorMetadata, @@ -179,23 +186,23 @@ export function createSentryApiError( const sentryError = new Error(error.message); sentryError.name = `${metadata.level} · api-error · ${metadata.feature} · [${metadata.statusLabel}] ${metadata.method} ${metadata.endpoint}`; sentryError.stack = error.stack; - (sentryError as SentryCapturedError).__sentry_captured__ = true; return sentryError; } +// 렌더링 이슈 제목 export function createSentryRenderError(error: Error, feature: DebateFeature) { const sentryError = new Error(error.message); sentryError.name = `fatal · render-error · ${feature} · ${error.name}: ${error.message}`; 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; + (error as SentryCapturedError).__debate_timer_sentry_captured__ = true; } } @@ -203,10 +210,27 @@ export function isSentryCaptured(error: unknown) { return ( typeof error === 'object' && error !== null && - (error as SentryCapturedError).__sentry_captured__ === true + (error as SentryCapturedError).__debate_timer_sentry_captured__ === true ); } +// 전역 이벤트 필터 +export function shouldSkipSentryEvent( + originalException: unknown, + exceptionValue?: string, +) { + if (isSentryCaptured(originalException)) { + return true; + } + + if (isCanceledError(originalException)) { + return true; + } + + return exceptionValue === 'Script error.'; +} + +// 컨텍스트 마스킹 export function sanitizeSentrySearch(search: string) { if (!search) { return ''; @@ -224,6 +248,7 @@ export function sanitizeSentrySearch(search: string) { return sanitizedSearch ? `?${sanitizedSearch}` : ''; } +// 요청 URL 마스킹 export function sanitizeSentryUrl(url?: string) { if (!url) { return url; @@ -244,6 +269,7 @@ export function sanitizeSentryUrl(url?: string) { } } +// 요청/응답 데이터 마스킹 export function sanitizeSentryContext(value: unknown): unknown { if (Array.isArray(value)) { return value.map(sanitizeSentryContext); @@ -261,6 +287,7 @@ export function sanitizeSentryContext(value: unknown): unknown { ); } +// URL 파싱 function resolveUrlPath(url: string) { try { return new URL(url, window.location.origin).pathname; @@ -269,20 +296,40 @@ function resolveUrlPath(url: string) { } } +// 라우트 정규화 function removeLanguagePrefix(pathname: string) { const path = pathname.replace(/^\/(ko|en)(?=\/|$)/, ''); return path === '' ? '/' : path; } +// 라우트 접두사 매칭 function matchesPathPrefix(path: string, prefix: string) { return path === prefix || path.startsWith(`${prefix}/`); } +// 라우트 세그먼트 매칭 function hasPathSegment(path: string, segment: string) { return path.split('/').includes(segment); } +// 취소성 에러 판별 +function isCanceledError(originalException: unknown) { + if (typeof originalException !== 'object' || originalException === null) { + return false; + } + + const { name, code } = originalException as { + name?: unknown; + code?: unknown; + }; + + return ( + name === 'AbortError' || name === 'CanceledError' || code === 'ERR_CANCELED' + ); +} + +// 오프라인 네트워크 에러 판별 function isOfflineNetworkError(code?: string) { return ( typeof navigator !== 'undefined' && @@ -291,6 +338,7 @@ function isOfflineNetworkError(code?: string) { ); } +// 민감 필드 판별 function isSensitiveKey(key: string) { return sensitiveKeys.some( (sensitiveKey) => sensitiveKey.toLowerCase() === key.toLowerCase(), diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 17433776..3e8bfa1a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_MOCK_API: string; readonly VITE_BASE_PATH: string; + readonly VITE_SENTRY_DSN?: string; + readonly VITE_ENABLE_SENTRY?: string; } interface ImportMeta {