Django

One middleware, and every request, error and log line from your Django app reaches Sentrinel โ€” correlated, so a log line opens the request that wrote it and an error opens the user it happened to.

Tested against Django 3.1 through 6.1, on Python 3.9 through 3.14. It has no runtime dependencies: a monitoring library is installed into someone else's dependency tree, and every package it drags in is a version it can conflict with.

Install

Not on PyPI yet โ€” it installs from the public packages repo:

pip install "git+https://github.com/Zaga-ltd/sentinel_packages.git#subdirectory=sentrinel_django"

Your build image needs git for that; many slim Python images do not have it. To pin rather than track main, append @<commit> to the URL.

# settings.py
MIDDLEWARE = [
    "sentrinel_django.SentrinelMiddleware",
    "django.middleware.security.SecurityMiddleware",
    ...
]

SENTRINEL = {
    "SERVER_URL": "https://api.sentrinel.dev",
    "APP_NAME": "orders",
    "MODULE": "api",
    "ENV": "prod",
    "API_KEY": os.environ["SENTRINEL_API_KEY"],
}

APP_NAME is the project, MODULE is this service. An app in Sentrinel is the whole product โ€” the API, a worker, the web front end, the phone app, the database โ€” and each part reports into the same app as a module of it, so a request can be followed from the page that made it through the service that served it. Use the same APP_NAME everywhere and give each service its own MODULE ("api", "worker", "admin"). Left unset, the part is named after the API key. The API Keys page lists every part and when it last reported.

Put it first. The middleware measures the time from when it is entered to when the response comes back, so placed first it reports what the user waited. Placed last it reports handler time and under-reports every request that a slow middleware made slow.

The key is a Server key โ€” API Keys โ†’ Generate โ†’ Server. Keys are bound to one integration, so a leaked one reaches nothing else.

Data appears with the next request. Nothing else is required.

What you get without configuring anything

Requests method, route, status, duration, size, client IP, host โ€” one row each
Endpoints grouped by Django's own route (/orders/:pk), with p50/p95/p99
Errors type, message, full traceback, and the request it came out of โ€” plus every 4xx/5xx response, with the message from its body
Consumers per-user request and error counts, from request.user

