quapp-common 0.0.13.dev9__tar.gz → 0.0.14.dev2__tar.gz

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 (107) hide show
  1. {quapp_common-0.0.13.dev9/quapp_common.egg-info → quapp_common-0.0.14.dev2}/PKG-INFO +114 -2
  2. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/README.md +113 -1
  3. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/pyproject.toml +1 -1
  4. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/async_tasks/post_processing_task.py +3 -4
  5. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/invocation.py +24 -20
  6. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/job_fetcher.py +5 -8
  7. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/job_fetching.py +5 -10
  8. quapp_common-0.0.14.dev2/quapp_common/config/log_context.py +186 -0
  9. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/config/logging_config.py +63 -11
  10. quapp_common-0.0.14.dev2/quapp_common/config/thread_config.py +28 -0
  11. quapp_common-0.0.14.dev2/quapp_common/data/response/error_report.py +191 -0
  12. quapp_common-0.0.14.dev2/quapp_common/enum/error_category.py +91 -0
  13. quapp_common-0.0.14.dev2/quapp_common/enum/runtime_phase.py +118 -0
  14. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/device/custom_device.py +4 -5
  15. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/device/device.py +48 -32
  16. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/invocation.py +13 -1
  17. quapp_common-0.0.14.dev2/quapp_common/util/error_classifier.py +456 -0
  18. quapp_common-0.0.14.dev2/quapp_common/util/invocation_failure.py +103 -0
  19. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/util/response_utils.py +21 -6
  20. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2/quapp_common.egg-info}/PKG-INFO +114 -2
  21. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common.egg-info/SOURCES.txt +10 -0
  22. quapp_common-0.0.14.dev2/tests/test_error_classifier.py +382 -0
  23. quapp_common-0.0.14.dev2/tests/test_error_log_dedupe.py +176 -0
  24. quapp_common-0.0.14.dev2/tests/test_error_report.py +405 -0
  25. quapp_common-0.0.14.dev2/tests/test_invocation_failure.py +148 -0
  26. quapp_common-0.0.14.dev2/tests/test_log_secret_leak.py +160 -0
  27. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_logging_config.py +35 -0
  28. quapp_common-0.0.13.dev9/quapp_common/config/log_context.py +0 -102
  29. quapp_common-0.0.13.dev9/quapp_common/config/thread_config.py +0 -10
  30. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/LICENSE +0 -0
  31. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/__init__.py +0 -0
  32. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/async_tasks/__init__.py +0 -0
  33. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/async_tasks/async_invocation_task.py +0 -0
  34. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/async_tasks/async_task.py +0 -0
  35. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/async_tasks/export_circuit_task.py +0 -0
  36. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/__init__.py +0 -0
  37. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/__init__.py +0 -0
  38. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/job_heartbeat.py +0 -0
  39. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/backend/job_manager.py +0 -0
  40. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/bridge.py +0 -0
  41. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/callback/__init__.py +0 -0
  42. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/callback/update_job_metadata.py +0 -0
  43. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/circuit_adapter.py +0 -0
  44. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/device/__init__.py +0 -0
  45. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/device/device_selection.py +0 -0
  46. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/dispatcher.py +0 -0
  47. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/component/result_serializer.py +0 -0
  48. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/config/__init__.py +0 -0
  49. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/__init__.py +0 -0
  50. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/async_task/__init__.py +0 -0
  51. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/async_task/circuit_export/__init__.py +0 -0
  52. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/async_task/circuit_export/backend_holder.py +0 -0
  53. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/async_task/circuit_export/circuit_holder.py +0 -0
  54. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/backend/__init__.py +0 -0
  55. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/backend/backend_information.py +0 -0
  56. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/callback/__init__.py +0 -0
  57. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/callback/callback_url.py +0 -0
  58. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/device/__init__.py +0 -0
  59. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/device/circuit_running_option.py +0 -0
  60. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/promise/__init__.py +0 -0
  61. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/promise/post_processing_promise.py +0 -0
  62. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/promise/promise.py +0 -0
  63. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/request/__init__.py +0 -0
  64. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/request/invocation_request.py +0 -0
  65. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/request/job_fetching_request.py +0 -0
  66. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/request/request.py +0 -0
  67. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/response/__init__.py +0 -0
  68. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/response/authentication.py +0 -0
  69. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/response/custom_header.py +0 -0
  70. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/data/response/job_response.py +0 -0
  71. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/__init__.py +0 -0
  72. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/base_enum.py +0 -0
  73. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/http_header.py +0 -0
  74. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/invocation_step.py +0 -0
  75. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/language.py +0 -0
  76. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/media_type.py +0 -0
  77. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/processing_unit.py +0 -0
  78. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/provider_tag.py +0 -0
  79. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/sdk.py +0 -0
  80. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/status/__init__.py +0 -0
  81. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/status/job_status.py +0 -0
  82. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/status/status_code.py +0 -0
  83. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/enum/token_type.py +0 -0
  84. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/factory/__init__.py +0 -0
  85. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/factory/device_factory.py +0 -0
  86. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/factory/handler_factory.py +0 -0
  87. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/factory/provider_factory.py +0 -0
  88. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/handler/__init__.py +0 -0
  89. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/handler/handler.py +0 -0
  90. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/__init__.py +0 -0
  91. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/device/__init__.py +0 -0
  92. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/provider/__init__.py +0 -0
  93. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/model/provider/provider.py +0 -0
  94. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/util/__init__.py +0 -0
  95. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/util/file_utils.py +0 -0
  96. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/util/http_utils.py +0 -0
  97. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common/util/json_parser_utils.py +0 -0
  98. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common.egg-info/dependency_links.txt +0 -0
  99. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common.egg-info/requires.txt +0 -0
  100. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/quapp_common.egg-info/top_level.txt +0 -0
  101. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/setup.cfg +0 -0
  102. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_circuit_adapter.py +0 -0
  103. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_dispatcher_runner_error.py +0 -0
  104. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_job_heartbeat.py +0 -0
  105. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_json_parser_utils.py +0 -0
  106. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_language_dispatcher.py +0 -0
  107. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev2}/tests/test_result_serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quapp-common
