quapp-common 0.0.13.dev9__tar.gz → 0.0.14.dev1__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 (105) hide show
  1. {quapp_common-0.0.13.dev9/quapp_common.egg-info → quapp_common-0.0.14.dev1}/PKG-INFO +64 -2
  2. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/README.md +63 -1
  3. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/pyproject.toml +1 -1
  4. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/async_tasks/post_processing_task.py +3 -1
  5. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/invocation.py +24 -9
  6. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/job_fetcher.py +5 -2
  7. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/job_fetching.py +5 -2
  8. quapp_common-0.0.14.dev1/quapp_common/config/log_context.py +186 -0
  9. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/config/logging_config.py +33 -5
  10. quapp_common-0.0.14.dev1/quapp_common/config/thread_config.py +28 -0
  11. quapp_common-0.0.14.dev1/quapp_common/data/response/error_report.py +191 -0
  12. quapp_common-0.0.14.dev1/quapp_common/enum/error_category.py +91 -0
  13. quapp_common-0.0.14.dev1/quapp_common/enum/runtime_phase.py +118 -0
  14. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/device/custom_device.py +4 -1
  15. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/device/device.py +48 -29
  16. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/invocation.py +13 -1
  17. quapp_common-0.0.14.dev1/quapp_common/util/error_classifier.py +456 -0
  18. quapp_common-0.0.14.dev1/quapp_common/util/invocation_failure.py +103 -0
  19. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/util/response_utils.py +21 -6
  20. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1/quapp_common.egg-info}/PKG-INFO +64 -2
  21. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common.egg-info/SOURCES.txt +8 -0
  22. quapp_common-0.0.14.dev1/tests/test_error_classifier.py +382 -0
  23. quapp_common-0.0.14.dev1/tests/test_error_report.py +405 -0
  24. quapp_common-0.0.14.dev1/tests/test_invocation_failure.py +148 -0
  25. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_logging_config.py +35 -0
  26. quapp_common-0.0.13.dev9/quapp_common/config/log_context.py +0 -102
  27. quapp_common-0.0.13.dev9/quapp_common/config/thread_config.py +0 -10
  28. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/LICENSE +0 -0
  29. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/__init__.py +0 -0
  30. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/async_tasks/__init__.py +0 -0
  31. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/async_tasks/async_invocation_task.py +0 -0
  32. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/async_tasks/async_task.py +0 -0
  33. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/async_tasks/export_circuit_task.py +0 -0
  34. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/__init__.py +0 -0
  35. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/__init__.py +0 -0
  36. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/job_heartbeat.py +0 -0
  37. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/backend/job_manager.py +0 -0
  38. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/bridge.py +0 -0
  39. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/callback/__init__.py +0 -0
  40. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/callback/update_job_metadata.py +0 -0
  41. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/circuit_adapter.py +0 -0
  42. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/device/__init__.py +0 -0
  43. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/device/device_selection.py +0 -0
  44. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/dispatcher.py +0 -0
  45. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/component/result_serializer.py +0 -0
  46. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/config/__init__.py +0 -0
  47. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/__init__.py +0 -0
  48. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/async_task/__init__.py +0 -0
  49. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/async_task/circuit_export/__init__.py +0 -0
  50. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/async_task/circuit_export/backend_holder.py +0 -0
  51. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/async_task/circuit_export/circuit_holder.py +0 -0
  52. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/backend/__init__.py +0 -0
  53. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/backend/backend_information.py +0 -0
  54. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/callback/__init__.py +0 -0
  55. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/callback/callback_url.py +0 -0
  56. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/device/__init__.py +0 -0
  57. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/device/circuit_running_option.py +0 -0
  58. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/promise/__init__.py +0 -0
  59. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/promise/post_processing_promise.py +0 -0
  60. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/promise/promise.py +0 -0
  61. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/request/__init__.py +0 -0
  62. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/request/invocation_request.py +0 -0
  63. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/request/job_fetching_request.py +0 -0
  64. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/request/request.py +0 -0
  65. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/response/__init__.py +0 -0
  66. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/response/authentication.py +0 -0
  67. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/response/custom_header.py +0 -0
  68. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/data/response/job_response.py +0 -0
  69. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/__init__.py +0 -0
  70. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/base_enum.py +0 -0
  71. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/http_header.py +0 -0
  72. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/invocation_step.py +0 -0
  73. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/language.py +0 -0
  74. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/media_type.py +0 -0
  75. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/processing_unit.py +0 -0
  76. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/provider_tag.py +0 -0
  77. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/sdk.py +0 -0
  78. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/status/__init__.py +0 -0
  79. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/status/job_status.py +0 -0
  80. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/status/status_code.py +0 -0
  81. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/enum/token_type.py +0 -0
  82. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/factory/__init__.py +0 -0
  83. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/factory/device_factory.py +0 -0
  84. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/factory/handler_factory.py +0 -0
  85. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/factory/provider_factory.py +0 -0
  86. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/handler/__init__.py +0 -0
  87. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/handler/handler.py +0 -0
  88. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/__init__.py +0 -0
  89. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/device/__init__.py +0 -0
  90. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/provider/__init__.py +0 -0
  91. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/model/provider/provider.py +0 -0
  92. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/util/__init__.py +0 -0
  93. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/util/file_utils.py +0 -0
  94. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/util/http_utils.py +0 -0
  95. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common/util/json_parser_utils.py +0 -0
  96. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common.egg-info/dependency_links.txt +0 -0
  97. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common.egg-info/requires.txt +0 -0
  98. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/quapp_common.egg-info/top_level.txt +0 -0
  99. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/setup.cfg +0 -0
  100. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_circuit_adapter.py +0 -0
  101. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_dispatcher_runner_error.py +0 -0
  102. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_job_heartbeat.py +0 -0
  103. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_json_parser_utils.py +0 -0
  104. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/tests/test_language_dispatcher.py +0 -0
  105. {quapp_common-0.0.13.dev9 → quapp_common-0.0.14.dev1}/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.dev1
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,69 @@ Notes:
114
114
 
