Skip to content

Commit 2dc1c94

Browse files
committed
Minimal implementation of structlog for EmbedAPI
Use structlog _only_ for EmbedAPI v2/v3 for now. We will do a test in production to this and decide the migration path for the whole codebase.
1 parent d65241d commit 2dc1c94

File tree

9 files changed

+121
-63
lines changed

9 files changed

+121
-63
lines changed

readthedocs/core/apps.py

+3
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ class CoreAppConfig(AppConfig):
1111

1212
def ready(self):
1313
import readthedocs.core.signals # noqa
14+
15+
# Import `readthedocs.core.logs` to set up structlog
16+
import readthedocs.core.logs # noqa

readthedocs/core/logs.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import structlog
2+
3+
from django_structlog.middlewares.request import RequestMiddleware
4+
5+
6+
class ReadTheDocsRequestMiddleware(RequestMiddleware):
7+
8+
"""
9+
``ReadTheDocsRequestMiddleware`` adds request metadata to ``structlog``'s logger context.
10+
11+
This middleware overrides the original ``format_request`` to log the full
12+
URL instead of just its path.
13+
14+
>>> MIDDLEWARE = [
15+
... # ...
16+
... 'readthedocs.core.logs.ReadTheDocsRequestMiddleware',
17+
... ]
18+
19+
"""
20+
21+
def format_request(self, request):
22+
return request.build_absolute_uri()
23+
24+
25+
class NewRelicProcessor:
26+
27+
"""
28+
New Relic structlog's processor.
29+
30+
It adds extra fields. Borrowed from
31+
https://github.com/newrelic/newrelic-python-agent/blob/c1764f8a/newrelic/api/log.py#L39
32+
33+
It must be *before*
34+
``structlog.stdlib.ProcessorFormatter.remove_processors_meta`` to have
35+
access to ``_record`` key.
36+
"""
37+
38+
def __call__(self, logger, method_name, event_dict):
39+
# Import ``newrelic`` here because it's only installed in production
40+
from newrelic.api.log import format_exc_info # noqa
41+
from newrelic.api.time_trace import get_linking_metadata # noqa
42+
43+
if not isinstance(event_dict, dict):
44+
return event_dict
45+
46+
record = event_dict.get('_record')
47+
if record is None:
48+
return event_dict
49+
50+
event_dict.update(get_linking_metadata())
51+
52+
output = {
53+
# "timestamp": int(record.created * 1000),
54+
# "message": record.getMessage(),
55+
"message": event_dict['event'],
56+
# "log.level": record.levelname,
57+
# "logger.name": record.name,
58+
"thread.id": record.thread,
59+
"thread.name": record.threadName,
60+
"process.id": record.process,
61+
"process.name": record.processName,
62+
"file.name": record.pathname,
63+
"line.number": record.lineno,
64+
}
65+
66+
if record.exc_info:
67+
output.update(format_exc_info(record.exc_info))
68+
69+
event_dict.update(output)
70+
return event_dict
71+
72+
73+
structlog.configure(
74+
processors=[
75+
structlog.stdlib.filter_by_level,
76+
structlog.processors.TimeStamper(fmt='iso'),
77+
structlog.stdlib.add_logger_name,
78+
structlog.stdlib.add_log_level,
79+
structlog.stdlib.PositionalArgumentsFormatter(),
80+
structlog.processors.StackInfoRenderer(),
81+
structlog.processors.format_exc_info,
82+
structlog.processors.UnicodeDecoder(),
83+
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
84+
],
85+
context_class=structlog.threadlocal.wrap_dict(dict),
86+
logger_factory=structlog.stdlib.LoggerFactory(),
87+
wrapper_class=structlog.stdlib.BoundLogger,
88+
cache_logger_on_first_use=True,
89+
)

readthedocs/core/signals.py

-11
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from django.db.models.signals import pre_delete
99
from django.dispatch import Signal, receiver
1010