3
- Version: 0.0.13.dev9
3
+ Version: 0.0.14.dev2
4
4
  Summary: Quapp common library supporting Quapp Platform for Quantum Computing
5
5
  Author-email: "CITYNOW Co. Ltd. " <corp@citynow.vn>
6
6
  License: The MIT License (MIT)
@@ -114,7 +114,119 @@ Notes:
114
114
 
115
115
  ## Recently Changes Highlights
116
116
 
117
- ### v0.0.14.dev1Error logging & observability (Phase 0)
117
+ ### v0.0.14.dev2Fix: log lỗi bị nhân đôi
118
+
119
+ - **Fix**: mỗi lần lỗi phát **hai** dòng ERROR cùng `event=job_failed`, mỗi dòng
120
+ kèm một traceback riêng. Nguyên nhân: `build_error_job_response()` (thêm ở
121
+ `0.0.13.dev9`) tự phát một dòng log tập trung, nhưng 10 call-site vẫn giữ
122
+ `self.logger.exception(...)` của chúng.
123
+
124
+ Ngoài việc làm ồn Function Log và nhân đôi lượng traceback, nó **làm sai metric
125
+ nếu backend đếm `event=job_failed`** để tính tỉ lệ job thất bại.
126
+
127
+ Đã gỡ log ở 10 call-site trong `device.py`, `custom_device.py`,
128
+ `invocation.py` (×3), `job_fetcher.py` (×2), `job_fetching.py` (×2),
129
+ `post_processing_task.py`. Dòng log tập trung mang nhiều thông tin hơn bản bị
130
+ gỡ: `event` / `category` / `error_code` / `phase` + full traceback.
131
+
132
+ `phase_failed` (WARNING, mốc timeline) là event khác, không bị tính là trùng.
133
+
134
+ - **Fix (security)**: credential lọt vào log text qua **hai đường** mà
135
+ `redact()` không chặn được:
136
+
137
+ 1. `loguru diagnose=True` (default) in giá trị **biến cục bộ** trong traceback
138
+ dưới dạng `└ 'value'`. Không có tên key nên `redact()` — vốn match
139
+ `key: value` — không mask. Token provider nằm trong biến cục bộ lọt nguyên.
140
+ Đã set `diagnose=False` cho sink text (`backtrace=True` vẫn giữ: nó mở rộng
141
+ call chain nhưng không in giá trị).
142
+ 2. Placeholder `{exception}` được loguru render **trực tiếp từ exception
143
+ object**, không đi qua filter. Secret nằm trong `str(exception)` — ví dụ
144
+ response body của provider bị nhét vào `ValueError` — lọt nguyên. Sink text
145
+ nay append traceback đã redact ở `extra[_exc_text]` thay cho placeholder đó.
146
+
147
+ Sink JSON không bị ảnh hưởng: nó đã dùng `_exc_text` và `diagnose=False`.
148
+
149
+ - **Docs**: ghi rõ trong docstring nên chọn `QUAPP_LOG_FORMAT` nào. Với
150
+ Grafana/Loki phải dùng `json`: ở chế độ `text`, traceback là các dòng **riêng
151
+ không có prefix** `[ConsoleJobLog][<job_id>]`, nên filter theo job_id sẽ mất
152
+ toàn bộ stack. `both` ghi mỗi record hai lần — chỉ dùng khi debug.
153
+
154
+ - **Test**: thêm `tests/test_log_secret_leak.py` — 5 case, test qua sink THẬT do
155
+ `configure_logging()` tạo (không introspect internals). Có một case khoá lại
156
+ hành vi `diagnose=True` của loguru, để nếu upstream đổi thì ta biết.
157
+
158
+ - **Test**: thêm `tests/test_error_log_dedupe.py` — khoá hồi quy ở hai mức: đếm
159
+ số dòng `job_failed` khi chạy thật, **và** quét source để bắt trường hợp ai đó
160
+ thêm lại `logger.exception()` ngay trước `build_error_job_response()`.
161
+
162
+ Kèm test cho trường hợp **nhiều job đồng thời**: 4 job qua
163
+ `run_in_threadpool` + `circuit_running_pool` phải có số dòng log đều nhau,
164
+ không job nào nhận log của job khác, và `phase` giữ được khi đi vào thread
165
+ pool.
166
+
167
+ ### v0.0.14.dev1 — Error taxonomy & error envelope (Phase 1)
168
+
169
+ Tiếp nối Phase 0. Bổ sung phân loại lỗi có ngữ nghĩa và envelope dùng chung cho
170
+ Function Log lẫn Job Log. **Additive**: `job_result` chỉ thêm field `error`,
171
+ `message` và `exception` giữ nguyên; contract callback URL không đổi.
172
+
173
+ **Taxonomy — hai chiều phân loại độc lập**
174
+ - `enum/error_category.py`: `ErrorCategory` 13 giá trị, nhóm theo *ai xử lý
175
+ được* (`owner_of()` → USER / WORKSPACE_ADMIN / PROVIDER / PLATFORM).
176
+ - `enum/runtime_phase.py`: `RuntimePhase` 9 giá trị. Tách được
177
+ `PROVIDER_INVOCATION` / `COMPILATION` / `EXECUTION` — trước đây auth sai,
178
+ transpile fail và provider từ chối job đều ra chung nhãn `EXECUTION`.
179
+ `PHASE_TO_STEP` giữ nguyên contract `InvocationStep` của callback URL.
180
+
181
+ **Classifier (`util/error_classifier.py`)** — tổ hợp 5 tín hiệu theo độ tin cậy:
182
+ 1. `phase` — biết chắc từ context manager, không suy đoán;
183
+ 2. `frame_owner()` — code của ai, dựa vào đường dẫn frame sâu nhất
184
+ (`function/` = user, `quapp_*` = platform, `site-packages/` = SDK);
185
+ 3. tên class exception, đi hết **cause chain** qua cả `__cause__` *và*
186
+ `__context__` — cần thiết vì nhiều provider lib bọc lỗi bằng
187
+ `raise ValueError(...)` trong khối `except` mà không có `from`;
188
+ 4. attribute đọc được: HTTP status, AWS error code, exit code, signal;
189
+ 5. message pattern (dùng cuối cùng).
190
+
191
+ `register_rules()` cho phép lib provider đăng ký rule đặc thù SDK khi import,
192
+ nên `qapp-common` không phải import qiskit/braket. Batch đăng ký sau được xét
193
+ trước; trong cùng batch giữ nguyên thứ tự. Rule ném lỗi bị bỏ qua chứ không làm
194
+ hỏng đường báo lỗi. 20 rule generic sẵn có.
195
+
196
+ Fallback nhiều tầng nên **không bao giờ trả `UNKNOWN` một cách mù**: không rule
197
+ nào khớp thì suy từ frame ownership, rồi tới phase.
198
+
199
+ **Envelope (`data/response/error_report.py`)** — `job_result['error']` gồm
200
+ `code`, `category`, `phase`, `owner`, `retryable`, `rootCause` (type + detail +
201
+ file:line), `causeChain`, `stackTrace` (full, đã redact),
202
+ `handlerStackTrace` (stack JS/.NET cho handler không phải Python),
203
+ `context`, `resolution` (summary + steps + docUrl), `retry`, `traceId`.
204
+ `log_fields()` bind cùng vocabulary vào Function Log.
205
+
206
+ **Timeline (`config/log_context.py`)** — context manager `phase()` phát
207
+ `phase_start` / `phase_end` / `phase_failed` kèm `duration_ms`. Đây là dữ liệu
208
+ để UI vẽ timeline và chỉ ra job chết ở bước nào. `phase_failed` ở mức WARNING để
209
+ thấy được khi chạy `LOG_LEVEL=INFO`.
210
+
211
+ `_FAILED_PHASE` ghi lại phase mà exception thoát ra: khối `except` xử lý lỗi gần
212
+ như luôn nằm **ngoài** `with phase(...)`, lúc đó phase đang mở đã bị reset trong
213
+ `finally`. Không có nó thì mọi `ErrorReport` đều `phase=None` và fallback theo
214
+ phase không bao giờ chạy. Dùng `effective_phase()` ở đường báo lỗi.
215
+
216
+ **`config/thread_config.py`** — `ContextAwareThreadPoolExecutor` copy contextvars
217
+ khi submit. `ThreadPoolExecutor` thuần không làm việc này (khác
218
+ `asyncio.to_thread`), nên analysis + finalization chạy trong
219
+ `circuit_running_pool` sẽ mất `job_id` / `trace_id` / `phase`.
220
+
221
+ **Call-site** — `phase()` bọc PREPROCESSING / DEVICE_SELECTION /
222
+ PROVIDER_INVOCATION / COMPILATION / EXECUTION / ANALYSIS; toàn bộ 13 call-site
223
+ của `build_error_job_response()` truyền `phase=`.
224
+
225
+ **Test** — `tests/test_error_classifier.py` (52 case: cause chain, HTTP/AWS
226
+ code, frame ownership, phase fallback, rule ordering) và
227
+ `tests/test_error_report.py` (44 case: envelope, timeline, thread pool).
228
+
229
+ ### v0.0.13.dev9 — Error logging & observability (Phase 0)
118
230
 
