Skip to content

Commit 4b4c714

Browse files
committed
Fix bug where request body couldn't be logged after being used in logging middleware; Updated readme file to document current behaviour for request payload logging
1 parent 468e1f9 commit 4b4c714

File tree

4 files changed

+30
-5
lines changed

4 files changed

+30
-5
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,5 @@ cython_debug/
160160
# and can be added to the global gitignore or merged into this file. For a more nuclear
161161
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
162162
#.idea/
163+
164+
dev/

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ Groups logs coming from the same request lifecycle and propagates the maximum lo
99

1010
- **Request Logs Grouping**: Groups logs coming from the same request lifecycle to ease out log analysis using the Google Cloud Log Explorer. Grouping logger name can be customised and it defaults to the Google Cloud Project ID with '-request-logger' as a suffix.
1111
- **Request Maximum Log Level propagation**: Propagates the maximum log level throughout the request lifecycle to ease out log searching based on severity of an issue.
12+
- **Request Payload Logging** - More options to be exposed on that behaviour:
13+
- In case the request contains a payload, then this is logged into the parent log of request grouped logs
14+
- For now content type of payload is assumed to be a valid JSON, otherwise it is discarded. More to be supported later on.
15+
- The payload is logged as a dictionary using the `google-cloud-logging`'s method for `log_struct`. Since that method is logging only dictionaries, any JSON valid payload that is not a dictionary is wrapped into a dummy keyword to constuct one. The dummy keyword looks like that: `<original_type_of_payload>__payload_wrapper` The original or virtual dictionary is logged with `log_struct` along with the parent log of request grouped logs and ends up in field `jsonPayload`.
1216

1317
## API
1418
- Custom Cloud Logging Handler to use with official library `google-cloud-logging`: `FastAPIGAELoggingHandler`

fastapi_gae_logging/fastapi_gae_logging.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import contextvars
44
import os
55
import re
6+
import json
67
from typing import Optional, Dict, Any
78
from fastapi import FastAPI
89
from starlette.exceptions import HTTPException
@@ -119,9 +120,14 @@ async def emit_request_log(self, request: Request, response: Response) -> None:
119120
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
120121
try:
121122
payload = await request.json()
122-
except Exception:
123+
except json.JSONDecodeError:
123124
pass
124125

126+
if not isinstance(payload, dict):
127+
payload = {
128+
f"{type(payload).__name__}_payload_wrapper": payload
129+
}
130+
125131
self.logger.log_struct(
126132
info=payload,
127133
resource=self.resource,
@@ -143,25 +149,38 @@ def __init__(self, app: ASGIApp, logger: GAERequestLogger):
143149

144150
async def __call__(self, scope: Scope, receive: Receive, send: Send):
145151
if scope["type"] == "http":
146-
request = Request(scope, receive=receive)
152+
153+
# https://stackoverflow.com/questions/64115628/get-starlette-request-body-in-the-middleware-context
154+
# Mock function that returns a cached copy of the request
155+
# so that anyone can ask for the body aftewards from the request object
156+
157+
receive_cached_ = await receive()
158+
159+
async def receive_cached():
160+
return receive_cached_
161+
162+
request = Request(scope, receive=receive_cached)
147163

148164
gae_request_context.set({
149165
'trace': request.headers.get('X-Cloud-Trace-Context'),
150166
'start_time': time.time(),
151167
'max_log_level': logging.NOTSET
152168
})
153169

170+
# start with the default response in case of generic error
154171
response = Response(status_code=500)
155172

156-
async def send_wrapper(message: Dict[str, Any]) -> None:
173+
# intercept the sent message to get the response status and body
174+
# for later use
175+
async def send_spoof_wrapper(message: Dict[str, Any]) -> None:
157176
if message["type"] == "http.response.start":
158177
response.status_code = message["status"]
159178
elif message["type"] == "http.response.body":
160179
response.body = message.get("body", b"")
161180
await send(message)
162181

163182
try:
164-
await self.app(scope, receive, send_wrapper)
183+
await self.app(scope, receive_cached, send_spoof_wrapper)
165184
except Exception as e:
166185
if not isinstance(e, HTTPException):
167186
logging.exception(e)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
setup(
44
name="fastapi-gae-logging",
5-
version="0.0.1",
5+
version="0.0.2",
66
description="Custom Cloud Logging handler for FastAPI applications deployed in Google App Engine. \
77
Groups logs coming from the same request lifecycle and propagates the maximum log level \
88
throughout the request lifecycle using middleware and context management.",

0 commit comments

Comments
 (0)