Affiliate Click Tracking in Django: Why GA Doesn't See It
Your own /go/ redirect with a bot filter, a GDPR-lean click log & Consent Mode v2
The problem: affiliate clicks are missing in Google Analytics
I had built my own affiliate system into my Wagtail/Django site: a few curated product links, a click counter, done. After a few days came the reality check – the counter showed a handful of clicks, but in Google Analytics there was nothing to see of these clicks. No event, no outbound, nothing.
The first reflex: “My tracking is broken.” But it wasn't. The counter was right – GA simply doesn't see such clicks on principle. Why that is, and how I ended up getting both honest first-party numbers and visibility in GA, is what this article is about.
Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.
Why Google Analytics doesn't see them
Two reasons, both by design:
- The click is a server-side redirect. My link doesn't point straight to the merchant but to
/go/<slug>/– a Django view that counts the click and then redirects via HTTP 302. A 302 response delivers no HTML. So the Google Analytics JavaScript is never loaded on this URL and never fires. GA only sees the article pageview before it, not the jump through/go/. - GA is consent-gated. As in most GDPR-compliant setups, GA only loads if the visitor has accepted analytics cookies. Anyone who ignores or declines the banner isn't measured at all.
On top of that: a portion of the hits on /go/ are bots – crawlers, link-preview fetchers, scanners. They bump a naive counter but run no JavaScript and likewise never show up in GA. The symptom “counter rises, GA stays flat” is thereby fully explained – and not a bug.
The plan: your own redirect + a lean log
If GA can't see the clicks anyway, I need my own honest measurement. Three requirements:
- Data-minimal: store as little as possible – ideally no personal data at all, which keeps the GDPR question small from the start.
- Honest: bot and direct hits shouldn't inflate the numbers.
- Simple: one redirect, one counter, one log – no tracking behemoth.
The data model
Two models. An AffiliateLink with a denormalized fast counter and an AffiliateClick as a per-click log for history. The latter deliberately without IP and without user agent – only a timestamp and the 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)
No IP, no user agent, no cookies – which means the AffiliateClick isn't personal data in the strict sense. The referer is in practice almost always the site's own article URL; it only serves to tell real clicks from bot/direct hits.
The redirect view with a bot filter
The core. Only a real click is counted: no bot user agent and a referer from the site's own domain. The redirect itself always happens – counting must never slow the user down:
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 sit on your own pages. A real reader who clicks in an article sends the article URL as the referer – i.e. your own domain. A direct call to /go/<slug>/ without (or with a foreign) referer is almost always a bot or scanner. The combination “no bot UA + internal referer” is thus the most reliable authenticity signal you can get without personal data.
What the numbers tell you
With the referer in the log, every click can be classified: internal (from one of your own articles – the real reader), external (foreign domain) or without referer (direct hit/bot). In my case the ratio was unambiguous: of five counted raw clicks one night, exactly one had an internal referer – and that exact one also showed up in GA as one new user. The other four were referer-less, i.e. automated noise. Without the filter my counter would have overstated the real interest fivefold.
Getting clicks into GA after all: the Measurement Protocol
Sometimes you do want the clicks in GA – as a conversion alongside the pageviews. Since the browser loads no gtag on /go/, you send the event server-side via the GA4 Measurement Protocol. Important: bind it to the same GA client_id from the _ga cookie so the event is attributed to the same user/session – and don't block (fire-and-forget in a 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()
A server-side event technically bypasses the cookie banner – but not legally. Only send it if the visitor has consented to analytics (the same rule as for the front-end gtag). Otherwise you're tracking without consent, and that was never the goal.
The real lever: Consent Mode v2
The Measurement Protocol brings back individual clicks. The big GA gap, though, is consent-gating: anyone who declines the banner is missing entirely. This is where Google Consent Mode v2 (advanced) helps: gtag is always loaded but, without consent, runs in the denied state – no cookies are set, only anonymous, cookieless pings are sent, from which GA models the missing users/conversions. Since the server knows the consent state at render time, it's enough to set the default state server-side:
<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 sends cookieless signals to Google even without consent. That's Google's intended behavior and common, but on the GDPR side it's a deliberate decision – and belongs transparently in the privacy policy. If you don't want that, stay on “basic” (gtag loads only with consent) and accept the undercount.
What I left out
- No IP / fingerprint storage. Deliberately not – the insight gained doesn't justify the GDPR overhead.
- No client-side outbound tracking. Because the link runs through the site's own domain (
/go/), GA4's automatic “outbound click” doesn't fire; the server event fills the gap. - No bot list claiming to be complete. A substring heuristic catches the big crawlers; it isn't perfect and doesn't need to be for an honest trend.
Conclusion
Missing affiliate clicks in GA isn't a bug but the consequence of a server-side redirect plus consent-gating. With your own /go/ redirect, a bot filter and a GDPR-lean log you get honest first-party numbers; with the Measurement Protocol and Consent Mode v2 you bring the visibility back into GA. The key is not to confuse the two worlds: your own counter measures real clicks, GA measures (models) reach. Deliberately pragmatic – and data-minimal, because here that's actually the simpler solution.
Ad · Affiliate link – if you buy through it, I may earn a commission. It doesn’t change the price for you.