115
115
  ## Recently Changes Highlights
116
116
 
117
- ### v0.0.14.dev1 — Error logging & observability (Phase 0)
117
+ ### v0.0.14.dev1 — Error taxonomy & error envelope (Phase 1)
118
+
119
+ Tiếp nối Phase 0. Bổ sung phân loại lỗi có ngữ nghĩa và envelope dùng chung cho
120
+ Function Log lẫn Job Log. **Additive**: `job_result` chỉ thêm field `error`,
121
+ `message` và `exception` giữ nguyên; contract callback URL không đổi.
122
+
123
+ **Taxonomy — hai chiều phân loại độc lập**
124
+ - `enum/error_category.py`: `ErrorCategory` 13 giá trị, nhóm theo *ai xử lý
125
+ được* (`owner_of()` → USER / WORKSPACE_ADMIN / PROVIDER / PLATFORM).
126
+ - `enum/runtime_phase.py`: `RuntimePhase` 9 giá trị. Tách được
127
+ `PROVIDER_INVOCATION` / `COMPILATION` / `EXECUTION` — trước đây auth sai,
128
+ transpile fail và provider từ chối job đều ra chung nhãn `EXECUTION`.
129
+ `PHASE_TO_STEP` giữ nguyên contract `InvocationStep` của callback URL.
130
+
131
+ **Classifier (`util/error_classifier.py`)** — tổ hợp 5 tín hiệu theo độ tin cậy:
132
+ 1. `phase` — biết chắc từ context manager, không suy đoán;
133
+ 2. `frame_owner()` — code của ai, dựa vào đường dẫn frame sâu nhất
134
+ (`function/` = user, `quapp_*` = platform, `site-packages/` = SDK);
135
+ 3. tên class exception, đi hết **cause chain** qua cả `__cause__` *và*
136
+ `__context__` — cần thiết vì nhiều provider lib bọc lỗi bằng
137
+ `raise ValueError(...)` trong khối `except` mà không có `from`;
138
+ 4. attribute đọc được: HTTP status, AWS error code, exit code, signal;
139
+ 5. message pattern (dùng cuối cùng).
140
+
141
+ `register_rules()` cho phép lib provider đăng ký rule đặc thù SDK khi import,
142
+ nên `qapp-common` không phải import qiskit/braket. Batch đăng ký sau được xét
143
+ trước; trong cùng batch giữ nguyên thứ tự. Rule ném lỗi bị bỏ qua chứ không làm
144
+ hỏng đường báo lỗi. 20 rule generic sẵn có.
145
+
146
+ Fallback nhiều tầng nên **không bao giờ trả `UNKNOWN` một cách mù**: không rule
147
+ nào khớp thì suy từ frame ownership, rồi tới phase.
148
+
149
+ **Envelope (`data/response/error_report.py`)** — `job_result['error']` gồm
150
+ `code`, `category`, `phase`, `owner`, `retryable`, `rootCause` (type + detail +
151
+ file:line), `causeChain`, `stackTrace` (full, đã redact),
152
+ `handlerStackTrace` (stack JS/.NET cho handler không phải Python),
153
+ `context`, `resolution` (summary + steps + docUrl), `retry`, `traceId`.
154
+ `log_fields()` bind cùng vocabulary vào Function Log.
155
+
156
+ **Timeline (`config/log_context.py`)** — context manager `phase()` phát
157
+ `phase_start` / `phase_end` / `phase_failed` kèm `duration_ms`. Đây là dữ liệu
158
+ để UI vẽ timeline và chỉ ra job chết ở bước nào. `phase_failed` ở mức WARNING để
159
+ thấy được khi chạy `LOG_LEVEL=INFO`.
160
+
161
+ `_FAILED_PHASE` ghi lại phase mà exception thoát ra: khối `except` xử lý lỗi gần
162
+ như luôn nằm **ngoài** `with phase(...)`, lúc đó phase đang mở đã bị reset trong
163
+ `finally`. Không có nó thì mọi `ErrorReport` đều `phase=None` và fallback theo
164
+ phase không bao giờ chạy. Dùng `effective_phase()` ở đường báo lỗi.
165
+
166
+ **`config/thread_config.py`** — `ContextAwareThreadPoolExecutor` copy contextvars
167
+ khi submit. `ThreadPoolExecutor` thuần không làm việc này (khác
168
+ `asyncio.to_thread`), nên analysis + finalization chạy trong
169
+ `circuit_running_pool` sẽ mất `job_id` / `trace_id` / `phase`.
170
+
171
+ **Call-site** — `phase()` bọc PREPROCESSING / DEVICE_SELECTION /
172
+ PROVIDER_INVOCATION / COMPILATION / EXECUTION / ANALYSIS; toàn bộ 13 call-site
173
+ của `build_error_job_response()` truyền `phase=`.
174
+
175
+ **Test** — `tests/test_error_classifier.py` (52 case: cause chain, HTTP/AWS
176
+ code, frame ownership, phase fallback, rule ordering) và
177
+ `tests/test_error_report.py` (44 case: envelope, timeline, thread pool).
178
+
179
+ ### v0.0.13.dev9 — Error logging & observability (Phase 0)
118
180
 
