Affiliate-Klick-Tracking in Django: warum GA es nicht sieht
Eigener /go/-Redirect mit Bot-Filter, DSGVO-schlankes Klick-Log & Consent Mode v2
Das Problem: Affiliate-Klicks fehlen in Google Analytics
Ich hatte mir ein eigenes Affiliate-System in meine Wagtail/Django-Seite gebaut: ein paar kuratierte Produktlinks, ein Klick-Zähler, fertig. Nach ein paar Tagen der Realitätscheck – der Zähler stand auf einer Handvoll Klicks, aber in Google Analytics war von diesen Klicks nichts zu sehen. Kein Event, kein Ausgang, nichts.
Der erste Reflex: „Mein Tracking ist kaputt.“ War es aber nicht. Der Zähler stimmte – GA sieht solche Klicks nur prinzipiell nicht. Warum das so ist und wie ich am Ende sowohl ehrliche eigene Zahlen als auch Sichtbarkeit in GA bekommen habe, ist dieser Artikel.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.
Warum Google Analytics sie nicht sieht
Zwei Gründe, beide systembedingt:
- Der Klick ist ein server-seitiger Redirect. Mein Link zeigt nicht direkt zum Händler, sondern auf
/go/<slug>/– eine Django-View, die den Klick zählt und dann per HTTP 302 weiterleitet. Eine 302-Antwort liefert kein HTML. Das Google-Analytics-JavaScript wird auf dieser URL also nie geladen und feuert nie. GA sieht nur den Artikel-Pageview davor, nicht den Sprung über/go/. - GA ist Consent-gated. Wie bei den meisten DSGVO-konformen Setups lädt GA nur, wenn der Besucher Analyse-Cookies akzeptiert hat. Wer den Banner ignoriert oder ablehnt, wird gar nicht gemessen.
Dazu kommt: Ein Teil der Aufrufe auf /go/ sind Bots – Crawler, Link-Preview-Fetcher, Scanner. Die erhöhen einen naiven Zähler, führen aber kein JavaScript aus und tauchen in GA ebenfalls nie auf. Das Symptom „Zähler steigt, GA bleibt flach“ ist damit vollständig erklärt – und kein Bug.
Der Plan: eigener Redirect + schlankes Log
Wenn GA die Klicks ohnehin nicht sehen kann, brauche ich eine eigene, ehrliche Messung. Drei Anforderungen:
- Datensparsam: So wenig wie möglich speichern – idealerweise gar keine personenbezogenen Daten, dann ist die DSGVO-Frage von vornherein klein.
- Ehrlich: Bot- und Direktaufrufe sollen die Zahlen nicht aufblähen.
- Einfach: Ein Redirect, ein Zähler, ein Log – kein Tracking-Moloch.
Das Datenmodell
Zwei Modelle. Ein AffiliateLink mit denormalisiertem Schnellzähler und ein AffiliateClick als Einzel-Log für den Verlauf. Letzteres bewusst ohne IP und ohne User-Agent – nur Zeitstempel und Referer:
class AffiliateLink(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True) # ergibt /go/<slug>/
url = models.URLField(max_length=1024) # Ziel beim Händler
is_active = models.BooleanField(default=True)
click_count = models.PositiveIntegerField(default=0) # schneller Zähler
class AffiliateClick(models.Model):
"""Ein Klick – bewusst DSGVO-schlank: nur Zeit + Referer, KEINE IP/UA."""
link = models.ForeignKey(
AffiliateLink, on_delete=models.CASCADE, related_name="clicks"
)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
referer = models.CharField(max_length=512, blank=True)
Kein IP, kein User-Agent, keine Cookies – damit ist der AffiliateClick kein personenbezogenes Datum im engeren Sinn. Der Referer ist in der Praxis fast immer die eigene Artikel-URL; er dient nur dazu, echte Klicks von Bot-/Direktaufrufen zu unterscheiden.
Die Redirect-View mit Bot-Filter
Der Kern. Gezählt wird nur ein echter Klick: kein Bot-User-Agent und ein Referer von der eigenen Domain. Die Weiterleitung selbst passiert immer – die Zählung darf den Nutzer nie ausbremsen:
from urllib.parse import urlparse
from django.db.models import F
from django.shortcuts import get_object_or_404, redirect
BOT_SIGNATURES = (
"bot", "crawler", "spider", "slurp", "facebookexternalhit",
"telegrambot", "python-requests", "curl/", "wget/", "headless",
)
def is_bot(ua: str) -> bool:
ua = (ua or "").lower()
return not ua or any(sig in ua for sig in BOT_SIGNATURES)
def is_internal_referer(request, referer: str) -> bool:
if not referer:
return False
host = (urlparse(referer).hostname or "").lower()
return host == request.get_host().split(":")[0].lower()
def affiliate_redirect(request, slug):
link = get_object_or_404(AffiliateLink, slug=slug, is_active=True)
ua = request.META.get("HTTP_USER_AGENT", "")
referer = request.META.get("HTTP_REFERER") or ""
if not is_bot(ua) and is_internal_referer(request, referer):
AffiliateLink.objects.filter(pk=link.pk).update(
click_count=F("click_count") + 1
)
AffiliateClick.objects.create(link=link, referer=referer[:512])
return redirect(link.url) # Weiterleitung passiert IMMER
Affiliate-Links stehen auf den eigenen Seiten. Ein echter Leser, der im Artikel klickt, schickt als Referer die Artikel-URL mit – also die eigene Domain. Ein direkter Aufruf von /go/<slug>/ ohne (oder mit fremdem) Referer ist nahezu immer ein Bot oder Scanner. Die Kombination „kein Bot-UA + interner Referer“ ist damit das verlässlichste Echtheits-Signal, das man ohne personenbezogene Daten bekommt.
Was die Zahlen dann erzählen
Mit dem Referer im Log lässt sich jeder Klick einordnen: intern (aus einem eigenen Artikel – der echte Leser), extern (fremde Domain) oder ohne Referer (Direktaufruf/Bot). In meinem Fall war das Verhältnis eindeutig: Von fünf gezählten Roh-Klicks einer Nacht hatte genau einer einen internen Referer – und exakt dieser eine tauchte auch in GA als ein neuer Nutzer auf. Die anderen vier waren referer-los, also Automatik-Rauschen. Ohne den Filter hätte mein Zähler das echte Interesse um das Fünffache überzeichnet.
Klicks doch in GA bekommen: das Measurement Protocol
Manchmal will man die Klicks trotzdem in GA sehen – als Conversion neben den Pageviews. Da der Browser auf /go/ kein gtag lädt, schickt man das Event server-seitig über das GA4 Measurement Protocol. Wichtig: an dieselbe GA-client_id aus dem _ga-Cookie binden, damit das Event demselben Nutzer/derselben Session zugeordnet wird – und nicht blockieren (Fire-and-forget im Thread):
import threading
import requests
GA_MP = "https://www.google-analytics.com/mp/collect"
def _ga_client_id(request) -> str:
raw = request.COOKIES.get("_ga", "") # Format: GA1.1.<a>.<b>
parts = raw.split(".")
return f"{parts[-2]}.{parts[-1]}" if len(parts) >= 4 else "0.0"
def send_ga_event(request, link, measurement_id, api_secret):
url = f"{GA_MP}?measurement_id={measurement_id}&api_secret={api_secret}"
payload = {
"client_id": _ga_client_id(request),
"events": [{
"name": "affiliate_click",
"params": {"link_slug": link.slug, "engagement_time_msec": "1"},
}],
}
# nicht blockieren: Fire-and-forget im Daemon-Thread
threading.Thread(
target=lambda: requests.post(url, json=payload, timeout=3),
daemon=True,
).start()
Ein server-seitiges Event umgeht technisch den Cookie-Banner – rechtlich aber nicht. Sende es nur, wenn der Besucher der Analyse zugestimmt hat (dieselbe Regel wie für das Frontend-gtag). Sonst trackst du ohne Einwilligung, und das war ja nicht das Ziel.
Der eigentliche Hebel: Consent Mode v2
Das Measurement Protocol holt einzelne Klicks zurück. Das große GA-Loch ist aber das Consent-Gating: Wer den Banner ablehnt, fehlt komplett. Hier hilft Google Consent Mode v2 (advanced): gtag wird immer geladen, läuft ohne Zustimmung aber im Zustand denied – es werden keine Cookies gesetzt, nur anonyme, cookielose Pings gesendet, aus denen GA die fehlenden Nutzer/Conversions modelliert. Da der Server beim Rendern die Einwilligung kennt, genügt es, den Default-Status server-seitig zu setzen:
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
// Default je nach gespeicherter Einwilligung (server-seitig gerendert):
gtag('consent', 'default', {
'analytics_storage': '{{ analytics_granted|yesno:"granted,denied" }}',
'ad_storage': 'denied'
});
gtag('js', new Date());
gtag('config', 'G-XXXXXXX', { 'anonymize_ip': true });
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
Consent Mode advanced sendet auch ohne Zustimmung cookielose Signale an Google. Das ist Googles vorgesehenes Verhalten und gängig, aber DSGVO-seitig eine bewusste Entscheidung – sie gehört transparent in die Datenschutzerklärung. Wer das nicht will, bleibt bei „basic“ (gtag lädt erst mit Zustimmung) und akzeptiert die Unterzählung.
Was ich weggelassen habe
- Keine IP-/Fingerprint-Speicherung. Bewusst nicht – der Erkenntnisgewinn rechtfertigt den DSGVO-Aufwand nicht.
- Kein clientseitiges Outbound-Tracking. Da der Link über die eigene Domain (
/go/) läuft, greift GA4s automatisches „Outbound-Click“ nicht; das Server-Event füllt die Lücke. - Keine Bot-Liste mit Anspruch auf Vollständigkeit. Eine Substring-Heuristik fängt die großen Crawler; perfekt ist sie nicht und muss es für einen ehrlichen Trend auch nicht sein.
Fazit
Affiliate-Klicks in GA zu vermissen ist kein Bug, sondern die Folge eines server-seitigen Redirects plus Consent-Gating. Mit einem eigenen /go/-Redirect, einem Bot-Filter und einem DSGVO-schlanken Log bekommt man ehrliche eigene Zahlen; mit dem Measurement Protocol und Consent Mode v2 holt man die Sichtbarkeit in GA zurück. Wichtig ist, beide Welten nicht zu verwechseln: Der eigene Zähler misst echte Klicks, GA misst (modelliert) Reichweite. Bewusst pragmatisch – und datensparsam, weil das hier sogar die einfachere Lösung ist.
Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.