- PHP 85.2%
- Shell 6.4%
- Dockerfile 5.6%
- JavaScript 2.8%
| config | ||
| cron | ||
| data | ||
| docker | ||
| docs | ||
| public | ||
| src | ||
| traefik | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| composer.json | ||
| composer.lock | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
Sammler
Leichtgewichtiges, DSGVO-konformes Echtzeit-Tracking-System. Erfasst Seitenaufrufe, anonymisiert Daten und stellt sie per Pull-API bereit.
- Kein Cookie-Banner nötig (cookieless, consent-frei)
- IP-Adressen werden nie gespeichert (HMAC-Anonymisierung mit tagesaktuellem Salt)
- Tracking-Snippet < 1 KB, ein einziger Script-Tag
- Self-hosted mit Docker Compose
- Automatische Bot-Filterung (Matomo DeviceDetector)
- Rate-Limiting: 60 Hits/min pro IP
- Events werden 24 Stunden vorgehalten, danach automatisch bereinigt
Architektur
Browser --> lib.js --> POST /collect --> Sammler (PHP-FPM + NGINX + Redis)
|
Pull-API <-- Auswerter
Das System besteht aus zwei getrennten Komponenten:
- Sammler (Collector): Empfängt Hits von Webseiten, anonymisiert, speichert in Redis Streams, stellt Pull-API bereit. (dieses Repository)
- Auswerter (Analyzer): Separates System, pullt Events vom Sammler, speichert und visualisiert.
Request-Flow
- Browser sendet Hit via
lib.jsanPOST /collect - Validierung: JSON parsen, Site-ID bekannt?, CORS-Origin prüfen, Rate-Limit prüfen
- Sofort HTTP 204 antworten (
fastcgi_finish_request()) — der Browser ist fertig - Hintergrund-Verarbeitung: Bot-Filter → GeoIP-Lookup → Token generieren → Session erkennen → Event in Redis Stream schreiben
Anonymisierung
- Token:
hash_hmac('sha256', $ip . $userAgent, $dailySalt)— anonymer 64-Zeichen-Hex-String - Die IP-Adresse wird nur im RAM für GeoIP-Lookup und Token-Berechnung verwendet, nie in Redis oder auf Disk gespeichert
- Der Salt rotiert täglich um Mitternacht — bestehende Sessions werden unterbrochen, Tokens ändern sich. Bei einem 30-Minuten-Fenster betrifft das nur aktive Nacht-User.
Session-Erkennung
Sliding-Window mit 30 Minuten Timeout:
SET session:{site_id}:{token} 1 NX EX 1800— gibttruezurück wenn neu →is_entry = true- Bei bestehendem Key:
EXPIRErefreshen (Sliding Window) - Wenn der Key abläuft, wird der nächste Hit als neue Session gewertet
Voraussetzungen
- Docker + Docker Compose
- Reverse Proxy mit TLS-Terminierung (Traefik, NGINX, Caddy o.a.)
- DNS-Zugriff für Tracking-Domains
Installation
1. Repository klonen
git clone https://git.quinta.dev/keksdestodes/intraday-analytics.git
cd intraday-analytics
2. Umgebungsvariablen
cp .env.example .env
In .env ein Redis-Passwort eintragen und Dateiberechtigungen setzen:
REDIS_PASSWORD=<ein sicheres Passwort> # z.B. mit: openssl rand -base64 32
chmod 600 .env # Passwort nur für den eigenen User lesbar
3. Sites konfigurieren
cp config/sites.example.yaml config/sites.yaml
Die Datei config/sites.yaml ist die zentrale Konfiguration:
# Domain, unter der der Sammler erreichbar ist
tracker_domain: tracking.example.com
sites:
meine-website:
name: Meine Website
domains:
- meine-website.de
- www.meine-website.de
cname: t.meine-website.de
zweite-website:
name: Zweite Website
domains:
- zweite-website.de
- www.zweite-website.de
api_keys:
<api-key-hier>: # z.B. mit: openssl rand -hex 32
name: Auswerter Produktion
sites:
- meine-website
- zweite-website
Felder im Detail
| Feld | Pflicht | Beschreibung |
|---|---|---|
tracker_domain |
Ja | Domain, unter der der Sammler erreichbar ist (z.B. tracking.example.com) |
sites.<id> |
Ja | Eindeutiger Bezeichner der Site (nur a-z, 0-9, -, _). Wird im Tracking-Snippet als data-site verwendet. |
sites.<id>.name |
Ja | Anzeigename der Site (wird in der API zurückgegeben) |
sites.<id>.domains |
Ja | Liste der Website-Domains. Dient der CORS-Validierung — Hits von anderen Origins werden abgelehnt. Alle Domains eintragen, von denen getrackt wird (mit und ohne www). |
sites.<id>.cname |
Nein | Eigene Tracking-Subdomain für diese Site (siehe CNAME-Tracking) |
api_keys.<key> |
Ja | Bearer-Token für die Pull-API. Der Key selbst ist das Token. |
api_keys.<key>.name |
Ja | Anzeigename des API-Keys |
api_keys.<key>.sites |
Ja | Liste der Site-IDs, auf die dieser Key Zugriff hat |
4. Starten
docker compose up -d
5. Prüfen
# Health-Check
curl https://tracking.example.com/api/health
# Erwartete Antwort: {"status":"ok","redis":"connected","api_version":2}
Reverse Proxy
Der Sammler-Container lauscht auf Port 80 (HTTP). TLS wird extern durch einen Reverse Proxy terminiert. Es gibt zwei Varianten:
Variante A: Traefik (automatisch)
Der Sammler generiert automatisch eine Traefik-Routing-Config aus sites.yaml (inkl. tracker_domain und aller CNAMEs) — beim Container-Start und danach alle 5 Minuten per Cron. Traefik liest diese per File-Provider und richtet Routing + TLS-Zertifikate automatisch ein.
Dabei gilt:
- Ein Router (= eigenes Zertifikat) pro Domain. Eine Domain mit kaputtem DNS kann die Zertifikate der anderen Sites nicht gefährden.
- DNS-Preflight: Ein CNAME wird erst aufgenommen, wenn er tatsächlich auf die
tracker_domainzeigt (CNAME-Ziel oder identische A/AAAA-Records). Solange das DNS fehlt, wird die Domain mit Warnung übersprungen (docker logs sammler) und automatisch nachgezogen, sobald es steht — kein Neustart nötig.
Traefik vorbereiten — eine gehärtete Beispielkonfiguration liegt unter traefik/docker-compose.example.yaml (mit Docker-Socket-Proxy, Capability-Dropping, Memory-Limits und Access-Logging). Kopieren und anpassen:
mkdir -p /opt/traefik
cp traefik/docker-compose.example.yaml /opt/traefik/docker-compose.yaml
# E-Mail-Adresse für Let's Encrypt anpassen
touch /opt/traefik/acme.json && chmod 600 /opt/traefik/acme.json
sudo mkdir -p /var/log/traefik
Falls du eine bestehende Traefik-Installation nutzt, stelle sicher, dass der File-Provider konfiguriert ist:
services:
traefik:
command:
# ... bestehende Flags ...
- "--providers.file.directory=/etc/traefik/dynamic"
- "--providers.file.watch=true"
volumes:
# ... bestehende Volumes ...
- traefik_dynamic:/etc/traefik/dynamic:ro
volumes:
traefik_dynamic:
external: true
Reihenfolge: Erst den Sammler starten (erzeugt das traefik_dynamic Volume), dann Traefik neu starten.
# Im Sammler-Verzeichnis:
docker compose up -d
# Traefik neu starten:
cd /opt/traefik && docker compose restart
Die generierte Config kann geprüft werden mit:
docker exec sammler cat /app/traefik/dynamic.yaml
Bei Änderungen an sites.yaml genügt ein docker compose restart app — Traefik erkennt die neue Config automatisch.
Variante B: NGINX, Caddy oder anderer Proxy (manuell)
Wenn kein Traefik verwendet wird, einfach den Reverse Proxy manuell konfigurieren. Der Sammler-Container muss unter allen konfigurierten Domains erreichbar sein (tracker_domain + alle cname-Werte).
In docker-compose.yml die Traefik-spezifischen Teile anpassen:
services:
app:
# ...
labels: [] # Traefik-Labels entfernen
ports:
- "127.0.0.1:8080:80" # Lokalen Port freigeben
volumes:
- ./config/sites.yaml:/app/config/sites.yaml:ro
- sammler_data:/app/data
# traefik_dynamic Volume entfällt
networks:
- internal # Nur internes Netzwerk nötig
Beispiel NGINX (als externer Reverse Proxy):
server {
listen 443 ssl;
server_name tracking.example.com t.meine-website.de;
ssl_certificate /etc/letsencrypt/live/tracking.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tracking.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Beispiel Caddy (automatisches TLS):
tracking.example.com, t.meine-website.de {
reverse_proxy 127.0.0.1:8080
}
Tracking-Snippet
Auf der zu trackenden Website einen Script-Tag einbauen:
<script src="https://tracking.example.com/lib.js" data-site="meine-website" defer></script>
srczeigt auf den Sammler (oder den CNAME, siehe unten)data-sitemuss dem Key untersites:in dersites.yamlentsprechen- Das Snippet erkennt automatisch den Endpunkt aus seiner eigenen URL
- Wird per
defergeladen und blockiert nicht das Rendering - Feuert einmal bei Page Load (kein SPA-Support)
sendBeaconals primärer Sendemechanismus, XHR als Fallback- Keine Cookies, kein localStorage
Erfasste Daten (Payload)
| Feld | Key | Beispiel | Beschreibung |
|---|---|---|---|
| Site-ID | s |
"meine-website" |
Identifikator der Website |
| Pfad | p |
"/artikel/beispiel" |
window.location.pathname |
| Titel | t |
"Beispiel-Artikel" |
document.title |
| H1-Überschrift | h |
"Beispiel-Artikel" |
Erste <h1> der Seite |
| Referrer | r |
"https://google.de" |
document.referrer |
| UTM-Parameter | u |
{"utm_source":"newsletter"} |
Objekt mit gefundenen UTM-Parametern |
| Sprache | l |
"de-DE" |
navigator.language, max. 5 Zeichen |
CNAME-Tracking
Standardmäßig laufen alle Hits über die zentrale tracker_domain. Mit CNAMEs kann jede Site ihren eigenen Tracking-Endpunkt unter einer Subdomain der eigenen Website bekommen.
Vorteil: Adblocker blockieren seltener First-Party-Subdomains.
Einrichtung
-
DNS: CNAME-Record anlegen, der auf die
tracker_domainzeigt:t.meine-website.de CNAME tracking.example.com -
sites.yaml:
cname-Feld bei der Site eintragen:sites: meine-website: name: Meine Website domains: - meine-website.de - www.meine-website.de cname: t.meine-website.de -
Container neu starten — die Traefik-Config wird automatisch neu generiert, inklusive TLS-Zertifikat für den CNAME:
docker compose restart app -
Snippet anpassen —
srcauf den CNAME ändern:<script src="https://t.meine-website.de/lib.js" data-site="meine-website" defer></script>
Bei Nutzung ohne Traefik muss der CNAME manuell im Reverse Proxy als server_name bzw. Domain ergänzt werden.
CMS-Sync (Sites automatisch übernehmen)
Statt jede Site von Hand in die sites.yaml zu schreiben, kann der Sammler sich
Sites, Domains, CNAMEs und API-Keys beim CMS abholen. Das CMS pflegt diese Daten
ohnehin — so gibt es keine zweite Liste, die auseinanderläuft.
Pull, nicht Push: Der Sammler holt ab, das CMS schreibt nicht. Damit bleibt
config/sites.yaml read-only gemountet, und der Sammler bekommt keinen
Schreibpfad in seine eigene Sicherheitskonfiguration (dort stehen CORS-Domains
und Auth-Keys). Ist das CMS nicht erreichbar, läuft der Sammler mit dem zuletzt
geholten Stand weiter.
Einrichtung
-
Im CMS ein Secret setzen (
TRACKER_SYNC_SECRET) und dasselbe hier eintragen:CMS_SYNC_URL=https://one-stack.ai/api/v1/tracker/config CMS_SYNC_SECRET=<dasselbe Secret wie im CMS> -
docker compose up -d --build— Cron-Script und Crontab stecken im Image, beim ersten Rollout also einmal neu bauen. Danach zieht der Cron alle 5 Minuten nach.
Verhalten
- Geschrieben wird nach
/app/data/sites-remote.yaml(imsammler_data-Volume). config/sites.yamlhat immer Vorrang. Ein dort von Hand gepflegter Eintrag wird nie überschrieben — praktisch für Sites, die nicht aus dem CMS kommen.- Geschrieben wird atomar und nur bei inhaltlicher Änderung.
- Antworten ohne gültige Site oder mit unerwartetem Format werden verworfen; die bestehende Konfiguration bleibt dann stehen.
- Ohne
CMS_SYNC_URL/CMS_SYNC_SECRETpassiert nichts (Sync aus).
Stand prüfen:
docker exec sammler php /app/cron/sync-sites.php # einmalig, zeigt die Anzahl
docker exec sammler cat /app/data/sites-remote.yaml
Neue Site hinzufügen
Bei aktivem CMS-Sync entfällt Schritt 1 — dort genügt die Site-ID im CMS. Manuell:
config/sites.yamleditieren (Site + Domains + optional CNAME + API-Key-Zuordnung)- Beim Site-Betreiber den CNAME auf die
tracker_domaineinrichten lassen
Das war's — kein Neustart nötig. Die Site selbst ist sofort aktiv (sites.yaml wird pro Request gelesen); die Traefik-Routing-Config inkl. TLS-Zertifikat zieht der Cron innerhalb von 5 Minuten nach, sobald das DNS steht. Fortschritt prüfen: docker logs sammler (DNS-Warnungen) und docker exec sammler cat /app/traefik/dynamic.yaml.
GeoIP (optional)
Für Geo-Lokalisierung der Besucher:
- Kostenlosen MaxMind-Account anlegen
- In
.enveintragen:
Mögliche Werte fürGEOIP_LEVEL=city MAXMIND_LICENSE_KEY=dein-license-keyGEOIP_LEVEL:off(Standard),country,region,city - Container neu starten — die Datenbank wird beim ersten Start automatisch heruntergeladen.
Pull-API
Alle API-Endpunkte antworten mit JSON (Content-Type: application/json). Jede Response enthält den Header X-API-Version: 2. Authentifizierte Endpunkte erwarten einen Authorization: Bearer <api-key> Header.
GET /api/health
Kein Auth nötig. Prüft die Redis-Verbindung.
curl https://tracking.example.com/api/health
Response:
{
"status": "ok",
"redis": "connected",
"api_version": 2
}
GET /api/sites
Listet alle Sites auf, auf die der API-Key Zugriff hat.
curl -H "Authorization: Bearer <api-key>" \
https://tracking.example.com/api/sites
Response:
{
"sites": [
{ "id": "meine-website", "name": "Meine Website" },
{ "id": "zweite-website", "name": "Zweite Website" }
]
}
GET /api/events
Ruft Events aus dem Stream einer Site ab. Cursor-basierte Pagination.
Parameter:
| Parameter | Pflicht | Beschreibung |
|---|---|---|
site_id |
Ja | Site-ID (z.B. meine-website) |
since |
Nein | Cursor vom vorherigen Request (Format: <timestamp>-<sequence>). Ohne since werden Events ab Beginn geliefert. |
count |
Nein | Anzahl Events pro Request (1–5000, Standard: 1000) |
# Erster Abruf:
curl -H "Authorization: Bearer <api-key>" \
"https://tracking.example.com/api/events?site_id=meine-website"
# Folgeabruf mit Cursor:
curl -H "Authorization: Bearer <api-key>" \
"https://tracking.example.com/api/events?site_id=meine-website&since=1708123456789-0"
Response:
{
"site_id": "meine-website",
"events": [
{
"id": "1708123456789-0",
"token": "a1b2c3...",
"path": "/artikel/beispiel",
"title": "Beispiel-Artikel",
"h1": "Beispiel-Artikel",
"referrer": "https://google.com",
"utm_source": "newsletter",
"utm_medium": "email",
"utm_campaign": "feb2025",
"utm_term": "",
"utm_content": "",
"country": "DE",
"region": "Bayern",
"city": "München",
"device": "desktop",
"browser": "Firefox",
"os": "Windows",
"language": "de-DE",
"is_entry": true
}
],
"cursor": "1708123456789-0",
"has_more": false
}
Event-Felder:
| Feld | Beschreibung |
|---|---|
id |
Redis Stream ID (Timestamp in ms + Sequenz). Dient als Cursor für Pagination. |
token |
Anonymer Besucher-Token (HMAC-SHA256, rotiert täglich mit dem Salt) |
path |
Seitenpfad (max. 500 Zeichen) |
title |
Seitentitel (max. 200 Zeichen) |
h1 |
Erste H1-Überschrift der Seite (max. 200 Zeichen) |
referrer |
HTTP Referrer (max. 500 Zeichen) |
utm_source |
UTM Source (max. 200 Zeichen) |
utm_medium |
UTM Medium (max. 200 Zeichen) |
utm_campaign |
UTM Campaign (max. 200 Zeichen) |
utm_term |
UTM Term (max. 200 Zeichen) |
utm_content |
UTM Content (max. 200 Zeichen) |
country |
Ländercode, z.B. DE (leer wenn GeoIP deaktiviert) |
region |
Region/Bundesland (leer bei GEOIP_LEVEL=country oder off) |
city |
Stadt (nur bei GEOIP_LEVEL=city) |
device |
Gerätetyp: desktop, smartphone, tablet etc. |
browser |
Browser-Name (max. 100 Zeichen) |
os |
Betriebssystem (max. 100 Zeichen) |
language |
Browser-Sprache, z.B. de-DE |
is_entry |
true wenn dies der erste Hit einer neuen Session ist (30-Min-Fenster) |
Response-Felder:
| Feld | Beschreibung |
|---|---|
site_id |
Die abgefragte Site-ID |
events |
Array der Events seit since |
cursor |
Stream-ID des letzten Events — beim nächsten Request als since übergeben |
has_more |
true wenn mehr Events vorhanden sind als count erlaubt — sofort nochmal pullen |
Fehler-Responses:
| HTTP Status | Error | Beschreibung |
|---|---|---|
| 400 | missing_parameter |
site_id fehlt |
| 400 | invalid_parameter |
site_id oder since hat ungültiges Format |
| 401 | invalid_api_key |
API-Key fehlt oder ungültig |
| 403 | access_denied |
API-Key hat keinen Zugriff auf diese Site |
| 404 | site_not_found |
Site-ID existiert nicht |
POST /collect
Wird vom Tracking-Snippet aufgerufen. Nicht für den direkten Einsatz gedacht.
- Erwartet JSON-Body mit Site-ID, Pfad, Titel, Referrer etc.
- Antwortet sofort mit HTTP 204 (kein Body)
- Verarbeitung läuft im Hintergrund nach
fastcgi_finish_request() - CORS: Nur Origins von registrierten Domains erlaubt
- Rate-Limit: 60 Hits/min pro IP, danach HTTP 429
- Bots (erkannt via DeviceDetector) werden still verworfen
Auswerter-Integration (Pull-Workflow)
Der empfohlene Workflow für ein Auswerter-System:
Auswerter startet
|
v
GET /api/sites --> Kennt seine Sites
|
v
Für jede Site: cursor = "" (oder letzter gespeicherter Cursor)
|
v
+--- Loop (alle 30-60 Sekunden) ---+
| |
| GET /api/events?site_id=X |
| &since={cursor} |
| &count=1000 |
| | |
| v |
| Events verarbeiten & speichern |
| cursor = response.cursor |
| | |
| v |
| has_more == true? |
| Ja --> sofort nochmal pullen |
| Nein --> warten, nächster Loop |
| |
+------------------------------------+
Der Auswerter muss den letzten Cursor pro Site persistent speichern (DB/Datei), damit er nach einem Neustart nicht alle Events erneut lädt.
Redis-Internals
Alle Keys sind mit dem Prefix tracker: versehen.
| Key | Typ | TTL | Beschreibung |
|---|---|---|---|
events:{site_id} |
Stream | 24h (Cronjob) | Event-Stream pro Site |
session:{site_id}:{token} |
String ("1") |
30 min (sliding) | Session-Erkennung |
rate:{ip_hash} |
Counter | 60 s | Rate-Limiting pro IP |
daily_salt |
String | — (Datei) | HMAC-Salt, täglich rotiert |
Speicherverbrauch (Schätzung)
Pro Event im Stream: ca. 400–600 Bytes.
| Szenario | Hits/Tag | 24h Speicher |
|---|---|---|
| Klein (1 Site, wenig Traffic) | 50.000 | ~25 MB |
| Mittel (3–5 Sites) | 500.000 | ~250 MB |
| Groß (10+ Sites, viel Traffic) | 5.000.000 | ~2,5 GB |
Session-Keys sind vernachlässigbar (~100 Bytes pro Key, max. 30 Min TTL).
DSGVO
Warum consent-frei
Das System erfüllt die Voraussetzungen für eine Verarbeitung ohne Einwilligung (Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse):
| Punkt | Umsetzung |
|---|---|
| Keine Cookies | Snippet setzt keine Cookies, kein localStorage |
| Keine IP-Speicherung | IP wird nur im RAM für GeoIP-Lookup und Token-Hashing verwendet, nie auf Disk/Redis |
| Anonyme Tokens | hash_hmac(ip + ua, daily_salt) ist nicht rückverfolgbar, Salt rotiert täglich |
| Keine Profilbildung | 24h Retention, keine historischen Daten, keine Cross-Site-Verknüpfung |
| Keine personenbezogenen Daten gespeichert | Nur: Pfad, Titel, Referrer, UTM, Land/Region, Gerätetyp, Sprache, anonymer Token |
| Kein Fingerprinting | Token dient nur der Session-Erkennung im 30-Min-Fenster, nicht der Wiedererkennung |
Was trotzdem nötig ist
- Datenschutzerklärung der getrackten Website muss das Tracking erwähnen (Art. 13 DSGVO): Zweck, Rechtsgrundlage, keine personenbezogene Datenspeicherung, 24h Retention
- Auftragsverarbeitungsvertrag (AVV) wenn der Sammler auf fremder Infrastruktur läuft
- Verarbeitungsverzeichnis (Art. 30 DSGVO) pflegen
Datenfluss
Browser des Besuchers
|
| POST /collect (IP im TCP-Header, User-Agent im HTTP-Header)
|
v
Sammler (PHP im RAM)
|
+-- IP --> GeoIP-Lookup --> country + region --+
| (IP wird danach verworfen) |
| |
+-- IP + UA --> HMAC + Salt --> Token ----------+
| (IP wird danach verworfen) |
| |
+-- Payload-Daten (Pfad, Titel, ...) ----------+
|
v
Redis Stream
(nur anonyme Daten,
keine IP, kein UA,
24h TTL)