Inhalt: Cookieless Tracking, Cross-Domain iFrame Tracking, Privacy Compliance, client_id/session_id, Measurement Protocol Endpoint, Case Study
Zugebenen, Cookieless Tracking ist im Analytics-Alltag nach wie vor eher die Ausnahme als die Regel. Die meisten Setups verlassen sich (mindestens teilweise) auf Cookies, z.B. um Nutzer zu identifizieren oder sitzungsübergreifend zu messen. Trotzdem gibt es immer wieder Situationen, in denen genau dieses Vorgehen nicht gewünscht oder sogar unmöglich ist:
Gerade in privacy-sensiblen Branchen oder generell im GDPR-Umfeld besteht sehr oft der Wunsch, die Datenerhebung (und damit rechtliche Angriffsfläche) auf ein Minimum zu reduzieren und/oder ohne Cookie-Banner auszukommen. In vielen Fällen lassen sich solche Anforderungen mit einem Privacy-First-Tool wie Matomo oder über ein serverseitiges Tracking-Setup lösen.
Aber: Das geht nicht immer oder der Aufwand dafür ist einfach nicht vertretbar. Genau ein solcher Fall ergab sich bei einem meiner Kunden, der ein kleines Tool als interaktives iframe auf einer Partnerwebseite einbinden lassen wollte. Der Kampagnenerfolg sollte selbstverständlich anschliessend ausgewertet werden, um die Wirksamkeit dieser Kampagne beurteilen zu können. Klingt einfach? Leider nein.
Cross-Domain-Setups & Cookies
Eine reguläre Einbindung von Google Tag Manager und GA4 ist aufgrund der Cross-Domain-Einschränkungen für solche Fälle leider nicht möglich. Moderne Browser und viele Webseiten unterbinden in solchen Szenarien, je nach Konfiguration, das Setzen von Cookies. Selbst ein eigentlich «unkritisches» First-Party-Cookie wird in diesem Setup zum Third-Party-Cookie, weil es von der Ursprungsdomain des iframe geschrieben wird. Und damit fehlt GA4 eine grundlegende Voraussetzung für die Initialisierung.
Der Roadblock war perfekt: Für eine zeitlich begrenzte Kampagne ein neues Analytics-Tool einzuführen oder serverseitiges Tracking aufzusetzen, wäre klar unverhältnismässig. Und damit landet man schnell bei der Frage «Gibt es clientseitiges Tracking mit GA4 auch ohne Cookies?» Und die Antwort lautet: Ja, zumindest wenn man mit ein paar Einschränkungen und ein paar «hacky» Workarounds leben kann.
GA4 braucht (eigentlich) keine Cookies
Ein «normales» Setup für GA4 mit dem Tag Manager ist eigentlich sehr unkompliziert: Das GTM-Snippet wird eingebunden, im GTM-Container wird ein G-Tag angelegt und anschliessend wird die gewünschte Eventstruktur definiert. Die Bibliothek im Browser übernimmt von dort den Rest, setzt dafür die notwendigen Cookies und sendet die gesammelten Daten dann automatisiert an Google Analytics.
Im beschriebenen iframe-Szenario bricht dieser Ablauf jedoch direkt am Anfang. GA4 initialisiert nicht korrekt und dadurch werden überhaupt keine Daten an GA4 gesendet. Der Grund dafür ist eine fehlende Voraussetzung. Das G-Tag schlägt fehlt, wenn zentrale Werte wie die client_id nicht verfügbar sind. Diese werden normalerweise aus dem Cookie ausgelesen. Und genau dieses Cookie kann in einem solchen Fall nicht geschrieben werden.
Vergleich: Analytics-Request & Cookie-Daten
Auf den ersten Blick könnte man denken, dass clientseitiges Tracking mit GA4 ohne Cookies damit unmöglich ist. Das ist nicht ganz korrekt: Tatsächlich benötigt GA4 nicht zwingend Cookies, sondern nur die IDs, die im Normalfall aus dem Cookie stammen. Wenn man diese IDs anderweitig erzeugen und übermittelt kann, lassen sich Events auch ganz ohne Cookie an GA4 senden.
Hinweis: Cookieless bedeutet übrigens nicht zwangsläufig anonym. Auch ohne Cookies können beim Versand an GA4 personenbezogene bzw. personenbeziehbare Daten verarbeitet werden. Abhängig vom rechtlichen Kontext können daher weiterhin Anforderungen an Transparenz, Datenschutzerklärung oder Consent bestehen (bleiben). Im beschriebenen Projekt wurde das im Sinne des revDSG über einen Disclaimer im iframe berücksichtigt.
Analytics-Daten direkt an den Endpoint schicken
Die Lösung im Fall meines Kunden sah so aus: Wir senden GA4-Events per Measurement Protocol direkt an /g/collect, erzeugen dafür eine ephemere Pseudo-Session-ID und -Client-ID und steuern alles über den Tag Manager. Sprich, wir machen alles, was die gtag.js Bibliothek normalerweise macht, nur manuell und ohne ein Cookie zu setzen.
Wer schon mit SST gearbeitet hat, möchte dafür wahrscheinlich direkt den Measurement-Protocol-Endpoint /mp/collect verwenden, der typischerweise für serverseitiges Tracking oder Offline-Conversions gedacht ist. Für ein rein clientseitiges Setup bringt dieser Weg jedoch Hürden mit sich, nicht zuletzt die Anforderung, dafür api_secret im Frontend bereitzustellen.
API-Keys leaken wollten wir natürlich nicht, was diese Methode disqualifiziert hat. Aus diesem Grund fiel unsere Entscheidung auf den Web-Endpoint /g/collect, der auch von der regulären Web-Bibliothek genutzt wird und sich ohne zusätzliche Authentifizierung direkt aus dem Browser ansprechen lässt.
GA4 Base Tag & Events ohne gtag.js
Da in diesem Setup weder ein klassisches GA4-Tag noch die gtag.js-Bibliothek eingesetzt werden können, mussten alle benötigten Events manuell aufgebaut und versendet werden. Wie aufwändig das ist, hängt davon ab, wie viele Events umgesetzt werden sollen.
Für grundlegende Ereignisse wie first_visit, session_start und page_view wurde in unserem Fall ein gemeinsamer Tag definiert, der beim DOM Ready feuert. Innerhalb dieses Scripts wurden die notwendigen Parameter zusammengesetzt: Measurement ID, pseudo client_id, pseudo session_id, Sprache sowie weitere Metadaten wie URL, Referrer oder Seitentitel. Der Scriptblock bildete dabei im Grunde das nach, was gtag.js normalerweise im Hintergrund erledigt: Er baut die Request-URL und sendet Tracking-Daten via sendBeacon (oder alternativ per fetch) an Google Analytics.
<script>
(function() {
var measurementId = '{{Mess-ID}}'; // GA4-Mess-ID (z.B. "G-A1BCDEF234")
var clientId = '{{ga4_pseudo_cid}}'; // Pseudo-Client-ID (unique value) – mehr dazu später
var sid = '{{ga4_pseudo_sid}}'; // Pseudo-Session-ID (unique value) – mehr dazu später
var lang = '{{language}}'; // User Language (hier aus URL-Query-Parameter)
var sct = 1; // Der Session-Count ist in diesem Fall als 1 hartcodiert, da keine echte Zählung über mehrere Sessions hinweg möglich ist
// session_start event :
var sessionStartURL = 'https://www.google-analytics.com/g/collect?v=2' +
'&tid=' + encodeURIComponent(measurementId) +
'&cid=' + encodeURIComponent(clientId) +
'&sid=' + encodeURIComponent(sid) +
'&ul=' + encodeURIComponent(lang) +
'&sct=' + encodeURIComponent(sct) +
'&en=session_start'; // en = event name.
// String-Konkatenation baut hier den Query-Parameter für GA4 /g/collect Endpoint
if (navigator.sendBeacon) {
navigator.sendBeacon(sessionStartURL);
} else {
fetch(sessionStartURL, {method:'POST', keepalive:true, mode:'no-cors'});
}
// first_visit event :
var sessionStartURL = 'https://www.google-analytics.com/g/collect?v=2' +
'&tid=' + encodeURIComponent(measurementId) +
'&cid=' + encodeURIComponent(clientId) +
'&sid=' + encodeURIComponent(sid) +
'&ul=' + encodeURIComponent(lang) +
'&sct=' + encodeURIComponent(sct) +
'&en=first_visit;' // en = event name.
if (navigator.sendBeacon) {
navigator.sendBeacon(sessionStartURL);
} else {
fetch(sessionStartURL, {method:'POST', keepalive:true, mode:'no-cors'});
}
// page_view event :
var pageViewURL = 'https://www.google-analytics.com/g/collect?v=2' +
'&tid=' + encodeURIComponent(measurementId) +
'&cid=' + encodeURIComponent(clientId) +
'&sid=' + encodeURIComponent(sid) +
'&sct=' + encodeURIComponent(sct) +
'&en=page_view' +
'&dl=' + encodeURIComponent(location.href) + // dl = document location: URL
'&dr=' + encodeURIComponent(document.referrer) + // dr = verweisende Seite
'&ul=' + encodeURIComponent(lang) +
'&dt=' + encodeURIComponent('{{document_title}}'); // dt = document title: Seitentitel
if (navigator.sendBeacon) {
navigator.sendBeacon(pageViewURL);
} else {
fetch(pageViewURL, {method:'POST', keepalive:true, mode:'no-cors'});
}
})();
</script>
Da im Falle meines Kundens das iframe als Single Page Application umgesetzt wurde, konnte für diese Events problemlos ein gemeinsames Tag versendet werden, da kein klassischer Reload stattfindet. In anderen Szenarien (z.B. bei multiplen virtuellen Seitenwechseln) kann es sinnvoll sein, diese Events zu trennen und über eigene Trigger zu steuern.
Zusätzlich zum Basis-Tracking wurde auch ein user_engagement-Event implementiert. Für den Kunden war allerdings nicht nur relevant, wie oft das Banner eingeblendet wurde (im Gegensatz zu einer «echten» Seite ist durch den Load ja nicht sichergestellt, dass der Nutzer das Banner wahrgenommen hat), sondern vor allem auch, ob Nutzer aktiv damit interagieren. Darum weicht die Umsetzung vom «GA4-Standard» ab: Während GA4 user_engagement normalerweise auch nach einer gewissen Sitzungsdauer automatisch auslöst (das lässt sich, falls gewünscht, per Timer-Trigger in GTM nachbauen), sollte in unserem Fall erst ein bewusster Interaktionspunkt gemessen werden. Entsprechend wurde das Event über einen Klick-Trigger im iframe gesteuert. Ausserdem sendet unser Script neben den üblichen Parametern auch eine ungefähre Engagement-Zeit seit dem initialen Laden der Anwendung.
<script>
(function() {
var tid = '{{Mess-ID}}';
var cid = '{{ga4_pseudo_cid}}';
var sid = '{{ga4_pseudo_sid}}';
var lang = '{{language}}';
var sct = 1;
// Engagement-Zeit in Millisekunden seit Page-/SPA-Start (Annäherung an aktive Nutzungszeit)
var engagedMs = Math.max(0, Math.round(performance.now()));
// user_engagement Event an GA4 /g/collect Endpoint
var url = 'https://www.google-analytics.com/g/collect?v=2' +
'&tid=' + encodeURIComponent(tid) +
'&cid=' + encodeURIComponent(cid) +
'&sid=' + encodeURIComponent(sid) +
'&sct=' + encodeURIComponent(sct) +
'&ul=' + encodeURIComponent(lang) +
'&en=user_engagement' + // GA4 Standard-Event für Engagement-Messung
'&epn.engagement_time_msec=' + engagedMs; // Engagement-Dauer
if (navigator.sendBeacon) {
navigator.sendBeacon(url);
} else {
fetch(url, { method: 'POST', keepalive: true, mode: 'no-cors' });
}
})();
</script>
Custom Events per HTML-Tag
Den gleichen Mechanismus konnten wir auch für weitere Custom Events nutzen und auch hierzu gibt es bei diesem Ansatz keine nennenswerten Einschränkungen. Als typisches Beispiel nutze ich hier ausgehende Link-Klicks: Über einen Klick-Trigger im GTM wird ein Custom HTML Tag ausgelöst, das ein Event mit frei definierbarem Namen, hier linkclick_outgoing, sowie mit einem Event-Parameter für die Ziel-URL an GA4 schickt.
<script>
(function() {
var measurementId = '{{Mess-ID}}';
var clientId = '{{ga4_pseudo_cid}}';
var sid = '{{ga4_pseudo_sid}}';
var lang = '{{language}}';
var sct = 1;
var linkUrl = '{{Click URL}}';
var url = 'https://www.google-analytics.com/g/collect?v=2' +
'&tid=' + encodeURIComponent(measurementId) +
'&cid=' + encodeURIComponent(clientId) +
'&sid=' + encodeURIComponent(sid) +
'&ul=' + encodeURIComponent(lang) +
'&sct=' + encodeURIComponent(sct) +
// Custom Event Parameter
'&en=linkclick_outgoing' + // Eventname = linkclick_outgoing
'&ep.link_url=' + encodeURIComponent(linkUrl); // Event-Parameter (ep.) link_url
if (navigator.sendBeacon) {
navigator.sendBeacon(url);
} else {
fetch(url, {method:'POST', keepalive:true, mode:'no-cors'});
}
})();
</script>
User-Level-Parameter und Pseudo-IDs
Wie oberhalb bereits angedeutet, liegt der eigentliche Knackpunkt bei diesem Setups aber bei den Identifikatoren für Nutzer und Sitzung. GA4 benötigt zwingend Werte, um Events einem Nutzer und einer Session zuordnen zu können. Fehlen client_id und session_id, werden die eingehenden Hits zwar technisch verarbeitet, lassen sich aber nicht sinnvoll aggregieren und werden nicht korrekt angezeigt.
Im konkreten Projekt hatten wir hier einen entscheidenden Vorteil durch technischen Architektur: Im iframe lud eine Single Page Application. Da damit keine echten Seitenreloads stattfinden, konnten temporär persistente IDs erzeugt und im Data Layer abgelegt werden, wo sie für alle Events wiederverwendet werden konnten.
Für die pseudo client_id wurde dafür eine zufällige 128-Bit-ID generiert und in eine GTM-JavaScript-Variable gekapselt (wenn du eine CSP hast, wirst du vermutlich eine der alternativen Methoden unterhalb nutzen müssen). Diese JS-Variable prüft, ob bereits ein entsprechender Wert im Data Layer vorhanden ist, und erzeugt eine neue ID, wenn noch keine existiert. Dadurch bleibt die Kennung während der Sitzung stabil.
function() {
var DL_KEY = 'ga4_pseudo_cid';
window.dataLayer = window.dataLayer || [];
for (var i = window.dataLayer.length - 1; i >= 0; i--) {
var e = window.dataLayer[i];
if (e && typeof e === 'object' && e[DL_KEY]) {
return e[DL_KEY];
}
}
var cid;
try {
var a = new Uint8Array(16);
(window.crypto || window.msCrypto).getRandomValues(a);
var hex = Array.from(a, function(b) {
return ('00' + b.toString(16)).slice(-2);
}).join('');
cid =
hex.slice(0, 8) + '-' +
hex.slice(8, 12) + '-' +
hex.slice(12, 16) + '-' +
hex.slice(16, 20) + '-' +
hex.slice(20, 32);
} catch (e) {
cid = 'dl-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
}
window.dataLayer.push({ [DL_KEY]: cid });
return cid;
}
Die Anforderung an diese Pseudo-Client-ID ist übrigens vergleichsweise einfach: Sie muss (mit sehr hoher Wahrscheinlichkeit) eindeutig sein, damit nicht mehrere Sitzungen unterschiedlicher Nutzer fälschlicherweise zusammengeführt werden. Eine ausreichend komplexe Zufalls-ID erfüllt diesen Zweck dadurch mehr ausreichend.
Noch unkomplizierter ist die Pseudo-Session-ID. Da sie lediglich innerhalb eines Nutzers eindeutig sein muss, reicht ein monoton steigender Wert. In unserem Fall konnte das über einen UNIX-Time-Stamp sichergestellt werden, der beim ersten Ausführen der Variable erzeugt und ebenfalls im Data Layer abgelegt wird.
function() {
var DL_KEY = 'ga4_pseudo_sid';
window.dataLayer = window.dataLayer || [];
for (var i = window.dataLayer.length - 1; i >= 0; i--) {
var e = window.dataLayer[i];
if (e && typeof e === 'object' && e[DL_KEY]) {
return e[DL_KEY];
}
}
var sid = Math.floor(Date.now() / 1000).toString();
window.dataLayer.push({ [DL_KEY]: sid });
return sid;
}
Aber: Nicht jedes Szenario erlaubt jedoch die komfortable Data-Layer-Variante. Sobald echte Seitenwechsel stattfinden, muss sichergestellt werden, dass die IDs in irgendeiner Form zumindest innerhalb der Sitzung persistent bleiben. Das erfordert eine Abwägung zwischen Datenqualität, technischer Robustheit und Datenschutzüberlegungen:
Die Optionen dafür reichen von einer rein flüchtigen In-Memory-Speicherung über die Übergabe der Werte als Query-Parameter bis hin zur Nutzung von sessionStorage und haben eigene Vor- und Nachteile. In-Memory-Werte sind datenschutzrechtlich am unproblematischsten, aber bei jedem Reload verloren, wohingegen sessionStorage für die gesamte Browser-Session erhalten bleibt, jedoch datenschutzrechtlich nahe am Setzen eines Cookies ist. Welche Variante sinnvoll ist, hängt darum entsprechend vom konkreten Anwendungsfall und Privacy-Anforderungen ab.
Achtung: Nur für Edge Cases sinnvoll
Mit überschaubarem Aufwand ist das Ziel erreicht: ein clientseitiges GA4-Tracking ohne Cookies. ABER: Die Implementierung ist selten der Zweck eines Tracking-Setup. Diese Architektur hat entscheidende Nachteile, die bedacht werden sollten und diese Lösung eher für Edge Cases relevant machen.
Kein sitzungsübergreifendes Tracking
Der Session Count im session_start-Event ist in diesem Setup bewusst statisch. Die verwendeten Identifikatoren sind flüchtig und werden bei jedem Besuch neu generiert. Entsprechend ist eine Wiedererkennung von Nutzern praktisch ausgeschlossen.
Für die Kampagne meines Kunden war dieser Nachteil vertretbar: Das Banner war kein Produkt, das Nutzer wiederholt aufrufen sollten. Entscheidend war darum weniger die Wiedererkennung einzelner Nutzer als vielmehr die Frage, wie häufig Interaktionen stattfinden und wie sich Nutzer innerhalb des Funnels verhalten. In einem regulären Website-Tracking kann die fehlende Wiedererkennung allerdings ein klarer Dealbreaker sein, der diese Methode unbrauchbar macht.
Privacy-First-Tools wie Matomo haben hier einen Vorteil, weil sie Nutzer über mehrere Besuche hinweg identifizieren können. Im konkreten iframe-Szenario hätte allerdings auch ein klassisches Matomo-Cookie nicht geschrieben werden können, sodass selbst dieses Tool sich lediglich auf heuristische Anhaltspunkte verlassen hätte müssen.
Tracking-Maintenance und zusätzliche Fragilität
Wer Tracking betreibt, weiss: Die grösste Herausforderung beginnt oft erst nach der Implementierung. Formulare werden umgebaut, CSS-Selektoren ändern sich, Data-Layer-Strukturen verschieben sich und plötzlich fehlen Monate später wichtige Daten, die unwiederbringlich verloren sind.
In diesem Setup vervielfältigt sich dieses Risiko. Neben den üblichen Änderungen durch Webseitenbetreiber entsteht nämlich ein zusätzlicher Abhängigkeitspunkt: der direkte Versand an den /g/collect-Endpoint. Sollte Google Anpassungen an Parametern, Validierungslogik oder Request-Handling vornehmen, kann das Tracking ohne sichtbare Fehlermeldung ausfallen. Diese Methode fällt darum ganz klar in die Kategorie «Auf eigene Gefahr». Und wenn in diesem Setup dann tatsächlich etwas kaputt geht, ist Debugging ebenfalls eher mühsam.
Aber auch dieses Risiko war im beschriebenen Projekt aber akzeptabel. Die Banner liefen nur innerhalb eines klar begrenzten Kampagnenzeitraums, und es war sichergestellt, dass die Analytics-Daten regelmässig geprüft werden. Für langfristige Tracking-Setups, die möglichst wartungsarm funktionieren sollen, spricht aber allein das klar gegen den Ansatz.
Was möglich ist
Nach dem Lesen dieser Einschränkungen könnte man sich fragen, ob eine solche Case Study überhaupt relevant ist. Meiner Meinung nach Ja: Gerade das iframe-Szenario, ein interaktives Banner auf einer Partnerseite, das messbar gemacht werden soll, ist zwar kein Daily Business, taucht aber in der Praxis trotzdem gelegentlich auf.
Der spannendere Take-Away liegt aber eine Ebene darüber: Es zeigt wie flexibel Tracking-Setups wirklich sein können. Die Google-Produktpalette bringt viele Komfortfunktionen mit, die direkt out-of-the-box funktionieren und alle gängigen Anwendungsfälle bedienen können. Gleichzeitig bedeutet das aber nicht, dass man auf die vorgesehenen Wege beschränkt ist. Mit einem guten Verständnis der zugrunde liegenden Mechanismen lassen sich auch ungewöhnliche Konstellationen abbilden und individuelle Anforderungen an Privacy, Flexibilität oder Datenkontrolle umsetzen.
Nicht jede dieser Lösungen ist universell oder langfristig sinnvoll, aber sie erweitern den Handlungsspielraum. Und manchmal ist genau das der Unterschied zwischen “geht nicht” und “geht doch, einfach mit Einschränkungen”. Und falls du bei einem ähnlichen Setup Unterstützung brauchst oder eine zweite Meinung willst, meld dich gern.