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
7 changes: 4 additions & 3 deletions .github/scripts/sentry-daily-digest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ if [ "$DRY_RUN" != "1" ]; then
fi

case "$SENTRY_ENV" in
production) LABEL="[WEB·PROD]" ;;
staging) LABEL="[WEB·STG]" ;;
*) LABEL="[WEB·$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]')]" ;;
production) STAGE="PROD" ;;
staging) STAGE="STG" ;;
*) STAGE=$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]') ;;
esac
LABEL="[${SENTRY_PLATFORM:-WEB}·$STAGE]"

API="https://sentry.io/api/0"
WORK=$(mktemp -d)
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/sentry-daily-digest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,24 @@ jobs:
sparse-checkout: .github/scripts
persist-credentials: false

- name: Sentry 요약 전송 (허거덩 봇)
- name: 웹 Sentry 요약 전송 (허거덩 봇)
env:
SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }}
SENTRY_ORG: piki-92
SENTRY_PROJECT: piki-web
SENTRY_PLATFORM: WEB
SENTRY_ENV: production
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }}
run: bash .github/scripts/sentry-daily-digest.sh

- name: 앱 Sentry 요약 전송 (허거덩 봇)
if: ${{ !cancelled() }}
env:
SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }}
SENTRY_ORG: piki-92
SENTRY_PROJECT: piki-app
SENTRY_PLATFORM: APP
SENTRY_ENV: production
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }}
Expand Down
6 changes: 5 additions & 1 deletion apps/web/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import * as Sentry from '@sentry/nextjs';
import { isAxiosError } from 'axios';

export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') await import('./sentry.server.config');
if (process.env.NEXT_RUNTIME === 'edge') await import('./sentry.edge.config');
}

export const onRequestError = Sentry.captureRequestError;
export const onRequestError: typeof Sentry.captureRequestError = (error, request, errorContext) => {
if (isAxiosError(error)) return;
Sentry.captureRequestError(error, request, errorContext);
};
28 changes: 2 additions & 26 deletions apps/web/src/apis/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { QUERY_ACTION } from '@/consts/queryAction';
import { ROUTES } from '@/consts/route';
import { CLIENT_TYPE } from '@/consts/webBridge';
import type { ApiErrorResponseT } from '@/types/api';
import { captureError } from '@/utils/captureError';
import { captureApiError } from '@/utils/captureError';
import { deleteCookie, getCookie } from '@/utils/cookie';
import { handleSessionExpired } from '@/utils/handleSessionExpired';
import { getLoginPath } from '@/utils/loginRedirect';
Expand Down Expand Up @@ -82,31 +82,7 @@ clientApi.interceptors.response.use(
return Promise.reject(error);
}

/** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */
const status = error.response?.status;
const shouldReport =
error.code !== 'ERR_CANCELED' &&
(error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500));