119
231
  Mục tiêu: Function Log và Job Log phải chỉ ra được nguyên nhân lỗi. Toàn bộ thay
120
232
  đổi trong bản này là **additive hoặc bug fix** — không đổi contract callback,
@@ -73,7 +73,119 @@ Notes:
73
73
 
74
74
  ## Recently Changes Highlights
75
75
 
76
- ### v0.0.14.dev1Error logging & observability (Phase 0)
76
+ ### v0.0.14.dev2Fix: log lỗi bị nhân đôi
77
+
78
+ - **Fix**: mỗi lần lỗi phát **hai** dòng ERROR cùng `event=job_failed`, mỗi dòng
79
+ kèm một traceback riêng. Nguyên nhân: `build_error_job_response()` (thêm ở
80
+ `0.0.13.dev9`) tự phát một dòng log tập trung, nhưng 10 call-site vẫn giữ
81
+ `self.logger.exception(...)` của chúng.
82
+
83
+ Ngoài việc làm ồn Function Log và nhân đôi lượng traceback, nó **làm sai metric
84
+ nếu backend đếm `event=job_failed`** để tính tỉ lệ job thất bại.
85
+
86
+ Đã gỡ log ở 10 call-site trong `device.py`, `custom_device.py`,
87
+ `invocation.py` (×3), `job_fetcher.py` (×2), `job_fetching.py` (×2),
88
+ `post_processing_task.py`. Dòng log tập trung mang nhiều thông tin hơn bản bị
89
+ gỡ: `event` / `category` / `error_code` / `phase` + full traceback.
90
+
91
+ `phase_failed` (WARNING, mốc timeline) là event khác, không bị tính là trùng.
92
+
93
+ - **Fix (security)**: credential lọt vào log text qua **hai đường** mà
94
+ `redact()` không chặn được:
95
+
96
+ 1. `loguru diagnose=True` (default) in giá trị **biến cục bộ** trong traceback
97
+ dưới dạng `└ 'value'`. Không có tên key nên `redact()` — vốn match
98
+ `key: value` — không mask. Token provider nằm trong biến cục bộ lọt nguyên.
99
+ Đã set `diagnose=False` cho sink text (`backtrace=True` vẫn giữ: nó mở rộng
100
+ call chain nhưng không in giá trị).
101
+ 2. Placeholder `{exception}` được loguru render **trực tiếp từ exception
102
+ object**, không đi qua filter. Secret nằm trong `str(exception)` — ví dụ
103
+ response body của provider bị nhét vào `ValueError` — lọt nguyên. Sink text
104
+ nay append traceback đã redact ở `extra[_exc_text]` thay cho placeholder đó.
105
+
106
+ Sink JSON không bị ảnh hưởng: nó đã dùng `_exc_text` và `diagnose=False`.
107
+
108
+ - **Docs**: ghi rõ trong docstring nên chọn `QUAPP_LOG_FORMAT` nào. Với
109
+ Grafana/Loki phải dùng `json`: ở chế độ `text`, traceback là các dòng **riêng
110
+ không có prefix** `[ConsoleJobLog][<job_id>]`, nên filter theo job_id sẽ mất
111
+ toàn bộ stack. `both` ghi mỗi record hai lần — chỉ dùng khi debug.
112
+
113
+ - **Test**: thêm `tests/test_log_secret_leak.py` — 5 case, test qua sink THẬT do
114
+ `configure_logging()` tạo (không introspect internals). Có một case khoá lại
115
+ hành vi `diagnose=True` của loguru, để nếu upstream đổi thì ta biết.
116
+
117
+ - **Test**: thêm `tests/test_error_log_dedupe.py` — khoá hồi quy ở hai mức: đếm
118
+ số dòng `job_failed` khi chạy thật, **và** quét source để bắt trường hợp ai đó
119
+ thêm lại `logger.exception()` ngay trước `build_error_job_response()`.
120
+
121
+ Kèm test cho trường hợp **nhiều job đồng thời**: 4 job qua
122
+ `run_in_threadpool` + `circuit_running_pool` phải có số dòng log đều nhau,
123
+ không job nào nhận log của job khác, và `phase` giữ được khi đi vào thread
124
+ pool.
125
+
126
+ ### v0.0.14.dev1 — Error taxonomy & error envelope (Phase 1)
127
+
128
+ Tiếp nối Phase 0. Bổ sung phân loại lỗi có ngữ nghĩa và envelope dùng chung cho
129
+ Function Log lẫn Job Log. **Additive**: `job_result` chỉ thêm field `error`,
130
+ `message` và `exception` giữ nguyên; contract callback URL không đổi.
131
+
132
+ **Taxonomy — hai chiều phân loại độc lập**
133
+ - `enum/error_category.py`: `ErrorCategory` 13 giá trị, nhóm theo *ai xử lý
134
+ được* (`owner_of()` → USER / WORKSPACE_ADMIN / PROVIDER / PLATFORM).
135
+ - `enum/runtime_phase.py`: `RuntimePhase` 9 giá trị. Tách được
136
+ `PROVIDER_INVOCATION` / `COMPILATION` / `EXECUTION` — trước đây auth sai,
137
+ transpile fail và provider từ chối job đều ra chung nhãn `EXECUTION`.
138
+ `PHASE_TO_STEP` giữ nguyên contract `InvocationStep` của callback URL.
139
+
140
+ **Classifier (`util/error_classifier.py`)** — tổ hợp 5 tín hiệu theo độ tin cậy:
141
+ 1. `phase` — biết chắc từ context manager, không suy đoán;
142
+ 2. `frame_owner()` — code của ai, dựa vào đường dẫn frame sâu nhất
143
+ (`function/` = user, `quapp_*` = platform, `site-packages/` = SDK);
144
+ 3. tên class exception, đi hết **cause chain** qua cả `__cause__` *và*
145
+ `__context__` — cần thiết vì nhiều provider lib bọc lỗi bằng
146
+ `raise ValueError(...)` trong khối `except` mà không có `from`;
147
+ 4. attribute đọc được: HTTP status, AWS error code, exit code, signal;
148
+ 5. message pattern (dùng cuối cùng).
149
+
150
+ `register_rules()` cho phép lib provider đăng ký rule đặc thù SDK khi import,
151
+ nên `qapp-common` không phải import qiskit/braket. Batch đăng ký sau được xét
152
+ trước; trong cùng batch giữ nguyên thứ tự. Rule ném lỗi bị bỏ qua chứ không làm
153
+ hỏng đường báo lỗi. 20 rule generic sẵn có.
154
+
155
+ Fallback nhiều tầng nên **không bao giờ trả `UNKNOWN` một cách mù**: không rule
156
+ nào khớp thì suy từ frame ownership, rồi tới phase.
157
+
158
+ **Envelope (`data/response/error_report.py`)** — `job_result['error']` gồm
159
+ `code`, `category`, `phase`, `owner`, `retryable`, `rootCause` (type + detail +
160
+ file:line), `causeChain`, `stackTrace` (full, đã redact),
161
+ `handlerStackTrace` (stack JS/.NET cho handler không phải Python),
162
+ `context`, `resolution` (summary + steps + docUrl), `retry`, `traceId`.
163
+ `log_fields()` bind cùng vocabulary vào Function Log.
164
+
165
+ **Timeline (`config/log_context.py`)** — context manager `phase()` phát
166
+ `phase_start` / `phase_end` / `phase_failed` kèm `duration_ms`. Đây là dữ liệu
167
+ để UI vẽ timeline và chỉ ra job chết ở bước nào. `phase_failed` ở mức WARNING để
168
+ thấy được khi chạy `LOG_LEVEL=INFO`.
169
+
170
+ `_FAILED_PHASE` ghi lại phase mà exception thoát ra: khối `except` xử lý lỗi gần
171
+ như luôn nằm **ngoài** `with phase(...)`, lúc đó phase đang mở đã bị reset trong
172
+ `finally`. Không có nó thì mọi `ErrorReport` đều `phase=None` và fallback theo
173
+ phase không bao giờ chạy. Dùng `effective_phase()` ở đường báo lỗi.
174
+
175
+ **`config/thread_config.py`** — `ContextAwareThreadPoolExecutor` copy contextvars
176
+ khi submit. `ThreadPoolExecutor` thuần không làm việc này (khác
177
+ `asyncio.to_thread`), nên analysis + finalization chạy trong
178
+ `circuit_running_pool` sẽ mất `job_id` / `trace_id` / `phase`.
179
+
180
+ **Call-site** — `phase()` bọc PREPROCESSING / DEVICE_SELECTION /
181
+ PROVIDER_INVOCATION / COMPILATION / EXECUTION / ANALYSIS; toàn bộ 13 call-site
182
+ của `build_error_job_response()` truyền `phase=`.
183
+
184
+ **Test** — `tests/test_error_classifier.py` (52 case: cause chain, HTTP/AWS
185
+ code, frame ownership, phase fallback, rule ordering) và
186
+ `tests/test_error_report.py` (44 case: envelope, timeline, thread pool).
187
+
188
+ ### v0.0.13.dev9 — Error logging & observability (Phase 0)
77
189
 