119
181
  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
182
  đổi trong bản này là **additive hoặc bug fix** — không đổi contract callback,
@@ -73,7 +73,69 @@ Notes:
73
73
 
74
74
  ## Recently Changes Highlights
75
75
 
76
- ### v0.0.14.dev1 — Error logging & observability (Phase 0)
76
+ ### v0.0.14.dev1 — Error taxonomy & error envelope (Phase 1)
77
+
78
+ Tiếp nối Phase 0. Bổ sung phân loại lỗi có ngữ nghĩa và envelope dùng chung cho
79
+ Function Log lẫn Job Log. **Additive**: `job_result` chỉ thêm field `error`,
80
+ `message` và `exception` giữ nguyên; contract callback URL không đổi.
81
+
82
+ **Taxonomy — hai chiều phân loại độc lập**
83
+ - `enum/error_category.py`: `ErrorCategory` 13 giá trị, nhóm theo *ai xử lý
84
+ được* (`owner_of()` → USER / WORKSPACE_ADMIN / PROVIDER / PLATFORM).
85
+ - `enum/runtime_phase.py`: `RuntimePhase` 9 giá trị. Tách được
86
+ `PROVIDER_INVOCATION` / `COMPILATION` / `EXECUTION` — trước đây auth sai,
87
+ transpile fail và provider từ chối job đều ra chung nhãn `EXECUTION`.
88
+ `PHASE_TO_STEP` giữ nguyên contract `InvocationStep` của callback URL.
89
+
90
+ **Classifier (`util/error_classifier.py`)** — tổ hợp 5 tín hiệu theo độ tin cậy:
91
+ 1. `phase` — biết chắc từ context manager, không suy đoán;
92
+ 2. `frame_owner()` — code của ai, dựa vào đường dẫn frame sâu nhất
93
+ (`function/` = user, `quapp_*` = platform, `site-packages/` = SDK);
94
+ 3. tên class exception, đi hết **cause chain** qua cả `__cause__` *và*
95
+ `__context__` — cần thiết vì nhiều provider lib bọc lỗi bằng
96
+ `raise ValueError(...)` trong khối `except` mà không có `from`;
97
+ 4. attribute đọc được: HTTP status, AWS error code, exit code, signal;
98
+ 5. message pattern (dùng cuối cùng).
99
+
100
+ `register_rules()` cho phép lib provider đăng ký rule đặc thù SDK khi import,
101
+ nên `qapp-common` không phải import qiskit/braket. Batch đăng ký sau được xét
102
+ trước; trong cùng batch giữ nguyên thứ tự. Rule ném lỗi bị bỏ qua chứ không làm
103
+ hỏng đường báo lỗi. 20 rule generic sẵn có.
104
+
105
+ Fallback nhiều tầng nên **không bao giờ trả `UNKNOWN` một cách mù**: không rule
106
+ nào khớp thì suy từ frame ownership, rồi tới phase.
107
+
108
+ **Envelope (`data/response/error_report.py`)** — `job_result['error']` gồm
109
+ `code`, `category`, `phase`, `owner`, `retryable`, `rootCause` (type + detail +
110
+ file:line), `causeChain`, `stackTrace` (full, đã redact),
111
+ `handlerStackTrace` (stack JS/.NET cho handler không phải Python),
112
+ `context`, `resolution` (summary + steps + docUrl), `retry`, `traceId`.
113
+ `log_fields()` bind cùng vocabulary vào Function Log.
114
+
115
+ **Timeline (`config/log_context.py`)** — context manager `phase()` phát
116
+ `phase_start` / `phase_end` / `phase_failed` kèm `duration_ms`. Đây là dữ liệu
117
+ để UI vẽ timeline và chỉ ra job chết ở bước nào. `phase_failed` ở mức WARNING để
118
+ thấy được khi chạy `LOG_LEVEL=INFO`.
119
+
120
+ `_FAILED_PHASE` ghi lại phase mà exception thoát ra: khối `except` xử lý lỗi gần
121
+ như luôn nằm **ngoài** `with phase(...)`, lúc đó phase đang mở đã bị reset trong
122
+ `finally`. Không có nó thì mọi `ErrorReport` đều `phase=None` và fallback theo
123
+ phase không bao giờ chạy. Dùng `effective_phase()` ở đường báo lỗi.
124
+
125
+ **`config/thread_config.py`** — `ContextAwareThreadPoolExecutor` copy contextvars
126
+ khi submit. `ThreadPoolExecutor` thuần không làm việc này (khác
127
+ `asyncio.to_thread`), nên analysis + finalization chạy trong
128
+ `circuit_running_pool` sẽ mất `job_id` / `trace_id` / `phase`.
129
+
130
+ **Call-site** — `phase()` bọc PREPROCESSING / DEVICE_SELECTION /
131
+ PROVIDER_INVOCATION / COMPILATION / EXECUTION / ANALYSIS; toàn bộ 13 call-site
132
+ của `build_error_job_response()` truyền `phase=`.
133
+
134
+ **Test** — `tests/test_error_classifier.py` (52 case: cause chain, HTTP/AWS
135
+ code, frame ownership, phase fallback, rule ordering) và
136
+ `tests/test_error_report.py` (44 case: envelope, timeline, thread pool).
137
+
138
+ ### v0.0.13.dev9 — Error logging & observability (Phase 0)
77
139
 
