Skip to main content

Privatus Analytics for Python

Adds the cookieless Privatus Analytics tracking script to your Django, Flask, FastAPI or Starlette app. You get a template tag, and an optional middleware that puts the script in the <head> for you.

Privatus Analytics counts visits, sources, pages and conversions without cookies, local storage or fingerprinting (guessing who someone is from their browser settings). So analytics does not need a consent banner by itself. Check with your own lawyer for your country.

The package only prints HTML: the browser script tag and a few HTML attributes. It never sends events from your server, sets no cookies and reads nothing about your visitors. You need a Privatus Analytics account to see reports, and there is a free plan. Sign up at https://privatusanalytics.com.

Requirements

  • Python 3.9 or later.
  • Django 4.2 or later, Flask 2.3 or later, or Starlette 0.29 or later (FastAPI is built on Starlette). The core works with no framework at all.
  • A Privatus Analytics site id (it starts with pa_). You find it under Site settings, Tracking.

Install

pip install privatus-analytics

The package has no required dependencies. It uses the framework you already have. For the full setup guide and every tracker option, see the Privatus Analytics docs.

Set your site id with an environment variable:

PRIVATUS_SITE_ID=pa_YOURSITEID

or in your settings, as shown for each framework below.

Add the tag

Django

Add the app and your settings in settings.py:

INSTALLED_APPS = [
    # ...
    "privatus_analytics.django",
]

PRIVATUS_ANALYTICS = {
    "site_id": "pa_YOURSITEID",
}

Put the tag in the <head> of your base template:

{% load privatus %}
<head>
  <title>My app</title>
  {% privatus_analytics %}
</head>

The tag prints only when DEBUG is off. It needs the request in the template context (the default django.template.context_processors.request adds it) for the nonce and for page props that depend on the request.

Flask

from flask import Flask
from privatus_analytics.flask import PrivatusAnalytics

app = Flask(__name__)
app.config["PRIVATUS_ANALYTICS"] = {"site_id": "pa_YOURSITEID"}
PrivatusAnalytics(app)  # or PrivatusAnalytics().init_app(app)
<head>
  <title>My app</title>
  {{ privatus_analytics() }}
</head>

The tag prints only when the app is not in debug or testing mode.

FastAPI and Starlette

from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from privatus_analytics import Config
from privatus_analytics.starlette import setup_templates

config = Config(site_id="pa_YOURSITEID")
templates = setup_templates(Jinja2Templates(directory="templates"), config)
app = FastAPI()


@app.get("/")
async def home(request: Request):
    return templates.TemplateResponse(request, "home.html")

Use {{ privatus_analytics() }} in the template, the same as Flask. Starlette has no shared "production" switch, so the tag prints only when the PRIVATUS_ENV environment variable is production, or when you set enabled=True yourself, for example Config(enabled=not settings.debug).

Without a framework

from privatus_analytics import Config, render_tag

html = render_tag(Config(site_id="pa_YOURSITEID", enabled=True))

To add the tag to any Jinja2 environment, call privatus_analytics.jinja.install(env, config).

What it prints

In production it prints:

<script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID"></script>

The tag prints nothing when there is no site id, and nothing in development (see below). The one exception is the queue stub: when you turn it on, it still prints. Every value is HTML-escaped.

If your app uses a Content Security Policy (CSP, a list of rules that tells the browser which scripts may run) with a nonce (a random code that marks a script as allowed), the tag adds it for you. It looks for request.csp_nonce (django-csp, Flask-Talisman), a csp_nonce template value (Django 6) and request.state.csp_nonce (Starlette and FastAPI). You can also set the nonce setting or pass nonce= to the tag.

Or let the package add it

If you would rather not touch your templates, switch on auto inject:

PRIVATUS_ANALYTICS = {"site_id": "pa_YOURSITEID", "auto_inject": True}
  • Django: also add "privatus_analytics.django.middleware.PrivatusAnalyticsMiddleware" to MIDDLEWARE. Put it below GZipMiddleware in the list.

  • Flask: nothing else to do. The extension adds an after_request hook.

  • FastAPI and Starlette: add the ASGI middleware:

    from privatus_analytics.starlette import PrivatusAnalyticsMiddleware
    
    app.add_middleware(PrivatusAnalyticsMiddleware, config=Config(site_id="pa_YOURSITEID", auto_inject=True))
    

It adds the tag just before </head> in uncompressed text/html responses with status 200, and fixes the Content-Length header. It skips Ajax requests (background requests sent by JavaScript), pages without a <head>, and pages that already have the tag. Responses that carry a Content-Encoding header (compressed responses) are left alone, so add the middleware where it runs before compression, or use the template tag.

Configuration

Every setting is optional except the site id. Django reads them from settings.PRIVATUS_ANALYTICS, Flask from app.config["PRIVATUS_ANALYTICS"], and Starlette takes a Config(...) or a plain dict. Unknown setting names raise an error, so typos do not go unnoticed.

PRIVATUS_ANALYTICS = {
    "site_id": "pa_YOURSITEID",  # or PRIVATUS_SITE_ID
    "script_host": "https://privatusstats.com",  # or PRIVATUS_SCRIPT_HOST
    "modules": ["engage", "auto", "vitals", "clicks", "ask"],
    "excluded_paths": ["/admin/*", "/preview/*"],
    "domains": ["example.com", "www.example.com"],
    "spa": "auto",  # auto, history, hash or off
    "manual": False,  # True turns off the automatic pageview
    "auto_inject": False,
    "extra_attributes": {"data-mask": "/orders/*->/orders/:id"},
    "props": lambda request: {"plan": "pro"},
}
Setting Default What it does
site_id PRIVATUS_SITE_ID The data-site attribute. Nothing prints without it
script_host https://privatusstats.com Where /js/pa.js is served. Change it only if you host your own copy
modules tracker default (engage) The data-modules attribute. Setting it replaces the default, so keep engage for time on page and scroll depth. [] loads none
excluded_paths none The data-exclude attribute. Path patterns the tracker never counts
domains all hosts The data-domains attribute. Count only these hostnames
spa auto The data-spa attribute, for single-page apps
manual False The data-manual attribute
enabled None True or False forces the tag on or off. None uses the framework (see below)
enabled_environments ("production",) Used when no framework decides. Compared with environment
environment PRIVATUS_ENV, else development The current environment when no framework decides
auto_inject False Adds the tag with a middleware
extra_attributes {} Any other data-* attribute. True prints the name alone
nonce framework nonce The CSP nonce, if your framework does not give one
props None Page props: a dict, or a function that gets the request and returns a dict
queue_stub False Prints the window.privatus queue stub before the tag

Each tracker attribute is explained in the script attributes reference, and the modules in Modules.

You can also change a setting for one page:

{% privatus_analytics modules="engage,auto" excluded_paths="/preview/*" %}
{{ privatus_analytics(modules=["engage", "auto"], excluded_paths=["/preview/*"]) }}

Development and test

Your own clicking around should not reach your reports, so by default the tag prints nothing:

  • Django: when DEBUG is on.
  • Flask: when app.debug or app.testing is on.
  • Starlette, FastAPI and plain Python: unless PRIVATUS_ENV=production.

If queue_stub is on, the tag still prints the small queue stub (and nothing else), so privatus.track calls in your own JavaScript keep working. They wait in a queue and are never sent.

To see the tag while you build, set "enabled": True. The tracker also ignores localhost unless you pass data-allow-local or data-debug (see Debug mode):

PRIVATUS_ANALYTICS = {"site_id": "pa_YOURSITEID", "enabled": True, "extra_attributes": {"data-debug": "true"}}

Leaving people out

Use excluded_paths (the data-exclude attribute) for admin areas. To leave out signed-in staff, print the tag only for everyone else:

{% if not request.user.is_staff %}{% privatus_analytics %}{% endif %}

Product analytics: see which features people use

privatus_event prints the attributes that make the tracker send a custom event when someone clicks the element:

<button {% privatus_event "Report exported" format="csv" source="toolbar" %}>Export</button>
<button {{ privatus_event("Report exported", format="csv", source="toolbar") }}>Export</button>

Both print:

<button data-privatus-event="Report exported" data-privatus-prop-format="csv" data-privatus-prop-source="toolbar">Export</button>

Put it on a <form> and the event is sent when the form is submitted:

<form method="post" {% privatus_event "Newsletter signup" list="weekly" %}>

The rules:

  • The event name is trimmed and cut to 120 characters. An empty name prints nothing.
  • Detail names (props) are made lowercase, any character other than a-z, 0-9 and _ becomes _, and they are cut to 64 characters. At most 30 props are kept.
  • Values become text: True becomes true, numbers stay as written, and lists are joined with commas. None values are dropped. Values are cut to 500 characters.
  • These attributes print even when the tag is turned off. They do nothing without the tracker, and your development and production HTML stay the same.

In plain Python, use event_attrs("Report exported", {"format": "csv"}).

Click maps

Give important elements a stable name so click maps can group clicks on them:

<a {% privatus_click "pricing.cta" %} href="/pricing">See pricing</a>
<a {{ privatus_click("pricing.cta") }} href="/pricing">See pricing</a>

