Docs

    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_call span of kind SERVER per request, named by the method and the resolved URL pattern, such as POST /chat or GET /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 of POST, PUT and PATCH requests, and the response headers and body. A streamed response is recorded without its body (read in the SDK source).
    • Every response carries X-Trace-Id and a traceparent header. An incoming traceparent is 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.user when the user is authenticated (read in the SDK source).

    The tutorial below gives this trace:

    Waterfall
    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_call

    Install

    bash
    pip install syntropylabs-evalkit django openai

    Set up

    mysite/settings.py
    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 of settings.py. Every Django process loads the settings before it imports a view: runserver, gunicorn with mysite.wsgi, uvicorn with mysite.asgi:application and management commands. manage.py alone 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(). From settings.py that is the project package, mysite/, and your apps beside it are not included. Add them with trace_packages=["support"] to get a span for each of their public functions. A function you also decorate, such as lookup_order below, keeps its one span; on 0.3.4 and earlier it gets two, see Automatic function tracing.
    • Place the middleware after AuthenticationMiddleware, so request.user is 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. 1

      Create the project

      Run django-admin startproject mysite . and python manage.py startapp support.

    2. 2

      Add EvalKit to the settings

      Add the init() block, support in INSTALLED_APPS and the middleware entry, as in Set up.

    3. 3

      Write the agent and the view

      Save support/agent.py, support/views.py and mysite/urls.py below. The view answers POST /chat with 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. 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. 5

      Open the trace

      Find POST /chat in Traces, as in See the trace.

    Create the project
    django-admin startproject mysite .
    python manage.py startapp support
    support/agent.py
    import 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.content
    support/views.py
    import 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"])})
    mysite/urls.py
    from django.urls import path
    
    from support import views
    
    urlpatterns = [path("chat", views.chat)]
    Environment
    export EVALKIT_SUBSCRIPTION_KEY=tk_live_...   # Settings → This project → Environments → Copy key
    export OPENAI_API_KEY=sk-...
    Run
    python manage.py runserver   # serves on port 8000
    Request
    curl -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"}'
    Response
    {"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 /chat from the service django-support, with the tokens of both model calls.
    • Click it. The waterfall shows the request span with the two llm_call spans, each with its POST, and the lookup_order tool span under it.
    • To open one exact request, copy the X-Trace-Id response header, paste it into Search traces and click Open trace.
    • Traces → Sessions lists conv_1 with user u_42, and Traces → Agents lists support-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 to conv_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 log span 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:

    support/identity.py
    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)
    mysite/settings.py
    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 --workers or --reload, a worker serving mysite.asgi:application is stopped without running exit handlers. From 0.3.5 EvalKitDjangoMiddleware gives 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 with mysite.wsgi is not affected.
    • No spans from gunicorn with --preload, on 0.3.4 and earlier. With --preload, gunicorn runs init() 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 --preload there. From 0.3.5 a worker forked after init() starts an export thread of its own: in a run of Flask under gunicorn --preload -w 2, the workers' spans arrived while they ran and at shutdown. The same holds for multiprocessing with fork; see Workers and shutdown for Celery.
    • A StreamingHttpResponse model 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() in settings.py, automatic function tracing covers the project package only. Add your apps with trace_packages, as in Where init() goes.
    • runserver calls init() twice. The autoreloader runs a watcher process and a serving process, and both load the settings, so each calls init(). 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 log span, 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 carries trace_id and span_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 after init() there.
    • Every management command needs the key. settings.py loads for migrate, test and every other command, so os.environ["EVALKIT_SUBSCRIPTION_KEY"] raises KeyError wherever the variable is unset. Call init() only when the variable is set if your CI runs commands without it, and guard @evalkit.trace_tool() and the other decorators too: they raise RuntimeError when init() never ran. The middleware itself lets requests through without init() (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 retries as 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-Id header. No header means the middleware is not in MIDDLEWARE, 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=True to init() and set the evalkit logger to DEBUG to log each export.

    Next steps

    EvalKit is built by SyntropyLabs. Published on PyPI and npm.