diff --git a/frontend/src/app.html b/frontend/src/app.html index 440156d..6cdf730 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -42,8 +42,13 @@ function report(reason) { if (sent) return; sent = true; // one beacon per page try { + // The entry chunk's hashed filename identifies the build this page + // booted from — lets deploy-related errors correlate to a version. + var ver = ''; + var pre = document.querySelector('link[rel="modulepreload"]'); + if (pre) ver = (pre.getAttribute('href') || '').split('/').pop().slice(0, 60); var b = new Blob([JSON.stringify({ reason: String(reason || 'unknown').slice(0, 500), - path: location.pathname })], { type: 'application/json' }); + path: location.pathname, version: ver })], { type: 'application/json' }); navigator.sendBeacon && navigator.sendBeacon('/api/client-error', b); } catch (e) { /* best-effort telemetry */ } } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index bd19ed5..9831e7f 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -462,7 +462,7 @@ {fdate(e.created_at)} {e.reason || '—'}{#if e.bot}bot{/if} {e.path || '/'} - {e.user_agent} + {e.user_agent}{#if e.app_version} · build {e.app_version}{/if} {/each} diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js index f588f6a..1d3c15c 100644 --- a/frontend/src/service-worker.js +++ b/frontend/src/service-worker.js @@ -19,6 +19,25 @@ function isServerPath(p) { return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml'; } +// Mutable files Caddy serves with `no-cache` — the browser's HTTP cache +// revalidates these correctly, but SW cache-as-you-go would pin them until the +// next SW version and silently defeat that policy. version.json is the big one +// (it's how the app detects a new deploy); stale word lists could drift from +// the server's validated answer pool. Let the network/browser cache own them. +function isMutablePath(p) { + return ( + p === '/service-worker.js' || + p === '/_app/version.json' || + p === '/manifest.webmanifest' || + p === '/words-5.json' || + p === '/words-6.json' || + p === '/favicon.svg' || + p === '/logo.svg' || + p === '/logo-email.png' || + p.startsWith('/icon-') + ); +} + self.addEventListener('install', (event) => { // Best-effort: grab the app shell as an offline fallback. No bulk precache. event.waitUntil( @@ -40,7 +59,7 @@ self.addEventListener('fetch', (event) => { if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== location.origin) return; - if (isServerPath(url.pathname)) return; // let the network/server handle these + if (isServerPath(url.pathname) || isMutablePath(url.pathname)) return; // network/browser cache owns these // Navigations: network-first, but a SLOW network must not mean a white screen — // "slow" and "failed" both fall back to the cached shell. We race the fetch diff --git a/goodnews/api.py b/goodnews/api.py index c68e3fc..7f52811 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -959,7 +959,7 @@ def create_app() -> FastAPI: with get_conn() as conn: _require_admin(conn, request) rows = conn.execute( - "SELECT reason, path, user_agent, created_at FROM client_errors ORDER BY id DESC LIMIT 20" + "SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20" ).fetchall() # Bots stay visible in the list (tagged) but are excluded from the # headline counts — see queries.admin_stats. diff --git a/tests/test_admin.py b/tests/test_admin.py index 8b88e1a..bf89701 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -412,12 +412,14 @@ def test_word_pool_admin(tmp_path, monkeypatch): def test_client_error_telemetry(tmp_path, monkeypatch): app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") anon = TestClient(app) - assert anon.post("/api/client-error", json={"reason": "boot-timeout", "path": "/play"}).json()["ok"] is True + assert anon.post("/api/client-error", + json={"reason": "boot-timeout", "path": "/play", "version": "start.Bzfu1yPF.js"}).json()["ok"] is True assert anon.get("/api/admin/client-errors").status_code == 401 # gated tc = _signin(app, api, "boss@x.com") rows = tc.get("/api/admin/client-errors").json() assert len(rows) == 1 and rows[0]["reason"] == "boot-timeout" and rows[0]["path"] == "/play" assert rows[0]["user_agent"] # captured from the request header + assert rows[0]["app_version"] == "start.Bzfu1yPF.js" # build correlation for deploy-related errors assert rows[0]["bot"] is False assert tc.get("/api/admin/stats").json()["client_errors"]["today"] == 1 # A throttled crawler tripping the beacon must NOT inflate the headline count,