log-collector-async 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- log_collector/__init__.py +13 -0
- log_collector/async_client.py +651 -0
- log_collector_async-1.1.0.dist-info/METADATA +804 -0
- log_collector_async-1.1.0.dist-info/RECORD +10 -0
- log_collector_async-1.1.0.dist-info/WHEEL +5 -0
- log_collector_async-1.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_async_client.py +292 -0
- tests/test_integration.py +372 -0
- tests/test_performance.py +143 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""
|
|
2
|
+
비동기 로그 클라이언트 - 프로덕션 구성
|
|
3
|
+
|
|
4
|
+
성능 목표:
|
|
5
|
+
- 앱 블로킹: < 0.1ms
|
|
6
|
+
- 배치 전송: 5-10ms (압축 사용 시)
|
|
7
|
+
- 메모리: < 10MB
|
|
8
|
+
- 유실률: < 0.01%
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import gzip
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import atexit
|
|
16
|
+
import traceback
|
|
17
|
+
import functools
|
|
18
|
+
import os
|
|
19
|
+
import inspect
|
|
20
|
+
from collections import deque
|
|
21
|
+
from threading import Thread, Event
|
|
22
|
+
from typing import Dict, Any, Optional, Callable
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from contextvars import ContextVar
|
|
25
|
+
import aiohttp
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from dotenv import load_dotenv
|
|
29
|
+
load_dotenv() # .env 파일 자동 로드
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass # python-dotenv 없으면 환경 변수만 사용
|
|
32
|
+
|
|
33
|
+
# HTTP 요청 컨텍스트 저장용 (웹 프레임워크 통합용)
|
|
34
|
+
_request_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar('request_context', default=None)
|
|
35
|
+
|
|
36
|
+
# 사용자 컨텍스트 저장용 (user_id, trace_id, session_id 등)
|
|
37
|
+
_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar('user_context', default=None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AsyncLogClient:
|
|
41
|
+
"""
|
|
42
|
+
비동기 로그 수집 클라이언트
|
|
43
|
+
|
|
44
|
+
특징:
|
|
45
|
+
- 로컬 큐 + 백그라운드 스레드
|
|
46
|
+
- 스마트 배치 (1000건 or 1초)
|
|
47
|
+
- 압축 전송 (100건 이상)
|
|
48
|
+
- Graceful shutdown
|
|
49
|
+
- 재시도 로직 (3회)
|
|
50
|
+
- duration_ms 자동 측정
|
|
51
|
+
- stack_trace 자동 추출
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
server_url: str = None,
|
|
57
|
+
service: Optional[str] = None,
|
|
58
|
+
environment: str = None,
|
|
59
|
+
service_version: str = None,
|
|
60
|
+
log_type: str = None,
|
|
61
|
+
batch_size: int = 1000,
|
|
62
|
+
flush_interval: float = 1.0,
|
|
63
|
+
max_queue_size: int = 10000,
|
|
64
|
+
enable_compression: bool = True,
|
|
65
|
+
max_retries: int = 3,
|
|
66
|
+
enable_global_error_handler: bool = False
|
|
67
|
+
):
|
|
68
|
+
"""
|
|
69
|
+
Args:
|
|
70
|
+
server_url: 로그 서버 URL (기본: 환경 변수 LOG_SERVER_URL)
|
|
71
|
+
service: 서비스 이름 (기본: 환경 변수 SERVICE_NAME)
|
|
72
|
+
environment: 환경 (기본: 환경 변수 ENVIRONMENT 또는 'development')
|
|
73
|
+
service_version: 서비스 버전 (기본: 환경 변수 SERVICE_VERSION 또는 'v0.0.0-dev')
|
|
74
|
+
log_type: 로그 타입 (기본: 환경 변수 LOG_TYPE 또는 'BACKEND')
|
|
75
|
+
batch_size: 배치 크기 (기본: 1000)
|
|
76
|
+
flush_interval: Flush 간격 (초, 기본: 1.0)
|
|
77
|
+
max_queue_size: 최대 큐 크기 (기본: 10000)
|
|
78
|
+
enable_compression: gzip 압축 활성화 (기본: True)
|
|
79
|
+
max_retries: 최대 재시도 횟수 (기본: 3)
|
|
80
|
+
enable_global_error_handler: 글로벌 에러 핸들러 활성화 (기본: False)
|
|
81
|
+
|
|
82
|
+
환경 변수 우선순위: 명시적 파라미터 > 환경 변수 > 기본값
|
|
83
|
+
|
|
84
|
+
.env 파일 예시:
|
|
85
|
+
LOG_SERVER_URL=http://localhost:8000
|
|
86
|
+
SERVICE_NAME=payment-api
|
|
87
|
+
ENVIRONMENT=production
|
|
88
|
+
SERVICE_VERSION=v1.2.3
|
|
89
|
+
LOG_TYPE=BACKEND
|
|
90
|
+
ENABLE_GLOBAL_ERROR_HANDLER=true
|
|
91
|
+
"""
|
|
92
|
+
# 환경 변수에서 자동 로드 (우선순위: 파라미터 > 환경 변수 > 기본값)
|
|
93
|
+
self.server_url = (server_url or os.getenv('LOG_SERVER_URL', 'http://localhost:8000')).rstrip('/')
|
|
94
|
+
self.service = service or os.getenv('SERVICE_NAME')
|
|
95
|
+
self.environment = environment or os.getenv('ENVIRONMENT', 'development')
|
|
96
|
+
self.service_version = service_version or os.getenv('SERVICE_VERSION', 'v0.0.0-dev')
|
|
97
|
+
self.log_type = log_type or os.getenv('LOG_TYPE', 'BACKEND')
|
|
98
|
+
self.batch_size = batch_size
|
|
99
|
+
self.flush_interval = flush_interval
|
|
100
|
+
self.max_queue_size = max_queue_size
|
|
101
|
+
self.enable_compression = enable_compression
|
|
102
|
+
self.max_retries = max_retries
|
|
103
|
+
self.enable_global_error_handler = enable_global_error_handler or os.getenv('ENABLE_GLOBAL_ERROR_HANDLER', 'false').lower() == 'true'
|
|
104
|
+
|
|
105
|
+
self.queue = deque(maxlen=max_queue_size)
|
|
106
|
+
self._stop_event = Event()
|
|
107
|
+
self._worker_thread: Optional[Thread] = None
|
|
108
|
+
self._original_excepthook = None
|
|
109
|
+
|
|
110
|
+
# 백그라운드 워커 시작
|
|
111
|
+
self._start_background_worker()
|
|
112
|
+
|
|
113
|
+
# Graceful shutdown 등록
|
|
114
|
+
atexit.register(self._graceful_shutdown)
|
|
115
|
+
|
|
116
|
+
# 글로벌 에러 핸들러 설정 (옵션)
|
|
117
|
+
if self.enable_global_error_handler:
|
|
118
|
+
self._setup_global_error_handler()
|
|
119
|
+
|
|
120
|
+
def log(
|
|
121
|
+
self,
|
|
122
|
+
level: str,
|
|
123
|
+
message: str,
|
|
124
|
+
auto_caller: bool = True,
|
|
125
|
+
**kwargs: Any
|
|
126
|
+
) -> None:
|
|
127
|
+
"""
|
|
128
|
+
로그 추가 (비블로킹, ~0.05ms)
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
level: 로그 레벨 (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)
|
|
132
|
+
message: 로그 메시지
|
|
133
|
+
auto_caller: 호출 위치 자동 추적 활성화 (기본: True)
|
|
134
|
+
**kwargs: 추가 필드 (trace_id, user_id, duration_ms 등)
|
|
135
|
+
"""
|
|
136
|
+
log_entry = {
|
|
137
|
+
"level": level,
|
|
138
|
+
"message": message,
|
|
139
|
+
"created_at": time.time(),
|
|
140
|
+
**kwargs
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# 호출 위치 자동 추적 (function_name, file_path)
|
|
144
|
+
if auto_caller:
|
|
145
|
+
try:
|
|
146
|
+
# 현재 프레임의 호출자 정보 추출
|
|
147
|
+
frame = inspect.currentframe()
|
|
148
|
+
if frame and frame.f_back:
|
|
149
|
+
caller_frame = frame.f_back
|
|
150
|
+
log_entry.setdefault("function_name", caller_frame.f_code.co_name)
|
|
151
|
+
log_entry.setdefault("file_path", caller_frame.f_code.co_filename)
|
|
152
|
+
except Exception:
|
|
153
|
+
# 프레임 추출 실패 시 무시
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# HTTP 요청 컨텍스트 자동 추가 (웹 프레임워크에서 설정한 경우)
|
|
157
|
+
request_ctx = _request_context.get()
|
|
158
|
+
if request_ctx:
|
|
159
|
+
for key, value in request_ctx.items():
|
|
160
|
+
log_entry.setdefault(key, value)
|
|
161
|
+
|
|
162
|
+
# 사용자 컨텍스트 자동 추가 (user_id, trace_id, session_id 등)
|
|
163
|
+
user_ctx = _user_context.get()
|
|
164
|
+
if user_ctx:
|
|
165
|
+
for key, value in user_ctx.items():
|
|
166
|
+
log_entry.setdefault(key, value)
|
|
167
|
+
|
|
168
|
+
# 공통 필드 자동 추가
|
|
169
|
+
if self.service:
|
|
170
|
+
log_entry.setdefault("service", self.service)
|
|
171
|
+
if self.environment:
|
|
172
|
+
log_entry.setdefault("environment", self.environment)
|
|
173
|
+
if self.service_version:
|
|
174
|
+
log_entry.setdefault("service_version", self.service_version)
|
|
175
|
+
if self.log_type:
|
|
176
|
+
log_entry.setdefault("log_type", self.log_type)
|
|
177
|
+
|
|
178
|
+
# 큐에 추가만 (즉시 리턴!)
|
|
179
|
+
self.queue.append(log_entry)
|
|
180
|
+
|
|
181
|
+
def start_timer(self) -> float:
|
|
182
|
+
"""
|
|
183
|
+
타이머 시작
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
시작 시간 (time.time())
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
timer = client.start_timer()
|
|
190
|
+
result = expensive_operation()
|
|
191
|
+
client.end_timer(timer, "INFO", "Operation completed")
|
|
192
|
+
"""
|
|
193
|
+
return time.time()
|
|
194
|
+
|
|
195
|
+
def end_timer(
|
|
196
|
+
self,
|
|
197
|
+
start_time: float,
|
|
198
|
+
level: str,
|
|
199
|
+
message: str,
|
|
200
|
+
**kwargs: Any
|
|
201
|
+
) -> None:
|
|
202
|
+
"""
|
|
203
|
+
타이머 종료 및 로그 전송 (duration_ms 자동 계산)
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
start_time: start_timer()의 반환값
|
|
207
|
+
level: 로그 레벨
|
|
208
|
+
message: 로그 메시지
|
|
209
|
+
**kwargs: 추가 필드
|
|
210
|
+
"""
|
|
211
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
212
|
+
self.log(level, message, duration_ms=duration_ms, **kwargs)
|
|
213
|
+
|
|
214
|
+
@contextmanager
|
|
215
|
+
def timer(self, message: str, level: str = "INFO", **kwargs: Any):
|
|
216
|
+
"""
|
|
217
|
+
컨텍스트 매니저 타이머 (duration_ms 자동 측정)
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
message: 로그 메시지
|
|
221
|
+
level: 로그 레벨 (기본: INFO)
|
|
222
|
+
**kwargs: 추가 필드
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
with client.timer("Database query"):
|
|
226
|
+
result = db.query("SELECT ...")
|
|
227
|
+
"""
|
|
228
|
+
start_time = time.time()
|
|
229
|
+
try:
|
|
230
|
+
yield
|
|
231
|
+
finally:
|
|
232
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
233
|
+
self.log(level, message, duration_ms=duration_ms, **kwargs)
|
|
234
|
+
|
|
235
|
+
def measure(self, message: Optional[str] = None, level: str = "INFO"):
|
|
236
|
+
"""
|
|
237
|
+
함수 실행 시간 측정 데코레이터 (duration_ms 자동 측정)
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
message: 로그 메시지 (기본: 함수명)
|
|
241
|
+
level: 로그 레벨 (기본: INFO)
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
@client.measure("Process payment")
|
|
245
|
+
def process_payment(amount):
|
|
246
|
+
return payment_api.charge(amount)
|
|
247
|
+
"""
|
|
248
|
+
def decorator(func: Callable) -> Callable:
|
|
249
|
+
@functools.wraps(func)
|
|
250
|
+
def wrapper(*args, **kwargs):
|
|
251
|
+
start_time = time.time()
|
|
252
|
+
try:
|
|
253
|
+
result = func(*args, **kwargs)
|
|
254
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
255
|
+
log_message = message or f"{func.__name__} completed"
|
|
256
|
+
self.log(
|
|
257
|
+
level,
|
|
258
|
+
log_message,
|
|
259
|
+
duration_ms=duration_ms,
|
|
260
|
+
function_name=func.__name__
|
|
261
|
+
)
|
|
262
|
+
return result
|
|
263
|
+
except Exception as e:
|
|
264
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
265
|
+
error_message = message or f"{func.__name__} failed"
|
|
266
|
+
self.error_with_trace(
|
|
267
|
+
error_message,
|
|
268
|
+
exception=e,
|
|
269
|
+
duration_ms=duration_ms,
|
|
270
|
+
function_name=func.__name__
|
|
271
|
+
)
|
|
272
|
+
raise
|
|
273
|
+
return wrapper
|
|
274
|
+
return decorator
|
|
275
|
+
|
|
276
|
+
def error_with_trace(
|
|
277
|
+
self,
|
|
278
|
+
message: str,
|
|
279
|
+
exception: Optional[Exception] = None,
|
|
280
|
+
**kwargs: Any
|
|
281
|
+
) -> None:
|
|
282
|
+
"""
|
|
283
|
+
에러 로그 + stack_trace 자동 추출
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
message: 에러 메시지
|
|
287
|
+
exception: Exception 객체 (선택, 없으면 현재 stack trace)
|
|
288
|
+
**kwargs: 추가 필드
|
|
289
|
+
|
|
290
|
+
Example:
|
|
291
|
+
try:
|
|
292
|
+
risky_operation()
|
|
293
|
+
except Exception as e:
|
|
294
|
+
client.error_with_trace("Operation failed", exception=e)
|
|
295
|
+
"""
|
|
296
|
+
# Stack trace 추출
|
|
297
|
+
if exception:
|
|
298
|
+
stack_trace_str = ''.join(traceback.format_exception(
|
|
299
|
+
type(exception),
|
|
300
|
+
exception,
|
|
301
|
+
exception.__traceback__
|
|
302
|
+
))
|
|
303
|
+
error_type = type(exception).__name__
|
|
304
|
+
else:
|
|
305
|
+
stack_trace_str = ''.join(traceback.format_stack())
|
|
306
|
+
error_type = None
|
|
307
|
+
|
|
308
|
+
# Stack trace에서 function_name, file_path 자동 추출
|
|
309
|
+
tb_lines = stack_trace_str.strip().split('\n')
|
|
310
|
+
function_name = None
|
|
311
|
+
file_path = None
|
|
312
|
+
|
|
313
|
+
# 마지막 호출 위치 파싱
|
|
314
|
+
for line in reversed(tb_lines):
|
|
315
|
+
if 'File "' in line:
|
|
316
|
+
try:
|
|
317
|
+
# 예: File "/path/to/file.py", line 123, in function_name
|
|
318
|
+
parts = line.split(',')
|
|
319
|
+
if len(parts) >= 3:
|
|
320
|
+
file_path = parts[0].split('"')[1]
|
|
321
|
+
func_part = parts[2].strip()
|
|
322
|
+
if func_part.startswith('in '):
|
|
323
|
+
function_name = func_part[3:].strip()
|
|
324
|
+
break
|
|
325
|
+
except:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
self.log(
|
|
329
|
+
"ERROR",
|
|
330
|
+
message,
|
|
331
|
+
stack_trace=stack_trace_str,
|
|
332
|
+
error_type=error_type,
|
|
333
|
+
function_name=function_name,
|
|
334
|
+
file_path=file_path,
|
|
335
|
+
**kwargs
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# 편의 메서드
|
|
339
|
+
def trace(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
340
|
+
"""TRACE 레벨 로그"""
|
|
341
|
+
self._log_with_caller_adjustment("TRACE", message, auto_caller, **kwargs)
|
|
342
|
+
|
|
343
|
+
def debug(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
344
|
+
"""DEBUG 레벨 로그"""
|
|
345
|
+
self._log_with_caller_adjustment("DEBUG", message, auto_caller, **kwargs)
|
|
346
|
+
|
|
347
|
+
def info(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
348
|
+
"""INFO 레벨 로그"""
|
|
349
|
+
self._log_with_caller_adjustment("INFO", message, auto_caller, **kwargs)
|
|
350
|
+
|
|
351
|
+
def warn(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
352
|
+
"""WARN 레벨 로그"""
|
|
353
|
+
self._log_with_caller_adjustment("WARN", message, auto_caller, **kwargs)
|
|
354
|
+
|
|
355
|
+
def error(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
356
|
+
"""ERROR 레벨 로그"""
|
|
357
|
+
self._log_with_caller_adjustment("ERROR", message, auto_caller, **kwargs)
|
|
358
|
+
|
|
359
|
+
def fatal(self, message: str, auto_caller: bool = True, **kwargs: Any) -> None:
|
|
360
|
+
"""FATAL 레벨 로그"""
|
|
361
|
+
self._log_with_caller_adjustment("FATAL", message, auto_caller, **kwargs)
|
|
362
|
+
|
|
363
|
+
def _log_with_caller_adjustment(
|
|
364
|
+
self,
|
|
365
|
+
level: str,
|
|
366
|
+
message: str,
|
|
367
|
+
auto_caller: bool,
|
|
368
|
+
**kwargs: Any
|
|
369
|
+
) -> None:
|
|
370
|
+
"""
|
|
371
|
+
편의 메서드를 위한 로그 호출 (호출자 프레임 조정)
|
|
372
|
+
편의 메서드를 통해 호출되므로 한 단계 위의 프레임을 추적
|
|
373
|
+
"""
|
|
374
|
+
if auto_caller:
|
|
375
|
+
try:
|
|
376
|
+
# 2단계 위의 프레임 추출 (호출자 -> 편의 메서드 -> 이 메서드)
|
|
377
|
+
frame = inspect.currentframe()
|
|
378
|
+
if frame and frame.f_back and frame.f_back.f_back:
|
|
379
|
+
caller_frame = frame.f_back.f_back
|
|
380
|
+
kwargs.setdefault("function_name", caller_frame.f_code.co_name)
|
|
381
|
+
kwargs.setdefault("file_path", caller_frame.f_code.co_filename)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
# auto_caller=False로 설정해서 log()에서 중복 추출 방지
|
|
386
|
+
self.log(level, message, auto_caller=False, **kwargs)
|
|
387
|
+
|
|
388
|
+
def _start_background_worker(self) -> None:
|
|
389
|
+
"""백그라운드 워커 스레드 시작"""
|
|
390
|
+
self._worker_thread = Thread(
|
|
391
|
+
target=self._flush_loop,
|
|
392
|
+
daemon=True,
|
|
393
|
+
name="log-worker"
|
|
394
|
+
)
|
|
395
|
+
self._worker_thread.start()
|
|
396
|
+
|
|
397
|
+
def _flush_loop(self) -> None:
|
|
398
|
+
"""배치 전송 루프 (백그라운드 스레드)"""
|
|
399
|
+
loop = asyncio.new_event_loop()
|
|
400
|
+
asyncio.set_event_loop(loop)
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
while not self._stop_event.is_set():
|
|
404
|
+
if len(self.queue) >= self.batch_size:
|
|
405
|
+
# 1000건 모이면 즉시 전송
|
|
406
|
+
batch = [self.queue.popleft() for _ in range(self.batch_size)]
|
|
407
|
+
loop.run_until_complete(self._send_batch(batch))
|
|
408
|
+
|
|
409
|
+
elif len(self.queue) > 0:
|
|
410
|
+
# 1초 지나면 쌓인 것만이라도 전송
|
|
411
|
+
time.sleep(self.flush_interval)
|
|
412
|
+
if len(self.queue) > 0:
|
|
413
|
+
batch = [self.queue.popleft() for _ in range(len(self.queue))]
|
|
414
|
+
loop.run_until_complete(self._send_batch(batch))
|
|
415
|
+
else:
|
|
416
|
+
# 큐가 비어있으면 대기
|
|
417
|
+
time.sleep(0.1)
|
|
418
|
+
finally:
|
|
419
|
+
loop.close()
|
|
420
|
+
|
|
421
|
+
async def _send_batch(self, batch: list, retry_count: int = 0) -> None:
|
|
422
|
+
"""
|
|
423
|
+
배치 전송 (비동기 HTTP POST)
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
batch: 로그 배치
|
|
427
|
+
retry_count: 현재 재시도 횟수
|
|
428
|
+
"""
|
|
429
|
+
# JSON 직렬화
|
|
430
|
+
payload = json.dumps({"logs": batch})
|
|
431
|
+
|
|
432
|
+
# 압축 (100건 이상)
|
|
433
|
+
headers = {"Content-Type": "application/json"}
|
|
434
|
+
if self.enable_compression and len(batch) >= 100:
|
|
435
|
+
payload = gzip.compress(payload.encode())
|
|
436
|
+
headers["Content-Encoding"] = "gzip"
|
|
437
|
+
|
|
438
|
+
# HTTP POST
|
|
439
|
+
try:
|
|
440
|
+
async with aiohttp.ClientSession() as session:
|
|
441
|
+
async with session.post(
|
|
442
|
+
f"{self.server_url}/logs",
|
|
443
|
+
data=payload if isinstance(payload, bytes) else payload.encode(),
|
|
444
|
+
headers=headers,
|
|
445
|
+
timeout=aiohttp.ClientTimeout(total=5)
|
|
446
|
+
) as response:
|
|
447
|
+
if response.status != 200:
|
|
448
|
+
raise Exception(f"HTTP {response.status}: {await response.text()}")
|
|
449
|
+
|
|
450
|
+
except Exception as e:
|
|
451
|
+
# 재시도 로직
|
|
452
|
+
if retry_count < self.max_retries:
|
|
453
|
+
# Exponential backoff
|
|
454
|
+
await asyncio.sleep(2 ** retry_count)
|
|
455
|
+
await self._send_batch(batch, retry_count + 1)
|
|
456
|
+
else:
|
|
457
|
+
print(f"[Log Client] Final retry failed: {e}")
|
|
458
|
+
|
|
459
|
+
def _graceful_shutdown(self) -> None:
|
|
460
|
+
"""Graceful shutdown - 앱 종료 시 큐 비우기"""
|
|
461
|
+
if len(self.queue) > 0:
|
|
462
|
+
print(f"[Log Client] Flushing {len(self.queue)} remaining logs...")
|
|
463
|
+
batch = [self.queue.popleft() for _ in range(len(self.queue))]
|
|
464
|
+
|
|
465
|
+
# 동기적으로 전송
|
|
466
|
+
loop = asyncio.new_event_loop()
|
|
467
|
+
try:
|
|
468
|
+
loop.run_until_complete(self._send_batch(batch))
|
|
469
|
+
finally:
|
|
470
|
+
loop.close()
|
|
471
|
+
|
|
472
|
+
def flush(self) -> None:
|
|
473
|
+
"""수동 flush - 큐에 있는 모든 로그 즉시 전송"""
|
|
474
|
+
if len(self.queue) > 0:
|
|
475
|
+
batch = [self.queue.popleft() for _ in range(len(self.queue))]
|
|
476
|
+
loop = asyncio.new_event_loop()
|
|
477
|
+
try:
|
|
478
|
+
loop.run_until_complete(self._send_batch(batch))
|
|
479
|
+
finally:
|
|
480
|
+
loop.close()
|
|
481
|
+
|
|
482
|
+
def close(self) -> None:
|
|
483
|
+
"""클라이언트 종료"""
|
|
484
|
+
self._stop_event.set()
|
|
485
|
+
if self._worker_thread and self._worker_thread.is_alive():
|
|
486
|
+
self._worker_thread.join(timeout=5)
|
|
487
|
+
self._graceful_shutdown()
|
|
488
|
+
|
|
489
|
+
# 글로벌 에러 핸들러 해제
|
|
490
|
+
if self.enable_global_error_handler:
|
|
491
|
+
self._teardown_global_error_handler()
|
|
492
|
+
|
|
493
|
+
def _setup_global_error_handler(self) -> None:
|
|
494
|
+
"""
|
|
495
|
+
글로벌 에러 핸들러 설정
|
|
496
|
+
모든 uncaught exceptions를 자동으로 로깅
|
|
497
|
+
"""
|
|
498
|
+
import sys
|
|
499
|
+
|
|
500
|
+
# 기존 excepthook 저장
|
|
501
|
+
self._original_excepthook = sys.excepthook
|
|
502
|
+
|
|
503
|
+
def exception_handler(exc_type, exc_value, exc_traceback):
|
|
504
|
+
# 에러 로깅
|
|
505
|
+
self.error_with_trace(
|
|
506
|
+
"Uncaught exception",
|
|
507
|
+
exception=exc_value,
|
|
508
|
+
error_type=exc_type.__name__,
|
|
509
|
+
auto_caller=False
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
# 기존 excepthook 호출
|
|
513
|
+
if self._original_excepthook:
|
|
514
|
+
self._original_excepthook(exc_type, exc_value, exc_traceback)
|
|
515
|
+
|
|
516
|
+
sys.excepthook = exception_handler
|
|
517
|
+
|
|
518
|
+
def _teardown_global_error_handler(self) -> None:
|
|
519
|
+
"""글로벌 에러 핸들러 해제"""
|
|
520
|
+
import sys
|
|
521
|
+
|
|
522
|
+
if self._original_excepthook:
|
|
523
|
+
sys.excepthook = self._original_excepthook
|
|
524
|
+
self._original_excepthook = None
|
|
525
|
+
|
|
526
|
+
# HTTP 요청 컨텍스트 관리 (웹 프레임워크 통합용)
|
|
527
|
+
@staticmethod
|
|
528
|
+
def set_request_context(**context: Any) -> None:
|
|
529
|
+
"""
|
|
530
|
+
HTTP 요청 컨텍스트 설정 (웹 프레임워크 미들웨어에서 호출)
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
**context: HTTP 요청 정보 (path, method, ip 등)
|
|
534
|
+
|
|
535
|
+
Example (Flask):
|
|
536
|
+
from flask import request
|
|
537
|
+
AsyncLogClient.set_request_context(
|
|
538
|
+
path=request.path,
|
|
539
|
+
method=request.method,
|
|
540
|
+
ip=request.remote_addr
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
Example (FastAPI):
|
|
544
|
+
AsyncLogClient.set_request_context(
|
|
545
|
+
path=request.url.path,
|
|
546
|
+
method=request.method,
|
|
547
|
+
ip=request.client.host
|
|
548
|
+
)
|
|
549
|
+
"""
|
|
550
|
+
_request_context.set(context)
|
|
551
|
+
|
|
552
|
+
@staticmethod
|
|
553
|
+
def clear_request_context() -> None:
|
|
554
|
+
"""
|
|
555
|
+
HTTP 요청 컨텍스트 초기화
|
|
556
|
+
|
|
557
|
+
Example:
|
|
558
|
+
AsyncLogClient.clear_request_context()
|
|
559
|
+
"""
|
|
560
|
+
_request_context.set(None)
|
|
561
|
+
|
|
562
|
+
@staticmethod
|
|
563
|
+
def get_request_context() -> Optional[Dict[str, Any]]:
|
|
564
|
+
"""
|
|
565
|
+
현재 HTTP 요청 컨텍스트 조회
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
현재 설정된 컨텍스트 또는 None
|
|
569
|
+
"""
|
|
570
|
+
return _request_context.get()
|
|
571
|
+
|
|
572
|
+
# 사용자 컨텍스트 관리 (user_id, trace_id, session_id 등)
|
|
573
|
+
@staticmethod
|
|
574
|
+
def set_user_context(**context: Any) -> None:
|
|
575
|
+
"""
|
|
576
|
+
사용자 컨텍스트 설정 (애플리케이션 코드에서 호출)
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
**context: 사용자 정보 (user_id, trace_id, session_id, tenant_id 등)
|
|
580
|
+
|
|
581
|
+
Example:
|
|
582
|
+
# 인증 후 사용자 ID 설정
|
|
583
|
+
AsyncLogClient.set_user_context(
|
|
584
|
+
user_id="user_12345",
|
|
585
|
+
trace_id="trace_xyz",
|
|
586
|
+
session_id="sess_abc"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# 이후 모든 로그에 자동으로 포함됨
|
|
590
|
+
logger.info("User action completed")
|
|
591
|
+
# → user_id="user_12345", trace_id="trace_xyz" 자동 포함
|
|
592
|
+
"""
|
|
593
|
+
_user_context.set(context)
|
|
594
|
+
|
|
595
|
+
@staticmethod
|
|
596
|
+
def clear_user_context() -> None:
|
|
597
|
+
"""
|
|
598
|
+
사용자 컨텍스트 초기화
|
|
599
|
+
|
|
600
|
+
Example:
|
|
601
|
+
# 로그아웃 시
|
|
602
|
+
AsyncLogClient.clear_user_context()
|
|
603
|
+
"""
|
|
604
|
+
_user_context.set(None)
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def get_user_context() -> Optional[Dict[str, Any]]:
|
|
608
|
+
"""
|
|
609
|
+
현재 사용자 컨텍스트 조회
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
현재 설정된 컨텍스트 또는 None
|
|
613
|
+
"""
|
|
614
|
+
return _user_context.get()
|
|
615
|
+
|
|
616
|
+
@staticmethod
|
|
617
|
+
@contextmanager
|
|
618
|
+
def user_context(**context: Any):
|
|
619
|
+
"""
|
|
620
|
+
사용자 컨텍스트 컨텍스트 매니저 (with 문 사용)
|
|
621
|
+
|
|
622
|
+
Args:
|
|
623
|
+
**context: 사용자 정보 (user_id, trace_id 등)
|
|
624
|
+
|
|
625
|
+
Example:
|
|
626
|
+
# 특정 블록에만 컨텍스트 적용
|
|
627
|
+
with AsyncLogClient.user_context(user_id="user_123", trace_id="trace_xyz"):
|
|
628
|
+
logger.info("Processing user request")
|
|
629
|
+
# → user_id, trace_id 자동 포함
|
|
630
|
+
process_payment()
|
|
631
|
+
# with 블록 벗어나면 컨텍스트 자동 초기화
|
|
632
|
+
|
|
633
|
+
# 중첩 컨텍스트도 가능 (병합됨)
|
|
634
|
+
with AsyncLogClient.user_context(tenant_id="tenant_1"):
|
|
635
|
+
with AsyncLogClient.user_context(user_id="user_123"):
|
|
636
|
+
logger.info("Nested context")
|
|
637
|
+
# → tenant_id, user_id 둘 다 포함
|
|
638
|
+
"""
|
|
639
|
+
# 현재 컨텍스트를 가져와서 새로운 컨텍스트와 병합
|
|
640
|
+
current_context = _user_context.get()
|
|
641
|
+
if current_context:
|
|
642
|
+
# 기존 컨텍스트와 병합 (새로운 값이 우선)
|
|
643
|
+
merged_context = {**current_context, **context}
|
|
644
|
+
else:
|
|
645
|
+
merged_context = context
|
|
646
|
+
|
|
647
|
+
token = _user_context.set(merged_context)
|
|
648
|
+
try:
|
|
649
|
+
yield
|
|
650
|
+
finally:
|
|
651
|
+
_user_context.reset(token)
|