This prints data-privatus-click="pricing.cta". Names may use letters, digits and _ . : -, up to 100 characters. Anything else prints nothing and logs a warning on the privatus_analytics logger. In plain Python, use click_attr("pricing.cta"). In Jinja2, privatus_event and privatus_click also work as filters: {{ "pricing.cta" | privatus_click }}.

Page props: plan, role and other groups

Page props are sent with every pageview and event on the page. Use them to compare groups, such as plan, role, app version, feature flags or account size. Set them in your settings as a function that gets the request:

def privatus_props(request):
    if not request.user.is_authenticated:
        return {"plan": "visitor"}
    return {"plan": request.user.account.plan, "role": request.user.role, "app_version": "4.2"}


PRIVATUS_ANALYTICS = {"site_id": "pa_YOURSITEID", "props": privatus_props}

or pass them to one tag, which adds to the props from your settings:

{% privatus_analytics props=page_props %}

The tag then prints a small script before the tracker, and points the tracker at it with data-props:

<script>window.privatusPageProps=function(){return {"plan":"pro","role":"admin"}}</script>
<script defer src="https://privatusstats.com/js/pa.js" data-site="pa_YOURSITEID" data-props="privatusPageProps"></script>

Detail names follow the same rules as events. Numbers and True/False stay numbers and booleans, and lists stay lists (at most 30 items). The data is escaped so it is safe inside a <script> element.

Never put names, emails, user ids or anything else that points to one person in props. Privatus counts visitors without any id. Put groups (plan, role), not people. The server also removes emails and long ids it finds.

Track from your own JavaScript

The package only loads the tracker. You can also send events from your own scripts, as described in the JavaScript API docs:

privatus.track("Feature used", { feature: "export" });

If your code may run before the tracker has loaded, set "queue_stub": True. The tag then prints this small script first (with your nonce), which keeps the calls until the tracker is ready:

<script>window.privatus=window.privatus||{q:[]};['track','pageview','props','optOut','optIn','ready'].forEach(function(m){window.privatus[m]=window.privatus[m]||function(){window.privatus.q.push([m,Array.prototype.slice.call(arguments)])}})</script>

The stub prints even when the tag is off (in development, or with "enabled": False). Only the stub prints then: no tracker and no page props. So your privatus.track calls never fail; they wait in the queue and are never sent.

In plain Python, queue_stub(nonce=...) returns the same script.

Naming events

Name events as "Noun verb", in the past tense, like Report exported, Invite sent or Plan upgraded. Keep the name short and put details in props (format: csv), so one event covers every variant. Then use them in:

  • Funnels, to see where people drop off.
  • Click maps, to see what people click.
  • Goals, to count conversions.
  • Events, for every event and its props.

Privacy

The script sets no cookies and stores nothing in the browser. This package sends nothing from your server and reads no IP addresses, browser details or visitor ids. See the privacy documentation for what is collected.

If you have a privacy policy, it should say which tools handle your visitors' data. You can paste this:

This website uses Privatus Analytics to count visits. It does not use cookies and does not follow you from one website to another. Your IP address is used for a moment to count visits and is never stored.

Change it to fit your site. The privacy policy generator makes a longer version with your site's name in it.

Development

The tests run in Docker, so your local Python does not matter:

./run-docker.sh

It runs the pytest suite against small Django, Flask and Starlette apps, Ruff (the linter and formatter), and the package build.

Contributing

Bug reports and pull requests are welcome at https://github.com/Privatus-Analytics/python.

License

MIT. See LICENSE. Privatus Analytics is a product of Two Phase LLC, Wyoming, USA.

Metadata

Release files for privatus-analytics 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for privatus-analytics 1.0.0
File Size Uploaded Provenance
privatus_analytics-1.0.0.tar.gz 22.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for privatus-analytics 1.0.0
File Interpreter ABI Platform
privatus_analytics-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 44.0 kB

Release files / privatus_analytics-1.0.0.tar.gz

Download URL privatus_analytics-1.0.0.tar.gz
Size 22.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a1a47e2e504cc5c7c206aafe33aa73b54acc4caf7beffec70d333dfb64089e97
BLAKE2b-256 checksum
How to use checksums
74427828ed6278cc7bd9711f46cfc048a7d1873705e2cc249a03a965e7298965
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Release files / privatus_analytics-1.0.0-py3-none-any.whl

Download URL privatus_analytics-1.0.0-py3-none-any.whl
Size 21.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aaba5182cc082c70800b61388f0197300fa2e471e75938b714512825f44b7124
BLAKE2b-256 checksum
How to use checksums
860ff5e49e2b0a9d08d53d14954284f5a80b387c11e2fccb3dc468ab824ca1cc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

Security for privatus-analytics 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for privatus_analytics-1.0.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page