Files
ServerKit/backend/app/api/support_bundle.py
Juan DenisandClaude Opus 5 a7d5a0b919 fix(api): a crash the route caught must still reach /monitoring/errors
Plan 76 milestone B, drift/bug row 4. 43 API handlers answered HTTP 500 from
their own `except Exception`, so the global 500 handler never ran — and that
handler was the ONLY caller of error_log_service.record_error(source='backend').
Every one of those crashes was absent from /monitoring/errors, and the page read
empty precisely because someone had "handled" them.

35 of the 43 also returned str(exc) to the caller. Two returned the full
traceback (templates.py, which also print()ed it), and several logged with a
locally-minted uuid4 'ref' that correlated with nothing but a log line.

The recording side of the 500 handler moves to app/error_reporting.py, so it is
importable rather than reachable only by not-catching:

    record_unexpected(exc)   -> logs, rolls the session back, records, returns
                                the request id
    unexpected_response(exc) -> the above plus the documented crash body

The 500 handler now calls the same pair, so there is one recorder rather than
one recorder and 43 routes that quietly opted out of it.

Caller-visible change on those endpoints: the body is now the documented
{'error': 'Internal server error', 'status': 500, 'code': 'internal_error',
'request_id': ...} instead of the exception text. That is the point — an
unexpected exception's text is written for whoever reads the log and routinely
carries connection strings, absolute paths, and SQL. The request id is the part
a caller can act on, and it matches the row in the error log. No frontend code
read the 'ref' or 'trace' keys this drops (checked).

Two sites keep their own wording and call record_unexpected instead: sso.py's
login failures should not read as a generic crash on the login page.
docker.py's bulk endpoint keeps its per-item `results` — the caller's half-done
work survives, only the exception text is dropped.

Ratchet: tests/unreported_crash_census.py + its test, held at **0**, not a
legacy baseline to shrink. It counts blanket handlers that answer 500 without
calling either reporter, and is scoped to app/api — a service catching an
exception and returning a value is making a domain decision, not declaring an
HTTP crash. Verified non-vacuous: an injected handler fails it, and it passes
again on revert.

Measured and deliberately still open: 56 API handlers swallow a crash and
answer 200. A failure reported as success is worse, but converging it is
milestone C's response-envelope work, not this row.

Gate: tests/test_unexpected_error_reporting.py (11, including the two that pin
why the old shape lost the crash), test_unreported_crash_ratchet.py (9), and
the 66 test files covering every touched module: 1105 passed, 7 skipped, 1
pre-existing Windows-only failure (test_fleet_doctor's socket.gaierror).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:38:20 -04:00

39 lines
1.2 KiB
Python

"""Support bundle API: build and download a scrubbed diagnostic zip.
Registration (app/__init__.py):
from app.api.support_bundle import support_bundle_bp
app.register_blueprint(support_bundle_bp, url_prefix='/api/v1/support-bundle')
"""
import os
from datetime import datetime
from flask import Blueprint, jsonify, send_file
from app.middleware.rbac import admin_required
from app.services import support_bundle_service
from app.error_reporting import unexpected_response
support_bundle_bp = Blueprint('support_bundle', __name__)
@support_bundle_bp.route('', methods=['POST'])
@admin_required
def build_support_bundle():
"""Build a diagnostic support bundle and return it as a zip download."""
try:
path = support_bundle_service.build()
except Exception as exc: # noqa: BLE001 - reported, not swallowed
return unexpected_response(exc)
if not os.path.isfile(path):
return jsonify({'error': 'Support bundle was not created'}), 500
filename = f"serverkit-support-{datetime.utcnow().strftime('%Y-%m-%d')}.zip"
return send_file(
path,
mimetype='application/zip',
as_attachment=True,
download_name=filename,
max_age=0,
)