A raised Http404 is recorded as a 404 and PermissionDenied as a 403 โ€” the statuses Django answers with โ€” not as server errors. A 4xx your code returns (Django REST framework's {"detail": โ€ฆ} responses, HttpResponseBadRequest) is an error row too, with the message from the body, the way the Node and FastAPI SDKs count it.

Bodies are off by default and sensitive headers are masked whether you configure it or not. A deployment that sets only the four keys above collects the useful things and none of the dangerous ones.

Logs

Add the handler and the lines you already write arrive with the request that wrote them:

LOGGING = {
    "version": 1,
    "handlers": {
        "sentrinel": {"class": "sentrinel_django.SentrinelLogHandler", "level": "INFO"},
    },
    "root": {"handlers": ["console", "sentrinel"], "level": "INFO"},
}
logger.info("card declined", extra={"order_id": order.id, "gateway": "stripe"})

The message is stored uninterpolated, so every occurrence of one statement groups together, and extra= becomes structured attributes you can filter by. Each line carries the request id, the consumer, and the trace โ€” stored on the row rather than resolved by joining back, because the request may age out of retention first and a log line that has lost its owner cannot be found by anyone looking for that user.

Identity

By default a request belongs to request.user.pk when authenticated, and to nobody when not. To use something else โ€” a tenant, an API client, a header:

SENTRINEL = {
    ...,
    "CONSUMER_IDENTIFIER": "myapp.telemetry.consumer_for",
}

# myapp/telemetry.py
def consumer_for(request):
    return request.headers.get("X-Tenant-Id") or getattr(request.user, "email", None)

A dotted path or a callable. If it raises, the request is recorded as anonymous rather than failing.

From inside a view, when identity is only known there:

from sentrinel_django import set_consumer, add_context

def checkout(request):
    set_consumer(order.customer_id)
    add_context(tier="enterprise", cart_value=order.total)

add_context turns the request row into a canonical wide event: the attributes land on the request, on any error it produces, and on its log lines.

Errors you handled

An exception you caught never reaches the middleware, and "handled" often means a degraded path the user still noticed:

from sentrinel_django import capture_exception

try:
    charge(order)
except PaymentError as exc:
    capture_exception(exc, attributes={"order_id": order.id})
    return fallback()

The error takes the status of the response that follows: a view that answers 503 records a 503, and the response is not counted as a second error. A fallback the caller never noticed โ€” the view still answered 200 โ€” is recorded as a 500 marked Handled. Outside a request, in a management command or a worker, the error is sent straight away.

One trace, phone to backend

The Flutter and browser SDKs put a traceparent header on every call they make. The middleware reads it, so a request from your app and the server work it caused are one trace rather than two unrelated rows โ€” which is what makes the timeline showing a tap cause a database query exist at all.

Nothing to configure. A request that arrives with a valid header continues that trace; one that arrives without starts its own. A malformed or forged header is ignored rather than trusted.

Every row carries it: the request, the log lines written while serving it, and any error it raised โ€” so an issue opens the waterfall that produced it.

Calls you make onward need the header passed on. A service that joins a trace and then drops it looks, on the timeline, like the downstream service never ran:

import requests
from sentrinel_django import outgoing_headers

requests.post(url, json=payload, headers=outgoing_headers())

The forwarded header carries this service's span, not the caller's, so the next hop hangs off your work rather than off the phone's.

To show a user the id behind a failure โ€” an error page, a support ticket:

from sentrinel_django import current_trace

trace_id = current_trace()["trace_id"]

Outside a request both return nothing rather than inventing a trace, because a root span belonging to nobody is worse than no span.

Spans: why a request was slow

A request row says a page took 900 ms. A trace says 740 of those were one query, it ran four times, and the third one blocked.

from sentrinel_django import span, traced

def checkout(request):
    with span("db.load_cart", {"table": "carts"}):
        cart = Cart.objects.get(...)

    with span("gateway.charge", {"gateway": "stripe"}) as s:
        result = gateway.charge(cart)
        s["attributes"]["authorised"] = result.ok
    ...

@traced("pricing.quote")          # or bare @traced() โ€” it names the function
def quote(order):
    ...

Spans nest the way your code does, and the whole tree hangs off the request's server span. An exception passing through a span marks it ERROR and is re-raised โ€” a span that swallowed the error would be worse than no span.

Outside a request span() is inert rather than an error, so the same helper works in a view and in a management command.

A trace is only sent when there is something to say: a request that recorded no spans produces no trace row, because a tree containing only its own server span repeats what the request row already said.

Calls your app makes

Without this the waterfall stops at your own code, and the payment gateway that took four seconds is invisible.

from sentrinel_django import SentrinelSession

http = SentrinelSession()          # wraps requests.Session
http.post("https://api.stripe.com/v1/charges", json=payload)

Every call becomes a CLIENT span with its status, and carries traceparent so the service you called joins the same trace. The span is named POST api.stripe.com/v1/charges โ€” host and path, never the query string, which carries tokens and ids and would give you one span name per request.

requests is not a dependency of this package. Pass your own session (SentrinelSession(my_session)) and it is never imported at all.

With httpx โ€” sync or async โ€” wrap the transport instead:

import httpx
from sentrinel_django import httpx_transport, async_httpx_transport

client = httpx.Client(transport=httpx_transport())
aclient = httpx.AsyncClient(transport=async_httpx_transport())

@traced works on async def too, timing the awaited work rather than the creation of the coroutine.

Your web pages

Everything in a JavaScript bundle is public, so the browser SDK holds no API key. It posts to your own server, and your server forwards with the key:

# urls.py
from sentrinel_django import sentrinel_tunnel

urlpatterns = [path("api/_sentrinel", sentrinel_tunnel), ...]

Point the browser SDK at that path and browser errors, requests and session replays arrive under the same app as your backend. The endpoint pins appName, env and module from your settings, ignoring whatever the batch claims โ€” otherwise anyone who found the URL could write telemetry into a different app, or label it as a different part of this one.

The page is its own part of the project, not the server forwarding for it, so a batch through the tunnel is labelled TUNNEL_MODULE ("web" unless you set it), never this server's MODULE. A request that started in the page and was served by this app then shows as one trace: a web span with the api span under it.

The tunnel's own requests are not recorded as traffic of this app โ€” they are the browser's telemetry in transit, one per flush.

Resource usage

CPU and resident memory per worker ride the metrics flush; nothing to enable. Each row is tagged with hostname:pid, because gunicorn runs several workers and one pegged at 100% while three idle reads as a comfortable 25% when they are averaged together.

Python has no JavaScript heap, so the heap figures a Node service reports are absent rather than invented.

Custom metrics

The numbers only your application knows โ€” tokens, revenue, queue depth:

from sentrinel_django import count, gauge, histogram

count("llm.tokens", usage.input_tokens, {"model": "deepseek", "direction": "input"})
gauge("queue.depth", tasks.count())
histogram("checkout.cart_value", order.total, {"plan": user.plan})

Increments fold in memory and leave as one row per series per flush, so calling count() in a loop is a normal thing to do. Same registry and the same caps as the Node plugin, so a metric recorded from Django and from Node means the same thing.

Every setting

SENTRINEL = {
    # โ”€โ”€ Required โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    "SERVER_URL": "https://api.sentrinel.dev",
    "APP_NAME": "orders",             # the project every part reports into
    "ENV": "prod",                    # must match the key's environment
    "API_KEY": os.environ["SENTRINEL_API_KEY"],

    # โ”€โ”€ Optional โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    "MODULE": "api",                  # which part of the project this service is (default: the key's name)
    "TUNNEL_MODULE": "web",           # the part a browser batch through the tunnel is labelled as
    "VERSION": "2026.9.1",            # drives deploy markers
    "ENABLED": True,                  # False turns everything off
    "DEBUG": False,                   # print why a batch was refused
    "FLUSH_INTERVAL": 10.0,           # seconds
    "TIMEOUT": 5.0,                   # per request to Sentrinel
    "MAX_BUFFER": 10_000,             # rows held before dropping oldest

    "CAPTURE_REQUESTS": True,
    "CAPTURE_ERRORS": True,
    "CAPTURE_LOGS": True,
    "SAMPLE_RATE": 1.0,               # errors and slow requests are never sampled away
    "SLOW_REQUEST_MS": 2000,

    "LOG_REQUEST_HEADERS": True,
    "LOG_REQUEST_BODY": False,
    "LOG_RESPONSE_BODY": False,
    "MAX_BODY_BYTES": 10_000,

    "EXCLUDE_PATHS": [r"^/health", r"^/static/"],
    "MASK_HEADERS": [r"^x-internal-token$"],   # added to the defaults, not instead of
    "MASK_FIELDS": [r"account_number"],
    "CONSUMER_IDENTIFIER": "myapp.telemetry.consumer_for",
}

Every value also reads from the environment โ€” SENTRINEL_SERVER_URL, SENTRINEL_APP_NAME, SENTRINEL_MODULE, SENTRINEL_TUNNEL_MODULE, SENTRINEL_ENV, SENTRINEL_API_KEY, SENTRINEL_VERSION, SENTRINEL_ENABLED โ€” so a container can be configured without editing settings.

Sampling, and why errors survive it

SAMPLE_RATE drops a fraction of ordinary traffic. It never drops an error or a slow request, whatever the rate: sampling a 500 away to save storage loses the row somebody is about to go looking for, and the rows worth dropping are the thousands of identical 200s.

Endpoint metrics are exact regardless of sampling โ€” they are rolled up from every request before the sampling decision, so your request counts and percentiles stay right while the stored rows get cheaper.

What it will not do to your app

It will not raise into your request. Every entry point swallows its own errors. A telemetry bug must not turn a working page into a 500.

It will not block your request. Recording appends to a list. Sending happens on a background thread, so a slow or unreachable Sentrinel costs the application nothing.

It will not grow without bound. Buffers are capped and the oldest rows are dropped and counted past the cap, because a process that runs out of memory during an incident takes the service with it.

It survives your process manager forking. Gunicorn and uWSGI fork workers after loading the app; the flush thread notices the new process and starts itself there, so every worker ships rather than buffering forever.

Management commands, Celery, and anything short-lived

The background thread flushes on a timer, and a process that exits before the next tick takes its buffer with it. At the end of a command or a task:

from sentrinel_django import flush

flush()

Optional: catching more errors

Adding the app additionally hooks got_request_exception, which catches exceptions raised inside another middleware or in a template โ€” places process_exception never sees:

INSTALLED_APPS = [..., "sentrinel_django"]

The two paths deduplicate, so an error is counted once.

Troubleshooting

You see It means
Nothing arrives SERVER_URL and APP_NAME must both be set, or the middleware is inert by design. Set "DEBUG": True and it prints why.
403 in the debug output The key is the wrong kind, or ENV does not match the environment the key was issued for.
Endpoints listed per id The route could not be resolved โ€” usually a 404 that matched no pattern. Resolved routes use Django's own pattern.
Logs missing The handler is not in LOGGING, or its level is above the lines you are writing.
Nothing from a management command Call flush() before it exits.

Not covered yet

Automatic database spans: the ORM's queries are not traced for you. Wrap the ones that matter in span(), and the Postgres collector covers the database side. Session replay is the browser SDK's, through the tunnel above.