whatap-python 2.1.4rc1__py3-none-any.whl → 2.2.1__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.
Files changed (132) hide show
  1. whatap/__init__.py +376 -109
  2. whatap/agent/linux/amd64/whatap_python +0 -0
  3. whatap/agent/linux/arm64/whatap_python +0 -0
  4. whatap/build.py +2 -2
  5. whatap/conf/configuration.py +235 -4
  6. whatap/conf/configure.py +55 -0
  7. whatap/counter/__init__.py +26 -4
  8. whatap/counter/tasks/base_task.py +11 -2
  9. whatap/counter/tasks/llm_evaluator_task.py +56 -10
  10. whatap/counter/tasks/llm_log_sink_task.py +85 -19
  11. whatap/counter/tasks/llm_stat_task.py +73 -3
  12. whatap/doctor.py +402 -0
  13. whatap/llm/__init__.py +54 -1
  14. whatap/llm/api/__init__.py +20 -0
  15. whatap/llm/api/agent_name.py +83 -0
  16. whatap/llm/{prompt_meta.py → api/prompt_meta.py} +31 -4
  17. whatap/llm/api/session.py +175 -0
  18. whatap/llm/api/turn.py +88 -0
  19. whatap/llm/api/user.py +176 -0
  20. whatap/llm/api/workflow.py +115 -0
  21. whatap/llm/control/__init__.py +59 -0
  22. whatap/llm/control/cut.py +519 -0
  23. whatap/llm/control/guard.py +217 -0
  24. whatap/llm/control/trajectory.py +524 -0
  25. whatap/llm/definitions.py +21 -1
  26. whatap/llm/evaluators/__init__.py +4 -0
  27. whatap/llm/evaluators/base.py +15 -11
  28. whatap/llm/evaluators/builtins/_json_repair.py +106 -0
  29. whatap/llm/evaluators/builtins/combined_judge.py +7 -4
  30. whatap/llm/evaluators/builtins/judge_factories.py +90 -0
  31. whatap/llm/evaluators/builtins/llm_judge.py +107 -375
  32. whatap/llm/evaluators/builtins/pii_leak.py +5 -20
  33. whatap/llm/evaluators/builtins/url_scan.py +21 -22
  34. whatap/llm/evaluators/registry.py +91 -0
  35. whatap/llm/evaluators/scope.py +22 -2
  36. whatap/llm/instrument/__init__.py +32 -0
  37. whatap/llm/instrument/_boundary.py +606 -0
  38. whatap/llm/instrument/agent.py +35 -0
  39. whatap/llm/instrument/llm_api.py +89 -0
  40. whatap/llm/instrument/workflow.py +42 -0
  41. whatap/llm/log_sink_packs/llm_log_sink_pack.py +32 -0
  42. whatap/llm/log_sink_packs/llm_step_status.py +9 -0
  43. whatap/llm/log_sink_packs/llm_tx_status.py +51 -0
  44. whatap/llm/orchestration/__init__.py +16 -0
  45. whatap/llm/orchestration/hermes/__init__.py +143 -0
  46. whatap/llm/orchestration/hermes/gateway.py +172 -0
  47. whatap/llm/orchestration/hermes/kanban.py +353 -0
  48. whatap/llm/orchestration/langgraph/__init__.py +134 -0
  49. whatap/llm/pricing.py +89 -123
  50. whatap/llm/providers/anthropic/__init__.py +50 -15
  51. whatap/llm/providers/anthropic/messages/messages.py +3 -2
  52. whatap/llm/providers/anthropic/messages/messages_context.py +48 -0
  53. whatap/llm/providers/anthropic/messages/messages_extractor.py +53 -0
  54. whatap/llm/providers/anthropic/messages/messages_stream.py +163 -0
  55. whatap/llm/providers/bedrock/__init__.py +92 -0
  56. whatap/llm/providers/bedrock/_content.py +81 -0
  57. whatap/llm/providers/bedrock/converse/__init__.py +2 -0
  58. whatap/llm/providers/bedrock/converse/converse_context.py +155 -0
  59. whatap/llm/providers/bedrock/converse/converse_extractor.py +196 -0
  60. whatap/llm/providers/bedrock/event_stream.py +254 -0
  61. whatap/llm/providers/bedrock/http.py +395 -0
  62. whatap/llm/providers/bedrock/invoke_model/__init__.py +2 -0
  63. whatap/llm/providers/bedrock/invoke_model/invoke_model_context.py +126 -0
  64. whatap/llm/providers/bedrock/invoke_model/invoke_model_extractor.py +763 -0
  65. whatap/llm/providers/bedrock/runtime.py +291 -0
  66. whatap/llm/providers/bedrock/step.py +76 -0
  67. whatap/llm/providers/interceptor.py +182 -56
  68. whatap/llm/providers/openai/__init__.py +47 -70
  69. whatap/llm/providers/openai/chat/chat.py +71 -14
  70. whatap/llm/providers/openai/chat/chat_context.py +6 -0
  71. whatap/llm/providers/openai/chat/chat_extractor.py +8 -1
  72. whatap/llm/providers/openai/completions/completions.py +3 -2
  73. whatap/llm/providers/openai/content_parser.py +4 -1
  74. whatap/llm/providers/openai/embeddings/embeddings.py +3 -2
  75. whatap/llm/providers/openai/responses/responses.py +3 -2
  76. whatap/llm/providers/stream_accumulator.py +162 -4
  77. whatap/llm/providers/workflow.py +763 -0
  78. whatap/llm/stats/__init__.py +6 -0
  79. whatap/llm/stats/active_agent_stat.py +93 -0
  80. whatap/llm/stats/active_stat.py +48 -51
  81. whatap/llm/stats/actx_stat.py +80 -0
  82. whatap/llm/stats/base_stat.py +6 -0
  83. whatap/llm/stats/eval_stat.py +1 -0
  84. whatap/llm/stats/pii_leak_eval_stat.py +2 -1
  85. whatap/llm/stats/traj_actx_stat.py +133 -0
  86. whatap/llm/stats/url_scan_eval_stat.py +2 -1
  87. whatap/net/async_sender.py +242 -23
  88. whatap/net/packet_enum.py +1 -1
  89. whatap/net/param_def.py +17 -0
  90. whatap/net/udp_session.py +461 -34
  91. whatap/net/udp_thread.py +51 -2
  92. whatap/scripts/__init__.py +89 -0
  93. whatap/skills/whatap-python-install/AGENTS.md +29 -0
  94. whatap/skills/whatap-python-install/INSTALL.md +96 -0
  95. whatap/skills/whatap-python-install/SKILL.md +42 -0
  96. whatap/skills/whatap-python-install/references/config-keys.md +59 -0
  97. whatap/skills/whatap-python-install/references/frameworks.md +55 -0
  98. whatap/skills/whatap-python-install/references/llm-api-usage.md +81 -0
  99. whatap/skills/whatap-python-install/references/llm-evaluation.md +114 -0
  100. whatap/skills/whatap-python-install/references/llm-observability.md +123 -0
  101. whatap/skills/whatap-python-install/references/troubleshooting.md +41 -0
  102. whatap/trace/mod/application/fastapi/instrumentation.py +264 -65
  103. whatap/trace/mod/application/fastapi/scope.py +17 -0
  104. whatap/trace/mod/application/wsgi.py +19 -1
  105. whatap/trace/mod/hook_service_patterns.py +8 -54
  106. whatap/trace/mod/httpc/httpx.py +26 -3
  107. whatap/trace/mod/httpc/httpx2.py +23 -0
  108. whatap/trace/mod/httpc/requests.py +16 -3
  109. whatap/trace/mod/httpc/stream_end.py +136 -0
  110. whatap/trace/mod/httpc/util.py +594 -75
  111. whatap/trace/mod/pattern_targets.py +137 -0
  112. whatap/trace/mod/plugin.py +22 -59
  113. whatap/trace/mod/process/__init__.py +0 -0
  114. whatap/trace/mod/process/subprocess_spawn.py +390 -0
  115. whatap/trace/mod/standalone/multiple.py +2 -1
  116. whatap/trace/mod/standalone/single.py +2 -1
  117. whatap/trace/mod/thread_context.py +202 -0
  118. whatap/trace/mod/workflow_plugin.py +33 -0
  119. whatap/trace/mtrace_env.py +368 -0
  120. whatap/trace/patch.py +98 -0
  121. whatap/trace/trace_context.py +119 -1
  122. whatap/trace/trace_context_manager.py +773 -23
  123. whatap/trace/trace_error.py +16 -3
  124. whatap/trace/trace_import.py +59 -22
  125. whatap/trace/trace_module_definition.py +43 -0
  126. whatap/util/native_process.py +458 -0
  127. {whatap_python-2.1.4rc1.dist-info → whatap_python-2.2.1.dist-info}/METADATA +1 -1
  128. {whatap_python-2.1.4rc1.dist-info → whatap_python-2.2.1.dist-info}/RECORD +131 -73
  129. {whatap_python-2.1.4rc1.dist-info → whatap_python-2.2.1.dist-info}/entry_points.txt +2 -0
  130. whatap/llm/stats/meter.py +0 -18
  131. {whatap_python-2.1.4rc1.dist-info → whatap_python-2.2.1.dist-info}/WHEEL +0 -0
  132. {whatap_python-2.1.4rc1.dist-info → whatap_python-2.2.1.dist-info}/top_level.txt +0 -0
