Spaces:
Sleeping
Sleeping
Download app/services/diagnostics.py from SkillArbitrage/UpworkAutomation: direct link, hf CLI and curl.
- Browser
- Download file 18.1 kB
-
https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/services/diagnostics.py
- Command line
-
hf download hf://spaces/SkillArbitrage/UpworkAutomation/app/services/diagnostics.py
-
curl -L -o diagnostics.py https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/services/diagnostics.py
18.1 kB
| """Durable, shareable error diagnostics. | |
| The session-state usage log (:mod:`app.services.llm_client`) is the right | |
| surface for "what happened during *this* run", but it dies the moment the | |
| browser reloads β which is a user's first instinct when something breaks, | |
| so the evidence is usually gone before they think to report it. This | |
| module keeps a **durable** copy on disk and turns it into a single, | |
| redacted report a user can hand to the app team. | |
| Three responsibilities | |
| ---------------------- | |
| 1. :func:`record_event` β append one call record to a bounded JSONL file | |
| the instant it happens (never at share time). | |
| 2. :func:`build_report` β assemble an envelope (app/runtime versions, | |
| active provider/model, one-way browser id) plus the most recent | |
| events into one JSON-serialisable dict. | |
| 3. :func:`upload_report` β optionally push that report to a private | |
| Supabase bucket so support can pull it by ``report_id``. | |
| What is never written | |
| --------------------- | |
| The same contract :mod:`app.utils.logging_utils` already commits to: | |
| **metadata only**. No dossier text, no screenshot bytes, no job | |
| descriptions, no generated proposals, no API keys. Records arrive here | |
| already sanitised by ``llm_client``; :func:`_scrub` re-applies | |
| :func:`app.utils.redaction.redact` on the way in *and* on the way out | |
| because it is idempotent and the cost of a double-scrub is zero next to | |
| the cost of leaking a key into a file a user emails around. | |
| Shared hosts | |
| ------------ | |
| On the cloud app one server process serves every visitor, so the durable | |
| file holds everyone's calls. There it is never read back into a panel or | |
| a report: :func:`visitor_events` uses only the visitor's own session log. | |
| Fail-safe | |
| --------- | |
| Every public function swallows its own errors and degrades to a no-op or | |
| an empty result. Diagnostics must never be the reason the app breaks β | |
| a logger that crashes the program it is instrumenting is worse than no | |
| logger at all. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| import os | |
| import platform | |
| import secrets | |
| import sys | |
| from datetime import datetime, timezone | |
| from typing import Any, Optional | |
| from app.config import LOG_DIR | |
| from app.utils.redaction import redact | |
| # Report format version β bump when the envelope/event shape changes so a | |
| # support tool reading old reports can tell which schema it is looking at. | |
| # 2: ``browser_id`` (a one-way hash) replaced the raw ``browser_tag``. | |
| SCHEMA_VERSION = 2 | |
| # Salt for the report's browser id. Different from both key_store salts, so | |
| # the id can be turned into neither the storage object id nor the key. | |
| _BROWSER_ID_SALT = b"upwork-strategist::diag-id::v1" | |
| # Where the durable log lives. Sits beside app.log in the same per-user | |
| # state dir (repo root in dev, %APPDATA% when packaged) β see app.paths. | |
| _EVENTS_FILENAME = "diagnostics.jsonl" | |
| # Bounded on-disk history. A long session with retries would otherwise grow | |
| # the file without limit. We append cheaply and only pay the rewrite cost | |
| # when the file crosses the size ceiling. | |
| _MAX_EVENTS_ON_DISK = 500 | |
| _TRIM_WHEN_BYTES_EXCEED = 1_000_000 # ~1 MB | |
| # How many events a generated report carries. Deliberately smaller than the | |
| # on-disk cap: a report is for a human to read and a support engineer to | |
| # scan, not a full archive. | |
| _REPORT_EVENT_LIMIT = 50 | |
| # Supabase bucket for uploaded reports. Separate from the `apikeys` bucket | |
| # used by key_store β different data, different retention, different blast | |
| # radius if either is ever misconfigured. | |
| _BUCKET = "diagnostics" | |
| _TIMEOUT = 8.0 | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| def events_path(): | |
| """Absolute path to the durable JSONL event log.""" | |
| return LOG_DIR / _EVENTS_FILENAME | |
| # --------------------------------------------------------------------------- | |
| # Scrubbing | |
| # --------------------------------------------------------------------------- | |
| def _scrub(value: Any) -> Any: | |
| """Recursively redact secrets from any JSON-ish value. | |
| ``redact`` is idempotent, so applying it to already-clean records is | |
| free insurance rather than duplicated work. | |
| """ | |
| if isinstance(value, str): | |
| return redact(value) | |
| if isinstance(value, dict): | |
| return {k: _scrub(v) for k, v in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [_scrub(v) for v in value] | |
| return value | |
| # --------------------------------------------------------------------------- | |
| # Recording | |
| # --------------------------------------------------------------------------- | |
| def record_event(entry: dict) -> None: | |
| """Append one call record to the durable log. Never raises. | |
| Called from ``llm_client._finalize`` for *every* call β success and | |
| failure alike β so a report can show what worked right up to the point | |
| something didn't. | |
| """ | |
| if not isinstance(entry, dict): | |
| return | |
| try: | |
| LOG_DIR.mkdir(parents=True, exist_ok=True) | |
| path = events_path() | |
| line = json.dumps(_scrub(entry), ensure_ascii=False, default=str) | |
| with open(path, "a", encoding="utf-8") as fh: | |
| fh.write(line + "\n") | |
| _trim_if_large(path) | |
| except Exception: # noqa: BLE001 - diagnostics must never break the app | |
| return | |
| def _trim_if_large(path) -> None: | |
| """Keep the newest ``_MAX_EVENTS_ON_DISK`` lines once the file is big. | |
| Checking size first keeps the common append path O(1); the rewrite only | |
| happens on the rare crossing of the ceiling. | |
| """ | |
| try: | |
| if path.stat().st_size <= _TRIM_WHEN_BYTES_EXCEED: | |
| return | |
| with open(path, "r", encoding="utf-8", errors="replace") as fh: | |
| lines = fh.readlines() | |
| keep = lines[-_MAX_EVENTS_ON_DISK:] | |
| with open(path, "w", encoding="utf-8") as fh: | |
| fh.writelines(keep) | |
| except Exception: # noqa: BLE001 | |
| return | |
| def load_events(limit: int = _REPORT_EVENT_LIMIT) -> list[dict]: | |
| """Return the most recent ``limit`` events, oldest first. Never raises. | |
| Malformed lines (a half-written record from a hard kill) are skipped | |
| rather than failing the whole read. | |
| """ | |
| try: | |
| path = events_path() | |
| if not path.exists(): | |
| return [] | |
| with open(path, "r", encoding="utf-8", errors="replace") as fh: | |
| lines = fh.readlines() | |
| except Exception: # noqa: BLE001 | |
| return [] | |
| out: list[dict] = [] | |
| for raw in lines[-max(1, limit):]: | |
| raw = raw.strip() | |
| if not raw: | |
| continue | |
| try: | |
| parsed = json.loads(raw) | |
| except ValueError: | |
| continue | |
| if isinstance(parsed, dict): | |
| out.append(parsed) | |
| return out | |
| def clear_events() -> bool: | |
| """Delete the durable log. Returns True when it is gone afterwards.""" | |
| try: | |
| path = events_path() | |
| if path.exists(): | |
| path.unlink() | |
| return True | |
| except Exception: # noqa: BLE001 | |
| return False | |
| def _is_shared_host() -> bool: | |
| """True when one server process serves many visitors (the cloud app). | |
| Same signal as ``dossier_screen.is_cloud_mode``: Hugging Face's | |
| ``SPACE_ID`` or the Dockerfile's ``UPWORK_CLOUD``. | |
| """ | |
| return bool(os.getenv("SPACE_ID") or os.getenv("UPWORK_CLOUD")) | |
| def visitor_events( | |
| session_events: Optional[list[dict]] = None, | |
| *, | |
| limit: int = _REPORT_EVENT_LIMIT, | |
| ) -> list[dict]: | |
| """The current visitor's most recent ``limit`` events, oldest first. | |
| On a single-user install the durable log wins: it survives the page | |
| reload that usually follows a failure and spans earlier sessions, and | |
| the session log only covers a blocked disk write (a read-only volume, | |
| a locked file). On a shared host the durable log mixes every visitor's | |
| calls, so only this session's own log is used β another visitor's | |
| failures are not this visitor's problem, and their model names and | |
| request ids are not this visitor's to see or share. | |
| """ | |
| own = list(session_events or [])[-max(1, limit):] | |
| if _is_shared_host(): | |
| return own | |
| return load_events(limit=limit) or own | |
| # --------------------------------------------------------------------------- | |
| # Report assembly | |
| # --------------------------------------------------------------------------- | |
| def new_report_id() -> str: | |
| """Short, readable id a user can read aloud to support. | |
| Format ``UPS-<YYYYMMDD>-<6 hex>`` β date-prefixed so support can find | |
| the right day's uploads without opening every object. | |
| """ | |
| stamp = datetime.now(timezone.utc).strftime("%Y%m%d") | |
| return f"UPS-{stamp}-{secrets.token_hex(3).upper()}" | |
| def _package_version(name: str) -> Optional[str]: | |
| try: | |
| from importlib.metadata import version | |
| return version(name) | |
| except Exception: # noqa: BLE001 - package absent or metadata unreadable | |
| return None | |
| def _app_revision() -> Optional[str]: | |
| """Best-effort build identifier. | |
| Prefers an explicit ``APP_REVISION`` env var (which a deploy can set to | |
| the commit SHA); falls back to reading ``.git/HEAD`` in a dev checkout. | |
| Returns None when neither is available β a packaged build with no env | |
| var simply reports no revision rather than guessing. | |
| """ | |
| explicit = (os.getenv("APP_REVISION") or "").strip() | |
| if explicit: | |
| return explicit[:40] | |
| try: | |
| from app.paths import resource_base | |
| head = resource_base() / ".git" / "HEAD" | |
| if not head.exists(): | |
| return None | |
| ref = head.read_text(encoding="utf-8").strip() | |
| if ref.startswith("ref:"): | |
| target = resource_base() / ".git" / ref.split(" ", 1)[1].strip() | |
| if target.exists(): | |
| return target.read_text(encoding="utf-8").strip()[:40] | |
| return None | |
| return ref[:40] | |
| except Exception: # noqa: BLE001 | |
| return None | |
| def _browser_id() -> Optional[str]: | |
| """Anonymous, stable per-browser id β lets support correlate repeat | |
| reports from one user without any login. Never identifies a person. | |
| A salted one-way hash of the browser tag, never the tag itself: | |
| :mod:`app.services.key_store` derives both the storage object id and | |
| the encryption key from the tag alone, so a report carrying the tag | |
| would unlock the visitor's saved API key for anyone who reads it. | |
| """ | |
| try: | |
| from app.services import browser_tag | |
| tag = browser_tag.get_or_create_tag() | |
| if not isinstance(tag, str) or not tag: | |
| return None | |
| return hashlib.sha256(_BROWSER_ID_SALT + tag.encode("utf-8")).hexdigest()[:12] | |
| except Exception: # noqa: BLE001 | |
| return None | |
| def _active_config() -> dict: | |
| """Provider/model context for the report. Never includes the key.""" | |
| try: | |
| from app.config import get_settings | |
| settings = get_settings() | |
| return { | |
| "provider": getattr(settings, "active_provider", None) | |
| or getattr(settings, "llm_provider", None), | |
| "model": getattr(settings, "active_model", None), | |
| "vision_model": getattr(settings, "active_vision_model", None), | |
| "has_api_key": bool(getattr(settings, "has_api_key", False)), | |
| } | |
| except Exception: # noqa: BLE001 | |
| return {} | |
| def _environment() -> dict: | |
| try: | |
| packaged = False | |
| try: | |
| from app.paths import is_packaged | |
| packaged = bool(is_packaged()) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| return { | |
| "python": sys.version.split()[0], | |
| "platform": platform.platform(), | |
| "packaged": packaged, | |
| "streamlit": _package_version("streamlit"), | |
| "anthropic_sdk": _package_version("anthropic"), | |
| "openai_sdk": _package_version("openai"), | |
| "google_generativeai_sdk": _package_version("google-generativeai"), | |
| } | |
| except Exception: # noqa: BLE001 | |
| return {} | |
| def build_report( | |
| *, | |
| report_id: Optional[str] = None, | |
| limit: int = _REPORT_EVENT_LIMIT, | |
| session_events: Optional[list[dict]] = None, | |
| ) -> dict: | |
| """Assemble the full, redacted diagnostics report. | |
| ``session_events`` lets a caller pass the in-memory session log so a | |
| report is complete even if the disk write was blocked (a read-only | |
| volume, a locked file). Disk events win when both are present because | |
| they span earlier sessions too β except on a shared host, where only | |
| the session's own events are used (see :func:`visitor_events`). | |
| """ | |
| events = visitor_events(session_events, limit=limit) | |
| report = { | |
| "report_id": report_id or new_report_id(), | |
| "schema_version": SCHEMA_VERSION, | |
| "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "app_revision": _app_revision(), | |
| "browser_id": _browser_id(), | |
| "config": _active_config(), | |
| "environment": _environment(), | |
| "event_count": len(events), | |
| "events": events, | |
| "contains": ( | |
| "Metadata only β no API key, dossier text, screenshots, job " | |
| "descriptions, or generated proposals." | |
| ), | |
| } | |
| return _scrub(report) | |
| def report_to_json(report: dict) -> str: | |
| """Pretty-printed JSON for download / clipboard. Never raises.""" | |
| try: | |
| return json.dumps(report, indent=2, ensure_ascii=False, default=str) | |
| except Exception: # noqa: BLE001 | |
| return json.dumps({"error": "report serialization failed"}, indent=2) | |
| def error_events(events: list[dict]) -> list[dict]: | |
| """Filter ``events`` down to the ones that represent a real failure. | |
| ``local_placeholder`` is excluded: it is a deliberate design choice | |
| (a stage that ran without the API), not an error. | |
| """ | |
| ok_statuses = {"ok", "local_placeholder"} | |
| return [ | |
| e | |
| for e in events | |
| if isinstance(e, dict) and str(e.get("status") or "") not in ok_statuses | |
| ] | |
| def unresolved_errors(events: list[dict]) -> list[dict]: | |
| """Failures that occurred *after* the last successful API call. | |
| The durable log deliberately outlives the session, which creates a trap | |
| the session-only log never had: a user who fixes a bad key would keep | |
| seeing yesterday's failure forever, and a permanent warning is one | |
| users learn to ignore. A successful call is the app's own evidence that | |
| whatever was broken now works, so anything before it is history rather | |
| than a live problem. | |
| Position is used rather than timestamps because ``events`` is already | |
| append-ordered β no parsing, and no dependence on clock consistency | |
| across sessions. | |
| """ | |
| last_ok = -1 | |
| for idx, event in enumerate(events): | |
| if isinstance(event, dict) and str(event.get("status") or "") == "ok": | |
| last_ok = idx | |
| return error_events(events[last_ok + 1:]) | |
| # --------------------------------------------------------------------------- | |
| # Optional upload (same Supabase project as key_store, different bucket) | |
| # --------------------------------------------------------------------------- | |
| def _base_url() -> str: | |
| return (os.getenv("SUPABASE_URL") or "").rstrip("/") | |
| def _service_key() -> str: | |
| return os.getenv("SUPABASE_SERVICE_KEY") or "" | |
| def is_upload_configured() -> bool: | |
| """True only when Supabase credentials are present in the environment.""" | |
| return bool(_base_url() and _service_key()) | |
| def _auth_headers(extra: Optional[dict] = None) -> dict: | |
| key = _service_key() | |
| headers = {"Authorization": f"Bearer {key}", "apikey": key} | |
| if extra: | |
| headers.update(extra) | |
| return headers | |
| def _http(method: str, url: str, headers: dict, content: Optional[bytes] = None): | |
| """One HTTP request β ``(status_code, body_bytes)``. | |
| Isolated so tests can replace it with an in-memory fake, mirroring | |
| :mod:`app.services.key_store`. | |
| """ | |
| import httpx | |
| with httpx.Client(timeout=_TIMEOUT) as client: | |
| resp = client.request(method, url, headers=headers, content=content) | |
| return resp.status_code, resp.content | |
| def _ensure_bucket() -> None: | |
| """Create the private bucket if missing. Idempotent; errors ignored.""" | |
| url = f"{_base_url()}/storage/v1/bucket" | |
| body = json.dumps({"id": _BUCKET, "name": _BUCKET, "public": False}).encode() | |
| try: | |
| _http("POST", url, _auth_headers({"Content-Type": "application/json"}), body) | |
| except Exception: # noqa: BLE001 - best-effort; upload surfaces real failure | |
| pass | |
| def upload_report(report: dict) -> bool: | |
| """Upload ``report`` to the private diagnostics bucket. Never raises. | |
| Returns False (rather than raising) when Supabase is not configured or | |
| the network call fails, so the UI can fall back to "download and email | |
| it instead" without any error handling of its own. | |
| """ | |
| if not is_upload_configured() or not isinstance(report, dict): | |
| return False | |
| try: | |
| _ensure_bucket() | |
| report_id = str(report.get("report_id") or new_report_id()) | |
| # Safe object name: the id is generated by us, but never trust a | |
| # value straight into a URL path. | |
| safe_id = "".join(ch for ch in report_id if ch.isalnum() or ch in "-_") | |
| payload = report_to_json(report).encode("utf-8") | |
| url = f"{_base_url()}/storage/v1/object/{_BUCKET}/{safe_id}.json" | |
| status, _ = _http( | |
| "POST", | |
| url, | |
| _auth_headers({"Content-Type": "application/json", "x-upsert": "true"}), | |
| payload, | |
| ) | |
| return 200 <= status < 300 | |
| except Exception: # noqa: BLE001 - fail-safe: never break the UI | |
| return False | |
| __all__ = [ | |
| "SCHEMA_VERSION", | |
| "record_event", | |
| "load_events", | |
| "clear_events", | |
| "events_path", | |
| "visitor_events", | |
| "build_report", | |
| "report_to_json", | |
| "error_events", | |
| "new_report_id", | |
| "is_upload_configured", | |
| "upload_report", | |
| ] | |