78
140
  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
141
  đổ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.dev1"
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
 
@@ -50,7 +51,8 @@ class PostProcessingTask(AsyncTask):
50
51
  job_response = build_error_job_response(
51
52
  exception, job_response,
52
53
  message='Error when post processing job result',
53
- stage='while post-processing the job result')
54
+ stage='while post-processing the job result',
55
+ phase=RuntimePhase.POST_PROCESSING)
54
56
 
55
57
  update_job_metadata(job_response,
56
58
  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,8 +84,12 @@ 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
 
@@ -94,7 +100,8 @@ class Invocation(ABC):
94
100
 
95
101
  job_response = build_error_job_response(exception,
96
102
  message='Error when prepare backend data',
97
- stage='while preparing the backend')
103
+ stage='while preparing the backend',
104
+ phase=RuntimePhase.DEVICE_SELECTION)
98
105
  job_response.authentication = self.authentication
99
106
  update_job_metadata(job_response, self.callback_dict.get(
100
107
  InvocationStep.PREPARATION).on_error)
@@ -125,10 +132,15 @@ class Invocation(ABC):
125
132
  self.logger.debug(
126
133
  f"Executing job with provider tag: {provider_tag.value}")
127
134
 
128
- provider = self._create_provider()
135
+ # PROVIDER_INVOCATION tách riêng khỏi EXECUTION: lỗi authenticate
136
+ # với provider và lỗi provider từ chối circuit là hai chuyện khác
137
+ # nhau, trước đây cùng ra nhãn EXECUTION.
138
+ with phase(RuntimePhase.PROVIDER_INVOCATION):
139
+ provider = self._create_provider()
129
140
 