11-
from django_structlog.signals import bind_extra_request_metadata
1211
from rest_framework.permissions import SAFE_METHODS
1312
from simple_history.models import HistoricalRecords
1413
from simple_history.signals import pre_create_historical_record
@@ -147,14 +146,4 @@ def add_extra_historical_fields(sender, **kwargs):
147146
history_instance.extra_history_browser = request.headers.get('User-Agent')
148147

149148

150-
@receiver(bind_extra_request_metadata)
151-
def bind_user_username(request, logger, **kwargs):
152-
logger.bind(user_username=getattr(request.user, 'username', ''))
153-
154-
155-
@receiver(bind_extra_request_metadata)
156-
def rewrite_request(request, logger, **kwargs):
157-
logger.bind(absolute_url=request.build_absolute_uri())
158-
159-
160149
signals.check_request_enabled.connect(decide_if_cors)

readthedocs/embed/v3/views.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,8 @@ def get(self, request): # noqa
344344
}
345345
log.info(
346346
'EmbedAPI successful response.',
347-
project=self.unresolved_url.project.slug if not external else domain,
347+
project_slug=self.unresolved_url.project.slug if not external else None,
348+
domain=domain if external else None,
348349
url=url,
349350
referer=request.META.get('HTTP_REFERER'),
350351
external=external,

readthedocs/embed/views.py

+6-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import functools
44
import json
5-
import logging
65
import re
76

87
from django.shortcuts import get_object_or_404
@@ -15,6 +14,8 @@
1514
from rest_framework.response import Response
1615
from rest_framework.views import APIView
1716

17+
import structlog
18+
1819
from readthedocs.api.v2.mixins import CachedResponseMixin
1920
from readthedocs.api.v2.permissions import IsAuthorizedToViewVersion
2021
from readthedocs.builds.constants import EXTERNAL
@@ -25,7 +26,7 @@
2526
from readthedocs.projects.models import Project
2627
from readthedocs.storage import build_media_storage
2728

28-
log = logging.getLogger(__name__)
29+
log = structlog.get_logger(__name__)
2930

3031

3132
def escape_selector(selector):
@@ -143,11 +144,9 @@ def get(self, request):
143144
)
144145

145146
log.info(
146-
'EmbedAPI successful response. '
147-
'project=%s version=%s doc=%s section=%s path=%s '
148-
'url=%s referer=%s hoverxref-version=%s',
149-
project=project.slug,
150-
version=version.verbose_name,
147+
'EmbedAPI successful response.',
148+
project_slug=project.slug,
149+
version_slug=version.slug,
151150
doct=doc,
152151
section=section,
153152
path=path,

readthedocs/settings/base.py

+15-43
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# pylint: disable=missing-docstring
22

3+
import logging
34
import os
45
import subprocess
56
import socket
67

7-
import requests
88
import structlog
99

1010
from celery.schedules import crontab
@@ -27,27 +27,7 @@
2727

2828

2929
_ = gettext = lambda s: s
30-
log = structlog.get_logger(__name__)
31-
32-
def send_to_newrelic(logger, log_method, event_dict):
33-
34-
if 'request' in event_dict:
35-
# WSGIRequest is not JSONSerializable
36-
event_dict.pop('request')
37-
38-
# Uses the New Relic Log API
39-
# https://docs.newrelic.com/docs/logs/log-management/log-api/introduction-log-api/
40-
headers = {"Api-Key": ''}
41-
42-
# Our log message and all the event context is sent as a JSON string
43-
# in the POST body
44-
# https://docs.newrelic.com/docs/logs/log-management/log-api/introduction-log-api/#json-content
45-
payload = {
46-
"message": f"{log_method} - {event_dict['event']}",
47-
}
48-
payload.update(event_dict)
49-
requests.post("https://log-api.newrelic.com/log/v1", json=payload, headers=headers)
50-
return event_dict
30+
log = logging.getLogger(__name__)
5131

5232

5333
class CommunityBaseSettings(Settings):
@@ -271,7 +251,7 @@ def USE_PROMOS(self): # noqa
271251
'readthedocs.core.middleware.ReferrerPolicyMiddleware',
272252
'django_permissions_policy.PermissionsPolicyMiddleware',
273253
'simple_history.middleware.HistoryRequestMiddleware',
274-
'django_structlog.middlewares.RequestMiddleware',
254+
'readthedocs.core.logs.ReadTheDocsRequestMiddleware',
275255
)
276256

