Skip to content
Merged
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
9 changes: 6 additions & 3 deletions src/apis/primitives.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand Down Expand Up @@ -56,7 +55,11 @@ export async function request<T>(
error.response?.status || 500,
responseData,
);
apiError.__sentry_captured__ = isSentryCaptured(error);
// Axios 인터셉터에서 이미 보낸 에러는 APIError로 감싼 뒤에도
// ErrorBoundary/전역 핸들러에서 중복 수집되지 않도록 캡처 상태를 전달한다.
if (isSentryCaptured(error)) {
markSentryCaptured(apiError);
}
throw apiError;
}

Expand Down
3 changes: 2 additions & 1 deletion src/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}

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);
Expand Down
28 changes: 11 additions & 17 deletions src/instrument.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
}

Expand Down
57 changes: 55 additions & 2 deletions src/util/sentry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
buildSentryApiErrorMetadata,
createSentryApiError,
createSentryRenderError,
isSentryCaptured,
markSentryCaptured,
normalizeEndpoint,
resolveApiErrorLevel,
resolveFeatureFromPathname,
sanitizeSentryContext,
sanitizeSentrySearch,
sanitizeSentryUrl,
shouldSkipSentryEvent,
shouldSkipApiError,
} from './sentry';

Expand Down Expand Up @@ -99,6 +102,56 @@
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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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(

Check warning on line 150 in src/util/sentry.test.ts

View workflow job for this annotation

GitHub Actions / test

Replace `shouldSkipSentryEvent(new·Error('Request·was·aborted·by·server'))).toBe(⏎······false,⏎····` with `⏎······shouldSkipSentryEvent(new·Error('Request·was·aborted·by·server')),⏎····).toBe(false`
false,
);
});

it('Sentry context에 원본 요청/응답 데이터가 그대로 전송되지 않도록 민감 필드를 마스킹한다', () => {
expect(
sanitizeSentryContext({
Expand Down Expand Up @@ -144,7 +197,7 @@
);
});

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',
Expand All @@ -159,7 +212,7 @@
);
});

it('렌더링 에러 알림 제목도 level, error type, feature, 원본 에러 순서로 구성한다', () => {
it('렌더링 에러 알림 제목은 level, error type, feature, 원본 에러 순서로 구성한다', () => {
const error = new TypeError('Cannot read properties of undefined');

const sentryError = createSentryRenderError(error, 'timer');
Expand Down
58 changes: 53 additions & 5 deletions src/util/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -46,6 +47,7 @@ export type SentryApiErrorMetadata = {
pathname: string;
};

// 엔드포인트 그룹핑
export function normalizeEndpoint(url?: string) {
if (!url) {
return 'unknown';
Expand All @@ -61,6 +63,7 @@ export function normalizeEndpoint(url?: string) {
.replace(/\/[0-9]+(?=\/|$)/g, '/:id');
}

// 기능 태그
export function resolveFeatureFromPathname(pathname: string): DebateFeature {
const path = removeLanguagePrefix(pathname);

Expand Down Expand Up @@ -123,6 +126,7 @@ export function resolveFeatureFromPathname(pathname: string): DebateFeature {
return 'unknown';
}

// API 에러 심각도
export function resolveApiErrorLevel(
status: number | undefined,
feature: DebateFeature,
Expand All @@ -142,6 +146,7 @@ export function resolveApiErrorLevel(
return 'error';
}

// API 에러 수집 제외
export function shouldSkipApiError(error: AxiosError) {
const status = error.response?.status;

Expand All @@ -152,6 +157,7 @@ export function shouldSkipApiError(error: AxiosError) {
return isOfflineNetworkError(error.code);
}

// API 에러 메타데이터
export function buildSentryApiErrorMetadata(
error: AxiosError,
pathname: string,
Expand All @@ -172,41 +178,59 @@ export function buildSentryApiErrorMetadata(
};
}

// API 이슈 제목
export function createSentryApiError(
error: AxiosError,
metadata: SentryApiErrorMetadata,
) {
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;
}
}

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 '';
Expand All @@ -224,6 +248,7 @@ export function sanitizeSentrySearch(search: string) {
return sanitizedSearch ? `?${sanitizedSearch}` : '';
}

// 요청 URL 마스킹
export function sanitizeSentryUrl(url?: string) {
if (!url) {
return url;
Expand All @@ -244,6 +269,7 @@ export function sanitizeSentryUrl(url?: string) {
}
}

// 요청/응답 데이터 마스킹
export function sanitizeSentryContext(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sanitizeSentryContext);
Expand All @@ -261,6 +287,7 @@ export function sanitizeSentryContext(value: unknown): unknown {
);
}

// URL 파싱
function resolveUrlPath(url: string) {
try {
return new URL(url, window.location.origin).pathname;
Expand All @@ -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' &&
Expand All @@ -291,6 +338,7 @@ function isOfflineNetworkError(code?: string) {
);
}

// 민감 필드 판별
function isSensitiveKey(key: string) {
return sensitiveKeys.some(
(sensitiveKey) => sensitiveKey.toLowerCase() === key.toLowerCase(),
Expand Down
2 changes: 2 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading