diff --git a/CHANGELOG.md b/CHANGELOG.md index ea0082a..ee9a529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). bookmarks survive the move and search engines are told where the pages went. ### Fixed +- **A bad analytics-ledger path can no longer take the whole site down.** + Pointing `TRAFFIC_ANALYTICS_FILE` at a persistent-disk path before the disk + existed crashed every worker at boot, so the deploy never went live and the + old build kept serving. The tracker now creates the directory when it can, + and when the path is truly unwritable it disables itself with a clear log + line — the site serves either way, and a test pins both behaviours. - **Search engines were being told this site is a copy of a site that does not exist.** The page template still carried the URL it was built with (`dash-mui-scheduler.onrender.com`) rather than the address the docs actually diff --git a/lib/analytics_tracker.py b/lib/analytics_tracker.py index 3199c50..626a7c6 100644 --- a/lib/analytics_tracker.py +++ b/lib/analytics_tracker.py @@ -70,11 +70,28 @@ class AnalyticsTracker: def __init__(self, data_file=None): self.data_file = Path(data_file or os.getenv("TRAFFIC_ANALYTICS_FILE") or "visitor_analytics.json") - self._ensure_file_exists() + # An unusable ledger path must NEVER crash the boot. This module is + # imported at the top of run.py, so an exception here kills every + # worker before a port is bound and the platform loops the deploy + # forever — which is exactly what happened when TRAFFIC_ANALYTICS_FILE + # pointed at /var/data before the disk was attached. Analytics is an + # accessory; the site serving is the product. + self._disabled = False + try: + self._ensure_file_exists() + except Exception as exc: + self._disabled = True + print(f"[analytics] ledger {self.data_file} is not writable " + f"({exc!r}) — visitor tracking DISABLED. Fix " + "TRAFFIC_ANALYTICS_FILE (attach the disk it points at, or " + "unset it) to re-enable; nothing else is affected.") def _ensure_file_exists(self): - """Create analytics file if it doesn't exist.""" + """Create the analytics file (and its directory) if missing.""" if not self.data_file.exists(): + # A persistent-disk path like /var/data/… may not exist yet on + # first boot — create the directory rather than failing on it. + self.data_file.parent.mkdir(parents=True, exist_ok=True) self.data_file.write_text(json.dumps({ "visits": [], "stats": { @@ -193,6 +210,10 @@ def track_visit(self, path, user_agent, ip_address=None, auth_name=None, """Track a visitor. auth_name (the verified Clerk display name, when the caller resolved one) stamps the hit as authenticated. country is the edge-supplied CF-IPCountry code, when the request carried one.""" + # Ledger unusable at boot (see __init__) → tracking is off, not broken. + if self._disabled: + return + # The network's internal-traffic contract: hub health sweeps, CI smoke # batteries and satellite-to-satellite calls identify themselves with # INTERNAL_UA_TOKEN in the User-Agent. Dropped at WRITE time — before diff --git a/tests/test_analytics_boot.py b/tests/test_analytics_boot.py new file mode 100644 index 0000000..5fd7d4d --- /dev/null +++ b/tests/test_analytics_boot.py @@ -0,0 +1,51 @@ +"""The analytics ledger must never be able to kill the boot. + +lib/analytics_tracker is imported at the top of run.py and constructs its +module-level tracker at import time — so an exception there crashes every +worker before a port is bound, and the platform loops the deploy forever +while the old build keeps serving. That is exactly what happened in +production on 2026-08-02: TRAFFIC_ANALYTICS_FILE pointed at +/var/data/visitor_analytics.json before the persistent disk was attached, +and `_ensure_file_exists()` raised FileNotFoundError in `__init__`. + +Analytics is an accessory. These tests pin the two required behaviours: +a missing parent directory is created, and a genuinely unwritable path +disables tracking instead of raising. +""" + +from __future__ import annotations + +import json + +from lib.analytics_tracker import AnalyticsTracker + +UA = "Mozilla/5.0 test-browser" + + +def test_a_missing_parent_directory_is_created(tmp_path): + """The /var/data case once the disk IS there but empty: first boot must + create the directory chain rather than failing on it.""" + ledger = tmp_path / "var" / "data" / "visitor_analytics.json" + tracker = AnalyticsTracker(data_file=str(ledger)) + + assert not tracker._disabled + assert ledger.exists(), "the ledger file was not seeded" + + tracker.track_visit("/quickstart", UA, "203.0.113.9") + visits = json.loads(ledger.read_text())["visits"] + assert len(visits) == 1 and visits[0]["path"] == "/quickstart" + + +def test_an_unwritable_ledger_path_disables_tracking_not_the_boot(tmp_path): + """The production crash, pinned: a path that cannot be created (here, a + directory component that is actually a FILE) must yield a tracker that + constructs fine and no-ops — never an exception at import time.""" + blocker = tmp_path / "not-a-directory" + blocker.write_text("occupied") + ledger = blocker / "visitor_analytics.json" + + tracker = AnalyticsTracker(data_file=str(ledger)) # must not raise + + assert tracker._disabled + tracker.track_visit("/quickstart", UA, "203.0.113.9") # must not raise + assert not ledger.exists()