Sentrinel now has a Django SDK. One middleware entry and every request, error and log line from your app arrives correlated โ a log line opens the request that wrote it, an error opens the user it happened to.
MIDDLEWARE = [
"sentrinel_django.SentrinelMiddleware",
"django.middleware.security.SecurityMiddleware",
...
]
SENTRINEL = {
"SERVER_URL": "https://api.sentrinel.dev",
"APP_NAME": "orders",
"ENV": "prod",
"API_KEY": os.environ["SENTRINEL_API_KEY"],
}
That is the setup. The rest of this post is about the three decisions that shaped it, and the two bugs that only showed up when we pointed it at a real API.
It has no dependencies
Not "few". None. Everything is standard library, and Django is the host rather than something to pin.
This is not minimalism for its own sake. A monitoring library is installed into somebody else's dependency tree, and every package it brings is a version it can conflict with. The worst version of that is specific and common: a team wants monitoring precisely because something is wrong, and the install fails on a resolver conflict with the library they are trying to observe. The tool that was supposed to help has become another thing to debug.
Uploading a batch is an HTTP POST. urllib does that.
It runs on the Django you actually have
Tested against 3.1 and 5.2, and the suite is the same suite both times.
The temptation with a new SDK is to require a recent framework, because that is the version you develop against and every compatibility shim is code you would rather not write. But the projects most in need of monitoring skew old. A service on Django 3.1 that nobody has dared upgrade is exactly the service where nobody knows what is happening in production.
It cost one line. response.headers arrived in Django 3.2; response.get() works everywhere and does the same thing. That was the whole difference.
Endpoints group by route, because Django already knows the answer
/orders/1042 and /orders/1043 are one endpoint. Group by the raw path and you get one row per id โ an unbounded table, and an "active endpoints" count in the thousands for an app with thirty routes.
Most SDKs guess this back from the URL with a heuristic. We have that heuristic too, and it is careful: a segment is an id when it is eight characters or more and contains a digit, so facade and settings do not silently become wildcards. But in Django the guess is a fallback, because request.resolver_match.route is the pattern that actually matched. orders/<int:pk>/ normalises to /orders/:pk and the guessing never runs.
Same normalisation as the Node plugin, deliberately. The same endpoint reported by two services in two languages has to be one row, or the numbers never add up.
Sampling that cannot lose the row you need
SAMPLE_RATE drops a fraction of ordinary traffic. It never drops an error or a slow request, at any rate.
Sampling a 500 away to save storage loses the row somebody is about to go looking for. The rows worth dropping are the thousands of identical 200s, and the rule is one line: keep it if the status is 400 or above, or it took longer than the slow threshold.
Endpoint metrics stay exact regardless of sampling, because they are rolled up from every request before the sampling decision. Your request counts and percentiles are right; only the stored rows get cheaper.
What it will not do to your app
Three properties, each of which is a way a monitoring library can be worse than none.
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 under a lock. Sending happens on a background thread, so an unreachable Sentrinel costs the application nothing.
It will not grow without bound. Buffers are capped, and past the cap the oldest rows are dropped and counted. A process that runs out of memory during an incident takes the service with it, and the telemetry was supposed to help.
There is a fourth that is easy to miss: gunicorn and uWSGI fork their workers after loading your app. A background thread started at import time exists in the parent and in none of the children โ so every worker would buffer forever and flush from nowhere. The flusher compares its pid on each record and starts itself in the new process. It is four lines, and without them the library appears to work in development and sends nothing in production.
Two bugs the unit tests did not find
The suite passed. Then we pointed it at a running API, sent real traffic through a real Django app, and read the rows back out of the database.
Errors registered a second endpoint. The endpoint list came back with /boom and /boom/. Requests report the route; the error path was reporting the raw URL, and the API registers an endpoint from whatever an error names. The result is a twin endpoint whose error count is split away from the traffic it belongs to โ both rows look plausible, neither is complete. The Node plugin had this right; the Django one had to match.
A second middleware instance evicted the first. get_collector(config) replaced the process-wide collector whenever it was handed a config. Django can construct more than one middleware instance, and when it did, everything the first had buffered was discarded โ and the log handler, which looks the collector up on its own, went on shipping into an instance nobody was flushing. Constructing a collector now joins the existing one rather than replacing it.
Neither was findable from inside the test suite, because both tests and units were mocking the thing that was wrong. What found them was reading the rows.
Getting it
pip install "git+https://github.com/Zaga-ltd/sentinel_packages.git#subdirectory=sentrinel_django"
Logs, custom metrics, identity, masking and every setting are in the Django guide. There is a single-file example app that drives traffic through itself, so there is something on the dashboard within a flush interval.
Distributed tracing spans are the next piece. Requests already carry a traceId when one is set, so a trace begun upstream stays linked โ Django just does not start spans of its own yet.