Skip to content

Commit bf48843

Browse files
authored
feat: Ability to log WSGI environ in JSON logs, log_extra utility (#55)
1 parent 053f485 commit bf48843

File tree

5 files changed

+112
-32
lines changed

5 files changed

+112
-32
lines changed

src/common/gunicorn/constants.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
WSGI_DJANGO_ROUTE_ENVIRON_KEY = "wsgi.django_route"
1+
import re
2+
3+
WSGI_EXTRA_PREFIX = "flagsmith."
4+
WSGI_EXTRA_SUFFIX_TO_CATEGORY = {
5+
"i": "request_headers",
6+
"o": "response_headers",
7+
"e": "environ_variables",
8+
}
29
HTTP_SERVER_RESPONSE_SIZE_DEFAULT_BUCKETS = (
310
# 1 kB, 10 kB, 100 kB, 500 kB, 1 MB, 5 MB, 10 MB
411
1 * 1024,
@@ -10,3 +17,7 @@
1017
10 * 1024 * 1024,
1118
float("inf"),
1219
)
20+
21+
wsgi_extra_key_regex = re.compile(
22+
r"^{(?P<key>[^}]+)}(?P<suffix>[%s])$" % "".join(WSGI_EXTRA_SUFFIX_TO_CATEGORY)
23+
)

src/common/gunicorn/logging.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,42 @@
1313

1414
from common.core.logging import JsonFormatter
1515
from common.gunicorn import metrics
16-
from common.gunicorn.constants import WSGI_DJANGO_ROUTE_ENVIRON_KEY
16+
from common.gunicorn.constants import (
17+
WSGI_EXTRA_PREFIX,
18+
WSGI_EXTRA_SUFFIX_TO_CATEGORY,
19+
wsgi_extra_key_regex,
20+
)
21+
from common.gunicorn.utils import get_extra
1722

1823

1924
class GunicornAccessLogJsonFormatter(JsonFormatter):
25+
def _get_extra(self, record_args: dict[str, Any]) -> dict[str, Any]:
26+
ret: dict[str, dict[str, Any]] = {}
27+
28+
extra_items_to_log: list[str] | None
29+
if extra_items_to_log := getattr(settings, "ACCESS_LOG_EXTRA_ITEMS", None):
30+
# We expect the extra items to be in the form of
31+
# Gunicorn's access log format string for
32+
# request headers, response headers and environ variables
33+
# without the % prefix, e.g. "{origin}i" or "{flagsmith.environment_id}e"
34+
# https://docs.gunicorn.org/en/stable/settings.html#access-log-format
35+
for extra_key in extra_items_to_log:
36+
extra_key_lower = extra_key.lower()
37+
if (
38+
(extra_value := record_args.get(extra_key_lower))
39+
and (re_match := wsgi_extra_key_regex.match(extra_key_lower))
40+
and (
41+
extra_category := WSGI_EXTRA_SUFFIX_TO_CATEGORY.get(
42+
re_match.group("suffix")
43+
)
44+
)
45+
):
46+
ret.setdefault(extra_category, {})[re_match.group("key")] = (
47+
extra_value
48+
)
49+
50+
return ret
51+
2052
def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
2153
args = record.args
2254

@@ -32,11 +64,13 @@ def get_json_record(self, record: logging.LogRecord) -> dict[str, Any]:
3264
"time": datetime.strptime(args["t"], "[%d/%b/%Y:%H:%M:%S %z]").isoformat(),
3365
"path": url,
3466
"remote_ip": args["h"],
35-
"route": args["R"],
67+
"route": args.get(f"{{{WSGI_EXTRA_PREFIX}route}}e") or "",
3668
"method": args["m"],
3769
"status": str(args["s"]),
3870
"user_agent": args["a"],
3971
"duration_in_ms": args["M"],
72+
"response_size_in_bytes": args["B"] or 0,
73+
**self._get_extra(args),
4074
}
4175

4276

