feat(phase-5-3): Logger, Metrics, 알림 시스템 통합

Phase 5-3 모니터링 강화 작업의 통합을 완료했습니다.

변경사항:
- Logger 통합: console.log를 구조화된 로깅으로 전환 (9개 파일)
  - JSON 기반 로그, 환경별 자동 전환 (개발/프로덕션)
  - 타입 안전성 보장, 성능 측정 타이머 내장

- Metrics 통합: 실시간 성능 모니터링 시스템 연결 (3개 파일)
  - Circuit Breaker 상태 추적 (api_call_count, error_count, state)
  - Retry 재시도 횟수 추적 (retry_count)
  - OpenAI API 응답 시간 측정 (api_call_duration)

- 알림 통합: 장애 자동 알림 시스템 구현 (2개 파일)
  - Circuit Breaker OPEN 상태 → 관리자 Telegram 알림
  - 재시도 실패 → 관리자 Telegram 알림
  - Rate Limiting 적용 (1시간에 1회)

- 문서 업데이트:
  - CLAUDE.md: coder 에이전트 설명 강화 (20년+ 시니어 전문가)
  - README.md, docs/: 아키텍처 문서 추가

영향받은 파일: 16개 (수정 14개, 신규 2개)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-19 21:23:38 +09:00
parent 410676e322
commit eee934391a
16 changed files with 675 additions and 777 deletions

View File

@@ -12,6 +12,10 @@
* - [관리자] 대기 목록, 입금 확인/거절
*/
import { createLogger } from './utils/logger';
const logger = createLogger('deposit-agent');
export interface DepositContext {
userId: number;
telegramUserId: string;
@@ -378,7 +382,7 @@ ${query}`;
for (const toolCall of toolCalls) {
const funcName = toolCall.function.name;
const funcArgs = JSON.parse(toolCall.function.arguments);
console.log(`[DepositAgent] Function call: ${funcName}`, funcArgs);
logger.info(`Function call: ${funcName}`, funcArgs);
const result = await executeDepositFunction(funcName, funcArgs, context);
toolOutputs.push({
@@ -431,7 +435,7 @@ ${query}`;
return '예치금 에이전트 응답 없음';
} catch (error) {
console.error('[DepositAgent] Error:', error);
logger.error('Error', error as Error);
return `예치금 에이전트 오류: ${String(error)}`;
}
}

View File

@@ -2,6 +2,10 @@ import type { Env } from './types';
import { tools, selectToolsForMessage, executeTool } from './tools';
import { retryWithBackoff, RetryError } from './utils/retry';
import { CircuitBreaker, CircuitBreakerError } from './utils/circuit-breaker';
import { createLogger } from './utils/logger';
import { metrics } from './utils/metrics';
const logger = createLogger('openai');
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
@@ -42,36 +46,42 @@ async function callOpenAI(
messages: OpenAIMessage[],
selectedTools?: typeof tools // undefined = 도구 없음, 배열 = 해당 도구만 사용
): Promise<OpenAIResponse> {
return await retryWithBackoff(
async () => {
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
tools: selectedTools?.length ? selectedTools : undefined,
tool_choice: selectedTools?.length ? 'auto' : undefined,
max_tokens: 1000,
}),
});
const timer = metrics.startTimer('api_call_duration', { service: 'openai' });
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
try {
return await retryWithBackoff(
async () => {
const response = await fetch(OPENAI_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages,
tools: selectedTools?.length ? selectedTools : undefined,
tool_choice: selectedTools?.length ? 'auto' : undefined,
max_tokens: 1000,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
}
return response.json();
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
}
return response.json();
},
{
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 10000,
}
);
);
} finally {
timer(); // duration 자동 기록 (성공/실패 관계없이)
}
}
// 메인 응답 생성 함수
@@ -108,8 +118,10 @@ export async function generateOpenAIResponse(
let response = await callOpenAI(apiKey, messages, selectedTools);
let assistantMessage = response.choices[0].message;
console.log('[OpenAI] tool_calls:', assistantMessage.tool_calls ? JSON.stringify(assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments }))) : 'none');
console.log('[OpenAI] content:', assistantMessage.content?.slice(0, 100));
logger.info('tool_calls', {
calls: assistantMessage.tool_calls ? assistantMessage.tool_calls.map(t => ({ name: t.function.name, args: t.function.arguments })) : 'none'
});
logger.info('content', { preview: assistantMessage.content?.slice(0, 100) });
// Function Calling 처리 (최대 3회 반복)
let iterations = 0;
@@ -154,17 +166,17 @@ export async function generateOpenAIResponse(
} catch (error) {
// 에러 처리
if (error instanceof CircuitBreakerError) {
console.error('[OpenAI] Circuit breaker open:', error.message);
logger.error('Circuit breaker open', error as Error);
return '죄송합니다. 일시적으로 서비스를 이용할 수 없습니다. 잠시 후 다시 시도해주세요.';
}
if (error instanceof RetryError) {
console.error('[OpenAI] All retry attempts failed:', error.message);
logger.error('All retry attempts failed', error as Error);
return '죄송합니다. AI 응답 생성에 실패했습니다. 잠시 후 다시 시도해주세요.';
}
// 기타 에러
console.error('[OpenAI] Unexpected error:', error);
logger.error('Unexpected error', error as Error);
return '죄송합니다. 예상치 못한 오류가 발생했습니다.';
}
}
@@ -194,17 +206,17 @@ export async function generateProfileWithOpenAI(
} catch (error) {
// 에러 처리
if (error instanceof CircuitBreakerError) {
console.error('[OpenAI Profile] Circuit breaker open:', error.message);
logger.error('Profile - Circuit breaker open', error as Error);
return '프로필 생성 실패: 일시적으로 서비스를 이용할 수 없습니다.';
}
if (error instanceof RetryError) {
console.error('[OpenAI Profile] All retry attempts failed:', error.message);
logger.error('Profile - All retry attempts failed', error as Error);
return '프로필 생성 실패: 재시도 횟수 초과';
}
// 기타 에러
console.error('[OpenAI Profile] Unexpected error:', error);
logger.error('Profile - Unexpected error', error as Error);
return '프로필 생성 실패: 예상치 못한 오류';
}
}