78
190
  Mục tiêu: Function Log và Job Log phải chỉ ra được nguyên nhân lỗi. Toàn bộ thay
79
191
  đổi trong bản này là **additive hoặc bug fix** — không đổi contract callback,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quapp-common"
7
- version = "0.0.13.dev9"
7
+ version = "0.0.14.dev2"
8
8
  description = "Quapp common library supporting Quapp Platform for Quantum Computing"
9
9
  readme = "README.md"
10
10
  authors = [{ name = "CITYNOW Co. Ltd. ", email = "corp@citynow.vn" }]
@@ -5,6 +5,7 @@ from .async_task import AsyncTask
5
5
  from ..config.logging_config import job_logger
6
6
  from ..data.promise.post_processing_promise import PostProcessingPromise
7
7
  from ..enum.media_type import MediaType
8
+ from ..enum.runtime_phase import RuntimePhase
8
9
  from ..util.http_utils import get_job_id_from_url
9
10
  from ..util.json_parser_utils import parse
10
11
 
@@ -44,13 +45,11 @@ class PostProcessingTask(AsyncTask):
44
45
  update_job_metadata(job_response, self.promise.callback_url.on_done)
45
46
 
46
47
  except Exception as exception:
47
- self.logger.bind(event='job_failed').exception(
48
- f"Post-processing failed: {exception}")
49
-
50
48
  job_response = build_error_job_response(
51
49
  exception, job_response,
52
50
  message='Error when post processing job result',
53
- stage='while post-processing the job result')
51
+ stage='while post-processing the job result',
52
+ phase=RuntimePhase.POST_PROCESSING)
54
53
 
