Chris Tam · Last updated 21 October 2026
A backend sees product actions that happen after a request reaches the server. Amplitude product analytics turns those actions into events I can inspect by user and property.
In this tutorial, I'll add a report preview endpoint, identify signed-in users from verified authentication, pass through an anonymous device ID, and handle SDK shutdown.
A Report Previewed event with report_type, days, and framework properties. Signed-in requests also update account_type; anonymous requests carry only a device ID. I'll verify delivery status locally, then inspect the event in Amplitude.
Prerequisites
- Python 3.12 or later, an existing uv-managed project, and a Django 5.2 project using the standard authentication and CSRF middleware.
- A stable user ID convention shared with any browser analytics. This example prefixes the internal ID with
user-; keep your existing convention if you already have one. - An Amplitude project API key from Settings > Projects > your project > General > Manage next to API Key.
The new modules are complete; additions to existing project files are labeled as additions.
Sign up for free to create an Amplitude account and get a project API key.
1. Install the Python SDK
I'll use the package named in the Python SDK reference. Run this from the project root. Keep the resulting uv.lock with the project. Terminal
uv add "amplitude-analytics>=1.2,<2"
# For a new project only:
uv add "django>=5.2,<5.3"
uv run django-admin startproject config .Keep the dependency release-age policy in the existing uv configuration.
pyproject.toml (add to your existing configuration)
[tool.uv]
exclude-newer = "7 days"2. Configure a client for each worker
I'll load the key from the process environment. These exports apply to the shell that starts the app; a .env file alone isn't loaded by this code. In production, set the same names through the host's environment settings. Ingestion uses the project API key, with no secret key.
Terminal
export AMPLITUDE_API_KEY="your-project-api-key"
export AMPLITUDE_SERVER_ZONE="US"Use EU for an EU project. I'll create metrics/__init__.py as an empty file and add this module. The first call initializes the client inside a worker, so importing the module in a pre-fork master doesn't create SDK threads.
metrics/analytics.py
# Keep additions from later Python hub pages when merging this module.
from threading import Lock
import os
from amplitude import Amplitude, BaseEvent, EventOptions, Identify
def delivery_status(event, code, message=None):
# Log the response code, never the payload, key, or user details.
print(f"Amplitude delivery: HTTP {code}")
_client = None
_client_lock = Lock()
def get_client():
global _client
# Lock creation so simultaneous first requests share one client.
with _client_lock:
if _client is None:
_client = Amplitude(os.environ["AMPLITUDE_API_KEY"])
_client.configuration.callback = delivery_status
_client.configuration.server_zone = os.environ.get("AMPLITUDE_SERVER_ZONE", "US")
return _client
def shutdown_client():
global _client
with _client_lock:
if _client is not None:
try:
for pending in _client.flush():
if pending is not None:
pending.result()
finally:
_client.shutdown()
_client = None
def record_report_preview(user_id, device_id, framework, account_type=None):
# Only device IDs come from the request. User IDs come from verified auth.
if not isinstance(device_id, str) or not 5 <= len(device_id) <= 1024:
device_id = None
if user_id is None and device_id is None:
return False
client = get_client()
if user_id is not None and account_type is not None:
identify = Identify()
identify.set("account_type", account_type)
client.identify(identify, EventOptions(user_id=user_id, device_id=device_id))
client.track(BaseEvent(
event_type="Report Previewed",
user_id=user_id,
device_id=device_id,
event_properties={"report_type": "usage", "days": 7, "framework": framework},
))
return TrueWhy. The client is shared, but identity is passed separately on each event and Identify call. A request for a second person can't overwrite an SDK-wide current user. IDs shorter than five characters are invalid by default. A signed-in user-42 is long enough; an anonymous request without a usable device ID still gets its report response, with analytics skipped.
The delivery callback logs only an HTTP status. The SDK queues events in memory and sends batches in the background. A successful application response does not confirm that Amplitude accepted the event.
3. Use the app's verified authentication
I'll keep Django's existing session authentication and CSRF protection. This helper reads the authenticated request.user; it never accepts a user ID from JSON or a custom header.
metrics/auth.py
# Preserve this helper when adding later Django hub pages.
def get_current_user(request):
user = request.user
return user if user.is_authenticated else NoneKeep the existing SessionMiddleware, CsrfViewMiddleware, and AuthenticationMiddleware in settings. The Django project template already includes them. This tutorial adds no login endpoint.
4. Track the report preview
I'll record the event after computing the response. The preview values are fixed to keep the example runnable; merge the instrumentation after the corresponding operation in an existing report route.
metrics/views.py
# Keep routes and decorators added by other Django hub pages.
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods
from .auth import get_current_user
from .analytics import record_report_preview
@require_http_methods(["GET", "POST"])
def report_preview(request):
if request.method == "GET":
return JsonResponse({"csrfToken": get_token(request)})
user = get_current_user(request)
# Use the same canonical ID format in browser instrumentation.
user_id = f"user-{user.pk}" if user is not None else None
# Replace these fixed preview values with your existing report computation.
preview = {"report_type": "usage", "days": 7}
record_report_preview(
user_id=user_id,
device_id=request.headers.get("X-Amplitude-Device-Id"),
framework="django",
account_type="staff" if user is not None and user.is_staff else
"member" if user is not None else None,
)
return JsonResponse(preview)metrics/urls.py
# Keep URL patterns added by other Django hub pages.
from django.urls import path
from .views import report_preview
urlpatterns = [path("reports/preview", report_preview)]Add these imports and this URL pattern to the existing root URL configuration. Keep every existing route.
config/urls.py (additions only)
from django.urls import include, path
urlpatterns += [path("api/", include("metrics.urls"))]A GET to the route supplies a CSRF token and cookie. Only a valid POST records an event. Keep authorization checks from an existing route when adding this instrumentation.
The optional X-Amplitude-Device-Id header carries the device ID already used by the browser's Amplitude installation. It is analytics context, never proof of identity. Pass the same canonical signed-in user ID on browser and server events. Do not invent a shared anonymous user ID or generate a fresh device ID for every request. This example forwards device identity only; it doesn't join browser sessions or record session replay.
MCP. I can use the Amplitude MCP server to read the project's existing event taxonomy before adding Report Previewed, so the new event follows existing naming.
5. Run the app and drain events
Django's development server uses the normal Python process exit path; the SDK registers its own shutdown handler. In a managed worker, keep the process manager's graceful shutdown window and drain analytics before that worker exits. Do not initialize the client in AppConfig.ready() or a pre-fork master. A forced kill can't run cleanup.
Terminal
uv run python manage.py migrate
uv run python manage.py runserver --noreloadFor a short-lived script or job using this module, close the client in finally. flush() triggers delivery; shutdown() flushes remaining events and closes the SDK's threads. The helper waits for the delivery futures before closing the client, then clears the reference so a later call can create a fresh one.
scripts/send_report_event.py
from metrics.analytics import record_report_preview, shutdown_client
try:
record_report_preview("user-42", None, "django", "member")
finally:
shutdown_client()Create an empty scripts/__init__.py, then run the script from the project root:
Terminal
uv run python -m scripts.send_report_eventVerify it worked
I'll send one anonymous request from a second terminal. This synthetic device ID is for the local smoke test; a real frontend sends its existing Amplitude device ID.
Terminal
curl -s -c /tmp/django-build-cookies http://127.0.0.1:8000/api/reports/preview > /tmp/django-build-csrf.json
CSRF_TOKEN=$(uv run python -c 'import json; print(json.load(open("/tmp/django-build-csrf.json"))["csrfToken"])')
curl -i -b /tmp/django-build-cookies -X POST \
-H "X-CSRFToken: $CSRF_TOKEN" \
-H "X-Amplitude-Device-Id: build-anonymous-device-42" \
http://127.0.0.1:8000/api/reports/preview- Application response: expect HTTP 200 with
report_typeequal tousageanddaysequal to7. A missing anonymous device ID deliberately skips instrumentation. - Process output: wait for
Amplitude delivery: HTTP 200. A different response code means delivery needs investigation. This is independent of the route's HTTP response. - Ingestion Debugger: in Amplitude, open Data > Sources > Ingestion Debugger to check aggregate request, event, and Identify counts. It doesn't show individual event contents.
- User profile: open Users > User Profiles and search for device ID
build-anonymous-device-42. Allow ten seconds to one minute after delivery. OpenReport Previewedand inspectreport_type,days, andframework; use Raw for the event's raw data. - Signed-in identity: repeat through the app's authenticated client, using the same device ID. For internal account
42, search foruser-42, confirm the event, and inspectaccount_type. Identify is sent before the event, so its properties can apply to the following event. Never fake authentication with a user header. - Exit delivery: stop the server gracefully after a final request. Confirm its delivery callback before the process exits. Exercise the actual deployment manager's shutdown path separately when deploying.
Troubleshooting
The API returns 200 but no event appears
Check the delivery callback first. The response only confirms report generation. An anonymous request without a device ID, or with a device ID shorter than five characters, intentionally skips tracking. Verify the project key, region, and outbound connectivity if the SDK reports a delivery failure.
The signed-in account still appears anonymous
Connect the auth helper to verified authentication and use the same canonical ID format as the frontend. The backend never reads a user ID from request JSON or a header. Keep user-42 only if that is also the convention in browser instrumentation.
Anonymous visits become separate people
Forward the existing browser device ID on each relevant request. A new ID per request creates disconnected identities. On logout, reset identity in the browser and forward its new anonymous device ID; omitting the server user ID alone doesn't make a previously known device anonymous again.
The final event disappears when a worker stops
Allow a graceful shutdown and call shutdown_client() before a short-lived worker exits. In-memory buffering can't survive a forced kill. Don't reuse a closed client, and don't create it before forking workers.
Django returns CSRF verification failed
Keep CSRF middleware enabled. GET the token and cookie first, then send both with the POST. For a browser frontend, use the app's existing same-origin CSRF flow. This tutorial does not exempt the analytics route from CSRF protection.
Frequently asked questions
Do I need a secret key?
The SDK sends events with the project's API key. The ingestion endpoint doesn't require a secret key. I'll keep the key in the server environment, and avoid printing it in delivery logs. Administrative API operations can use different credentials and aren't part of this tutorial.
Can I track anonymous requests?
Yes. I'll forward a stable device ID while leaving user_id unset. If neither identifier is available, this example skips analytics and returns the report normally. Passing the existing browser device ID preserves identity continuity; joining a browser session requires additional session context.
Does this install browser tracking or session replay?
No. The Python SDK sends the backend events I instrument. This page doesn't add browser SDKs, capture clicks, or record session replay. If the app already has browser instrumentation, I can use the same user and device IDs for continuity across those events.
Should I flush after every request?
The SDK already sends buffered events in the background. I'll reuse its client and close it when a worker or job exits. A regular HTTP response doesn't wait for remote ingestion. For business-critical delivery, keep an application-owned durable queue or outbox rather than treating an in-memory analytics buffer as a transaction log.
Full code reference
The new modules below match the steps above. Keep the existing application settings, authentication implementation, and unrelated routes. Empty __init__.py files make the shown directories packages.
Complete new modules
# -- metrics/analytics.py --
# Keep additions from later Python hub pages when merging this module.
from threading import Lock
import os
from amplitude import Amplitude, BaseEvent, EventOptions, Identify
def delivery_status(event, code, message=None):
# Log the response code, never the payload, key, or user details.
print(f"Amplitude delivery: HTTP {code}")
_client = None
_client_lock = Lock()
def get_client():
global _client
# Lock creation so simultaneous first requests share one client.
with _client_lock:
if _client is None:
_client = Amplitude(os.environ["AMPLITUDE_API_KEY"])
_client.configuration.callback = delivery_status
_client.configuration.server_zone = os.environ.get("AMPLITUDE_SERVER_ZONE", "US")
return _client
def shutdown_client():
global _client
with _client_lock:
if _client is not None:
try:
for pending in _client.flush():
if pending is not None:
pending.result()
finally:
_client.shutdown()
_client = None
def record_report_preview(user_id, device_id, framework, account_type=None):
# Only device IDs come from the request. User IDs come from verified auth.
if not isinstance(device_id, str) or not 5 <= len(device_id) <= 1024:
device_id = None
if user_id is None and device_id is None:
return False
client = get_client()
if user_id is not None and account_type is not None:
identify = Identify()
identify.set("account_type", account_type)
client.identify(identify, EventOptions(user_id=user_id, device_id=device_id))
client.track(BaseEvent(
event_type="Report Previewed",
user_id=user_id,
device_id=device_id,
event_properties={"report_type": "usage", "days": 7, "framework": framework},
))
return True
# -- metrics/auth.py --
# Preserve this helper when adding later Django hub pages.
def get_current_user(request):
user = request.user
return user if user.is_authenticated else None
# -- metrics/views.py --
# Keep routes and decorators added by other Django hub pages.
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods
from .auth import get_current_user
from .analytics import record_report_preview
@require_http_methods(["GET", "POST"])
def report_preview(request):
if request.method == "GET":
return JsonResponse({"csrfToken": get_token(request)})
user = get_current_user(request)
# Use the same canonical ID format in browser instrumentation.
user_id = f"user-{user.pk}" if user is not None else None
# Replace these fixed preview values with your existing report computation.
preview = {"report_type": "usage", "days": 7}
record_report_preview(
user_id=user_id,
device_id=request.headers.get("X-Amplitude-Device-Id"),
framework="django",
account_type="staff" if user is not None and user.is_staff else
"member" if user is not None else None,
)
return JsonResponse(preview)
# -- metrics/urls.py --
# Keep URL patterns added by other Django hub pages.
from django.urls import path
from .views import report_preview
urlpatterns = [path("reports/preview", report_preview)]Root URL addition, shown separately so existing routes remain intact:
config/urls.py (additions only)
from django.urls import include, path
urlpatterns += [path("api/", include("metrics.urls"))]Unchanged from the existing project; keep your versions: environment management, dependency configuration, application settings, authentication routes, and unrelated endpoints. Keep AMPLITUDE_API_KEY and AMPLITUDE_SERVER_ZONE available to every worker.
Hand it to your agent
I'll give a coding agent the page and these constraints:
Agent prompt
Add Amplitude product analytics to this existing Django app using /build/django-product-analytics.
Install amplitude-analytics with uv and preserve the project's uv.lock and release-age policy.
Merge new code into existing modules, routes, auth dependencies, and lifespan work. Never remove lines another Amplitude Build page added. Create the base auth helper and report preview route if absent.
Reuse one SDK client per worker, initialized after fork. Keep user_id and device_id on each event and EventOptions, never on shared mutable request identity. Get user IDs only from verified auth. Preserve the existing canonical ID convention and ensure IDs have at least five characters.
Forward the existing browser device ID for anonymous requests. Skip analytics if neither ID is usable. Never treat a device header as authentication, and never create a shared anonymous user ID.
Track Report Previewed after computing the report, with report_type=usage, days=7, and framework=django. Set account_type using Identify before the event for signed-in users.
Preserve authorization and CSRF controls. Keep API keys in process environment and log delivery status without payloads or credentials.
Preserve Django middleware and root URL patterns; do not initialize in AppConfig.ready or a pre-fork master.
Flush and close the SDK at graceful worker/job exit; do not shut it down after each request.
Report files changed and verify anonymous identity, signed-in identity, concurrent users, invalid device IDs, and exit delivery. Confirm remote ingestion separately in Amplitude.What this unlocks
Product analytics: compare Report Previewed by report_type and account_type.
Feature flags: add a later backend flag evaluation to decide who sees a new report, while keeping this event as its usage signal.
Experimentation: instrument a later experiment with explicit exposure tracking and use the report event to measure outcomes.