Repository: encode/httpx

Issue:
Logging levels
I've been spending some time looking at logging levels with `uvicorn` lately, see eg. https://github.com/encode/uvicorn/pull/474 and would like to take a consistent approach across other projects in `encode`.

We've currently got some very detailed logging, that's available, tho it's really at a "dig into the internals" level, rather than general purpose log outputs.

Something that would tie in really nicely with the approach I'm taking elsewhere would be to have DEBUG-level outputs that issue a single line per-request...

`[DEBUG] HTTP Request: "GET https://api.github.com/user HTTP/1.1" 200 OK`

And then have a `TRACE` level outputs reserved for the really low-level stuff.


Discussion:
100% yes for me. We currently have very verbose debug logs, which does seem to hint that they should be traces for low-level debugging (e.g. for bug reports etc.). A debug level with just a summary of requests would be enough to address "are my HTTP requests being fired at all?".
I see this approach has already been implemented in a few other Encode projects:

- Uvicorn: https://github.com/encode/uvicorn/pull/474
- Databases: https://github.com/encode/databases/pull/156

I think I'll take a look at adding this to HTTPX. :-)

Resolving patch:
diff --git a/httpx/client.py b/httpx/client.py
--- a/httpx/client.py
+++ b/httpx/client.py
@@ -49,7 +49,9 @@
     ResponseContent,
     URLTypes,
 )
-from .utils import ElapsedTimer, get_environment_proxies, get_netrc
+from .utils import ElapsedTimer, get_environment_proxies, get_logger, get_netrc
+
+logger = get_logger(__name__)
 
 
 class BaseClient:
@@ -241,6 +243,12 @@ async def get_response(request: AsyncRequest) -> AsyncResponse:
                 finally:
                     await response.close()
 
+            status = f"{response.status_code} {response.reason_phrase}"
+            response_line = f"{response.http_version} {status}"
+            logger.debug(
+                f'HTTP Request: {request.method} {request.url} "{response_line}"'
+            )
+
             return response
 
         def wrap(
diff --git a/httpx/utils.py b/httpx/utils.py
--- a/httpx/utils.py
+++ b/httpx/utils.py
@@ -194,6 +194,7 @@ def get_logger(name: str) -> Logger:
 
     if not _LOGGER_INITIALIZED:
         _LOGGER_INITIALIZED = True
+        logging.addLevelName(TRACE_LOG_LEVEL, "TRACE")
 
         log_level = os.environ.get("HTTPX_LOG_LEVEL", "").upper()
         if log_level in ("DEBUG", "TRACE"):
@@ -202,8 +203,8 @@ def get_logger(name: str) -> Logger:
             handler = logging.StreamHandler(sys.stderr)
             handler.setFormatter(
                 logging.Formatter(
-                    fmt="%(asctime)s.%(msecs)03d - %(name)s - %(message)s",
-                    datefmt="%H:%M:%S",
+                    fmt="%(levelname)s [%(asctime)s] %(name)s - %(message)s",
+                    datefmt="%Y-%m-%d %H:%M:%S",
                 )
             )
             logger.addHandler(handler)


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -69,6 +69,8 @@ async def app(scope, receive, send):
         await echo_body(scope, receive, send)
     elif scope["path"].startswith("/echo_headers"):
         await echo_headers(scope, receive, send)
+    elif scope["path"].startswith("/redirect_301"):
+        await redirect_301(scope, receive, send)
     else:
         await hello_world(scope, receive, send)
 
@@ -147,6 +149,17 @@ async def echo_headers(scope, receive, send):
     await send({"type": "http.response.body", "body": json.dumps(body).encode()})
 
 
+async def redirect_301(scope, receive, send):
+    await send(
+        {
+            "type": "http.response.start",
+            "status": 301,
+            "headers": [[b"location", b"/"]],
+        }
+    )
+    await send({"type": "http.response.body"})
+
+
 SERVER_SCOPE = "session"
 
 
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -108,6 +108,7 @@ async def test_logs_debug(server, capsys):
             response = await client.get(server.url)
             assert response.status_code == 200
     stderr = capsys.readouterr().err
+    assert 'HTTP Request: GET http://127.0.0.1:8000/ "HTTP/1.1 200 OK"' in stderr
     assert "httpx.dispatch.connection_pool" not in stderr
 
 
@@ -118,9 +119,28 @@ async def test_logs_trace(server, capsys):
             response = await client.get(server.url)
             assert response.status_code == 200
     stderr = capsys.readouterr().err
+    assert 'HTTP Request: GET http://127.0.0.1:8000/ "HTTP/1.1 200 OK"' in stderr
     assert "httpx.dispatch.connection_pool" in stderr
 
 
+@pytest.mark.asyncio
+async def test_logs_redirect_chain(server, capsys):
+    with override_log_level("debug"):
+        async with httpx.AsyncClient() as client:
+            response = await client.get(server.url.copy_with(path="/redirect_301"))
+            assert response.status_code == 200
+
+    stderr = capsys.readouterr().err.strip()
+    redirected_request_line, ok_request_line = stderr.split("\n")
+    assert redirected_request_line.endswith(
+        "HTTP Request: GET http://127.0.0.1:8000/redirect_301 "
+        '"HTTP/1.1 301 Moved Permanently"'
+    )
+    assert ok_request_line.endswith(
+        'HTTP Request: GET http://127.0.0.1:8000/ "HTTP/1.1 200 OK"'
+    )
+
+
 def test_get_ssl_cert_file():
     # Two environments is not set.
     assert get_ca_bundle_from_env() is None