No description
  • PHP 85.2%
  • Shell 6.4%
  • Dockerfile 5.6%
  • JavaScript 2.8%
Find a file
2026-07-21 20:22:57 +02:00
config sites.yaml als Single Source of Truth für Traefik-Routing 2026-02-17 21:08:19 +00:00
cron fix: Typ-Guards in der Sync-Validierung — kaputte Site kippt nicht den ganzen Lauf 2026-07-21 20:19:08 +02:00
data Initial commit: Sammler (Collector) mit Security-Hardening und API-Doku 2026-02-10 21:13:57 +00:00
docker fix: CMS-Sync und Traefik-Generierung verketten, README-Rollout korrigiert 2026-07-21 20:21:45 +02:00
docs Config-Umbau: YAML + ENV statt PHP-Array 2026-02-17 15:44:56 +00:00
public Docker-Container: Compose-Setup mit PHP-FPM, NGINX, Redis 2026-02-17 16:55:06 +00:00
src feat: Sites-Konfiguration aus dem CMS nachziehen (Pull-Modell) 2026-07-21 19:51:12 +02:00
traefik Docker-Hardening: Capabilities einschränken, Memory-Limits, Traefik-Beispiel 2026-03-23 12:22:51 +01:00
.dockerignore Docker-Container: Compose-Setup mit PHP-FPM, NGINX, Redis 2026-02-17 16:55:06 +00:00
.env.example feat: Sites-Konfiguration aus dem CMS nachziehen (Pull-Modell) 2026-07-21 19:51:12 +02:00
.gitignore README ergänzt, CLAUDE.md aus Git entfernt 2026-02-17 21:42:35 +00:00
composer.json Config-Umbau: YAML + ENV statt PHP-Array 2026-02-17 15:44:56 +00:00
composer.lock Config-Umbau: YAML + ENV statt PHP-Array 2026-02-17 15:44:56 +00:00
docker-compose.yml Merge branch 'feat/cms-config-sync' 2026-07-21 20:22:57 +02:00
Dockerfile Traefik-Certs robuster: ein Cert pro Domain, DNS-Preflight, Cron-Regeneration 2026-07-02 13:56:31 +02:00
README.md fix: CMS-Sync und Traefik-Generierung verketten, README-Rollout korrigiert 2026-07-21 20:21:45 +02:00

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

  1. Browser sendet Hit via lib.js an POST /collect
  2. Validierung: JSON parsen, Site-ID bekannt?, CORS-Origin prüfen, Rate-Limit prüfen
  3. Sofort HTTP 204 antworten (fastcgi_finish_request()) — der Browser ist fertig
  4. 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 — gibt true zurück wenn neu → is_entry = true
  • Bei bestehendem Key: EXPIRE refreshen (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_domain zeigt (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>
  • src zeigt auf den Sammler (oder den CNAME, siehe unten)
  • data-site muss dem Key unter sites: in der sites.yaml entsprechen
  • Das Snippet erkennt automatisch den Endpunkt aus seiner eigenen URL
  • Wird per defer geladen und blockiert nicht das Rendering
  • Feuert einmal bei Page Load (kein SPA-Support)
  • sendBeacon als 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

  1. DNS: CNAME-Record anlegen, der auf die tracker_domain zeigt:

    t.meine-website.de  CNAME  tracking.example.com
    
  2. 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
    
  3. Container neu starten — die Traefik-Config wird automatisch neu generiert, inklusive TLS-Zertifikat für den CNAME:

    docker compose restart app
    
  4. Snippet anpassensrc auf 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

  1. 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>
    
  2. 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 (im sammler_data-Volume).
  • config/sites.yaml hat 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_SECRET passiert 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:

  1. config/sites.yaml editieren (Site + Domains + optional CNAME + API-Key-Zuordnung)
  2. Beim Site-Betreiber den CNAME auf die tracker_domain einrichten 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:

  1. Kostenlosen MaxMind-Account anlegen
  2. In .env eintragen:
    GEOIP_LEVEL=city
    MAXMIND_LICENSE_KEY=dein-license-key
    
    Mögliche Werte für GEOIP_LEVEL: off (Standard), country, region, city
  3. 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 (15000, 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. 400600 Bytes.

Szenario Hits/Tag 24h Speicher
Klein (1 Site, wenig Traffic) 50.000 ~25 MB
Mittel (35 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

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)