debug.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. """Per-component health-signal log (signals only, never transcripts/audio).
  2. Persists structured health signals to ``dir/YYYY-MM-DD.jsonl`` so the next
  3. silent freeze is diagnosable from disk. No transcript text, no audio paths,
  4. no prompt content -- numeric/boolean signal fields only.
  5. Freeze-safety invariant: :class:`DebugSink` is a leaf lock. It never calls
  6. back into the daemon, the assistant service, or any other component, so the
  7. internal :class:`threading.Lock` cannot participate in an ABBA deadlock.
  8. ``emit()`` total-swallows every exception and latches a disabled circuit
  9. breaker, because a raise in the daemon reader is misclassified as capture
  10. failure (reopen churn/shutdown) and a raise in the worker is fatal.
  11. """
  12. import json
  13. import logging
  14. import os
  15. import threading
  16. from datetime import datetime
  17. from pathlib import Path
  18. logger = logging.getLogger(__name__)
  19. _VALID_PROCS = ("daemon", "assistant", "supervisor")
  20. _FORBIDDEN_FIELDS = ("text", "audio", "prompt")
  21. class DebugSink:
  22. """Append-only per-day JSONL signal log, safe to share across threads.
  23. State invariant: this sink owns no reference to any daemon/service
  24. object and performs no callbacks -- it is a leaf lock. The lock is held
  25. only for a day-rollover ``os.open`` (when needed), a single ``os.write``
  26. of the fully serialized line, and the byte-counter increment.
  27. """
  28. def __init__(self, dir, proc, max_bytes_per_day=50_000_000, now_fn=None):
  29. """Create the sink, making ``dir`` once.
  30. Args:
  31. dir: Destination directory for daily JSONL files.
  32. proc: One of ``daemon`` | ``assistant`` | ``supervisor``.
  33. max_bytes_per_day: Best-effort per-date cap; writes past the cap
  34. are skipped with a single warning (never crash, never rotate).
  35. now_fn: Injectable clock returning a ``datetime`` (tests only).
  36. Raises:
  37. ValueError: If ``proc`` is not a known component name.
  38. """
  39. if proc not in _VALID_PROCS:
  40. raise ValueError(f"unknown proc: {proc!r} (expected one of {_VALID_PROCS})")
  41. self._proc = proc
  42. self._dir = Path(dir)
  43. self._max = max_bytes_per_day
  44. self._now = now_fn or datetime.now
  45. self._lock = threading.Lock()
  46. self._fd = None
  47. self._date = None
  48. self._bytes = 0
  49. self._disabled = False
  50. self._cap_warned = False
  51. try:
  52. self._dir.mkdir(parents=True, exist_ok=True)
  53. except Exception as e: # noqa: BLE001 - debug logging is best effort
  54. self._disabled = True
  55. logger.warning("debug log disabled (mkdir failed): %s", e)
  56. def _open_day(self, today):
  57. """Open today's file, closing the previous day's fd. Caller holds the lock."""
  58. if self._fd is not None:
  59. try:
  60. os.close(self._fd)
  61. except OSError:
  62. pass
  63. self._fd = None
  64. path = str(self._dir / f"{today}.jsonl")
  65. self._fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
  66. self._date = today
  67. self._bytes = 0
  68. self._cap_warned = False
  69. def emit(self, type, **fields):
  70. """Append one signal record. Never raises.
  71. The line is serialized BEFORE acquiring the lock; the lock covers
  72. only the day-rollover open (when needed), one ``os.write`` of the
  73. full line, and the byte-counter increment. The first I/O failure
  74. latches a disabled circuit breaker: it warns once and every
  75. subsequent emit is a no-op. ``text``/``audio``/``prompt`` fields are
  76. silently dropped (signals only).
  77. """
  78. try:
  79. if self._disabled:
  80. return
  81. for key in _FORBIDDEN_FIELDS:
  82. fields.pop(key, None)
  83. now = self._now()
  84. # Serialize before opening/locking: a failure here must not leave
  85. # a partial line in the file for the next record to append onto.
  86. record = {"ts": now.isoformat(), "proc": self._proc, "type": type, **fields}
  87. data = (json.dumps(record, ensure_ascii=False) + "\n").encode("utf-8")
  88. with self._lock:
  89. if self._disabled:
  90. return
  91. today = now.strftime("%Y-%m-%d")
  92. if today != self._date:
  93. self._open_day(today)
  94. if self._bytes + len(data) > self._max:
  95. if not self._cap_warned:
  96. self._cap_warned = True
  97. logger.warning("debug log cap reached for %s; skipping", today)
  98. return
  99. assert self._fd is not None
  100. os.write(self._fd, data)
  101. self._bytes += len(data)
  102. except Exception as e: # noqa: BLE001 - debug logging must never break callers
  103. with self._lock:
  104. self._disabled = True
  105. if self._fd is not None:
  106. try:
  107. os.close(self._fd)
  108. except OSError:
  109. pass
  110. self._fd = None
  111. logger.warning("debug log disabled after write failure: %s", e)
  112. def close(self):
  113. """Clean up (no-op; per-day fds are reclaimed on rollover/exit)."""