130
- self.logger.debug(f"Executing job with device name: {device_name}")
131
- device = self._create_device(provider)
141
+ self.logger.debug(
142
+ f"Executing job with device name: {device_name}")
143
+ device = self._create_device(provider)
132
144
 
133
145
  except Exception as exception:
134
146
  self.logger.exception(
@@ -137,7 +149,8 @@ class Invocation(ABC):
137
149
 
138
150
  job_response = build_error_job_response(exception,
139
151
  message='Error when create provider or device',
140
- stage='while running the circuit on the device')
152
+ stage='while running the circuit on the device',
153
+ phase=RuntimePhase.PROVIDER_INVOCATION)
141
154
  update_job_metadata(job_response, self.callback_dict.get(
142
155
  InvocationStep.EXECUTION).on_error, )
143
156
 
@@ -170,7 +183,8 @@ class Invocation(ABC):
170
183
  InvocationStep.PREPARATION).on_start)
171
184
 
172
185
  try:
173
- circuit = circuit_preparation_fn(self.input)
186
+ with phase(RuntimePhase.PREPROCESSING):
187
+ circuit = circuit_preparation_fn(self.input)
174
188
 
175
189
  if circuit is None:
176
190
  self.logger.warning(
@@ -190,7 +204,8 @@ class Invocation(ABC):
190
204
 
191
205
  job_response = build_error_job_response(exception, job_response,
192
206
  message='Error when prepare circuit',
193
- stage='while preparing the circuit')
207
+ stage='while preparing the circuit',
208
+ phase=RuntimePhase.PREPROCESSING)
194
209
 
195
210
  update_job_metadata(job_response, self.callback_dict.get(
196
211
  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
@@ -135,7 +136,8 @@ class JobFetcher(ABC):
135
136
  exception, job_response,
136
137
  message='Error when fetching job with provider_job_id {0}'.format(
137
138
  self.provider_job_id),
138
- stage='while fetching the job result from the provider')
139
+ stage='while fetching the job result from the provider',
140
+ phase=RuntimePhase.POLLING)
139
141
  update_job_metadata(job_response, self.callback_urls[
140
142
  InvocationStep.EXECUTION].on_error)
141
143
 
@@ -213,7 +215,8 @@ class JobFetcher(ABC):
213
215
  job_response = build_error_job_response(
214
216
  exception, job_response,
215
217
  message='Error when analyzing job result',
216
- stage='while analysing the job result')
218
+ stage='while analysing the job result',
219
+ phase=RuntimePhase.ANALYSIS)
217
220
 
218
221
  update_job_metadata(job_response, callback_url.on_error)
219
222
  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
@@ -97,7 +98,8 @@ class JobFetching(ABC):
97
98
  exception, job_response,
98
99
  message='Exception when fetch job with provider_job_id {0}'.format(
99
100
  self.provider_job_id),
100
- stage='while fetching the job result from the provider')
101
+ stage='while fetching the job result from the provider',
102
+ phase=RuntimePhase.POLLING)
101
103
 
102
104
  update_job_metadata(job_response, self.callback_dict.get(
103
105
  InvocationStep.EXECUTION).on_error)
@@ -162,7 +164,8 @@ class JobFetching(ABC):
162
164
  exception, job_response,
163
165
  message='Exception when analyst job result with provider_job_id {0}'.format(
164
166
  self.provider_job_id),
165
- stage='while analysing the job result')
167
+ stage='while analysing the job result',
168
+ phase=RuntimePhase.ANALYSIS)
166
169
 
167
170
  update_job_metadata(job_response, callback_url.on_error)
168
171
 
@@ -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)
@@ -90,8 +90,35 @@ def redact(text) -> str:
90
90
  EXC_TEXT = '_exc_text'
91
91
 
92
92
 
93
+ def _inject_job_context(record) -> None:
94
+ """Bổ sung job context vào record nếu chưa có.
95
+
96
+ Cần thiết vì không phải log nào cũng đi qua ``job_logger()``. Có ba đường
97
+ vào và cả ba phải có context:
98
+
99
+ - ``job_logger()`` -- đã bind sẵn, không đụng tới;
100
+ - stdlib ``logging`` qua ``InterceptHandler`` -- handler tự bind;
101
+ - **raw loguru** ``from quapp_common.config.logging_config import logger`` --
102
+ đường này 10 template và nhiều module trong lib đang dùng
103
+ (``logger.bind(context='Provider')``, ``dispatcher``, ``bridge``...), và
104
+ ``extra`` của nó không có job_id.
105
+
106
+ Đọc contextvars ở đây (thời điểm emit) thay vì lúc lấy logger, nên đúng cả
107
+ khi logger là biến module-level được tạo một lần lúc import.
108
+ """
109
+ from .log_context import current_phase_value, job_context
110
+
111
+ extra = record['extra']
112
+ for key, value in job_context().items():
113
+ if value is not None and extra.get(key) is None:
114
+ extra[key] = value
115
+
116
+ if extra.get('phase') is None:
117
+ extra['phase'] = current_phase_value()
118
+
119
+
93
120
  def _prepare_record(record) -> bool:
