Source code for annie.core.bugreport
"""Bug reports from the Log tab: what is sent, where to, and how often (service).
The Log tab's **Report** button emails an error to Annie's maintainer. Annie cannot send
mail itself, so the report goes to a form-to-email service (`Web3Forms
<https://web3forms.com>`_) over HTTPS, from the user's browser, where Annie's UI runs.
The service's access key decides the recipient, so no email address is stored here;
the key is public by design (it can only deliver to that one inbox).
Before anything is sent, the user sees the exact text (:func:`build_report`) and may add a
note. Each error can be reported once, and at most :data:`MAX_REPORTS_PER_HOUR` reports
leave one Annie per hour, so a crash loop cannot flood the inbox.
"""
from __future__ import annotations
import os
import platform
import sys
import threading
import time
from collections import deque
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Mapping
from annie.core.logbook import LogEvent
REPORT_SUBJECT = "Annie bug report"
"""The email subject of every report."""
REPORT_URL = "https://api.web3forms.com/submit"
"""Where reports are posted (JSON); the service emails them to the maintainer."""
ACCESS_KEY = "2ac1f9d9-3a01-4c94-a74c-87c623e9717f"
"""The Web3Forms access key bound to the maintainer's inbox (public by design).
It can only deliver to that inbox; if it is ever abused, reset it on web3forms.com and
ship the new one in a release. ``ANNIE_REPORT_KEY`` in the environment overrides it
(e.g. to test with another inbox); an empty key turns reporting off.
"""
MAX_REPORTS_PER_HOUR = 5
"""How many reports one Annie process sends per hour at most."""
_lock = threading.Lock()
_sent: set[int] = set() #: log event ``seq`` numbers reported in this process
_recent: deque[float] = deque() #: when the reports of the last hour were sent
[docs]
def access_key(environ: Mapping[str, str] | None = None, built_in: str | None = None) -> str:
"""The access key in use: ``ANNIE_REPORT_KEY`` if set, else the built-in key.
Args:
environ: The environment to read (default: the process environment).
built_in: The key shipped with this release (default :data:`ACCESS_KEY`).
"""
env = os.environ if environ is None else environ
return env.get("ANNIE_REPORT_KEY", "").strip() or (ACCESS_KEY if built_in is None else built_in)
[docs]
def reporting_available(
environ: Mapping[str, str] | None = None, built_in: str | None = None
) -> bool:
"""Whether reports can be sent (an access key is configured); see :func:`access_key`."""
return bool(access_key(environ, built_in))
[docs]
def build_report(event: LogEvent, note: str, *, version: str) -> str:
"""The plain text that is sent, exactly as the user previews it.
Args:
event: The logged error being reported.
note: What the user was doing (optional; may be empty).
version: The running Annie version.
Returns:
The report: the note, the error with its full details, and where it happened
(Annie, Python and OS versions). Nothing else from the PC is included.
"""
lines = []
if note.strip():
lines += ["What I was doing:", note.strip(), ""]
lines += [
f"Error: {event.message}",
f"Time: {event.time_text}",
f"Annie: {version}",
f"Python: {sys.version.split()[0]}",
f"OS: {platform.platform()}",
"",
"Details:",
event.details or "(none)",
]
return "\n".join(lines)
[docs]
def payload(text: str, key: str | None = None) -> dict[str, str]:
"""The JSON body the form-to-email service expects (``key`` defaults to the key in use)."""
return {
"access_key": access_key() if key is None else key,
"subject": REPORT_SUBJECT,
"from_name": "Annie",
"message": text,
}
[docs]
def already_sent(seq: int) -> bool:
"""Whether the error with log sequence number ``seq`` was reported already."""
with _lock:
return seq in _sent
[docs]
def can_send(now: float | None = None) -> bool:
"""Whether another report fits within :data:`MAX_REPORTS_PER_HOUR`."""
now = time.monotonic() if now is None else now
with _lock:
while _recent and now - _recent[0] >= 3600:
_recent.popleft()
return len(_recent) < MAX_REPORTS_PER_HOUR
[docs]
def mark_sent(seq: int, now: float | None = None) -> None:
"""Remember that the error ``seq`` was reported, for the Sent state and the limit."""
now = time.monotonic() if now is None else now
with _lock:
_sent.add(seq)
_recent.append(now)
[docs]
def reset() -> None:
"""Forget sent reports (for tests)."""
with _lock:
_sent.clear()
_recent.clear()