diff --git a/.env.example b/.env.example index e1a5465..f5fb3a9 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,6 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret DISCORD_GUILD_ID=your_discord_guild_id ADMIN_DISCORD_IDS=discord_id_1,discord_id_2 -# Error Reporting (optional) -DISCORD_ERROR_WEBHOOK_URL=https://discord.com/api/webhooks/... - # Supabase Configuration NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key @@ -15,8 +12,9 @@ SUPABASE_SERVICE_KEY=your_supabase_service_key DATABASE_URL=postgresql://user:password@host:6543/database DATABASE_URL_DIRECT=postgresql://user:password@host:5432/database -# Sentry (web only — SENTRY_AUTH_TOKEN은 Vercel/CI에서만 설정, 로컬 불필요) +# Sentry (에러 모니터링) NEXT_PUBLIC_SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456 +SENTRY_DSN=https://your-dsn@o123.ingest.us.sentry.io/456 # SENTRY_AUTH_TOKEN=sntrys_xxx (CI/Vercel 환경변수로만 설정) # Application diff --git a/CLAUDE.md b/CLAUDE.md index 73a5de0..8a4903d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ deploy/ | 영역 | 기술 | |------|------| | Runtime | Node.js 22, TypeScript 5.x | -| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐) | +| Bot | discord.js v14, feedsmith (RSS 파서), pg-boss (PostgreSQL 잡 큐), Sentry (에러 모니터링) | | Web | Next.js 16 App Router, React 19, shadcn/ui, Tailwind CSS v4, Tiptap (리치 에디터), sonner (토스트), Framer Motion (랜딩 애니메이션), Sentry (에러 모니터링) | | DB | Supabase PostgreSQL + Drizzle ORM (Transaction Pooler, `prepare: false`) | | Auth | Supabase Auth (Discord OAuth) + `@supabase/ssr` | @@ -78,6 +78,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) | `packages/web/src/lib/rss-detect.ts` | 블로그 URL → RSS URL 자동 감지 | | `packages/web/src/app/(user)/layout.tsx` | 사용자 레이아웃 (상태 체크 + 리다이렉트) | | `packages/web/src/app/` | Next.js 페이지/라우트 | +| `packages/bot/src/lib/sentry.ts` | 봇 Sentry SDK 초기화 (PII 스크러빙, DB URL/토큰 마스킹) | | `packages/bot/src/bot.ts` | Discord 클라이언트 초기화 (이벤트 핸들러만) | | `packages/bot/src/job-queue.ts` | pg-boss 싱글톤 (시작/종료/조회) | | `packages/bot/src/scheduler-registry.ts` | 잡 등록 + RSS→Post→Notification 파이프라인 | @@ -199,6 +200,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_GUILD_ID` - `ADMIN_DISCORD_IDS` (관리자 Discord ID, 쉼표 구분) - `NEXT_PUBLIC_SENTRY_DSN` (Sentry 에러 모니터링, web 전용) +- `SENTRY_DSN` (Sentry 에러 모니터링, bot 전용) - `SENTRY_AUTH_TOKEN` (소스맵 업로드, Vercel/CI에서만 설정) **env 파일 위치** (2곳): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b1724a2..8c5dd28 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Blog Study Admin - 시스템 아키텍처 -> 최종 업데이트: 2026-03-13 (v5) +> 최종 업데이트: 2026-03-13 (v6) 블로그 글쓰기 스터디 운영 자동화 플랫폼. 웹 대시보드에서 모든 관리/유저 기능을 제공하고, Discord 봇은 스케줄러(RSS 수집/출석/벌금/큐레이션)와 이벤트 핸들러만 담당한다. @@ -35,6 +35,7 @@ graph TB SCH["Schedulers
pg-boss"] EVT["Event Handlers
discord.js v14"] SVC["Service Layer
RSS · Fine · Curation · Score"] + SENTRY_BOT["Sentry SDK
에러 모니터링 + PII 스크러빙"] end subgraph DB["Supabase · PostgreSQL"] @@ -71,6 +72,7 @@ graph TB API --> TABLES PAGES --> API SENTRY_WEB -->|tunnel /api/_sentry-tunnel| SENTRY + SENTRY_BOT -->|HTTPS| SENTRY SENTRY -->|Alert| DISCORD_WH ``` @@ -83,6 +85,7 @@ mindmap discord.js v14 pg-boss Job Queue feedsmith RSS + Sentry 에러 모니터링 Web Next.js 16 App Router React 19 @@ -437,7 +440,8 @@ erDiagram | **API 성공** | `successResponse(data, message?, status?)` 통일 | `lib/api-error.ts` | | **캐시** | `withCache(response, maxAge, scope?)` — 읽기 API에 Cache-Control 적용 | `lib/api-error.ts` | | **클라이언트 에러** | Error Boundary → `Sentry.captureException()` — 사용자/관리자/전역 | `(user)/error.tsx`, `(admin)/error.tsx`, `global-error.tsx` | -| **에러 모니터링** | Sentry SDK (DSN 가드, `beforeSend` PII 스크러빙, tunnel route) | `sentry.*.config.ts`, `instrumentation.ts`, `next.config.ts` | +| **웹 에러 모니터링** | Sentry SDK (DSN 가드, `beforeSend` PII 스크러빙, tunnel route) | `sentry.*.config.ts`, `instrumentation.ts`, `next.config.ts` | +| **봇 에러 모니터링** | Sentry Node SDK (`sendDefaultPii: false`, DB URL/토큰 마스킹) | `bot/src/lib/sentry.ts` | | **404** | 커스텀 Not Found 페이지 | `not-found.tsx` | | **사용자 피드백** | sonner 토스트 (`toast.success()`, `toast.error()`) | `layout.tsx` (``) | @@ -502,4 +506,5 @@ graph LR | Tiptap | 3.20 | Rich text editor | | shadcn/ui | latest | UI components | | Framer Motion | 12.x | Landing page animations | -| @sentry/nextjs | 10.43 | Error monitoring + source maps | +| @sentry/nextjs | 10.43 | Web error monitoring + source maps | +| @sentry/node | 10.43 | Bot error monitoring | diff --git a/packages/bot/package.json b/packages/bot/package.json index c5501ae..9567c8e 100644 --- a/packages/bot/package.json +++ b/packages/bot/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@blog-study/shared": "workspace:*", + "@sentry/node": "^10.43.0", "@types/express": "^5.0.6", "@types/express-rate-limit": "^6.0.2", "axios": "^1.13.5", diff --git a/packages/bot/src/api-server.ts b/packages/bot/src/api-server.ts index 7a02fe0..3e6b243 100644 --- a/packages/bot/src/api-server.ts +++ b/packages/bot/src/api-server.ts @@ -6,6 +6,7 @@ import express, { type Express } from 'express'; import rateLimit from 'express-rate-limit'; import logger from './lib/logger'; +import { Sentry } from './lib/sentry'; import { getRssPoller, getAttendanceChecker, @@ -47,6 +48,7 @@ export function createBotApiServer(): Express { const result = await rssPoller.poll(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] RSS poll error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -65,6 +67,7 @@ export function createBotApiServer(): Express { const result = await attendanceChecker.check(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Attendance check error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -83,6 +86,7 @@ export function createBotApiServer(): Express { const result = await fineReminder.sendReminders(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Fine reminder error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -101,6 +105,7 @@ export function createBotApiServer(): Express { const result = await roundReporter.sendRoundReport(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Round report error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -114,6 +119,7 @@ export function createBotApiServer(): Express { const result = await roundReporter.sendRoundStartAnnouncement(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Round start error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -132,6 +138,7 @@ export function createBotApiServer(): Express { const result = await curationCrawler.crawl(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Curation crawl error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -150,6 +157,7 @@ export function createBotApiServer(): Express { const result = await curationCrawler.shareDailyContent(); res.json({ success: true, result }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Curation share error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' @@ -177,6 +185,7 @@ export function createBotApiServer(): Express { res.json({ success: true, result: serializedResult }); } catch (error) { + Sentry.captureException(error); logger.error({ error }, '[API] Weekly ranking error'); res.status(500).json({ error: error instanceof Error ? error.message : '알 수 없는 오류' diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts index 452533a..78d51c5 100644 --- a/packages/bot/src/bot.ts +++ b/packages/bot/src/bot.ts @@ -1,6 +1,6 @@ import { Client, Events, GatewayIntentBits, } from 'discord.js'; import logger, { serializeError } from './lib/logger'; -import { reportError } from './lib/error-webhook'; +import { Sentry } from './lib/sentry'; /** * Create and configure the Discord bot client @@ -34,17 +34,10 @@ export function setupEventHandlers(client: Client): void { }); // Error handling - client.on(Events.Error, async (error) => { + client.on(Events.Error, (error) => { const errorObj = error instanceof Error ? error : new Error(String(error)); logger.error({ error: serializeError(errorObj) }, 'Discord client error'); - - // Send critical error notification to Discord webhook - await reportError(errorObj, { - location: 'Discord.Client', - event: 'error', - }).catch(() => { - // Ignore webhook errors - }); + Sentry.captureException(errorObj); }); client.on(Events.Warn, (warning) => { @@ -74,6 +67,7 @@ export function setupGracefulShutdown(client: Client, onShutdown?: () => Promise await onShutdown(); } client.destroy(); + await Sentry.flush(2000); process.exit(0); }; diff --git a/packages/bot/src/index.ts b/packages/bot/src/index.ts index db89e37..dbf6909 100644 --- a/packages/bot/src/index.ts +++ b/packages/bot/src/index.ts @@ -1,7 +1,10 @@ // @blog-study/bot // 큐스팅 4th 디스코드 봇 엔트리포인트 -import { loadBotEnv, getErrorWebhookUrl } from '@blog-study/shared'; +// Sentry must be initialized before anything else +import './lib/sentry'; + +import { loadBotEnv } from '@blog-study/shared'; import { createBotClient, setupEventHandlers, setupGracefulShutdown, startBot } from './bot'; import { startJobQueue, stopJobQueue } from './job-queue'; import { registerAllJobs } from './scheduler-registry'; @@ -10,7 +13,7 @@ import { setupDMHandler } from './handlers/dm-handler'; import { initNotificationService } from './services/notification.service'; import { startBotApiServer } from './api-server'; import logger, { serializeError } from './lib/logger'; -import { initErrorWebhook, reportError } from './lib/error-webhook'; +import { Sentry } from './lib/sentry'; async function main(): Promise { logger.info('Blog Study Discord Bot starting...'); @@ -19,13 +22,6 @@ async function main(): Promise { const env = loadBotEnv(); logger.info('Environment variables loaded'); - // Initialize error webhook if configured - const errorWebhookUrl = getErrorWebhookUrl(); - if (errorWebhookUrl) { - initErrorWebhook(errorWebhookUrl); - logger.info('Error webhook initialized'); - } - // Create bot client const client = createBotClient(); logger.debug('Bot client created'); @@ -58,18 +54,12 @@ async function main(): Promise { await startBot(client, env.DISCORD_TOKEN); } -main().catch((error) => { +main().catch(async (error) => { const errorObj = error instanceof Error ? error : new Error(String(error)); logger.error({ error: serializeError(errorObj) }, 'Failed to start bot'); + Sentry.captureException(errorObj); - // Send error notification to Discord webhook - reportError(errorObj, { - location: 'main()', - phase: 'bot-startup', - }).catch(() => { - // Ignore webhook errors during startup - }); - + await Sentry.flush(2000); process.exit(1); }); diff --git a/packages/bot/src/job-queue.ts b/packages/bot/src/job-queue.ts index a6cd6fc..d20620a 100644 --- a/packages/bot/src/job-queue.ts +++ b/packages/bot/src/job-queue.ts @@ -5,6 +5,7 @@ import { PgBoss } from 'pg-boss'; import logger from './lib/logger'; +import { Sentry } from './lib/sentry'; let boss: PgBoss | null = null; @@ -16,7 +17,10 @@ export async function startJobQueue(connectionString: string): Promise { // Simple connection string - use default pg-boss settings boss = new PgBoss(connectionString); - boss.on('error', (error: Error) => logger.error({ error }, '[pg-boss] Error')); + boss.on('error', (error: Error) => { + logger.error({ error }, '[pg-boss] Error'); + Sentry.captureException(error); + }); await boss.start(); logger.info('[pg-boss] Started'); diff --git a/packages/bot/src/lib/error-webhook.ts b/packages/bot/src/lib/error-webhook.ts deleted file mode 100644 index 6c716cf..0000000 --- a/packages/bot/src/lib/error-webhook.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Discord Webhook Error Reporter - * 심각한 에러 발생 시 Discord 웹훅으로 알림 전송 - */ - -import { WebhookClient } from 'discord.js'; - -let webhookClient: WebhookClient | null = null; - -/** - * Initialize Discord webhook for error reporting - */ -export function initErrorWebhook(webhookUrl: string): void { - try { - webhookClient = new WebhookClient({ url: webhookUrl }); - } catch (error) { - console.error('Failed to initialize error webhook:', error); - } -} - -/** - * Send error notification to Discord webhook - */ -export async function reportError( - error: Error, - context: { - location: string; - [key: string]: unknown; - } -): Promise { - if (!webhookClient) { - return; // Webhook not configured, silently skip - } - - try { - const errorMessage = error.message || String(error); - const errorStack = error.stack || 'No stack trace available'; - - const embed = { - title: '🚨 Bot Error', - description: `\`\`\`${errorMessage}\`\`\``, - color: 0xFF0000, // Red - fields: [ - { - name: '📍 Location', - value: context.location, - inline: true, - }, - { - name: '⏰ Time', - value: new Date().toISOString(), - inline: true, - }, - { - name: '🔧 Context', - value: '```json\n' + JSON.stringify(context, null, 2) + '\n```', - inline: false, - }, - ], - }; - - // Send stack trace in a file if too long - const stackTrace = errorStack.length > 1000 - ? `\`\`\`\n${errorStack.slice(0, 1000)}...\n\`\`\` (truncated)` - : `\`\`\`\n${errorStack}\n\`\`\``; - - await webhookClient.send({ - username: 'Bot Error Reporter', - avatarURL: 'https://github.githubassets.com/assets/oerror-404-77e4b2e.png', - embeds: [embed], - content: stackTrace, - }); - } catch (webhookError) { - // Don't throw errors when reporting errors (prevent infinite loop) - console.error('Failed to send error webhook:', webhookError); - } -} diff --git a/packages/bot/src/lib/sentry.ts b/packages/bot/src/lib/sentry.ts new file mode 100644 index 0000000..8e80dd1 --- /dev/null +++ b/packages/bot/src/lib/sentry.ts @@ -0,0 +1,44 @@ +import * as Sentry from '@sentry/node'; + +const dsn = process.env.SENTRY_DSN; + +if (dsn) { + Sentry.init({ + dsn, + environment: process.env.NODE_ENV ?? 'production', + tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 0, + sendDefaultPii: false, + debug: false, + beforeSend(event) { + // Strip cookies + if (event.request?.cookies) { + delete event.request.cookies; + } + + // Scrub sensitive patterns from exception messages and stack traces + const scrub = (str: string) => + str + .replace(/postgres(ql)?:\/\/[^\s"']+/gi, '[DATABASE_URL]') + .replace(/\b[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{6}\b/g, '[DISCORD_TOKEN]') + .replace(/\beyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+/g, '[JWT]'); + + if (event.exception?.values) { + for (const ex of event.exception.values) { + if (ex.value) ex.value = scrub(ex.value); + if (ex.stacktrace?.frames) { + for (const frame of ex.stacktrace.frames) { + if (frame.context_line) frame.context_line = scrub(frame.context_line); + } + } + } + } + + return event; + }, + }); +} else if (process.env.NODE_ENV === 'production') { + // eslint-disable-next-line no-console + console.warn('[Sentry] SENTRY_DSN is not set — error monitoring is disabled'); +} + +export { Sentry }; diff --git a/packages/bot/src/scheduler-registry.ts b/packages/bot/src/scheduler-registry.ts index 69ccaff..6fa4f27 100644 --- a/packages/bot/src/scheduler-registry.ts +++ b/packages/bot/src/scheduler-registry.ts @@ -24,6 +24,7 @@ import { extractOgImage } from '@blog-study/shared/utils'; import { getCurrentRound } from './services/round.service'; import { eq } from 'drizzle-orm'; import logger from './lib/logger'; +import { Sentry } from './lib/sentry'; /** * Job definitions with cron schedules @@ -180,6 +181,7 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise