Skip to content

Commit 4c237c8

Browse files
committed
Implement gunicorn access log format for httptools impl
issue ref encode#527
1 parent 1d9511f commit 4c237c8

File tree

3 files changed

+207
-1
lines changed

3 files changed

+207
-1
lines changed

uvicorn/config.py

+3
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ def __init__(
231231
ssl_ciphers: str = "TLSv1",
232232
headers: Optional[List[List[str]]] = None,
233233
factory: bool = False,
234+
gunicorn_log=None,
234235
):
235236
self.app = app
236237
self.host = host
@@ -274,6 +275,8 @@ def __init__(
274275
self.encoded_headers: List[Tuple[bytes, bytes]] = []
275276
self.factory = factory
276277

278+
self.gunicorn_log = gunicorn_log
279+
277280
self.loaded = False
278281
self.configure_logging()
279282

uvicorn/protocols/http/httptools_impl.py

+203-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import http
33
import logging
44
import re
5+
import time
56
import urllib
7+
from collections import abc
8+
from os import getpid
69
from typing import Callable
710

811
import httptools
@@ -39,6 +42,174 @@ def _get_status_line(status_code):
3942
}
4043

4144

45+
class GunicornSafeAtoms(abc.Mapping):
46+
def __init__(self, scope):
47+
self.scope = scope
48+
self.status_code = None
49+
self.response_headers = {}
50+
self.response_length = 0
51+
52+
self._request_headers = None
53+
54+
@property
55+
def request_headers(self):
56+
if self._request_headers is None:
57+
self._request_headers = dict(self.scope['headers'])
58+
return self._request_headers
59+
60+
@property
61+
def duration(self):
62+
duration_extension = self.scope['extensions']['uvicorn_request_duration']
63+
d = duration_extension['response_end_time'] - duration_extension['request_start_time']
64+
return d
65+
66+
def on_asgi_message(self, message):
67+
if message['type'] == 'http.response.start':
68+
self.status_code = message['status']
69+
self.response_headers = dict(message['headers'])
70+
elif message['type'] == 'http.response.body':
71+
self.response_length += len(message.get('body', ''))
72+
73+
def _request_header(self, key):
74+
return self.request_headers.get(key.lower())
75+
76+
def _response_header(self, key):
77+
return self.response_headers.get(key.lower())
78+
79+
def _wsgi_environ_variable(self, key):
80+
# FIXME: provide fallbacks to access WSGI environ (at least the
81+
# required variables).
82+
return None
83+
84+
def __getitem__(self, key):
85+
if key in self.HANDLERS:
86+
retval = self.HANDLERS[key](self)
87+
elif key.startswith('{'):
88+
if key.endswith('}i'):
89+
retval = self._request_header(key[1:-2])
90+
elif key.endswith('}o'):
91+
retval = self._response_header(key[1:-2])
92+
elif key.endswith('}e'):
93+
retval = self._wsgi_environ_variable(key[1:-2])
94+
else:
95+
retval = None
96+
else:
97+
retval = None
98+
99+
if retval is None:
100+
return '-'
101+
if isinstance(retval, str):
102+
return retval.replace('"', '\\"')
103+
return retval
104+
105+
HANDLERS = {}
106+
107+
def _register_handler(key, handlers=HANDLERS):
108+
def decorator(fn):
109+
handlers[key] = fn
110+
return fn
111+
return decorator
112+
113+
@_register_handler('h')
114+
def _remote_address(self, *args, **kwargs):
115+
return self.scope['client'][0]
116+
117+
@_register_handler('l')
118+
def _dash(self, *args, **kwargs):
119+
return '-'
120+
121+
@_register_handler('u')
122+
def _user_name(self, *args, **kwargs):
123+
pass
124+
125+
@_register_handler('t')
126+
def date_of_the_request(self, *args, **kwargs):
127+
"""Date and time in Apache Common Log Format"""
128+
return time.strftime('[%d/%b/%Y:%H:%M:%S %z]')
129+
130+
@_register_handler('r')
131+
def status_line(self, *args, **kwargs):
132+
full_raw_path = (self.scope['raw_path'] + self.scope['query_string'])
133+
full_path = full_raw_path.decode('ascii')
134+
return '{method} {full_path} HTTP/{http_version}'.format(
135+
full_path=full_path, **self.scope
136+
)
137+
138+
@_register_handler('m')
139+
def request_method(self, *args, **kwargs):
140+
return self.scope['method']
141+
142+
@_register_handler('U')
143+
def url_path(self, *args, **kwargs):
144+
return self.scope['raw_path'].decode('ascii')
145+
146+
@_register_handler('q')
147+
def query_string(self, *args, **kwargs):
148+
return self.scope['query_string'].decode('ascii')
149+
150+
@_register_handler('H')
151+
def protocol(self, *args, **kwargs):
152+
return 'HTTP/%s' % self.scope['http_version']
153+
154+
@_register_handler('s')
155+
def status(self, *args, **kwargs):
156+
return self.status_code or '-'
157+
158+
@_register_handler('B')
159+
def response_length(self, *args, **kwargs):
160+
return self.response_length
161+
162+
@_register_handler('b')
163+
def response_length_or_dash(self, *args, **kwargs):
164+
return self.response_length or '-'
165+
166+
@_register_handler('f')
167+
def referer(self, *args, **kwargs):
168+
val = self.request_headers.get(b'referer')
169+
if val is None:
170+
return None
171+
return val.decode('ascii')
172+
173+
@_register_handler('a')
174+
def user_agent(self, *args, **kwargs):
175+
val = self.request_headers.get(b'user-agent')
176+
if val is None:
177+
return None
178+
return val.decode('ascii')
179+
180+
@_register_handler('T')
181+
def request_time_seconds(self, *args, **kwargs):
182+
return int(self.duration)
183+
184+
@_register_handler('D')
185+
def request_time_microseconds(self, *args, **kwargs):
186+
return int(self.duration * 1_000_000)
187+
188+
@_register_handler('L')
189+
def request_time_decimal_seconds(self, *args, **kwargs):
190+
return "%.6f" % self.duration
191+
192+
@_register_handler('p')
193+
def process_id(self, *args, **kwargs):
194+
return "<%s>" % getpid()
195+
196+
def __iter__(self):
197+
# FIXME: add WSGI environ
198+
yield from self.HANDLERS
199+
for k, _ in self.scope['headers']:
200+
yield '{%s}i' % k.lower()
201+
for k in self.response_headers:
202+
yield '{%s}o' % k.lower()
203+
204+
def __len__(self):
205+
# FIXME: add WSGI environ
206+
return (
207+
len(self.HANDLERS)
208+
+ len(self.scope['headers'] or ())
209+
+ len(self.response_headers)
210+
)
211+
212+
42213
class HttpToolsProtocol(asyncio.Protocol):
43214
def __init__(
44215
self, config, server_state, on_connection_lost: Callable = None, _loop=None
@@ -53,6 +224,7 @@ def __init__(
53224
self.logger = logging.getLogger("uvicorn.error")
54225
self.access_logger = logging.getLogger("uvicorn.access")
55226
self.access_log = self.access_logger.hasHandlers()
227+
self.gunicorn_log = config.gunicorn_log
56228
self.parser = httptools.HttpRequestParser(self)
57229
self.ws_protocol_class = config.ws_protocol_class
58230
self.root_path = config.root_path
@@ -211,6 +383,11 @@ def on_url(self, url):
211383
"raw_path": raw_path,
212384
"query_string": parsed_url.query if parsed_url.query else b"",
213385
"headers": self.headers,
386+
"extensions": {
387+
"uvicorn_request_duration": {
388+
"request_start_time": time.monotonic(),
389+
}
390+
},
214391
}
215392

216393
def on_header(self, name: bytes, value: bytes):
@@ -245,6 +422,7 @@ def on_headers_complete(self):
245422
logger=self.logger,
246423
access_logger=self.access_logger,
247424
access_log=self.access_log,
425+
gunicorn_log=self.gunicorn_log,
248426
default_headers=self.default_headers,
249427
message_event=asyncio.Event(),
250428
expect_100_continue=self.expect_100_continue,
@@ -338,6 +516,7 @@ def __init__(
338516
logger,
339517
access_logger,
340518
access_log,
519+
gunicorn_log,
341520
default_headers,
342521
message_event,
343522
expect_100_continue,
@@ -350,6 +529,7 @@ def __init__(
350529
self.logger = logger
351530
self.access_logger = access_logger
352531
self.access_log = access_log
532+
self.gunicorn_log = gunicorn_log
353533
self.default_headers = default_headers
354534
self.message_event = message_event
355535
self.on_response = on_response
@@ -369,6 +549,12 @@ def __init__(
369549
self.chunked_encoding = None
370550
self.expected_content_length = 0
371551

552+
# For logging.
553+
if self.gunicorn_log:
554+
self.gunicorn_atoms = GunicornSafeAtoms(self.scope)
555+
else:
556+
self.gunicorn_atoms = None
557+
372558
# ASGI exception wrapper
373559
async def run_asgi(self, app):
374560
try:
@@ -415,6 +601,9 @@ async def send_500_response(self):
415601
async def send(self, message):
416602
message_type = message["type"]
417603

604+
if self.gunicorn_atoms is not None:
605+
self.gunicorn_atoms.on_asgi_message(message)
606+
418607
if self.flow.write_paused and not self.disconnected:
419608
await self.flow.drain()
420609

@@ -436,7 +625,7 @@ async def send(self, message):
436625
if CLOSE_HEADER in self.scope["headers"] and CLOSE_HEADER not in headers:
437626
headers = headers + [CLOSE_HEADER]
438627

439-
if self.access_log:
628+
if self.access_log and not self.gunicorn_log:
440629
self.access_logger.info(
441630
'%s - "%s %s HTTP/%s" %d',
442631
get_client_addr(self.scope),
@@ -511,6 +700,19 @@ async def send(self, message):
511700
if self.expected_content_length != 0:
512701
raise RuntimeError("Response content shorter than Content-Length")
513702
self.response_complete = True
703+
duration_extension = self.scope['extensions']['uvicorn_request_duration']
704+
duration_extension['response_end_time'] = time.monotonic()
705+
706+
if self.gunicorn_log:
707+
try:
708+
self.gunicorn_log.access_log.info(
709+
self.gunicorn_log.cfg.access_log_format,
710+
self.gunicorn_atoms,
711+
)
712+
except:
713+
import traceback
714+
self.gunicorn_log.error(traceback.format_exc())
715+
514716
self.message_event.set()
515717
if not self.keep_alive:
516718
self.transport.close()

uvicorn/workers.py

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
4040
"callback_notify": self.callback_notify,
4141
"limit_max_requests": self.max_requests,
4242
"forwarded_allow_ips": self.cfg.forwarded_allow_ips,
43+
'gunicorn_log': self.log,
4344
}
4445

4546
if self.cfg.is_ssl:

0 commit comments

Comments
 (0)