Frameworks
Django
Trace a Django app with EvalKitDjangoMiddleware: init() in settings.py, one trace per request on WSGI and ASGI, sync and async views, request.user as the user id, a session middleware, and fixes for preload and streamed responses.
A Django app is traced by one entry in MIDDLEWARE, evalkit.EvalKitDjangoMiddleware. Each request becomes one trace: a root span for the request, with every model call, tool call, HTTP call and database query it makes nested underneath.
What you get
- A root
http_callspan of kindSERVERper request, named by the method and the resolved URL pattern, such asPOST /chatorGET /orders/<int:order_id>/. A request that resolved nothing keeps its raw path. - On the root span:
http.method,http.url,http.route,http.status_code, request headers, the body ofPOST,PUTandPATCHrequests, and the response headers and body. A streamed response is recorded without its body (read in the SDK source). - Every response carries
X-Trace-Idand atraceparentheader. An incomingtraceparentis honoured. - It works on WSGI and ASGI, with sync and async views: served by gunicorn (
mysite.wsgi) and by uvicorn (mysite.asgi:application), both kinds of view kept their model calls under the request span. - The user id comes from
request.userwhen the user is authenticated (read in the SDK source).
The tutorial below gives this trace:
POST /chat http_call the request, named by its route
chat.completions.create llm_call the model asks for lookup_order
POST http_call the OpenAI client's own request
lookup_order tool_call the tool's arguments and result
chat.completions.create llm_call the model's answer
POST http_callInstall
pip install syntropylabs-evalkit django openaiSet up
import os
from pathlib import Path
import evalkit
# Settings load first in every Django process (runserver, gunicorn, uvicorn), so init() here
# patches OpenAI before any view imports it.
evalkit.init(
subscription_key=os.environ["EVALKIT_SUBSCRIPTION_KEY"],
service_name="django-support",
environment="development",
)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'support',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'evalkit.EvalKitDjangoMiddleware', # after AuthenticationMiddleware, so request.user is known
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]Where init() goes
- Call
init()at the top ofsettings.py. Every Django process loads the settings before it imports a view:runserver, gunicorn withmysite.wsgi, uvicorn withmysite.asgi:applicationand management commands.manage.pyalone would miss gunicorn and uvicorn, which never run it. - Create model clients in your apps, which load after the settings, never in the settings themselves.
- Automatic function tracing covers the modules under the directory of the file that calls
init(). Fromsettings.pythat is the project package,mysite/, and your apps beside it are not included. Add them withtrace_packages=["support"]to get a span for each of their public functions. A function you also decorate, such aslookup_orderbelow, keeps its one span; on 0.3.4 and earlier it gets two, see Automatic function tracing. - Place the middleware after
AuthenticationMiddleware, sorequest.useris set when the request span opens. The middleware above it in the list runs outside the request span.
Tutorial: a support agent with one tool
- 1
Create the project
Run
django-admin startproject mysite .andpython manage.py startapp support. - 2
Add EvalKit to the settings
Add the
init()block,supportinINSTALLED_APPSand the middleware entry, as in Set up. - 3
Write the agent and the view
Save
support/agent.py,support/views.pyandmysite/urls.pybelow. The view answersPOST /chatwith an agent that has one tool,lookup_order: the model asks for the tool, the app runs it, and the model answers with its result. - 4
Set the keys, run it and send one request
Export the two keys, start the development server and send the request below. The spans follow within 5 seconds.
- 5
Open the trace
Find
POST /chatin Traces, as in See the trace.
django-admin startproject mysite .
python manage.py startapp supportimport json
import evalkit
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
SYSTEM = "You are a support agent for an online shop. Use lookup_order for questions about an order."
TOOLS = [{
"type": "function",
"function": {
"name": "lookup_order",
"description": "Find an order from the customer's words.",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
},
}]
@evalkit.trace_tool(name="lookup_order") # a tool_call span with the arguments and the result
def lookup_order(query: str) -> dict:
return {"order_id": "1001", "status": "shipped", "arrives": "Thursday"}
def answer(message: str) -> str:
messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": message}]
reply = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=TOOLS).choices[0].message
if reply.tool_calls:
messages.append(reply.model_dump(exclude_none=True))
for call in reply.tool_calls:
result = lookup_order(**json.loads(call.function.arguments))
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
reply = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=TOOLS).choices[0].message
return reply.contentimport json
import evalkit
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .agent import answer
@csrf_exempt # a JSON API called without Django's CSRF cookie
@require_POST
def chat(request):
body = json.loads(request.body)
evalkit.set_agent("support-bot") # the trace lists under this agent in Traces > Agents
with evalkit.session(body["session_id"], user_id=body.get("user_id")): # groups the turns of one conversation
return JsonResponse({"reply": answer(body["message"])})from django.urls import path
from support import views
urlpatterns = [path("chat", views.chat)]export EVALKIT_SUBSCRIPTION_KEY=tk_live_... # Settings → This project → Environments → Copy key
export OPENAI_API_KEY=sk-...python manage.py runserver # serves on port 8000curl -s -X POST localhost:8000/chat \
-H 'content-type: application/json' \
-d '{"message": "Where is my order 1001?", "session_id": "conv_1", "user_id": "u_42"}'{"reply": "Order 1001 shipped yesterday and arrives on Thursday."}Tested with syntropylabs-evalkit 0.3.5, Python 3.13, Django 6.1.2 and openai 3.31 on runserver, with and without trace_packages, and on gunicorn 26.2 and uvicorn --workers 2 (mysite.asgi:application).
See the trace
- Open Traces. The newest row is
POST /chatfrom the servicedjango-support, with the tokens of both model calls. - Click it. The waterfall shows the request span with the two
llm_callspans, each with itsPOST, and thelookup_ordertool span under it. - To open one exact request, copy the
X-Trace-Idresponse header, paste it into Search traces and click Open trace. - Traces → Sessions lists
conv_1with useru_42, and Traces → Agents listssupport-bot. The root span carries the agent but no session id, because the request span opened before the view named the session. The trace still belongs toconv_1: a trace belongs to every session that any of its spans carries. - The development server's start-up line, "Watching for file changes with StatReloader", arrives as a
logspan in a trace of its own.
Sessions, users and agents
With AuthenticationMiddleware above it, the middleware sets the user id from request.user when the user is authenticated: its pk, id, identity, username or email, the first one set (read in the SDK source). A user id you set yourself wins.
For the conversation, open evalkit.session(...) in the view, as the tutorial does, or once for every request in a middleware placed after EvalKitDjangoMiddleware:
import evalkit
class ConversationMiddleware:
"""Puts each request's spans in the conversation its headers name."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
session_id = request.headers.get("X-Session-Id")
if not session_id:
return self.get_response(request)
with evalkit.session(session_id, user_id=request.headers.get("X-User-Id")):
return self.get_response(request)MIDDLEWARE = [
# ...
'django.contrib.auth.middleware.AuthenticationMiddleware',
'evalkit.EvalKitDjangoMiddleware',
'support.identity.ConversationMiddleware', # inside the request span, so the view's spans carry the session
# ...
]The spans of the view carry the session and user, and the next request starts clean, because with restores the previous ids. evalkit.set_user(...) without a reset stays in force on that thread after the request; reset it with evalkit.clear_user(token).
Pitfalls
- ASGI workers and the last seconds of spans. Under
uvicorn --workersor--reload, a worker servingmysite.asgi:applicationis stopped without running exit handlers. From 0.3.5EvalKitDjangoMiddlewaregives Django's ASGI handler a lifespan that sends the queue at shutdown, so 3 requests made just before a SIGTERM all arrived. On 0.3.4 and earlier those spans are lost; gunicorn withmysite.wsgiis not affected. - No spans from gunicorn with --preload, on 0.3.4 and earlier. With
--preload, gunicorn runsinit()in the master process and then forks the workers, and there a forked worker never sends its spans, because the export thread does not survive the fork. Run gunicorn without--preloadthere. From 0.3.5 a worker forked afterinit()starts an export thread of its own: in a run of Flask undergunicorn --preload -w 2, the workers' spans arrived while they ran and at shutdown. The same holds formultiprocessingwithfork; see Workers and shutdown for Celery. - A
StreamingHttpResponsemodel call is not under the request span. Django iterates a streamed response after the middleware has returned, so the request span has ended when the generator runs. The model call made while streaming stays in the request's trace, with the session opened inside the generator, but as a second root beside the request span rather than under it. This held on gunicorn and on uvicorn. - Your apps' functions have no spans. With
init()insettings.py, automatic function tracing covers the project package only. Add your apps withtrace_packages, as in Where init() goes. runservercallsinit()twice. The autoreloader runs a watcher process and a serving process, and both load the settings, so each callsinit(). Requests are recorded by the process that serves them.- Log lines become spans; your log output stays as it was. Each record that propagates to the root logger is recorded as a
logspan, as a handler there would record it: at the level of the logger that wrote it, which is the root level,WARNING, unless that logger or your app sets another.init()changes no handler, formatter or level, so your console output stays as it was. Every record carriestrace_idandspan_id, so a format of your own can show them, as in%(asctime)s %(levelname)s [%(trace_id)s] %(name)s: %(message)s. On 0.3.4 and earlier,init()changes the format of the root logger's console handlers and sets the root level to INFO; configure logging afterinit()there. - Every management command needs the key.
settings.pyloads formigrate,testand every other command, soos.environ["EVALKIT_SUBSCRIPTION_KEY"]raisesKeyErrorwherever the variable is unset. Callinit()only when the variable is set if your CI runs commands without it, and guard@evalkit.trace_tool()and the other decorators too: they raiseRuntimeErrorwheninit()never ran. The middleware itself lets requests through withoutinit()(read in the SDK source). - A short script, a CLI command or a serverless handler loses its last spans. Spans go out every 5 seconds and when the process exits normally. A serverless runtime freezes the process between invocations instead of ending it, so call
evalkit.flush()at the end of each invocation, and in a script before it exits early.
Safe by design
- Spans are queued in memory and sent from a background thread, so a request never waits for the trace service.
- If the trace service is down or unreachable, your app answers as before. The SDK retries each export 3 times, logs
evalkit: export failed after 3 retriesas a warning and drops those spans. - If the key is wrong, the trace service refuses the spans. After 3 refusals in a row the SDK logs one warning and stops exporting until the process restarts; your app keeps answering.
- A streamed model reply reaches your code chunk by chunk, unchanged. The SDK reads the same chunks on the side and writes the span when the stream ends.
- Tracing never breaks the call it records. An error inside the SDK is logged at debug level and the call runs as if untraced, and an exception from the provider reaches your code unchanged.
- A failed export never raises in your code, and
evalkit.flush()returns normally too. At exit the SDK sends what is queued within 3 seconds in all, whether or not the trace service answers, so an outage never holds up a deploy.
Verify it worked
- Each response has an
X-Trace-Idheader. No header means the middleware is not inMIDDLEWARE, or a middleware above it answered the request first. - The trace appears in Traces within about 5 seconds. If it does not, look for
evalkit:warnings in the server's log. - Pass
debug=Truetoinit()and set theevalkitlogger to DEBUG to log each export.
Next steps
- Choose what is recorded, from prompts to function arguments, and mask what must not leave the process: Configuration and privacy.
- Every library the SDK traces without code, and how streamed calls are counted: What is traced automatically.
- Follow one request across services, or run beside an OpenTelemetry setup you already have: OpenTelemetry and distributed tracing.
- Read the trace you just sent: Your first trace, and group turns into conversations: Sessions and users.