@@ -56,7 +90,7 @@ def access(
5690
# To avoid cardinality explosion, we use a resolved Django route
5791
# instead of raw path.
5892
# The Django route is set by `RouteLoggerMiddleware`.
59-
"route": environ.get(WSGI_DJANGO_ROUTE_ENVIRON_KEY) or "",
93+
"route": get_extra(environ=environ, key="route") or "",
6094
"method": environ.get("REQUEST_METHOD") or "",
6195
"response_status": resp.status_code,
6296
}
@@ -65,22 +99,11 @@ def access(
6599
)
66100
metrics.flagsmith_http_server_requests_total.labels(**labels).inc()
67101
metrics.flagsmith_http_server_response_size_bytes.labels(**labels).observe(
68-
resp.response_length or 0,
102+
getattr(resp, "sent", 0),
69103
)
70104

71105

72106
class GunicornJsonCapableLogger(PrometheusGunicornLogger):
73-
def atoms(
74-
self,
75-
resp: Response,
76-
req: Request,
77-
environ: dict[str, Any],
78-
request_time: timedelta,
79-
) -> dict[str, str]:
80-
atoms: dict[str, str] = super().atoms(resp, req, environ, request_time)
81-
atoms["R"] = environ.get(WSGI_DJANGO_ROUTE_ENVIRON_KEY) or "-"
82-
return atoms
83-
84107
def setup(self, cfg: Config) -> None:
85108
super().setup(cfg)
86109
if getattr(settings, "LOG_FORMAT", None) == "json":

src/common/gunicorn/middleware.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
from django.http import HttpRequest, HttpResponse
44

5-
from common.gunicorn.constants import WSGI_DJANGO_ROUTE_ENVIRON_KEY
6-
from common.gunicorn.utils import get_route_template
5+
from common.gunicorn.utils import get_route_template, log_extra
76

87

98
class RouteLoggerMiddleware:
@@ -22,10 +21,10 @@ def __call__(self, request: HttpRequest) -> HttpResponse:
2221
response = self.get_response(request)
2322

