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