Skip to content

Commit e9b5bed

Browse files
authored
structure the logs (#3188)
1 parent 3be345e commit e9b5bed

9 files changed

Lines changed: 234 additions & 4 deletions

File tree

‎config/nginx.conf‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,32 @@ http {
1414

1515
server_tokens off;
1616

17-
access_log /tmp/logs/nginx/access.log;
17+
# $request_time is seconds with millisecond resolution ("0.042", "12.345").
18+
map $request_time $request_time_ms {
19+
~^0\.0*(?<ms>\d+)$ $ms;
20+
~^(?<s>\d+)\.(?<frac>\d+)$ $s$frac;
21+
default null;
22+
}
23+
24+
log_format json escape=json '{'
25+
'"timestamp":"$time_iso8601",'
26+
'"level":"info",'
27+
'"logger":"nginx.access",'
28+
'"event":"$request_method $request_uri",'
29+
'"method":"$request_method",'
30+
'"path":"$uri",'
31+
'"query":"$args",'
32+
'"status":$status,'
33+
'"bytes":$body_bytes_sent,'
34+
'"duration_ms":$request_time_ms,'
35+
'"upstream_response_time":"$upstream_response_time",'
36+
'"protocol":"$server_protocol",'
37+
'"referer":"$http_referer",'
38+
'"user_agent":"$http_user_agent",'
39+
'"request_id":"$request_id"'
40+
'}';
41+
42+
access_log /tmp/logs/nginx/access.log json;
1843
error_log /tmp/logs/nginx/error.log;
1944

2045
include mime.types;
@@ -343,6 +368,7 @@ http {
343368

344369
location /downloads/ {
345370
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
371+
proxy_set_header X-Request-ID $request_id;
346372
proxy_set_header Host $http_host;
347373
proxy_redirect off;
348374
add_header Cache-Control "max-age=604800, public";
@@ -353,6 +379,7 @@ http {
353379

354380
location / {
355381
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
382+
proxy_set_header X-Request-ID $request_id;
356383
proxy_set_header Host $http_host;
357384
proxy_redirect off;
358385
proxy_pass http://app_server;

‎gunicorn.conf‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ keepalive = 2
1212
errorlog = '-'
1313
loglevel = 'info'
1414
accesslog = '-'
15-
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
15+
logger_class = 'pydotorg.gunicorn_logging.JSONLogger'
1616

1717
control_socket_disable = True
1818

‎pydotorg/celery.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44

55
from celery import Celery
6+
from celery.signals import setup_logging
67
from django.core import management
78

89
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pydotorg.settings.local")
@@ -11,6 +12,15 @@
1112
app.config_from_object("django.conf:settings", namespace="CELERY")
1213

1314

15+
@setup_logging.connect
16+
def configure_logging(**kwargs):
17+
"""Use Django's LOGGING for worker and beat instead of Celery's own handlers."""
18+
from django.conf import settings
19+
from django.utils.log import configure_logging as configure_django_logging
20+
21+
configure_django_logging(settings.LOGGING_CONFIG, settings.LOGGING)
22+
23+
1424
@app.task(bind=True)
1525
def run_management_command(self, command_name, args, kwargs):
1626
"""Execute a Django management command as an async Celery task."""

‎pydotorg/gunicorn_logging.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Gunicorn logger that writes error and access logs as JSON lines.
2+
3+
Enabled via ``logger_class`` in ``gunicorn.conf``. Kept apart from
4+
``pydotorg.logs`` because gunicorn is only installed with the ``prod`` extra.
5+
"""
6+
7+
from gunicorn import glogging
8+
9+
from pydotorg.logs import json_formatter
10+
11+
12+
class JSONLogger(glogging.Logger):
13+
"""Gunicorn logger rendering through ``pydotorg.logs.json_formatter``."""
14+
15+
def setup(self, cfg):
16+
"""Configure gunicorn's handlers, then swap their formatters for JSON."""
17+
super().setup(cfg)
18+
formatter = json_formatter()
19+
for log in (self.error_log, self.access_log):
20+
for handler in log.handlers:
21+
if getattr(handler, "_gunicorn", False):
22+
handler.setFormatter(formatter)
23+
24+
def access(self, resp, req, environ, request_time):
25+
"""Log one request with method, path, status, and duration as fields."""
26+
if not self.access_log_enabled:
27+
return
28+
try:
29+
status = resp.status
30+
if isinstance(status, str):
31+
status = status.split(None, 1)[0]
32+
method = environ["REQUEST_METHOD"]
33+
# gunicorn's error path passes a minimal environ: no PATH_INFO, QUERY_STRING, or HTTP_*.
34+
fields = {
35+
"method": method,
36+
"path": environ.get("PATH_INFO", getattr(req, "path", "")),
37+
"query": environ.get("QUERY_STRING", getattr(req, "query", "")),
38+
"status": int(status),
39+
"bytes": getattr(resp, "sent", None),
40+
"duration_ms": round(request_time.total_seconds() * 1000, 3),
41+
"protocol": environ.get("SERVER_PROTOCOL"),
42+
"referer": environ.get("HTTP_REFERER", ""),
43+
"user_agent": environ.get("HTTP_USER_AGENT", ""),
44+
# Set by nginx; django-structlog binds the same value to app log lines.
45+
"request_id": environ.get("HTTP_X_REQUEST_ID")
46+
or next((value for name, value in getattr(req, "headers", ()) if name == "X-REQUEST-ID"), ""),
47+
}
48+
self.access_log.info("%s %s", method, environ["RAW_URI"], extra=fields)
49+
except Exception: # noqa: BLE001 - mirror gunicorn: never let logging break a response
50+
self.exception("Failed to write access log")

‎pydotorg/logs.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""structlog setup shared by Django, Celery, and gunicorn.
2+
3+
Both structlog loggers and stdlib ``logging`` records (Django, Celery, gunicorn,
4+
third-party libraries) render through ``structlog.stdlib.ProcessorFormatter``:
5+
``json_formatter`` for production (one JSON object per line) and
6+
``console_formatter`` for local development.
7+
"""
8+
9+
import structlog
10+
11+
# Applied to every entry. Context bound by django-structlog's RequestMiddleware
12+
# (request_id, user_id) is merged into stdlib records too.
13+
_SHARED_PROCESSORS = [
14+
structlog.contextvars.merge_contextvars,
15+
structlog.stdlib.add_log_level,
16+
structlog.stdlib.add_logger_name,
17+
structlog.processors.TimeStamper(fmt="iso", utc=True),
18+
structlog.processors.StackInfoRenderer(),
19+
]
20+
21+
# Stdlib records also carry fields passed via ``extra=``.
22+
_FOREIGN_PRE_CHAIN = [*_SHARED_PROCESSORS, structlog.stdlib.ExtraAdder()]
23+
24+
25+
def configure_structlog():
26+
"""Route structlog loggers through stdlib ``logging`` so ``LOGGING`` picks the renderer."""
27+
structlog.configure(
28+
processors=[
29+
structlog.stdlib.filter_by_level,
30+
*_SHARED_PROCESSORS,
31+
structlog.stdlib.PositionalArgumentsFormatter(),
32+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
33+
],
34+
logger_factory=structlog.stdlib.LoggerFactory(),
35+
wrapper_class=structlog.stdlib.BoundLogger,
36+
cache_logger_on_first_use=True,
37+
)
38+
39+
40+
def json_formatter():
41+
"""Return a formatter that writes each entry as one JSON object."""
42+
return structlog.stdlib.ProcessorFormatter(
43+
foreign_pre_chain=_FOREIGN_PRE_CHAIN,
44+
processors=[
45+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
46+
structlog.processors.format_exc_info,
47+
structlog.processors.JSONRenderer(),
48+
],
49+
)
50+
51+
52+
def console_formatter():
53+
"""Return a formatter that writes aligned, human-readable lines."""
54+
return structlog.stdlib.ProcessorFormatter(
55+
foreign_pre_chain=_FOREIGN_PRE_CHAIN,
56+
processors=[
57+
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
58+
structlog.dev.ConsoleRenderer(),
59+
],
60+
)

‎pydotorg/settings/base.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Base Django settings for the python.org project."""
22

3+
import logging
34
from pathlib import Path
45

56
from csp.constants import NONCE, SELF, UNSAFE_INLINE
67
from decouple import config
78
from dj_database_url import parse as dj_database_url_parser
89
from django.contrib.messages import constants
910

11+
from pydotorg.logs import configure_structlog
1012
from pydotorg.markup import MARKUP_FIELD_TYPES # noqa: F401 - read by django-markupfield via settings
1113
from pydotorg.settings.pipeline import PIPELINE # noqa: F401 - accessed by django-pipeline via settings
1214

@@ -158,6 +160,7 @@
158160
"pydotorg.middleware.AdminNoCaching",
159161
"pydotorg.middleware.GlobalSurrogateKey",
160162
"django.contrib.auth.middleware.AuthenticationMiddleware",
163+
"django_structlog.middlewares.RequestMiddleware",
161164
"django.contrib.messages.middleware.MessageMiddleware",
162165
"waffle.middleware.WaffleMiddleware",
163166
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -186,6 +189,7 @@
186189
"django.contrib.admin",
187190
"django.contrib.admindocs",
188191
"django_celery_beat",
192+
"django_structlog",
189193
"django_translation_aliases",
190194
"pipeline",
191195
"sitetree",
@@ -239,22 +243,47 @@
239243
"version": 1,
240244
"disable_existing_loggers": False,
241245
"filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
246+
"formatters": {
247+
"console": {"()": "pydotorg.logs.console_formatter"},
248+
"json": {"()": "pydotorg.logs.json_formatter"},
249+
},
242250
"handlers": {
251+
"console": {
252+
"class": "logging.StreamHandler",
253+
"stream": "ext://sys.stdout",
254+
"formatter": "console",
255+
},
243256
"mail_admins": {
244257
"level": "ERROR",
245258
"filters": ["require_debug_false"],
246259
"class": "django.utils.log.AdminEmailHandler",
247-
}
260+
},
248261
},
262+
"root": {"handlers": ["console"], "level": "WARNING"},
249263
"loggers": {
264+
# Replaces Django's default console handler so records reach stdout once, via root.
265+
"django": {"handlers": ["mail_admins"], "level": "INFO"},
266+
# Emails only; django-structlog's request_failed puts the traceback on stdout with request context.
250267
"django.request": {
251268
"handlers": ["mail_admins"],
252269
"level": "ERROR",
253-
"propagate": True,
270+
"propagate": False,
254271
},
272+
"django_structlog": {"level": "INFO"},
273+
"apps": {"level": "INFO"},
274+
"pydotorg": {"level": "INFO"},
275+
"celery": {"level": "INFO"},
255276
},
256277
}
257278

279+
configure_structlog()
280+
281+
# nginx and gunicorn already log every request; keep only 5xx and request_failed from django-structlog.
282+
DJANGO_STRUCTLOG_STATUS_DEFAULT_LOG_LEVEL = logging.DEBUG
283+
DJANGO_STRUCTLOG_STATUS_4XX_LOG_LEVEL = logging.DEBUG
284+
# Client IPs were never logged; keep it that way.
285+
DJANGO_STRUCTLOG_IP_LOGGING_ENABLED = False
286+
258287
### Honeypot
259288
HONEYPOT_FIELD_NAME = "email_body_text"
260289
HONEYPOT_VALUE = "write your message"

‎pydotorg/settings/cabotage.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
DEBUG = TEMPLATE_DEBUG = False
1010

11+
LOGGING["handlers"]["console"]["formatter"] = "json"
12+
1113
DATABASE_CONN_MAX_AGE = 600
1214
DATABASES["default"]["CONN_MAX_AGE"] = DATABASE_CONN_MAX_AGE
1315

‎pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies = [
3131
"django-pipeline==4.1.0",
3232
"django-polymorphic==4.11.7",
3333
"django-sitetree==1.18.1",
34+
"django-structlog==10.1.0",
3435
"django-tastypie==0.15.1",
3536
"django-translation-aliases==0.1.0",
3637
"django-waffle==5.0.0",
@@ -55,6 +56,7 @@ dependencies = [
5556
"python3-openid==3.2.0",
5657
"requests>=2.34.2",
5758
"sorl-thumbnail==13.1.0",
59+
"structlog==26.1.0",
5860
"unidecode==1.4.0",
5961
]
6062
[project.optional-dependencies]

0 commit comments

Comments
 (0)