QR-Link-Tracking — Impression, Confirmed, Privacy
QR-Links liefern Statistiken — wie viele Menschen scannen einen Code, wer klickt weiter, woher kommen sie? SpeamCore baut diese Auswertung auf einem zweistufigen Modell auf, das DSGVO-konformes Tracking ohne Personenbezug ermöglicht. Dieser Artikel erklärt die Architektur, Cookie-Lebenszeit und Privacy-Garantien.
Die zwei Scan-Arten
Jeder Scan eines QR-Codes löst potenziell zwei Datensätze in der Tabelle qr_link_scans aus:
Art (kind) | Wann? | Cookie | IP-Hash | UA / Geo / Device |
|---|---|---|---|---|
impression | sobald die Landing-Page /q/:slug aufgerufen wird | nein, neue UUID pro Aufruf | nein | ja |
confirmed | wenn der User auf „Fortfahren" klickt und auf die Ziel-URL weitergeleitet wird | ja, persistenter qr_device_id (1 Jahr HttpOnly) | ja, mit täglich rotierendem Salt | ja |
Warum zwei Stufen? Eine reine „Page-View"-Statistik wäre verzerrt — viele Scans führen aus Versehen oder bewusst nicht zum eigentlichen Ziel. Das Confirmed-Modell zwingt zu einer expliziten User-Aktion und ermöglicht damit:
- Klar getrennte Reichweite (Impressions) vs. Conversion (Confirmed).
- Unique Devices verlässlich nur auf Confirmed-Basis zählen.
- Eine Einwilligungs-Flow-Geste vor dem persistenten Cookie — der User sieht den Konsent-Hinweis und klickt aktiv zu.
Der Public-Flow
Die Public-Route /q/:slug läuft ohne Auth, ohne App-Layout — also auch komplett ohne SpeamCore-Login. Marketing-Empfänger müssen keine Accounts haben.
Privacy-Garantien
Anonyme Impression
Der erste Track-Aufruf legt eine frische UUID als deviceCookieId an — der User wird zwischen einzelnen Impressions also nicht wiedererkannt. Das verhindert Profilbildung beim bloßen Anschauen der Landing-Page.
Zusätzlich:
- Kein IP-Hash beim Impression-Track.
- Trotzdem werden anonyme Demografie-Felder (Browser, OS, Device, Country) erfasst, weil sie aus dem User-Agent-Header und Cloudflare-Geo-Header kommen und keinen Personenbezug haben.
Confirmed mit Device-Cookie
Erst beim Klick auf „Fortfahren" setzt der Backend-Service einen HttpOnly-Cookie qr_device_id mit der UUID + SameSite=Lax + 1 Jahr Lebenszeit. Bei wiederholten Confirmed-Scans desselben Browsers wird die Cookie-UUID wiederverwendet — so entsteht der Unique-Devices-Count.
Der Cookie ist:
- HttpOnly — nicht aus JavaScript lesbar (kein XSS-Vektor).
- SameSite=Lax — kommt nicht bei Cross-Site-Embeds mit.
- Pro SpeamCore-Mandanten-Domain — kein domainübergreifendes Tracking.
IP-Hashing mit täglichem Salt
Beim Confirmed-Scan wird die IP-Adresse als Hash gespeichert — nicht im Klartext und nicht persistent verkettbar:
dailySalt = QR_IP_SALT::YYYYMMDD
ipHash = HMAC-SHA256(ip, dailySalt) [64 Zeichen]
Die QR_IP_SALT-Konstante lebt nur im Server-Env. Jeden Tag rotiert das YYYYMMDD-Postfix — der Hash einer IP am 19.05. unterscheidet sich vom Hash derselben IP am 20.05..
Konsequenz:
- Tages-Dedup ist möglich (z. B. wiederholte Confirmed-Scans vom selben Anschluss am gleichen Tag).
- Persistente Verfolgung über mehrere Tage hinweg ist ausgeschlossen — selbst der Datenbank-Inhalt erlaubt keine Wieder-Verkettung.
Was NICHT gespeichert wird
- Klartext-IP (weder im DB-Datensatz noch in den Logs der Resolve-/Impression-/Confirm-Endpoints).
- Auth-Token oder Identifikatoren des Scannenden (Public-Route hat keine Session).
- Browser-Fingerprints (Canvas, WebGL, AudioContext) — SpeamCore verwendet nichts davon.
- Geo-Daten unterhalb Land-Granularität.
Was die Statistik zeigt
Der Stats-Endpoint GET /api/qr-links/:id/stats?from=&to= aggregiert ausschließlich anonym:
| KPI | Berechnung | Aussagekraft |
|---|---|---|
totalImpressions | COUNT(kind = 'impression') | Reichweite — wie oft hat jemand die Landing-Page geöffnet |
totalScans | COUNT(kind = 'confirmed') | Conversion — wie oft ist jemand auf die Ziel-URL weiter |
uniqueDevices | COUNT(DISTINCT deviceCookieId) FILTER (kind = 'confirmed') | Wie viele unterschiedliche Browser haben überhaupt confirmed |
byCountry | GROUP BY countryCode (Confirmed) | Geografische Verteilung |
byUaFamily | GROUP BY uaFamily (Confirmed) | Browser-Mix |
byDeviceType | GROUP BY deviceType (Confirmed) | Mobile vs. Desktop |
daily | GROUP BY DATE(scannedAt), kind | Tages-Verlauf, beide Stufen |
Die Conversion-Rate ergibt sich als totalScans / totalImpressions. Sie ist keine Persistent-Linkung zwischen einem Impression-Datensatz und seinem späteren Confirmed-Pendant — beide Datensätze leben isoliert nebeneinander.
Datenhaltung & Löschung
QrLinkScanist append-only, nicht paranoid → keine Soft-Delete-Spalte. Statistik-Datensätze werden zur Auswertung gehalten.- Wird ein
QrLink(Soft-)gelöscht, bleiben die zugehörigen Scans erhalten — Auswertungen sind weiterhin lesbar (CASLview:QrLinkScanvorausgesetzt). Public-Resolve liefert ab dann404. - Wer einen Aufbewahrungs-Cutoff benötigt (z. B. „nach 13 Monaten DSGVO-konform löschen"), kann einen Cron-Job auf
qr_link_scansaufsetzen — aktuell nicht aus SpeamCore heraus exponiert.
Verknüpfungen
- QR-Links — Modul-Doku mit Editor, Live-Vorschau, Export.
- QR-Link-Templates — wiederverwendbare Style-Vorlagen.
- Berechtigungen verstehen (CASL) — Permission-Subjects
QrLink,QrLinkScan,QrLinkTemplate.
Versionshinweise
- 2026-05-20 (Welle 130): Initiale Veröffentlichung. Quelle: BE-Controller
qrLink.controller.ts(Zeilen 117–250+ für Track-Logik), Migration20260519150001-add-kind-to-qr-link-scans.js. Tracking-Modell entstand mit dem QR-Link-Modul aus Commit2813f706.