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.