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
53 changes: 43 additions & 10 deletions apps/app/apis/postSocialLogin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { SocialLoginSuccessPayloadT, SocialProviderT } from '@piki/core';

import { captureError } from '@/utils/captureError';

type PostSocialLoginRequestT = {
accessToken: string;
};
Expand All @@ -8,17 +10,48 @@ export const postSocialLogin = async (
provider: SocialProviderT,
body: PostSocialLoginRequestT
): Promise<SocialLoginSuccessPayloadT> => {
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Type': 'app',
},
body: JSON.stringify(body),
});
let response: Response;
try {
response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Client-Type': 'app',
},
body: JSON.stringify(body),
});
} catch (error) {
/** 네트워크 오류 */
captureError(error, { tags: { source: 'api', api: 'postSocialLogin', provider } });
throw error;
}

let data: { data: SocialLoginSuccessPayloadT; detail?: string } | null = null;
try {
data = await response.json();
} catch {
/** 바디가 JSON이 아님 (프록시 HTML 에러 페이지·빈 바디 등) — data는 null 유지 */
}

if (!response.ok) {
/** 5xx 서버 오류만 수집 (4xx는 예상된 흐름이라 제외) */
if (response.status >= 500) {
captureError(new Error(`postSocialLogin ${response.status}: ${data?.detail ?? 'unknown'}`), {
tags: { source: 'api', api: 'postSocialLogin', provider },
extra: { status: response.status },
});
}
throw new Error(data?.detail ?? '로그인에 실패했습니다.');
}

const data = await response.json();
if (!response.ok) throw new Error(data.detail ?? '로그인에 실패했습니다.');
if (!data) {
/** 2xx인데 본문이 비었거나 깨짐 — 예상 못 한 이상 응답이라 수집 */
captureError(new Error(`postSocialLogin ${response.status}: empty/invalid body`), {
tags: { source: 'api', api: 'postSocialLogin', provider },
extra: { status: response.status },
});
throw new Error('서버 응답을 해석할 수 없습니다.');
}

