Skip to content

Derive log file path from BASE_DIR, degrade gracefully if unwritable (#1283) - #1415

Merged
jonfroehlich merged 4 commits into
masterfrom
1283-derive-log-path-from-base-dir
Jul 28, 2026
Merged

Derive log file path from BASE_DIR, degrade gracefully if unwritable (#1283)#1415
jonfroehlich merged 4 commits into
masterfrom
1283-derive-log-path-from-base-dir

Conversation

@jonfroehlich

Copy link
Copy Markdown
Member

Fixes #1283.

Problem

The LOGGING file handler in makeabilitylab/settings.py hardcoded an absolute, container-specific path:

'filename': '/code/media/debug.log',

Django evaluates LOGGING at django.setup(), so on any host lacking that exact directory (e.g. GitHub Actions CI) startup died with FileNotFoundError before a single request or test ran. This was papered over in settings_test.py (swapping the handler for a NullHandler), but the source-level fragility remained for any non-/code deploy or tooling.

Fix

Combines the issue's Option 1 (derive from BASE_DIR) and Option 2 (degrade gracefully):

  • LOG_DIR = os.environ.get('ML_LOG_DIR', os.path.join(BASE_DIR, 'media')), LOG_FILE = LOG_DIR/debug.log.
  • os.makedirs(LOG_DIR, exist_ok=True) and check writability; if the dir can't be created or written, the file handler falls back to logging.NullHandler so startup never dies over a log path.
  • Adds an ML_LOG_DIR env override for future non-/code hosts.

Deliberately does not relocate the log out of MEDIA_ROOT (the issue's Option 3) — the /logs/ exposure is intentional (docs/DEPLOYMENT.md) and relocating needs CSE IT coordination.

No behavior change on servers/local dev

In the container BASE_DIR is /code, so the default still resolves to /code/media/debug.log. Prod, test, and local dev are unaffected. The NullHandler swap in settings_test.py is now belt-and-suspenders rather than the only crash guard.

Verification

  • Normal path → RotatingFileHandler at /code/media/debug.log, writable.
  • ML_LOG_DIR=/nonexistent/cannot/create → degrades to NullHandler, django.setup() succeeds, logging still functions.
  • manage.py check --settings=makeabilitylab.settings_test → no issues.

🤖 Generated with Claude Code

jonfroehlich and others added 4 commits July 1, 2026 12:55
…1283)

The LOGGING 'file' handler hardcoded an absolute, container-specific path
(/code/media/debug.log). Django evaluates LOGGING at django.setup(), so on
any host lacking that exact directory (e.g. GitHub Actions CI) startup died
with FileNotFoundError before a single request or test ran.

Derive the path from BASE_DIR instead (still under media/ so it stays
reachable via the intentional /logs/ URL per docs/DEPLOYMENT.md), allow an
ML_LOG_DIR env override, and fall back to a NullHandler when the log dir
can't be created or written so a bad log path never crashes startup.

In the container/servers BASE_DIR is /code, so the default still resolves to
/code/media/debug.log — no behavior change on prod, test, or local dev. The
NullHandler swap in settings_test.py is now belt-and-suspenders rather than
the only crash guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review feedback on the log-path degradation fix:

- Pull the try/except guard out of the LOGGING block into a documented
  _log_dir_is_writable() helper so it can be unit-tested directly (no
  settings reload / subprocess needed).
- Note the dir-vs-file writability gap in the helper docstring: the check
  is on the directory, so a writable dir holding a root-owned read-only
  debug.log could still make RotatingFileHandler raise on open — an
  accepted edge case, strictly better than the prior unconditional crash.
- Add website/tests/test_logging_config.py: a fast SimpleTestCase pinning
  both the writable (→ file handler) and uncreatable (→ NullHandler) paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-ups to the review of PR #1415. The substantive one: degrading to a
NullHandler was completely silent. The 'django' logger has only the 'file'
handler and the 'website' logger's console handler is gated by
require_debug_true (False in prod), so an unwritable log dir means the server
runs blind — and there is no console access on -test or prod to notice it.

Surface the degraded state two web-reachable ways:
- /version.json gains log_to_file + log_file (scriptable deploy check).
- The admin dashboard gains a superuser-only warning callout naming the path
  that failed. Red-tinted so it doesn't read as another amber tip box; both
  light and dark variants clear WCAG AA (ratios noted inline).

Also from the review:
- Rename _log_dir_is_writable -> _ensure_log_dir_writable; the function calls
  os.makedirs, so a pure-predicate name was misleading. Document the os.access
  root caveat (the deployed container runs as apache, so the guard still bites).
- Extract _file_log_handler() so both branches are testable and the LOGGING
  literal loses its two-dict inline ternary. LOGGING is evaluated once at
  import, so the degrade branch was otherwise untestable — settings_test.py
  swaps the handler before any test runs.
- Cross-reference LOG_DIR and MEDIA_ROOT at both sites, and add a test pinning
  LOG_FILE to MEDIA_ROOT/debug.log — the contract the log's readability rests on.
- Document ML_LOG_DIR (previously undocumented) in DEPLOYMENT.md and CLAUDE.md.

Correcting a stale premise while here: the /logs/ URL that settings.py and
DEPLOYMENT.md cite as the way to read debug.log 404s on BOTH prod and test
(verified by curl, 2026-07-28). SSH to the shared filesystem is the reliable
path; the docs now say so. The INFO-not-DEBUG conservatism stays, since the
file does still live in a web-served tree.

Tests: 724 pass. New coverage for the helper's dir creation, both handler
branches, the MEDIA_ROOT contract, the version.json fields, and the admin
callout (shown to superusers when degraded, hidden otherwise).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonfroehlich

Copy link
Copy Markdown
Member Author

Review follow-ups pushed (ec94c733)

Merged master (branch was 33 commits behind) and addressed all six review items.

The substantive one: degradation was silent

The django logger has only the file handler, and the website logger's console handler is gated by require_debug_true (False in prod). So an unwritable log dir meant the server ran completely blind — and there is no console access on -test or prod to notice. The fix needed a web-reachable signal:

  1. /version.json gains log_to_file + log_file — one curl answers "is this server logging?" and names the path that failed.
  2. Admin dashboard gains a superuser-only warning callout.
{ "version": "2.31.1", "git_sha": "...", "server": "gunicorn/23.0.0",
  "log_to_file": true, "log_file": "/code/media/debug.log" }

Red-tinted rather than amber so it doesn't read as another "Tip" box. Both themes clear WCAG AA (ratios noted inline in the CSS); .pa11yci.json doesn't scan /admin, so this was checked by hand.

Other items

  • _log_dir_is_writable_ensure_log_dir_writable (it calls os.makedirs, so the predicate name was misleading). Documented the os.access root caveat — the deployed container runs as apache (UID 48), so the guard still bites where it matters.
  • Extracted _file_log_handler(), which both removes the two-dict inline ternary and makes the degrade branch testable. LOGGING is evaluated once at import and settings_test.py swaps the handler before any test runs, so the branch had no reachable coverage before.
  • LOG_DIRMEDIA_ROOT cross-referenced at both sites, plus test_default_log_file_is_under_media_root pinning them together.
  • ML_LOG_DIR documented in docs/DEPLOYMENT.md and CLAUDE.md.

⚠️ Stale premise corrected

settings.py and DEPLOYMENT.md both said debug.log is readable at the /logs/ URL. It isn't — /logs/ and /logs/debug.log return 404 on both prod and test (verified by curl, 2026-07-28). The docs now point at SSH on the shared filesystem as the reliable path. The INFO-not-DEBUG conservatism stays, since the file does still live in a web-served tree.

Verification

  • 724 tests pass in the container.
  • Default path unchanged: log_file: /code/media/debug.log, RotatingFileHandler — no behavior change on prod/test/local dev.
  • ML_LOG_DIR=/dev/null/nopedjango.setup() succeeds, NullHandler, /version.json reports log_to_file: false, callout renders.

Out of scope

Pre-existing: prod/test run 3 Gunicorn workers (docker-entrypoint.sh:211), each with its own RotatingFileHandler on the same file — rotation is racy under multi-process. Worth a separate issue.

🤖 Generated with Claude Code

@jonfroehlich
jonfroehlich merged commit 618a10c into master Jul 28, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

settings.py hardcodes the log file to /code/media/debug.log (breaks on any host without that path)

1 participant