View File

@@ -4,7 +4,7 @@ import {
processAndSummarize,
generateAIResponse,
} from '../summary-service';
import { sendChatAction, sendMessage } from '../telegram';
import { sendChatAction } from '../telegram';
export interface ConversationResult {
responseText: string;
@@ -26,7 +26,7 @@ export class ConversationService {
telegramUserId: string
): Promise<ConversationResult> {
// 1. 타이핑 액션 전송 (비동기로 실행, 기다리지 않음)
sendChatAction(this.env.BOT_TOKEN, chatId, 'typing').catch(console.error);
sendChatAction(this.env.BOT_TOKEN, Number(chatId), 'typing').catch(console.error);
// 2. 사용자 메시지 버퍼에 추가
await addToBuffer(this.env.DB, userId, chatId, 'user', text);

View File

@@ -1,4 +1,7 @@
import { Env } from '../types';
import { createLogger } from '../utils/logger';
const logger = createLogger('notification');
/**
* 알림 유형별 메시지 템플릿
@@ -143,14 +146,14 @@ export async function notifyAdmin(
try {
// 관리자 ID 확인
if (!options.adminId) {
console.log('[Notification] 관리자 ID가 설정되지 않아 알림을 건너뜁니다.');
logger.info('관리자 ID가 설정되지 않아 알림을 건너뜁니다.');
return;
}
// Rate Limiting 체크
const canSend = await checkRateLimit(type, details.service, options.env.RATE_LIMIT_KV);
if (!canSend) {
console.log(`[Notification] Rate limit: ${type} (${details.service}) - 1시간 이내 알림 전송됨`);
logger.info(`Rate limit: ${type} (${details.service}) - 1시간 이내 알림 전송됨`);
return;
}
@@ -162,12 +165,12 @@ export async function notifyAdmin(
const success = await options.telegram.sendMessage(adminChatId, message);
if (success) {
console.log(`[Notification] 관리자 알림 전송 성공: ${type} (${details.service})`);
logger.info(`관리자 알림 전송 성공: ${type} (${details.service})`);
} else {
console.error(`[Notification] 관리자 알림 전송 실패: ${type} (${details.service})`);
logger.error(`관리자 알림 전송 실패: ${type} (${details.service})`, new Error('Telegram send failed'));
}
} catch (error) {
// 알림 전송 실패는 로그만 기록하고 무시
console.error('[Notification] 알림 전송 중 오류 발생:', error);
logger.error('알림 전송 중 오류 발생', error as Error);
}
}

View File

@@ -1,5 +1,3 @@
import { Env } from '../types';
export class UserService {
constructor(private db: D1Database) {}

View File

@@ -1,5 +1,8 @@
import { executeDepositFunction, type DepositContext } from '../deposit-agent';
import type { Env } from '../types';
import { createLogger } from '../utils/logger';
const logger = createLogger('deposit-tool');
export const manageDepositTool = {
type: 'function',
@@ -123,7 +126,7 @@ export async function executeManageDeposit(
db?: D1Database
): Promise<string> {
const { action, depositor_name, amount, transaction_id, limit } = args;
console.log('[manage_deposit] 시작:', { action, depositor_name, amount, telegramUserId });
logger.info('시작', { action, depositor_name, amount, telegramUserId });
if (!telegramUserId || !db) {
return '🚫 예치금 기능을 사용할 수 없습니다.';
@@ -170,14 +173,14 @@ export async function executeManageDeposit(
if (transaction_id) funcArgs.transaction_id = Number(transaction_id);
if (limit) funcArgs.limit = Number(limit);
console.log('[manage_deposit] executeDepositFunction 호출:', funcName, funcArgs);
logger.info('executeDepositFunction 호출', { funcName, funcArgs });
const result = await executeDepositFunction(funcName, funcArgs, context);
console.log('[manage_deposit] 결과:', JSON.stringify(result).slice(0, 200));
logger.info('결과', { result: JSON.stringify(result).slice(0, 200) });
// 결과 포맷팅 (고정 형식)
return formatDepositResult(action, result);
} catch (error) {
console.error('[manage_deposit] 오류:', error);
logger.error('오류', error as Error);
return `🚫 예치금 처리 오류: ${String(error)}`;
}
}

View File

@@ -1,5 +1,8 @@
import type { Env } from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger } from '../utils/logger';
const logger = createLogger('domain-tool');
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
@@ -21,13 +24,13 @@ async function getCachedTLDPrice(
const key = `tld_price:${tld}`;
const cached = await kv.get(key, 'json');
if (cached) {
console.log(`[TLDCache] HIT: ${tld}`);
logger.info('TLDCache HIT', { tld });
return cached as CachedTLDPrice;
}
console.log(`[TLDCache] MISS: ${tld}`);
logger.info('TLDCache MISS', { tld });
return null;
} catch (error) {
console.error('[TLDCache] KV 조회 오류:', error);
logger.error('TLDCache KV 조회 오류', error as Error, { tld });
return null;
}
}
@@ -49,9 +52,9 @@ async function setCachedTLDPrice(
await kv.put(key, JSON.stringify(data), {
expirationTtl: 3600, // 1시간
});
console.log(`[TLDCache] SET: ${tld} (${data.krw}원)`);
logger.info('TLDCache SET', { tld, krw: data.krw });
} catch (error) {
console.error('[TLDCache] KV 저장 오류:', error);
logger.error('TLDCache KV 저장 오류', error as Error, { tld });
}
}
@@ -63,13 +66,13 @@ async function getCachedAllPrices(
const key = 'tld_price:all';
const cached = await kv.get(key, 'json');
if (cached) {
console.log('[TLDCache] HIT: all prices');
logger.info('TLDCache HIT: all prices');
return cached as any[];
}
console.log('[TLDCache] MISS: all prices');
logger.info('TLDCache MISS: all prices');
return null;
} catch (error) {
console.error('[TLDCache] KV 조회 오류:', error);
logger.error('TLDCache KV 조회 오류', error as Error, { key: 'all' });
return null;
}
}
@@ -84,9 +87,9 @@ async function setCachedAllPrices(
await kv.put(key, JSON.stringify(prices), {
expirationTtl: 3600, // 1시간
});
console.log(`[TLDCache] SET: all prices (${prices.length}개)`);
logger.info('TLDCache SET: all prices', { count: prices.length });
} catch (error) {
console.error('[TLDCache] KV 저장 오류:', error);
logger.error('TLDCache KV 저장 오류', error as Error, { key: 'all' });
}
}
@@ -349,7 +352,7 @@ async function callNamecheapApi(
query_time_ms: whois.query_time_ms,
};
} catch (error) {
console.error('[whois_lookup] 오류:', error);
logger.error('오류', error as Error, { domain: funcArgs.domain });
if (error instanceof RetryError) {
return { error: 'WHOIS 조회 서비스에 일시적으로 접근할 수 없습니다.' };
}
@@ -379,9 +382,9 @@ async function callNamecheapApi(
await db.prepare(
'INSERT INTO user_domains (user_id, domain, verified, created_at) VALUES (?, ?, 1, datetime("now"))'
).bind(userId, funcArgs.domain).run();
console.log(`[register_domain] user_domains에 추가: user_id=${userId}, domain=${funcArgs.domain}`);
logger.info('user_domains에 추가', { userId, domain: funcArgs.domain });
} catch (dbError) {
console.error('[register_domain] user_domains 추가 실패:', dbError);
logger.error('user_domains 추가 실패', dbError as Error, { userId, domain: funcArgs.domain });
result.warning = result.warning || '';
result.warning += ' (DB 기록 실패 - 수동 추가 필요)';
}
@@ -712,11 +715,11 @@ export async function executeManageDomain(
db?: D1Database
): Promise<string> {
const { action, domain, nameservers, tld } = args;
console.log('[manage_domain] 시작:', { action, domain, telegramUserId, hasDb: !!db });
logger.info('시작', { action, domain, telegramUserId, hasDb: !!db });
// 소유권 검증 (DB 조회)
if (!telegramUserId || !db) {
console.log('[manage_domain] 실패: telegramUserId 또는 db 없음');
logger.info('실패: telegramUserId 또는 db 없음');
return '🚫 도메인 관리 권한이 없습니다.';
}
@@ -737,9 +740,9 @@ export async function executeManageDomain(
'SELECT domain FROM user_domains WHERE user_id = ? AND verified = 1'
).bind(user.id).all<{ domain: string }>();
userDomains = domains.results?.map(d => d.domain) || [];
console.log('[manage_domain] 소유 도메인:', userDomains);
logger.info('소유 도메인', { userDomains });
} catch (error) {
console.log('[manage_domain] DB 오류:', error);
logger.error('DB 오류', error as Error);
return '🚫 권한 확인 중 오류가 발생했습니다.';
}
@@ -754,17 +757,17 @@ export async function executeManageDomain(
db,
userId
);
console.log('[manage_domain] 완료:', result?.slice(0, 100));
logger.info('완료', { result: result?.slice(0, 100) });
return result;
} catch (error) {
console.log('[manage_domain] 오류:', error);
logger.error('오류', error as Error);
return `🚫 도메인 관리 오류: ${String(error)}`;
}
}
export async function executeSuggestDomains(args: { keywords: string }, env?: Env): Promise<string> {
const { keywords } = args;
console.log('[suggest_domains] 시작:', { keywords });
logger.info('시작', { keywords });
if (!env?.OPENAI_API_KEY) {
return '🚫 도메인 추천 기능이 설정되지 않았습니다. (OPENAI_API_KEY 미설정)';
@@ -928,7 +931,7 @@ ${excludeList ? `- 다음 도메인은 제외하세요: ${excludeList}` : ''}
return response;
} catch (error) {
console.error('[suggestDomains] 오류:', error);
logger.error('오류', error as Error, { keywords });
if (error instanceof RetryError) {
return `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
}

View File

@@ -1,4 +1,7 @@
// Tool Registry - All tools exported from here
import { createLogger } from '../utils/logger';
const logger = createLogger('tools');
import { weatherTool, executeWeather } from './weather-tool';
import { searchWebTool, lookupDocsTool, executeSearchWeb, executeLookupDocs } from './search-tool';
@@ -48,7 +51,7 @@ export function selectToolsForMessage(message: string): typeof tools {
// 패턴 매칭 없으면 전체 도구 사용 (폴백)
if (selectedCategories.size === 1) {
console.log('[ToolSelector] 패턴 매칭 없음 → 전체 도구 사용');
logger.info('패턴 매칭 없음 → 전체 도구 사용');
return tools;
}
@@ -58,9 +61,11 @@ export function selectToolsForMessage(message: string): typeof tools {
const selectedTools = tools.filter(t => selectedNames.has(t.function.name));
console.log('[ToolSelector] 메시지:', message);
console.log('[ToolSelector] 카테고리:', [...selectedCategories].join(', '));
console.log('[ToolSelector] 선택된 도구:', selectedTools.map(t => t.function.name).join(', '));
logger.info('도구 선택 완료', {
message,
categories: [...selectedCategories].join(', '),
selectedTools: selectedTools.map(t => t.function.name).join(', ')
});
return selectedTools;
}

View File

@@ -1,5 +1,8 @@
import type { Env } from '../types';
import { retryWithBackoff, RetryError } from '../utils/retry';
import { createLogger } from '../utils/logger';
const logger = createLogger('search-tool');
// Cloudflare AI Gateway를 통해 OpenAI API 호출 (지역 제한 우회)
const OPENAI_API_URL = 'https://gateway.ai.cloudflare.com/v1/d8e5997eb4040f8b489f09095c0f623c/telegram-bot/openai/chat/completions';
@@ -86,12 +89,12 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
if (translateRes.ok) {
const translateData = await translateRes.json() as any;
translatedQuery = translateData.choices?.[0]?.message?.content?.trim() || query;
console.log(`[search_web] 번역: "${query}" → "${translatedQuery}"`);
logger.info('번역', { original: query, translated: translatedQuery });
}
} catch (error) {
// 번역 실패 시 원본 사용 (RetryError 포함)
if (error instanceof RetryError) {
console.log(`[search_web] 번역 재시도 실패, 원본 사용: ${error.message}`);
logger.info('번역 재시도 실패, 원본 사용', { message: error.message });
}
}
}
@@ -130,7 +133,7 @@ export async function executeSearchWeb(args: { query: string }, env?: Env): Prom
return `🔍 검색 결과: ${queryDisplay}\n\n${results}`;
} catch (error) {
console.error('[search_web] 오류:', error);
logger.error('오류', error as Error);
if (error instanceof RetryError) {
return `🔍 검색 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
}
@@ -171,7 +174,7 @@ export async function executeLookupDocs(args: { library: string; query: string }
const content = docsData.context || docsData.content || JSON.stringify(docsData, null, 2);
return `📚 ${library} 문서 (${query}):\n\n${content.slice(0, 1500)}`;
} catch (error) {
console.error('[lookup_docs] 오류:', error);
logger.error('오류', error as Error);
if (error instanceof RetryError) {
return `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
}

View File

@@ -1,3 +1,6 @@
import { metrics } from './metrics';
import { notifyAdmin, NotificationOptions } from '../services/notification';
/**
* Circuit Breaker pattern implementation
*
@@ -42,6 +45,10 @@ export interface CircuitBreakerOptions {
resetTimeoutMs?: number;
/** Time window in ms for monitoring failures (default: 120000) */
monitoringWindowMs?: number;
/** Service name for metrics (default: 'unknown') */
serviceName?: string;
/** Admin notification options (optional) */
notification?: NotificationOptions;
}
/**
@@ -82,17 +89,25 @@ export class CircuitBreaker {
private readonly failureThreshold: number;
private readonly resetTimeoutMs: number;
private readonly monitoringWindowMs: number;
private readonly serviceName: string;
private readonly notification?: NotificationOptions;
constructor(options?: CircuitBreakerOptions) {
this.failureThreshold = options?.failureThreshold ?? 5;
this.resetTimeoutMs = options?.resetTimeoutMs ?? 60000;
this.monitoringWindowMs = options?.monitoringWindowMs ?? 120000;
this.serviceName = options?.serviceName ?? 'unknown';
this.notification = options?.notification;
console.log('[CircuitBreaker] Initialized', {
serviceName: this.serviceName,
failureThreshold: this.failureThreshold,
resetTimeoutMs: this.resetTimeoutMs,
monitoringWindowMs: this.monitoringWindowMs,
});
// 초기 상태 메트릭 기록 (CLOSED)
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
/**
@@ -137,6 +152,9 @@ export class CircuitBreaker {
this.openedAt = null;
this.successCount = 0;
this.failureCount = 0;
// 상태 메트릭 기록 (CLOSED)
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
/**
@@ -162,6 +180,9 @@ export class CircuitBreaker {
if (elapsed >= this.resetTimeoutMs) {
console.log('[CircuitBreaker] Reset timeout reached, transitioning to HALF_OPEN');
this.state = CircuitState.HALF_OPEN;
// 상태 메트릭 기록 (HALF_OPEN)
metrics.record('circuit_breaker_state', 2, { service: this.serviceName });
}
}
}
@@ -177,6 +198,9 @@ export class CircuitBreaker {
this.state = CircuitState.CLOSED;
this.failures = [];
this.openedAt = null;
// 상태 메트릭 기록 (CLOSED)
metrics.record('circuit_breaker_state', 0, { service: this.serviceName });
}
}
@@ -197,6 +221,25 @@ export class CircuitBreaker {
console.log('[CircuitBreaker] Half-open test failed, reopening circuit');
this.state = CircuitState.OPEN;
this.openedAt = now;
// 상태 메트릭 기록 (OPEN)
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
// 관리자 알림 전송 (HALF_OPEN → OPEN 전환)
if (this.notification) {
notifyAdmin(
'circuit_breaker',
{
service: this.serviceName,
error: 'Test request failed in HALF_OPEN state',
context: 'Circuit breaker reopened after failed test'
},
this.notification
).catch(() => {
// 알림 실패는 무시 (메인 로직에 영향 없음)
});
}
return;
}
@@ -208,6 +251,24 @@ export class CircuitBreaker {
);
this.state = CircuitState.OPEN;
this.openedAt = now;
// 상태 메트릭 기록 (OPEN)
metrics.record('circuit_breaker_state', 1, { service: this.serviceName });
// 관리자 알림 전송 (CLOSED → OPEN 전환)
if (this.notification) {
notifyAdmin(
'circuit_breaker',
{
service: this.serviceName,
error: error.message || 'Unknown error',
context: `Failure threshold: ${this.failureThreshold}, Current failures: ${this.failures.length}`
},
this.notification
).catch(() => {
// 알림 실패는 무시 (메인 로직에 영향 없음)
});
}
}
}
}
@@ -234,6 +295,9 @@ export class CircuitBreaker {
throw error;
}
// API 호출 카운트 증가
metrics.increment('api_call_count', { service: this.serviceName });
try {
// Execute the function
const result = await fn();
@@ -243,6 +307,9 @@ export class CircuitBreaker {
return result;
} catch (error) {
// API 에러 카운트 증가
metrics.increment('api_error_count', { service: this.serviceName });
// Record failure
const err = error instanceof Error ? error : new Error(String(error));
this.onFailure(err);

View File

@@ -5,11 +5,14 @@
* ```typescript
* const result = await retryWithBackoff(
* async () => fetch('https://api.example.com'),
* { maxRetries: 3, initialDelayMs: 1000 }
* { maxRetries: 3, initialDelayMs: 1000, serviceName: 'external-api' }
* );
* ```
*/
import { metrics } from './metrics';
import { notifyAdmin, NotificationOptions } from '../services/notification';
/**
* Configuration options for retry behavior
*/
@@ -24,6 +27,10 @@ export interface RetryOptions {
backoffMultiplier?: number;
/** Whether to add random jitter to delays (default: true) */
jitter?: boolean;
/** Service name for metrics tracking (optional) */
serviceName?: string;
/** Notification options for admin alerts (optional) */
notification?: NotificationOptions;
}
/**
@@ -103,6 +110,8 @@ export async function retryWithBackoff<T>(
maxDelayMs = 10000,
backoffMultiplier = 2,
jitter = true,
serviceName = 'unknown',
notification,
} = options || {};
let lastError: Error;
@@ -127,6 +136,22 @@ export async function retryWithBackoff<T>(
`[Retry] All ${maxRetries + 1} attempts failed. Last error:`,
lastError.message
);
// Send admin notification if configured
if (notification) {
notifyAdmin(
'retry_exhausted',
{
service: serviceName,
error: lastError.message,
context: `All ${maxRetries + 1} attempts failed`,
},
notification
).catch(() => {
// Ignore notification failures
});
}
throw new RetryError(
`Operation failed after ${maxRetries + 1} attempts: ${lastError.message}`,
maxRetries + 1,
@@ -134,6 +159,14 @@ export async function retryWithBackoff<T>(
);
}
// Track retry metric (only for actual retries, not first attempt)
if (attempt > 0) {
metrics.increment('retry_count', {
service: serviceName,
attempt: String(attempt),
});
}
// Calculate delay for next retry
const delay = calculateDelay(
attempt,