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:
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '프로필 생성 실패: 예상치 못한 오류';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
export class UserService {
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `🚫 도메인 추천 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 `📚 문서 조회 서비스에 일시적으로 접근할 수 없습니다. 잠시 후 다시 시도해주세요.`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user