Audience statistics (Umami)
One self-hosted Umami counts the visitors of every ISC web site — hub, landing page, curriculum, theses, clock, timetable, this documentation — at stats.isc-vs.ch (September 2026). No cookie and no personal data, so no consent banner on any site; visitors are counted by a daily-rotated hash, never stored as an address.
At a glance
| Guest | srv-stats — VM 121 on rumba, 192.168.88.174, Debian 13, 2 cores / 2 GB / 16 GB |
| Stack | Docker Compose in /srv/app: Umami 3.3.1 (ghcr.io) + PostgreSQL 17, port 3000. A VM because Docker does not run in an unprivileged LXC (srv-docker01) |
| Published | stats.isc-vs.ch through srv-web01: /isc.js, /api/pulse, /api/heartbeat are public; everything else (the dashboard) sits behind the admin gate, then Umami's own login |
| Login | admin, password in secretzone/rumba.md → srv-stats |
| Managed from | provisioning/stats/ — deploy-stats.sh (idempotent), compose.yml, dns.sh; the vhost in provisioning/web/Caddyfile |
| Sites and ids | ISC web sites — one fixed website id per site, created by the deploy script; ISC Learn included since 2026-09-10 (Google Analytics removed) |
| Backups | the VM is in the nightly vzdump like every guest; the database is a named volume on its disk |
How a visit is counted
Each site loads https://stats.isc-vs.ch/isc.js with its data-website-id and data-domains;
the script posts page views and events to /api/pulse. Both names are deliberately not Umami's
defaults (script.js, /api/send), which every content blocklist knows — a share of visitors
still runs an ad blocker, so the numbers are a floor, not a census. Hash-routed pages (the hub)
count because the tracker hooks history.pushState.
The landing page also sends events: inscription, pibs, etudiant-un-jour,
contact-mail, projet, vers-le-hub, faq, langue, each with the section it was clicked
from. Campaign attribution (UTM tags, isc.hevs.ch/go/<slug> short links) is documented
in the hub repository: docs/campagnes.md.
Since September 2026 the tag also carries data-performance="true" on the hub, the landing page,
this documentation and ISC Learn: the same script reports the Core
Web Vitals of each page view — LCP, INP, CLS, FCP, TTFB — read under Performance as p50/p75/p95
per page. No second script and no extra request; the measurements come from real visits, so a page
appears there only once it has been loaded from the Internet.
Heatmaps and session replays are available in this version and are not enabled: they need
recorder.js, 190 KB of rrweb, on the pages whose LCP and INP are being measured.
Operations
- Add a site: web-fleet.md → Adding a site.
- Upgrade: bump the image tag in
provisioning/stats/compose.yml, rundeploy-stats.sh. It pushes the file, recreates the container, and Umami migrates its own schema at start. - Saved reports:
provisioning/stats/reports.py, run on the VM (ssh root@srv-stats.isc3 'python3 -' < provisioning/stats/reports.py,--dry-runlists what it would create). Idempotent, matched by name: funnel, goals, attribution and UTM on the landing page, journey and retention on the hub, retention on Learn. A saved report lives inside its website, one menu entry per type (/websites/<id>/funnels,/goals,/utm, …) — there is no page listing them all. - The dashboard:
provisioning/stats/dashboard.py, same way.stats.isc-vs.ch/dashboardis a board and opens empty until composed; the script writes the rows/columns the drag-and-drop editor would write — metrics bars and visitor charts for the hub, the landing page, Learn and this documentation, the landing funnel and goal, three tables. It refuses to touch a dashboard that already has rows unless given--force, so a layout rearranged by hand survives. The dashboard belongs to the account it was saved for, here the sharedadmin. - Logs:
ssh root@srv-stats.isc3 'docker logs --tail 50 umami'. - Re-run anything:
deploy-stats.shis idempotent — the VM, the secrets, the compose file and the websites are each left alone when already in place.
Traps
TRACKER_SCRIPT_NAMEis matched as an exact path: it must beisc.js, notisc.GET /api/websites/<id>answers200with anullbody for a missing website, not404. The bootstrap checks the body; a first version did not and created nothing.192.168.88.173looked free in the rumba table but belongs tosrv-freeipa(VM 106) — ping before taking an address, the table lags.