55
54
  update_job_metadata(job_response,
56
55
  self.promise.callback_url.on_error)
@@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
6
6
 
7
7
  from ..callback.update_job_metadata import update_job_metadata
8
8
  from ...component.device.device_selection import DeviceSelection
9
+ from ...config.log_context import bind_job, enrich_job_context, phase
9
10
  from ...config.logging_config import job_logger
10
11
  from ...config.thread_config import circuit_running_pool
11
12
  from ...data.backend.backend_information import BackendInformation
@@ -15,6 +16,7 @@ from ...data.response.authentication import Authentication
15
16
  from ...data.response.custom_header import CustomHeader
16
17
  from ...data.response.job_response import JobResponse
17
18
  from ...enum.invocation_step import InvocationStep
19
+ from ...enum.runtime_phase import RuntimePhase
18
20
  from ...enum.sdk import Sdk
19
21
  from ...enum.status.status_code import StatusCode
20
22
  from ...model.provider.provider import Provider
@@ -82,19 +84,20 @@ class Invocation(ABC):
82
84
  return None
83
85
 
84
86
  try:
85
- self.logger.debug("Preparing backend data")
86
- self.__prepare_backend_data(circuit)
87
+ with phase(RuntimePhase.DEVICE_SELECTION):
88
+ self.__prepare_backend_data(circuit)
89
+
90
+ enrich_job_context(
91
+ device=self.backend_information.device_name,
92
+ provider=self.backend_information.provider_tag.value)
87
93
  self.logger.info(
88
94
  f"Backend data prepared: device {self.backend_information.device_name}, provider {self.backend_information.provider_tag.value}")
