django-wide-events
One rich, structured log line per request, instead of a dozen scattered ones.
django-wide-events brings the wide event (or canonical log line) pattern to Django. It is built on Django's own logging, so it drops into the LOGGING config you already have instead of replacing it. The pattern is described in detail at https://loggingsucks.com/.
Table of contents
- Why wide events
- Install
- Quickstart
- Lifecycle of a log in this project
- Event blocks
- Collectors
- Static fields
- Sampling
- Formatters
- Per-view control
- Settings reference
- Compatibility
- Design philosophy
- Documentation
- Status
- Contributing
- Prior art
- License
💡 Why wide events
Scattered log lines describe what the code did. A wide event describes what happened to the request. Instead of a dozen logger.info(...) calls spread over middleware, views and services, each request builds up one structured record as it runs and emits it once when it finishes.
That record is meant to be wide: user context, route, status, timings and your own domain data all sit side by side. With many fields per event, your logs can be queried like an analytics table ("slow downloads by paid users, grouped by route") instead of grepped line by line. The full argument is at https://loggingsucks.com/.
How is this different from django-structlog?
django-structlog adds request
context (request id, user, IP) to every log line you write, and logs
request_started / request_finished itself. It makes scattered logs easier to
correlate.
django-wide-events replaces the scattered lines with one event per request that you build up as the request runs. On top of that it adds:
- Tail sampling as a standard
logging.Filter: errors, slow requests and writes are always kept, the rest sampled - Namespaced event blocks for nested, domain-specific fields and timers
- No extra logging framework: just Django and stdlib
logging
What one event looks like
{
"loglevel": "INFO",
"message": "request",
"logger": "wide_events.request",
"timestamp": 1789650000.123,
"service": "shop",
"env": "prod",
"request_id": "9f1c2e...",
"meta": {"method": "GET", "timezone": "UTC", "timestamp": "2026-09-17T10:00:00.000+00:00"},
"user": {"id": 42, "username": "ada", "is_authenticated": true, "is_staff": false},
"picture": {"id": "abc", "is_owner": true, "timers": {"s3_fetch_ms": 12.4}},
"route": "pictures:download",
"status_code": 200,
"duration_ms": 48.31,
"sample_rate": 10
}
Framework fields (route, status_code, duration_ms, user) sit next to your own domain data (picture).
📦 Install
pip install django-wide-events/uv add django-wide-events- Requires Python >= 3.10 and Django >= 4.2
🚀 Quickstart
-
Add the middleware to
MIDDLEWARE, as early as possible, so the event covers (and times) the rest of the stack- No
INSTALLED_APPSentry needed
MIDDLEWARE = [ "django_wide_events.middleware.WideEventMiddleware", # ... the rest of your middleware ]
- No
-
Write the
LOGGINGconfig yourself; the package deliberately ships no helper, so you keep full control over handlers, formatters and filters- Minimal setup: a formatter, a sampling filter and a stdout handler on the
wide_events.requestlogger
LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "wide": { "()": "django_wide_events.formatters.JSONFORMATTER", }, }, "filters": { "sampling": { "()": "django_wide_events.filter.TailSampling", "base_rate": 10, }, }, "handlers": { "stdout": { "class": "logging.StreamHandler", "formatter": "wide", "filters": ["sampling"], }, }, "loggers": { "wide_events.request": { "handlers": ["stdout"], "level": "INFO", "propagate": False, }, }, }
- Minimal setup: a formatter, a sampling filter and a stdout handler on the
-
Result: one JSON line per request
-
For readable output during local development, indent it:
"formatters": { "wide": { "()": "django_wide_events.formatters.JSONFORMATTER", "indent": True, }, },
-
The split of responsibilities:
- The middleware collects the data and emits the event once
- Formatters decide what the line looks like
- Filters decide whether it's kept
🔄 Lifecycle of a log in this project
Each step is explained in detail in docs/INTERNALS.md.
🧱 Event blocks
- Blocks are the intended way to write to the event
- A block is a class with a
namespace; everything it writes lands under that key in the event - Blocks are stateless: the class holds no data
- Every
set(...)goes straight into the current request's event Block.current()finds the attached block again from anywhere in the request
- Every
- The middleware writes everything the blocks collected to the log at the end of the request, including after an early return or an exception
- There's nothing to emit or render yourself
Example
from django.http import HttpResponse
from django_wide_events.event_blocks import EventBlock, TimerEventBlock
class PictureBlock(TimerEventBlock):
namespace = "picture"
@classmethod
def download(cls, picture_id):
return cls.from_kwargs(action="download", id=picture_id)
def owner(self, is_owner):
return self.set(is_owner=is_owner)
class StorageBlock(EventBlock):
namespace = "storage"
parent = PictureBlock # nests under event["picture"]
def download(request, picture_id):
PictureBlock.download(picture_id) # attaches to this request's event
fetch_file(picture_id)
return HttpResponse("ok")
def fetch_file(picture_id):
block = PictureBlock.current() # no request object needed
block.owner(True).set(caption=None) # chainable; None is dropped
with block.timer("s3_fetch"):
...
StorageBlock(kwargs={"bucket": "pictures"})
-
Resulting event (only the block part shown):
{ "picture": { "action": "download", "id": "abc", "is_owner": true, "timers": {"s3_fetch_ms": 10.07}, "storage": {"bucket": "pictures"} } }
Rules
namespaceis required (orabstract=Truefor base classes), and can't contain.from_kwargs(...)reuses the attached block; constructing the same block twice raisesBlockAlreadyAttachedmultiple = True: each instance is kept as a list entry;current()returns the latestparent = OtherBlocknests the block; the parent can't be abstract ormultipleTimerEventBlock.timer(name)works as a context manager or viastart_timer()/stop_timer(), and writestimers.<name>_ms- Class flags:
drop_none,append_list,use_namespace_on_write - Advanced: the raw event API behind the blocks is described in docs/INTERNALS.md
🪝 Collectors
- Collectors add framework-level fields at fixed points in the event's lifecycle:
on_create,on_exception,on_finish,on_finish_no_response - A collector only runs for the hooks it overrides
- A collector that raises never breaks the request; the error is recorded under
hook_error.hook_errors - Collectors are stateful: anything stored in
__init__oron_createis still there for the later hooks - A fresh set of collectors is created at the start of every request
- Built-in and default collectors: see docs/INTERNALS.md
Example
-
Record which picture was requested, and whether it was served:
# myapp/collectors.py from django_wide_events.collectors import Collector class RequestedPicture(Collector): def __init__(self): self.picture_id = None def on_create(self, request): # resolver_match isn't set yet in on_create; read the raw path instead parts = request.path.strip("/").split("/") if len(parts) >= 2 and parts[0] == "pictures": self.picture_id = parts[1] self.set(picture={"id": self.picture_id}) def on_finish(self, request, response): if self.picture_id is not None: self.set(picture={"served": response.status_code == 200}) def on_exception(self, request, exception): if self.picture_id is not None: self.set(picture={"failed": True})
-
Register it next to the defaults; setting
COLLECTORSreplaces the list, so keepDEFAULT_COLLECTORSin it:# settings.py from django_wide_events.collectors import DEFAULT_COLLECTORS WIDE_EVENTS = { "COLLECTORS": [ *DEFAULT_COLLECTORS, "myapp.collectors.RequestedPicture", ], }
📌 Static fields
-
STATIC_FIELDS: fixed fields written on every event, e.g.service,env,commit,region -
Nonevalues are droppedWIDE_EVENTS = { "STATIC_FIELDS": { "service": "shop", "env": os.environ.get("ENV"), "commit": os.environ.get("GIT_SHA"), }, }
🎲 Sampling
-
Sampling is a standard
logging.Filter, so it's configured inLOGGING -
TailSamplingkeeps every event that matches a rule, and 1 inbase_rateof the rest:"filters": { "sampling": { "()": "django_wide_events.filter.TailSampling", "base_rate": 20, # 1 = keep all, 0 = drop everything no rule keeps }, },
-
Default rules: errors, 5xx / no status, slow (>= 1000 ms), non-GET/HEAD, 401/403/429
-
The decision is deterministic per
request_id -
Events sampled at the base rate carry
sample_rate, so counts can be scaled back up -
Custom rules replace the defaults; keep
DEFAULT_RULESin the list to extend them instead:from django_wide_events.filter.rules import DEFAULT_RULES, BaseRule class PaidUser(BaseRule): def keep(self, record, event): return self.lookup(event, "user.tier") == "paid" "sampling": { "()": "django_wide_events.filter.TailSampling", "base_rate": 20, "keep_rules": [*DEFAULT_RULES, PaidUser()], },
-
Built-in rules take options, e.g.
SlowRequest(slow_ms=500), when you list them yourself -
RandomSamplingtakes the same arguments but has no rules
🎨 Formatters
-
JSONFORMATTERwrites the event as one JSON line; anyexc_infobecomeserror.{type, stack} -
GoogleFormatterdoes the same, using aseverityfield for Google Cloud Logging -
A common setup: indented output in dev, one line in prod:
"formatters": { "wide": { "()": "django_wide_events.formatters.JSONFORMATTER", "indent": DEBUG, }, "gcp": { "()": "django_wide_events.formatters.GoogleFormatter", }, },
🎛️ Per-view control
-
Decorators mark individual views; they never add data:
from django_wide_events.decorators import always_capture, never_capture @always_capture # kept even when sampling would drop it def checkout(request): ... @never_capture # dropped by the sampling filter def poll(request): ... class PictureDetail(DetailView): capture = True # the class-based form
- The decorators take effect in the sampling filter, so they need
TailSamplingorRandomSamplinginstalled
- The decorators take effect in the sampling filter, so they need
-
NO_LOGGING_PATHSskips path prefixes entirely: no event is created and no collectors runWIDE_EVENTS = { "NO_LOGGING_PATHS": ["/static/", "/media/", "/health", "/metrics"], }
-
Use settings for URL prefixes you never want logged; use decorators for views you own
⚙️ Settings reference
-
All settings live in one
WIDE_EVENTSdict:Key Default Notes COLLECTORSDEFAULT_COLLECTORS(User,MetaData)dotted paths; setting it replaces the list; built-ins always run STATIC_FIELDS{}merged into every event LOGGER_NAME"wide_events.request"the logger your LOGGINGconfig should targetNO_LOGGING_PATHS[]path prefixes skipped entirely REQUEST_ID.TRUST_ID_HEADERTrueTrue: always reuse an incomingX-Request-Id;False: always generate a new idREQUEST_ID.RESPONSE_HEADER"X-Request-Id"name of the response header that carries the id; falsy = don't set it REQUEST_ID.ID_GENERATORuuid4 hex callable returning a new id -
REQUEST_IDis merged key by key with the defaults; the other keys are replaced whole -
Sampling settings are passed to the filter in
LOGGING
✅ Compatibility
django-wide-events supports Python 3.10 and newer with Django 4.2 LTS and 5.x, under both WSGI and ASGI.
The event lives in a ContextVar, so it follows the request wherever Python copies the context: views and code run through sync_to_async or asyncio.to_thread all write to the same event.
One caveat: manual threads. A thread started with threading.Thread(...) or a bare ThreadPoolExecutor.submit(...) begins with an empty context, so anything it writes is silently lost. Run such code inside contextvars.copy_context().run(...) to give it access to the request's event.
🧭 Design philosophy
The package is built to be modular. Each part does one job: the middleware collects, collectors and blocks supply fields, filters decide what's kept, and formatters decide how it looks. Every part plugs into Django's standard middleware and logging machinery rather than working around it.
Nearly everything can be customised. When the settings don't cover your case, subclass the part in question (the middleware, a collector, a sampling filter or rule, a formatter) and override only the behaviour you need.
📚 Documentation
This README is the usage reference. For what happens under the hood (the context variables, event blocks, collectors and middleware) read docs/INTERNALS.md. Planned features that aren't built yet are collected in docs/ROADMAP.md. docs/EXAMPLE_USAGE.md served as the planning document and is retired now that these files cover it.
🚧 Status
The project is pre-1.0 (0.1.0). Event blocks, collector hooks and the filter and formatter wiring are considered stable; setting names may still change before 1.0. See docs/ROADMAP.md for what comes next.
🤝 Contributing
uv syncuv run pytest- The tests use a small Django project in
tests/
🔍 Prior art
The pattern comes from https://loggingsucks.com/, which builds on Stripe's "canonical log lines" and the wide events popularised by Honeycomb and the observability-2.0 movement.
📄 License
GPL-3.0. See LICENSE.
Metadata
Release files for django-wide-events 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_wide_events-0.1.0.tar.gz | 37.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_wide_events-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.5 kB
Release files / django_wide_events-0.1.0.tar.gz
| Download URL | django_wide_events-0.1.0.tar.gz |
|---|---|
| Size | 37.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f41c297c62e7d7caa204f58efbf5478a6c46bc8f50237c6501c13c00c60c6b32
|
|
BLAKE2b-256 checksum How to use checksums |
68f6ea0ba7894aa1de73e0a3dc188e0b628bbc67e0e48289c5da0b9183484a9e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / django_wide_events-0.1.0-py3-none-any.whl
| Download URL | django_wide_events-0.1.0-py3-none-any.whl |
|---|---|
| Size | 43.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1dc987ac84058f4d456ad03266817b1992433c2eeda0ac08ee0fc4f07dc4f45b
|
|
BLAKE2b-256 checksum How to use checksums |
1b3c73c30e0dc29268f2cd50b1f7d01bde56e043a12aa9432805500ac901dbf4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Arch Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|