Skip to Content

Privacy

Analytics is on by default. This page tells you exactly what that means, so you can decide whether it is right for your project.

What is never stored

The IP address and the User-Agent string never reach the database.

They are inputs to a one-way hash and are then discarded. There is no column holding them, and no way to recover them from what is stored.

visitor_id = uuid5(sha256(f"{site_id}|{ip}|{user_agent}|{salt}"))

The salt is derived from SECRET_KEY and rotates daily. After rotation, the same person is a new visitor — which bounds how long any pseudonym can be correlated.

This is a deliberately stronger posture than the common implementations:

ProjectSaltConsequence
django-cfgSECRET_KEY-derived, rotates dailyNot recomputable by an outsider
Umamisha256(date) — a nonce, rotates monthlyRecomputable ⇒ effectively unsalted
ShynetAGGRESSIVE_HASH_SALTING = False by defaultNo secret salt at all

The raw User-Agent is also never stored — it is reduced to (browser, os, device) at ingest and discarded. Unique-UA cardinality is what blew up GoatCounter’s database; keeping the string would hand us the same problem.

What is stored

DataExampleWhy
Path/en/pricingThe page that was viewed
Templated route/[locale]/pricingSo locales do not fragment the report
Referrer domaingoogle.comWhere the visit came from
Channelorganic_searchResolved at write time
UTM parametersutm_source=newsletterCampaign attribution
Browser / OS / deviceChrome, macOS, desktopParsed from the UA, which is then dropped
CountryDEOptional; requires a GeoIP database
Visitor ida UUIDA daily-rotating pseudonym
User idFK to your user tableOn a server-confirmed successful login, and on authenticated ingest when available
Custom event props{ "plan": "pro" }Bounded product dimensions attached by your frontend
Server-event idempotency keyan HMAC digestOnly an HMAC-SHA256 of the key is stored (source_id_hash); the raw key never reaches the database

Custom event props are application-controlled JSON. Never put an email, phone number, name, access token, free-text message, payment data, or any other personal/sensitive value there. Use stable IDs only when your own legal basis and retention policy explicitly allow it; for most analytics dimensions, an enum such as plan: "pro" is enough.

Cookies

None. The visitor id is derived, not stored on the device. Analytics sets no cookies and reads none.

This matters for the ePrivacy Directive (the “cookie law”), which is about storage on the device — not about processing. No cookie banner is required for the mechanism.

It does not, by itself, settle GDPR.

GDPR — what you still have to decide

django-cfg cannot make this decision for you, and this page is not legal advice.

Two things are true at once:

1. The anonymous case is strong. No cookies, no IP stored, no UA stored, a daily-rotating pseudonym, no data leaving your infrastructure, no third party. Regulators have accepted cookieless, IP-less analytics as legitimate interest in several jurisdictions. This is roughly the posture Plausible and GoatCounter argue from.

2. store_user_id changes the picture. The moment an event carries a foreign key to your user table, it is personal data by definition — it is directly linked to an identified person. That is not a grey area.

This is also the entire point of the feature. It is what hosted analytics cannot do, and it is why Signed in appears on your dashboard. A successful framework login is attributed server-side from its trusted request origin; the browser never gets to claim an arbitrary user id.

So what do you do

If you already process user data lawfully (you have an account system, a privacy policy, and a legal basis), then attributing that user’s pageviews to them is usually within the same basis — it is first-party, self-hosted, and never leaves your infrastructure. Say so in your privacy policy.

If you cannot make that argument for your jurisdiction or your users:

class MyConfig(DjangoConfig): analytics = AnalyticsConfig(store_user_id=False)

Everything else keeps working. You get pageviews, sessions, referrers, and breakdowns — you just do not get the one column that ties them to a person.

And if you want none of it:

class MyConfig(DjangoConfig): analytics = AnalyticsConfig(enabled=False)

Do Not Track

AnalyticsConfig(respect_dnt=True)

Off by default, because DNT is deprecated and unreliable — Safari removed it entirely, and the signal was never widely honoured. Turn it on if you want to honour it anyway.

Data you still leak elsewhere

Analytics does not store IPs. Your other infrastructure probably does.

  • nginx / Cloudflare access logs
  • Sentry and error trackers
  • django_monitor (it captures the request IP with the error)

Audit the whole path, not just this app. A privacy posture is only as strong as its weakest log file.

Retention

There is no automatic deletion. Events accumulate.

To purge:

from datetime import timedelta from django.utils import timezone from django_cfg.apps.tools.analytics.models import AnalyticsEvent cutoff = timezone.now() - timedelta(days=90) AnalyticsEvent.objects.filter(ts__lt=cutoff).delete()

A DELETE of tens of millions of rows leaves that many dead tuples for autovacuum and does not return the space to the OS. At large volume, monthly table partitioning turns retention into a DROP TABLE — milliseconds, and the space comes back immediately. The event table’s primary key is (id, ts) precisely so that partitioning stays a cheap change later.

Last updated on