Chris Tam · Last updated 21 October 2026
Django can choose a report experience before returning its API response. I use feature flags to control that choice and experimentation to compare what users do afterward. The report's export action supplies the outcome event.
In this tutorial, I extend the product analytics setup, evaluate a flag for signed-in users, request exposure tracking at the decision, and record exports separately.
A POST /api/reports/preview response with either the existing report or an added summary, plus POST /api/reports/export that returns a CSV and tracks Report Exported. The remote evaluation request asks Amplitude to track exposure to report-summary; the export route never evaluates the flag again.
Prerequisites
- The Django product analytics tutorial, including its
metrics/analytics.pyandmetrics/auth.pymodules, the preview route, and the app's normal startup and shutdown handling. I keep those analytics and identity helpers unchanged. - Python 3.12 or later and a uv-managed project. The new modules below are complete; merge route changes into the existing application.
- An Amplitude project with access to feature experimentation and permission to create deployments, flags, and experiments. Sign up for an account and compare plans for your team's needs.
- A verified application login. This example evaluates only signed-in users. Django uses the existing
request.userhelper.
1. Create the flag and server deployment
In Amplitude, open Experiment > Deployments, select Create Deployment, choose the same project as the analytics API key, and create a Server deployment for this service. Copy its deployment key. Keep that key in the server's environment; it is a different credential from the analytics project API key.
Open Experiment > Feature Flags > Create Feature Flag. Name the flag Report summary, confirm its key is report-summary, choose Remote evaluation, and attach the server deployment. In Variants, add values control and treatment. The application compares these exact values.
For an initial rollout, target the account_type property your application sends. Use member and staff as eligible values. For a split test, use user bucketing and a 50/50 distribution within that audience. Keep a limited rollout while testing, then select Activate flag when the configuration is ready.
Process environment additions
# Keep AMPLITUDE_API_KEY from the product analytics tutorial.
export AMPLITUDE_DEPLOYMENT_KEY="your-server-deployment-key"
export AMPLITUDE_SERVER_ZONE="US"Use EU for an EU project, for both analytics and evaluation. Never put the server deployment key in browser code or a public environment variable. The deployment guide covers the project and deployment relationship.
2. Add a shared evaluation client
The Experiment Python SDK provides remote evaluation. Install it in the existing project:
Terminal
uv add "amplitude-experiment>=1.13,<2"Create metrics/experiments.py. Each worker creates its client on first use; user identity is supplied with each fetch. The connection and request limits below bound waiting for this cosmetic report choice. Tune them for the service's latency budget.
metrics/experiments.py
# Keep analytics and authentication from the product analytics tutorial.
import logging
import os
from threading import Lock
from amplitude_experiment import Experiment, RemoteEvaluationConfig, User
from amplitude_experiment.remote.fetch_options import FetchOptions
from amplitude_experiment.server_zone import ServerZone
FLAG_KEY = "report-summary"
_client = None
_lock = Lock()
log = logging.getLogger(__name__)
def get_experiment():
global _client
with _lock:
if _client is None:
zone = ServerZone.EU if os.environ.get("AMPLITUDE_SERVER_ZONE", "US") == "EU" else ServerZone.US
_client = Experiment.initialize_remote(
os.environ["AMPLITUDE_DEPLOYMENT_KEY"],
RemoteEvaluationConfig(
server_zone=zone,
fetch_timeout_millis=500,
fetch_retries=0,
connection_pool_max_size=8,
fetch_pool_acquire_timeout_millis=100,
),
)
return _client
def choose_report_layout(user_id, account_type):
# This experiment is for signed-in users. Auth determines eligibility.
if user_id is None:
return "control"
client = get_experiment()
try:
variants = client.fetch_v2(
User(user_id=user_id, user_properties={"account_type": account_type}),
FetchOptions(tracksExposure=True, flagKeys=[FLAG_KEY]),
) or {}
variant = variants.get(FLAG_KEY)
if variant is not None and variant.value in {"control", "treatment"}:
return variant.value
except Exception:
# Availability wins for this cosmetic layout. Keep authorization separate.
log.warning("Experiment evaluation failed; serving the baseline report")
return "control"
def close_experiment():
# Call only at worker exit, after requests have drained.
if _client is not None:
_client.close()Why. A shared client can serve many users, but a shared mutable user object can mix their assignments. I build a new User inside each call. Missing identity skips evaluation. An absent flag, an unexpected value, or a fetch failure serves the baseline report. That fallback is not a real control assignment, and the application does not fabricate an exposure for it.
tracksExposure=True requests server-side exposure tracking when this flag is evaluated. flagKeys limits the request to the experience about to be used. Call this helper at the preview decision, rather than during generic request middleware or background prefetching. A server decision does not confirm that a browser rendered the response.
This flag controls presentation. Existing authentication and authorization still decide whether a user can access a real report.
3. Serve the variant and record exports
The export event uses the same analytics client and canonical user ID as Report Previewed. Create a separate module so the base analytics helper stays unchanged:
metrics/report_events.py
from amplitude import BaseEvent
from .analytics import get_client
def record_report_export(user_id, device_id, framework):
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
get_client().track(BaseEvent(
event_type="Report Exported",
user_id=user_id,
device_id=device_id,
event_properties={"report_type": "usage", "days": 7, "framework": framework},
))
return TrueMerge these two view functions and their imports into the existing module. Preserve other views, authorization decorators, and CSRF middleware. GET on the preview route still supplies a CSRF token; neither POST is exempt from CSRF checks.
metrics/views.py
# Keep routes and decorators from /build/django-product-analytics.
from django.http import HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods, require_POST
from .auth import get_current_user
from .analytics import record_report_preview
from .experiments import choose_report_layout
from .report_events import record_report_export
@require_http_methods(["GET", "POST"])
def report_preview(request):
if request.method == "GET":
return JsonResponse({"csrfToken": get_token(request)})
user = get_current_user(request)
user_id = f"user-{user.pk}" if user is not None else None
account_type = "staff" if user is not None and user.is_staff else "member" if user is not None else None
# Evaluate only where the selected experience is about to be used.
layout = choose_report_layout(user_id, account_type)
preview = {"report_type": "usage", "days": 7, "layout": layout}
if layout == "treatment":
preview["summary"] = "Your last seven days of usage are ready to export."
record_report_preview(user_id, request.headers.get("X-Amplitude-Device-Id"), "django", account_type)
return JsonResponse(preview)
@require_POST
def report_export(request):
user = get_current_user(request)
user_id = f"user-{user.pk}" if user is not None else None
# No flag fetch here: exporting is the outcome, not another exposure.
response = HttpResponse("report_type,days\nusage,7\n", content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="usage.csv"'
record_report_export(user_id, request.headers.get("X-Amplitude-Device-Id"), "django")
return responseAdd the export pattern beside the existing preview pattern. Keep the root path("api/", include("metrics.urls")) and any other URL patterns.
metrics/urls.py, report patterns
# Add export beside the existing preview; keep all other URL patterns.
from django.urls import path
from .views import report_preview, report_export
urlpatterns = [
path("reports/preview", report_preview),
path("reports/export", report_export),
]Keep the worker-exit flushing from the product analytics tutorial. Call close_experiment() from metrics.experiments in the worker's existing graceful-exit hook after requests drain, alongside analytics cleanup. Remote evaluation has no application-side exposure queue to flush; the analytics conversion events still need the base SDK's shutdown handling.
The fixed report data makes the example runnable. Merge the variant selection with the application's real computation and authorization. Report Exported means the backend prepared the CSV response; it does not prove a client finished downloading the file.
4. Exercise the two report actions
Use the application's normal login to prepare a valid session in cookies.txt, or adapt the request to its existing verified token mechanism. Do not add a user-ID header as a substitute for authentication. The device header forwards an existing browser analytics device ID; it is not an authentication credential.
Terminal, with an authenticated application session
# Keep your existing authenticated cookies in cookies.txt.
ORIGIN="http://127.0.0.1:8000"
CSRF_TOKEN=$(curl -sS -b cookies.txt -c cookies.txt "$ORIGIN/api/reports/preview" |
python3 -c 'import json,sys; print(json.load(sys.stdin)["csrfToken"])')
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/preview" -H "X-CSRFToken: $CSRF_TOKEN" -H "X-Amplitude-Device-Id: existing-browser-device-id"The response contains layout: "control" or layout: "treatment". Only treatment includes summary. Keep the same user identity for the later export. Preserve any existing browser CORS and cookie-CSRF policy when integrating these endpoints; curl does not exercise browser policies.
5. Turn the flag into an experiment
Convert the existing flag to an experiment through the documented experiment setup workflow. In Metrics, add a conversion metric based on Report Exported and set the goal to increase exports. Keep the same server deployment and audience.
Use Amplitude's [Experiment] Exposure event for exposure attribution. Report Previewed is a product event, and [Experiment] Assignment is a distinct assignment event. This implementation explicitly requests exposure tracking; it does not rename either event or send a second manual exposure.
Test the variants and metric configuration before selecting Start Experiment. After previewing the report, invoke the export action:
Terminal, same authenticated session
curl --fail-with-body -b cookies.txt -X POST "$ORIGIN/api/reports/export" -H "X-CSRFToken: $CSRF_TOKEN" -H "X-Amplitude-Device-Id: existing-browser-device-id" -o usage.csvVerify it worked
- Check both response variants using your development flag's test targeting. A control response has no
summary; a treatment response includes it. Test rollout and property targeting separately, then remove test targeting before collecting production results. - Confirm the evaluation request succeeds. The SDK requests only
report-summaryand asks the evaluation service to track exposure. A successful HTTP request alone is not proof that Amplitude processed its exposure event. - Export once after a preview. The response is a CSV attachment; the export route makes no flag fetch. In the analytics delivery callback, confirm HTTP 200. The base SDK buffers events, so allow its normal interval or graceful shutdown to upload them.
- In Amplitude's Ingestion Debugger, check request and event counts. Then open Users > User Profiles, find the same canonical user ID, and inspect
Report PreviewedandReport Exportedin the event stream. Check[Experiment] Exposurefor the flag and actual variant, then confirm the experiment's exposure and conversion analysis uses those events. - Repeat as another signed-in user and an anonymous request. The anonymous request gets the baseline without evaluation. Force an evaluation failure in development and confirm the report remains usable; do not count that fallback as a control assignment.
MCP. With existing project access, use the Amplitude MCP server to inspect the event taxonomy and confirm Report Exported matches the event used by the metric. Do not create a near-duplicate conversion name.
Troubleshooting
Every response uses the baseline
Confirm verified authentication returns a user, the deployment key belongs to the analytics project, the flag is attached and active, and the configured values are exactly control and treatment. Check evaluation errors. Anonymous requests, absent flags, unknown values, and failures deliberately choose the baseline.
The wrong users qualify
Use user_properties.account_type from trusted application data. Check the audience property name and rollout in Amplitude. A property sent through Identify is useful for analytics, but this request supplies its targeting property directly.
Exports appear but the experiment has no exposures
Check tracksExposure=True, the report-summary key, deployment membership, and the same canonical user ID in evaluation and analytics. Verify processed [Experiment] Exposure events separately from assignment events. The export action does not produce an exposure.
A request hangs during evaluation
Tune the fetch timeout and connection-pool wait limit for your latency budget. These examples disable retries and fall back to the baseline. Pool saturation is also a waiting condition, not only a slow remote response.
Django rejects the POST with HTTP 403
Keep CSRF middleware enabled and send the token from the GET response with the same cookie session. Preserve the application's authorization checks; do not exempt the analytics routes from them.
FAQ
Is the analytics project API key the deployment key?
Use a server deployment key for Experiment and the analytics project API key for conversion events. Both belong to the same Amplitude project. Keep the server deployment key on the server.
Does this experiment include anonymous users?
This example evaluates signed-in users only. Anonymous requests retain the baseline and can still send product events with a usable device ID. An anonymous experiment needs an explicit device bucketing and identity-continuity design.
Does an evaluation timeout mean control won?
A timeout selects the baseline for availability. It is not a successful control assignment and does not establish an experiment result. Wait for processed exposures, conversions, and the experiment's statistical analysis before choosing a winner.
Full code reference
Unchanged from Django product analytics; keep your versions: metrics/analytics.py, metrics/auth.py, the root application configuration, and existing shutdown hooks.
# -- metrics/experiments.py --
# Keep analytics and authentication from the product analytics tutorial.
import logging
import os
from threading import Lock
from amplitude_experiment import Experiment, RemoteEvaluationConfig, User
from amplitude_experiment.remote.fetch_options import FetchOptions
from amplitude_experiment.server_zone import ServerZone
FLAG_KEY = "report-summary"
_client = None
_lock = Lock()
log = logging.getLogger(__name__)
def get_experiment():
global _client
with _lock:
if _client is None:
zone = ServerZone.EU if os.environ.get("AMPLITUDE_SERVER_ZONE", "US") == "EU" else ServerZone.US
_client = Experiment.initialize_remote(
os.environ["AMPLITUDE_DEPLOYMENT_KEY"],
RemoteEvaluationConfig(
server_zone=zone,
fetch_timeout_millis=500,
fetch_retries=0,
connection_pool_max_size=8,
fetch_pool_acquire_timeout_millis=100,
),
)
return _client
def choose_report_layout(user_id, account_type):
# This experiment is for signed-in users. Auth determines eligibility.
if user_id is None:
return "control"
client = get_experiment()
try:
variants = client.fetch_v2(
User(user_id=user_id, user_properties={"account_type": account_type}),
FetchOptions(tracksExposure=True, flagKeys=[FLAG_KEY]),
) or {}
variant = variants.get(FLAG_KEY)
if variant is not None and variant.value in {"control", "treatment"}:
return variant.value
except Exception:
# Availability wins for this cosmetic layout. Keep authorization separate.
log.warning("Experiment evaluation failed; serving the baseline report")
return "control"
def close_experiment():
# Call only at worker exit, after requests have drained.
if _client is not None:
_client.close()
# -- metrics/report_events.py --
from amplitude import BaseEvent
from .analytics import get_client
def record_report_export(user_id, device_id, framework):
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
get_client().track(BaseEvent(
event_type="Report Exported",
user_id=user_id,
device_id=device_id,
event_properties={"report_type": "usage", "days": 7, "framework": framework},
))
return True
# -- metrics/views.py --
# Keep routes and decorators from /build/django-product-analytics.
from django.http import HttpResponse, JsonResponse
from django.middleware.csrf import get_token
from django.views.decorators.http import require_http_methods, require_POST
from .auth import get_current_user
from .analytics import record_report_preview
from .experiments import choose_report_layout
from .report_events import record_report_export
@require_http_methods(["GET", "POST"])
def report_preview(request):
if request.method == "GET":
return JsonResponse({"csrfToken": get_token(request)})
user = get_current_user(request)
user_id = f"user-{user.pk}" if user is not None else None
account_type = "staff" if user is not None and user.is_staff else "member" if user is not None else None
# Evaluate only where the selected experience is about to be used.
layout = choose_report_layout(user_id, account_type)
preview = {"report_type": "usage", "days": 7, "layout": layout}
if layout == "treatment":
preview["summary"] = "Your last seven days of usage are ready to export."
record_report_preview(user_id, request.headers.get("X-Amplitude-Device-Id"), "django", account_type)
return JsonResponse(preview)
@require_POST
def report_export(request):
user = get_current_user(request)
user_id = f"user-{user.pk}" if user is not None else None
# No flag fetch here: exporting is the outcome, not another exposure.
response = HttpResponse("report_type,days\nusage,7\n", content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="usage.csv"'
record_report_export(user_id, request.headers.get("X-Amplitude-Device-Id"), "django")
return response
# -- metrics/urls.py --
# Add export beside the existing preview; keep all other URL patterns.
from django.urls import path
from .views import report_preview, report_export
urlpatterns = [
path("reports/preview", report_preview),
path("reports/export", report_export),
]Hand it to your agent
Coding-agent prompt
Extend the existing Django report example using https://amplitude.com/build/django-feature-flags.
Keep metrics/analytics.py and metrics/auth.py from /build/django-product-analytics unchanged, including real verified authentication and worker shutdown flushing. Merge route and lifespan changes; preserve unrelated code and authorization/CSRF controls.
Use amplitude-experiment>=1.13,<2 with one remote client per worker. Read AMPLITUDE_DEPLOYMENT_KEY only on the server; it must belong to the analytics project. Honor AMPLITUDE_SERVER_ZONE for both SDKs.
Use the tested FetchOptions import from amplitude_experiment.remote.fetch_options, with tracksExposure=True and flagKeys=["report-summary"]. Supply a new User with the canonical authenticated ID and trusted account_type per request.
Evaluate only at POST /api/reports/preview. Serve control or treatment; only treatment adds summary. Anonymous identity, missing/unknown variants, and evaluation errors use the baseline without inventing exposures. Keep authentication separate from the flag.
Add POST /api/reports/export to return CSV and send Report Exported after preparing the response. Do not fetch a flag or manually send exposure on export. Keep the same user ID as evaluation and product analytics.
Keep both POSTs protected by Django CSRF and merge URL patterns.
Verify both variants, property targeting context, scoped exposure requests, conversion identity, timeout/failure fallback, concurrency, and shutdown delivery. Confirm remote exposures and experiment analysis separately in Amplitude. Report every file changed.What this unlocks
Feature flags: control the report summary for eligible users without changing route code.
Experimentation: compare exports after exposure to the summary or baseline experience.
Product analytics: inspect Report Previewed and Report Exported with the same canonical user identity.