return data.data;
};
7 changes: 7 additions & 0 deletions apps/app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@
{
"nativeAppKey": "326734abdf8de5c7e090a2fc72e1dce6"
}
],
[
"@sentry/react-native",
{
"organization": "piki-92",
"project": "piki-app"
}
]
],
"experiments": {
Expand Down
5 changes: 4 additions & 1 deletion apps/app/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { GoogleSignin } from '@react-native-google-signin/google-signin';
import { initializeKakaoSDK } from '@react-native-kakao/core';
import * as Sentry from '@sentry/react-native';
import { Stack } from 'expo-router';
import { ShareIntentProvider } from 'expo-share-intent';
import * as SplashScreen from 'expo-splash-screen';
Expand All @@ -9,6 +10,8 @@ import 'react-native-reanimated';
import PushNotificationProvider from '@/components/PushNotificationProvider';
import { SplashScreenControllerProvider } from '@/hooks/useSplashScreenController';

/** Sentry 초기화는 진입점 index.js(initSentry)에서 수행 — 여기선 wrap 만 적용 */

initializeKakaoSDK(process.env.EXPO_PUBLIC_KAKAO_NATIVE_APP_KEY ?? '');

GoogleSignin.configure({
Expand All @@ -34,4 +37,4 @@ function RootLayout() {
);
}

export default RootLayout;
export default Sentry.wrap(RootLayout);
32 changes: 31 additions & 1 deletion apps/app/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, Linking, Platform } from 'react-native';
import type { WebView } from 'react-native-webview';
import Webview from 'react-native-webview';
import type {
WebViewErrorEvent,
WebViewHttpErrorEvent,
} from 'react-native-webview/lib/WebViewTypes';

import { USER_AGENT } from '@/constants/userAgent';
import { useShareIntent } from '@/hooks/useShareIntent';
Expand All @@ -17,6 +21,7 @@ import { useWebBridgeMessage } from '@/hooks/useWebBridgeMessage';
import { useWebDeepLink } from '@/hooks/useWebDeepLink';
import { useWebviewCookieSync } from '@/hooks/useWebviewCookieSync';
import { logAnalyticsEvent, logAppOpenEvent } from '@/utils/analytics';
import { captureError } from '@/utils/captureError';
import { handleOpenImagePicker } from '@/utils/handleImage';
import {
handleRequestPushPermission,
Expand Down Expand Up @@ -130,6 +135,30 @@ function Page() {
const { onMessage } = useWebBridgeMessage(handleWebMessage);
const { onWebViewLoadEnd, onWebViewLoadError } = useSplashScreenController();

/** WebView 로드 실패(네이티브 측) 수집 + 기존 스플래시 처리 유지 */
const handleWebViewError = useCallback(
(event: WebViewErrorEvent) => {
const { nativeEvent } = event;
captureError(new Error(`WebView load error: ${nativeEvent.description}`), {
tags: { source: 'webview' },
extra: { url: nativeEvent.url, code: nativeEvent.code },
});
onWebViewLoadError();
},
[onWebViewLoadError]
);

/** WebView HTTP 5xx 응답만 수집 */
const handleWebViewHttpError = useCallback((event: WebViewHttpErrorEvent) => {
const { nativeEvent } = event;
if (nativeEvent.statusCode >= 500) {
captureError(new Error(`WebView HTTP ${nativeEvent.statusCode}: ${nativeEvent.url}`), {
tags: { source: 'webview' },
extra: { url: nativeEvent.url, statusCode: nativeEvent.statusCode },
});
}
}, []);

return (
<>
{isSynced && (
Expand All @@ -140,7 +169,8 @@ function Page() {
source={{ uri: webviewUri }}
onMessage={onMessage}
onLoadEnd={onWebViewLoadEnd}
onError={onWebViewLoadError}
onError={handleWebViewError}
onHttpError={handleWebViewHttpError}
allowsBackForwardNavigationGestures
cacheEnabled
webviewDebuggingEnabled={__DEV__}
Expand Down
27 changes: 23 additions & 4 deletions apps/app/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { getMessaging, setBackgroundMessageHandler } from '@react-native-firebase/messaging';
import * as Sentry from '@sentry/react-native';
import 'expo-router/entry';

import { captureError } from '@/utils/captureError';
import { setAppBadgeCount } from '@/utils/pushNotification';

/** foreground 메인 + background headless 둘 다 여기서 초기화 (쉐어 익스텐션 제외) */
const stage = process.env.EXPO_PUBLIC_STAGE;

Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
environment: stage,
enabled: stage === 'production' || stage === 'staging',
/** Performance(Tracing)는 초기엔 off */
tracesSampleRate: 0,
/** PII 기본 마스킹 (Session Replay 는 web 만, 앱은 에러/크래시만) */
sendDefaultPii: false,
});

/** 백그라운드 FCM 메시지 핸들러 */
setBackgroundMessageHandler(getMessaging(), async remoteMessage => {
console.warn('[FCM] Background message:', remoteMessage.messageId ?? 'unknown');
const unreadCount = remoteMessage.data?.unreadCount;
if (unreadCount !== undefined) {
await setAppBadgeCount(Number(unreadCount));
try {
console.warn('[FCM] Background message:', remoteMessage.messageId ?? 'unknown');
const unreadCount = remoteMessage.data?.unreadCount;
if (unreadCount !== undefined) {
await setAppBadgeCount(Number(unreadCount));
}
} catch (error) {
captureError(error, { tags: { source: 'fcm-background' } });
}
});
5 changes: 3 additions & 2 deletions apps/app/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { getDefaultConfig } = require('expo/metro-config');
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { withShareExtension } = require('expo-share-extension/metro');
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand All @@ -8,7 +8,8 @@ const path = require('path');
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, '../..');

const config = getDefaultConfig(projectRoot);
/** Sentry 소스맵(Debug ID) 생성을 위해 getDefaultConfig 대신 사용 */
const config = getSentryExpoConfig(projectRoot);

config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
Expand Down
1 change: 1 addition & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@sentry/react-native": "~7.2.0",
"expo": "~54.0.33",
"expo-apple-authentication": "~8.0.8",
"expo-application": "^7.0.8",
Expand Down
3 changes: 3 additions & 0 deletions apps/app/types/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ declare namespace NodeJS {
EXPO_PUBLIC_KAKAO_NATIVE_APP_KEY: string;
EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: string;
EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: string;
/** 배포 환경: production | staging | dev */
EXPO_PUBLIC_STAGE: string;
EXPO_PUBLIC_SENTRY_DSN: string;
}
}
16 changes: 16 additions & 0 deletions apps/app/utils/captureError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/react-native';

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

/** 잡은(caught) 에러를 Sentry에 수동 리포트 — 태그/컨텍스트를 일관되게 부착 */
export const captureError = (error: unknown, context?: CaptureErrorContextT) => {
Sentry.captureException(error, {
tags: context?.tags,
extra: context?.extra,
});
};
1 change: 1 addition & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ yarn-error.log*

# env files (can opt-in for commiting if needed)
.env*
!.env.example

# vercel
.vercel
Expand Down
41 changes: 41 additions & 0 deletions apps/web/instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as Sentry from '@sentry/nextjs';

/** production/staging 에서만 수집 (dev/local 비활성) */
const stage = process.env.NEXT_PUBLIC_STAGE;
const enabled = stage === 'production' || stage === 'staging';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: stage,
enabled,
...(process.env.NEXT_PUBLIC_WEB_VERSION ? { release: process.env.NEXT_PUBLIC_WEB_VERSION } : {}),
/** Performance(Tracing)는 초기엔 off */
tracesSampleRate: 0,
/** Session Replay — 에러 발생 세션만 녹화 */
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({
/** 렌더된 콘텐츠·이미지는 노출(디버깅용), 유저가 타이핑하는 입력창만 마스킹 */
maskAllText: false,
blockAllMedia: false,
maskAllInputs: true,
mask: ['[data-sentry-mask]'],
}),
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/** PII 기본 마스킹 */
sendDefaultPii: false,
/** 브라우저 확장·네트워크 취소 등 우리 코드와 무관한 노이즈 제외 */
ignoreErrors: [
'Network Error',
'Failed to fetch',
'Load failed',
'AbortError',
'The operation was aborted',
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed with undelivered notifications',
'Non-Error promise rejection captured',
],
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
8 changes: 8 additions & 0 deletions apps/web/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/nextjs';

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;
17 changes: 16 additions & 1 deletion apps/web/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { withSentryConfig } from '@sentry/nextjs';

import { getWebVersion } from './config/getWebVersion.mjs';

/** @type {import('next').NextConfig} */
Expand Down Expand Up @@ -45,4 +47,17 @@ const nextConfig = async () => {
};
};

export default nextConfig;
export default withSentryConfig(nextConfig, {
org: 'piki-92',
project: 'piki-web',
/** SENTRY_AUTH_TOKEN 이 있을 때만 소스맵 업로드 (CI/Vercel env 주입) */
silent: !process.env.CI,
/** 소스맵 업로드 후 클라이언트 번들에서 제거 (원본 코드 노출 방지) */
sourcemaps: {
deleteSourcemapsAfterUpload: true,
},
/** Sentry SDK 디버그 로그 제거로 번들 크기 절감 */
bundleSizeOptimizations: {
excludeDebugStatements: true,
},
});
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@microsoft/fetch-event-source": "^2.0.1",
"@next/third-parties": "^16.2.9",
"@piki/core": "workspace:*",
"@sentry/nextjs": "^10.63.0",
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-query-devtools": "^5.96.2",
"axios": "^1.15.2",
Expand Down
14 changes: 14 additions & 0 deletions apps/web/sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';

/** 배포 환경(production/staging/dev). Sentry 는 production/staging 에서만 수집 (dev·로컬 비활성) */
const stage = process.env.NEXT_PUBLIC_STAGE;
const enabled = stage === 'production' || stage === 'staging';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: stage,
enabled,
...(process.env.NEXT_PUBLIC_WEB_VERSION ? { release: process.env.NEXT_PUBLIC_WEB_VERSION } : {}),
tracesSampleRate: 0,
sendDefaultPii: false,
});
14 changes: 14 additions & 0 deletions apps/web/sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';

/** 배포 환경(production/staging/dev). Sentry 는 production/staging 에서만 수집 (dev·로컬 비활성) */
const stage = process.env.NEXT_PUBLIC_STAGE;
const enabled = stage === 'production' || stage === 'staging';

Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: stage,
enabled,
...(process.env.NEXT_PUBLIC_WEB_VERSION ? { release: process.env.NEXT_PUBLIC_WEB_VERSION } : {}),
tracesSampleRate: 0,
sendDefaultPii: false,
});
Loading
Loading