From dfdc8ec18b1367ab3b55df647c9cd2a267b67986 Mon Sep 17 00:00:00 2001 From: Vishal Anand Date: Sat, 4 Jul 2026 17:56:07 +0530 Subject: [PATCH 1/5] test: define production diagnostics contract --- tests/test_diagnostics.py | 401 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 tests/test_diagnostics.py diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..61d32ef --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,401 @@ +""" +Tests for production diagnostics and doctor readiness checks. +""" +from unittest.mock import Mock, patch + +from django.test import TestCase +from django.test.utils import override_settings + + +class DiagnosticsSettingsTests(TestCase): + """Diagnostics should report configuration and runtime readiness clearly.""" + + @override_settings(DRF_API_LOGGER_DATABASE=False, DRF_API_LOGGER_SIGNAL=False) + def test_reports_logging_disabled_warning(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF001", + level="warning", + message="DRF API Logger is disabled.", + ) + + @override_settings(DRF_API_LOGGER_DATABASE=False, DRF_API_LOGGER_SIGNAL=True) + def test_signal_only_mode_skips_database_and_worker_checks(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + codes = {result.code for result in results} + + self.assert_result( + results, + code="DRF002", + level="ok", + message="Signal logging is enabled without database logging.", + ) + self.assertFalse(codes.intersection({"DRF101", "DRF102", "DRF103", "DRF104", "DRF105", "DRF106"})) + self.assertFalse(codes.intersection({"DRF201", "DRF202", "DRF203", "DRF204", "DRF205"})) + + @override_settings(DRF_API_LOGGER_DATABASE=True, DRF_API_LOGGER_DEFAULT_DATABASE="missing") + def test_database_alias_must_exist_when_database_logging_enabled(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF101", + level="error", + message='Configured log database alias "missing" is not available.', + ) + + @override_settings(DRF_API_LOGGER_DATABASE=True) + @patch("drf_api_logger.diagnostics._database_table_exists", return_value=True) + @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True) + @patch("drf_api_logger.diagnostics._has_pending_migrations", return_value=False) + def test_database_ready_state_reports_alias_migrations_and_table( + self, + mock_pending, + mock_alias, + mock_table, + ): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result(results, code="DRF102", level="ok", message='Configured log database alias "default" is available.') + self.assert_result(results, code="DRF104", level="ok", message="DRF API Logger migrations are applied.") + self.assert_result(results, code="DRF105", level="ok", message="API log table is available.") + + @override_settings(DRF_API_LOGGER_DATABASE=True) + @patch("drf_api_logger.diagnostics._database_table_exists", return_value=False) + @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True) + @patch("drf_api_logger.diagnostics._has_pending_migrations", return_value=True) + def test_database_not_ready_reports_pending_migrations_and_missing_table( + self, + mock_pending, + mock_alias, + mock_table, + ): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF103", + level="error", + message="DRF API Logger migrations are not fully applied.", + ) + self.assert_result( + results, + code="DRF106", + level="error", + message="API log table is missing.", + ) + + @override_settings(DRF_API_LOGGER_DATABASE=True) + @patch("drf_api_logger.diagnostics._database_table_exists", return_value=True) + @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True) + @patch("drf_api_logger.diagnostics._has_pending_migrations", return_value=False) + def test_reports_missing_background_worker( + self, + mock_pending, + mock_alias, + mock_table, + ): + from drf_api_logger import apps as logger_apps + from drf_api_logger.diagnostics import run_diagnostics + + original_thread = logger_apps.LOGGER_THREAD + logger_apps.LOGGER_THREAD = None + try: + results = run_diagnostics() + finally: + logger_apps.LOGGER_THREAD = original_thread + + self.assert_result( + results, + code="DRF202", + level="warning", + message="Background database worker is not available.", + ) + + @override_settings(DRF_API_LOGGER_DATABASE=True) + @patch("drf_api_logger.diagnostics._database_table_exists", return_value=True) + @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True) + @patch("drf_api_logger.diagnostics._has_pending_migrations", return_value=False) + def test_reports_stopped_background_worker( + self, + mock_pending, + mock_alias, + mock_table, + ): + from drf_api_logger import apps as logger_apps + from drf_api_logger.diagnostics import run_diagnostics + + worker = Mock() + worker.is_alive.return_value = False + worker.get_status.return_value = {"queue_backlog": 0} + + original_thread = logger_apps.LOGGER_THREAD + logger_apps.LOGGER_THREAD = worker + try: + results = run_diagnostics() + finally: + logger_apps.LOGGER_THREAD = original_thread + + self.assert_result( + results, + code="DRF203", + level="error", + message="Background database worker is not running.", + ) + + @override_settings(DRF_API_LOGGER_DATABASE=True) + @patch("drf_api_logger.diagnostics._database_table_exists", return_value=True) + @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True) + @patch("drf_api_logger.diagnostics._has_pending_migrations", return_value=False) + def test_reports_running_background_worker_and_risky_worker_stats( + self, + mock_pending, + mock_alias, + mock_table, + ): + from drf_api_logger import apps as logger_apps + from drf_api_logger.diagnostics import run_diagnostics + + worker = Mock() + worker.is_alive.return_value = True + worker.get_status.return_value = { + "queue_backlog": 251, + "batch_size": 50, + "interval": 10, + "dropped_count": 1, + "inserted_count": 20, + "failed_insert_count": 2, + } + + original_thread = logger_apps.LOGGER_THREAD + logger_apps.LOGGER_THREAD = worker + try: + results = run_diagnostics() + finally: + logger_apps.LOGGER_THREAD = original_thread + + self.assert_result( + results, + code="DRF201", + level="ok", + message="Background database worker is running.", + ) + self.assert_result( + results, + code="DRF204", + level="warning", + message="Background worker has failed insert attempts.", + ) + self.assert_result( + results, + code="DRF205", + level="warning", + message="Background worker queue backlog is high.", + ) + worker.get_status.assert_called_once() + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_LOGGER_QUEUE_MAX_SIZE=0, + DRF_LOGGER_INTERVAL=0, + ) + def test_reports_invalid_queue_settings(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF401", + level="error", + message="DRF_LOGGER_QUEUE_MAX_SIZE must be greater than 0.", + ) + self.assert_result( + results, + code="DRF403", + level="error", + message="DRF_LOGGER_INTERVAL must be greater than 0.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE=-1, + DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE=-1, + ) + def test_warns_about_unbounded_payload_limits(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF301", + level="warning", + message="Request body logging is unbounded.", + ) + self.assert_result( + results, + code="DRF302", + level="warning", + message="Response body logging is unbounded.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE="large", + DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE="larger", + ) + def test_reports_invalid_payload_limit_types(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF303", + level="error", + message="Request body size limit must be an integer.", + ) + self.assert_result( + results, + code="DRF305", + level="error", + message="Response body size limit must be an integer.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_EXCLUDE_KEYS=[], + ) + def test_warns_when_application_specific_masking_keys_are_empty(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF502", + level="warning", + message="No application-specific masking keys are configured.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_EXCLUDE_KEYS="token", + ) + def test_reports_invalid_masking_key_type(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF501", + level="error", + message="DRF_API_LOGGER_EXCLUDE_KEYS must be a list or tuple.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_ENABLE_PROFILING=True, + DRF_API_LOGGER_PROFILING_SAMPLE_RATE=1.0, + ) + def test_warns_when_profiling_every_logged_request(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF603", + level="warning", + message="Profiling is enabled for every logged request.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_ENABLE_PROFILING=True, + DRF_API_LOGGER_PROFILING_SAMPLE_RATE="all", + ) + def test_reports_invalid_profiling_sample_rate(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF602", + level="error", + message="Profiling sample rate must be numeric.", + ) + + @override_settings( + DRF_API_LOGGER_DATABASE=False, + DRF_API_LOGGER_SIGNAL=True, + DRF_API_LOGGER_ENABLE_PROFILING=True, + DRF_API_LOGGER_PROFILING_SAMPLE_RATE=0.25, + ) + def test_reports_limited_profiling_sample_rate_as_ok(self): + from drf_api_logger.diagnostics import run_diagnostics + + results = run_diagnostics() + + self.assert_result( + results, + code="DRF604", + level="ok", + message="Profiling sample rate is limited.", + ) + + def test_result_summary_and_fail_level_helpers(self): + from drf_api_logger.diagnostics import ( + DiagnosticResult, + result_summary, + results_as_dict, + should_fail, + ) + + results = [ + DiagnosticResult("DRF900", "ok", "OK"), + DiagnosticResult("DRF901", "warning", "Warning"), + DiagnosticResult("DRF902", "error", "Error", details={"value": 1}), + ] + + self.assertEqual( + result_summary(results), + {"level": "error", "ok": 1, "warning": 1, "error": 1}, + ) + self.assertEqual(results_as_dict(results)["results"][0]["details"], {}) + self.assertTrue(should_fail(results, "warning")) + self.assertTrue(should_fail(results, "error")) + self.assertFalse(should_fail(results[:2], "error")) + self.assertFalse(should_fail(results, "ok")) + + def assert_result(self, results, code, level, message): + matches = [ + result + for result in results + if result.code == code + and result.level == level + and result.message == message + ] + self.assertTrue(matches, "Missing diagnostic result {}".format(code)) From 15456db9d01053b61c83308c76b64c0c331f0af3 Mon Sep 17 00:00:00 2001 From: Vishal Anand Date: Sat, 4 Jul 2026 17:57:38 +0530 Subject: [PATCH 2/5] feat: add production diagnostics checks --- drf_api_logger/diagnostics.py | 456 ++++++++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 drf_api_logger/diagnostics.py diff --git a/drf_api_logger/diagnostics.py b/drf_api_logger/diagnostics.py new file mode 100644 index 0000000..a839989 --- /dev/null +++ b/drf_api_logger/diagnostics.py @@ -0,0 +1,456 @@ +from dataclasses import asdict, dataclass + +from django.conf import settings +from django.db import connections +from django.db.migrations.executor import MigrationExecutor +from django.db.utils import OperationalError, ProgrammingError + +from drf_api_logger import apps as logger_apps +from drf_api_logger.models import APILogsModel +from drf_api_logger.utils import database_log_enabled, is_api_logger_enabled + + +LEVEL_RANK = {"ok": 0, "warning": 1, "error": 2} + + +@dataclass(frozen=True) +class DiagnosticResult: + code: str + level: str + message: str + hint: str = "" + details: dict | None = None + + def as_dict(self): + data = asdict(self) + if self.details is None: + data["details"] = {} + return data + + +def run_diagnostics(database_alias=None): + results = [] + results.extend(_check_logging_mode()) + results.extend(_check_queue_settings()) + results.extend(_check_payload_limits()) + results.extend(_check_masking_settings()) + results.extend(_check_profiling_settings()) + + if database_log_enabled(): + database = database_alias or getattr( + settings, + "DRF_API_LOGGER_DEFAULT_DATABASE", + "default", + ) + results.extend(_check_database(database)) + results.extend(_check_worker()) + + return results + + +def result_summary(results): + highest = "ok" + for result in results: + if LEVEL_RANK[result.level] > LEVEL_RANK[highest]: + highest = result.level + return { + "level": highest, + "ok": len([result for result in results if result.level == "ok"]), + "warning": len([result for result in results if result.level == "warning"]), + "error": len([result for result in results if result.level == "error"]), + } + + +def results_as_dict(results): + return { + "summary": result_summary(results), + "results": [result.as_dict() for result in results], + } + + +def should_fail(results, fail_level): + if fail_level not in ("warning", "error"): + return False + threshold = LEVEL_RANK[fail_level] + return any(LEVEL_RANK[result.level] >= threshold for result in results) + + +def _check_logging_mode(): + if not is_api_logger_enabled(): + return [ + DiagnosticResult( + code="DRF001", + level="warning", + message="DRF API Logger is disabled.", + hint=( + "Set DRF_API_LOGGER_DATABASE=True for database logging or " + "DRF_API_LOGGER_SIGNAL=True for signal-only logging." + ), + ) + ] + + if database_log_enabled(): + return [ + DiagnosticResult( + code="DRF003", + level="ok", + message="Database logging is enabled.", + ) + ] + + return [ + DiagnosticResult( + code="DRF002", + level="ok", + message="Signal logging is enabled without database logging.", + hint="Database table and worker checks are skipped in signal-only mode.", + ) + ] + + +def _check_database(database): + if not _database_alias_available(database): + return [ + DiagnosticResult( + code="DRF101", + level="error", + message='Configured log database alias "{}" is not available.'.format(database), + hint="Check DATABASES and DRF_API_LOGGER_DEFAULT_DATABASE.", + details={"database": database}, + ) + ] + + results = [ + DiagnosticResult( + code="DRF102", + level="ok", + message='Configured log database alias "{}" is available.'.format(database), + details={"database": database}, + ) + ] + + if _has_pending_migrations(database): + results.append( + DiagnosticResult( + code="DRF103", + level="error", + message="DRF API Logger migrations are not fully applied.", + hint="Run python manage.py migrate drf_api_logger before enabling production database logging.", + details={"database": database}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF104", + level="ok", + message="DRF API Logger migrations are applied.", + details={"database": database}, + ) + ) + + if _database_table_exists(database): + results.append( + DiagnosticResult( + code="DRF105", + level="ok", + message="API log table is available.", + details={"database": database, "table": APILogsModel._meta.db_table}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF106", + level="error", + message="API log table is missing.", + hint="Run migrations on the configured log database before enabling database logging.", + details={"database": database, "table": APILogsModel._meta.db_table}, + ) + ) + + return results + + +def _database_alias_available(database): + return database in connections.databases + + +def _database_table_exists(database): + try: + connection = connections[database] + return APILogsModel._meta.db_table in connection.introspection.table_names() + except (KeyError, OperationalError, ProgrammingError): + return False + + +def _has_pending_migrations(database): + try: + connection = connections[database] + executor = MigrationExecutor(connection) + targets = executor.loader.graph.leaf_nodes("drf_api_logger") + return bool(executor.migration_plan(targets)) + except (KeyError, OperationalError, ProgrammingError, ValueError): + return True + + +def _check_worker(): + worker = logger_apps.LOGGER_THREAD + if worker is None: + return [ + DiagnosticResult( + code="DRF202", + level="warning", + message="Background database worker is not available.", + hint=( + "Confirm drf_api_logger is in INSTALLED_APPS, " + "DRF_API_LOGGER_DATABASE=True, and the app process has started." + ), + ) + ] + + status = worker.get_status() if hasattr(worker, "get_status") else {} + is_alive = worker.is_alive() if hasattr(worker, "is_alive") else False + if not is_alive: + return [ + DiagnosticResult( + code="DRF203", + level="error", + message="Background database worker is not running.", + hint="Restart the application process and check startup logs.", + details=status, + ) + ] + + results = [ + DiagnosticResult( + code="DRF201", + level="ok", + message="Background database worker is running.", + details=status, + ) + ] + + if status.get("failed_insert_count", 0) > 0: + results.append( + DiagnosticResult( + code="DRF204", + level="warning", + message="Background worker has failed insert attempts.", + hint="Check database connectivity, migrations, and log table availability.", + details=status, + ) + ) + + if status.get("queue_backlog", 0) > status.get("batch_size", 50) * 5: + results.append( + DiagnosticResult( + code="DRF205", + level="warning", + message="Background worker queue backlog is high.", + hint="Check database write throughput, batch size, and request volume.", + details=status, + ) + ) + + return results + + +def _check_queue_settings(): + results = [] + batch_size = getattr(settings, "DRF_LOGGER_QUEUE_MAX_SIZE", 50) + interval = getattr(settings, "DRF_LOGGER_INTERVAL", 10) + + if not _is_positive_int(batch_size): + results.append( + DiagnosticResult( + code="DRF401", + level="error", + message="DRF_LOGGER_QUEUE_MAX_SIZE must be greater than 0.", + hint="Set DRF_LOGGER_QUEUE_MAX_SIZE to a positive integer such as 50 or 100.", + details={"value": batch_size}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF402", + level="ok", + message="Queue batch size is valid.", + details={"value": batch_size}, + ) + ) + + if not _is_positive_int(interval): + results.append( + DiagnosticResult( + code="DRF403", + level="error", + message="DRF_LOGGER_INTERVAL must be greater than 0.", + hint="Set DRF_LOGGER_INTERVAL to a positive integer number of seconds.", + details={"value": interval}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF404", + level="ok", + message="Queue flush interval is valid.", + details={"value": interval}, + ) + ) + + return results + + +def _check_payload_limits(): + results = [] + request_limit = getattr(settings, "DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE", 32768) + response_limit = getattr(settings, "DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE", 65536) + + if request_limit == -1: + results.append( + DiagnosticResult( + code="DRF301", + level="warning", + message="Request body logging is unbounded.", + hint="Use a finite DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE in production.", + details={"value": request_limit}, + ) + ) + elif not _is_int(request_limit): + results.append( + DiagnosticResult( + code="DRF303", + level="error", + message="Request body size limit must be an integer.", + hint="Set DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE to bytes or -1.", + details={"type": type(request_limit).__name__}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF304", + level="ok", + message="Request body size limit is bounded.", + details={"value": request_limit}, + ) + ) + + if response_limit == -1: + results.append( + DiagnosticResult( + code="DRF302", + level="warning", + message="Response body logging is unbounded.", + hint="Use a finite DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE in production.", + details={"value": response_limit}, + ) + ) + elif not _is_int(response_limit): + results.append( + DiagnosticResult( + code="DRF305", + level="error", + message="Response body size limit must be an integer.", + hint="Set DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE to bytes or -1.", + details={"type": type(response_limit).__name__}, + ) + ) + else: + results.append( + DiagnosticResult( + code="DRF306", + level="ok", + message="Response body size limit is bounded.", + details={"value": response_limit}, + ) + ) + + return results + + +def _check_masking_settings(): + configured = getattr(settings, "DRF_API_LOGGER_EXCLUDE_KEYS", []) + if configured and not isinstance(configured, (list, tuple)): + return [ + DiagnosticResult( + code="DRF501", + level="error", + message="DRF_API_LOGGER_EXCLUDE_KEYS must be a list or tuple.", + hint="Use a list such as ['password', 'token', 'api_key'].", + details={"type": type(configured).__name__}, + ) + ] + + if not configured: + return [ + DiagnosticResult( + code="DRF502", + level="warning", + message="No application-specific masking keys are configured.", + hint="Add domain-specific secrets to DRF_API_LOGGER_EXCLUDE_KEYS before production use.", + ) + ] + + return [ + DiagnosticResult( + code="DRF503", + level="ok", + message="Application-specific masking keys are configured.", + details={"count": len(configured)}, + ) + ] + + +def _check_profiling_settings(): + if not getattr(settings, "DRF_API_LOGGER_ENABLE_PROFILING", False): + return [ + DiagnosticResult( + code="DRF601", + level="ok", + message="Profiling is disabled.", + ) + ] + + sample_rate = getattr(settings, "DRF_API_LOGGER_PROFILING_SAMPLE_RATE", 1.0) + if not isinstance(sample_rate, (int, float)) or isinstance(sample_rate, bool): + return [ + DiagnosticResult( + code="DRF602", + level="error", + message="Profiling sample rate must be numeric.", + hint="Set DRF_API_LOGGER_PROFILING_SAMPLE_RATE between 0.0 and 1.0.", + details={"type": type(sample_rate).__name__}, + ) + ] + + if float(sample_rate) >= 1.0: + return [ + DiagnosticResult( + code="DRF603", + level="warning", + message="Profiling is enabled for every logged request.", + hint="Use a lower sample rate such as 0.1 on high-traffic production systems.", + details={"value": sample_rate}, + ) + ] + + return [ + DiagnosticResult( + code="DRF604", + level="ok", + message="Profiling sample rate is limited.", + details={"value": sample_rate}, + ) + ] + + +def _is_int(value): + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_positive_int(value): + return _is_int(value) and value > 0 From 0d12b26307fa0ab8c1ebbf94ec37a98cbcb8d5e3 Mon Sep 17 00:00:00 2001 From: Vishal Anand Date: Sat, 4 Jul 2026 17:59:16 +0530 Subject: [PATCH 3/5] feat: expose production diagnostics command --- .../commands/drf_api_logger_doctor.py | 64 +++++++++++++++++++ tests/test_management_commands.py | 47 ++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 drf_api_logger/management/commands/drf_api_logger_doctor.py diff --git a/drf_api_logger/management/commands/drf_api_logger_doctor.py b/drf_api_logger/management/commands/drf_api_logger_doctor.py new file mode 100644 index 0000000..367551f --- /dev/null +++ b/drf_api_logger/management/commands/drf_api_logger_doctor.py @@ -0,0 +1,64 @@ +import json + +from django.core.management.base import BaseCommand, CommandError + +from drf_api_logger.diagnostics import ( + results_as_dict, + run_diagnostics, + should_fail, +) + + +class Command(BaseCommand): + help = 'Validate DRF API Logger production readiness.' + + def add_arguments(self, parser): + parser.add_argument( + '--database', + help='Database alias to inspect. Defaults to DRF_API_LOGGER_DEFAULT_DATABASE or default.', + ) + parser.add_argument( + '--format', + choices=('text', 'json'), + default='text', + help='Output format. Default: text.', + ) + parser.add_argument( + '--fail-level', + choices=('warning', 'error'), + default='error', + help='Raise CommandError when diagnostics include this level or worse. Default: error.', + ) + + def handle(self, *args, **options): + results = run_diagnostics(database_alias=options.get('database')) + payload = results_as_dict(results) + + if options['format'] == 'json': + self.stdout.write(json.dumps(payload, indent=2, sort_keys=True)) + else: + self._write_text(payload) + + if should_fail(results, options['fail_level']): + raise CommandError( + 'DRF API Logger diagnostics failed at {} level.'.format( + options['fail_level'] + ) + ) + + def _write_text(self, payload): + summary = payload['summary'] + self.stdout.write('DRF API Logger diagnostics') + self.stdout.write( + 'Summary: {ok} ok, {warning} warning, {error} error'.format(**summary) + ) + for result in payload['results']: + self.stdout.write( + '{level} {code} {message}'.format( + level=result['level'].upper(), + code=result['code'], + message=result['message'], + ) + ) + if result.get('hint'): + self.stdout.write(' hint: {}'.format(result['hint'])) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py index d00bc13..b000a95 100644 --- a/tests/test_management_commands.py +++ b/tests/test_management_commands.py @@ -121,3 +121,50 @@ def test_requires_database_logging_enabled(self): call_command('prune_api_logs', '--days', '30') self.assertIn('DRF_API_LOGGER_DATABASE must be True', str(context.exception)) + + +class TestDRFAPILoggerDoctorCommand(TestCase): + """Test cases for production diagnostics command output and exit behavior.""" + + @override_settings(DRF_API_LOGGER_DATABASE=False, DRF_API_LOGGER_SIGNAL=False) + def test_text_output_includes_summary_results_and_hints(self): + output = StringIO() + + call_command('drf_api_logger_doctor', stdout=output) + + content = output.getvalue() + self.assertIn('DRF API Logger diagnostics', content) + self.assertIn('Summary:', content) + self.assertIn('WARNING DRF001 DRF API Logger is disabled.', content) + self.assertIn('hint:', content) + + @override_settings(DRF_API_LOGGER_DATABASE=False, DRF_API_LOGGER_SIGNAL=False) + def test_json_output_is_machine_readable(self): + import json + + output = StringIO() + + call_command('drf_api_logger_doctor', '--format', 'json', stdout=output) + + payload = json.loads(output.getvalue()) + self.assertEqual(payload['summary']['warning'], 1) + self.assertEqual(payload['results'][0]['code'], 'DRF001') + self.assertEqual(payload['results'][0]['details'], {}) + + @override_settings(DRF_API_LOGGER_DATABASE=False, DRF_API_LOGGER_SIGNAL=False) + def test_fail_level_warning_raises_command_error(self): + output = StringIO() + + with self.assertRaises(CommandError) as context: + call_command('drf_api_logger_doctor', '--fail-level', 'warning', stdout=output) + + self.assertIn('DRF API Logger diagnostics failed at warning level', str(context.exception)) + + @override_settings(DRF_API_LOGGER_DATABASE=True, DRF_API_LOGGER_SIGNAL=False) + def test_fail_level_error_raises_for_error_result(self): + output = StringIO() + + with self.assertRaises(CommandError) as context: + call_command('drf_api_logger_doctor', '--database', 'missing', stdout=output) + + self.assertIn('DRF API Logger diagnostics failed at error level', str(context.exception)) From 7417339f98e26a1b2e47d51ccda932af78255876 Mon Sep 17 00:00:00 2001 From: Vishal Anand Date: Sat, 4 Jul 2026 18:01:30 +0530 Subject: [PATCH 4/5] docs: add production doctor guidance --- README.md | 15 +++++++++ TESTING.md | 9 +++++ docs/developer_testing.rst | 11 +++++++ docs/index.rst | 12 +++++++ docs/operations.rst | 67 ++++++++++++++++++++++++++++++++++++++ tests/test_docs_content.py | 17 ++++++++++ 6 files changed, 131 insertions(+) diff --git a/README.md b/README.md index f4b2c04..68dc870 100644 --- a/README.md +++ b/README.md @@ -625,6 +625,21 @@ For detailed testing instructions, see [TESTING.md](TESTING.md). ## 🚀 Performance & Production +### Production Diagnostics + +Run production diagnostics before deploying database logging: + +```bash +python manage.py drf_api_logger_doctor +python manage.py drf_api_logger_doctor --format json +python manage.py drf_api_logger_doctor --fail-level warning +``` + +The doctor command is read-only. It checks logging mode, database readiness, +migrations, table availability, queue settings, worker status, payload limits, +masking configuration, and profiling risk. Use `--fail-level error` when a +deployment should fail only for blocking misconfiguration. + ### Database Optimization For high-traffic applications: diff --git a/TESTING.md b/TESTING.md index 1a40950..64c22a1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -63,6 +63,7 @@ make check-package - `tests/test_middleware.py`: request/response logging, filtering, tracing, body limits, and content types. - `tests/test_models.py`: model fields, admin display, filters, and CSV export. - `tests/test_signals.py`: event listeners, background queue behavior, app startup, and worker stats. +- `tests/test_diagnostics.py`: production doctor checks for logging mode, database readiness, queue status, payload limits, masking, and profiling risk. - `tests/test_observability.py`: dependency-free Prometheus, OpenTelemetry, and Sentry helper behavior. - `tests/test_policy.py`: logging policy decisions, endpoint rules, callable overrides, extra mask keys, and safe failure behavior. - `tests/test_profiling.py`: profiling settings, SQL tracking, admin diagnosis, and nullable profiling fields. @@ -116,6 +117,14 @@ python manage.py prune_api_logs --days 30 --dry-run python manage.py prune_api_logs --days 30 --batch-size 1000 ``` +Production diagnostics: + +```bash +python manage.py drf_api_logger_doctor +python manage.py drf_api_logger_doctor --format json +python manage.py drf_api_logger_doctor --fail-level warning +``` + Queue health check: ```python diff --git a/docs/developer_testing.rst b/docs/developer_testing.rst index 2329cd4..f7f530a 100644 --- a/docs/developer_testing.rst +++ b/docs/developer_testing.rst @@ -88,6 +88,9 @@ Add or update tests for every behavior change. Cover the touched surface: label prevention. - Policy gate behavior for full log drops, metadata-only logging, extra mask keys, signal/export gating, safe failures, and default backward compatibility. +- Production diagnostics and doctor command behavior, including JSON output, + fail-level behavior, database readiness, queue health, payload limits, + masking, and profiling risk. - Background queue flushing, stats, shutdown, and database alias handling. - Admin display, filters, CSV export, and profiling diagnosis. - Management commands such as ``prune_api_logs``. @@ -103,6 +106,14 @@ Retention command: python manage.py prune_api_logs --days 30 --dry-run python manage.py prune_api_logs --days 30 --batch-size 1000 +Production diagnostics: + +.. code-block:: bash + + python manage.py drf_api_logger_doctor + python manage.py drf_api_logger_doctor --format json + python manage.py drf_api_logger_doctor --fail-level warning + Queue health: .. code-block:: python diff --git a/docs/index.rst b/docs/index.rst index a96f315..c0791c3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -612,6 +612,18 @@ Model Schema Performance & Production ========================= +Run production diagnostics during deployment: + +.. code-block:: bash + + python manage.py drf_api_logger_doctor + python manage.py drf_api_logger_doctor --format json + python manage.py drf_api_logger_doctor --fail-level warning + +The command is read-only and reports database readiness, queue health, payload +limits, masking configuration, and profiling risk. Use ``--fail-level error`` +when deployment checks should fail only on blocking misconfiguration. + .. code-block:: python # Use a dedicated database for logs diff --git a/docs/operations.rst b/docs/operations.rst index 22f23fd..8922943 100644 --- a/docs/operations.rst +++ b/docs/operations.rst @@ -16,6 +16,73 @@ DRF API Logger 1.2.3 supports: The GitHub Actions workflow tests representative Django versions from this support range before publishing package artifacts. +Production Doctor Command +------------------------- + +Run ``drf_api_logger_doctor`` before enabling database logging in production +and after deployment changes that affect logging, storage, retention, masking, +payload limits, or profiling. + +.. code-block:: bash + + python manage.py drf_api_logger_doctor + +The command is read-only. It validates logging mode, database alias readiness, +DRF API Logger migrations, the log table, queue settings, background worker +status, payload limits, masking configuration, and profiling settings. It does +not create tables, run migrations, prune rows, or inspect stored request or +response payloads. + +Use JSON output in CI or deployment checks: + +.. code-block:: bash + + python manage.py drf_api_logger_doctor --format json + +Fail a deployment on warnings or errors: + +.. code-block:: bash + + python manage.py drf_api_logger_doctor --fail-level warning + python manage.py drf_api_logger_doctor --fail-level error + +Use ``--database`` when ``DRF_API_LOGGER_DEFAULT_DATABASE`` points to a +dedicated log database and the deployment check must inspect that alias +explicitly: + +.. code-block:: bash + + python manage.py drf_api_logger_doctor --database logs_db --format json + +Result levels: + +``OK`` + The checked condition is valid for the current configuration. + +``WARNING`` + The package can run, but the setting or runtime state deserves operator + attention before production use. + +``ERROR`` + The package is likely misconfigured for the selected logging mode. Fix the + issue before relying on production database logging. + +Deployment Checklist +-------------------- + +Before enabling database logging in production: + +- Run ``python manage.py migrate drf_api_logger`` on the configured log + database. +- Run ``python manage.py drf_api_logger_doctor --fail-level warning``. +- Keep finite body limits with ``DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE`` and + ``DRF_API_LOGGER_MAX_RESPONSE_BODY_SIZE``. +- Add application-specific secrets to ``DRF_API_LOGGER_EXCLUDE_KEYS``. +- Skip health and metrics endpoints with ``DRF_API_LOGGER_SKIP_URL_NAME`` or + ``DRF_API_LOGGER_SKIP_NAMESPACE``. +- Schedule ``prune_api_logs`` with a dry run first. +- Monitor ``queue_backlog``, ``dropped_count``, and ``failed_insert_count``. + Database Growth --------------- diff --git a/tests/test_docs_content.py b/tests/test_docs_content.py index 1b4931a..d0ec2f9 100644 --- a/tests/test_docs_content.py +++ b/tests/test_docs_content.py @@ -251,6 +251,23 @@ def test_policy_docs_cover_safe_logging_controls(self): self.assertIn("Safe Failure Behavior", guide) self.assertIn("Do not put secrets", llms) + def test_doctor_command_is_documented(self): + operations = read_text("docs/operations.rst") + index = read_text("docs/index.rst") + readme = read_text("README.md") + testing = read_text("TESTING.md") + developer_testing = read_text("docs/developer_testing.rst") + + for content in (operations, index, readme, testing, developer_testing): + self.assertIn("drf_api_logger_doctor", content) + self.assertIn("--fail-level", content) + + self.assertIn("--format json", operations) + self.assertIn("queue_backlog", operations) + self.assertIn("DRF_API_LOGGER_MAX_REQUEST_BODY_SIZE", operations) + self.assertIn("tests/test_diagnostics.py", testing) + self.assertIn("Production diagnostics", developer_testing) + if __name__ == "__main__": unittest.main() From cfe4091247f31375d5b67c4f44b1fbe81c0231a7 Mon Sep 17 00:00:00 2001 From: Vishal Anand Date: Sat, 4 Jul 2026 18:03:43 +0530 Subject: [PATCH 5/5] test: cover diagnostics database helper failures --- tests/test_diagnostics.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 61d32ef..0b00b84 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -3,6 +3,7 @@ """ from unittest.mock import Mock, patch +from django.db.utils import OperationalError from django.test import TestCase from django.test.utils import override_settings @@ -97,6 +98,29 @@ def test_database_not_ready_reports_pending_migrations_and_missing_table( message="API log table is missing.", ) + def test_database_helpers_use_real_connection_when_available(self): + from drf_api_logger.diagnostics import ( + _database_table_exists, + _has_pending_migrations, + ) + + self.assertTrue(_database_table_exists("default")) + self.assertFalse(_has_pending_migrations("default")) + + def test_database_helpers_fail_closed_on_connection_errors(self): + from drf_api_logger.diagnostics import ( + _database_table_exists, + _has_pending_migrations, + ) + + class FailingConnections: + def __getitem__(self, database): + raise OperationalError("database unavailable") + + with patch("drf_api_logger.diagnostics.connections", FailingConnections()): + self.assertFalse(_database_table_exists("default")) + self.assertTrue(_has_pending_migrations("default")) + @override_settings(DRF_API_LOGGER_DATABASE=True) @patch("drf_api_logger.diagnostics._database_table_exists", return_value=True) @patch("drf_api_logger.diagnostics._database_alias_available", return_value=True)