whatap/__init__.py CHANGED
@@ -117,6 +117,37 @@ def whatap_print(*args):
117
117
  message = " ".join(args)
118
118
  print(message)
119
119
 
120
+
121
+ def whatap_error_log(msg, log_id='WHATAP_AGENT_ERROR'):
122
+ """에이전트 시작 오류를 프로세스 로그(stdout)와 hook.log 양쪽에 동일하게 노출한다.
123
+
124
+ hook.log FileHandler 는 Configure.load(display=True)->Logger() 시점에 붙는데,
125
+ go() 스레드가 config() 보다 먼저 도는 경로(자동 attach)나 start_agent 경로에서는
126
+ 아직 핸들러가 없을 수 있다. 그 경우에도 오류가 유실되지 않도록, 핸들러가 없으면
127
+ whatap-hook.log 에 직접 append 한다."""
128
+ # 1) 프로세스 로그 (사용자 애플리케이션 stdout)
129
+ whatap_print(msg)
130
+
131
+ # 2) hook.log
132
+ try:
133
+ if logging.handlers:
134
+ logging.error(msg, extra={'id': log_id})
135
+ return
136
+ except Exception:
137
+ pass
138
+
139
+ # FileHandler 미부착: whatap-hook.log 에 직접 기록
140
+ try:
141
+ logs_dir = os.environ.get('WHATAP_LOGS') or \
142
+ os.path.join(os.environ.get('WHATAP_HOME', '.'), 'logs')
143
+ if not os.path.exists(logs_dir):
144
+ os.makedirs(logs_dir)
145
+ with open(os.path.join(logs_dir, LOG_FILE_NAME), 'a') as f:
146
+ f.write('[{0}] : {1} - {2}\n'.format(
147
+ time.strftime(LOGGING_DATE_FORMAT), log_id, msg))
148
+ except Exception as e:
149
+ whatap_print('WHATAP: hook.log write failed: {}'.format(e))
150
+
120
151
  class Logger(object):
