codebuff-agent 2.1.74__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.
- codebuff_agent-2.1.74.data/data/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/package.json +157 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/150.39fa98117cc8ca590465.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/166.386ff9b2c37ac865fdee.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/216.25cf81131bf7274dabf4.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/353.581bd1cb00746399b62e.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/367.575f9621b63c9a641aa9.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/421.11dac223a2c2da1b09f5.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/481.cc2dec9fa869d66f1407.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/487.0796e931d6e651482c14.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/49.d326f554b6f9ccde6580.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/541.26897ea32845659e36be.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/579.e569d3edd8391fd8fbb3.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/666.d9bfdb23f98489d0a188.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/68.26b6f59c8454adb17f20.js +204 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/694.4ac443814260057e68f7.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/791.61affb8d3480d3d60e0d.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js +2 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/style.js +4 -0
- codebuff_agent-2.1.74.data/data/share/jupyter/labextensions/codebuff/static/third-party-licenses.json +724 -0
- codebuff_agent-2.1.74.dist-info/METADATA +142 -0
- codebuff_agent-2.1.74.dist-info/RECORD +217 -0
- codebuff_agent-2.1.74.dist-info/WHEEL +4 -0
- codebuff_agent-2.1.74.dist-info/licenses/LICENSE +21 -0
- codebuff_core/__init__.py +153 -0
- codebuff_core/codebuff_config_defaults.json +64 -0
- codebuff_core/core/__init__.py +35 -0
- codebuff_core/core/code_validator.py +1238 -0
- codebuff_core/core/error_classifier.py +579 -0
- codebuff_core/core/exceptions.py +49 -0
- codebuff_core/data/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
- codebuff_core/directory_policy.py +32 -0
- codebuff_core/factory.py +155 -0
- codebuff_core/interfaces.py +197 -0
- codebuff_core/knowledge/__init__.py +31 -0
- codebuff_core/knowledge/chunking.py +375 -0
- codebuff_core/knowledge/loader.py +339 -0
- codebuff_core/knowledge/watchdog_service.py +354 -0
- codebuff_core/legacy_paths.py +69 -0
- codebuff_core/llm/__init__.py +13 -0
- codebuff_core/llm/service.py +407 -0
- codebuff_core/managers/__init__.py +22 -0
- codebuff_core/managers/config_manager.py +690 -0
- codebuff_core/managers/remote_config.py +673 -0
- codebuff_core/managers/remote_skills.py +539 -0
- codebuff_core/managers/session_manager.py +255 -0
- codebuff_core/middleware/__init__.py +3 -0
- codebuff_core/middleware/error_recovery.py +394 -0
- codebuff_core/middleware/logging_utils.py +33 -0
- codebuff_core/models/__init__.py +111 -0
- codebuff_core/models/agent.py +306 -0
- codebuff_core/models/chat.py +57 -0
- codebuff_core/models/common.py +82 -0
- codebuff_core/models/rag.py +300 -0
- codebuff_core/platform_rules.py +127 -0
- codebuff_core/prompts/__init__.py +44 -0
- codebuff_core/prompts/auto_agent_prompts.py +1278 -0
- codebuff_core/services/__init__.py +30 -0
- codebuff_core/services/agent_service.py +345 -0
- codebuff_core/services/analytics_aggregate.py +837 -0
- codebuff_core/services/chat_service.py +898 -0
- codebuff_core/services/context_condenser.py +306 -0
- codebuff_core/services/pii_guardrail.py +393 -0
- codebuff_core/services/rag_chat_agent.py +2993 -0
- codebuff_core/services/rag_collections.py +2740 -0
- codebuff_core/services/rag_manager.py +890 -0
- codebuff_core/services/rag_service.py +145 -0
- codebuff_core/services/retriever.py +260 -0
- codebuff_core/services/vllm_embedding_service.py +290 -0
- codebuff_core/tests/__init__.py +1 -0
- codebuff_core/tests/conftest.py +81 -0
- codebuff_core/tests/test_error_recovery.py +647 -0
- codebuff_core/tests/test_factory.py +135 -0
- codebuff_core/tests/test_services.py +150 -0
- jupyter_ext/__init__.py +266 -0
- jupyter_ext/_version.py +4 -0
- jupyter_ext/agent/__init__.py +49 -0
- jupyter_ext/agent/agent_factory.py +2270 -0
- jupyter_ext/agent/ast_tools.py +451 -0
- jupyter_ext/agent/autofix_middleware.py +481 -0
- jupyter_ext/agent/background_procs.py +372 -0
- jupyter_ext/agent/code_history_middleware.py +211 -0
- jupyter_ext/agent/codex_autofix.py +160 -0
- jupyter_ext/agent/codex_bridge.py +3339 -0
- jupyter_ext/agent/codex_hooks.py +320 -0
- jupyter_ext/agent/codex_slash.py +685 -0
- jupyter_ext/agent/codex_threads.py +114 -0
- jupyter_ext/agent/content_injection.py +377 -0
- jupyter_ext/agent/continuation_middleware.py +548 -0
- jupyter_ext/agent/custom_skills.py +335 -0
- jupyter_ext/agent/file_diffs.py +99 -0
- jupyter_ext/agent/git_history.py +188 -0
- jupyter_ext/agent/guardrail_middleware.py +421 -0
- jupyter_ext/agent/json_repair_utils.py +54 -0
- jupyter_ext/agent/langchain_tools.py +2673 -0
- jupyter_ext/agent/mermaid_fixer.py +289 -0
- jupyter_ext/agent/mermaid_middleware.py +135 -0
- jupyter_ext/agent/middleware.py +359 -0
- jupyter_ext/agent/middleware_prompts.py +53 -0
- jupyter_ext/agent/observability.py +334 -0
- jupyter_ext/agent/patch_tool_calls.py +57 -0
- jupyter_ext/agent/prompts.py +112 -0
- jupyter_ext/agent/skill_middleware.py +257 -0
- jupyter_ext/agent/skills/airflow_dag_development.md +36 -0
- jupyter_ext/agent/skills/anomaly_detection.md +12 -0
- jupyter_ext/agent/skills/athena_sql.md +156 -0
- jupyter_ext/agent/skills/bdp_table_search.md +33 -0
- jupyter_ext/agent/skills/bigquery.md +65 -0
- jupyter_ext/agent/skills/bigquery_oracle.md +231 -0
- jupyter_ext/agent/skills/bigquery_tuning.md +307 -0
- jupyter_ext/agent/skills/classification_evaluation.md +11 -0
- jupyter_ext/agent/skills/clustering_analysis.md +12 -0
- jupyter_ext/agent/skills/comprehensive_eda.md +11 -0
- jupyter_ext/agent/skills/data_analysis.md +236 -0
- jupyter_ext/agent/skills/data_cleaning_pipeline.md +12 -0
- jupyter_ext/agent/skills/data_loading.md +158 -0
- jupyter_ext/agent/skills/data_quality_report.md +13 -0
- jupyter_ext/agent/skills/dimensionality_reduction.md +12 -0
- jupyter_ext/agent/skills/emr_script_development.md +17 -0
- jupyter_ext/agent/skills/experiment_tracking.md +13 -0
- jupyter_ext/agent/skills/feature_importance_analysis.md +10 -0
- jupyter_ext/agent/skills/hyperparameter_tuning.md +12 -0
- jupyter_ext/agent/skills/hypothesis_testing.md +12 -0
- jupyter_ext/agent/skills/imbalanced_data_handling.md +12 -0
- jupyter_ext/agent/skills/inference.md +392 -0
- jupyter_ext/agent/skills/lambda_function_development.md +19 -0
- jupyter_ext/agent/skills/model_comparison.md +11 -0
- jupyter_ext/agent/skills/model_explainability.md +12 -0
- jupyter_ext/agent/skills/model_training.md +318 -0
- jupyter_ext/agent/skills/pyspark.md +352 -0
- jupyter_ext/agent/skills/python_code_quality.md +35 -0
- jupyter_ext/agent/skills/regression_evaluation.md +12 -0
- jupyter_ext/agent/skills/spring_java_development.md +35 -0
- jupyter_ext/agent/skills/text_preprocessing.md +12 -0
- jupyter_ext/agent/skills/time_series_analysis.md +13 -0
- jupyter_ext/agent/skills/unit_testing.md +203 -0
- jupyter_ext/agent/todo_prompts.py +57 -0
- jupyter_ext/agent/tool_report.py +45 -0
- jupyter_ext/analytics_ingest.py +599 -0
- jupyter_ext/etc/jupyter/jupyter_server_config.d/codebuff_agent.json +7 -0
- jupyter_ext/handlers/__init__.py +8 -0
- jupyter_ext/handlers/analytics_handler.py +170 -0
- jupyter_ext/handlers/benchmark_handler.py +794 -0
- jupyter_ext/handlers/eda_benchmark_handler.py +1328 -0
- jupyter_ext/handlers/rag_admin_handler.py +689 -0
- jupyter_ext/handlers/websocket.py +497 -0
- jupyter_ext/http_handlers.py +8744 -0
- jupyter_ext/idle_monitor.py +346 -0
- jupyter_ext/labextension/package.json +157 -0
- jupyter_ext/labextension/static/104.2b8f20f9d1ac2c9db01b.js +1 -0
- jupyter_ext/labextension/static/150.39fa98117cc8ca590465.js +1 -0
- jupyter_ext/labextension/static/166.386ff9b2c37ac865fdee.js +1 -0
- jupyter_ext/labextension/static/216.25cf81131bf7274dabf4.js +1 -0
- jupyter_ext/labextension/static/353.581bd1cb00746399b62e.js +1 -0
- jupyter_ext/labextension/static/367.575f9621b63c9a641aa9.js +1 -0
- jupyter_ext/labextension/static/421.11dac223a2c2da1b09f5.js +1 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js +2 -0
- jupyter_ext/labextension/static/432.8513b4c6cc1d1232d165.js.LICENSE.txt +19 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js +2 -0
- jupyter_ext/labextension/static/439.ba5b5ef70ec6de214b8c.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/481.cc2dec9fa869d66f1407.js +1 -0
- jupyter_ext/labextension/static/487.0796e931d6e651482c14.js +1 -0
- jupyter_ext/labextension/static/49.d326f554b6f9ccde6580.js +1 -0
- jupyter_ext/labextension/static/541.26897ea32845659e36be.js +1 -0
- jupyter_ext/labextension/static/579.e569d3edd8391fd8fbb3.js +1 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js +2 -0
- jupyter_ext/labextension/static/58.eb7cc679f7535cc8a2f1.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/666.d9bfdb23f98489d0a188.js +1 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js +2 -0
- jupyter_ext/labextension/static/677.d266c39dbed33bc388b3.js.LICENSE.txt +8 -0
- jupyter_ext/labextension/static/68.26b6f59c8454adb17f20.js +204 -0
- jupyter_ext/labextension/static/694.4ac443814260057e68f7.js +1 -0
- jupyter_ext/labextension/static/791.61affb8d3480d3d60e0d.js +1 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js +2 -0
- jupyter_ext/labextension/static/848.4d2db09cfbb09374b299.js.LICENSE.txt +9 -0
- jupyter_ext/labextension/static/868.5cfe7e1a348ec5af1b8c.js +1 -0
- jupyter_ext/labextension/static/97.ea559a5b8a9a09f17c6b.js +1 -0
- jupyter_ext/labextension/static/remoteEntry.5292fc9c6df7e7920cc8.js +1 -0
- jupyter_ext/labextension/static/style.js +4 -0
- jupyter_ext/labextension/static/third-party-licenses.json +724 -0
- jupyter_ext/log_paths.py +113 -0
- jupyter_ext/resource_usage.py +519 -0
- jupyter_ext/secrets_bootstrap.py +116 -0
- jupyter_ext/team_access.py +105 -0
- jupyter_ext/tests/__init__.py +1 -0
- jupyter_ext/tests/test_context.py +234 -0
- jupyter_ext/tests/test_factory.py +563 -0
- jupyter_ext/tests/test_file_finder.py +139 -0
- jupyter_ext/tests/test_handlers.py +58 -0
- jupyter_ext/tests/test_mcp_selftest.py +204 -0
- jupyter_ext/tests/test_mermaid_fixer.py +272 -0
- jupyter_ext/tests/test_resource_tiers.py +298 -0
- jupyter_ext/tools/__init__.py +15 -0
- jupyter_ext/tools/base.py +90 -0
- jupyter_ext/tools/context.py +268 -0
- jupyter_ext/tools/dir_complete.py +86 -0
- jupyter_ext/tools/file_finder.py +303 -0
- jupyter_ext/tools/file_tools.py +244 -0
- jupyter_ext/tools/jupyter_tools.py +296 -0
- jupyter_ext/tools/registry.py +159 -0
- jupyter_ext/tools/search_tools.py +230 -0
- jupyter_ext/tools/security_tools.py +657 -0
- jupyter_ext/tools/shell_tools.py +109 -0
- jupyter_ext/tools/user_tools.py +132 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""코드버프 로그 RDS 적재 송신부 (이슈 #30).
|
|
2
|
+
|
|
3
|
+
지금 로그는 pod 로컬 JSONL 파일로만 쌓인다. 이 모듈은 **파일 쓰기는 그대로 두고**
|
|
4
|
+
같은 이벤트를 cd1-agent 수집 API 로 한 벌 더 보낸다. 파일이 내구 사본으로 남으므로
|
|
5
|
+
전송이 실패해도 데이터를 잃지 않는다.
|
|
6
|
+
|
|
7
|
+
세 가지가 이 모듈의 계약이다.
|
|
8
|
+
|
|
9
|
+
1. **호출자를 절대 막지 않는다.** `enqueue`/`flush` 는 어떤 경우에도 예외를 밖으로
|
|
10
|
+
내지 않고, 큐가 차면 버린다. 로깅 실패가 사용자 작업을 막으면 안 된다 —
|
|
11
|
+
기존 `analytics_handler` 의 `except: pass` 성질을 그대로 잇는다.
|
|
12
|
+
2. **`CODEBUFF_INGEST_URL` 이 비면 완전한 no-op 이다.** 큐에 쌓지도 않는다. 그래서
|
|
13
|
+
수집 API·시크릿·네트워크가 준비되기 전에도 이 코드를 안전하게 머지할 수 있다.
|
|
14
|
+
3. **`context` 는 원문 그대로 보낸다** (이슈 #30 판단, 2026-09-03). 화이트리스트로
|
|
15
|
+
지우지 않는다. 대신 행 크기 상한 하나만 코드가 진다 — `MAX_CONTEXT_BYTES` 를
|
|
16
|
+
넘으면 **행을 버리지 않고 잘라서** `context_truncated` 로 표시한다.
|
|
17
|
+
⚠️ 원문에는 사용자 프롬프트와 셀 소스코드가 들어 있다. 접근 통제(GRANT)와
|
|
18
|
+
보존기간(TTL)은 DB 쪽에서 정해야 하는 몫이다 (이슈 #30 `## 미해결`).
|
|
19
|
+
|
|
20
|
+
재시도는 하지 않는다. 대신 `event_id` 에 UNIQUE 를 걸어(`schemas/rds/codebuff_mysql.sql`)
|
|
21
|
+
수집 API 가 나중에 재시도를 넣더라도 중복이 쌓이지 않게 해 뒀다.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import atexit
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import queue
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from datetime import datetime, timedelta, timezone
|
|
35
|
+
from typing import Any, Optional
|
|
36
|
+
|
|
37
|
+
from .team_access import resolve_team
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
#: 사내 표준 시간대. 컨테이너 TZ 에 기대지 않고 오프셋을 명시한다 — 기존
|
|
42
|
+
#: `analytics_handler` 의 `datetime.now()` 는 naive 라 컨테이너가 UTC 면 9시간 밀린다.
|
|
43
|
+
KST = timezone(timedelta(hours=9))
|
|
44
|
+
|
|
45
|
+
#: `context` JSON 한 건의 상한. 원문 적재를 택했으므로 상한이 없으면 페이로드가
|
|
46
|
+
#: 무한정 커진다 (선례 `telemetry_ingest.py` 는 본문 4MB 를 넘기면 요청 전체를 버린다).
|
|
47
|
+
MAX_CONTEXT_BYTES = 64 * 1024
|
|
48
|
+
|
|
49
|
+
QUEUE_MAXSIZE = 5000
|
|
50
|
+
|
|
51
|
+
#: 절단 폴백에서 "작다" 의 기준. 집계가 쓰는 필드(cell_index, cell_attribution,
|
|
52
|
+
#: agent_code_ratio_pct)는 전부 이보다 훨씬 작다.
|
|
53
|
+
_SMALL_VALUE_BYTES = 512
|
|
54
|
+
FLUSH_BATCH = 200
|
|
55
|
+
#: 요청 1건의 바이트 상한 (이슈 #98). 행 수만으로 자르면 최악 200행 × 64KB = 12.8MB 가
|
|
56
|
+
#: 나가는데, 수신 선례(`telemetry_ingest.py`)의 본문 상한은 4MB 이고 413 이면 배치 전체를
|
|
57
|
+
#: 버린다. 헤더·키 여유를 두고 3MB 로 자른다.
|
|
58
|
+
MAX_BATCH_BYTES = 3 * 1024 * 1024
|
|
59
|
+
FLUSH_INTERVAL_SEC = 30.0
|
|
60
|
+
HTTP_TIMEOUT_SEC = 5.0
|
|
61
|
+
|
|
62
|
+
#: 세션 종료 경로의 예산. 이 경로는 **async 호출자 안에서 동기로** 돌기 때문에
|
|
63
|
+
#: (`idle_monitor._monitor_loop`, `IdleShutdownHandler.post` 둘 다 `async def`)
|
|
64
|
+
#: 이벤트루프를 잡는다. 그래서 요청 1건의 타임아웃이 아니라 **전체 예산**을 건다 —
|
|
65
|
+
#: 요청마다 2초씩 무한정 쓰는 일이 없도록 배치 루프가 이 예산을 보고 멈춘다.
|
|
66
|
+
SESSION_END_BUDGET_SEC = 2.0
|
|
67
|
+
SESSION_END_REQUEST_TIMEOUT_SEC = 1.0
|
|
68
|
+
SCHEMA_VERSION = 1
|
|
69
|
+
#: codebuff↔centri 통신의 공유 키 — codex 게이트웨이와 같은 `codex_api_key` 를 Bearer 로 보낸다.
|
|
70
|
+
#: 수신기 전용 토큰은 두지 않는다 (이슈 #113, 2026-09-25 사용자 결정).
|
|
71
|
+
TOKEN_ENV = "CODEX_SERVER_API_KEY"
|
|
72
|
+
|
|
73
|
+
TABLE_UX_EVENT = "ux_event_hst"
|
|
74
|
+
TABLE_AGENT_CALL = "agent_call_hst"
|
|
75
|
+
TABLE_SESSION_END = "session_end_hst"
|
|
76
|
+
_TABLES = (TABLE_UX_EVENT, TABLE_AGENT_CALL, TABLE_SESSION_END)
|
|
77
|
+
|
|
78
|
+
_UX_EVENT_TYPES = ("impression", "click", "error")
|
|
79
|
+
_AGENT_EVENT_KINDS = ("model_call", "tool_call", "error_classification", "turn")
|
|
80
|
+
|
|
81
|
+
_ENV_TEAM = resolve_team()
|
|
82
|
+
_ENV_PROJECT = os.environ.get("HDSP_PRJ_ID", "")
|
|
83
|
+
_ENV_USER = os.environ.get("JUPYTERHUB_USER", "") or os.environ.get("HDSP_BDP_ID", "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# 값 정규화
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
def _as_float(value: Any) -> Optional[float]:
|
|
90
|
+
try:
|
|
91
|
+
if value is None or isinstance(value, bool):
|
|
92
|
+
return None
|
|
93
|
+
return float(value)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _as_int(value: Any) -> Optional[int]:
|
|
99
|
+
try:
|
|
100
|
+
if value is None or isinstance(value, bool):
|
|
101
|
+
return None
|
|
102
|
+
return int(value)
|
|
103
|
+
except (TypeError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _as_flag(value: Any) -> Optional[int]:
|
|
108
|
+
if value is None:
|
|
109
|
+
return None
|
|
110
|
+
return 1 if value else 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _as_text(value: Any) -> Optional[str]:
|
|
114
|
+
if value is None:
|
|
115
|
+
return None
|
|
116
|
+
return value if isinstance(value, str) else str(value)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _as_clipped(value: Any, limit: int) -> Optional[str]:
|
|
120
|
+
"""칼럼 폭까지 자른다. 넘겨 보내면 INSERT IGNORE 가 경고만 남기고 조용히 자른다."""
|
|
121
|
+
text = _as_text(value)
|
|
122
|
+
return None if text is None else text[:limit]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _blob_len(obj: Any) -> int:
|
|
126
|
+
return len(json.dumps(obj, ensure_ascii=False, default=str).encode("utf-8"))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _normalize_context(raw: Any) -> "tuple[dict, int]":
|
|
130
|
+
"""`context` 를 원문 그대로 두되 타입 드리프트만 흡수하고 상한을 건다.
|
|
131
|
+
|
|
132
|
+
반환: (context, context_truncated)
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(raw, dict):
|
|
135
|
+
return {}, 0
|
|
136
|
+
|
|
137
|
+
ctx = dict(raw)
|
|
138
|
+
|
|
139
|
+
# 실측 드리프트: 같은 키가 int(43건)/float(26건) 으로 섞여 들어온다.
|
|
140
|
+
# JSON 타입이 섞이면 조회 쪽에서 캐스팅이 갈라지므로 여기서 하나로 만든다.
|
|
141
|
+
if "agent_code_ratio_pct" in ctx:
|
|
142
|
+
ctx["agent_code_ratio_pct"] = _as_float(ctx["agent_code_ratio_pct"])
|
|
143
|
+
|
|
144
|
+
if _blob_len(ctx) <= MAX_CONTEXT_BYTES:
|
|
145
|
+
return ctx, 0
|
|
146
|
+
|
|
147
|
+
# 긴 문자열부터 반씩 줄인다. 집계가 쓰는 필드는 짧아서 마지막까지 남는다.
|
|
148
|
+
for _ in range(64):
|
|
149
|
+
if _blob_len(ctx) <= MAX_CONTEXT_BYTES:
|
|
150
|
+
return ctx, 1
|
|
151
|
+
longest_key = None
|
|
152
|
+
longest_len = 0
|
|
153
|
+
for key, value in ctx.items():
|
|
154
|
+
if isinstance(value, str) and len(value) > longest_len:
|
|
155
|
+
longest_key, longest_len = key, len(value)
|
|
156
|
+
if longest_key is None:
|
|
157
|
+
break
|
|
158
|
+
ctx[longest_key] = (
|
|
159
|
+
ctx[longest_key][: longest_len // 2] if longest_len > 16 else ""
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if _blob_len(ctx) > MAX_CONTEXT_BYTES:
|
|
163
|
+
# 문자열 halving 으로 못 줄인 경우(중첩 구조·키가 아주 많은 경우).
|
|
164
|
+
# 통째로 버리면 A5 가 쓰는 cell_attribution 같은 **작은 집계 필드까지** 사라진다.
|
|
165
|
+
# 그래서 작은 값만 남기고 큰 값을 버린다.
|
|
166
|
+
kept = {
|
|
167
|
+
key: value
|
|
168
|
+
for key, value in ctx.items()
|
|
169
|
+
if _blob_len(value) <= _SMALL_VALUE_BYTES
|
|
170
|
+
}
|
|
171
|
+
kept["_truncated"] = True
|
|
172
|
+
ctx = kept if _blob_len(kept) <= MAX_CONTEXT_BYTES else {"_truncated": True}
|
|
173
|
+
return ctx, 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _identity_of(event: dict, identity: Optional[dict]) -> "dict[str, str]":
|
|
177
|
+
src = identity or {}
|
|
178
|
+
return {
|
|
179
|
+
"team": event.get("team") or src.get("team") or _ENV_TEAM,
|
|
180
|
+
"project": event.get("project") or src.get("project") or _ENV_PROJECT,
|
|
181
|
+
"user_id": event.get("user_id") or src.get("user_id") or _ENV_USER,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _from_epoch_ms(value: Any) -> Optional[datetime]:
|
|
186
|
+
"""브라우저 `Date.now()` (epoch ms) → KST. 유일하게 모호하지 않은 시각원이다."""
|
|
187
|
+
ms = _as_int(value)
|
|
188
|
+
if ms is None or ms <= 0:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
return datetime.fromtimestamp(ms / 1000.0, KST)
|
|
192
|
+
except (OverflowError, OSError, ValueError):
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _from_naive_text(value: Any) -> Optional[datetime]:
|
|
197
|
+
"""파일에 적힌 naive 시각 문자열 → KST 로 간주.
|
|
198
|
+
|
|
199
|
+
⚠️ 원본이 `datetime.now()` (naive) 라 pod 컨테이너 TZ 가 UTC 면 실제로는 UTC 다.
|
|
200
|
+
그 판정은 이슈 #30 `## 미해결` [측정] 이 추적한다. 여기서는 KST 로 간주하되,
|
|
201
|
+
epoch ms 가 있으면 그쪽을 먼저 쓴다.
|
|
202
|
+
"""
|
|
203
|
+
text = _as_text(value)
|
|
204
|
+
if not text:
|
|
205
|
+
return None
|
|
206
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"):
|
|
207
|
+
try:
|
|
208
|
+
return datetime.strptime(text, fmt).replace(tzinfo=KST)
|
|
209
|
+
except ValueError:
|
|
210
|
+
continue
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _stamp(*candidates: Any) -> "tuple[str, str]":
|
|
215
|
+
"""이벤트 자신의 시각을 우선 쓰고, 없을 때만 현재 시각으로 떨어진다.
|
|
216
|
+
|
|
217
|
+
빌드 시각을 찍으면 라이브 적재는 맞지만 **파일을 나중에 배치로 백필할 때
|
|
218
|
+
전부 오늘 날짜가 된다.** 파일이 내구 사본이라는 이 설계의 전제가 깨지므로
|
|
219
|
+
이벤트가 들고 온 시각을 먼저 본다.
|
|
220
|
+
"""
|
|
221
|
+
moment = next((c for c in candidates if isinstance(c, datetime)), None)
|
|
222
|
+
if moment is None:
|
|
223
|
+
moment = datetime.now(KST)
|
|
224
|
+
return moment.strftime("%Y-%m-%d %H:%M:%S"), moment.strftime("%Y-%m-%d")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# 행 빌더 — 순수 함수. 실패하면 예외 대신 None 을 돌려준다
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
def build_ux_event_row(
|
|
231
|
+
event: Any,
|
|
232
|
+
identity: Optional[dict] = None,
|
|
233
|
+
now: Optional[datetime] = None,
|
|
234
|
+
) -> Optional[dict]:
|
|
235
|
+
"""analytics 이벤트(impression/click/error) → `ux_event_hst` 행."""
|
|
236
|
+
try:
|
|
237
|
+
if not isinstance(event, dict):
|
|
238
|
+
return None
|
|
239
|
+
event_type = event.get("event_type")
|
|
240
|
+
if event_type not in _UX_EVENT_TYPES:
|
|
241
|
+
return None
|
|
242
|
+
# 레거시 키 `event_name` 을 현행 `feature` 로 접는다 (실측 2건).
|
|
243
|
+
feature = event.get("feature") or event.get("event_name")
|
|
244
|
+
if not feature:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
context, truncated = _normalize_context(event.get("context"))
|
|
248
|
+
event_at, event_dt = _stamp(
|
|
249
|
+
now,
|
|
250
|
+
_from_epoch_ms(event.get("timestamp")),
|
|
251
|
+
_from_naive_text(event.get("datetime")),
|
|
252
|
+
)
|
|
253
|
+
return {
|
|
254
|
+
"event_id": _as_text(event.get("event_id")) or str(uuid.uuid4()),
|
|
255
|
+
"session_id": _as_text(event.get("session_id")),
|
|
256
|
+
"event_type": event_type,
|
|
257
|
+
"feature": _as_text(feature),
|
|
258
|
+
"event_at": event_at,
|
|
259
|
+
"event_dt": event_dt,
|
|
260
|
+
"client_ts": _as_int(event.get("timestamp")),
|
|
261
|
+
"extension_version": _as_text(event.get("extension_version")),
|
|
262
|
+
"cpu_cores": _as_float(event.get("cpu_cores")),
|
|
263
|
+
"memory_total_gb": _as_float(event.get("memory_total_gb")),
|
|
264
|
+
"gpu_name": _as_text(event.get("gpu_name")),
|
|
265
|
+
"gpu_memory_total_mb": _as_int(event.get("gpu_memory_total_mb")),
|
|
266
|
+
"context": context,
|
|
267
|
+
"context_truncated": truncated,
|
|
268
|
+
**_identity_of(event, identity),
|
|
269
|
+
}
|
|
270
|
+
except Exception:
|
|
271
|
+
logger.debug("build_ux_event_row failed", exc_info=True)
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def build_agent_call_row(
|
|
276
|
+
event: Any,
|
|
277
|
+
identity: Optional[dict] = None,
|
|
278
|
+
now: Optional[datetime] = None,
|
|
279
|
+
) -> Optional[dict]:
|
|
280
|
+
"""구조화 이벤트(model_call/tool_call/error_classification/turn) → `agent_call_hst` 행."""
|
|
281
|
+
try:
|
|
282
|
+
if not isinstance(event, dict):
|
|
283
|
+
return None
|
|
284
|
+
kind = event.get("event")
|
|
285
|
+
if kind not in _AGENT_EVENT_KINDS:
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
called_at, called_dt = _stamp(now, _from_naive_text(event.get("ts")))
|
|
289
|
+
return {
|
|
290
|
+
"event_id": str(uuid.uuid4()),
|
|
291
|
+
"event_kind": kind,
|
|
292
|
+
"agent_backend": _as_text(event.get("agent_backend")),
|
|
293
|
+
"called_at": called_at,
|
|
294
|
+
"called_dt": called_dt,
|
|
295
|
+
"session_id": _as_text(event.get("session_id")),
|
|
296
|
+
"ux_session_id": _as_text(event.get("ux_session_id")),
|
|
297
|
+
"call_num": _as_int(event.get("call_num")),
|
|
298
|
+
"elapsed_ms": _as_float(event.get("elapsed_ms")),
|
|
299
|
+
"status": _as_text(event.get("status")),
|
|
300
|
+
"tool_name": _as_text(event.get("tool_name")),
|
|
301
|
+
"error_message": _as_text(event.get("error")),
|
|
302
|
+
"input_msg_count": _as_int(event.get("input_msg_count")),
|
|
303
|
+
"output_tool_calls": _as_int(event.get("output_tool_calls")),
|
|
304
|
+
"model_name": _as_clipped(event.get("model_name"), 64),
|
|
305
|
+
"agent_name": _as_clipped(event.get("agent_name"), 64),
|
|
306
|
+
"input_tokens": _as_int(event.get("input_tokens")),
|
|
307
|
+
"output_tokens": _as_int(event.get("output_tokens")),
|
|
308
|
+
"cached_tokens": _as_int(event.get("cached_tokens")),
|
|
309
|
+
"finish_reason": _as_clipped(event.get("finish_reason"), 32),
|
|
310
|
+
"error_type": _as_text(event.get("error_type")),
|
|
311
|
+
"decision": _as_text(event.get("decision")),
|
|
312
|
+
"root_cause": _as_text(event.get("root_cause")),
|
|
313
|
+
"used_llm": _as_flag(event.get("used_llm")),
|
|
314
|
+
"confidence": _as_float(event.get("confidence")),
|
|
315
|
+
**_identity_of(event, identity),
|
|
316
|
+
}
|
|
317
|
+
except Exception:
|
|
318
|
+
logger.debug("build_agent_call_row failed", exc_info=True)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_session_end_row(
|
|
323
|
+
event: Any,
|
|
324
|
+
identity: Optional[dict] = None,
|
|
325
|
+
now: Optional[datetime] = None,
|
|
326
|
+
) -> Optional[dict]:
|
|
327
|
+
"""idle_shutdown 이벤트 → `session_end_hst` 행.
|
|
328
|
+
|
|
329
|
+
이 이벤트는 pod 가 죽기 직전에 한 번 나온다. 필드가 비어 있어도 "세션이 끝났다" 는
|
|
330
|
+
사실 자체가 정보이므로 **행을 만들어 낸다** (다른 둘과 달리 None 을 돌려주지 않는다).
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
if not isinstance(event, dict):
|
|
334
|
+
event = {}
|
|
335
|
+
ended_at, ended_dt = _stamp(now, _from_naive_text(event.get("ts")))
|
|
336
|
+
return {
|
|
337
|
+
"event_id": str(uuid.uuid4()),
|
|
338
|
+
"ended_at": ended_at,
|
|
339
|
+
"ended_dt": ended_dt,
|
|
340
|
+
"idle_seconds": _as_float(event.get("idle_seconds")),
|
|
341
|
+
"timeout_setting": _as_int(event.get("timeout_setting")),
|
|
342
|
+
"trigger_source": _as_text(event.get("trigger_source")),
|
|
343
|
+
**_identity_of(event, identity),
|
|
344
|
+
}
|
|
345
|
+
except Exception:
|
|
346
|
+
logger.debug("build_session_end_row failed", exc_info=True)
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------------------
|
|
351
|
+
# 전송 클라이언트
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
class IngestClient:
|
|
354
|
+
"""유계 큐 + 데몬 스레드 1개로 수집 API 에 배치 전송한다.
|
|
355
|
+
|
|
356
|
+
스레드가 하나인 것은 의도다 — async 핸들러(`analytics_handler`)와 sync writer
|
|
357
|
+
(`observability`, `log_paths`) 세 경로가 같은 큐를 쓰므로 전송 코드가 한 벌로 남는다.
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
def __init__(
|
|
361
|
+
self,
|
|
362
|
+
url: str = "",
|
|
363
|
+
token: str = "",
|
|
364
|
+
queue_maxsize: int = QUEUE_MAXSIZE,
|
|
365
|
+
transport: Any = None,
|
|
366
|
+
start_worker: bool = True,
|
|
367
|
+
flush_interval: float = FLUSH_INTERVAL_SEC,
|
|
368
|
+
batch: int = FLUSH_BATCH,
|
|
369
|
+
) -> None:
|
|
370
|
+
self._url = (url or "").strip()
|
|
371
|
+
self._token = token or ""
|
|
372
|
+
self._queue: "queue.Queue[tuple]" = queue.Queue(maxsize=queue_maxsize)
|
|
373
|
+
self._transport = transport
|
|
374
|
+
self._batch = batch
|
|
375
|
+
self._flush_interval = flush_interval
|
|
376
|
+
self._stop = threading.Event()
|
|
377
|
+
self._thread: Optional[threading.Thread] = None
|
|
378
|
+
# 이벤트루프 스레드·데몬·to_thread 워커가 동시에 올릴 수 있다.
|
|
379
|
+
self._counter_lock = threading.Lock()
|
|
380
|
+
# 바이트 상한에 걸려 다음 배치로 넘긴 행. 큐 앞에 되돌릴 수 없어서 따로 든다.
|
|
381
|
+
# drain 은 데몬과 세션 종료 경로가 동시에 부를 수 있어 잠근다.
|
|
382
|
+
self._carry: Optional[tuple] = None
|
|
383
|
+
self._drain_lock = threading.Lock()
|
|
384
|
+
self.dropped = 0
|
|
385
|
+
# 설정 화면의 "사용 로그 수신" 줄 (이슈 #183). 실패는 여전히 삼키고 기록만 한다
|
|
386
|
+
self._last_ok_at: Optional[str] = None
|
|
387
|
+
self._last_error: Optional[str] = None
|
|
388
|
+
if start_worker and self.enabled:
|
|
389
|
+
self._thread = threading.Thread(
|
|
390
|
+
target=self._run, name="codebuff-ingest", daemon=True
|
|
391
|
+
)
|
|
392
|
+
self._thread.start()
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def enabled(self) -> bool:
|
|
396
|
+
return bool(self._url)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def queued(self) -> int:
|
|
400
|
+
return self._queue.qsize() + (1 if self._carry is not None else 0)
|
|
401
|
+
|
|
402
|
+
def status(self) -> "dict[str, Any]":
|
|
403
|
+
"""송신 상태 — `disabled`·`pending`(아직 안 보냄)·`ok`·`error`. 마지막 전송이 정한다."""
|
|
404
|
+
with self._counter_lock:
|
|
405
|
+
if not self.enabled:
|
|
406
|
+
state = "disabled"
|
|
407
|
+
elif self._last_error is not None:
|
|
408
|
+
state = "error"
|
|
409
|
+
elif self._last_ok_at is not None:
|
|
410
|
+
state = "ok"
|
|
411
|
+
else:
|
|
412
|
+
state = "pending"
|
|
413
|
+
return {
|
|
414
|
+
"state": state,
|
|
415
|
+
"queued": self.queued,
|
|
416
|
+
"dropped": self.dropped,
|
|
417
|
+
"lastOkAt": self._last_ok_at,
|
|
418
|
+
"lastError": self._last_error,
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
def _record(self, error: Optional[str]) -> None:
|
|
422
|
+
with self._counter_lock:
|
|
423
|
+
if error is None:
|
|
424
|
+
self._last_ok_at = datetime.now(KST).isoformat(timespec="seconds")
|
|
425
|
+
self._last_error = error
|
|
426
|
+
|
|
427
|
+
def enqueue(self, table: str, row: Optional[dict]) -> None:
|
|
428
|
+
"""행 하나를 큐에 넣는다. **어떤 경우에도 예외를 내지 않는다.**"""
|
|
429
|
+
try:
|
|
430
|
+
if not self.enabled or row is None or table not in _TABLES:
|
|
431
|
+
return
|
|
432
|
+
try:
|
|
433
|
+
self._queue.put_nowait((table, row))
|
|
434
|
+
except queue.Full:
|
|
435
|
+
# 버리는 것이 맞다. 여기서 블로킹하면 사용자 요청이 멈춘다.
|
|
436
|
+
with self._counter_lock:
|
|
437
|
+
self.dropped += 1
|
|
438
|
+
except Exception:
|
|
439
|
+
logger.debug("ingest enqueue failed", exc_info=True)
|
|
440
|
+
|
|
441
|
+
def drain_payload(self, limit: Optional[int] = None) -> dict:
|
|
442
|
+
"""큐에서 최대 `limit` 행, `MAX_BATCH_BYTES` 이하를 꺼내 페이로드로 묶는다.
|
|
443
|
+
|
|
444
|
+
상한을 넘기는 행은 버리지 않고 다음 배치의 첫 행으로 넘긴다. 행 1개가 상한보다
|
|
445
|
+
크더라도 그 행 혼자 한 배치로 나간다 (`MAX_CONTEXT_BYTES` 가 행 크기를 막는다).
|
|
446
|
+
"""
|
|
447
|
+
rows: "dict[str, list]" = {}
|
|
448
|
+
cap = limit or self._batch
|
|
449
|
+
with self._drain_lock:
|
|
450
|
+
used = 0
|
|
451
|
+
for _ in range(cap):
|
|
452
|
+
if self._carry is not None:
|
|
453
|
+
item, self._carry = self._carry, None
|
|
454
|
+
else:
|
|
455
|
+
try:
|
|
456
|
+
item = self._queue.get_nowait()
|
|
457
|
+
except queue.Empty:
|
|
458
|
+
break
|
|
459
|
+
size = _blob_len(item[1])
|
|
460
|
+
if rows and used + size > MAX_BATCH_BYTES:
|
|
461
|
+
self._carry = item
|
|
462
|
+
break
|
|
463
|
+
used += size
|
|
464
|
+
rows.setdefault(item[0], []).append(item[1])
|
|
465
|
+
return {"schema_version": SCHEMA_VERSION, "rows": rows}
|
|
466
|
+
|
|
467
|
+
def flush(
|
|
468
|
+
self,
|
|
469
|
+
timeout: float = HTTP_TIMEOUT_SEC,
|
|
470
|
+
budget: Optional[float] = None,
|
|
471
|
+
) -> None:
|
|
472
|
+
"""큐를 비운다. 전송 실패도 조용히 버린다 — 재시도하지 않는다.
|
|
473
|
+
|
|
474
|
+
`timeout` 은 **요청 1건**의 상한이고 `budget` 은 이 호출 **전체**의 상한이다.
|
|
475
|
+
둘을 나눈 이유: 큐에 5,000행이 있으면 배치가 25개까지 나오므로 요청 상한만으로는
|
|
476
|
+
전체가 25배로 늘어난다. 호출자를 막는 경로(세션 종료)는 반드시 `budget` 을 준다.
|
|
477
|
+
"""
|
|
478
|
+
try:
|
|
479
|
+
if not self.enabled:
|
|
480
|
+
return
|
|
481
|
+
deadline = None if budget is None else time.monotonic() + budget
|
|
482
|
+
while True:
|
|
483
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
484
|
+
return
|
|
485
|
+
payload = self.drain_payload()
|
|
486
|
+
if not payload["rows"]:
|
|
487
|
+
return
|
|
488
|
+
self._send(payload, timeout)
|
|
489
|
+
except Exception:
|
|
490
|
+
logger.debug("ingest flush failed", exc_info=True)
|
|
491
|
+
|
|
492
|
+
def close(self, timeout: float = 2.0) -> None:
|
|
493
|
+
self._stop.set()
|
|
494
|
+
thread = self._thread
|
|
495
|
+
if thread is not None:
|
|
496
|
+
thread.join(timeout=timeout)
|
|
497
|
+
self.flush(timeout=timeout)
|
|
498
|
+
|
|
499
|
+
# -- 내부 ---------------------------------------------------------------
|
|
500
|
+
def _run(self) -> None:
|
|
501
|
+
while not self._stop.wait(self._flush_interval):
|
|
502
|
+
self.flush()
|
|
503
|
+
|
|
504
|
+
def _send(self, payload: dict, timeout: float) -> None:
|
|
505
|
+
try:
|
|
506
|
+
import httpx
|
|
507
|
+
|
|
508
|
+
body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
509
|
+
headers = {
|
|
510
|
+
"Content-Type": "application/json",
|
|
511
|
+
"Authorization": f"Bearer {self._token}",
|
|
512
|
+
}
|
|
513
|
+
# 단일 실수(float)는 connect/read/write/pool 각각에 적용된다. 전체 상한은
|
|
514
|
+
# flush(budget=...) 가 본다.
|
|
515
|
+
limit = httpx.Timeout(timeout)
|
|
516
|
+
with httpx.Client(transport=self._transport, timeout=limit) as client:
|
|
517
|
+
response = client.post(self._url, content=body, headers=headers)
|
|
518
|
+
if response.status_code >= 400:
|
|
519
|
+
self._record(f"HTTP {response.status_code}")
|
|
520
|
+
logger.debug(
|
|
521
|
+
"ingest rejected: status=%s rows=%s",
|
|
522
|
+
response.status_code,
|
|
523
|
+
{k: len(v) for k, v in payload["rows"].items()},
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
self._record(None)
|
|
527
|
+
except Exception as e:
|
|
528
|
+
self._record(f"{type(e).__name__}: {e}"[:300])
|
|
529
|
+
logger.debug("ingest send failed", exc_info=True)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# 프로세스 단일 클라이언트 + 훅이 부르는 얇은 헬퍼
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
_CLIENT: Optional[IngestClient] = None
|
|
536
|
+
_CLIENT_LOCK = threading.Lock()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def get_client() -> IngestClient:
|
|
540
|
+
global _CLIENT
|
|
541
|
+
if _CLIENT is None:
|
|
542
|
+
with _CLIENT_LOCK:
|
|
543
|
+
if _CLIENT is None:
|
|
544
|
+
_CLIENT = IngestClient(
|
|
545
|
+
url=os.environ.get("CODEBUFF_INGEST_URL", ""),
|
|
546
|
+
token=os.environ.get(TOKEN_ENV, ""),
|
|
547
|
+
)
|
|
548
|
+
# 정상 종료(SIGTERM 이 아닌) 경로에서 큐 잔량을 흘려보낸다.
|
|
549
|
+
# 이게 없으면 close() 를 아무도 부르지 않아 죽은 코드가 된다.
|
|
550
|
+
atexit.register(_close_client_at_exit)
|
|
551
|
+
return _CLIENT
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _close_client_at_exit() -> None:
|
|
555
|
+
client = _CLIENT
|
|
556
|
+
if client is not None:
|
|
557
|
+
try:
|
|
558
|
+
client.close()
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def enqueue_ux_event(event: Any, identity: Optional[dict] = None) -> None:
|
|
564
|
+
try:
|
|
565
|
+
get_client().enqueue(TABLE_UX_EVENT, build_ux_event_row(event, identity))
|
|
566
|
+
except Exception:
|
|
567
|
+
logger.debug("enqueue_ux_event failed", exc_info=True)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def enqueue_agent_call(event: Any, identity: Optional[dict] = None) -> None:
|
|
571
|
+
try:
|
|
572
|
+
get_client().enqueue(TABLE_AGENT_CALL, build_agent_call_row(event, identity))
|
|
573
|
+
except Exception:
|
|
574
|
+
logger.debug("enqueue_agent_call failed", exc_info=True)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def enqueue_session_end(event: Any, identity: Optional[dict] = None) -> None:
|
|
578
|
+
"""세션 종료는 pod 가 곧 죽으므로 넣고 바로 동기 플러시한다.
|
|
579
|
+
|
|
580
|
+
⚠️ 호출자(`log_paths.write_idle_shutdown_log`)가 `async def` 안에서 불리므로 이
|
|
581
|
+
함수는 **이벤트루프를 잡는다.** 그래서 전체 예산(`SESSION_END_BUDGET_SEC`)을 걸어
|
|
582
|
+
상한을 실제로 만든다 — 예산이 없으면 배치 수만큼 곱해진다.
|
|
583
|
+
"""
|
|
584
|
+
try:
|
|
585
|
+
client = get_client()
|
|
586
|
+
row = build_session_end_row(event, identity)
|
|
587
|
+
before = client.dropped
|
|
588
|
+
client.enqueue(TABLE_SESSION_END, row)
|
|
589
|
+
if client.dropped > before:
|
|
590
|
+
# 큐가 차 있었다. 이 경로가 존재하는 이유인 행이 제일 먼저 버려지면 안 된다.
|
|
591
|
+
client.flush(
|
|
592
|
+
timeout=SESSION_END_REQUEST_TIMEOUT_SEC, budget=SESSION_END_BUDGET_SEC
|
|
593
|
+
)
|
|
594
|
+
client.enqueue(TABLE_SESSION_END, row)
|
|
595
|
+
client.flush(
|
|
596
|
+
timeout=SESSION_END_REQUEST_TIMEOUT_SEC, budget=SESSION_END_BUDGET_SEC
|
|
597
|
+
)
|
|
598
|
+
except Exception:
|
|
599
|
+
logger.debug("enqueue_session_end failed", exc_info=True)
|