uptime · 1416 days · 30 posts published · last deploy 2 hours, 17 minutes ago build:passing rss
~ / software-web / affiliate-klick-tracking-django-ohne-google-analytics.md
Software & Web · 16. Juni 2026 · ~10min · 5d9422b

Affiliate-Klick-Tracking in Django: warum GA es nicht sieht

Eigener /go/-Redirect mit Bot-Filter, DSGVO-schlankes Klick-Log & Consent Mode v2

>
devmaker.net
author · 5d9422b · 2026-06-16
Affiliate Reports – Developer at Desk (GA-style screen).jpg 1408×768
Affiliate Reports – Developer at Desk (GA-style screen)
Du baust dir ein eigenes Affiliate-System in deine Django/Wagtail-Seite, zählst Klicks – und in Google Analytics taucht trotzdem nichts davon auf. Genau das ist mir passiert: Der Klick-Zähler stieg, GA blieb flach. Der Grund ist kein Bug, sondern systembedingt. In diesem Artikel zeige ich mein komplettes Setup aus dem echten Betrieb: ein /go/-Redirect mit Klick-Tracking, ein bewusst DSGVO-schlankes Log (kein IP, kein User-Agent), ein Bot-Filter, der die Zahlen ehrlich hält – und warum GA Affiliate-Klicks prinzipiell nicht sieht. Zum Schluss zwei Wege, sie doch sichtbar zu machen: ein server-seitiges GA-Event und Consent Mode v2. Du brauchst Django-Grundlagen, am Ende hast du ein nachvollziehbares, datensparsames Tracking.

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.

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)
DSGVO by design

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
Warum der interne Referer?

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()
Nur mit Einwilligung senden

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>
Abwägung

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

Ehrliche Abgrenzung
  • 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.

// Weitere Empfehlungen

Anzeige · Affiliate-Link – kaufst du darüber, erhalte ich ggf. eine Provision. Für dich ändert sich am Preis nichts.

// responses (0)
> echo "your thoughts" >> affiliate-klick-tracking-django-ohne-google-analytics.responses

Schreibe einen Kommentar

Wird für die Bestätigung benötigt