94
- """loguru filter: mask message và pre-format traceback.
121
+ """loguru filter: bổ sung context, mask message và pre-format traceback.
95
122
 
96
123
  Filter chạy ở process gọi log, TRƯỚC khi record được đưa vào queue của
97
124
  ``enqueue=True``. Đây là chỗ duy nhất còn nhìn thấy traceback object thật --
@@ -100,6 +127,7 @@ def _prepare_record(record) -> bool:
100
127
 
101
128
  Trả True để giữ record.
102
129
  """
130
+ _inject_job_context(record)
103
131
  record['message'] = redact(record['message'])
104
132
 
105
133
  exception = record['exception']
@@ -191,7 +219,7 @@ class InterceptHandler(logging.Handler):
191
219
  """
192
220
 
193
221
  def emit(self, record: logging.LogRecord) -> None:
194
- from .log_context import current_phase, job_context
222
+ from .log_context import current_phase_value, job_context
195
223
 
196
224
  try:
197
225
  level = logger.level(record.levelname).name
@@ -209,7 +237,7 @@ class InterceptHandler(logging.Handler):
209
237
  depth += 1
210
238
 
211
239
  logger.opt(depth=depth, exception=record.exc_info).bind(
212
- **job_context(), phase=current_phase(),
240
+ **job_context(), phase=current_phase_value(),
213
241
  stdlib_logger=record.name).log(level, record.getMessage())
214
242
 
215
243
 
@@ -264,13 +292,13 @@ def job_logger(job_id: str = None):
264
292
  Khác biệt so với trước: khi không truyền ``job_id``, lấy context đã bind bởi
265
293
  ``bind_job()`` thay vì rơi về ``"QuappLibs"``.
266
294
  """
267
- from .log_context import current_phase, job_context
295
+ from .log_context import current_phase_value, job_context
268
296
 
269
297
  context = job_context()
270
298
  if job_id:
271
299
  context = {**context, 'job_id': job_id}
272
300
 
273
- return logger.bind(**context, phase=current_phase())
301
+ return logger.bind(**context, phase=current_phase_value())
274
302
 
275
303
 
276
304
  configure_logging()
@@ -0,0 +1,28 @@
1
+ """
2
+ QApp Platform Project thread_config.py Copyright © CITYNOW Co. Ltd. All rights reserved.
3
+ """
4
+ import contextvars
5
+ from concurrent.futures import ThreadPoolExecutor
6
+
7
+
8
+ class ContextAwareThreadPoolExecutor(ThreadPoolExecutor):
9
+ """ThreadPoolExecutor giữ lại contextvars của caller.
10
+
11
+ ``ThreadPoolExecutor.submit()`` KHÔNG copy context (khác ``asyncio.to_thread``
12
+ và ``starlette.run_in_threadpool``). Cả ``JobFetcher._handle_successful_job``
13
+ lẫn ``JobFetching.fetch`` đều submit phần analysis + finalization vào
14
+ ``circuit_running_pool``, nên nếu không copy context thì toàn bộ log của hai
15
+ phase đó mất ``job_id`` / ``trace_id`` / ``phase`` và rơi về bucket
16
+ "QuappLibs".
17
+ """
18
+
19
+ def submit(self, fn, /, *args, **kwargs):
20
+ return super().submit(contextvars.copy_context().run, fn, *args,
21
+ **kwargs)
22
+
23
+
24
+ circuit_exporting_pool = ContextAwareThreadPoolExecutor(
25
+ max_workers=10, thread_name_prefix='circuit-exporting-pool-')
26
+
27
+ circuit_running_pool = ContextAwareThreadPoolExecutor(
28
+ max_workers=20, thread_name_prefix='circuit-running-pool-')