if (shouldReport) {
const method = originalRequest?.method?.toUpperCase() ?? 'UNKNOWN';
const path = originalRequest?.url?.split('?')[0] ?? 'unknown';

/** 디코/대시보드 제목을 알아보기 쉽게 (원본 axios 정보는 extra 유지) */
const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`);
apiError.name = 'ApiError';

captureError(apiError, {
tags: { source: 'api-client' },
extra: {
url: originalRequest?.url,
method: originalRequest?.method,
status,
code: error.code,
apiCode: error.response?.data?.code,
},
});
}
captureApiError(error, 'api-client');

return Promise.reject(error);
}
Expand Down
27 changes: 2 additions & 25 deletions apps/web/src/apis/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { ENDPOINTS } from '@/consts/api';
import { QUERY_ACTION } from '@/consts/queryAction';
import { CLIENT_TYPE } from '@/consts/webBridge';
import type { ApiErrorResponseT } from '@/types/api';
import { captureError } from '@/utils/captureError';
import { captureApiError } from '@/utils/captureError';
import { getLoginPath } from '@/utils/loginRedirect';
import { isWebview } from '@/utils/webBridge';

Expand Down Expand Up @@ -59,30 +59,7 @@ serverApi.interceptors.response.use(
redirect(getLoginPath(null, QUERY_ACTION.VALUE.WITHDRAWN_ACCOUNT));
}

/** 5xx·네트워크 오류만 수집 (4xx는 예상된 흐름이라 제외) */
const status = error.response?.status;
const shouldReport =
error.code !== 'ERR_CANCELED' &&
(error.code === 'ERR_NETWORK' || (typeof status === 'number' && status >= 500));

if (shouldReport) {
const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN';
const path = error.config?.url?.split('?')[0] ?? 'unknown';

const apiError = new Error(`API ${status ?? error.code} ${method} ${path}`);
apiError.name = 'ApiError';

captureError(apiError, {
tags: { source: 'api-server' },
extra: {
url: error.config?.url,
method: error.config?.method,
status,
code: error.code,
apiCode: error.response?.data?.code,
},
});
}
captureApiError(error, 'api-server');

return Promise.reject(error);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import Link from 'next/link';
import { useEffect } from 'react';

import { WarningIconFill } from '@/assets/icons';
import { buttonStyles } from '@/components/button/button.style';
import ErrorScreen from '@/components/common/error-screen';
import { ROUTES } from '@/consts/route';
import { captureNonApiError } from '@/utils/captureError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';

type Props = {
Expand All @@ -17,7 +17,7 @@ type Props = {

function Error({ error, reset }: Props) {
useEffect(() => {
Sentry.captureException(error);
captureNonApiError(error);
}, [error]);

const description = getApiErrorMessage(error);
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import * as Sentry from '@sentry/nextjs';
import Link from 'next/link';
import { useEffect } from 'react';

import { WarningIconFill } from '@/assets/icons';
import { buttonStyles } from '@/components/button/button.style';
import { ROUTES } from '@/consts/route';
import '@/styles/globals.css';
import { captureNonApiError } from '@/utils/captureError';

type Props = {
error: Error & { digest?: string };
Expand All @@ -16,7 +16,7 @@ type Props = {

export default function GlobalError({ error, reset }: Props) {
useEffect(() => {
Sentry.captureException(error);
captureNonApiError(error);
}, [error]);

return (
Expand All @@ -28,7 +28,7 @@ export default function GlobalError({ error, reset }: Props) {
<WarningIconFill className="size-15 text-icon-warning" />
<div className="flex flex-col items-center gap-2">
<h1 className="heading-1-semibold text-text-neutral-primary">오류가 발생했어요</h1>
<p className="body-1-medium text-text-neutral-tertiary">
<p className="body-1-medium text-text-neutral-tertiary">
잠시 후 다시 시도해 주세요
</p>
</div>
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/hooks/useFcmTokenSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useCallback, useEffect } from 'react';

import { postFcmToken } from '@/apis/postFcmToken';
import { useWebBridgeMessage } from '@/hooks/useWebBridgeMessage';
import { captureError } from '@/utils/captureError';
import { captureNonApiError } from '@/utils/captureError';
import { getCookie } from '@/utils/cookie';
import { WebBridge, isWebview as isWebviewFn } from '@/utils/webBridge';

Expand All @@ -24,15 +24,11 @@ export const useFcmTokenSync = () => {
message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_PUSH_PERMISSION_STATUS &&
message.payload.token
) {
postFcmToken(message.payload.token, message.payload.deviceId).catch(error =>
captureError(error, { tags: { source: 'fcm-token-sync' } })
);
postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError);
}

if (message.type === WEBBRIDGE_MESSAGE_TYPE.APP_RES_FCM_TOKEN) {
postFcmToken(message.payload.token, message.payload.deviceId).catch(error =>
captureError(error, { tags: { source: 'fcm-token-sync' } })
);
postFcmToken(message.payload.token, message.payload.deviceId).catch(captureNonApiError);
}
}, [])
);
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/utils/captureError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import * as Sentry from '@sentry/nextjs';
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { AxiosError } from 'axios';
import { describe, expect, it, vi } from 'vitest';

import type { ApiErrorResponseT } from '@/types/api';

import { captureApiError } from './captureError';

vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() }));

const config = { method: 'post', url: '/api/v1/wishes/123?x=1' } as InternalAxiosRequestConfig;

const axiosError = ({ code, status }: { code?: string; status?: number }) => {
const response = status
? ({ status, data: { data: null, code: 'WISH-004' } } as AxiosResponse<ApiErrorResponseT>)
: void 0;

return new AxiosError<ApiErrorResponseT>('failed', code, config, void 0, response);
};

const captured = () => vi.mocked(Sentry.captureException).mock.calls;

describe('captureApiError', () => {
it('5xx 는 메서드·status·경로로 ApiError 를 보낸다', () => {
vi.mocked(Sentry.captureException).mockClear();

captureApiError(axiosError({ code: 'ERR_BAD_RESPONSE', status: 500 }), 'api-client');

const [error, context] = captured()[0] ?? [];
expect((error as Error).name).toBe('ApiError');
expect((error as Error).message).toBe('API 500 POST /api/v1/wishes/123');
expect(context).toMatchObject({
tags: { source: 'api-client', errorType: 'server' },
fingerprint: ['api', 'POST', '500', '/api/v1/wishes/:id'],
});
});

it('응답 없는 서버 네트워크 오류는 ERR_NETWORK 가 아니어도 보낸다', () => {
vi.mocked(Sentry.captureException).mockClear();

captureApiError(axiosError({ code: 'ECONNREFUSED' }), 'api-server');
captureApiError(axiosError({ code: 'ECONNABORTED' }), 'api-client');

expect(captured().map(([error]) => (error as Error).message)).toEqual([
'API ECONNREFUSED POST /api/v1/wishes/123',
'API ECONNABORTED POST /api/v1/wishes/123',
]);
expect(captured()[0]?.[1]).toMatchObject({ tags: { errorType: 'network' } });
});

it('4xx 와 취소된 요청은 보내지 않는다', () => {
vi.mocked(Sentry.captureException).mockClear();

captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 401 }), 'api-client');
captureApiError(axiosError({ code: 'ERR_BAD_REQUEST', status: 404 }), 'api-client');
captureApiError(axiosError({ code: 'ERR_CANCELED' }), 'api-client');

expect(captured()).toHaveLength(0);
});
});
44 changes: 44 additions & 0 deletions apps/web/src/utils/captureError.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,60 @@
import * as Sentry from '@sentry/nextjs';
import type { AxiosError } from 'axios';
import { isAxiosError } from 'axios';

import type { ApiErrorResponseT } from '@/types/api';

type CaptureErrorContextT = {
/** 검색/그룹핑용 태그 (예: { source: 'api-client' }) */
tags?: Record<string, string>;
/** 디버깅용 부가 정보 (요청 URL, status 등) */
extra?: Record<string, unknown>;
fingerprint?: string[];
};

/** 잡은(caught) 에러를 Sentry에 수동 리포트 — 태그/컨텍스트를 일관되게 부착 */
export const captureError = (error: unknown, context?: CaptureErrorContextT) => {
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
fingerprint: context?.fingerprint,
});
};

const isServerOriginError = (error: unknown) =>
error instanceof Error && 'digest' in error && typeof error.digest === 'string';

/** API(axios) 에러는 인터셉터(apis/client.ts·server.ts)가 5xx·네트워크만 단독 수집 — 여기선 그 외 에러만 보낸다 */
export const captureNonApiError = (error: unknown) => {
if (isAxiosError(error) || isServerOriginError(error)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Sentry.captureException(error);
};

/** fingerprint 용 — 리소스 ID 가 달라도 같은 엔드포인트면 한 이슈로 묶는다 */
export const normalizeApiPath = (path: string) =>
path.replace(/\/\d+(?=\/|$)/g, '/:id').replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}(?=\/|$)/gi, '/:id');

/** 5xx 와 응답 없는 네트워크 오류만 수집 */
export const captureApiError = (error: AxiosError<ApiErrorResponseT>, source: string) => {
const status = error.response?.status;
if (error.code === 'ERR_CANCELED' || (typeof status === 'number' && status < 500)) return;

const method = error.config?.method?.toUpperCase() ?? 'UNKNOWN';
const path = error.config?.url?.split('?')[0] ?? 'unknown';
const reason = String(status ?? error.code ?? 'NETWORK');

const apiError = new Error(`API ${reason} ${method} ${path}`);
apiError.name = 'ApiError';

captureError(apiError, {
tags: { source, errorType: status ? 'server' : 'network' },
fingerprint: ['api', method, reason, normalizeApiPath(path)],
extra: {
url: error.config?.url,
method: error.config?.method,
status,
code: error.code,
apiCode: error.response?.data?.code,
},
});
};
12 changes: 5 additions & 7 deletions apps/web/src/utils/queryClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/nextjs';
import {
MutationCache,
QueryCache,
Expand All @@ -15,13 +14,14 @@ import {
isServerOrNetworkError,
isWithdrawnAccountError,
} from '@/utils/apiError';
import { captureNonApiError } from '@/utils/captureError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';

/** REF: https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr */

const makeQueryClient = () => {
return new QueryClient({
/** mutation — 사용자 액션 실패: 토스트 + (Sentry) 로깅 */
/** mutation — 사용자 액션 실패: 토스트 + (API 외 에러만) Sentry 로깅 */
mutationCache: new MutationCache({
onError: (error, _variables, _context, mutation) => {
/** 401·탈퇴 계정은 전역 인터셉터(apis/client.ts)가 refresh·redirect로 처리 → 이중 토스트 방지 */
Expand All @@ -30,7 +30,7 @@ const makeQueryClient = () => {
/** 5xx·네트워크는 전역이 단독 처리 (개별 onError는 5xx를 건드리지 않음) */
if (isServerOrNetworkError(error)) {
toast.error(getApiErrorMessage(error));
Sentry.captureException(error);
captureNonApiError(error);
return;
}

Expand All @@ -39,11 +39,9 @@ const makeQueryClient = () => {
toast.error(getApiErrorMessage(error));
},
}),
/** query — 데이터 fetch 실패: 토스트 X, 로깅만 (표시는 boundary/isError가 담당) */
/** query — 데이터 fetch 실패: 토스트 X, API 외 에러만 로깅 (표시는 boundary/isError가 담당) */
queryCache: new QueryCache({
onError: _error => {
Sentry.captureException(_error);
},
onError: captureNonApiError,
}),
defaultOptions: {
queries: {
Expand Down
Loading
Loading