121
152
  def __init__(self):
122
153
  self.logger = logging
@@ -291,6 +322,18 @@ def config(home):
291
322
  from whatap.conf.license import License
292
323
  conf.PCODE = License.getProjectCode(conf.license)
293
324
 
325
+ # LLM 채널은 자기 프로젝트 코드를 가진다. LLM 트랜잭션(is_llm=1)은 LLM Go Agent
326
+ # 소켓으로만 나가므로 결합 좌표에 APM PCODE 를 실으면 서버가 호출자를 엉뚱한
327
+ # 프로젝트에서 찾는다(conf.pcode_of 참조). getProjectCode 는 키 문자열 파싱이라
328
+ # 서버 왕복이 없고, apm_enabled=false 인 LLM 전용 배포에서도 해소된다.
329
+ llm_key = str(getattr(conf, 'llm_accesskey', '') or '').strip()
330
+ if llm_key:
331
+ try:
332
+ from whatap.conf.license import License
333
+ conf.LLM_PCODE = License.getProjectCode(llm_key)
334
+ except Exception as e:
335
+ whatap_print('WHATAP: LLM PCODE RESOLVE ERROR: {}'.format(e))
336
+
294
337
  import whatap.counter
295
338
 
296
339
  hooks(home)
@@ -335,11 +378,20 @@ def _is_instrumentation_ignored(definition_key, ignore_set):
335
378
  def hooks(home):
336
379
  ignore_set = _build_ignore_instrumentation_set()
337
380
  skipped_keys = []
381
+ # LLM 계측은 llm_enabled 일 때만 **등록**한다. instrument_* 함수들도 각자
382
+ # llm_enabled 를 보고 되돌아가지만, 등록만 해둬도 대상 모듈이 로드되는 순간
383
+ # import 훅이 ``whatap.llm.*`` 서브트리를 통째로 끌어온다 — 켜지도 않은 기능
384
+ # 때문에. ``botocore.client``(Bedrock) 처럼 LLM 과 무관한 앱도 흔히 쓰는 모듈이
385
+ # 대상에 들어오면서 이 비용이 실제로 문제가 된다.
386
+ # conf.init() 이 hooks() 보다 먼저 돌므로 여기서 llm_enabled 는 이미 확정이다.
387
+ llm_enabled = bool(getattr(conf, 'llm_enabled', False))
338
388
  try:
339
389
  for key, value_list in DEFINITION.items():
340
390
  if _is_instrumentation_ignored(key, ignore_set):
341
391
  skipped_keys.append(key)
342
392
  continue
393
+ if key.startswith('llm.') and not llm_enabled:
394
+ continue
343
395
  for value in value_list:
344
396
  if len(value) >= 3 and isinstance(value[2], str):
345
397
  module_path = value[2]
@@ -366,47 +418,61 @@ def hooks(home):
366
418
  from whatap.trace.mod.logging import instrument_logging
367
419
  instrument_logging(logging_module)
368
420
 
421
+ # 한 모듈에 여러 패턴 계열(스텝=instrument_plugin / 워크플로우=instrument_workflow
422
+ # / 트랜잭션=instrument_service)이 공존할 수 있도록, IMPORT_HOOKS[모듈]에
423
+ # 인스트루먼터를 각자의 class_defs 와 함께 리스트로 누적한다. 과거엔 단일 dict 라
424
+ # 뒤 계열이 앞을 덮어써서 같은 모듈에 두 패턴(예: predict=트랜잭션 + 헬퍼=스텝)을
425
+ # 못 걸었다.
426
+ def _register_pattern_hook(module_name, class_def, def_name, mod_path):
427
+ if not PLUGIN.get(module_name):
428
+ PLUGIN[module_name] = []
429
+ if class_def not in PLUGIN[module_name]:
430
+ PLUGIN[module_name].append(class_def) # 호환용(외부 참조)
431
+ existing = IMPORT_HOOKS.get(module_name)
432
+ if isinstance(existing, list):
433
+ entry_list = existing
434
+ elif isinstance(existing, dict):
435
+ entry_list = [existing]
436
+ IMPORT_HOOKS[module_name] = entry_list
437
+ else:
438
+ entry_list = []
439
+ IMPORT_HOOKS[module_name] = entry_list
440
+ for e in entry_list:
441
+ if isinstance(e, dict) and e.get('def') == def_name \
442
+ and e.get('module') == mod_path:
443
+ e.setdefault('class_defs', []).append(class_def)
444
+ return
445
+ entry_list.append({'def': def_name, 'module': mod_path,
446
+ 'class_defs': [class_def]})
447
+
369
448
  if conf.hook_method_patterns:
370
- from whatap.trace.mod.plugin import instrument_plugin
371
- patterns = conf.hook_method_patterns.split(',')
372
- for pattern in patterns:
373
- pattern=pattern.strip()
374
-
375
- module_name, class_def = pattern.split(':')
376
- if not PLUGIN.get(module_name):
377
- PLUGIN[module_name] = []
378
- PLUGIN[module_name].append(class_def)
379
-
380
- DEFINITION["plugin"].append(
381
- (module_name, 'instrument_plugin'))
382
-
383
- key = 'plugin'
384
- for value in DEFINITION[key]:
385
- IMPORT_HOOKS[value[0]] = {'def': value[1],
386
- 'module': '{0}.{1}.{2}.{3}'.format(
387
- 'whatap',
388
- 'trace',
389
- 'mod',
390
- key)}
391
-
392
- # 서비스(트랜잭션) 진입점 자동 래핑 (conf 방법). hook_method_patterns 와 같은
393
- # import-hook 플러밍, 래퍼만 트랜잭션 open/close (whatap.trace.mod.hook_service_patterns).
394
- # @method_profiling 을 소스 수정 없이 conf 로 적용하는 경로.
449
+ for pattern in conf.hook_method_patterns.split(','):
450
+ pattern = pattern.strip()
451
+ if not pattern or ':' not in pattern:
452
+ continue
453
+ module_name, class_def = pattern.split(':', 1)
454
+ _register_pattern_hook(module_name.strip(), class_def.strip(),
455
+ 'instrument_plugin', 'whatap.trace.mod.plugin')
456
+
457
+ # LLM 워크플로우 경계 자동 래핑 (conf 방법). 래퍼만 워크플로우 open/close.
458
+ if conf.workflow_method_patterns:
459
+ for pattern in conf.workflow_method_patterns.split(','):
460
+ pattern = pattern.strip()
461
+ if not pattern or ':' not in pattern:
462
+ continue
463
+ module_name, class_def = pattern.split(':', 1)
464
+ _register_pattern_hook(module_name.strip(), class_def.strip(),
465
+ 'instrument_workflow', 'whatap.trace.mod.workflow_plugin')
466
+
467
+ # 서비스(트랜잭션) 진입점 자동 래핑 (conf 방법). @method_profiling 의 conf 버전.
395
468
  if conf.hook_service_patterns:
396
469
  for pattern in conf.hook_service_patterns.split(','):
397
470
  pattern = pattern.strip()
398
471
  if not pattern or ':' not in pattern:
399
472
  continue
400
473
  module_name, class_def = pattern.split(':', 1)
401
- module_name = module_name.strip()
402
- class_def = class_def.strip()
403
- if not PLUGIN.get(module_name):
404
- PLUGIN[module_name] = []
405
- PLUGIN[module_name].append(class_def)
406
- # 대상 모듈이 PLUGIN 에 등록되면 ImportFinder 가 class_defs 패키징.
407
- IMPORT_HOOKS[module_name] = {
408
- 'def': 'instrument_service',
409
- 'module': 'whatap.trace.mod.hook_service_patterns'}
474
+ _register_pattern_hook(module_name.strip(), class_def.strip(),
475
+ 'instrument_service', 'whatap.trace.mod.hook_service_patterns')
410
476
 
411
477
  if conf.standalone_enabled:
412
478
  if conf.standalone_type == 'multiple-transaction':
@@ -420,9 +486,43 @@ def hooks(home):
420
486
  logging.debug(e, extra={'id': 'PLUGIN ERROR'})
421
487
  finally:
422
488
  sys.meta_path.insert(0, ImportFinder())
489
+ _instrument_already_imported()
423
490
  logging.debug('WHATAP AGENT START!', extra={'id': 'WA000'})
424
491
 
425
492
 
493
+ def _instrument_already_imported():
494
+ """finder 설치 시점에 **이미 import 돼 있던** 훅 대상을 직접 계측한다.
495
+
496
+ ``ImportFinder.find_spec`` 은 import 시스템이 ``sys.modules`` 미스일 때만 호출하므로,
497
+ finder 설치보다 먼저 로드된 모듈은 훅이 영원히 안 걸린다. 그리고 이 에이전트는 자기
498
+ 부팅에 필요한 모듈(``subprocess`` — 최상단 import, 데몬 기동에 사용)을 finder 설치
499
+ **전에** 로드하므로, 그 대상은 DEFINITION 에 등록해도 계측되지 않는다.
500
+
501
+ 앱이 우회할 수 없는 종류의 구멍이다(앱이 할 수 있는 건 에이전트 내부 instrument_*
502
+ 함수를 대신 호출하는 것뿐이고, 그건 자동계측을 쓰는 의미를 없앤다).
503
+
504
+ ``load_module`` 을 그대로 재사용하므로 정상 import 경로와 완전히 같은 처리를 받는다
505
+ (엔트리 리스트/class_defs 패키징 포함). instrument_* 는 멱등이어야 한다 — 이 스윕과 이후의
506
+ 재-import 가 같은 대상을 두 번 건드릴 수 있다.
507
+ """
508
+ try:
509
+ from whatap.trace.trace_import import load_module
510
+ except Exception as e:
511
+ logging.info("pre-imported sweep unavailable: %s: %s",
512
+ e.__class__.__name__, e, exc_info=True)
513
+ return
514
+ for fullname in list(IMPORT_HOOKS.keys()):
515
+ module = sys.modules.get(fullname)
516
+ if module is None:
517
+ continue
518
+ try:
519
+ load_module(module, fullname)
520
+ except Exception as e:
521
+ # 계측 실패는 조용히 넘기면 "데이터가 안 보인다" 로만 드러난다.
522
+ logging.info("failed to inject pre-imported %s: %s: %s",
523
+ fullname, e.__class__.__name__, e, exc_info=True)
524
+
525
+
426
526
  def agent():