89
95
 
90
96
  except Exception as exception:
91
- self.logger.exception(
92
- f"Error when prepare backend data: {exception}",
93
- exc_info=True)
94
-
95
97
  job_response = build_error_job_response(exception,
96
98
  message='Error when prepare backend data',
97
- stage='while preparing the backend')
99
+ stage='while preparing the backend',
100
+ phase=RuntimePhase.DEVICE_SELECTION)
98
101
  job_response.authentication = self.authentication
99
102
  update_job_metadata(job_response, self.callback_dict.get(
100
103
  InvocationStep.PREPARATION).on_error)
@@ -125,19 +128,21 @@ class Invocation(ABC):
125
128
  self.logger.debug(
126
129
  f"Executing job with provider tag: {provider_tag.value}")
127
130
 
128
- provider = self._create_provider()
131
+ # PROVIDER_INVOCATION tách riêng khỏi EXECUTION: lỗi authenticate
132
+ # với provider và lỗi provider từ chối circuit là hai chuyện khác
133
+ # nhau, trước đây cùng ra nhãn EXECUTION.
134
+ with phase(RuntimePhase.PROVIDER_INVOCATION):
135
+ provider = self._create_provider()
129
136
 
130
- self.logger.debug(f"Executing job with device name: {device_name}")
131
- device = self._create_device(provider)
137
+ self.logger.debug(
138
+ f"Executing job with device name: {device_name}")
139
+ device = self._create_device(provider)
132
140
 
133
141
  except Exception as exception:
134
- self.logger.exception(
135
- f"Exception when create provider or device: {exception}",
136
- exc_info=True)
137
-
138
142
  job_response = build_error_job_response(exception,
139
143
  message='Error when create provider or device',
140
- stage='while running the circuit on the device')
144
+ stage='while running the circuit on the device',
145
+ phase=RuntimePhase.PROVIDER_INVOCATION)
141
146
  update_job_metadata(job_response, self.callback_dict.get(
142
147
  InvocationStep.EXECUTION).on_error, )
143
148
 
@@ -170,7 +175,8 @@ class Invocation(ABC):
170
175
  InvocationStep.PREPARATION).on_start)
171
176
 
172
177
  try:
173
- circuit = circuit_preparation_fn(self.input)
178
+ with phase(RuntimePhase.PREPROCESSING):
179
+ circuit = circuit_preparation_fn(self.input)
174
180
 
175
181
  if circuit is None:
176
182
  self.logger.warning(
@@ -185,12 +191,10 @@ class Invocation(ABC):
185
191
  return circuit
186
192
 
187
193
  except Exception as exception:
188
- self.logger.exception(f'Error when prepare circuit: {exception}',
189
- exc_info=True)
190
-
191
194
  job_response = build_error_job_response(exception, job_response,
192
195
  message='Error when prepare circuit',
193
- stage='while preparing the circuit')
196
+ stage='while preparing the circuit',
197
+ phase=RuntimePhase.PREPROCESSING)
194
198
 
195
199
  update_job_metadata(job_response, self.callback_dict.get(
196
200
  InvocationStep.PREPARATION).on_error)
@@ -16,6 +16,7 @@ from ...data.response.custom_header import CustomHeader
16
16
  from ...data.response.job_response import JobResponse
17
17
  from ...enum.invocation_step import InvocationStep
18
18
  from ...enum.media_type import MediaType
19
+ from ...enum.runtime_phase import RuntimePhase
19
20
  from ...enum.status.job_status import JobStatus
20
21
  from ...enum.status.status_code import StatusCode
21
22
  from ...util.json_parser_utils import parse
@@ -127,15 +128,12 @@ class JobFetcher(ABC):
127
128
  job_response.status_code = StatusCode.POLLING
128
129
 
129
130
  except Exception as exception:
130
- self.logger.exception(
131
- f"Exception during job fetch for provider_job_id {self.provider_job_id}: {exception}",
132
- exc_info=True)
133
-
134
131
  job_response = build_error_job_response(
135
132
  exception, job_response,
136
133
  message='Error when fetching job with provider_job_id {0}'.format(
137
134
  self.provider_job_id),
138
- stage='while fetching the job result from the provider')
135
+ stage='while fetching the job result from the provider',
136
+ phase=RuntimePhase.POLLING)
139
137
  update_job_metadata(job_response, self.callback_urls[
140
138
  InvocationStep.EXECUTION].on_error)
141
139
 
@@ -207,13 +205,12 @@ class JobFetcher(ABC):
207
205
  return job_response
208
206
 
209
207
  except Exception as exception:
210
- self.logger.exception(f"Exception during analysis: {exception}",
211
- exc_info=True)
212
208
  from ...util.response_utils import build_error_job_response
213
209
  job_response = build_error_job_response(
214
210
  exception, job_response,
215
211
  message='Error when analyzing job result',
216
- stage='while analysing the job result')
212
+ stage='while analysing the job result',
213
+ phase=RuntimePhase.ANALYSIS)
217
214
 
218
215
  update_job_metadata(job_response, callback_url.on_error)
219
216
  return None
@@ -17,6 +17,7 @@ from ...data.response.custom_header import CustomHeader
17
17
  from ...data.response.job_response import JobResponse
