Add Google Analytics 4 with cookie consent integration
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m2s

- Add GA4 measurement ID to config (default: G-J0PCVT14NY)
- Add /api/config endpoint to expose GA ID to frontend
- Update cookie consent with Analytics category (opt-in)
- Load GA4 only after user consents to analytics cookies
- Update CSP to allow Google Analytics domains

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-01-11 15:19:05 +00:00
parent 1f6b2dd773
commit 9cd36a0b15
3 changed files with 60 additions and 5 deletions

View File

@@ -65,11 +65,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# Content Security Policy # Content Security Policy
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://unpkg.com https://www.googletagmanager.com; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; "
"font-src 'self' https://fonts.gstatic.com; " "font-src 'self' https://fonts.gstatic.com; "
"img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com; " "img-src 'self' data: https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com; "
"connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com; " "connect-src 'self' https://cdn.jsdelivr.net https://*.tile.openstreetmap.org https://unpkg.com https://www.google-analytics.com https://analytics.google.com https://*.google-analytics.com; "
"frame-ancestors 'none'; " "frame-ancestors 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
"form-action 'self';" "form-action 'self';"
@@ -199,6 +199,14 @@ async def serve_rankings():
return FileResponse(settings.frontend_dir / "index.html") return FileResponse(settings.frontend_dir / "index.html")
@app.get("/api/config")
async def get_config():
"""Return public configuration for the frontend."""
return {
"ga_measurement_id": settings.ga_measurement_id
}
@app.get("/api/schools") @app.get("/api/schools")
@limiter.limit(f"{settings.rate_limit_per_minute}/minute") @limiter.limit(f"{settings.rate_limit_per_minute}/minute")
async def get_schools( async def get_schools(

View File

@@ -38,6 +38,9 @@ class Settings(BaseSettings):
rate_limit_burst: int = 10 # Allow burst of requests rate_limit_burst: int = 10 # Allow burst of requests
max_request_size: int = 1024 * 1024 # 1MB max request size max_request_size: int = 1024 * 1024 # 1MB max request size
# Analytics
ga_measurement_id: Optional[str] = "G-J0PCVT14NY" # Google Analytics 4 Measurement ID
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"

View File

@@ -354,6 +354,38 @@
</footer> </footer>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<!-- Google Analytics (loaded conditionally after consent) -->
<script>
var GA_MEASUREMENT_ID = null;
// Fetch GA ID from server config
fetch('/api/config')
.then(function(response) { return response.json(); })
.then(function(config) {
if (config.ga_measurement_id) {
GA_MEASUREMENT_ID = config.ga_measurement_id;
}
})
.catch(function(err) { console.warn('Failed to load config:', err); });
function loadGoogleAnalytics() {
if (window.gaLoaded || !GA_MEASUREMENT_ID) return;
window.gaLoaded = true;
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_MEASUREMENT_ID;
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = gtag;
gtag('js', new Date());
gtag('config', GA_MEASUREMENT_ID);
}
</script>
<!-- Cookie Consent Banner --> <!-- Cookie Consent Banner -->
<script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script> <script src="https://cdn.jsdelivr.net/gh/silktide/consent-manager@main/silktide-consent-manager.js"></script>
<script> <script>
@@ -365,14 +397,26 @@
description: 'Essential cookies required for the website to function properly.', description: 'Essential cookies required for the website to function properly.',
required: true, required: true,
defaultValue: true defaultValue: true
},
{
id: 'analytics',
label: 'Analytics',
description: 'Help us understand how visitors use our site so we can improve it.',
required: false,
defaultValue: false
} }
], ],
text: { text: {
title: 'Cookie Preferences', title: 'Cookie Preferences',
description: 'This website does not use tracking cookies. We only use essential cookies required for the site to function.', description: 'We use cookies to improve your experience. Analytics cookies help us understand how you use the site.',
acceptAll: 'Accept', acceptAll: 'Accept All',
rejectAll: 'Reject All', rejectAll: 'Reject All',
save: 'Save Preferences' save: 'Save Preferences'
},
onConsentChange: function(consent) {
if (consent.analytics) {
loadGoogleAnalytics();
}
} }
}); });
</script> </script>