Building a Zero-Boilerplate, Timezone-Aware Logging System in Django
How a single `logging.getLogger(__name__)` call, with no per-module setup, ends up as structured JSON on disk, timestamped in the reader's own timezone.
CBI built this because I got tired of two things happening over and over. First, every time I added a new app, I'd either copy-paste a logging block into it or just forget to add logging at all until I needed it during an incident. Second, whenever I actually needed to read a log file, the timestamps were in UTC, and I'd sit there doing timezone math in my head at 2am.
So the logging setup in DjangoPlay is built around one idea: a developer should never have to think about logging configuration. You import the standard library, write logger = logging.getLogger(__name__) at the top of your file like you would anyway, and it just works — correctly leveled, correctly formatted, written to the right file, and timestamped in whatever timezone the person reading it actually cares about.
Here's the shape of it:
1. One Function Call, No Configuration
This works because of something Python's logging module has always done, that most people never lean on: logger names form a hierarchy based on dots, and a log record climbs that hierarchy looking for the nearest configured ancestor.
Every DjangoPlay app has exactly one entry in a list, and that's the only logging setup it ever needs:
# paystream/app_settings/logging.py
AUTO_LOG_APPS = [
"users", "mailer", "mailer.events", "mailer.errors", "mailer.audit",
"aicore", "policyengine", "audit", "django",
]
for app_name in AUTO_LOG_APPS:
LOGGING["loggers"][app_name] = {
"handlers": ["console", "console_errors", f"{app_name}_file"],
"level": DEFAULT_APP_LOG_LEVEL,
"propagate": False,
}Now say a file inside the mailer app writes logger = logging.getLogger(__name__). Its name ends up being something like mailer.engine.engine — which was never explicitly registered anywhere. Python doesn't drop that message; it walks up the chain, mailer.engine.engine → mailer.engine → mailer, until it finds a logger that actually has handlers attached. That's mailer, the one line in AUTO_LOG_APPS. No new module ever needs its own entry.
2. Three Destinations for Every Line
Once a record reaches that app-level logger, it goes to three places at once, because a developer staring at a terminal and a script parsing yesterday's logs want different things from the same event:
console— clean, human-readable text, everything below ERROR. What you watch while developing.console_errors— the same human-readable format, but only ERROR and above, so a real problem doesn't scroll past unnoticed in a wall of INFO lines.{app}_file— a rotating file (5MB, 5 backups kept) written as structured JSON, one object per line, meant to be grepped, shipped, or parsed by a script rather than read directly.
The console formatter and the file formatter aren't just different styles of the same thing — they're built for two different readers, one human and one machine, and both get the full picture without either one having to compromise.
3. A Dedicated Channel When You Actually Need One
Most modules are happy bubbling up into their app's single log file. But sometimes you want one category of event kept completely separate — delivery failures that need a full traceback, for instance, without mixing them into the app's day-to-day noise. For that, you register a named child logger the same way, and turn propagation off:
# mailer/constants/loggers.py MAILER_EVENT_LOG = "mailer.events" MAILER_ERROR_LOG = "mailer.errors" MAILER_AUDIT_LOG = "mailer.audit" event_logger = logging.getLogger(MAILER_EVENT_LOG) error_logger = logging.getLogger(MAILER_ERROR_LOG) audit_logger = logging.getLogger(MAILER_AUDIT_LOG)
Because mailer.errors is registered in AUTO_LOG_APPS with its own file and propagate: False, anything logged through error_logger stops there — it never also lands in mailer.log, and it gets a formatter that includes the full exception traceback, which the everyday app log doesn't bother with. Same trick as before, just applied on purpose to carve out an exception to the default path.
4. Every Timestamp in the Reader's Own Timezone
Database timestamps stay in UTC, always — that part's non-negotiable and normal for Django. But a log file is something a person reads, usually while trying to figure out what happened around a specific moment in their own day. So DjangoPlay tracks the current request's timezone in a context variable — safe to use with async code, since it doesn't leak between concurrent requests — and both log formatters read it fresh at the moment they render each line:
# core/middleware/timezone.py
class TimezoneMiddleware:
def __call__(self, request):
user = getattr(request, "user", None)
tzname = getattr(user, "effective_timezone", None) if user and user.is_authenticated else None
tzname = tzname or settings.DEFAULT_USER_TIMEZONE
timezone.activate(ZoneInfo(tzname))
set_timezone(tzname)
return self.get_response(request)That last line is the important one: it stashes the resolved timezone somewhere the logging formatter can reach it later, without threading it through every function call in between. The formatter doesn't store a timestamp and convert it once — it computes "what time was this, for this reader" at the moment it writes the line, using whatever timezone was active for that request.
What I'd Tell You If You're Building Something Similar
- Let the framework's hierarchy do the routing. Naming a logger after its module and configuring only the top-level name means new code never needs new logging setup — it just inherits the right destination for free.
- Split for the reader, not for yourself. A human at a terminal and a script tailing a file want different formats from the same event; give each one what it actually needs instead of picking one and forcing both to live with it.
- Resolve context at render time, not write time. Stashing the timezone in a context variable and reading it inside the formatter means the same log line renders correctly no matter when or by whom it's eventually read.