Files
ServerKit/backend/app/api/error_logs.py
T
Juan Denis 55d8fb1e44 Read route bodies through json_object()
328 more routes used a JSON body as a dict straight from
request.get_json(), so a body of null, [] or a bare string crashed them
into a 500. Bare reads become json_object(); the
'get_json(silent=True) or {}' reads of action routes become
json_object(optional=True), which still treats a missing body as {}.
Routes that take arrays or pass the body through untouched are left
alone. The full backend suite shows no new failures.

Also carries three files' bare section citations (apps, backups,
notifications) and the extended citation guard, which needed them.
2026-10-10 20:53:43 -04:00

150 lines
5.0 KiB
Python

"""Centralized error log endpoints: admin browsing + frontend client ingestion."""
import time
from collections import defaultdict, deque
from flask import Blueprint, jsonify, request
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
from app.api._query import PageQuery
from app.middleware.rbac import admin_required
from app.services import error_log_service
from app.api._request import json_object
error_logs_bp = Blueprint('error_logs', __name__)
# Simple in-memory rate limit for the client ingestion endpoint:
# max CLIENT_RATE_LIMIT requests per CLIENT_RATE_WINDOW seconds per client IP.
CLIENT_RATE_LIMIT = 10
CLIENT_RATE_WINDOW = 60
_client_hits = defaultdict(deque)
def _client_rate_limited(ip):
now = time.time()
hits = _client_hits[ip]
while hits and now - hits[0] > CLIENT_RATE_WINDOW:
hits.popleft()
if len(hits) >= CLIENT_RATE_LIMIT:
return True
hits.append(now)
return False
def _optional_str(data, key, max_len):
"""Validate an optional string field; raises ValueError(key) when invalid."""
value = data.get(key)
if value is None:
return None
if not isinstance(value, str) or len(value) > max_len:
raise ValueError(key)
return value
@error_logs_bp.route('', methods=['GET'])
@admin_required
def list_error_logs():
"""List error log entries with optional filters."""
resolved_arg = request.args.get('resolved')
resolved = None
if resolved_arg is not None:
resolved = resolved_arg.lower() in ('1', 'true', 'yes')
paging = PageQuery.from_request(request, default_per_page=25)
result = error_log_service.list_errors(
source=request.args.get('source'),
resolved=resolved,
search=request.args.get('search'),
page=paging.page,
per_page=paging.per_page,
)
return jsonify(result), 200
@error_logs_bp.route('/stats', methods=['GET'])
@admin_required
def error_log_stats():
"""Totals for the admin overview."""
return jsonify(error_log_service.get_stats()), 200
@error_logs_bp.route('/<int:error_id>', methods=['GET'])
@admin_required
def get_error_log(error_id):
"""Get a single error log entry."""
entry = error_log_service.get_error(error_id)
if not entry:
return jsonify({'error': 'Error log not found'}), 404
return jsonify(entry.to_dict()), 200
@error_logs_bp.route('/<int:error_id>/resolve', methods=['POST'])
@admin_required
def resolve_error_log(error_id):
"""Set the resolved flag: body {"resolved": true|false}."""
data = json_object(optional=True)
if not isinstance(data.get('resolved'), bool):
return jsonify({'error': 'resolved (boolean) is required'}), 400
entry = error_log_service.set_resolved(error_id, data['resolved'])
if not entry:
return jsonify({'error': 'Error log not found'}), 404
return jsonify(entry.to_dict()), 200
@error_logs_bp.route('/<int:error_id>', methods=['DELETE'])
@admin_required
def delete_error_log(error_id):
"""Delete an error log entry."""
if not error_log_service.delete_error(error_id):
return jsonify({'error': 'Error log not found'}), 404
return jsonify({'success': True}), 200
@error_logs_bp.route('/client', methods=['POST'])
def client_error_log():
"""Frontend error ingestion. Auth optional: a valid JWT attaches user_id."""
ip = request.remote_addr or 'unknown'
if _client_rate_limited(ip):
return jsonify({'error': 'Rate limit exceeded'}), 429
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON body is required'}), 400
message = data.get('message')
if not isinstance(message, str) or not message.strip():
return jsonify({'error': 'message is required'}), 400
if len(message) > error_log_service.MAX_MESSAGE_LEN:
return jsonify({'error': 'message is too long'}), 400
try:
exception_type = _optional_str(data, 'exception_type', 200)
tb = _optional_str(data, 'traceback', error_log_service.MAX_TRACEBACK_LEN)
endpoint = _optional_str(data, 'endpoint', 255)
level = _optional_str(data, 'level', 20)
except ValueError as exc:
return jsonify({'error': f'invalid field: {exc}'}), 400
context = data.get('context')
if context is not None and not isinstance(context, dict):
return jsonify({'error': 'invalid field: context'}), 400
user_id = None
try:
verify_jwt_in_request(optional=True)
user_id = get_jwt_identity()
except Exception: # noqa: BLE001 - anonymous client reports are allowed
user_id = None
entry, deduplicated = error_log_service.record_error(
source='frontend',
message=message,
exception_type=exception_type,
traceback=tb,
endpoint=endpoint,
user_id=user_id,
context=context,
level=level or 'error',
)
if entry is None:
return jsonify({'error': 'Failed to record error'}), 500
return jsonify({'id': entry.id, 'deduplicated': deduplicated}), 201