From 0b2cf1ee1feaa081175f47d595c4067e7db69620 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 15:37:01 +1000 Subject: [PATCH 1/2] Proxy Plausible analytics through the backend The tracker posts to /log/api/event, and only Netlify ever redirected that path. The container serves the SPA from a FastAPI static mount that answers GET and HEAD only, so every event returned 405 and no page views were recorded from staging. - Adds a /log router that forwards script.js and api/event to plausible.io. It is registered above the static mount, so it wins over the file handler. - Keeps the router out of the OpenAPI schema, so the generated client is unchanged. - Returns 202 when the upstream call fails, because a dropped event is not worth failing the page over. - Makes the tracker endpoint relative, so production stops reporting into the staging site. netlify.toml is untouched, so both deploy targets now behave the same. --- backend/pyproject.toml | 1 + backend/src/ref_backend/analytics.py | 79 +++++++++++++++++++++++++++ backend/src/ref_backend/builder.py | 4 ++ backend/tests/test_analytics.py | 82 ++++++++++++++++++++++++++++ backend/uv.lock | 2 + changelog/92.fix.md | 4 ++ frontend/src/routes/__root.tsx | 5 +- 7 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 backend/src/ref_backend/analytics.py create mode 100644 backend/tests/test_analytics.py create mode 100644 changelog/92.fix.md diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6be07cef..c80fc634 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,6 +5,7 @@ description = "Backend for the Climate Rapid Evaluation Framework" requires-python = ">=3.12" dependencies = [ "fastapi[standard]<1.0.0,>=0.114.2", + "httpx>=0.27", "pydantic>2.0", "psycopg[binary]<4.0.0,>=3.1.13", "pydantic-settings<3.0.0,>=2.13.1", diff --git a/backend/src/ref_backend/analytics.py b/backend/src/ref_backend/analytics.py new file mode 100644 index 00000000..fc63e054 --- /dev/null +++ b/backend/src/ref_backend/analytics.py @@ -0,0 +1,79 @@ +""" +Same-origin proxy for Plausible Analytics. + +The tracker posts to a path on this app rather than to plausible.io directly, +so that content blockers and strict connect-src policies do not drop the events. +Netlify deploys handle the same paths through redirects in `frontend/netlify.toml`, +so both deploy targets need to stay in step. +""" + +import httpx +from fastapi import APIRouter, Request, Response +from loguru import logger + +PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/script.file-downloads.outbound-links.js" +PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event" + +UPSTREAM_TIMEOUT_SECONDS = 10.0 + +router = APIRouter(prefix="/log", tags=["analytics"], include_in_schema=False) + +_client = httpx.AsyncClient(timeout=UPSTREAM_TIMEOUT_SECONDS, follow_redirects=True) + + +def _forwarded_for(request: Request) -> str | None: + """ + Build the client chain that Plausible uses to derive a visitor hash. + """ + existing = request.headers.get("x-forwarded-for") + if existing: + return existing + if request.client: + return request.client.host + return None + + +@router.get("/script.js") +async def script() -> Response: + """ + Serve the Plausible tracker script from this origin. + """ + try: + upstream = await _client.get(PLAUSIBLE_SCRIPT_URL) + except httpx.HTTPError as exc: + logger.warning(f"Could not fetch the Plausible script: {exc}") + return Response(status_code=502) + + return Response( + content=upstream.content, + status_code=upstream.status_code, + media_type=upstream.headers.get("content-type", "application/javascript"), + headers={"cache-control": upstream.headers.get("cache-control", "public, max-age=3600")}, + ) + + +@router.post("/api/event") +async def event(request: Request) -> Response: + """ + Forward a tracker event to Plausible. + """ + headers = { + "content-type": request.headers.get("content-type", "text/plain"), + "user-agent": request.headers.get("user-agent", ""), + } + forwarded_for = _forwarded_for(request) + if forwarded_for: + headers["x-forwarded-for"] = forwarded_for + + try: + upstream = await _client.post(PLAUSIBLE_EVENT_URL, content=await request.body(), headers=headers) + except httpx.HTTPError as exc: + # A dropped event is not worth failing the page over. + logger.warning(f"Could not forward a Plausible event: {exc}") + return Response(status_code=202) + + return Response( + content=upstream.content, + status_code=upstream.status_code, + media_type=upstream.headers.get("content-type"), + ) diff --git a/backend/src/ref_backend/builder.py b/backend/src/ref_backend/builder.py index c5029c80..53fbc0e9 100644 --- a/backend/src/ref_backend/builder.py +++ b/backend/src/ref_backend/builder.py @@ -14,6 +14,7 @@ from climate_ref.config import Config from climate_ref.database import Database +from ref_backend.analytics import router as analytics_router from ref_backend.api.main import api_router from ref_backend.core.config import Settings @@ -130,6 +131,9 @@ def build_app(settings: Settings, ref_config: Config, database: Database) -> Fas app.include_router(api_router, prefix=settings.API_V1_STR) + # Mounted above the static files, which only answer GET and HEAD. + app.include_router(analytics_router) + if settings.STATIC_DIR: logger.info(f"Serving static files from {settings.STATIC_DIR}") app.mount( diff --git a/backend/tests/test_analytics.py b/backend/tests/test_analytics.py new file mode 100644 index 00000000..ecfdf6c0 --- /dev/null +++ b/backend/tests/test_analytics.py @@ -0,0 +1,82 @@ +""" +Tests for the Plausible proxy. + +The upstream calls are served by a mock transport, so no request leaves the test run. +""" + +import httpx +import pytest +from starlette.testclient import TestClient + +from ref_backend import analytics + + +@pytest.fixture() +def upstream_requests(monkeypatch): + """ + Capture the requests the proxy makes and answer them from a mock transport. + """ + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if request.url.path == "/api/event": + return httpx.Response(202, text="ok", headers={"content-type": "text/plain"}) + return httpx.Response( + 200, + text="console.log('tracker');", + headers={"content-type": "application/javascript", "cache-control": "public, max-age=60"}, + ) + + monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) + return captured + + +def test_script_is_proxied(client: TestClient, upstream_requests): + response = client.get("/log/script.js") + + assert response.status_code == 200 + assert response.text == "console.log('tracker');" + assert response.headers["content-type"].startswith("application/javascript") + assert response.headers["cache-control"] == "public, max-age=60" + assert str(upstream_requests[0].url) == analytics.PLAUSIBLE_SCRIPT_URL + + +def test_event_is_proxied(client: TestClient, upstream_requests): + response = client.post( + "/log/api/event", + content=b'{"n":"pageview"}', + headers={"content-type": "text/plain", "user-agent": "test-agent"}, + ) + + assert response.status_code == 202 + assert response.text == "ok" + + forwarded = upstream_requests[0] + assert str(forwarded.url) == analytics.PLAUSIBLE_EVENT_URL + assert forwarded.content == b'{"n":"pageview"}' + assert forwarded.headers["content-type"] == "text/plain" + assert forwarded.headers["user-agent"] == "test-agent" + assert forwarded.headers["x-forwarded-for"] == "testclient" + + +def test_event_survives_an_unreachable_upstream(client: TestClient, monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("upstream down", request=request) + + monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + response = client.post("/log/api/event", content=b'{"n":"pageview"}') + + assert response.status_code == 202 + + +def test_script_reports_an_unreachable_upstream(client: TestClient, monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("upstream down", request=request) + + monkeypatch.setattr(analytics, "_client", httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + response = client.get("/log/script.js") + + assert response.status_code == 502 diff --git a/backend/uv.lock b/backend/uv.lock index ef0390b5..47c7edd9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -3409,6 +3409,7 @@ dependencies = [ { name = "climate-ref", extra = ["aft-providers", "postgres"] }, { name = "fastapi", extra = ["standard"] }, { name = "fastapi-sqlalchemy-monitor" }, + { name = "httpx" }, { name = "loguru" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, @@ -3435,6 +3436,7 @@ requires-dist = [ { name = "climate-ref", extras = ["aft-providers", "postgres"], specifier = ">=0.17.2,<0.18" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" }, { name = "fastapi-sqlalchemy-monitor", specifier = ">=1.1.3" }, + { name = "httpx", specifier = ">=0.27" }, { name = "loguru" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" }, { name = "pydantic", specifier = ">2.0" }, diff --git a/changelog/92.fix.md b/changelog/92.fix.md new file mode 100644 index 00000000..76cbc057 --- /dev/null +++ b/changelog/92.fix.md @@ -0,0 +1,4 @@ +Fixed page view analytics on the container deployments. +The backend now proxies `/log/script.js` and `/log/api/event` through to plausible.io, +so the tracker no longer hits the static file mount and gets a 405. +The tracker also posts to a relative path, so production stops reporting into the staging site. diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 68435077..0239afe6 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -14,10 +14,11 @@ import { WelcomeModal } from "@/components/app/welcomeModal"; import { useApiEndpoint } from "@/hooks/useApiEndpoint"; import { useDocumentTitle } from "@/hooks/useDocumentTitle"; -// Initialize Plausible Analytics +// The endpoint is relative so that events go to whichever origin is serving the app. +// Both Netlify and the container image proxy /log through to plausible.io. init({ domain: "climate-ref.org", - endpoint: "https://staging.climate-ref.org/log/api/event", + endpoint: "/log/api/event", outboundLinks: true, captureOnLocalhost: false, fileDownloads: true, From 9070db9939e41a9228332810d628727784573f63 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Fri, 4 Sep 2026 15:37:26 +1000 Subject: [PATCH 2/2] Rename the changelog fragment to the PR number --- changelog/{92.fix.md => 95.fix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{92.fix.md => 95.fix.md} (100%) diff --git a/changelog/92.fix.md b/changelog/95.fix.md similarity index 100% rename from changelog/92.fix.md rename to changelog/95.fix.md