feat(WEB): 에러 리포팅 시스템 및 ErrorBoundary 개선
- 에러 리포팅 유틸리티 및 API route 추가 - ErrorBoundary(error.tsx) 에러 리포팅 연동 - providers 컴포넌트 구조 추가 - layout에 provider 적용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
178
src/app/api/error-report/route.ts
Normal file
178
src/app/api/error-report/route.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* POST /api/error-report
|
||||
*
|
||||
* 클라이언트 에러를 수신하여 Slack Webhook으로 전송
|
||||
* - 서버 측 스로틀링: 동일 에러 30초 쿨다운
|
||||
* - Webhook URL 미설정 시 조용히 실패
|
||||
*/
|
||||
|
||||
interface ErrorReport {
|
||||
errorType: 'ERROR_BOUNDARY' | 'CHUNK_LOAD_ERROR';
|
||||
message: string;
|
||||
pageUrl: string;
|
||||
timestamp: string;
|
||||
digest?: string;
|
||||
stack?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
const VALID_TYPES = new Set(['ERROR_BOUNDARY', 'CHUNK_LOAD_ERROR']);
|
||||
const SERVER_COOLDOWN_MS = 30_000;
|
||||
const MAX_THROTTLE_ENTRIES = 500;
|
||||
const recentErrors = new Map<string, number>();
|
||||
|
||||
function cleanupThrottleMap() {
|
||||
if (recentErrors.size <= MAX_THROTTLE_ENTRIES) return;
|
||||
const now = Date.now();
|
||||
for (const [key, ts] of recentErrors) {
|
||||
if (now - ts > SERVER_COOLDOWN_MS) {
|
||||
recentErrors.delete(key);
|
||||
}
|
||||
}
|
||||
// 정리 후에도 초과하면 전체 초기화
|
||||
if (recentErrors.size > MAX_THROTTLE_ENTRIES) {
|
||||
recentErrors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isThrottled(report: ErrorReport): boolean {
|
||||
const key = `${report.errorType}::${report.message}`;
|
||||
const now = Date.now();
|
||||
const last = recentErrors.get(key);
|
||||
|
||||
if (last && now - last < SERVER_COOLDOWN_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
cleanupThrottleMap();
|
||||
recentErrors.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getErrorLabel(type: string): string {
|
||||
return type === 'ERROR_BOUNDARY'
|
||||
? 'Error Boundary (컴포넌트 크래시)'
|
||||
: 'ChunkLoadError (JS 로딩 실패)';
|
||||
}
|
||||
|
||||
function buildSlackPayload(report: ErrorReport) {
|
||||
const blocks: Record<string, unknown>[] = [
|
||||
{
|
||||
type: 'header',
|
||||
text: { type: 'plain_text', text: '🔴 Frontend Error', emoji: true },
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Type:*\n${getErrorLabel(report.errorType)}` },
|
||||
{ type: 'mrkdwn', text: `*Time:*\n${report.timestamp}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
fields: [
|
||||
{ type: 'mrkdwn', text: `*Page:*\n${report.pageUrl}` },
|
||||
...(report.digest
|
||||
? [{ type: 'mrkdwn', text: `*Digest:*\n${report.digest}` }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Error:*\n\`\`\`${report.message.slice(0, 300)}\`\`\``,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (report.stack) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: {
|
||||
type: 'mrkdwn',
|
||||
text: `*Stack:*\n\`\`\`${report.stack.slice(0, 300)}\`\`\``,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (report.userAgent) {
|
||||
blocks.push({
|
||||
type: 'context',
|
||||
elements: [
|
||||
{ type: 'mrkdwn', text: `UA: ${report.userAgent.slice(0, 150)}` },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return { blocks };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// 입력 검증
|
||||
if (
|
||||
!body.errorType ||
|
||||
!VALID_TYPES.has(body.errorType) ||
|
||||
!body.message ||
|
||||
!body.pageUrl ||
|
||||
!body.timestamp
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid error report' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const report: ErrorReport = {
|
||||
errorType: body.errorType,
|
||||
message: String(body.message).slice(0, 1000),
|
||||
pageUrl: String(body.pageUrl).slice(0, 500),
|
||||
timestamp: String(body.timestamp),
|
||||
digest: body.digest ? String(body.digest).slice(0, 100) : undefined,
|
||||
stack: body.stack ? String(body.stack).slice(0, 500) : undefined,
|
||||
userAgent: body.userAgent
|
||||
? String(body.userAgent).slice(0, 300)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 서버 측 스로틀링
|
||||
if (isThrottled(report)) {
|
||||
return NextResponse.json({ status: 'throttled' });
|
||||
}
|
||||
|
||||
// Webhook URL 확인
|
||||
const webhookUrl = process.env.SLACK_FRONTEND_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
console.warn('[error-report] SLACK_FRONTEND_WEBHOOK_URL not configured');
|
||||
return NextResponse.json({ status: 'skipped' });
|
||||
}
|
||||
|
||||
// Slack 전송
|
||||
const slackRes = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildSlackPayload(report)),
|
||||
});
|
||||
|
||||
if (!slackRes.ok) {
|
||||
console.error('[error-report] Slack webhook failed:', slackRes.status);
|
||||
return NextResponse.json(
|
||||
{ status: 'slack_error' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: 'sent' });
|
||||
} catch (err) {
|
||||
console.error('[error-report] Unexpected error:', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user