18
18
  from ...enum.invocation_step import InvocationStep
19
19
  from ...enum.media_type import MediaType
20
+ from ...enum.runtime_phase import RuntimePhase
20
21
  from ...enum.status.job_status import JobStatus
21
22
  from ...enum.status.status_code import StatusCode
22
23
  from ...util.json_parser_utils import parse
@@ -89,15 +90,12 @@ class JobFetching(ABC):
89
90
  job_response.status_code = StatusCode.POLLING
90
91
 
91
92
  except Exception as exception:
92
- self.logger.bind(event='job_failed').exception(
93
- "Exception when fetch job with provider_job_id {0}: {1}".format(
94
- self.provider_job_id, str(exception)))
95
-
96
93
  job_response = build_error_job_response(
97
94
  exception, job_response,
98
95
  message='Exception when fetch job with provider_job_id {0}'.format(
99
96
  self.provider_job_id),
100
- stage='while fetching the job result from the provider')
97
+ stage='while fetching the job result from the provider',
98
+ phase=RuntimePhase.POLLING)
101
99
 
102
100
  update_job_metadata(job_response, self.callback_dict.get(
103
101
  InvocationStep.EXECUTION).on_error)
@@ -154,15 +152,12 @@ class JobFetching(ABC):
154
152
  return job_response
155
153
 
156
154
  except Exception as exception:
157
- self.logger.exception(
158
- "Exception when analyst job result with provider_job_id {0}: {1}".format(
159
- self.provider_job_id, str(exception)))
160
-
161
155
  job_response = build_error_job_response(
162
156
  exception, job_response,
163
157
  message='Exception when analyst job result with provider_job_id {0}'.format(
164
158
  self.provider_job_id),
165
- stage='while analysing the job result')
159
+ stage='while analysing the job result',
160
+ phase=RuntimePhase.ANALYSIS)
166
161
 
167
162
  update_job_metadata(job_response, callback_url.on_error)
168
163
 
