Lightweight, cookie-free analytics for all services behind Traefik. Tracks page views per subdomain using browser fingerprinting, SPA route detection, and sendBeacon. Data stored in MongoDB, viewable via a static HTML dashboard.
Browser ──▶ Cloudflare ──▶ Traefik (ports 80/443, host network)
│
├─ rewritebody plugin injects <script> into HTML responses
│
├─ /m/s.js ──▶ analytics backend (port 4000) ──▶ serves script
├─ /m/e ──▶ analytics backend (port 4000) ──▶ stores event in MongoDB
└─ /* ──▶ your service (port N)
Each tracked subdomain has two Traefik routers:
- High priority (
/m/prefix) — routes to the analytics backend - Low priority (catch-all) — routes to the actual service, with the
rewritebodymiddleware attached
| Component | Location | Purpose |
|---|---|---|
public/s.js |
Served at /m/s.js |
Frontend analytics script (fingerprinting, SPA tracking, sendBeacon) |
backend/server.py |
Port 4000 | FastAPI backend (receives beacons, serves script, dashboard API) |
public/dashboard.html |
Served at / on analytics.maltemeng.com |
Static HTML dashboard with host filtering |
docker-compose.yml |
/root/analytics/ |
Runs backend + MongoDB |
| Traefik route files | /root/traefik/routes/*.yml |
Per-service routing + script injection |
Each page view event stores:
| Field | Type | Source | Description |
|---|---|---|---|
hostname |
string | location.hostname |
Subdomain the event came from |
path |
string | location.pathname + search |
Current page URL path |
referrer |
string | document.referrer |
Page that linked the visitor here |
visitorId |
string | djb2 hash | Fingerprint (see below) |
clientTs |
int | Date.now() |
Client-side timestamp (ms since epoch) |
serverTs |
datetime | server | Server-side UTC timestamp on receipt |
screenWidth |
int | screen.width |
Screen width in pixels |
screenHeight |
int | screen.height |
Screen height in pixels |
ip |
string | x-forwarded-for header |
Visitor IP (from Traefik/Cloudflare) |
userAgent |
string | request header | Browser user-agent string |
No cookies are used. The visitor ID is a djb2 hash of these browser properties:
| Property | Source |
|---|---|
| Language | navigator.language |
| Screen size | screen.width + 'x' + screen.height |
| Color depth | screen.colorDepth |
| Timezone | Intl.DateTimeFormat().resolvedOptions().timeZone |
| CPU cores | navigator.hardwareConcurrency |
| Platform | navigator.platform |
The script monkey-patches history.pushState and history.replaceState and listens for popstate events to capture SPA navigation without touching application code. Uses navigator.sendBeacon for reliable delivery that survives page unloads.
All served by the analytics backend on port 4000:
| Method | Path | Description |
|---|---|---|
| GET | /m/s.js |
Analytics script (cached 24h) |
| POST | /m/e |
Beacon receiver (returns 204) |
| GET | /m/dashboard?key=SECRET |
Aggregated stats JSON. Optional &host= filter |
| GET | /m/recent?key=SECRET |
Last 50 events JSON. Optional &host= filter |
| GET | /m/health |
Health check |
| GET | / |
Dashboard HTML (served on analytics.maltemeng.com) |
All public-facing paths use generic names to avoid filter lists:
- Script:
/m/s.js(notanalytics.js,tracking.js, etc.) - Beacon:
/m/e(not/api/analytics,/beacon, etc.) - Same-origin: script and beacon served from the same subdomain as the app
- No third-party domains, no known library filenames
When you have a new service to track (e.g. app.maltemeng.com on port 5000), only two things are needed:
Create /root/traefik/routes/app.yml:
http:
routers:
app-metrics:
rule: "Host(`app.maltemeng.com`) && PathPrefix(`/m/`)"
entryPoints:
- websecure
service: app-analytics
priority: 100
tls:
certResolver: letsencrypt
app:
rule: "Host(`app.maltemeng.com`)"
entryPoints:
- websecure
service: app
priority: 50
tls:
certResolver: letsencrypt
middlewares:
- app-inject-script
middlewares:
app-inject-script:
plugin:
rewritebody:
rewrites:
- regex: "</head>"
replacement: '<script defer src="/m/s.js"></script></head>'
services:
app:
loadBalancer:
servers:
- url: "http://127.0.0.1:5000"
app-analytics:
loadBalancer:
servers:
- url: "http://127.0.0.1:4000"What this does:
app-metricsrouter (priority 100) catches/m/*requests and routes them to the analytics backend on port 4000, so the script and beacon endpoint are reachableapprouter (priority 50) catches everything else and forwards to your service, with therewritebodymiddleware injecting the<script>tag before</head>- Middleware and service names must be unique across all route files (prefix with the app name)
Traefik's file watcher picks this up automatically — no restart needed.
Use the Cloudflare DNS service on this server:
curl -X POST "http://127.0.0.1:8000/api/zones/9f39f5582e0d1da2a53eba2f1ce6f8da/records" \
-H "Content-Type: application/json" \
-d '{"type":"A","name":"app","content":"87.106.219.33","proxied":true,"ttl":1}'The zone ID 9f39f5582e0d1da2a53eba2f1ce6f8da is for maltemeng.com. The server IP is 87.106.219.33. Set proxied: true to route through Cloudflare.
Nothing else changes. The analytics backend, script, dashboard, MongoDB, and traefik.yml are all shared. The new hostname will appear automatically in the dashboard host filter once events start arriving.
Three pieces work together:
In /root/traefik/traefik.yml, the plugin-rewritebody is registered under experimental.plugins. This tells Traefik to download and load the Go plugin at startup:
experimental:
plugins:
rewritebody:
moduleName: "github.com/traefik/plugin-rewritebody"
version: "v0.3.1"Each route file defines a middleware that uses the plugin to regex-replace </head> in the response body:
middlewares:
app-inject-script:
plugin:
rewritebody:
rewrites:
- regex: "</head>"
replacement: '<script defer src="/m/s.js"></script></head>'Important: The key under plugin: must be rewritebody (matching the name in static config), not the middleware name.
The middleware is referenced on the router:
routers:
app:
middlewares:
- app-inject-scriptAt runtime: Traefik forwards the request to your service → your service returns HTML → the rewritebody middleware intercepts the response, finds </head>, injects the script tag → the modified HTML reaches the browser.
- Plugin name mismatch: The key under
plugin:in the middleware definition must exactly match the plugin name intraefik.yml(rewritebody). Using a different name (e.g.rewrite-body) causes"unknown plugin type"errors. - Static config changes require restart: Modifying
traefik.yml(e.g. adding the plugin) requiresdocker compose restartin/root/traefik/. Route file changes are hot-reloaded. - Cloudflare caching: After adding injection, Cloudflare may serve cached HTML without the script tag. Purge the cache from the Cloudflare dashboard or wait for TTL expiry.
- Compression: If a service compresses responses (gzip/brotli), the rewritebody plugin handles gzip transparently. For brotli, you may need to disable compression on the service side (e.g.
compress: falsein Next.js config). - Unique names: Middleware and service names must be globally unique across all route files. Prefix with the app name (e.g.
app-inject-script,app-analytics). - CORS: The dashboard at
analytics.maltemeng.comfetches data frommaltemeng.com/m/dashboard. CORS is configured in the backend to allowanalytics.maltemeng.comandmaltemeng.comorigins.
# Start analytics backend + MongoDB
cd /root/analytics
docker compose up -d --build
# Restart Traefik (only needed after traefik.yml changes)
cd /root/traefik
docker compose restartAccessible at https://analytics.maltemeng.com. Protected by DASHBOARD_SECRET env var (set in /root/analytics/docker-compose.yml, default: changeme).
Features:
- Total views, unique visitors, daily average
- Daily views bar chart
- Traffic by host table (click to filter)
- Top pages and referrers
- Recent events feed
- Host filter buttons to drill into per-subdomain data
- Full endpoint and data collection documentation
/root/analytics/ # Analytics repo
├── docker-compose.yml # Backend + MongoDB containers
├── public/
│ ├── s.js # Frontend analytics script
│ └── dashboard.html # Dashboard UI
└── backend/
├── server.py # FastAPI application
├── requirements.txt # Python dependencies
└── Dockerfile
/root/traefik/ # Traefik config
├── traefik.yml # Static config (includes rewritebody plugin)
├── docker-compose.yml # Traefik container
└── routes/
├── homepage.yml # maltemeng.com (with injection middleware)
├── dns.yml # dns.maltemeng.com (with injection middleware)
├── analytics.yml # maltemeng.com/m/* → analytics backend
├── dashboard.yml # analytics.maltemeng.com → analytics backend
└── example.yml # Template (not active)