Extract 5 focused helpers from the 138-line capture_error(): - _extract_origin(): walk traceback for file/line - _log_error_event(): log to event log (best-effort) - _create_bug_report(): create task and log creation event - _send_error_notification(): push notification - _record_to_session(): forward to session recorder capture_error() now orchestrates the helpers in ~25 lines. Added tests for each new helper. Fixes #506 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
8.0 KiB
Python
271 lines
8.0 KiB
Python
"""Centralized error capture with automatic bug report creation.
|
|
|
|
Catches errors from anywhere in the system, deduplicates them, logs them
|
|
to the event log, and creates bug report tasks in the task queue.
|
|
|
|
Usage:
|
|
from infrastructure.error_capture import capture_error
|
|
|
|
try:
|
|
risky_operation()
|
|
except Exception as exc:
|
|
capture_error(exc, source="my_module", context={"request": "/api/foo"})
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import traceback
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# In-memory dedup cache: hash -> last_seen timestamp
|
|
_dedup_cache: dict[str, datetime] = {}
|
|
|
|
_error_recorder = None
|
|
|
|
|
|
def register_error_recorder(fn):
|
|
"""Register a callback for recording errors to session log."""
|
|
global _error_recorder
|
|
_error_recorder = fn
|
|
|
|
|
|
def _stack_hash(exc: Exception) -> str:
|
|
"""Create a stable hash of the exception type + traceback locations.
|
|
|
|
Only hashes the file/line/function info from the traceback, not
|
|
variable values, so the same bug produces the same hash even if
|
|
runtime data differs.
|
|
"""
|
|
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
# Extract only "File ..., line ..., in ..." lines for stable hashing
|
|
stable_parts = [type(exc).__name__]
|
|
for line in tb_lines:
|
|
stripped = line.strip()
|
|
if stripped.startswith("File "):
|
|
stable_parts.append(stripped)
|
|
return hashlib.sha256("\n".join(stable_parts).encode()).hexdigest()[:16]
|
|
|
|
|
|
def _is_duplicate(error_hash: str) -> bool:
|
|
"""Check if this error was seen recently (within dedup window)."""
|
|
from config import settings
|
|
|
|
now = datetime.now(UTC)
|
|
window = timedelta(seconds=settings.error_dedup_window_seconds)
|
|
|
|
if error_hash in _dedup_cache:
|
|
last_seen = _dedup_cache[error_hash]
|
|
if now - last_seen < window:
|
|
return True
|
|
|
|
_dedup_cache[error_hash] = now
|
|
|
|
# Prune old entries
|
|
cutoff = now - window * 2
|
|
expired = [k for k, v in _dedup_cache.items() if v < cutoff]
|
|
for k in expired:
|
|
del _dedup_cache[k]
|
|
|
|
return False
|
|
|
|
|
|
def _get_git_context() -> dict:
|
|
"""Get current git branch and commit for the bug report."""
|
|
try:
|
|
import subprocess
|
|
|
|
from config import settings
|
|
|
|
branch = subprocess.run(
|
|
["git", "branch", "--show-current"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
cwd=settings.repo_root,
|
|
).stdout.strip()
|
|
|
|
commit = subprocess.run(
|
|
["git", "rev-parse", "--short", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
cwd=settings.repo_root,
|
|
).stdout.strip()
|
|
|
|
return {"branch": branch, "commit": commit}
|
|
except Exception as exc:
|
|
logger.warning("Git info capture error: %s", exc)
|
|
return {"branch": "unknown", "commit": "unknown"}
|
|
|
|
|
|
def _extract_origin(exc: Exception) -> tuple[str, int]:
|
|
"""Walk the traceback to find the deepest file and line number."""
|
|
tb_obj = exc.__traceback__
|
|
while tb_obj and tb_obj.tb_next:
|
|
tb_obj = tb_obj.tb_next
|
|
if tb_obj:
|
|
return tb_obj.tb_frame.f_code.co_filename, tb_obj.tb_lineno
|
|
return "unknown", 0
|
|
|
|
|
|
def _log_error_event(
|
|
exc: Exception,
|
|
source: str,
|
|
error_hash: str,
|
|
affected_file: str,
|
|
affected_line: int,
|
|
git_ctx: dict,
|
|
) -> None:
|
|
"""Log the error to the event log (best-effort)."""
|
|
try:
|
|
from swarm.event_log import EventType, log_event
|
|
|
|
log_event(
|
|
EventType.ERROR_CAPTURED,
|
|
source=source,
|
|
data={
|
|
"error_type": type(exc).__name__,
|
|
"message": str(exc)[:500],
|
|
"hash": error_hash,
|
|
"file": affected_file,
|
|
"line": affected_line,
|
|
"git_branch": git_ctx.get("branch", ""),
|
|
"git_commit": git_ctx.get("commit", ""),
|
|
},
|
|
)
|
|
except Exception as log_exc:
|
|
logger.debug("Failed to log error event: %s", log_exc)
|
|
|
|
|
|
def _create_bug_report(
|
|
exc: Exception,
|
|
source: str,
|
|
error_hash: str,
|
|
affected_file: str,
|
|
affected_line: int,
|
|
git_ctx: dict,
|
|
tb_str: str,
|
|
context: dict | None,
|
|
) -> str | None:
|
|
"""Create a bug report task and return its ID (best-effort)."""
|
|
try:
|
|
from swarm.task_queue.models import create_task
|
|
|
|
title = f"[BUG] {type(exc).__name__}: {str(exc)[:80]}"
|
|
|
|
description_parts = [
|
|
f"**Error:** {type(exc).__name__}: {str(exc)}",
|
|
f"**Source:** {source}",
|
|
f"**File:** {affected_file}:{affected_line}",
|
|
f"**Git:** {git_ctx.get('branch', '?')} @ {git_ctx.get('commit', '?')}",
|
|
f"**Time:** {datetime.now(UTC).isoformat()}",
|
|
f"**Hash:** {error_hash}",
|
|
]
|
|
|
|
if context:
|
|
ctx_str = ", ".join(f"{k}={v}" for k, v in context.items())
|
|
description_parts.append(f"**Context:** {ctx_str}")
|
|
|
|
description_parts.append(f"\n**Stack Trace:**\n```\n{tb_str[:2000]}\n```")
|
|
|
|
task = create_task(
|
|
title=title,
|
|
description="\n".join(description_parts),
|
|
assigned_to="default",
|
|
created_by="system",
|
|
priority="normal",
|
|
requires_approval=False,
|
|
auto_approve=True,
|
|
task_type="bug_report",
|
|
)
|
|
|
|
try:
|
|
from swarm.event_log import EventType, log_event
|
|
|
|
log_event(
|
|
EventType.BUG_REPORT_CREATED,
|
|
source=source,
|
|
task_id=task.id,
|
|
data={
|
|
"error_hash": error_hash,
|
|
"title": title[:100],
|
|
},
|
|
)
|
|
except Exception as log_exc:
|
|
logger.warning("Bug report log error: %s", log_exc)
|
|
|
|
return task.id
|
|
except Exception as task_exc:
|
|
logger.debug("Failed to create bug report task: %s", task_exc)
|
|
return None
|
|
|
|
|
|
def _send_error_notification(exc: Exception, source: str) -> None:
|
|
"""Push a notification about the captured error (best-effort)."""
|
|
try:
|
|
from infrastructure.notifications.push import notifier
|
|
|
|
notifier.notify(
|
|
title="Bug Report Filed",
|
|
message=f"{type(exc).__name__} in {source}: {str(exc)[:80]}",
|
|
category="system",
|
|
)
|
|
except Exception as notify_exc:
|
|
logger.warning("Bug report notification error: %s", notify_exc)
|
|
|
|
|
|
def _record_to_session(exc: Exception, source: str) -> None:
|
|
"""Forward the error to the registered session recorder (best-effort)."""
|
|
if _error_recorder is not None:
|
|
try:
|
|
_error_recorder(
|
|
error=f"{type(exc).__name__}: {str(exc)}",
|
|
context=source,
|
|
)
|
|
except Exception as log_exc:
|
|
logger.warning("Bug report session logging error: %s", log_exc)
|
|
|
|
|
|
def capture_error(
|
|
exc: Exception,
|
|
source: str = "unknown",
|
|
context: dict | None = None,
|
|
) -> str | None:
|
|
"""Capture an error and optionally create a bug report.
|
|
|
|
Args:
|
|
exc: The exception to capture
|
|
source: Module/component where the error occurred
|
|
context: Optional dict of extra context (request path, etc.)
|
|
|
|
Returns:
|
|
Task ID of the created bug report, or None if deduplicated/disabled
|
|
"""
|
|
from config import settings
|
|
|
|
if not settings.error_feedback_enabled:
|
|
return None
|
|
|
|
error_hash = _stack_hash(exc)
|
|
|
|
if _is_duplicate(error_hash):
|
|
logger.debug("Duplicate error suppressed: %s (hash=%s)", exc, error_hash)
|
|
return None
|
|
|
|
tb_str = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
|
affected_file, affected_line = _extract_origin(exc)
|
|
git_ctx = _get_git_context()
|
|
|
|
_log_error_event(exc, source, error_hash, affected_file, affected_line, git_ctx)
|
|
|
|
task_id = _create_bug_report(
|
|
exc, source, error_hash, affected_file, affected_line, git_ctx, tb_str, context
|
|
)
|
|
|
|
_send_error_notification(exc, source)
|
|
_record_to_session(exc, source)
|
|
|
|
return task_id
|