@@ -0,0 +1,186 @@
1
+ # Quapp Platform Project
2
+ # log_context.py
3
+ # Copyright © CITYNOW Co. Ltd. All rights reserved.
4
+
5
+ """Per-job logging context.
6
+
7
+ Vì sao cần: hiện tại mỗi nơi phải tự truyền ``job_id`` vào ``job_logger()``, và
8
+ rất nhiều nơi quên -- ``Device.__init__``, ``Provider``, ``DeviceSelection``,
9
+ các ``*Factory`` đều dùng ``logger.bind(context='...')`` hoặc gọi
10
+ ``job_logger()`` không tham số, nên log của chúng rơi vào context
11
+ ``"QuappLibs"`` và không gắn được vào job nào trong Job Log UI.
12
+
13
+ Cách xử lý: bind context MỘT lần ở đầu request vào ``contextvars``. Mọi log sau
14
+ đó -- kể cả log của thư viện bên thứ ba đi qua ``InterceptHandler`` -- tự có đủ
15
+ field ``job_id`` / ``trace_id`` / ``sdk`` / ``provider`` / ``device``.
16
+
17
+ Lưu ý về thread: ``contextvars`` KHÔNG tự propagate qua
18
+ ``ThreadPoolExecutor.submit()``. ``circuit_running_pool`` được dùng cho analysis
19
+ và finalization, nên hai phase đó sẽ mất context nếu pool không copy context --
20
+ xem ``thread_config.ContextAwareThreadPoolExecutor`` (Phase 1).
21
+ """
22
+
23
+ import contextvars
24
+ import time
25
+ import uuid
26
+ from contextlib import contextmanager
27
+
28
+ from loguru import logger
29
+
30
+ from ..enum.runtime_phase import RuntimePhase
31
+
32
+ JOB_ID = 'job_id'
33
+ TRACE_ID = 'trace_id'
34
+
35
+ _CONTEXT_KEYS = (JOB_ID, TRACE_ID, 'sdk', 'provider', 'device',
36
+ 'provider_job_id')
37
+
38
+ _JOB_CONTEXT: contextvars.ContextVar[dict] = contextvars.ContextVar(
39
+ 'quapp_job_context', default={})
40
+
41
+ _PHASE: contextvars.ContextVar['RuntimePhase | None'] = contextvars.ContextVar(
42
+ 'quapp_phase', default=None)
43
+
44
+ # Phase mà exception thoát ra gần nhất.
45
+ #
46
+ # Cần biến riêng vì `phase()` reset _PHASE trong `finally`, mà khối `except` xử
47
+ # lý lỗi hầu như luôn nằm NGOÀI khối `with phase(...)`:
48
+ #
49
+ # try:
50
+ # with phase(RuntimePhase.COMPILATION):
51
+ # transpile(...)
52
+ # except Exception: # <- _PHASE đã là None ở đây
53
+ # build_error_job_response(...)
54
+ #
55
+ # Không có biến này thì mọi ErrorReport đều có phase=None và fallback theo phase
56
+ # không bao giờ chạy.
57
+ _FAILED_PHASE: contextvars.ContextVar[
58
+ 'RuntimePhase | None'] = contextvars.ContextVar(
59
+ 'quapp_failed_phase', default=None)
60
+
61
+
62
+ def bind_job(job_id: str = None, *, sdk=None, provider=None, device=None,
63
+ provider_job_id=None, trace_id=None):
64
+ """Khởi tạo context cho một job và trả về logger đã bind.
65
+
66
+ Gọi một lần càng sớm càng tốt -- ``Handler.__init__`` hoặc ``index.py``.
67
+
68
+ @param job_id: id job phía Quapp backend
69
+ @param trace_id: tự sinh nếu không truyền; dùng để join Function Log <-> Job Log
70
+ @return: logger đã bind toàn bộ context
71
+ """
72
+ context = {JOB_ID : job_id,
73
+ TRACE_ID : trace_id or uuid.uuid4().hex[:16],
74
+ 'sdk' : sdk,
75
+ 'provider' : provider,
76
+ 'device' : device,
77
+ 'provider_job_id': provider_job_id}
78
+ _JOB_CONTEXT.set(context)
79
+ _FAILED_PHASE.set(None)
80
+ return logger.bind(**context)
81
+
82
+
83
+ def enrich_job_context(**fields):
84
+ """Bổ sung field khi biết thêm thông tin (device_name sau device selection,
85
+ provider_job_id sau khi submit...). Chỉ nhận các key trong _CONTEXT_KEYS.
86
+
87
+ @return: logger đã bind context mới
88
+ """
89
+ context = dict(_JOB_CONTEXT.get())
90
+ context.update(
91
+ {key: value for key, value in fields.items() if key in _CONTEXT_KEYS})
92
+ _JOB_CONTEXT.set(context)
93
+ return logger.bind(**context)
94
+
95
+
96
+ def job_context() -> dict:
97
+ """Snapshot context hiện tại. Trả về dict rỗng nếu chưa bind."""
98
+ return dict(_JOB_CONTEXT.get())
99
+
100
+
101
+ def current_job_id() -> str | None:
102
+ return _JOB_CONTEXT.get().get(JOB_ID)
103
+
104
+
105
+ def trace_id() -> str | None:
106
+ return _JOB_CONTEXT.get().get(TRACE_ID)
107
+
108
+
109
+ def current_phase():
110
+ """RuntimePhase đang mở, hoặc None."""
111
+ return _PHASE.get()
112
+
113
+
114
+ def failed_phase():
115
+ """Phase mà exception thoát ra gần nhất trong job này, hoặc None."""
116
+ return _FAILED_PHASE.get()
117
+
118
+
119
+ def effective_phase():
120
+ """Phase dùng để phân loại lỗi: phase đang mở, nếu không thì phase vừa fail.
121
+
122
+ Đây là hàm mà đường báo lỗi nên gọi -- khối `except` gần như luôn nằm ngoài
123
+ `with phase(...)`, nên `current_phase()` một mình sẽ trả None.
124
+ """
125
+ return _PHASE.get() or _FAILED_PHASE.get()
126
+
127
+
128
+ def current_phase_value():
129
+ """Giá trị str của phase đang mở, để bind vào log."""
130
+ phase = _PHASE.get()
131
+ return phase.value if phase is not None else None
132
+
133
+
134
+ @contextmanager
135
+ def phase(runtime_phase: RuntimePhase):
136
+ """Đánh dấu một runtime phase.
137
+
138
+ Làm ba việc cùng lúc:
139
+
140
+ 1. gán ``phase`` cho mọi log bên trong, kể cả log của thư viện bên thứ ba đi
141
+ qua ``InterceptHandler``;
142
+ 2. phát event ``phase_start`` / ``phase_end`` / ``phase_failed`` kèm
143
+ ``duration_ms`` -- đây chính là dữ liệu để UI vẽ timeline và chỉ ra job
144
+ chết ở bước nào;
145
+ 3. cung cấp phase cho classifier khi exception bay ra, mà không nơi nào phải
146
+ truyền tay.
147
+
148
+ Phase lồng nhau được: phase con khôi phục lại phase cha khi thoát.
149
+
150
+ @return: logger đã bind context + phase
151
+ """
152
+ token = _PHASE.set(runtime_phase)
153
+ bound = logger.bind(**_JOB_CONTEXT.get(), phase=runtime_phase.value)
154
+ started = time.perf_counter()
155
+ bound.bind(event='phase_start').info(f'{runtime_phase.value} started')
156
+
157
+ try:
158
+ yield bound
159
+ except BaseException:
160
+ # Ghi lại để khối except bên ngoài -- nơi _PHASE đã bị reset -- vẫn biết
161
+ # lỗi thoát ra từ phase nào.
162
+ _FAILED_PHASE.set(runtime_phase)
163
+ elapsed = _elapsed_ms(started)
164
+ # WARNING chứ không phải DEBUG: đây là mốc timeline, phải thấy được khi
165
+ # chạy ở LOG_LEVEL=INFO. Không kèm traceback vì build_error_job_response
166
+ # sẽ log exception đầy đủ ngay sau đó -- tránh in stack hai lần.
167
+ bound.bind(event='phase_failed', duration_ms=elapsed).warning(
168
+ f'{runtime_phase.value} failed after {elapsed} ms')
169
+ raise
170
+ else:
171
+ elapsed = _elapsed_ms(started)
172
+ bound.bind(event='phase_end', duration_ms=elapsed).info(
173
+ f'{runtime_phase.value} completed in {elapsed} ms')
174
+ finally:
175
+ _PHASE.reset(token)
176
+
177
+
178
+ def _elapsed_ms(started: float) -> float:
179
+ return round((time.perf_counter() - started) * 1000, 2)
180
+
181
+
182
+ def reset() -> None:
183
+ """Xoá context. Chủ yếu dùng trong test."""
184
+ _JOB_CONTEXT.set({})
185
+ _PHASE.set(None)
186
+ _FAILED_PHASE.set(None)