2423
if resolver_match := request.resolver_match:
25-
# https://peps.python.org/pep-3333/#specification-details
26-
# "...the application is allowed to modify the dictionary in any way it desires"
27-
request.META[WSGI_DJANGO_ROUTE_ENVIRON_KEY] = get_route_template(
28-
resolver_match.route
24+
log_extra(
25+
request=request,
26+
key="route",
27+
value=get_route_template(resolver_match.route),
2928
)
3029

3130
return response

src/common/gunicorn/utils.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55

66
from django.core.handlers.wsgi import WSGIHandler
77
from django.core.wsgi import get_wsgi_application
8+
from django.http import HttpRequest
89
from drf_yasg.generators import EndpointEnumerator # type: ignore[import-untyped]
910
from environs import Env
1011
from gunicorn.app.wsgiapp import ( # type: ignore[import-untyped]
1112
WSGIApplication as GunicornWSGIApplication,
1213
)
1314
from gunicorn.config import Config # type: ignore[import-untyped]
1415

16+
from common.gunicorn.constants import WSGI_EXTRA_PREFIX
17+
1518
env = Env()
1619

1720
DEFAULT_ACCESS_LOG_FORMAT = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %({origin}i)s %({access-control-allow-origin}o)s'
@@ -76,3 +79,26 @@ def get_route_template(route: str) -> str:
7679
"""
7780
route_template: str = EndpointEnumerator().get_path_from_regex(route)
7881
return route_template
82+
83+
84+
def log_extra(
85+
request: HttpRequest,
86+
key: str,
87+
value: Any,
88+
) -> None:
89+
"""
90+
Store a value in the WSGI request `environ` using a prefixed key.
91+
92+
https://peps.python.org/pep-3333/#specification-details
93+
"...the application is allowed to modify the dictionary in any way it desires"
94+
"""
95+
meta_key = f"{WSGI_EXTRA_PREFIX}{key}"
96+
request.META[meta_key] = value
97+
98+
99+
def get_extra(environ: dict[str, Any], key: str) -> Any:
100+
"""
101+
Retrieve a value from the WSGI request `environ` using a prefixed key.
102+
"""
103+
meta_key = f"{WSGI_EXTRA_PREFIX}{key}"
104+
return environ.get(meta_key)

tests/unit/common/gunicorn/test_logging.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@
1717

1818

1919
@pytest.mark.freeze_time("2023-12-08T06:05:47.320000+00:00")
20-
def test_gunicorn_access_log_json_formatter__outputs_expected() -> None:
20+
def test_gunicorn_access_log_json_formatter__outputs_expected(
21+
settings: SettingsWrapper,
22+
) -> None:
2123
# Given
24+
settings.ACCESS_LOG_EXTRA_ITEMS = [
25+
"{flagsmith.route}e",
26+
"{X-LOG-ME-STATUS}o",
27+
"{x-log-me}i",
28+
]
29+
2230
gunicorn_access_log_json_formatter = GunicornAccessLogJsonFormatter()
2331
log_record = logging.LogRecord(
2432
name="gunicorn.access",
@@ -27,9 +35,13 @@ def test_gunicorn_access_log_json_formatter__outputs_expected() -> None:
2735
lineno=1,
2836
msg=AccessLogFormat.default,
2937
args={
38+
"{flagsmith.route}e": "/test/{test_id}",
39+
"{wsgi.version}e": (1, 0),
40+
"{x-log-me-status}o": "acked",
41+
"{x-log-me}i": "42",
3042
"a": "requests",
31-
"b": "-",
32-
"B": None,
43+
"b": "42",
44+
"B": 42,
3345
"D": 1000000,
3446
"f": "-",
3547
"h": "192.168.0.1",
@@ -40,15 +52,14 @@ def test_gunicorn_access_log_json_formatter__outputs_expected() -> None:
4052
"M": 1000,
4153
"p": "<42>",
4254
"q": "foo=bar",
43-
"R": "^test/",
4455
"r": "GET",
4556
"s": 200,
4657
"T": 1,
4758
"t": datetime.fromisoformat("2023-12-08T06:05:47.320000+00:00").strftime(
4859
"[%d/%b/%Y:%H:%M:%S %z]"
4960
),
5061
"u": "-",
51-
"U": "/test",
62+
"U": "/test/42",
5263
},
5364
exc_info=None,
5465
)
@@ -60,14 +71,24 @@ def test_gunicorn_access_log_json_formatter__outputs_expected() -> None:
6071
# Then
6172
assert json_log == {
6273
"duration_in_ms": 1000,
74+
"environ_variables": {
75+
"flagsmith.route": "/test/{test_id}",
76+
},
6377
"levelname": "INFO",
6478
"logger_name": "gunicorn.access",
65-
"message": '192.168.0.1 - - [08/Dec/2023:06:05:47 +0000] "GET" 200 - "-" "requests"',
79+
"message": '192.168.0.1 - - [08/Dec/2023:06:05:47 +0000] "GET" 200 42 "-" "requests"',
6680
"method": "GET",
67-
"path": "/test?foo=bar",
81+
"path": "/test/42?foo=bar",
6882
"pid": expected_pid,
6983
"remote_ip": "192.168.0.1",
70-
"route": "^test/",
84+
"request_headers": {
85+
"x-log-me": "42",
86+
},
87+
"response_headers": {
88+
"x-log-me-status": "acked",
89+
},
90+
"response_size_in_bytes": 42,
91+
"route": "/test/{test_id}",
7192
"status": "200",
7293
"thread_name": "MainThread",
7394
"time": "2023-12-08T06:05:47+00:00",
@@ -87,13 +108,13 @@ def test_gunicorn_prometheus_gunicorn_logger__expected_metrics(
87108
response_mock = mocker.Mock()
88109
response_mock.status = b"200 OK"
89110
response_mock.status_code = 200
90-
response_mock.response_length = 42
111+
response_mock.sent = 42
91112

92113
# When
93114
logger.access(
94115
response_mock,
95116
mocker.Mock(),
96-
{"wsgi.django_route": "/health", "REQUEST_METHOD": "GET"},
117+
{"flagsmith.route": "/health", "REQUEST_METHOD": "GET"},
97118
timedelta(milliseconds=101),
98119
)
99120

0 commit comments

Comments
 (0)