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.
@@ -0,0 +1,13 @@
1
+ """
2
+ 로그 수집 클라이언트 라이브러리
3
+
4
+ 성능 최적화:
5
+ - 앱 블로킹 < 0.1ms
6
+ - 처리량 50K logs/sec
7
+ - 메모리 < 10MB
8
+ """
9
+
10
+ from .async_client import AsyncLogClient
11
+
12
+ __version__ = "1.0.0"
13
+ __all__ = ["AsyncLogClient"]
@@ -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)