-
-
Notifications
You must be signed in to change notification settings - Fork 745
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement gunicorn access log format #947
base: master
Are you sure you want to change the base?
Changes from all commits
8ebe781
409add9
769d3f9
ecfe25d
dbcc940
23d7221
5517640
f4e513d
93191fd
8146e71
f2f322b
7f7f7f2
b1cab5f
cd9861b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,6 +23,8 @@ coverage==5.5 | |
coverage-conditional-plugin==0.4.0 | ||
httpx==1.0.0b0 | ||
pytest-asyncio==0.15.1 | ||
async_generator==1.10 ; python_version < '3.7' | ||
gunicorn==20.1.0 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This comment applies to both the source code and tests. AFAIK, the Gunicorn worker in
I would suggest removing Gunicorn from |
||
|
||
|
||
# Documentation | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -154,3 +154,43 @@ async def app(scope, receive, send): | |
if record.name == "uvicorn.access" | ||
] | ||
assert '"GET / HTTP/1.1" 599' in messages.pop() | ||
|
||
|
||
@pytest.mark.asyncio | ||
@pytest.mark.parametrize( | ||
"access_log_format,expected_output", | ||
[ | ||
# TODO: add more tests | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with the |
||
("access: %(h)s", "access: 127.0.0.1"), | ||
('access: "%({test-request-header}i)s"', 'access: "request-header-val"'), | ||
('access: "%({test-response-header}o)s"', 'access: "response-header-val"'), | ||
], | ||
) | ||
async def test_access_log_format(access_log_format, expected_output, caplog): | ||
async def app(scope, receive, send): # pragma: no cover | ||
assert scope["type"] == "http" | ||
await send( | ||
{ | ||
"type": "http.response.start", | ||
"status": 204, | ||
"headers": [(b"test-response-header", b"response-header-val")], | ||
} | ||
) | ||
await send({"type": "http.response.body", "body": b"", "more_body": False}) | ||
|
||
config = Config(app=app, access_log_format=access_log_format) | ||
with caplog_for_logger(caplog, "uvicorn.access"): | ||
async with run_server(config): | ||
async with httpx.AsyncClient() as client: | ||
response = await client.get( | ||
"http://127.0.0.1:8000", | ||
headers={"test-request-header": "request-header-val"}, | ||
) | ||
assert response.status_code == 204 | ||
|
||
access_log_messages = [ | ||
record.message for record in caplog.records if "uvicorn.access" in record.name | ||
] | ||
|
||
assert len(access_log_messages) == 1 | ||
assert access_log_messages[0] == expected_output |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import random | ||
import re | ||
import signal | ||
import subprocess | ||
import sys | ||
import time | ||
|
||
import pytest | ||
import requests | ||
|
||
|
||
# XXX: copypaste from test_main | ||
# Exclude from coverage, because this code is executed in subprocess. | ||
async def app(scope, receive, send): # pragma: no cover | ||
assert scope["type"] == "http" | ||
await send( | ||
{ | ||
"type": "http.response.start", | ||
"status": 204, | ||
"headers": [(b"test-response-header", b"response-header-val")], | ||
} | ||
) | ||
await send({"type": "http.response.body", "body": b"", "more_body": False}) | ||
|
||
|
||
@pytest.mark.xfail( | ||
sys.platform == "win32", reason="gunicorn process doesn't start on windows?" | ||
) | ||
@pytest.mark.parametrize( | ||
"worker_class", | ||
[ | ||
"uvicorn.workers.UvicornWorker", | ||
"uvicorn.workers.UvicornH11Worker", | ||
], | ||
) | ||
def test_gunicorn_worker_stdout_access_log_format(worker_class): | ||
random_port = random.randint(1024, 49151) | ||
access_logformat = ( | ||
'hellotest %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s"' | ||
' "%(a)s" %(L)s "%({test-request-header}i)s"' | ||
' "%({test-response-header}o)s"' | ||
) | ||
process = subprocess.Popen( | ||
[ | ||
"gunicorn", | ||
"tests.test_gunicorn_worker:app", | ||
"-k=%s" % worker_class, | ||
"--bind=127.0.0.1:%d" % random_port, | ||
"--access-logfile=-", | ||
"--access-logformat=%s" % access_logformat, | ||
], | ||
stdout=subprocess.PIPE, | ||
stderr=subprocess.PIPE, | ||
) | ||
|
||
attempts = 0 | ||
while True: | ||
attempts += 1 | ||
try: | ||
resp = requests.get( | ||
"http://127.0.0.1:%d" % random_port, | ||
headers={ | ||
"Test-Request-Header": "request-header-val", | ||
"User-Agent": "request-user-agent", | ||
}, | ||
) | ||
except requests.exceptions.ConnectionError: | ||
if attempts > 10: | ||
raise | ||
time.sleep(0.05) | ||
else: | ||
break | ||
|
||
assert resp.status_code == 204 | ||
process.send_signal(signal.SIGTERM) | ||
stdout, stderr = process.communicate() | ||
|
||
stdout_lines = stdout.decode().splitlines() | ||
if not len(stdout_lines) == 1: | ||
pytest.fail("Access log line not found, stderr:\n" + stderr.decode()) | ||
|
||
assert re.match( | ||
r'hellotest 127\.0\.0\.1 - - \[[^]]+\] "GET / HTTP/1\.1" 204 - "-"' | ||
r' "request-user-agent" [0-9.]+ "request-header-val" ' | ||
'"response-header-val"', | ||
stdout_lines[0], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -211,6 +211,7 @@ def __init__( | |
log_config: Optional[Union[dict, str]] = LOGGING_CONFIG, | ||
log_level: Optional[Union[str, int]] = None, | ||
access_log: bool = True, | ||
access_log_format: Optional[str] = None, | ||
use_colors: Optional[bool] = None, | ||
interface: InterfaceType = "auto", | ||
debug: bool = False, | ||
|
@@ -284,6 +285,8 @@ def __init__( | |
self.encoded_headers: List[Tuple[bytes, bytes]] = [] | ||
self.factory = factory | ||
|
||
self.access_log_format = access_log_format | ||
|
||
self.loaded = False | ||
self.configure_logging() | ||
|
||
|
@@ -385,6 +388,10 @@ def configure_logging(self) -> None: | |
self.log_config["formatters"]["access"][ | ||
"use_colors" | ||
] = self.use_colors | ||
if self.access_log_format: | ||
self.log_config["formatters"]["access"][ | ||
"fmt" | ||
] = "%(levelprefix)s %(message)s" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be like: if self.access_log_format:
self.log_config["formatters"]["access"][
"fmt"
] = self.access_log_format There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not quite: in Python logging it happens in two stages. E.g. for
class LogRecord:
def getMessage(self):
msg = str(self.msg)
if self.args:
msg = msg % self.args
return msg
def format(self, record):
record.message = record.getMessage()
...
class PercentStyle:
def _format(self, record):
return self._fmt % record.__dict__
# and
class Formatter:
def formatMessage(self, record):
return self._style.format(record) So right now uvicorn's log formatting and gunicorn access log formatting happen in different stages of processing. They can be made to both work on |
||
logging.config.dictConfig(self.log_config) | ||
elif self.log_config.endswith(".json"): | ||
with open(self.log_config) as file: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for adding these docs.