test_debug_log.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. """Unit tests for cq2_sdk.debug_log.DebugLogger.
  2. Covers:
  3. * file creation & naming
  4. * auto session_id + manual override
  5. * session_start / session_end markers
  6. * info / warn / error levels
  7. * http_request / http_response correlation via req_id
  8. * body_preview truncation
  9. * step() markers
  10. * context manager + auto-error on exception
  11. * thread-safety (concurrent writes don't corrupt lines)
  12. * extra= kwargs surfaced in session_start
  13. """
  14. from __future__ import annotations
  15. import json
  16. import threading
  17. from pathlib import Path
  18. import pytest
  19. from cq2_sdk.debug_log import DebugLogger, _preview, BODY_PREVIEW_LIMIT
  20. # ---------- helpers ----------
  21. def _read_jsonl(path: Path) -> list[dict]:
  22. with path.open("r", encoding="utf-8") as f:
  23. return [json.loads(line) for line in f if line.strip()]
  24. # ---------- file creation & session ----------
  25. def test_creates_jsonl_file_in_log_dir(tmp_path):
  26. with DebugLogger(log_dir=tmp_path) as log:
  27. log.info("hello")
  28. files = list(tmp_path.glob("session_*.jsonl"))
  29. assert len(files) == 1
  30. lines = _read_jsonl(files[0])
  31. assert lines[0]["event"] == "session_start"
  32. assert lines[-1]["event"] == "session_end"
  33. assert lines[1]["msg"] == "hello"
  34. def test_session_id_format():
  35. log = DebugLogger(log_dir=Path("/tmp"))
  36. try:
  37. # s_YYYYMMDD_HHMMSS_xxxxxx (6 hex chars)
  38. assert log.session_id.startswith("s_")
  39. parts = log.session_id.split("_")
  40. assert len(parts) == 4 # s, YYYYMMDD, HHMMSS, suffix
  41. assert len(parts[3]) == 6
  42. assert all(c in "0123456789abcdef" for c in parts[3])
  43. finally:
  44. log.close()
  45. def test_manual_session_id(tmp_path):
  46. with DebugLogger(session_id="s_test_2026", log_dir=tmp_path) as log:
  47. log.info("x")
  48. files = list(tmp_path.glob("session_*.jsonl"))
  49. assert files[0].name == "session_s_test_2026.jsonl"
  50. rec = _read_jsonl(files[0])[1]
  51. assert rec["session"] == "s_test_2026"
  52. def test_script_field_inferred_from_argv(tmp_path):
  53. # when run via pytest, sys.argv[0] is the pytest binary
  54. with DebugLogger(log_dir=tmp_path) as log:
  55. log.info("x")
  56. lines = _read_jsonl(log.path)
  57. assert lines[1]["script"] # non-empty string
  58. # ---------- log() / info / warn / error ----------
  59. def test_log_writes_one_jsonl_line(tmp_path):
  60. with DebugLogger(log_dir=tmp_path) as log:
  61. log.log("custom_event", foo=1, bar="x")
  62. rec = _read_jsonl(log.path)[1]
  63. assert rec["event"] == "custom_event"
  64. assert rec["foo"] == 1
  65. assert rec["bar"] == "x"
  66. assert "ts" in rec
  67. # Python's datetime.isoformat() with timezone.utc renders as "+00:00";
  68. # we accept either that or the "Z" shorthand. Just verify it's parseable.
  69. from datetime import datetime
  70. parsed = datetime.fromisoformat(rec["ts"])
  71. assert parsed.tzinfo is not None # has tz
  72. assert parsed.utcoffset().total_seconds() == 0 # is UTC
  73. def test_info_warn_levels(tmp_path):
  74. with DebugLogger(log_dir=tmp_path) as log:
  75. log.info("an info", k=1)
  76. log.warn("a warning", k=2)
  77. recs = _read_jsonl(log.path)
  78. assert recs[1]["level"] == "info"
  79. assert recs[2]["level"] == "warn"
  80. assert recs[2]["event"] == "warn"
  81. def test_error_from_exception(tmp_path):
  82. with DebugLogger(log_dir=tmp_path) as log:
  83. try:
  84. raise ValueError("bad sig")
  85. except ValueError as e:
  86. log.error(e, step="sign_body", account="x")
  87. rec = _read_jsonl(log.path)[1]
  88. assert rec["event"] == "error"
  89. assert rec["level"] == "error"
  90. assert rec["msg"] == "bad sig"
  91. assert rec["exc_type"] == "ValueError"
  92. assert rec["step"] == "sign_body"
  93. assert rec["account"] == "x"
  94. def test_error_from_string(tmp_path):
  95. with DebugLogger(log_dir=tmp_path) as log:
  96. log.error("just a string", step="x")
  97. rec = _read_jsonl(log.path)[1]
  98. assert rec["msg"] == "just a string"
  99. assert rec["exc_type"] is None
  100. # ---------- http correlation ----------
  101. def test_http_request_returns_correlatable_id(tmp_path):
  102. with DebugLogger(log_dir=tmp_path) as log:
  103. rid = log.http_request("POST", "https://api/foo", body="<rsa>", step="login")
  104. log.http_response(rid, status=200, body="<ok>", duration_ms=12.5, step="login")
  105. recs = _read_jsonl(log.path)
  106. req = recs[1]
  107. resp = recs[2]
  108. assert req["event"] == "http_request"
  109. assert req["req_id"] == rid
  110. assert req["method"] == "POST"
  111. assert req["step"] == "login"
  112. assert resp["event"] == "http_response"
  113. assert resp["req_id"] == req["req_id"]
  114. assert resp["status"] == 200
  115. assert resp["duration_ms"] == 12.5
  116. def test_http_request_id_is_unique_per_instance(tmp_path):
  117. with DebugLogger(log_dir=tmp_path) as log:
  118. ids = {log.http_request("GET", "u") for _ in range(50)}
  119. assert len(ids) == 50
  120. assert all(i.startswith("r_") for i in ids)
  121. def test_http_request_with_headers_and_body_dict(tmp_path):
  122. with DebugLogger(log_dir=tmp_path) as log:
  123. log.http_request("POST", "https://x", headers={"X-A": "1"},
  124. body={"k": "v", "n": 42})
  125. rec = _read_jsonl(log.path)[1]
  126. assert rec["headers"] == {"X-A": "1"}
  127. # body_preview stringifies dict to JSON
  128. assert '"k": "v"' in rec["body_preview"] or '"k":"v"' in rec["body_preview"]
  129. def test_http_error_event(tmp_path):
  130. with DebugLogger(log_dir=tmp_path) as log:
  131. rid = log.http_request("POST", "https://x")
  132. try:
  133. raise TimeoutError("connect")
  134. except TimeoutError as e:
  135. log.http_error(rid, e, step="connect")
  136. recs = _read_jsonl(log.path)
  137. err = recs[2]
  138. assert err["event"] == "http_error"
  139. assert err["req_id"] == rid
  140. assert err["exc_type"] == "TimeoutError"
  141. assert err["level"] == "error"
  142. # ---------- body_preview ----------
  143. def test_preview_truncates_long_strings():
  144. big = "x" * (BODY_PREVIEW_LIMIT + 200)
  145. out = _preview(big)
  146. assert isinstance(out, str)
  147. assert out.startswith("x" * BODY_PREVIEW_LIMIT)
  148. assert "truncated" in out
  149. def test_preview_short_string_passes_through():
  150. assert _preview("short") == "short"
  151. def test_preview_dict_serializes():
  152. out = _preview({"a": 1, "b": "x"})
  153. parsed = json.loads(out)
  154. assert parsed == {"a": 1, "b": "x"}
  155. def test_preview_none_stays_none():
  156. assert _preview(None) is None
  157. # ---------- step ----------
  158. def test_step_emits_step_event(tmp_path):
  159. with DebugLogger(log_dir=tmp_path) as log:
  160. log.step("login_attempt", account="u1")
  161. log.step("launch_game", server="玖扬复古176")
  162. recs = _read_jsonl(log.path)
  163. assert recs[1]["event"] == "step" and recs[1]["name"] == "login_attempt"
  164. assert recs[2]["event"] == "step" and recs[2]["name"] == "launch_game"
  165. assert recs[2]["server"] == "玖扬复古176" # Chinese preserved
  166. # ---------- context manager ----------
  167. def test_context_manager_writes_session_end(tmp_path):
  168. with DebugLogger(log_dir=tmp_path) as log:
  169. log.info("inside")
  170. recs = _read_jsonl(log.path)
  171. assert recs[0]["event"] == "session_start"
  172. assert recs[-1]["event"] == "session_end"
  173. def test_context_manager_auto_logs_exception_on_exit(tmp_path):
  174. with pytest.raises(RuntimeError):
  175. with DebugLogger(log_dir=tmp_path) as log:
  176. raise RuntimeError("boom")
  177. recs = _read_jsonl(log.path)
  178. err = [r for r in recs if r["event"] == "error"]
  179. assert len(err) == 1
  180. assert err[0]["msg"] == "boom"
  181. assert err[0]["exc_type"] == "RuntimeError"
  182. assert err[0]["step"] == "__exit__"
  183. def test_close_idempotent(tmp_path):
  184. log = DebugLogger(log_dir=tmp_path)
  185. log.close()
  186. log.close() # must not raise
  187. recs = _read_jsonl(log.path)
  188. # exactly one session_end
  189. ends = [r for r in recs if r["event"] == "session_end"]
  190. assert len(ends) == 1
  191. def test_log_after_close_is_silent(tmp_path):
  192. log = DebugLogger(log_dir=tmp_path)
  193. log.close()
  194. log.info("ignored") # must not raise
  195. recs = _read_jsonl(log.path)
  196. # session_start and session_end have no "msg" key — filter to actual events.
  197. msg_events = [r for r in recs if r["event"] not in ("session_start", "session_end")]
  198. assert all(r.get("msg") != "ignored" for r in msg_events)
  199. # ---------- extra kwargs in session_start ----------
  200. def test_extra_kwargs_appear_in_session_start(tmp_path):
  201. with DebugLogger(log_dir=tmp_path, extra={"branch": "main",
  202. "commit": "5c37d16"}):
  203. pass
  204. rec = _read_jsonl(_next_log(tmp_path))[0]
  205. assert rec["event"] == "session_start"
  206. assert rec["branch"] == "main"
  207. assert rec["commit"] == "5c37d16"
  208. # ---------- thread safety ----------
  209. def test_concurrent_writes_dont_corrupt_lines(tmp_path):
  210. """100 threads × 10 events each. Each line must be valid JSON, no
  211. interleaving. Verifies the per-instance threading.Lock."""
  212. n_threads = 100
  213. per_thread = 10
  214. errors: list[Exception] = []
  215. def worker(tid: int, log: DebugLogger) -> None:
  216. try:
  217. for i in range(per_thread):
  218. log.info(f"t{tid}_i{i}", payload={"tid": tid, "i": i})
  219. except Exception as e:
  220. errors.append(e)
  221. with DebugLogger(log_dir=tmp_path) as log:
  222. threads = [
  223. threading.Thread(target=worker, args=(t, log))
  224. for t in range(n_threads)
  225. ]
  226. for t in threads:
  227. t.start()
  228. for t in threads:
  229. t.join()
  230. assert not errors, f"writer errors: {errors}"
  231. recs = _read_jsonl(log.path)
  232. # session_start + 100*10 info events + session_end
  233. assert len(recs) == n_threads * per_thread + 2
  234. # every line is valid JSON (already parsed) and has session field
  235. assert all(r["session"] == log.session_id for r in recs)
  236. # ---------- helpers ----------
  237. def _next_log(dirpath: Path) -> Path:
  238. files = list(dirpath.glob("session_*.jsonl"))
  239. assert len(files) == 1, files
  240. return files[0]
  241. # ---------- non-ASCII & Chinese safe ----------
  242. def test_chinese_in_body_and_fields(tmp_path):
  243. with DebugLogger(log_dir=tmp_path) as log:
  244. log.info("服务器返回了玖扬复古176的开服列表", # noqa: E501
  245. servers=["玖扬复古176", "复古180"], note="中文OK")
  246. recs = _read_jsonl(log.path)
  247. info = recs[1]
  248. assert "玖扬复古176" in info["msg"]
  249. assert "玖扬复古176" in info["servers"]
  250. assert info["note"] == "中文OK"