427
527
  home = 'WHATAP_HOME'
428
528
  whatap_home = os.environ.get(home)
@@ -446,14 +546,28 @@ def agent():
446
546
  llm_enabled = preview_whatap_conf("llm_enabled")
447
547
 
448
548
  if apm_enabled == 'true':
449
- t = threading.Thread(target=go)
450
- t.setDaemon(True)
451
- t.start()
549
+ if sys.platform.startswith('linux'):
550
+ # Publish native ownership before a short-lived interpreter
551
+ # can exit. This does not wait for collector/TCP readiness.
552
+ go()
553
+ else:
554
+ t = threading.Thread(target=go)
555
+ t.setDaemon(True)
556
+ t.start()
452
557
 
453
558
  if llm_enabled == 'true':
454
- t = threading.Thread(target=go, kwargs={'llm': True})
455
- t.setDaemon(True)
456
- t.start()
559
+ # llm_enabled=true 라도 LLM 채널 자격증명(llm_accesskey)이 없으면 LLM
560
+ # 에이전트를 띄우지 않는다. (env 로 플래그만 켜진 경우의 오작동 방지 —
561
+ # llm_accesskey/LLM 서버 호스트 없이 LLM 모드로 도는 것을 막는다.)
562
+ llm_accesskey = (preview_whatap_conf("llm_accesskey")
563
+ or os.environ.get("llm_accesskey"))
564
+ if llm_accesskey:
565
+ t = threading.Thread(target=go, kwargs={'llm': True})
566
+ t.setDaemon(True)
567
+ t.start()
568
+ else:
569
+ whatap_print("WHATAP: llm_enabled=true 이나 llm_accesskey 가 "
570
+ "없어 LLM 에이전트 기동을 건너뜁니다")
457
571
 
458
572
  config(home)
459
573
 
@@ -464,14 +578,135 @@ ARCH = {
464
578
  'ARM': 'arm',
465
579
  'AArch64': 'arm64',
466
580
  'arm64': 'arm64',
467
- 'aarch64': 'arm64'
581
+ 'aarch64': 'arm64',
582
+ # Windows 의 platform.machine() 은 **대문자**로 돌려준다('AMD64'/'ARM64').
583
+ # Windows 는 arch 하위 디렉토리를 쓰지 않아 지금은 이 표를 안 타지만, 빠져 있으면
584
+ # 누가 조회하는 순간 KeyError 로 기동이 통째로 죽는 종류의 구멍이라 채워 둔다.
585
+ 'AMD64': 'amd64',
586
+ 'ARM64': 'arm64',
587
+ 'i386': '386',
468
588
  }
469
589
 
470
590
  AGENT_NAME = 'whatap_python'
471
591
 
472
592
 
593
+ def agent_binary_name():
594
+ """OS 별 에이전트 실행 파일 이름."""
595
+ return AGENT_NAME + '.exe' if sys.platform == 'win32' else AGENT_NAME
596
+
597
+
598
+ def llm_agent_reldir():
599
+ """LLM 전용 ``whatap_python`` 이 놓이는 ``$WHATAP_HOME`` 하위 상대경로.
600
+
601
+ Unix 계열은 ``<os>/<arch>`` (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64).
602
+ **Windows 는 arch 하위를 쓰지 않는다** — x64 하나면 ARM64 Windows 에서도 에뮬레이션
603
+ 으로 돌아서 아키텍처별 빌드를 나눌 이유가 없다. 번들 APM 쪽
604
+ ``whatap/agent/windows/`` 가 평평한 것과 같은 이유이고, 같은 모양이다.
605
+
606
+ 반환: ``(상대경로, 사유)``. 상대경로가 None 이면 사유에 왜 못 구하는지가 담긴다.
607
+ """
608
+ system = platform.system().lower()
609
+ if sys.platform == 'win32':
610
+ return system, ''
611
+ machine = platform.machine()
612
+ arch = ARCH.get(machine)
613
+ if not arch:
614
+ return None, ('unsupported architecture {0!r} for the LLM agent '
615
+ '(known: {1})'.format(machine, ', '.join(sorted(set(ARCH.values())))))
616
+ return os.path.join(system, arch), ''
617
+
618
+
619
+ def llm_agent_path():
620
+ """LLM 전용 ``whatap_python`` 의 배포 위치. @see ``llm_agent_reldir``
621
+
622
+ 반환: ``(경로, 사유)``. 경로가 None 이면 사유에 왜 못 쓰는지가 담긴다.
623
+ """
624
+ reldir, reason = llm_agent_reldir()
625
+ if reldir is None:
626
+ return None, reason
627
+ home_path = os.environ.get('WHATAP_HOME')
628
+ if not home_path:
629
+ return None, 'WHATAP_HOME is not set'
630
+ path = os.path.join(home_path, reldir, agent_binary_name())
631
+ if not os.path.exists(path):
632
+ return None, ('LLM agent binary not found: {0} '
633
+ '(place the LLM {1} under $WHATAP_HOME/{2}/)'.format(
634
+ path, agent_binary_name(), reldir.replace(os.sep, '/')))
635
+ return path, ''
636
+
637
+
638
+
639
+ def _native_agent_spec(opts):
640
+ from whatap.util.native_process import AgentSpec
641
+ source = os.path.join(os.path.dirname(__file__), 'agent', 'linux',
642
+ ARCH[platform.machine()], AGENT_NAME)
643
+ env = os.environ.copy()
644
+ env.update(opts)
645
+ return AgentSpec(os.environ['WHATAP_HOME'], source, env)
646
+
647
+
648
+ def _native_agent_log_path():
649
+ return os.path.join(os.environ.get('WHATAP_HOME', '.'), 'logs', AGENT_NAME + '.log')
650
+
651
+
652
+ def _open_native_agent_log():
653
+ # managed 경로는 PIPE 를 읽지 않으므로 네이티브 stdout/stderr 를 파일로 남겨
654
+ # 기동 직후 실패 원인을 추적할 수 있게 한다.
655
+ try:
656
+ log_path = _native_agent_log_path()
657
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
658
+ return open(log_path, 'ab')
659
+ except Exception as e:
660
+ whatap_print('WHATAP: native agent log open failed: %s' % e)
661
+ return subprocess.DEVNULL
662
+
473
663
 
