---
title: "How to add agent analytics to a Django app"
description: "Add agent analytics to Django views with OpenAI, stable conversation IDs, streaming replies, browser identity, and a flush-and-wait step."
canonical: "https://amplitude.com/en-us/build/django-agent-analytics"
---

# How to add agent analytics to a Django app

Add agent analytics to Django views with OpenAI, stable conversation IDs, streaming replies, browser identity, and a flush-and-wait step.

Source: https://amplitude.com/en-us/build/django-agent-analytics

---

# How to add agent analytics to a Django app

Install the Amplitude AI SDK in a Django project, wrap a chat view in an agent session, stream replies with StreamingHttpResponse, and link each conversation to the user's browser session. About 20 minutes, all copy-paste.

- Django
- Gunicorn
- Amplitude AI SDK
- 20 min

Chris Tam · Last updated 19 October 2026

Django finishes a normal view before it finishes a streaming response. I keep the [agent analytics](https://amplitude.com/agent-analytics) session inside the streaming generator, then wait for the upload attempt after it closes.

I’ll instrument sync Django views with the OpenAI wrapper, stable conversation IDs, and CSRF-aware browser requests. The complete files below cover both JSON and streamed replies.

A `POST /api/chat` view that records `[Agent] User Message` and `[Agent] AI Response` for every turn under one `[Agent] Session ID` per conversation, with `[Agent] Model Name`, `[Agent] Input Tokens`, `[Agent] Output Tokens`, `[Agent] Cost USD`, and `[Agent] Latency Ms` filled in by the OpenAI wrapper, plus a streaming `POST /api/chat/stream` view. Both are tied to the same user and browser session as your product analytics. By the end you'll see those events in your user's event stream and the conversation in agent analytics.

## Prerequisites

- A Django project with sync views, served by Gunicorn, uWSGI, or `runserver` in development, that calls a model. The examples use OpenAI; the SDK also wraps Anthropic, Azure OpenAI, Google Gen AI, Bedrock, and Mistral.
- An Amplitude project with agent analytics enabled, and its project API key. In Amplitude, go to Settings > Projects > your project > General, then Manage next to API Key. Agent data can live in the same project as your product analytics, which is the simplest setup.
- The View Agent Analytics Objects permission on your role, which you need to open agent analytics at all.
- Optional, for step 5: browser analytics with `@amplitude/unified`, as in [How to add product analytics to a React app](https://amplitude.com/build/react-product-analytics).

No Amplitude account yet? A free account is enough for this tutorial; every plan includes a free allotment of agent sessions. [Sign up for free](https://app.amplitude.com/signup) to get your project API key.

These examples use a US-region Amplitude project. For an EU project, supply an [EU-configured analytics client](https://amplitude.com/docs/amplitude-ai/agent-analytics/privacy-modes#data-residency) instead of initializing with the API key alone.

## 1. Install the SDK

The Python package is `amplitude-ai`. It installs provider support as extras, so add the `openai` extra for the OpenAI wrapper. For every option, see the [agent analytics SDK reference](https://amplitude.com/docs/sdks/agent-analytics/sdk).

```sh
uv add "amplitude-ai[openai]"
```

The docs name the variable `AMPLITUDE_AI_API_KEY`. It holds your standard project API key. Set both keys in the environment your Django process runs in: your process manager, your host's settings, or the `.env` loader you already use.

.env

```sh
# .env
AMPLITUDE_AI_API_KEY=your-project-api-key
OPENAI_API_KEY=your-openai-key
```

Prefer to let a coding agent do the instrumentation? Install the package, then tell your agent to run `amplitude-ai --print-guide` and follow the guide it prints. The steps below are what it should produce on Django.

## 2. Initialize once in a module of its own

Create one module in your app that constructs the client, the wrapped OpenAI client, and the agent at import time. Views import from it and never construct their own. Each Gunicorn worker imports it once and gets its own instance.

chat/agent\_analytics.py

```python
# chat/agent_analytics.py
import os

from amplitude_ai import AIConfig, AmplitudeAI, OpenAI

ai = AmplitudeAI(
    api_key=os.environ["AMPLITUDE_AI_API_KEY"],
    config=AIConfig(
        content_mode="full",  # prompt and response text, with PII redaction
        redact_pii=True,
        debug=os.environ.get("AMPLITUDE_AI_DEBUG") == "true",
    ),
)

# Use this instead of the client from the openai package. It forwards every
# call and records model, tokens, latency, and cost on [Agent] AI Response.
openai_client = OpenAI(amplitude=ai, api_key=os.environ["OPENAI_API_KEY"])

support_agent = ai.agent("support-chat")
```

**Why.** The module isn't called `amplitude_ai.py` because that's the SDK's own package name, and a local file with that name shadows it, so `from amplitude_ai import AmplitudeAI` imports your file instead. `ai.agent(...)` lives here so requests reuse the same named agent and shared configuration.

Add one helper for the user ID, and use the same format your product analytics already sends.

chat/identity.py

```python
# chat/identity.py
def amplitude_user_id(user):
    # No account, no user ID. Never send placeholders like "anonymous".
    if user is None or not user.is_authenticated:
        return None
    # Prefix integer primary keys: Amplitude drops user IDs shorter than
    # 5 characters, and a pk of 42 is two.
    return f"user-{user.pk}"
```

`content_mode="full"` is the default and sends prompt and response text after scrubbing emails, phone numbers, SSNs, card numbers, and IP addresses. `redact_pii` defaults to `True` from version 1.17.0 and to `False` before it, so set it explicitly when upgrading an older installation. If message text must stay in your infrastructure, use `"metadata_only"`: you still get tokens, latency, model, cost, and session grouping, with no text. With `AMPLITUDE_AI_DEBUG=true`, every tracked event prints to stderr.

Redaction matches patterns; it does not remove every kind of personal data. Review representative payloads before production capture. In `metadata_only`, built-in quality signals that need message text are unavailable.

## 3. Wrap the chat view in a session

For Django and other WSGI frameworks, the SDK's pattern is a session opened in the view itself. Open one session per request, keyed on the conversation's ID, and make the LLM call inside the `with` block. The wrapper sees the call happen inside the session and records the user's latest message from `messages` and the AI response against it, so the view doesn't call `track_user_message()` as well.

chat/views.py

```python
# chat/views.py
import json

from django.http import JsonResponse
from django.views.decorators.http import require_POST

from .agent_analytics import ai, openai_client, support_agent
from .identity import amplitude_user_id

def session_for(request, conversation_id):
    return support_agent.session(
        user_id=amplitude_user_id(request.user),
        # Sent by the browser in step 5, from the Browser SDK.
        device_id=request.headers.get("x-amplitude-device-id"),
        browser_session_id=request.headers.get("x-amplitude-session-id"),
        session_id=conversation_id,
    )

@require_POST
def chat(request):
    body = json.loads(request.body)
    messages = body["messages"]

    try:
        with session_for(request, body["conversationId"]):
            completion = openai_client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
            )
            reply = completion.choices[0].message.content or ""
    finally:
        # Ship this request's events now, even if the model call raised.
        for pending in ai.flush():
            if pending is not None:
                pending.result()

    return JsonResponse({"reply": reply})
```

chat/urls.py

```python
# chat/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("api/chat", views.chat, name="chat"),
    path("api/chat/stream", views.chat_stream, name="chat-stream"),
]
```

Include `chat.urls` from your project's root `urls.py` if it isn't already. `chat_stream` comes in step 4.

**Why.** Flushing after each session is the docs' pattern for one-shot endpoints. A Gunicorn or uWSGI worker is a long-running process, so the SDK doesn't flush when the session closes; it batches. The `finally` block starts the upload and waits for each returned future. Concurrent requests can share a batch; a completed upload attempt does not guarantee successful delivery.

Reuse `conversationId` across every turn of the same conversation, and never across users. Every turn then carries the same `[Agent] Session ID`, so don't generate a fresh one per request. When the same user starts a new conversation, send a new ID.

The `with` block closes the session when it exits, and Amplitude enriches a session once, at its first close. Later turns with the same ID are stored, but they don't reach that session's `[Agent] Session Record`. Pass a user ID, a device ID, or both on every session; a session with neither has nothing to attach to.

Use the canonical model ID, `gpt-4o-mini`, not a gateway alias. Cost comes from token counts multiplied against a public model-price catalog, and a name the catalog can't price gets no cost.

**MCP.** Working in Cursor or Claude Code? Amplitude's MCP server lets your agent read your project's existing taxonomy before it adds anything, so the `Plan Upgraded` event your billing view already sends and the `[Agent]` events from this page end up in charts together instead of next to a duplicate `planUpgraded`. [Set up the MCP server](https://amplitude.com/docs/amplitude-ai/amplitude-mcp).

## 4. Stream replies without losing the response event

A `StreamingHttpResponse` keeps sending after the view returns. If the session opens in the view, it closes before the model finishes and the `[Agent] AI Response` event is dropped. Open the session inside the generator instead, so it stays open until the last chunk goes out, and flush when the generator finishes.

chat/views.py

```python
# chat/views.py (add to the file from step 3)
from django.http import StreamingHttpResponse

def stream_answer(request, conversation_id, messages):
    try:
        with session_for(request, conversation_id):
            stream = openai_client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                stream=True,
                # Streams only report token usage when asked; tokens and cost need it.
                stream_options={"include_usage": True},
            )
            for chunk in stream:
                if chunk.choices and chunk.choices[0].delta.content:
                    yield chunk.choices[0].delta.content
    finally:
        # The view returned long ago; flush when the stream is done.
        for pending in ai.flush():
            if pending is not None:
                pending.result()

@require_POST
def chat_stream(request):
    body = json.loads(request.body)
    return StreamingHttpResponse(
        stream_answer(request, body["conversationId"], body["messages"]),
        content_type="text/plain",
    )
```

**Why.** The docs' rule is that a streaming session must stay open until the stream is fully consumed. A generator that holds the `with` block does exactly that, and its `finally` starts the flush after the stream is consumed and waits for each returned upload future.

## 5. Send the browser IDs with each request

To tie a conversation to the product session around it, and to get a session replay link on it, the view needs the Browser SDK's session ID and device ID. Steps 3 and 4 already read them from two headers; send them from the browser, along with Django's CSRF token.

static/chat.js

```javascript
// static/chat.js (browser)
import * as amplitude from '@amplitude/unified';

function csrfToken() {
  return document.cookie
    .split('; ')
    .find((c) => c.startsWith('csrftoken='))
    ?.split('=')[1];
}

export async function sendChat(conversationId, messages) {
  const headers = { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() };
  const deviceId = amplitude.getDeviceId();
  const sessionId = amplitude.getSessionId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);

  const res = await fetch('/api/chat', {
    method: 'POST',
    headers,
    body: JSON.stringify({ conversationId, messages }),
  });
  if (!res.ok) throw new Error(`/api/chat failed with ${res.status}`);
  return (await res.json()).reply;
}
```

Create `conversationId` with `crypto.randomUUID()` when a conversation starts and keep it for that conversation only.

**Why.** The agent session and the browser session are different things. The agent session is the conversation, and your app owns its ID. The browser session is the visit that powers session replay, and the Browser SDK owns it. Forwarding the device ID also keeps a signed-out visitor from splitting into two users, since a backend that makes up its own device ID per request breaks the merge.

## Verify it worked

Run `AMPLITUDE_AI_DEBUG=true python manage.py runserver`, send two messages in the same conversation, then check in this order:

1. **Terminal.** Every tracked event prints to stderr as a one-line summary, for example `[amplitude-ai] [Agent] AI Response | user=user-42 session=... agent=support-chat model=gpt-4o-mini ...`. If nothing prints, check instrumentation, including an LLM call outside the session or an unwrapped client. You can also run `amplitude-ai doctor`, or `amplitude-ai verify` for local checks.
2. **Network tab.** Filter for `/api/chat`. The request headers should include `x-amplitude-session-id`, `x-amplitude-device-id`, and `X-CSRFToken`. The server's own upload to Amplitude doesn't show here, since it leaves from Python.
3. **Ingestion Debugger.** In Amplitude, click Data, select Sources, then open the Ingestion Debugger tab. The successful requests chart should show your server's uploads. It shows counts, not individual events.
4. **User lookup.** Go to Users > User Profiles and search your user ID or device ID. Within ten seconds to a minute the event stream shows `[Agent] User Message` and `[Agent] AI Response` for each turn. Open an AI Response and confirm `[Agent] Model Name`, `[Agent] Provider`, `[Agent] Input Tokens`, `[Agent] Output Tokens`, `[Agent] Cost USD`, `[Agent] Latency Ms`, `[Agent] Session ID` (your `conversationId`), and `[Agent] Agent ID` (`support-chat`) are all set.
5. **Agent analytics.** Open agent analytics in the left nav and find the session by your `conversationId`. Quality signals typically arrive on `[Agent] Session Record` 15 to 20 minutes after the first close, and cover the events received before it. An enrichment backlog can delay them.

## Troubleshooting

### ImportError: cannot import name 'AmplitudeAI' from partially initialized module

A file in your project is named `amplitude_ai.py` and shadows the SDK package. Rename it, for example to `agent_analytics.py`, and delete any stale `__pycache__` copy.

### Django returns 403 on POST /api/chat

Django's CSRF protection rejected a JSON request with no token. Send the `X-CSRFToken` header, as in step 5, and make sure the page that loads `chat.js` sets the `csrftoken` cookie. If that view doesn't render a template with `{% csrf_token %}`, Django might not set the cookie; decorate the view with `ensure_csrf_cookie`.

### Nothing prints in the terminal and no AI Response arrives

The model call used an unwrapped client or ran outside the session. Import `openai_client` from `chat/agent_analytics.py`, not `OpenAI` from the `openai` package, and make the call inside the `with` block.

### Every turn has two User Message events

The view calls `s.track_user_message()` for a message it also sends through the wrapped client, which records it already. Remove the manual call.

### Events print locally but never arrive from production

The key isn't set where the workers run, or the flush never ran. Confirm `AMPLITUDE_AI_API_KEY` is in the Gunicorn or uWSGI process's environment, not only in a local `.env`, and keep the flush-and-wait loop in both `finally` blocks.

### Turns of one conversation have different session IDs

The browser sends a new `conversationId` per message, or different agent names are used across requests. Keep the agent in `agent_analytics.py` and hold the ID for the life of the conversation.

### Streamed replies have no AI Response

The session closed before the stream finished. Open the session inside the generator, as in step 4, not in the view that returns the `StreamingHttpResponse`.

### `[Agent] Cost USD` is empty

The model name isn't one the price catalog knows, such as a gateway alias or a fine-tuned `ft:` model. Pass the canonical provider model ID. On streamed replies, also check that `stream_options` asks for usage.

## FAQ

### Can I keep conversation text out of Amplitude?

Yes. Set the privacy mode to `metadata_only`. Tokens, cost, latency, models, and session grouping remain available; message text and tool payloads stay out. Built-in quality signals that need message text will not run.

### Should every message get a new agent session ID?

Use one stable ID for the same conversation or task, and a new ID for a new job. Never reuse an ID across users. These scope helpers close at the end of each request or task; later events with the same ID are stored but do not update the first Session Record.

### Why are events visible before quality signals?

Events normally appear within about a minute. The Session Record typically follows 15 to 20 minutes after an explicit close, and can take longer during an enrichment backlog.

## Full code reference

Every file from this tutorial in one block, the version to hand to an agent.

Full code reference

```python
# -- .env --
# AMPLITUDE_AI_API_KEY=your-project-api-key
# OPENAI_API_KEY=your-openai-key

# -- chat/agent_analytics.py --
import os

from amplitude_ai import AIConfig, AmplitudeAI, OpenAI

ai = AmplitudeAI(
    api_key=os.environ["AMPLITUDE_AI_API_KEY"],
    config=AIConfig(
        content_mode="full",
        redact_pii=True,
        debug=os.environ.get("AMPLITUDE_AI_DEBUG") == "true",
    ),
)

openai_client = OpenAI(amplitude=ai, api_key=os.environ["OPENAI_API_KEY"])

support_agent = ai.agent("support-chat")

# -- chat/identity.py --
def amplitude_user_id(user):
    if user is None or not user.is_authenticated:
        return None
    return f"user-{user.pk}"

# -- chat/views.py --
import json

from django.http import JsonResponse, StreamingHttpResponse
from django.views.decorators.http import require_POST

from .agent_analytics import ai, openai_client, support_agent
from .identity import amplitude_user_id

def session_for(request, conversation_id):
    return support_agent.session(
        user_id=amplitude_user_id(request.user),
        device_id=request.headers.get("x-amplitude-device-id"),
        browser_session_id=request.headers.get("x-amplitude-session-id"),
        session_id=conversation_id,
    )

@require_POST
def chat(request):
    body = json.loads(request.body)
    messages = body["messages"]

    try:
        with session_for(request, body["conversationId"]):
            completion = openai_client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
            )
            reply = completion.choices[0].message.content or ""
    finally:
        for pending in ai.flush():
            if pending is not None:
                pending.result()

    return JsonResponse({"reply": reply})

def stream_answer(request, conversation_id, messages):
    try:
        with session_for(request, conversation_id):
            stream = openai_client.chat.completions.create(
                model="gpt-4o-mini",
                messages=messages,
                stream=True,
                stream_options={"include_usage": True},
            )
            for chunk in stream:
                if chunk.choices and chunk.choices[0].delta.content:
                    yield chunk.choices[0].delta.content
    finally:
        for pending in ai.flush():
            if pending is not None:
                pending.result()

@require_POST
def chat_stream(request):
    body = json.loads(request.body)
    return StreamingHttpResponse(
        stream_answer(request, body["conversationId"], body["messages"]),
        content_type="text/plain",
    )

# -- chat/urls.py --
from django.urls import path

from . import views

urlpatterns = [
    path("api/chat", views.chat, name="chat"),
    path("api/chat/stream", views.chat_stream, name="chat-stream"),
]
```

Full code reference

```javascript
// -- static/chat.js (browser) --
import * as amplitude from '@amplitude/unified';

function csrfToken() {
  return document.cookie
    .split('; ')
    .find((c) => c.startsWith('csrftoken='))
    ?.split('=')[1];
}

export async function sendChat(conversationId, messages) {
  const headers = { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken() };
  const deviceId = amplitude.getDeviceId();
  const sessionId = amplitude.getSessionId();
  if (deviceId) headers['x-amplitude-device-id'] = deviceId;
  if (sessionId) headers['x-amplitude-session-id'] = String(sessionId);

  const res = await fetch('/api/chat', {
    method: 'POST',
    headers,
    body: JSON.stringify({ conversationId, messages }),
  });
  if (!res.ok) throw new Error(`/api/chat failed with ${res.status}`);
  return (await res.json()).reply;
}
```

## Hand it to your agent

Prefer to have Cursor, Claude Code, or Codex CLI do the wiring? This prompt points the agent at this page and pins the decisions that matter.

Using Cursor or Claude Code? Paste this prompt

```text
Add Amplitude agent analytics to this Django project by following
https://amplitude.com/build/django-agent-analytics exactly. Install
"amplitude-ai[openai]". Create chat/agent_analytics.py (never name it
amplitude_ai.py) that builds AmplitudeAI from
os.environ["AMPLITUDE_AI_API_KEY"], the wrapped OpenAI client from
amplitude_ai, and the agent, all at module level; never call ai.agent()
inside a view. Use the same user ID format as our product analytics,
prefixed so it's at least 5 characters, and pass None when signed out
instead of a placeholder. In each LLM view, open
agent.session(user_id, device_id, browser_session_id, session_id) with the
conversation ID as session_id, make the model call inside the with block,
and iterate ai.flush() in a finally block, calling result() on each non-None future. Don't call track_user_message()
for a message you pass to the wrapped client; the wrapper records it.
For streaming, open the session
inside the generator passed to StreamingHttpResponse, pass
stream_options={"include_usage": True}, and flush in the generator's
finally. Read device and session IDs from the x-amplitude-device-id and
x-amplitude-session-id headers, and send X-CSRFToken from the browser.
Don't hardcode keys. Report every file you changed.
```

## What this unlocks

- Chart `[Agent] Cost USD` and `[Agent] Latency Ms` by model on AI Response events.
- Connect agent and product events in funnels when they use the same stable user ID.
- Review content-based quality signals on the Session Record after the session closes, when your privacy mode sends message text.

When browser session replay is already recording, forwarding both browser IDs adds a replay link to the agent session. Verify that recording separately from the server events.

## Next steps

[Web frameworksHow to add agent analytics to a FastAPI backendThe async version, with a lifespan shutdown instead of a flush per request.]()[Web frameworksHow to add agent analytics to a Node.js backendThe same setup for an Express server, with tool calls and server-sent events.]()[Use caseHow to stitch client and server events to one userMake the user ID your backend sends match the one the browser sets.](https://amplitude.com/build/stitch-client-server-events)

Reference docs

[Agent analytics quickstart →](https://amplitude.com/docs/amplitude-ai/agent-analytics/quickstart)[Amplitude AI SDK reference →](https://amplitude.com/docs/sdks/agent-analytics/sdk)[Agent analytics sessions and user identity →](https://amplitude.com/docs/amplitude-ai/agent-analytics/sessions)[Link agent sessions to session replay →](https://amplitude.com/docs/amplitude-ai/agent-analytics/link-session-replay)[Verify your agent analytics instrumentation →](https://amplitude.com/docs/amplitude-ai/agent-analytics/verify)[Amplitude MCP server →](https://amplitude.com/docs/amplitude-ai/amplitude-mcp)

For agents

[View raw .md](/build/django-agent-analytics.md)[llms.txt](/llms.txt)

## Ship the instrumentation, then read the data

Use the verification steps above to check your agent events, then configure the reports and replay links you need.

[Create a free account](https://app.amplitude.com/signup)[Browse all Build tutorials](https://amplitude.com/build)
