Here’s a clean, copy-pasteable usage guide for your containerized FastAPI visitor tracker (with Web UI, stats, CSV, and an invisible pixel).
visitor-tracker-with-ui.zip.unzip visitor-tracker-with-ui.zip
cd visitor-tracker
docker compose up --buildWhat this does:
Health check:
curl http://localhost:8000/healthz
# {"status":"ok"}| Endpoint | Method | Purpose |
|---|---|---|
/pixel |
GET | Logs a visit (returns 1×1 GIF). For invisible image/fetch embeds. |
/collect |
POST | Alternative logging endpoint (accepts JSON body). |
/tags |
GET | Returns known tags & counts. |
/stats |
GET | Returns bucketed time-series (hour/day/week/month) with filters. |
/visits.json |
GET | Paginated table API (filters + search + sort). |
/visits.csv |
GET | CSV export for current filters. |
/dashboard |
GET | Web UI: interactive Chart.js time-series. |
/visits |
GET | Web UI: paginated table + CSV download. |
/healthz |
GET | Health probe. |
Replace
TRACK_DOMAINandTAG.
<script>
(function () {
var url = "https://TRACK_DOMAIN/pixel?t=TAG&u=" + encodeURIComponent(location.href);
if (window.fetch && window.AbortController) {
var c = new AbortController();
setTimeout(function(){ try{ c.abort(); }catch(e){} }, 1000);
fetch(url, { mode: "no-cors", cache: "no-store", keepalive: false, signal: c.signal })
.catch(function(){});
return;
}
var img = new Image();
img.decoding = "async";
img.loading = "eager";
img.src = url;
})();
</script>
<noscript>
<img src="https://TRACK_DOMAIN/pixel?t=TAG" width="1" height="1" style="display:none" alt="" />
</noscript>Local testing: use http://localhost:8000 as TRACK_DOMAIN.
HTTPS sites: don’t mix https pages with http tracker—browsers will block mixed content.
curl⚠️ Use GET (do not use
-I/HEAD). HEAD returns 405 by design.
# Home
curl "http://localhost:8000/pixel?t=home&u=http%3A%2F%2Flocalhost%2F"
# Pricing with referrer and spoofed client IP
curl -H "Referer: https://google.com" -H "X-Forwarded-For: 203.0.113.77" \
"http://localhost:8000/pixel?t=pricing&u=http%3A%2F%2Flocalhost%2Fpricing"
# Blog
curl "http://localhost:8000/pixel?t=blog&u=http%3A%2F%2Flocalhost%2Fblog%2Fpost-42"curl "http://localhost:8000/tags"
# e.g. [{"tag":"home","count":1},{"tag":"pricing","count":1},{"tag":"blog", "count":1}]
curl "http://localhost:8000/visits.json"
# {"page":1,"page_size":50,"total":3,"items":[...]}# All tags, daily buckets
curl "http://localhost:8000/stats?interval=day"
# Only 'pricing' for a range (inclusive)
curl "http://localhost:8000/stats?interval=hour&tags=pricing&start=2025-08-01T00:00:00&end=2025-08-31T23:59:59"# Newest first (default), 50 per page
curl "http://localhost:8000/visits.json?page=1&page_size=50"
# Filter by tags and date
curl "http://localhost:8000/visits.json?tags=home&tags=pricing&start=2025-08-01&end=2025-08-31"
# Text search in url/referrer/user-agent
curl "http://localhost:8000/visits.json?search=google"curl -o visits.csv "http://localhost:8000/visits.csv?tags=pricing"
# Open visits.csv in Excel/SheetsDashboard (graph): http://localhost:8000/dashboard
YYYY-MM-DD or full ISO YYYY-MM-DDTHH:MM:SS.Tabular view: http://localhost:8000/visits
/pixel (GET)Query:
t — tag (e.g., home, pricing)u — full page URL (optional but recommended)Response: 1×1 GIF (image/gif), cache-disabled.
/collect (POST)t (tag){"url": "https://example.com/page"}/stats (GET)Query:
tags=tag1&tags=tag2 (repeatable)interval=hour|day|week|month (default day)start, end — ISO date/datetime (2025-08-01 or 2025-08-01T00:00:00)limit — max buckets (default 5000)Response:
{
"interval": "day",
"tags": ["pricing"],
"series": [
{"bucket":"2025-08-20","count":12},
...
]
}/visits.json (GET)Query:
tags (repeatable), start, endpage (default 1), page_size (1..500, default 50)sort=ts_desc|ts_asc (default ts_desc)search — substring search in url/referrer/user-agentResponse:
{
"page":1,
"page_size":50,
"total":123,
"items":[
{
"id": 42,
"ts": "2025-08-20T03:40:12.123456",
"page_tag": "pricing",
"url": "https://mysite.com/pricing",
"referrer": "https://google.com",
"ip": "203.0.113.77",
"user_agent": "Mozilla/5.0 ...",
"accept_lang": "en-US,en;q=0.9"
}
]
}/visits.csv (GET)Same filters as /visits.json; returns a CSV file.
/pixelYou likely used curl -I (HEAD). The route is GET only. Use curl "http://.../pixel?... (no -I).
SQLite needs a directory that exists & is writable. The provided docker-compose.yml mounts a directory and uses:
DATABASE_URL=sqlite:////app/data/visits.dbIf you changed it, ensure the parent folder exists or revert to the compose defaults.
/dashboard or /visits/pixel a few times (GET), then re-check /visits.json.Remove date filters until you see rows, then add specific ranges:
start=2025-08-01T00:00:00&end=2025-08-31T23:59:59Your site is HTTPS, but pixel is HTTP (localhost). Use HTTPS in production (reverse proxy like Caddy/Nginx with Let’s Encrypt).
Forward X-Forwarded-For or X-Real-IP. The app already reads both and falls back to request.client.host.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: tracker
POSTGRES_PASSWORD: trackerpass
POSTGRES_DB: tracker
volumes:
- pg_data:/var/lib/postgresql/data
tracker:
build: .
depends_on: [db]
ports: ["8000:8000"]
environment:
DATABASE_URL: "postgresql+psycopg://tracker:trackerpass@db:5432/tracker"
volumes:
pg_data:The app already uses date_trunc on Postgres for bucketing; no code changes required.
visits table
id (int, PK)ts (datetime, UTC)page_tag (str, index)url (str)referrer (str)ip (str, index)user_agent (str)accept_lang (str)/dashboard and /visits if the tracker is public-facing.Seed a lot of visits quickly:
for i in $(seq 1 10); do curl -s "http://localhost:8000/pixel?t=home&u=http%3A%2F%2Flocalhost%2F" >/dev/null; doneNewest 50 rows:
curl "http://localhost:8000/visits.json?page=1&page_size=50"Only “pricing” this month:
curl "http://localhost:8000/visits.json?tags=pricing&start=$(date -u +%Y-%m-01T00:00:00Z)&end=$(date -u +%Y-%m-%dT23:59:59Z)"CSV of last 7 days:
START=$(date -u -v-7d +%Y-%m-%d 2>/dev/null || date -u -d '-7 days' +%F)
curl -o visits_last7.csv "http://localhost:8000/visits.csv?start=$START"If you want, I can add quick presets on /dashboard (Last 24h / 7d / 30d buttons), Basic Auth for the UI, and IST bucketing (tz_offset_minutes=330).