474
664
  def go(batch=False, opts={}, llm=False):
665
+ if sys.platform.startswith('linux') and not batch and not llm:
666
+ from whatap.util.native_process import FAILED, ensure_agent_running
667
+
668
+ def on_child_exit(pid, exit_code):
669
+ whatap_error_log(
670
+ 'WHATAP: native APM exited (PID: {}, exit code: {}, log: {})'.format(
671
+ pid, exit_code, _native_agent_log_path()),
672
+ log_id='WHATAP_AGENT_EXITED')
673
+
674
+ try:
675
+ spec = _native_agent_spec(opts)
676
+ result = ensure_agent_running(
677
+ spec, lambda: _start_go(batch, opts, llm, managed=True),
678
+ on_child_exit=on_child_exit)
679
+ if result.status == FAILED:
680
+ whatap_error_log('WHATAP: native APM startup failed',
681
+ log_id='WHATAP_AGENT_START_FAILED')
682
+ else:
683
+ whatap_print('WHATAP: native APM {} (PID: {})'.format(
684
+ result.status, result.pid))
685
+ except Exception as e:
686
+ whatap_error_log('WHATAP: native APM startup failed: {}'.format(e),
687
+ log_id='WHATAP_AGENT_START_FAILED')
688
+ return
689
+ return _start_go(batch, opts, llm)
690
+
691
+
692
+ def _start_go(batch=False, opts={}, llm=False, managed=False):
693
+ # LLM 에이전트는 전용 whatap_python 이 $WHATAP_HOME 하위에 따로 배포된다
694
+ # (unix 는 <os>/<arch>, windows 는 windows/ — @see llm_agent_reldir).
695
+ # **OS 로 막지 않는다** — 그 자리에 바이너리가 놓였는지로 판단한다(그래야 windows
696
+ # 빌드가 배포되는 순간 코드 변경 없이 살아난다).
697
+ #
698
+ # 여기서 미리 걸러야 하는 이유: 그냥 흘려보내면 배포 분기로 빠져 **번들 APM
699
+ # 바이너리**를 골라 ``--llm`` 으로 띄우고, 심지어 APM 과 같은
700
+ # ``$WHATAP_HOME/whatap_python`` 에 배포해 서로를 덮는다(둘은 다른 바이너리다).
701
+ llm_agent_cmd = None
702
+ if llm:
703
+ llm_agent_cmd, reason = llm_agent_path()
704
+ if llm_agent_cmd is None:
705
+ whatap_error_log(
706
+ 'WHATAP: skipping LLM agent start — {0}'.format(reason),
707
+ log_id='WHATAP_LLM_AGENT_UNAVAILABLE')
708
+ return
709
+
475
710
  newenv=os.environ.copy()
476
711
  newenv['WHATAP_VERSION'] = build.version
477
712
  newenv['whatap.start'] = str(DateUtil.now())
@@ -514,7 +749,7 @@ def go(batch=False, opts={}, llm=False):
514
749
  return ''
515
750
 
516
751
  pid = read_file(home, file_name)
517
- if pid and get_pname(pid).find('whatap_python') >= 0:
752
+ if not managed and pid and get_pname(pid).find('whatap_python') >= 0:
518
753
  if sys.platform == 'win32':
519
754
  # Windows: Don't kill, just skip
520
755
  whatap_print('WHATAP: Agent already running (PID: {}). Skipping duplicate execution.'.format(pid))
@@ -531,48 +766,75 @@ def go(batch=False, opts={}, llm=False):
531
766
  home_path= os.environ.get(home)
532
767
 
533
768
  # Windows uses .exe extension