277257
AUTHENTICATION_BACKENDS = (
@@ -816,6 +796,11 @@ def DOCKER_LIMITS(self):
816796
'version': 1,
817797
'disable_existing_loggers': True,
818798
'formatters': {
799+
'default': {
800+
'format': LOG_FORMAT,
801+
'datefmt': '%d/%b/%Y %H:%M:%S',
802+
},
803+
# structlog
819804
"plain_console": {
820805
"()": structlog.stdlib.ProcessorFormatter,
821806
"processor": structlog.dev.ConsoleRenderer(),
@@ -829,7 +814,7 @@ def DOCKER_LIMITS(self):
829814
'console': {
830815
'level': 'INFO',
831816
'class': 'logging.StreamHandler',
832-
'formatter': 'plain_console',
817+
'formatter': 'default',
833818
},
834819
'debug': {
835820
'level': 'DEBUG',
@@ -847,6 +832,12 @@ def DOCKER_LIMITS(self):
847832
# Always send from the root, handlers can filter levels
848833
'level': 'INFO',
849834
},
835+
'django_structlog': {
836+
'handlers': ['null'],
837+
'level': 'INFO',
838+
# Don't double log at the root logger for these.
839+
'propagate': False,
840+
},
850841
'readthedocs': {
851842
'handlers': ['debug', 'console'],
852843
'level': 'DEBUG',
@@ -859,25 +850,6 @@ def DOCKER_LIMITS(self):
859850
},
860851
},
861852
}
862-
structlog.configure(
863-
processors=[
864-
structlog.stdlib.filter_by_level,
865-
structlog.processors.TimeStamper(fmt="iso"),
866-
structlog.stdlib.add_logger_name,
867-
structlog.stdlib.add_log_level,
868-
structlog.stdlib.PositionalArgumentsFormatter(),
869-
structlog.processors.StackInfoRenderer(),
870-
structlog.processors.format_exc_info,
871-
structlog.processors.UnicodeDecoder(),
872-
send_to_newrelic,
873-
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
874-
],
875-
context_class=structlog.threadlocal.wrap_dict(dict),
876-
logger_factory=structlog.stdlib.LoggerFactory(),
877-
wrapper_class=structlog.stdlib.BoundLogger,
878-
cache_logger_on_first_use=True,
879-
)
880-
881853

882854
# MailerLite API for newsletter signups
883855
MAILERLITE_API_SUBSCRIBERS_URL = 'https://api.mailerlite.com/api/v2/subscribers'

readthedocs/settings/dev.py

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def DATABASES(self): # noqa
5757
def LOGGING(self): # noqa - avoid pep8 N802
5858
logging = super().LOGGING
5959
logging['handlers']['console']['level'] = 'DEBUG'
60+
logging['formatters']['default']['format'] = '[%(asctime)s] ' + self.LOG_FORMAT
6061
# Allow Sphinx and other tools to create loggers
6162
logging['disable_existing_loggers'] = False
6263
return logging

requirements/docker.txt

+3
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ tox==3.24.4
3131

3232
# AWS utilities to use against MinIO
3333
awscli==1.22.5
34+
35+
# Used together with structlog to have nicer logs locally
36+
rich==10.14.0

requirements/pip.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,5 @@ django-csp==3.7
120120
# For setting the permissions-policy security header
121121
django-permissions-policy==4.5.0
122122

123-
django-structlog==2.1.3
123+
django-structlog==2.2.0
124+
structlog==21.3.0

0 commit comments

Comments
 (0)