diff --git a/app/__init__.py b/app/__init__.py index 9ff7e26..b02c291 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -8,38 +8,54 @@ from app.utils.formatters import format_currency, format_percent, format_large_n def _setup_logging(app): """ - Ensure app.* module loggers emit at INFO level. + Configure the 'app' namespace logger to write to a rotating file AND stderr. - Under Gunicorn the root logger already has handlers (pointing to Gunicorn's - error log / stderr) but its level is WARNING, so INFO records are dropped - before they reach any handler. We fix that by: - 1. Reusing Gunicorn's handlers on the 'app' namespace logger so records - go to the same destination as Gunicorn's own logs. - 2. Falling back to a plain stderr StreamHandler in dev / direct-run mode. + Uses a pipe-delimited format so the log viewer can parse each field easily: + 2026-06-01 12:00:00|INFO|app.services.teller_service|message text + + The file path comes from LOG_FILE_PATH config (defaults to logs/app.log + next to the project root). The directory is created automatically. """ import sys + from logging.handlers import RotatingFileHandler app_log = logging.getLogger('app') - app_log.setLevel(logging.INFO) - if app_log.handlers: - return # already configured (e.g. running tests) + return # already configured (avoids duplicate handlers on reload) - gunicorn_handlers = logging.getLogger('gunicorn.error').handlers - if gunicorn_handlers: - # Running under Gunicorn — attach its handlers so our logs land in the - # same error log file that Gunicorn writes to. - for h in gunicorn_handlers: - app_log.addHandler(h) - else: - # Dev / direct python run — stderr is fine. - handler = logging.StreamHandler(sys.stderr) - handler.setFormatter(logging.Formatter( - '%(asctime)s %(levelname)s %(name)s: %(message)s' - )) - app_log.addHandler(handler) + app_log.setLevel(logging.INFO) + app_log.propagate = False # don't double-emit through the root logger - app_log.propagate = False # avoid double-printing via root + fmt = logging.Formatter( + '%(asctime)s|%(levelname)s|%(name)s|%(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + ) + + # --- rotating file handler (primary — always on) --- + log_file = app.config.get('LOG_FILE_PATH', '') + if log_file: + try: + os.makedirs(os.path.dirname(log_file), exist_ok=True) + fh = RotatingFileHandler( + log_file, + maxBytes=app.config.get('LOG_MAX_BYTES', 10 * 1024 * 1024), + backupCount=app.config.get('LOG_BACKUP_COUNT', 5), + encoding='utf-8', + ) + fh.setLevel(logging.INFO) + fh.setFormatter(fmt) + app_log.addHandler(fh) + except Exception as exc: + # Can't open file (permissions, bad path) — fall through to stderr only + print(f'[pfm] WARNING: could not open log file {log_file!r}: {exc}', file=sys.stderr) + + # --- stderr handler (secondary — also always on so Gunicorn captures it) --- + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(logging.INFO) + sh.setFormatter(fmt) + app_log.addHandler(sh) + + app_log.info('Logging initialised — file=%s', log_file or '(none)') def create_app(config_name=None): @@ -73,6 +89,7 @@ def create_app(config_name=None): from app.routes.reports import reports_bp from app.routes.settings import settings_bp from app.routes.teller import teller_bp + from app.routes.logs import logs_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -86,6 +103,7 @@ def create_app(config_name=None): app.register_blueprint(reports_bp) app.register_blueprint(settings_bp) app.register_blueprint(teller_bp) + app.register_blueprint(logs_bp) with app.app_context(): from app.models import ( diff --git a/app/config.py b/app/config.py index bc4d62c..d6ef60e 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,14 @@ class Config: TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem') TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '') + # Application log file (rotating, shared by all app.* loggers) + LOG_FILE_PATH = os.environ.get( + 'LOG_FILE_PATH', + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs', 'app.log') + ) + LOG_MAX_BYTES = int(os.environ.get('LOG_MAX_BYTES', 10 * 1024 * 1024)) # 10 MB + LOG_BACKUP_COUNT = int(os.environ.get('LOG_BACKUP_COUNT', 5)) + class DevelopmentConfig(Config): DEBUG = True diff --git a/app/routes/logs.py b/app/routes/logs.py new file mode 100644 index 0000000..a1e232a --- /dev/null +++ b/app/routes/logs.py @@ -0,0 +1,125 @@ +import os +import logging +from flask import (Blueprint, render_template, request, jsonify, + current_app, send_file, abort) +from flask_login import login_required + +logs_bp = Blueprint('logs', __name__, url_prefix='/logs') +log = logging.getLogger(__name__) + + +def _tail_lines(filepath, n=2000): + """Return the last n lines from a file without loading it all into memory.""" + try: + with open(filepath, 'rb') as f: + f.seek(0, 2) + size = f.tell() + if size == 0: + return [] + buf = bytearray() + pos = size + while len(buf) < 256 * 1024 and pos > 0: # max 256 KB scan + chunk = min(65536, pos) + pos -= chunk + f.seek(pos) + buf = bytearray(f.read(chunk)) + buf + lines = buf.decode('utf-8', errors='replace').splitlines() + return lines[-n:] + except FileNotFoundError: + return [] + except Exception as exc: + log.error('log viewer _tail_lines error: %s', exc) + return [] + + +def _parse_line(raw): + """Parse pipe-delimited log line: timestamp|level|name|message""" + parts = raw.split('|', 3) + if len(parts) == 4: + return { + 'ts': parts[0], + 'level': parts[1], + 'name': parts[2], + 'message': parts[3], + } + # Fallback for lines that don't match the format (e.g. tracebacks) + return {'ts': '', 'level': 'RAW', 'name': '', 'message': raw} + + +@logs_bp.route('/') +@login_required +def index(): + log_file = current_app.config.get('LOG_FILE_PATH', '') + file_exists = bool(log_file) and os.path.isfile(log_file) + file_size = os.path.getsize(log_file) if file_exists else 0 + return render_template('logs/index.html', + log_file=log_file, + file_exists=file_exists, + file_size=file_size) + + +@logs_bp.route('/api') +@login_required +def api(): + """JSON endpoint used by the log viewer to fetch and filter entries.""" + log_file = current_app.config.get('LOG_FILE_PATH', '') + if not log_file: + return jsonify({'entries': [], 'error': 'LOG_FILE_PATH not configured'}) + + level_filter = request.args.get('level', 'ALL').upper() + search = request.args.get('search', '').lower() + limit = min(int(request.args.get('limit', 200)), 2000) + module = request.args.get('module', '').lower() + + raw_lines = _tail_lines(log_file, n=5000) + entries = [_parse_line(l) for l in raw_lines if l.strip()] + + if level_filter not in ('ALL', ''): + entries = [e for e in entries if e['level'] == level_filter] + + if module: + entries = [e for e in entries if module in e['name'].lower()] + + if search: + entries = [e for e in entries + if search in e['message'].lower() or search in e['name'].lower()] + + # Most-recent first, capped at limit + entries = list(reversed(entries))[:limit] + + # Count per level across ALL unfiltered lines (for the stats bar) + all_entries = [_parse_line(l) for l in raw_lines if l.strip()] + counts = {} + for e in all_entries: + counts[e['level']] = counts.get(e['level'], 0) + 1 + + return jsonify({ + 'entries': entries, + 'counts': counts, + 'total_raw': len(raw_lines), + 'log_file': log_file, + }) + + +@logs_bp.route('/download') +@login_required +def download(): + log_file = current_app.config.get('LOG_FILE_PATH', '') + if not log_file or not os.path.isfile(log_file): + abort(404) + return send_file(log_file, as_attachment=True, download_name='pfm-app.log') + + +@logs_bp.route('/clear', methods=['POST']) +@login_required +def clear(): + log_file = current_app.config.get('LOG_FILE_PATH', '') + if not log_file: + return jsonify({'error': 'LOG_FILE_PATH not configured'}), 400 + try: + with open(log_file, 'w', encoding='utf-8'): + pass + log.info('Log file cleared by user') + return jsonify({'status': 'ok'}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 diff --git a/app/templates/base.html b/app/templates/base.html index 0777f65..44d318d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,6 +3,7 @@
+| Timestamp | +Level | +Module | +Message | +
|---|---|---|---|
| + + Loading… + | |||