534
- agent_binary_name = AGENT_NAME + '.exe' if sys.platform == 'win32' else AGENT_NAME
535
- dest_agent = os.path.join(home_path, agent_binary_name)
536
-
537
- # Try to remove existing binary
538
- # Windows: File may still be locked even after waiting, ignore error and use existing binary
539
- # Unix/Linux: Removal should always succeed
540
- needs_deployment = False
541
- if os.path.exists(dest_agent):
542
- try:
543
- os.remove(dest_agent)
544
- needs_deployment = True
545
- except (OSError, PermissionError) as e:
546
- if sys.platform == 'win32':
547
- # Windows: File is still locked, use existing binary
548
- pass
549
- else:
550
- # Unix/Linux: This should not happen
551
- raise
769
+ binary_name = agent_binary_name()
770
+
771
+ # LLM mode runs a dedicated whatap_python binary that ships separately under
772
+ # $WHATAP_HOME (unix: <os>/<arch>, windows: windows/ — @see llm_agent_reldir).
773
+ # It is executed in place from there rather than deployed
774
+ # from the bundled package into $WHATAP_HOME/whatap_python, which the APM
775
+ # agent uses (the two are distinct binaries and would otherwise clobber each
776
+ # other). **OS 로 가르지 않는다** — Windows 만 빼면 거기서 APM 바이너리를
777
+ # 골라 --llm 으로 띄우고 APM 의 배포 경로까지 덮어쓴다.
778
+ run_in_place = llm
779
+
780
+ if run_in_place:
781
+ # 경로·존재 확인은 _start_go() 진입부의 llm_agent_path() 가 이미 했다(단일 소스).
782
+ source_agent = llm_agent_cmd
783
+ # Ensure executable (best effort; ignore on read-only locations)
784
+ if not os.access(source_agent, os.X_OK):
785
+ try:
786
+ os.chmod(source_agent, 0o755)
787
+ except OSError as e:
788
+ whatap_print('WHATAP: chmod LLM agent binary failed: %s' % e)
789
+ agent_cmd = source_agent
552
790
  else:
553
- needs_deployment = True
791
+ dest_agent = os.path.join(home_path, binary_name)
554
792
 
555
- # Windows doesn't use architecture subdirectories (only single x64 binary for now)
556
- if sys.platform == 'win32':
557
- source_cwd = os.path.join(os.path.dirname(__file__), 'agent', 'windows')
558
- else:
559
- source_cwd = os.path.join(os.path.join(os.path.dirname(__file__), 'agent'), platform.system().lower(),
560
- ARCH[platform.machine()])
793
+ # Try to remove existing binary
794
+ # Windows: File may still be locked even after waiting, ignore error and use existing binary
795
+ # Unix/Linux: Removal should always succeed
796
+ needs_deployment = False
797
+ if os.path.exists(dest_agent):
798
+ try:
799
+ os.remove(dest_agent)
800
+ needs_deployment = True
801
+ except (OSError, PermissionError) as e:
802
+ if sys.platform == 'win32':
803
+ # Windows: File is still locked, use existing binary
804
+ pass
805
+ else:
806
+ # Unix/Linux: This should not happen
807
+ raise
808
+ else:
809
+ needs_deployment = True
561
810
 
562
- source_agent = os.path.join(source_cwd, agent_binary_name)
811
+ # Windows doesn't use architecture subdirectories (only single x64 binary for now)
812
+ if sys.platform == 'win32':
813
+ source_cwd = os.path.join(os.path.dirname(__file__), 'agent', 'windows')
814
+ else:
815
+ source_cwd = os.path.join(os.path.join(os.path.dirname(__file__), 'agent'), platform.system().lower(),
816
+ ARCH[platform.machine()])
563
817
 
564
- # Deploy agent binary only if needed
565
- if needs_deployment:
566
- # Try symlink first (Unix/Linux or Windows with proper permissions)
567
- try:
568
- os.symlink(source_agent, dest_agent)
569
- except (OSError, NotImplementedError):
570
- # Fallback to copy on Windows or when symlink fails
571
- import shutil
572
- shutil.copy2(source_agent, dest_agent)
573
- # Make executable on Unix-like systems
574
- if sys.platform != 'win32':
575
- os.chmod(dest_agent, 0o755)
818
+ source_agent = os.path.join(source_cwd, binary_name)
819
+
820
+ # Deploy agent binary only if needed
821
+ if needs_deployment:
822
+ # Try symlink first (Unix/Linux or Windows with proper permissions)
823
+ try:
824
+ os.symlink(source_agent, dest_agent)
825
+ except (OSError, NotImplementedError):
826
+ # Fallback to copy on Windows or when symlink fails
827
+ import shutil
828
+ shutil.copy2(source_agent, dest_agent)
829
+ # Make executable on Unix-like systems
830
+ if sys.platform != 'win32':
831
+ os.chmod(dest_agent, 0o755)
832
+
833
+ # Windows uses absolute path, Unix/Linux uses relative path
834
+ if sys.platform == 'win32':
835
+ agent_cmd = os.path.join(home_path, binary_name)
836
+ else:
837
+ agent_cmd = './{0}'.format(binary_name)
576
838
 
577
839
  sockfile_path = os.path.join(home_path, 'run')
578
840
  if not os.path.exists(sockfile_path):
@@ -581,12 +843,6 @@ def go(batch=False, opts={}, llm=False):
581
843
  newenv['WHATAP_PID_FILE'] = file_name
582
844
  newenv['PYTHON_PARENT_APP_PID'] = str(os.getpid())
583
845
 
584
- # Windows uses absolute path, Unix/Linux uses relative path
585
- if sys.platform == 'win32':
586
- agent_cmd = os.path.join(home_path, agent_binary_name)
587
- else:
588
- agent_cmd = './{0}'.format(agent_binary_name)
589
-
590
846
  # Windows: Run in background with DETACHED_PROCESS
591
847
  # Unix/Linux: Run in background with start_new_session
592
848
  # 실행 인자 구성
@@ -638,6 +894,17 @@ def go(batch=False, opts={}, llm=False):
638
894
  cwd=home_path, env=newenv,
639
895
  creationflags=DETACHED_PROCESS,
640
896
  stdout=subprocess.PIPE, stderr=subprocess.PIPE)
897
+ elif managed:
898
+ go_out = _open_native_agent_log()
899
+ try:
900
+ process = subprocess.Popen(cmd_args,
901
+ cwd=home_path, env=newenv,
902
+ start_new_session=True,
903
+ stdout=go_out, stderr=go_out)
904
+ finally:
905
+ if go_out is not subprocess.DEVNULL:
906
+ go_out.close()
907
+ return process
641
908
  else:
642
909
  process = subprocess.Popen(cmd_args,
643
910
  cwd=home_path, env=newenv,
@@ -657,7 +924,10 @@ def go(batch=False, opts={}, llm=False):
657
924
  except Exception as e:
658
925
  import traceback
659
926
  traceback.print_exc()
660
- whatap_print('WHATAP: AGENT ERROR: {}'.format(e))
927
+ # hook.log + 프로세스 로그 양쪽에 동일하게 노출
928
+ whatap_error_log(
929
+ 'WHATAP: {0} AGENT ERROR: {1}'.format('LLM' if llm else 'APM', e),
930
+ log_id='WHATAP_LLM_AGENT_ERROR' if llm else 'WHATAP_AGENT_ERROR')
661
931
  else:
662
932
  whatap_print('WHATAP: AGENT UP! (process name: {}{})\n'.format(AGENT_NAME, ' --llm' if llm else ''))
663
933
 
@@ -695,8 +965,8 @@ def _service_trace(fn, service_name=None):
695
965
  """fn 을 하나의 트랜잭션(TX_START~TX_END)으로 감싼 래퍼를 반환한다.
696
966
 
697
967
  ``@method_profiling`` 데코레이터와 conf ``hook_service_patterns``
698
- (whatap.trace.mod.hook_service_patterns) 의 **공용 코어**. 여기엔 agent() 부팅이
699
- 없다 (데코레이터/부트 경로가 각자 처리).
968
+ (whatap.trace.mod.hook_service_patterns) 의 **공용 코어**. 여기엔 agent() 부팅이 없다
969
+ (데코레이터/부트 경로가 각자 처리).
700
970
 
701
971
  코루틴 함수는 동기 래퍼로 감싸면 트랜잭션이 start_interceptor → 코루틴 생성 →
702
972
  end_interceptor 로 즉시 열렸다 닫히고, 실제 본문은 호출자가 await 할 때 트랜잭션
@@ -772,26 +1042,7 @@ def _service_trace(fn, service_name=None):
772
1042
 
773
1043
 
774
1044
  def method_profiling(fn):
775
- # NOTE: 의도적으로 _service_trace 를 쓰지 않는다. 이 데코레이터는 이미 배포된
776
- # 공개 API 라, 예외 재전파/컨텍스트 정리/async 지원으로 바꾸면 기존 사용자의
777
- # 동작이 달라진다. 해당 수정은 dev 에 있고 정식 머지 때 함께 들어온다.
778
- def trace(*args, **kwargs):
779
- callback = None
780
- try:
781
- ctx = TraceContext()
782
- ctx.service_name=fn.__name__
783
- start_interceptor(ctx)
784
- callback = fn(*args, **kwargs)
785
- except Exception as e:
786
- ctx = TraceContextManager.getLocalContext()
787
- interceptor_step_error(e, ctx=ctx)
788
- interceptor_error_log(ctx.id, e, fn, args, kwargs)
789
- logging.debug('WHATAP(@method_profiling): ' + str(e),
790
- extra={'id': 'WA776'}, exc_info=True)
791
- finally:
792
- ctx = TraceContextManager.getLocalContext()
793
- end_interceptor(ctx=ctx)
794
- return callback
1045
+ trace = _service_trace(fn)
795
1046
 
796
1047
  if not os.environ.get('whatap.enabled'):
797
1048
  agent()
@@ -966,6 +1217,22 @@ def get_port_number(port=6600, home=os.environ.get('WHATAP_HOME'), lock_file=Non
966
1217
 
967
1218
 
968
1219
  def configPort():
1220
+ if sys.platform.startswith('linux') and preview_whatap_conf('apm_enabled') == 'true':
1221
+ from whatap.util.native_process import acquire_start_lock, configured_channel, find_existing_agent
1222
+ try:
1223
+ spec = _native_agent_spec({})
1224
+ with acquire_start_lock(spec):
1225
+ if find_existing_agent(spec):
1226
+ # CLI allocation must not rewrite a live bootstrap agent's channel.
1227
+ return configured_channel(spec.home, spec.environment, include_unix=False)[1]
1228
+ return _config_port()
1229
+ except Exception as e:
1230
+ # 락 타임아웃/다중 인스턴스 등은 CLI 를 죽이지 말고 기존 포트 할당으로 폴백
1231
+ whatap_print('WHATAP: live agent check skipped, falling back to port file: %s' % e)
1232
+ return _config_port()
1233
+
1234
+
1235
+ def _config_port():
969
1236
  # force_net_udp_port가 설정된 경우 lock 파일 무시하고 해당 포트 강제 사용
970
1237
  force_port_str = preview_whatap_conf("force_net_udp_port")
971
1238
  if force_port_str and force_port_str not in ('false', 'False'):
Binary file
Binary file
whatap/build.py CHANGED
@@ -1,4 +1,4 @@
1
1
  app = 'Python'
2
2
  name = 'whatap-python'
3
- version = '2.1.4rc1'
4
- release_date = '20260817'
3
+ version = '2.2.1'
4
+ release_date = '20260929'